mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
|
||||
import { carveBedRoles, carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
|
||||
|
||||
function previewDoc(html: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument("preview");
|
||||
@@ -82,3 +82,37 @@ describe("carverAgainst", () => {
|
||||
expect(carverAgainst(doc, "vo-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("carveBedRoles", () => {
|
||||
const roles = (html: string, id: string) => {
|
||||
const doc = previewDoc(html);
|
||||
return carveBedRoles(id, doc.getElementById(id));
|
||||
};
|
||||
|
||||
// The observed bug: the bus labelled "Music bed" classified as music, so it
|
||||
// auto-carved against the same voiceover its own member clip had already
|
||||
// carved against — the bed ran through both chains. A bus is never a bed, and
|
||||
// `autoBed` matters as much as `couldBeBed`: that is the half that wrote one
|
||||
// without being asked.
|
||||
it("never makes a bus a bed, however it is labelled", () => {
|
||||
expect(
|
||||
roles(`<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>`, "music"),
|
||||
).toEqual({ couldBeBed: false, autoBed: false });
|
||||
expect(
|
||||
roles(`<hf-audio-group id="sfx" data-label="Sound FX"></hf-audio-group>`, "sfx"),
|
||||
).toEqual({ couldBeBed: false, autoBed: false });
|
||||
});
|
||||
|
||||
it("still reads a clip's label, id and src", () => {
|
||||
expect(roles(`<audio id="a1" data-label="Music bed"></audio>`, "a1")).toEqual({
|
||||
couldBeBed: true,
|
||||
autoBed: true,
|
||||
});
|
||||
// A name that says nothing may be offered the control but never carves itself.
|
||||
expect(roles(`<audio id="a1"></audio>`, "a1")).toEqual({ couldBeBed: true, autoBed: false });
|
||||
expect(roles(`<audio id="vo-2"></audio>`, "vo-2")).toEqual({
|
||||
couldBeBed: false,
|
||||
autoBed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
isNamedCarveBed,
|
||||
type HfCarveSettings,
|
||||
} from "@hyperframes/core/audio-carve";
|
||||
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
|
||||
import {
|
||||
HF_AUDIO_GROUP_TAG,
|
||||
resolveAudioGroups,
|
||||
resolveCarveSourceIds,
|
||||
} from "@hyperframes/core/audio-groups";
|
||||
|
||||
/**
|
||||
* An id for a new voiceover group, de-duped against every id already in the
|
||||
@@ -189,13 +193,33 @@ export function collectCarveCandidates(
|
||||
* source side already makes between what the picker may show (`sourceOptions`)
|
||||
* and what it may choose unprompted (`autoSourceIds`).
|
||||
*
|
||||
* Reads the element's `data-label` as well as its id and `src`: a group carries
|
||||
* its name there rather than in a filename, and a group can be a bed.
|
||||
* Reads the element's `data-label` as well as its id and `src`, because a clip's
|
||||
* display name is a hint its filename may not carry. A BUS is excluded outright
|
||||
* — see the first branch.
|
||||
*/
|
||||
export function carveBedRoles(
|
||||
id: string | null | undefined,
|
||||
node: Element | null | undefined,
|
||||
): { couldBeBed: boolean; autoBed: boolean } {
|
||||
// A BUS is never a bed, whatever it is called. Its rack reaches `useFxCarve`
|
||||
// too, and reading `data-label` — which is what lets a group be classified at
|
||||
// all — made a bus labelled "Music bed" read as music: it then auto-carved
|
||||
// against the same voice its own member clip had already auto-carved against,
|
||||
// and the bed ran through both sets of filters. Nothing caught that, because
|
||||
// the only guard (`carverAgainst`) asks "is somebody naming ME as a source",
|
||||
// never "is my own bus, or my own member, already carved".
|
||||
//
|
||||
// A bus could not do the whole job anyway: the level half of the carve reads
|
||||
// the bed's own `src` to measure how far over the voice it sits, and a bus has
|
||||
// no `src` — so a bus carve was always the spectral half alone, filters with
|
||||
// no level match. `data-fx-carve` is a clip attribute; the skill has said so
|
||||
// ("A carve stays on the clip") since the bus was documented.
|
||||
//
|
||||
// Not `couldBeBed: false` alone: that leaves `autoBed` free to fire from a
|
||||
// name, which is the half that wrote these unasked.
|
||||
if (node?.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
||||
return { couldBeBed: false, autoBed: false };
|
||||
}
|
||||
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
|
||||
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"files": 121
|
||||
},
|
||||
"hyperframes-audio": {
|
||||
"hash": "73942820ff9cc774",
|
||||
"hash": "037e080729140b19",
|
||||
"files": 6
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
|
||||
@@ -306,10 +306,21 @@ clip. A lane on a clip is clip-local; the same numbers mean different instants o
|
||||
the two, which is the one thing to get right when moving an envelope from a clip
|
||||
up onto its bus.
|
||||
|
||||
**A carve stays on the clip.** `data-fx-carve` is not a group attribute: the bus
|
||||
has no carve, and putting one there does nothing. The bed being carved is a
|
||||
single track, and it is that track which carries `data-fx-carve` — pointed AT a
|
||||
group, per the rule above. Group and carve meet in `sources`, not on one element.
|
||||
**A carve stays on the clip.** `data-fx-carve` is not a group attribute. The bed
|
||||
being carved is a single track, and it is that track which carries
|
||||
`data-fx-carve` — pointed AT a group, per the rule above. Group and carve meet in
|
||||
`sources`, not on one element. A carve written onto a bus is half an effect
|
||||
applied twice: the level half measures the bed's own audio, which a bus has none
|
||||
of, so only the filters survive — and a bus and its members are one signal path,
|
||||
so the bed then runs through the bus's filters AND its own. The
|
||||
`audio_group_carve_attr` lint rule catches it.
|
||||
|
||||
**One clip is not a bus.** A group exists to give several tracks one chain, one
|
||||
fader and one clock. Wrapping a single clip in a bus buys nothing the clip's own
|
||||
`data-fx-chain` does not already do, and it doubles the places a later edit has
|
||||
to land. The one reason to do it anyway: a bus's automation clock is composition
|
||||
time, so a single-member bus is how a lane on that clip gets composition-time
|
||||
timing.
|
||||
|
||||
Nothing here needs a feature flag: the Studio UI for building groups is behind
|
||||
the `audio-groups` canary, but a hand-authored `<hf-audio-group>` parses, plays
|
||||
|
||||
Reference in New Issue
Block a user