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:
+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:
|
||||
* - 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);
|
||||
|
||||
Reference in New Issue
Block a user