Files
hyperframes/packages/cli/src/server/studioServer.ts
T
Miguel Ángel ada4e0d289 fix(studio): show each timeline row its own layer instead of a flat bar (#4917)
* fix(studio): show each timeline row its own layer instead of a grey bar

A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.

* fix(studio): read timeline rows from any realm so each keeps its selector

* refactor(studio-server): always isolate the element in a thumbnail clip

* fix(cli): undo a thumbnail's isolation before its page takes the next capture

* chore(core): generate the dom-realm export and trim thumbnail clip comments

* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys

* fix(studio-server): keep faded siblings in a page-owned list so restore is exact

* feat(studio): draw a text layer row as its own live text instead of a captured picture

* refactor(studio): move timeline text reading into its own module

* fix(studio): keep text rows readable and current, and leave painted layers as pictures

* fix(studio): refresh a canvas-edited text row by its own identity and source file

* fix(studio): refresh a canvas-edited text row known only by its data-hf-id
2026-10-03 00:22:49 +00:00

1145 lines
46 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, relative, sep } from "node:path";
import { readBundleFile } from "./readBundleFile.js";
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 { 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 { 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,
type BrowserGpuMode,
type ResolvedBrowserGpuMode,
} from "../browser/gpuPolicy.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;
}
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;
} | null = null;
interface ThumbnailBrowserSession {
browser: import("puppeteer-core").Browser;
requestedGpuMode: BrowserGpuMode;
resolvedGpuMode: ResolvedBrowserGpuMode;
}
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,
};
}
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 acquired = await acquireBrowser(
buildChromeArgs(
{ width: 1920, height: 1080, captureMode: "screenshot" },
{ browserGpuMode: resolvedGpuMode },
),
{ forceScreenshot: true },
);
_thumbnailBrowserLease = acquired;
_thumbnailBrowserModes = { requested: requestedGpuMode, resolved: resolvedGpuMode };
acquired.browser.on("disconnected", () => {
if (_thumbnailBrowserLease !== acquired) return;
_thumbnailBrowserLease = null;
_thumbnailBrowserModes = null;
_thumbnailBrowserInitializing = null;
});
return { browser: acquired.browser, requestedGpuMode, resolvedGpuMode };
} 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;
}
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();
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 (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.
const histories = historyCache(() =>
openProjectHistory({
projectDir,
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
}).catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
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(),
// 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("../../../producer/src/services/deterministicFonts.js");
const { prepareAnimatedGifInputs } =
await import("../../../producer/src/services/animatedGifPrep.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 prepared = await prepareAnimatedGifInputs(html, {
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: string, opts?: { filePath?: string; isSubComposition?: boolean }) {
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",
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 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)$/, 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,
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;
}
if (job.audioLoweredDb !== undefined) state.audioLoweredDb = job.audioLoweredDb;
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,
...(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);
} 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);
try {
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
}
}
})();
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);
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": "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.
//
// 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(),
});
});
// ── 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 };
}