Skip to content

fix(core): restore nested seek scrubbing#404

Merged
miguel-heygen merged 1 commit intomainfrom
codex/fix-product-promo-seek-regression
Apr 22, 2026
Merged

fix(core): restore nested seek scrubbing#404
miguel-heygen merged 1 commit intomainfrom
codex/fix-product-promo-seek-regression

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 22, 2026

What changed

  • restore nested composition scrubbing in the runtime player by re-arming sibling timelines before a master seek and pausing them again afterward
  • add focused regression coverage for both seek() and renderSeek() in packages/core/src/runtime/player.test.ts

Why this changed

  • product-promo scrubbed correctly in 0.3.1 but regressed in 0.4.12
  • the regression came from e72bcfaed303ad977f655f7a3e11dd6641ba95d5, which fixed nested play/pause propagation but left seek propagation incomplete

Root cause

  • after e72bcfae, pausing the master timeline also paused the nested scene timelines (scene1-logo-intro, scene2-4-canvas, scene5-logo-outro), which was correct for playback control
  • however, later Studio scrubs still only called seek() on the master timeline
  • in GSAP, once those child timelines are individually paused, moving the master timeline no longer advances their local playheads automatically
  • the observed result in 0.4.12 was: the scrubber moved, window.__player.seek() ran, main moved to the requested time, but the nested scene timelines stayed pinned at their old local times, so the preview frame was wrong

Why this fix works

  • the fix does not hard-seek sibling timelines to the absolute master time, because that would be wrong for nested scenes with local offsets
  • instead, it temporarily re-arms sibling timelines, seeks the master timeline deterministically, lets GSAP recompute each child timeline to the correct local time, and then pauses the siblings again
  • this preserves the pause semantics introduced in e72bcfae while restoring correct scrub behavior

Verification

  • bun run --filter @hyperframes/core test -- src/runtime/player.test.ts
  • cd packages/core && bun run test:hyperframe-runtime-seek
  • browser verification with agent-browser against product-promo preview
  • A/B check:
  • 0.3.1: scrubbing advanced nested scenes correctly
  • 0.4.12: scrubbing moved the master timeline but left nested scene timelines stuck
  • this branch: scrubbing advances the nested scenes correctly again in Studio

Reviewer notes

  • this is intentionally scoped to the runtime player path only; no unrelated Studio or renderer changes are included in the PR

@miguel-heygen miguel-heygen changed the title [codex] fix nested seek sync in product-promo preview fix: nested seek sync in product-promo preview Apr 22, 2026
@miguel-heygen miguel-heygen marked this pull request as ready for review April 22, 2026 03:47
@miguel-heygen miguel-heygen changed the title fix: nested seek sync in product-promo preview fix(core): restore nested seek scrubbing Apr 22, 2026
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.

Root-cause analysis in the PR body is correct, and the fix shape is right. Walking through the mechanics for the record since this is the kind of thing that's easy to regress again:

The regression chain is:

  1. Pre-e72bcfa: master pause didn't propagate to siblings, so scene animations kept running after the user clicked pause. That was a real bug #359 fixed.
  2. e72bcfa fixed it by calling .pause() on every sibling timeline in window.__timelines whenever the master paused.
  3. The gap: seek didn't get the same treatment. In GSAP, once a child timeline has been individually paused via child.pause(), master's .seek() doesn't advance that child's local playhead on its own — the child's _paused flag gates the propagation. So scrub → master moves, scene visuals stay frozen at their pre-pause state. That's the "scrubbing doesn't work" James saw.

Why this fix works and doesn't introduce a new regression:

The seek path now re-arms siblings via .play() before the master seek, lets the deterministic seek propagate through the master→children graph, then re-pauses in a finally. Semantically that matches what a user expects from "seek": a single paused snapshot of the composition at time T. The setIsPlaying(false) post-seek confirms the player ends in paused state regardless of prior state.

try/finally around the master seek + try/catch around each sibling pause is the right shape — one broken sibling timeline shouldn't poison the whole seek. Nice defensive shape.

To answer James's earlier question ("is e72bcfa still needed?"): yes, keep it. This fix is the counterpart, not a replacement. Reverting #359 would reintroduce the "pause doesn't stop scene animations" bug. Miguel's fix is additive — pause propagation + seek propagation, both required. The two together are what the old (pre-e72bcfa) code got right by accident because master-pause was the only pause happening.

Non-blocking observations worth noting:

  1. Re-arm fires on every sibling regardless of prior state. If a sibling was already playing when seek was called, it gets .play() (probably no-op), then .pause() in the finally. Net effect: any sibling that was playing before the seek is now paused. Consistent with player.seek() being a pause-and-seek operation per setIsPlaying(false), but technically a behavior change from the pre-e72bcfa state where a seek during playback kept siblings running. A test that exercises the "sibling was playing" branch would pin the current contract explicitly — the existing tests both pause first, so the "was-playing" case is implicit.

  2. play()/pause() vs paused(bool) setter. In GSAP, .play() and .pause() walk the timeline's event path and mark it as active. .paused(true) / .paused(false) flip the internal flag without the event overhead. For this use case (round-trip re-arm → seek → re-pause inside a single synchronous block) either works, but the setter form is marginally cheaper and avoids any edge-case where GSAP's play event fires a listener that observes the transient unpaused state. Worth considering if you ever profile scrub latency.

  3. Scope of the sweep. forEachSiblingTimeline(registry, master, ...) walks every entry in window.__timelines except the master. If a composition registers unrelated background timelines there (e.g. an ambient audio timeline not part of the scene graph), they'll also get re-armed and re-paused on every scrub. Probably not an issue today because the convention is that anything in __timelines is scene-related, but worth a comment documenting that invariant so a future author doesn't add an unrelated entry and get surprised.

  4. Master's paused state. seekTimelineDeterministically runs .seek() on the master regardless of its paused state. If the master is paused and .seek() respects the paused flag, child propagation might also be gated. Worth confirming empirically (or via GSAP docs) — but the fact that Miguel verified against product-promo in agent-browser and it works tells me the master-seek-while-paused path is fine in practice.

Test coverage is focused on exactly the scenario that regressed — pause + seek on a nested composition — with both seek() and renderSeek() covered. Scene5 being at 0 when master seeks to 3 (because scene5 starts at 12) is the right edge case to pin.

Approved. Nice turnaround on a subtle regression.

Rames Jusso

@miguel-heygen miguel-heygen merged commit d38a4f1 into main Apr 22, 2026
29 of 44 checks passed
@miguel-heygen miguel-heygen deleted the codex/fix-product-promo-seek-regression branch April 22, 2026 04:13
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.

2 participants