mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
205 lines
7.4 KiB
JavaScript
205 lines
7.4 KiB
JavaScript
// untar -> bundle (cached per source sha256) -> getCompositions ->
|
|||
|
|
// renderMedia. Bundling is the expensive webpack step; the orchestrator
|
||
|
|
// calls POST /render twice per video (once per orientation) with the
|
||
|
|
// *same* tarball, so caching the bundle by content hash means the second
|
||
|
|
// call skips straight to getCompositions/renderMedia instead of
|
||
|
|
// re-bundling identical source (a documented Remotion anti-pattern).
|
||
|
|
//
|
||
|
|
// Only the bundle (the webpack-compiled serveUrl + its extracted source
|
||
|
|
// dir) is cached, across requests, until evicted. The per-request render
|
||
|
|
// OUTPUT (the rendered mp4) is never cached — every render call produces
|
||
|
|
// its own temp file that the caller cleans up once it has streamed the
|
||
|
|
// response.
|
||
|
|
import { bundle } from "@remotion/bundler";
|
||
|
|
import { getCompositions, renderMedia } from "@remotion/renderer";
|
||
|
|
import { createHash } from "node:crypto";
|
||
|
|
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||
|
|
import { tmpdir } from "node:os";
|
||
|
|
import path from "node:path";
|
||
|
|
import { Readable } from "node:stream";
|
||
|
|
import * as tar from "tar";
|
||
|
|
|
||
|
|
const MAX_CACHED_BUNDLES = 8;
|
||
|
|
|
||
|
|
/** Root for both the extracted-source AND webpack-bundle-output temp dirs,
|
||
|
|
* so both halves of one cache entry are easy to spot as a pair on disk. */
|
||
|
|
const BUNDLE_TMP_ROOT = tmpdir();
|
||
|
|
|
||
|
|
/** sha256(tar bytes) -> Promise<{serveUrl, sourceDir, bundleDir}>,
|
||
|
|
* oldest-first (Map preserves insertion order; re-inserting a key moves it
|
||
|
|
* to the end). */
|
||
|
|
const bundleCache = new Map();
|
||
|
|
|
||
|
|
function markRecentlyUsed(sha, promise) {
|
||
|
|
bundleCache.delete(sha);
|
||
|
|
bundleCache.set(sha, promise);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function evictExcess() {
|
||
|
|
while (bundleCache.size > MAX_CACHED_BUNDLES) {
|
||
|
|
const oldestSha = bundleCache.keys().next().value;
|
||
|
|
const promise = bundleCache.get(oldestSha);
|
||
|
|
bundleCache.delete(oldestSha);
|
||
|
|
try {
|
||
|
|
const { sourceDir, bundleDir } = await promise;
|
||
|
|
await Promise.all([
|
||
|
|
rm(sourceDir, { recursive: true, force: true }),
|
||
|
|
rm(bundleDir, { recursive: true, force: true }),
|
||
|
|
]);
|
||
|
|
} catch {
|
||
|
|
// Best-effort: a failed bundle was never cached with real dirs, and
|
||
|
|
// a stat/rm race on an already-gone temp dir is harmless either way.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function extractTar(tarBuffer, destDir) {
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
const extractor = tar.extract({ cwd: destDir });
|
||
|
|
extractor.on("finish", resolve);
|
||
|
|
extractor.on("error", reject);
|
||
|
|
Readable.from(tarBuffer).pipe(extractor);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The extracted motion/ ships no node_modules (never committed/tarred).
|
||
|
|
* Symlink this sidecar's own pre-installed remotion/react/etc so webpack's
|
||
|
|
* module resolution finds them without a per-render `npm install` —
|
||
|
|
* motion/'s package.json is kept version-aligned with this image's
|
||
|
|
* dependencies by construction (both pin the same `remotion` major). */
|
||
|
|
async function linkSharedNodeModules(motionDir) {
|
||
|
|
const target = path.join(process.cwd(), "node_modules");
|
||
|
|
const linkPath = path.join(motionDir, "node_modules");
|
||
|
|
await symlink(target, linkPath, "dir");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {Buffer} tarBuffer
|
||
|
|
* @param {string} sha sha256(tarBuffer), already computed by the caller —
|
||
|
|
* threaded through so the bundle-output dir can be named after the same
|
||
|
|
* content hash as the source dir, making an evicted pair easy to
|
||
|
|
* correlate on disk.
|
||
|
|
*/
|
||
|
|
async function buildBundle(tarBuffer, sha) {
|
||
|
|
const workDir = await mkdtemp(path.join(BUNDLE_TMP_ROOT, "remotion-src-"));
|
||
|
|
// bundle({entryPoint}) with no `outDir` silently creates its OWN temp dir
|
||
|
|
// (a fresh `remotion-webpack-bundle-*` under the OS tmpdir, ~19MB of
|
||
|
|
// webpack output) that Remotion never cleans up and we'd otherwise never
|
||
|
|
// learn the path of. Naming it ourselves means we can track + reclaim it
|
||
|
|
// exactly like sourceDir below.
|
||
|
|
const bundleDir = path.join(BUNDLE_TMP_ROOT, `remotion-webpack-bundle-${sha}`);
|
||
|
|
try {
|
||
|
|
await extractTar(tarBuffer, workDir);
|
||
|
|
const motionDir = path.join(workDir, "motion");
|
||
|
|
await linkSharedNodeModules(motionDir);
|
||
|
|
const entryPoint = path.join(motionDir, "src", "index.ts");
|
||
|
|
const serveUrl = await bundle({ entryPoint, outDir: bundleDir });
|
||
|
|
return { serveUrl, sourceDir: workDir, bundleDir };
|
||
|
|
} catch (err) {
|
||
|
|
await Promise.all([
|
||
|
|
rm(workDir, { recursive: true, force: true }).catch(() => {}),
|
||
|
|
rm(bundleDir, { recursive: true, force: true }).catch(() => {}),
|
||
|
|
]);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getOrCreateBundle(tarBuffer) {
|
||
|
|
const sha = createHash("sha256").update(tarBuffer).digest("hex");
|
||
|
|
|
||
|
|
const existing = bundleCache.get(sha);
|
||
|
|
if (existing) {
|
||
|
|
markRecentlyUsed(sha, existing);
|
||
|
|
const { serveUrl } = await existing;
|
||
|
|
return serveUrl;
|
||
|
|
}
|
||
|
|
|
||
|
|
const bundlePromise = buildBundle(tarBuffer, sha);
|
||
|
|
bundleCache.set(sha, bundlePromise);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const { serveUrl } = await bundlePromise;
|
||
|
|
await evictExcess();
|
||
|
|
return serveUrl;
|
||
|
|
} catch (err) {
|
||
|
|
bundleCache.delete(sha);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Thrown when `composition_id` isn't one of what Root.tsx actually
|
||
|
|
* registers — server.js maps this to a 400 instead of the generic 500 a
|
||
|
|
* deep-in-renderMedia failure would otherwise produce. */
|
||
|
|
export class UnknownCompositionError extends Error {
|
||
|
|
constructor(compositionId, knownIds) {
|
||
|
|
super(
|
||
|
|
`Unknown composition_id "${compositionId}". Registered: ${
|
||
|
|
knownIds.length ? knownIds.join(", ") : "(none)"
|
||
|
|
}`,
|
||
|
|
);
|
||
|
|
this.name = "UnknownCompositionError";
|
||
|
|
this.statusCode = 400;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Render one composition/orientation cut. Returns the temp mp4 path plus a
|
||
|
|
* cleanup callback the caller MUST invoke once the response has been sent
|
||
|
|
* (streamed bytes, not returned in-memory, so a large render never holds
|
||
|
|
* the whole file in RAM twice).
|
||
|
|
*/
|
||
|
|
export async function renderComposition({
|
||
|
|
tarBuffer,
|
||
|
|
compositionId,
|
||
|
|
inputProps,
|
||
|
|
orientation,
|
||
|
|
}) {
|
||
|
|
const serveUrl = await getOrCreateBundle(tarBuffer);
|
||
|
|
|
||
|
|
// The one Remotion v4 fact that makes two aspect ratios work from one
|
||
|
|
// timeline: there is no width/height render parameter. The identical
|
||
|
|
// inputProps object (here: the caller's props merged with `orientation`)
|
||
|
|
// goes to both getCompositions and renderMedia; calculateMetadata on
|
||
|
|
// the composition itself branches on inputProps.orientation to pick the
|
||
|
|
// frame (1080x1920 vs 1080x1080 for ReleaseAnnouncement).
|
||
|
|
const mergedProps = { ...inputProps, orientation };
|
||
|
|
|
||
|
|
// getCompositions() evaluates the *actual* Root.tsx this request's tar
|
||
|
|
// bundled (never a stale/hardcoded id list) — its returned entries are
|
||
|
|
// renderMedia-ready composition objects, so finding by id here also
|
||
|
|
// replaces the old selectComposition() call rather than duplicating a
|
||
|
|
// second Root evaluation.
|
||
|
|
const compositions = await getCompositions(serveUrl, {
|
||
|
|
inputProps: mergedProps,
|
||
|
|
});
|
||
|
|
const composition = compositions.find((c) => c.id === compositionId);
|
||
|
|
if (!composition) {
|
||
|
|
throw new UnknownCompositionError(
|
||
|
|
compositionId,
|
||
|
|
compositions.map((c) => c.id),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const outDir = await mkdtemp(path.join(tmpdir(), "remotion-out-"));
|
||
|
|
const outputLocation = path.join(outDir, "render.mp4");
|
||
|
|
|
||
|
|
try {
|
||
|
|
await renderMedia({
|
||
|
|
composition,
|
||
|
|
serveUrl,
|
||
|
|
codec: "h264",
|
||
|
|
outputLocation,
|
||
|
|
inputProps: mergedProps,
|
||
|
|
chromiumOptions: { enableMultiProcessOnLinux: true },
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
await rm(outDir, { recursive: true, force: true }).catch(() => {});
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
outputLocation,
|
||
|
|
cleanup: () => rm(outDir, { recursive: true, force: true }).catch(() => {}),
|
||
|
|
};
|
||
|
|
}
|