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
+565 -1504
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -15,7 +15,6 @@
"preview": "vite preview",
"clean": "rm -rf dist out server.js",
"lint": "tsc --noEmit",
"remotion:bundle": "echo 'Remotion bundle step removed — using Bradly Engine'",
"package": "npm run build && electron-forge package",
"make": "npm run build && electron-forge make"
},
@@ -23,8 +22,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^2.4.0",
"@remotion/bundler": "^4.0.468",
"@remotion/renderer": "^4.0.468",
"puppeteer-core": "^24.9.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
-50
View File
@@ -1,50 +0,0 @@
/**
* Pre-build Remotion Bundle for Packaging
*
* This script runs BEFORE electron-forge packages the app.
* It pre-bundles the Remotion project so the packaged app
* doesn't need to call bundle() at runtime (which requires
* webpack and other heavy deps).
*
* Usage: node scripts/bundle-remotion.js
* Output: out/remotion-bundle/
*/
const path = require('path');
const fs = require('fs');
async function main() {
const outDir = path.join(__dirname, '..', 'out', 'remotion-bundle');
// Clean previous bundle
if (fs.existsSync(outDir)) {
fs.rmSync(outDir, { recursive: true });
}
console.log('📦 Building Remotion bundle for packaging...');
console.log(' Entry: src/Root.tsx');
console.log(' Output:', outDir);
const { bundle } = require('@remotion/bundler');
const entryPoint = path.join(__dirname, '..', 'src', 'Root.tsx');
const bundlePath = await bundle({
entryPoint,
outDir,
webpackOverride: (config) => config,
});
console.log('✅ Remotion bundle ready:', bundlePath);
// Verify the bundle exists
const indexPath = path.join(bundlePath, 'index.html');
if (!fs.existsSync(indexPath)) {
throw new Error(`Bundle verification failed: ${indexPath} not found`);
}
console.log('✅ Bundle verified (index.html exists)');
}
main().catch((err) => {
console.error('❌ Remotion bundle failed:', err);
process.exit(1);
});
+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;
}
+5 -1
View File
@@ -1,10 +1,14 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import { RenderPage } from './pages/RenderPage.tsx';
import './index.css';
// Detect render mode (used by headless Puppeteer renderer)
const isRenderMode = new URLSearchParams(window.location.search).has('renderMode');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
{isRenderMode ? <RenderPage /> : <App />}
</StrictMode>,
);
+110
View File
@@ -0,0 +1,110 @@
/**
* RenderPage — Headless rendering page for Puppeteer capture.
*
* When the app loads with ?renderMode=true, this component mounts instead
* of the full editor UI. It renders a BradlyPlayer at exact pixel dimensions
* and exposes window.__BRADLY_RENDER__ API for external control.
*
* API:
* __BRADLY_RENDER__.init({ inputProps, width, height, fps, durationInFrames })
* __BRADLY_RENDER__.seekTo(frame)
* __BRADLY_RENDER__.ready // boolean
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { BradlyPlayer, BradlyPlayerRef } from '../engine/player';
import { BrandComposition } from '../components/BrandComposition';
interface RenderConfig {
inputProps: Record<string, any>;
width: number;
height: number;
fps: number;
durationInFrames: number;
}
export const RenderPage: React.FC = () => {
const [config, setConfig] = useState<RenderConfig | null>(null);
const playerRef = useRef<BradlyPlayerRef>(null);
const seekTo = useCallback((frame: number) => {
playerRef.current?.seekTo(frame);
}, []);
// Expose global API for Puppeteer
useEffect(() => {
const api = {
ready: false,
init: (cfg: RenderConfig) => {
setConfig(cfg);
// Mark ready after React renders
requestAnimationFrame(() => {
requestAnimationFrame(() => {
api.ready = true;
});
});
},
seekTo,
};
(window as any).__BRADLY_RENDER__ = api;
return () => {
delete (window as any).__BRADLY_RENDER__;
};
}, [seekTo]);
// Update ready state when config changes
useEffect(() => {
if (config && (window as any).__BRADLY_RENDER__) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
(window as any).__BRADLY_RENDER__.ready = true;
});
});
}
}, [config]);
if (!config) {
return (
<div style={{
width: '100vw',
height: '100vh',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontFamily: 'monospace',
fontSize: 12,
}}>
Waiting for render init...
</div>
);
}
return (
<div style={{
width: config.width,
height: config.height,
overflow: 'hidden',
background: '#000',
}}>
<BradlyPlayer
ref={playerRef}
component={BrandComposition}
inputProps={config.inputProps}
durationInFrames={config.durationInFrames}
compositionWidth={config.width}
compositionHeight={config.height}
fps={config.fps}
controls={false}
autoPlay={false}
clickToPlay={false}
style={{
width: config.width,
height: config.height,
}}
/>
</div>
);
};
+55 -94
View File
@@ -1,15 +1,19 @@
/**
* Render Queue — Server-side job queue for Remotion rendering.
* Render Queue — Server-side job queue for Bradly rendering.
*
* Features:
* - In-memory job queue with concurrent rendering limit
* - SSE (Server-Sent Events) for real-time progress
* - Support for video (MP4) and image (PNG) export
* - Support for video (MP4/WebM) and image (PNG/JPEG) export
* - Job lifecycle: queued → rendering → done / error
*
* Uses Puppeteer + FFmpeg instead of @remotion/renderer.
*/
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { renderFrames, renderStill } from '../engine/renderer/puppeteerRenderer';
import { encodeVideo, cleanupFrames } from '../engine/renderer/videoEncoder';
// ═══ Types ═══
@@ -52,9 +56,12 @@ export interface RenderJobCreateParams {
// ═══ Constants ═══
const MAX_CONCURRENT = 1; // Remotion renders are CPU-intensive
const MAX_CONCURRENT = 1; // Rendering is CPU-intensive
const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), 'renders');
// Default serve URL for the running app (dev server or built app)
const DEFAULT_SERVE_URL = process.env.BRADLY_SERVE_URL || 'http://localhost:5173';
// Ensure renders directory exists
if (!fs.existsSync(RENDERS_DIR)) {
fs.mkdirSync(RENDERS_DIR, { recursive: true });
@@ -65,8 +72,6 @@ if (!fs.existsSync(RENDERS_DIR)) {
const jobs = new Map<string, RenderJob>();
const sseClients = new Map<string, Set<(data: string) => void>>();
let activeRenders = 0;
let bundlePath: string | null = null;
let isBundling = false;
// ═══ SSE Helpers ═══
@@ -106,54 +111,6 @@ export function addSSEClient(clientId: string, send: (data: string) => void): ()
};
}
// ═══ Bundle Management ═══
async function ensureBundle(): Promise<string> {
// In packaged Electron, use pre-built bundle from app resources
if (process.env.BRADLY_REMOTION_BUNDLE) {
const prebuilt = process.env.BRADLY_REMOTION_BUNDLE;
if (fs.existsSync(prebuilt)) return prebuilt;
throw new Error(`Pre-built Remotion bundle not found at: ${prebuilt}`);
}
// Development: bundle on demand
if (bundlePath) return bundlePath;
if (isBundling) {
// Wait for existing bundle
return new Promise((resolve) => {
const check = setInterval(() => {
if (bundlePath) {
clearInterval(check);
resolve(bundlePath);
}
}, 500);
});
}
isBundling = true;
console.log('📦 Bundling Remotion project...');
try {
const { bundle } = await import('@remotion/bundler');
const entryPoint = process.env.BRADLY_REMOTION_ENTRY
|| path.join(process.cwd(), 'src', 'Root.tsx');
bundlePath = await bundle({
entryPoint,
// Use the project's Webpack config if it exists
webpackOverride: (config) => config,
});
console.log('✅ Bundle ready:', bundlePath);
return bundlePath;
} catch (err) {
console.error('❌ Bundle failed:', err);
throw err;
} finally {
isBundling = false;
}
}
// ═══ Job Management ═══
export function createJob(params: RenderJobCreateParams): RenderJob {
@@ -229,67 +186,71 @@ async function processQueue() {
}
async function renderJob(job: RenderJob): Promise<void> {
const serveUrl = await ensureBundle();
const serveUrl = process.env.BRADLY_SERVE_URL || DEFAULT_SERVE_URL;
const isStill = job.format === 'png' || job.format === 'jpeg';
const ext = job.format;
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`);
// In packaged Electron, point to unpacked compositor binaries
const binariesDirectory = process.env.BRADLY_BINARIES_DIR || undefined;
console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
// Resolve the full composition config from the bundle
const { selectComposition } = await import('@remotion/renderer');
const composition = await selectComposition({
serveUrl,
id: job.compositionId,
inputProps: job.inputProps,
});
// Override dimensions and duration from job config
const config = {
...composition,
width: job.width,
height: job.height,
fps: job.fps,
durationInFrames: isStill ? 1 : job.durationInFrames,
};
if (isStill) {
const { renderStill } = await import('@remotion/renderer');
// ── Still image render ──
await renderStill({
serveUrl,
composition: config,
output: outputPath,
imageFormat: job.format as 'png' | 'jpeg',
inputProps: job.inputProps,
binariesDirectory,
width: job.width,
height: job.height,
outputPath,
imageFormat: job.format as 'png' | 'jpeg',
});
job.progress = 100;
job.renderedFrames = 1;
broadcastJobUpdate(job);
} else {
const { renderMedia } = await import('@remotion/renderer');
// ── Video render ──
// Step 1: Capture all frames as images
const framesDir = path.join(RENDERS_DIR, `${job.id}-frames`);
await renderMedia({
await renderFrames({
serveUrl,
composition: config,
codec: job.format === 'webm' ? 'vp8' : 'h264',
outputLocation: outputPath,
inputProps: job.inputProps,
binariesDirectory,
onProgress: ({ renderedFrames, encodedFrames }) => {
const progress = Math.round(
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100
);
job.progress = Math.min(progress, 99);
job.renderedFrames = renderedFrames ?? encodedFrames ?? 0;
width: job.width,
height: job.height,
fps: job.fps,
durationInFrames: job.durationInFrames,
framesDir,
imageFormat: 'png',
onProgress: (rendered, total) => {
// Frame capture is ~70% of the work
const progress = Math.round((rendered / total) * 70);
job.progress = Math.min(progress, 69);
job.renderedFrames = rendered;
broadcastJobUpdate(job);
},
});
// Step 2: Encode frames to video with FFmpeg
job.progress = 70;
broadcastJobUpdate(job);
const codec = job.format === 'webm' ? 'vp8' as const : 'h264' as const;
await encodeVideo({
framesDir,
framePattern: 'frame-%06d.png',
outputPath,
fps: job.fps,
codec,
onProgress: (percent) => {
// Encoding is ~30% of the work
job.progress = 70 + Math.round(percent * 0.29);
broadcastJobUpdate(job);
},
});
// Step 3: Cleanup frame images
cleanupFrames(framesDir);
}
job.status = 'done';
@@ -297,13 +258,13 @@ async function renderJob(job: RenderJob): Promise<void> {
job.completedAt = Date.now();
job.outputPath = outputPath;
job.downloadUrl = `/api/renders/${job.id}.${ext}`;
// Capture file size
try {
const stats = fs.statSync(outputPath);
job.fileSizeBytes = stats.size;
} catch {}
broadcastJobUpdate(job);
const elapsed = ((job.completedAt - (job.startedAt ?? job.createdAt)) / 1000).toFixed(1);