/** * title-overlay.ts — music-video TITLE overlays (the "titles like you see in * music videos" layer): a faded lower-third early in the song and/or a centred * end card that holds to EOF. Works on ANY ffmpeg build and ANY script * (Devanagari/Arabic) because the text is rasterized by Pillow + RAQM rather than * ffmpeg `drawtext` (no libfreetype needed, complex scripts shaped correctly). * * Two halves mirror {@link ./assemble/text-card}: the PURE {@link buildTitleCards} * turns parsed options + probed video dims/duration into the card SPECS (text → * PNG) and their composite TIMINGS — fully unit-testable, no I/O. The thin * {@link runTitleOverlay} probes the input, renders each spec to a PNG, then burns * them on with {@link buildTitleCompositeArgs}. `--dry-run` returns the plan * without rendering or spawning. */ import { buildTitleCompositeArgs, renderTextCard, pillowAvailable, type TextCardSpec, type TitleCard, } from './assemble/text-card.js'; import { probeMedia } from './final-media.js'; import { runFfmpeg } from './assemble/ffmpeg.js'; import { join } from 'node:path'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; /** Defaults tuned for a cinematic music-video look. */ export const TITLE_DEFAULTS = { titleColor: '#f5f0e6', subColor: '#cbb89a', accentColor: '#d4622a', titleFont: 'didot', bodyFont: 'avenir', lowerThirdStartSec: 4, lowerThirdEndSec: 11, fadeSec: 0.6, endCardSec: 4.5, } as const; export interface TitleOverlayOptions { /** Lower-third lines (first = title-weight, rest = body). Empty = no lower third. */ lowerThird?: string[]; lowerThirdStartSec?: number; lowerThirdEndSec?: number; /** End-card lines (centred). Empty = no end card. */ endCard?: string[]; /** How long before EOF the end card starts; it holds to the end with no fade-out. */ endCardSec?: number; /** Font alias or absolute path for the first/title line. */ titleFont?: string; /** Font alias or absolute path for body/subtitle lines. */ bodyFont?: string; titleColor?: string; subColor?: string; accentColor?: string; /** Drop the ember accent rule on the lower third. */ noAccent?: boolean; } export interface TitleCardsPlan { specs: TextCardSpec[]; /** Composite timings, parallel to `specs` (pngPath filled at render time). */ timings: Array>; } function scaled(value: number, height: number, ref = 1080): number { return Math.max(1, Math.round((value * height) / ref)); } /** * Build the title-card specs + composite timings for a `width`×`height` video of * `durationSec`. PURE — no I/O. Sizes type relative to the frame height so a 720p * and a 1080p master read the same. Throws if neither a lower third nor an end * card is requested. */ export function buildTitleCards( opts: TitleOverlayOptions, width: number, height: number, durationSec: number, ): TitleCardsPlan { const hasLT = (opts.lowerThird ?? []).filter((l) => l.trim()).length > 0; const hasEnd = (opts.endCard ?? []).filter((l) => l.trim()).length > 0; if (!hasLT && !hasEnd) { throw new Error('buildTitleCards: at least one of lowerThird / endCard is required.'); } const titleFont = opts.titleFont ?? TITLE_DEFAULTS.titleFont; const bodyFont = opts.bodyFont ?? TITLE_DEFAULTS.bodyFont; const titleColor = opts.titleColor ?? TITLE_DEFAULTS.titleColor; const subColor = opts.subColor ?? TITLE_DEFAULTS.subColor; const specs: TextCardSpec[] = []; const timings: Array> = []; if (hasLT) { const lines = opts.lowerThird!.filter((l) => l.trim()); const start = opts.lowerThirdStartSec ?? TITLE_DEFAULTS.lowerThirdStartSec; const end = Math.min(opts.lowerThirdEndSec ?? TITLE_DEFAULTS.lowerThirdEndSec, durationSec); // An empty/out-of-range window silently produced an output with NO title // (the drawtext enable window was never true) — fail at plan time instead. if (start >= durationSec) { throw new Error( `buildTitleCards: lower-third starts at ${start}s but the video is only ${durationSec.toFixed(2)}s — the title would never appear.`, ); } if (end <= start) { throw new Error( `buildTitleCards: lower-third window is empty (start ${start}s, end ${end}s after clamping to the ${durationSec.toFixed(2)}s video).`, ); } specs.push({ width, height, outputPath: '', // filled by runTitleOverlay anchor: 'bottom-left', lines: lines.map((text, i) => ({ text, font: i === 0 ? titleFont : bodyFont, size: i === 0 ? scaled(64, height) : scaled(40, height), color: i === 0 ? titleColor : subColor, ...(i === 0 ? { tracking: scaled(2, height) } : {}), })), shadow: true, ...(opts.noAccent ? {} : { accentRule: { color: opts.accentColor ?? TITLE_DEFAULTS.accentColor, width: scaled(7, height) } }), }); timings.push({ startSec: start, endSec: end, fadeInSec: TITLE_DEFAULTS.fadeSec, fadeOutSec: TITLE_DEFAULTS.fadeSec }); } if (hasEnd) { const lines = opts.endCard!.filter((l) => l.trim()); const hold = opts.endCardSec ?? TITLE_DEFAULTS.endCardSec; const start = Math.max(0, durationSec - hold); specs.push({ width, height, outputPath: '', anchor: 'center', // Drop the end card into the lower third (not dead center) so it sits // UNDERNEATH a centered subject — e.g. a product whose own wordmark is // mid-frame — instead of colliding with it. y: Math.round(height * 0.72), lines: lines.map((text, i) => ({ text, font: i === 0 ? titleFont : bodyFont, // Smaller than before (was 88/44): a held end card over busy product // footage reads better small + low, and the fit-to-width guard keeps a // long tagline from overflowing regardless. size: i === 0 ? scaled(52, height) : scaled(30, height), color: i === 0 ? titleColor : subColor, ...(i === 0 ? { tracking: scaled(2, height) } : {}), })), shadow: true, }); // hold to EOF: fade in, no fade-out. timings.push({ startSec: start, endSec: durationSec, fadeInSec: TITLE_DEFAULTS.fadeSec, fadeOutSec: 0 }); } return { specs, timings }; } export interface RunTitleOverlayResult { output: string; width: number; height: number; durationSec: number; cards: Array<{ kind: 'lower-third' | 'end-card'; lines: string[]; startSec: number; endSec: number }>; dryRun: boolean; } export interface RunTitleOverlayOptions extends TitleOverlayOptions { input: string; output: string; dryRun?: boolean; python?: string; ffmpegBin?: string; } /** * Probe the input, render the title PNGs (Pillow + RAQM), and burn them on. On * `dryRun`, returns the planned cards without rendering or spawning. Throws a * clear error if Pillow/RAQM is unavailable on a real run (the renderer dep). */ export async function runTitleOverlay(opts: RunTitleOverlayOptions): Promise { const probe = await probeMedia(opts.input); const { width, height } = probe; // Cards are sized/positioned off the real canvas — silently composing for an // assumed 1280x720 would misplace and mis-scale every title on other inputs. if (!width || !height) { throw new Error(`runTitleOverlay: could not determine the dimensions of ${opts.input}.`); } const durationSec = probe.durationSeconds ?? 0; if (!durationSec) { throw new Error(`runTitleOverlay: could not determine the duration of ${opts.input}.`); } const plan = buildTitleCards(opts, width, height, durationSec); const describe = (i: number): RunTitleOverlayResult['cards'][number] => ({ kind: plan.specs[i].anchor === 'center' ? 'end-card' : 'lower-third', lines: plan.specs[i].lines.map((l) => l.text), startSec: plan.timings[i].startSec, endSec: plan.timings[i].endSec, }); const cards = plan.specs.map((_, i) => describe(i)); if (opts.dryRun) { return { output: opts.output, width, height, durationSec, cards, dryRun: true }; } if (!(await pillowAvailable({ ...(opts.python ? { python: opts.python } : {}) }))) { throw new Error( 'runTitleOverlay: python3 with Pillow + RAQM is required to rasterize titles ' + '(pip install "Pillow[raqm]"). Use --dry-run to plan without rendering.', ); } const dir = await mkdtemp(join(tmpdir(), 'vclaw-titles-')); try { const titleCards: TitleCard[] = []; for (let i = 0; i < plan.specs.length; i++) { const pngPath = join(dir, `card-${i}.png`); await renderTextCard({ ...plan.specs[i], outputPath: pngPath }, { ...(opts.python ? { python: opts.python } : {}) }); titleCards.push({ ...plan.timings[i], pngPath }); } const args = buildTitleCompositeArgs({ baseVideo: opts.input, cards: titleCards, outputPath: opts.output }); await runFfmpeg(args, { ...(opts.ffmpegBin ? { ffmpegBin: opts.ffmpegBin } : {}) }); } finally { await rm(dir, { recursive: true, force: true }); } return { output: opts.output, width, height, durationSec, cards, dryRun: false }; }