[object Object]

← back to Paul Conrad Cartoons Shadowman

TK-12179: Shadow Man generator (prompt composer, local SDXL renderer w/ memory guard + signature stamp, contact sheet + verifier)

f6437ab238911390b2e684408b000724a042e5ad · 2026-09-24 15:23:26 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019vkV6GBLJ9rN1VQ8oM8HJc

Files touched

Diff

commit f6437ab238911390b2e684408b000724a042e5ad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 15:23:26 2026 -0700

    TK-12179: Shadow Man generator (prompt composer, local SDXL renderer w/ memory guard + signature stamp, contact sheet + verifier)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019vkV6GBLJ9rN1VQ8oM8HJc
---
 generator/build-contact-sheet.mjs  |  97 ++++++++++++++++++
 generator/compose-prompts.mjs      | 196 +++++++++++++++++++++++++++++++++++++
 generator/gen_shadowman.py         | 185 ++++++++++++++++++++++++++++++++++
 generator/verify-contact-sheet.cjs |  55 +++++++++++
 4 files changed, 533 insertions(+)

diff --git a/generator/build-contact-sheet.mjs b/generator/build-contact-sheet.mjs
new file mode 100644
index 0000000..6fb8322
--- /dev/null
+++ b/generator/build-contact-sheet.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// build-contact-sheet.mjs — writes generator/out/index.html, a LOCAL-ONLY contact
+// sheet for the Shadow Man sample batch. The manifest is embedded inline so the
+// page works straight from file:// (no server). Sort <select> + density slider,
+// both persisted in localStorage; every card shows caption + created date/time.
+// Usage: node generator/build-contact-sheet.mjs
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'out');
+const man = JSON.parse(fs.readFileSync(path.join(OUT, 'manifest.json'), 'utf8'));
+const data = man.items.map(({ id, created_at, theme, era, caption, seed, gen_seconds, file }) => ({ id, created_at, theme, era, caption, seed, gen_seconds, file }));
+const esc = s => JSON.stringify(s).replace(/</g, '\\u003c');
+
+const html = `<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex">
+<title>Shadow Man — sample contact sheet (local)</title>
+<style>
+  :root { --min: 300px; }
+  * { box-sizing: border-box; }
+  body { margin: 0; font: 15px/1.4 Georgia, serif; background: #f4f1ea; color: #111; }
+  header { padding: 18px 24px 8px; border-bottom: 3px solid #111; background: #fff; position: sticky; top: 0; z-index: 2; }
+  h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: .5px; }
+  .sub { color: #555; font-size: 13px; }
+  .controls { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; margin-top: 10px; font-family: system-ui, sans-serif; font-size: 13px; }
+  .controls label { display: flex; gap: 8px; align-items: center; }
+  select, input[type=range] { font: inherit; }
+  main { padding: 20px 24px 40px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(var(--min), 1fr)); }
+  .card { background: #fff; border: 2px solid #111; box-shadow: 4px 4px 0 #111; display: flex; flex-direction: column; }
+  .card img { width: 100%; display: block; border-bottom: 1px solid #ddd; background: #eee; aspect-ratio: 1176 / 1000; object-fit: contain; }
+  .body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; }
+  .cap { font-style: italic; font-weight: bold; font-size: 16px; }
+  .meta { font-family: system-ui, sans-serif; font-size: 12px; color: #444; display: flex; gap: 6px; flex-wrap: wrap; }
+  .chip { border: 1px solid #999; border-radius: 10px; padding: 1px 8px; }
+  .when { font-family: system-ui, sans-serif; font-size: 12px; color: #222; }
+</style></head>
+<body>
+<header>
+  <h1>Shadow Man — sample batch</h1>
+  <div class="sub">${data.length} original AI editorial cartoons · local SDXL · $0 · signed “Shadow Man” · local-only preview, not published</div>
+  <div class="controls">
+    <label for="sort">Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="oldest">Oldest</option>
+        <option value="theme">Theme A→Z</option>
+        <option value="era">Era</option>
+        <option value="caption">Caption A→Z</option>
+        <option value="gen">Render time ↓</option>
+      </select>
+    </label>
+    <label for="density">Density
+      <input id="density" type="range" min="180" max="620" step="20" value="300" aria-label="Card minimum width in pixels">
+      <span id="dval">300px</span>
+    </label>
+  </div>
+</header>
+<main id="grid" aria-live="polite"></main>
+<script>
+const DATA = ${esc(data)};
+const grid = document.getElementById('grid'), sortSel = document.getElementById('sort'), dens = document.getElementById('density'), dval = document.getElementById('dval');
+const fmtDate = iso => new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
+const SORTS = {
+  newest: (a, b) => b.created_at.localeCompare(a.created_at),
+  oldest: (a, b) => a.created_at.localeCompare(b.created_at),
+  theme: (a, b) => a.theme.localeCompare(b.theme) || a.id.localeCompare(b.id),
+  era: (a, b) => a.era.localeCompare(b.era) || a.id.localeCompare(b.id),
+  caption: (a, b) => a.caption.replace(/^\\W+/, '').localeCompare(b.caption.replace(/^\\W+/, '')),
+  gen: (a, b) => b.gen_seconds - a.gen_seconds,
+};
+function render() {
+  grid.replaceChildren(...[...DATA].sort(SORTS[sortSel.value] || SORTS.newest).map(d => {
+    const c = el('article', 'card');
+    const img = el('img'); img.src = d.file; img.alt = 'Shadow Man editorial cartoon: ' + d.caption; img.loading = 'lazy';
+    const b = el('div', 'body');
+    b.append(el('div', 'cap', d.caption));
+    const m = el('div', 'meta'); m.append(el('span', 'chip', d.theme), el('span', 'chip', d.era), el('span', 'chip', d.gen_seconds + 's'));
+    const w = el('div', 'when', '\\u{1F553} ' + fmtDate(d.created_at)); w.title = d.created_at;
+    b.append(m, w); c.append(img, b); return c;
+  }));
+}
+function setDensity(v) { document.documentElement.style.setProperty('--min', v + 'px'); dval.textContent = v + 'px'; }
+sortSel.value = localStorage.getItem('shadowman.sort') || 'newest';
+dens.value = localStorage.getItem('shadowman.density') || '300';
+setDensity(dens.value);
+sortSel.addEventListener('change', () => { localStorage.setItem('shadowman.sort', sortSel.value); render(); });
+dens.addEventListener('input', () => { localStorage.setItem('shadowman.density', dens.value); setDensity(dens.value); });
+render();
+</script>
+</body></html>
+`;
+fs.writeFileSync(path.join(OUT, 'index.html'), html);
+console.log(`wrote ${path.join(OUT, 'index.html')} (${data.length} cards)`);
diff --git a/generator/compose-prompts.mjs b/generator/compose-prompts.mjs
new file mode 100644
index 0000000..1945451
--- /dev/null
+++ b/generator/compose-prompts.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+// compose-prompts.mjs — Shadow Man editorial-cartoon brief composer (TK-12179 step 2).
+//
+// Composes ORIGINAL single-panel editorial-cartoon briefs from the research theme
+// bank (research/theme-bank.json): the bank decides WHICH topics and eras get
+// weight; the scenes, metaphors and captions below are newly written stock
+// satire built from archetypal, unnamed figures (a senator, a general, a
+// lobbyist...). Hard rules enforced in code:
+//   - no prompt or caption may name the research subject (the historical
+//     cartoonist) or ask for any artist's "style";
+//   - no real, named people in prompts (archetypes only);
+//   - nothing here recreates a specific archival cartoon (captions are
+//     invented; the bank's example caption titles are never copied).
+// Optionally blends in today's headline TAGS from crazy-news-channel's
+// real-news-data.js (read-only; that repo is never modified).
+//
+// Selection logic is adapted from crazy-news-channel/scripts/daily-cartoon-gen.mjs
+// (date-seeded deterministic shuffle so a re-run for the same seed is idempotent).
+//
+// Usage: node generator/compose-prompts.mjs [--count=20] [--seed=YYYY-MM-DD] [--out=generator/out/batch.json]
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.join(HERE, '..');
+const NEWS = path.join(process.env.HOME, 'Projects', 'crazy-news-channel', 'real-news-data.js');
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a, true]; }));
+const COUNT = parseInt(args.count || '20', 10);
+const SEED = args.seed || new Date().toISOString().slice(0, 10);
+const OUT = path.resolve(ROOT, args.out || 'generator/out/batch.json');
+
+// ---- deterministic RNG (same LCG family as daily-cartoon-gen.mjs) ----
+let s = SEED.split(/\D+/).filter(Boolean).reduce((a, n) => (a * 31 + parseInt(n, 10)) & 0x7fffffff, 7);
+const rand = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
+const pick = a => a[Math.floor(rand() * a.length)];
+const weighted = entries => { const t = entries.reduce((a, [, w]) => a + w, 0); let r = rand() * t; for (const [k, w] of entries) { if ((r -= w) <= 0) return k; } return entries.at(-1)[0]; };
+
+// ---- original scene library: [scene, caption] per theme (all invented) ----
+const SCENES = {
+  'Presidential power & scandal': [
+    ['a tiny president in an enormous throne-like chair, feet dangling, sawing off the chair legs labeled CHECKS and BALANCES', '"Just a little trim."'],
+    ['a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry', '"There is no leak. Next question."'],
+    ['a president shredding documents that pile up into a mountain spelling out the very thing he denies', '"Nothing to see here."'],
+    ['a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome', '"We found a shortcut."'],
+  ],
+  'War & militarism': [
+    ['a general pinning a medal on a gravestone while a defense contractor counts money behind him', '"Mission accomplished, again."'],
+    ['a giant war machine made of dollar bills, rolling over a tiny olive branch', '"Peace is expensive. War is more profitable."'],
+    ['a map room where generals move toy soldiers while real boots stand empty in rows outside the window', '"Acceptable losses."'],
+    ['an endless line of flag-draped coffins receding to the horizon under a banner reading LIGHT AT THE END OF THE TUNNEL', '"Almost there."'],
+  ],
+  'Nuclear arms & the Cold War': [
+    ['two tiny statesmen shaking hands atop a gigantic stack of missiles that towers above the clouds', '"A sensible balance."'],
+    ['a doomsday clock drawn as a kitchen timer, a diplomat casually setting it to one minute', '"We work better under pressure."'],
+    ['a mushroom cloud rendered as a giant question mark over a quiet suburb', '"Who pushed it first?"'],
+  ],
+  'Civil rights & race': [
+    ['a long voting line stretching past a closed polling door guarded by a bureaucrat holding a rulebook', '"Please take a number. Any number but yours."'],
+    ['a statue of Justice peeking under her blindfold at the people in front of her', '"Just checking."'],
+    ['a staircase labeled OPPORTUNITY with the bottom steps sawed off, a smug official at the top saying come on up', '"The door is wide open!"'],
+  ],
+  'Guns & violence': [
+    ['a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head', '"Good boy."'],
+    ['a child\'s school backpack drawn as a bulletproof vest on a playground', '"Back to school supplies."'],
+    ['a giant revolver used as a podium by a politician giving a speech about freedom', '"We will not be intimidated."'],
+  ],
+  'Corruption & money in politics': [
+    ['a senator as a vending machine, coins going in the slot and legislation dropping out the tray', '"Exact change only."'],
+    ['a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs', '"Democracy in action."'],
+    ['a politician taking an oath with one hand on a bible and the other hand behind his back in a donor\'s pocket', '"So help me, donors."'],
+  ],
+  'Taxes, economy & inequality': [
+    ['a fat cat in a top hat lounging on a hammock strung between two tiny exhausted workers', '"Trickle-down is working fine up here."'],
+    ['an ordinary taxpayer squeezed in a giant lemon press labeled TAXES while a yacht sails by labeled LOOPHOLE', '"Everybody pays their fair share."'],
+    ['a budget pie chart drawn as a real pie, a general eating most of it while a schoolteacher gets crumbs', '"Priorities."'],
+  ],
+  'Environment & energy': [
+    ['an oil executive watering a single dead tree with crude oil from a can', '"It just needs more growth."'],
+    ['a planet Earth sweating on a hospital bed while lobbyists argue at the foot about the thermometer', '"The readings are exaggerated."'],
+    ['a city skyline disappearing into smog, a mayor pointing proudly at a single potted plant', '"Our green initiative."'],
+  ],
+  'Courts, law & justice': [
+    ['the scales of justice with a wallet on one side outweighing a whole crowd of people on the other', '"Perfectly balanced."'],
+    ['a judge with a gavel shaped like a price tag', '"Sold to the highest bidder."'],
+    ['a constitution being folded into a paper airplane by men in robes', '"Just a new interpretation."'],
+  ],
+  'Religion & politics': [
+    ['a politician wearing a halo held up by a stick in his back pocket, preaching from a campaign podium', '"Vote your values. Mine are on sale."'],
+    ['a collection plate being passed around a legislative chamber', '"Tithing, bipartisan."'],
+  ],
+  'Press, speech & dissent': [
+    ['a reporter\'s typewriter locked in a cage while an official reads a press release to a room of empty chairs', '"Any questions? Good."'],
+    ['a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline', '"Corrections department."'],
+    ['a protester with a blank sign being arrested for what the sign might say', '"Pre-emptive order."'],
+  ],
+  'Elections & party politics': [
+    ['an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table', '"May the best party win."'],
+    ['a ballot box drawn as a slot machine, a candidate pulling the lever', '"Feeling lucky, America?"'],
+    ['a candidate kissing a baby while picking the baby\'s pocket', '"Family values."'],
+    ['a gerrymandered district map shaped like a pretzel with a politician tied up inside it', '"My constituents love me."'],
+  ],
+  'Local LA & California politics': [
+    ['a city hall floating away on a freeway overpass while commuters sit in endless gridlock below', '"Traffic relief is on the way."'],
+    ['a mayor cutting a ribbon on a pothole', '"Another infrastructure milestone."'],
+  ],
+  'Health & medicine': [
+    ['a patient on an operating table while insurance executives argue over his wallet instead of his heart', '"Is it a covered organ?"'],
+    ['a pharmacy counter with a single pill on a velvet pillow priced like a diamond', '"Generic, you say?"'],
+  ],
+  'Labor & unions': [
+    ['a worker holding up an entire factory on his shoulders while a boss on the roof takes a bow', '"Self-made."'],
+    ['a robot handing a pink slip to a worker who built the robot', '"Nothing personal."'],
+  ],
+  'Middle East & foreign policy': [
+    ['a diplomat handing out olive branches with one hand and weapons crates with the other', '"We are committed to peace."'],
+    ['a globe balanced on a teetering stack of treaties, a statesman removing one from the bottom', '"This one was outdated."'],
+  ],
+  'Space & technology': [
+    ['a billionaire rocket leaving Earth, leaving behind a sign reading NOT MY PROBLEM', '"One small step for a man."'],
+    ['a giant smartphone watching a tiny citizen through its camera', '"We value your privacy."'],
+  ],
+  'Immigration & borders': [
+    ['the Statue of Liberty putting down her torch to install a doorbell camera', '"Who\'s there?"'],
+    ['a wall built out of campaign speeches with a ladder made of labor shortages leaning against it', '"Mixed signals."'],
+  ],
+  'Education': [
+    ['a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door', '"Go team."'],
+    ['a teacher buying her own chalk at a pawn shop', '"Investing in our future."'],
+  ],
+  'Memorials & tributes': [
+    ['an empty podium with a single lit candle and a folded flag, in solemn quiet', '"The silence says it."'],
+  ],
+};
+
+const ERA_FLAVOR = {
+  '1950s': 'mid-century props, fedoras and rotary phones',
+  '1960s': '1960s props, skinny ties and television antennas',
+  '1970s': '1970s props, wide lapels and gas lines',
+  '1980s': '1980s props, power suits and big desk phones',
+  '1990s': '1990s props, pagers and cable news monitors',
+  '2000s': '2000s props, flip phones and flat screens',
+  '2010s': 'modern props, smartphones and podiums',
+  '2020s': 'contemporary props, smartphones and livestream cameras',
+};
+
+// Style block: generic editorial-cartoon technique words only (no artist names).
+// Kept short: CLIP truncates at 77 tokens, so the scene goes FIRST and style last.
+const STYLE = 'bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire';
+const BANNED = /conrad|in the style of|style of [A-Z]|pulitzer/i;
+
+function loadNewsThemes(themeNames) {
+  try {
+    const src = fs.readFileSync(NEWS, 'utf8');
+    const arr = JSON.parse(src.slice(src.indexOf('['), src.lastIndexOf(']') + 1));
+    const tagMap = { immigration: 'Immigration & borders', elections: 'Elections & party politics', congress: 'Corruption & money in politics', 'white house': 'Presidential power & scandal', economy: 'Taxes, economy & inequality', climate: 'Environment & energy', courts: 'Courts, law & justice', 'supreme court': 'Courts, law & justice', guns: 'Guns & violence', health: 'Health & medicine', war: 'War & militarism', military: 'War & militarism', tech: 'Space & technology', education: 'Education', labor: 'Labor & unions' };
+    const counts = {};
+    for (const st of arr) for (const t of st.tags || []) { const th = tagMap[t]; if (th && themeNames.includes(th)) counts[th] = (counts[th] || 0) + 1; }
+    return { stories: arr.length, counts };
+  } catch { return null; }
+}
+
+function main() {
+  const bank = JSON.parse(fs.readFileSync(path.join(ROOT, 'research', 'theme-bank.json'), 'utf8'));
+  const themeNames = Object.keys(bank.themes).filter(t => SCENES[t]);
+  const news = loadNewsThemes(themeNames);
+  // Weight = archival theme volume (sqrt-damped so the long tail still appears)
+  const baseW = themeNames.map(t => [t, Math.sqrt(bank.themes[t].total)]);
+  const used = new Set();
+  const items = [];
+  const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
+  for (let i = 0; i < COUNT; i++) {
+    // ~30% of briefs are "today's desk": themes weighted by current headline tags.
+    const fromNews = news && Object.keys(news.counts).length && rand() < 0.3;
+    const theme = fromNews ? weighted(Object.entries(news.counts)) : weighted(baseW);
+    const eraEntries = Object.entries(bank.themes[theme].by_decade).filter(([d]) => ERA_FLAVOR[d]);
+    const era = fromNews ? '2020s' : (eraEntries.length ? weighted(eraEntries) : '2020s');
+    let options = SCENES[theme].filter(([sc]) => !used.has(sc));
+    if (!options.length) options = SCENES[theme];
+    const [scene, caption] = pick(options);
+    used.add(scene);
+    const prompt = `${scene}, ${ERA_FLAVOR[era]}. ${STYLE}`;
+    if (BANNED.test(prompt) || BANNED.test(caption)) throw new Error(`banned term in prompt ${i}`);
+    items.push({
+      id: `shadowman-${stamp}-${String(i + 1).padStart(3, '0')}`,
+      theme, era, source: fromNews ? 'theme-bank+today-headline-tags' : 'theme-bank',
+      scene, caption, prompt,
+      seed: Math.floor(rand() * 2 ** 31),
+    });
+  }
+  fs.mkdirSync(path.dirname(OUT), { recursive: true });
+  fs.writeFileSync(OUT, JSON.stringify({ generated_at: new Date().toISOString(), seed: SEED, news_blend: news ? { stories: news.stories, theme_counts: news.counts } : null, items }, null, 2));
+  console.log(`Composed ${items.length} briefs -> ${path.relative(ROOT, OUT)}${news ? ` (news blend: ${news.stories} stories)` : ''}`);
+  for (const it of items) console.log(`  ${it.id}  [${it.era}] ${it.theme} :: ${it.caption}`);
+}
+main();
diff --git a/generator/gen_shadowman.py b/generator/gen_shadowman.py
new file mode 100644
index 0000000..21f4849
--- /dev/null
+++ b/generator/gen_shadowman.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+"""Shadow Man editorial-cartoon renderer (TK-12179 step 2). Local SDXL, $0.
+
+  ~/.venvs/sdxl/bin/python generator/gen_shadowman.py generator/out/batch.json [--limit 20]
+
+Adapted from crazy-news-channel/tools/gen_images.py (same local SDXL checkpoint +
+diffusers pipeline; that file is not modified). Differences:
+  * ink editorial-cartoon style, square-ish single panel, no photo style;
+  * post-process to high-contrast black ink, add a caption band, and stamp the
+    signature "Shadow Man" lower-right on EVERY final image (PIL);
+  * one image at a time with a memory guard: before loading the model and before
+    every image it checks kern.memorystatus_vm_pressure_level and the
+    memory_pressure free %, and aborts cleanly (exit 3) if pressure is critical,
+    so the exo vision instance and other LLM lanes on this box are not starved;
+  * appends each finished image to generator/out/manifest.json immediately
+    (a crash mid-batch still leaves a truthful manifest).
+"""
+import argparse, datetime, gc, json, os, re, subprocess, sys, time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.environ.get("SHADOWMAN_OUT") or os.path.join(HERE, "out")
+MANIFEST = os.path.join(OUT, "manifest.json")
+CKPT = os.environ.get("SDXL_CKPT") or \
+    "/Volumes/Henry/mac2-archive/2026-06-26-reclaim/ComfyUI-checkpoints/sd_xl_base_1.0.safetensors"
+MODEL = "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)"
+SIGNATURE = "Shadow Man"
+NEGATIVE = ("text, words, letters, caption, speech bubble text, watermark, logo, signature, "
+            "artist name, typography, color, colorful, photograph, photorealistic, 3d render, "
+            "blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip")
+BANNED = re.compile(r"conrad|in the style of|pulitzer", re.I)
+MIN_FREE_PCT = int(os.environ.get("SHADOWMAN_MIN_FREE_PCT", "12"))
+FONT_CAPTION = "/System/Library/Fonts/Supplemental/Georgia Bold Italic.ttf"
+FONT_SIG = "/System/Library/Fonts/Supplemental/Bradley Hand Bold.ttf"
+
+
+def memory_ok():
+    """Return (ok, detail). Critical pressure (level 4) or free% below floor -> not ok."""
+    level, free = None, None
+    try:
+        level = int(subprocess.run(["sysctl", "-n", "kern.memorystatus_vm_pressure_level"],
+                                   capture_output=True, text=True, timeout=10).stdout.strip())
+    except Exception:
+        pass
+    try:
+        out = subprocess.run(["memory_pressure", "-Q"], capture_output=True, text=True, timeout=20).stdout
+        m = re.search(r"free percentage:\s*(\d+)%", out)
+        free = int(m.group(1)) if m else None
+    except Exception:
+        pass
+    detail = f"pressure_level={level} free={free}%"
+    if level is None and free is None:
+        return False, detail + " (NOT MEASURED — refusing to run blind)"
+    if level is not None and level >= 4:
+        return False, detail + " (CRITICAL)"
+    if free is not None and free < MIN_FREE_PCT:
+        return False, detail + f" (< {MIN_FREE_PCT}% floor)"
+    return True, detail
+
+
+def load_manifest():
+    if os.path.exists(MANIFEST):
+        return json.load(open(MANIFEST))
+    return {"signature": SIGNATURE, "note": "AI-generated original editorial cartoons signed Shadow Man. $0 local SDXL.", "items": []}
+
+
+def save_manifest(m):
+    tmp = MANIFEST + ".tmp"
+    json.dump(m, open(tmp, "w"), indent=2)
+    os.replace(tmp, MANIFEST)
+
+
+def wrap(draw, text, font, width):
+    words, lines, cur = text.split(), [], ""
+    for w in words:
+        t = (cur + " " + w).strip()
+        if draw.textlength(t, font=font) <= width:
+            cur = t
+        else:
+            lines.append(cur)
+            cur = w
+    if cur:
+        lines.append(cur)
+    return lines
+
+
+def finish(img, caption):
+    """Ink post-process + caption band + Shadow Man signature."""
+    from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance
+    g = ImageOps.grayscale(img)
+    g = ImageEnhance.Contrast(g).enhance(1.6)
+    g = g.point(lambda p: 255 if p > 200 else (0 if p < 45 else p))  # push toward ink/paper
+    art = g.convert("RGB")
+    W, H = art.size
+    d = ImageDraw.Draw(art)
+    # signature: lower-right corner of the drawing, ink-style hand lettering on a paper patch
+    sf = ImageFont.truetype(FONT_SIG, max(30, W // 26))
+    sw = d.textlength(SIGNATURE, font=sf)
+    bbox = d.textbbox((0, 0), SIGNATURE, font=sf)
+    sh = bbox[3] - bbox[1]
+    x, y = W - sw - W // 30, H - sh - H // 22
+    d.rectangle([x - 12, y - 6, x + sw + 12, y + sh + 16], fill=(255, 255, 255))
+    d.text((x, y - bbox[1]), SIGNATURE, font=sf, fill=(10, 10, 10))
+    d.line([x, y + sh + 8, x + sw, y + sh + 4], fill=(10, 10, 10), width=3)  # brush underline
+    # caption band under the panel
+    cf = ImageFont.truetype(FONT_CAPTION, max(26, W // 34))
+    lines = wrap(d, caption, cf, W - 80)
+    lh = int(cf.size * 1.35)
+    band = 40 + lh * len(lines)
+    canvas = Image.new("RGB", (W + 24, H + band + 24), (255, 255, 255))
+    canvas.paste(art, (12, 12))
+    cd = ImageDraw.Draw(canvas)
+    cd.rectangle([10, 10, W + 13, H + 13], outline=(0, 0, 0), width=3)  # panel border
+    for i, ln in enumerate(lines):
+        tw = cd.textlength(ln, font=cf)
+        cd.text(((W + 24 - tw) / 2, H + 12 + 22 + i * lh), ln, font=cf, fill=(0, 0, 0))
+    return canvas
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("batch")
+    ap.add_argument("--limit", type=int, default=20)
+    ap.add_argument("--steps", type=int, default=30)
+    a = ap.parse_args()
+
+    items = json.load(open(a.batch))["items"][: a.limit]
+    for it in items:
+        if BANNED.search(it["prompt"]) or BANNED.search(it["caption"]):
+            sys.exit(f"refusing: banned term in {it['id']}")
+    os.makedirs(OUT, exist_ok=True)
+    man = load_manifest()
+    done = {m["id"] for m in man["items"]}
+    todo = [it for it in items if it["id"] not in done and not os.path.exists(os.path.join(OUT, it["id"] + ".png"))]
+    print(f"{len(items)} briefs, {len(todo)} to generate (limit {a.limit})", flush=True)
+    if not todo:
+        return 0
+
+    ok, det = memory_ok()
+    print(f"memory pre-load: {det}", flush=True)
+    if not ok:
+        print("ABORT: memory guard tripped before model load", flush=True)
+        return 3
+
+    import torch
+    from diffusers import StableDiffusionXLPipeline
+    t_load = time.time()
+    device = "mps" if torch.backends.mps.is_available() else "cpu"
+    pipe = StableDiffusionXLPipeline.from_single_file(
+        CKPT, torch_dtype=torch.float16 if device == "mps" else torch.float32)
+    pipe.to(device)
+    pipe.set_progress_bar_config(disable=True)
+    print(f"model loaded on {device} in {time.time() - t_load:.0f}s", flush=True)
+
+    t_batch = time.time()
+    for i, it in enumerate(todo, 1):
+        ok, det = memory_ok()
+        if not ok:
+            print(f"ABORT before {it['id']}: {det}", flush=True)
+            return 3
+        t0 = time.time()
+        img = pipe(prompt=it["prompt"], negative_prompt=NEGATIVE, width=1152, height=896,
+                   num_inference_steps=a.steps, guidance_scale=7.0,
+                   generator=torch.Generator("cpu").manual_seed(int(it["seed"]))).images[0]
+        final = finish(img, it["caption"])
+        final.save(os.path.join(OUT, it["id"] + ".png"), optimize=True)
+        secs = round(time.time() - t0, 1)
+        man["items"].append({
+            "id": it["id"], "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
+            "theme": it["theme"], "era": it["era"], "caption": it["caption"], "prompt": it["prompt"],
+            "negative_prompt": NEGATIVE, "seed": it["seed"], "model": MODEL, "steps": a.steps,
+            "signature": SIGNATURE, "source": it.get("source"), "gen_seconds": secs, "cost_usd": 0,
+            "file": it["id"] + ".png",
+        })
+        save_manifest(man)
+        print(f"[{i}/{len(todo)}] {it['id']} {secs}s  {det}", flush=True)
+        del img, final
+        gc.collect()
+        if device == "mps":
+            torch.mps.empty_cache()
+    print(f"batch done: {len(todo)} images in {time.time() - t_batch:.0f}s (+ load)", flush=True)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/generator/verify-contact-sheet.cjs b/generator/verify-contact-sheet.cjs
new file mode 100644
index 0000000..c315241
--- /dev/null
+++ b/generator/verify-contact-sheet.cjs
@@ -0,0 +1,55 @@
+// verify-contact-sheet.cjs — headless-Chromium E2E check of generator/out/index.html.
+// Asserts: every manifest item renders as a card, every <img> actually decoded
+// (naturalWidth > 0), every card shows a created date/time chip, sort + density
+// controls work and persist across reload, zero console errors. Then screenshots
+// to generator/out/contact-sheet.png. Exit 1 on any failed assertion.
+// Negative self-test: `node generator/verify-contact-sheet.cjs --test-broken`
+// points at a copy with a missing image and MUST exit 1.
+const path = require('path');
+const fs = require('fs');
+const { chromium } = require(process.env.PW || '/Users/macstudio3/Projects/animals/node_modules/playwright');
+
+const OUT = path.join(__dirname, 'out');
+(async () => {
+  const broken = process.argv.includes('--test-broken');
+  let page_path = path.join(OUT, 'index.html');
+  if (broken) {
+    const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'sm-neg-'));
+    fs.writeFileSync(path.join(tmp, 'index.html'), fs.readFileSync(page_path, 'utf8')); // no PNGs next to it
+    page_path = path.join(tmp, 'index.html');
+  }
+  const manifest = JSON.parse(fs.readFileSync(path.join(OUT, 'manifest.json'), 'utf8'));
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
+  const page = await ctx.newPage();
+  const errors = [];
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push(String(e)));
+  await page.goto('file://' + page_path);
+  await page.waitForLoadState('networkidle');
+  await page.evaluate(async () => { for (const i of document.images) { i.loading = 'eager'; if (!i.complete) await new Promise(r => { i.onload = i.onerror = r; }); } });
+  const fails = [];
+  const cards = await page.locator('.card').count();
+  if (cards !== manifest.items.length) fails.push(`cards ${cards} != manifest ${manifest.items.length}`);
+  const badImgs = await page.evaluate(() => [...document.images].filter(i => !i.naturalWidth).length);
+  if (badImgs) fails.push(`${badImgs} images failed to decode`);
+  const whens = await page.locator('.when').count();
+  if (whens !== cards) fails.push(`date chips ${whens} != cards ${cards}`);
+  const firstWhen = await page.locator('.when').first().textContent().catch(() => '');
+  if (!/\d{1,2}:\d{2}/.test(firstWhen || '')) fails.push(`date chip lacks time: ${firstWhen}`);
+  // sort + density persistence
+  await page.selectOption('#sort', 'caption');
+  await page.locator('#density').fill('420');
+  await page.reload(); await page.waitForLoadState('networkidle');
+  const sortV = await page.inputValue('#sort'), densV = await page.inputValue('#density');
+  if (sortV !== 'caption' || densV !== '420') fails.push(`persistence failed sort=${sortV} density=${densV}`);
+  // reset to defaults for the screenshot
+  await page.selectOption('#sort', 'newest'); await page.locator('#density').fill('300');
+  await page.evaluate(async () => { for (const i of document.images) { i.loading = 'eager'; if (!i.complete) await new Promise(r => { i.onload = i.onerror = r; }); } });
+  if (errors.filter(e => !broken || !/ERR_FILE_NOT_FOUND/.test(e)).length) fails.push(`console errors: ${errors.join(' | ')}`);
+  if (!broken) await page.screenshot({ path: path.join(OUT, 'contact-sheet.png'), fullPage: true });
+  await browser.close();
+  const result = { verdict: fails.length ? 'FAIL' : 'PASS', cards, bad_images: badImgs, date_chips: whens, first_date_chip: firstWhen, persisted: { sort: sortV, density: densV }, console_errors: errors.length, fails, mode: broken ? 'negative-test' : 'real' };
+  console.log(JSON.stringify(result, null, 1));
+  process.exit(fails.length ? 1 : 0);
+})().catch(e => { console.error(e); process.exit(1); });

← d51a0f3 auto-data-snapshot: 2026-09-24T15:23:04 (101 data files) — r  ·  back to Paul Conrad Cartoons Shadowman  ·  auto-data-snapshot: 2026-09-24T15:53:39 (49 data files) — re f8ea2cd →