eldouma-graphics / server /render.mjs
Moeeldouma's picture
Sync export fidelity fixes + more AI providers
cf18fff verified
Raw
History Blame Contribute Delete
6.12 kB
// 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);
}
}