Files
RingBRP/tools/gen-art.mjs
T
slaguru666andClaude Opus 5 c6924d6ea8 THROUGH TRAIN: art prompt sheet, and a runner for it
Prompts for all 43 assets - 16 duty-roster portraits, 14 NPC portraits from TT_CAST, and the 13 scene plates, none of which had a recorded prompt, so none of them could be regenerated.

Art direction is frontier newspaper engraving, and the present-day Bureau material is drawn in the same 1881 hand as Cordera with modern people composed as tintype portraits, so the two halves read as one document. Three rules are written into the sheet because they protect meaning rather than style: nothing modern is drawn on the 1881 side, the train is absence rather than a glowing spectre, and nobody is lit as a villain.

tools/gen-art.mjs parses the sheet rather than keeping a second copy of the prompts, expands the style tokens and drives mj-gen. Dry run by default, because --run fires paid generations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:08:36 +01:00

126 lines
4.5 KiB
JavaScript

/**
* Art generation runner.
*
* Reads a scenario art prompt sheet, expands its style tokens, and either prints
* the mj-gen commands or runs them. The markdown IS the source of truth — the
* prompts are not duplicated here, for the same reason the rules journal is
* generated from rules.mjs: two copies of a prompt is one copy too many.
*
* node tools/gen-art.mjs print every command (default, safe)
* node tools/gen-art.mjs --run actually invoke mj-gen
* node tools/gen-art.mjs --only pc_finch one asset, or a comma-separated list
* node tools/gen-art.mjs --only pc_ prefix match works too
* node tools/gen-art.mjs --force regenerate assets that already exist
* node tools/gen-art.mjs --sheet <path> a different prompt sheet
*
* --run requires mj-gen on PATH. It is deliberately not the default: this fires
* paid generations, and forty-three of them is not something to do by typo.
*/
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
const argv = process.argv.slice(2);
const flag = n => argv.includes(n);
const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
const SHEET = path.resolve(opt("--sheet", "docs/scenarios/THROUGH_TRAIN_ART.md"));
const RUN = flag("--run");
const FORCE = flag("--force");
const ONLY = (opt("--only", "") || "").split(",").map(s => s.trim()).filter(Boolean);
/* ---------------------------------------------------------------- parse */
const rows = readFileSync(SHEET, "utf8")
.split(/\r?\n/)
.filter(l => /^\|/.test(l) && !/^\|\s*-+/.test(l))
.map(l => l.replace(/^\|/, "").replace(/\|\s*$/, "").split("|").map(c => c.trim()));
const tokens = {};
const assets = [];
for (const cells of rows) {
if (cells.length === 2) {
const m = cells[0].match(/^`(\[[A-Z]+\])`$/);
if (m) tokens[m[1]] = cells[1];
} else if (cells.length === 3) {
const m = cells[0].match(/^`([a-z0-9_]+)`$/);
if (m) assets.push({ id: m[1], label: cells[1], raw: cells[2] });
}
}
if (!Object.keys(tokens).length) throw new Error(`no style tokens found in ${SHEET}`);
if (!assets.length) throw new Error(`no asset rows found in ${SHEET}`);
/* -------------------------------------------------------------- expand */
const expand = (s, depth = 0) => {
if (depth > 8) throw new Error(`style token loop: ${s.slice(0, 60)}`);
const out = s.replace(/\[([A-Z]+)\]/g, (whole, name) =>
tokens[`[${name}]`] !== undefined ? tokens[`[${name}]`] : whole);
return out === s ? out : expand(out, depth + 1);
};
const tidy = s => s
.replace(/`/g, "")
.replace(/\s*\+\s*/g, ", ")
.replace(/\s*,\s*,\s*/g, ", ")
.replace(/\s+/g, " ")
.replace(/,\s*(--)/g, " $1")
.trim();
/* Portraits and scenes live in different folders; a numeric suffix means a plate. */
const destOf = id =>
id.startsWith("pc_") ? "art/portraits"
: /^tt_\d/.test(id) ? "art/scenes"
: "art/portraits";
/* ----------------------------------------------------------------- run */
const selected = assets.filter(a =>
!ONLY.length || ONLY.some(o => a.id === o || a.id.startsWith(o)));
if (!selected.length) {
console.error(`nothing matched --only ${ONLY.join(",")}`);
process.exit(1);
}
/* No shell anywhere below: a prompt is full of quotes, commas and double dashes,
and concatenating that into a command line is how you lose an afternoon. */
if (RUN && spawnSync("mj-gen", ["--help"]).error?.code === "ENOENT") {
console.error("mj-gen is not on PATH — run this on the machine that has the Midjourney bridge.");
process.exit(1);
}
let made = 0, skipped = 0;
for (const a of selected) {
const dir = destOf(a.id);
const out = path.join(dir, `${a.id}.webp`);
const prompt = tidy(expand(a.raw));
if (!FORCE && existsSync(out)) {
console.log(`skip ${a.id.padEnd(20)} exists — use --force to regenerate`);
skipped++;
continue;
}
if (!RUN) {
console.log(`\n# ${a.id} — ${a.label}\n# -> ${out}\nmj-gen "${prompt}"`);
made++;
continue;
}
mkdirSync(dir, { recursive: true });
console.log(`gen ${a.id.padEnd(20)} ${a.label}`);
const r = spawnSync("mj-gen", [prompt], { stdio: "inherit" });
if (r.status !== 0) {
console.error(`FAILED on ${a.id} (exit ${r.status}) — stopping so you can look at it.`);
process.exit(r.status || 1);
}
made++;
}
console.log(`\n${RUN ? "generated" : "printed"} ${made}, skipped ${skipped}, of ${assets.length} in the sheet.`);
if (!RUN) console.log("Nothing was generated. Add --run on a machine with mj-gen.");