Merge cut/vili-request-marks: a request's marks and inPlace are kept in the job record and never reach the worker (Soul s1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-26 03:40:30 +01:00
commit 7f6e0ce7c1
3 changed files with 86 additions and 5 deletions

View file

@ -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 `<clipName>_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.

View file

@ -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();

View file

@ -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),