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
+19 -5
View File
@@ -461,15 +461,18 @@ export function collectRuntimeTimelinePayload(params: {
}
// ── Persistent overlays ─────────────────────────────────────────────────
// Direct children of root with an ID that weren't picked up by either the
// DOM query or GSAP introspection are persistent overlays (e.g. grid, border
// decorations). Show them as full-duration clips on their own track.
// Direct children of root that are pure structural overlays should only
// surface in the timeline when authors explicitly opt them in. Otherwise
// background layers like "backdrop" make the whole composition read as a
// long clip, which is misleading in Studio.
if (root && rootCompositionDuration != null && rootCompositionDuration > 0) {
const overlayTrack = clips.length > 0 ? Math.max(...clips.map((c) => c.track)) + 1 : 0;
for (const child of root.children) {
const el = child as HTMLElement;
if (!el.id) continue;
if (gsapClipIds.has(el.id)) continue;
const timelineRole = el.getAttribute("data-timeline-role");
if (timelineRole !== "overlay" && timelineRole !== "persistent-overlay") continue;
const tag = el.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
// Skip elements that are invisible (display:none in their CSS class)
@@ -500,7 +503,7 @@ export function collectRuntimeTimelinePayload(params: {
nodePath: null,
compositionSrc: null,
assetUrl: null,
timelineRole: el.getAttribute("data-timeline-role"),
timelineRole,
timelineLabel: el.getAttribute("data-timeline-label"),
timelineGroup: el.getAttribute("data-timeline-group"),
timelinePriority: parseNum(el.getAttribute("data-timeline-priority")),
@@ -536,7 +539,18 @@ export function collectRuntimeTimelinePayload(params: {
avatarName: null,
});
}
const safeDuration = Math.max(1, Math.min(maxEnd || 1, params.maxTimelineDurationSeconds));
// Timeline payload duration should reflect the playable composition window,
// not just the furthest currently-surfaced clip. Studio can intentionally
// hide structural/background tracks from the timeline UI; if we collapse the
// payload duration down to the last visible clip end, the controls jump even
// though playback still runs for the full authored root duration.
const safeDuration = Math.max(
1,
Math.min(
Math.max(maxEnd || 1, rootCompositionDuration ?? 0),
params.maxTimelineDurationSeconds,
),
);
const shouldEmitNonDeterministicInf = timelineLooksLoopInflated && attrDurationCandidate == null;
const durationInFrames = shouldEmitNonDeterministicInf
? Number.POSITIVE_INFINITY