mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* fix(studio): fix capture button silent failures and broken CLI seek
The Capture button could silently fail with no user feedback due to
several compounding issues:
- The click handler's try-catch only covered the fetch call, leaving
waitForPendingDomEditSaves() and URL construction unprotected. Any
error there became an unhandled promise rejection with zero UI
feedback. Wrap the entire handler body in try-catch.
- No timeout on the fetch or save-queue drain, so a hung server or
stuck save queue caused the button to appear permanently broken.
Add a 30s AbortController timeout on the fetch and a 5s race
timeout on waitForPendingDomEditSaves.
- The CLI server's thumbnail seek used `__timeline` (singular) which
doesn't exist — the runtime registers `__timelines` (plural). Also
used `.seek()` instead of `.pause(t)` and didn't kick the GSAP
ticker. Align with the Vite adapter's working seek logic.
- The CLI server's getThumbnailBrowser and generateThumbnail catch
blocks swallowed all errors silently — Chrome launch failures and
screenshot errors were invisible. Add console.warn logging.
- Parse the JSON error body from the server so the toast shows the
actual message ("Chrome browser may not be available") instead of
just "Capture failed (500)".
Closes #902
* fix(cli): apply same seek fix to snapshot command, address review nits
- Fix snapshot.ts seek logic: same __timeline→__timelines + .pause(t)
+ gsap ticker kick fix as studioServer.ts (caught by Vai's review)
- Use typed Window shape in waitForFunction instead of (window as any)
- Use function-form page.evaluate for document.fonts?.ready
* fix(cli): force screenshot mode for thumbnail browser on Linux
Root cause: on Linux, acquireBrowser defaults to beginframe mode
(--enable-begin-frame-control) which makes page.screenshot() hang
indefinitely — beginframe mode expects CDP HeadlessExperimental.beginFrame
commands, not Puppeteer's Page.captureScreenshot.
Pass forceScreenshot: true and captureMode: "screenshot" so the
thumbnail browser always uses screenshot-compatible Chrome flags.
Reproduced on Linux devbox: thumbnail endpoint hung >30s with
beginframe flags; returns a valid PNG instantly in screenshot mode.
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
const script = createStudioManualEditsRenderBodyScript(manifestContent, {
|
|
activeCompositionPath,
|
|
});
|
|
if (!script) return;
|
|
await page.addScriptTag({ content: script });
|
|
}
|
|
|
|
async function reapplyStudioManualEditsToThumbnailPage(
|
|
page: import("puppeteer-core").Page,
|
|
): Promise<void> {
|
|
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<import("puppeteer-core").Browser | null> | null = null;
|
|
|
|
async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
|
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<string | null> {
|
|
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<Buffer | null> {
|
|
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<string, unknown>;
|
|
};
|
|
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<string, { pause?: (time?: number) => 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(
|
|
`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>HyperFrames Studio unavailable</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
background: #0d0f14;
|
|
color: #eef2f7;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
main {
|
|
width: min(560px, calc(100vw - 48px));
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 8px;
|
|
padding: 28px;
|
|
background: #151923;
|
|
}
|
|
h1 {
|
|
margin: 0 0 12px;
|
|
font-size: 22px;
|
|
line-height: 1.2;
|
|
}
|
|
p {
|
|
margin: 0 0 18px;
|
|
color: #aab3c2;
|
|
line-height: 1.5;
|
|
}
|
|
code {
|
|
display: block;
|
|
padding: 12px 14px;
|
|
border-radius: 6px;
|
|
background: #090b10;
|
|
color: #8ff0c2;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Studio bundle missing</h1>
|
|
<p>The preview server started, but this CLI build does not contain the Studio assets.</p>
|
|
<code>bun run build</code>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
500,
|
|
);
|
|
}
|
|
return c.html(readFileSync(indexPath, "utf-8"));
|
|
});
|
|
|
|
return { app, watcher };
|
|
}
|