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
+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);