89 lines
3.2 KiB
JavaScript
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("<", "<")
|
|
.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(
|
|
`<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));
|