/** * VideoEncoder — Combines frame images into video using FFmpeg CLI. * * Uses child_process.spawn to invoke ffmpeg directly. * Requires ffmpeg to be available on PATH. */ import { spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; export interface AudioTrackInput { url: string; startFrame: number; volume: number; // 0 to 1 } export interface EncodeOptions { /** Directory containing numbered frame images */ framesDir: string; /** Frame image pattern (e.g., 'frame-%06d.png') */ framePattern?: string; /** Output file path */ outputPath: string; /** Frames per second */ fps: number; /** Output codec: 'h264' for MP4, 'vp8'/'vp9' for WebM */ codec: 'h264' | 'vp8' | 'vp9'; /** Width (for scaling, optional) */ width?: number; /** Height (for scaling, optional) */ height?: number; /** Quality: CRF value (lower = better, 18-28 typical) */ crf?: number; /** Audio tracks to mix */ audioTracks?: AudioTrackInput[]; /** Total frames (used to force exact video length) */ durationInFrames?: number; /** Progress callback */ onProgress?: (percent: number) => void; } /** * Encode frame images to video using FFmpeg. */ export async function encodeVideo(options: EncodeOptions): Promise { const { framesDir, framePattern = 'frame-%06d.png', outputPath, fps, codec, crf = 23, audioTracks = [], durationInFrames, onProgress, } = options; const ffmpegPath = findFFmpegPath(); if (!ffmpegPath) { throw new Error( 'FFmpeg not found. Install it via: brew install ffmpeg (macOS) or set FFMPEG_PATH env var.' ); } const inputPattern = path.join(framesDir, framePattern); // Build ffmpeg args const args: string[] = [ '-y', // Overwrite output '-framerate', String(fps), // Input framerate '-i', inputPattern, // Input 0: Image pattern ]; // Add audio inputs audioTracks.forEach(track => { args.push('-i', track.url); }); // Codec-specific settings if (codec === 'h264') { args.push( '-c:v', 'libx264', '-crf', String(crf), '-preset', 'medium', '-pix_fmt', 'yuv420p', // Compatibility with most players '-movflags', '+faststart', // Web-optimized MP4 ); } else if (codec === 'vp8') { args.push( '-c:v', 'libvpx', '-crf', String(crf), '-b:v', '0', // Constant quality mode '-pix_fmt', 'yuv420p', ); } else if (codec === 'vp9') { args.push( '-c:v', 'libvpx-vp9', '-crf', String(crf), '-b:v', '0', '-pix_fmt', 'yuv420p', ); } // Audio codec args.push('-c:a', 'aac', '-b:a', '192k'); // Build Audio Filter Complex if (audioTracks.length > 0) { const filterParts: string[] = []; const mixInputs: string[] = []; audioTracks.forEach((track, index) => { const inputIndex = index + 1; // 0 is video const delayMs = Math.round((track.startFrame / fps) * 1000); const outLabel = `[a${inputIndex}]`; // adelay applies delay to all channels (L|R) filterParts.push(`[${inputIndex}:a]adelay=${delayMs}|${delayMs},volume=${track.volume}${outLabel}`); mixInputs.push(outLabel); }); // Mix all processed audio streams const mixFilter = `${mixInputs.join('')}amix=inputs=${audioTracks.length}:duration=longest:dropout_transition=2[aout]`; filterParts.push(mixFilter); args.push('-filter_complex', filterParts.join(';')); args.push('-map', '0:v'); // Map video from input 0 args.push('-map', '[aout]'); // Map audio from complex filter } // Force video length if (durationInFrames) { args.push('-frames:v', String(Math.round(durationInFrames))); } args.push(outputPath); // Count total frames for progress const frameFiles = fs.readdirSync(framesDir) .filter(f => f.startsWith('frame-')) .length; return new Promise((resolve, reject) => { const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; proc.stderr?.on('data', (data: Buffer) => { const chunk = data.toString(); stderr += chunk; // Parse progress from ffmpeg stderr // FFmpeg outputs: frame= 123 fps= 30 ... const frameMatch = chunk.match(/frame=\s*(\d+)/); if (frameMatch && onProgress && frameFiles > 0) { const rendered = parseInt(frameMatch[1], 10); onProgress(Math.min(100, Math.round((rendered / frameFiles) * 100))); } }); proc.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`)); } }); proc.on('error', (err) => { reject(new Error(`FFmpeg spawn error: ${err.message}`)); }); }); } /** * Clean up frame images after encoding. */ export function cleanupFrames(framesDir: string): void { if (fs.existsSync(framesDir)) { fs.rmSync(framesDir, { recursive: true, force: true }); } } /** * Find FFmpeg executable on the system. */ function findFFmpegPath(): string | null { // 1. Environment variable if (process.env.FFMPEG_PATH) { return process.env.FFMPEG_PATH; } // 2. Check PATH const { execSync } = require('child_process'); try { const result = execSync('which ffmpeg', { encoding: 'utf-8' }).trim(); if (result) return result; } catch { // Not on PATH } // 3. Common paths const candidates = [ '/usr/local/bin/ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/bin/ffmpeg', ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return null; }