diff --git a/README.md b/README.md index a05fbfe..477f91e 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`, BVH, and an FBX for Unity, see FBX Export) 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` and BVH; FBX for Unity is planned, see below) 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: `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`. +- **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`. ## Authority @@ -133,7 +133,6 @@ 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) @@ -146,6 +145,8 @@ 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 @@ -178,8 +179,6 @@ 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. diff --git a/scripts/test-vili-daemon.mjs b/scripts/test-vili-daemon.mjs index 023aec2..b1fd271 100644 --- a/scripts/test-vili-daemon.mjs +++ b/scripts/test-vili-daemon.mjs @@ -1543,73 +1543,6 @@ 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(); diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index 4714830..298ac71 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -882,17 +882,6 @@ 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; @@ -970,8 +959,6 @@ 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),