/** * stitch-ad.ts — assemble a short character ad from ordered scene clips with * **cross-dissolves** between shots and an optional **music bed laid UNDER the * native voice** (plus an optional one-shot SFX). The proven character-ad * finishing recipe, productized from the hand-built ffmpeg graph. * * Two halves, mirroring {@link ./text-card}: * - PURE {@link buildStitchAdArgs} turns clips-with-durations + options into the * exact ffmpeg args (xfade video chain + acrossfade native audio + bed/sfx * amix). Fully unit-testable; ffmpeg is never spawned here. * - {@link runStitchAd} probes each clip's duration, builds the args, and runs * ffmpeg (`--dry-run` returns the planned command without spawning). * * Why bed-UNDER-voice matters: the R2V clips carry voice-only audio (Veo is told * to bake in no music, see native-flow-r2v prompt hygiene), so a low instrumental * bed mixed under the crossfaded native voice never clashes with or overwrites a * Veo-generated soundtrack. */ import { probeMedia } from '../final-media.js'; import { runFfmpeg } from './ffmpeg.js'; export interface StitchAdClipTimed { path: string; durationSec: number; } export interface StitchAdBuildInput { clips: StitchAdClipTimed[]; output: string; /** Cross-dissolve length between shots, seconds (default 0.6). */ dissolveSec?: number; width?: number; // default 1280 height?: number; // default 720 fps?: number; // default 24 /** Optional instrumental music bed, laid UNDER the native voice. */ bedPath?: string; /** Bed volume (default 0.13 — sits under the voice). */ bedLevel?: number; /** Optional one-shot SFX. */ sfxPath?: string; /** When the SFX fires, seconds (default 0). */ sfxAtSec?: number; /** SFX volume (default 0.4). */ sfxLevel?: number; /** H.264 CRF (default 18). */ crf?: number; preset?: string; } export interface StitchAdResult { output: string; clipCount: number; durationSec: number; ffmpegCommand: string; dryRun: boolean; } const VID_NORMALIZE = (w: number, h: number, fps: number): string => `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:black,fps=${fps},format=yuv420p,setsar=1`; /** * Compute the cross-dissolve total runtime + per-join xfade offsets. With clip * durations D and dissolve d, each join overlaps the running cut by d, so the * total is sum(D) - (N-1)*d and each offset is "d before the running end". */ export function stitchTimings(durations: number[], dissolveSec: number): { total: number; offsets: number[] } { if (durations.length === 0) return { total: 0, offsets: [] }; let acc = durations[0]; const offsets: number[] = []; for (let j = 1; j < durations.length; j++) { offsets.push(round3(acc - dissolveSec)); acc = acc + durations[j] - dissolveSec; } return { total: round3(acc), offsets }; } function round3(n: number): number { return Math.round(n * 1000) / 1000; } /** * Build the ffmpeg args (PURE). Validates clip count and that the dissolve fits * inside the shortest clip (an over-long dissolve would push an xfade offset * negative and corrupt the cut). */ export function buildStitchAdArgs(input: StitchAdBuildInput): { args: string[]; durationSec: number } { const n = input.clips.length; if (n === 0) throw new Error('stitch-ad requires at least one clip.'); const dissolve = input.dissolveSec ?? 0.6; const width = input.width ?? 1280; const height = input.height ?? 720; const fps = input.fps ?? 24; const crf = input.crf ?? 18; const preset = input.preset ?? 'medium'; const durations = input.clips.map((c) => c.durationSec); if (n > 1) { const minDur = Math.min(...durations); if (dissolve >= minDur) { throw new Error(`stitch-ad: dissolve ${dissolve}s must be shorter than the shortest clip (${minDur}s).`); } } const { total, offsets } = stitchTimings(durations, dissolve); // --- inputs --- const args: string[] = []; for (const clip of input.clips) args.push('-i', clip.path); const bedIdx = input.bedPath ? n : -1; if (input.bedPath) args.push('-i', input.bedPath); const sfxIdx = input.sfxPath ? (input.bedPath ? n + 1 : n) : -1; if (input.sfxPath) args.push('-i', input.sfxPath); // --- video: normalize each, then xfade chain --- const filters: string[] = []; for (let i = 0; i < n; i++) filters.push(`[${i}:v]${VID_NORMALIZE(width, height, fps)}[v${i}]`); let vLabel = '[v0]'; for (let j = 1; j < n; j++) { const out = j === n - 1 ? '[vv]' : `[x${j}]`; filters.push(`${vLabel}[v${j}]xfade=transition=fade:duration=${dissolve}:offset=${offsets[j - 1]}${out}`); vLabel = out; } if (n === 1) vLabel = '[v0]'; // --- native audio: resample each, then acrossfade chain → [nat] --- for (let i = 0; i < n; i++) filters.push(`[${i}:a]aresample=48000[a${i}]`); let natLabel = '[a0]'; for (let j = 1; j < n; j++) { const out = j === n - 1 ? '[nat]' : `[ac${j}]`; filters.push(`${natLabel}[a${j}]acrossfade=d=${dissolve}${out}`); natLabel = out; } if (n === 1) natLabel = '[a0]'; // --- bed (UNDER the voice) + sfx, mixed onto the native track --- const mixInputs: string[] = [natLabel]; if (input.bedPath) { const level = input.bedLevel ?? 0.13; // Clamp the fades to the actual stitched length so a short ad (< the fade // windows) still reaches silence at the cut instead of being chopped mid-fade. const fadeInDur = round3(Math.min(1.5, total)); const fadeOutDur = round3(Math.min(3, total)); const fadeOutStart = Math.max(0, round3(total - fadeOutDur)); filters.push(`[${bedIdx}:a]aloop=loop=-1:size=2e9,atrim=0:${total},afade=t=in:st=0:d=${fadeInDur},afade=t=out:st=${fadeOutStart}:d=${fadeOutDur},volume=${level}[bed]`); mixInputs.push('[bed]'); } if (input.sfxPath) { const level = input.sfxLevel ?? 0.4; const ms = Math.round((input.sfxAtSec ?? 0) * 1000); filters.push(`[${sfxIdx}:a]adelay=${ms}|${ms},volume=${level}[sfx]`); mixInputs.push('[sfx]'); } let aLabel = natLabel; if (mixInputs.length > 1) { filters.push(`${mixInputs.join('')}amix=inputs=${mixInputs.length}:normalize=0,alimiter=limit=0.95[aa]`); aLabel = '[aa]'; } args.push( '-filter_complex', filters.join(';'), '-map', vLabel, '-map', aLabel, '-c:v', 'libx264', '-preset', preset, '-crf', String(crf), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', input.output, ); return { args, durationSec: total }; } /** Probe each clip's duration, build the args, run ffmpeg. */ export async function runStitchAd(options: { clips: string[]; output: string; dissolveSec?: number; width?: number; height?: number; fps?: number; bedPath?: string; bedLevel?: number; sfxPath?: string; sfxAtSec?: number; sfxLevel?: number; crf?: number; dryRun?: boolean; ffmpegBin?: string; ffprobeBin?: string; }): Promise { if (options.clips.length === 0) throw new Error('stitch-ad requires at least one --clip.'); const timed: StitchAdClipTimed[] = []; for (const path of options.clips) { const probe = await probeMedia(path, options.ffprobeBin ? { ffprobeBin: options.ffprobeBin } : {}); const durationSec = probe.durationSeconds ?? 0; if (!durationSec) throw new Error(`stitch-ad: could not determine the duration of ${path}.`); timed.push({ path, durationSec }); } const { args, durationSec } = buildStitchAdArgs({ clips: timed, output: options.output, ...(options.dissolveSec !== undefined ? { dissolveSec: options.dissolveSec } : {}), ...(options.width !== undefined ? { width: options.width } : {}), ...(options.height !== undefined ? { height: options.height } : {}), ...(options.fps !== undefined ? { fps: options.fps } : {}), ...(options.bedPath ? { bedPath: options.bedPath } : {}), ...(options.bedLevel !== undefined ? { bedLevel: options.bedLevel } : {}), ...(options.sfxPath ? { sfxPath: options.sfxPath } : {}), ...(options.sfxAtSec !== undefined ? { sfxAtSec: options.sfxAtSec } : {}), ...(options.sfxLevel !== undefined ? { sfxLevel: options.sfxLevel } : {}), ...(options.crf !== undefined ? { crf: options.crf } : {}), }); const result = await runFfmpeg(args, { ...(options.ffmpegBin ? { ffmpegBin: options.ffmpegBin } : {}), ...(options.dryRun ? { dryRun: true } : {}), }); return { output: options.output, clipCount: timed.length, durationSec, ffmpegCommand: result.command, dryRun: !!options.dryRun, }; }