Files
brandly/src/engine/renderer/videoEncoder.ts
T

219 lines
5.6 KiB
TypeScript

/**
* 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<void> {
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<void>((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;
}