/** * Motion-graphics overlays for the assemble layer (the After-Effects-shaped gap): * composite a graphic onto footage, burn an animated alert, or a lower-third title * — all time-gated with fades. These are PURE FFmpeg arg-builders (the tested * surface; ffmpeg is never spawned here), mirroring stitch.ts. They pair with * gen-image: generate a "SYSTEM COMPROMISED" / dashboard graphic, then overlay it. * * Conventions: `-y` is auto-prepended by runFfmpeg, so it is NOT included here. * Time gating uses `enable='between(t,start,end)'`; fades use the `fade` filter on * the overlay's alpha, so the composite eases in/out rather than popping. * * VALIDATION STATUS: {@link buildOverlayArgs} (graphic composite) is real-render * validated — pair it with gen-image to overlay a generated "SYSTEM COMPROMISED" / * dashboard graphic with no font dependency (the recommended path). The two text * builders ({@link buildAlertTextArgs}, {@link buildLowerThirdArgs}) use the * `drawtext` filter, which requires an ffmpeg built with libfreetype; their arg * shapes are unit-tested but NOT render-validated on every host. On a drawtext-less * ffmpeg, render the text to a PNG (gen-image / a title-card) and composite it via * buildOverlayArgs instead. */ export type OverlayPosition = | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center' | 'bottom-center' | 'top-center' | 'full'; export const OVERLAY_POSITION_IDS: OverlayPosition[] = [ 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center', 'bottom-center', 'top-center', 'full', ]; /** overlay x/y expressions for a position, given a pixel `margin`. `full` is 0,0 * (the caller scales the graphic to the frame). Uses overlay-filter vars * (main_w/main_h = base, overlay_w/overlay_h = the graphic). */ function overlayXY(position: OverlayPosition, margin: number): { x: string; y: string } { const m = String(margin); switch (position) { case 'top-left': return { x: m, y: m }; case 'top-right': return { x: `main_w-overlay_w-${m}`, y: m }; case 'bottom-left': return { x: m, y: `main_h-overlay_h-${m}` }; case 'bottom-right': return { x: `main_w-overlay_w-${m}`, y: `main_h-overlay_h-${m}` }; case 'center': return { x: '(main_w-overlay_w)/2', y: '(main_h-overlay_h)/2' }; case 'bottom-center': return { x: '(main_w-overlay_w)/2', y: `main_h-overlay_h-${m}` }; case 'top-center': return { x: '(main_w-overlay_w)/2', y: m }; case 'full': return { x: '0', y: '0' }; } } export interface BuildOverlayOptions { position?: OverlayPosition; /** Visible window in seconds. Defaults to the whole clip (no `enable` gate). */ startSec?: number; endSec?: number; fadeInSec?: number; fadeOutSec?: number; /** 0..1 overall opacity. Default 1. */ opacity?: number; /** Inset from the frame edge in px (ignored for `center`/`full`). Default 48. */ margin?: number; /** H.264 CRF. Default 18 (overlays want to preserve text edges). */ crf?: number; } /** * Composite a graphic (PNG/MP4-with-alpha) onto a base video (PURE). Fades the * overlay's alpha in/out and gates it to [startSec, endSec] when given. `full` * scales the graphic to the frame; other positions overlay at native size. */ export function buildOverlayArgs( baseVideo: string, graphic: string, outputPath: string, opts: BuildOverlayOptions = {}, ): string[] { const position = opts.position ?? 'bottom-center'; const margin = opts.margin ?? 48; const { x, y } = overlayXY(position, margin); const start = opts.startSec; const end = opts.endSec; const ovChain: string[] = ['format=rgba']; if (typeof opts.opacity === 'number' && opts.opacity < 1) { ovChain.push(`colorchannelmixer=aa=${opts.opacity}`); } if (opts.fadeInSec && opts.fadeInSec > 0) { ovChain.push(`fade=t=in:st=${start ?? 0}:d=${opts.fadeInSec}:alpha=1`); } if (opts.fadeOutSec && opts.fadeOutSec > 0 && typeof end === 'number') { ovChain.push(`fade=t=out:st=${Math.max(0, end - opts.fadeOutSec)}:d=${opts.fadeOutSec}:alpha=1`); } const enable = typeof start === 'number' && typeof end === 'number' ? `:enable='between(t,${start},${end})'` : ''; // For `full`, scale the graphic to the base frame with scale2ref (a runtime // reference to the base video's real WxH) — NOT `scale=main_w:main_h`, whose // main_w/main_h are overlay-filter-only vars that evaluate to 0 inside `scale` // (→ scale=0:0, which ffmpeg rejects with "Output size 0x0 is not allowed"). // Other positions overlay the graphic at its native size. const filter = position === 'full' ? `[1:v]${ovChain.join(',')}[ovpre];` + `[ovpre][0:v]scale2ref=w=main_w:h=main_h[ov][base];` + `[base][ov]overlay=0:0${enable}[v]` : `[1:v]${ovChain.join(',')}[ov];` + `[0:v][ov]overlay=${x}:${y}${enable}[v]`; return [ '-i', baseVideo, '-i', graphic, '-filter_complex', filter, '-map', '[v]', '-map', '0:a?', '-c:v', 'libx264', '-preset', 'fast', '-crf', String(opts.crf ?? 18), '-c:a', 'copy', '-movflags', '+faststart', outputPath, ]; } export interface BuildAlertTextOptions { startSec?: number; endSec?: number; /** Pulse frequency in Hz (the flash rate). Default 2. */ pulseHz?: number; /** Text color. Default red. */ color?: string; fontSize?: number; /** Draw a flashing border box. Default true. */ border?: boolean; crf?: number; } /** * Burn an animated alert (e.g. "SYSTEM COMPROMISED") onto a base video WITHOUT a * pre-rendered graphic (PURE). The text alpha pulses via a sine expression and an * optional border box flashes in sync, gated to [startSec, endSec]. This is the * templated motion-graphic the reference advert uses for its breach moment. */ export function buildAlertTextArgs( baseVideo: string, text: string, outputPath: string, opts: BuildAlertTextOptions = {}, ): string[] { const start = opts.startSec ?? 0; const end = opts.endSec; const hz = opts.pulseHz ?? 2; const color = opts.color ?? 'red'; const fontSize = opts.fontSize ?? 72; const gate = typeof end === 'number' ? `between(t,${start},${end})` : `gte(t,${start})`; // Pulsing alpha: 0 outside the window, 0.55..1.0 sine inside. const alpha = `if(${gate}\\,0.55+0.45*sin(2*PI*${hz}*t)\\,0)`; const safeText = text.replace(/:/g, '\\:').replace(/'/g, "\\'"); const filters: string[] = []; if (opts.border !== false) { // A thick frame border that shares the pulsing alpha. filters.push( `drawbox=x=24:y=24:w=iw-48:h=ih-48:color=${color}:t=8:enable='${gate}'`, ); } filters.push( `drawtext=text='${safeText}':fontcolor=${color}:fontsize=${fontSize}:` + `x=(w-text_w)/2:y=(h-text_h)/2:alpha='${alpha}'`, ); return [ '-i', baseVideo, '-vf', filters.join(','), '-c:v', 'libx264', '-preset', 'fast', '-crf', String(opts.crf ?? 18), '-c:a', 'copy', '-movflags', '+faststart', outputPath, ]; } export interface BuildLowerThirdOptions { startSec?: number; endSec?: number; fadeInSec?: number; fadeOutSec?: number; fontSize?: number; color?: string; /** Background box color@opacity, e.g. `black@0.55`. Default `black@0.55`. */ boxColor?: string; /** Inset from the bottom-left in px. Default 64. */ margin?: number; crf?: number; } /** * Burn a lower-third title (text on a translucent box, bottom-left) onto a base * video (PURE), gated to [startSec, endSec] with optional fades. The standard * name/role caption the reference uses to introduce characters. */ export function buildLowerThirdArgs( baseVideo: string, text: string, outputPath: string, opts: BuildLowerThirdOptions = {}, ): string[] { const start = opts.startSec; const end = opts.endSec; const margin = opts.margin ?? 64; const fontSize = opts.fontSize ?? 40; const safeText = text.replace(/:/g, '\\:').replace(/'/g, "\\'"); const enable = typeof start === 'number' && typeof end === 'number' ? `:enable='between(t,${start},${end})'` : ''; const drawtext = `drawtext=text='${safeText}':fontcolor=${opts.color ?? 'white'}:fontsize=${fontSize}:` + `x=${margin}:y=h-${margin}-text_h:box=1:boxcolor=${opts.boxColor ?? 'black@0.55'}:boxborderw=18${enable}`; return [ '-i', baseVideo, '-vf', drawtext, '-c:v', 'libx264', '-preset', 'fast', '-crf', String(opts.crf ?? 18), '-c:a', 'copy', '-movflags', '+faststart', outputPath, ]; }