219 lines
5.6 KiB
TypeScript
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;
|
|
}
|