Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type RuntimeBridgeControlAction =
| "play"
| "pause"
| "seek"
| "tick"
| "set-muted"
| "set-volume"
| "set-media-output-muted"
Expand Down
47 changes: 45 additions & 2 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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: () => ({
Expand Down Expand Up @@ -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();
Expand Down
Loading