mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(core): extract shared studio API module (#113)
## Summary Extracts all studio API routes into a shared Hono-based module at `@hyperframes/core/studio-api`. ### Architecture - **`StudioApiAdapter` interface** — consumers inject host-specific behavior (project resolution, bundling, rendering, thumbnails) - **Shared route modules**: projects, files, preview, lint, render, thumbnail - **Shared helpers**: `isSafePath`, `walkDir`, `getMimeType`, `buildSubCompositionHtml` ### What this PR does - Creates the shared module with all API routes extracted from both `vite.config.ts` and `studioServer.ts` - Both consumers will be refactored in follow-up commits to mount this module with their own adapter ### What stays in each consumer - **Vite**: SSR module loading, Puppeteer thumbnails, file watcher + HMR, producer HTTP proxy, multi-project scanning - **CLI**: in-process `executeRenderJob`, local runtime serving, browser management, SPA static file serving ### Follow-up needed - [ ] Refactor `packages/studio/vite.config.ts` to use `createStudioApi(adapter)` via `@hono/node-server`'s `getRequestListener` - [ ] Refactor `packages/cli/src/server/studioServer.ts` to use `createStudioApi(adapter)` - [ ] Add `./studio-api` export path to `packages/core/package.json` - [ ] Add `hono` as peer dependency of `@hyperframes/core` ## Test plan - [ ] Verify shared module compiles without type errors - [ ] After consumer refactoring: all studio features work identically via both vite dev and CLI embedded servers 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "./types.js";
|
||||
import { registerProjectRoutes } from "./routes/projects.js";
|
||||
import { registerFileRoutes } from "./routes/files.js";
|
||||
import { registerPreviewRoutes } from "./routes/preview.js";
|
||||
import { registerLintRoutes } from "./routes/lint.js";
|
||||
import { registerRenderRoutes } from "./routes/render.js";
|
||||
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
|
||||
|
||||
/**
|
||||
* Create a Hono sub-app with all studio API routes.
|
||||
*
|
||||
* Both the vite dev server and CLI embedded server mount this app
|
||||
* under /api, each providing their own adapter for host-specific behavior.
|
||||
*/
|
||||
export function createStudioApi(adapter: StudioApiAdapter): Hono {
|
||||
const api = new Hono();
|
||||
|
||||
registerProjectRoutes(api, adapter);
|
||||
registerFileRoutes(api, adapter);
|
||||
registerPreviewRoutes(api, adapter);
|
||||
registerLintRoutes(api, adapter);
|
||||
registerRenderRoutes(api, adapter);
|
||||
registerThumbnailRoutes(api, adapter);
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
};
|
||||
|
||||
export function getMimeType(path: string): string {
|
||||
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolve, sep, join } from "node:path";
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
/** Reject paths that escape the project directory. */
|
||||
export function isSafePath(base: string, resolved: string): boolean {
|
||||
const norm = resolve(base) + sep;
|
||||
return resolved.startsWith(norm) || resolved === resolve(base);
|
||||
}
|
||||
|
||||
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
|
||||
|
||||
/** Recursively walk a directory and return relative file paths. */
|
||||
export function walkDir(dir: string, prefix = ""): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (IGNORE_DIRS.has(entry.name)) continue;
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkDir(join(dir, entry.name), rel));
|
||||
} else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Build a standalone HTML page for a sub-composition.
|
||||
*
|
||||
* Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
|
||||
* Lottie, reset styles, runtime) are preserved — instead of building a minimal
|
||||
* page from scratch that would miss important scripts/styles.
|
||||
*/
|
||||
export function buildSubCompositionHtml(
|
||||
projectDir: string,
|
||||
compPath: string,
|
||||
runtimeUrl: string,
|
||||
baseHref?: string,
|
||||
): string | null {
|
||||
const compFile = join(projectDir, compPath);
|
||||
if (!existsSync(compFile)) return null;
|
||||
|
||||
const rawComp = readFileSync(compFile, "utf-8");
|
||||
|
||||
// Extract content from <template> wrapper (compositions are always templates)
|
||||
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
const content = templateMatch?.[1] ?? rawComp;
|
||||
|
||||
// Use the project's index.html <head> to preserve all dependencies
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
let headContent = "";
|
||||
|
||||
if (existsSync(indexPath)) {
|
||||
const indexHtml = readFileSync(indexPath, "utf-8");
|
||||
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
||||
headContent = headMatch?.[1] ?? "";
|
||||
}
|
||||
|
||||
// Inject <base> for relative asset resolution (before other tags)
|
||||
if (baseHref && !headContent.includes("<base")) {
|
||||
headContent = `<base href="${baseHref}">\n${headContent}`;
|
||||
}
|
||||
|
||||
// Ensure runtime is present (might differ from the one in index.html)
|
||||
if (
|
||||
!headContent.includes("hyperframe.runtime") &&
|
||||
!headContent.includes("hyperframes-preview-runtime")
|
||||
) {
|
||||
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
|
||||
}
|
||||
|
||||
// Fallback: if no index.html head was found, add minimal deps
|
||||
if (!headContent.includes("gsap")) {
|
||||
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<script>window.__timelines=window.__timelines||{};</script>
|
||||
${content}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createStudioApi } from "./createStudioApi.js";
|
||||
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
|
||||
export { isSafePath, walkDir } from "./helpers/safePath.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Hono } from "hono";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Read file content
|
||||
api.get("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file) || !existsSync(file)) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const content = readFileSync(file, "utf-8");
|
||||
return c.json({ filename: filePath, content });
|
||||
});
|
||||
|
||||
// Write file content
|
||||
api.put("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const dir = dirname(file);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const body = await c.req.text();
|
||||
writeFileSync(file, body, "utf-8");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { walkDir } from "../helpers/safePath.js";
|
||||
|
||||
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/projects/:id/lint", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
try {
|
||||
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
|
||||
const allFindings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}> = [];
|
||||
for (const file of htmlFiles) {
|
||||
const content = readFileSync(join(project.dir, file), "utf-8");
|
||||
const result = await adapter.lint(content, { filePath: file });
|
||||
if (result?.findings) {
|
||||
for (const f of result.findings) {
|
||||
allFindings.push({ ...f, file });
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.json({ findings: allFindings });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Lint failed: ${msg}` }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Hono } from "hono";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { getMimeType } from "../helpers/mime.js";
|
||||
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Bundled composition preview
|
||||
api.get("/projects/:id/preview", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
try {
|
||||
let bundled = await adapter.bundle(project.dir);
|
||||
if (!bundled) {
|
||||
const indexPath = resolve(project.dir, "index.html");
|
||||
if (!existsSync(indexPath)) return c.text("not found", 404);
|
||||
bundled = readFileSync(indexPath, "utf-8");
|
||||
}
|
||||
|
||||
// Inject runtime if not already present (check URL pattern and bundler attribute)
|
||||
if (
|
||||
!bundled.includes("hyperframe.runtime") &&
|
||||
!bundled.includes("hyperframes-preview-runtime")
|
||||
) {
|
||||
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
|
||||
bundled = bundled.includes("</body>")
|
||||
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
|
||||
: bundled + `\n${runtimeTag}`;
|
||||
}
|
||||
|
||||
// Inject <base> for relative asset resolution
|
||||
const baseHref = `/api/projects/${project.id}/preview/`;
|
||||
if (!bundled.includes("<base")) {
|
||||
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
||||
}
|
||||
|
||||
return c.html(bundled);
|
||||
} catch {
|
||||
const file = resolve(project.dir, "index.html");
|
||||
if (existsSync(file)) return c.html(readFileSync(file, "utf-8"));
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
});
|
||||
|
||||
// Sub-composition preview
|
||||
api.get("/projects/:id/preview/comp/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const compPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const compFile = resolve(project.dir, compPath);
|
||||
if (
|
||||
!isSafePath(project.dir, compFile) ||
|
||||
!existsSync(compFile) ||
|
||||
!statSync(compFile).isFile()
|
||||
) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const baseHref = `/api/projects/${project.id}/preview/`;
|
||||
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
|
||||
if (!html) return c.text("not found", 404);
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// Static asset serving
|
||||
api.get("/projects/:id/preview/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const subPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const file = resolve(project.dir, subPath);
|
||||
if (!isSafePath(project.dir, file) || !existsSync(file) || !statSync(file).isFile()) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const contentType = getMimeType(subPath);
|
||||
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
|
||||
const content = readFileSync(file, isText ? "utf-8" : undefined);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { walkDir } from "../helpers/safePath.js";
|
||||
|
||||
export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// List all projects
|
||||
api.get("/projects", async (c) => {
|
||||
const projects = await adapter.listProjects();
|
||||
return c.json({ projects });
|
||||
});
|
||||
|
||||
// Resolve session to project (multi-project mode)
|
||||
api.get("/resolve-session/:sessionId", async (c) => {
|
||||
if (!adapter.resolveSession) {
|
||||
return c.json({ error: "not available" }, 404);
|
||||
}
|
||||
const { sessionId } = c.req.param();
|
||||
const result = await adapter.resolveSession(sessionId);
|
||||
if (!result) return c.json({ error: "Session not found" }, 404);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Project file tree
|
||||
api.get("/projects/:id", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const files = walkDir(project.dir);
|
||||
return c.json({ id: project.id, files });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
!process.argv.includes("build")
|
||||
) {
|
||||
cleanupTimer = setInterval(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Self-cleanup when no jobs remain
|
||||
if (renderJobs.size === 0 && cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
// Prevent the timer from keeping the process alive
|
||||
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
const format = body.format === "webm" ? "webm" : "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 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 === "webm" ? ".webm" : ".mp4";
|
||||
const outputPath = join(rendersDir, `${jobId}${ext}`);
|
||||
|
||||
const jobState = adapter.startRender({
|
||||
project,
|
||||
outputPath,
|
||||
format: format as "mp4" | "webm",
|
||||
fps,
|
||||
quality,
|
||||
jobId,
|
||||
});
|
||||
renderJobs.set(jobId, { ...jobState, createdAt: Date.now() });
|
||||
|
||||
// Restart cleanup timer if needed
|
||||
if (!cleanupTimer && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
cleanupTimer = setInterval(() => {
|
||||
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;
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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 isWebm = job.outputPath.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
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", ".meta.json"]) {
|
||||
const fp = join(dir, `${jobId}${ext}`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
renderJobs.delete(jobId);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// 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"))
|
||||
.map((f) => {
|
||||
const fp = join(rendersDir, f);
|
||||
const stat = statSync(fp);
|
||||
const rid = f.replace(/\.(mp4|webm)$/, "");
|
||||
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);
|
||||
return c.json({ renders: files });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Hono } from "hono";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
|
||||
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/projects/:id/thumbnail/*", async (c) => {
|
||||
if (!adapter.generateThumbnail) {
|
||||
return c.json({ error: "Thumbnails not available" }, 501);
|
||||
}
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
let compPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
if (compPath && !compPath.includes(".")) compPath += ".html";
|
||||
|
||||
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
|
||||
const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
|
||||
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
|
||||
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
|
||||
|
||||
// Determine composition dimensions from HTML
|
||||
let compW = vpWidth || 1920;
|
||||
let compH = vpHeight || 1080;
|
||||
if (!vpWidth) {
|
||||
const htmlFile = join(project.dir, compPath);
|
||||
if (existsSync(htmlFile)) {
|
||||
const html = readFileSync(htmlFile, "utf-8");
|
||||
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
||||
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
||||
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
|
||||
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const previewUrl =
|
||||
compPath === "index.html"
|
||||
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
|
||||
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
|
||||
|
||||
// Cache
|
||||
const cacheDir = join(project.dir, ".thumbnails");
|
||||
const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`;
|
||||
const cachePath = join(cacheDir, cacheKey);
|
||||
if (existsSync(cachePath)) {
|
||||
return new Response(new Uint8Array(readFileSync(cachePath)), {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await adapter.generateThumbnail({
|
||||
project,
|
||||
compPath,
|
||||
seekTime,
|
||||
width: compW,
|
||||
height: compH,
|
||||
previewUrl,
|
||||
});
|
||||
if (!buffer) {
|
||||
return c.json({ error: "Thumbnail generation returned null" }, 500);
|
||||
}
|
||||
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(cachePath, buffer);
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Resolved info about a single project. */
|
||||
export interface ResolvedProject {
|
||||
id: string;
|
||||
dir: string;
|
||||
title?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/** Observable render job state, polled by the SSE progress handler. */
|
||||
export interface RenderJobState {
|
||||
id: string;
|
||||
status: "rendering" | "complete" | "failed";
|
||||
progress: number;
|
||||
stage?: string;
|
||||
outputPath: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Lint result from the core linter. */
|
||||
export interface LintResult {
|
||||
findings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter interface — injected by each consumer to handle host-specific behavior.
|
||||
* The shared API module calls these methods; each host (vite dev, CLI embedded)
|
||||
* provides its own implementation.
|
||||
*/
|
||||
export interface StudioApiAdapter {
|
||||
/** List all available projects. */
|
||||
listProjects(): Promise<ResolvedProject[]> | ResolvedProject[];
|
||||
|
||||
/** Resolve a project ID (or session ID) to its directory. Returns null if not found. */
|
||||
resolveProject(id: string): Promise<ResolvedProject | null> | ResolvedProject | null;
|
||||
|
||||
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
|
||||
bundle(projectDir: string): Promise<string | null>;
|
||||
|
||||
/** Lint a single HTML string. */
|
||||
lint(html: string, opts?: { filePath?: string }): Promise<LintResult> | LintResult;
|
||||
|
||||
/** URL to the hyperframe runtime JS (injected into preview HTML). */
|
||||
runtimeUrl: string;
|
||||
|
||||
/** Directory where render output files are stored. */
|
||||
rendersDir(project: ResolvedProject): string;
|
||||
|
||||
/**
|
||||
* Start a render job. The adapter owns the async execution and must
|
||||
* update the returned RenderJobState object reactively.
|
||||
*/
|
||||
startRender(opts: {
|
||||
project: ResolvedProject;
|
||||
outputPath: string;
|
||||
format: "mp4" | "webm";
|
||||
fps: number;
|
||||
quality: string;
|
||||
jobId: string;
|
||||
}): RenderJobState;
|
||||
|
||||
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
|
||||
generateThumbnail?: (opts: {
|
||||
project: ResolvedProject;
|
||||
compPath: string;
|
||||
seekTime: number;
|
||||
width: number;
|
||||
height: number;
|
||||
previewUrl: string;
|
||||
}) => Promise<Buffer | null>;
|
||||
|
||||
/** Optional: resolve session ID to project (multi-project mode). */
|
||||
resolveSession?: (sessionId: string) => Promise<{ projectId: string; title: string } | null>;
|
||||
}
|
||||
Reference in New Issue
Block a user