mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): validate audio group membership and timing (#3447)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack * fix(engine): harden grouped audio rendering * docs(engine): explain grouped mix fallback invariant * test(engine): allow grouped mixes to finish on Windows * feat(lint): validate audio group membership and timing * test(lint): pin audio group membership guards
This commit is contained in:
@@ -556,6 +556,168 @@ describe("audio_volume_double_automation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio_group_no_members", () => {
|
||||
const doc = (body: string) => `<!DOCTYPE html><html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
|
||||
${body}
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
const BUS = `<hf-audio-group id="voiceover" data-label="Voiceover" data-volume="0.4"
|
||||
data-fx-chain='{"version":1,"nodes":[{"type":"peaking","id":"n1","params":{"frequency":250,"gain":-3,"q":1.2}}]}'></hf-audio-group>`;
|
||||
|
||||
it("errors on a bus no clip in the file belongs to", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(
|
||||
`${BUS}<audio id="s-1" src="s.wav" data-start="0" data-duration="2" data-audio-group="sfx"></audio>`,
|
||||
),
|
||||
);
|
||||
const finding = res.findings.find((f) => f.code === "audio_group_no_members");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("voiceover");
|
||||
});
|
||||
|
||||
// The whole point: one typo drops the authored bus (fader AND chain) and
|
||||
// invents a phantom group at unity, with nothing said about either.
|
||||
it("catches the misspelled member — the case that motivated the rule", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(
|
||||
`${BUS}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceovr"></audio>`,
|
||||
),
|
||||
);
|
||||
const finding = res.findings.find((f) => f.code === "audio_group_no_members");
|
||||
expect(finding?.elementId).toBe("voiceover");
|
||||
expect(finding?.message).toContain("voiceovr");
|
||||
});
|
||||
|
||||
it("suggests only unmatched member ids, not a healthy sibling group", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(`${BUS}<hf-audio-group id="music"></hf-audio-group>
|
||||
<audio id="bgm" src="music.wav" data-start="0" data-duration="5" data-audio-group="music"></audio>
|
||||
<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceovr"></audio>`),
|
||||
);
|
||||
const finding = res.findings.find((item) => item.code === "audio_group_no_members");
|
||||
expect(finding?.message).toContain('"voiceovr"');
|
||||
expect(finding?.message).not.toContain('"music"');
|
||||
});
|
||||
|
||||
it("does not count video as group membership", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(`${BUS}<video id="v" src="v.mp4" data-start="0" data-duration="5" data-audio-group="voiceover"></video>
|
||||
<audio id="s-1" src="s.wav" data-start="0" data-duration="2" data-audio-group="sfx"></audio>`),
|
||||
);
|
||||
expect(
|
||||
res.findings.some(
|
||||
(finding) => finding.code === "audio_group_no_members" && finding.elementId === "voiceover",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays quiet when a clip belongs to it", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(
|
||||
`${BUS}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>`,
|
||||
),
|
||||
);
|
||||
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
|
||||
});
|
||||
|
||||
// A bus with no id cannot be joined at all, and `resolveAudioGroups` skips it
|
||||
// when building its element map — a different mistake, not this rule's.
|
||||
// The rule can only speak about a file it can see all of. `lintHyperframeHtml`
|
||||
// takes ONE file, and the studio's own group creation writes the bus into the
|
||||
// active composition while patching `data-audio-group` into each member's own
|
||||
// file (timelineAudioGroupCreate) — so a file holding a bus and no members at
|
||||
// all is the normal cross-file shape, not a mistake.
|
||||
it("stays quiet in a file that declares no members at all", async () => {
|
||||
const res = await lintHyperframeHtml(doc(BUS));
|
||||
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
|
||||
});
|
||||
|
||||
it("stays quiet for an unmatched bus when another group has local members", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(`<hf-audio-group id="local"></hf-audio-group>
|
||||
<audio id="local-1" src="local.wav" data-start="0" data-duration="5" data-audio-group="local"></audio>
|
||||
${BUS}
|
||||
<div id="host" data-composition-src="compositions/voices.html" data-start="0" data-duration="10"></div>`),
|
||||
);
|
||||
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
|
||||
});
|
||||
|
||||
it("stays quiet for a bus with no id", async () => {
|
||||
const res = await lintHyperframeHtml(
|
||||
doc(`<hf-audio-group data-label="Nameless"></hf-audio-group>
|
||||
<audio id="s-1" src="s.wav" data-start="0" data-duration="2" data-audio-group="sfx"></audio>`),
|
||||
);
|
||||
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio_group_timing_attrs", () => {
|
||||
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="voiceover" data-label="Voiceover" ${busAttrs}></hf-audio-group>
|
||||
<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("warns on data-start", async () => {
|
||||
const res = await lintHyperframeHtml(doc(`data-start="0" data-duration="40"`));
|
||||
const finding = res.findings.find((f) => f.code === "audio_group_timing_attrs");
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.elementId).toBe("voiceover");
|
||||
expect(finding?.message).toContain("data-start");
|
||||
expect(finding?.message).toContain("data-duration");
|
||||
});
|
||||
|
||||
it("warns on data-track-index", async () => {
|
||||
const res = await lintHyperframeHtml(doc(`data-track-index="7"`));
|
||||
expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(true);
|
||||
});
|
||||
|
||||
it("stays quiet on a bus carrying only its own attributes", async () => {
|
||||
const res = await lintHyperframeHtml(doc(`data-volume="0.4" data-hidden`));
|
||||
expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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">
|
||||
|
||||
@@ -634,6 +634,15 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
findVolumeTweenOverridesGainFindings,
|
||||
// audio_carve_ungrouped_sources
|
||||
findCarveUngroupedSourcesFindings,
|
||||
|
||||
// audio_group_no_members
|
||||
findAudioGroupNoMembersFindings,
|
||||
|
||||
// audio_group_timing_attrs
|
||||
findAudioGroupTimingAttrFindings,
|
||||
|
||||
// audio_group_carve_attr
|
||||
findAudioGroupCarveAttrFindings,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -769,3 +778,143 @@ function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFind
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Timing attributes a bus must never carry. It has no clip window of its own:
|
||||
* a group's automation clock is COMPOSITION time, and its members carry the
|
||||
* timing. */
|
||||
const AUDIO_GROUP_TIMING_ATTRS = ["data-start", "data-duration", "data-track-index"] as const;
|
||||
|
||||
/**
|
||||
* A bus nobody joined does nothing, silently.
|
||||
*
|
||||
* `resolveAudioGroups` builds groups from the MEMBERS (`audio[data-audio-group]`)
|
||||
* and only then looks for a matching `<hf-audio-group>` element, so a bus whose
|
||||
* id no clip names is dropped entirely — its fader, FX chain and automation
|
||||
* never reach preview or render, and nothing says so. One typo is enough:
|
||||
* `data-audio-group="voiceovr"` against `id="voiceover"` loses the authored bus
|
||||
* AND invents a phantom group at unity gain with no chain, which is what the
|
||||
* timeline then draws.
|
||||
*/
|
||||
function findAudioGroupNoMembersFindings(ctx: LintContext): HyperframeLintFinding[] {
|
||||
const memberGroupIds = new Set(
|
||||
ctx.tags
|
||||
.filter((tag) => tag.name === "audio")
|
||||
.map((tag) => readAttr(tag.raw, "data-audio-group"))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
// Only a file that declares SOME membership can be judged. `lintHyperframeHtml`
|
||||
// sees one file, and the studio's own group creation writes the bus into the
|
||||
// active composition while patching `data-audio-group` into each member's own
|
||||
// file (`timelineAudioGroupCreate`) — so a file carrying a bus and no members
|
||||
// at all is the ordinary cross-file shape. Firing there reported the studio's
|
||||
// own output as an error, and said "No clip carries `data-audio-group` at all"
|
||||
// about clips it simply could not see.
|
||||
if (memberGroupIds.size === 0) return [];
|
||||
const mayHaveCrossFileMembers = ctx.tags.some((tag) =>
|
||||
Boolean(readAttr(tag.raw, "data-composition-src")),
|
||||
);
|
||||
const declaredGroupIds = new Set(
|
||||
ctx.tags
|
||||
.filter((tag) => tag.name === "hf-audio-group")
|
||||
.map((tag) => readAttr(tag.raw, "id"))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const unmatchedMemberGroupIds = [...memberGroupIds].filter((id) => !declaredGroupIds.has(id));
|
||||
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of ctx.tags) {
|
||||
if (tag.name !== "hf-audio-group") continue;
|
||||
// A bus with no id cannot be joined at all — a different mistake, and
|
||||
// `resolveAudioGroups` skips it when building its element map.
|
||||
const elementId = readAttr(tag.raw, "id");
|
||||
if (!elementId) continue;
|
||||
if (memberGroupIds.has(elementId)) continue;
|
||||
// A mixed file is still not closed-world: one bus may have local members
|
||||
// while another serves clips inside a referenced composition. The linter
|
||||
// cannot inspect that file here, so an unmatched bus is only provably empty
|
||||
// when this source has no cross-file composition hosts at all.
|
||||
if (mayHaveCrossFileMembers) continue;
|
||||
|
||||
// Naming the near-misses is the whole value: the fix is almost always a
|
||||
// typo on one member, and the author is looking at the bus, not the clip.
|
||||
// Do not offer a correctly matched sibling bus as the fix for this one.
|
||||
// Only member ids with no declared bus are plausible typos.
|
||||
const nearby = unmatchedMemberGroupIds.filter((id) => id !== elementId);
|
||||
const suffix =
|
||||
nearby.length > 0
|
||||
? ` Clips in this file name ${nearby.map((id) => `"${id}"`).join(", ")} instead.`
|
||||
: "";
|
||||
findings.push({
|
||||
code: "audio_group_no_members",
|
||||
severity: "error",
|
||||
message: `#${elementId} is an audio group no clip belongs to, so its fader, effect chain and automation are dropped.${suffix}`,
|
||||
elementId,
|
||||
fixHint: `Add \`data-audio-group="${elementId}"\` to the clips this bus is for, or delete the bus.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing on a bus is meaningless — and it is how a phantom clip row appears.
|
||||
*
|
||||
* The preview runtime stamps `data-start`/`data-duration` on id'd children of
|
||||
* the composition root so they show up in the timeline; a bus caught by that
|
||||
* became a full-duration clip row above its own group header, draggable and
|
||||
* deletable (fixed in core). Timing PERSISTED into the file is the same shape
|
||||
* with none of the excuse: the render reads a group's `fxChain`, `automation`
|
||||
* and `volume` only, so these attributes change nothing and mislead the next
|
||||
* reader into thinking the bus has a window.
|
||||
*/
|
||||
function findAudioGroupTimingAttrFindings(ctx: LintContext): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of ctx.tags) {
|
||||
if (tag.name !== "hf-audio-group") continue;
|
||||
const present = AUDIO_GROUP_TIMING_ATTRS.filter((attr) => hasAttrName(tag.raw, attr));
|
||||
if (present.length === 0) continue;
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "audio_group_timing_attrs",
|
||||
severity: "warning",
|
||||
message: `${elementId ? `#${elementId}` : "This audio group"} carries ${present.map((attr) => `\`${attr}\``).join(", ")}, which a bus has no use for — its members carry the timing and its automation clock is composition time.`,
|
||||
elementId,
|
||||
fixHint: `Remove ${present.map((attr) => `\`${attr}\``).join(", ")} from the group element.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user