mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(player): drive src URL timelines without runtime (#673)
* fix(player): drive src URL timelines without runtime * fix(player): pause direct timeline after seek
This commit is contained in:
@@ -790,12 +790,22 @@ describe("HyperframesPlayer seek() sync path", () => {
|
||||
play?: () => void;
|
||||
pause?: () => void;
|
||||
};
|
||||
type TimelineStub = {
|
||||
duration: () => number;
|
||||
time: () => number;
|
||||
seek: (t: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
};
|
||||
type FakeContentWindow = {
|
||||
__player?: SyncPlayerStub;
|
||||
__timelines?: Record<string, TimelineStub>;
|
||||
postMessage?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type PlayerInternal = HTMLElement & {
|
||||
seek: (t: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
iframe: HTMLIFrameElement;
|
||||
_currentTime: number;
|
||||
};
|
||||
@@ -874,6 +884,88 @@ describe("HyperframesPlayer seek() sync path", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeks same-origin __timelines when no runtime bridge exists", () => {
|
||||
const timeline: TimelineStub = {
|
||||
duration: vi.fn(() => 5),
|
||||
time: vi.fn(() => 0),
|
||||
seek: vi.fn(),
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
};
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
||||
|
||||
player.seek(2);
|
||||
|
||||
expect(timeline.seek).toHaveBeenCalledTimes(1);
|
||||
expect(timeline.seek).toHaveBeenCalledWith(2);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plays and pauses same-origin __timelines when no runtime bridge exists", () => {
|
||||
const timeline: TimelineStub = {
|
||||
duration: vi.fn(() => 5),
|
||||
time: vi.fn(() => 0),
|
||||
seek: vi.fn(),
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
};
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
||||
|
||||
player.play();
|
||||
player.pause();
|
||||
|
||||
expect(timeline.play).toHaveBeenCalledTimes(1);
|
||||
expect(timeline.pause).toHaveBeenCalledTimes(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pauses same-origin __timelines after seek while playing", () => {
|
||||
const pause = vi.fn();
|
||||
const timeline: TimelineStub = {
|
||||
duration: vi.fn(() => 5),
|
||||
time: vi.fn(() => 0),
|
||||
seek: vi.fn(),
|
||||
play: vi.fn(),
|
||||
pause,
|
||||
};
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
||||
|
||||
player.play();
|
||||
pause.mockClear();
|
||||
player.seek(2);
|
||||
|
||||
expect(timeline.seek).toHaveBeenCalledWith(2);
|
||||
expect(pause).toHaveBeenCalledTimes(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bypass an installed runtime bridge for direct __timelines playback", () => {
|
||||
const timeline: TimelineStub = {
|
||||
duration: vi.fn(() => 5),
|
||||
time: vi.fn(() => 0),
|
||||
seek: vi.fn(),
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
};
|
||||
const post = vi.fn();
|
||||
stubContentWindow({
|
||||
__player: { play: vi.fn(), pause: vi.fn() },
|
||||
__timelines: { main: timeline },
|
||||
postMessage: post,
|
||||
});
|
||||
|
||||
player.play();
|
||||
player.pause();
|
||||
|
||||
expect(timeline.play).not.toHaveBeenCalled();
|
||||
expect(timeline.pause).not.toHaveBeenCalled();
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "play" }), "*");
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "pause" }), "*");
|
||||
});
|
||||
|
||||
it("falls back to postMessage when __player exists but lacks seek()", () => {
|
||||
// Defensive: a partial `__player` (e.g. older runtime, mocked stub) must
|
||||
// not be assumed callable. `typeof seek !== "function"` guards this.
|
||||
|
||||
@@ -50,6 +50,22 @@ interface ShaderLoaderElements {
|
||||
frameRow: HTMLDivElement;
|
||||
}
|
||||
|
||||
interface RuntimeDurationAdapter {
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
interface DirectTimelineAdapter {
|
||||
duration: () => number;
|
||||
time: () => number;
|
||||
seek: (timeInSeconds: number) => unknown;
|
||||
play: () => unknown;
|
||||
pause: () => unknown;
|
||||
}
|
||||
|
||||
type PlaybackDurationAdapter =
|
||||
| { kind: "runtime"; getDuration: () => number }
|
||||
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
|
||||
|
||||
const SHADER_LOADING_PHRASES = [
|
||||
"Preparing scene transitions",
|
||||
"Sampling outgoing scene motion",
|
||||
@@ -110,6 +126,25 @@ function withShaderQueryParams(
|
||||
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
|
||||
return isObjectRecord(value) && typeof value.getDuration === "function";
|
||||
}
|
||||
|
||||
function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
|
||||
return (
|
||||
isObjectRecord(value) &&
|
||||
typeof value.duration === "function" &&
|
||||
typeof value.time === "function" &&
|
||||
typeof value.seek === "function" &&
|
||||
typeof value.play === "function" &&
|
||||
typeof value.pause === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function injectShaderOptionsIntoSrcdoc(
|
||||
html: string,
|
||||
scale: string | null,
|
||||
@@ -172,6 +207,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _compositionHeight = 1080;
|
||||
private _probeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private _lastUpdateMs = 0;
|
||||
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
|
||||
private _directTimelineRaf: number | null = null;
|
||||
|
||||
/**
|
||||
* Parent-frame audio/video proxies, preloaded mirror copies of the iframe's
|
||||
@@ -308,6 +345,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
this._stopDirectTimelineClock();
|
||||
this._directTimelineAdapter = null;
|
||||
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
this._teardownMediaObserver();
|
||||
@@ -418,18 +457,21 @@ class HyperframesPlayer extends HTMLElement {
|
||||
if (this._duration > 0 && this._currentTime >= this._duration) {
|
||||
this.seek(0);
|
||||
}
|
||||
// Always drive the iframe runtime — it's the single source of timeline
|
||||
// truth regardless of who owns audible output. When we own audio, the
|
||||
// proxies join; when the runtime owns, they stay silent.
|
||||
this._sendControl("play");
|
||||
// Drive the iframe runtime when present. Same-origin standalone GSAP
|
||||
// compositions can expose only `window.__timelines`, so they use a direct
|
||||
// timeline adapter instead of a postMessage bridge nobody is listening to.
|
||||
const directTimelineStarted = this._tryDirectTimelinePlay();
|
||||
if (!directTimelineStarted) this._sendControl("play");
|
||||
if (this._audioOwner === "parent") this._playParentMedia();
|
||||
this._paused = false;
|
||||
this.controlsApi?.updatePlaying(true);
|
||||
this.dispatchEvent(new Event("play"));
|
||||
if (directTimelineStarted) this._startDirectTimelineClock();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this._sendControl("pause");
|
||||
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
|
||||
this._stopDirectTimelineClock();
|
||||
if (this._audioOwner === "parent") this._pauseParentMedia();
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
@@ -461,10 +503,11 @@ class HyperframesPlayer extends HTMLElement {
|
||||
* either way; the asymmetry only affects what the runtime actually paints.
|
||||
*/
|
||||
seek(timeInSeconds: number) {
|
||||
if (!this._trySyncSeek(timeInSeconds)) {
|
||||
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
|
||||
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
|
||||
this._sendControl("seek", { frame });
|
||||
}
|
||||
this._stopDirectTimelineClock();
|
||||
this._currentTime = timeInSeconds;
|
||||
|
||||
// Mirror parent proxy currentTime only while parent owns audible output.
|
||||
@@ -819,6 +862,150 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _tryDirectTimelineSeek(timeInSeconds: number): boolean {
|
||||
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
|
||||
if (!timeline) return false;
|
||||
try {
|
||||
timeline.seek(timeInSeconds);
|
||||
// GSAP seek() preserves play state; the player seek() contract lands paused.
|
||||
timeline.pause();
|
||||
this._directTimelineAdapter = timeline;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _tryDirectTimelinePlay(): boolean {
|
||||
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
|
||||
if (!timeline) return false;
|
||||
try {
|
||||
timeline.play();
|
||||
this._directTimelineAdapter = timeline;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _tryDirectTimelinePause(): boolean {
|
||||
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
|
||||
if (!timeline) return false;
|
||||
try {
|
||||
timeline.pause();
|
||||
this._directTimelineAdapter = timeline;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _startDirectTimelineClock(): void {
|
||||
this._stopDirectTimelineClock();
|
||||
|
||||
const tick = () => {
|
||||
const timeline = this._directTimelineAdapter;
|
||||
if (!timeline || this._paused) {
|
||||
this._directTimelineRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentTime: number;
|
||||
try {
|
||||
currentTime = timeline.time();
|
||||
} catch {
|
||||
this._directTimelineRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._duration > 0) currentTime = Math.min(currentTime, this._duration);
|
||||
this._currentTime = currentTime;
|
||||
const completedPlayback = this._duration > 0 && currentTime >= this._duration;
|
||||
const now = performance.now();
|
||||
if (now - this._lastUpdateMs > 100 || completedPlayback) {
|
||||
this._lastUpdateMs = now;
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("timeupdate", { detail: { currentTime: this._currentTime } }),
|
||||
);
|
||||
}
|
||||
|
||||
if (completedPlayback) {
|
||||
if (this.loop) {
|
||||
this.seek(0);
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
timeline.pause();
|
||||
if (this._audioOwner === "parent") this._pauseParentMedia();
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.dispatchEvent(new Event("ended"));
|
||||
this._directTimelineRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._directTimelineRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
this._directTimelineRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
private _stopDirectTimelineClock(): void {
|
||||
if (this._directTimelineRaf === null) return;
|
||||
cancelAnimationFrame(this._directTimelineRaf);
|
||||
this._directTimelineRaf = null;
|
||||
}
|
||||
|
||||
private _resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
|
||||
try {
|
||||
const win = this.iframe.contentWindow;
|
||||
if (!win) return null;
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
if (this._hasRuntimeBridge(win)) return null;
|
||||
|
||||
const timelines = Reflect.get(win, "__timelines");
|
||||
if (!isObjectRecord(timelines)) return null;
|
||||
|
||||
const keys = Object.keys(timelines);
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const rootId = this.iframe.contentDocument
|
||||
?.querySelector("[data-composition-id]")
|
||||
?.getAttribute("data-composition-id");
|
||||
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
|
||||
const timeline = timelines[key];
|
||||
return isDirectTimelineAdapter(timeline) ? timeline : null;
|
||||
}
|
||||
|
||||
private _hasRuntimeBridge(win: Window): boolean {
|
||||
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
|
||||
}
|
||||
|
||||
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
|
||||
const runtimePlayer = Reflect.get(win, "__player");
|
||||
if (isRuntimeDurationAdapter(runtimePlayer)) {
|
||||
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
|
||||
}
|
||||
|
||||
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
if (timeline) {
|
||||
return {
|
||||
kind: "direct-timeline",
|
||||
timeline,
|
||||
getDuration: () => timeline.duration(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _isControlsClick(event: Event) {
|
||||
return event
|
||||
.composedPath()
|
||||
@@ -917,6 +1104,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _onIframeLoad() {
|
||||
let attempts = 0;
|
||||
this._runtimeInjected = false;
|
||||
this._directTimelineAdapter = null;
|
||||
this._stopDirectTimelineClock();
|
||||
this._resetShaderLoader();
|
||||
// A fresh iframe means a fresh runtime — `mediaOutputMuted` and the
|
||||
// autoplay-blocked latch are both reset inside it. The web component's
|
||||
@@ -976,31 +1165,12 @@ class HyperframesPlayer extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const getAdapter = () => {
|
||||
if (win.__player && typeof win.__player.getDuration === "function") return win.__player;
|
||||
if (win.__timelines) {
|
||||
const keys = Object.keys(win.__timelines);
|
||||
if (keys.length > 0) {
|
||||
// Resolve the root composition id from the DOM — the outermost
|
||||
// `[data-composition-id]` element is the master. Bundled previews
|
||||
// register the root composition alongside sub-compositions, and
|
||||
// without this lookup Object.keys() order would make a
|
||||
// sub-composition's duration hijack the overall video length.
|
||||
const rootId = this.iframe.contentDocument
|
||||
?.querySelector("[data-composition-id]")
|
||||
?.getAttribute("data-composition-id");
|
||||
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
|
||||
const tl = win.__timelines[key];
|
||||
return { getDuration: () => tl.duration() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const adapter = getAdapter();
|
||||
const adapter = this._resolvePlaybackDurationAdapter(win);
|
||||
if (adapter && adapter.getDuration() > 0) {
|
||||
clearInterval(this._probeInterval!);
|
||||
this._duration = adapter.getDuration();
|
||||
this._directTimelineAdapter =
|
||||
adapter.kind === "direct-timeline" ? adapter.timeline : null;
|
||||
this._ready = true;
|
||||
this.controlsApi?.updateTime(0, this._duration);
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { duration: this._duration } }));
|
||||
|
||||
Reference in New Issue
Block a user