/** * Pure narration/video fit planner. * * Adapted from the useful timing rule in Google's story-generator skill: * speed voiceover only within a natural threshold, otherwise keep narration * natural and loop/cap the visual bed to the narration length. */ export interface NarrationFitInput { /** Original narration duration in milliseconds. */ voiceDurationMs: number; /** Available/generated video duration in milliseconds. */ videoDurationMs: number; /** Maximum acceptable voice speed-up. Defaults to 1.25x. */ maxTempo?: number; /** Safety pad removed from the visual target before fitting. Defaults to 500ms. */ tailPadMs?: number; } export interface NarrationFitWarning { code: 'voice-speedup-applied' | 'voice-too-long-loop-video'; message: string; } export interface NarrationFitPlan { /** FFmpeg atempo value for narration. */ tempo: number; /** Whether the visual bed must be looped/extended to preserve natural speech. */ loopVideo: boolean; /** Exact mixed output duration to cap with ffmpeg -t. */ targetDurationMs: number; warnings: NarrationFitWarning[]; } function assertPositiveDuration(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`${name} must be a positive finite duration in milliseconds`); } } function roundTempo(value: number): number { return Math.round(value * 100) / 100; } export function planNarrationFit(input: NarrationFitInput): NarrationFitPlan { assertPositiveDuration('voiceDurationMs', input.voiceDurationMs); assertPositiveDuration('videoDurationMs', input.videoDurationMs); const maxTempo = input.maxTempo ?? 1.25; const tailPadMs = input.tailPadMs ?? 500; if (!Number.isFinite(maxTempo) || maxTempo < 1) { throw new Error('maxTempo must be a finite number >= 1'); } if (!Number.isFinite(tailPadMs) || tailPadMs < 0) { throw new Error('tailPadMs must be a finite number >= 0'); } if (input.voiceDurationMs <= input.videoDurationMs) { return { tempo: 1, loopVideo: false, targetDurationMs: input.voiceDurationMs, warnings: [], }; } const fitTargetMs = Math.max(1000, input.videoDurationMs - tailPadMs); const requiredTempo = input.voiceDurationMs / fitTargetMs; if (requiredTempo <= maxTempo) { const tempo = roundTempo(Math.max(1, requiredTempo)); return { tempo, loopVideo: false, targetDurationMs: Math.round(input.voiceDurationMs / tempo), warnings: [{ code: 'voice-speedup-applied', message: `Narration speed-fit applied at ${tempo.toFixed(2)}x.`, }], }; } return { tempo: 1, loopVideo: true, targetDurationMs: input.voiceDurationMs, warnings: [{ code: 'voice-too-long-loop-video', message: `Required narration speed-up ${requiredTempo.toFixed(2)}x exceeds the ${maxTempo.toFixed(2)}x threshold; keep speech natural and loop the visual bed.`, }], }; }