← 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}`);