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:
Vance Ingalls
2026-04-22 17:50:23 -07:00
committed by GitHub
parent f906797222
commit ef3de5bcd3
2 changed files with 216 additions and 2 deletions
@@ -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);
});
});
+59 -2
View File
@@ -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()