Files
healer-man/scripts/runewaker-pipeline/audit-animated-poses.mjs
T
2026-08-14 15:56:39 -04:00

367 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Batch the Blender evaluated-pose audit over packaged RuneWaker actor GLBs.
*
* Examples:
* node scripts/runewaker-pipeline/audit-animated-poses.mjs --include paspers-shrine
* node scripts/runewaker-pipeline/audit-animated-poses.mjs --jobs 2 --resume
* node scripts/runewaker-pipeline/audit-animated-poses.mjs \
* --input path/to/actor.animated.raw.glb --render-mode families --contact-sheets
*
* Warnings are retained in the reports but do not fail the process. Blender
* failures, missing reports, and malformed reports do fail it.
*/
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile, readdir, stat, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const projectRoot = path.resolve(import.meta.dirname, "..", "..");
const scriptFile = path.join(
projectRoot,
"scripts",
"runewaker-pipeline",
"blender",
"audit-animated-actor-poses.py",
);
const defaultBlender = path.resolve(
process.env.BLENDER_BIN ??
path.join(
process.env.USERPROFILE ?? "",
"blender-portable",
"blender-5.0.1-windows-x64",
"blender.exe",
),
);
function usage() {
console.log(`Usage: node scripts/runewaker-pipeline/audit-animated-poses.mjs [options]
Options:
--root DIRECTORY Discovery root (default public/assets/creatures)
--input FILE Audit one file; repeatable and bypasses discovery
--input-list JSON JSON array or {"inputs": [...]} file; repeatable
--output-dir DIRECTORY Report root (default runewaker-export-work/pose-audit)
--summary FILE Batch summary JSON (default <output>/summary.json)
--blender FILE Blender executable (or set BLENDER_BIN)
--include REGEX Filter discovered paths; repeatable
--jobs INTEGER Concurrent Blender processes (default 1)
--sample-mode all|families Pose sampling mode (default all)
--render-mode none|flagged|families|all (default none)
--render-limit INTEGER Non-bind frames per actor (default 24)
--contact-sheets Write one contact sheet per rendered actor
--resume Reuse reports whose source SHA-256 still matches
--help Show this message`);
}
function parseArguments(values) {
const result = {
root: path.join(projectRoot, "public", "assets", "creatures"),
inputs: [],
inputLists: [],
outputDir: undefined,
summary: undefined,
blender: defaultBlender,
includes: [],
jobs: 1,
sampleMode: "all",
renderMode: "none",
renderLimit: 24,
contactSheets: false,
resume: false,
};
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
const next = () => {
index += 1;
if (index >= values.length) throw new Error(`Missing value after ${value}`);
return values[index];
};
if (value === "--help" || value === "-h") {
usage();
process.exit(0);
} else if (value === "--root") result.root = path.resolve(next());
else if (value === "--input") result.inputs.push(path.resolve(next()));
else if (value === "--input-list") result.inputLists.push(path.resolve(next()));
else if (value === "--output-dir") result.outputDir = path.resolve(next());
else if (value === "--summary") result.summary = path.resolve(next());
else if (value === "--blender") result.blender = path.resolve(next());
else if (value === "--include") result.includes.push(new RegExp(next(), "i"));
else if (value === "--jobs") result.jobs = Number.parseInt(next(), 10);
else if (value === "--sample-mode") result.sampleMode = next();
else if (value === "--render-mode") result.renderMode = next();
else if (value === "--render-limit") result.renderLimit = Number.parseInt(next(), 10);
else if (value === "--contact-sheets") result.contactSheets = true;
else if (value === "--resume") result.resume = true;
else throw new Error(`Unknown argument: ${value}`);
}
if (!Number.isInteger(result.jobs) || result.jobs < 1) throw new Error("--jobs must be positive");
if (!Number.isInteger(result.renderLimit) || result.renderLimit < 0) {
throw new Error("--render-limit must be a non-negative integer");
}
if (!new Set(["all", "families"]).has(result.sampleMode)) {
throw new Error("--sample-mode must be all or families");
}
if (!new Set(["none", "flagged", "families", "all"]).has(result.renderMode)) {
throw new Error("Invalid --render-mode");
}
result.outputDir ??= path.join(projectRoot, "runewaker-export-work", "pose-audit");
result.summary ??= path.join(result.outputDir, "summary.json");
return result;
}
async function isFile(file) {
try {
return (await stat(file)).isFile();
} catch {
return false;
}
}
async function discover(directory) {
const result = [];
async function visit(current) {
const entries = await readdir(current, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const file = path.join(current, entry.name);
if (entry.isDirectory()) await visit(file);
else if (entry.isFile() && entry.name.endsWith(".glb")) result.push(file);
}
}
await visit(directory);
return result;
}
function slash(value) {
return value.replaceAll("\\", "/");
}
function actorName(file) {
return path.basename(file).replace(/\.animated\.raw\.glb$/i, "").replace(/\.glb$/i, "");
}
function outputFor(input, options) {
let relative = path.relative(options.root, input);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
relative = path.join("explicit", actorName(input), path.basename(input));
}
return path.join(
options.outputDir,
relative.replace(/(?:\.animated\.raw)?\.glb$/i, ".pose-audit.json"),
);
}
async function fileHash(file) {
const digest = createHash("sha256");
digest.update(await readFile(file));
return digest.digest("hex");
}
async function reusableReport(input, reportFile) {
try {
const report = JSON.parse(await readFile(reportFile, "utf8"));
return report?.source?.sha256 === (await fileHash(input)) ? report : undefined;
} catch {
return undefined;
}
}
function run(executable, args) {
return new Promise((resolve) => {
const child = spawn(executable, args, { cwd: projectRoot, windowsHide: true });
let output = "";
const append = (chunk) => {
output = (output + chunk.toString()).slice(-80_000);
};
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", (error) => resolve({ code: -1, output: `${output}\n${error.stack}` }));
child.on("close", (code) => resolve({ code: code ?? -1, output }));
});
}
async function audit(input, options, position, total) {
const reportFile = outputFor(input, options);
await mkdir(path.dirname(reportFile), { recursive: true });
if (options.resume) {
const report = await reusableReport(input, reportFile);
if (report) {
console.log(`[${position}/${total}] reuse ${slash(path.relative(options.root, input))}`);
return { input, reportFile, report, reused: true };
}
}
const relativeLabel = slash(path.relative(options.root, input));
console.log(`[${position}/${total}] audit ${relativeLabel}`);
const sourceSha256 = await fileHash(input);
const alreadyRaw = input.endsWith(".animated.raw.glb");
const diagnosticInput = alreadyRaw
? input
: reportFile.replace(/\.pose-audit\.json$/i, ".decompressed.glb");
if (!alreadyRaw) {
const gltfTransform = path.join(
projectRoot,
"node_modules",
"@gltf-transform",
"cli",
"bin",
"cli.js",
);
const decompression = await run(process.execPath, [gltfTransform, "copy", input, diagnosticInput]);
if (decompression.code !== 0) {
return {
input,
reportFile,
error: `glTF Transform decompression exited ${decompression.code}`,
logTail: decompression.output,
reused: false,
};
}
}
const blenderArgs = [
"--background",
"--factory-startup",
"--python",
scriptFile,
"--",
diagnosticInput,
reportFile,
"--source-file",
input,
"--source-sha256",
sourceSha256,
"--variant-id",
relativeLabel,
"--sample-mode",
options.sampleMode,
"--render-mode",
options.renderMode,
"--render-limit",
String(options.renderLimit),
];
if (options.renderMode !== "none") {
const renderDir = reportFile.replace(/\.pose-audit\.json$/i, ".review");
blenderArgs.push("--render-dir", renderDir);
if (options.contactSheets) blenderArgs.push("--contact-sheet", path.join(renderDir, "contact-sheet.png"));
}
const execution = await run(options.blender, blenderArgs);
if (!alreadyRaw) await rm(diagnosticInput, { force: true });
if (execution.code !== 0) {
return {
input,
reportFile,
error: `Blender exited ${execution.code}`,
logTail: execution.output,
reused: false,
};
}
try {
const report = JSON.parse(await readFile(reportFile, "utf8"));
return { input, reportFile, report, reused: false };
} catch (error) {
return { input, reportFile, error: error.message, logTail: execution.output, reused: false };
}
}
async function parallelMap(items, concurrency, callback) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (true) {
const index = cursor;
cursor += 1;
if (index >= items.length) return;
results[index] = await callback(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
return results;
}
async function main() {
const options = parseArguments(process.argv.slice(2));
for (const required of [options.blender, scriptFile]) {
if (!(await isFile(required))) throw new Error(`Required file is missing: ${required}`);
}
for (const listFile of options.inputLists) {
const parsed = JSON.parse(await readFile(listFile, "utf8"));
const listedInputs = Array.isArray(parsed) ? parsed : parsed.inputs;
if (!Array.isArray(listedInputs)) {
throw new Error(`Input list must be a JSON array or { inputs: [] }: ${listFile}`);
}
options.inputs.push(
...listedInputs.map((item) => {
const file = typeof item === "string" ? item : item.file;
if (typeof file !== "string") throw new Error(`Invalid input-list entry in ${listFile}`);
return path.resolve(path.dirname(listFile), file);
}),
);
}
let inputs = options.inputs.length ? options.inputs : await discover(options.root);
inputs = [...new Set(inputs.map((file) => path.resolve(file)))].sort((a, b) => a.localeCompare(b));
inputs = inputs.filter((file) => {
const relative = slash(path.relative(options.root, file));
return options.includes.length === 0 || options.includes.every((expression) => expression.test(relative));
});
for (const input of inputs) {
if (!(await isFile(input))) throw new Error(`Input is missing: ${input}`);
}
if (!inputs.length) throw new Error("No GLB inputs matched");
await mkdir(options.outputDir, { recursive: true });
console.log(`Auditing ${inputs.length} actors with ${options.jobs} Blender worker(s).`);
const results = await parallelMap(inputs, options.jobs, (input, index) =>
audit(input, options, index + 1, inputs.length),
);
const entries = results.map((result) => ({
actorId: result.report?.actorId ?? actorName(result.input),
input: slash(path.relative(options.root, result.input)),
report: slash(path.relative(options.outputDir, result.reportFile)),
status: result.error ? "failed" : result.report?.summary?.status ?? "invalid",
actions: result.report?.summary?.sampledActions ?? null,
poses: result.report?.summary?.sampledPoses ?? null,
flaggedSamples: result.report?.summary?.flaggedSamples ?? null,
errors: result.report?.summary?.errors ?? null,
warnings: result.report?.summary?.warnings ?? null,
reused: result.reused,
failure: result.error ?? null,
}));
const failed = entries.filter((entry) => entry.status === "failed" || entry.status === "invalid");
const summary = {
schemaVersion: 1,
tool: "RuneWaker evaluated-pose batch audit",
configuration: {
sampling: options.sampleMode,
renderMode: options.renderMode,
sourcePattern: "packaged *.glb (decompressed with glTF Transform before Blender import)",
},
totals: {
actors: entries.length,
pass: entries.filter((entry) => entry.status === "pass").length,
warning: entries.filter((entry) => entry.status === "warning").length,
error: entries.filter((entry) => entry.status === "error").length,
failed: failed.length,
poses: entries.reduce((sum, entry) => sum + (entry.poses ?? 0), 0),
flaggedSamples: entries.reduce((sum, entry) => sum + (entry.flaggedSamples ?? 0), 0),
},
actors: entries,
};
await mkdir(path.dirname(options.summary), { recursive: true });
await writeFile(options.summary, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify(summary.totals));
console.log(`Summary: ${options.summary}`);
if (failed.length) {
for (const result of results.filter((item) => item.error)) {
console.error(`\nFAILED ${result.input}: ${result.error}\n${result.logTail ?? ""}`);
}
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});