Skip to content

Commit 2665278

Browse files
committed
fix(core): align preview transport with grouped audio
1 parent 9afb4a4 commit 2665278

6 files changed

Lines changed: 569 additions & 413 deletions

File tree

‎packages/core/src/runtime/init.test.ts‎

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)