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..0785868d90 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -2501,3 +2501,153 @@ 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; + _runtimeBridgeReady: boolean; + _onMessage: (event: MessageEvent) => void; + probe: { start: () => 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 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)); + 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..8383fa0275 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 @@ -294,11 +290,12 @@ 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) { + 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 @@ -701,14 +724,31 @@ 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, + * 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 + * `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(); const tick = () => { - if (this._paused) { - this._parentTickRaf = null; - return; - } this._sendControl("tick"); this._parentTickRaf = requestAnimationFrame(tick); }; @@ -765,6 +805,10 @@ class HyperframesPlayer extends HTMLElement { play: () => this.play(), getLoop: () => this.loop, media: this._media, + onRuntimePlaybackReport: (isPlaying) => { + if (isPlaying) this._awaitingPlayEcho = false; + else if (!this._awaitingPlayEcho) this._stopParentTickClock(); + }, }); } @@ -836,7 +880,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..286a43fc6b 100644 --- a/packages/player/src/playback-state.ts +++ b/packages/player/src/playback-state.ts @@ -25,6 +25,12 @@ export interface PlaybackStateCallbacks { play: () => void; getLoop: () => boolean; media: ParentMediaManager; + /** 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; } /** @@ -38,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; diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index f463c5f65c..218251adac 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), + 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 3d40714176..aaf7c5d044 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, + onRuntimePlaybackReport: () => {}, media: { audioOwner: "iframe", promoteToParentProxy: () => {},