2026-07-07 10:09:23 +02:00
|
|
|
// video-renderer — HTTP sidecar for the RoboCo video engine.
|
2026-07-05 13:37:17 +02:00
|
|
|
//
|
|
|
|
|
// POST /render accepts a gzipped tar of a motion/ composition source (arcname
|
|
|
|
|
// "motion") plus {composition_id, orientation, input_props} form fields, and
|
|
|
|
|
// responds with the rendered MP4 as the raw response body. GET /health is a
|
|
|
|
|
// plain liveness probe. Credential-free and git-free: this process never
|
|
|
|
|
// holds a git token, never shells out to git, and only ever reads what the
|
|
|
|
|
// orchestrator POSTs to it.
|
|
|
|
|
import express from "express";
|
|
|
|
|
import multer from "multer";
|
|
|
|
|
import rateLimit from "express-rate-limit";
|
|
|
|
|
import { createReadStream } from "node:fs";
|
2026-07-16 19:49:26 +02:00
|
|
|
import { fileURLToPath } from "node:url";
|
2026-07-08 03:26:12 +02:00
|
|
|
import {
|
|
|
|
|
renderComposition,
|
2026-07-16 19:49:26 +02:00
|
|
|
renderFrames,
|
2026-07-08 03:26:12 +02:00
|
|
|
ExtractedSizeExceededError,
|
2026-07-16 19:49:26 +02:00
|
|
|
MAX_PREVIEW_FRAMES,
|
2026-07-08 03:26:12 +02:00
|
|
|
RenderTimeoutError,
|
|
|
|
|
UnknownCompositionError,
|
|
|
|
|
} from "./render.js";
|
2026-07-05 13:37:17 +02:00
|
|
|
|
|
|
|
|
const PORT = Number(process.env.PORT ?? 3001);
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// composition_id flows into a path.join inside render.js
|
|
|
|
|
// (extractDir/motion/compositions/<composition_id>); restrict it to a single
|
|
|
|
|
// safe path segment so a ".." / "/" / absolute-path value can't escape the
|
|
|
|
|
// composition dir (path traversal). The orchestrator only ever sends a real
|
|
|
|
|
// composition id, but this is the trust boundary — validate here.
|
2026-07-16 17:15:12 +02:00
|
|
|
// Letters/digits/_/- plus interior single dots (e.g. release-0.25.0).
|
|
|
|
|
// No leading dot and no adjacent dots, so '.'/'..' path segments and
|
|
|
|
|
// hidden-file names remain unrepresentable — this stays the trust boundary.
|
|
|
|
|
const COMPOSITION_ID_RE = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
|
2026-07-07 10:09:23 +02:00
|
|
|
|
2026-07-05 13:37:17 +02:00
|
|
|
// motion/ source (no node_modules, no build output) is a few hundred KB in
|
|
|
|
|
// practice; this cap is generous headroom, not a tuned budget.
|
|
|
|
|
const MAX_UPLOAD_BYTES = 256 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
const upload = multer({
|
|
|
|
|
storage: multer.memoryStorage(),
|
|
|
|
|
limits: { fileSize: MAX_UPLOAD_BYTES },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
|
|
|
|
// This sidecar is container-network-only (no published ports) with a single
|
|
|
|
|
// trusted caller (the orchestrator), which already renders cuts serially.
|
|
|
|
|
// The limiter is not the primary control — it's a cheap ceiling against a
|
|
|
|
|
// runaway retry storm or a misbehaving caller tying up Chrome headless +
|
|
|
|
|
// the render temp dir. 30/min is well above any legitimate render rate
|
|
|
|
|
// (each render takes seconds, ~a few calls/min) so it never blocks real use.
|
|
|
|
|
const renderLimiter = rateLimit({
|
|
|
|
|
windowMs: 60_000,
|
|
|
|
|
max: 30,
|
|
|
|
|
standardHeaders: true,
|
|
|
|
|
legacyHeaders: false,
|
|
|
|
|
message: { error: "rate limit: too many render requests" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get("/health", (_req, res) => {
|
|
|
|
|
res.status(200).json({ status: "ok" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 19:49:26 +02:00
|
|
|
/**
|
|
|
|
|
* Validate the optional 'frames' form field. Absent/empty keeps the
|
|
|
|
|
* existing MP4 behavior (`count: null`); present must be an integer in
|
|
|
|
|
* 1..MAX_PREVIEW_FRAMES or `error` names the bound for the 400 response.
|
|
|
|
|
* Pure + exported so the branch is unit-testable without a live server.
|
|
|
|
|
*/
|
|
|
|
|
export function parseFramesField(raw) {
|
|
|
|
|
if (raw === undefined || raw === null || raw === "") return { count: null };
|
|
|
|
|
const count = Number(raw);
|
|
|
|
|
if (!Number.isInteger(count) || count < 1 || count > MAX_PREVIEW_FRAMES) {
|
|
|
|
|
return {
|
|
|
|
|
error: `'frames' must be an integer between 1 and ${MAX_PREVIEW_FRAMES}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return { count };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stream `filePath` as the response body and run `cleanup` once the
|
|
|
|
|
* response is done — shared by both the MP4 and the frames-tar branches of
|
|
|
|
|
* /render. See the inline comment below on why cleanup fires from both the
|
|
|
|
|
* source stream's own "close" and the response's "close" (client abort).
|
|
|
|
|
*/
|
|
|
|
|
function streamFileWithCleanup(res, filePath, { contentType, headers, cleanup }) {
|
|
|
|
|
res.status(200);
|
|
|
|
|
res.setHeader("Content-Type", contentType);
|
|
|
|
|
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
|
|
|
res.setHeader(name, value);
|
|
|
|
|
}
|
|
|
|
|
const stream = createReadStream(filePath);
|
|
|
|
|
stream.on("error", (err) => {
|
|
|
|
|
console.error("video-renderer: stream error", err);
|
|
|
|
|
if (!res.headersSent) {
|
|
|
|
|
res.status(500);
|
|
|
|
|
}
|
|
|
|
|
res.end();
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
stream.on("close", () => {
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
// stream.pipe() never propagates a DESTINATION close back to the
|
|
|
|
|
// source: if the client aborts, or the orchestrator's retry-on-timeout
|
|
|
|
|
// hangs up mid-download, `res` closes but the source stream's own
|
|
|
|
|
// "close" above never fires — leaking this request's render-output
|
|
|
|
|
// temp dir on every such disconnect. Destroying the still-open source
|
|
|
|
|
// releases its fd immediately; cleanup() is idempotent (rm force:true)
|
|
|
|
|
// so also landing here on a normal end-of-stream close is harmless.
|
|
|
|
|
res.on("close", () => {
|
|
|
|
|
stream.destroy();
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
stream.pipe(res);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 13:37:17 +02:00
|
|
|
app.post("/render", renderLimiter, upload.single("source"), async (req, res) => {
|
|
|
|
|
const body = req.body ?? {};
|
|
|
|
|
const compositionId = body.composition_id;
|
|
|
|
|
const orientation = body.orientation;
|
|
|
|
|
const inputPropsRaw = body.input_props;
|
|
|
|
|
|
|
|
|
|
if (!req.file) {
|
|
|
|
|
res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: "missing 'source' file field (gzipped tar of motion/)" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-07 10:09:23 +02:00
|
|
|
if (
|
|
|
|
|
typeof compositionId !== "string" ||
|
|
|
|
|
!compositionId.trim() ||
|
|
|
|
|
!COMPOSITION_ID_RE.test(compositionId)
|
|
|
|
|
) {
|
|
|
|
|
res.status(400).json({
|
|
|
|
|
error:
|
2026-07-16 17:15:12 +02:00
|
|
|
"'composition_id' must be letters, digits, '_' or '-', with optional interior dots",
|
2026-07-07 10:09:23 +02:00
|
|
|
});
|
2026-07-05 13:37:17 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (orientation !== "vertical" && orientation !== "square") {
|
|
|
|
|
res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: "'orientation' must be 'vertical' or 'square'" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let inputProps;
|
|
|
|
|
try {
|
|
|
|
|
inputProps = inputPropsRaw ? JSON.parse(inputPropsRaw) : {};
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(400).json({ error: "'input_props' is not valid JSON" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 19:49:26 +02:00
|
|
|
const framesField = parseFramesField(body.frames);
|
|
|
|
|
if (framesField.error) {
|
|
|
|
|
res.status(400).json({ error: framesField.error });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 13:37:17 +02:00
|
|
|
try {
|
2026-07-16 19:49:26 +02:00
|
|
|
if (framesField.count !== null) {
|
|
|
|
|
const { tarPath, duration, cleanup } = await renderFrames({
|
|
|
|
|
tarBuffer: req.file.buffer,
|
|
|
|
|
compositionId,
|
|
|
|
|
inputProps,
|
|
|
|
|
orientation,
|
|
|
|
|
frameCount: framesField.count,
|
|
|
|
|
});
|
|
|
|
|
streamFileWithCleanup(res, tarPath, {
|
|
|
|
|
contentType: "application/gzip",
|
|
|
|
|
headers: { "X-Video-Duration": String(duration) },
|
|
|
|
|
cleanup,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 13:37:17 +02:00
|
|
|
const { outputLocation, cleanup } = await renderComposition({
|
|
|
|
|
tarBuffer: req.file.buffer,
|
|
|
|
|
compositionId,
|
|
|
|
|
inputProps,
|
|
|
|
|
orientation,
|
|
|
|
|
});
|
2026-07-16 19:49:26 +02:00
|
|
|
streamFileWithCleanup(res, outputLocation, {
|
|
|
|
|
contentType: "video/mp4",
|
|
|
|
|
cleanup,
|
2026-07-05 13:37:17 +02:00
|
|
|
});
|
|
|
|
|
} catch (err) {
|
2026-07-08 03:26:12 +02:00
|
|
|
if (
|
|
|
|
|
err instanceof UnknownCompositionError ||
|
|
|
|
|
err instanceof ExtractedSizeExceededError
|
|
|
|
|
) {
|
2026-07-05 13:37:17 +02:00
|
|
|
res.status(err.statusCode).json({ error: err.message });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-08 03:26:12 +02:00
|
|
|
if (err instanceof RenderTimeoutError) {
|
|
|
|
|
console.error("video-renderer: render timed out", err.message);
|
|
|
|
|
res.status(500).json({ error: err.message });
|
|
|
|
|
// ponytail: Promise.race in render.js abandons the wait but can't
|
|
|
|
|
// cancel a wedged headless-Chrome render tree — hard-exiting this
|
|
|
|
|
// process is the only reliable kill. Docker's restart policy brings
|
|
|
|
|
// up a clean container; wait for the response to flush first so the
|
|
|
|
|
// caller still gets the 500 instead of a dropped connection.
|
|
|
|
|
res.on("finish", () => process.exit(1));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-05 13:37:17 +02:00
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
2026-07-07 10:09:23 +02:00
|
|
|
console.error("video-renderer: render failed", message);
|
2026-07-05 13:37:17 +02:00
|
|
|
res.status(500).json({ error: `render failed: ${message}` });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 4-arg signature required for Express to treat this as error-handling
|
|
|
|
|
// middleware. Catches errors `next()`-ed from earlier middleware — in
|
|
|
|
|
// practice, today, that's multer rejecting an oversized/malformed upload
|
|
|
|
|
// before our route body ever runs (otherwise Express's default handler
|
|
|
|
|
// returns a generic 500 HTML page instead of a clear JSON 4xx).
|
|
|
|
|
app.use((err, req, res, _next) => {
|
|
|
|
|
if (err instanceof multer.MulterError) {
|
|
|
|
|
const status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400;
|
|
|
|
|
res.status(status).json({ error: `upload rejected: ${err.message}` });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
2026-07-07 10:09:23 +02:00
|
|
|
console.error("video-renderer: unhandled error", message);
|
2026-07-05 13:37:17 +02:00
|
|
|
res.status(500).json({ error: "internal error" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 19:49:26 +02:00
|
|
|
// Guarded so a test can `import` this module (for parseFramesField) without
|
|
|
|
|
// also binding the real port — only `node server.js` triggers the listen.
|
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
|
|
app.listen(PORT, () => {
|
|
|
|
|
console.log(`video-renderer listening on :${PORT}`);
|
|
|
|
|
});
|
|
|
|
|
}
|