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,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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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