/** * Animation timing math for the per-frame overlay renderer. * * The static `render-local` path composites one PNG per element with a fade + slide. * The animated path instead evaluates every overlay as a function of time `t` (in * seconds) — entrance, word-by-word reveal, stat count-up, gauge fill, push-in — and * rasterises one transparent frame per output frame, which ffmpeg overlays on the * footage. Everything here is pure and unit-tested; the rasterise + ffmpeg run are * injected by the caller (see `animate-render.ts`). */ export function clamp01(x: number): number { return x < 0 ? 0 : x > 1 ? 1 : x; } /** ease-out cubic — fast in, decelerate into rest (the Apple-ish settle). */ export function easeOutCubic(t: number): number { const u = 1 - clamp01(t); return 1 - u * u * u; } /** ease-out back — a slight overshoot then settle (spring-like entrance). */ export function easeOutBack(t: number): number { const c1 = 1.70158; const c3 = c1 + 1; const u = clamp01(t) - 1; return 1 + c3 * u * u * u + c1 * u * u; } /** Opacity over an element's [start,end] window with symmetric fade in/out. */ export function elementOpacity(t: number, start: number, end: number, fade = 0.3): number { if (t <= start || t >= end) return 0; if (t < start + fade) return clamp01((t - start) / fade); if (t > end - fade) return clamp01((end - t) / fade); return 1; } /** Entrance slide offset in px (eased): `dist` at `start`, 0 once the entrance ends. */ export function slideOffset(t: number, start: number, dur: number, dist: number): number { if (t <= start) return dist; if (t >= start + dur) return 0; return dist * (1 - easeOutCubic((t - start) / dur)); } /** Entrance scale (eased, overshoot): `from` at `start`, settles to 1 over `dur`. */ export function popScale(t: number, start: number, dur: number, from = 0.9): number { if (t <= start) return from; if (t >= start + dur) return 1; return from + (1 - from) * easeOutBack((t - start) / dur); } /** Slow push-in scale across the whole window: `from`→`to` (e.g. 1.0→1.02). */ export function pushInScale(t: number, start: number, end: number, from = 1, to = 1.02): number { if (t <= start) return from; if (t >= end) return to; return from + (to - from) * clamp01((t - start) / (end - start)); } /** * Word-by-word reveal: how many of `totalWords` are visible at `t`. Words appear * across the first `leadFrac` of the window, then all hold. At least 1 once started. */ export function revealWordCount(t: number, start: number, end: number, totalWords: number, leadFrac = 0.6): number { if (totalWords <= 0 || t <= start) return 0; const revealEnd = start + (end - start) * leadFrac; if (t >= revealEnd || revealEnd <= start) return totalWords; const frac = (t - start) / (revealEnd - start); return Math.min(totalWords, Math.max(1, Math.ceil(frac * totalWords))); } /** Integer count-up 0→`target` over [start, start+dur], eased. */ export function countUpValue(t: number, start: number, dur: number, target: number): number { if (t <= start) return 0; if (t >= start + dur) return target; return Math.round(target * easeOutCubic((t - start) / dur)); } /** Gauge fill 0→`targetPct` (0..1) over [start, start+dur], eased. */ export function gaugeProgress(t: number, start: number, dur: number, targetPct: number): number { if (t <= start) return 0; if (t >= start + dur) return targetPct; return targetPct * easeOutCubic((t - start) / dur); } /** Number of frames for a clip of `durationSeconds` at `fps` (inclusive of frame 0). */ export function frameCount(durationSeconds: number, fps: number): number { return Math.max(1, Math.ceil(durationSeconds * fps)); } /** Timestamp (seconds) of frame index `i` at `fps`. */ export function frameTime(i: number, fps: number): number { return i / fps; }