Repository navigation
fix(player): gate play on real composition readiness, not just duration - #4054
Conversation
… playing The player used to fire ready/autoplay the moment the timeline resolved, even if the composition's video, images or fonts hadn't loaded yet - visible as a flash of missing content or a frozen first frame. It now waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded images, document.fonts.ready) before play() proceeds, showing its own loading overlay (reusing the shader-transition loading panel) while it waits, bounded by an 8s timeout that plays anyway and logs which assets were still stuck. Settles synchronously with no overlay when nothing needs waiting (already loaded, or a cross-origin composition the player has no DOM access to inspect), so today's immediate-autoplay behavior is unchanged for the common case. A new assetsReady getter and assetsready event mirror the gate for hosts that want to observe it.
An internal tracking id leaked into a code comment and a test name in the previous commit. This repo is public; internal references don't belong here.
An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.
Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.
Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.
…t two The generation-counter fix only bumped at two of the six places that set _ready = false ahead of a navigation, reload, or disconnect - src/srcdoc attribute changes, a sandbox-origin policy reload, and a shader-options reload all started a new navigation without invalidating the previous wait, leaving the same race open through those paths. One _invalidateAssetsWait() helper now owns the reset (assetsReady, pendingPlay, generation, the loading attribute, hiding the overlay) and every one of the six sites calls it before starting whatever comes next.
Add a shared composition-readiness gate in packages/core, exported as the @hyperframes/core/composition-readiness subpath. It resolves once every declared input settles (media for now: video/audio/image/font loads), or after a timeout, and calls back synchronously when nothing is pending so a caller that plays immediately after sees the decision already applied. The player element now queues an early play() call instead of dropping it when called before the runtime is ready, and both of Studio's readiness call sites (the direct-adapter path and the message-based fallback) go through one store action, requestTimelineReady, instead of duplicating a generation-counter-and-settle pattern across two hook files.
…ble-firing readiness An independent review of the readiness gate found two real races: play() queued before the runtime was ready re-ran its full body once ready, dispatching the public "play" event twice for one user action. It now dispatches "play" only on the call that actually starts playback. Studio's requestTimelineReady generation guard was never invalidated by a project switch or a full timeline reset, so a slow-loading project's in-flight wait could resolve after the switch and mark the new, unchecked project ready. Resetting the timeline now bumps the generation, and setting timelineReady directly does too.
somanshreddy
left a comment
There was a problem hiding this comment.
Reviewed — core design is sound, but one real (nonblocking, should-fix) bug in the queue-play lifecycle, and it's untested. Holding my stamp on it; CI is all-pending so it can't merge yet regardless. Manual pass (Codex disabled per workspace cap).
Bug: a play queued while assets buffer is not cancelled by pause()/seek(), so it fires against the user's pause when assets settle.
Sequence (heavy comp — the exact scenario this PR targets):
- Assets still buffering. User presses play →
if (this._ready && !this._assetsReady) { this._pendingPlay = true; return; }(hyperframes-player.ts:364). Nothing plays; overlay shows. - User presses pause (or seek). Both set
_paused = truebut neither clears_pendingPlaynor calls_invalidateAssetsWait(that's wired only to_ready = falsesites). - Assets settle →
_settleAssetsReady→if (this._pendingPlay) this.play()(:1062), andplay()has no_pausedguard — it sets_paused = falseand starts playback + dispatchesplay.
Result: the composition plays even though the user paused/seeked while it buffered. The buffering window is precisely the heavy-comp case this PR exists for, so it's reachable, not theoretical.
The same shape applies to the pre-existing runtime-readiness queue (_onProbeReady/_onRuntimeTimelineReady also do if (… || _pendingPlay) this.play() with no _paused guard), so the cleanest fix is at the cancel sites: clear _pendingPlay = false in pause() and seek() — one place, covers both queue paths. (Guarding each retry with !this._paused also works but is the enumerate-every-site version.)
Untested: the asset-ready gate tests (:2765+) cover doc === null, defer-then-play, and the 8s timeout — none exercises pause/seek during the pending window. A test that queues a play, pauses, then settles the asset and asserts isPlaying() === false (and no play event) would fail today.
Verified sound:
- Core
compositionReadinessmodule is well-built — realm-aware asset scan (cross-realm iframeinstanceofhandled), settles oncanplay-or-error(a failed media element can't block), 8s timeout fail-opens so play never hangs,onSettledfires exactly once, synchronous callback when nothing's pending (no regression for ready comps). - Parity holds — player and Studio both consume the one
@hyperframes/core/composition-readinessmodule. Studio'sreadinessSliceguards the async settle with a module-scope generation counter so a superseded wait can't settimelineReadyfor stale content; the player's_waitForAssetsReadyuses the same generation pattern (_assetsGeneration) and_invalidateAssetsWaiton teardown.
Will stamp once the pending checks are green and the pause/seek-cancels-pending gap is closed (or we agree it's a fast-follow).
jrusso1020
left a comment
There was a problem hiding this comment.
Requesting one change at 41704f94e89de3d531ebb43d5e7c1c38665c4298. The core module, the Studio slice and the export wiring are all sound — the single problem is in the player's queue-play lifecycle, and it fires in exactly the window this PR creates.
The change
pause() and seek() don't clear _pendingPlay, so a queued play survives an explicit user pause and then starts playback anyway.
_pendingPlay is set in two places (hyperframes-player.ts:365, :388) and cleared in only two: the top of play() (:368) and _invalidateAssetsWait() (:1069). _invalidateAssetsWait() is called from navigation, disconnect, shader-option change and iframe reload — never from user transport control. pause() (:405) and seek() (:421) both set this._paused = true and neither touches the flag.
So, on a heavy composition inside the up-to-8s asset wait:
_readyis true,_assetsReadyis false.play()early-returns at:364and parks_pendingPlay = true. Nothing visibly starts —_pausedstays true andcontrolsApi.updatePlayingis never called.- The user pauses, or scrubs.
pause()dispatchespause;seek()honours its own documented contract that a seek lands paused. Either way the host now believes playback is stopped, and_pendingPlayis still true. - Assets settle.
_settleAssetsReady(:1062) callsthis.play()with no_pausedguard, which falls straight through the:364early-return (_assetsReadyis now true), sets_paused = false, sendsplayto the runtime and dispatches the publicplayevent.
The composition starts playing against the user's explicit pause, seconds after they stopped it. It's small and it's recoverable, but it makes pause() non-authoritative during the feature's own target scenario, and it is new behaviour — before this PR there was no queue for a pause to lose a race against.
The fix is one place, two lines — clear the flag wherever the user takes control:
pause() {
this._pendingPlay = false;
if (!this._tryDirectTimelinePause()) this._sendControl("pause");and the same first line at the top of seek(). seek() needs it as much as pause() does, since seek's contract is that it lands paused.
What I verified at source
- The ready-ordering the whole gate depends on is correct. Both
_onRuntimeTimelineReady(:983-987) and_onProbeReady(:1001-1005) setthis._ready = truebefore calling_waitForAssetsReady, so by the time the trailingif (autoplay || _pendingPlay) this.play()runs,play()'s:364guard can actually see the pending state. If those were ordered the other way this gate would be a no-op on the autoplay path. - The generation guards hold. In the player, every
_ready = falsesite calls_invalidateAssetsWait()first, and_settleAssetsReadyis idempotent (:1060). In Studio,timelineReadyGenerationis bumped onrequestTimelineReady, onsetTimelineReady, and inresetPlaybackReadinessState()— whichcreateTimelineResetState()spreads, sobeginTimelineSessioninvalidates too. I could not construct a stale settle that lands in a superseded project. - The synchronous-settle claim is real, and it matters:
settleCompositionReadinesscalls back before returning when nothing is pending, which is why_waitForAssetsReadycan checkif (!this._assetsReady)afterwards and skip the loading overlay for the common case rather than flashing it. - The export wiring is complete and the repo's own gates agree.
./composition-readinessis added in all three required places (devexports,publishConfig.exports,package-subpaths.json), consistent with the neighbouring./mediaTimingentry, andtscemitsdist/compositionReadiness.jswith no build-entry registration needed. I ran the gates rather than eyeballing it:check:package-subpaths→ "Package subpaths verified: 12 public workspaces";check:package-cycles→ no new cycle;check:workspace-contracts→ clean;scripts/package-subpaths.test.mjs→ 4/4. packages/corecompositionReadiness.test.ts→ 7/7, matching the test plan.
What I could not run, and the false finding I nearly filed
I could not run packages/player's suite, so the "376/376" and "1,413/1,413" claims are unverified by me.
This one is worth spelling out because it looked like a finding. packages/player/vitest.config.ts carries a hand-maintained alias list, and it covers exactly three specifiers — @hyperframes/core/composition-contract, /slideshow, /runtime/protocol. Those are precisely the three @hyperframes/core subpaths the player imported before this PR. This PR adds a fourth import, @hyperframes/core/composition-readiness, and does not add a fourth alias — and the suite fails for me with Missing "./composition-readiness" specifier in "@hyperframes/core" package, 143 of 154 red. That reads like an exhaustive list with one entry missing.
It isn't. I ran the control before writing it up: in my environment all four of those specifiers fail to resolve, including the three that are fine on main. My borrowed node_modules has no workspace link for @hyperframes/core at all, so nothing resolves through its export map; the alias list was simply masking that for the other three and left the new one exposed. So this is my rig, not your branch, and I am not reporting it as a defect.
The residual, stated as a question rather than a finding: I have no evidence either way about whether that alias list is load-bearing in CI. If your local run of the player suite was against a full bun install, export-map resolution answered and there is nothing to do. Worth one glance, no more.
Non-blocking
collectPendingCompositionAssetsleaks its media listeners on the timeout path (compositionReadiness.ts:49-60).onSettledis the only thing that removes thecanplay/errorpair, so whensettleCompositionReadinessresolves through the 8s timeout instead, those listeners stay attached and the per-element promise stays pending. Bounded and low-impact — but the elements it leaks on are exactly the stuck ones you're already warning about._onProbeReadyhas noif (this._ready) returnguard, while_onRuntimeTimelineReady(:984) does. That asymmetry predates this PR, but it now has a wider blast radius: a late probe callback arriving after the runtime path already won would re-enter_waitForAssetsReady, bump the generation and re-show the loading overlay on a composition that had already settled.- Everything else in the pre-review table I audited independently and agree with; the readiness slice's module-scope generation counter is the right call for state no component renders from.
Happy to re-read as soon as _pendingPlay is cleared on the user-control paths — that alone clears my block.
— Rames
| // fallow-ignore-next-line complexity | ||
| play() { | ||
| if (this._ready && !this._assetsReady) { | ||
| this._pendingPlay = true; |
There was a problem hiding this comment.
pause() and seek() never clear this, and _invalidateAssetsWait() — the only other place that does — is wired to navigation and disconnect, not to transport control.
That leaves the window this PR introduces: play() parks _pendingPlay = true here without starting anything, the user then pauses or scrubs, and when the assets settle _settleAssetsReady (line 1062) calls play() with no _paused guard. Playback starts against the pause, seconds later.
Clear it wherever the user takes control. In pause():
pause() {
this._pendingPlay = false;
if (!this._tryDirectTimelinePause()) this._sendControl("pause");and the same line at the top of seek(), whose own contract is that a seek lands paused.
|
Closing out the vitest-alias question I left open in my review — it's an artifact of my environment, not a defect on this branch. Evidence, in order:
For contrast, Non-blocking nit: a 4th alias would still be worth adding, since without it the player suite exercises the built The — Rames |
A play() queued while assets buffer (_pendingPlay) used to fire once they settled even after an explicit pause or seek during the wait. pause() and seek() now clear the flag, so a user pause/seek wins.
|
Fixed at 39e58ba: pause() and seek() now clear _pendingPlay, same as the Added one test per ordering, per the request:
Mutation-tested: reverting the two _pendingPlay = false lines fails the new Simplify + independent adversarial review recorded against 39e58ba. |
jrusso1020
left a comment
There was a problem hiding this comment.
Approving — this clears my 5244505964.
Delta against the head I blocked (41704f94..39e58ba8): ahead 1, behind 0, two files, +90/−0, of which the product change is two lines.
The fix is the one I asked for, and it is in the better of the two possible places. this._pendingPlay = false at the top of pause() (:409) and seek() (:429) — at the flag rather than at a fire site. That matters more than it looks, because the queued play has three fire sites and my review named one. _settleAssetsReady:1070 was the one I traced; _onRuntimeTimelineReady:995 and _onProbeReady:1013 also do if (this.hasAttribute("autoplay") || this._pendingPlay) this.play(). Clearing at the source covers all three, including the two I never mentioned.
I checked those two for the same bug arriving through the other half of that condition — user pauses during the wait, a settle later plays anyway because autoplay is still on the element — and it is not reachable. Both are load-time readiness handlers (_onRuntimeTimelineReady returns early on this._ready, and both set it), so they run before there is anything for a user to pause, and a composition swap goes through _invalidateAssetsWait(), which clears the flag and then legitimately re-autoplays the new composition.
The new tests discriminate, and I proved it by mutation rather than by reading them. With the fix in place the asset-ready gate block is 9 passed. With only the two assignments removed and the test file untouched: exactly 2 failed, 7 passed — the two failures being the pause case and the seek case. The third new test ("settles a play() called twice while buffering into exactly one playback start (pre-existing idempotency, not the pause/seek cancel)") stays green under the mutation, so its name is honest: it documents behaviour that predates this commit instead of quietly claiming the new one.
One environment note for whoever runs this next: the block fails 9-for-9 with Failed to resolve import "@hyperframes/core/composition-readiness" until packages/core is built, because that export's node condition resolves to dist/compositionReadiness.js and a fresh bun install does not produce it. Every arm failing — including the pre-existing tests — is the only reason I read that as my own rig rather than as the branch.
Review by Rames
Summary
Play now gates on the composition's real declared-media readiness (video/audio/image/font loads), not just on a known duration. Two consumers previously had gaps that could enable Play, or drop a Play click, before what it plays was actually buffered:
<hyperframes-player>web component: an earlyplay()call made before the runtime probe resolved used to be silently dropped. It now queues and retries once ready, and no longer double-fires the publicplayevent while doing so.A new shared module,
@hyperframes/core/composition-readiness, is the single place both consumers ask "is this composition's declared media actually ready," so a future readiness input (compute, paint-idle) has one place to plug into.This ships as a correctness improvement on its own merits. It does not claim to be the fix for a specific reported live bug — that investigation is still open.
What changed
packages/core/src/compositionReadiness.ts(new):scanPendingCompositionAssets,mediaReadinessInput,settleCompositionReadiness— resolves synchronously when nothing is pending, asynchronously with a timeout otherwise.packages/player/src/hyperframes-player.ts:play()queues instead of drops when called before the runtime is ready, and only dispatches the publicplayevent once, on the call that actually starts playback._waitForAssetsReadynow uses the shared gate.packages/studio/src/player/store/readinessSlice.ts(new): a store slice owningrequestTimelineReady(doc), with a generation guard that's invalidated on every timeline reset and on any directsetTimelineReadycall, so a stale in-flight wait can never resolve into a project it was superseded by.packages/studio/src/player/hooks/useTimelineSyncCallbacks.tsanduseTimelinePlayer.ts: both readiness call sites now call the shared store action instead of each keeping its own generation counter.Pre-review pass
An independent reviewer (fresh context, no access to this PR's framing) audited the diff before this was posted, hunting for exactly the two failure classes this kind of change tends to hide: a stale async wait resolving into the wrong state, and a duplicated side effect. It found two real, confirmed defects, both fixed in a follow-up commit with a regression test proving each was reachable before the fix (verified by reverting the fix and re-running):
playevent twice for one early callTest plan
packages/core: 7/7 (compositionReadiness.test.ts)packages/player: 376/376,tsc --noEmitcleanpackages/studio: 1413/1413 (player-area suites),tsc --noEmitcleanplay-event dispatch, one for the stale-readiness-across-project-switch race