diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json
index ef217b46f..b6acfeeef 100644
--- a/packages/core/package-subpaths.json
+++ b/packages/core/package-subpaths.json
@@ -152,6 +152,12 @@
"types": "./dist/audioCarve.d.ts",
"environments": ["browser", "bun", "node"]
},
+ "./audio-groups": {
+ "source": "./src/audioGroups.ts",
+ "runtime": "./dist/audioGroups.js",
+ "types": "./dist/audioGroups.d.ts",
+ "environments": ["browser", "bun", "node"]
+ },
"./audio-automation": {
"source": "./src/audioAutomation.ts",
"runtime": "./dist/audioAutomation.js",
diff --git a/packages/core/package.json b/packages/core/package.json
index c10d7a2d0..74e5778fc 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -166,6 +166,12 @@
"import": "./src/audioCarve.ts",
"types": "./src/audioCarve.ts"
},
+ "./audio-groups": {
+ "bun": "./src/audioGroups.ts",
+ "node": "./dist/audioGroups.js",
+ "import": "./src/audioGroups.ts",
+ "types": "./src/audioGroups.ts"
+ },
"./audio-automation": {
"bun": "./src/audioAutomation.ts",
"node": "./dist/audioAutomation.js",
@@ -480,6 +486,10 @@
"import": "./dist/audioCarve.js",
"types": "./dist/audioCarve.d.ts"
},
+ "./audio-groups": {
+ "import": "./dist/audioGroups.js",
+ "types": "./dist/audioGroups.d.ts"
+ },
"./audio-automation": {
"import": "./dist/audioAutomation.js",
"types": "./dist/audioAutomation.d.ts"
diff --git a/packages/core/src/audioGroups.test.ts b/packages/core/src/audioGroups.test.ts
new file mode 100644
index 000000000..588e8c45f
--- /dev/null
+++ b/packages/core/src/audioGroups.test.ts
@@ -0,0 +1,136 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ audioGroupOf,
+ ensureAudioGroupInertStyle,
+ HF_AUDIO_GROUP_ATTR,
+ resolveAudioGroups,
+} from "./audioGroups.js";
+
+beforeEach(() => {
+ document.body.innerHTML = "";
+ // The inert stylesheet is injected once per document, so a leftover from an
+ // earlier test would carry the assertion for the one after it.
+ document.getElementById("__hf-audio-group-inert")?.remove();
+});
+
+describe("resolveAudioGroups", () => {
+ it("returns one group of two members plus ignores an ungrouped track", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "voiceover", label: "Voiceover", memberIds: ["vo-1", "vo-2"] }]);
+ });
+
+ it("resolves from member tags alone when the group element is absent, label = id", () => {
+ document.body.innerHTML = `
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "narration", label: "narration", memberIds: ["vo-1"] }]);
+ });
+
+ it("ignores data-audio-group on the group element itself (groups do not nest)", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const groups = resolveAudioGroups(document);
+ expect(groups).toEqual([{ id: "outer", label: "outer", memberIds: ["vo-1"] }]);
+ expect(audioGroupOf(document.getElementById("outer") as Element)).toBeNull();
+ });
+
+ it("drops a member removed from the DOM on re-resolve — nothing dangles", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ expect(resolveAudioGroups(document)[0].memberIds).toEqual(["vo-1", "vo-2"]);
+
+ document.getElementById("vo-2")?.remove();
+ expect(resolveAudioGroups(document)[0].memberIds).toEqual(["vo-1"]);
+ });
+
+ it("ignores a data-audio-group on a video element (audio only in v1)", () => {
+ document.body.innerHTML = ``;
+ expect(resolveAudioGroups(document)).toEqual([]);
+ });
+});
+
+describe("audioGroupOf", () => {
+ it("reads the member's group id", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBe("voiceover");
+ });
+
+ it("returns null when the attribute is absent", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull();
+ });
+
+ // The mirror of resolveAudioGroups' own video case. These two readers used to
+ // disagree here: the resolver saw no group, this one answered "voiceover", so
+ // preview routed a track through a bus the export would never build (the
+ // render enforces audio-only in audioMixer).
+ it("returns null for a video, matching resolveAudioGroups", () => {
+ document.body.innerHTML = ``;
+ const el = document.getElementById("v-1") as Element;
+ expect(audioGroupOf(el)).toBeNull();
+ expect(resolveAudioGroups(document)).toEqual([]);
+ });
+
+ it("returns null for an empty attribute, not an empty string", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull();
+ expect(resolveAudioGroups(document)).toEqual([]);
+ });
+
+ // Groups do not nest, and the group element is not a member of itself.
+ it("returns null for the group element even when it carries the attribute", () => {
+ document.body.innerHTML = ``;
+ expect(audioGroupOf(document.getElementById("bus") as Element)).toBeNull();
+ });
+});
+
+describe("ensureAudioGroupInertStyle", () => {
+ it("takes the group element out of layout", () => {
+ document.body.innerHTML = ``;
+ const el = document.getElementById("voiceover") as HTMLElement;
+ ensureAudioGroupInertStyle(document);
+ expect(getComputedStyle(el).display).toBe("none");
+ });
+
+ // An unknown custom element is an ordinary inline box, so in a flex or grid
+ // root it takes a slot: a gap, a justify-content share, and every
+ // :nth-child after it shifts. An author rule must not be able to put it
+ // back — and an id selector outranks this rule's type selector no matter
+ // which stylesheet came last, so `!important` is the only thing holding the
+ // contract. Dropping it makes this case fail.
+ it("beats an author rule that outranks it on specificity", () => {
+ document.head.insertAdjacentHTML(
+ "beforeend",
+ ``,
+ );
+ document.body.innerHTML = ``;
+ ensureAudioGroupInertStyle(document);
+ expect(getComputedStyle(document.getElementById("voiceover") as HTMLElement).display).toBe(
+ "none",
+ );
+ document.getElementById("author")?.remove();
+ });
+
+ it("injects once, however many times it is called", () => {
+ ensureAudioGroupInertStyle(document);
+ ensureAudioGroupInertStyle(document);
+ expect(document.querySelectorAll("#__hf-audio-group-inert")).toHaveLength(1);
+ });
+});
+
+describe(HF_AUDIO_GROUP_ATTR, () => {
+ it("is the attribute name membership is keyed on", () => {
+ expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group");
+ });
+});
diff --git a/packages/core/src/audioGroups.ts b/packages/core/src/audioGroups.ts
new file mode 100644
index 000000000..86ebdb9d7
--- /dev/null
+++ b/packages/core/src/audioGroups.ts
@@ -0,0 +1,105 @@
+/**
+ * The audio group model: a named bucket of audio tracks that shares a label,
+ * an FX chain, and automation. Membership is held by the member (`data-audio-group`
+ * pointing at a group id), not by the group nesting its members, so a track
+ * dropped from the DOM simply disappears from the group on the next resolve —
+ * nothing dangles.
+ *
+ * Parse-only: this module answers "what groups exist and who is in them," and
+ * nothing here routes or sums audio yet.
+ */
+
+export const HF_AUDIO_GROUP_TAG = "hf-audio-group";
+export const HF_AUDIO_GROUP_ATTR = "data-audio-group";
+
+/**
+ * v1 membership, in one place: an `