← back to Paul Conrad Cartoons
generator/compose-prompts.mjs
223 lines
#!/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]
// node generator/compose-prompts.mjs --from-queue [--count=20] [--seed=...]
// --from-queue: build the batch from generator/idea-queue.json (Model Arena ideas Steve approved in
// Inkwell) instead of the scene library; marks each used entry batched_at so it is not re-queued.
// Output defaults to generator/out/batch-queue-<seed>.json. Same BANNED guard.
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.replace(/^--/, ''), 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 fromQueue() {
const QUEUE = path.join(ROOT, 'generator', 'idea-queue.json');
const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { items: [] };
const todo = q.items.filter(b => !b.batched_at).slice(0, COUNT);
if (!todo.length) { console.log('idea-queue: nothing un-batched'); return; }
const out = path.resolve(ROOT, args.out || `generator/out/batch-queue-${SEED}.json`);
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
const items = todo.map((b, i) => {
const prompt = `${b.scene.replace(/\.$/, '')}. ${STYLE}`;
if (BANNED.test(prompt) || BANNED.test(b.caption)) throw new Error(`banned term in queued idea ${b.id}`);
const caption = /^["“]/.test(b.caption) ? b.caption : `"${b.caption}"`;
return { id: `shadowman-${stamp}-q${String(i + 1).padStart(3, "0")}`, theme: b.theme, era: b.era || "contemporary", source: b.source, idea_id: b.id, title: b.title, scene: b.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, from_queue: true, items }, null, 2));
const now = new Date().toISOString();
for (const b of todo) { b.batched_at = now; b.batch = path.relative(ROOT, out); }
fs.writeFileSync(QUEUE, JSON.stringify(q, null, 2) + '\n');
console.log(`Composed ${items.length} briefs from idea-queue -> ${path.relative(ROOT, out)}`);
}
function main() {
if (args['from-queue']) return fromQueue();
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();