Fix: job ids from one source with an exclusive mkdir, an integer seed at the door, Blender handed a private copy and its FBX created exclusively (Soul s1: job-id-collision, seed-recorded-not-used, bvh-swap-after-check)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3574efe92e
commit
ea7ce34d90
2 changed files with 113 additions and 42 deletions
|
|
@ -1360,10 +1360,16 @@ test("an fbx job exports every listed BVH", async () => {
|
|||
|
||||
const [probe, run] = daemon.starts();
|
||||
assert.deepEqual(probe.argv, ["-b", "--factory-startup", "--version"]);
|
||||
assert.deepEqual(run.argv, [
|
||||
"-b", "--factory-startup", "--python-use-system-env", "--python-exit-code", "1", "--python", exportScriptPath,
|
||||
"--", path.join(job.artifactDir, "motion.bvh"), path.join(job.artifactDir, "motion.fbx"), "wave_01",
|
||||
assert.deepEqual(run.argv.slice(0, 8), [
|
||||
"-b", "--factory-startup", "--python-use-system-env", "--python-exit-code", "1", "--python", exportScriptPath, "--",
|
||||
]);
|
||||
// Blender reads a private copy and writes a private FBX, in a directory made for this export alone: it is never
|
||||
// handed a path in the job's directory, and the directory is gone once the export is over.
|
||||
const [privateBvh, privateFbx, claimed] = run.argv.slice(8);
|
||||
assert.match(path.relative(daemon.stateRoot, privateBvh), /^work\/export-[A-Za-z0-9]{6}\/in\.bvh$/);
|
||||
assert.equal(privateFbx, path.join(path.dirname(privateBvh), "out.fbx"));
|
||||
assert.equal(claimed, "wave_01");
|
||||
assert.deepEqual(readdirSync(path.join(daemon.stateRoot, "work")), []);
|
||||
assert.deepEqual(run.env, {
|
||||
PATH: process.env.PATH,
|
||||
LANG: "xx_TEST.UTF-8",
|
||||
|
|
@ -1651,7 +1657,7 @@ test("rest-fbx is a job whose FBX is readable", async () => {
|
|||
const answer = await request(daemon.port, "POST", "/motion/rest-fbx", { body: "{}", headers: { "content-type": "application/json" } });
|
||||
assert.equal(answer.status, 200);
|
||||
const job = answer.body;
|
||||
assert.match(job.jobId, /^rest-[0-9]{13}$/);
|
||||
assert.match(job.jobId, /^rest-[0-9]{13}-[0-9a-f]{8}$/);
|
||||
assert.equal(job.schema, "gamecult.vili.motion_job.v1");
|
||||
assert.deepEqual(job.export.clips, [{ file: "SOMA77_Rest.fbx", clipName: "SOMA77_Rest", bones: 78, frames: [1, 60], fps: 30 }]);
|
||||
assert.deepEqual(job.files.map((file) => file.name), ["SOMA77_Rest.bvh", "SOMA77_Rest.fbx"]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import { createHash, randomInt } from "node:crypto";
|
||||
import { closeSync, lstatSync, openSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||
import { closeSync, constants, lstatSync, openSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { copyFile, mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import net from "node:net";
|
||||
|
|
@ -564,44 +564,88 @@ async function probeBlender() {
|
|||
blender.version = run.code === 0 && run.first?.startsWith("Blender ") ? run.first : null;
|
||||
}
|
||||
|
||||
// One BVH to one FBX beside it. The FBX counts only if Blender exits 0, prints its result line and the file exists.
|
||||
// A failure names the exit or the signal and never Blender's output.
|
||||
async function exportClip(bvh, fbx, clipName, timeoutMs) {
|
||||
const run = await runBlender([
|
||||
"-b", "--factory-startup", "--python-use-system-env", "--python-exit-code", "1",
|
||||
"--python", exportScript, "--", bvh, fbx, clipName,
|
||||
], timeoutMs);
|
||||
let info = null;
|
||||
if (run.code === 0 && run.result !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(run.result);
|
||||
if (Number.isInteger(parsed.bones) && Array.isArray(parsed.frames) && parsed.frames.length === 2
|
||||
&& parsed.frames.every(Number.isInteger) && Number.isFinite(parsed.fps)) info = parsed;
|
||||
} catch {
|
||||
// not the result line
|
||||
}
|
||||
// The bytes of a regular file strictly inside `directory`, or null. The file is opened without following a symlink
|
||||
// and the open file is the one checked, so a path swapped after the check cannot redirect the read.
|
||||
async function readInside(file, directory) {
|
||||
let handle = null;
|
||||
try {
|
||||
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const opened = await handle.stat();
|
||||
if (!opened.isFile() || !regularFileInside(file, directory)) return null;
|
||||
const named = lstatSync(file);
|
||||
if (named.dev !== opened.dev || named.ino !== opened.ino) return null;
|
||||
return await handle.readFile();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// One BVH to one FBX beside it. Blender never sees a path in the job's artifact directory: it reads a private copy of
|
||||
// the BVH and writes a private FBX in a directory made for this export alone, and the FBX is then created beside the
|
||||
// BVH exclusively, so nothing planted at either path is read, written through or replaced. The FBX counts only if
|
||||
// Blender exits 0, prints its result line and its file exists. A failure names the exit or the signal and never
|
||||
// Blender's output.
|
||||
async function exportClip(bvh, fbx, clipName, timeoutMs, artifactDir) {
|
||||
const input = await readInside(bvh, artifactDir);
|
||||
if (input === null) return { error: "fbx-export-refused" };
|
||||
guard();
|
||||
await mkdir(path.join(args.stateRoot, "work"), { recursive: true });
|
||||
guard();
|
||||
const work = await mkdtemp(path.join(args.stateRoot, "work", "export-"));
|
||||
try {
|
||||
const privateBvh = path.join(work, "in.bvh");
|
||||
const privateFbx = path.join(work, "out.fbx");
|
||||
guard();
|
||||
await writeFile(privateBvh, input, { flag: "wx" });
|
||||
const run = await runBlender([
|
||||
"-b", "--factory-startup", "--python-use-system-env", "--python-exit-code", "1",
|
||||
"--python", exportScript, "--", privateBvh, privateFbx, clipName,
|
||||
], timeoutMs);
|
||||
let info = null;
|
||||
if (run.code === 0 && run.result !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(run.result);
|
||||
if (Number.isInteger(parsed.bones) && Array.isArray(parsed.frames) && parsed.frames.length === 2
|
||||
&& parsed.frames.every(Number.isInteger) && Number.isFinite(parsed.fps)) info = parsed;
|
||||
} catch {
|
||||
// not the result line
|
||||
}
|
||||
}
|
||||
const made = info ? await readInside(privateFbx, work) : null;
|
||||
if (made !== null) {
|
||||
guard();
|
||||
try {
|
||||
await writeFile(fbx, made, { flag: "wx" });
|
||||
return { clip: { file: fbx, clipName, bones: info.bones, frames: info.frames, fps: info.fps } };
|
||||
} catch (error) {
|
||||
if (error instanceof LeaseLost) throw error;
|
||||
return { error: "fbx-export-refused" };
|
||||
}
|
||||
}
|
||||
const how = run.code === 0 ? "no result" : run.code !== null ? `exit ${run.code}` : `signal ${run.signal ?? "none"}`;
|
||||
return { error: `fbx-export-failed (${how})` };
|
||||
} finally {
|
||||
if (mayWrite()) await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
if (info && isRegularFile(fbx)) return { clip: { file: fbx, clipName, bones: info.bones, frames: info.frames, fps: info.fps } };
|
||||
const how = run.code === 0 ? "no result" : run.code !== null ? `exit ${run.code}` : `signal ${run.signal ?? "none"}`;
|
||||
return { error: `fbx-export-failed (${how})` };
|
||||
}
|
||||
|
||||
// Every BVH the worker listed becomes an FBX, or the job fails. A listed BVH must be a regular file inside the job's
|
||||
// artifact directory, and all are checked before Blender starts for any. Sample N of several is <clipName>_NN.
|
||||
// artifact directory, and that is decided when its export begins (exportClip), never earlier. Sample N of several is
|
||||
// <clipName>_NN.
|
||||
async function exportListed(listed, artifactDir, clipName, timeoutMs) {
|
||||
const bvhs = [];
|
||||
for (const file of Array.isArray(listed) ? listed : []) {
|
||||
if (typeof file !== "string" || !file.endsWith(".bvh")) continue;
|
||||
const full = path.resolve(artifactDir, file);
|
||||
if (!regularFileInside(full, artifactDir)) return { clips: [], error: "fbx-export-refused" };
|
||||
bvhs.push(full);
|
||||
bvhs.push(path.resolve(artifactDir, file));
|
||||
}
|
||||
if (bvhs.length === 0) return { clips: [], error: "fbx-export-failed (no bvh)" };
|
||||
bvhs.sort();
|
||||
const clips = [];
|
||||
for (const [index, bvh] of bvhs.entries()) {
|
||||
const name = bvhs.length === 1 ? clipName : `${clipName}_${String(index).padStart(2, "0")}`;
|
||||
const done = await exportClip(bvh, `${bvh.slice(0, -".bvh".length)}.fbx`, name, timeoutMs);
|
||||
const done = await exportClip(bvh, `${bvh.slice(0, -".bvh".length)}.fbx`, name, timeoutMs, artifactDir);
|
||||
if (done.error) return { clips, error: done.error };
|
||||
clips.push({ ...done.clip, file: path.relative(artifactDir, done.clip.file).split(path.sep).join("/") });
|
||||
}
|
||||
|
|
@ -751,6 +795,28 @@ async function underLease(response, run) {
|
|||
}
|
||||
}
|
||||
|
||||
// A seed is an integer Kimodo can take: the worker seeds with int(seed), so the daemon records exactly what it sends.
|
||||
const SEED_LIMIT = 2 ** 31;
|
||||
|
||||
// The one source of job ids, for a generate job and a rest job alike: the clock for order, random bits for
|
||||
// uniqueness, and an exclusive mkdir that decides, so two jobs never share a directory, a record or a file.
|
||||
async function newJobDir(prefix) {
|
||||
guard();
|
||||
await mkdir(path.join(args.stateRoot, "artifacts"), { recursive: true });
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const jobId = `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
const artifactDir = path.join(args.stateRoot, "artifacts", jobId);
|
||||
guard();
|
||||
try {
|
||||
await mkdir(artifactDir);
|
||||
return { jobId, artifactDir };
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("no free job id");
|
||||
}
|
||||
|
||||
async function generate(request, response) {
|
||||
guard();
|
||||
if (workerUnconfigured()) {
|
||||
|
|
@ -797,21 +863,23 @@ async function generate(request, response) {
|
|||
bvhStandardTpose: fbx || boolValue(askedTpose),
|
||||
};
|
||||
|
||||
const jobId = `vili-${Date.now()}`;
|
||||
const clipName = fbx ? (body.clipName ?? jobId) : undefined;
|
||||
const artifactDir = path.join(args.stateRoot, "artifacts", jobId);
|
||||
guard();
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const duration = firstDefined(body.durationSeconds, body.duration, 4);
|
||||
const bodyMeta = typeof body.meta === "object" && body.meta ? body.meta : {};
|
||||
const askedSeed = firstDefined(body.seed, bodyMeta.seed);
|
||||
if (askedSeed !== undefined && !(Number.isInteger(askedSeed) && askedSeed >= 0 && askedSeed < SEED_LIMIT)) {
|
||||
json(response, 400, { ok: false, error: "Invalid seed." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { jobId, artifactDir } = await newJobDir("vili");
|
||||
const clipName = fbx ? (body.clipName ?? jobId) : undefined;
|
||||
const duration = firstDefined(body.durationSeconds, body.duration, 4);
|
||||
const diffusionSteps = Math.max(1, Number(firstDefined(
|
||||
body.diffusionSteps,
|
||||
body.diffusion_steps,
|
||||
bodyMeta.diffusion_steps,
|
||||
20,
|
||||
)));
|
||||
const askedSeed = Number(firstDefined(body.seed, bodyMeta.seed));
|
||||
const seed = Number.isFinite(askedSeed) ? askedSeed : randomInt(0, 2 ** 31);
|
||||
const seed = askedSeed ?? randomInt(0, SEED_LIMIT);
|
||||
const numSamples = Math.max(1, Number(firstDefined(
|
||||
body.numSamples,
|
||||
body.num_samples,
|
||||
|
|
@ -944,10 +1012,7 @@ async function restFbx(response) {
|
|||
json(response, 503, { ok: false, code: "rest-skeleton-missing" });
|
||||
return;
|
||||
}
|
||||
const jobId = `rest-${Date.now()}`;
|
||||
const artifactDir = path.join(args.stateRoot, "artifacts", jobId);
|
||||
guard();
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const { jobId, artifactDir } = await newJobDir("rest");
|
||||
guard();
|
||||
await copyFile(restBvh, path.join(artifactDir, "SOMA77_Rest.bvh"));
|
||||
const exported = await exportListed([path.join(artifactDir, "SOMA77_Rest.bvh")], artifactDir, "SOMA77_Rest", EXPORT_TIMEOUT_MS);
|
||||
|
|
@ -970,7 +1035,7 @@ async function restFbx(response) {
|
|||
}
|
||||
|
||||
// Reads only: a job's own FBX and BVH files, by exact name, regular files inside the job's artifact directory.
|
||||
const ARTIFACT_ROUTE = /^\/motion\/artifacts\/((?:vili|rest)-[0-9]{13})\/(motion\.fbx|motion\.bvh|SOMA77_Rest\.fbx|motion\/motion_[0-9]{2}\.(?:fbx|bvh))$/;
|
||||
const ARTIFACT_ROUTE = /^\/motion\/artifacts\/((?:vili|rest)-[0-9]{13}-[0-9a-f]{8})\/(motion\.fbx|motion\.bvh|SOMA77_Rest\.fbx|motion\/motion_[0-9]{2}\.(?:fbx|bvh))$/;
|
||||
|
||||
async function serveArtifact(response, jobId, name) {
|
||||
const directory = path.join(args.stateRoot, "artifacts", jobId);
|
||||
|
|
|
|||
Loading…
Reference in a new issue