mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): clamp the native volume of an over-unity clip
`onSetVolume` assigned `clipVolume * volume` straight to
`HTMLMediaElement.volume`. `data-volume` is an authoring GAIN up to
MAX_AUDIO_GAIN (12 dB, ~3.98) — the native property accepts only 0..1 — so a
clip authored above unity threw
IndexSizeError: Failed to set the 'volume' property on 'HTMLMediaElement':
The volume provided (2.42103) is outside the range [0, 1].
2.42103 is the +7.68 dB fader stop, serialized by `formatAudioGain`. The bridge
clamps its OWN argument to [0,1] (bridge.ts) but nothing clamped the product,
and because the throw escaped mid-loop it abandoned the rest of the sweep:
every media element after the loud one kept its previous volume.
`clampNativeMediaVolume` already existed in audioGain.ts for exactly this — used
by `withUnclampedVolume`, never here. The gain is not lost by clamping: the Web
Audio transport owns it (`webAudio.setVolume` on the line above), and this
native assignment is only the fallback for elements the transport does not route.
Pre-existing, not from this branch: the line dates to a7a664885 (2026-05-07,
"feat(player): add volume/mute controls"), written before over-unity authoring
gain existed. This branch's faders are what make an over-unity `data-volume`
routine, so it surfaces here.
Regression test in init.test.ts drives a real `set-volume` control message at a
2.42103 clip and asserts no error reaches the window; verified it fails on a
revert of the clamp. Note for whoever edits it: the bridge only accepts
`source: "hf-parent"` — a message with any other source is silently ignored, so
a test that gets that wrong passes while proving nothing.
core: 122 files, 2480 tests pass.
This commit is contained in:
@@ -11,6 +11,55 @@ it("schedules WebAudio element gain from author volume without bridge volume", (
|
||||
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
|
||||
* `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
|
||||
* argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
|
||||
* clip authored above unity threw
|
||||
* `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
|
||||
* (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
|
||||
* every media element after it kept its old volume too.
|
||||
*/
|
||||
it("clamps the native volume of an over-unity clip instead of throwing", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const loud = document.createElement("audio");
|
||||
loud.setAttribute("data-start", "0");
|
||||
loud.setAttribute("data-duration", "10");
|
||||
loud.setAttribute("data-volume", "2.42103");
|
||||
loud.load = () => {};
|
||||
root.appendChild(loud);
|
||||
// Second element proves the throw took the whole sweep down with it, not just
|
||||
// the offending clip.
|
||||
const quiet = document.createElement("audio");
|
||||
quiet.setAttribute("data-start", "0");
|
||||
quiet.setAttribute("data-duration", "10");
|
||||
quiet.setAttribute("data-volume", "0.5");
|
||||
quiet.load = () => {};
|
||||
root.appendChild(quiet);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const errors: string[] = [];
|
||||
const onError = (e: ErrorEvent) => errors.push(String(e.message ?? e.error));
|
||||
window.addEventListener("error", onError);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
|
||||
}),
|
||||
);
|
||||
window.removeEventListener("error", onError);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const state = { time: 0, paused: true, duration };
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import { HF_AUDIO_GROUP_TAG } from "../audioGroups";
|
||||
import { clampNativeMediaVolume } from "../audioGain";
|
||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
||||
import type {
|
||||
@@ -3235,7 +3236,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
// assigning the product raw THROWS IndexSizeError and takes the rest of
|
||||
// the loop with it. The element carries the legal part; the boost above
|
||||
// unity belongs to Web Audio, which already has it from `setVolume`.
|
||||
el.volume = Math.max(0, Math.min(1, clipVolume * volume));
|
||||
//
|
||||
// Through `clampNativeMediaVolume` rather than an inline clamp: that
|
||||
// helper exists in `audioGain.ts` for exactly this bound and is what
|
||||
// `withUnclampedVolume` uses, so the two cannot drift.
|
||||
el.volume = clampNativeMediaVolume(clipVolume * volume);
|
||||
}
|
||||
},
|
||||
onSetMediaOutputMuted: (muted) => {
|
||||
|
||||
Reference in New Issue
Block a user