232 lines
10 KiB
JavaScript
232 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/** Compile deterministic human-review JSON/Markdown from pose-audit reports. */
|
|
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
|
|
function slash(value) {
|
|
return value.replaceAll("\\", "/");
|
|
}
|
|
|
|
function countBy(values, selector) {
|
|
const counts = new Map();
|
|
for (const value of values) {
|
|
const key = selector(value);
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
return Object.fromEntries([...counts].sort(([left], [right]) => left.localeCompare(right)));
|
|
}
|
|
|
|
function markdownCell(value) {
|
|
return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
}
|
|
|
|
async function main() {
|
|
const [batchRootValue, reviewInputValue, outputJsonValue, outputMarkdownValue] = process.argv.slice(2);
|
|
if (!outputMarkdownValue) {
|
|
throw new Error(
|
|
"Usage: compile-pose-audit-review.mjs <batch-root> <visual-review-input.json> " +
|
|
"<output.json> <output.md>",
|
|
);
|
|
}
|
|
const batchRoot = path.resolve(batchRootValue);
|
|
const reviewInput = JSON.parse(await readFile(path.resolve(reviewInputValue), "utf8"));
|
|
const summary = JSON.parse(await readFile(path.join(batchRoot, "summary.json"), "utf8"));
|
|
const expected = summary.actors.map((actor) => actor.input).sort();
|
|
const reviewed = [...new Set(reviewInput.reviewedVariants)].sort();
|
|
if (JSON.stringify(expected) !== JSON.stringify(reviewed)) {
|
|
const missing = expected.filter((variant) => !reviewed.includes(variant));
|
|
const extra = reviewed.filter((variant) => !expected.includes(variant));
|
|
throw new Error(`Visual review inventory mismatch. Missing=${missing.join(",")} Extra=${extra.join(",")}`);
|
|
}
|
|
|
|
const actors = [];
|
|
const warningSamples = [];
|
|
const assetWarnings = [];
|
|
for (const summaryActor of [...summary.actors].sort((left, right) => left.input.localeCompare(right.input))) {
|
|
const reportFile = path.join(batchRoot, summaryActor.report);
|
|
const report = JSON.parse(await readFile(reportFile, "utf8"));
|
|
const override = reviewInput.notes[summaryActor.input];
|
|
const visualVerdict =
|
|
override?.verdict ??
|
|
(summaryActor.status === "pass"
|
|
? "visually-coherent"
|
|
: "visually-coherent-with-numeric-warning");
|
|
const visualNote =
|
|
override?.note ??
|
|
"Bind and every rendered semantic-family pose preserve a coherent silhouette, joint layout, " +
|
|
"and attached equipment; no visible quaternion-basis deformation.";
|
|
const actionWarnings = [];
|
|
for (const sample of report.samples) {
|
|
if (!sample.issues.length) continue;
|
|
const row = {
|
|
variantId: summaryActor.input,
|
|
action: sample.action,
|
|
semanticFamily: sample.semanticFamily,
|
|
frameLabel: sample.frameLabel,
|
|
frame: sample.frame,
|
|
issues: sample.issues,
|
|
};
|
|
actionWarnings.push(row);
|
|
warningSamples.push(row);
|
|
}
|
|
for (const issue of report.assetIssues ?? []) {
|
|
assetWarnings.push({ variantId: summaryActor.input, ...issue });
|
|
}
|
|
const reviewDirectory = summaryActor.report.replace(/\.pose-audit\.json$/i, ".review");
|
|
actors.push({
|
|
dungeonId: summaryActor.input.split("/")[0],
|
|
variantId: summaryActor.input,
|
|
numericStatus: summaryActor.status,
|
|
actions: summaryActor.actions,
|
|
evaluatedPoses: summaryActor.poses,
|
|
flaggedSamples: summaryActor.flaggedSamples,
|
|
warningCodes: countBy(
|
|
actionWarnings.flatMap((sample) => sample.issues),
|
|
(issue) => issue.code,
|
|
),
|
|
warningActions: countBy(actionWarnings, (sample) => sample.action),
|
|
assetIssues: report.assetIssues ?? [],
|
|
visualVerdict,
|
|
visualNote,
|
|
report: slash(path.relative(batchRoot, reportFile)),
|
|
contactSheet: slash(path.join(reviewDirectory, "contact-sheet.png")),
|
|
});
|
|
}
|
|
|
|
const dungeonIds = [
|
|
...new Set([...actors.map((actor) => actor.dungeonId), ...reviewInput.explicitEmptyDungeons]),
|
|
].sort();
|
|
const dungeons = dungeonIds.map((dungeonId) => {
|
|
const rows = actors.filter((actor) => actor.dungeonId === dungeonId);
|
|
return {
|
|
dungeonId,
|
|
configuredVariants: rows.length,
|
|
numericPass: rows.filter((actor) => actor.numericStatus === "pass").length,
|
|
numericWarning: rows.filter((actor) => actor.numericStatus === "warning").length,
|
|
numericError: rows.filter((actor) => actor.numericStatus === "error").length,
|
|
failed: rows.filter((actor) => actor.numericStatus === "failed").length,
|
|
actions: rows.reduce((sum, actor) => sum + actor.actions, 0),
|
|
evaluatedPoses: rows.reduce((sum, actor) => sum + actor.evaluatedPoses, 0),
|
|
flaggedSamples: rows.reduce((sum, actor) => sum + actor.flaggedSamples, 0),
|
|
visualAccepted: rows.filter((actor) => !actor.visualVerdict.startsWith("defect")).length,
|
|
confirmedDefects: rows.filter((actor) => actor.visualVerdict.startsWith("defect")).length,
|
|
emptyReason:
|
|
rows.length === 0 ? reviewInput.emptyReasons?.[dungeonId] ?? "No configured runtime variants." : null,
|
|
};
|
|
});
|
|
|
|
const result = {
|
|
schemaVersion: 1,
|
|
batch: reviewInput.batch,
|
|
reviewedOn: reviewInput.reviewedOn,
|
|
authority: {
|
|
runtimeCoverage: reviewInput.runtimeCoverage,
|
|
numericSummary: "summary.json",
|
|
method: reviewInput.method,
|
|
command: reviewInput.command,
|
|
},
|
|
totals: {
|
|
configuredVariants: actors.length,
|
|
contactSheetsReviewed: reviewed.length,
|
|
numericPassActors: actors.filter((actor) => actor.numericStatus === "pass").length,
|
|
numericWarningActors: actors.filter((actor) => actor.numericStatus === "warning").length,
|
|
numericErrorActors: actors.filter((actor) => actor.numericStatus === "error").length,
|
|
failedActors: actors.filter((actor) => actor.numericStatus === "failed").length,
|
|
sampledActions: actors.reduce((sum, actor) => sum + actor.actions, 0),
|
|
evaluatedPoses: actors.reduce((sum, actor) => sum + actor.evaluatedPoses, 0),
|
|
flaggedSamples: actors.reduce((sum, actor) => sum + actor.flaggedSamples, 0),
|
|
sampleWarnings: warningSamples.length,
|
|
assetWarnings: assetWarnings.length,
|
|
visualAccepted: actors.filter((actor) => !actor.visualVerdict.startsWith("defect")).length,
|
|
confirmedDefects: actors.filter((actor) => actor.visualVerdict.startsWith("defect")).length,
|
|
},
|
|
disposition: reviewInput.disposition,
|
|
dungeons,
|
|
warningActors: actors.filter(
|
|
(actor) => actor.numericStatus !== "pass" || actor.assetIssues.length > 0,
|
|
),
|
|
warningSamples,
|
|
assetWarnings,
|
|
errors: actors.filter((actor) => ["error", "failed"].includes(actor.numericStatus)),
|
|
actors,
|
|
limitations: reviewInput.limitations,
|
|
};
|
|
await writeFile(path.resolve(outputJsonValue), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
|
|
|
const lines = [
|
|
`# RuneWaker animation pose audit - ${reviewInput.batch}`,
|
|
"",
|
|
reviewInput.disposition,
|
|
"",
|
|
`- Configured shipping variants: ${result.totals.configuredVariants}`,
|
|
`- Contact sheets reviewed: ${result.totals.contactSheetsReviewed}`,
|
|
`- Evaluated poses: ${result.totals.evaluatedPoses} across ${result.totals.sampledActions} actions`,
|
|
`- Numeric actor results: ${result.totals.numericPassActors} pass, ${result.totals.numericWarningActors} warning, ${result.totals.numericErrorActors} error, ${result.totals.failedActors} failed`,
|
|
`- Numeric issue events: ${result.totals.sampleWarnings} sampled-pose warnings and ${result.totals.assetWarnings} asset warning`,
|
|
`- Visual results: ${result.totals.visualAccepted} accepted, ${result.totals.confirmedDefects} confirmed defects`,
|
|
"",
|
|
"## Dungeon totals",
|
|
"",
|
|
"| Dungeon | Variants | Pass | Warning | Error/failed | Actions | Poses | Flagged | Visual accepted |",
|
|
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
|
...dungeons.map(
|
|
(dungeon) =>
|
|
`| ${dungeon.dungeonId} | ${dungeon.configuredVariants} | ${dungeon.numericPass} | ${dungeon.numericWarning} | ${dungeon.numericError + dungeon.failed} | ${dungeon.actions} | ${dungeon.evaluatedPoses} | ${dungeon.flaggedSamples} | ${dungeon.visualAccepted} |`,
|
|
),
|
|
"",
|
|
"## Warning disposition",
|
|
"",
|
|
"Every numeric warning is retained in `batch-a-review.json` down to action, frame label, frame value, and issue payload.",
|
|
"",
|
|
"| Variant | Numeric warnings | Affected actions | Visual verdict | Disposition |",
|
|
"| --- | ---: | --- | --- | --- |",
|
|
...result.warningActors.map((actor) => {
|
|
const warningCount = Object.values(actor.warningCodes).reduce((sum, count) => sum + count, 0);
|
|
const actions = Object.entries(actor.warningActions)
|
|
.map(([name, count]) => `${name} (${count})`)
|
|
.join(", ");
|
|
return `| ${actor.variantId} | ${warningCount + actor.assetIssues.length} | ${markdownCell(actions || actor.assetIssues.map((issue) => issue.code).join(", "))} | ${actor.visualVerdict} | ${markdownCell(actor.visualNote)} |`;
|
|
}),
|
|
"",
|
|
"## Per-actor visual verdicts",
|
|
];
|
|
for (const dungeon of dungeons) {
|
|
lines.push("", `### ${dungeon.dungeonId}`, "");
|
|
if (dungeon.configuredVariants === 0) {
|
|
lines.push(dungeon.emptyReason, "");
|
|
continue;
|
|
}
|
|
lines.push(
|
|
"| Variant | Numeric | Actions/poses | Visual verdict | Review note | Evidence |",
|
|
"| --- | --- | ---: | --- | --- | --- |",
|
|
);
|
|
for (const actor of actors.filter((row) => row.dungeonId === dungeon.dungeonId)) {
|
|
lines.push(
|
|
`| ${actor.variantId.split("/").at(-1)} | ${actor.numericStatus} | ${actor.actions}/${actor.evaluatedPoses} | ${actor.visualVerdict} | ${markdownCell(actor.visualNote)} | [sheet](${actor.contactSheet}) / [metrics](${actor.report}) |`,
|
|
);
|
|
}
|
|
}
|
|
lines.push(
|
|
"",
|
|
"## Command",
|
|
"",
|
|
"```powershell",
|
|
reviewInput.command,
|
|
"```",
|
|
"",
|
|
"## Limits",
|
|
"",
|
|
...reviewInput.limitations.map((limitation) => `- ${limitation}`),
|
|
"",
|
|
);
|
|
await writeFile(path.resolve(outputMarkdownValue), lines.join("\n"), "utf8");
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack ?? error.message);
|
|
process.exitCode = 1;
|
|
});
|