fix(studio): make static-seek adapter honor keepPlaying option#1089
Merged
miguel-heygen merged 1 commit intoMay 27, 2026
Merged
Conversation
createStaticSeekPlaybackAdapter.seek now accepts the same options as the PlaybackAdapter contract and aligns the default-pause semantics with wrapTimeline (hardened in 3e7b464). Without keepPlaying the adapter clears its `playing` flag and cancels the RAF ticker, so on non-GSAP compositions a scrub during playback no longer leaves the iframe silently advancing while the public seek wrapper marks isPlaying=false. Follow-up to heygen-com#863 review: jrusso called out the type drift and invited a separate PR; this also closes the asymmetry with wrapTimeline.
miguel-heygen
approved these changes
May 27, 2026
Collaborator
miguel-heygen
left a comment
There was a problem hiding this comment.
Approved. I reviewed the live head fd2a97e, ran the focused Studio playback tests/typecheck/lint locally, and smoke-tested the keep-playing seek path in Studio with agent-browser. No blockers from my side.\n\nNote before merge: the commit on this PR is currently unverified by GitHub (verification reason: unknown_key). The PR author needs to verify/sign the commit(s) so the branch protection checks can allow the PR to merge.
Contributor
Author
|
Thanks @miguel-heygen — signing key added; |
miguel-heygen
pushed a commit
that referenced
this pull request
May 28, 2026
…1103) When forward playback reaches loopEnd and the loop wraps back to loopStart, the RAF tick was calling `adapter.seek(loopStart)` without keepPlaying, then immediately `adapter.play()` to resume. With the post-3e7b464b wrapTimeline contract (default seek pauses), this means every loop boundary executes pause→seek→pause→play for GSAP and a stop/start RAF ticker cycle for the static-seek adapter — purely unnecessary churn. Pass { keepPlaying: true } so seek skips the implicit pause; the follow-up adapter.play() is then a no-op because the underlying adapter never paused. Adds two tests covering the wrap-around branch (previously uncovered) and the no-loop terminal path as a regression guard. Completes the keepPlaying rollout: #842 introduced the option for A/E shortcuts, #863 extended it to the runtime player, #1089 aligned the static-seek adapter, and this applies it to the last internal caller that explicitly resumes after seek. Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
createStaticSeekPlaybackAdapter.seeknow accepts the{ keepPlaying }option declared by thePlaybackAdaptercontract (playbackTypes.ts:10) and aligns its default-pause semantics withwrapTimeline.seek(recently hardened in3e7b464b, "ensure GSAP timeline stays paused after seek"). Same call sites, same wrapTimeline default — now the static-seek path matches.Background
This is the follow-up I called out as "Out of scope" in #863 and that @jrusso1020 invited in review:
What was wrong
The adapter's
seeksignature was(time) => void, narrower than thePlaybackAdapter.seekcontract. The body also had an asymmetric default vswrapTimeline.seek:wrapTimeline.seek(line 137, post-3e7b464b): default seektl.pause()→tl.seek()→tl.pause().keepPlaying: trueskips both pauses.createStaticSeekPlaybackAdapter.seek(before this PR): default seek keptplaying = true, so the RAF ticker continued callingrenderSeekin the iframe even when the publicuseTimelinePlayer.seekwrapper had already calledstopRAFLoop()+setIsPlaying(false).User-observable on non-GSAP compositions (the path where this adapter is used —
useTimelinePlayer.ts:160fallback whenwin.__timelinesis absent): scrubbing the timeline during playback would leave the iframe content silently advancing while the UI reported "paused".Fix
packages/studio/src/player/lib/playbackAdapter.ts— 7 lines:keepPlaying: truekeeps the existing "rebase playStartTime/playStartNow" behavior so the next tick renders from the new position. Default and explicitkeepPlaying: falsenow clearplaying+ cancel the RAF.Internal callers checked
All
adapter.seek()call sites inpackages/studio/src/player/hooks/useTimelinePlayer.tsare safe under the new default:useTimelinePlayer.ts:199(forward loop wrap-around): immediately callsadapter.play()after the seek, so a pause-on-default seek is replayed in the same tick.useTimelinePlayer.ts:259(replay from end insideplay()): same — followed byadapter.play().useTimelinePlayer.ts:286(playBackwardinit): preceded by an explicitadapter.pause(), so default-pause is a no-op.useTimelinePlayer.ts:307(backward loop end, no-loop terminal): immediately followed bysetIsPlaying(false); default-pause is the desired terminal state.useTimelinePlayer.ts:316(backward tick mid-traversal): the reverse path drives playback through its own RAF (reverseRafRef), not the static-seek internal tick, so cancelling the static-seek tick on every reverse step is harmless.useTimelinePlayer.ts:362(publicseek(time, options)): forwardsoptionsstraight through.keepPlaying: truefrom A/E shortcuts now reaches the static-seek adapter end-to-end.useTimelineSyncCallbacks.ts:161(initial adapter setup): adapter is paused at construction; default-pause is consistent.Tests
packages/studio/src/player/lib/playbackAdapter.test.tsonly coveredwrapTimeline(no coverage forcreateStaticSeekPlaybackAdapter). Adds 7 tests under a newdescribe("createStaticSeekPlaybackAdapter seek keepPlaying option")block, driving the adapter with a deterministic fakeStaticSeekPlaybackClock:isPlaying() === false,cancelAnimationFrameobserved)keepPlaying: truepreserves playback and rebases the ticker so the next frame renders from the new positionkeepPlaying: falsematches the defaultkeepPlaying: truedoes not force playback when the adapter is already paused (intent is preserve, not force play)seek(t)without options matches the contract (still pauses)Validation
bun run --cwd packages/studio testbun run --cwd packages/studio typecheckbunx oxlint(touched files)bunx oxfmt --check(touched files)Cross-PR check
Branched from
upstream/mainat3cd6cd6a. Of the 30 open PRs, only #1085 (@miguel-heygen) touches the seek path — it refactorsuseTimelinePlayer.tsto extractshouldResumeForwardPlaybackAfterSeek/shouldStopAfterSeekhelpers but does not touchplaybackAdapter.ts. Zero file overlap. #1085's new public-seek logic still forwardsoptionstoadapter.seek(nextTime, options), so the contract this PR establishes is what that refactor relies on.Out of scope (intentionally)
The remaining nits from @jrusso1020's #863 review (sibling play→pause→play churn in
seekMasterAndSiblingTimelinesDeterministically, missingonDeterministicPauseemit onseek(),setIsPlaying(true)asymmetry in thekeepPlayingbranch ofruntime/player.ts) are independent of this adapter and not addressed here.