Files
heygen-com__hyperframes/packages/player/src/parent-media.ts
Vance Ingalls a95e49dbda fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window

When iframe autoplay is blocked, audible playback is promoted to a parent-frame
audio proxy. The proxy read the clip's data-start/data-duration once at adopt
time and mirrorTime() only skipped (never paused) the element outside that
window — so a trimmed/moved music clip kept playing the full source past its
on-timeline end, even though the iframe element was correctly paused.

Fix: the proxy keeps a reference to its source iframe element and re-reads
data-start/data-duration each mirror tick (live trims/moves apply), pauses the
proxy when the playhead leaves [start, start+duration), and resumes it when the
playhead re-enters during parent-owned playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,studio): bound trimmed audio playback to the clip window

Trimmed audio played to the source file's natural end instead of
stopping at the clip edge, on every audio path:

- WebAudio (the audible path in Studio): schedulePlayback now passes
  the clip's data-duration as the third start() arg, so the decoded
  buffer stops at the trimmed edge instead of running to the file end.
- Runtime element gating: the duration resolver caps each clip by its
  own data-duration (min of source length, host window, authored
  duration), so a trimmed <audio>/<video> element pauses at its edge.

Studio trim UX:

- Resize live-patches the media-start/playback-start offset, so a
  start-edge drag trims into the source instead of only repositioning
  the clip.
- AudioWaveform windows the rendered peaks to the trimmed slice so the
  waveform tracks the clip edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(player,core): gate proxy playback to the live clip window

Review follow-ups on the parent-audio-proxy / WebAudio bound:
- seekAll now re-reads live source bounds (_refreshEntryBounds) before
  gating, so a paused scrub right after a trim/move uses the current clip
  window instead of the adopt-time one.
- playAll and clip adoption only start a proxy when the playhead is inside
  the clip's window (_playEntryIfActive), so bulk starts / promotion no
  longer blip audio for clips outside their window until the next tick.
- The WebAudio buffer is now bounded by the host-composition window too
  (matching resolveDurationSeconds), so a sub-composition-nested clip stops
  at the same edge on the WebAudio and HTMLMedia paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds

A bounded WebAudio source's wall-clock length is baked into start()'s duration
arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in
place on a later rate change does not rescale that bound, so a trimmed clip ends
early (fast) or late (slow). setRate now reports whether the rate changed and
exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active
clips at the new rate when any bounded source is live. The per-clip schedule
loop is extracted to a shared closure so play() and the rate path agree.

Also guard _refreshEntryBounds against a non-numeric duration attribute parsing
to NaN, which would make every window check false and let the proxy play past
its clip end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
2026-06-14 17:14:49 -07:00

442 lines
16 KiB
TypeScript
Raw Permalink 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.
/**
* Parent-frame media proxy subsystem.
*
* Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
* in the parent frame so that mobile browsers — which gate `el.play()` on user
* activation in the *same* frame — can still produce audible output via proxies
* the parent controls directly.
*
* See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
*/
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
/** Minimum absolute drift before a currentTime correction is attempted. */
const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
/**
* How many *consecutive* over-threshold samples are required before issuing a
* `currentTime` write. Absorbs single-sample jitter (GC pause, slow bridge
* tick) without thrashing. Forced calls bypass this gate.
*
* Worst-case correction latency ≈ this × bridgeMaxPostIntervalMs (80 ms in
* core/runtime/state.ts) = 160 ms — well under human A/V re-sync tolerance.
*/
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
export interface ProxyEntry {
el: HTMLMediaElement;
start: number;
duration: number;
/**
* The iframe media element this proxy mirrors, when adopted from the DOM.
* Its `data-start`/`data-duration` are re-read each tick so live timeline
* edits (trim/move) bound the proxy correctly. Null for URL-driven proxies.
*/
source?: HTMLMediaElement | null;
/**
* Count of consecutive steady-state samples in which the proxy's
* `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
* Reset on every in-threshold sample. A write is only issued once this
* reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
* single-sample jitter without thrashing.
*/
driftSamples: number;
}
export class ParentMediaManager {
private _entries: ProxyEntry[] = [];
private _mediaObserver?: MutationObserver;
private _playbackErrorPosted = false;
private _audioOwner: "runtime" | "parent" = "runtime";
/** The proxy created from the `audio-src` attribute, tracked so it can be
* replaced or cleared instead of accumulating on every attribute change. */
private _urlAudioEntry: ProxyEntry | null = null;
private _urlAudioSrc: string | null = null;
private readonly _dispatchEvent: (event: Event) => void;
private readonly _getMuted: () => boolean;
private readonly _getVolume: () => number;
private readonly _getPlaybackRate: () => number;
private readonly _getCurrentTime: () => number;
private readonly _isPaused: () => boolean;
constructor(opts: {
dispatchEvent: (event: Event) => void;
getMuted: () => boolean;
getVolume: () => number;
getPlaybackRate: () => number;
getCurrentTime: () => number;
isPaused: () => boolean;
}) {
this._dispatchEvent = opts.dispatchEvent;
this._getMuted = opts.getMuted;
this._getVolume = opts.getVolume;
this._getPlaybackRate = opts.getPlaybackRate;
this._getCurrentTime = opts.getCurrentTime;
this._isPaused = opts.isPaused;
}
get audioOwner(): "runtime" | "parent" {
return this._audioOwner;
}
/** Exposed for test instrumentation only — do not use in production code. */
get entries(): ProxyEntry[] {
return this._entries;
}
resetForIframeLoad(): void {
this._playbackErrorPosted = false;
const wasPromoted = this._audioOwner === "parent";
this._audioOwner = "runtime";
this.pauseAll();
this.teardownObserver();
if (wasPromoted) {
this._dispatchEvent(
new CustomEvent("audioownershipchange", {
detail: { owner: "runtime", reason: "iframe-reload" },
}),
);
}
}
destroy(): void {
this.teardownObserver();
for (const m of this._entries) {
m.el.pause();
m.el.src = "";
}
this._entries = [];
this._urlAudioEntry = null;
this._urlAudioSrc = null;
}
updateMuted(muted: boolean): void {
for (const m of this._entries) m.el.muted = muted;
}
updateVolume(volume: number): void {
for (const m of this._entries) m.el.volume = volume;
}
updatePlaybackRate(rate: number): void {
for (const m of this._entries) m.el.playbackRate = rate;
}
private _playEntry(m: ProxyEntry): void {
if (!m.el.src) return;
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
}
// Play only if the current playhead is inside the clip's (live) window, so
// bulk starts (playAll / adopt) don't blip audio for clips outside their
// window until the next mirrorTime tick gates them off.
private _playEntryIfActive(m: ProxyEntry): void {
this._refreshEntryBounds(m);
const relTime = this._getCurrentTime() - m.start;
if (relTime < 0 || relTime >= m.duration) return;
this._playEntry(m);
}
// Re-read the source clip's live timing so trims/moves bound the proxy
// (adopt-time values go stale when the timeline is edited).
private _refreshEntryBounds(m: ProxyEntry): void {
if (!m.source?.isConnected) return;
// Guard against a malformed (non-numeric) attribute parsing to NaN: an NaN
// duration makes every `relTime >= m.duration` window check false, so the
// gate never closes and the proxy plays past its clip end.
const start = parseFloat(m.source.getAttribute("data-start") || "0");
m.start = Number.isFinite(start) ? start : 0;
const duration = parseFloat(m.source.getAttribute("data-duration") || "");
m.duration = Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY;
}
// Pause the proxy outside its clip window; resume it on re-entry during
// parent-owned playback. Returns whether the proxy is within the window.
private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean {
if (relTime < 0 || relTime >= m.duration) {
if (!m.el.paused) m.el.pause();
m.driftSamples = 0;
return false;
}
if (this._audioOwner === "parent" && !this._isPaused() && m.el.paused) this._playEntry(m);
return true;
}
playAll(): void {
for (const m of this._entries) this._playEntryIfActive(m);
}
pauseAll(): void {
for (const m of this._entries) m.el.pause();
}
seekAll(timeInSeconds: number): void {
for (const m of this._entries) {
// Re-read live bounds so a trim/move just before a paused scrub gates and
// positions against the current clip window, not the adopt-time one.
this._refreshEntryBounds(m);
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
}
}
/**
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
* promotion, new proxy initialization) where drift must be corrected
* immediately.
*/
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
const force = options?.force === true;
for (const m of this._entries) {
this._refreshEntryBounds(m);
const relTime = timelineSeconds - m.start;
if (!this._gateEntryPlayback(m, relTime)) continue;
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
m.driftSamples += 1;
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
m.el.currentTime = relTime;
m.driftSamples = 0;
}
} else {
m.driftSamples = 0;
}
}
}
/**
* Take ownership of audible playback in response to the runtime's
* `media-autoplay-blocked` signal. Idempotent.
*
* The caller is responsible for muting the iframe's own media output via the
* postMessage bridge (`set-media-output-muted`) after calling this.
*/
/**
* Take ownership of audible playback. Idempotent. The `onMirror` callback
* is called with the current timeline time and `{ force: true }` so the
* caller's mirror implementation runs (enabling test spies on the player
* to fire). If omitted, `mirrorTime` is called directly.
*/
promoteToParentProxy(
iframeDoc: Document | null,
onMirror?: (t: number, opts: { force: boolean }) => void,
): void {
if (this._audioOwner === "parent") return;
this._audioOwner = "parent";
// Synchronously mute iframe media to close the race window.
if (iframeDoc) {
for (const el of iframeDoc.querySelectorAll<HTMLMediaElement>("video, audio")) {
el.muted = true;
}
}
// One-shot alignment — bypass jitter-coalescing gate.
const t = this._getCurrentTime();
if (onMirror) onMirror(t, { force: true });
else this.mirrorTime(t, { force: true });
if (!this._isPaused()) this.playAll();
this._dispatchEvent(
new CustomEvent("audioownershipchange", {
detail: { owner: "parent", reason: "autoplay-blocked" },
}),
);
}
/**
* Set up proxies for all timed media currently in the iframe document, then
* install a MutationObserver for media added later (sub-composition activation).
*/
setupFromIframe(iframeDoc: Document): void {
const mediaEls = iframeDoc.querySelectorAll<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
for (const iframeEl of mediaEls) this._adoptIframeMedia(iframeEl);
this._observeDynamicMedia(iframeDoc);
}
/**
* Set (or replace) the parent-frame audio proxy driven by the `audio-src`
* attribute. Re-setting with a different URL tears down the previous proxy
* first, so changing `audio-src` swaps the track instead of stacking a
* second one that keeps preloading and plays in parallel.
*/
setupFromUrl(audioSrc: string): void {
if (this._urlAudioSrc === audioSrc && this._urlAudioEntry) return;
this.teardownUrlAudio();
const entry = this._createEntry(audioSrc, "audio", 0, Infinity);
// `_createEntry` returns null when a proxy for this URL already exists
// (e.g. the composition already adopted the same media). In that case we do
// not own a proxy, so leave the tracking cleared rather than recording a
// src with no entry — otherwise teardown would target nothing and the
// no-op guard would never engage.
this._urlAudioEntry = entry;
this._urlAudioSrc = entry ? audioSrc : null;
// If the parent already owns playback, bring the fresh proxy online so a
// mid-playback swap is not silent until the next play tick.
if (entry && this._audioOwner === "parent" && !this._isPaused()) {
this.mirrorTime(this._getCurrentTime(), { force: true });
this.playAll();
}
}
/** Tear down the `audio-src` proxy (used when the attribute is removed). */
teardownUrlAudio(): void {
const entry = this._urlAudioEntry;
this._urlAudioEntry = null;
this._urlAudioSrc = null;
if (!entry) return;
entry.el.pause();
entry.el.src = "";
const idx = this._entries.indexOf(entry);
if (idx !== -1) this._entries.splice(idx, 1);
}
teardownObserver(): void {
this._mediaObserver?.disconnect();
this._mediaObserver = undefined;
}
// ── Private ──────────────────────────────────────────────────────────────
private _reportPlaybackError(err: unknown): void {
if (this._playbackErrorPosted) return;
this._playbackErrorPosted = true;
this._dispatchEvent(
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
);
}
/**
* Create a parent-frame media element and start preloading it. Returns the
* new entry, or `null` if a proxy for this src already exists (dedup).
*/
private _createEntry(
src: string,
tag: "audio" | "video",
start: number,
duration: number,
source?: HTMLMediaElement | null,
): ProxyEntry | null {
if (this._entries.some((m) => m.el.src === src)) return null;
const el = tag === "video" ? document.createElement("video") : new Audio();
el.preload = "auto";
el.src = src;
el.load();
el.muted = this._getMuted();
el.volume = this._getVolume();
const rate = this._getPlaybackRate();
if (rate !== 1) el.playbackRate = rate;
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
this._entries.push(entry);
return entry;
}
/** Resolve an iframe media element's source to an absolute URL, or null. */
private _resolveIframeMediaSrc(iframeEl: HTMLMediaElement): string | null {
const rawSrc =
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
return rawSrc ? new URL(rawSrc, iframeEl.ownerDocument.baseURI).href : null;
}
// fallow-ignore-next-line complexity
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
// Skip elements the preloader has demoted — the observer will re-trigger
// when the preload attribute is promoted to "auto".
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
const src = this._resolveIframeMediaSrc(iframeEl);
if (!src) return;
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
const created = this._createEntry(src, tag, start, duration, iframeEl);
// If already under parent ownership and playing, the new proxy must catch
// up immediately — bypass the jitter-coalescing gate.
if (created && this._audioOwner === "parent") {
this.mirrorTime(this._getCurrentTime(), { force: true });
if (!this._isPaused()) this._playEntryIfActive(created);
}
}
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
const src = this._resolveIframeMediaSrc(iframeEl);
if (!src) return;
const idx = this._entries.findIndex((m) => m.el.src === src);
if (idx === -1) return;
const entry = this._entries[idx];
entry.el.pause();
entry.el.src = "";
this._entries.splice(idx, 1);
}
private _observeDynamicMedia(doc: Document): void {
this.teardownObserver();
if (typeof MutationObserver === "undefined" || !doc.body) return;
// fallow-ignore-next-line complexity
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "preload") {
const target = m.target;
if (
target instanceof HTMLMediaElement &&
target.matches("audio[data-start], video[data-start]") &&
target.preload === "auto"
) {
this._adoptIframeMedia(target);
}
continue;
}
for (const added of m.addedNodes) {
if (!(added instanceof Element)) continue;
const candidates: HTMLMediaElement[] = [];
if (added.matches?.("audio[data-start], video[data-start]")) {
candidates.push(added as HTMLMediaElement);
}
const inside = added.querySelectorAll?.<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
if (inside) for (const el of inside) candidates.push(el);
for (const el of candidates) this._adoptIframeMedia(el);
}
for (const removed of m.removedNodes) {
if (!(removed instanceof Element)) continue;
const dropped: HTMLMediaElement[] = [];
if (removed.matches?.("audio[data-start], video[data-start]")) {
dropped.push(removed as HTMLMediaElement);
}
const inside = removed.querySelectorAll?.<HTMLMediaElement>(
"audio[data-start], video[data-start]",
);
if (inside) for (const el of inside) dropped.push(el);
for (const el of dropped) this._detachIframeMedia(el);
}
}
});
const observeOpts: MutationObserverInit = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["preload"],
};
const targets = selectMediaObserverTargets(doc);
for (const target of targets) {
obs.observe(target, observeOpts);
}
this._mediaObserver = obs;
}
}