Skip to content

fix(studio): fit preview reset to composition dimensions#1085

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/studio-playhead-zoom-reset
May 27, 2026
Merged

fix(studio): fit preview reset to composition dimensions#1085
miguel-heygen merged 3 commits into
mainfrom
fix/studio-playhead-zoom-reset

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 26, 2026

Problem

Issue #1080 covered two Studio playback/preview failures:

  • A/E playhead jumps should preserve the correct playback state while the timeline is already playing.
  • Canvas reset-to-fit should fill the available preview space in split-screen/narrow windows, including portrait compositions.

After browser testing the PR locally, there was one more playback-state failure in the same user flow: pressing Play and then A from the preview-focused path could leave the visible transport frozen at 0:00 while the control still showed Pause.

What this fixes

  • Makes Studio preview stage sizing derive from the loaded iframe composition dimensions instead of only the optional legacy portrait prop.
  • Lets the player composition probe read dimensions from plain data-width/data-height roots, not only [data-composition-id] roots.
  • Keeps reset-to-fit aligned with the real composition aspect ratio after zoom changes.
  • Makes keep-playing seeks actively resume the preview adapter/RAF loop when Studio state says playback should continue, so the control icon and advancing time stay in sync.
  • Adds regression coverage across Studio preview sizing, player probing, keyboard playback, and keep-playing seek behavior.

Root cause

There were two separate root causes:

  1. Dimension discovery was split across Studio and player paths. NLEPreview computed its fit stage from a portrait prop that the main Studio preview path does not pass, while the player probe only treated [data-composition-id] elements as composition roots. Plain HTML compositions with valid data-width/data-height could therefore preview inside a default 16:9 stage.
  2. seek(..., { keepPlaying: true }) preserved Studio's Zustand isPlaying state but assumed the underlying iframe adapter and RAF loop were still running. In the preview-focus path, the adapter could already be paused, leaving Studio showing Pause while the runtime stayed frozen at 0:00.

Reset zoom was doing what it was told: resetting transform to 100%. The underlying stage dimensions were wrong. The frozen 0:00 case was a real transport-state divergence, not just a misleading test project.

Verification

Local checks

  • bun run --cwd packages/core test -- init
  • bun run --cwd packages/player test -- composition-probe hyperframes-player
  • bun run --cwd packages/studio test -- useTimelinePlayer.seek
  • bun run --cwd packages/studio test -- useTimelinePlayer.seek usePlaybackKeyboard
  • bun run --cwd packages/studio test -- NLEPreview previewZoom usePlaybackKeyboard useTimelinePlayer.seek
  • bun run --cwd packages/core typecheck
  • bun run --cwd packages/player typecheck
  • bun run --cwd packages/studio typecheck
  • bunx oxlint packages/player/src/composition-probe.ts packages/player/src/composition-probe.test.ts packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts packages/studio/src/player/lib/playbackSeek.ts
  • bunx oxfmt --check packages/player/src/composition-probe.ts packages/player/src/composition-probe.test.ts packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts packages/studio/src/player/lib/playbackSeek.ts
  • bun run --cwd packages/core build
  • bun run --cwd packages/player build
  • bun run --cwd packages/studio build
  • git diff --check
  • pre-commit hook passed: filesize, format, lint, fallow, typecheck

Browser verification

Using agent-browser against local Studio:

  • Reproduced the portrait reset issue before the fix: portrait composition root was 1080x1920, but Studio/player preview iframe fit as a 16:9 496x279 stage in a narrow viewport.
  • Verified after the fix: outer stage fit to 217.125x386, and inner iframe fit to 217x385.78 using width: 1080px; height: 1920px; scale(0.200926).
  • Zoomed the canvas, clicked reset zoom, and verified reset button disappeared while the portrait iframe stayed height-fitted.
  • Reproduced the follow-up Play + A freeze in product-promo: parent transport showed Pause at 0:00/0:20, while iframe window.__player.isPlaying() was false and runtime time stayed at 0.
  • Verified after the fix with preview focus: clicked Play, pressed A, and observed the parent control stayed Pause while time advanced past 0:00; CDP also showed window.__player.isPlaying() === true and runtime time advancing.
  • Recorded the final tested flow with agent-browser.

Local proof artifacts:

  • /tmp/hyperframes-1080-artifacts/verified-portrait-fit-final.png
  • /tmp/hyperframes-1080-artifacts/verified-reset-after-fix.png
  • /tmp/hyperframes-1080-artifacts/verified-playhead-a-after-fix.png
  • /tmp/hyperframes-1080-artifacts/issue-1080-browser-proof.webm
  • /tmp/hyperframes-pr-1085-test/screenshot-1779847819316.png
  • /tmp/hyperframes-pr-1085-test/play-a-continues.webm

Notes

Temporary Studio repro projects and generated .thumbnails directories were removed before committing. No generated runtime/player/studio build artifacts are committed.

Closes #1080

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the full NLEPreview.tsx, useTimelinePlayer.ts seek path, playbackSeek.ts, and composition-probe.ts. Both fixes are correct and well-tested; CI is fully green (Studio smoke + all regression shards). A few non-blocking cleanup notes.

Fix 1 — keep-playing seek resumes the adapter/RAF (the frozen-0:00 fix)

The extraction to playbackSeek.ts makes the decision testable, and the logic is sound across every case I traced:

  • keepPlaying && storeWasPlaying && forward && nextTime < duration → actively adapter.play() + startRAFLoop() + setIsPlaying(true). This is the real fix: previously keepPlaying only preserved the store's isPlaying flag and assumed the adapter+RAF were still live — which broke when the preview-focus path had already paused the adapter, leaving Pause-icon + frozen 0:00. Now it re-syncs the adapter to the store state. The "restarts playback when the iframe adapter was paused" test pins exactly this.
  • keepPlaying=falseshouldStopAfterSeek true → stop. ✓
  • keepPlaying from paused store → neither resume nor stop → stays paused (no spurious resume). ✓
  • reverse shuttle → shouldStopAfterSeek true → stop (reverse RAF can't survive a seek). ✓

hf#1076 adjacency (Home's flag): clean. The playhead clamp produces nextTime = max(0, min(duration, time)), and shouldResumeForwardPlaybackAfterSeek gates on nextTime < duration — so a seek clamped to the end won't spuriously resume. The clamp and the resume-check compose correctly; no conflict.

One micro-edge (not worth changing): keepPlaying + playing + nextTime === duration hits neither branch (no resume, no explicit stop). If the RAF was already live it self-terminates at the end; only matters in the already-paused-adapter case seeked exactly to end, which is degenerate.

Fix 2 — composition-dimension-driven fit

resolvePreviewStageSize is a correct contain-fit (try width-constrained, fall to height-constrained on overflow), now driven by the real composition aspect ratio with the legacy portrait prop as fallback. Verified Home's edges against the math: portrait (1080×1920) in a 496×386 space → height-constrained 217.125×386 ✓; landscape → width-constrained; composition dims correctly override the portrait prop. The probe fix (reading plain [data-width][data-height] roots, not only [data-composition-id]) is the right call for plain-HTML compositions.

Non-blocking observations

  1. Duplicate composition-size readers. readCompositionSizeFromDocument (player/composition-probe.ts) and readPreviewCompositionSize (studio/NLEPreview.tsx) are near-identical (same [data-composition-id][data-width][data-height] ?? [data-width][data-height] fallback, same parse+validate). Studio already imports from player (import { Player }), so NLEPreview could call the exported readCompositionSizeFromDocument(iframe.contentDocument) inside its try/catch. Two copies will drift if the validation rules ever change.

  2. Stage size is read single-shot in onLoad. Fine for authored-HTML composition roots (the hyperframes model — root is in the served markup at load). But the player's CompositionProbe already discovers compositionSize and surfaces it via onReady (and it polls until the adapter is ready, so it handles async roots). Threading the probe's compositionSize up to NLEPreview instead of re-reading on onLoad would both dedupe (#1) and be robust to any composition that materializes its root after the load event. Design suggestion, not a bug for the current cases.

  3. fallow-ignore-next-line unused-class-member on runtimeInjected and resolveDirectTimelineAdapterFromWindow in composition-probe.ts — if these are genuinely unused now, prefer deleting over suppressing (suppressed-unused accumulates dead code); if they're public API consumed by tests, the ignore is fine. Quick confirm.

Verdict

Both fixes correct, root causes accurately diagnosed, regression coverage added at the right layers (probe / stage-size / keep-playing seek), and the browser-verification in the PR body matches the code paths. CI green. The three items above are cleanup, not blockers — James/Miguel to merge.

— Rames Jusso (hyperframes)

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — both fixes are correct and the root causes accurately diagnosed. Fix 1 (keep-playing seek) actively re-syncs the iframe adapter + RAF loop to the store's play state, resolving the frozen-0:00/Pause-icon divergence, with the right regression test pinning the adapter-was-paused case; clean composition with the hf#1076 playhead clamp. Fix 2 (reset-to-fit) drives stage sizing from real composition dimensions via a correct contain-fit, verified for portrait/narrow/aspect-mismatch, with the probe now reading plain data-width/data-height roots. CI fully green (Studio smoke + all regression shards). The three notes in my prior review (duplicate composition-size reader, single-shot onLoad read vs the probe's async discovery, fallow unused-member suppressions) are non-blocking cleanup.

@miguel-heygen miguel-heygen force-pushed the fix/studio-playhead-zoom-reset branch from af50679 to dce9bd3 Compare May 27, 2026 03:43
@miguel-heygen miguel-heygen merged commit 3a24aed into main May 27, 2026
27 checks passed
@miguel-heygen miguel-heygen deleted the fix/studio-playhead-zoom-reset branch May 27, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio: Playhead jump (A/E) overrides player state and canvas zoom reset fails in split-screen view

2 participants