/** * 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, unlinkSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { hashSignatureParts, loadRuntimeSource, loadRuntimeSourceSignature, } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js"; import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js"; import { isDevMode } from "../utils/env.js"; import { createStudioManualEditsRenderBodyScript, createStudioApi, createProjectSignature, createBackgroundRemovalJob, getMimeType, type StudioApiAdapter, type ResolvedProject, type RenderJobState, type BackgroundRemovalRender, } from "@hyperframes/studio-server"; import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { RenderJob } from "@hyperframes/producer"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; const REMOTE_GIF_IMG_SRC_RE = /]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi; async function loadStudioProducer() { return isDevMode() ? await import("../../../producer/src/index.js") : await import("@hyperframes/producer"); } // ── 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(); }); } function collectRemoteGifImageSources(html: string): string[] { const urls = new Set(); const re = new RegExp(REMOTE_GIF_IMG_SRC_RE.source, REMOTE_GIF_IMG_SRC_RE.flags); let match: RegExpExecArray | null; while ((match = re.exec(html)) !== null) { if (match[1]) urls.add(match[1]); } return [...urls]; } async function downloadRemoteGifImageSources( html: string, downloadDir: string, downloadToTemp: (url: string, destDir: string) => Promise, ): Promise> { const sourceAssets = new Map(); await Promise.all( collectRemoteGifImageSources(html).map(async (url) => { try { sourceAssets.set(url, await downloadToTemp(url, downloadDir)); } catch (err) { console.warn( "[Studio] Remote animated GIF prep skipped:", err instanceof Error ? err.message : err, ); } }), ); return sourceAssets; } // ── 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({ preferManagedChrome: true }); 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; }); return _thumbnailBrowser; } catch (err) { console.warn( "[Studio] Failed to launch thumbnail browser:", err instanceof Error ? err.message : err, ); _thumbnailBrowserInitializing = null; return null; } })(); return _thumbnailBrowserInitializing; } export async function closeThumbnailBrowser(): Promise { if (!_thumbnailBrowser) return; const browser = _thumbnailBrowser; _thumbnailBrowser = null; _thumbnailBrowserInitializing = null; const { releaseBrowser } = await import("@hyperframes/engine"); await releaseBrowser(browser).catch(() => {}); } // ── 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 async function loadPreviewServerBuildSignature(): Promise { const runtimeSignature = await loadRuntimeSourceSignature(); const studioBundle = resolveStudioBundle(); const studioIndex = studioBundle.available && existsSync(studioBundle.indexPath) ? readFileSync(studioBundle.indexPath, "utf-8") : ""; return hashSignatureParts([ version, runtimeSignature, studioIndex, createStudioServer.toString(), createStudioApi.toString(), createProjectSignature.toString(), getMimeType.toString(), getElementScreenshotClip.toString(), ]); } // Rewrite the viewport meta + inline width/height in every written .html to the // host composition's dimensions, so an installed fragment matches the host // canvas. Applies to ALL written files — including any .html a dependency ships, // not just the requested block's — which is intentional. No-op when the host // index.html is absent or carries no dimensions. function rewriteWrittenToHostViewport(projectDir: string, written: string[]): void { const indexPath = join(projectDir, "index.html"); if (!existsSync(indexPath)) return; const indexHtml = readFileSync(indexPath, "utf-8"); const hostW = indexHtml.match(/data-width="(\d+)"/)?.[1]; const hostH = indexHtml.match(/data-height="(\d+)"/)?.[1]; if (!hostW || !hostH) return; for (const absPath of written) { if (!absPath.endsWith(".html")) continue; let content = readFileSync(absPath, "utf-8"); content = content.replace( /( { if (match.includes("1920") || match.includes("1080")) { return `${pre}${hostW}${mid}${hostH}${post}`; } return match; }, ); writeFileSync(absPath, content, "utf-8"); } } 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", inlineColorGradingLuts: false, }); 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, project }) { const { injectDeterministicFontFaces } = await import("../../../producer/src/services/deterministicFonts.js"); const { prepareAnimatedGifInputs } = await import("../../../producer/src/services/animatedGifPrep.js"); const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js"); const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif"); const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads"); const prepared = await prepareAnimatedGifInputs(html, { projectDir: project.dir, downloadDir: gifDownloadDir, outputDir: gifOutputDir, outputSrcPrefix: ".hyperframes/prepared-assets/gif", cacheDir: gifOutputDir, sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp), }); return injectDeterministicFontFaces(prepared.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/lint"); return await lintHyperframeHtml(html, opts); }, runtimeUrl: "/api/runtime.js", rendersDir: () => join(projectDir, "renders"), startRender(opts): RenderJobState { const abortController = new AbortController(); const state: RenderJobState = { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath, cancel: () => abortController.abort(), }; // Run render asynchronously, mutating the state object const startTime = Date.now(); (async () => { let renderJob: RenderJob | undefined; const removeCancelledOutput = () => { // User-initiated cancel: not a failure. Remove any output so the // cancelled job doesn't resurrect in the render history. state.status = "cancelled"; for (const suffix of ["", ".meta.json"]) { const fp = suffix ? opts.outputPath.replace(/\.(mp4|webm|mov)$/, suffix) : opts.outputPath; try { if (existsSync(fp)) unlinkSync(fp); } catch { /* ignore */ } } }; try { const { createRenderJob, executeRenderJob } = await loadStudioProducer(); const { ensureBrowser } = await import("../browser/manager.js"); try { const browser = await ensureBrowser({ preferManagedChrome: true }); 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 } : {}), ...(opts.variables ? { variables: opts.variables } : {}), }); renderJob = job; 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, abortController.signal, ); if (abortController.signal.aborted) { // Cancel landed just as the render finished: honor the cancel the // route already reported instead of resurrecting a completed job. removeCancelledOutput(); return; } 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 }), ); emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary); } catch (err) { if (abortController.signal.aborted) { removeCancelledOutput(); return; } state.status = "failed"; state.error = err instanceof Error ? err.message : String(err); // fallow-ignore-next-line code-duplication emitStudioRenderError(opts, Date.now() - startTime, state.stage, err, renderJob); try { const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"); writeFileSync(metaPath, JSON.stringify({ status: "failed" })); } catch { /* ignore */ } } })(); return state; }, startBackgroundRemoval(opts) { return createBackgroundRemovalJob(opts, async (renderOpts) => { const sourcePipelinePath = "../background-removal/pipeline.ts"; const pipeline = (await import("../background-removal/pipeline.js").catch( () => import(sourcePipelinePath), )) as { render: BackgroundRemovalRender }; return pipeline.render(renderOpts); }); }, 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(() => {}); // fallow-ignore-next-line code-duplication 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(() => { void document.fonts?.ready; const body = document.body; if (body && getComputedStyle(body).backgroundColor === "rgba(0, 0, 0, 0)") { body.style.backgroundColor = "#1c2028"; } }); 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(() => {}); } }, async listRegistryCatalog() { const { listRegistryItems, loadAllItems } = await import("../registry/resolver.js"); const entries = await listRegistryItems(); const blockAndComponentEntries = entries.filter( (e) => e.type === "hyperframes:block" || e.type === "hyperframes:component", ); return loadAllItems(blockAndComponentEntries); }, async installRegistryBlock(opts) { const { resolveItemWithDependencies } = await import("../registry/resolver.js"); const { installItem } = await import("../registry/installer.js"); const { gateRegistryItemsCompatibility } = await import("../registry/compatibility.js"); // Resolve transitive registryDependencies and install them first so a // block that depends on other registry items installs completely. const items = await resolveItemWithDependencies(opts.blockName); // Compatibility-gate the whole set before writing anything (same gate as // `hyperframes add`), so an incompatible block or dep aborts cleanly. const warnings = gateRegistryItemsCompatibility(items); for (const warning of warnings) { process.stderr.write(`hyperframes:registry ${warning}\n`); } const written: string[] = []; for (const dep of items) { const result = await installItem(dep, { destDir: opts.project.dir }); written.push(...result.written); } const item = items[items.length - 1]!; rewriteWrittenToHostViewport(opts.project.dir, written); const relativePaths = written.map((abs) => { const rel = abs.startsWith(opts.project.dir) ? abs.slice(opts.project.dir.length + 1) : abs; return rel; }); return { written: relativePaths, block: item }; }, }; // ── 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) => { const serve = async () => { const serverBuildSignature = await loadPreviewServerBuildSignature(); return c.json({ isHyperframes: true, pid: process.pid, projectName: projectId, projectDir: projectDir, serverBuildSignature, version, }); }; return serve(); }); // 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(); }); // CLI → Studio telemetry identity endpoint (Layer 1). Studio reads the // injected `window.__HF_CLI_DISTINCT_ID` first; this GET is a fallback for // clients that can't rely on the injected global. Returns the CLI's anonymous // distinct id (no PII) so the browser session can join the CLI's PostHog // person, or `{ distinctId: null }` when CLI telemetry is disabled. app.get("/api/telemetry-identity", (c) => { return c.json({ distinctId: resolveCliTelemetryDistinctId() }); }); 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); } }); }); // ── Pre-flight checks for render ──────────────────────────────────────── // Intercept render requests before they reach the shared API so we can // fail fast with an actionable hint instead of burning through the entire // capture pipeline before hitting "spawn ffmpeg ENOENT" at encode. let cachedFFmpegPath: string | undefined; app.post("/api/projects/:id/render", async (c, next) => { const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js"); if (!cachedFFmpegPath) { cachedFFmpegPath = findFFmpeg(); } if (!cachedFFmpegPath) { return c.json({ error: "FFmpeg not found", hint: getFFmpegInstallHint() }, 503); } return next(); }); // 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); // ── Runtime env injection ─────────────────────────────────────────────── // When the studio is served as a pre-built SPA, Vite `VITE_STUDIO_*` env // vars were baked at build time. Collect any such vars from the current // process.env and inject them as `window.__HF_STUDIO_ENV__` so the client // can pick them up at runtime, overriding the baked defaults. function buildRuntimeEnvScript(): string { const overrides: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (key.startsWith("VITE_STUDIO_") && value !== undefined) { overrides[key] = value; } } if (Object.keys(overrides).length === 0) return ""; return ``; } // 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, ); } let html = readFileSync(indexPath, "utf-8"); // Inject before the studio bundle runs. Identity script first (see // buildStudioHeadScripts) so the CLI distinct id is on `window` by the time // telemetry init reads it. const headScript = buildStudioHeadScripts(buildRuntimeEnvScript()); if (headScript) { html = html.replace("", `${headScript}`); } return c.html(html); }); return { app, watcher }; }