Skip to content

Commit c08e8b2

Browse files
terencechoclaude
andauthored
fix(player): drive composition ticks from widget-frame rAF via postMessage (#805)
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 <noreply@anthropic.com>
1 parent 30348af commit c08e8b2

5 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎packages/core/src/runtime/bridge.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ function createMockDeps() {
66
onPlay: vi.fn(),
77
onPause: vi.fn(),
88
onSeek: vi.fn(),
9+
onTick: vi.fn(),
910
onSetMuted: vi.fn(),
1011
onSetVolume: vi.fn(),
1112
onSetMediaOutputMuted: vi.fn(),
@@ -110,6 +111,13 @@ describe("installRuntimeControlBridge", () => {
110111
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
111112
});
112113

114+
it("dispatches tick command", () => {
115+
const deps = createMockDeps();
116+
const handler = installRuntimeControlBridge(deps);
117+
handler(makeControlMessage("tick"));
118+
expect(deps.onTick).toHaveBeenCalledOnce();
119+
});
120+
113121
it("dispatches enable-pick-mode", () => {
114122
const deps = createMockDeps();
115123
const handler = installRuntimeControlBridge(deps);

‎packages/core/src/runtime/bridge.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type BridgeDeps = {
55
onPlay: () => void;
66
onPause: () => void;
77
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
8+
onTick: () => void;
89
onSetMuted: (muted: boolean) => void;
910
onSetVolume: (volume: number) => void;
1011
onSetMediaOutputMuted: (muted: boolean) => void;
@@ -39,6 +40,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
3940
deps.onSeek(Number(data.frame ?? 0), data.seekMode ?? "commit");
4041
return;
4142
}
43+
if (action === "tick") {
44+
deps.onTick();
45+
return;
46+
}
4247
if (action === "set-muted") {
4348
deps.onSetMuted(Boolean(data.muted));
4449
return;

‎packages/core/src/runtime/init.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,6 +1602,27 @@ export function initSandboxRuntimeModular(): void {
16021602
applyPlaybackRate(rate);
16031603
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
16041604
},
1605+
onTick: () => {
1606+
if (state.tornDown || !clock.isPlaying()) return;
1607+
const t = clock.now();
1608+
state.currentTime = t;
1609+
seekTimelineAndAdapters(t);
1610+
if (clock.reachedEnd()) {
1611+
webAudio.stopAll();
1612+
clock.detachAudioSource();
1613+
clock.pause();
1614+
state.isPlaying = false;
1615+
const dur = clock.getDuration();
1616+
if (Number.isFinite(dur)) {
1617+
clock.seek(dur);
1618+
state.currentTime = dur;
1619+
seekTimelineAndAdapters(dur);
1620+
}
1621+
runAdapters("pause");
1622+
syncMediaForCurrentState();
1623+
postState(true);
1624+
}
1625+
},
16051626
onEnablePickMode: () => picker.enablePickMode(),
16061627
onDisablePickMode: () => picker.disablePickMode(),
16071628
});

‎packages/core/src/runtime/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type RuntimeBridgeControlAction =
1010
| "play"
1111
| "pause"
1212
| "seek"
13+
| "tick"
1314
| "set-muted"
1415
| "set-volume"
1516
| "set-media-output-muted"

‎packages/player/src/hyperframes-player.ts‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class HyperframesPlayer extends HTMLElement {
5555
private _compositionHeight = 1080;
5656
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
5757
private _directTimelineClock: DirectTimelineClock;
58+
private _parentTickRaf: number | null = null;
5859
private _media: ParentMediaManager;
5960

6061
constructor() {
@@ -134,6 +135,7 @@ class HyperframesPlayer extends HTMLElement {
134135
this.iframe.removeEventListener("load", this._onIframeLoad);
135136
this.probe.stop();
136137
this._directTimelineClock.stop();
138+
this._stopParentTickClock();
137139
this._directTimelineAdapter = null;
138140
this.shaderLoader.destroy();
139141
this._media.destroy();
@@ -217,10 +219,21 @@ class HyperframesPlayer extends HTMLElement {
217219
this.posterEl?.remove();
218220
this.posterEl = null;
219221
if (this._duration > 0 && this._currentTime >= this._duration) this.seek(0);
222+
// Must be set before _startParentTickClock so the RAF loop's `_paused`
223+
// check doesn't immediately self-terminate on the first callback.
224+
this._paused = false;
220225
const directTimelineStarted = this._tryDirectTimelinePlay();
221-
if (!directTimelineStarted) this._sendControl("play");
226+
if (!directTimelineStarted) {
227+
this._sendControl("play");
228+
// Only start the parent tick clock once the composition is ready and
229+
// confirmed on the runtime bridge path (not the direct-timeline path).
230+
// Guards against firing ticks into an uninitialized iframe when play()
231+
// is called before the probe has resolved.
232+
if (this._ready && !this._directTimelineAdapter) {
233+
this._startParentTickClock();
234+
}
235+
}
222236
if (this._media.audioOwner === "parent") this._media.playAll();
223-
this._paused = false;
224237
this.controlsApi?.updatePlaying(true);
225238
this.dispatchEvent(new Event("play"));
226239
if (directTimelineStarted && this._directTimelineAdapter) {
@@ -236,6 +249,7 @@ class HyperframesPlayer extends HTMLElement {
236249
pause() {
237250
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
238251
this._directTimelineClock.stop();
252+
this._stopParentTickClock();
239253
if (this._media.audioOwner === "parent") this._media.pauseAll();
240254
this._paused = true;
241255
this.controlsApi?.updatePlaying(false);
@@ -247,6 +261,7 @@ class HyperframesPlayer extends HTMLElement {
247261
this._sendControl("seek", { frame: Math.round(timeInSeconds * 30) });
248262
}
249263
this._directTimelineClock.stop();
264+
this._stopParentTickClock();
250265
this._currentTime = timeInSeconds;
251266
if (this._media.audioOwner === "parent") this._media.seekAll(timeInSeconds);
252267
this._paused = true;
@@ -378,6 +393,33 @@ class HyperframesPlayer extends HTMLElement {
378393
return this._withDirectTimeline((tl) => void tl.pause());
379394
}
380395

396+
/**
397+
* Widget-frame RAF loop that sends "tick" postMessages to the composition
398+
* iframe on every frame. Used for the runtime bridge path so that animation
399+
* advances even when the composition iframe's own rAF is throttled by
400+
* Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop).
401+
* The runtime's own rAF loop still runs — ticking GSAP twice per frame is
402+
* harmless because seekTimelineAndAdapters is idempotent.
403+
*/
404+
private _startParentTickClock(): void {
405+
this._stopParentTickClock();
406+
const tick = () => {
407+
if (this._paused) {
408+
this._parentTickRaf = null;
409+
return;
410+
}
411+
this._sendControl("tick");
412+
this._parentTickRaf = requestAnimationFrame(tick);
413+
};
414+
this._parentTickRaf = requestAnimationFrame(tick);
415+
}
416+
417+
private _stopParentTickClock(): void {
418+
if (this._parentTickRaf === null) return;
419+
cancelAnimationFrame(this._parentTickRaf);
420+
this._parentTickRaf = null;
421+
}
422+
381423
private _onMessage(e: MessageEvent) {
382424
handleRuntimeMessage(e, this.iframe.contentWindow, {
383425
getPlaybackState: () => ({
@@ -438,6 +480,7 @@ class HyperframesPlayer extends HTMLElement {
438480
private _onIframeLoad() {
439481
this._directTimelineAdapter = null;
440482
this._directTimelineClock.stop();
483+
this._stopParentTickClock();
441484
this.shaderLoader.reset();
442485
this._media.resetForIframeLoad();
443486
this.probe.start();

0 commit comments

Comments
 (0)