Files
hyperframes/packages/core/src/runtime/state.ts
T
Vance Ingalls f906797222 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.
2026-04-22 17:44:49 -07:00

105 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "./types";
import type { RuntimeMediaClip } from "./media";
export type RuntimeState = {
capturedTimeline: RuntimeTimelineLike | null;
isPlaying: boolean;
rafId: number | null;
currentTime: number;
deterministicAdapters: RuntimeDeterministicAdapter[];
parityModeEnabled: boolean;
canonicalFps: number;
bridgeMuted: boolean;
/**
* Internal mute of audible media output, owned by the audio-ownership
* protocol between the parent (`<hyperframes-player>`) and this runtime.
* Independent of `bridgeMuted` (the user's mute preference). When the
* parent takes over audible playback via parent-frame proxies, it sets
* this to `true` so the runtime keeps driving timed media for frame
* accuracy but produces no audio of its own.
*/
mediaOutputMuted: boolean;
/**
* Latch so the `media-autoplay-blocked` outbound message is posted at most
* once per runtime session. The parent only needs the first signal — it
* takes over playback and further rejections are the same problem.
*/
mediaAutoplayBlockedPosted: boolean;
playbackRate: number;
bridgeLastPostedFrame: number;
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;
clampDurationLoggedRaw: number | null;
beforeUnloadHandler: (() => void) | null;
domReadyHandler: (() => void) | null;
injectedCompStyles: HTMLStyleElement[];
injectedCompScripts: HTMLScriptElement[];
cachedTimedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
cachedMediaClips: RuntimeMediaClip[];
cachedVideoClips: RuntimeMediaClip[];
cachedMediaTimelineDurationSeconds: number;
tornDown: boolean;
maxTimelineDurationSeconds: number;
nativeVisualWatchdogTick: number;
};
export function createRuntimeState(): RuntimeState {
return {
capturedTimeline: null,
isPlaying: false,
rafId: null,
currentTime: 0,
deterministicAdapters: [],
parityModeEnabled: true,
canonicalFps: 30,
bridgeMuted: false,
mediaOutputMuted: false,
mediaAutoplayBlockedPosted: false,
playbackRate: 1,
bridgeLastPostedFrame: -1,
bridgeLastPostedAt: 0,
bridgeLastPostedPlaying: false,
bridgeLastPostedMuted: false,
bridgeMaxPostIntervalMs: 80,
timelinePollIntervalId: null,
controlBridgeHandler: null,
clampDurationLoggedRaw: null,
beforeUnloadHandler: null,
domReadyHandler: null,
injectedCompStyles: [],
injectedCompScripts: [],
cachedTimedMediaEls: [],
cachedMediaClips: [],
cachedVideoClips: [],
cachedMediaTimelineDurationSeconds: 0,
tornDown: false,
maxTimelineDurationSeconds: 1800,
nativeVisualWatchdogTick: 0,
};
}