From c28300f0cb736d6dd4b3c3793c678967da34d555 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 | 7 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 7ce83106b..5a6ea2509 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 c53f948b9..539313e2c 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 { @@ -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) => {