import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import react from "@vitejs/plugin-react"; import { readFileSync, readdirSync, existsSync, writeFileSync, lstatSync, realpathSync, } from "node:fs"; import { join, resolve } from "node:path"; import type { StudioApiAdapter, ResolvedProject, RenderJobState, } from "@hyperframes/core/studio-api"; // ── Shared Puppeteer browser ───────────────────────────────────────────────── let _browser: import("puppeteer-core").Browser | null = null; let _browserLaunchPromise: Promise | null = null; async function getSharedBrowser(): Promise { if (_browser?.connected) return _browser; if (_browserLaunchPromise) return _browserLaunchPromise; _browserLaunchPromise = (async () => { const puppeteer = await import("puppeteer-core"); const executablePath = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", ].find((p) => existsSync(p)); if (!executablePath) return null; _browser = await puppeteer.default.launch({ headless: true, executablePath, args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], }); _browserLaunchPromise = null; return _browser; })(); return _browserLaunchPromise; } // In-flight thumbnail dedup const _thumbnailInflight = new Map>(); // ── Vite adapter for the shared studio API ─────────────────────────────────── function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter { // 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; } } return _bundler; }; return { 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 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 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 ?? "")); }, 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 { 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 */ } } return null; } return { id, dir: projectDir }; }, 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; }, async lint(html: string, opts?: { filePath?: string }) { const mod = await server.ssrLoadModule("@hyperframes/core/lint"); return mod.lintHyperframeHtml(html, opts); }, runtimeUrl: "/api/runtime.js", rendersDir: () => resolve(dataDir, "../renders"), startRender(opts): RenderJobState { const state: RenderJobState = { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath, }; const startTime = Date.now(); (async () => { try { // Help the producer find a browser — it checks PRODUCER_HEADLESS_SHELL_PATH // but doesn't search system Chrome paths. Reuse the same discovery as thumbnails. if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) { const systemChrome = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", ].find((p) => existsSync(p)); if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome; } // Dynamic import hidden from esbuild's static analysis (vite.config.ts is // bundled by esbuild at startup; a bare specifier would fail the externalize-deps plugin). const producerPkg = "@hyperframes/producer"; const { createRenderJob, executeRenderJob } = await import( /* @vite-ignore */ producerPkg ); const job = createRenderJob({ fps: opts.fps as 24 | 30 | 60, quality: opts.quality as "draft" | "standard" | "high", format: opts.format, }); 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) { 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 } }; }; 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(); } }, 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; }, async resolveSession(sessionId: string) { const sessionsDir = resolve(dataDir, "../sessions"); const sessionFile = join(sessionsDir, `${sessionId}.json`); if (!existsSync(sessionFile)) return null; try { const raw = JSON.parse(readFileSync(sessionFile, "utf-8")); if (raw.projectId) return { projectId: raw.projectId, title: raw.title }; } catch { /* ignore */ } return null; }, }; } // ── Bridge Hono fetch → Node http response ─────────────────────────────────── async function bridgeHonoResponse( honoResponse: Response, res: import("node:http").ServerResponse, ): Promise { const headers: Record = {}; honoResponse.headers.forEach((v, k) => { headers[k] = v; }); res.writeHead(honoResponse.status, headers); if (!honoResponse.body) { res.end(); return; } // Stream the response body (important for SSE) const reader = honoResponse.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; res.write(value); } } catch { /* client disconnected */ } res.end(); } // ── Vite plugin ────────────────────────────────────────────────────────────── function devProjectApi(): Plugin { const dataDir = resolve(__dirname, "data/projects"); return { name: "studio-dev-api", configureServer(server): void { // Load the shared module lazily via SSR (resolves hono + TypeScript) let _api: { fetch: (req: Request) => Promise } | null = null; const getApi = async () => { if (!_api) { const mod = await server.ssrLoadModule("@hyperframes/core/studio-api"); const adapter = createViteAdapter(dataDir, server); _api = mod.createStudioApi(adapter); } return _api; }; // Serve the local runtime IIFE so compositions don't depend on CDN const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js"); server.middlewares.use((req, res, next) => { if (req.url !== "/api/runtime.js") return next(); if (!existsSync(runtimePath)) { res.writeHead(404); res.end("runtime not built — run pnpm build in packages/core"); return; } res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-store", }); res.end(readFileSync(runtimePath, "utf-8")); }); server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith("/api/")) return next(); try { const api = await getApi(); // Build a Fetch Request from the Node IncomingMessage const url = new URL(req.url, `http://${req.headers.host}`); // Strip /api prefix — shared module routes are relative url.pathname = url.pathname.slice(4); // Read body for non-GET/HEAD let body: string | undefined; if (req.method !== "GET" && req.method !== "HEAD") { body = await new Promise((resolve) => { let data = ""; req.on("data", (chunk: Buffer) => (data += chunk.toString())); req.on("end", () => resolve(data)); }); } const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (value != null) headers[key] = Array.isArray(value) ? value.join(", ") : value; } const fetchReq = new Request(url.toString(), { method: req.method, headers, body, }); const response = await api.fetch(fetchReq); await bridgeHonoResponse(response, res); } catch (err) { console.error("[Studio API] Error:", err); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Internal server error" })); } } }); // Watch project directories for file changes → HMR const realProjectPaths: string[] = []; try { for (const entry of readdirSync(dataDir, { withFileTypes: true })) { const full = join(dataDir, entry.name); try { const real = lstatSync(full).isSymbolicLink() ? realpathSync(full) : full; realProjectPaths.push(real); server.watcher.add(real); } catch { /* skip broken symlinks */ } } } catch { /* dataDir doesn't exist yet */ } server.watcher.on("change", (filePath: string) => { const isProjectFile = realProjectPaths.some((p) => filePath.startsWith(p)); if ( isProjectFile && (filePath.endsWith(".html") || filePath.endsWith(".css") || filePath.endsWith(".js")) ) { console.log(`[Studio] File changed: ${filePath}`); server.ws.send({ type: "custom", event: "hf:file-change", data: {} }); } }); }, }; } export default defineConfig({ plugins: [react(), devProjectApi()], build: { outDir: "dist", emptyOutDir: true, }, server: { port: 5190, }, });