@@ -190,6 +190,99 @@ describe("initSandboxRuntimeModular", () => {
190190 window . cancelAnimationFrame = originalCancelAnimationFrame ;
191191 } ) ;
192192
193+ /**
194+ * `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
195+ * `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
196+ * argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
197+ * clip authored above unity threw
198+ * `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
199+ * (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
200+ * every media element after it kept its old volume too.
201+ */
202+ it ( "clamps the native volume of an over-unity clip instead of throwing" , ( ) => {
203+ const root = document . createElement ( "div" ) ;
204+ root . setAttribute ( "data-composition-id" , "main" ) ;
205+ root . setAttribute ( "data-root" , "true" ) ;
206+ root . setAttribute ( "data-start" , "0" ) ;
207+ root . setAttribute ( "data-duration" , "10" ) ;
208+ root . setAttribute ( "data-width" , "1920" ) ;
209+ root . setAttribute ( "data-height" , "1080" ) ;
210+ document . body . appendChild ( root ) ;
211+
212+ const loud = document . createElement ( "audio" ) ;
213+ loud . setAttribute ( "data-start" , "0" ) ;
214+ loud . setAttribute ( "data-duration" , "10" ) ;
215+ loud . setAttribute ( "data-volume" , "2.42103" ) ;
216+ loud . load = ( ) => { } ;
217+ root . appendChild ( loud ) ;
218+ // Second element proves the throw took the whole sweep down with it, not just
219+ // the offending clip.
220+ const quiet = document . createElement ( "audio" ) ;
221+ quiet . setAttribute ( "data-start" , "0" ) ;
222+ quiet . setAttribute ( "data-duration" , "10" ) ;
223+ quiet . setAttribute ( "data-volume" , "0.5" ) ;
224+ quiet . load = ( ) => { } ;
225+ root . appendChild ( quiet ) ;
226+
227+ window . __timelines = { main : createMockTimeline ( 10 ) } ;
228+ initSandboxRuntimeModular ( ) ;
229+
230+ const errors : string [ ] = [ ] ;
231+ const onError = ( e : ErrorEvent ) => errors . push ( String ( e . message ?? e . error ) ) ;
232+ window . addEventListener ( "error" , onError ) ;
233+ window . dispatchEvent (
234+ new MessageEvent ( "message" , {
235+ data : { source : "hf-parent" , type : "control" , action : "set-volume" , volume : 1 } ,
236+ } ) ,
237+ ) ;
238+ window . removeEventListener ( "error" , onError ) ;
239+ expect ( errors ) . toEqual ( [ ] ) ;
240+ } ) ;
241+
242+ /**
243+ * The runtime stamps `data-start`/`data-duration` on every id'd child of the
244+ * composition root so a blank canvas still shows selectable rows. An
245+ * `<hf-audio-group>` is a mixer BUS, not a clip: stamping it put it in
246+ * `__clipManifest` as a full-duration element, which the studio drew as an
247+ * ordinary clip row above the real group header — draggable, trimmable, and
248+ * deletable, and deleting it takes the bus (so the group's FX rack) with it.
249+ */
250+ it ( "does not stamp timing onto an <hf-audio-group> bus" , ( ) => {
251+ const root = document . createElement ( "div" ) ;
252+ root . setAttribute ( "data-composition-id" , "main" ) ;
253+ root . setAttribute ( "data-root" , "true" ) ;
254+ root . setAttribute ( "data-start" , "0" ) ;
255+ root . setAttribute ( "data-duration" , "10" ) ;
256+ root . setAttribute ( "data-width" , "1920" ) ;
257+ root . setAttribute ( "data-height" , "1080" ) ;
258+ document . body . appendChild ( root ) ;
259+
260+ const bus = document . createElement ( "hf-audio-group" ) ;
261+ bus . id = "voiceover" ;
262+ bus . setAttribute ( "data-label" , "Voiceover" ) ;
263+ root . appendChild ( bus ) ;
264+
265+ // A plain id'd sibling proves the stamp still happens for everything else.
266+ const caption = document . createElement ( "div" ) ;
267+ caption . id = "cap-1" ;
268+ root . appendChild ( caption ) ;
269+
270+ window . __timelines = { main : createMockTimeline ( 10 ) } ;
271+ // The stamp only runs inside the studio preview (`window.parent !== window`),
272+ // which jsdom is not — so the condition has to be staged for the test.
273+ const realParent = window . parent ;
274+ Object . defineProperty ( window , "parent" , { value : { } , configurable : true } ) ;
275+ try {
276+ initSandboxRuntimeModular ( ) ;
277+ } finally {
278+ Object . defineProperty ( window , "parent" , { value : realParent , configurable : true } ) ;
279+ }
280+
281+ expect ( bus . hasAttribute ( "data-start" ) ) . toBe ( false ) ;
282+ expect ( bus . hasAttribute ( "data-duration" ) ) . toBe ( false ) ;
283+ expect ( caption . getAttribute ( "data-start" ) ) . toBe ( "0" ) ;
284+ } ) ;
285+
193286 it ( "resolves Studio hold as a deterministic step at the segment end" , ( ) => {
194287 const defaultEase = ( progress : number ) => progress ;
195288 const originalParseEase = vi . fn ( ( ) => defaultEase ) ;
@@ -1350,10 +1443,13 @@ describe("initSandboxRuntimeModular", () => {
13501443 window . __timelines = { main : createMockTimeline ( 10 ) } ;
13511444 initSandboxRuntimeModular ( ) ;
13521445
1353- // `scheduleMediaElementPlayback` is the Web Audio scheduling entry point (#3322 routed
1354- // media-element clips straight through the graph; `decodeAudioElement` is only the fallback
1355- // for the rate-shifted case, so it is NOT called on this path).
1356- const scheduleSpy = vi . spyOn ( WebAudioTransport . prototype , "scheduleMediaElementPlayback" ) ;
1446+ // `scheduleMediaElementPlayback`, not `decodeAudioElement`: the media-element
1447+ // transport is the path the runtime tries FIRST for audio, and the decoded
1448+ // buffer is only its fallback. What is under test either way is which
1449+ // ELEMENTS get scheduled at all.
1450+ const scheduleSpy = vi
1451+ . spyOn ( WebAudioTransport . prototype , "scheduleMediaElementPlayback" )
1452+ . mockResolvedValue ( null ) ;
13571453
13581454 const player = window . __player ;
13591455 player ?. play ( ) ;
@@ -1363,6 +1459,50 @@ describe("initSandboxRuntimeModular", () => {
13631459 expect ( scheduleSpy . mock . calls [ 0 ] ?. [ 0 ] ) . toBe ( audibleAudio ) ;
13641460 } ) ;
13651461
1462+ it ( "reschedules only when a data-hidden mutation actually moved something" , ( ) => {
1463+ const root = document . createElement ( "div" ) ;
1464+ root . setAttribute ( "data-composition-id" , "main" ) ;
1465+ root . setAttribute ( "data-root" , "true" ) ;
1466+ root . setAttribute ( "data-start" , "0" ) ;
1467+ root . setAttribute ( "data-duration" , "10" ) ;
1468+ root . setAttribute ( "data-width" , "1920" ) ;
1469+ root . setAttribute ( "data-height" , "1080" ) ;
1470+ document . body . appendChild ( root ) ;
1471+
1472+ const hiddenAudio = document . createElement ( "audio" ) ;
1473+ hiddenAudio . setAttribute ( "data-start" , "0" ) ;
1474+ hiddenAudio . setAttribute ( "data-duration" , "10" ) ;
1475+ hiddenAudio . setAttribute ( "data-hidden" , "" ) ;
1476+ hiddenAudio . load = ( ) => { } ;
1477+ hiddenAudio . play = vi . fn ( ( ) => Promise . resolve ( ) ) ;
1478+ root . appendChild ( hiddenAudio ) ;
1479+
1480+ window . __timelines = { main : createMockTimeline ( 10 ) } ;
1481+ initSandboxRuntimeModular ( ) ;
1482+
1483+ const stopSpy = vi . spyOn ( WebAudioTransport . prototype , "stopAll" ) ;
1484+ const player = window . __player ;
1485+ player ?. play ( ) ;
1486+
1487+ // A seek stops the transport itself, so the COUNT is the measure. Without
1488+ // the dirty gate the reschedule fired on every visibility pass, adding a
1489+ // second stop — an audible stop-and-restart across the whole mix — to
1490+ // rebuild an identical active set.
1491+ stopSpy . mockClear ( ) ;
1492+ player ?. seek ( 1 , { keepPlaying : true } ) ;
1493+ const seekOnly = stopSpy . mock . calls . length ;
1494+
1495+ // The dirty flag is set by a data-hidden MUTATION, so the toggle is the
1496+ // gesture; a plain seek never reaches the reschedule at all.
1497+ stopSpy . mockClear ( ) ;
1498+ hiddenAudio . removeAttribute ( "data-hidden" ) ;
1499+ player ?. seek ( 2 , { keepPlaying : true } ) ;
1500+ const afterToggle = stopSpy . mock . calls . length ;
1501+
1502+ expect ( seekOnly ) . toBe ( 1 ) ;
1503+ expect ( afterToggle ) . toBe ( seekOnly + 1 ) ;
1504+ } ) ;
1505+
13661506 it ( "batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule" , ( ) => {
13671507 const root = document . createElement ( "div" ) ;
13681508 root . setAttribute ( "data-composition-id" , "main" ) ;
@@ -1401,7 +1541,9 @@ describe("initSandboxRuntimeModular", () => {
14011541 // toggles away from.
14021542 player ?. play ( ) ;
14031543
1404- const scheduleSpy = vi . spyOn ( WebAudioTransport . prototype , "scheduleMediaElementPlayback" ) ;
1544+ const scheduleSpy = vi
1545+ . spyOn ( WebAudioTransport . prototype , "scheduleMediaElementPlayback" )
1546+ . mockResolvedValue ( null ) ;
14051547 const generationSpy = vi . spyOn ( WebAudioTransport . prototype , "startGeneration" ) ;
14061548
14071549 // Both become visible in the SAME sync pass — must still be one reschedule.
@@ -1415,6 +1557,52 @@ describe("initSandboxRuntimeModular", () => {
14151557 expect ( scheduleSpy ) . toHaveBeenCalledTimes ( 2 ) ;
14161558 } ) ;
14171559
1560+ // Scheduling does NOT replace the active set: it bumps a generation, which
1561+ // only rejects schedules still in flight. Every source already started keeps
1562+ // playing and there is no per-element dedup, so rescheduling on its own laid
1563+ // a second buffer source over every sounding clip — the whole mix doubled,
1564+ // slightly out of phase, from one mute click until the next pause.
1565+ it ( "stops the running sources before rescheduling on a data-hidden toggle" , ( ) => {
1566+ const root = document . createElement ( "div" ) ;
1567+ root . setAttribute ( "data-composition-id" , "main" ) ;
1568+ root . setAttribute ( "data-root" , "true" ) ;
1569+ root . setAttribute ( "data-start" , "0" ) ;
1570+ root . setAttribute ( "data-duration" , "10" ) ;
1571+ root . setAttribute ( "data-width" , "1920" ) ;
1572+ root . setAttribute ( "data-height" , "1080" ) ;
1573+ document . body . appendChild ( root ) ;
1574+
1575+ const audio = document . createElement ( "audio" ) ;
1576+ audio . setAttribute ( "data-start" , "0" ) ;
1577+ audio . setAttribute ( "data-duration" , "10" ) ;
1578+ audio . setAttribute ( "data-hidden" , "" ) ;
1579+ audio . load = ( ) => { } ;
1580+ audio . play = vi . fn ( ( ) => Promise . resolve ( ) ) ;
1581+ root . appendChild ( audio ) ;
1582+
1583+ window . __timelines = { main : createMockTimeline ( 10 ) } ;
1584+ initSandboxRuntimeModular ( ) ;
1585+ const player = window . __player ;
1586+ player ?. play ( ) ;
1587+
1588+ vi . spyOn ( WebAudioTransport . prototype , "decodeAudioElement" ) . mockResolvedValue ( null ) ;
1589+ const stopSpy = vi . spyOn ( WebAudioTransport . prototype , "stopAll" ) ;
1590+ const generationSpy = vi . spyOn ( WebAudioTransport . prototype , "startGeneration" ) ;
1591+
1592+ audio . removeAttribute ( "data-hidden" ) ;
1593+ player ?. seek ( 1 , { keepPlaying : true } ) ;
1594+
1595+ expect ( generationSpy ) . toHaveBeenCalledTimes ( 1 ) ;
1596+ // Two: `seek` clears the graph on its way in, and the toggle's own
1597+ // reschedule clears it again. Only the second one is what this covers —
1598+ // without it the count is 1 and the reschedule stacks on live sources.
1599+ expect ( stopSpy . mock . calls . length ) . toBeGreaterThanOrEqual ( 2 ) ;
1600+ // Order matters, not just presence: stopping AFTER the reschedule would
1601+ // silence the clips it had just started.
1602+ const lastStop = Math . max ( ...stopSpy . mock . invocationCallOrder ) ;
1603+ expect ( lastStop ) . toBeLessThan ( generationSpy . mock . invocationCallOrder [ 0 ] ?? 0 ) ;
1604+ } ) ;
1605+
14181606 it ( "does not stamp Studio timing on GSAP targets inside authored timed clips" , ( ) => {
14191607 withStudioIframe ( ( ) => {
14201608 const root = document . createElement ( "div" ) ;
0 commit comments