Files
healer-man/scripts/runewaker-pipeline/make-pose-audit-review-pages.mjs
T
2026-08-14 15:56:39 -04:00

89 lines
3.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Combine per-actor pose-audit contact sheets into paged dungeon review images.
*
* Usage:
* node scripts/runewaker-pipeline/make-pose-audit-review-pages.mjs \
* artifacts/animation-audit/batches/batch-d
*/
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import sharp from "sharp";
const batchRoot = path.resolve(process.argv[2] ?? "");
if (!process.argv[2]) {
throw new Error("Expected a pose-audit batch directory.");
}
const columns = 3;
const rows = 4;
const perPage = columns * rows;
const imageWidth = 480;
const imageHeight = 360;
const labelHeight = 34;
const cellWidth = imageWidth;
const cellHeight = imageHeight + labelHeight;
function escapeXml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
async function directories(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
}
const outputRoot = path.join(batchRoot, "review-pages");
await mkdir(outputRoot, { recursive: true });
const written = [];
for (const dungeon of await directories(batchRoot)) {
if (dungeon === "review-pages") continue;
const dungeonRoot = path.join(batchRoot, dungeon);
const actorDirectories = (await directories(dungeonRoot)).filter((name) => name.endsWith(".review"));
for (let offset = 0; offset < actorDirectories.length; offset += perPage) {
const pageActors = actorDirectories.slice(offset, offset + perPage);
const page = sharp({
create: {
width: columns * cellWidth,
height: rows * cellHeight,
channels: 4,
background: { r: 13, g: 17, b: 22, alpha: 1 },
},
});
const composites = [];
for (let index = 0; index < pageActors.length; index += 1) {
const actorDirectory = pageActors[index];
const actor = actorDirectory.replace(/\.review$/, "");
const left = (index % columns) * cellWidth;
const top = Math.floor(index / columns) * cellHeight;
const input = path.join(dungeonRoot, actorDirectory, "contact-sheet.png");
const resized = await sharp(input)
.resize(imageWidth, imageHeight, { fit: "contain", background: "#0d1116" })
.png()
.toBuffer();
const label = Buffer.from(
`<svg width="${cellWidth}" height="${labelHeight}" xmlns="http://www.w3.org/2000/svg">`
+ `<rect width="100%" height="100%" fill="#1d2730"/>`
+ `<text x="12" y="23" fill="#f0f5f8" font-family="Segoe UI,Arial,sans-serif" font-size="18">`
+ `${escapeXml(actor)}</text></svg>`,
);
composites.push({ input: resized, left, top });
composites.push({ input: label, left, top: top + imageHeight });
}
const pageNumber = Math.floor(offset / perPage) + 1;
const output = path.join(outputRoot, `${dungeon}-${String(pageNumber).padStart(2, "0")}.png`);
await page.composite(composites).png().toFile(output);
written.push(path.relative(batchRoot, output).replaceAll("\\", "/"));
}
}
console.log(JSON.stringify({ batchRoot, pages: written.length, files: written }, null, 2));