From 386c4d669117fe2e80760c3e7815f2b809f61793 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 03:19:17 +0100 Subject: [PATCH 1/3] Tests: a request's marks and inPlace are kept in the record, never reach the worker, and malformed ones are refused first Red at base: the fields are not yet read. Co-Authored-By: Claude Opus 5.5 --- scripts/test-vili-daemon.mjs | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/scripts/test-vili-daemon.mjs b/scripts/test-vili-daemon.mjs index b1fd271..023aec2 100644 --- a/scripts/test-vili-daemon.mjs +++ b/scripts/test-vili-daemon.mjs @@ -1543,6 +1543,73 @@ test("loop is a boolean and needs fbx, refused before anything is written or spa } }); +test("marks and inPlace are kept in the record and never reach the worker", async () => { + const daemon = await startDaemon(); + try { + const marks = [{ name: "RpsHit", frame: 38 }, { name: "RpsHit", frame: 53 }, { name: "RpsHit", frame: 68 }]; + const marked = await generate(daemon, exportRequest({ clipName: "cd", seed: 7, marks, inPlace: true })); + assert.equal(marked.status, 200); + assert.deepEqual(marked.body.request.marks, marks); + assert.equal(marked.body.request.inPlace, true); + const record = JSON.parse(readFileSync(path.join(daemon.stateRoot, "jobs", `${marked.body.jobId}.json`), "utf8")); + assert.deepEqual(record.request.marks, marks, "the stored record keeps them too"); + const sent = JSON.parse(readFileSync(path.join(marked.body.artifactDir, "request.json"), "utf8")); + assert.ok(!("marks" in sent) && !("inPlace" in sent), "the worker request carries neither"); + const meta = readFileSync(path.join(marked.body.artifactDir, "meta.json"), "utf8"); + assert.ok(!meta.includes("RpsHit") && !meta.includes("inPlace"), "meta.json carries neither"); + + const plain = await generate(daemon, exportRequest({ clipName: "cd", seed: 7 })); + assert.equal(plain.status, 200); + assert.ok(!("marks" in plain.body.request) && !("inPlace" in plain.body.request), "no marks, no keys"); + const hash = (job, name) => job.files.find((file) => file.name === name).sha256; + for (const name of ["motion.bvh", "motion.fbx"]) assert.equal(hash(marked.body, name), hash(plain.body, name), `${name} is the same motion`); + + const empty = await generate(daemon, exportRequest({ marks: [], inPlace: false })); + assert.equal(empty.status, 200); + assert.ok(!("marks" in empty.body.request) && !("inPlace" in empty.body.request), "empty marks and inPlace false are not recorded"); + + const bare = await generate(daemon, { prompt: "a person waves", duration: 2, marks: [{ name: "a", frame: 0 }], inPlace: true }); + assert.equal(bare.status, 200, "without fbx"); + assert.deepEqual(bare.body.request.marks, [{ name: "a", frame: 0 }]); + assert.equal(bare.body.request.inPlace, true); + const batch = await generate(daemon, { texts: ["a person waves", "a person sits"], durations: [1, 1], marks: [{ name: "b", frame: 2147483647 }] }); + assert.equal(batch.status, 200, "a batch"); + assert.deepEqual(batch.body.request.marks, [{ name: "b", frame: 2147483647 }]); + const many = Array.from({ length: 32 }, (_, i) => ({ name: `m${i}`, frame: i })); + assert.equal((await generate(daemon, exportRequest({ marks: many }))).status, 200, "32 marks is the limit, not past it"); + } finally { + await daemon.stop(); + } +}); + +test("malformed marks or inPlace are refused before anything is written or spawned", async () => { + const daemon = await startDaemon(); + try { + const mark = (extra) => ({ name: "a", frame: 1, ...extra }); + for (const bad of [ + { marks: "x canary" }, { marks: {} }, { marks: null }, { marks: [null] }, { marks: ["a"] }, + { marks: [mark({ name: "a b canary" })] }, { marks: [mark({ name: "" })] }, { marks: [mark({ name: 5 })] }, + { marks: [{ frame: 1 }] }, { marks: [{ name: "a" }] }, + { marks: [mark({ frame: -1 })] }, { marks: [mark({ frame: 1.5 })] }, { marks: [mark({ frame: "1" })] }, + { marks: [mark({ frame: 2147483648 })] }, { marks: [mark({ frame: null })] }, + { marks: [mark({ extra: 0 })] }, + { marks: Array.from({ length: 33 }, (_, i) => ({ name: `m${i}`, frame: i })) }, + { inPlace: "true" }, { inPlace: 1 }, { inPlace: null }, { inPlace: {} }, + ]) { + const answer = await generate(daemon, exportRequest(bad)); + assert.equal(answer.status, 400, JSON.stringify(bad)); + assert.deepEqual(answer.body, { ok: false, error: "Invalid marks request." }); + assert.ok(!answer.raw.includes("canary"), "the refusal repeated the input"); + } + assert.equal(existsSync(path.join(daemon.stateRoot, "artifacts")), false, "no artifact directory was made"); + assert.deepEqual(jobFiles(daemon), []); + assert.deepEqual(daemon.spawns(), [], "no worker was started"); + assert.equal(exportStarts(daemon).length, 0); + } finally { + await daemon.stop(); + } +}); + test("a loop request whose answer has no usable window for a sample fails without exporting that sample", async () => { for (const [model, exported] of [["no-loops", 0], ["bad-loops", 0], ["negative-loops", 0], ["empty-loops", 0], ["fractional-last-loops", 0], ["short-loops", 1]]) { const daemon = await startDaemon(); From 6976520e5289631625e77744bfafc6f3f339149c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 03:23:42 +0100 Subject: [PATCH 2/3] Vili keeps a request's marks and inPlace in the job record, never gives them to the worker, and refuses malformed ones first Co-Authored-By: Claude Opus 5.5 --- scripts/vili-daemon.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 298ac71..4714830 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -882,6 +882,17 @@ async function generate(request, response) { json(response, 400, { ok: false, error: "Invalid fbx request." }); return; } + // The caller's own marks (named take frames) and whether it plays the take in place: kept in the record for the + // caller, interpreted by nobody here, never given to the worker. + const marks = body.marks === undefined ? [] : body.marks; + const markOk = (mark) => mark !== null && typeof mark === "object" && !Array.isArray(mark) + && Object.keys(mark).length === 2 && typeof mark.name === "string" && CLIP_NAME.test(mark.name) + && Number.isInteger(mark.frame) && mark.frame >= 0 && mark.frame < 2 ** 31; + if (!Array.isArray(marks) || marks.length > 32 || !marks.every(markOk) + || (body.inPlace !== undefined && typeof body.inPlace !== "boolean")) { + json(response, 400, { ok: false, error: "Invalid marks request." }); + return; + } if (fbx && !blenderReady()) { json(response, 503, { ok: false, code: "blender-unavailable" }); return; @@ -959,6 +970,8 @@ async function generate(request, response) { fbx: fbx || undefined, loop: loop || undefined, clipName, + marks: marks.length > 0 ? marks.map(({ name, frame }) => ({ name, frame })) : undefined, + inPlace: body.inPlace === true || undefined, cfg: body.cfg, cfgType: firstDefined(body.cfgType, body.cfg_type), cfgWeight: firstDefined(body.cfgWeight, body.cfg_weight), From d1fcb03858d947f0ccf317dd24a219675a21ee87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 03:23:42 +0100 Subject: [PATCH 3/3] README: FBX export is live, and marks and inPlace are the caller's, kept in the record Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 477f91e..a05fbfe 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Linux. It runs on Yggdrasil as an Idunn target (see Front Door). ## Front Door -- **Value:** a supervised path from a text prompt to generated body motion. The daemon spawns one resident Kimodo worker, caps its threads, and returns motion artifacts (`motion.npz` and BVH; FBX for Unity is planned, see below) for a job. +- **Value:** a supervised path from a text prompt to generated body motion. The daemon spawns one resident Kimodo worker, caps its threads, and returns motion artifacts (`motion.npz`, BVH, and an FBX for Unity, see FBX Export) for a job. - **Problem:** Personas and games need generated motion, but cold per-request model loads cost minutes. One daemon owns job identity, worker lifecycle, the thread budget and artifact references, so callers touch no model state. - **Entry points:** `scripts/vili-daemon.mjs` (the daemon; `npm start`, `npm test`), `scripts/idunn-entry.mjs` (the process Idunn runs), `deployment/idunn/recipe.toml` (the sealed release), `scripts/kimodo-resident-worker.py` (the worker it spawns), `scripts/kimodo-text-encode.py` (the one-shot encoder child), `POST /motion/generate` (see Motion Generation), `docs/verse-service-contract.md` and `docs/motion-pipeline-target.md`. -- **Live:** the Idunn target `vili` on Yggdrasil at `http://10.77.0.1:8824` (WireGuard only), CPU only, running the revision deployed before the FBX export: `POST /motion/generate` (`motion.npz` and optional BVH on the SOMA skeleton; no FBX), `GET /health`, `GET /operator-state`, and the CultCache service store. -- **Planned:** FBX export, which is in this tree and not yet deployed: `"fbx": true` on `POST /motion/generate` (a Unity-ready FBX per clip), `POST /motion/rest-fbx` (the avatar's rest pose) and `GET /motion/artifacts/{jobId}/{name}` (the FBX and BVH bytes), from `scripts/bvh-to-fbx.py` in the release's own Blender 5.2.2 (see FBX Export). They answer only after the next `idunn up`. Also planned: worker intake closed to local callers only, publication to Odin, and a Thing 2 operator surface. The text-prompt-to-Unity-clip path with no hand steps is `docs/motion-pipeline-target.md`. +- **Live:** the Idunn target `vili` on Yggdrasil at `http://10.77.0.1:8824` (WireGuard only), CPU only: `POST /motion/generate` (`motion.npz`, optional BVH on the SOMA skeleton, and with `"fbx": true` a Unity-ready FBX per clip, which the live unit exported for the H3-s2 take), `POST /motion/rest-fbx` (the avatar's rest pose), `GET /motion/artifacts/{jobId}/{name}` (the FBX and BVH bytes), `GET /health`, `GET /operator-state`, and the CultCache service store. The fields added since that export (`loop`, `marks`, `inPlace`) answer on the live unit once a deploy carries them; the job record's `request` shows which ones it kept. +- **Planned:** worker intake closed to local callers only, publication to Odin, and a Thing 2 operator surface. The text-prompt-to-Unity-clip path with no hand steps is `docs/motion-pipeline-target.md`. ## Authority @@ -133,6 +133,7 @@ Supported request fields: - `bvh`, `bvhStandardTpose` / `bvh_standard_tpose` - `fbx` and `clipName` (see FBX Export) - `loop` (a boolean, valid only with `fbx: true`; see FBX Export) +- `marks` and `inPlace` (see FBX Export): the caller's own, kept in the job record and never given to the worker - `noPostprocess` / `no_postprocess` - `saveExampleDir` / `save_example_dir` - `timeoutMs`, `workerTimeoutMs`, `exportTimeoutMs` (at most 120000) @@ -145,8 +146,6 @@ exports CSV when that model is resident. ## FBX Export -Planned until the next deploy: the live unit does not have these routes or the `fbx` field yet. - `"fbx": true` makes the job's BVH an FBX for Unity, beside it: `motion.fbx`, or `motion/motion_NN.fbx` for several samples. It implies `bvh` and `bvhStandardTpose`; an explicit `bvhStandardTpose: false` is a 400. `clipName` (`^[A-Za-z0-9_-]{1,64}$`, default the job id; sample N of several is `_NN`) names the armature, the action @@ -179,6 +178,8 @@ The BVH and NPZ stay the whole generated take. The job record keeps `request.loo `frames` as Blender reports them, and keeps inline `constraints` as sent (a path otherwise), so a committed record reproduces its clip. Without `loop` the FBX is the whole take, as before. +`marks` (absent, or at most 32 objects, each exactly `{name, frame}`: `name` matches `^[A-Za-z0-9_-]{1,64}$`, `frame` an integer in `[0, 2^31)` in the take's own frame indices, frame 0 being BVH frame 0) and `inPlace` (absent or a boolean: the caller plays the take in place, so its consumer keeps the root travel in the pose) are the caller's. Vili keeps them verbatim in `request.marks` and `request.inPlace` of the job record and the answer (`marks` omitted when absent or empty, `inPlace` only when true), interprets neither, and never gives either to the worker, so the same request yields the same motion. Anything else is a 400 `Invalid marks request.` before a job directory, a file or a spawn. + One BVH gives the same FBX bytes on one host body (a fixed hash seed and a fixed time stamp); FBX bytes differ between glibc versions in the last float digits, so the BVH is the cross-body reproducibility unit.