/** * Embedded studio server for `hyperframes preview` outside the monorepo. * * Uses the shared studio API module from @hyperframes/core/studio-api, * providing a CLI-specific adapter for single-project, in-process rendering. */ import { Hono, type Context } from "hono"; import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { loadRuntimeSource } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; import { createStudioManualEditsRenderBodyScript, createStudioApi, createProjectSignature, getMimeType, type StudioApiAdapter, type ResolvedProject, type RenderJobState, } from "@hyperframes/core/studio-api"; import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; // ── Path resolution ───────────────────────────────────────────────────────── function resolveDistDir(): string { return resolveStudioBundle().dir; } export interface StudioBundleResolution { dir: string; indexPath: string; available: boolean; checkedPaths: string[]; } export function resolveStudioBundle(): StudioBundleResolution { const builtPath = resolve(__dirname, "studio"); const builtIndex = resolve(builtPath, "index.html"); if (existsSync(builtIndex)) { return { dir: builtPath, indexPath: builtIndex, available: true, checkedPaths: [builtIndex] }; } const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist"); const devIndex = resolve(devPath, "index.html"); if (existsSync(devIndex)) { return { dir: devPath, indexPath: devIndex, available: true, checkedPaths: [builtIndex, devIndex], }; } return { dir: builtPath, indexPath: builtIndex, available: false, checkedPaths: [builtIndex, devIndex], }; } function resolveRuntimePath(): string { const builtPath = resolve(__dirname, "hyperframe-runtime.js"); if (existsSync(builtPath)) return builtPath; const iifePath = resolve(__dirname, "hyperframe.runtime.iife.js"); if (existsSync(iifePath)) return iifePath; const devPath = resolve( __dirname, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js", ); if (existsSync(devPath)) return devPath; return builtPath; } function readStudioManualEditManifestContent(projectDir: string): string { const manifestPath = join(projectDir, STUDIO_MANUAL_EDITS_PATH); if (!existsSync(manifestPath)) return ""; try { return readFileSync(manifestPath, "utf-8"); } catch { return ""; } } async function applyStudioManualEditsToThumbnailPage( page: import("puppeteer-core").Page, manifestContent: string, activeCompositionPath: string, ): Promise { const script = createStudioManualEditsRenderBodyScript(manifestContent, { activeCompositionPath, }); if (!script) return; await page.addScriptTag({ content: script }); } async function reapplyStudioManualEditsToThumbnailPage( page: import("puppeteer-core").Page, ): Promise { await page.evaluate(() => { const apply = (window as Window & { __hfStudioManualEditsApply?: () => number }) .__hfStudioManualEditsApply; if (typeof apply === "function") apply(); }); } // ── Shared thumbnail browser (pool-backed) ────────────────────────────────── // Uses the engine's browser pool so the thumbnail browser and render workers // share a single Chrome process instead of running two independent ones. let _thumbnailBrowser: import("puppeteer-core").Browser | null = null; let _thumbnailBrowserInitializing: Promise | null = null; async function getThumbnailBrowser(): Promise { if (_thumbnailBrowser?.connected) return _thumbnailBrowser; if (_thumbnailBrowserInitializing) return _thumbnailBrowserInitializing; _thumbnailBrowserInitializing = (async () => { try { const { ensureBrowser } = await import("../browser/manager.js"); const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine"); try { const b = await ensureBrowser(); if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) { process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath; } } catch { /* continue — acquireBrowser will try its own resolution */ } const acquired = await acquireBrowser( buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }), { forceScreenshot: true }, ); _thumbnailBrowser = acquired.browser; _thumbnailBrowser.on("disconnected", () => { _thumbnailBrowser = null; _thumbnailBrowserInitializing = null; }); // Release the pool ref on process exit so the browser closes cleanly. const onExit = async () => { const { releaseBrowser } = await import("@hyperframes/engine"); if (_thumbnailBrowser) { await releaseBrowser(_thumbnailBrowser).catch(() => {}); _thumbnailBrowser = null; } }; process.once("SIGTERM", () => void onExit()); process.once("SIGINT", () => void onExit()); return _thumbnailBrowser; } catch (err) { console.warn( "[Studio] Failed to launch thumbnail browser:", err instanceof Error ? err.message : err, ); _thumbnailBrowserInitializing = null; return null; } })(); return _thumbnailBrowserInitializing; } // ── Server factory ────────────────────────────────────────────────────────── export interface StudioServerOptions { projectDir: string; /** Display name for the project. Defaults to basename of projectDir. */ projectName?: string; } export interface StudioServer { app: Hono; watcher: ProjectWatcher; } export function createStudioServer(options: StudioServerOptions): StudioServer { const { projectDir, projectName } = options; const projectId = projectName || basename(projectDir); const studioDir = resolveDistDir(); const runtimePath = resolveRuntimePath(); const watcher = createProjectWatcher(projectDir); // ── CLI adapter for the shared studio API ────────────────────────────── const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId }; let cachedProjectSignature: string | null = null; watcher.addListener(() => { cachedProjectSignature = null; }); const adapter: StudioApiAdapter = { listProjects: () => [project], resolveProject: (id: string) => (id === projectId ? project : null), async bundle(dir: string): Promise { try { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); // Studio dev server: ask the bundler for an empty `src=""` placeholder so // we can point it at our hot-reloadable local runtime endpoint. Inlining // ~150 KB of runtime body on every preview render would defeat browser // caching across composition edits. let html = await bundleToSingleHtml(dir, { runtime: "placeholder" }); html = html.replace( 'data-hyperframes-preview-runtime="1" src=""', 'data-hyperframes-preview-runtime="1" src="/api/runtime.js"', ); return html; } catch (err) { console.error("[studio] Bundle failed:", err); return null; } }, async transformPreviewHtml({ html }) { const { injectDeterministicFontFaces } = await import("../../../producer/src/services/deterministicFonts.js"); return injectDeterministicFontFaces(html); }, getProjectSignature(dir: string): string { if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir); cachedProjectSignature ??= createProjectSignature(projectDir); return cachedProjectSignature; }, 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 manifestContent = readStudioManualEditManifestContent(opts.project.dir); const manualEditsRenderScript = createStudioManualEditsRenderBodyScript(manifestContent); const job = createRenderJob({ // opts.fps is already an Fps rational — see vite-config-studio // adapter for the same convention. fps: opts.fps, quality: opts.quality as "draft" | "standard" | "high", format: opts.format, outputResolution: opts.outputResolution, ...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}), ...(opts.composition ? { entryFile: opts.composition } : {}), }); 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|mov)$/, ".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|mov)$/, ".meta.json"); writeFileSync(metaPath, JSON.stringify({ status: "failed" })); } catch { /* ignore */ } } })(); return state; }, async generateThumbnail(opts): Promise { const browser = await getThumbnailBrowser(); if (!browser) { console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed"); return null; } let page: import("puppeteer-core").Page | null = null; try { page = await browser.newPage(); await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 }); await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); await page .waitForFunction( () => { const w = window as Window & { __timelines?: Record; }; return !!(w.__timelines && Object.keys(w.__timelines).length > 0); }, { timeout: 5000 }, ) .catch(() => {}); await page.evaluate((t: number) => { const w = window as Window & { __player?: { seek?: (time: number) => void }; __timelines?: Record void }>; gsap?: { ticker?: { tick?: () => void } }; }; if (typeof w.__player?.seek === "function") { w.__player.seek(t); } else if (w.__timelines) { for (const tl of Object.values(w.__timelines)) { tl?.pause?.(t); } w.gsap?.ticker?.tick?.(); } }, opts.seekTime); const manifestContent = readStudioManualEditManifestContent(opts.project.dir); await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath); await page.evaluate(() => document.fonts?.ready); await new Promise((r) => setTimeout(r, 200)); await reapplyStudioManualEditsToThumbnailPage(page); let clip: ScreenshotClip | undefined; if (opts.selector) { clip = await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex); } const screenshot = (await page.screenshot( opts.format === "png" ? { type: "png", ...(clip ? { clip } : {}), } : { type: "jpeg", quality: 80, ...(clip ? { clip } : {}), }, )) as Buffer; return screenshot; } catch (err) { console.warn( "[Studio] Thumbnail generation failed:", err instanceof Error ? err.message : err, ); return null; } finally { await page?.close().catch(() => {}); } }, }; // ── Build the Hono app ───────────────────────────────────────────────── const app = new Hono(); // Config probe endpoint — used by port detection to identify existing // HyperFrames instances and reuse them instead of spawning duplicates. // See portUtils.ts detectHyperframesServer() for the consumer. app.get("/__hyperframes_config", (c) => { return c.json({ isHyperframes: true, projectName: projectId, projectDir: projectDir, version, }); }); // CLI-specific routes (before shared API) app.get("/api/runtime.js", (c) => { const serve = async () => { const runtimeSource = (await loadRuntimeSource()) ?? (existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null); if (!runtimeSource) return c.text("runtime not available", 404); return c.body(runtimeSource, 200, { "Content-Type": "text/javascript", "Cache-Control": "no-store", }); }; return serve(); }); app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = (path: string) => { stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {}); }; watcher.addListener(listener); while (true) { await stream.sleep(30000); } }); }); // 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); }); // Studio SPA static files const serveStudioStaticFile = (c: Context) => { 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("/assets/*", serveStudioStaticFile); app.get("/icons/*", serveStudioStaticFile); app.get("/favicon.svg", serveStudioStaticFile); // SPA fallback app.get("*", (c) => { const indexPath = resolve(studioDir, "index.html"); if (!existsSync(indexPath)) { return c.html( ` HyperFrames Studio unavailable

Studio bundle missing

The preview server started, but this CLI build does not contain the Studio assets.

bun run build
`, 500, ); } return c.html(readFileSync(indexPath, "utf-8")); }); return { app, watcher }; }