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:
Generated
+565
-1504
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -15,7 +15,6 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist out server.js",
|
"clean": "rm -rf dist out server.js",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"remotion:bundle": "echo 'Remotion bundle step removed — using Bradly Engine'",
|
|
||||||
"package": "npm run build && electron-forge package",
|
"package": "npm run build && electron-forge package",
|
||||||
"make": "npm run build && electron-forge make"
|
"make": "npm run build && electron-forge make"
|
||||||
},
|
},
|
||||||
@@ -23,8 +22,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@remotion/bundler": "^4.0.468",
|
"puppeteer-core": "^24.9.0",
|
||||||
"@remotion/renderer": "^4.0.468",
|
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+5
-1
@@ -1,10 +1,14 @@
|
|||||||
import {StrictMode} from 'react';
|
import {StrictMode} from 'react';
|
||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
import { RenderPage } from './pages/RenderPage.tsx';
|
||||||
import './index.css';
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
{isRenderMode ? <RenderPage /> : <App />}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Render Queue — Server-side job queue for Remotion rendering.
|
* Render Queue — Server-side job queue for Bradly rendering.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - In-memory job queue with concurrent rendering limit
|
* - In-memory job queue with concurrent rendering limit
|
||||||
* - SSE (Server-Sent Events) for real-time progress
|
* - 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
|
* - Job lifecycle: queued → rendering → done / error
|
||||||
|
*
|
||||||
|
* Uses Puppeteer + FFmpeg instead of @remotion/renderer.
|
||||||
*/
|
*/
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { renderFrames, renderStill } from '../engine/renderer/puppeteerRenderer';
|
||||||
|
import { encodeVideo, cleanupFrames } from '../engine/renderer/videoEncoder';
|
||||||
|
|
||||||
// ═══ Types ═══
|
// ═══ Types ═══
|
||||||
|
|
||||||
@@ -52,9 +56,12 @@ export interface RenderJobCreateParams {
|
|||||||
|
|
||||||
// ═══ Constants ═══
|
// ═══ 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');
|
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
|
// Ensure renders directory exists
|
||||||
if (!fs.existsSync(RENDERS_DIR)) {
|
if (!fs.existsSync(RENDERS_DIR)) {
|
||||||
fs.mkdirSync(RENDERS_DIR, { recursive: true });
|
fs.mkdirSync(RENDERS_DIR, { recursive: true });
|
||||||
@@ -65,8 +72,6 @@ if (!fs.existsSync(RENDERS_DIR)) {
|
|||||||
const jobs = new Map<string, RenderJob>();
|
const jobs = new Map<string, RenderJob>();
|
||||||
const sseClients = new Map<string, Set<(data: string) => void>>();
|
const sseClients = new Map<string, Set<(data: string) => void>>();
|
||||||
let activeRenders = 0;
|
let activeRenders = 0;
|
||||||
let bundlePath: string | null = null;
|
|
||||||
let isBundling = false;
|
|
||||||
|
|
||||||
// ═══ SSE Helpers ═══
|
// ═══ 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 ═══
|
// ═══ Job Management ═══
|
||||||
|
|
||||||
export function createJob(params: RenderJobCreateParams): RenderJob {
|
export function createJob(params: RenderJobCreateParams): RenderJob {
|
||||||
@@ -229,67 +186,71 @@ async function processQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderJob(job: RenderJob): Promise<void> {
|
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 isStill = job.format === 'png' || job.format === 'jpeg';
|
||||||
const ext = job.format;
|
const ext = job.format;
|
||||||
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`);
|
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})`);
|
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) {
|
if (isStill) {
|
||||||
const { renderStill } = await import('@remotion/renderer');
|
// ── Still image render ──
|
||||||
|
|
||||||
await renderStill({
|
await renderStill({
|
||||||
serveUrl,
|
serveUrl,
|
||||||
composition: config,
|
|
||||||
output: outputPath,
|
|
||||||
imageFormat: job.format as 'png' | 'jpeg',
|
|
||||||
inputProps: job.inputProps,
|
inputProps: job.inputProps,
|
||||||
binariesDirectory,
|
width: job.width,
|
||||||
|
height: job.height,
|
||||||
|
outputPath,
|
||||||
|
imageFormat: job.format as 'png' | 'jpeg',
|
||||||
});
|
});
|
||||||
|
|
||||||
job.progress = 100;
|
job.progress = 100;
|
||||||
job.renderedFrames = 1;
|
job.renderedFrames = 1;
|
||||||
broadcastJobUpdate(job);
|
broadcastJobUpdate(job);
|
||||||
} else {
|
} 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,
|
serveUrl,
|
||||||
composition: config,
|
|
||||||
codec: job.format === 'webm' ? 'vp8' : 'h264',
|
|
||||||
outputLocation: outputPath,
|
|
||||||
inputProps: job.inputProps,
|
inputProps: job.inputProps,
|
||||||
binariesDirectory,
|
width: job.width,
|
||||||
onProgress: ({ renderedFrames, encodedFrames }) => {
|
height: job.height,
|
||||||
const progress = Math.round(
|
fps: job.fps,
|
||||||
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100
|
durationInFrames: job.durationInFrames,
|
||||||
);
|
framesDir,
|
||||||
job.progress = Math.min(progress, 99);
|
imageFormat: 'png',
|
||||||
job.renderedFrames = renderedFrames ?? encodedFrames ?? 0;
|
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);
|
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';
|
job.status = 'done';
|
||||||
@@ -297,13 +258,13 @@ async function renderJob(job: RenderJob): Promise<void> {
|
|||||||
job.completedAt = Date.now();
|
job.completedAt = Date.now();
|
||||||
job.outputPath = outputPath;
|
job.outputPath = outputPath;
|
||||||
job.downloadUrl = `/api/renders/${job.id}.${ext}`;
|
job.downloadUrl = `/api/renders/${job.id}.${ext}`;
|
||||||
|
|
||||||
// Capture file size
|
// Capture file size
|
||||||
try {
|
try {
|
||||||
const stats = fs.statSync(outputPath);
|
const stats = fs.statSync(outputPath);
|
||||||
job.fileSizeBytes = stats.size;
|
job.fileSizeBytes = stats.size;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
broadcastJobUpdate(job);
|
broadcastJobUpdate(job);
|
||||||
|
|
||||||
const elapsed = ((job.completedAt - (job.startedAt ?? job.createdAt)) / 1000).toFixed(1);
|
const elapsed = ((job.completedAt - (job.startedAt ?? job.createdAt)) / 1000).toFixed(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user