← back to Crazy News Channel
daily-cartoons/generate.py
300 lines
#!/usr/bin/env python3
"""P24 daily editorial cartoons -> LOCAL review queue (TK-12153, article-driven TK-12237).
Never publishes.
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 if --video]) fits; otherwise the run stops
and exits 2.
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, 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, "
"scratchboard-like white highlights cut out of heavy solid black. ")
STYLE_POST = (" Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, "
"hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, "
"no text, no letters, no caption, no speech bubbles, no watermark, no signature.")
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 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)
def recent_titles(days=14):
out = []
if os.path.isdir(C.QUEUE):
for d in C.queue_dates()[-days:]:
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 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 C.queue_dates()[-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: {{{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=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"
def duotone(raw_bytes, out_path):
im = Image.open(io.BytesIO(raw_bytes)).convert("L")
im = ImageEnhance.Contrast(im).enhance(1.25)
ImageOps.colorize(im, black=INK, white=CREAM, mid=MINT, blackpoint=0, whitepoint=255, midpoint=150)\
.convert("RGB").save(out_path, "JPEG", quality=90)
def gen_poster(image_prompt, slug):
C.guard("flux")
pr = C.api("https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
{"input": {"prompt": image_prompt, "aspect_ratio": "4:3", "output_format": "jpg",
"output_quality": 92, "num_outputs": 1, "megapixels": "1"}}, prefer_wait=True)
cost = C.log_cost("flux", f"poster {slug} pred={pr.get('id')}") # billed at create
pr = C.poll(pr, 2, 300)
if pr["status"] != "succeeded":
raise RuntimeError(f"flux {pr['status']}: {pr.get('error')}")
out = pr["output"]; out = out[0] if isinstance(out, list) else out
return urllib.request.urlopen(out, timeout=120).read(), cost, pr["id"]
def gen_clip(poster_path, motion_prompt, slug, out_path):
C.guard("wan")
import base64
img = "data:image/jpeg;base64," + base64.b64encode(open(poster_path, "rb").read()).decode()
pr = C.api("https://api.replicate.com/v1/models/wan-video/wan-2.2-i2v-fast/predictions",
{"input": {"prompt": motion_prompt, "image": img, "num_frames": 81, "frames_per_second": 16,
"resolution": "480p", "go_fast": True}})
cost = C.log_cost("wan", f"clip {slug} pred={pr.get('id')}")
print(f" [wan] prediction {pr['id']} {pr['status']}")
pr = C.poll(pr, 8, 1500)
if pr["status"] != "succeeded":
raise RuntimeError(f"wan {pr['status']}: {pr.get('error')}")
urllib.request.urlretrieve(pr["output"], out_path)
return cost, pr["id"], pr.get("metrics", {})
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("--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"] + (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} video={a.video} cap=${cap:.2f} spent_today=${spent:.3f} "
f"est=${per:.3f}/cartoon, ${per*a.n:.3f} total")
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("[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 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}) <- story {art['id']}")
img_prompt = STYLE_PRE + c["scene"].strip() + STYLE_POST
meta = {"slug": slug, "date": a.date, "title": c["title"].strip(), "caption": c["caption"].strip(),
"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 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)")
except C.CapExceeded as e:
print("CAP:", e); refused = True
meta["status"] = "incomplete-cap"
except Exception as e:
print("ERROR:", e); errors += 1
meta["status"] = "error"; meta["error"] = str(e)[:500]
meta["cost"]["total"] = round(sum(v for k, v in meta["cost"].items() if k != "total"), 4)
total += meta["cost"]["total"]
json.dump(meta, open(os.path.join(d, "meta.json"), "w"), indent=2)
if refused: break
import build_review; build_review.build()
print(f"\nRun spend ${total:.3f} · spent today ${C.spent_today():.3f} / cap ${C.cap_usd():.2f} · review: {os.path.join(C.HERE,'review.html')}")
return 2 if refused else (1 if errors else 0)
if __name__ == "__main__":
sys.exit(main())