From 999e0bed52448ca5a753766cca507c2b5f01ca78 Mon Sep 17 00:00:00 2001 From: Terence Cho Date: Wed, 13 May 2026 14:38:34 -0700 Subject: [PATCH] fix(player): drive composition ticks from widget-frame rAF via postMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium throttles requestAnimationFrame in deeply nested cross-origin iframes. In Claude desktop (Electron), the composition iframe's own rAF loop stalls, so GSAP is never seeked and animation freezes even when TransportClock.isPlaying() is true. The correct fix is to drive ticks from the widget-frame rAF, which lives one level up and is not subject to the same throttling. When play() takes the runtime bridge path (no direct timeline adapter), the player now starts a parent-frame rAF loop that sends "tick" postMessages to the composition iframe on every frame. The runtime's control bridge handles "tick" by calling seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to what transportTick does on each rAF, just driven from outside. The composition iframe's own rAF loop is unchanged and keeps running normally in standard browsers. Seeking GSAP twice per frame is idempotent, so there is no regression on claude.ai or any other non-throttled environment. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/runtime/bridge.test.ts | 8 ++++ packages/core/src/runtime/bridge.ts | 5 +++ packages/core/src/runtime/init.ts | 21 ++++++++++ packages/core/src/runtime/types.ts | 1 + packages/player/src/hyperframes-player.ts | 47 ++++++++++++++++++++++- 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 81f7560d5..7b2f3ee8a 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -6,6 +6,7 @@ function createMockDeps() { onPlay: vi.fn(), onPause: vi.fn(), onSeek: vi.fn(), + onTick: vi.fn(), onSetMuted: vi.fn(), onSetVolume: vi.fn(), onSetMediaOutputMuted: vi.fn(), @@ -110,6 +111,13 @@ describe("installRuntimeControlBridge", () => { expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1); }); + it("dispatches tick command", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("tick")); + expect(deps.onTick).toHaveBeenCalledOnce(); + }); + it("dispatches enable-pick-mode", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index d6f95ef18..0efa5ac4f 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -5,6 +5,7 @@ type BridgeDeps = { onPlay: () => void; onPause: () => void; onSeek: (frame: number, seekMode: "drag" | "commit") => void; + onTick: () => void; onSetMuted: (muted: boolean) => void; onSetVolume: (volume: number) => void; onSetMediaOutputMuted: (muted: boolean) => void; @@ -39,6 +40,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv deps.onSeek(Number(data.frame ?? 0), data.seekMode ?? "commit"); return; } + if (action === "tick") { + deps.onTick(); + return; + } if (action === "set-muted") { deps.onSetMuted(Boolean(data.muted)); return; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2ee2f5eef..438568d6a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1602,6 +1602,27 @@ export function initSandboxRuntimeModular(): void { applyPlaybackRate(rate); if (state.transportClock) state.transportClock.setRate(state.playbackRate); }, + onTick: () => { + if (state.tornDown || !clock.isPlaying()) return; + const t = clock.now(); + state.currentTime = t; + seekTimelineAndAdapters(t); + if (clock.reachedEnd()) { + webAudio.stopAll(); + clock.detachAudioSource(); + clock.pause(); + state.isPlaying = false; + const dur = clock.getDuration(); + if (Number.isFinite(dur)) { + clock.seek(dur); + state.currentTime = dur; + seekTimelineAndAdapters(dur); + } + runAdapters("pause"); + syncMediaForCurrentState(); + postState(true); + } + }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), }); diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 2e10a6d12..f1c1c3872 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -10,6 +10,7 @@ export type RuntimeBridgeControlAction = | "play" | "pause" | "seek" + | "tick" | "set-muted" | "set-volume" | "set-media-output-muted" diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index beb8e0e18..79097e2e5 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -55,6 +55,7 @@ class HyperframesPlayer extends HTMLElement { private _compositionHeight = 1080; private _directTimelineAdapter: DirectTimelineAdapter | null = null; private _directTimelineClock: DirectTimelineClock; + private _parentTickRaf: number | null = null; private _media: ParentMediaManager; constructor() { @@ -134,6 +135,7 @@ class HyperframesPlayer extends HTMLElement { this.iframe.removeEventListener("load", this._onIframeLoad); this.probe.stop(); this._directTimelineClock.stop(); + this._stopParentTickClock(); this._directTimelineAdapter = null; this.shaderLoader.destroy(); this._media.destroy(); @@ -217,10 +219,21 @@ 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. + this._paused = false; const directTimelineStarted = this._tryDirectTimelinePlay(); - if (!directTimelineStarted) this._sendControl("play"); + if (!directTimelineStarted) { + 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). + // Guards against firing ticks into an uninitialized iframe when play() + // is called before the probe has resolved. + if (this._ready && !this._directTimelineAdapter) { + this._startParentTickClock(); + } + } if (this._media.audioOwner === "parent") this._media.playAll(); - this._paused = false; this.controlsApi?.updatePlaying(true); this.dispatchEvent(new Event("play")); if (directTimelineStarted && this._directTimelineAdapter) { @@ -236,6 +249,7 @@ class HyperframesPlayer extends HTMLElement { pause() { if (!this._tryDirectTimelinePause()) this._sendControl("pause"); this._directTimelineClock.stop(); + this._stopParentTickClock(); if (this._media.audioOwner === "parent") this._media.pauseAll(); this._paused = true; this.controlsApi?.updatePlaying(false); @@ -247,6 +261,7 @@ class HyperframesPlayer extends HTMLElement { this._sendControl("seek", { frame: Math.round(timeInSeconds * 30) }); } this._directTimelineClock.stop(); + this._stopParentTickClock(); this._currentTime = timeInSeconds; if (this._media.audioOwner === "parent") this._media.seekAll(timeInSeconds); this._paused = true; @@ -378,6 +393,33 @@ class HyperframesPlayer extends HTMLElement { return this._withDirectTimeline((tl) => void tl.pause()); } + /** + * Widget-frame RAF loop that sends "tick" postMessages to the composition + * iframe on every frame. Used for the runtime bridge path so that animation + * advances even when the composition iframe's own rAF is throttled by + * 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. + */ + private _startParentTickClock(): void { + this._stopParentTickClock(); + const tick = () => { + if (this._paused) { + this._parentTickRaf = null; + return; + } + this._sendControl("tick"); + this._parentTickRaf = requestAnimationFrame(tick); + }; + this._parentTickRaf = requestAnimationFrame(tick); + } + + private _stopParentTickClock(): void { + if (this._parentTickRaf === null) return; + cancelAnimationFrame(this._parentTickRaf); + this._parentTickRaf = null; + } + private _onMessage(e: MessageEvent) { handleRuntimeMessage(e, this.iframe.contentWindow, { getPlaybackState: () => ({ @@ -438,6 +480,7 @@ class HyperframesPlayer extends HTMLElement { private _onIframeLoad() { this._directTimelineAdapter = null; this._directTimelineClock.stop(); + this._stopParentTickClock(); this.shaderLoader.reset(); this._media.resetForIframeLoad(); this.probe.start();