mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: support animated audio volume
This commit is contained in:
@@ -1241,6 +1241,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
for (const mediaEl of mediaEls) {
|
||||
if (metadataBoundMedia.has(mediaEl)) continue;
|
||||
metadataBoundMedia.add(mediaEl);
|
||||
const parsedVolume = Number.parseFloat(mediaEl.dataset.volume ?? "");
|
||||
if (Number.isFinite(parsedVolume)) {
|
||||
mediaEl.volume = Math.max(0, Math.min(1, parsedVolume));
|
||||
}
|
||||
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
|
||||
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
|
||||
|
||||
@@ -1312,6 +1316,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
userMuted: state.bridgeMuted,
|
||||
userVolume: state.bridgeVolume,
|
||||
forceSync,
|
||||
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -1461,8 +1466,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
externalCompositionsReady = true;
|
||||
bindRootTimelineIfAvailable();
|
||||
window.__renderReady = true;
|
||||
runAdapters("discover", state.currentTime);
|
||||
bindMediaMetadataListeners();
|
||||
runAdapters("discover", state.currentTime);
|
||||
installAssetFailureDiagnostics();
|
||||
applyCaptionOverrides();
|
||||
postTimeline();
|
||||
@@ -1667,8 +1672,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
] as RuntimeDeterministicAdapter[];
|
||||
patchVideoTextureCompat();
|
||||
installRuntimeErrorDiagnostics();
|
||||
runAdapters("discover");
|
||||
bindMediaMetadataListeners();
|
||||
runAdapters("discover");
|
||||
// ── Single-clock transport ──
|
||||
//
|
||||
// TransportClock is the sole time authority. GSAP is always paused —
|
||||
|
||||
@@ -343,6 +343,41 @@ describe("syncRuntimeMedia", () => {
|
||||
expect(clip.el.volume).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it("preserves authored volume changes made between sync ticks", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0 });
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 0, playing: false, playbackRate: 1 });
|
||||
expect(clip.el.volume).toBe(0);
|
||||
|
||||
clip.el.volume = 0.5;
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: 0.5, playing: false, playbackRate: 1 });
|
||||
|
||||
expect(clip.el.volume).toBe(0.5);
|
||||
});
|
||||
|
||||
it("reports the effective element volume to external audio transports", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0 });
|
||||
const onElementVolume = vi.fn();
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 0,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
onElementVolume,
|
||||
});
|
||||
clip.el.volume = 0.75;
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 1,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
userVolume: 0.5,
|
||||
onElementVolume,
|
||||
});
|
||||
|
||||
expect(clip.el.volume).toBeCloseTo(0.375);
|
||||
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375);
|
||||
});
|
||||
|
||||
it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
|
||||
@@ -103,6 +103,13 @@ function markPlayRequested(el: HTMLMediaElement): void {
|
||||
el.addEventListener("error", clear, { once: true });
|
||||
}
|
||||
|
||||
const lastRuntimeAppliedVolume = new WeakMap<HTMLMediaElement, number>();
|
||||
|
||||
function clampVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume)) return 1;
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
export function syncRuntimeMedia(params: {
|
||||
clips: RuntimeMediaClip[];
|
||||
timeSeconds: number;
|
||||
@@ -132,6 +139,7 @@ export function syncRuntimeMedia(params: {
|
||||
* outbound message; further invocations are suppressed by the caller.
|
||||
*/
|
||||
onAutoplayBlocked?: () => void;
|
||||
onElementVolume?: (el: HTMLMediaElement, volume: number) => void;
|
||||
forceSync?: boolean;
|
||||
}): void {
|
||||
// Either flag silences output. Combined up front so the per-clip loop is
|
||||
@@ -151,8 +159,19 @@ export function syncRuntimeMedia(params: {
|
||||
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
|
||||
}
|
||||
}
|
||||
const userVol = params.userVolume ?? 1;
|
||||
el.volume = (clip.volume ?? 1) * userVol;
|
||||
const userVol = clampVolume(params.userVolume ?? 1);
|
||||
const fallbackAuthorVolume = clampVolume(clip.volume ?? 1);
|
||||
const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el);
|
||||
const currentElementVolume = clampVolume(el.volume);
|
||||
const authorVolume =
|
||||
previousRuntimeVolume !== undefined &&
|
||||
Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001
|
||||
? currentElementVolume
|
||||
: fallbackAuthorVolume;
|
||||
const effectiveVolume = clampVolume(authorVolume * userVol);
|
||||
el.volume = effectiveVolume;
|
||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||
params.onElementVolume?.(el, effectiveVolume);
|
||||
if (shouldMute) el.muted = true;
|
||||
// Ensure full preload for every active media element. Streaming
|
||||
// formats (MP3) may arrive with preload="metadata", which only
|
||||
@@ -283,6 +302,7 @@ export function syncRuntimeMedia(params: {
|
||||
lastOffset.delete(el);
|
||||
strictDriftSamples.delete(el);
|
||||
seekLoadRetried.delete(el);
|
||||
lastRuntimeAppliedVolume.delete(el);
|
||||
if (!el.paused) el.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,18 @@ export class WebAudioTransport {
|
||||
}
|
||||
}
|
||||
|
||||
setElementVolume(el: HTMLMediaElement, volume: number): void {
|
||||
const safeVolume = Math.max(0, Math.min(1, volume));
|
||||
for (const source of this._activeSources) {
|
||||
if (source.el !== el) continue;
|
||||
try {
|
||||
source.gainNode.gain.value = safeVolume;
|
||||
} catch (err) {
|
||||
swallow("webAudioTransport.setElementVolume", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (this._masterGain) {
|
||||
this._masterGain.gain.value = muted ? 0 : 1;
|
||||
|
||||
Reference in New Issue
Block a user