mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
Merge 062227c148 into 61ba800a5d
This commit is contained in:
@@ -17,12 +17,14 @@ function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
|
||||
function makeManager(
|
||||
overrides: Partial<{ isPaused: boolean; playbackRate: number; volume: number }> = {},
|
||||
) {
|
||||
const mgr = new ParentMediaManager({
|
||||
dispatchEvent: () => {},
|
||||
getMuted: () => false,
|
||||
getVolume: () => 1,
|
||||
getPlaybackRate: () => 1,
|
||||
getVolume: () => overrides.volume ?? 1,
|
||||
getPlaybackRate: () => overrides.playbackRate ?? 1,
|
||||
getCurrentTime: () => 0,
|
||||
isPaused: () => overrides.isPaused ?? true,
|
||||
});
|
||||
@@ -90,7 +92,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
});
|
||||
|
||||
it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
|
||||
const mgr = makeManager({ owner: "parent", isPaused: false });
|
||||
const mgr = makeManager({ isPaused: false });
|
||||
const el = makeFakeAudio(false); // already playing within the clip
|
||||
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });
|
||||
|
||||
@@ -102,7 +104,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
});
|
||||
|
||||
it("re-reads the source element's live data-duration so trims bound the proxy", () => {
|
||||
const mgr = makeManager({ owner: "parent", isPaused: false });
|
||||
const mgr = makeManager({ isPaused: false });
|
||||
const source = new Audio();
|
||||
source.setAttribute("data-start", "0");
|
||||
source.setAttribute("data-duration", "30");
|
||||
@@ -123,7 +125,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
});
|
||||
|
||||
it("scrubAll plays in-window proxies at the playhead and pauses out-of-window ones", () => {
|
||||
const mgr = makeManager({ owner: "parent" });
|
||||
const mgr = makeManager();
|
||||
const inWin = makeFakeAudio(true); // currently paused — scrub should start it
|
||||
const outWin = makeFakeAudio(false); // currently playing, but outside its window
|
||||
mgr.entries.push({ el: inWin, start: 0, duration: 5, driftSamples: 0 });
|
||||
@@ -161,4 +163,170 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
});
|
||||
|
||||
it("defers connected gain changes to the runtime's live effective volume", () => {
|
||||
const mgr = makeManager({ volume: 0.5 });
|
||||
const iframeDoc = document.implementation.createHTMLDocument();
|
||||
const source = iframeDoc.createElement("audio");
|
||||
source.src = "https://example.test/scored.mp3";
|
||||
source.preload = "auto";
|
||||
source.setAttribute("data-start", "0");
|
||||
source.setAttribute("data-duration", "10");
|
||||
source.setAttribute("data-volume", "0.12");
|
||||
// The iframe runtime has already applied authored × global volume.
|
||||
source.volume = 0.06;
|
||||
iframeDoc.body.appendChild(source);
|
||||
|
||||
mgr.setupFromIframe(iframeDoc);
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
const proxy = mgr.entries[0].el;
|
||||
expect(proxy.volume).toBeCloseTo(0.06);
|
||||
|
||||
// GSAP/runtime envelopes are already effective values on the source. The
|
||||
// parent proxy copies them directly instead of multiplying global volume
|
||||
// a second time.
|
||||
source.volume = 0.018;
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBeCloseTo(0.018);
|
||||
|
||||
// A non-zero player-volume update must not reconstruct the GSAP envelope
|
||||
// from static data-volume. Keep the last effective gain until the iframe
|
||||
// runtime publishes its next authoritative source.volume value.
|
||||
mgr.updateVolume(0.25);
|
||||
expect(proxy.volume).toBeCloseTo(0.018);
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBeCloseTo(0.018);
|
||||
|
||||
// Once the iframe applies the new global volume, mirror it directly.
|
||||
source.volume = 0.009;
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBeCloseTo(0.009);
|
||||
|
||||
// Zero is safe to apply immediately. On unmute, remain silent until the
|
||||
// runtime has re-established the current envelope at the new gain.
|
||||
mgr.updateVolume(0);
|
||||
expect(proxy.volume).toBe(0);
|
||||
// A mirror can run before the iframe handles set-volume; stale non-zero
|
||||
// source gain must not undo the immediate silence.
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBe(0);
|
||||
source.volume = 0;
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
mgr.updateVolume(0.75);
|
||||
expect(proxy.volume).toBe(0);
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBe(0);
|
||||
source.volume = 0.09;
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBeCloseTo(0.09);
|
||||
|
||||
// A fully faded track stays fully faded when global volume changes.
|
||||
source.volume = 0;
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
mgr.updateVolume(0.5);
|
||||
mgr.mirrorTime(1, { force: true });
|
||||
expect(proxy.volume).toBe(0);
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
it("applies live media offsets to mirror, seek, and scrub positioning", () => {
|
||||
const mgr = makeManager();
|
||||
const iframeDoc = document.implementation.createHTMLDocument();
|
||||
const source = iframeDoc.createElement("audio");
|
||||
source.src = "https://example.test/offset.mp3";
|
||||
source.preload = "auto";
|
||||
source.setAttribute("data-start", "5");
|
||||
source.setAttribute("data-duration", "10");
|
||||
source.setAttribute("data-media-start", "36.947");
|
||||
iframeDoc.body.appendChild(source);
|
||||
|
||||
mgr.setupFromIframe(iframeDoc);
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
const proxy = mgr.entries[0].el;
|
||||
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.currentTime).toBeCloseTo(38.947);
|
||||
|
||||
mgr.seekAll(8);
|
||||
expect(proxy.currentTime).toBeCloseTo(39.947);
|
||||
|
||||
mgr.scrubAll(9);
|
||||
expect(proxy.currentTime).toBeCloseTo(40.947);
|
||||
|
||||
// Both aliases are live, with data-playback-start taking precedence just
|
||||
// as it does in the iframe runtime.
|
||||
source.setAttribute("data-media-start", "10");
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.currentTime).toBeCloseTo(12);
|
||||
source.setAttribute("data-playback-start", "4");
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.currentTime).toBeCloseTo(6);
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
it("combines authored and global playback rates in positioning and playback", () => {
|
||||
const state = { playbackRate: 0.5 };
|
||||
const mgr = makeManager(state);
|
||||
const iframeDoc = document.implementation.createHTMLDocument();
|
||||
const source = iframeDoc.createElement("audio");
|
||||
source.src = "https://example.test/rate.mp3";
|
||||
source.preload = "auto";
|
||||
source.setAttribute("data-start", "5");
|
||||
source.setAttribute("data-duration", "10");
|
||||
source.setAttribute("data-media-start", "3");
|
||||
source.setAttribute("data-playback-rate", "2");
|
||||
iframeDoc.body.appendChild(source);
|
||||
|
||||
mgr.setupFromIframe(iframeDoc);
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
const proxy = mgr.entries[0].el;
|
||||
expect(proxy.playbackRate).toBe(1);
|
||||
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.currentTime).toBe(7);
|
||||
|
||||
mgr.seekAll(8);
|
||||
expect(proxy.currentTime).toBe(9);
|
||||
|
||||
mgr.scrubAll(9);
|
||||
expect(proxy.currentTime).toBe(11);
|
||||
|
||||
state.playbackRate = 1.5;
|
||||
mgr.updatePlaybackRate(state.playbackRate);
|
||||
expect(proxy.playbackRate).toBe(3);
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.playbackRate).toBe(3);
|
||||
|
||||
// Live authored-rate edits affect both source-time mapping and effective
|
||||
// proxy playback without requiring re-adoption.
|
||||
source.setAttribute("data-playback-rate", "0.75");
|
||||
mgr.mirrorTime(7, { force: true });
|
||||
expect(proxy.currentTime).toBe(4.5);
|
||||
expect(proxy.playbackRate).toBe(1.125);
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
|
||||
it("keeps URL-driven proxies at the global playback rate", () => {
|
||||
const state = { playbackRate: 0.75, volume: 0.5 };
|
||||
const mgr = makeManager(state);
|
||||
mgr.setupFromUrl("https://example.test/url-rate.mp3");
|
||||
expect(mgr.entries[0].el.playbackRate).toBe(0.75);
|
||||
expect(mgr.entries[0].el.volume).toBe(0.5);
|
||||
|
||||
state.volume = 0.25;
|
||||
mgr.updateVolume(state.volume);
|
||||
expect(mgr.entries[0].el.volume).toBe(0.25);
|
||||
|
||||
state.playbackRate = 1.25;
|
||||
mgr.updatePlaybackRate(state.playbackRate);
|
||||
expect(mgr.entries[0].el.playbackRate).toBe(1.25);
|
||||
mgr.mirrorTime(2, { force: true });
|
||||
expect(mgr.entries[0].el.currentTime).toBe(2);
|
||||
expect(mgr.entries[0].el.playbackRate).toBe(1.25);
|
||||
|
||||
mgr.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,14 +26,46 @@ const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
|
||||
*/
|
||||
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
|
||||
|
||||
function clampVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume)) return 1;
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
function readAuthorVolume(source: HTMLMediaElement): number {
|
||||
const raw = Number.parseFloat(source.dataset.volume ?? "");
|
||||
return clampVolume(Number.isFinite(raw) ? raw : 1);
|
||||
}
|
||||
|
||||
function readMediaStart(source: HTMLMediaElement): number {
|
||||
const raw = Number.parseFloat(source.dataset.playbackStart ?? source.dataset.mediaStart ?? "");
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
}
|
||||
|
||||
function normalizePlaybackRate(rate: number): number {
|
||||
return Number.isFinite(rate) && rate > 0 ? Math.max(0.1, Math.min(5, rate)) : 1;
|
||||
}
|
||||
|
||||
function readAuthorPlaybackRate(source: HTMLMediaElement): number {
|
||||
const authored = Number.parseFloat(source.dataset.playbackRate ?? "");
|
||||
return normalizePlaybackRate(
|
||||
Number.isFinite(authored) && authored > 0 ? authored : source.defaultPlaybackRate,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProxyEntry {
|
||||
el: HTMLMediaElement;
|
||||
start: number;
|
||||
duration: number;
|
||||
/** Offset into the source media at the start of the timeline clip. Defaults to 0. */
|
||||
mediaStart?: number;
|
||||
/** Per-track authored gain before global volume is applied. Defaults to 1. */
|
||||
authorVolume?: number;
|
||||
/** Per-clip source playback rate before global playback rate. Defaults to 1. */
|
||||
authorPlaybackRate?: 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.
|
||||
* Its timing, media offset, authored volume, and authored playback rate are
|
||||
* re-read each tick so live edits stay reflected. Null for URL-driven proxies.
|
||||
*/
|
||||
source?: HTMLMediaElement | null;
|
||||
/**
|
||||
@@ -55,6 +87,7 @@ export class ParentMediaManager {
|
||||
* replaced or cleared instead of accumulating on every attribute change. */
|
||||
private _urlAudioEntry: ProxyEntry | null = null;
|
||||
private _urlAudioSrc: string | null = null;
|
||||
private _userVolume: number;
|
||||
|
||||
private readonly _dispatchEvent: (event: Event) => void;
|
||||
private readonly _getMuted: () => boolean;
|
||||
@@ -77,6 +110,7 @@ export class ParentMediaManager {
|
||||
this._getPlaybackRate = opts.getPlaybackRate;
|
||||
this._getCurrentTime = opts.getCurrentTime;
|
||||
this._isPaused = opts.isPaused;
|
||||
this._userVolume = clampVolume(opts.getVolume());
|
||||
}
|
||||
|
||||
get audioOwner(): "runtime" | "parent" {
|
||||
@@ -94,6 +128,7 @@ export class ParentMediaManager {
|
||||
this._audioOwner = "runtime";
|
||||
this.pauseAll();
|
||||
this.teardownObserver();
|
||||
this._userVolume = clampVolume(this._getVolume());
|
||||
if (wasPromoted) {
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
@@ -114,6 +149,7 @@ export class ParentMediaManager {
|
||||
this._urlAudioSrc = null;
|
||||
this._audioOwner = "runtime";
|
||||
this._playbackErrorPosted = false;
|
||||
this._userVolume = clampVolume(this._getVolume());
|
||||
}
|
||||
|
||||
updateMuted(muted: boolean): void {
|
||||
@@ -121,11 +157,29 @@ export class ParentMediaManager {
|
||||
}
|
||||
|
||||
updateVolume(volume: number): void {
|
||||
for (const m of this._entries) m.el.volume = volume;
|
||||
const userVolume = clampVolume(volume);
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryContract(m);
|
||||
if (m.source?.isConnected) {
|
||||
// The iframe runtime owns GSAP volume envelopes and will publish the
|
||||
// authoritative effective gain on `source.volume`. Never reconstruct
|
||||
// that envelope from the static data-volume attribute. A zero player
|
||||
// volume is safe to apply immediately; non-zero changes keep the last
|
||||
// effective gain until the runtime's next state tick reaches us.
|
||||
if (userVolume === 0) m.el.volume = 0;
|
||||
} else {
|
||||
m.el.volume = clampVolume((m.authorVolume ?? 1) * userVolume);
|
||||
}
|
||||
}
|
||||
this._userVolume = userVolume;
|
||||
}
|
||||
|
||||
updatePlaybackRate(rate: number): void {
|
||||
for (const m of this._entries) m.el.playbackRate = rate;
|
||||
const globalPlaybackRate = normalizePlaybackRate(rate);
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryContract(m);
|
||||
m.el.playbackRate = (m.authorPlaybackRate ?? 1) * globalPlaybackRate;
|
||||
}
|
||||
}
|
||||
|
||||
private _playEntry(m: ProxyEntry): void {
|
||||
@@ -137,15 +191,18 @@ export class ParentMediaManager {
|
||||
// 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);
|
||||
this._refreshEntryContract(m);
|
||||
const relTime = this._getCurrentTime() - m.start;
|
||||
if (relTime < 0 || relTime >= m.duration) return;
|
||||
this._syncEntryVolume(m);
|
||||
this._syncEntryPlaybackRate(m);
|
||||
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 {
|
||||
// Re-read the source clip's live contract so edits made after adoption are
|
||||
// reflected by the proxy. URL-driven entries have no source and retain the
|
||||
// defaults captured at creation.
|
||||
private _refreshEntryContract(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
|
||||
@@ -154,6 +211,36 @@ export class ParentMediaManager {
|
||||
m.start = timing.start ?? 0;
|
||||
m.duration =
|
||||
timing.duration != null && timing.duration > 0 ? timing.duration : Number.POSITIVE_INFINITY;
|
||||
m.mediaStart = readMediaStart(m.source);
|
||||
m.authorVolume = readAuthorVolume(m.source);
|
||||
m.authorPlaybackRate = readAuthorPlaybackRate(m.source);
|
||||
}
|
||||
|
||||
// The runtime owns animation envelopes and writes their effective gain to
|
||||
// the iframe media element. Copy that value directly: it already includes
|
||||
// the player's global volume, so multiplying it again would attenuate the
|
||||
// proxy twice. Entries without a live source use their authored ratio.
|
||||
private _syncEntryVolume(m: ProxyEntry): void {
|
||||
// Keep zero authoritative while the iframe's set-volume message is still
|
||||
// in flight; otherwise a mirror tick could briefly restore stale audio.
|
||||
if (this._userVolume === 0) {
|
||||
m.el.volume = 0;
|
||||
return;
|
||||
}
|
||||
if (m.source?.isConnected) {
|
||||
m.el.volume = clampVolume(m.source.volume);
|
||||
return;
|
||||
}
|
||||
m.el.volume = clampVolume((m.authorVolume ?? 1) * this._userVolume);
|
||||
}
|
||||
|
||||
private _syncEntryPlaybackRate(m: ProxyEntry): void {
|
||||
m.el.playbackRate =
|
||||
(m.authorPlaybackRate ?? 1) * normalizePlaybackRate(this._getPlaybackRate());
|
||||
}
|
||||
|
||||
private _sourceTime(m: ProxyEntry, relTime: number): number {
|
||||
return (m.mediaStart ?? 0) + relTime * (m.authorPlaybackRate ?? 1);
|
||||
}
|
||||
|
||||
// Pause the proxy outside its clip window; resume it on re-entry during
|
||||
@@ -186,9 +273,13 @@ export class ParentMediaManager {
|
||||
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);
|
||||
this._refreshEntryContract(m);
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
|
||||
if (relTime >= 0 && relTime < m.duration) {
|
||||
m.el.currentTime = this._sourceTime(m, relTime);
|
||||
this._syncEntryVolume(m);
|
||||
this._syncEntryPlaybackRate(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +292,12 @@ export class ParentMediaManager {
|
||||
// for output). Out-of-window proxies are paused.
|
||||
scrubAll(timeInSeconds: number): void {
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryBounds(m);
|
||||
this._refreshEntryContract(m);
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) {
|
||||
m.el.currentTime = relTime;
|
||||
m.el.currentTime = this._sourceTime(m, relTime);
|
||||
this._syncEntryVolume(m);
|
||||
this._syncEntryPlaybackRate(m);
|
||||
this._playEntry(m);
|
||||
} else if (!m.el.paused) {
|
||||
m.el.pause();
|
||||
@@ -221,13 +314,16 @@ export class ParentMediaManager {
|
||||
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
|
||||
const force = options?.force === true;
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryBounds(m);
|
||||
this._refreshEntryContract(m);
|
||||
const relTime = timelineSeconds - m.start;
|
||||
if (!this._gateEntryPlayback(m, relTime)) continue;
|
||||
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||||
this._syncEntryVolume(m);
|
||||
this._syncEntryPlaybackRate(m);
|
||||
const mediaTime = this._sourceTime(m, relTime);
|
||||
if (Math.abs(m.el.currentTime - mediaTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||||
m.driftSamples += 1;
|
||||
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
|
||||
m.el.currentTime = relTime;
|
||||
m.el.currentTime = mediaTime;
|
||||
m.driftSamples = 0;
|
||||
}
|
||||
} else {
|
||||
@@ -358,11 +454,24 @@ export class ParentMediaManager {
|
||||
el.src = src;
|
||||
el.load();
|
||||
el.muted = this._getMuted();
|
||||
el.volume = this._getVolume();
|
||||
const rate = this._getPlaybackRate();
|
||||
if (rate !== 1) el.playbackRate = rate;
|
||||
const authorVolume = source ? readAuthorVolume(source) : 1;
|
||||
const mediaStart = source ? readMediaStart(source) : 0;
|
||||
const authorPlaybackRate = source ? readAuthorPlaybackRate(source) : 1;
|
||||
el.volume = clampVolume(authorVolume * this._userVolume);
|
||||
const effectivePlaybackRate =
|
||||
authorPlaybackRate * normalizePlaybackRate(this._getPlaybackRate());
|
||||
if (effectivePlaybackRate !== 1) el.playbackRate = effectivePlaybackRate;
|
||||
|
||||
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
|
||||
const entry: ProxyEntry = {
|
||||
el,
|
||||
start,
|
||||
duration,
|
||||
mediaStart,
|
||||
authorVolume,
|
||||
authorPlaybackRate,
|
||||
driftSamples: 0,
|
||||
source,
|
||||
};
|
||||
this._entries.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user