← back to Crazy News Channel Shadowman
p24 daily-cartoons: generate cartoons from real articles, still image by default (TK-12237)
b8bc8d014f387174842a5b7fa4f09e4e0a0018de · 2026-09-25 10:20:16 -0700 · Steve Abrams
generate.py now reads today's real-news-data.js (falling back to stories-data.js's
invented P24 stories) via a sandboxed extract_stories.mjs parser, picks one unused
article per cartoon (skipping story_ids cartooned in the last 14 days of the queue
or already in cartoons/manifest.js, preferring newest real news), and asks local
Ollama for exactly one gag per article instead of inventing abstract concepts with
no article input. Video generation (Replicate wan-2.2 i2v) is now opt-in via
--video (default OFF, ~$0.003/cartoon instead of ~$0.103) per Steve: "just an
image, not moving." Added --concepts-only/--dry-run for a $0 preview of picked
articles + concepts with no Replicate calls.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq
Files touched
A daily-cartoons/extract_stories.mjsM daily-cartoons/generate.py
Diff
commit b8bc8d014f387174842a5b7fa4f09e4e0a0018de
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 10:20:16 2026 -0700
p24 daily-cartoons: generate cartoons from real articles, still image by default (TK-12237)
generate.py now reads today's real-news-data.js (falling back to stories-data.js's
invented P24 stories) via a sandboxed extract_stories.mjs parser, picks one unused
article per cartoon (skipping story_ids cartooned in the last 14 days of the queue
or already in cartoons/manifest.js, preferring newest real news), and asks local
Ollama for exactly one gag per article instead of inventing abstract concepts with
no article input. Video generation (Replicate wan-2.2 i2v) is now opt-in via
--video (default OFF, ~$0.003/cartoon instead of ~$0.103) per Steve: "just an
image, not moving." Added --concepts-only/--dry-run for a $0 preview of picked
articles + concepts with no Replicate calls.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq
---
daily-cartoons/extract_stories.mjs | 58 ++++++++++
daily-cartoons/generate.py | 231 ++++++++++++++++++++++++++++---------
2 files changed, 236 insertions(+), 53 deletions(-)
diff --git a/daily-cartoons/extract_stories.mjs b/daily-cartoons/extract_stories.mjs
new file mode 100644
index 0000000..ea07661
--- /dev/null
+++ b/daily-cartoons/extract_stories.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// daily-cartoons/extract_stories.mjs — TK-12237.
+//
+// Safely parses ../real-news-data.js (window.P24_REAL_STORIES) and
+// ../stories-data.js (window.P24_EXTRA_STORIES) using the same sandboxed
+// vm-context pattern already used elsewhere in this repo for these plain
+// <script src> globals (scripts/verify-tags.mjs,
+// scripts/cartoon-shorts/make-cartoon-shorts.mjs) — never eval'd in the
+// current process, never a require() of the file. Prints ONE flat JSON
+// array of candidate articles to stdout. Read-only, $0, no network.
+//
+// Each article: { id, source: 'real-news'|'p24', headline, summary, tags,
+// sourceUrl (real-news only, else null), sourceName, publishedAt (ISO or
+// null if publishedLabel didn't parse) }.
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const SITE = path.resolve(HERE, '..');
+
+const ctx = { window: {}, console };
+vm.createContext(ctx);
+for (const f of ['real-news-data.js', 'stories-data.js']) {
+ const p = path.join(SITE, f);
+ if (fs.existsSync(p)) vm.runInContext(fs.readFileSync(p, 'utf8'), ctx, { filename: f });
+}
+const REAL = Array.isArray(ctx.window.P24_REAL_STORIES) ? ctx.window.P24_REAL_STORIES : [];
+const EXTRA = Array.isArray(ctx.window.P24_EXTRA_STORIES) ? ctx.window.P24_EXTRA_STORIES : [];
+
+function publishedAt(story) {
+ const t = Date.parse(story.publishedLabel || '');
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+}
+
+function toArticle(story, source) {
+ const stages = Array.isArray(story.stages) ? story.stages : [];
+ const stage = stages[story.stageIndex || 0] || stages[0] || {};
+ const headline = stage.headline || story.article?.dek || story.id;
+ const summary = stage.detail || story.article?.dek || (story.article?.paragraphs || [])[0] || '';
+ return {
+ id: story.id,
+ source,
+ headline: String(headline || '').trim(),
+ summary: String(summary || '').trim(),
+ tags: Array.isArray(story.tags) ? story.tags : [],
+ sourceUrl: source === 'real-news' ? (story.sourceUrl || null) : null,
+ sourceName: story.sourceName || null,
+ publishedAt: publishedAt(story),
+ };
+}
+
+const out = [
+ ...REAL.filter((s) => s && s.id).map((s) => toArticle(s, 'real-news')),
+ ...EXTRA.filter((s) => s && s.id).map((s) => toArticle(s, 'p24')),
+];
+process.stdout.write(JSON.stringify(out));
diff --git a/daily-cartoons/generate.py b/daily-cartoons/generate.py
index 33b7ca1..008c347 100755
--- a/daily-cartoons/generate.py
+++ b/daily-cartoons/generate.py
@@ -1,25 +1,31 @@
#!/usr/bin/env python3
-"""P24 daily editorial cartoons -> LOCAL review queue (TK-12153). Never publishes.
+"""P24 daily editorial cartoons -> LOCAL review queue (TK-12153, article-driven TK-12237).
+Never publishes.
- 1. concepts local Ollama ($0)
- 2. poster Replicate black-forest-labs/flux-schnell (~$0.003) + PIL cream/mint duotone
- 3. clip Replicate wan-video/wan-2.2-i2v-fast, 81 frames @ 16fps (~5s), 480p (~$0.10 est)
- 4. queue queue/<date>/<slug>/{poster.jpg, clip.mp4, meta.json}; rebuilds review.html
+ 1. articles today's real news (real-news-data.js) + invented P24 stories (stories-data.js,
+ fallback/fill) via extract_stories.mjs, skipping story_ids already cartooned
+ in the last 14 days of the queue or anywhere in cartoons/manifest.js ($0)
+ 2. concepts local Ollama, ONE gag per article, satirizing that article's topic ($0)
+ 3. poster Replicate black-forest-labs/flux-schnell (~$0.003) + PIL cream/mint duotone
+ 4. clip OPTIONAL, --video only (default OFF): Replicate wan-2.2-i2v-fast 5s clip (~$0.10)
+ 5. queue queue/<date>/<slug>/{poster.jpg, clip.mp4 (if --video), meta.json}; rebuilds review.html
Cost guard: P24_CARTOON_CAP_USD (default 1.00) is a hard DAILY cap across all runs,
computed from ~/.claude/cost-ledger.jsonl (app=p24-daily-cartoons). A cartoon is only
-started if its full estimate (poster + clip) fits; otherwise the run stops and exits 2.
+started if its full estimate (poster [+ clip if --video]) fits; otherwise the run stops
+and exits 2.
-Usage: generate.py [N=4] [--date YYYY-MM-DD] [--no-video]
-Exit: 0 ok · 1 error(s) · 2 cap refused remaining generations
+Usage: generate.py [N=4] [--date YYYY-MM-DD] [--video] [--concepts-only]
+Exit: 0 ok · 1 error(s)/no candidates · 2 cap refused remaining generations
"""
-import argparse, datetime, io, json, os, re, sys, urllib.request
+import argparse, datetime, io, json, os, re, subprocess, sys, urllib.request
from PIL import Image, ImageOps, ImageEnhance
import common as C
OLLAMA = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
MODEL = os.environ.get("P24_CARTOON_OLLAMA_MODEL", "hf.co/bartowski/Qwen2.5-Coder-32B-Instruct-GGUF:Q4_K_M")
INK, MINT, CREAM = (11, 11, 12), (15, 157, 120), (205, 245, 224)
+SPARE = 6 # extra article candidates fetched beyond N so a failed concept can skip to the next article
STYLE_PRE = ("Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. "
"Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, "
@@ -30,7 +36,10 @@ STYLE_POST = (" Figures are invented anonymous caricatures, not any real person.
MOTION_PRE = "Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. "
MOTION_POST = " Slow, dry, deadpan comedy timing."
-# real-person guard: concepts naming real public figures are dropped (invented figures only)
+# real-person guard: concepts naming well-known real public figures are dropped (invented figures
+# only). This is a BACKSTOP, not the primary defense — the prompt itself instructs the model to
+# never carry any real person/party/company/brand/place/org named IN THE SOURCE ARTICLE into the
+# cartoon; this regex only catches a fixed list of the most likely current-events names.
DENY = re.compile(r"\b(trump|biden|harris|obama|pelosi|mcconnell|schumer|desantis|newsom|musk|bezos|zuckerberg|putin|"
r"xi jinping|zelensky|netanyahu|vance|aoc|ocasio|sanders|clinton|bush|reagan|kennedy|democrat|republican|"
r"gop|maga)\b", re.I)
@@ -39,35 +48,120 @@ def recent_titles(days=14):
out = []
if os.path.isdir(C.QUEUE):
for d in sorted(os.listdir(C.QUEUE))[-days:]:
- for s in os.listdir(os.path.join(C.QUEUE, d)):
- try: out.append(json.load(open(os.path.join(C.QUEUE, d, s, "meta.json")))["title"])
+ dp = os.path.join(C.QUEUE, d)
+ if not os.path.isdir(dp): continue
+ for s in os.listdir(dp):
+ try: out.append(json.load(open(os.path.join(dp, s, "meta.json")))["title"])
except Exception: pass
return out
-def ollama_concepts(n, avoid):
- prompt = f"""You are the gag writer for P24, a satirical "crazy news" site. Invent {n} ORIGINAL single-panel
-editorial-cartoon concepts satirizing politics, bureaucracy, institutions, tech, business or daily life IN THE ABSTRACT.
-Rules: invented anonymous figures only (e.g. "a jowly senator", "a committee of identical bureaucrats"); never name
-or depict any real person, party, company or brand; one strong absurd visual metaphor per cartoon; nothing hateful.
+def used_story_ids(days=14):
+ """story_ids to skip: cartooned in the last N days of the local queue, PLUS anything
+ already approved into cartoons/manifest.js (full history, no day window)."""
+ used = set()
+ if os.path.isdir(C.QUEUE):
+ for d in sorted(os.listdir(C.QUEUE))[-days:]:
+ dp = os.path.join(C.QUEUE, d)
+ if not os.path.isdir(dp): continue
+ for s in os.listdir(dp):
+ try:
+ m = json.load(open(os.path.join(dp, s, "meta.json")))
+ if m.get("story_id"): used.add(m["story_id"])
+ except Exception: pass
+ mf = os.path.join(C.SITE, "cartoons", "manifest.js")
+ if os.path.exists(mf):
+ mm = re.search(r"window\.P24_CARTOONS\s*=\s*(\[[\s\S]*\])\s*;\s*$", open(mf).read())
+ if mm:
+ try:
+ for c in json.loads(mm.group(1)):
+ if c.get("story_id"): used.add(c["story_id"])
+ except Exception: pass
+ return used
+
+def load_articles():
+ """Run extract_stories.mjs (sandboxed vm-context parse of real-news-data.js /
+ stories-data.js) and return the flat JSON article list. Read-only, $0."""
+ p = subprocess.run(["node", os.path.join(C.HERE, "extract_stories.mjs")],
+ capture_output=True, text=True, cwd=C.HERE, timeout=60)
+ if p.returncode != 0:
+ raise RuntimeError(f"extract_stories.mjs failed: {p.stderr.strip()[:400]}")
+ return json.loads(p.stdout)
+
+def pick_articles(n, excluded):
+ """Newest real-news first (by parsed publishedAt), then real-news without a parseable
+ date, then invented P24 stories (stories-data.js) as fallback/fill — skipping any id
+ in `excluded`. Returns up to n articles; fewer (or []) if candidates run out."""
+ arts = load_articles()
+ dated = sorted([a for a in arts if a.get("publishedAt")], key=lambda a: a["publishedAt"], reverse=True)
+ undated_real = [a for a in arts if not a.get("publishedAt") and a.get("source") == "real-news"]
+ fallback = [a for a in arts if a.get("source") == "p24"]
+ seen, out = set(excluded), []
+ for a in dated + undated_real + fallback:
+ if a["id"] in seen: continue
+ seen.add(a["id"])
+ out.append(a)
+ if len(out) >= n: break
+ return out
+
+def story_url(article):
+ if article.get("source") == "real-news":
+ return article.get("sourceUrl")
+ return f"index.html#/article/{article['id']}"
+
+def concept_prompt(article, avoid, want_motion):
+ fields = ('"title":"3-6 words","caption":"one witty line, max 18 words",'
+ '"scene":"60-110 word purely visual description of the drawing: foreground figure(s), '
+ 'action, background, the absurd detail. No text or lettering in the image."')
+ if want_motion:
+ fields += ',"motion":"25-50 words: what moves in a 5-second deadpan animation of this exact scene"'
+ return f"""You are the gag writer for P24, a satirical "crazy news" site. Read this real news item and invent ONE
+ORIGINAL single-panel editorial-cartoon concept that satirizes its topic IN THE ABSTRACT — the same underlying
+absurdity, institution, or behavior, generalized so it stands alone as a gag.
+ARTICLE HEADLINE: {article['headline']}
+ARTICLE SUMMARY: {article['summary']}
+ARTICLE TAGS: {', '.join(article.get('tags') or [])}
+Rules: invented anonymous figures only (e.g. "a jowly senator", "a committee of identical bureaucrats");
+NEVER name or depict any real person, party, company, brand, place, or organization mentioned in (or
+implied by) the article above — invent a generic stand-in that represents the same role or theme instead;
+one strong absurd visual metaphor per cartoon; nothing hateful.
Avoid repeating these recent titles: {json.dumps(avoid[-30:])}
-Return ONLY JSON: {{"concepts":[{{"title":"3-6 words","caption":"one witty line, max 18 words",
-"scene":"60-110 word purely visual description of the drawing: foreground figure(s), action, background, the absurd detail. No text or lettering in the image.",
-"motion":"25-50 words: what moves in a 5-second deadpan animation of this exact scene"}}]}}"""
- body = {"model": MODEL, "prompt": prompt, "format": "json", "stream": False,
- "options": {"temperature": 0.9, "num_predict": 2200}}
+Return ONLY JSON: {{{fields}}}"""
+
+def ollama_concept_for_article(article, avoid, want_motion):
+ body = {"model": MODEL, "prompt": concept_prompt(article, avoid, want_motion), "format": "json", "stream": False,
+ "options": {"temperature": 0.9, "num_predict": 900}}
r = urllib.request.Request(f"{OLLAMA}/api/generate", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
- resp = json.load(urllib.request.urlopen(r, timeout=900))
- data = json.loads(resp["response"])
- cs = data.get("concepts", data if isinstance(data, list) else [])
- good = []
- for c in cs:
- if not all(isinstance(c.get(k), str) and c[k].strip() for k in ("title", "caption", "scene", "motion")):
- continue
- if DENY.search(" ".join(c[k] for k in ("title", "caption", "scene", "motion"))):
- print(" [concepts] dropped (names real person/party):", c["title"]); continue
- good.append(c)
- return good
+ resp = json.load(urllib.request.urlopen(r, timeout=300))
+ c = json.loads(resp["response"])
+ required = ["title", "caption", "scene"] + (["motion"] if want_motion else [])
+ if not all(isinstance(c.get(k), str) and c[k].strip() for k in required):
+ return None
+ if DENY.search(" ".join(c[k] for k in required)):
+ print(" [concepts] dropped (names real person/party):", c.get("title")); return None
+ return {k: c[k].strip() for k in required}
+
+def gather_concepts(pool, n, want_motion):
+ """One Ollama call per candidate article (up to n successes, 2 tries each);
+ on failure, skip that article and move to the next candidate in the pool."""
+ avoid = recent_titles()
+ picked = []
+ for a in pool:
+ if len(picked) >= n: break
+ c = None
+ for attempt in range(2):
+ try:
+ c = ollama_concept_for_article(a, avoid + [p[1]["title"] for p in picked], want_motion)
+ except Exception as e:
+ print(f" [concepts] ollama error for '{a['headline'][:60]}' (try {attempt+1}/2): {e}")
+ c = None
+ if c: break
+ if c:
+ print(f" [concepts] {a['id']} ({a['source']}) -> \"{c['title']}\"")
+ picked.append((a, c))
+ else:
+ print(f" [concepts] SKIP — no usable concept for: {a['headline'][:70]}")
+ return picked
def slugify(t):
return re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")[:48] or "cartoon"
@@ -109,50 +203,81 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("n", nargs="?", type=int, default=4)
ap.add_argument("--date", default=datetime.date.today().isoformat())
- ap.add_argument("--no-video", action="store_true")
+ ap.add_argument("--video", action="store_true",
+ help="also render a Replicate wan-2.2 i2v clip (~$0.10/cartoon). Default OFF — still image only.")
+ ap.add_argument("--concepts-only", "--dry-run", dest="concepts_only", action="store_true",
+ help="fetch articles + Ollama concepts and print/save them; no Replicate calls, $0")
a = ap.parse_args()
- per = C.EST["flux"] + (0 if a.no_video else C.EST["wan"])
+ per = C.EST["flux"] + (C.EST["wan"] if a.video else 0)
cap, spent = C.cap_usd(), C.spent_today()
- print(f"P24 daily cartoons: N={a.n} date={a.date} cap=${cap:.2f} spent_today=${spent:.3f} "
+ print(f"P24 daily cartoons: N={a.n} date={a.date} video={a.video} cap=${cap:.2f} spent_today=${spent:.3f} "
f"est=${per:.3f}/cartoon, ${per*a.n:.3f} total")
- # preflight: refuse before doing anything if not even one cartoon fits
- if spent + per > cap + 1e-9:
+ if not a.concepts_only and spent + per > cap + 1e-9:
print(f"REFUSED: cap ${cap:.2f} leaves no room (spent ${spent:.3f} + est ${per:.3f}). No paid calls made.")
return 2
- print("[concepts] asking local Ollama ($0)…")
- concepts = []
- for attempt in range(3):
- concepts += ollama_concepts(a.n - len(concepts), recent_titles())
- if len(concepts) >= a.n: break
- concepts = concepts[:a.n]
- if not concepts:
+
+ print("[articles] selecting candidates from real-news-data.js / stories-data.js…")
+ excluded = used_story_ids()
+ pool = pick_articles(a.n + SPARE, excluded)
+ if not pool:
+ print(f"ERROR: 0 article candidates — every story_id was already cartooned in the last 14 days "
+ f"of the queue or is present in cartoons/manifest.js ({len(excluded)} excluded).")
+ return 1
+ print(f" {len(pool)} candidate article(s) available (targeting {a.n}); {len(excluded)} story_id(s) excluded")
+
+ print("[concepts] asking local Ollama, one gag per article ($0)…")
+ picked = gather_concepts(pool, a.n, a.video)
+ if not picked:
print("ERROR: no usable concepts from Ollama"); return 1
+ if len(picked) < a.n:
+ print(f" only {len(picked)}/{a.n} article(s) produced a usable concept")
+
+ if a.concepts_only:
+ for art, c in picked:
+ print(f"\n== story {art['id']} ({art['source']}) — {art['headline'][:90]}")
+ print(f" title: {c['title']}")
+ print(f" caption: {c['caption']}")
+ print(f" scene: {c['scene']}")
+ if a.video: print(f" motion: {c.get('motion','')}")
+ os.makedirs(os.path.join(C.HERE, "logs"), exist_ok=True)
+ out = [{"story_id": art["id"], "story_source": art["source"], "story_title": art["headline"],
+ "story_url": story_url(art), **c} for art, c in picked]
+ outpath = os.path.join(C.HERE, "logs", f"concepts-{a.date}.json")
+ json.dump(out, open(outpath, "w"), indent=2)
+ print(f"\n[concepts-only] {len(out)} concept(s) written to {outpath}. $0 spent — no Replicate calls made.")
+ return 0
+
day = os.path.join(C.QUEUE, a.date); os.makedirs(day, exist_ok=True)
errors, refused, total = 0, False, 0.0
- for c in concepts:
+ for art, c in picked:
slug = slugify(c["title"]); d = os.path.join(day, slug)
if os.path.exists(os.path.join(d, "meta.json")):
slug += "-" + datetime.datetime.now().strftime("%H%M%S"); d = os.path.join(day, slug)
if C.spent_today() + per > C.cap_usd() + 1e-9:
print(f"CAP: stopping before '{c['title']}' — would exceed ${C.cap_usd():.2f}"); refused = True; break
os.makedirs(d, exist_ok=True)
- print(f"\n== {c['title']} ({slug})")
+ print(f"\n== {c['title']} ({slug}) <- story {art['id']}")
img_prompt = STYLE_PRE + c["scene"].strip() + STYLE_POST
- mot_prompt = MOTION_PRE + c["motion"].strip() + MOTION_POST
meta = {"slug": slug, "date": a.date, "title": c["title"].strip(), "caption": c["caption"].strip(),
- "image_prompt": img_prompt, "motion_prompt": mot_prompt, "concept_model": MODEL,
- "image_model": "black-forest-labs/flux-schnell", "video_model": "wan-video/wan-2.2-i2v-fast",
- "video_params": {"num_frames": 81, "frames_per_second": 16, "resolution": "480p"},
+ "image_prompt": img_prompt, "concept_model": MODEL,
+ "image_model": "black-forest-labs/flux-schnell",
+ "story_id": art["id"], "story_title": art["headline"], "story_url": story_url(art),
+ "story_source": art["source"],
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"status": "queued", "cost": {"concepts": 0.0}, "predictions": {}}
+ if a.video:
+ mot_prompt = MOTION_PRE + c.get("motion", "").strip() + MOTION_POST
+ meta["motion_prompt"] = mot_prompt
+ meta["video_model"] = "wan-video/wan-2.2-i2v-fast"
+ meta["video_params"] = {"num_frames": 81, "frames_per_second": 16, "resolution": "480p"}
try:
raw, cost, pid = gen_poster(img_prompt, slug)
meta["cost"]["image"] = cost; meta["predictions"]["image"] = pid
open(os.path.join(d, "raw.jpg"), "wb").write(raw)
duotone(raw, os.path.join(d, "poster.jpg"))
print(f" poster.jpg ok (actual ${cost:.3f})")
- if not a.no_video:
- cost, pid, metrics = gen_clip(os.path.join(d, "poster.jpg"), mot_prompt, slug, os.path.join(d, "clip.mp4"))
+ if a.video:
+ cost, pid, metrics = gen_clip(os.path.join(d, "poster.jpg"), meta["motion_prompt"], slug, os.path.join(d, "clip.mp4"))
meta["cost"]["video"] = cost; meta["predictions"]["video"] = pid
meta["video_metrics"] = metrics
print(f" clip.mp4 ok (actual-logged ${cost:.3f}, predict_time {metrics.get('predict_time')}s)")
← dee75b4 auto-data-snapshot: 2026-09-25T09:51:23 (1 data files) — .cl
·
back to Crazy News Channel Shadowman
·
p24 daily-cartoons: approve.py defaults --story-id from meta 0b40f82 →