Files
hyperframes/packages/cli/src/server/studioServer.ts
T
Miguel Ángel 95bf333895 fix: stabilize apple master timeline and playback (#419)
## Summary
- preserve authored non-root composition timing before runtime sanitization so Studio can build the correct master timeline for chained subcompositions
- prefer the fresh runtime source in Studio dev so local preview does not serve a stale `/api/runtime.js`
- restrict preserved authored timing inference to the Studio timeline payload instead of the general runtime resolver

## What this fixes
This PR fixes the Apple presentation class of failures where the root `index.html` / `Master` view looked correct at first and then collapsed into an incorrect short timeline.

Before this change:
- the master transport could report a short duration like `0:12` instead of the real deck length (`2:21` in the Apple project)
- composition clips bunched near the start instead of laying out sequentially across the deck
- seeking into later parts of the deck would land in the wrong place or show the wrong active composition
- local Studio debugging could be misleading because dev sometimes served a stale runtime bundle

After this change:
- the master transport reflects the authored composition-chain duration
- master clips resolve linearly across the whole deck
- late seeks land on the correct slide window
- Studio dev uses the current runtime implementation, so local preview matches the branch you are testing

## Root cause
There were two related issues:

1. Studio/master timeline inference lost authored composition timing
- missing timing attrs were treated like `0` instead of `null`
- non-root composition `data-duration` / `data-end` were stripped before Studio timing resolution could use them
- root duration inference trusted an incomplete live timeline window instead of the authored composition chain

2. Preserved authored timing leaked into the general runtime resolver
- preserving authored timing was correct for Studio timeline payload generation
- but using those preserved attrs for normal runtime playback/render resolution caused visual regressions in producer CI
- the follow-up fix keeps authored timing available only for Studio payload collection while normal runtime playback continues to resolve from the real live timeline/media state

## Why the later regression fix was needed
The initial runtime change fixed the Apple master timeline, but it also widened timing inference in the core runtime too far. That caused Dockerized producer regressions because rendered visibility started respecting preserved authored timing where it should have relied on the live resolved runtime state.

The latest commit fixes that by splitting the behavior:
- Studio timeline payload: authored timing allowed
- general runtime resolver: authored timing ignored by default

That preserves the Apple master timeline fix without changing producer render semantics.

## Verification
### Local checks
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts`
- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `cd packages/core && bun run test src/runtime/startResolver.test.ts src/runtime/timeline.test.ts`
- `bun test packages/cli/src/server/studioServer.test.ts --timeout 20000`

### Browser proof
Tested in Studio with `agent-browser` against the Apple presentation project.
- root/master transport now shows `0:00 / 2:21`
- master clip manifest resolves sequentially (`slide-1 -> slide-2 -> slide-3 ...`)
- seeking to `120s` lands on a late slide instead of a collapsed early timeline state
- after refreshing onto the fresh runtime source, the visible later-slide media advanced correctly in local Studio playback

### CI-equivalent regression proof on devbox
The previously failing producer regressions were rerun on devbox using the same Dockerized path GitHub Actions uses:
- `docker build -f Dockerfile.test -t hyperframes-producer:test .`
- `docker run ... hyperframes-producer:test style-1-prod style-5-prod style-9-prod style-12-prod --sequential`

Those previously failing suites all passed after the runtime split fix:
- `style-1-prod`
- `style-5-prod`
- `style-9-prod`
- `style-12-prod`

## Notes
- the Apple project volume tweak stayed local-only for testing and is not part of this PR
- this PR fixes the master/root timeline bug and the runtime regression it introduced; it does not add general subtimeline authoring support
2026-04-23 04:05:11 +02:00

360 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 { loadRuntimeSourceFallback } 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 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 () => {
// Prefer the runtime generated from the current core source over a
// potentially stale copied artifact. This keeps local studio/preview
// sessions aligned with source edits without requiring a manual
// rebuild of the CLI runtime bundle first.
const runtimeSource =
(await loadRuntimeSourceFallback()) ??
(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 };
}