diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index f6ab17ef..f936e7da 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -1,27 +1,22 @@ -import { defineConfig, type Plugin } from "vite"; +import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import react from "@vitejs/plugin-react"; import { readFileSync, readdirSync, existsSync, - statSync, writeFileSync, lstatSync, realpathSync, - createReadStream, } from "node:fs"; -import { join, resolve, sep } from "node:path"; +import { join, resolve } from "node:path"; +import type { + StudioApiAdapter, + ResolvedProject, + RenderJobState, +} from "@hyperframes/core/studio-api"; -/** Reject paths that escape the project directory. */ -function isSafePath(base: string, resolved: string): boolean { - const norm = resolve(base) + sep; - return resolved.startsWith(norm) || resolved === resolve(base); -} - -// Lazy-load the bundler via Vite's SSR module loader (resolves .ts imports correctly) -let _bundler: ((dir: string) => Promise) | null = null; +// ── Shared Puppeteer browser ───────────────────────────────────────────────── -// Shared Puppeteer browser instance — lazy-init, reused across thumbnail requests let _browser: import("puppeteer-core").Browser | null = null; let _browserLaunchPromise: Promise | null = null; @@ -47,749 +42,371 @@ async function getSharedBrowser(): Promise>(); -// Render job store with TTL cleanup (fixes globalThis memory leak) -const renderJobs = new Map< - string, - { id: string; status: string; progress: number; outputPath: string } ->(); -// Only run cleanup interval in dev mode — setInterval keeps the process -// alive and prevents `vite build` from exiting, causing CI timeouts. -if (process.env.NODE_ENV !== "production" && !process.argv.includes("build")) { - setInterval(() => { - const now = Date.now(); - for (const [key, job] of renderJobs) { - if ( - (job.status === "complete" || job.status === "failed") && - now - parseInt(key.split("-").pop() || "0") > 300_000 - ) { - renderJobs.delete(key); +// ── Vite adapter for the shared studio API ─────────────────────────────────── + +function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter { + const PRODUCER_URL = (process.env.PRODUCER_SERVER_URL || "http://127.0.0.1:9847").replace( + /\/+$/, + "", + ); + + // Lazy-load the bundler via Vite's SSR module loader + let _bundler: ((dir: string) => Promise) | null = null; + const getBundler = async () => { + if (!_bundler) { + try { + const mod = await server.ssrLoadModule("@hyperframes/core/compiler"); + _bundler = (dir: string) => mod.bundleToSingleHtml(dir); + } catch (err) { + console.warn("[Studio] Failed to load compiler, previews will use raw HTML:", err); + _bundler = null as never; } } - }, 60_000); -} - -/** Minimal project API for standalone dev mode */ -function devProjectApi(): Plugin { - const dataDir = resolve(__dirname, "data/projects"); + return _bundler; + }; return { - name: "studio-dev-api", - configureServer(server): void { - // Load the bundler via Vite's SSR module loader (resolves .ts imports) - const getBundler = async () => { - if (!_bundler) { + listProjects() { + const sessionsDir = resolve(dataDir, "../sessions"); + const sessionMap = new Map(); + if (existsSync(sessionsDir)) { + for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith(".json"))) { try { - const mod = await server.ssrLoadModule("@hyperframes/core/compiler"); - _bundler = (dir: string) => mod.bundleToSingleHtml(dir); - } catch (err) { - console.warn("[Studio] Failed to load compiler, previews will use raw HTML:", err); - _bundler = null as never; + const raw = JSON.parse(readFileSync(join(sessionsDir, file), "utf-8")); + if (raw.projectId) { + sessionMap.set(raw.projectId, { + sessionId: file.replace(".json", ""), + title: raw.title || "Untitled", + }); + } + } catch { + /* skip corrupt */ } } - return _bundler; - }; - - server.middlewares.use(async (req, res, next) => { - if (!req.url?.startsWith("/api/")) return next(); + } + return readdirSync(dataDir, { withFileTypes: true }) + .filter( + (d) => + (d.isDirectory() || d.isSymbolicLink()) && + existsSync(join(dataDir, d.name, "index.html")), + ) + .map((d) => { + const session = sessionMap.get(d.name); + return { + id: d.name, + dir: join(dataDir, d.name), + title: session?.title ?? d.name, + sessionId: session?.sessionId, + } satisfies ResolvedProject; + }) + .sort((a, b) => (a.title ?? "").localeCompare(b.title ?? "")); + }, - // ── Render endpoints ────────────────────────────────────────── - const PRODUCER_URL = (process.env.PRODUCER_SERVER_URL || "http://127.0.0.1:9847").replace( - /\/+$/, - "", - ); - - // POST /api/projects/:id/render — start a render job via producer - const renderMatch = - req.method === "POST" && req.url.match(/\/api\/projects\/([^/]+)\/render/); - if (renderMatch) { - const pid = renderMatch[1]; - const pDir = join(dataDir, pid); - if (!existsSync(pDir)) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Project not found" })); - return; - } - // Parse request body for format option - let bodyStr = ""; - for await (const chunk of req) bodyStr += chunk; - let reqBody: { format?: string; fps?: number; quality?: string } = {}; + resolveProject(id: string) { + let projectDir = join(dataDir, id); + if (!existsSync(projectDir)) { + // Try resolving as session ID + const sessionsDir = resolve(dataDir, "../sessions"); + const sessionFile = join(sessionsDir, `${id}.json`); + if (existsSync(sessionFile)) { try { - reqBody = bodyStr ? JSON.parse(bodyStr) : {}; + const session = JSON.parse(readFileSync(sessionFile, "utf-8")); + if (session.projectId) { + projectDir = join(dataDir, session.projectId); + if (existsSync(projectDir)) { + return { id: session.projectId, dir: projectDir, title: session.title }; + } + } } catch { /* ignore */ } - const format = reqBody.format === "webm" ? "webm" : "mp4"; - const fps = [24, 30, 60].includes(reqBody.fps ?? 0) ? reqBody.fps! : 30; - const quality = ["draft", "standard", "high"].includes(reqBody.quality ?? "") - ? reqBody.quality! - : "standard"; - - // Human-readable render name: project-name_2026-03-27_14-30-45 - const now = new Date(); - const datePart = now.toISOString().slice(0, 10); - const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-"); - const jobId = `${pid}_${datePart}_${timePart}`; - const outputDir = resolve(dataDir, "../renders"); - if (!existsSync(outputDir)) { - const { mkdirSync: mk } = await import("fs"); - mk(outputDir, { recursive: true }); - } - const outputPath = join(outputDir, `${jobId}.mp4`); - // Store job state — referenced by the SSE progress endpoint and the fetch callback below - const startTime = Date.now(); - const _jobState = { id: jobId, status: "rendering", progress: 0, outputPath }; - renderJobs.set(jobId, _jobState); - - // Start render in background - fetch(`${PRODUCER_URL}/render/stream`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - projectDir: pDir, - outputPath, - fps, - quality, - format, - }), - }) - .then(async (resp) => { - if (!resp.ok || !resp.body) { - _jobState.status = "failed"; - return; - } - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split("\n\n"); - buffer = blocks.pop() || ""; - for (const block of blocks) { - const data = block - .split("\n") - .filter((l) => l.startsWith("data:")) - .map((l) => l.slice(5).trim()) - .join(""); - if (!data) continue; - try { - const evt = JSON.parse(data); - if (evt.type === "progress") { - _jobState.progress = evt.progress; - if (evt.stage || evt.message) { - (_jobState as Record).stage = evt.stage || evt.message; - } - } - if (evt.type === "complete") { - _jobState.status = "complete"; - _jobState.outputPath = evt.outputPath || outputPath; - const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); - writeFileSync( - metaPath, - JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }), - ); - } - if (evt.type === "error") { - _jobState.status = "failed"; - const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); - writeFileSync(metaPath, JSON.stringify({ status: "failed" })); - } - } catch {} - } - } - if (_jobState.status === "rendering") { - _jobState.status = "complete"; - const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); - writeFileSync( - metaPath, - JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }), - ); - } - }) - .catch(() => { - _jobState.status = "failed"; - const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); - try { - writeFileSync(metaPath, JSON.stringify({ status: "failed" })); - } catch { - /* ignore */ - } - }); - - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ jobId, status: "rendering" })); - return; } + return null; + } + return { id, dir: projectDir }; + }, - // GET /api/render/:jobId/progress — SSE progress stream - if ( - req.method === "GET" && - req.url.startsWith("/api/render/") && - req.url.endsWith("/progress") - ) { - const jobId = req.url.replace("/api/render/", "").replace("/progress", ""); - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - const interval = setInterval(() => { - const state = renderJobs.get(jobId) as { status: string; progress: number } | undefined; - if (!state) { - clearInterval(interval); - res.end(); - return; - } - res.write( - `event: progress\ndata: ${JSON.stringify({ status: state.status, progress: state.progress, stage: (state as Record).stage })}\n\n`, - ); - if (state.status === "complete" || state.status === "failed") { - clearInterval(interval); - setTimeout(() => res.end(), 100); - } - }, 500); - req.on("close", () => clearInterval(interval)); - return; - } + async bundle(dir: string) { + const bundler = await getBundler(); + if (!bundler) return null; + let html = await bundler(dir); + // Fix empty runtime src from bundler — point to the CDN runtime + html = html.replace( + 'data-hyperframes-preview-runtime="1" src=""', + `data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`, + ); + return html; + }, - // GET /api/render/:jobId/download — serve the rendered MP4 - if ( - req.method === "GET" && - req.url.startsWith("/api/render/") && - req.url.endsWith("/download") - ) { - const jobId = req.url.replace("/api/render/", "").replace("/download", ""); - const jobState = renderJobs.get(jobId) as - | { outputPath?: string; status: string } - | undefined; - if ( - !jobState || - jobState.status !== "complete" || - !jobState.outputPath || - !existsSync(jobState.outputPath) - ) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Render not ready or not found" })); - return; - } - const fileStat = statSync(jobState.outputPath); - const isWebm = jobState.outputPath.endsWith(".webm"); - const contentType = isWebm ? "video/webm" : "video/mp4"; - const ext = isWebm ? ".webm" : ".mp4"; - res.writeHead(200, { - "Content-Type": contentType, - "Content-Length": String(fileStat.size), - "Content-Disposition": `attachment; filename="${jobId}${ext}"`, - }); - const stream = createReadStream(jobState.outputPath); - stream.pipe(res); - return; - } + async lint(html: string, opts?: { filePath?: string }) { + const mod = await server.ssrLoadModule("@hyperframes/core/lint"); + return mod.lintHyperframeHtml(html, opts); + }, - // GET /api/projects/:id/renders — list render outputs for a project - const rendersMatch = - req.method === "GET" && req.url?.match(/^\/api\/projects\/([^/]+)\/renders/); - if (rendersMatch) { - const rendersDir = resolve(dataDir, "../renders"); - if (!existsSync(rendersDir)) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ renders: [] })); - return; - } - 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 id = f.replace(/\.(mp4|webm)$/, ""); - // Read meta.json for status (failed renders persist as small files) - const metaPath = join(rendersDir, `${id}.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 corrupt meta */ - } - } - return { - id, - filename: f, - size: stat.size, - createdAt: stat.mtimeMs, - status, - durationMs, - }; - }) - .sort( - (a: { createdAt: number }, b: { createdAt: number }) => b.createdAt - a.createdAt, - ); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ renders: files })); - return; - } + runtimeUrl: "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js", - // DELETE /api/render/:jobId — delete a render output - const deleteRenderMatch = - req.method === "DELETE" && req.url?.match(/^\/api\/render\/([^/]+)$/); - if (deleteRenderMatch) { - const jobId = deleteRenderMatch[1]; - const rendersDir = resolve(dataDir, "../renders"); - for (const ext of [".mp4", ".webm", ".meta.json"]) { - const fp = join(rendersDir, `${jobId}${ext}`); - if (existsSync(fp)) unlinkSync(fp); - } - renderJobs.delete(jobId); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ deleted: true })); - return; - } + rendersDir: () => resolve(dataDir, "../renders"), + + startRender(opts): RenderJobState { + const state: RenderJobState = { + id: opts.jobId, + status: "rendering", + progress: 0, + outputPath: opts.outputPath, + }; - // GET /api/projects — list all projects with session metadata - if (req.method === "GET" && (req.url === "/api/projects" || req.url === "/api/projects/")) { - // Build session → project mapping for titles - const sessionsDir = resolve(dataDir, "../sessions"); - const sessionMap = new Map(); - if (existsSync(sessionsDir)) { - for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith(".json"))) { + // Proxy to producer server + const startTime = Date.now(); + fetch(`${PRODUCER_URL}/render/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectDir: opts.project.dir, + outputPath: opts.outputPath, + fps: opts.fps, + quality: opts.quality, + format: opts.format, + }), + }) + .then(async (resp) => { + if (!resp.ok || !resp.body) { + state.status = "failed"; + return; + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() || ""; + for (const block of blocks) { + const data = block + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()) + .join(""); + if (!data) continue; try { - const raw = JSON.parse(readFileSync(join(sessionsDir, file), "utf-8")); - if (raw.projectId) { - sessionMap.set(raw.projectId, { - sessionId: file.replace(".json", ""), - title: raw.title || "Untitled", - }); + const evt = JSON.parse(data); + if (evt.type === "progress") { + state.progress = evt.progress; + if (evt.stage || evt.message) state.stage = evt.stage || evt.message; + } + if (evt.type === "complete") { + state.status = "complete"; + state.outputPath = evt.outputPath || opts.outputPath; + const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); + writeFileSync( + metaPath, + JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }), + ); + } + if (evt.type === "error") { + state.status = "failed"; + const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); + writeFileSync(metaPath, JSON.stringify({ status: "failed" })); } } catch { - /* skip corrupt */ + /* ignore parse errors */ } } } - - const projects = readdirSync(dataDir, { withFileTypes: true }) - .filter( - (d) => - (d.isDirectory() || d.isSymbolicLink()) && - existsSync(join(dataDir, d.name, "index.html")), - ) - .map((d) => { - const session = sessionMap.get(d.name); - return { id: d.name, title: session?.title ?? d.name, sessionId: session?.sessionId }; - }) - .sort((a, b) => a.title.localeCompare(b.title)); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ projects })); - return; - } - - // GET /api/resolve-session/:sessionId — resolve session ID to project ID - const sessionMatch = req.url.match(/^\/api\/resolve-session\/([^/]+)/); - if (req.method === "GET" && sessionMatch) { - const sessionsDir = resolve(dataDir, "../sessions"); - const sessionFile = join(sessionsDir, `${sessionMatch[1]}.json`); - if (existsSync(sessionFile)) { - try { - const raw = JSON.parse(readFileSync(sessionFile, "utf-8")); - if (raw.projectId) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ projectId: raw.projectId, title: raw.title })); - return; - } - } catch { - /* ignore */ - } + if (state.status === "rendering") { + state.status = "complete"; + const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); + writeFileSync( + metaPath, + JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }), + ); } - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Session not found" })); - return; - } - - const match = req.url.match(/^\/api\/projects\/([^/]+)(.*)/); - if (!match) return next(); - - let [, projectId, rest] = match; - let projectDir = join(dataDir, projectId); - - // If project ID not found, try resolving it as a session ID - if (!existsSync(projectDir)) { - const sessionsDir = resolve(dataDir, "../sessions"); - const sessionFile = join(sessionsDir, `${projectId}.json`); - if (existsSync(sessionFile)) { - try { - const session = JSON.parse(readFileSync(sessionFile, "utf-8")); - if (session.projectId) { - projectId = session.projectId; - projectDir = join(dataDir, projectId); - } - } catch { - /* ignore */ - } + }) + .catch(() => { + state.status = "failed"; + try { + const metaPath = opts.outputPath.replace(/\.(mp4|webm)$/, ".meta.json"); + writeFileSync(metaPath, JSON.stringify({ status: "failed" })); + } catch { + /* ignore */ } - } + }); - if (!existsSync(projectDir)) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "not found" })); - return; - } + return state; + }, - // GET /api/projects/:id/lint — run the core linter on project HTML files - if (req.method === "GET" && rest === "/lint") { - try { - const lintMod = await server.ssrLoadModule("@hyperframes/core/lint"); - const lintFn = lintMod.lintHyperframeHtml as ( - html: string, - opts?: { filePath?: string }, - ) => { - findings: Array<{ - severity: string; - message: string; - file?: string; - fixHint?: string; - }>; + async generateThumbnail(opts) { + const cacheKey = `${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}.jpg`; + + let bufferPromise = _thumbnailInflight.get(cacheKey); + if (!bufferPromise) { + bufferPromise = (async () => { + const browser = await getSharedBrowser(); + if (!browser) return null; + const page = await browser.newPage(); + await page.setViewport({ + width: opts.width, + height: opts.height, + deviceScaleFactor: 0.5, + }); + await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); + await page.evaluate(() => { + document.documentElement.style.background = "#000"; + document.body.style.background = "#000"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + }); + await page + .waitForFunction( + `!!(window.__timelines && Object.keys(window.__timelines).length > 0)`, + { timeout: 5000 }, + ) + .catch(() => {}); + await page.evaluate((t: number) => { + const w = window as Window & { + __timelines?: Record< + string, + { seek: (t: number) => void; pause: (t?: number) => void } + >; + gsap?: { ticker: { tick: () => void } }; }; - const htmlFiles: string[] = []; - const IGNORE = new Set([".thumbnails", "node_modules", ".git"]); - function walkLint(d: string, prefix: string) { - for (const entry of readdirSync(d, { withFileTypes: true })) { - if (IGNORE.has(entry.name)) continue; - const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) walkLint(join(d, entry.name), rel); - else if (entry.name.endsWith(".html")) htmlFiles.push(rel); - } - } - walkLint(projectDir, ""); - 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 = lintFn(content, { filePath: file }); - if (result?.findings) { - for (const f of result.findings) { - allFindings.push({ ...f, file }); + if (w.__timelines) { + // Seek ALL timelines (compositions may register multiple) + for (const tl of Object.values(w.__timelines)) { + if (tl) { + // pause(t) both seeks AND forces GSAP to render the frame + tl.pause(t); } } + // Force GSAP to flush any pending renders + if (w.gsap?.ticker) w.gsap.ticker.tick(); } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ findings: allFindings })); - } catch (err) { - console.warn("[Studio] Lint failed:", err); - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Lint failed" })); - } - return; - } - - // GET /api/projects/:id - if (req.method === "GET" && !rest) { - const files: string[] = []; - const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); - function walk(d: string, prefix: string) { - for (const entry of readdirSync(d, { withFileTypes: true })) { - if (IGNORE_DIRS.has(entry.name)) continue; - const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) walk(join(d, entry.name), rel); - else files.push(rel); - } - } - walk(projectDir, ""); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ id: projectId, files })); - return; - } - - // GET /api/projects/:id/preview — bundle and serve the full composition - if (req.method === "GET" && rest === "/preview") { - try { - const bundler = await getBundler(); - let bundled = bundler - ? await bundler(projectDir) - : readFileSync(join(projectDir, "index.html"), "utf-8"); - - // Inject runtime if not already present - const runtimeUrl = - "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; - if (!bundled.includes("hyperframe.runtime")) { - const runtimeTag = ``; - if (bundled.includes("")) { - bundled = bundled.replace("", `${runtimeTag}\n`); - } else { - bundled += `\n${runtimeTag}`; - } - } - - // Inject for relative asset resolution - const baseHref = `/api/projects/${projectId}/preview/`; - if (!bundled.includes("/i, ``); - } - - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - }); - res.end(bundled); - } catch { - // Fallback to raw HTML if bundling fails - const file = join(projectDir, "index.html"); - if (existsSync(file)) { - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(readFileSync(file, "utf-8")); - } else { - res.writeHead(404); - res.end("not found"); - } - } - return; - } - - // GET /api/projects/:id/preview/comp/* — serve sub-composition as standalone playable page - if (req.method === "GET" && rest.startsWith("/preview/comp/")) { - const compPath = decodeURIComponent(rest.replace("/preview/comp/", "").split("?")[0]); - const compFile = resolve(projectDir, compPath); - if ( - !isSafePath(projectDir, compFile) || - !existsSync(compFile) || - !statSync(compFile).isFile() - ) { - res.writeHead(404); - res.end("not found"); - return; - } - - const rawComp = readFileSync(compFile, "utf-8"); + }, opts.seekTime); + await page.evaluate("document.fonts?.ready"); + await new Promise((r) => setTimeout(r, 200)); + const buf = await page.screenshot({ type: "jpeg", quality: 75 }); + await page.close(); + return buf as Buffer; + })(); + _thumbnailInflight.set(cacheKey, bufferPromise); + bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey)); + } + return bufferPromise; + }, - // Extract content from