perf(player): coalesce _mirrorParentMediaTime writes (#396)

## Summary

Coalesce writes to `el.currentTime` inside `_mirrorParentMediaTime` so a single jitter sample no longer triggers a parent-media seek. A drift correction now requires **two consecutive samples** above the threshold (~`MIRROR_DRIFT_THRESHOLD_SECONDS`) before the player writes back. One-shot alignment paths (`promoteToParentProxy`, `_onIframeMediaAdded`) opt out via `force: true` so initial alignment stays immediate.

## Why

Step `P1-4` of the player perf proposal. `_mirrorParentMediaTime` is called every animation frame on parent media proxies. Even without true drift, browser internals report tiny jitter on `currentTime` reads — typically below 30 ms but occasionally crossing the threshold for a frame. Writing to `currentTime` triggers a seek, which is expensive *and* invalidates pipeline buffers, which causes the next frame's reading to jitter further. The result was unnecessary seek thrash on otherwise-aligned media.

By requiring two consecutive over-threshold samples, transient jitter is filtered out while real drift (a sustained offset) still corrects within ~1 frame of latency. This eliminates the most common cause of dropped frames on the studio thumbnail grid.

## What changed

- Each `_parentMedia` entry gains a `driftSamples` counter that increments while the absolute drift is above `MIRROR_DRIFT_THRESHOLD_SECONDS` and resets to 0 on the first sample below.
- `_mirrorParentMediaTime(el, opts)` only writes back when `driftSamples >= 2`, except when `opts.force === true`.
- `promoteToParentProxy` and `_onIframeMediaAdded` pass `force: true` so the first alignment after registration is still immediate (these are user-visible state transitions, not steady-state telemetry).

## Test plan

- [x] 11 new unit/integration tests in `hyperframes-player.test.ts` covering:
  - Single-sample jitter does not trigger a write.
  - Two-sample sustained drift does trigger a write.
  - Trending drift correction (gradually increasing offset) is detected within 2 samples.
  - `force: true` override bypasses the sample requirement.
  - Out-of-range proxies (proxies whose source has been removed) do not panic.
  - Multiple proxies maintain independent counters — drift on one does not affect the other.
  - `_promoteToParentProxy` alignment is immediate.

## Stack

Step `P1-4` of the player perf proposal. Builds on `P1-1` (shared adopted stylesheets) and `P1-2` (scoped media observer). Together these three target the studio multi-player render path — `P0-1*` perf gate scenarios will pick up the wins automatically.
This commit is contained in:
Vance Ingalls
2026-04-22 17:44:49 -07:00
committed by GitHub
parent 113f9eafd5
commit f906797222
3 changed files with 295 additions and 8 deletions
+21
View File
@@ -30,6 +30,27 @@ export type RuntimeState = {
bridgeLastPostedAt: number;
bridgeLastPostedPlaying: boolean;
bridgeLastPostedMuted: boolean;
/**
* Max interval (ms) between outbound timeline samples on the parent-frame
* control bridge. The bridge posts on every changed frame, but also at
* least once per this interval so a paused/idle timeline still confirms
* its position to any listener.
*
* **Cross-reference (do not change in isolation)**: the parent-frame
* audio-mirror loop in `<hyperframes-player>` waits for
* `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES` consecutive over-threshold
* samples before issuing a `currentTime` correction. The product of
* those two constants is the worst-case A/V re-sync latency:
*
* worst_case_correction_latency_ms
* ≈ MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES × bridgeMaxPostIntervalMs
*
* Today: `2 × 80 ms = 160 ms`, which sits comfortably under the
* perceptual A/V re-sync tolerance. If you raise this interval, audit
* `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES` in
* `packages/player/src/hyperframes-player.ts` — leaving it at `2` will
* silently push correction latency past the tolerance budget.
*/
bridgeMaxPostIntervalMs: number;
timelinePollIntervalId: ReturnType<typeof setInterval> | null;
controlBridgeHandler: ((event: MessageEvent) => void) | null;