+ `;
+
+ const BUS = ``;
+
+ it("errors on a bus no clip belongs to", async () => {
+ const res = await lintHyperframeHtml(doc(BUS));
+ 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}`,
+ ),
+ );
+ const finding = res.findings.find((f) => f.code === "audio_group_no_members");
+ expect(finding?.elementId).toBe("voiceover");
+ expect(finding?.message).toContain("voiceovr");
+ });
+
+ it("stays quiet when a clip belongs to it", async () => {
+ const res = await lintHyperframeHtml(
+ doc(
+ `${BUS}`,
+ ),
+ );
+ 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.
+ it("stays quiet for a bus with no id", async () => {
+ const res = await lintHyperframeHtml(
+ doc(``),
+ );
+ expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
+ });
+});
+
+describe("audio_group_timing_attrs", () => {
+ const doc = (busAttrs: string) => `
+
+
+
+
+ `;
+
+ 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_carve_ungrouped_sources", () => {
const withCarve = (carveJson: string, extra = "") => `
diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts
index 11c2bf4cf..cd1396dc7 100644
--- a/packages/lint/src/rules/media.ts
+++ b/packages/lint/src/rules/media.ts
@@ -634,6 +634,12 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
findVolumeTweenOverridesGainFindings,
// audio_carve_ungrouped_sources
findCarveUngroupedSourcesFindings,
+
+ // audio_group_no_members
+ findAudioGroupNoMembersFindings,
+
+ // audio_group_timing_attrs
+ findAudioGroupTimingAttrFindings,
];
/**
@@ -769,3 +775,85 @@ 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 `` 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)),
+ );
+
+ 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;
+
+ // 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.
+ const nearby = [...memberGroupIds].filter((id) => id !== elementId);
+ const suffix =
+ nearby.length > 0
+ ? ` Clips name ${nearby.map((id) => `"${id}"`).join(", ")} instead.`
+ : " No clip carries `data-audio-group` at all.";
+ 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;
+}