import { spawn } from "child_process"; import fs from "fs/promises"; import path from "path"; import ffmpegStatic from "ffmpeg-static"; import { getVideoAttributionText } from "../branding"; import { getResolution } from "../constants"; import { getWatermarkPath } from "../storage"; function getFfmpegPath(): string { if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH; if (ffmpegStatic) return ffmpegStatic; return "ffmpeg"; } export { getFfmpegPath }; export async function encodeVideo(options: { imagePath: string; audioPath: string; outputPath: string; resolution: string; includeWatermark: boolean; }): Promise { const res = getResolution(options.resolution); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`; const watermarkPath = getWatermarkPath(); let watermarkExists = false; try { await fs.access(watermarkPath); watermarkExists = true; } catch { watermarkExists = false; } const useWatermark = options.includeWatermark && watermarkExists; const attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'"); const watermarkWidth = Math.max(1, Math.round(res.width * 0.32)); // Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4 const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath]; if (useWatermark) { args.push("-i", watermarkPath); args.push( "-filter_complex", `[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`, "-map", "[vout]", "-map", "1:a", ); } else if (options.includeWatermark && !watermarkExists) { args.push( "-filter_complex", `[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`, "-map", "[vout]", "-map", "1:a", ); } else { args.push("-vf", scaleFilter); } args.push( "-c:v", "libx264", "-tune", "stillimage", "-c:a", "copy", "-shortest", "-pix_fmt", "yuv420p", options.outputPath, ); await runFfmpeg(args); } function runFfmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] }); let stderr = ""; proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); proc.on("close", (code) => { if (code === 0) resolve(); else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`)); }); proc.on("error", (err) => { reject(new Error(`FFmpeg not found or failed to start: ${err.message}`)); }); }); } export async function cleanupFiles(paths: string[]) { for (const p of paths) { try { await fs.unlink(p); } catch { // ignore missing files } } }