import { execFile, execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { buildSrt, type Clip } from "./subtitles"; const execFileAsync = promisify(execFile); function hasFfmpeg() { try { execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); return true; } catch { return false; } } const SUBTITLE_STYLE = "FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40"; function escapeForFilter(p: string) { // ffmpeg filter arguments treat ':' and '\' specially — escape both. return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:"); } export async function renderFinalVideo({ rawWebmPath, clips, outDir, }: { rawWebmPath: string; clips: Clip[]; outDir: string; }): Promise<{ videoFile: string; degraded: string[] }> { const degraded: string[] = []; const srtPath = path.join(outDir, "captions.srt"); fs.writeFileSync(srtPath, buildSrt(clips)); if (!hasFfmpeg()) { const dest = path.join(outDir, "video.webm"); fs.renameSync(rawWebmPath, dest); degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration."); return { videoFile: dest, degraded }; } const narrated = clips.filter((c) => c.narration); const mp4Path = path.join(outDir, "video.mp4"); const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`; if (narrated.length === 0) { degraded.push("No narration available — burned-in subtitles only."); await execFileAsync("ffmpeg", [ "-y", "-i", rawWebmPath, "-vf", srtArg, "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", mp4Path, ]); } else { const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]); const adelayParts = narrated.map( (c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`, ); const mixInputs = narrated.map((_, i) => `[a${i}]`).join(""); const filterComplex = [ `[0:v]${srtArg}[vout]`, ...adelayParts, `${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`, ].join(";"); await execFileAsync("ffmpeg", [ "-y", "-i", rawWebmPath, ...audioInputs, "-filter_complex", filterComplex, "-map", "[vout]", "-map", "[aout]", "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", "-movflags", "+faststart", mp4Path, ]); } fs.rmSync(rawWebmPath, { force: true }); for (const c of narrated) fs.rmSync(c.narration!.path, { force: true }); return { videoFile: mp4Path, degraded }; }