From 8144b9dafd0f8b67d5fab9de7935c4038c193602 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 29 Aug 2026 21:04:32 -0400 Subject: [PATCH 1/3] fix(player): keep the parent tick clock alive in a cross-origin iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composition in a cross-origin iframe froze a few seconds in while the player still reported `paused === false`. Three separate things had to be true for it to play, and none of them were. The parent tick clock exists to drive a composition whose own rAF Chromium has throttled, which is the normal state of a cross-origin frame. It was being torn down before it delivered a single tick: - The rAF loop re-read `_paused` every frame and returned permanently on the first `true`. That field has a second writer: the runtime's "state" message, which reports the iframe's playback as of the moment it was posted. A state message already in flight when `play()` runs carries the pre-play `isPlaying: false` and lands a frame later, ending the loop for good. The loop now runs between its explicit start and stop and nothing else decides its lifetime; the end of the timeline, which is the one stop the runtime initiates on its own, is relayed to it explicitly. - The iframe `load` handler reset readiness and cancelled the clock. `load` is the end of subresource fetching, not the start of a document, and the runtime announces its timeline on DOMContentLoaded — so on a composition still fetching images or fonts it arrives after the player is already driving that same document. A new document is always preceded by a `src`/`srcdoc` assignment, which clears `_ready` first, so a load seen while ready has nothing to reset. - With the clock alive, the runtime advanced the timeline but never told the embedder where it had got to: the parent-driven tick seeked but did not post state, so the playhead, `timeupdate` and end-of-playback detection stayed frozen at the last frame the (now throttled) local transport managed to post. The reported runaway on a second, late `play()` was a consequence of the above rather than a clock bug: `TransportClock` is wall-clock and `play()` is idempotent, so time kept running correctly while the starved frame fell behind, and the first tick delivered afterwards caught up in one step. With the clock running from the first `play()` there is nothing to catch up. Verified in Chrome against a runtime-injected composition served same-origin, under `Content-Security-Policy: sandbox allow-scripts`, from a genuinely cross-origin host, and with the cross-origin frame scrolled out of the viewport so Chromium suspends its rAF. All four now hold 1x for the full 15s and end on time; the throttled one previously sat at the same frame for the whole run. The same-origin direct-timeline path is untouched. --- packages/core/src/runtime/init.test.ts | 62 ++++++++++ packages/core/src/runtime/init.ts | 9 ++ .../player/src/hyperframes-player.test.ts | 109 ++++++++++++++++++ packages/player/src/hyperframes-player.ts | 36 ++++-- packages/player/src/playback-state.ts | 6 + .../src/runtime-message-handler.test.ts | 1 + .../slideshow/hyperframes-slideshow.test.ts | 1 + 7 files changed, 217 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 87e467c73c..5ab30026ae 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -3307,3 +3307,65 @@ describe("initSandboxRuntimeModular", () => { }); }); }); + +describe("parent-driven transport tick", () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + beforeEach(() => { + document.body.innerHTML = ""; + (globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {}; + globalThis.CSS.escape ??= (value: string) => value; + // The recursive schedule inside transportTick is short-circuited by the + // runtime's own re-entry guard, so a synchronous rAF runs the local + // transport exactly once and then leaves it stopped — which is the state + // this suite needs: a frame whose own rAF has stopped delivering. + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; + }); + + afterEach(() => { + window.__hfRuntimeTeardown?.(); + document.body.innerHTML = ""; + window.__timelines = {} as Record; + delete window.__player; + delete window.__playerReady; + vi.restoreAllMocks(); + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + function control(action: string, extra: Record = {}): void { + window.dispatchEvent( + new MessageEvent("message", { + data: { source: "hf-parent", type: "control", action, ...extra }, + }), + ); + } + + it("posts the position a parent tick advanced to", () => { + document.body.innerHTML = `
`; + window.__timelines = {}; + const nowMs = vi.spyOn(performance, "now"); + nowMs.mockReturnValue(1_000); + initSandboxRuntimeModular(); + + const posted: Record[] = []; + vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { + posted.push(message as Record); + }); + + control("play"); + nowMs.mockReturnValue(3_000); + control("tick"); + + const states = posted.filter((m) => m["type"] === "state"); + // 2s of wall clock at the default 30fps canonical rate = frame 60. Without + // the tick reporting the position it advanced to, the embedder only ever + // sees the frame the (now stopped) local transport last posted. + expect(states.at(-1)).toMatchObject({ frame: 60, isPlaying: true }); + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 880c9692bb..6bdbdce20d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3381,7 +3381,16 @@ export function initSandboxRuntimeModular(): void { runAdapters("pause"); syncMediaForCurrentState(); postState(true); + return; } + // The parent drives this tick precisely when our own rAF transport is + // throttled, so nothing else will report the position it just advanced + // to. Without this the composition animates while the embedder's + // playhead, "timeupdate" and end-of-playback detection stay frozen at + // the last frame the local transport managed to post. postState's own + // change/interval filter keeps the message rate the same as the local + // transport's. + postState(false); }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 8b7fadc7a5..a55d5fb4bc 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -2501,3 +2501,112 @@ describe("HyperframesPlayer retained runtime data", () => { expect(() => player.setRuntimeData("captions", () => undefined)).toThrow(); }); }); + +// The parent tick clock is the only thing driving a composition whose own rAF +// Chromium has throttled — which is exactly the cross-origin case. These cover +// the two ways it used to be torn down before it had sent a single tick. +describe("HyperframesPlayer parent tick clock lifetime", () => { + type PlayerInternal = HTMLElement & { + iframe: HTMLIFrameElement; + play: () => void; + pause: () => void; + _ready: boolean; + _duration: number; + _paused: boolean; + _parentTickRaf: number | null; + _onMessage: (event: MessageEvent) => void; + }; + + let player: PlayerInternal; + let frameWindow: Window; + let postSpy: ReturnType; + let frames: FrameRequestCallback[]; + const originalRaf = window.requestAnimationFrame; + const originalCancelRaf = window.cancelAnimationFrame; + + /** Run one animation frame's worth of scheduled callbacks. */ + const advanceFrame = () => { + const due = frames; + frames = []; + for (const cb of due) cb(0); + }; + + const tickCount = () => + postSpy.mock.calls.filter( + (call) => (call[0] as { action?: string } | undefined)?.action === "tick", + ).length; + + const stateMessage = (frame: number, isPlaying: boolean) => + new MessageEvent("message", { + source: frameWindow, + data: { source: "hf-preview", type: "state", frame, isPlaying }, + }); + + beforeEach(async () => { + frames = []; + window.requestAnimationFrame = ((cb: FrameRequestCallback) => { + frames.push(cb); + return frames.length; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((id: number) => { + frames.splice(id - 1, 1); + }) as typeof window.cancelAnimationFrame; + + await import("./hyperframes-player.js"); + player = document.createElement("hyperframes-player") as PlayerInternal; + frameWindow = window; + postSpy = vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined); + Object.defineProperty(player.iframe, "contentWindow", { + configurable: true, + get: () => frameWindow, + }); + document.body.appendChild(player); + player._ready = true; + player._duration = 15; + }); + + afterEach(() => { + player.remove(); + vi.restoreAllMocks(); + window.requestAnimationFrame = originalRaf; + window.cancelAnimationFrame = originalCancelRaf; + }); + + it("keeps ticking when a state message posted before play() lands after it", () => { + player.play(); + // The runtime posts this on the frame before it receives "play", so it + // reports isPlaying: false and arrives while the parent is already playing. + player._onMessage(stateMessage(0, false)); + + advanceFrame(); + advanceFrame(); + + expect(tickCount()).toBe(2); + }); + + it("keeps ticking when the iframe load event lands after the composition is ready", () => { + player.play(); + // A composition still fetching images/fonts announces its timeline on + // DOMContentLoaded and only fires `load` afterwards. + player.iframe.dispatchEvent(new Event("load")); + + advanceFrame(); + + expect(player._ready).toBe(true); + expect(tickCount()).toBe(1); + }); + + it("stops ticking once the composition reports the end of the timeline", () => { + player.play(); + advanceFrame(); + expect(tickCount()).toBe(1); + + // frame 450 at the default 30fps protocol rate = 15s = the full duration. + player._onMessage(stateMessage(450, false)); + advanceFrame(); + advanceFrame(); + + expect(player._parentTickRaf).toBeNull(); + expect(tickCount()).toBe(1); + }); +}); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 94d1a4d277..413a02b06d 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -294,8 +294,8 @@ class HyperframesPlayer extends HTMLElement { this.posterEl?.remove(); this.posterEl = null; if (this._duration > 0 && this._currentTime >= this._duration) this.seek(0); - // Must be set before _startParentTickClock so the RAF loop's `_paused` - // check doesn't immediately self-terminate on the first callback. + // Must be set before the clocks start: DirectTimelineClock polls `_paused` + // and would self-terminate on its first callback otherwise. this._paused = false; const directTimelineStarted = this._tryDirectTimelinePlay(); if (!directTimelineStarted) { @@ -701,14 +701,23 @@ class HyperframesPlayer extends HTMLElement { * Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop). * The runtime's own rAF loop still runs — ticking GSAP twice per frame is * harmless because seekTimelineAndAdapters is idempotent. + * + * The loop runs from `_startParentTickClock` until `_stopParentTickClock`, + * and nothing else decides its lifetime. In particular it must not re-read + * `_paused`: that field has a second writer — the runtime's "state" message, + * which reports the iframe's playback as of the moment it was posted. A state + * message already in flight when `play()` runs carries the pre-play + * `isPlaying: false`, lands a frame later, and would end the loop for good + * (it has no restart path) while the player still reports `paused === false`. + * Cross-origin that leaves nothing driving the composition at all, because + * the throttled iframe rAF this clock exists to replace is not running + * either. Every transition out of playback — pause(), seek(), a new document, + * disconnect, and the end of the timeline — calls `_stopParentTickClock` + * directly instead. */ private _startParentTickClock(): void { this._stopParentTickClock(); const tick = () => { - if (this._paused) { - this._parentTickRaf = null; - return; - } this._sendControl("tick"); this._parentTickRaf = requestAnimationFrame(tick); }; @@ -765,6 +774,7 @@ class HyperframesPlayer extends HTMLElement { play: () => this.play(), getLoop: () => this.loop, media: this._media, + stopPlaybackClock: () => this._stopParentTickClock(), }); } @@ -836,7 +846,19 @@ class HyperframesPlayer extends HTMLElement { } private _onIframeLoad() { - this._ready = false; + // `load` marks the end of a document's subresource fetching, not the start + // of a new document. The runtime announces its timeline on DOMContentLoaded, + // so on a composition with images/fonts/video still in flight this event + // arrives AFTER the player has gone ready and started driving that very + // document — and the teardown below would then cancel the tick clock a + // play() in the "ready" handler had just started. + // + // A genuinely new document is always preceded by assigning `src`/`srcdoc`, + // which clears `_ready` first. So a load seen while ready belongs to the + // document already playing and there is nothing to reset. (A composition + // navigating its own frame would also land here, but cross-origin the + // player cannot observe that in any case.) + if (this._ready) return; this._runtimeBridgeReady = false; this._directTimelineAdapter = null; this._directTimelineClock.stop(); diff --git a/packages/player/src/playback-state.ts b/packages/player/src/playback-state.ts index 02a3f26aaf..4e08926fa1 100644 --- a/packages/player/src/playback-state.ts +++ b/packages/player/src/playback-state.ts @@ -25,6 +25,11 @@ export interface PlaybackStateCallbacks { play: () => void; getLoop: () => boolean; media: ParentMediaManager; + /** End the parent-driven tick clock. Reaching the end of the timeline is the + * only playback stop the runtime initiates by itself, so it is the only one + * that has to be relayed here; every other stop already goes through the + * player's own pause() / seek() / teardown paths. */ + stopPlaybackClock: () => void; } /** @@ -76,6 +81,7 @@ export function applyRuntimeStateMessage( if (completedPlayback) { if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll(); + callbacks.stopPlaybackClock(); next.paused = true; callbacks.updateControlsPlaying(false); callbacks.dispatchEvent(new Event("ended")); diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index f463c5f65c..fd7070b751 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -27,6 +27,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({ setCompositionSize: vi.fn(), sendControl: vi.fn(), getIframeDoc: vi.fn(() => null), + stopPlaybackClock: vi.fn(), }); const stageSizeEvent = (width: unknown, height: unknown, source: object): MessageEvent => diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index 3d40714176..8309cb7b43 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -509,6 +509,7 @@ describe("handleRuntimeMessage scenes seam", () => { seek: () => {}, play: () => {}, getLoop: () => false, + stopPlaybackClock: () => {}, media: { audioOwner: "iframe", promoteToParentProxy: () => {}, From 96adc660a83819fd08486f3f883f375d1dff8084 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 29 Aug 2026 23:05:30 -0400 Subject: [PATCH 2/3] fix(player): one owner for document reassignment, stop the clock on runtime pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on two holes in the previous commit. The `_onIframeLoad` early return rests on "a new document is always preceded by assigning src/srcdoc, which clears readiness first". That was true of the two attributeChangedCallback branches and false of `_reloadShaderOptions`, which reassigns the document on a shader-option change without touching readiness. Flipping `shader-loading` or `shader-capture-scale` on a ready player therefore loaded a genuinely new document that the load handler then skipped the teardown for, leaving a stale bridge flag and no probe running, so it had no path to ready at all. All six reassignment sites now route through `_setIframeSrc` / `_setIframeSrcdoc`, which own clearing readiness, so the load handler's test holds by construction rather than by convention. Collapsing the tick loop to a single owner also removed an accidental backstop. `_paused` was gating the loop, so a runtime that stopped itself short of the end used to end it; the replacement only covered end-of-timeline, which needs `currentTime >= duration`. A composition calling `pause()` on `window.__player` left the loop scheduled forever. That is a leaked rAF and not a divergence: `createRuntimePlayer` returns its transport-backed object whenever a transport is passed, and the runtime always passes one, so the `setIsPlaying` seam that writes `state.isPlaying` without touching the clock is unreachable there. Every `state.isPlaying = false` in the runtime sits in the transport beside a `clock.pause()`, so the composition cannot advance while the player reports paused. The fix covers the whole class instead of the one instance: the raw `isPlaying` from the wire is forwarded to the player, which stops the clock on any runtime-reported stop. A report seen before the runtime has echoed our own play is ignored, since postMessage delivery between two windows is ordered and such a report was posted before the play arrived — that is what keeps the original stale-message immunity intact. All four playback configurations re-measured and unchanged: 1x throughout, ended at ~14.99s of a 15s composition, clock started once and stopped once. --- .../player/src/hyperframes-player.test.ts | 41 +++++++++++ packages/player/src/hyperframes-player.ts | 68 +++++++++++++------ packages/player/src/playback-state.ts | 18 +++-- .../src/runtime-message-handler.test.ts | 2 +- .../slideshow/hyperframes-slideshow.test.ts | 2 +- 5 files changed, 104 insertions(+), 27 deletions(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index a55d5fb4bc..0785868d90 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -2514,7 +2514,9 @@ describe("HyperframesPlayer parent tick clock lifetime", () => { _duration: number; _paused: boolean; _parentTickRaf: number | null; + _runtimeBridgeReady: boolean; _onMessage: (event: MessageEvent) => void; + probe: { start: () => void }; }; let player: PlayerInternal; @@ -2596,10 +2598,49 @@ describe("HyperframesPlayer parent tick clock lifetime", () => { expect(tickCount()).toBe(1); }); + it("stops ticking when the composition pauses itself short of the end", () => { + player.play(); + advanceFrame(); + expect(tickCount()).toBe(1); + // The runtime confirms the play, so the next not-playing report is a real + // stop rather than a message that crossed our own play command. + player._onMessage(stateMessage(30, true)); + + // Composition code calling pause() on `window.__player`: mid-timeline, so + // the completed-playback path never runs. + player._onMessage(stateMessage(60, false)); + advanceFrame(); + advanceFrame(); + + expect(player._parentTickRaf).toBeNull(); + expect(tickCount()).toBe(1); + }); + + it("keeps ticking after a shader-option reload starts a new document", () => { + // _reloadShaderOptions reassigns the iframe document. If that path does not + // clear readiness, the load handler skips the teardown and the fresh + // document is left with no probe and a stale bridge flag. + player.setAttribute("src", "https://composition.example/comp.html"); + player._ready = true; + + const probeStart = vi.spyOn(player.probe, "start"); + + player.setAttribute("shader-loading", "eager"); + expect(player._ready).toBe(false); + + player.iframe.dispatchEvent(new Event("load")); + + expect(probeStart).toHaveBeenCalled(); + expect(player._runtimeBridgeReady).toBe(false); + }); + it("stops ticking once the composition reports the end of the timeline", () => { player.play(); advanceFrame(); expect(tickCount()).toBe(1); + // Playback reports itself as it runs; the runtime cannot reach the end of a + // timeline without having said it was playing on the way there. + player._onMessage(stateMessage(30, true)); // frame 450 at the default 30fps protocol rate = 15s = the full duration. player._onMessage(stateMessage(450, false)); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 413a02b06d..c8d0c88101 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -99,6 +99,11 @@ class HyperframesPlayer extends HTMLElement { private _directTimelineAdapter: DirectTimelineAdapter | null = null; private _directTimelineClock: DirectTimelineClock; private _parentTickRaf: number | null = null; + /** True between sending "play" and the runtime echoing that it is playing. + * postMessage delivery between two windows is ordered, so an + * `isPlaying: false` seen inside that window was posted before our play + * command arrived and describes the state we just left, not a stop. */ + private _awaitingPlayEcho = false; private _media: ParentMediaManager; private _scenes: { id: string; start: number; duration: number }[] = []; private _runtimeFps = 30; @@ -170,10 +175,8 @@ class HyperframesPlayer extends HTMLElement { if (this.hasAttribute("poster")) this.posterEl = setupPoster(this.shadow, this.getAttribute("poster"), this.posterEl); if (this.hasAttribute("audio-src")) this._media.setupFromUrl(this.getAttribute("audio-src")!); - if (this.hasAttribute("srcdoc")) - this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc")!); - if (this.hasAttribute("src")) - this.iframe.src = prepareSrcForElement(this, this.getAttribute("src")!); + if (this.hasAttribute("srcdoc")) this._setIframeSrcdoc(this.getAttribute("srcdoc")!); + if (this.hasAttribute("src")) this._setIframeSrc(this.getAttribute("src")!); // Host-environment audio lock: when the embedding host (e.g. Claude // desktop) drops the `audio-locked` attribute, attributeChangedCallback @@ -206,17 +209,10 @@ class HyperframesPlayer extends HTMLElement { attributeChangedCallback(name: string, _old: string | null, val: string | null) { switch (name) { case "src": - if (val) { - this._ready = false; - this._runtimeBridgeReady = false; - this.iframe.src = prepareSrcForElement(this, val); - } + if (val) this._setIframeSrc(val); break; case "srcdoc": - this._ready = false; - this._runtimeBridgeReady = false; - if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val); - else this.iframe.removeAttribute("srcdoc"); + this._setIframeSrcdoc(val); break; // Reject NaN/zero/negative dimensions the same way the composition // probe does (a typo like width="abc" or width="0" would otherwise @@ -299,6 +295,7 @@ class HyperframesPlayer extends HTMLElement { this._paused = false; const directTimelineStarted = this._tryDirectTimelinePlay(); if (!directTimelineStarted) { + this._awaitingPlayEcho = true; this._sendControl("play"); // Only start the parent tick clock once the composition is ready and // confirmed on the runtime bridge path (not the direct-timeline path). @@ -643,14 +640,40 @@ class HyperframesPlayer extends HTMLElement { private _reloadShaderOptions(): void { if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset(); if (this.hasAttribute("srcdoc")) { - this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || ""); + this._setIframeSrcdoc(this.getAttribute("srcdoc") || ""); return; } if (this.hasAttribute("src")) { - this.iframe.src = prepareSrcForElement(this, this.getAttribute("src") || ""); + this._setIframeSrc(this.getAttribute("src") || ""); } } + /** + * Point the iframe at a new `src` document. + * + * Every reassignment of the iframe's document goes through here or + * `_setIframeSrcdoc`, and clearing readiness is theirs alone. That is what + * makes `_onIframeLoad`'s test sound: a `load` seen while still ready can + * only be the late load of the document already playing, because starting a + * new one clears readiness first. The shader-option reload is why this is a + * method rather than a convention — it reassigns the document too, and when + * it did so without clearing readiness the load handler skipped the teardown + * and left the fresh document with a stale bridge flag and no probe running. + */ + private _setIframeSrc(src: string): void { + this._ready = false; + this._runtimeBridgeReady = false; + this.iframe.src = prepareSrcForElement(this, src); + } + + /** Point the iframe at a new `srcdoc` document, or clear it. See `_setIframeSrc`. */ + private _setIframeSrcdoc(html: string | null): void { + this._ready = false; + this._runtimeBridgeReady = false; + if (html === null) this.iframe.removeAttribute("srcdoc"); + else this.iframe.srcdoc = prepareSrcdocForElement(this, html); + } + private _trySyncSeek(timeInSeconds: number): boolean { try { const win = this.iframe.contentWindow as @@ -711,9 +734,13 @@ class HyperframesPlayer extends HTMLElement { * (it has no restart path) while the player still reports `paused === false`. * Cross-origin that leaves nothing driving the composition at all, because * the throttled iframe rAF this clock exists to replace is not running - * either. Every transition out of playback — pause(), seek(), a new document, - * disconnect, and the end of the timeline — calls `_stopParentTickClock` - * directly instead. + * either. Instead the two ways playback can end each stop it explicitly: the + * player's own pause(), seek(), new-document and disconnect paths call + * `_stopParentTickClock` directly, and a stop the runtime initiates for + * itself — the end of the timeline, or composition code calling pause() on + * `window.__player` — arrives as a playback report and is relayed through + * `onRuntimePlaybackReport`. Enumerating only the first group is what left + * the loop running after a runtime-side pause. */ private _startParentTickClock(): void { this._stopParentTickClock(); @@ -774,7 +801,10 @@ class HyperframesPlayer extends HTMLElement { play: () => this.play(), getLoop: () => this.loop, media: this._media, - stopPlaybackClock: () => this._stopParentTickClock(), + onRuntimePlaybackReport: (isPlaying) => { + if (isPlaying) this._awaitingPlayEcho = false; + else if (!this._awaitingPlayEcho) this._stopParentTickClock(); + }, }); } diff --git a/packages/player/src/playback-state.ts b/packages/player/src/playback-state.ts index 4e08926fa1..286a43fc6b 100644 --- a/packages/player/src/playback-state.ts +++ b/packages/player/src/playback-state.ts @@ -25,11 +25,12 @@ export interface PlaybackStateCallbacks { play: () => void; getLoop: () => boolean; media: ParentMediaManager; - /** End the parent-driven tick clock. Reaching the end of the timeline is the - * only playback stop the runtime initiates by itself, so it is the only one - * that has to be relayed here; every other stop already goes through the - * player's own pause() / seek() / teardown paths. */ - stopPlaybackClock: () => void; + /** The runtime's own view of whether it is playing, forwarded verbatim from + * the wire before any of this function's interpretation of it. The player + * uses it to decide the fate of the parent-driven tick clock, which is the + * one piece of state a runtime-initiated stop must reach: a stop the player + * itself performs already goes through pause() / seek() / teardown. */ + onRuntimePlaybackReport: (isPlaying: boolean) => void; } /** @@ -43,6 +44,12 @@ export function applyRuntimeStateMessage( current: PlaybackState, callbacks: PlaybackStateCallbacks, ): PlaybackState { + // Before any interpretation: a runtime that reports itself stopped is the + // only stop the player cannot see coming, and end-of-timeline is just one + // instance of it. Forwarding the raw flag here covers the whole class, + // including a composition calling pause() on `window.__player` itself. + callbacks.onRuntimePlaybackReport(data.isPlaying); + const rawTime = (data.frame ?? 0) / fps; const currentTime = current.duration > 0 ? Math.min(rawTime, current.duration) : rawTime; const wasPlaying = !current.paused; @@ -81,7 +88,6 @@ export function applyRuntimeStateMessage( if (completedPlayback) { if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll(); - callbacks.stopPlaybackClock(); next.paused = true; callbacks.updateControlsPlaying(false); callbacks.dispatchEvent(new Event("ended")); diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index fd7070b751..218251adac 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -27,7 +27,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({ setCompositionSize: vi.fn(), sendControl: vi.fn(), getIframeDoc: vi.fn(() => null), - stopPlaybackClock: vi.fn(), + onRuntimePlaybackReport: vi.fn(), }); const stageSizeEvent = (width: unknown, height: unknown, source: object): MessageEvent => diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index 8309cb7b43..aaf7c5d044 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -509,7 +509,7 @@ describe("handleRuntimeMessage scenes seam", () => { seek: () => {}, play: () => {}, getLoop: () => false, - stopPlaybackClock: () => {}, + onRuntimePlaybackReport: () => {}, media: { audioOwner: "iframe", promoteToParentProxy: () => {}, From b01bb39ae638471cff89566ad3a10691ae2bb118 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 30 Aug 2026 13:13:17 -0400 Subject: [PATCH 3/3] docs(player): correct what the stale state message leaves behind Review follow-up. The tick-clock docstring said a stale pre-play `state` message ends the loop "while the player still reports `paused === false`". That message runs through `applyRuntimeStateMessage` like any other, which writes `paused = !data.isPlaying`, so the player reports paused at that instant and the parenthetical described a state that never occurs. The reported symptom is real, it is just one report later: nothing actually paused the composition, so the runtime's next state message carries `isPlaying: true` and puts `paused` back to `false`, against a timeline the now-dead loop is no longer advancing. Say that instead of the instant. --- packages/player/src/hyperframes-player.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index c8d0c88101..8383fa0275 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -730,11 +730,15 @@ class HyperframesPlayer extends HTMLElement { * `_paused`: that field has a second writer — the runtime's "state" message, * which reports the iframe's playback as of the moment it was posted. A state * message already in flight when `play()` runs carries the pre-play - * `isPlaying: false`, lands a frame later, and would end the loop for good - * (it has no restart path) while the player still reports `paused === false`. - * Cross-origin that leaves nothing driving the composition at all, because - * the throttled iframe rAF this clock exists to replace is not running - * either. Instead the two ways playback can end each stop it explicitly: the + * `isPlaying: false`, lands a frame later, and would end the loop for good, + * because it has no restart path. Cross-origin that leaves nothing driving + * the composition at all, because the throttled iframe rAF this clock exists + * to replace is not running either. The same message also writes + * `paused = true`, so the player is momentarily consistent with the freeze; + * it is the runtime's next report — still playing, since nothing actually + * paused it — that puts `paused` back to `false` and settles into the shape + * the bug was reported as: a frozen composition on a player that says it is + * playing. Instead the two ways playback can end each stop it explicitly: the * player's own pause(), seek(), new-document and disconnect paths call * `_stopParentTickClock` directly, and a stop the runtime initiates for * itself — the end of the timeline, or composition code calling pause() on