mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## 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.
105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
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,
|
||
};
|
||
}
|