// Server-side MP4 export: bundle the render entry once (cached), then renderMedia // the editor's current timeline (passed as inputProps) via headless Chrome. import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { bundle } from "@remotion/bundler"; import { selectComposition, renderMedia, ensureBrowser } from "@remotion/renderer"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); const ENTRY = path.join(ROOT, "src", "remotion", "entry.ts"); const PUBLIC = path.join(ROOT, "public"); // holds episode.mp3 + fonts/ + sfx/ (real files) // finished exports live on the PERSISTED disk (render.yaml mounts /app/public/projects), NOT the // ephemeral .cache layer — so a render survives a restart/redeploy until the user downloads it. // "_renders" is not a valid project id (validId = /^p_…/), so the project system ignores it. const OUT = path.join(PUBLIC, "projects", "_renders"); fs.mkdirSync(OUT, { recursive: true }); export const jobs = {}; // drop finished renders older than 24h so the disk doesn't fill (downloads happen right after a render) function sweepRenders(maxAgeMs = 24 * 60 * 60 * 1000) { try { const now = Date.now(); for (const f of fs.readdirSync(OUT)) { const fp = path.join(OUT, f); try { if (now - fs.statSync(fp).mtimeMs > maxAgeMs) fs.rmSync(fp, { force: true }); } catch { /* ignore */ } } } catch { /* OUT may not exist yet */ } } // resolve a job by id: the live in-memory record, else recover a finished render from disk after a // restart (the .mp4 is on the persisted disk; the sidecar carries the owner id for the auth check). export function getJob(id) { if (jobs[id]) return jobs[id]; try { const file = path.join(OUT, `${id}.mp4`); if (!fs.existsSync(file)) return null; const meta = JSON.parse(fs.readFileSync(path.join(OUT, `${id}.json`), "utf8")); return { status: "done", progress: 1, file, userId: meta.userId ?? null, width: meta.width, height: meta.height, recovered: true }; } catch { return null; } } // newest mtime across the render source tree — so we re-bundle when templates change // (the bundle was previously cached for the whole process lifetime → stale: new templates // rendered as "Unknown scene template" and old animation timing persisted in exports). const latestSrcMtime = () => { let max = 0; const walk = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.name === "node_modules" || e.name === ".cache" || e.name.startsWith(".")) continue; const fp = path.join(dir, e.name); if (e.isDirectory()) walk(fp); else { const m = fs.statSync(fp).mtimeMs; if (m > max) max = m; } } }; try { walk(path.join(ROOT, "src")); } catch { /* ignore */ } return max; }; let bundlePromise = null; let bundleMtime = 0; const getBundle = (onB) => { const m = latestSrcMtime(); if (!bundlePromise || m > bundleMtime) { bundleMtime = m; bundlePromise = bundle({ entryPoint: ENTRY, publicDir: PUBLIC, onProgress: (p) => onB?.(p) }); } return bundlePromise; }; export async function startExport(jobId, inputProps, { codec = "h264", scale = 1 } = {}) { sweepRenders(); const job = (jobs[jobId] = { status: "starting", progress: 0, file: null, error: null }); try { job.status = "preparing browser"; await ensureBrowser(); job.status = "bundling"; const serveUrl = await getBundle((p) => (job.bundleProgress = p)); job.status = "preparing"; // gl:"angle" enables WebGL in headless Chromium so 3D (@remotion/three) scenes render instead of going black; harmless for 2D scenes. const chromiumOptions = { gl: "angle" }; const composition = await selectComposition({ serveUrl, id: "EditorComposition", inputProps, chromiumOptions }); job.status = "rendering"; const outPath = path.join(OUT, `${jobId}.mp4`); // scale multiplies the 1920×1080 composition → scale 2 = 3840×2160 (4K UHD), crisp since it's vector/DOM job.width = composition.width * scale; job.height = composition.height * scale; await renderMedia({ serveUrl, composition, codec, scale, outputLocation: outPath, inputProps, chromiumOptions, // COLOUR FIDELITY (two coupled bugs, both fixed here): // 1. colorSpace:"default" tags HD output as BT.601 (bt470bg). HD players (VLC / browsers) force // BT.709 regardless of the tag, so decoding 601-encoded HD as 709 gives a REDDISH hue shift. // → the output MUST be tagged BT.709. // 2. colorSpace:"bt709" tags correctly BUT Remotion's ffmpeg filter is // `zscale=matrix=709:matrixin=709:range=limited` — the `matrixin=709` mislabels the sRGB RGB // frames as if they were already BT.709 *YUV*, so the conversion DESATURATES saturated colours // (pure green 255→215, WhatsApp green 37,211,102→20,186,98, orange/red shift ~8-24). // FIX: colorSpace:"bt709" for the correct HD tag (no reddening), and drop the bogus `matrixin=709` // via ffmpegOverride so zscale treats the input as RGB and converts faithfully. Verified the export // then matches the editor to ±2-3 per channel across primaries, brand greens, orange and neutrals. colorSpace: "bt709", ffmpegOverride: ({ args }) => args.map((a) => (typeof a === "string" && a.startsWith("zscale=") && a.includes("matrixin=709")) ? a.replace(/:?matrixin=709/, "") : a), onProgress: ({ progress }) => { job.progress = progress; }, }); job.status = "done"; job.progress = 1; job.file = outPath; // persist a tiny ownership record so the download survives an in-memory-jobs wipe (restart/redeploy) try { fs.writeFileSync(path.join(OUT, `${jobId}.json`), JSON.stringify({ userId: job.userId ?? null, width: job.width, height: job.height, at: Date.now() })); } catch { /* best-effort */ } } catch (e) { job.status = "error"; job.error = String(e?.stack || e?.message || e).slice(0, 600); } }