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",
|
||||
"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",
|
||||
|
||||
@@ -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 {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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+53
-92
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user