Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 62 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuntimeTimelineLike>;
delete window.__player;
delete window.__playerReady;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});

function control(action: string, extra: Record<string, unknown> = {}): 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 = `<div data-composition-id="main" data-root="true" data-duration="10"></div>`;
window.__timelines = {};
const nowMs = vi.spyOn(performance, "now");
nowMs.mockReturnValue(1_000);
initSandboxRuntimeModular();

const posted: Record<string, unknown>[] = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
posted.push(message as Record<string, unknown>);
});

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 });
});
});
9 changes: 9 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
109 changes: 109 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;
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);
});
});
36 changes: 29 additions & 7 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -765,6 +774,7 @@ class HyperframesPlayer extends HTMLElement {
play: () => this.play(),
getLoop: () => this.loop,
media: this._media,
stopPlaybackClock: () => this._stopParentTickClock(),
});
}

Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions packages/player/src/playback-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions packages/player/src/runtime-message-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ describe("handleRuntimeMessage scenes seam", () => {
seek: () => {},
play: () => {},
getLoop: () => false,
stopPlaybackClock: () => {},
media: {
audioOwner: "iframe",
promoteToParentProxy: () => {},
Expand Down
Loading