#!/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("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } 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( `` + `` + `` + `${escapeXml(actor)}`, ); 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));