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
+8
View File
@@ -0,0 +1,8 @@
/**
* Bradly Renderer — barrel export.
*/
export { renderFrames, renderStill } from './puppeteerRenderer';
export type { RenderFrameOptions, StillOptions } from './puppeteerRenderer';
export { encodeVideo, cleanupFrames } from './videoEncoder';
export type { EncodeOptions } from './videoEncoder';
+253
View File
@@ -0,0 +1,253 @@
/**
* PuppeteerRenderer — Captures frames from a BradlyPlayer composition
* using a headless browser (puppeteer-core).
*
* Flow:
* 1. Start a local static file server for the renderer build
* 2. Open headless Chrome → navigate to render page
* 3. Inject composition props via page.evaluate()
* 4. For each frame: seekTo(frame) → waitForRender → screenshot
* 5. Return array of frame image buffers or write to disk
*/
import path from 'path';
import fs from 'fs';
import os from 'os';
export interface RenderFrameOptions {
/** URL of the running app (e.g. http://localhost:5173) */
serveUrl: string;
/** Props to pass to the composition (RenderProps) */
inputProps: Record<string, any>;
/** Composition dimensions */
width: number;
height: number;
/** Frames per second */
fps: number;
/** Total frames to render */
durationInFrames: number;
/** Output directory for frame images */
framesDir: string;
/** Image format for frames */
imageFormat?: 'png' | 'jpeg';
/** JPEG quality (1-100) */
quality?: number;
/** Progress callback */
onProgress?: (rendered: number, total: number) => void;
}
export interface StillOptions {
serveUrl: string;
inputProps: Record<string, any>;
width: number;
height: number;
outputPath: string;
imageFormat?: 'png' | 'jpeg';
quality?: number;
/** Which frame to capture (default: 0) */
frame?: number;
}
/**
* Render all frames of a composition to disk as images.
*/
export async function renderFrames(options: RenderFrameOptions): Promise<string[]> {
const {
serveUrl,
inputProps,
width,
height,
fps,
durationInFrames,
framesDir,
imageFormat = 'png',
quality = 90,
onProgress,
} = options;
// Ensure frames directory exists
fs.mkdirSync(framesDir, { recursive: true });
// Dynamic import puppeteer-core (heavy dep, lazy load)
const puppeteer = await import('puppeteer-core');
// Find Chrome/Chromium executable
const executablePath = findChromePath();
if (!executablePath) {
throw new Error(
'Could not find Chrome/Chromium. Install Chrome or set PUPPETEER_EXECUTABLE_PATH env var.'
);
}
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: [
`--window-size=${width},${height}`,
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width, height, deviceScaleFactor: 1 });
// Navigate to the render page
const renderUrl = `${serveUrl}?renderMode=true`;
await page.goto(renderUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Inject composition props and initialize the player
await page.evaluate(
(props, w, h, f, dur) => {
// The render page exposes this global API
const api = (window as any).__BRADLY_RENDER__;
if (!api) throw new Error('Render API not found on page');
api.init({
inputProps: props,
width: w,
height: h,
fps: f,
durationInFrames: dur,
});
},
inputProps, width, height, fps, durationInFrames
);
// Wait for player to be ready
await page.waitForFunction(
() => (window as any).__BRADLY_RENDER__?.ready === true,
{ timeout: 10000 }
);
// Capture each frame
const framePaths: string[] = [];
for (let frame = 0; frame < durationInFrames; frame++) {
// Seek to frame
await page.evaluate((f) => {
(window as any).__BRADLY_RENDER__.seekTo(f);
}, frame);
// Wait for React to render the new frame
await page.evaluate(() => {
return new Promise<void>((resolve) => {
// Use requestAnimationFrame to ensure paint is complete
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
});
// Capture screenshot
const frameName = `frame-${String(frame).padStart(6, '0')}.${imageFormat}`;
const framePath = path.join(framesDir, frameName);
await page.screenshot({
path: framePath,
type: imageFormat,
quality: imageFormat === 'jpeg' ? quality : undefined,
clip: { x: 0, y: 0, width, height },
});
framePaths.push(framePath);
onProgress?.(frame + 1, durationInFrames);
}
return framePaths;
} finally {
await browser.close();
}
}
/**
* Render a single frame (still image).
*/
export async function renderStill(options: StillOptions): Promise<void> {
const {
serveUrl,
inputProps,
width,
height,
outputPath,
imageFormat = 'png',
quality = 90,
frame = 0,
} = options;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bradly-still-'));
try {
await renderFrames({
serveUrl,
inputProps,
width,
height,
fps: 30,
durationInFrames: 1,
framesDir: tmpDir,
imageFormat,
quality,
onProgress: undefined,
});
// Move the single frame to the output path
const frameName = `frame-000000.${imageFormat}`;
const framePath = path.join(tmpDir, frameName);
if (fs.existsSync(framePath)) {
fs.copyFileSync(framePath, outputPath);
} else {
throw new Error(`Frame capture failed: ${framePath} not found`);
}
} finally {
// Cleanup temp dir
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Find Chrome/Chromium executable on the system.
*/
function findChromePath(): string | null {
// 1. Environment variable
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
return process.env.PUPPETEER_EXECUTABLE_PATH;
}
// 2. Common paths by OS
const platform = process.platform;
const candidates: string[] = [];
if (platform === 'darwin') {
candidates.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
);
} else if (platform === 'linux') {
candidates.push(
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/snap/bin/chromium',
);
} else if (platform === 'win32') {
candidates.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
);
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
+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;
}