← back to Paul Conrad Cartoons
scripts/build-shadowman.mjs
64 lines
#!/usr/bin/env node
// build-shadowman.mjs — publish reviewed Shadow Man renders into Inkwell (TK-12241). $0, local.
//
// Inputs: generator/out/manifest.json (written by generator/gen_shadowman.py, one row per render)
// scripts/shadowman-review.json (human/agent visual review: keep|drop + title + reason per id)
// Outputs: public/shadowman/<id>.jpg (kept finals, JPEG q85 via macOS sips — the PNGs stay in generator/out/)
// data/shadowman.json (served by /api/shadowman through sendClean())
//
// Every id in the manifest MUST have a review verdict — an unreviewed render is never published
// (and never silently skipped: the build fails loudly). Captions/titles/scenes are checked against
// the naming rule and the banned-style regex before anything is written.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'generator', 'out');
const PUB = path.join(ROOT, 'public', 'shadowman');
const BANNED = /conrad|in the style of|style of [A-Z]|pulitzer/i;
const man = JSON.parse(fs.readFileSync(path.join(OUT, 'manifest.json'), 'utf8'));
const review = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'shadowman-review.json'), 'utf8'));
const missing = man.items.filter(m => !review.items[m.id]);
if (missing.length) { console.error(`FAIL: ${missing.length} renders have no review verdict: ${missing.map(m => m.id).join(', ')}`); process.exit(1); }
fs.mkdirSync(PUB, { recursive: true });
// Carry curation state (approve/delete from the Inkwell UI) across rebuilds.
const DATA = path.join(ROOT, 'data', 'shadowman.json');
const prev = fs.existsSync(DATA) ? JSON.parse(fs.readFileSync(DATA, 'utf8')) : { items: [] };
const prevStatus = Object.fromEntries(prev.items.map(i => [i.id, { status: i.status, status_at: i.status_at }]));
const items = [];
const dropped = [];
for (const m of man.items) {
const r = review.items[m.id];
if (r.verdict !== 'keep') { dropped.push({ id: m.id, reason: r.reason }); continue; }
const rec = {
id: m.id, title: r.title, caption: m.caption, theme: m.theme, era: m.era,
scene: m.prompt.replace(/\. bold black ink.*$/, ''),
created_at: m.created_at, signature: m.signature, model: m.model, steps: m.steps, seed: m.seed,
gen_seconds: m.gen_seconds, cost_usd: m.cost_usd, source: m.source,
src: `/shadowman/${m.id}.jpg`, review_note: r.reason || '',
status: (prevStatus[m.id] && prevStatus[m.id].status) || 'pending',
status_at: (prevStatus[m.id] && prevStatus[m.id].status_at) || null,
};
for (const k of ['title', 'caption', 'scene', 'theme', 'era', 'review_note']) {
if (BANNED.test(rec[k])) { console.error(`FAIL: banned term in ${m.id}.${k}`); process.exit(1); }
}
const dst = path.join(PUB, `${m.id}.jpg`);
if (rec.status === 'deleted') { items.push(rec); continue; } // image lives in generator/out/_trash/
execFileSync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '85', path.join(OUT, m.file), '--out', dst], { stdio: 'ignore' });
items.push(rec);
}
const doc = {
generated_at: new Date().toISOString(),
note: 'Original single-panel editorial cartoons written from archive themes and eras (never a redraw of a specific published cartoon), rendered locally with SDXL at $0 and signed "Shadow Man".',
counts: { briefs: new Set(man.items.map(m => m.retry_of || m.id)).size, rendered: man.items.length, kept: items.length, dropped: dropped.length },
dropped,
items,
};
fs.writeFileSync(DATA, JSON.stringify(doc, null, 2) + '\n');
console.log(`kept ${items.length}/${man.items.length} -> data/shadowman.json + public/shadowman/*.jpg; dropped ${dropped.length}`);
for (const d of dropped) console.log(` dropped ${d.id}: ${d.reason}`);