Skip to content

Commit 6f1de66

Browse files
committed
feat(lint): validate audio group membership and timing
1 parent a678fed commit 6f1de66

2 files changed

Lines changed: 282 additions & 0 deletions

File tree

packages/lint/src/rules/media.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,148 @@ describe("audio_volume_double_automation", () => {
556556
});
557557
});
558558

559+
describe("audio_group_no_members", () => {
560+
const doc = (body: string) => `<!DOCTYPE html><html><body>
561+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
562+
${body}
563+
</div>
564+
</body></html>`;
565+
566+
const BUS = `<hf-audio-group id="voiceover" data-label="Voiceover" data-volume="0.4"
567+
data-fx-chain='{"version":1,"nodes":[{"type":"peaking","id":"n1","params":{"frequency":250,"gain":-3,"q":1.2}}]}'></hf-audio-group>`;
568+
569+
it("errors on a bus no clip in the file belongs to", async () => {
570+
const res = await lintHyperframeHtml(
571+
doc(
572+
`${BUS}<audio id="s-1" src="s.wav" data-start="0" data-duration="2" data-audio-group="sfx"></audio>`,
573+
),
574+
);
575+
const finding = res.findings.find((f) => f.code === "audio_group_no_members");
576+
expect(finding?.severity).toBe("error");
577+
expect(finding?.elementId).toBe("voiceover");
578+
});
579+
580+
// The whole point: one typo drops the authored bus (fader AND chain) and
581+
// invents a phantom group at unity, with nothing said about either.
582+
it("catches the misspelled member — the case that motivated the rule", async () => {
583+
const res = await lintHyperframeHtml(
584+
doc(
585+
`${BUS}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceovr"></audio>`,
586+
),
587+
);
588+
const finding = res.findings.find((f) => f.code === "audio_group_no_members");
589+
expect(finding?.elementId).toBe("voiceover");
590+
expect(finding?.message).toContain("voiceovr");
591+
});
592+
593+
it("stays quiet when a clip belongs to it", async () => {
594+
const res = await lintHyperframeHtml(
595+
doc(
596+
`${BUS}<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>`,
597+
),
598+
);
599+
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
600+
});
601+
602+
// A bus with no id cannot be joined at all, and `resolveAudioGroups` skips it
603+
// when building its element map — a different mistake, not this rule's.
604+
// The rule can only speak about a file it can see all of. `lintHyperframeHtml`
605+
// takes ONE file, and the studio's own group creation writes the bus into the
606+
// active composition while patching `data-audio-group` into each member's own
607+
// file (timelineAudioGroupCreate) — so a file holding a bus and no members at
608+
// all is the normal cross-file shape, not a mistake.
609+
it("stays quiet in a file that declares no members at all", async () => {
610+
const res = await lintHyperframeHtml(
611+
doc(
612+
`${BUS}<div id="host" data-composition-src="compositions/voices.html" data-start="0" data-duration="10"></div>`,
613+
),
614+
);
615+
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
616+
});
617+
618+
it("stays quiet for an unmatched bus when another group has local members", async () => {
619+
const res = await lintHyperframeHtml(
620+
doc(`<hf-audio-group id="local"></hf-audio-group>
621+
<audio id="local-1" src="local.wav" data-start="0" data-duration="5" data-audio-group="local"></audio>
622+
${BUS}
623+
<div id="host" data-composition-src="compositions/voices.html" data-start="0" data-duration="10"></div>`),
624+
);
625+
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
626+
});
627+
628+
it("stays quiet for a bus with no id", async () => {
629+
const res = await lintHyperframeHtml(
630+
doc(`<hf-audio-group data-label="Nameless"></hf-audio-group>`),
631+
);
632+
expect(res.findings.some((f) => f.code === "audio_group_no_members")).toBe(false);
633+
});
634+
});
635+
636+
describe("audio_group_timing_attrs", () => {
637+
const doc = (busAttrs: string) => `<!DOCTYPE html><html><body>
638+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
639+
<hf-audio-group id="voiceover" data-label="Voiceover" ${busAttrs}></hf-audio-group>
640+
<audio id="vo-1" src="vo.wav" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
641+
</div>
642+
</body></html>`;
643+
644+
it("warns on data-start", async () => {
645+
const res = await lintHyperframeHtml(doc(`data-start="0" data-duration="40"`));
646+
const finding = res.findings.find((f) => f.code === "audio_group_timing_attrs");
647+
expect(finding?.severity).toBe("warning");
648+
expect(finding?.elementId).toBe("voiceover");
649+
expect(finding?.message).toContain("data-start");
650+
expect(finding?.message).toContain("data-duration");
651+
});
652+
653+
it("warns on data-track-index", async () => {
654+
const res = await lintHyperframeHtml(doc(`data-track-index="7"`));
655+
expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(true);
656+
});
657+
658+
it("stays quiet on a bus carrying only its own attributes", async () => {
659+
const res = await lintHyperframeHtml(doc(`data-volume="0.4" data-hidden`));
660+
expect(res.findings.some((f) => f.code === "audio_group_timing_attrs")).toBe(false);
661+
});
662+
});
663+
664+
describe("audio_group_carve_attr", () => {
665+
const doc = (busAttrs: string) => `<!DOCTYPE html><html><body>
666+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
667+
<hf-audio-group id="music" data-label="Music bed" ${busAttrs}></hf-audio-group>
668+
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"></audio>
669+
</div>
670+
</body></html>`;
671+
672+
// The observed bug: the bus and its one member each carried a carve against
673+
// the same voiceover, so the bed ran through both sets of filters.
674+
it("warns on a carve written onto a bus", async () => {
675+
const res = await lintHyperframeHtml(
676+
doc(`data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'`),
677+
);
678+
const finding = res.findings.find((f) => f.code === "audio_group_carve_attr");
679+
expect(finding?.severity).toBe("warning");
680+
expect(finding?.elementId).toBe("music");
681+
expect(finding?.message).toContain("data-fx-carve");
682+
});
683+
684+
it("stays quiet on a bus carrying only its own attributes", async () => {
685+
const res = await lintHyperframeHtml(doc(`data-volume="0.4"`));
686+
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
687+
});
688+
689+
it("leaves a carve on the clip alone", async () => {
690+
const res = await lintHyperframeHtml(`<!DOCTYPE html><html><body>
691+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
692+
<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>
693+
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="10" data-audio-group="music"
694+
data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.25}'></audio>
695+
</div>
696+
</body></html>`);
697+
expect(res.findings.some((f) => f.code === "audio_group_carve_attr")).toBe(false);
698+
});
699+
});
700+
559701
describe("audio_carve_ungrouped_sources", () => {
560702
const withCarve = (carveJson: string, extra = "") => `<!DOCTYPE html><html><body>
561703
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">

packages/lint/src/rules/media.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,15 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
634634
findVolumeTweenOverridesGainFindings,
635635
// audio_carve_ungrouped_sources
636636
findCarveUngroupedSourcesFindings,
637+
638+
// audio_group_no_members
639+
findAudioGroupNoMembersFindings,
640+
641+
// audio_group_timing_attrs
642+
findAudioGroupTimingAttrFindings,
643+
644+
// audio_group_carve_attr
645+
findAudioGroupCarveAttrFindings,
637646
];
638647

639648
/**
@@ -769,3 +778,134 @@ function findCarveUngroupedSourcesFindings(ctx: LintContext): HyperframeLintFind
769778
}
770779
return findings;
771780
}
781+
782+
/** Timing attributes a bus must never carry. It has no clip window of its own:
783+
* a group's automation clock is COMPOSITION time, and its members carry the
784+
* timing. */
785+
const AUDIO_GROUP_TIMING_ATTRS = ["data-start", "data-duration", "data-track-index"] as const;
786+
787+
/**
788+
* A bus nobody joined does nothing, silently.
789+
*
790+
* `resolveAudioGroups` builds groups from the MEMBERS (`audio[data-audio-group]`)
791+
* and only then looks for a matching `<hf-audio-group>` element, so a bus whose
792+
* id no clip names is dropped entirely — its fader, FX chain and automation
793+
* never reach preview or render, and nothing says so. One typo is enough:
794+
* `data-audio-group="voiceovr"` against `id="voiceover"` loses the authored bus
795+
* AND invents a phantom group at unity gain with no chain, which is what the
796+
* timeline then draws.
797+
*/
798+
function findAudioGroupNoMembersFindings(ctx: LintContext): HyperframeLintFinding[] {
799+
const memberGroupIds = new Set(
800+
ctx.tags
801+
.filter((tag) => tag.name === "audio")
802+
.map((tag) => readAttr(tag.raw, "data-audio-group"))
803+
.filter((id): id is string => Boolean(id)),
804+
);
805+
806+
// Only a file that declares SOME membership can be judged. `lintHyperframeHtml`
807+
// sees one file, and the studio's own group creation writes the bus into the
808+
// active composition while patching `data-audio-group` into each member's own
809+
// file (`timelineAudioGroupCreate`) — so a file carrying a bus and no members
810+
// at all is the ordinary cross-file shape. Firing there reported the studio's
811+
// own output as an error, and said "No clip carries `data-audio-group` at all"
812+
// about clips it simply could not see.
813+
if (memberGroupIds.size === 0) return [];
814+
const mayHaveCrossFileMembers = ctx.tags.some((tag) =>
815+
Boolean(readAttr(tag.raw, "data-composition-src")),
816+
);
817+
818+
const findings: HyperframeLintFinding[] = [];
819+
for (const tag of ctx.tags) {
820+
if (tag.name !== "hf-audio-group") continue;
821+
// A bus with no id cannot be joined at all — a different mistake, and
822+
// `resolveAudioGroups` skips it when building its element map.
823+
const elementId = readAttr(tag.raw, "id");
824+
if (!elementId) continue;
825+
if (memberGroupIds.has(elementId)) continue;
826+
// A mixed file is still not closed-world: one bus may have local members
827+
// while another serves clips inside a referenced composition. The linter
828+
// cannot inspect that file here, so an unmatched bus is only provably empty
829+
// when this source has no cross-file composition hosts at all.
830+
if (mayHaveCrossFileMembers) continue;
831+
832+
// Naming the near-misses is the whole value: the fix is almost always a
833+
// typo on one member, and the author is looking at the bus, not the clip.
834+
const nearby = [...memberGroupIds].filter((id) => id !== elementId);
835+
const suffix =
836+
nearby.length > 0
837+
? ` Clips in this file name ${nearby.map((id) => `"${id}"`).join(", ")} instead.`
838+
: "";
839+
findings.push({
840+
code: "audio_group_no_members",
841+
severity: "error",
842+
message: `#${elementId} is an audio group no clip belongs to, so its fader, effect chain and automation are dropped.${suffix}`,
843+
elementId,
844+
fixHint: `Add \`data-audio-group="${elementId}"\` to the clips this bus is for, or delete the bus.`,
845+
snippet: truncateSnippet(tag.raw),
846+
});
847+
}
848+
return findings;
849+
}
850+
851+
/**
852+
* Timing on a bus is meaningless — and it is how a phantom clip row appears.
853+
*
854+
* The preview runtime stamps `data-start`/`data-duration` on id'd children of
855+
* the composition root so they show up in the timeline; a bus caught by that
856+
* became a full-duration clip row above its own group header, draggable and
857+
* deletable (fixed in core). Timing PERSISTED into the file is the same shape
858+
* with none of the excuse: the render reads a group's `fxChain`, `automation`
859+
* and `volume` only, so these attributes change nothing and mislead the next
860+
* reader into thinking the bus has a window.
861+
*/
862+
function findAudioGroupTimingAttrFindings(ctx: LintContext): HyperframeLintFinding[] {
863+
const findings: HyperframeLintFinding[] = [];
864+
for (const tag of ctx.tags) {
865+
if (tag.name !== "hf-audio-group") continue;
866+
const present = AUDIO_GROUP_TIMING_ATTRS.filter((attr) => hasAttrName(tag.raw, attr));
867+
if (present.length === 0) continue;
868+
const elementId = readAttr(tag.raw, "id") || undefined;
869+
findings.push({
870+
code: "audio_group_timing_attrs",
871+
severity: "warning",
872+
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.`,
873+
elementId,
874+
fixHint: `Remove ${present.map((attr) => `\`${attr}\``).join(", ")} from the group element.`,
875+
snippet: truncateSnippet(tag.raw),
876+
});
877+
}
878+
return findings;
879+
}
880+
881+
/**
882+
* A carve on a bus is half an effect, applied twice.
883+
*
884+
* `data-fx-carve` is a CLIP attribute. The bed being carved is one track, and
885+
* the level half of the analysis measures that track's own audio against the
886+
* voice — a bus has no `src`, so a carve there can only ever produce the
887+
* spectral half: filters with no level match.
888+
*
889+
* Worse, it stacks. A bus and a member clip are the same signal path, so a
890+
* carve on each puts the bed through both sets of filters — which is exactly
891+
* what happened when a bus labelled "Music bed" classified as one and carved
892+
* itself (fixed in Studio; this catches what was already written down).
893+
*/
894+
function findAudioGroupCarveAttrFindings(ctx: LintContext): HyperframeLintFinding[] {
895+
const findings: HyperframeLintFinding[] = [];
896+
for (const tag of ctx.tags) {
897+
if (tag.name !== "hf-audio-group") continue;
898+
if (!hasAttrName(tag.raw, "data-fx-carve")) continue;
899+
const elementId = readAttr(tag.raw, "id") || undefined;
900+
findings.push({
901+
code: "audio_group_carve_attr",
902+
severity: "warning",
903+
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.`,
904+
elementId,
905+
fixHint:
906+
"Remove `data-fx-carve` and the `fromCarve` nodes it wrote into this bus's `data-fx-chain`, and carve the bed clip instead.",
907+
snippet: truncateSnippet(tag.raw),
908+
});
909+
}
910+
return findings;
911+
}

0 commit comments

Comments
 (0)