mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(player): synchronous seek() API with same-origin detection (#397)
## Summary Formalizes the same-origin shortcut Studio has been using privately (`iframe.contentWindow.__player.seek` in `useTimelinePlayer.ts`) as a first-class behavior of `<hyperframes-player>`'s public `seek()` method. Same-origin seeks now land in the same task as the input event — no postMessage hop, no extra microtask, no perceived scrub lag. Cross-origin embeds fall through to the existing async bridge transparently. ## Why Step `P3-1` of the player perf proposal. The current `seek()` always posts a message to the iframe runtime, which means a single user scrub incurs: 1. JS task: fire postMessage from parent 2. Browser task switch into iframe context 3. Microtask: handler dispatches 4. Frame: runtime calls `markExplicitSeek` and updates DOM Same-origin embeds (Studio, preview pane, embedded compositions) can skip all four by calling the runtime's `seek` directly. Studio was already doing this manually but had to duplicate the local-state bookkeeping (`_currentTime`, `paused`, controls UI) — making it a first-class behavior of the player removes the workaround and gives every same-origin consumer the win for free. ## What changed - New `_trySyncSeek(time)` helper attempts a synchronous call into the iframe's `window.__player.seek`. Returns `true` on success, `false` on cross-origin or pre-bootstrap. - `seek()` calls `_trySyncSeek` first, falls through to the existing `_sendControl` postMessage path when sync isn't available. - Detection is a `try/catch` on `contentWindow` access (real cross-origin iframes throw `SecurityError`) plus a `typeof` guard on `__player.seek`. - Local `_currentTime`, the `paused` flag, and the controls UI update on both paths so scrubs never leave stale state. - Runtime-side `seek` is the same wrapped function the postMessage handler calls — `installRuntimeControlBridge` routes through `player.seek`, so `markExplicitSeek()` and downstream runtime state are identical between the two paths. ## Test plan - [x] 11 new unit tests in `hyperframes-player.test.ts` covering: - Same-origin sync path executes `__player.seek` synchronously and skips postMessage. - Cross-origin (simulated `SecurityError` on `contentWindow`) falls back to postMessage. - Pre-bootstrap (no `__player` installed) falls back to postMessage. - `__player.seek` not a function falls back to postMessage. - `_currentTime`, `paused`, and controls all stay in sync on both paths. - Errors thrown from `__player.seek` propagate without corrupting state. ## Stack Step `P3-1` of the player perf proposal. Independent of the `P1-*` work — this is a pure latency win on the seek/scrub path. Combined with `P3-2` (srcdoc composition switching, next in the stack) it removes most of the iframe-bridge overhead from the studio scrubber.
This commit is contained in:
@@ -640,3 +640,160 @@ describe("HyperframesPlayer parent-proxy time-mirror coalescing", () => {
|
||||
expect(forcedCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Synchronous seek() with same-origin detection ──
|
||||
//
|
||||
// Studio has long reached past the postMessage bridge and called the runtime's
|
||||
// `__player.seek` directly (`useTimelinePlayer.ts:233`) — that's the only way
|
||||
// to land a scrubbed frame in the same task as the input event so the user
|
||||
// sees no perceived lag. P3-1 promotes that pattern to a public API: the
|
||||
// player element's own `seek()` now tries the same shortcut first, and only
|
||||
// falls back to the async postMessage bridge when the iframe is genuinely
|
||||
// cross-origin (or the runtime hasn't installed `__player` yet). The tests
|
||||
// here stub `iframe.contentWindow` so we can exercise the branch matrix
|
||||
// without booting an actual runtime.
|
||||
|
||||
describe("HyperframesPlayer seek() sync path", () => {
|
||||
type SyncPlayerStub = {
|
||||
seek?: (t: number) => void;
|
||||
play?: () => void;
|
||||
pause?: () => void;
|
||||
};
|
||||
type FakeContentWindow = {
|
||||
__player?: SyncPlayerStub;
|
||||
postMessage?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type PlayerInternal = HTMLElement & {
|
||||
seek: (t: number) => void;
|
||||
iframe: HTMLIFrameElement;
|
||||
_currentTime: number;
|
||||
};
|
||||
|
||||
let player: PlayerInternal;
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./hyperframes-player.js");
|
||||
player = document.createElement("hyperframes-player") as PlayerInternal;
|
||||
document.body.appendChild(player);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
player.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Replace the iframe's `contentWindow` getter so the test controls what the
|
||||
// sync path sees. Passing `"throw"` simulates the cross-origin SecurityError
|
||||
// a real browser raises when reading `contentWindow.<anything>`.
|
||||
function stubContentWindow(stub: FakeContentWindow | "throw") {
|
||||
Object.defineProperty(player.iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (stub === "throw") throw new Error("SecurityError");
|
||||
return stub;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("calls __player.seek directly on the same-origin path", () => {
|
||||
// The whole point of P3-1: when the runtime is reachable, scrubs land in
|
||||
// the same task as the input. `postMessage` must NOT also fire — that
|
||||
// would cause a duplicate, async re-seek a tick later.
|
||||
const sync = vi.fn();
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ __player: { seek: sync }, postMessage: post });
|
||||
|
||||
player.seek(12.5);
|
||||
|
||||
expect(sync).toHaveBeenCalledTimes(1);
|
||||
expect(sync).toHaveBeenCalledWith(12.5);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the raw time-in-seconds through, not a rounded frame number", () => {
|
||||
// The postMessage bridge has to round to a frame at the wire boundary,
|
||||
// but the in-process call accepts seconds directly — preserving the
|
||||
// caller's precision for fractional scrubs.
|
||||
const sync = vi.fn();
|
||||
stubContentWindow({ __player: { seek: sync } });
|
||||
|
||||
player.seek(7.3333);
|
||||
|
||||
expect(sync).toHaveBeenCalledWith(7.3333);
|
||||
});
|
||||
|
||||
it("falls back to postMessage when __player has not been installed yet", () => {
|
||||
// Before the runtime bootstraps, `contentWindow` exists but `__player` is
|
||||
// undefined. The fallback queues the seek via postMessage, which the
|
||||
// runtime drains once `installRuntimeControlBridge` runs.
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ postMessage: post });
|
||||
|
||||
player.seek(12.5);
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "seek",
|
||||
frame: Math.round(12.5 * 30),
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
const post = vi.fn();
|
||||
stubContentWindow({
|
||||
__player: { play: vi.fn(), pause: vi.fn() },
|
||||
postMessage: post,
|
||||
});
|
||||
|
||||
player.seek(7);
|
||||
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek", frame: 210 }), "*");
|
||||
});
|
||||
|
||||
it("does not throw when contentWindow access raises (cross-origin embed)", () => {
|
||||
// Reading `iframe.contentWindow` on a true cross-origin iframe throws a
|
||||
// DOMException. Both `_trySyncSeek` AND the postMessage fallback hit the
|
||||
// same getter, so both swallow the error — the public seek() must remain
|
||||
// a clean no-op surface for the caller.
|
||||
stubContentWindow("throw");
|
||||
|
||||
expect(() => player.seek(12.5)).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to postMessage when __player.seek throws at runtime", () => {
|
||||
// If the runtime's seek implementation panics, we catch in `_trySyncSeek`
|
||||
// and degrade to the bridge. The postMessage path runs in a separate
|
||||
// task — it may succeed where the sync call failed, and at worst the
|
||||
// failure mode is identical.
|
||||
const sync = vi.fn(() => {
|
||||
throw new Error("runtime panic");
|
||||
});
|
||||
const post = vi.fn();
|
||||
stubContentWindow({ __player: { seek: sync }, postMessage: post });
|
||||
|
||||
expect(() => player.seek(12.5)).not.toThrow();
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek" }), "*");
|
||||
});
|
||||
|
||||
it("updates _currentTime regardless of which path is taken", () => {
|
||||
// `_currentTime` is the parent-side cache that drives controls and parent
|
||||
// proxy mirroring. It must update unconditionally — otherwise scrubs on a
|
||||
// cross-origin embed leave the controls UI showing stale time.
|
||||
const sync = vi.fn();
|
||||
stubContentWindow({ __player: { seek: sync } });
|
||||
player.seek(8.25);
|
||||
expect(player._currentTime).toBe(8.25);
|
||||
|
||||
// Reset and verify the fallback path produces the same caching behavior.
|
||||
stubContentWindow({ postMessage: vi.fn() });
|
||||
player.seek(11);
|
||||
expect(player._currentTime).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,9 +263,35 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move playback to `timeInSeconds`.
|
||||
*
|
||||
* Two transports, with different precision semantics — read this before
|
||||
* writing assertions against `seek` from outside the player:
|
||||
*
|
||||
* - **Same-origin (sync) path** — when the runtime's `window.__player.seek`
|
||||
* is reachable, we call it directly. `timeInSeconds` is forwarded
|
||||
* *verbatim* (no rounding), so a same-origin scrub of `seek(7.3333)`
|
||||
* lands the runtime at `7.3333 s` — sub-frame precision relative to
|
||||
* `DEFAULT_FPS` (30). Studio scrub UIs that need fractional-frame
|
||||
* alignment (e.g. waveform scrubbing on long-duration audio) get the
|
||||
* exact requested time.
|
||||
* - **Cross-origin (postMessage) path** — when same-origin access throws
|
||||
* or `__player.seek` is missing, we fall back to the postMessage bridge.
|
||||
* The wire protocol carries integer frames (`frame: Math.round(t × FPS)`),
|
||||
* so cross-origin embeds are *frame-quantized* and `seek(7.3333)` lands
|
||||
* at `Math.round(7.3333 × 30) / 30 ≈ 7.3333…` (same value here, but for
|
||||
* most fractional inputs you'll see a snap to the nearest 1/30 s).
|
||||
*
|
||||
* `this._currentTime` always reflects the *requested* `timeInSeconds`
|
||||
* regardless of transport, so the controls UI shows the un-quantized value
|
||||
* either way; the asymmetry only affects what the runtime actually paints.
|
||||
*/
|
||||
seek(timeInSeconds: number) {
|
||||
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
|
||||
this._sendControl("seek", { frame });
|
||||
if (!this._trySyncSeek(timeInSeconds)) {
|
||||
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
|
||||
this._sendControl("seek", { frame });
|
||||
}
|
||||
this._currentTime = timeInSeconds;
|
||||
|
||||
// Mirror parent proxy currentTime only while parent owns audible output.
|
||||
@@ -337,6 +363,37 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach into the runtime's `window.__player.seek` directly, skipping the
|
||||
* postMessage hop. Same-origin only — cross-origin embeds throw a
|
||||
* `SecurityError` on `contentWindow` property access, which we catch and
|
||||
* report as a no-op so the caller can transparently fall back to the
|
||||
* postMessage bridge. Returns `true` only when the runtime accepted the
|
||||
* call (`__player.seek` exists, is callable, and didn't throw).
|
||||
*
|
||||
* Studio has used this access path privately via `iframe.contentWindow.__player`
|
||||
* (see `useTimelinePlayer.ts`); this helper just formalizes the same
|
||||
* detection inside the player so external scrub UIs get the same
|
||||
* single-task latency. The runtime-side `seek` is the same wrapped
|
||||
* function the postMessage handler calls (`installRuntimeControlBridge`
|
||||
* routes through `player.seek`), so `markExplicitSeek()` and downstream
|
||||
* runtime state stay identical between the two paths.
|
||||
*/
|
||||
private _trySyncSeek(timeInSeconds: number): boolean {
|
||||
try {
|
||||
const win = this.iframe.contentWindow as
|
||||
| (Window & { __player?: { seek?: (t: number) => void } })
|
||||
| null;
|
||||
const player = win?.__player;
|
||||
const seek = player?.seek;
|
||||
if (typeof seek !== "function") return false;
|
||||
seek.call(player, timeInSeconds);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _isControlsClick(event: Event) {
|
||||
return event
|
||||
.composedPath()
|
||||
|
||||
Reference in New Issue
Block a user