From f464026f85bed100b44763463b29329d6982df78 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 20 Aug 2026 11:08:10 -0700 Subject: [PATCH] fix(core): clamp the native volume of an over-unity clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- packages/core/src/runtime/init.test.ts | 49 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 10 +++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8ca2e4bcc..1fe2cef4c 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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 { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 964aa0cd5..0ebca53a3 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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 { @@ -3230,7 +3231,14 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLMediaElement)) continue; const parsed = parseFloat(el.dataset.volume ?? ""); const clipVolume = Number.isFinite(parsed) ? parsed : 1; - el.volume = clipVolume * volume; + // Clamped, not assigned raw: `data-volume` is an authoring GAIN up to + // MAX_AUDIO_GAIN (12 dB ~ 3.98) while `HTMLMediaElement.volume` accepts + // only 0..1, so an over-unity clip threw IndexSizeError here — and the + // throw abandoned the rest of the sweep, leaving every later element at + // its old volume. The gain itself is not lost: the Web Audio transport + // above owns it (`webAudio.setVolume`), and this native assignment is + // the fallback for elements it does not route. + el.volume = clampNativeMediaVolume(clipVolume * volume); } }, onSetMediaOutputMuted: (muted) => {