Files
hyperframes/packages/studio-server/src/routes/render.ts
Miguel Ángel 6225271173 fix: preserve motion blur off through cloud render requests (#6087)
* fix: preserve motion blur off through cloud render requests

* fix: keep generated blur docs concise and format examples

* fix: freeze motion blur intent for distributed render workers
2026-10-22 23:54:54 +00:00

403 lines
16 KiB
TypeScript

import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, lstatSync, readFileSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
import {
folderGone,
isPrivateProjectFile,
mkdirWithinProject,
resolveWithinProject,
} from "../helpers/safePath.js";
import { projectDirMissing } from "../helpers/projectDirMissing.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
import { parseEncoderOptions, type EncoderOptionsBody } from "../helpers/encoderOptions.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
const RENDER_MIME: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
};
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
const RENDER_ARTIFACT_EXTENSIONS = [...RENDER_EXTENSIONS, ".meta.json"];
function resolveRenderArtifacts(directories: readonly string[], jobId: string): string[] | null {
const artifacts = [...new Set(directories)]
.filter(existsSync)
.flatMap((directory) =>
RENDER_ARTIFACT_EXTENSIONS.map((extension) =>
resolveWithinProject(directory, `${jobId}${extension}`),
),
);
return artifacts.every((path) => path !== null) ? artifacts : null;
}
function contentDispositionHeader(disposition: "inline" | "attachment", filename: string): string {
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
const encoded = encodeURIComponent(filename).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Scoped job store — not shared across createStudioApi() calls
const renderJobs = new Map<string, RenderJobState & { createdAt: number }>();
// TTL cleanup for completed jobs (5 minutes)
const TTL_MS = 300_000;
const CLEANUP_INTERVAL_MS = 60_000;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
const cleanupEnabled = () =>
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
!process.argv.includes("build");
const cleanupFinishedJobs = () => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if (job.status !== "rendering" && now - job.createdAt > TTL_MS) {
renderJobs.delete(key);
}
}
if (renderJobs.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
};
const ensureCleanupTimer = () => {
if (cleanupTimer || !cleanupEnabled()) return;
cleanupTimer = setInterval(cleanupFinishedJobs, CLEANUP_INTERVAL_MS);
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
};
ensureCleanupTimer();
// Start a render
api.post("/projects/:id/render", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as EncoderOptionsBody & {
// Polymorphic per design note in core.types.Fps:
// number → integer fps (e.g. 30)
// string → rational fps (e.g. "30000/1001" for NTSC 29.97)
// Decimals are rejected on purpose so the exact denominator stays
// unambiguous (29.97 ≠ 30000/1001 when ffmpeg consumes them).
fps?: number | string;
quality?: string;
format?: string;
resolution?: string;
composition?: string;
// Browser telemetry id, so the server-emitted render outcome is
// attributed to the user who triggered the render (joinable funnel).
telemetryDistinctId?: string;
// Explicit "this browser profile opted out" flag. Distinct from simply
// omitting the id: an OLD client omits it too, and that case falls back
// to the install anonymousId. Only an explicit `true` suppresses.
telemetryOptOut?: boolean;
// Composition-variable overrides ({variableId: value}), injected as
// window.__hfVariables — same channel as `hyperframes render --variables`.
variables?: Record<string, unknown>;
gpu?: boolean;
previewMotionBlur?: boolean;
pageMotionBlur?: unknown;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
// Default to 30 fps when unset or unparseable. The route stays lenient on
// invalid fps values (matching the lenient handling of `resolution` and
// `quality` already in this file) — the producer surfaces a clearer error
// message if the caller really did mean to fail loudly.
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
: "standard";
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as CanvasResolution)
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
// `body.composition` is attacker-controlled (from c.req.json()).
// resolveWithinProject dereferences symlinks, so an in-project symlink
// pointing outside the root can't smuggle the render target out.
if (!resolveWithinProject(project.dir, body.composition)) {
if (folderGone(project.dir)) return projectDirMissing(c);
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;
}
// Unlike fps/quality (lenient with safe fallbacks), a malformed variables
// payload means the user's values would be silently dropped — fail loudly.
let variables: Record<string, unknown> | undefined;
if (body.variables !== undefined) {
if (!isVariablesPayload(body.variables)) {
return c.json({ error: VARIABLES_PAYLOAD_ERROR }, 400);
}
variables = body.variables;
}
const encoder = parseEncoderOptions(body, format);
if (!encoder.ok) return c.json({ error: encoder.error }, 400);
const requestedPageMotionBlur = body.pageMotionBlur;
if (requestedPageMotionBlur !== undefined && requestedPageMotionBlur !== "off") {
return c.json({ error: "pageMotionBlur must be off when supplied" }, 400);
}
const pageMotionBlur =
requestedPageMotionBlur === undefined && body.previewMotionBlur === false
? "off"
: requestedPageMotionBlur;
const now = new Date();
const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
const rendersDir = adapter.rendersDir(project);
mkdirWithinProject(project.dir, rendersDir);
const ext = FORMAT_EXT[format] ?? ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);
const jobState = adapter.startRender({
project,
outputPath,
format: format as "mp4" | "webm" | "mov",
fps,
quality,
jobId,
outputResolution,
composition,
variables,
...encoder.options,
useGpu: body.gpu === true && format === "mp4",
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
telemetryOptOut: body.telemetryOptOut === true,
...(pageMotionBlur === undefined ? {} : { pageMotionBlur }),
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
ensureCleanupTimer();
return c.json({ jobId, status: "rendering" });
});
// SSE progress stream
api.get("/render/:jobId/progress", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = renderJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
progress: current.progress,
status: current.status,
stage: current.stage,
error: current.error,
...(current.status === "complete" && current.audioLoweredDb !== undefined
? { audioLoweredDb: current.audioLoweredDb }
: {}),
}),
});
if (current.status !== "rendering") break;
await stream.sleep(500);
}
});
});
// Cancel an in-flight render. Marks the job cancelled immediately (so the
// SSE stream terminates) and invokes the adapter's abort hook when present.
api.post("/render/:jobId/cancel", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
if (job.status === "rendering") {
job.status = "cancelled";
job.cancel?.();
}
return c.json({ status: job.status });
});
const isPlainFile = (path: string): boolean => {
try {
return lstatSync(path).isFile();
} catch {
return false;
}
};
function renderContentType(filePath: string): string {
const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e));
return (ext && RENDER_MIME[ext]) ?? "video/mp4";
}
// Serve render inline (for in-browser playback — opens in a new tab)
// fallow-ignore-next-line code-duplication
api.get("/render/:jobId/view", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !isPlainFile(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": contentDispositionHeader("inline", filename),
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// Download render
// fallow-ignore-next-line code-duplication
api.get("/render/:jobId/download", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !isPlainFile(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": contentDispositionHeader("attachment", filename),
},
});
});
async function renderDirectories(jobId: string): Promise<string[]> {
const outputPath = renderJobs.get(jobId)?.outputPath;
if (outputPath) return [dirname(outputPath)];
const projects = await adapter.listProjects();
return projects.map((project) => adapter.rendersDir(project));
}
// Delete render
api.delete("/render/:jobId", async (c) => {
const { jobId } = c.req.param();
if (jobId.includes("/") || jobId.includes("\\") || jobId.includes("\0")) {
return c.json({ error: "forbidden" }, 403);
}
const directories = await renderDirectories(jobId);
const artifacts = resolveRenderArtifacts(directories, jobId);
if (!artifacts) return c.json({ error: "forbidden" }, 403);
for (const path of artifacts) {
if (existsSync(path)) unlinkSync(path);
}
renderJobs.delete(jobId);
return c.json({ deleted: true });
});
// Serve render file directly from disk (no in-memory map dependency)
api.get("/projects/:id/renders/file/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filename = c.req.path.split("/renders/file/")[1];
if (!filename) return c.json({ error: "missing filename" }, 400);
const rendersDir = adapter.rendersDir(project);
// Containment guard: the filename is attacker-controlled wildcard input, so
// route it through the same chokepoint every other project-scoped path uses.
// Literal `..` is collapsed upstream by the URL parser, but a bare join() +
// readFileSync still followed an in-rendersDir symlink pointing outside the
// dir; resolveWithinProject canonicalizes with realpath before serving.
const fp = resolveWithinProject(rendersDir, filename);
if (!fp || isPrivateProjectFile(project.dir, fp)) return c.json({ error: "forbidden" }, 403);
if (!existsSync(fp)) return c.json({ error: "not found" }, 404);
const contentType = renderContentType(fp);
const content = readFileSync(fp);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": contentDispositionHeader("inline", filename),
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// List renders
api.get("/projects/:id/renders", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov"))
.flatMap((f) => {
const fp = resolveWithinProject(rendersDir, f);
return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : [];
})
.map(({ f, fp }) => {
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
let audioLoweredDb: number | undefined;
if (existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
// A stale failed sidecar can remain after a retry succeeds. An
// existing output artifact is authoritative for the list view;
// don't present a downloadable render as failed solely because
// an earlier attempt left behind failed metadata.
if (meta.status === "failed" && !existsSync(fp)) status = "failed";
if (meta.durationMs) durationMs = meta.durationMs;
if (typeof meta.audioLoweredDb === "number") audioLoweredDb = meta.audioLoweredDb;
} catch {
/* ignore */
}
}
return {
id: rid,
filename: f,
path: fp,
size: stat.size,
createdAt: stat.mtimeMs,
status,
durationMs,
...(audioLoweredDb !== undefined ? { audioLoweredDb } : {}),
};
})
.sort((a, b) => b.createdAt - a.createdAt);
// Register on-disk renders that aren't in the current session's job map
// so they remain downloadable after a server restart.
for (const file of files) {
if (!renderJobs.has(file.id)) {
renderJobs.set(file.id, {
id: file.id,
status: file.status,
progress: 100,
outputPath: file.path,
createdAt: file.createdAt,
} as RenderJobState & { createdAt: number });
}
}
return c.json({ renders: files.map(({ path: _path, ...render }) => render) });
});
}