Spaces:
Running
Running
| // 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); | |
| } | |
| } | |