#!/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 /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; });