← back to Crazy News Channel

scripts/cartoon-shorts/make-cartoon-shorts.mjs

577 lines

#!/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',
      '-background', 'none', '-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',
      '-background', 'none', '-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+300', kicker, png]);  // below the top-right SATIRE badge (ends ~y260)

  // 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+390', '-composite', png]);
  // caption sits just under the framed image instead of a fixed y, so a wide/short
  // strip (plot twist) doesn't leave a big empty gap
  let framedH = 960;
  try { framedH = parseInt(execFileSync(MAGICK, ['identify', '-format', '%h', framed]).toString(), 10) || 960; } catch {}
  const capY = Math.min(390 + framedH + 70, 1480);

  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+${capY}`, '-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,
  });
})();