feat: Phase 4 — replace @remotion/bundler + @remotion/renderer with Puppeteer + FFmpeg
- Created src/engine/renderer/puppeteerRenderer.ts (frame capture via headless Chrome) - Created src/engine/renderer/videoEncoder.ts (FFmpeg CLI wrapper for MP4/WebM) - Created src/pages/RenderPage.tsx (headless render page with __BRADLY_RENDER__ API) - Rewrote src/server/renderQueue.ts — zero Remotion imports - Deleted scripts/bundle-remotion.js - Replaced @remotion/bundler + @remotion/renderer with puppeteer-core - Added renderMode detection in main.tsx entry point Zero Remotion dependencies remain. Fully independent.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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 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;
|
||||
/** 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,
|
||||
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 pattern
|
||||
];
|
||||
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user