fix(core): stop the runtime stamping timing onto an <hf-audio-group> bus

Where the phantom rows came from. The runtime stamps `data-start="0"` and
`data-duration=<whole composition>` on every id'd child of the composition root
"so they appear in the timeline even without animations" (init.ts, the
`window.parent !== window` block). Its only skips were SCRIPT / STYLE / LINK, so
an `<hf-audio-group>` got stamped too — which made it match the clip-manifest
selector `[data-start], …`, so the bus entered `__clipManifest` as
`kind: "element"`, `tagName: "hf-audio-group"`, 0 → 40s, and the studio drew it
as an ordinary full-width clip row directly above the real group header.

Observed on audio-real: 18 timeline elements, two of them
`Voiceover|voiceover|hf-audio-group|manifest|0-40.0` and the same for `sfx`. 16
after this change, and the group rows and FX rack are unaffected.

That row was draggable, trimmable and DELETABLE, and deleting it deletes the bus
element — which is why deleting it took the group's automation lanes and its FX
rack with it. Nothing was corrupted; the rack's subject was gone.

`isTimelineIgnoredElement` in studio already excluded the tag with this exact
reasoning, but it only guards the DOM-scan and implicit-layer paths. The bus
arrived through the manifest, upstream of all of them, so the guard never saw
it. Fixed at the source instead: both stamp loops now skip the tag.

Not fixed: the `Stage` row in the same screenshot. That one is a real implicit
layer for `<div class="stage">` — a visual container the author wrote — and it
belongs in the timeline. `data-hf-ignore` on such a wrapper suppresses its row.

Regression test asserts the bus keeps no timing while an id'd sibling still gets
stamped; verified it fails on a revert. It has to stage `window.parent !== window`
because the stamp only runs inside the studio preview, and it lives inside the
`initSandboxRuntimeModular` describe so it gets the DOM reset — outside it, a
previous test's leftover root wins `resolveRootCompositionElement()` and nothing
is stamped at all, which reads as a pass.

core: 122 files, 2481 tests.
This commit is contained in:
Vance Ingalls
2026-08-20 16:41:07 -07:00
parent c28300f0cb
commit 5d0c9827aa
2 changed files with 103 additions and 49 deletions
+93 -49
View File
@@ -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 {
@@ -239,6 +190,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
* `<hf-audio-group>` 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 <hf-audio-group> 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);
+10
View File
@@ -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. `<hf-audio-group>` 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<Element>(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);