← back to Crazy News Channel Shadowman
P24 daily-cartoons: Ollama concepts + flux-schnell poster + WAN 2.2 clip into local review queue, $1/day cost cap (TK-12153)
33a362ffd65ce368198bab49608941df28245882 · 2026-09-24 15:15:07 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGPARcQepnFT49pzMk1ZE5
Files touched
A daily-cartoons/.gitignoreA daily-cartoons/README.mdA daily-cartoons/approve.pyA daily-cartoons/build_review.pyA daily-cartoons/com.steve.p24-daily-cartoons.plistA daily-cartoons/common.pyA daily-cartoons/generate.pyA daily-cartoons/queue/2026-09-24/committee-of-shadows/meta.jsonA daily-cartoons/queue/2026-09-24/committee-of-shadows/poster.jpgA daily-cartoons/review.htmlA daily-cartoons/run-daily.sh
Diff
commit 33a362ffd65ce368198bab49608941df28245882
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 15:15:07 2026 -0700
P24 daily-cartoons: Ollama concepts + flux-schnell poster + WAN 2.2 clip into local review queue, $1/day cost cap (TK-12153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGPARcQepnFT49pzMk1ZE5
---
daily-cartoons/.gitignore | 5 +
daily-cartoons/README.md | 38 +++++
daily-cartoons/approve.py | 60 +++++++
daily-cartoons/build_review.py | 76 +++++++++
daily-cartoons/com.steve.p24-daily-cartoons.plist | 19 +++
daily-cartoons/common.py | 89 +++++++++++
daily-cartoons/generate.py | 174 +++++++++++++++++++++
.../2026-09-24/committee-of-shadows/meta.json | 36 +++++
.../2026-09-24/committee-of-shadows/poster.jpg | Bin 0 -> 437518 bytes
daily-cartoons/review.html | 40 +++++
daily-cartoons/run-daily.sh | 13 ++
11 files changed, 550 insertions(+)
diff --git a/daily-cartoons/.gitignore b/daily-cartoons/.gitignore
new file mode 100644
index 0000000..85c446f
--- /dev/null
+++ b/daily-cartoons/.gitignore
@@ -0,0 +1,5 @@
+# generated media — regenerable, keep out of git (posters ~0.4MB are fine but raw originals aren't needed)
+queue/**/raw.jpg
+queue/**/*.mp4
+logs/
+__pycache__/
diff --git a/daily-cartoons/README.md b/daily-cartoons/README.md
new file mode 100644
index 0000000..ce2b280
--- /dev/null
+++ b/daily-cartoons/README.md
@@ -0,0 +1,38 @@
+# P24 daily editorial cartoons → local review queue (TK-12153)
+
+4 Paul-Conrad-style ink cartoons/day (cream #cdf5e0 / mint #0f9d78 duotone), each a
+poster + ~5s animated clip, written to a LOCAL queue for Steve to pick from.
+**Nothing here publishes** to p24.agentabrams.com — approving only edits the local site tree.
+
+| Step | Engine | Cost |
+|---|---|---|
+| concepts (title, caption, image prompt, motion prompt) | local Ollama (`P24_CARTOON_OLLAMA_MODEL`, default Qwen2.5-Coder-32B) | $0 |
+| poster | Replicate `black-forest-labs/flux-schnell`, 4:3 jpg → PIL duotone | ~$0.003 |
+| clip | Replicate `wan-video/wan-2.2-i2v-fast`, 81 frames @ 16 fps (5.06 s), 480p | ~$0.10 (assumed rate) |
+
+~**$0.103/cartoon, ~$0.41/day** at N=4.
+
+## Run
+ daily-cartoons/run-daily.sh [N] # default N=4
+ open daily-cartoons/review.html # review page (sort + size slider, created time, cost)
+
+Outputs: `queue/<date>/<slug>/{poster.jpg, clip.mp4, meta.json}` (meta = title, caption, prompts, cost, created_at, prediction ids).
+
+## Cost guard
+`P24_CARTOON_CAP_USD` (default `1.00`) is a hard **daily** cap summed from `~/.claude/cost-ledger.jsonl`
+(`app=p24-daily-cartoons`, today's local date). Each cartoon starts only if its full estimate fits;
+each paid call is re-checked. Every paid call is logged via the cost-tracker skill (`log.js`).
+Exit codes: `0` ok · `1` error · `2` cap refused (e.g. `P24_CARTOON_CAP_USD=0 run-daily.sh 1` → exit 2, no spend).
+
+## Approve (local only)
+ python3 daily-cartoons/approve.py <slug> --date YYYY-MM-DD --story-id <id> [--section politics]
+Copies media into `cartoons/media/`, writes `cartoons/<date>-<slug>.html`, appends to `cartoons/manifest.js`.
+`--story-id` is required and must exist in `stories-data.js`/`real-news-data.js` (TK-12158 rule).
+Deploying to Kamatera stays a separate step Steve runs. Note: `*.mp4` is gitignored repo-wide.
+
+## Schedule (NOT installed — paste to install; daily 07:23)
+ cp ~/Projects/crazy-news-channel/daily-cartoons/com.steve.p24-daily-cartoons.plist ~/Library/LaunchAgents/ && launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.p24-daily-cartoons.plist
+
+Remove: `launchctl bootout gui/$(id -u)/com.steve.p24-daily-cartoons`
+
+Distinct from the older text-strip pipeline (`scripts/daily-cartoon-gen.mjs`, `scripts/com.steve.p24-cartoons-daily.plist`), which only picks briefs from a bank.
diff --git a/daily-cartoons/approve.py b/daily-cartoons/approve.py
new file mode 100755
index 0000000..9908a9a
--- /dev/null
+++ b/daily-cartoons/approve.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Approve a queued cartoon INTO THE LOCAL SITE TREE ONLY. Never deploys/rsyncs.
+
+ python3 daily-cartoons/approve.py <slug> --date YYYY-MM-DD --story-id <id> [--section politics]
+
+Copies poster/clip to cartoons/media/, writes cartoons/<date>-<slug>.html, and appends a
+manifest.js entry. --story-id is REQUIRED and must exist in stories-data.js or
+real-news-data.js (standing rule TK-12158: every cartoon links to a real article).
+Publishing to p24.agentabrams.com stays a separate, Steve-run deploy."""
+import argparse, datetime, html, json, os, re, shutil, sys
+import common as C
+import build_review
+
+CART = os.path.join(C.SITE, "cartoons")
+
+def story_exists(sid):
+ for f in ("stories-data.js", "real-news-data.js"):
+ p = os.path.join(C.SITE, f)
+ if os.path.exists(p) and re.search(r"""["']?id["']?\s*:\s*["']%s["']""" % re.escape(sid), open(p).read()):
+ return True
+ return False
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("slug"); ap.add_argument("--date", default=datetime.date.today().isoformat())
+ ap.add_argument("--story-id", required=True); ap.add_argument("--section", default="politics")
+ a = ap.parse_args()
+ q = os.path.join(C.QUEUE, a.date, a.slug); mp = os.path.join(q, "meta.json")
+ if not os.path.exists(mp): sys.exit(f"not queued: {q}")
+ if not story_exists(a.story_id): sys.exit(f"story_id '{a.story_id}' not found in stories-data.js / real-news-data.js")
+ m = json.load(open(mp)); base = f"{a.date.replace('-','')}-{a.slug}"
+ os.makedirs(os.path.join(CART, "media"), exist_ok=True)
+ shutil.copy2(os.path.join(q, "poster.jpg"), os.path.join(CART, "media", base + ".jpg"))
+ has_clip = os.path.exists(os.path.join(q, "clip.mp4"))
+ if has_clip: shutil.copy2(os.path.join(q, "clip.mp4"), os.path.join(CART, "media", base + ".mp4"))
+ e = html.escape
+ media = (f'<video controls playsinline poster="media/{base}.jpg" src="media/{base}.mp4"></video>' if has_clip
+ else f'<img src="media/{base}.jpg" alt="{e(m["title"])}">')
+ open(os.path.join(CART, base + ".html"), "w").write(f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>{e(m["title"])}</title>
+<link rel="stylesheet" href="../assets/style.css"><style>:root{{--cream:#cdf5e0;--ink:#0b0b0c;--mint:#0f9d78}}
+body{{font-family:Georgia,"Times New Roman",serif;max-width:1000px;margin:0 auto;padding:24px 16px}}
+video,img{{display:block;width:100%;border:4px solid var(--ink)}} h1{{margin:14px 0 4px}} .cap{{font-style:italic}}</style></head>
+<body><p><a href="index.html">← Cartoon Desk</a></p>{media}<h1>{e(m["title"])}</h1>
+<p class="cap">“{e(m["caption"])}”</p><p><small>AI-generated editorial cartoon in the manner of Paul Conrad. Invented figures only.</small></p></body></html>''')
+ mf = os.path.join(CART, "manifest.js"); src = open(mf).read()
+ mm = re.search(r"(window\.P24_CARTOONS\s*=\s*)(\[[\s\S]*\])(\s*;\s*)$", src)
+ arr = json.loads(mm.group(2)); arr = [x for x in arr if x.get("id") != a.slug]
+ arr.append({"id": a.slug, "title": m["title"], "file": base + ".html", "created_at": m["created_at"],
+ "category": "Political Cartoon", "section": a.section, "blurb": m["caption"],
+ "thumb": f"media/{base}.jpg", "story_id": a.story_id, "ai_generated": True,
+ "style_reference": "paul-conrad", "tags": ["editorial cartoon", "paul conrad style", "daily cartoon"]})
+ open(mf, "w").write(src[:mm.start(2)] + json.dumps(arr, indent=2) + mm.group(3))
+ m["status"] = "approved"; m["approved_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
+ m["site_file"] = f"cartoons/{base}.html"; json.dump(m, open(mp, "w"), indent=2)
+ build_review.build()
+ print(f"approved locally → cartoons/{base}.html (+ manifest.js). NOT deployed.")
+
+if __name__ == "__main__":
+ main()
diff --git a/daily-cartoons/build_review.py b/daily-cartoons/build_review.py
new file mode 100755
index 0000000..9abf4cc
--- /dev/null
+++ b/daily-cartoons/build_review.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Rebuild daily-cartoons/review.html (static, local, file://-safe) from queue/*/*/meta.json."""
+import html, json, os
+import common as C
+
+def items():
+ out = []
+ if not os.path.isdir(C.QUEUE): return out
+ for d in sorted(os.listdir(C.QUEUE)):
+ dp = os.path.join(C.QUEUE, d)
+ if not os.path.isdir(dp): continue
+ for s in sorted(os.listdir(dp)):
+ mp = os.path.join(dp, s, "meta.json")
+ if os.path.exists(mp):
+ m = json.load(open(mp)); m["_rel"] = f"queue/{d}/{s}"
+ m["_has_clip"] = os.path.exists(os.path.join(dp, s, "clip.mp4"))
+ m["_has_poster"] = os.path.exists(os.path.join(dp, s, "poster.jpg"))
+ out.append(m)
+ return out
+
+def card(m):
+ e = lambda x: html.escape(str(x or ""))
+ media = (f'<video controls preload="none" poster="{e(m["_rel"])}/poster.jpg" src="{e(m["_rel"])}/clip.mp4"></video>'
+ if m["_has_clip"] else (f'<img src="{e(m["_rel"])}/poster.jpg" alt="">' if m["_has_poster"] else '<div class="nomedia">no media</div>'))
+ cmd = f'python3 daily-cartoons/approve.py {m["slug"]} --date {m["date"]} --story-id <story-id>'
+ return f'''<article class="card" data-created="{e(m.get("created_at"))}" data-title="{e(m.get("title"))}" data-cost="{m.get("cost",{}).get("total",0)}">
+ {media}
+ <div class="body">
+ <h2>{e(m.get("title"))}</h2>
+ <p class="cap">“{e(m.get("caption"))}”</p>
+ <div class="chips"><span class="chip when" title="{e(m.get("created_at"))}" data-iso="{e(m.get("created_at"))}">🕓 {e(m.get("created_at"))}</span>
+ <span class="chip">${m.get("cost",{}).get("total",0):.3f}</span><span class="chip st-{e(m.get("status"))}">{e(m.get("status"))}</span></div>
+ <details><summary>prompts</summary><p><b>image:</b> {e(m.get("image_prompt"))}</p><p><b>motion:</b> {e(m.get("motion_prompt"))}</p></details>
+ <code class="cmd" title="click to copy">{cmd}</code>
+ </div>
+</article>'''
+
+def build():
+ its = items()
+ its.sort(key=lambda m: m.get("created_at", ""), reverse=True)
+ total = sum(m.get("cost", {}).get("total", 0) for m in its)
+ page = f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>P24 Cartoon Queue</title>
+<style>
+:root{{--bg:#cdf5e0;--ink:#0b0b0c;--mint:#0f9d78;--card:#f3fbee;--dim:#3d5a4f}}
+@media (prefers-color-scheme:dark){{:root:not([data-theme="light"]){{--bg:#0b0b0c;--ink:#e8f6ee;--card:#15201c;--dim:#9fc4b4}}}}
+*{{box-sizing:border-box}} body{{margin:0;background:var(--bg);color:var(--ink);font-family:Georgia,"Times New Roman",serif;padding:16px}}
+header{{max-width:1400px;margin:0 auto 14px;display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between}}
+header>div{{min-width:0}} code{{word-break:break-all}} h1{{margin:0;font-size:1.5rem}} .note{{color:var(--dim);font-size:.9rem;margin:4px 0 0}}
+.ctrl{{display:flex;gap:12px;align-items:center;font-size:.9rem}} select,input{{font:inherit}}
+#grid{{max-width:1400px;margin:0 auto;display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(min(var(--min,340px),100%),1fr))}}
+.card{{background:var(--card);border:3px solid var(--ink);overflow:hidden}} .card video,.card img{{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;background:#000}}
+.body{{padding:10px 12px}} h2{{margin:0 0 4px;font-size:1.15rem}} .cap{{margin:0 0 8px;font-style:italic;color:var(--dim)}}
+.chips{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}} .chip{{font:12px/1.6 system-ui,sans-serif;border:1px solid var(--mint);padding:0 8px;border-radius:10px}}
+.st-error,.st-incomplete-cap{{border-color:#c0392b;color:#c0392b}} .st-approved{{background:var(--mint);color:#fff}}
+details{{font-size:.8rem;color:var(--dim)}} .cmd{{display:block;margin-top:8px;font-size:11px;background:var(--ink);color:var(--bg);padding:6px;cursor:pointer;word-break:break-all}}
+</style></head><body>
+<header><div><h1>🖋️ P24 Daily Cartoon Queue</h1>
+<p class="note">{len(its)} queued · total spend ${total:.3f} · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</p></div>
+<div class="ctrl"><label>Sort <select id="sort"><option value="new">Newest</option><option value="old">Oldest</option><option value="title">Title A→Z</option><option value="cost">Cost ↓</option></select></label>
+<label>Size <input id="dens" type="range" min="220" max="640" step="20" value="340"></label></div></header>
+<main id="grid">{"".join(card(m) for m in its) or "<p>Queue is empty — run daily-cartoons/run-daily.sh</p>"}</main>
+<script>
+const g=document.getElementById('grid'),S=document.getElementById('sort'),D=document.getElementById('dens');
+const ls=(k,v)=>{{try{{return v===undefined?localStorage.getItem(k):localStorage.setItem(k,v)}}catch(e){{return null}}}};
+document.querySelectorAll('.when').forEach(el=>{{const d=new Date(el.dataset.iso);if(!isNaN(d))el.textContent='🕓 '+d.toLocaleString(undefined,{{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}})}});
+function sort(){{const c=[...g.querySelectorAll('.card')],v=S.value;c.sort((a,b)=>v==='title'?a.dataset.title.localeCompare(b.dataset.title):v==='cost'?b.dataset.cost-a.dataset.cost:v==='old'?a.dataset.created.localeCompare(b.dataset.created):b.dataset.created.localeCompare(a.dataset.created));c.forEach(x=>g.appendChild(x));ls('p24q-sort',v)}}
+function dens(){{g.style.setProperty('--min',D.value+'px');ls('p24q-dens',D.value)}}
+S.value=ls('p24q-sort')||'new';D.value=ls('p24q-dens')||340;S.onchange=sort;D.oninput=dens;sort();dens();
+document.querySelectorAll('.cmd').forEach(el=>el.onclick=()=>navigator.clipboard&&navigator.clipboard.writeText(el.textContent));
+</script></body></html>'''
+ open(os.path.join(C.HERE, "review.html"), "w").write(page)
+ return len(its)
+
+if __name__ == "__main__":
+ print("review.html:", build(), "items")
diff --git a/daily-cartoons/com.steve.p24-daily-cartoons.plist b/daily-cartoons/com.steve.p24-daily-cartoons.plist
new file mode 100644
index 0000000..edbd65c
--- /dev/null
+++ b/daily-cartoons/com.steve.p24-daily-cartoons.plist
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key><string>com.steve.p24-daily-cartoons</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/bash</string>
+ <string>/Users/macstudio3/Projects/crazy-news-channel/daily-cartoons/run-daily.sh</string>
+ <string>4</string>
+ </array>
+ <key>EnvironmentVariables</key>
+ <dict><key>P24_CARTOON_CAP_USD</key><string>1.00</string></dict>
+ <key>StartCalendarInterval</key><dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>23</integer></dict>
+ <key>StandardOutPath</key><string>/Users/macstudio3/Projects/crazy-news-channel/daily-cartoons/logs/daily.log</string>
+ <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/crazy-news-channel/daily-cartoons/logs/daily.log</string>
+ <key>RunAtLoad</key><false/>
+</dict>
+</plist>
diff --git a/daily-cartoons/common.py b/daily-cartoons/common.py
new file mode 100644
index 0000000..7d8a032
--- /dev/null
+++ b/daily-cartoons/common.py
@@ -0,0 +1,89 @@
+"""Shared helpers for the P24 daily-cartoon pipeline (TK-12153).
+Cost guard reads ~/.claude/cost-ledger.jsonl (entries with app == APP) for today's
+local date; logging goes through the cost-tracker skill's log.js. Never prints the token."""
+import datetime, json, os, subprocess, sys, time, urllib.request
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SITE = os.environ.get("P24_SITE_DIR") or os.path.dirname(HERE) # override only for tests
+QUEUE = os.path.join(HERE, "queue")
+APP = "p24-daily-cartoons"
+LEDGER = os.path.expanduser("~/.claude/cost-ledger.jsonl")
+LOGJS = os.path.expanduser("~/.claude/skills/cost-tracker/scripts/log.js")
+# per-unit estimates (mirror cost-tracker pricing.json; WAN rate is a conservative assumption)
+EST = {"flux": 0.003, "wan": 0.10}
+API_KEYS = {"flux": ("replicate_flux_schnell", "image"), "wan": ("replicate_wan22_i2v_fast", "video")}
+
+class CapExceeded(Exception):
+ pass
+
+def cap_usd():
+ return float(os.environ.get("P24_CARTOON_CAP_USD", "1.00"))
+
+def spent_today():
+ today = datetime.date.today()
+ total = 0.0
+ try:
+ with open(LEDGER) as f:
+ for line in f:
+ try:
+ e = json.loads(line)
+ except Exception:
+ continue
+ if e.get("app") != APP:
+ continue
+ ts = e.get("ts", "")
+ try:
+ d = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().date()
+ except Exception:
+ continue
+ if d == today:
+ total += float(e.get("cost_usd") or 0)
+ except FileNotFoundError:
+ pass
+ return round(total, 6)
+
+def guard(kind, n=1):
+ """Raise CapExceeded unless today's spend + this call's estimate fits under the cap."""
+ est = EST[kind] * n
+ spent, cap = spent_today(), cap_usd()
+ if spent + est > cap + 1e-9:
+ raise CapExceeded(f"cap ${cap:.2f}: spent today ${spent:.3f} + est ${est:.3f} for {kind} would exceed it")
+ print(f" [cost] est ${est:.3f} for {kind} (spent today ${spent:.3f} / cap ${cap:.2f})")
+ return est
+
+def log_cost(kind, note, n=1):
+ api, unit = API_KEYS[kind]
+ out = subprocess.run(["node", LOGJS, "--api", api, "--units", f"{n}:{unit}", "--app", APP, "--note", note],
+ capture_output=True, text=True)
+ if out.returncode != 0:
+ print(" [cost] WARNING ledger log failed:", out.stderr.strip()[:200], file=sys.stderr)
+ return EST[kind] * n
+
+def token():
+ for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
+ if l.startswith("REPLICATE_API_TOKEN="):
+ return l.split("=", 1)[1].strip().strip("\"'")
+ raise SystemExit("REPLICATE_API_TOKEN missing from secrets-manager/.env")
+
+def api(url, data=None, prefer_wait=False):
+ h = {"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}
+ if prefer_wait:
+ h["Prefer"] = "wait"
+ r = urllib.request.Request(url, data=json.dumps(data).encode() if data else None, headers=h)
+ return json.load(urllib.request.urlopen(r, timeout=300))
+
+def poll(pr, interval, deadline_s):
+ # Replicate bills at create time: never lose the id, retry transient poll errors, bound the wait.
+ end = time.time() + deadline_s
+ while pr["status"] not in ("succeeded", "failed", "canceled"):
+ if time.time() > end:
+ raise RuntimeError(f"TIMEOUT after {deadline_s}s — recover later: GET {pr['urls']['get']}")
+ time.sleep(interval)
+ for a in range(5):
+ try:
+ pr = api(pr["urls"]["get"]); break
+ except Exception as e:
+ print(f" poll error ({a+1}/5): {e}"); time.sleep(interval * (a + 1))
+ else:
+ raise RuntimeError(f"poll failed 5x — recover later: GET {pr['urls']['get']}")
+ return pr
diff --git a/daily-cartoons/generate.py b/daily-cartoons/generate.py
new file mode 100755
index 0000000..b25de5a
--- /dev/null
+++ b/daily-cartoons/generate.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""P24 daily editorial cartoons -> LOCAL review queue (TK-12153). 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
+
+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.
+
+Usage: generate.py [N=4] [--date YYYY-MM-DD] [--no-video]
+Exit: 0 ok · 1 error(s) · 2 cap refused remaining generations
+"""
+import argparse, datetime, io, json, os, re, 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)
+
+STYLE_PRE = ("Black and white single-panel editorial cartoon in the style of Paul Conrad, 1970s newspaper op-ed page. "
+ "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 real public figures are dropped (invented figures only)
+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 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"])
+ 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.
+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}}
+ 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
+
+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("--no-video", action="store_true")
+ a = ap.parse_args()
+ per = C.EST["flux"] + (0 if a.no_video else C.EST["wan"])
+ 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} "
+ 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:
+ 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("ERROR: no usable concepts from Ollama"); return 1
+ 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:
+ 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})")
+ 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"},
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
+ "status": "queued", "cost": {"concepts": 0.0}, "predictions": {}}
+ 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"))
+ 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())
diff --git a/daily-cartoons/queue/2026-09-24/committee-of-shadows/meta.json b/daily-cartoons/queue/2026-09-24/committee-of-shadows/meta.json
new file mode 100644
index 0000000..deaac4e
--- /dev/null
+++ b/daily-cartoons/queue/2026-09-24/committee-of-shadows/meta.json
@@ -0,0 +1,36 @@
+{
+ "slug": "committee-of-shadows",
+ "date": "2026-09-24",
+ "title": "Committee of Shadows",
+ "caption": "In darkness, they find clarity.",
+ "image_prompt": "Black and white single-panel editorial cartoon in the style of Paul Conrad, 1970s newspaper op-ed page. 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. A committee of identical bureaucrats sitting around a circular table under dim red light, each with half their face obscured by shadowy masks; behind them is an endless sea of file cabinets with ominous glowing neon labels. 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_prompt": "Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The shadows on the masks shift and stretch as if animated, making the masked faces look like they are whispering to each other. Slow, dry, deadpan comedy timing.",
+ "concept_model": "hf.co/bartowski/Qwen2.5-Coder-32B-Instruct-GGUF:Q4_K_M",
+ "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"
+ },
+ "created_at": "2026-09-24T22:12:18+00:00",
+ "status": "queued",
+ "cost": {
+ "concepts": 0.0,
+ "image": 0.003,
+ "video": 0.1,
+ "total": 0.103
+ },
+ "predictions": {
+ "image": "8f8fag0e4xrzw0d0tnx92n2ap4",
+ "video": "m10sr291k1rmt0d0tnxtf9f7a0"
+ },
+ "video_metrics": {
+ "character_input_count": 278,
+ "model_variant": "base",
+ "predict_time": 21.807653811,
+ "resolution_target": "480p",
+ "total_time": 21.85222809,
+ "video_output_count": 1
+ }
+}
\ No newline at end of file
diff --git a/daily-cartoons/queue/2026-09-24/committee-of-shadows/poster.jpg b/daily-cartoons/queue/2026-09-24/committee-of-shadows/poster.jpg
new file mode 100644
index 0000000..4489987
Binary files /dev/null and b/daily-cartoons/queue/2026-09-24/committee-of-shadows/poster.jpg differ
diff --git a/daily-cartoons/review.html b/daily-cartoons/review.html
new file mode 100644
index 0000000..769cf1a
--- /dev/null
+++ b/daily-cartoons/review.html
@@ -0,0 +1,40 @@
+<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>P24 Cartoon Queue</title>
+<style>
+:root{--bg:#cdf5e0;--ink:#0b0b0c;--mint:#0f9d78;--card:#f3fbee;--dim:#3d5a4f}
+@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0b0b0c;--ink:#e8f6ee;--card:#15201c;--dim:#9fc4b4}}
+*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font-family:Georgia,"Times New Roman",serif;padding:16px}
+header{max-width:1400px;margin:0 auto 14px;display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between}
+header>div{min-width:0} code{word-break:break-all} h1{margin:0;font-size:1.5rem} .note{color:var(--dim);font-size:.9rem;margin:4px 0 0}
+.ctrl{display:flex;gap:12px;align-items:center;font-size:.9rem} select,input{font:inherit}
+#grid{max-width:1400px;margin:0 auto;display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(min(var(--min,340px),100%),1fr))}
+.card{background:var(--card);border:3px solid var(--ink);overflow:hidden} .card video,.card img{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;background:#000}
+.body{padding:10px 12px} h2{margin:0 0 4px;font-size:1.15rem} .cap{margin:0 0 8px;font-style:italic;color:var(--dim)}
+.chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px} .chip{font:12px/1.6 system-ui,sans-serif;border:1px solid var(--mint);padding:0 8px;border-radius:10px}
+.st-error,.st-incomplete-cap{border-color:#c0392b;color:#c0392b} .st-approved{background:var(--mint);color:#fff}
+details{font-size:.8rem;color:var(--dim)} .cmd{display:block;margin-top:8px;font-size:11px;background:var(--ink);color:var(--bg);padding:6px;cursor:pointer;word-break:break-all}
+</style></head><body>
+<header><div><h1>🖋️ P24 Daily Cartoon Queue</h1>
+<p class="note">1 queued · total spend $0.103 · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</p></div>
+<div class="ctrl"><label>Sort <select id="sort"><option value="new">Newest</option><option value="old">Oldest</option><option value="title">Title A→Z</option><option value="cost">Cost ↓</option></select></label>
+<label>Size <input id="dens" type="range" min="220" max="640" step="20" value="340"></label></div></header>
+<main id="grid"><article class="card" data-created="2026-09-24T22:12:18+00:00" data-title="Committee of Shadows" data-cost="0.103">
+ <video controls preload="none" poster="queue/2026-09-24/committee-of-shadows/poster.jpg" src="queue/2026-09-24/committee-of-shadows/clip.mp4"></video>
+ <div class="body">
+ <h2>Committee of Shadows</h2>
+ <p class="cap">“In darkness, they find clarity.”</p>
+ <div class="chips"><span class="chip when" title="2026-09-24T22:12:18+00:00" data-iso="2026-09-24T22:12:18+00:00">🕓 2026-09-24T22:12:18+00:00</span>
+ <span class="chip">$0.103</span><span class="chip st-queued">queued</span></div>
+ <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon in the style of Paul Conrad, 1970s newspaper op-ed page. 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. A committee of identical bureaucrats sitting around a circular table under dim red light, each with half their face obscured by shadowy masks; behind them is an endless sea of file cabinets with ominous glowing neon labels. 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.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The shadows on the masks shift and stretch as if animated, making the masked faces look like they are whispering to each other. Slow, dry, deadpan comedy timing.</p></details>
+ <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py committee-of-shadows --date 2026-09-24 --story-id <story-id></code>
+ </div>
+</article></main>
+<script>
+const g=document.getElementById('grid'),S=document.getElementById('sort'),D=document.getElementById('dens');
+const ls=(k,v)=>{try{return v===undefined?localStorage.getItem(k):localStorage.setItem(k,v)}catch(e){return null}};
+document.querySelectorAll('.when').forEach(el=>{const d=new Date(el.dataset.iso);if(!isNaN(d))el.textContent='🕓 '+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})});
+function sort(){const c=[...g.querySelectorAll('.card')],v=S.value;c.sort((a,b)=>v==='title'?a.dataset.title.localeCompare(b.dataset.title):v==='cost'?b.dataset.cost-a.dataset.cost:v==='old'?a.dataset.created.localeCompare(b.dataset.created):b.dataset.created.localeCompare(a.dataset.created));c.forEach(x=>g.appendChild(x));ls('p24q-sort',v)}
+function dens(){g.style.setProperty('--min',D.value+'px');ls('p24q-dens',D.value)}
+S.value=ls('p24q-sort')||'new';D.value=ls('p24q-dens')||340;S.onchange=sort;D.oninput=dens;sort();dens();
+document.querySelectorAll('.cmd').forEach(el=>el.onclick=()=>navigator.clipboard&&navigator.clipboard.writeText(el.textContent));
+</script></body></html>
\ No newline at end of file
diff --git a/daily-cartoons/run-daily.sh b/daily-cartoons/run-daily.sh
new file mode 100755
index 0000000..f3ed51c
--- /dev/null
+++ b/daily-cartoons/run-daily.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# P24 daily editorial cartoons → LOCAL review queue (TK-12153). Never deploys.
+# Usage: run-daily.sh [N=4] env: P24_CARTOON_CAP_USD (default 1.00 daily hard cap)
+set -uo pipefail
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+N="${1:-4}"
+mkdir -p "$HERE/logs"
+echo "=== $(date '+%F %T') run-daily N=$N cap=${P24_CARTOON_CAP_USD:-1.00}"
+/usr/bin/env python3 "$HERE/generate.py" "$N"
+rc=$?
+echo "=== $(date '+%F %T') exit=$rc"
+exit $rc
← dc17f16 reschedule P24 daily cartoon batch: 9:15 AM → 3:30 AM
·
back to Crazy News Channel Shadowman
·
auto-data-snapshot: 2026-09-24T15:23:04 (2 data files) — scr 2a7fdfb →