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:
2026-06-02 05:33:17 -05:00
parent 3e3e23b6b7
commit 551bff56a2
9 changed files with 1166 additions and 1652 deletions
+169
View File
@@ -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;
}