Files
hyperframes/packages/cli/src/server/studioServer.ts
T
Miguel Ángel 54d0af57eb feat(engine): render sets a keyframe interval and a peak bitrate cap (#6444)
* feat(engine): encoders cap peak bitrate and place keyframes on a set interval

* style(engine): shorten the rate-cap comment

* style(engine): drop the doc lines the comment ratchet flagged

* feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path

* style(producer): shorten the delivery encoder file headers

* style(producer): drop a comment line from the segment manifest

* style(producer): shorten the integration test header

* test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane

* feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap

* fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder

* chore(studio-server): drop a comment the helper's name already says

* feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
2026-10-27 00:29:06 +00:00

1260 lines
50 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 { realpath } from "@hyperframes/core";
import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { resolve, join, basename, dirname, relative, sep } from "node:path";
import { createRequire } from "node:module";
import { readBundleFile } from "./readBundleFile.js";
import { GSAP_CDN_VERSION } from "@hyperframes/core/gsap-cdn";
import {
createProjectWatcher,
shouldWatchProjectFile,
type ProjectWatcher,
} from "./fileWatcher.js";
import {
hashSignatureParts,
loadRuntimeSource,
loadRuntimeSourceSignature,
} from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
import {
buildStudioHeadScriptsForHost,
identityAllowed,
refreshTelemetryPosture,
resolveCliTelemetryDistinctId,
} from "./telemetryIdentity.js";
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
import { startRenderCpuSampler } from "../telemetry/renderCpu.js";
import { mountDesktopRoutes } from "./desktopRoutes.js";
import { isDevMode } from "../utils/env.js";
import { runRenderSetupWorker } from "../utils/cancellableProcess.js";
import type { ProjectLintResult } from "@hyperframes/lint";
import { resolveRenderBrowser } from "../browser/preflight.js";
import {
createStudioManualEditsRenderBodyScript,
createStudioApi,
createProjectSignature,
createBackgroundRemovalJob,
identifyFileWrite,
DELETED_VERSION,
fileContentVersion,
getMimeType,
affectsProjectSignature,
compositionsAffectedBy,
affectsPreview,
type PreviewApiAdapter,
PREVIEW_BUNDLE_OPTIONS,
createPreviewDocumentStore,
thumbnailDeviceScaleFactor,
type ResolvedProject,
type RenderJobState,
type BackgroundRemovalRender,
stampProjectHfIds,
DEFAULT_HISTORY_ROOT,
openProjectHistory,
HistoryBusyError,
HistoryClosedError,
historyCache,
} from "@hyperframes/studio-server";
import { encoderJobOptions } from "@hyperframes/studio-server/encoder-options";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import {
clearElementScreenshotIsolation,
getElementScreenshotClip,
} from "@hyperframes/studio-server/screenshot-clip";
import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
import type { RenderJob } from "@hyperframes/producer";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import { seekCompositionTimeline } from "../capture/captureCompositionFrame.js";
import { createThumbnailPages } from "./thumbnailPages.js";
import {
assertWebGpuAdapterAvailable,
compositionRequiresWebGpu,
resolveCaptureBrowserGpuMode,
resolveLocalBrowserGpuMode,
resolveLocalWebGpu,
type BrowserGpuMode,
type ResolvedBrowserGpuMode,
} from "../browser/gpuPolicy.js";
import { loadSharpInBackground } from "../utils/optionalPackages.js";
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
// Under preview.ts's 3s process-exit watchdog, so shutdown() always returns
// before that watchdog can fire and skip this file's browser cleanup.
const RENDER_SHUTDOWN_WAIT_MS = 2_000;
// Vite emits only content-hashed files under dist/assets; hand-authored
// public/ files land at the dist root. The route is the signal because the
// filename is not: rollup's base64url hash may itself contain a hyphen.
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
const REMOTE_GIF_IMG_SRC_RE =
/<img\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;
async function loadStudioProducer() {
if (!isDevMode()) return await import("@hyperframes/producer");
// The producer's SOURCE uses the TS convention of `.js` specifiers naming
// `.ts` files, which bun resolves and Node does not. Node 22 strips TS types
// natively, so a Node-hosted dev server boots fine and only dies here, as
// `Cannot find module .../renderOrchestrator.js` with no other context.
// Vite's own shebang is `#!/usr/bin/env node` and it hosts this API
// in-process, so `vite` without `bun --bun` lands exactly here.
if (!process.versions.bun) {
throw new Error(
"Studio dev-mode rendering requires bun (the producer is loaded from TypeScript source, " +
"which Node cannot resolve). Restart the studio with `bun run studio`.",
);
}
return await import("../../../producer/src/index.js");
}
// ── 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;
}
// Same-origin root for the GSAP files the CLI ships (build-copy.mjs), laid out
// like an npm mirror so preview can ask for `<root>/gsap@<version>/dist/<file>`.
const GSAP_LOCAL_ROOT = "/api/vendor/npm";
const GSAP_VENDOR_FILES = new Set(["gsap.min.js", "CustomEase.min.js", "MotionPathPlugin.min.js"]);
function installedGsapDist(): string | null {
try {
const coreRequire = createRequire(resolve(__dirname, "..", "..", "..", "core", "package.json"));
return join(dirname(coreRequire.resolve("gsap/package.json")), "dist");
} catch {
return null;
}
}
/** The bundled GSAP dist dir, or null so preview keeps loading GSAP from jsDelivr. */
function resolveGsapVendorDir(): string | null {
const vendored = join("vendor", "npm", `gsap@${GSAP_CDN_VERSION}`, "dist");
const candidates = [
resolve(__dirname, vendored),
// Running from source: the built CLI next door, then core's installed gsap.
resolve(__dirname, "..", "..", "dist", vendored),
installedGsapDist(),
];
for (const dir of candidates) {
if (!dir) continue;
if (!existsSync(join(dir, "gsap.min.js"))) continue;
const manifest = readBundleFile(join(dir, "..", "package.json"));
if (manifest && JSON.parse(manifest.toString("utf-8")).version !== GSAP_CDN_VERSION) continue;
return dir;
}
return null;
}
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();
});
}
function collectRemoteGifImageSources(html: string): string[] {
const urls = new Set<string>();
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<string>,
): Promise<Map<string, string>> {
const sourceAssets = new Map<string, string>();
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 _thumbnailBrowserLease: import("@hyperframes/engine").BrowserLease | null = null;
const thumbnailPages = createThumbnailPages();
let _thumbnailBrowserInitializing: Promise<ThumbnailBrowserSession | null> | null = null;
let _thumbnailBrowserModes: {
requested: BrowserGpuMode;
resolved: ResolvedBrowserGpuMode;
softwareWebGpu: boolean;
} | null = null;
interface ThumbnailBrowserSession {
browser: import("puppeteer-core").Browser;
requestedGpuMode: BrowserGpuMode;
resolvedGpuMode: ResolvedBrowserGpuMode;
softwareWebGpu: boolean;
}
async function getThumbnailBrowser(
requestedGpuMode: BrowserGpuMode,
isShuttingDown: () => boolean,
): Promise<ThumbnailBrowserSession | null> {
if (isShuttingDown()) return null;
if (
_thumbnailBrowserLease?.browser.connected &&
_thumbnailBrowserModes?.requested === requestedGpuMode
) {
return {
browser: _thumbnailBrowserLease.browser,
requestedGpuMode: _thumbnailBrowserModes.requested,
resolvedGpuMode: _thumbnailBrowserModes.resolved,
softwareWebGpu: _thumbnailBrowserModes.softwareWebGpu,
};
}
if (_thumbnailBrowserInitializing) {
const session = await _thumbnailBrowserInitializing;
if (session?.requestedGpuMode === requestedGpuMode) return session;
}
if (_thumbnailBrowserLease) await closeThumbnailBrowser();
_thumbnailBrowserInitializing = (async () => {
try {
if (isShuttingDown()) return null;
const { ensureBrowser } = await import("../browser/manager.js");
const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine");
let executablePath: string | undefined;
try {
const b = await ensureBrowser({ preferManagedChrome: true });
executablePath = b.executablePath;
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 resolvedGpuMode = await resolveCaptureBrowserGpuMode(requestedGpuMode, executablePath);
const { gpuConfig, softwareWebGpu } = resolveLocalWebGpu(resolvedGpuMode, true);
const acquired = await acquireBrowser(
buildChromeArgs(
{
width: 1920,
height: 1080,
captureMode: "screenshot",
requiresWebGpu: softwareWebGpu,
},
gpuConfig,
),
{ forceScreenshot: true },
);
_thumbnailBrowserLease = acquired;
_thumbnailBrowserModes = {
requested: requestedGpuMode,
resolved: resolvedGpuMode,
softwareWebGpu,
};
acquired.browser.on("disconnected", () => {
if (_thumbnailBrowserLease !== acquired) return;
_thumbnailBrowserLease = null;
_thumbnailBrowserModes = null;
_thumbnailBrowserInitializing = null;
});
return { browser: acquired.browser, requestedGpuMode, resolvedGpuMode, softwareWebGpu };
} catch (err) {
console.warn(
"[Studio] Failed to launch thumbnail browser:",
err instanceof Error ? err.message : err,
);
_thumbnailBrowserInitializing = null;
return null;
}
})();
return _thumbnailBrowserInitializing;
}
async function closeThumbnailBrowser(): Promise<void> {
thumbnailPages.closeAll();
// A launch kicked off just before this call isn't in _thumbnailBrowserLease
// yet; awaiting it here closes a browser that was mid-launch when the stop
// signal arrived, instead of leaving it running, unreferenced, after exit.
if (_thumbnailBrowserInitializing) await _thumbnailBrowserInitializing.catch(() => {});
if (!_thumbnailBrowserLease) return;
const lease = _thumbnailBrowserLease;
_thumbnailBrowserLease = null;
_thumbnailBrowserModes = null;
_thumbnailBrowserInitializing = null;
await lease.release().catch(() => {});
}
// ── Server factory ──────────────────────────────────────────────────────────
export interface StudioServerOptions {
projectDir: string;
/** Display name for the project. Defaults to basename of projectDir. */
projectName?: string;
/**
* Auto-transcode browser-hostile video codecs to a cached H.264 preview
* proxy. The preview command passes its resolved `--proxy`/`--no-proxy` +
* `hyperframes.json` value; when omitted, the project's `media.autoProxy`
* config (default true) applies.
*/
autoProxy?: boolean | undefined;
/** GPU policy used by Studio thumbnails and frame capture. */
browserGpuMode?: BrowserGpuMode;
/** Where project histories are kept; defaults to ~/.cache/hyperframes/history. */
historyRoot?: string;
origin?: () => string | undefined;
}
export interface StudioServer {
app: Hono;
watcher: ProjectWatcher;
/** Cancels in-flight renders, then closes every browser this server owns. */
shutdown(): Promise<void>;
/** Exposed for tests: the adapter handed to the shared studio API (carries
* the resolved `autoProxy` flag the preview routes read). */
adapter: PreviewApiAdapter;
}
export async function loadPreviewServerBuildSignature(): Promise<string> {
const runtimeSignature = await loadRuntimeSourceSignature();
const studioBundle = resolveStudioBundle();
const studioIndex = studioBundle.available
? (readBundleFile(studioBundle.indexPath)?.toString("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(
/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+/i,
`$1${hostW}$2${hostH}`,
);
content = content.replace(
/(\bwidth:\s*)\d+(px;\s*\n?\s*height:\s*)\d+(px;)/g,
(match, pre, mid, post) => {
if (match.includes("1920") || match.includes("1080")) {
return `${pre}${hostW}${mid}${hostH}${post}`;
}
return match;
},
);
replaceFileAtomically(absPath, content, statSync(absPath).mode);
}
}
export function createStudioServer(options: StudioServerOptions): StudioServer {
const { projectDir, projectName } = options;
const projectId = projectName || basename(projectDir);
const browserGpuMode = options.browserGpuMode ?? resolveLocalBrowserGpuMode();
const studioDir = resolveDistDir();
const runtimePath = resolveRuntimePath();
const gsapVendorDir = resolveGsapVendorDir();
stampProjectHfIds(projectDir);
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((changedPath) => {
if (changedPath === "." || affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
cachedProjectSignature = null;
}
});
const projectSignature = (dir: string): string => {
if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir);
cachedProjectSignature ??= createProjectSignature(projectDir);
return cachedProjectSignature;
};
// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
// for this run; one another process was holding is tried again on the next request, which no write waits for.
let ownerWaitMs: number | undefined;
const historyUrl = () => {
const origin = options.origin?.();
return origin && `${origin}/api/projects/${encodeURIComponent(projectId)}/history`;
};
const histories = historyCache(() =>
openProjectHistory({
projectDir,
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
ownerWaitMs,
ownerUrl: historyUrl(),
})
.then((history) => {
ownerWaitMs = undefined;
return history;
})
.catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
if (error instanceof HistoryBusyError) ownerWaitMs = 0;
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
histories.forget(projectDir);
return null;
}),
);
const projectHistory = () => histories.get(projectDir);
watcher.addListener((changedPath) => {
void histories.peek(projectDir)?.then((opened) => opened?.noteChange(changedPath));
});
const inFlightRenders = new Map<AbortController, Promise<void>>();
// Set synchronously by shutdown() before any await, so a render or
// thumbnail request already queued behind it sees the flag instead of
// launching a browser shutdown() has no way to know about and close.
let shuttingDown = false;
const adapter: PreviewApiAdapter = {
history: () => projectHistory(),
loadSharp: () => loadSharpInBackground("image thumbnails"),
// Explicit option wins (preview's resolved --proxy/--no-proxy + config);
// otherwise honor the project's hyperframes.json media.autoProxy so every
// createStudioServer caller (e.g. the background preview child) gets the
// configured behavior without its own plumbing.
autoProxy: options.autoProxy ?? resolveAutoProxy(projectDir, undefined),
listProjects: () => [project],
// Salted with the running CLI file, so an upgraded CLI never serves an older build's document.
previewDocuments: createPreviewDocumentStore(
projectDir,
createHash("sha256")
.update(readFileSync(fileURLToPath(import.meta.url)))
.digest("hex"),
),
resolveProject: (id: string) => (id === projectId ? project : null),
async bundle(dir, options): 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, { ...PREVIEW_BUNDLE_OPTIONS, ...options });
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("@hyperframes/core/fonts/embed");
const { prepareAnimatedGifInputs } =
await import("../../../producer/src/services/animatedGifPrep.js");
const { prepareHeifImageInputs } =
await import("../../../producer/src/services/heifImagePrep.js");
const { downloadToTemp, writeUrlDownloadTelemetry } =
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 heifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "heif");
const { html: htmlWithJpegs } = await prepareHeifImageInputs(html, {
projectDir: project.dir,
downloadDir: gifDownloadDir,
outputDir: heifOutputDir,
outputSrcPrefix: ".hyperframes/prepared-assets/heif",
onFailure: (error) => console.warn(`[studio] ${error.message}`),
});
const prepared = await prepareAnimatedGifInputs(htmlWithJpegs, {
projectDir: project.dir,
downloadDir: gifDownloadDir,
outputDir: gifOutputDir,
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
cacheDir: gifOutputDir,
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, (url, destDir) =>
downloadToTemp(url, destDir, undefined, undefined, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
}),
),
});
return injectDeterministicFontFaces(prepared.html);
},
getProjectSignature: projectSignature,
invalidateProjectSignature: (dir: string) => {
if (resolve(dir) === resolve(projectDir)) cachedProjectSignature = null;
},
async lint(html, opts) {
const { lintHyperframeHtml } = await import("@hyperframes/lint");
return await lintHyperframeHtml(html, { ...opts, host: "studio" });
},
// Out of process: linting a large composition is seconds of synchronous parsing, and on
// this event loop it stalls every other Studio request, the preview included.
lintProject: (dir: string) =>
runRenderSetupWorker<ProjectLintResult>(
"lint",
{ projectDir: dir, host: "studio" },
{ maxBufferBytes: 8 * 1024 * 1024 },
),
runtimeUrl: "/api/runtime.js",
gsapLocalRoot: gsapVendorDir ? GSAP_LOCAL_ROOT : undefined,
rendersDir: () => join(projectDir, "renders"),
startRender(opts): RenderJobState {
if (shuttingDown) {
return {
id: opts.jobId,
status: "failed",
progress: 0,
outputPath: opts.outputPath,
error: "Studio server is shutting down",
};
}
// The render POST is a request boundary like any other. Without this an
// already-open Studio tab keeps rendering under the posture cached when
// the server booted.
refreshTelemetryPosture();
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();
const cpu = startRenderCpuSampler(opts.telemetryOptOut !== true);
const run = (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|gif)$/, suffix)
: opts.outputPath;
try {
if (existsSync(fp)) unlinkSync(fp);
} catch {
/* ignore */
}
}
};
try {
const { createRenderJob, executeRenderJob } = await loadStudioProducer();
const browser = await resolveRenderBrowser(abortController.signal);
if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = browser.executablePath;
}
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,
...(opts.gifLoop !== undefined ? { gifLoop: opts.gifLoop } : {}),
outputResolution: opts.outputResolution,
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
...(opts.composition ? { entryFile: opts.composition } : {}),
...(opts.variables ? { variables: opts.variables } : {}),
...encoderJobOptions(opts),
...(opts.useGpu ? { useGpu: true } : {}),
...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}),
});
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;
}
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
state.status = "complete";
state.progress = 100;
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
writeFileSync(
metaPath,
JSON.stringify({
status: "complete",
durationMs: Date.now() - startTime,
...(job.audioLoweredDb !== undefined ? { audioLoweredDb: job.audioLoweredDb } : {}),
}),
);
// Refreshed HERE, not just at render start: a render can run for
// minutes, and `hyperframes telemetry disable` during one must be
// honoured by the event that reports it. Studio never polls
// /api/telemetry-identity, so this process would otherwise keep its
// startup-cached posture for the life of the preview server.
refreshTelemetryPosture();
emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary, cpu.stop());
} 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
refreshTelemetryPosture();
emitStudioRenderError(
opts,
Date.now() - startTime,
state.stage,
err,
renderJob,
cpu.stop(),
);
try {
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov|gif)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
}
} finally {
cpu.stop();
}
})();
inFlightRenders.set(abortController, run);
const forget = () => void inFlightRenders.delete(abortController);
run.then(forget, forget);
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<Buffer | null> {
const session = await getThumbnailBrowser(browserGpuMode, () => shuttingDown);
if (!session) {
if (!shuttingDown) {
console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed");
}
return null;
}
const sourcePath = join(opts.project.dir, opts.compPath);
// The shared browser launches once, before any composition is known,
// so it can't gain a WebGPU flag it didn't start with. Checked live
// below, against this page, after navigation — see assertWebGpuAdapterAvailable.
const requiresWebGpu = existsSync(sourcePath)
? compositionRequiresWebGpu(readFileSync(sourcePath, "utf-8"))
: false;
if (opts.signal.aborted) return null;
const width = opts.width || 1920;
const height = opts.height || 1080;
const viewport = { width, height, deviceScaleFactor: thumbnailDeviceScaleFactor(opts) };
try {
return await thumbnailPages.withPage(
session.browser,
opts.previewUrl,
projectSignature(opts.project.dir),
opts.seekTime,
async (page) => {
await page.setViewport(viewport);
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await assertWebGpuAdapterAvailable(
page,
requiresWebGpu,
requiresWebGpu && session.softwareWebGpu,
);
await page
.waitForFunction(
() => {
const w = window as Window & {
__timelines?: Record<string, unknown>;
};
return !!(w.__timelines && Object.keys(w.__timelines).length > 0);
},
{ timeout: 5000 },
)
.catch(() => {});
},
async (page) => {
if (opts.signal.aborted) return null;
await page.setViewport(viewport);
await seekCompositionTimeline(page, opts.seekTime, {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: 500,
animationFrameSettle: "double",
waitForFontsMs: 500,
});
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);
if (opts.signal.aborted) return null;
try {
const clip: ScreenshotClip | undefined = opts.selector
? await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex)
: undefined;
return (await page.screenshot(
opts.format === "png"
? { type: "png", ...(clip ? { clip } : {}) }
: { type: "jpeg", quality: 80, ...(clip ? { clip } : {}) },
)) as Buffer;
} finally {
if (opts.selector) await page.evaluate(clearElementScreenshotIsolation);
}
},
);
} catch (err) {
if (!opts.signal.aborted) {
console.warn(
"[Studio] Thumbnail generation failed:",
err instanceof Error ? err.message : err,
);
}
return null;
}
},
async listRegistryCatalog() {
const { listRegistryItems, loadAllItems } = await import("../registry/resolver.js");
const { loadProjectConfig } = await import("../utils/projectConfig.js");
// The same registry `add` installs from, so the panel lists what can be installed.
const registry = { baseUrl: loadProjectConfig(projectDir).registry };
const entries = await listRegistryItems(undefined, registry);
const blockAndComponentEntries = entries.filter(
(e) => e.type === "hyperframes:block" || e.type === "hyperframes:component",
);
return loadAllItems(blockAndComponentEntries, registry);
},
async installRegistryBlock(opts) {
const { addToProject, primaryInstalledTarget } = await import("../commands/add.js");
const { recordRewrittenInstall } = await import("../registry/installer.js");
const { registryTargetPath } = await import("../registry/publication.js");
const { result, item } = await addToProject({
name: opts.blockName,
projectDir: opts.project.dir,
skipClipboard: true,
source: "studio",
});
for (const warning of result.warnings) {
process.stderr.write(`hyperframes:registry ${warning}\n`);
}
const written = result.written;
rewriteWrittenToHostViewport(opts.project.dir, written);
recordRewrittenInstall(opts.project.dir, written);
// The item's own file first, as add recorded it, since Studio mounts the first .html it gets.
const root = realpath(opts.project.dir);
const primary = primaryInstalledTarget(item);
const primaryPath = registryTargetPath(root, primary);
const others = written
.filter((abs) => abs !== primaryPath)
.map((abs) => relative(root, abs).split(sep).join("/"));
return {
written: written.includes(primaryPath) ? [primary, ...others] : others,
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,
browserGpuMode,
version,
});
};
return serve();
});
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
const serve = async () => {
const runtimeSource =
(await loadRuntimeSource()) ?? readBundleFile(runtimePath)?.toString("utf-8") ?? null;
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
"Content-Type": getMimeType("runtime.js"),
"Cache-Control": "no-store",
});
};
return serve();
});
// Bundled GSAP for preview, so opening Studio makes no jsDelivr request.
app.get(`${GSAP_LOCAL_ROOT}/:pkg/dist/:file`, (c) => {
const file = c.req.param("file");
const body =
gsapVendorDir &&
c.req.param("pkg") === `gsap@${GSAP_CDN_VERSION}` &&
GSAP_VENDOR_FILES.has(file)
? readBundleFile(join(gsapVendorDir, file))
: null;
if (!body) return c.text("not found", 404);
return c.body(body, 200, {
"Content-Type": getMimeType(file),
"Cache-Control": "public, max-age=31536000, immutable",
});
});
// 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.
//
// Deliberately does NOT serve `bucketSeed`. Studio gets its canary answers
// from the injected `window.__HF_CLI_CANARY_DECISIONS` (booleans, not the
// value cohorts derive from), so nothing needs the seed over HTTP — and an
// unauthenticated local endpoint is a strictly worse place for it than an
// inline script scoped to Studio's own document.
//
// Host-guarded against DNS rebinding: a remote page can point a hostname it
// controls at 127.0.0.1 and read this response as same-origin. Pinning the
// Host header to a loopback name means such a request (which carries the
// attacker's hostname) is refused. Same-origin Studio traffic always
// presents the bound loopback host.
app.get("/api/telemetry-identity", (c) => {
if (!identityAllowed(c.req.header("host"))) {
return c.json({ error: "forbidden" }, 403);
}
// Same request-boundary refresh the head-script route does: this endpoint
// is polled by a long-lived Studio tab, so a cached posture here outlives
// an opt-out run in another terminal just as visibly.
refreshTelemetryPosture();
return c.json({ distinctId: resolveCliTelemetryDistinctId() });
});
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = (path: string) => {
const absPath = resolve(projectDir, path);
let version: string | null = null;
try {
version = fileContentVersion(readFileSync(absPath));
} catch {
// A deletion has no current bytes to match against an API write receipt.
}
// `version` ships even when no receipt matches: it is the client's only
// identity for an unlabelled change, and without it every duplicate
// delivery of one watcher event drains and reloads again.
const receipt = identifyFileWrite(absPath, version ?? DELETED_VERSION);
const reloads = affectsPreview(projectDir, path);
// `projectId` so a stale tab — one still pointed at a project this
// server no longer serves, because `hyperframes preview` reused this
// port for a different folder (see ProjectUnreachableBanner's doc
// comment) — can tell "my project changed" from "some OTHER project,
// now served on this same connection, changed". Every subscriber on
// this port shares one `/api/events` stream regardless of which
// project their tab was opened for; without this field a stale tab
// reloads its preview and re-reads its composition on every save the
// CURRENT project makes, 404ing each time.
stream
.writeSSE({
event: "file-change",
data: JSON.stringify({
path,
version,
projectId: project.id,
affectsPreview: reloads,
// Which thumbnails this write can change; null means all of them.
affectedCompositions: reloads ? compositionsAffectedBy(projectDir, path) : [],
...receipt,
}),
})
.catch(() => {});
};
// Re-applied here because the watcher now also emits the signature
// manifest files, which must not trigger a browser reload.
const wrappedListener = (changedPath: string) => {
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
};
watcher.addListener(wrappedListener);
stream.onAbort(() => watcher.removeListener(wrappedListener));
try {
while (true) {
await stream.sleep(30000);
}
} finally {
watcher.removeListener(wrappedListener);
}
});
});
// ── Encoder availability, asked before Export is offered ────────────────
// The render route below already refuses without FFmpeg, but discovering at
// export time that the encoder was never installed is the worst possible
// moment: the user has already built the whole composition. Studio asks here
// when the Render panel opens so it can say so up front, with the same
// per-platform install command `doctor` prints.
//
// Only a passing result is cached. A user who reads the prompt, installs
// FFmpeg and hits Recheck has to get a fresh answer, or the fix they just
// applied is invisible until they restart Studio.
let ffmpegReady = false;
app.get("/api/environment/ffmpeg", async (c) => {
if (ffmpegReady) return c.json({ ok: true });
const [{ runEnvironmentChecks }, { getFFmpegInstallCommand }] = await Promise.all([
import("../browser/preflight.js"),
import("../browser/ffmpeg.js"),
]);
// With every optional check off this is exactly the FFmpeg and ffprobe
// pair — the same two `doctor` runs. ffprobe matters on its own: it ships
// with FFmpeg but is a separate binary, and a project with any media asset
// fails at probe time without it.
const { outcomes } = await runEnvironmentChecks();
const failed = outcomes.find((outcome) => !outcome.ok);
if (!failed) {
ffmpegReady = true;
return c.json({ ok: true });
}
return c.json({
ok: false,
title: failed.title ?? `${failed.name} not found`,
detail: failed.detail,
hint: failed.hint,
command: getFFmpegInstallCommand(),
});
});
mountDesktopRoutes(app, projectDir);
// ── 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 = (cacheControl: string) => (c: Context) => {
const filePath = resolve(studioDir, c.req.path.slice(1));
// Percent escapes can decode into separators and dot segments before this
// resolve, so a hostile request can name files above the bundle
// directory. Containment stays lexical on purpose: bundle assets may sit
// behind symlinked directories (same tradeoff as the preview asset
// route), but dot segments must never collapse outside the bundle root.
if (!isWithinProjectRoot(studioDir, filePath)) return c.text("not found", 404);
const content = readBundleFile(filePath);
if (content === null) return c.text("not found", 404);
return new Response(content, {
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": cacheControl },
});
};
app.get("/assets/*", serveStudioStaticFile(IMMUTABLE_CACHE_CONTROL));
app.get("/icons/*", serveStudioStaticFile("no-store"));
app.get("/favicon.svg", serveStudioStaticFile("no-store"));
// ── 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<string, string> = {};
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 `<script>window.__HF_STUDIO_ENV__=${JSON.stringify(overrides)};</script>`;
}
// SPA fallback
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
const indexContent = readBundleFile(indexPath);
if (indexContent === null) {
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,
);
}
let html = indexContent.toString("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.
//
// Host-guarded for the same reason /api/telemetry-identity is, and it has
// to be checked HERE too: guarding only the endpoint leaves this route as
// an open side door, since a rebound origin can simply fetch `/` and read
// the same distinct id and seed out of the returned HTML.
//
// Only IDENTITY is withheld from an untrusted Host. The canary decisions
// map still goes out — it is non-identifying, and a LAN/remote Studio
// (`HYPERFRAMES_PREVIEW_HOST=0.0.0.0`) needs it to stay in agreement with
// the CLI. See buildStudioHeadScriptsForHost.
const headScript = buildStudioHeadScriptsForHost(buildRuntimeEnvScript(), c.req.header("host"));
if (headScript) {
html = html.replace("<head>", `<head>${headScript}`);
}
// The shell names the current hashed bundle, so it always revalidates.
// `no-cache` not `no-store`: same refetch without an ETag, but `no-store`
// would blocklist the document from Chrome's bfcache.
return c.html(html, 200, { "Cache-Control": "no-cache" });
});
const shutdown = async (): Promise<void> => {
shuttingDown = true;
// Commits any open edit window; bounded with the renders below, so a history still opening cannot hold exit.
const closeHistory = histories.closeAll().catch(() => {});
const renders = [...inFlightRenders];
for (const [abortController] of renders) abortController.abort();
const { killTrackedProcesses, closeBrowserPool } = await import("@hyperframes/engine");
killTrackedProcesses();
// Browser close must not wait on renders: a render can outlast preview.ts's
// 3s watchdog, which exits without running this cleanup. closeBrowserPool
// (not drainBrowserPool) also refuses a still-unwinding render's acquire().
const closeBrowsers = Promise.allSettled([
closeThumbnailBrowser().catch(() => {}),
closeBrowserPool().catch(() => {}),
]);
await Promise.race([
Promise.allSettled([...renders.map(([, done]) => done), closeHistory]),
new Promise<void>((resolve) => setTimeout(resolve, RENDER_SHUTDOWN_WAIT_MS).unref()),
]);
await closeBrowsers;
};
return { app, watcher, adapter, shutdown };
}