[object Object]

← back to Crazy News Channel

Add make-cartoon-shorts.mjs: turn each P24 cartoon into a vertical YouTube Short

ce98d615af382a040a8b5577ec42b2b5bd34e346 · 2026-09-24 15:43:02 -0700 · Steve Abrams

Playwright captures the 3-panel strip + Plot Twist state live from the cartoon
HTML; ImageMagick composites branded 1080x1920 cards (opening SATIRE card,
per-panel cards, plot-twist card, article card for the linked real story);
ffmpeg Ken-Burns + concat + mux produces out.mp4. Reuses the EXISTING
allnewsdaily pipeline modules (tts-elevenlabs.mjs, upload-youtube.mjs,
fit-caption.js) rather than copying them. Idempotent via
cartoons/shorts-ledger.json; every run logs PASS/FAIL/SKIP rows to
yolo/cartoon-shorts-log.jsonl and writes a fleet-health-rollup heartbeat to
~/.claude/skills/p24-cartoon-shorts/data/latest.json.

A cartoon whose story_id doesn't resolve to a real stories-data.js /
real-news-data.js entry is skipped (FAIL row) and never rendered/uploaded.

Tested (--no-upload): commission-turtles with VO ($0.087, 290 chars) and
budget-pie-fight silent, both 1080x1920 / 34.2s / <60s. Negative test
(unresolvable story_id) confirmed FAIL row + no video, no ledger write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files touched

Diff

commit ce98d615af382a040a8b5577ec42b2b5bd34e346
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 15:43:02 2026 -0700

    Add make-cartoon-shorts.mjs: turn each P24 cartoon into a vertical YouTube Short
    
    Playwright captures the 3-panel strip + Plot Twist state live from the cartoon
    HTML; ImageMagick composites branded 1080x1920 cards (opening SATIRE card,
    per-panel cards, plot-twist card, article card for the linked real story);
    ffmpeg Ken-Burns + concat + mux produces out.mp4. Reuses the EXISTING
    allnewsdaily pipeline modules (tts-elevenlabs.mjs, upload-youtube.mjs,
    fit-caption.js) rather than copying them. Idempotent via
    cartoons/shorts-ledger.json; every run logs PASS/FAIL/SKIP rows to
    yolo/cartoon-shorts-log.jsonl and writes a fleet-health-rollup heartbeat to
    ~/.claude/skills/p24-cartoon-shorts/data/latest.json.
    
    A cartoon whose story_id doesn't resolve to a real stories-data.js /
    real-news-data.js entry is skipped (FAIL row) and never rendered/uploaded.
    
    Tested (--no-upload): commission-turtles with VO ($0.087, 290 chars) and
    budget-pie-fight silent, both 1080x1920 / 34.2s / <60s. Negative test
    (unresolvable story_id) confirmed FAIL row + no video, no ledger write.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 .gitignore                                     |   6 +
 scripts/cartoon-shorts/make-cartoon-shorts.mjs | 571 +++++++++++++++++++++++++
 yolo/cartoon-shorts-log.jsonl                  |   5 +
 3 files changed, 582 insertions(+)

diff --git a/.gitignore b/.gitignore
index 7b435ea..387bb5f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,9 @@ build/
 shots*/
 _tmp-*/
 cartoons/_shot-*.png
+
+# scripts/cartoon-shorts/make-cartoon-shorts.mjs scratch working dir — per-cartoon
+# screenshots/cards/segment mp4s/out.mp4/thumb.jpg, all regenerable. The ledger
+# (cartoons/shorts-ledger.json) and the JSONL run log (yolo/cartoon-shorts-log.jsonl)
+# ARE tracked (source of truth for idempotency + audit trail).
+data/cartoon-shorts/
diff --git a/scripts/cartoon-shorts/make-cartoon-shorts.mjs b/scripts/cartoon-shorts/make-cartoon-shorts.mjs
new file mode 100644
index 0000000..bd7c627
--- /dev/null
+++ b/scripts/cartoon-shorts/make-cartoon-shorts.mjs
@@ -0,0 +1,571 @@
+#!/usr/bin/env node
+// scripts/cartoon-shorts/make-cartoon-shorts.mjs — TK-12191.
+//
+// Turns each P24 cartoon (cartoons/<file>.html, listed in cartoons/manifest.js) into its
+// own vertical YouTube Short for the "All News Daily" channel: SATIRE badge opening card,
+// the 3-panel strip + Plot Twist state (captured live via Playwright), an article card for
+// the cartoon's linked real story (stories-data.js / real-news-data.js), an optional
+// ElevenLabs voiceover, and an upload via the EXISTING allnewsdaily upload-youtube.mjs /
+// tts-elevenlabs.mjs modules (imported, not copied).
+//
+// HARD RAILS (Steve, TK-12191): this script NEVER uploads unless explicitly asked to —
+// always test with --no-upload. It never touches allnewsdaily's own daily-news short
+// (scripts/build-cartoon-batch.sh / the launchd plist are untouched). A cartoon whose
+// story_id does not resolve to a real stories-data.js/real-news-data.js entry is SKIPPED
+// (logged FAIL) and is NEVER rendered/uploaded without its article.
+//
+// Flags:
+//   --date=YYYY-MM-DD   cartoons whose created_at falls on this UTC date (default: today)
+//   --ids=a,b,c         explicit cartoon ids (overrides --date)
+//   --no-upload         render only; skip the YouTube upload entirely
+//   --no-vo             skip ElevenLabs; render a silent (AAC-null) track
+//   --limit=N           cap how many cartoons are processed this run
+//   --privacy=public|unlisted|private   YouTube privacyStatus (default: public — Steve's call)
+//
+// Usage:
+//   node scripts/cartoon-shorts/make-cartoon-shorts.mjs --ids=commission-turtles --no-upload
+//   node scripts/cartoon-shorts/make-cartoon-shorts.mjs --date=2026-09-24 --no-upload --no-vo
+
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..', '..'); // ~/Projects/crazy-news-channel
+const HOME = process.env.HOME || '/Users/macstudio3';
+
+const CARTOONS_DIR = path.join(ROOT, 'cartoons');
+const DATA_DIR = path.join(ROOT, 'data', 'cartoon-shorts');
+const LEDGER_PATH = path.join(CARTOONS_DIR, 'shorts-ledger.json');
+const LOG_PATH = path.join(ROOT, 'yolo', 'cartoon-shorts-log.jsonl');
+const SKILL_DATA_DIR = path.join(HOME, '.claude', 'skills', 'p24-cartoon-shorts', 'data');
+
+// The EXISTING allnewsdaily Short pipeline — imported, not copied (per TK-12191 brief).
+const AND_SHORT_DIR = path.join(HOME, 'Projects', 'allnewsdaily', 'scripts', 'short');
+// fit-caption.js is a tiny CJS util; reuse it (createRequire) rather than re-implement.
+const { fitCaption } = require(path.join(AND_SHORT_DIR, 'fit-caption.js'));
+
+const PLAYWRIGHT_PATH = path.join(HOME, 'Projects', 'animals', 'node_modules', 'playwright');
+
+// ---------------------------------------------------------------------------
+// CLI flags
+// ---------------------------------------------------------------------------
+const argv = process.argv.slice(2);
+const has = (f) => argv.includes(f);
+const opt = (name, def) => {
+  const pfx = `--${name}=`;
+  const hit = argv.find((a) => a.startsWith(pfx));
+  return hit ? hit.slice(pfx.length) : def;
+};
+const NO_UPLOAD = has('--no-upload');
+const NO_VO = has('--no-vo');
+const DATE = opt('date', new Date().toISOString().slice(0, 10));
+const IDS = opt('ids', null);
+const LIMIT = Number(opt('limit', Infinity));
+const PRIVACY = opt('privacy', 'public'); // Steve's decision: PUBLIC by default
+
+function die(msg) {
+  console.error(`\n[make-cartoon-shorts] FATAL: ${msg}\n`);
+  process.exit(1);
+}
+function warn(msg) { console.warn(`[make-cartoon-shorts] WARN: ${msg}`); }
+function log(msg) { console.log(`[make-cartoon-shorts] ${msg}`); }
+
+function which(bin) {
+  const envKey = bin.toUpperCase();
+  if (process.env[envKey] && fs.existsSync(process.env[envKey])) return process.env[envKey];
+  const brew = '/opt/homebrew/bin/' + bin;
+  if (fs.existsSync(brew)) return brew;
+  try { const p = execFileSync('/usr/bin/which', [bin], { encoding: 'utf8' }).trim(); if (p) return p; } catch {}
+  return null;
+}
+const FFMPEG = which('ffmpeg');
+const FFPROBE = which('ffprobe');
+const MAGICK = which('magick') || which('convert');
+if (!FFMPEG || !FFPROBE) die('ffmpeg/ffprobe not found (brew install ffmpeg)');
+if (!MAGICK) die('ImageMagick not found (brew install imagemagick)');
+
+function run(bin, args) { return execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); }
+
+const FONT_PRIMARY = '/System/Library/Fonts/Supplemental/Arial Bold.ttf';
+const FONT_FALLBACK = '/System/Library/Fonts/Helvetica.ttc';
+const FONT = fs.existsSync(FONT_PRIMARY) ? FONT_PRIMARY : (fs.existsSync(FONT_FALLBACK) ? FONT_FALLBACK : null);
+if (!FONT) die('no usable font found');
+
+// ---------------------------------------------------------------------------
+// Load manifest.js / stories-data.js / real-news-data.js the same way
+// scripts/verify-tags.mjs does — a vm Context over the plain <script src>
+// globals (window.P24_CARTOONS / window.P24_EXTRA_STORIES / window.P24_REAL_STORIES).
+// ---------------------------------------------------------------------------
+const ctx = { window: {}, console };
+vm.createContext(ctx);
+for (const f of ['stories-data.js', 'real-news-data.js', 'cartoons/manifest.js']) {
+  vm.runInContext(fs.readFileSync(path.join(ROOT, f), 'utf8'), ctx, { filename: f });
+}
+const CARTOONS = ctx.window.P24_CARTOONS || [];
+const EXTRA_STORIES = ctx.window.P24_EXTRA_STORIES || [];
+const REAL_STORIES = ctx.window.P24_REAL_STORIES || [];
+
+function resolveStory(storyId) {
+  if (!storyId) return null;
+  return EXTRA_STORIES.find((s) => s.id === storyId) || REAL_STORIES.find((s) => s.id === storyId) || null;
+}
+
+// ---------------------------------------------------------------------------
+// Select cartoons for this run
+// ---------------------------------------------------------------------------
+let selected;
+if (IDS) {
+  const idSet = new Set(IDS.split(',').map((s) => s.trim()).filter(Boolean));
+  selected = CARTOONS.filter((c) => idSet.has(c.id));
+  const missing = [...idSet].filter((id) => !selected.some((c) => c.id === id));
+  if (missing.length) warn(`--ids not found in manifest: ${missing.join(', ')}`);
+} else {
+  selected = CARTOONS.filter((c) => typeof c.created_at === 'string' && c.created_at.slice(0, 10) === DATE);
+}
+if (Number.isFinite(LIMIT)) selected = selected.slice(0, LIMIT);
+
+log(`selected ${selected.length} cartoon(s): ${selected.map((c) => c.id).join(', ') || '(none)'}`);
+
+// ---------------------------------------------------------------------------
+// Ledger (idempotency) + JSONL log + fleet-health heartbeat
+// ---------------------------------------------------------------------------
+function readLedger() {
+  try { return JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')); } catch { return {}; }
+}
+function writeLedger(ledger) {
+  fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
+  fs.writeFileSync(LEDGER_PATH, JSON.stringify(ledger, null, 2) + '\n');
+}
+function appendLog(row) {
+  fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
+  fs.appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
+}
+function writeHeartbeat(verdict, detail, extra = {}) {
+  const out = { skill: 'p24-cartoon-shorts', ts: new Date().toISOString(), verdict, status: verdict, detail, ...extra };
+  fs.mkdirSync(SKILL_DATA_DIR, { recursive: true });
+  fs.writeFileSync(path.join(SKILL_DATA_DIR, 'latest.json'), JSON.stringify(out, null, 2) + '\n');
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// Playwright capture — the 3 panels + the Plot Twist state.
+// Generic with fallbacks because not every shipped cartoon matches the
+// reference DOM (#nextBtn/#twistBtn/figure[data-i]) byte-for-byte.
+// ---------------------------------------------------------------------------
+async function capturePanels(htmlPath, outDir) {
+  const { chromium } = require(PLAYWRIGHT_PATH);
+  fs.mkdirSync(outDir, { recursive: true });
+  const browser = await chromium.launch({ channel: 'chrome', headless: true });
+  const warnings = [];
+  try {
+    const page = await browser.newPage({ viewport: { width: 1000, height: 1300 }, deviceScaleFactor: 2 });
+    const consoleErrors = [];
+    page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
+    page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push('console: ' + m.text()); });
+    await page.goto('file://' + htmlPath, { waitUntil: 'load' });
+    await page.waitForTimeout(400);
+
+    const title = (await page.title()).replace(/\s*[—-]\s*P24.*$/i, '').trim();
+
+    async function firstMatch(selectors) {
+      for (const sel of selectors) { const el = await page.$(sel); if (el) return el; }
+      return null;
+    }
+    async function click(selectors, label) {
+      const el = await firstMatch(selectors);
+      if (!el) { warnings.push(`no clickable "${label}" control found`); return false; }
+      await el.click();
+      await page.waitForTimeout(450);
+      return true;
+    }
+    async function shoot(selectors, outPath, label) {
+      const el = await firstMatch(selectors);
+      if (el) { await el.screenshot({ path: outPath }); return true; }
+      const strip = await firstMatch(['#strip', 'main .strip', 'main']);
+      if (strip) { await strip.screenshot({ path: outPath }); warnings.push(`fallback screenshot for ${label} (specific selector not found)`); return true; }
+      warnings.push(`could not screenshot ${label} — no matching element`);
+      return false;
+    }
+
+    const capFor = async (i) => {
+      try { return (await page.textContent(`#cap${i}`))?.trim() || ''; } catch { return ''; }
+    };
+
+    // advance panel 1
+    await click(['#nextBtn', 'button.primary', 'button:has-text("Process")', 'button:has-text("Panel")'], 'advance/next');
+    const panel0 = path.join(outDir, 'panel0.png');
+    await shoot([`.strip [data-i="0"]`, `figure[data-i="0"]`, `#strip > *:nth-child(1)`], panel0, 'panel 1');
+    const cap0 = await capFor(0);
+
+    // advance panel 2
+    await click(['#nextBtn', 'button.primary', 'button:has-text("Process")', 'button:has-text("Panel")'], 'advance/next');
+    const panel1 = path.join(outDir, 'panel1.png');
+    await shoot([`.strip [data-i="1"]`, `figure[data-i="1"]`, `#strip > *:nth-child(2)`], panel1, 'panel 2');
+    const cap1 = await capFor(1);
+
+    // advance panel 3
+    await click(['#nextBtn', 'button.primary', 'button:has-text("Process")', 'button:has-text("Panel")'], 'advance/next');
+    const panel2 = path.join(outDir, 'panel2.png');
+    await shoot([`.strip [data-i="2"]`, `figure[data-i="2"]`, `#strip > *:nth-child(3)`], panel2, 'panel 3');
+    const cap2 = await capFor(2);
+
+    // plot twist — capture the whole strip (most dramatic single frame of the "after" state)
+    await click(['#twistBtn', 'button.twist', 'button:has-text("Twist")'], 'plot twist');
+    const twist = path.join(outDir, 'twist.png');
+    await shoot(['#strip', 'main .strip', 'main'], twist, 'plot twist state');
+
+    if (consoleErrors.length) warnings.push(`page console errors: ${consoleErrors.slice(0, 3).join(' | ')}`);
+
+    return { title, panel0, panel1, panel2, twist, captions: [cap0, cap1, cap2], warnings };
+  } finally {
+    await browser.close();
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Card compositing (ImageMagick) — 1080x1920 branded vertical cards.
+// ---------------------------------------------------------------------------
+const W = 1080, H = 1920;
+const HEX_BG0 = '#0A0A0A', HEX_BG1 = '#1B1420', HEX_INK = '#F6F6F6', HEX_RED = '#E10600', HEX_DIM = '#B8B8B8';
+
+function writeRaw(dir, name, str) { const p = path.join(dir, name); fs.writeFileSync(p, String(str ?? '')); return p; }
+
+function baseCanvas(png) {
+  run(MAGICK, ['-size', `${W}x${H}`, `gradient:${HEX_BG0}-${HEX_BG1}`, png]);
+}
+
+// Persistent red "SATIRE" stamp badge — top-right, on EVERY card (per Steve's rule that
+// the video must be clearly labeled SATIRE throughout, not just on the opening card).
+function stampSatireBadge(png) {
+  run(MAGICK, [png,
+    '(', '-size', '360x140', 'xc:none',
+      '-fill', HEX_RED, '-draw', 'roundrectangle 0,0,359,139,18,18',
+      '-fill', 'white', '-font', FONT, '-pointsize', '58', '-gravity', 'center', '-annotate', '0', 'SATIRE',
+      '-rotate', '-8',
+    ')',
+    '-gravity', 'NorthEast', '-geometry', '+40+70', '-composite', png]);
+}
+
+function wordmark(png) {
+  // left-aligned so it never collides with the top-right SATIRE stamp badge
+  run(MAGICK, [png, '-font', FONT, '-gravity', 'NorthWest',
+    '-fill', HEX_INK, '-pointsize', '40', '-annotate', '+60+95', 'P24 CARTOON DESK',
+    '-fill', HEX_RED, '-draw', 'rectangle 60,148 220,160', png]);
+}
+
+function footer(png) {
+  run(MAGICK, [png, '-font', FONT, '-gravity', 'South', '-fill', HEX_DIM,
+    '-pointsize', '30', '-annotate', '+0+50', 'Invented satire · Not real news · Part of PANDEMONIUM-24', png]);
+}
+
+function fitBox(dir, text, box, size0, minSize) {
+  const f = writeRaw(dir, `t-${Math.random().toString(36).slice(2)}.txt`, text);
+  try { return fitCaption(MAGICK, FONT, f, box); } catch { return minSize; }
+}
+
+// Opening title card — big SATIRE stamp, kicker, cartoon title.
+function buildOpeningCard(dir, { title }) {
+  const png = path.join(dir, 'card-open.png');
+  baseCanvas(png);
+  wordmark(png);
+  run(MAGICK, [png,
+    '(', '-size', '700x260', 'xc:none',
+      '-fill', HEX_RED, '-stroke', 'white', '-strokewidth', '6', '-draw', 'roundrectangle 0,0,699,259,26,26',
+      '-fill', 'white', '-stroke', 'none', '-font', FONT, '-pointsize', '110', '-gravity', 'center', '-annotate', '0', 'SATIRE',
+      '-rotate', '-6',
+    ')',
+    '-gravity', 'North', '-geometry', '+0+330', '-composite', png]);
+  run(MAGICK, [png, '-font', FONT, '-fill', HEX_INK, '-pointsize', '42', '-gravity', 'North',
+    '-annotate', '+0+680', 'P24 Cartoon — not real news', png]);
+  const tFile = writeRaw(dir, 't-title.txt', title);
+  const size = fitCaption(MAGICK, FONT, tFile, '920x520');
+  run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT, '-pointsize', String(size),
+    '-size', '920x520', '-gravity', 'center', `caption:@${tFile}`, ')',
+    '-gravity', 'center', '-geometry', '+0+40', '-composite', png]);
+  footer(png);
+  stampSatireBadge(png);
+  return png;
+}
+
+// Panel / plot-twist card — the Playwright screenshot on a light frame, kicker + caption.
+function buildPanelCard(dir, { name, kicker, imgPath, caption }) {
+  const png = path.join(dir, `card-${name}.png`);
+  baseCanvas(png);
+  wordmark(png);
+  run(MAGICK, [png, '-font', FONT, '-fill', HEX_RED, '-pointsize', '46', '-gravity', 'North',
+    '-annotate', '+0+230', kicker, png]);
+
+  // frame the screenshot on a white card so it reads clearly against the dark background
+  const framed = path.join(dir, `framed-${name}.png`);
+  run(MAGICK, [imgPath, '-resize', '960x960>', '-bordercolor', 'white', '-border', '24', framed]);
+  run(MAGICK, [png, framed, '-gravity', 'North', '-geometry', '+0+330', '-composite', png]);
+
+  if (caption) {
+    const cFile = writeRaw(dir, `c-${name}.txt`, caption);
+    const size = fitCaption(MAGICK, FONT, cFile, '940x260');
+    run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT, '-pointsize', String(size),
+      '-size', '940x260', '-gravity', 'center', `caption:@${cFile}`, ')',
+      '-gravity', 'North', '-geometry', '+0+1480', '-composite', png]);
+  }
+  footer(png);
+  stampSatireBadge(png);
+  return png;
+}
+
+// Article card — the real linked story: headline + dek + location/byline.
+function buildArticleCard(dir, { headline, dek, location, byline, paragraph }) {
+  const png = path.join(dir, 'card-article.png');
+  baseCanvas(png);
+  run(MAGICK, [png, '(', '-size', '420x100', 'xc:none',
+      '-fill', HEX_RED, '-draw', 'roundrectangle 0,0,419,99,16,16',
+      '-fill', 'white', '-font', FONT, '-pointsize', '44', '-gravity', 'center', '-annotate', '0', 'P24 SATIRE',
+    ')', '-gravity', 'NorthWest', '-geometry', '+60+150', '-composite', png]);
+
+  const hlFile = writeRaw(dir, 'article-hl.txt', headline);
+  const hlSize = fitCaption(MAGICK, FONT, hlFile, '940x560');
+  run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT, '-pointsize', String(hlSize),
+    '-size', '940x560', '-gravity', 'center', `caption:@${hlFile}`, ')',
+    '-gravity', 'North', '-geometry', '+0+330', '-composite', png]);
+
+  const dekText = dek || paragraph || '';
+  if (dekText) {
+    const dkFile = writeRaw(dir, 'article-dek.txt', dekText);
+    const dkSize = fitCaption(MAGICK, FONT, dkFile, '900x460');
+    run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_DIM, '-font', FONT, '-pointsize', String(dkSize),
+      '-size', '900x460', '-gravity', 'center', `caption:@${dkFile}`, ')',
+      '-gravity', 'North', '-geometry', '+0+960', '-composite', png]);
+  }
+  const byl = [location, byline].filter(Boolean).join(' · ');
+  if (byl) {
+    const bFile = writeRaw(dir, 'article-byl.txt', byl);
+    run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_DIM, '-font', FONT, '-pointsize', '34',
+      '-size', '900x', '-gravity', 'center', `caption:@${bFile}`, ')',
+      '-gravity', 'North', '-geometry', '+0+1490', '-composite', png]);
+  }
+  footer(png);
+  stampSatireBadge(png);
+  return png;
+}
+
+// Ken-Burns push-in per card → seg mp4, then concat, then mux audio.
+function renderCardToMp4(png, dur, segMp4, fps = 30) {
+  const vf = "zoompan=z='min(1.0+0.00045*on,1.08)':d=1:" +
+    "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':" + `s=${W}x${H}:fps=${fps}`;
+  run(FFMPEG, ['-y', '-loop', '1', '-framerate', String(fps), '-t', dur.toFixed(3), '-i', png,
+    '-vf', vf, '-r', String(fps), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
+    '-pix_fmt', 'yuv420p', '-an', segMp4]);
+}
+
+function probe(p) {
+  return run(FFPROBE, ['-v', 'error', '-select_streams', 'v:0',
+    '-show_entries', 'stream=width,height,codec_name,avg_frame_rate,pix_fmt',
+    '-show_entries', 'format=duration,size', '-show_entries', 'stream=codec_type',
+    '-of', 'default=noprint_wrappers=1', p]);
+}
+
+// ---------------------------------------------------------------------------
+// Per-cartoon pipeline
+// ---------------------------------------------------------------------------
+async function processCartoon(cartoon, ledger) {
+  const id = cartoon.id;
+  if (ledger[id] && ledger[id].videoId) {
+    appendLog({ id, status: 'SKIP', detail: `already uploaded: ${ledger[id].url}` });
+    log(`SKIP ${id} — already uploaded (${ledger[id].url})`);
+    return { id, status: 'SKIP', costUSD: 0 };
+  }
+
+  const story = resolveStory(cartoon.story_id);
+  if (!story) {
+    const detail = `no resolvable article (story_id=${JSON.stringify(cartoon.story_id)})`;
+    appendLog({ id, status: 'FAIL', detail });
+    log(`FAIL ${id} — ${detail} — SKIPPED (never uploads without its article)`);
+    return { id, status: 'FAIL', detail, costUSD: 0 };
+  }
+
+  const htmlPath = path.join(CARTOONS_DIR, cartoon.file);
+  if (!fs.existsSync(htmlPath)) {
+    const detail = `cartoon file missing: ${cartoon.file}`;
+    appendLog({ id, status: 'FAIL', detail });
+    log(`FAIL ${id} — ${detail}`);
+    return { id, status: 'FAIL', detail, costUSD: 0 };
+  }
+
+  const workDir = path.join(DATA_DIR, id);
+  fs.mkdirSync(workDir, { recursive: true });
+
+  log(`--- ${id}: "${cartoon.title}" -> story ${story.id} ---`);
+  const cap = await capturePanels(htmlPath, workDir);
+  cap.warnings.forEach((w) => warn(`${id}: ${w}`));
+
+  const stage = (story.stages && story.stages[story.stageIndex || 0]) || (story.stages && story.stages[0]) || {};
+  const headline = stage.headline || story.title || cartoon.title;
+  const dek = story.article?.dek || '';
+  const paragraphs = story.article?.paragraphs || [];
+  const location = story.location || '';
+  const byline = story.byline || '';
+
+  // ---- build cards ----
+  const openPng = buildOpeningCard(workDir, { title: cartoon.title });
+  const p1Png = buildPanelCard(workDir, { name: 'p1', kicker: 'PANEL 1', imgPath: cap.panel0, caption: cap.captions[0] });
+  const p2Png = buildPanelCard(workDir, { name: 'p2', kicker: 'PANEL 2', imgPath: cap.panel1, caption: cap.captions[1] });
+  const p3Png = buildPanelCard(workDir, { name: 'p3', kicker: 'PANEL 3', imgPath: cap.panel2, caption: cap.captions[2] });
+  const twistPng = buildPanelCard(workDir, { name: 'twist', kicker: 'PLOT TWIST', imgPath: cap.twist, caption: cartoon.blurb || '' });
+  const articlePng = buildArticleCard(workDir, { headline, dek, location, byline, paragraph: paragraphs[0] });
+
+  const segments = [
+    { name: 'open', png: openPng, est: 2.2 },
+    { name: 'p1', png: p1Png, est: 6.0 },
+    { name: 'p2', png: p2Png, est: 6.0 },
+    { name: 'p3', png: p3Png, est: 6.0 },
+    { name: 'twist', png: twistPng, est: 6.5 },
+    { name: 'article', png: articlePng, est: 7.5 },
+  ];
+  const HARD_CAP_SEC = 55;
+  const estSum = segments.reduce((s, x) => s + x.est, 0);
+  let scale = estSum > HARD_CAP_SEC ? HARD_CAP_SEC / estSum : 1;
+  const durs = segments.map((s) => +(s.est * scale).toFixed(3));
+
+  const segFiles = segments.map((s, i) => {
+    const mp4 = path.join(workDir, `seg-${s.name}.mp4`);
+    renderCardToMp4(s.png, durs[i], mp4);
+    return mp4;
+  });
+  const videoDur = durs.reduce((a, b) => a + b, 0);
+
+  const concatList = path.join(workDir, 'concat.txt');
+  fs.writeFileSync(concatList, segFiles.map((f) => `file '${f}'`).join('\n'));
+  const videoOnly = path.join(workDir, 'video.mp4');
+  run(FFMPEG, ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', videoOnly]);
+
+  // ---- voiceover (unless --no-vo) ----
+  let costUSD = 0, voNote = 'silent (--no-vo)';
+  const voPath = path.join(workDir, 'vo.mp3');
+  if (!NO_VO) {
+    let narration = `A P24 satirical cartoon. ${headline}. ${dek}`.replace(/\s+/g, ' ').trim();
+    if (narration.length > 400) narration = narration.slice(0, 397).trimEnd() + '…';
+    const { synthesize } = await import(path.join(AND_SHORT_DIR, 'tts-elevenlabs.mjs'));
+    const r = await synthesize({ text: narration, out: voPath });
+    costUSD = r.costUSD;
+    voNote = `${r.chars} chars, ~$${r.costUSD} (${r.voice}/${r.model})`;
+    log(`VO: ${voNote}`);
+  }
+
+  const outPath = path.join(workDir, 'out.mp4');
+  const thumbPath = path.join(workDir, 'thumb.jpg');
+  if (!NO_VO && fs.existsSync(voPath) && fs.statSync(voPath).size > 0) {
+    run(FFMPEG, ['-y', '-i', videoOnly, '-i', voPath,
+      '-filter_complex', `[1:a]apad=whole_dur=${videoDur.toFixed(3)}[a]`,
+      '-map', '0:v:0', '-map', '[a]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
+      '-t', videoDur.toFixed(3), '-movflags', '+faststart', outPath]);
+  } else {
+    run(FFMPEG, ['-y', '-i', videoOnly,
+      '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+      '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
+      '-t', videoDur.toFixed(3), '-movflags', '+faststart', outPath]);
+  }
+
+  // thumbnail = first panel card + SATIRE badge (already stamped on every card)
+  run(MAGICK, [p1Png, '-quality', '90', thumbPath]);
+
+  // ---- verify ----
+  const rep = probe(outPath);
+  const wMatch = /width=(\d+)/.exec(rep), hMatch = /height=(\d+)/.exec(rep);
+  const dMatch = /duration=([\d.]+)/.exec(rep);
+  const dur = dMatch ? parseFloat(dMatch[1]) : NaN;
+  const okDims = wMatch && hMatch && +wMatch[1] === W && +hMatch[1] === H;
+  const okDur = isFinite(dur) && dur < 60;
+  log(`ffprobe out.mp4: ${wMatch ? wMatch[1] : '?'}x${hMatch ? hMatch[1] : '?'} ${isFinite(dur) ? dur.toFixed(2) : '?'}s`);
+  if (!okDims || !okDur) {
+    const detail = `render verification failed (dims ok=${okDims}, dur ok=${okDur}, dur=${dur})`;
+    appendLog({ id, status: 'FAIL', detail, costUSD });
+    log(`FAIL ${id} — ${detail}`);
+    return { id, status: 'FAIL', detail, costUSD, outPath };
+  }
+
+  // ---- metadata ----
+  const rawTitle = `Satire: ${cartoon.title} | P24 Cartoon`;
+  const title = rawTitle.length > 100 ? rawTitle.slice(0, 97).trimEnd() + '…' : rawTitle;
+  const descLines = [
+    'SATIRE — invented, not real news.',
+    '',
+    headline,
+    dek,
+    '',
+    ...paragraphs.slice(0, 2),
+    '',
+    cartoon.blurb || '',
+    '',
+    '#Shorts #satire #P24',
+  ].filter((l, i, arr) => !(l === '' && arr[i - 1] === '')); // collapse doubled blank lines
+  const description = descLines.join('\n').trim();
+  const tags = [...new Set([...(cartoon.tags || []), ...(story.tags || []), 'satire', 'P24'])].slice(0, 30);
+
+  const meta = { title, description, tags, privacyStatus: PRIVACY, thumbnail: thumbPath };
+
+  if (NO_UPLOAD) {
+    appendLog({ id, status: 'PASS', detail: `rendered ${outPath} (upload skipped --no-upload)`, costUSD, title, outPath, thumbPath });
+    log(`PASS ${id} — rendered ${outPath} (upload skipped)`);
+    return { id, status: 'PASS', costUSD, outPath, thumbPath, title, meta, voNote, videoDur, dur };
+  }
+
+  // ---- upload (only reached without --no-upload) ----
+  const { uploadShort } = await import(path.join(AND_SHORT_DIR, 'upload-youtube.mjs'));
+  const res = await uploadShort({ file: outPath, thumbnail: thumbPath, ...meta });
+  ledger[id] = { videoId: res.videoId, url: res.url, ts: new Date().toISOString(), costUSD };
+  writeLedger(ledger);
+  appendLog({ id, status: 'PASS', detail: `uploaded ${PRIVACY}: ${res.url}`, costUSD, videoId: res.videoId, url: res.url });
+  log(`PASS ${id} — uploaded ${PRIVACY}: ${res.url}`);
+  return { id, status: 'PASS', costUSD, videoId: res.videoId, url: res.url };
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+(async () => {
+  const ledger = readLedger();
+  const results = [];
+  let totalCost = 0;
+
+  if (!selected.length) {
+    writeHeartbeat('WARN', `no cartoons matched (date=${DATE}, ids=${IDS || 'n/a'}) — nothing to measure`, { processed: 0 });
+    log('nothing to do.');
+    return;
+  }
+
+  for (const cartoon of selected) {
+    try {
+      const r = await processCartoon(cartoon, ledger);
+      results.push(r);
+      totalCost += r.costUSD || 0;
+    } catch (e) {
+      appendLog({ id: cartoon.id, status: 'FAIL', detail: `unhandled error: ${e.message}` });
+      log(`FAIL ${cartoon.id} — unhandled error: ${e.message}\n${e.stack}`);
+      results.push({ id: cartoon.id, status: 'FAIL', detail: e.message, costUSD: 0 });
+    }
+  }
+
+  const passed = results.filter((r) => r.status === 'PASS').length;
+  const failed = results.filter((r) => r.status === 'FAIL').length;
+  const skipped = results.filter((r) => r.status === 'SKIP').length;
+
+  console.log('\n=== make-cartoon-shorts summary ===');
+  for (const r of results) console.log(`  ${r.status.padEnd(4)} ${r.id}${r.detail ? ' — ' + r.detail : ''}`);
+  console.log(`  total cost: $${totalCost.toFixed(4)}`);
+
+  // Verdict: FAIL only on an unexpected crash across the whole batch (none here reach this
+  // point on crash — see catch above, which records a per-item FAIL and continues). A FAIL
+  // row for "no resolvable article" is an expected data-gap signal, not a script failure —
+  // but it must never render as a silent PASS, so any FAIL row -> WARN at minimum.
+  let verdict = 'PASS';
+  if (failed > 0) verdict = 'WARN';
+  if (passed === 0 && skipped === 0 && failed > 0) verdict = 'FAIL'; // everything failed
+  writeHeartbeat(verdict, `${passed} passed, ${failed} failed, ${skipped} skipped, $${totalCost.toFixed(4)} spent`, {
+    processed: results.length, passed, failed, skipped, totalCostUSD: +totalCost.toFixed(4),
+    date: DATE, ids: IDS, noUpload: NO_UPLOAD, noVo: NO_VO,
+  });
+})();
diff --git a/yolo/cartoon-shorts-log.jsonl b/yolo/cartoon-shorts-log.jsonl
new file mode 100644
index 0000000..5d71e69
--- /dev/null
+++ b/yolo/cartoon-shorts-log.jsonl
@@ -0,0 +1,5 @@
+{"ts":"2026-09-24T22:37:58.042Z","id":"commission-turtles","status":"PASS","detail":"rendered /Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/out.mp4 (upload skipped --no-upload)","costUSD":0.087,"title":"Satire: Commissions All the Way Down | P24 Cartoon","outPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/out.mp4","thumbPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/thumb.jpg"}
+{"ts":"2026-09-24T22:39:12.791Z","id":"tk12191-negative-test","status":"FAIL","detail":"no resolvable article (story_id=\"tk12191-does-not-exist\")"}
+{"ts":"2026-09-24T22:39:19.805Z","id":"tk12191-negative-test","status":"FAIL","detail":"no resolvable article (story_id=\"tk12191-does-not-exist\")"}
+{"ts":"2026-09-24T22:40:05.152Z","id":"budget-pie-fight","status":"PASS","detail":"rendered /Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/budget-pie-fight/out.mp4 (upload skipped --no-upload)","costUSD":0,"title":"Satire: Who Gets the Last Slice | P24 Cartoon","outPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/budget-pie-fight/out.mp4","thumbPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/budget-pie-fight/thumb.jpg"}
+{"ts":"2026-09-24T22:42:22.933Z","id":"commission-turtles","status":"PASS","detail":"rendered /Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/out.mp4 (upload skipped --no-upload)","costUSD":0.087,"title":"Satire: Commissions All the Way Down | P24 Cartoon","outPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/out.mp4","thumbPath":"/Users/macstudio3/Projects/crazy-news-channel/data/cartoon-shorts/commission-turtles/thumb.jpg"}

← 2d4bee0 Add googly eyes (/eyes skill) pinned bottom-right above the  ·  back to Crazy News Channel  ·  cartoon shorts: transparent SATIRE badge (no white box), kic 89ae98f →