fix(studio,lint,skills): a bus is never a carve bed

A music bed came out carved twice. The `music` bus carried three
`fromCarve` peaking filters against `voiceover`, and its one member clip
`bgm` carried six of its own — the bed ran through both. Same on the SFX
side: the `sfx` bus plus `sfx-pan-2`.

**The bus classified as a bed.** `useFxCarve` is shared — a group's rack
reaches it too (`propertyPanelAudioFxGroup.tsx`, and the comment there
says so) — and `carveBedRoles` reads `data-label` so that a name living
outside a filename still counts. A bus labelled "Music bed" therefore
read as music: `couldBeBed` offered it the control and `autoBed` wrote
one without being asked, entirely independently of the member clip that
had already done the same. Nothing caught the collision, because the only
guard, `carverAgainst`, asks "is somebody naming ME as a source" — never
"is my own bus, or my own member, already carved".

**And a bus cannot carve properly anyway.** The level half of the
analysis measures the bed's own audio against the voice, read from
`element.getAttribute("src")`. A bus has no `src`, so `bedBuffer` was
null, `duck` was empty, and every bus carve was the spectral half alone:
filters and no level match. That is exactly the shape found in the file —
three peaking nodes and no gain node on the bus, five and a gain on the
clip.

So `carveBedRoles` now refuses a bus outright, both halves: `couldBeBed`
false alone would still leave `autoBed` free to fire off the label, which
is the half that wrote these. An already-configured bus carve still shows
its module (`carve !== null`), so it can be switched off rather than
becoming unreachable — the same escape hatch a38785003 kept.

`audio_group_carve_attr` names what is already written down, since the
Studio fix cannot unwrite it. Warning, beside `audio_group_timing_attrs`,
which is the same shape of mistake: an attribute a bus has no use for.

The skill has said "a carve stays on the clip" since the bus was
documented; it now says why, and says not to wrap a single clip in a bus
at all — with the one real exception, which is wanting a
composition-time automation clock.

Both guards mutation-checked: dropping the tag check fails the Studio
test, and `if (false)` in place of the attribute check fails both quiet
cases.
This commit is contained in:
Vance Ingalls
2026-08-23 01:57:45 -07:00
parent 952401d12a
commit 5dc39a84e9
6 changed files with 150 additions and 9 deletions
+37
View File
@@ -651,6 +651,43 @@ describe("audio_group_timing_attrs", () => {
});
});
describe("audio_group_carve_attr", () => {
const doc = (busAttrs: string) => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<hf-audio-group id="music" data-label="Music bed" ${busAttrs}></hf-audio-group>
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"></audio>
</div>
</body></html>`;
// The observed bug: the bus and its one member each carried a carve against
// the same voiceover, so the bed ran through both sets of filters.
it("warns on a carve written onto a bus", async () => {
const res = await lintHyperframeHtml(
doc(`data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'`),
);
const finding = res.findings.find((f) => f.code === "audio_group_carve_attr");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("music");
expect(finding?.message).toContain("data-fx-carve");
});
it("stays quiet on a bus carrying only its own attributes", async () => {
const res = await lintHyperframeHtml(doc(`data-volume="0.4"`));
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
});
it("leaves a carve on the clip alone", async () => {
const res = await lintHyperframeHtml(`<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"
data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'></audio>
</div>
</body></html>`);
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
});
});
describe("audio_carve_ungrouped_sources", () => {
const withCarve = (carveJson: string, extra = "") => `<!DOCTYPE html><html><body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
+35
View File
@@ -640,6 +640,9 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
// audio_group_timing_attrs
findAudioGroupTimingAttrFindings,
// audio_group_carve_attr
findAudioGroupCarveAttrFindings,
];
/**
@@ -866,3 +869,35 @@ function findAudioGroupTimingAttrFindings(ctx: LintContext): HyperframeLintFindi
}
return findings;
}
/**
* A carve on a bus is half an effect, applied twice.
*
* `data-fx-carve` is a CLIP attribute. The bed being carved is one track, and
* the level half of the analysis measures that track's own audio against the
* voice — a bus has no `src`, so a carve there can only ever produce the
* spectral half: filters with no level match.
*
* Worse, it stacks. A bus and a member clip are the same signal path, so a
* carve on each puts the bed through both sets of filters — which is exactly
* what happened when a bus labelled "Music bed" classified as one and carved
* itself (fixed in Studio; this catches what was already written down).
*/
function findAudioGroupCarveAttrFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
if (tag.name !== "hf-audio-group") continue;
if (!hasAttrName(tag.raw, "data-fx-carve")) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "audio_group_carve_attr",
severity: "warning",
message: `${elementId ? `#${elementId}` : "This audio group"} carries \`data-fx-carve\`, which belongs on the clip being carved — a bus has no audio of its own to level-match against, and a carve here stacks with any its members already have.`,
elementId,
fixHint:
"Remove `data-fx-carve` and the `fromCarve` nodes it wrote into this bus's `data-fx-chain`, and carve the bed clip instead.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}