mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* fix(cli): resolve runtime fallback for globally-installed hyperframes When hyperframes is installed globally via npm, the `loadRuntimeSourceFallback()` path that dynamically imports @hyperframes/core and runs esbuild fails because @hyperframes/core is inlined into cli.js and import.meta.url resolves to the wrong location for the entry.ts source file. Add a disk-based fallback that searches for the pre-built IIFE runtime artifact in multiple locations: - Alongside the bundled CLI (dist/hyperframe-runtime.js, dist/hyperframe.runtime.iife.js) - Walking up from __dirname through node_modules The esbuild path is tried first to preserve live-rebuild behavior in dev, with the pre-built artifact search as a safety net for the bundled context. Also adds the IIFE artifact name variant to resolveRuntimePath() in the studio server so it checks both naming conventions. * fix(cli): gate esbuild fallback on source availability The previous fix still triggered esbuild's stderr output before the catch could suppress it. Now check whether the runtime entry.ts source file actually exists before attempting the on-the-fly build, avoiding the noisy error in global installs entirely. * fix(cli): remove noisy console.warn from runtime fallback The caller already handles a null return — no need to warn about something the user can't act on. If both paths fail, the /api/runtime.js route returns a 404 which the studio handles gracefully. * style(engine): fix oxfmt trailing blank line in chunkEncoder test * fix(cli): guard against null/undefined from loadHyperframeRuntimeSource Fall through to the pre-built artifact if the function returns a falsy value without throwing. * refactor(cli): consolidate runtime source resolution into single module Replace the scattered path-probing logic with a single loadRuntimeSource() that encodes the full priority chain: esbuild from source (dev only, gated on entry.ts existence) → pre-built artifact alongside cli.js → core/dist artifact → node_modules walk. Rename loadRuntimeSourceFallback → loadRuntimeSource since it's now the primary resolution function, not a fallback.
358 lines
13 KiB
TypeScript
358 lines
13 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 } 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 {
|
|
createStudioApi,
|
|
getMimeType,
|
|
type StudioApiAdapter,
|
|
type ResolvedProject,
|
|
type RenderJobState,
|
|
} from "@hyperframes/core/studio-api";
|
|
|
|
// ── Path resolution ─────────────────────────────────────────────────────────
|
|
|
|
function resolveDistDir(): string {
|
|
const builtPath = resolve(__dirname, "studio");
|
|
if (existsSync(resolve(builtPath, "index.html"))) return builtPath;
|
|
const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist");
|
|
if (existsSync(resolve(devPath, "index.html"))) return devPath;
|
|
return builtPath;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ── Shared thumbnail browser (singleton per process) ────────────────────────
|
|
// One browser instance is reused across all composition thumbnail requests.
|
|
// Spawning a new Puppeteer process per request adds 2-5s overhead and causes
|
|
// contention when the sidebar requests multiple thumbnails simultaneously.
|
|
|
|
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 }), {
|
|
enableBrowserPool: false,
|
|
});
|
|
_thumbnailBrowser = acquired.browser;
|
|
_thumbnailBrowser.on("disconnected", () => {
|
|
_thumbnailBrowser = null;
|
|
_thumbnailBrowserInitializing = null;
|
|
});
|
|
return _thumbnailBrowser;
|
|
} catch {
|
|
_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 };
|
|
|
|
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");
|
|
let html = await bundleToSingleHtml(dir);
|
|
// Fix empty runtime src from bundler — point to the local runtime endpoint
|
|
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 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 job = createRenderJob({
|
|
fps: opts.fps as 24 | 30 | 60,
|
|
quality: opts.quality as "draft" | "standard" | "high",
|
|
format: opts.format,
|
|
});
|
|
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> {
|
|
// Reuse a single browser across all thumbnail requests for this server
|
|
// instance — avoids paying the ~2s Puppeteer startup cost per composition.
|
|
// The browser is created lazily and kept alive until the process exits.
|
|
const browser = await getThumbnailBrowser();
|
|
if (!browser) 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 });
|
|
// domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie,
|
|
// fonts) never reach "idle" and cause a 15s timeout per thumbnail.
|
|
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
|
// Wait for the runtime to register timelines (up to 5s, non-fatal).
|
|
await page
|
|
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
|
|
timeout: 5000,
|
|
})
|
|
.catch(() => {});
|
|
await page.evaluate((t: number) => {
|
|
const win = window as any;
|
|
if (win.__player?.seek) win.__player.seek(t);
|
|
else if (win.__timeline?.seek) {
|
|
win.__timeline.pause();
|
|
win.__timeline.seek(t);
|
|
}
|
|
}, opts.seekTime);
|
|
// Let the seek render settle.
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
|
if (opts.selector) {
|
|
clip = await page.evaluate((selector: string) => {
|
|
const el = document.querySelector(selector);
|
|
if (!(el instanceof HTMLElement)) return undefined;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width < 4 || rect.height < 4) return undefined;
|
|
const pad = 8;
|
|
const x = Math.max(0, rect.left - pad);
|
|
const y = Math.max(0, rect.top - pad);
|
|
const maxWidth = window.innerWidth - x;
|
|
const maxHeight = window.innerHeight - y;
|
|
return {
|
|
x,
|
|
y,
|
|
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
|
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
|
};
|
|
}, opts.selector);
|
|
}
|
|
const screenshot = (await page.screenshot({
|
|
type: "jpeg",
|
|
quality: 80,
|
|
...(clip ? { clip } : {}),
|
|
})) as Buffer;
|
|
return screenshot;
|
|
} catch {
|
|
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 = () => {
|
|
stream.writeSSE({ event: "file-change", data: "{}" }).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
|
|
app.get("/assets/*", (c) => {
|
|
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("/icons/*", (c) => {
|
|
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" },
|
|
});
|
|
});
|
|
|
|
// SPA fallback
|
|
app.get("*", (c) => {
|
|
const indexPath = resolve(studioDir, "index.html");
|
|
if (!existsSync(indexPath)) {
|
|
return c.text("Studio not found. Rebuild with: pnpm run build", 500);
|
|
}
|
|
return c.html(readFileSync(indexPath, "utf-8"));
|
|
});
|
|
|
|
return { app, watcher };
|
|
}
|