mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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:
@@ -71,6 +71,12 @@
|
||||
"cheerio": "^1.2.0",
|
||||
"esbuild": "^0.25.12",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4.0.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"hono",
|
||||
],
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
/**
|
||||
* Embedded studio server for `hyperframes dev` outside the monorepo.
|
||||
*
|
||||
* Serves the pre-built studio SPA and implements the project API that the
|
||||
* studio expects. Ports the API logic from packages/studio/vite.config.ts.
|
||||
* Uses the shared studio API module from @hyperframes/core/studio-api,
|
||||
* providing a CLI-specific adapter for single-project, in-process rendering.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { resolve, join, sep, basename, dirname, extname } from "node:path";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
import {
|
||||
createStudioApi,
|
||||
getMimeType,
|
||||
type StudioApiAdapter,
|
||||
type ResolvedProject,
|
||||
type RenderJobState,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
|
||||
// ── Path resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
function resolveDistDir(): string {
|
||||
// __dirname is injected by tsup banner — points to dist/ in the built CLI.
|
||||
// In dev mode (tsx), it points to src/server/.
|
||||
const builtPath = resolve(__dirname, "studio");
|
||||
if (existsSync(resolve(builtPath, "index.html"))) return builtPath;
|
||||
// Fallback for dev mode: built studio is at packages/studio/dist
|
||||
const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist");
|
||||
if (existsSync(resolve(devPath, "index.html"))) return devPath;
|
||||
return builtPath; // let it fail with a clear 404
|
||||
return builtPath;
|
||||
}
|
||||
|
||||
function resolveRuntimePath(): string {
|
||||
@@ -48,134 +44,6 @@ function resolveRuntimePath(): string {
|
||||
return builtPath;
|
||||
}
|
||||
|
||||
// ── Safety ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isSafePath(base: string, resolved: string): boolean {
|
||||
const norm = resolve(base) + sep;
|
||||
return resolved.startsWith(norm) || resolved === resolve(base);
|
||||
}
|
||||
|
||||
// ── MIME types ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".m4a": "audio/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ttf": "font/ttf",
|
||||
};
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// ── File helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function walkDir(dir: string, prefix: string = ""): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
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;
|
||||
}
|
||||
|
||||
function serveStaticFile(filePath: string): Response | null {
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return null;
|
||||
const mime = getMimeType(filePath);
|
||||
const content = readFileSync(filePath);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": mime, "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sub-composition builder ─────────────────────────────────────────────────
|
||||
// Ports vite.config.ts lines 216-301
|
||||
|
||||
function buildSubCompositionHtml(
|
||||
projectDir: string,
|
||||
compPath: string,
|
||||
runtimeUrl: string,
|
||||
): string | null {
|
||||
const compFile = resolve(projectDir, compPath);
|
||||
if (!isSafePath(projectDir, compFile) || !existsSync(compFile) || !statSync(compFile).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawComp = readFileSync(compFile, "utf-8");
|
||||
|
||||
// Extract content from <template> if present
|
||||
const templateMatch = rawComp.match(/<template>([\s\S]*)<\/template>/i);
|
||||
let content = (templateMatch ? templateMatch[1] : rawComp) ?? rawComp;
|
||||
|
||||
// Inline nested data-composition-src references
|
||||
content = content.replace(
|
||||
/(<[^>]*?)(data-composition-src=["']([^"']+)["'])([^>]*>)/g,
|
||||
(_match, before, srcAttr, src, after) => {
|
||||
const nestedFile = join(projectDir, src);
|
||||
if (!existsSync(nestedFile)) return before + srcAttr + after;
|
||||
const nestedRaw = readFileSync(nestedFile, "utf-8");
|
||||
const nestedTemplate = nestedRaw.match(/<template>([\s\S]*)<\/template>/i);
|
||||
const nestedContent = (nestedTemplate ? nestedTemplate[1] : nestedRaw) ?? nestedRaw;
|
||||
const styles: string[] = [];
|
||||
const scripts: string[] = [];
|
||||
let body = nestedContent
|
||||
.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
|
||||
styles.push(css);
|
||||
return "";
|
||||
})
|
||||
.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, (_, js) => {
|
||||
scripts.push(js);
|
||||
return "";
|
||||
});
|
||||
const innerRootMatch = body.match(
|
||||
/<([a-z][a-z0-9]*)\b[^>]*data-composition-id[^>]*>([\s\S]*)<\/\1>/i,
|
||||
);
|
||||
const innerHTML = innerRootMatch ? innerRootMatch[2] : body;
|
||||
return (
|
||||
before +
|
||||
srcAttr +
|
||||
after.replace(/>$/, ">") +
|
||||
innerHTML +
|
||||
(styles.length ? `<style>${styles.join("\n")}</style>` : "") +
|
||||
(scripts.length
|
||||
? `<script>${scripts.map((s) => `(function(){try{${s}}catch(e){}})();`).join("\n")}</script>`
|
||||
: "")
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Server factory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface StudioServerOptions {
|
||||
@@ -194,9 +62,101 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
const runtimePath = resolveRuntimePath();
|
||||
const watcher = createProjectWatcher(projectDir);
|
||||
|
||||
// ── CLI adapter for the shared studio API ──────────────────────────────
|
||||
|
||||
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
|
||||
|
||||
const adapter: StudioApiAdapter = {
|
||||
listProjects: () => [project],
|
||||
|
||||
resolveProject: (id: string) => (id === projectId ? project : null),
|
||||
|
||||
async bundle(dir: string): Promise<string | null> {
|
||||
try {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
let html = await bundleToSingleHtml(dir);
|
||||
// Fix empty runtime src from bundler — point to the local runtime endpoint
|
||||
html = html.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
|
||||
);
|
||||
return html;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async lint(html: string, opts?: { filePath?: string }) {
|
||||
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
|
||||
return lintHyperframeHtml(html, opts);
|
||||
},
|
||||
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
|
||||
rendersDir: () => join(projectDir, "renders"),
|
||||
|
||||
startRender(opts): RenderJobState {
|
||||
const state: RenderJobState = {
|
||||
id: opts.jobId,
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: opts.outputPath,
|
||||
};
|
||||
|
||||
// Run render asynchronously, mutating the state object
|
||||
(async () => {
|
||||
try {
|
||||
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
|
||||
try {
|
||||
const browser = await ensureBrowser();
|
||||
if (browser.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = browser.executablePath;
|
||||
}
|
||||
} catch {
|
||||
// Continue without — acquireBrowser will try its own resolution
|
||||
}
|
||||
|
||||
const job = createRenderJob({
|
||||
fps: opts.fps as 24 | 30 | 60,
|
||||
quality: opts.quality as "draft" | "standard" | "high",
|
||||
format: opts.format,
|
||||
});
|
||||
const startTime = Date.now();
|
||||
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
||||
state.progress = j.progress;
|
||||
if (j.currentStage) state.stage = j.currentStage;
|
||||
};
|
||||
await executeRenderJob(job, opts.project.dir, opts.outputPath, onProgress);
|
||||
state.status = "complete";
|
||||
state.progress = 100;
|
||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
} catch (err) {
|
||||
state.status = "failed";
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build the Hono app ─────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ── API: runtime.js ───────────────────────────────────────────────────
|
||||
// CLI-specific routes (before shared API)
|
||||
app.get("/api/runtime.js", (c) => {
|
||||
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
|
||||
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
|
||||
@@ -205,352 +165,55 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: SSE events ───────────────────────────────────────────────────
|
||||
app.get("/api/events", (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const listener = () => {
|
||||
stream.writeSSE({ event: "file-change", data: "{}" }).catch(() => {});
|
||||
};
|
||||
watcher.addListener(listener);
|
||||
// Keep connection alive until client disconnects
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: project listing ──────────────────────────────────────────────
|
||||
app.get("/api/projects", (c) => {
|
||||
return c.json({ projects: [{ id: projectId, title: projectId }] });
|
||||
});
|
||||
|
||||
// ── API: project file tree ────────────────────────────────────────────
|
||||
app.get("/api/projects/:id", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const files = walkDir(projectDir);
|
||||
return c.json({ id: projectId, files });
|
||||
});
|
||||
|
||||
// ── API: lint ───────────────────────────────────────────────────────
|
||||
app.get("/api/projects/:id/lint", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
try {
|
||||
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
|
||||
const htmlFiles = walkDir(projectDir).filter((f: string) => f.endsWith(".html"));
|
||||
const allFindings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}> = [];
|
||||
for (const file of htmlFiles) {
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
const result = lintHyperframeHtml(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);
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: preview — bundled composition ────────────────────────────────
|
||||
app.get("/api/projects/:id/preview", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
|
||||
let bundled: string;
|
||||
try {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
bundled = await bundleToSingleHtml(projectDir);
|
||||
} catch {
|
||||
// Fallback to raw HTML
|
||||
const file = join(projectDir, "index.html");
|
||||
if (!existsSync(file)) return c.text("not found", 404);
|
||||
bundled = readFileSync(file, "utf-8");
|
||||
}
|
||||
|
||||
// Inject <base> so relative asset paths resolve through /preview/ route
|
||||
const baseTag = `<base href="/api/projects/${projectId}/preview/">`;
|
||||
if (bundled.includes("<head>")) {
|
||||
bundled = bundled.replace("<head>", `<head>${baseTag}`);
|
||||
} else {
|
||||
bundled = baseTag + bundled;
|
||||
}
|
||||
|
||||
// Fix empty runtime src if present
|
||||
bundled = bundled.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
|
||||
);
|
||||
|
||||
return c.html(bundled);
|
||||
});
|
||||
|
||||
// ── API: sub-composition preview ──────────────────────────────────────
|
||||
app.get("/api/projects/:id/preview/comp/*", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const compPath = c.req.path.replace(`/api/projects/${id}/preview/comp/`, "");
|
||||
const html = buildSubCompositionHtml(
|
||||
projectDir,
|
||||
decodeURIComponent(compPath),
|
||||
"/api/runtime.js",
|
||||
);
|
||||
if (!html) return c.text("not found", 404);
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// ── API: preview static assets ────────────────────────────────────────
|
||||
app.get("/api/projects/:id/preview/*", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const subPath = decodeURIComponent(
|
||||
c.req.path.replace(`/api/projects/${id}/preview/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const file = resolve(projectDir, subPath);
|
||||
if (!isSafePath(projectDir, file) || !existsSync(file) || !statSync(file).isFile()) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const mime = getMimeType(file);
|
||||
const content = readFileSync(file);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": mime, "Cache-Control": "no-store" },
|
||||
// Mount the shared studio API at /api.
|
||||
// Use fetch() forwarding (not .route()) so the sub-app sees paths without
|
||||
// the /api prefix — the shared module's path extraction uses c.req.path.
|
||||
const api = createStudioApi(adapter);
|
||||
app.all("/api/*", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
url.pathname = url.pathname.slice(4); // Strip "/api" prefix
|
||||
const forwardReq = new Request(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: c.req.raw.headers,
|
||||
body: c.req.raw.body,
|
||||
// @ts-expect-error -- Node needs duplex for streaming bodies
|
||||
duplex: "half",
|
||||
});
|
||||
return api.fetch(forwardReq);
|
||||
});
|
||||
|
||||
// ── API: file read ────────────────────────────────────────────────────
|
||||
app.get("/api/projects/:id/files/*", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/api/projects/${id}/files/`, ""));
|
||||
const file = resolve(projectDir, filePath);
|
||||
if (!isSafePath(projectDir, file) || !existsSync(file)) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const content = readFileSync(file, "utf-8");
|
||||
return c.json({ filename: filePath, content });
|
||||
});
|
||||
|
||||
// ── API: file write ───────────────────────────────────────────────────
|
||||
app.put("/api/projects/:id/files/*", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/api/projects/${id}/files/`, ""));
|
||||
const file = resolve(projectDir, filePath);
|
||||
if (!isSafePath(projectDir, file)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
// Ensure parent directory exists
|
||||
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 });
|
||||
});
|
||||
|
||||
// ── API: stub endpoints ───────────────────────────────────────────────
|
||||
app.get("/api/resolve-session/:id", (c) => c.json({ error: "not available" }, 404));
|
||||
|
||||
// ── API: render ─────────────────────────────────────────────────────
|
||||
// In-memory job store for active renders
|
||||
const renderJobs = new Map<
|
||||
string,
|
||||
{ status: string; progress: number; stage?: string; error?: string; outputPath?: string }
|
||||
>();
|
||||
|
||||
app.post("/api/projects/:id/render", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) 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 "draft" | "standard" | "high")
|
||||
: "standard";
|
||||
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
||||
const jobId = `${projectId}_${datePart}_${timePart}`;
|
||||
const outputDir = join(projectDir, "renders");
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
const ext = format === "webm" ? ".webm" : ".mp4";
|
||||
const outputPath = join(outputDir, `${jobId}${ext}`);
|
||||
|
||||
renderJobs.set(jobId, { status: "rendering", progress: 0, outputPath });
|
||||
|
||||
// Run render asynchronously
|
||||
(async () => {
|
||||
try {
|
||||
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
|
||||
// Ensure browser is available and pass path to producer
|
||||
try {
|
||||
const browser = await ensureBrowser();
|
||||
if (browser.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = browser.executablePath;
|
||||
}
|
||||
} catch {
|
||||
// Continue without — acquireBrowser will try its own resolution
|
||||
}
|
||||
|
||||
const job = createRenderJob({ fps, quality, format });
|
||||
const startTime = Date.now();
|
||||
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
||||
const entry = renderJobs.get(jobId);
|
||||
if (entry) {
|
||||
entry.progress = j.progress;
|
||||
if (j.currentStage) entry.stage = j.currentStage;
|
||||
}
|
||||
};
|
||||
await executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
const entry = renderJobs.get(jobId);
|
||||
if (entry) {
|
||||
entry.status = "complete";
|
||||
entry.progress = 100;
|
||||
}
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
} catch (err) {
|
||||
const entry = renderJobs.get(jobId);
|
||||
if (entry) {
|
||||
entry.status = "failed";
|
||||
entry.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
try {
|
||||
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
|
||||
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return c.json({ jobId });
|
||||
});
|
||||
|
||||
app.get("/api/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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/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() ?? `${projectId}.mp4`;
|
||||
const content = readFileSync(job.outputPath);
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: renders listing ──────────────────────────────────────────────
|
||||
app.get("/api/projects/:id/renders", (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (id !== projectId) return c.json({ error: "not found" }, 404);
|
||||
const rendersDir = join(projectDir, "renders");
|
||||
if (!existsSync(rendersDir)) return c.json({ renders: [] });
|
||||
const files = readdirSync(rendersDir)
|
||||
.filter((f: string) => f.endsWith(".mp4") || f.endsWith(".webm"))
|
||||
.map((f: string) => {
|
||||
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: { createdAt: number }, b: { createdAt: number }) => b.createdAt - a.createdAt);
|
||||
return c.json({ renders: files });
|
||||
});
|
||||
|
||||
// ── API: delete render ───────────────────────────────────────────────
|
||||
app.delete("/api/render/:jobId", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
const rendersDir = join(projectDir, "renders");
|
||||
for (const ext of [".mp4", ".webm", ".meta.json"]) {
|
||||
const fp = join(rendersDir, `${jobId}${ext}`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
}
|
||||
renderJobs.delete(jobId);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// ── Studio SPA static files ───────────────────────────────────────────
|
||||
// Studio SPA static files
|
||||
app.get("/assets/*", (c) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1)); // strip leading /
|
||||
const resp = serveStaticFile(filePath);
|
||||
return resp ?? c.text("not found", 404);
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1));
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
||||
const content = readFileSync(filePath);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/icons/*", (c) => {
|
||||
const filePath = resolve(studioDir, c.req.path.slice(1));
|
||||
const resp = serveStaticFile(filePath);
|
||||
return resp ?? c.text("not found", 404);
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
||||
const content = readFileSync(filePath);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPA fallback — serve index.html for all unmatched routes ──────────
|
||||
// SPA fallback
|
||||
app.get("*", (c) => {
|
||||
const indexPath = resolve(studioDir, "index.html");
|
||||
if (!existsSync(indexPath)) {
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
"import": "./src/compiler/index.ts",
|
||||
"types": "./src/compiler/index.ts"
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js"
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js",
|
||||
"./studio-api": {
|
||||
"import": "./src/studio-api/index.ts",
|
||||
"types": "./src/studio-api/index.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -45,7 +49,11 @@
|
||||
"import": "./dist/compiler/index.js",
|
||||
"types": "./dist/compiler/index.d.ts"
|
||||
},
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js"
|
||||
"./runtime": "./dist/hyperframe.runtime.iife.js",
|
||||
"./studio-api": {
|
||||
"import": "./dist/studio-api/index.js",
|
||||
"types": "./dist/studio-api/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
@@ -81,6 +89,14 @@
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cheerio": "^1.2.0",
|
||||
"esbuild": "^0.25.12"
|
||||
|
||||
@@ -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