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:
Miguel Ángel
2026-03-28 22:28:12 +01:00
committed by GitHub
parent fecd4c8485
commit bd175b64a6
15 changed files with 848 additions and 471 deletions
@@ -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>`;
}
+5
View File
@@ -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);
}
});
}
+78
View File
@@ -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>;
}