fix(studio): preserve playback across forward RAF loop wrap-around#1103
Merged
miguel-heygen merged 1 commit intoMay 28, 2026
Merged
Conversation
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: heygen-com#842 introduced the option for A/E
shortcuts, heygen-com#863 extended it to the runtime player, heygen-com#1089 aligned the
static-seek adapter, and this applies it to the last internal caller
that explicitly resumes after seek.
miguel-heygen
approved these changes
May 28, 2026
Collaborator
miguel-heygen
left a comment
There was a problem hiding this comment.
Fix is correct. { keepPlaying: true } on the forward RAF loop wrap-around seek prevents both adapters from implicitly stopping playback mid-transition:
createStaticSeekPlaybackAdapter: skipsplaying = false+cancelAnimationFramewrapTimeline(GSAP): skips the doubletl.pause()aroundtl.seek()
The subsequent adapter.play() stays as a safety belt. Backward loop seek sites correctly left untouched (terminal-pause and per-tick-on-paused-adapter paths).
Tests cover both the positive case (keepPlaying: true passed + play() called after RAF) and the regression guard (no wrap-around seek when loopEnabled=false).
Minor: the inline comment "play() below is then a no-op" is only true for the happy path — if the adapter drifted paused despite keepPlaying, play() would catch it. Could say "fallback" instead of "no-op." Non-blocking.
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
Pass
{ keepPlaying: true }to the forward RAF loop's wrap-aroundadapter.seek(loopStart)call so the seek doesn't trigger the adapter's implicit pause that the very next line (adapter.play()) has to undo.Background
After the keepPlaying contract was established for the public seek API (#842, #863) and aligned across both adapters (
wrapTimelinehardened in3e7b464b,createStaticSeekPlaybackAdapterin #1089), the internal loop wrap-around inuseTimelinePlayer.ts:232still calls the bare 1-arg form:The intent here is unambiguous: we are inside
loopEnabled && dur > 0, thereturnshort-circuits the no-loop terminal-pause branch below, and the very next statement resumes playback. The default-pause behavior is pure churn.What happens per wrap, per adapter
wrapTimeline(GSAP)tl.pause()→tl.seek()→tl.pause()→tl.play()(4 ops, two of which fight each other)tl.seek();tl.play()is a no-op on an already-playing timeline (1 op)createStaticSeekPlaybackAdapterrenderSeek()→playing = false→cancelAnimationFrame→ thenplay()re-armsplaying = trueand re-schedules RAFrenderSeek()→ rebaseplayStartTime/playStartNow;play()early-returns becauseplayingis still trueThe follow-up
adapter.play()andsetIsPlaying(true)stay as a safety belt — they're cheap no-ops on the happy path but keep the branch correct if the adapter somehow already drifted to paused.Tests
packages/studio/src/player/hooks/useTimelinePlayer.seek.test.tshad no coverage for the RAF wrap-around path (verified by grep: zeroloop/wrap/loopStartreferences across the entire studio test suite for this file). Adds a newdescribe("useTimelinePlayer RAF loop wrap-around")block with:inPoint=2,outPoint=5(which auto-enables loop per fix(studio): auto-enable loop when work-area markers are set #859), callsapi.play(), advances mock adapter time pastoutPoint, drives one captured RAF callback, assertsadapter.seekwas invoked with(2, { keepPlaying: true })andadapter.playran.loopEnabled=false, advances past duration, asserts no seek to loopStart happened andadapter.pause()was called instead (the other branch in the sameif (time >= loopEnd)block).Both tests install/restore a local
requestAnimationFramecapture so RAF callbacks can be driven deterministically; no global state leaks between tests.Validation
bun run --cwd packages/studio testbun run --cwd packages/studio typecheckbunx oxlint(touched files)bunx oxfmt --check(touched files)useTimelinePlayer.tsline countCross-PR check
Branched from
upstream/mainat7be4f92f. Of the open PRs, none touchpackages/studio/src/player/hooks/useTimelinePlayer.ts:seekpath; the loop-wrap line is untouched by that refactor).runtime/,cli/,player/,usePlaybackKeyboard.tsrespectively — no overlap.Out of scope (intentionally)
adapter.seek(loopStart)at line 336 correctly stays on the default (it is followed bysetIsPlaying(false)and is the no-loop terminal pause).if (loopEnabled)branch at line 327) doesn't calladapter.seekdirectly — it resets localstartTime/nextTimeand lets the reverse RAF drive the seek on the next tick with the adapter already paused (adapter.pause()at the start ofplayBackward).play()callback's recover-from-end seek at line 297 was considered but is an edge case (the adapter is usually paused there, so keepPlaying would be inert); leaving it out keeps this PR tight.