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
@@ -59,6 +59,7 @@ export const NLELayout = memo(function NLELayout({
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
refreshPlayer,
saveSeekPosition,
} = useTimelinePlayer();
@@ -72,12 +73,13 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().reset();
}
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
// Refresh the existing iframe in place when source files change.
const prevRefreshKeyRef = useRef(refreshKey);
if (refreshKey !== prevRefreshKeyRef.current) {
useEffect(() => {
if (refreshKey === prevRefreshKeyRef.current) return;
prevRefreshKeyRef.current = refreshKey;
saveSeekPosition();
}
refreshPlayer();
}, [refreshKey, refreshPlayer]);
// Wrap onIframeLoad to also notify parent of iframe ref
const onIframeLoad = useCallback(() => {
@@ -351,12 +353,14 @@ export const NLELayout = memo(function NLELayout({
<>
{/* Resize divider */}
<div
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-row-resize transition-colors active:bg-studio-accent/80 z-10"
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
style={{ touchAction: "none" }}
onPointerDown={handleDividerPointerDown}
onPointerMove={handleDividerPointerMove}
onPointerUp={handleDividerPointerUp}
/>
>
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
</div>
{/* Timeline section — fixed height, resizable */}
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { getPreviewPlayerKey } from "./NLEPreview";
describe("getPreviewPlayerKey", () => {
it("keeps the same player identity when only refreshKey changes", () => {
expect(
getPreviewPlayerKey({
projectId: "timeline-edit-playground",
refreshKey: 1,
}),
).toBe(
getPreviewPlayerKey({
projectId: "timeline-edit-playground",
refreshKey: 2,
}),
);
});
it("switches identity when drilling into a different directUrl", () => {
expect(
getPreviewPlayerKey({
projectId: "timeline-edit-playground",
directUrl: "/api/projects/timeline-edit-playground/preview",
}),
).not.toBe(
getPreviewPlayerKey({
projectId: "timeline-edit-playground",
directUrl: "/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html",
}),
);
});
});
@@ -10,6 +10,17 @@ interface NLEPreviewProps {
refreshKey?: number;
}
export function getPreviewPlayerKey({
projectId,
directUrl,
}: {
projectId: string;
directUrl?: string;
refreshKey?: number;
}): string {
return directUrl ?? projectId;
}
export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
@@ -18,7 +29,7 @@ export const NLEPreview = memo(function NLEPreview({
directUrl,
refreshKey,
}: NLEPreviewProps) {
const playerKey = `${directUrl ?? projectId}_${refreshKey ?? 0}`;
const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
return (
<div className="flex flex-col h-full min-h-0">