← back to Allnewsdaily

scripts/short/render-short.js

368 lines

#!/usr/bin/env node
/**
 * STAGE 3 — render-short.js  (TK-11342)  owner: render subagent (ffmpeg heavy-lift)
 *
 * Reads:  data/short/stories.json + data/short/script.json + data/short/vo.mp3 (optional)
 * Writes: data/short/out.mp4   (1080x1920, H.264/yuv420p, 30fps, <60s, AAC audio)
 *         data/short/thumb.jpg (1080x1920, strong lead-card frame)
 *
 * If vo.mp3 is ABSENT we render a SILENT video (silent AAC track) and print a WARN.
 * Every card is a branded ALL NEWS DAILY vertical card built purely with ffmpeg drawtext
 * (no node-canvas / cairo). We do NOT embed any outlet imagery — branded cards only.
 */

'use strict';

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { validateScript } = require('./formats');
const { fitCaption } = require('./fit-caption');

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const ROOT = path.resolve(__dirname, '..', '..');            // ~/Projects/allnewsdaily
const DATA = path.join(ROOT, 'data', 'short');
let TMP;
const IMG_DIR = path.join(DATA, 'img');   // stock backgrounds from fetch-stock.mjs (optional)
const LOGO_DIR = path.join(DATA, 'logo'); // per-network source logos from fetch-stock.mjs (optional)

const STORIES_PATH = path.join(DATA, 'stories.json');
const SCRIPT_PATH = path.join(DATA, 'script.json');
const VO_PATH = path.join(DATA, 'vo.mp3');
const OUT_PATH = path.join(DATA, 'out.mp4');
const THUMB_PATH = path.join(DATA, 'thumb.jpg');

// ---------------------------------------------------------------------------
// Canvas / brand constants
// ---------------------------------------------------------------------------
const W = 1080, H = 1920, FPS = 30;
const HARD_CAP_SEC = 58;          // Shorts must be < 60s; keep a safety margin
const RED = '0xE10600';
const INK = '0xF6F6F6';
const SUB = '0xB8B8B8';
const WRAP_CHARS = 24;            // ~22–26 chars/line target
const FONT_PRIMARY = '/System/Library/Fonts/Supplemental/Arial Bold.ttf';
const FONT_FALLBACK = '/System/Library/Fonts/Helvetica.ttc';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function die(msg) {
  console.error('\n[render-short] FATAL: ' + msg + '\n');
  process.exit(1);
}

function which(bin) {
  // Honor env override, then homebrew, then PATH.
  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 (_) { /* fall through */ }
  return null;
}

function run(bin, args) {
  return execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
}

function readJSON(p, label) {
  if (!fs.existsSync(p)) die(`missing required input ${label} at ${p}`);
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
  catch (e) { die(`could not parse ${label} (${p}): ${e.message}`); }
}

// ---------------------------------------------------------------------------
// Boot: verify tools, inputs, tmp dir, font
// ---------------------------------------------------------------------------
const FFMPEG = which('ffmpeg');
const FFPROBE = which('ffprobe');
if (!FFMPEG) die('ffmpeg not found (checked $FFMPEG, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');
if (!FFPROBE) die('ffprobe not found (checked $FFPROBE, /opt/homebrew/bin, PATH). Install: brew install ffmpeg');

// Text is composited with ImageMagick (freetype), NOT node-canvas/cairo. We use this
// because THIS ffmpeg build was compiled without --enable-libfreetype, so the `drawtext`
// filter is unavailable (verified: `ffmpeg -filters` has no drawtext). ffmpeg still does
// all the video work (Ken-Burns zoompan, concat, AAC mux); IM only renders the card PNGs.
const MAGICK = which('magick') || which('convert');
const HAS_DRAWTEXT = (() => {
  try { return /(^|\s)drawtext(\s|$)/m.test(run(FFMPEG, ['-hide_banner', '-filters'])); }
  catch (_) { return false; }
})();
if (!MAGICK) {
  die('ImageMagick is required to composite cards. Install: brew install imagemagick');
}
if (!HAS_DRAWTEXT) console.warn('[render-short] NOTE: ffmpeg has no drawtext filter — compositing card text with ImageMagick.');

const stories = readJSON(STORIES_PATH, 'stories.json');
const script = readJSON(SCRIPT_PATH, 'script.json');
if (!script.beats || !Array.isArray(script.beats) || !script.beats.length) die('script.json has no beats[]');
if (!script.intro || !script.outro) die('script.json missing intro/outro');

let layout;
try { layout = validateScript(script); } catch (error) { die(error.message); }
fs.mkdirSync(DATA, { recursive: true });
TMP = fs.mkdtempSync(path.join(DATA, '.render-'));
console.log(`[render-short] format=${layout.id} retained scratch=${TMP}`);

// Resolve the branded font (Arial Bold, Helvetica fallback). Passed as a plain CLI arg to
// ImageMagick, so the space in the path is fine (no filtergraph escaping needed).
const FONT = fs.existsSync(FONT_PRIMARY) ? FONT_PRIMARY
  : (fs.existsSync(FONT_FALLBACK) ? FONT_FALLBACK : null);
if (!FONT) die(`no usable font (looked for "${FONT_PRIMARY}" then "${FONT_FALLBACK}")`);
if (FONT === FONT_FALLBACK) console.warn('[render-short] WARN: Arial Bold not found — using Helvetica fallback.');

// ---------------------------------------------------------------------------
// VO detection + duration → target total
// ---------------------------------------------------------------------------
let voDur = null, silent = true;
if (fs.existsSync(VO_PATH) && fs.statSync(VO_PATH).size > 0) {
  try {
    const out = run(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration',
      '-of', 'default=noprint_wrappers=1:nokey=1', VO_PATH]).trim();
    const d = parseFloat(out);
    if (isFinite(d) && d > 0) { voDur = d; silent = false; }
  } catch (e) { console.warn('[render-short] WARN: ffprobe on vo.mp3 failed: ' + e.message); }
}

const scriptTotal = Number(script.totalSec) || null;
let target;
if (!silent) {
  target = voDur;
  console.log(`[render-short] VO found: vo.mp3 = ${voDur.toFixed(2)}s — cards will sync to real audio.`);
} else {
  target = scriptTotal || script.intro.estSec + script.outro.estSec +
    script.beats.reduce((s, b) => s + (Number(b.estSec) || 0), 0);
  console.warn(`[render-short] WARN: no vo.mp3 — rendering SILENT. Using script timing (~${target.toFixed(1)}s). ` +
    `(TTS is skipped to avoid spend; STAGE 5 orchestrator supplies real VO.)`);
}
if (target > HARD_CAP_SEC) {
  console.warn(`[render-short] WARN: target ${target.toFixed(1)}s > ${HARD_CAP_SEC}s cap — compressing card durations to stay < 60s.`);
  target = HARD_CAP_SEC;
}

// ---------------------------------------------------------------------------
// Build the segment list (intro + beats + outro) and distribute durations
// proportional to each segment's estSec, scaled so the sum == target.
// ---------------------------------------------------------------------------
function cleanIntro(t) { return String(t).replace(/^\s*All News Daily\.\s*/i, '').trim() || t; }

// Guard against a stock "photo" that is actually SVG/HTML/text saved with a .jpg extension
// (TK-11458 — Openverse handed back an SVG world-map that fetch-stock.mjs's content-type check
// let through; ImageMagick content-sniffs the file regardless of extension, treats it as vector
// art, and tries to render any embedded <text> label via its font engine — an unresolvable
// font-family there crashes the ENTIRE 8-card render with a cryptic RenderFreetype error). This
// checks real magic bytes so any non-raster file (this SVG case, a truncated download, an HTML
// error page saved by mistake) is treated as "no image" and falls back to the gradient card
// below instead of taking the whole pipeline down.
function isRasterImage(p) {
  try {
    const fd = fs.openSync(p, 'r');
    const buf = Buffer.alloc(16);
    const n = fs.readSync(fd, buf, 0, 16, 0);
    fs.closeSync(fd);
    const b = buf.subarray(0, n);
    if (b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return true; // JPEG
    if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return true; // PNG
    if (b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.toString('ascii', 0, 6))) return true; // GIF
    if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return true; // WEBP
    if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4D) return true; // BMP
    return false; // anything else (SVG/XML/HTML/text/etc.) — not a raster image, reject
  } catch (_) { return false; }
}

function imgPath(name) {
  const p = path.join(IMG_DIR, `${name}.jpg`);
  if (!fs.existsSync(p)) return null;
  if (!isRasterImage(p)) {
    console.warn(`[render-short] WARN: ${name}.jpg is not a real raster image (magic-byte check ` +
      `failed — likely an SVG/HTML mis-saved as .jpg) — using gradient background instead.`);
    return null;
  }
  return p;
}
function logoPath(n) { try { const f = fs.readdirSync(LOGO_DIR).find((x) => x.startsWith(String(n) + '.')); return f ? path.join(LOGO_DIR, f) : null; } catch { return null; } }

const segments = [];
segments.push({ kind: 'intro', main: cleanIntro(script.intro.text), outlet: null,
  est: Number(script.intro.estSec) || 3, mainSize: 62, tag: 'BRIEFING', img: imgPath('intro') });
for (const b of script.beats) {
  segments.push({ kind: 'beat', main: b.headline || b.text, outlet: b.outlet || null,
    est: Number(b.estSec) || 5, mainSize: 74, n: b.n, img: imgPath(b.n), logo: logoPath(b.n) });
}
segments.push({ kind: 'outro', main: script.outro.text, outlet: null,
  est: Number(script.outro.estSec) || 5, mainSize: 60, tag: 'ALLNEWSDAILY.COM', img: imgPath('outro') });

const estSum = segments.reduce((s, x) => s + x.est, 0);
let durs = segments.map(x => Math.max(1.4, +(target * x.est / estSum).toFixed(3)));
// Rescale after the per-card minimum clamp so the sum lands on target and stays < cap.
let dsum = durs.reduce((a, b) => a + b, 0);
if (dsum > HARD_CAP_SEC || Math.abs(dsum - target) > 0.05) {
  const scale = Math.min(target, HARD_CAP_SEC) / dsum;
  durs = durs.map(d => +(d * scale).toFixed(3));
  dsum = durs.reduce((a, b) => a + b, 0);
}
console.log(`[render-short] ${segments.length} cards, total ${dsum.toFixed(2)}s (target ${target.toFixed(2)}s).`);

// ---------------------------------------------------------------------------
// Build one branded card as a supersampled PNG (1350x2400 → crisp when zoompan'd
// down into 1080x1920). Text is composited with ImageMagick; @file reads sidestep
// all shell/escaping issues for €, $, ', — etc. Headline uses caption: with a fixed
// box so ImageMagick auto-fits the point size (implements the word-wrap requirement
// by wrapping to width, no matter the headline length).
// ---------------------------------------------------------------------------
const CARD_W = 1350, CARD_H = 2400;
const HEX_BG0 = '#0A0A0A', HEX_BG1 = '#17171B', HEX_INK = '#F6F6F6', HEX_RED = '#E10600';

function writeRaw(name, str) { const p = path.join(TMP, name); fs.writeFileSync(p, String(str)); return p; }

function buildCardPng(seg, idx) {
  const png = path.join(TMP, `card${idx}.png`);

  // 1) base: a darkened full-bleed stock image (real news look) if we have one, else the gradient.
  if (seg.img) {
    // fill-crop to the card, darken hard so white text stays legible, slight blur to sit behind text
    run(MAGICK, [seg.img, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`,
      '-modulate', '54', '-fill', 'black', '-colorize', '26%', '-blur', '0x1.2', png]);
    // bottom-weighted dark scrim (transparent top → black bottom) for the outlet chip + lower text
    run(MAGICK, [png, '(', '-size', `${CARD_W}x${CARD_H}`, 'gradient:none-black', ')', '-gravity', 'center', '-composite', png]);
  } else {
    run(MAGICK, ['-size', `${CARD_W}x${CARD_H}`, `gradient:${HEX_BG0}-${HEX_BG1}`, png]);
  }

  // 2) tracked wordmark + red accent bar
  run(MAGICK, [png, '-font', FONT, '-gravity', 'North',
    '-fill', HEX_INK, '-pointsize', '56', '-annotate', '+0+215', 'A L L   N E W S   D A I L Y',
    '-fill', HEX_RED, '-draw', 'rectangle 540,352 810,366', png]);

  const marker = seg.kind === 'beat' ? String(seg.n).padStart(2, '0') : layout.label;
  if (layout.id === 'sidebar') {
    run(MAGICK, [png, '-fill', HEX_RED, '-draw', 'rectangle 85,600 235,1740',
      '-font', FONT, '-fill', 'white', '-pointsize', seg.kind === 'beat' ? '94' : '34',
      '-gravity', 'NorthWest', '-annotate', '+95+640', seg.kind === 'beat' ? marker : 'AND', png]);
  } else if (layout.id === 'bulletin') {
    run(MAGICK, [png, '-fill', HEX_RED, '-draw', 'rectangle 95,550 1255,810',
      '-fill', '#24242A', '-draw', 'rectangle 95,875 1255,1925',
      '-font', FONT, '-fill', 'white', '-pointsize', '100', '-gravity', 'NorthWest',
      '-annotate', '+140+600', seg.kind === 'beat' ? `STORY ${marker}` : marker, png]);
  }
  const hlFile = writeRaw(`hl${idx}.txt`, seg.main);
  const pointSize = fitCaption(MAGICK, FONT, hlFile, layout.box);
  run(MAGICK, [png, '(', '-background', 'none', '-fill', HEX_INK, '-font', FONT,
    '-pointsize', String(pointSize), '-size', layout.box, '-gravity', layout.gravity, `caption:@${hlFile}`, ')',
    '-gravity', 'NorthWest', '-geometry', `+${layout.x}+${layout.y}`, '-composite', png]);

  // 4) lower-third source chip — the network's real LOGO + name (beats with a logo),
  //    else the red "VIA {OUTLET}" text chip (intro/outro, or if the logo fetch missed).
  if (seg.logo && seg.outlet) {
    const logoSq = path.join(TMP, `logosq${idx}.png`);
    // [0] = first frame (multi-size .ico); flatten transparency onto white; square to 104px
    run(MAGICK, [`${seg.logo}[0]`, '-background', 'white', '-alpha', 'remove', '-alpha', 'off',
      '-resize', '104x104', '-gravity', 'center', '-extent', '104x104', logoSq]);
    const nmFile = writeRaw(`nm${idx}.txt`, ` ${seg.outlet} `);
    const nameImg = path.join(TMP, `name${idx}.png`);
    run(MAGICK, ['-background', 'white', '-fill', '#0A0A0A', '-font', FONT, '-pointsize', '54',
      `label:@${nmFile}`, '-gravity', 'center', '-background', 'white', '-extent', 'x104', nameImg]);
    const chip = path.join(TMP, `chip${idx}.png`);
    run(MAGICK, [logoSq, nameImg, '+append', '-bordercolor', 'white', '-border', '26x22', chip]);
    run(MAGICK, [png, chip, '-gravity', 'North', '-geometry', `+0+${layout.sourceY}`, '-composite', png]);
  } else {
    const chipText = seg.outlet ? ('VIA ' + String(seg.outlet).toUpperCase()) : (seg.tag || null);
    if (chipText) {
      const chFile = writeRaw(`chip${idx}.txt`, chipText);
      run(MAGICK, [png, '(', '-background', HEX_RED, '-fill', 'white', '-font', FONT,
        '-pointsize', '46', `label:@${chFile}`, '-bordercolor', HEX_RED, '-border', '28x18', ')',
        '-gravity', 'North', '-geometry', `+0+${layout.sourceY}`, '-composite', png]);
    }
  }
  return png;
}

// Render one card PNG → seg mp4 with a subtle Ken-Burns push-in (zoompan 1.0→~1.10).
function renderCard(seg, dur, idx) {
  const png = buildCardPng(seg, idx);
  const segMp4 = path.join(TMP, `seg${idx}.mp4`);
  const vf = "zoompan=z='min(1.0+0.00055*on,1.10)':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]);
  return segMp4;
}

console.log('[render-short] rendering cards…');
const segFiles = segments.map((seg, i) => {
  const f = renderCard(seg, durs[i], i);
  process.stdout.write(`  card ${i + 1}/${segments.length} (${seg.kind}) ${durs[i].toFixed(2)}s\n`);
  return f;
});

// ---------------------------------------------------------------------------
// Concat all cards (identical params → stream copy)
// ---------------------------------------------------------------------------
const concatList = path.join(TMP, 'concat.txt');
fs.writeFileSync(concatList, segFiles.map(f => `file '${f}'`).join('\n'));
const VIDEO = path.join(TMP, 'video.mp4');
run(FFMPEG, ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', VIDEO]);

// ---------------------------------------------------------------------------
// Mux audio → out.mp4  (real VO, or a silent AAC track if none)
// ---------------------------------------------------------------------------
console.log('[render-short] muxing audio → out.mp4…');
if (!silent) {
  run(FFMPEG, ['-y', '-i', VIDEO, '-i', VO_PATH,
    '-map', '0:v:0', '-map', '1:a:0',
    '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
    '-t', String(HARD_CAP_SEC), '-shortest', '-movflags', '+faststart', OUT_PATH]);
} else {
  run(FFMPEG, ['-y', '-i', VIDEO,
    '-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', String(HARD_CAP_SEC), '-shortest', '-movflags', '+faststart', OUT_PATH]);
}

// ---------------------------------------------------------------------------
// Thumbnail: a strong frame from the LEAD card (first beat = splash story)
// ---------------------------------------------------------------------------
const leadIdx = segments.findIndex(s => s.kind === 'beat');
const leadSeg = segFiles[leadIdx >= 0 ? leadIdx : 0];
const seekTo = Math.min(1.0, Math.max(0.2, (durs[leadIdx >= 0 ? leadIdx : 0]) * 0.5));
run(FFMPEG, ['-y', '-ss', seekTo.toFixed(2), '-i', leadSeg,
  '-frames:v', '1', '-q:v', '2', THUMB_PATH]);

// ---------------------------------------------------------------------------
// Verify & report
// ---------------------------------------------------------------------------
function probe(p) {
  const out = 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',
    '-of', 'default=noprint_wrappers=1', p]);
  return out;
}
const rep = probe(OUT_PATH);
const wMatch = /width=(\d+)/.exec(rep), hMatch = /height=(\d+)/.exec(rep);
const dMatch = /duration=([\d.]+)/.exec(rep);
const dur = dMatch ? parseFloat(dMatch[1]) : NaN;

console.log('\n[render-short] ==== out.mp4 ffprobe ====');
console.log(rep.trim());
console.log('[render-short] ==========================');

const okDims = wMatch && hMatch && +wMatch[1] === W && +hMatch[1] === H;
const okDur = isFinite(dur) && dur < 60;
if (!okDims) die(`out.mp4 dimensions are not ${W}x${H}`);
if (!okDur) die(`out.mp4 duration ${dur}s is not < 60s`);

console.log(`\n[render-short] OK — out.mp4: ${wMatch[1]}x${hMatch[1]}, ${dur.toFixed(2)}s (< 60s), ` +
  `${silent ? 'SILENT AAC' : 'VO AAC'}.`);
console.log(`[render-short] out:   ${OUT_PATH}`);
console.log(`[render-short] thumb: ${THUMB_PATH}`);