/** * Per-frame animated overlay renderer (`--animate`). * * The static path composites one PNG per overlay with a fade. This path instead * evaluates every overlay as a function of time and rasterises ONE transparent frame * per output frame — a real motion-graphics layer — then ffmpeg overlays the frame * sequence on the footage. Animations: lower-third fade + slide-up entrance + a * word-by-word reveal; stat callouts count up (0→N) and the % gauge fills; icons / * headlines pop in with a small overshoot. Original audio is preserved. * * `animatedFrameSvg` is pure (one composite SVG for a timestamp); the sharp rasterise * + the ffmpeg run are injectable seams, so the pipeline is unit-tested offline. */ import sharp from 'sharp'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { lowerThirdSvg, anchorHeadlineSvg, motifSvg, type LocalRenderStyle } from './render-local.js'; import type { MotifMoment } from './motifs.js'; import { elementOpacity, slideOffset, popScale, revealWordCount, countUpValue, gaugeProgress, frameCount, frameTime, } from './animate.js'; /** An overlay element placed on the animation timeline. */ export type AnimElement = | { kind: 'lower-third'; start: number; end: number; text: string } | { kind: 'headline'; start: number; end: number; word: string } | { kind: 'stat'; start: number; end: number; value: number; unit?: string; gauge?: number } | { kind: 'icon'; start: number; end: number; motif: MotifMoment }; /** Strip the outer `` wrapper so an element's content can be re-wrapped in a ``. */ function svgInner(svg: string): string { return svg.replace(/^\s*]*>/, '').replace(/<\/svg>\s*$/, ''); } /** Wrap content with an opacity + optional transform group. */ function group(content: string, opacity: number, transform?: string): string { const tr = transform ? ` transform="${transform}"` : ''; return `${content}`; } /** SVG transform that scales by `s` about the point (cx,cy). */ function scaleAbout(s: number, cx: number, cy: number): string { return `translate(${cx.toFixed(1)} ${cy.toFixed(1)}) scale(${s.toFixed(3)}) translate(${(-cx).toFixed(1)} ${(-cy).toFixed(1)})`; } /** * Build the composite overlay SVG for one timestamp `t` — every active element with * its animation applied. Pure & deterministic. */ export function animatedFrameSvg( t: number, dims: { width: number; height: number }, style: LocalRenderStyle, elements: AnimElement[], ): string { const { width: W, height: H } = dims; const motifCx = Math.round(W * 0.82); const motifCy = Math.round(H * 0.22); const parts: string[] = []; for (const el of elements) { const op = elementOpacity(t, el.start, el.end, 0.3); if (op <= 0.001) continue; if (el.kind === 'lower-third') { const words = el.text.split(/\s+/).filter(Boolean).length; const visible = revealWordCount(t, el.start, el.end, words); const dy = slideOffset(t, el.start, 0.35, 40); const content = svgInner(lowerThirdSvg({ text: el.text }, dims, { ...style, visibleWords: visible })); parts.push(group(content, op, `translate(0 ${dy.toFixed(1)})`)); } else if (el.kind === 'stat') { const value = countUpValue(t, el.start, 0.6, el.value); const gauge = el.gauge !== undefined ? gaugeProgress(t, el.start, 0.7, el.gauge) : undefined; const motif: MotifMoment = { kind: 'stat', value: String(value), ...(el.unit ? { unit: el.unit } : {}), ...(gauge !== undefined ? { gauge } : {}), start: el.start, end: el.end }; const content = svgInner(motifSvg(motif, dims, style)); parts.push(group(content, op, scaleAbout(popScale(t, el.start, 0.4), motifCx, motifCy))); } else if (el.kind === 'icon') { const content = svgInner(motifSvg(el.motif, dims, style)); parts.push(group(content, op, scaleAbout(popScale(t, el.start, 0.4), motifCx, motifCy))); } else { // headline — pop in larger about its centre const content = svgInner(anchorHeadlineSvg(el.word, dims, style)); parts.push(group(content, op, scaleAbout(popScale(t, el.start, 0.45, 0.82), W / 2, Math.round(H * 0.42)))); } } return `${parts.join('')}`; } /** Pure: ffmpeg argv overlaying the rendered frame sequence onto the source at `fps`. */ export function buildAnimateRenderArgs( sourceVideo: string, framePattern: string, fps: number, outputPath: string, ): string[] { return [ '-i', sourceVideo, '-framerate', String(fps), '-i', framePattern, '-filter_complex', `[0:v]fps=${fps}[base];[base][1:v]overlay=shortest=1[v]`, '-map', '[v]', '-map', '0:a?', '-c:v', 'libx264', '-crf', '18', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-r', String(fps), '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-shortest', outputPath, ]; } export type FfmpegRunner = (args: string[]) => Promise; export interface AnimateRenderDeps { ffmpeg: FfmpegRunner; /** Override the rasteriser (tests). Defaults to the live sharp path. */ rasterise?: (svg: string) => Promise; } export interface AnimateRenderOptions { sourceVideo: string; dims: { width: number; height: number }; durationSeconds: number; style: LocalRenderStyle; elements: AnimElement[]; fps?: number; /** Dir for the intermediate frame PNGs. */ frameDir: string; outputPath: string; } /** * Render the animated overlay reel: rasterise one transparent frame per output frame * (each the composite for that timestamp), then overlay the sequence on the source. * Returns the frame paths written. */ export async function renderAnimatedOverlay(opts: AnimateRenderOptions, deps: AnimateRenderDeps): Promise { const fps = opts.fps ?? 30; const rasterise = deps.rasterise ?? ((svg: string) => sharp(Buffer.from(svg)).png().toBuffer()); await mkdir(opts.frameDir, { recursive: true }); await mkdir(dirname(opts.outputPath), { recursive: true }); const n = frameCount(opts.durationSeconds, fps); const paths: string[] = []; for (let i = 0; i < n; i++) { const svg = animatedFrameSvg(frameTime(i, fps), opts.dims, opts.style, opts.elements); const png = await rasterise(svg); const p = join(opts.frameDir, `frame_${String(i).padStart(5, '0')}.png`); await writeFile(p, png); paths.push(p); } await deps.ffmpeg(buildAnimateRenderArgs(opts.sourceVideo, join(opts.frameDir, 'frame_%05d.png'), fps, opts.outputPath)); return paths; }