fix: stabilize studio preview and runtime sync (#389)

## Summary
Stabilize the Studio preview/runtime path so timeline data, preview rendering, and thumbnails stay in sync.

This PR includes:
- preview hot-refresh without remounting the iframe
- runtime duration/timeline fixes so Studio stops drifting from playback state
- thumbnail and selector-based preview fixes
- local Studio runtime serving and player-resolution fixes so dev/CI do not depend on prebuilt player artifacts
- tests around preview identity and thumbnail/runtime behavior

## Why This PR Exists
This is the foundation layer for timeline editing. Without it, the editor was prone to:
- iframe remount flashes after saves
- duration mismatches between preview and timeline
- stale or incorrect thumbnails
- CI/test failures when `@hyperframes/player` artifacts were not prebuilt

## Verification
- `bun run --filter @hyperframes/studio test`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/core typecheck`
- `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts`
- `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts`

## Stack
- base of stack
- followed by `feat: add studio timeline editing`
- followed by `fix: smooth scrubber end seeking`
This commit is contained in:
Miguel Ángel
2026-04-22 01:42:48 +02:00
committed by GitHub
parent 4ce1792601
commit 158204343d
19 changed files with 646 additions and 92 deletions
+11
View File
@@ -0,0 +1,11 @@
export async function loadRuntimeSourceFallback(): Promise<string | null> {
try {
const mod = await import("@hyperframes/core");
if (typeof mod.loadHyperframeRuntimeSource === "function") {
return mod.loadHyperframeRuntimeSource();
}
} catch (err) {
console.warn("[studio] Failed to load runtime source fallback:", err);
}
return null;
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
describe("loadRuntimeSourceFallback", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource());
});
});
+37 -6
View File
@@ -10,6 +10,7 @@ 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,
@@ -228,7 +229,31 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}, opts.seekTime);
// Let the seek render settle.
await new Promise((r) => setTimeout(r, 200));
const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer;
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;
@@ -256,11 +281,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
const serve = async () => {
const runtimeSource = existsSync(runtimePath)
? readFileSync(runtimePath, "utf-8")
: await loadRuntimeSourceFallback();
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) => {