mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
Follow-up to PR #298 addressing @jrusso1020's review. Each item below maps to a point in his comment. ## Significant ### 1\. Drift threshold 150 ms → 50 ms _mirrorParentMediaTime_ was too loose for lip-synced talking-head content. ITU-R BT.1359 puts A/V perceptibility at ±45 ms; 150 ms sat well inside the "unacceptable" zone. Dropped to 50 ms, extracted as a static constant for clarity. **Verified live on factory-series-c-video (agent-browser):** steady-state offset under parent ownership sampled five times over 400 ms = `[35.7, 33.5, 31.2, 27.2, 36.9]` ms — below the perceptibility floor. Before this PR the same measurement could drift up to 150 ms before correction. ### 2\. Dynamic sub-composition media proxies Under parent ownership, a sub-composition that attaches a new `<audio data-start>` mid-playback was correctly silenced in the iframe (sticky `outputMuted`) but had no parent-frame counterpart to play → silent hole in the audio track. Added a `MutationObserver` on the iframe body watching for `audio[data-start]` / `video[data-start]` additions. New elements are adopted through the same `_adoptIframeMedia` helper the initial scan uses, and if parent ownership is already active the new proxy gets its `currentTime` mirrored and `play()` called immediately (gated on `!this._paused`). Observer disconnects on iframe reload + component disconnect. ### 3\. `bridgeMuted` sticky in `syncRuntimeMedia` The asymmetry James flagged: `outputMuted` was sticky per-tick, `bridgeMuted` was one-shot via `onSetMuted`. A sub-composition activating after a user mute would briefly play at author volume before the next bridge message. `syncRuntimeMedia` now accepts `userMuted` and the per-clip loop uses a single combined `shouldMute` gate. One invariant, two inputs. ### 4\. Reset `_audioOwner` on iframe reload The latch never cleared. On composition switch the player would stay in `parent` ownership against a fresh runtime that hadn't received `set-media-output-muted` and whose autoplay-blocked latch was clean — a brief double-audio window until the next `NotAllowedError` re-promoted (idempotently). `_onIframeLoad` now resets `_audioOwner = "runtime"`, pauses any parent proxies, and disconnects the old MutationObserver before a fresh one attaches to the new document. If the player had been in `parent` ownership, a corresponding `audioownershipchange` event fires with `reason: "iframe-reload"`. ## Worth addressing ### 5\. Promotion → observable event + reason Promotion was invisible. Added `CustomEvent("audioownershipchange", { detail: { owner, reason } })` fired on every owner transition. `reason` is either `"autoplay-blocked"` (promote → parent) or `"iframe-reload"` (reset → runtime). Gives host apps an SLO-ready signal for "% of sessions in parent ownership" without exposing internal state. **Verified live:** dispatching a synthetic `media-autoplay-blocked` in the live studio produced `{ owner: "parent", reason: "autoplay-blocked" }` on the web component exactly once. ### 6\. Parent proxy play() rejection → `playbackerror` event Previously swallowed silently. Now re-emitted as `CustomEvent("playbackerror", { detail: { source: "parent-proxy", error } })` so embedding apps can recover or fall back. ### 7\. Mobile verification on real hardware Tested with a tunnel in a real iOS device. ## Test gaps (from review) - `userMuted` stickiness (mirror of the existing `outputMuted` test). - **OR invariant** between `outputMuted` and `userMuted` — explicit test that setting one false while the other is true keeps `el.muted === true`. - **Contract pin:** `syncRuntimeMedia` fires `onAutoplayBlocked` on **every** rejection (no internal dedupe) — so a future refactor can't quietly move the latch and break the caller's posting logic. - **Caller-side latch pattern:** a 5-rejection simulation with the init.ts-style wrapper posts exactly once. - **`audioownershipchange`** **dispatch** on promotion + once per transition (no duplicate on idempotent re-promote). - **Mid-playback promotion:** `_paused = false` at flip time fires `_playParentMedia` immediately. - **`playbackerror`** **surface** on parent proxy rejection with the right `source` tag. ## Minor - One-line comment on `_promoteToParentProxy` explaining the `postMessage` async race (the mute lands after ~one message-loop tick; the autoplay gate that triggered promotion keeps the iframe rejecting `play()` during that window, so the double-play bug doesn't reappear). ## What's good (from the review) Kept as-is — noted for posterity: - `muted` vs `volume` framing (orthogonal channels). - Probing reality via `NotAllowedError` instead of `matchMedia('(pointer: coarse)')` / UA sniffing. - Two orthogonal mute channels. - Backwards compat (new actions / messages safely ignored by either side). ## Test results - `packages/core/src/runtime/media.test.ts` — **42 tests pass** (+4 new: `userMuted` sticky, OR invariant, fires-every-rejection, caller-latch dedupe) - `packages/core/src/runtime/bridge.test.ts` — **15 tests pass** - `packages/player/src/hyperframes-player.test.ts` — **26 tests pass** (+3 new: `audioownershipchange` dispatch, mid-playback promotion, `playbackerror` surface) - Typecheck green on `core` + `player` - `tsup` build green on `core` / `player` / `cli` - Live factory-series-c-video repro via agent-browser: runtime ownership still zero `volumechange` thrash, zero `PARENT.play()` calls; parent ownership measures 27–37 ms steady-state drift, well inside the 50 ms threshold. ## Test plan - [x] Unit tests (83 total across touched files) - [x] Typecheck clean - [x] Build clean - [x] Live studio repro on factory-series-c-video: runtime path unchanged, parent path drift tightened - [x] `audioownershipchange` event fires with correct detail on synthetic autoplay block - [x] Physical iOS / Android device verification (unchanged since #298)
219 lines
9.9 KiB
TypeScript
219 lines
9.9 KiB
TypeScript
export type RuntimeMediaClip = {
|
||
el: HTMLVideoElement | HTMLAudioElement;
|
||
start: number;
|
||
mediaStart: number;
|
||
duration: number;
|
||
end: number;
|
||
volume: number | null;
|
||
playbackRate: number;
|
||
loop: boolean;
|
||
/** Source media duration in seconds (from el.duration). Used for loop wrapping. */
|
||
sourceDuration: number | null;
|
||
};
|
||
|
||
export function refreshRuntimeMediaCache(params?: {
|
||
resolveStartSeconds?: (element: Element) => number;
|
||
}): {
|
||
timedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
|
||
mediaClips: RuntimeMediaClip[];
|
||
videoClips: RuntimeMediaClip[];
|
||
maxMediaEnd: number;
|
||
} {
|
||
const mediaEls = Array.from(
|
||
document.querySelectorAll("video[data-start], audio[data-start]"),
|
||
) as Array<HTMLVideoElement | HTMLAudioElement>;
|
||
const mediaClips: RuntimeMediaClip[] = [];
|
||
const videoClips: RuntimeMediaClip[] = [];
|
||
let maxMediaEnd = 0;
|
||
for (const el of mediaEls) {
|
||
const start = params?.resolveStartSeconds
|
||
? params.resolveStartSeconds(el)
|
||
: Number.parseFloat(el.dataset.start ?? "0");
|
||
if (!Number.isFinite(start)) continue;
|
||
const mediaStart =
|
||
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
|
||
// Read per-element rate from the native defaultPlaybackRate property.
|
||
// LLMs set this via el.defaultPlaybackRate = 0.5 in a <script> tag.
|
||
const rawRate = el.defaultPlaybackRate;
|
||
const playbackRate =
|
||
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
|
||
const loop = el.loop;
|
||
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
|
||
let duration = Number.parseFloat(el.dataset.duration ?? "");
|
||
if ((!Number.isFinite(duration) || duration <= 0) && sourceDuration != null) {
|
||
// Effective duration accounts for playback rate:
|
||
// at 0.5x, a 10s source plays for 20s on the timeline
|
||
duration = Math.max(0, (sourceDuration - mediaStart) / playbackRate);
|
||
}
|
||
const end =
|
||
Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
||
const volumeRaw = Number.parseFloat(el.dataset.volume ?? "");
|
||
const clip: RuntimeMediaClip = {
|
||
el,
|
||
start,
|
||
mediaStart,
|
||
duration: Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY,
|
||
end,
|
||
volume: Number.isFinite(volumeRaw) ? volumeRaw : null,
|
||
playbackRate,
|
||
loop,
|
||
sourceDuration,
|
||
};
|
||
mediaClips.push(clip);
|
||
if (el.tagName === "VIDEO") videoClips.push(clip);
|
||
if (Number.isFinite(end)) maxMediaEnd = Math.max(maxMediaEnd, end);
|
||
}
|
||
return { timedMediaEls: mediaEls, mediaClips, videoClips, maxMediaEnd };
|
||
}
|
||
|
||
// Per-element timeline→media offset from the previous tick. Used to tell a
|
||
// gradual drift (initial buffer catch-up, where offset grows ~16ms/tick) from
|
||
// a scrub (where offset jumps in one tick). Cleared when a clip becomes
|
||
// inactive so the next activation gets a hard resync on its first tick.
|
||
const lastOffset = new WeakMap<HTMLMediaElement, number>();
|
||
|
||
// Elements whose play() is in flight. The sync runs on a 50 ms poll and with
|
||
// a 1–2 s buffer that would fire 20–40 spurious play() calls per element —
|
||
// noise in devtools and, worse, each `.catch(() => {})` would swallow a real
|
||
// AbortError / NotAllowedError that should surface. Cleared on the `playing`
|
||
// event (actual playback started) or on `pause`/`error` (state ended).
|
||
const playRequested = new WeakSet<HTMLMediaElement>();
|
||
function markPlayRequested(el: HTMLMediaElement): void {
|
||
if (playRequested.has(el)) return;
|
||
playRequested.add(el);
|
||
const clear = () => playRequested.delete(el);
|
||
el.addEventListener("playing", clear, { once: true });
|
||
el.addEventListener("pause", clear, { once: true });
|
||
el.addEventListener("error", clear, { once: true });
|
||
}
|
||
|
||
export function syncRuntimeMedia(params: {
|
||
clips: RuntimeMediaClip[];
|
||
timeSeconds: number;
|
||
playing: boolean;
|
||
playbackRate: number;
|
||
/**
|
||
* Parent-frame audio-owner has taken over audible playback. Assert
|
||
* `el.muted = true` on every active media element per tick so that any
|
||
* sub-composition media inserted mid-playback inherits the silence.
|
||
*/
|
||
outputMuted?: boolean;
|
||
/**
|
||
* User's explicit mute preference (set via `onSetMuted`). Symmetric to
|
||
* `outputMuted` — also asserted per tick — so a sub-composition that
|
||
* activates after the user mutes doesn't briefly play at author volume
|
||
* before the next bridge message lands.
|
||
*/
|
||
userMuted?: boolean;
|
||
/**
|
||
* Invoked at most once when a media element's `play()` promise rejects with
|
||
* `NotAllowedError`. The caller is expected to latch and post a single
|
||
* outbound message; further invocations are suppressed by the caller.
|
||
*/
|
||
onAutoplayBlocked?: () => void;
|
||
}): void {
|
||
// Either flag silences output. Combined up front so the per-clip loop is
|
||
// a single branch instead of two.
|
||
const shouldMute = !!(params.outputMuted || params.userMuted);
|
||
for (const clip of params.clips) {
|
||
const { el } = clip;
|
||
if (!el.isConnected) continue;
|
||
let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart;
|
||
const isActive =
|
||
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
|
||
if (isActive) {
|
||
// Loop wrapping: when media reaches end, restart from mediaStart
|
||
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {
|
||
const loopLength = clip.sourceDuration - clip.mediaStart;
|
||
if (loopLength > 0 && relTime >= clip.sourceDuration) {
|
||
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
|
||
}
|
||
}
|
||
if (clip.volume != null) el.volume = clip.volume;
|
||
if (shouldMute) el.muted = true;
|
||
try {
|
||
// Per-element rate × global transport rate
|
||
el.playbackRate = clip.playbackRate * params.playbackRate;
|
||
} catch {
|
||
// ignore unsupported playbackRate
|
||
}
|
||
// Drift correction. Forcing `el.currentTime = relTime` every frame
|
||
// causes an audible seek+rebuffer hiccup (readyState drops briefly).
|
||
//
|
||
// We only want to correct drift that came from an *event* — an explicit
|
||
// user seek, a sub-composition activation, or a timeline jump — not
|
||
// drift that grew naturally from initial-buffer latency. Telling them
|
||
// apart by timing: scrubs move the timeline-to-media offset by seconds
|
||
// in a single tick; buffer catch-up grows the offset by ~one frame
|
||
// (<20ms) per tick.
|
||
//
|
||
// The first tick a clip is active we don't have a previous offset to
|
||
// compare against — treat that as a hard resync so sub-compositions
|
||
// with non-zero `mediaStart` land on the right frame.
|
||
//
|
||
// Tradeoff: the 3 s catastrophic-drift valve means an unnoticed
|
||
// steady-state drift can accumulate up to ~3 s before we correct.
|
||
// For music / motion graphics this is inaudible; for lip-synced
|
||
// dialogue it is not. If that becomes a target use case, switch to
|
||
// a short-window tight threshold (e.g. tighten to 0.15 s when the
|
||
// last play/pause transition was >500 ms ago).
|
||
const currentElTime = el.currentTime || 0;
|
||
const drift = Math.abs(currentElTime - relTime);
|
||
const offset = relTime - currentElTime;
|
||
const prevOffset = lastOffset.get(el);
|
||
lastOffset.set(el, offset);
|
||
const firstTickOfClip = prevOffset === undefined;
|
||
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
|
||
const catastrophicDrift = drift > 3;
|
||
if (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)) {
|
||
try {
|
||
el.currentTime = relTime;
|
||
} catch {
|
||
// ignore browser seek restrictions
|
||
}
|
||
}
|
||
if (params.playing && el.paused && !playRequested.has(el)) {
|
||
// `HTMLMediaElement.play()` is spec'd to queue playback and resolve
|
||
// once enough data is buffered, so we can unconditionally call it —
|
||
// no need to gate on `readyState` or defer to a `canplay` listener.
|
||
//
|
||
// The old `readyState < HAVE_FUTURE_DATA` branch called `el.load()`
|
||
// inside the listener, which *aborts* the in-flight fetch that
|
||
// `bindMediaMetadataListeners` already started at init time and
|
||
// restarts from zero. On slow networks this delayed playback by
|
||
// seconds. The canplay listener was also racey — the event could
|
||
// fire between `load()` and `addEventListener` attachment, wedging
|
||
// the element waiting for a callback that never came.
|
||
//
|
||
// preload="auto" is already set at bind time in init.ts; the
|
||
// re-assignment here is defensive for media elements that were
|
||
// inserted after the runtime bound its listeners.
|
||
if (el.preload !== "auto") el.preload = "auto";
|
||
markPlayRequested(el);
|
||
void el.play().catch((err: unknown) => {
|
||
// If play() rejects — e.g. autoplay blocked, element removed
|
||
// mid-flight — drop the in-flight flag so a future sync tick can
|
||
// retry rather than getting stuck waiting for `playing`/`pause`.
|
||
playRequested.delete(el);
|
||
// `NotAllowedError` is the autoplay-gating browser response when
|
||
// the iframe has no user activation. Signal the parent exactly
|
||
// once so it can promote to parent-frame audio proxies. Retries
|
||
// here would be pointless — nothing the runtime does fixes it.
|
||
const name =
|
||
err && typeof err === "object" && "name" in err
|
||
? String((err as { name?: unknown }).name ?? "")
|
||
: "";
|
||
if (name === "NotAllowedError") params.onAutoplayBlocked?.();
|
||
});
|
||
} else if (!params.playing && !el.paused) {
|
||
el.pause();
|
||
}
|
||
continue;
|
||
}
|
||
// Clip left its active window — drop the offset baseline so the next
|
||
// activation (e.g. re-entering a sub-composition) gets a hard resync.
|
||
lastOffset.delete(el);
|
||
if (!el.paused) el.pause();
|
||
}
|
||
}
|