Skip to content

feat(studio): a host can hold playback so nothing starts while it is held - #5060

Merged
miguel-heygen merged 7 commits into
mainfrom
dvolume/studio-play-lock
Oct 5, 2026
Merged

miguel-heygen merged 7 commits into
mainfrom
dvolume/studio-play-lock

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

A host app can hold Studio's playback: usePlayerStore.getState().setPlayLocked(true). While it is held nothing starts the film, and turning the hold on pauses a film that is playing. Seeking still moves the picture, but the scrub audio stays silent. setPlayLocked(false) gives playback back.

Why

A host that edits the project underneath the preview (for example, an agent rewriting the composition) needs the film to stay still until it finishes. Pausing after a start is too late, because a frame can advance and audio can blip first. So the hold is checked where each start happens, before anything plays.

How

  • playLocked and setPlayLocked live in the readiness slice next to timelineReady, which already gates Play the same way. The host owns the hold, so a timeline reset leaves it alone.
  • useTimelinePlayer checks the hold in play() (this covers the Play button, Space and playback requests) and in the seek that would keep playing, which also skips scrub audio while held. The loop restart in useTimelinePlayerLoop stops at the loop's end instead. The J/L shuttle in usePlaybackKeyboard returns before it changes the speed. A store subscription pauses when the hold turns on.
  • pause() now stops the frame loop and clears the playing flag even before the preview has a player, so a hold set in that window cannot leave the bar reading Pause.

Before

On main, with playLocked set on the store, Space plays the film anyway (60f to 135f in 2.5 s). Studio's own decision-tree example, dev build, time shown in frames.

Before: held, Space plays

playlock-before.mp4

After

On this branch, with setPlayLocked(true), Space leaves the film at 60f; after setPlayLocked(false), Space plays it (60f to 135f).

After: held, Space does nothing

playlock-after.mp4

Tests

useTimelinePlayer.playLock.test.ts: while the hold is on, Play, a playback request, J and a keep-playing seek start nothing; a held seek moves the picture with no scrub audio; a looping film stops at its end; J and L keep the chosen speed; a hold set before the preview has a player still clears playing; turning it on pauses; turning it off lets Play start again.

The hold covers the film only. Sidebar and asset previews keep their own players.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2040 (base branch 2040), smooth 1601 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • crop-none-px-r0-nested-z100: tracking 0.04, pressJump 0, drop 40.07, reload 40.07, render 40.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.08, reload 0.08, render 0.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.08, reload 0.08, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 10:46

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review at 621ca937 (full PR). No blockers.

Start paths vs the hold. I traced each call site that can start the film or its audio at this head:

  • play() at useTimelinePlayer.ts:203 is guarded. That covers the Play button, Space/togglePlay, forward shuttle and playbackRequest(true) (FX audition, and the usePreviewInteraction.ts:108 resume after a drag).
  • The keep-playing seek resume (playbackSeek.ts, :326) is guarded.
  • The loop restart (useTimelinePlayerLoop.ts:63) is guarded and falls through to the end-of-film stop.
  • Reverse shuttle (playBackward) is only reachable through shuttle, which is guarded at usePlaybackKeyboard.ts:63.
  • Scrub audio (applyScrub) is only reached via scrubMusicAtSeek at :321, which is guarded.
  • previewFrameStore.ts:19 wraps the adapter, and its play only runs from the guarded play().

I found no unguarded start site. The new scrubMusicAtSeek condition (!keepPlaying && !playLocked) matches the old !shouldResumeAfterSeek && !keepPlaying plus the hold, because resume already implied keepPlaying. The hold isn't persisted (no persist in the player store), so it can't survive a reload.

Lows (non-blocking):

  1. Turning the hold on doesn't call stopScrubPreviewAudio(). A scrub blip already sounding plays out its ≤140 ms tail (timelineIframeHelpers.ts stop timer). This is the "audio can blip first" case the PR describes, but bounded. A one-line stop in the new subscription at :354 would close it.
  2. A playbackRequest(true) made while held is dropped silently. That's correct for a hold, but usePreviewInteraction's post-drag resume and an FX-audition hover don't come back when the hold is released. That matches "plays once released" only for an explicit Play. Worth one line in the host-facing doc for setPlayLocked.

Mutations. 8 mutants against useTimelinePlayer.playLock.test.ts + src/player/lib; 8 caught, 0 survived:

  • dropped play() guard;
  • dropped playLocked in shouldResumeForwardPlaybackAfterSeek;
  • scrub guard removed;
  • loop guard removed;
  • shuttle guard removed;
  • lock-on pause subscription disabled;
  • old early-return pause() without an adapter;
  • playLocked: false passed to the seek resume.

The tree was restored and verified clean after each one.

CI at 621ca937: 64 pass, 22 pending, 6 skipping, 0 failing at review time.

What I ran: a read of the full runtime diff and a grep of every play() / setIsPlaying(true) / scrub site in packages/studio/src. Then vitest run on the playLock test + src/player/lib: 22 files, 211 tests passing. Then the 8 mutants. I didn't view the Before/After videos.

This is a comment, not an approval.

— Somu

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at 621ca937. Approving.

Every start path checks the hold. The only places that start the film are the three adapter.play() calls:

  • play() at useTimelinePlayer.ts:215
  • the keep-playing seek at :326
  • the loop restart at useTimelinePlayerLoop.ts:67

All three are now behind the hold. Everything else funnels into them:

  • The Play button and Space.
  • requestPlayback(true), which covers the FX audition and the resume after a preview drag.
  • The shadow-reload hand-over (handOverPlayback, :439), which calls the guarded play().
  • The previewFrameStore wrapper.

The reverse shuttle only runs through shuttle, and that now returns while the hold is on. Scrub audio is only reached through scrubMusicAtSeek, and that call is guarded at :321. The new scrub condition is the old one plus the hold, since resuming already required keepPlaying. CompositionsTab and AudioRow start their own players, which matches the PR's note that the hold covers the film only.

Reset and persistence. createTimelineResetState doesn't touch playLocked, so a timeline reset leaves the host's hold in place, as described. The player store has no persist, so a reload doesn't bring a hold back.

Tests. I ran src/player three times: 179 files and 2211 tests passed each time. I then undid each guard on its own and ran the new test file. All 7 changes were caught:

  • the play() guard
  • the resume guard
  • the scrub guard
  • the loop guard
  • the shuttle guard
  • the subscription that pauses when the hold turns on
  • the pause() change for when there's no player yet

CI has 69 checks passing and 0 failing. The edit-accuracy shards were still running when I posted.

Reuse / simplification. This copies the existing timelineReady gate and adds one flag next to it, so there's nothing to reuse and nothing to cut.

Non-blocking:

  1. I agree with the earlier note: a one-line stopScrubPreviewAudio() in the new subscription would cut off a scrub sound that's already playing when the hold turns on. Without it, that sound runs out on its own within 140 ms.
  2. Hosts reach the hold through the raw store, while usePlayerHandle is the documented way for a host to read and control the player. Adding playLocked/setPlayLocked to the handle later would keep that in one place.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 6bb85af Oct 5, 2026
94 checks passed
@miguel-heygen
miguel-heygen deleted the dvolume/studio-play-lock branch October 5, 2026 11:26
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.

3 participants