diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 1fe2cef4c..f183ab6ff 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -11,55 +11,6 @@ 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 { @@ -208,6 +159,99 @@ describe("initSandboxRuntimeModular", () => { window.cancelAnimationFrame = originalCancelAnimationFrame; }); + /** + * `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([]); + }); + + /** + * The runtime stamps `data-start`/`data-duration` on every id'd child of the + * composition root so a blank canvas still shows selectable rows. An + * `` is a mixer BUS, not a clip: stamping it put it in + * `__clipManifest` as a full-duration element, which the studio drew as an + * ordinary clip row above the real group header — draggable, trimmable, and + * deletable, and deleting it takes the bus (so the group's FX rack) with it. + */ + it("does not stamp timing onto an bus", () => { + 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 bus = document.createElement("hf-audio-group"); + bus.id = "voiceover"; + bus.setAttribute("data-label", "Voiceover"); + root.appendChild(bus); + + // A plain id'd sibling proves the stamp still happens for everything else. + const caption = document.createElement("div"); + caption.id = "cap-1"; + root.appendChild(caption); + + window.__timelines = { main: createMockTimeline(10) }; + // The stamp only runs inside the studio preview (`window.parent !== window`), + // which jsdom is not — so the condition has to be staged for the test. + const realParent = window.parent; + Object.defineProperty(window, "parent", { value: {}, configurable: true }); + try { + initSandboxRuntimeModular(); + } finally { + Object.defineProperty(window, "parent", { value: realParent, configurable: true }); + } + + expect(bus.hasAttribute("data-start")).toBe(false); + expect(bus.hasAttribute("data-duration")).toBe(false); + expect(caption.getAttribute("data-start")).toBe("0"); + }); + it("resolves Studio hold as a deterministic step at the segment end", () => { const defaultEase = (progress: number) => progress; const originalParseEase = vi.fn(() => defaultEase); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 0ebca53a3..4c2e6b2f0 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1461,6 +1461,14 @@ export function initSandboxRuntimeModular(): void { // scene container we auto-stamp below (e.g. an opacity-crossfaded scene) // must NOT suppress its own animated children — otherwise those children // never become timeline clips and that scene can't inline-expand. + // A bus is not a clip. `` carries a group's label, fader, + // mute and FX chain and has no timing of its own, so stamping it put it in + // `__clipManifest` as a full-duration element — which the studio drew as an + // ordinary clip row above the real group header. That row was draggable, + // trimmable and deletable, and deleting it removed the bus, taking the + // group's automation lanes and FX rack with it. + const isAudioGroupBus = (el: Element): boolean => + el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG; const authoredTimed = new Set(document.querySelectorAll("[data-start]")); const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => { let node = element.parentElement; @@ -1479,6 +1487,7 @@ export function initSandboxRuntimeModular(): void { for (const target of child.targets()) { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; + if (isAudioGroupBus(target)) continue; if (target.hasAttribute("data-start")) continue; if (hasAuthoredTimedAncestor(target)) continue; if (seen.has(target)) continue; @@ -1506,6 +1515,7 @@ export function initSandboxRuntimeModular(): void { if (hasAuthoredTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; + if (isAudioGroupBus(el)) continue; seen.add(el); el.setAttribute("data-start", "0"); el.setAttribute("data-duration", dur);