← 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
A generator/build-contact-sheet.mjsA generator/compose-prompts.mjsA generator/gen_shadowman.pyA generator/verify-contact-sheet.cjs
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 →