367 lines
14 KiB
JavaScript
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;
|
|
});
|