/** * Render Module * * Handles Remotion video rendering with optimized settings. * After render completes, uploads to Firebase Storage. */ import { bundle } from '@remotion/bundler'; import { renderMedia, selectComposition, getCompositions } from '@remotion/renderer'; import path from 'path'; import fs from 'fs'; import { uploadToFirebase } from './firebase-admin'; const REMOTION_ENTRY = path.resolve(process.cwd(), 'remotion/index.tsx'); const VIDEOS_DIR = path.join(process.cwd(), 'videos'); const JOBS_DIR = path.join(process.cwd(), 'jobs'); const COMPOSITION_ID = 'VEditor'; // Configuration const CONFIG = { // Set concurrency to 1 to ensure stability in container environments // os.cpus() can report host cores which are not accessible concurrency: 1, verbose: false, timeoutMs: 300000, // 5 minutes }; interface RenderOptions { fps?: number; width?: number; height?: number; format?: 'mp4' | 'webm'; } // Helper to update job status file function updateJobStatus(jobId: string, status: object): void { const statusFile = path.join(JOBS_DIR, `${jobId}.status.json`); fs.writeFileSync(statusFile, JSON.stringify(status)); } export async function renderVideo( jobId: string, design: unknown, options: RenderOptions ): Promise { console.log(`[Render] Job ${jobId}: Starting`); console.log(`[Render] Concurrency: ${CONFIG.concurrency}`); // Update status to bundling updateJobStatus(jobId, { status: 'bundling', progress: 0 }); // Ensure output directory exists if (!fs.existsSync(VIDEOS_DIR)) { fs.mkdirSync(VIDEOS_DIR, { recursive: true }); } // Bundle console.log(`[Render] Bundling from: ${REMOTION_ENTRY}`); const bundlePath = await bundle({ entryPoint: REMOTION_ENTRY, }); console.log(`[Render] Bundle created: ${bundlePath}`); // Select composition const inputProps = { design }; const compositions = await getCompositions(bundlePath, { inputProps }); console.log(`[Render] Compositions: ${compositions.map(c => c.id).join(', ')}`); const composition = await selectComposition({ serveUrl: bundlePath, id: COMPOSITION_ID, inputProps, }); console.log(`[Render] Selected: ${composition.id} - ${composition.durationInFrames} frames`); // Update status to rendering updateJobStatus(jobId, { status: 'rendering', progress: 5 }); // Render const format = options.format || 'mp4'; const outputPath = path.join(VIDEOS_DIR, `${jobId}.${format}`); let lastReportedProgress = 0; await renderMedia({ composition, serveUrl: bundlePath, codec: format === 'webm' ? 'vp8' : 'h264', outputLocation: outputPath, inputProps, verbose: CONFIG.verbose, concurrency: CONFIG.concurrency, timeoutInMilliseconds: CONFIG.timeoutMs, chromiumOptions: { disableWebSecurity: true, gl: 'angle', }, onProgress: ({ progress, renderedFrames }) => { const pct = Math.round(progress * 100); // Only update if progress changed by at least 5% if (pct >= lastReportedProgress + 5 || pct === 100) { lastReportedProgress = pct; console.log(`[Render] Progress: ${pct}% (${renderedFrames}/${composition.durationInFrames})`); // Update status file with current progress (max 90% for render, 10% for upload) updateJobStatus(jobId, { status: 'rendering', progress: Math.min(pct * 0.9, 90), renderedFrames, totalFrames: composition.durationInFrames }); } }, }); console.log(`[Render] Render completed: ${outputPath}`); // Upload to Firebase Storage updateJobStatus(jobId, { status: 'uploading', progress: 92 }); console.log(`[Render] Uploading to Firebase...`); const firebaseUrl = await uploadToFirebase(outputPath, `exports/${jobId}.${format}`); console.log(`[Render] Upload completed: ${firebaseUrl}`); // Clean up local file to save space try { fs.unlinkSync(outputPath); console.log(`[Render] Cleaned up local file: ${outputPath}`); } catch (e) { console.warn(`[Render] Failed to clean up local file: ${outputPath}`); } return firebaseUrl; }