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:
commit
7f6e0ce7c1
3 changed files with 86 additions and 5 deletions
11
README.md
11
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 `<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue