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(VALID_CANVAS_RESOLUTIONS); const RENDER_MIME: Record = { ".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(); // TTL cleanup for completed jobs (5 minutes) const TTL_MS = 300_000; const CLEANUP_INTERVAL_MS = 60_000; let cleanupTimer: ReturnType | 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; gpu?: boolean; previewMotionBlur?: boolean; pageMotionBlur?: unknown; }; const VALID_FORMATS = new Set(["mp4", "webm", "mov"]); const FORMAT_EXT: Record = { 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 | 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 { 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) }); }); }