mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
258 lines
9.1 KiB
TypeScript
258 lines
9.1 KiB
TypeScript
import type { Hono } from "hono";
|
|
import { streamSSE } from "hono/streaming";
|
|
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { StudioApiAdapter, RenderJobState } from "../types.js";
|
|
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "../../core.types.js";
|
|
|
|
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
|
|
|
|
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 === "complete" || job.status === "failed") && 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 {
|
|
fps?: number;
|
|
quality?: string;
|
|
format?: string;
|
|
resolution?: string;
|
|
};
|
|
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";
|
|
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
|
|
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;
|
|
|
|
const now = new Date();
|
|
const datePart = now.toISOString().slice(0, 10);
|
|
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
|
const jobId = `${project.id}_${datePart}_${timePart}`;
|
|
const rendersDir = adapter.rendersDir(project);
|
|
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
|
|
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,
|
|
});
|
|
(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,
|
|
}),
|
|
});
|
|
if (current.status === "complete" || current.status === "failed") break;
|
|
await stream.sleep(500);
|
|
}
|
|
});
|
|
});
|
|
|
|
const RENDER_MIME: Record<string, string> = {
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".mov": "video/quicktime",
|
|
};
|
|
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
|
|
|
|
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)
|
|
api.get("/render/:jobId/view", (c) => {
|
|
const { jobId } = c.req.param();
|
|
const job = renderJobs.get(jobId);
|
|
if (!job?.outputPath || !existsSync(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": `inline; filename="${filename}"`,
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(content.length),
|
|
},
|
|
});
|
|
});
|
|
|
|
// Download render
|
|
api.get("/render/:jobId/download", (c) => {
|
|
const { jobId } = c.req.param();
|
|
const job = renderJobs.get(jobId);
|
|
if (!job?.outputPath || !existsSync(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": `attachment; filename="${filename}"`,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Delete render
|
|
api.delete("/render/:jobId", (c) => {
|
|
const { jobId } = c.req.param();
|
|
for (const [, state] of renderJobs) {
|
|
if (state.id === jobId && state.outputPath) {
|
|
const dir = state.outputPath.replace(/\/[^/]+$/, "");
|
|
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
|
|
const fp = join(dir, `${jobId}${ext}`);
|
|
if (existsSync(fp)) unlinkSync(fp);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
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);
|
|
const fp = join(rendersDir, filename);
|
|
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": `inline; filename="${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"))
|
|
.map((f) => {
|
|
const fp = join(rendersDir, f);
|
|
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;
|
|
if (existsSync(metaPath)) {
|
|
try {
|
|
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
if (meta.status === "failed") status = "failed";
|
|
if (meta.durationMs) durationMs = meta.durationMs;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return {
|
|
id: rid,
|
|
filename: f,
|
|
size: stat.size,
|
|
createdAt: stat.mtimeMs,
|
|
status,
|
|
durationMs,
|
|
};
|
|
})
|
|
.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: join(rendersDir, file.filename),
|
|
createdAt: file.createdAt,
|
|
} as RenderJobState & { createdAt: number });
|
|
}
|
|
}
|
|
return c.json({ renders: files });
|
|
});
|
|
}
|