mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { installRuntimeControlBridge } from "./bridge";
|
|
|
|
function createMockDeps() {
|
|
return {
|
|
onPlay: vi.fn(),
|
|
onPause: vi.fn(),
|
|
onSeek: vi.fn(),
|
|
onTick: vi.fn(),
|
|
onSetMuted: vi.fn(),
|
|
onSetVolume: vi.fn(),
|
|
onSetMediaOutputMuted: vi.fn(),
|
|
onSetPlaybackRate: vi.fn(),
|
|
onEnablePickMode: vi.fn(),
|
|
onDisablePickMode: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function makeControlMessage(action: string, extra?: Record<string, unknown>) {
|
|
return new MessageEvent("message", {
|
|
data: { source: "hf-parent", type: "control", action, ...extra },
|
|
});
|
|
}
|
|
|
|
describe("installRuntimeControlBridge", () => {
|
|
it("dispatches play command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("play"));
|
|
expect(deps.onPlay).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches pause command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("pause"));
|
|
expect(deps.onPause).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches seek command with frame and mode", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("seek", { frame: 150, seekMode: "drag" }));
|
|
expect(deps.onSeek).toHaveBeenCalledWith(150, "drag");
|
|
});
|
|
|
|
it("seek defaults frame to 0 and seekMode to commit", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("seek"));
|
|
expect(deps.onSeek).toHaveBeenCalledWith(0, "commit");
|
|
});
|
|
|
|
it("dispatches set-muted command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-muted", { muted: true }));
|
|
expect(deps.onSetMuted).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("dispatches set-volume command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-volume", { volume: 0.5 }));
|
|
expect(deps.onSetVolume).toHaveBeenCalledWith(0.5);
|
|
});
|
|
|
|
it("clamps set-volume to [0, 1]", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-volume", { volume: 1.5 }));
|
|
expect(deps.onSetVolume).toHaveBeenCalledWith(1);
|
|
handler(makeControlMessage("set-volume", { volume: -0.5 }));
|
|
expect(deps.onSetVolume).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it("defaults volume to 1 when absent", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-volume"));
|
|
expect(deps.onSetVolume).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("dispatches set-media-output-muted command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-media-output-muted", { muted: true }));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(true);
|
|
handler(makeControlMessage("set-media-output-muted", { muted: false }));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("set-media-output-muted coerces absent flag to false", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-media-output-muted"));
|
|
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("dispatches set-playback-rate command", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-playback-rate", { playbackRate: 2 }));
|
|
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(2);
|
|
});
|
|
|
|
it("defaults playbackRate to 1", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("set-playback-rate"));
|
|
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);
|
|
handler(makeControlMessage("enable-pick-mode"));
|
|
expect(deps.onEnablePickMode).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("dispatches disable-pick-mode", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(makeControlMessage("disable-pick-mode"));
|
|
expect(deps.onDisablePickMode).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("ignores messages from wrong source", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(
|
|
new MessageEvent("message", {
|
|
data: { source: "other", type: "control", action: "play" },
|
|
}),
|
|
);
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages with wrong type", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(
|
|
new MessageEvent("message", {
|
|
data: { source: "hf-parent", type: "state", action: "play" },
|
|
}),
|
|
);
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores null data", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
handler(new MessageEvent("message", { data: null }));
|
|
expect(deps.onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles flash-elements command without crashing", () => {
|
|
const deps = createMockDeps();
|
|
const handler = installRuntimeControlBridge(deps);
|
|
expect(() =>
|
|
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 })),
|
|
).not.toThrow();
|
|
});
|
|
});
|