diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 1485e4f022..965a985de4 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 41077e31d1..f0d598bbe8 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", @@ -474,6 +480,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 0000000000..232827456b --- /dev/null +++ b/packages/core/src/audioGroups.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { audioGroupOf, HF_AUDIO_GROUP_ATTR, resolveAudioGroups } from "./audioGroups.js"; + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +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(); + }); +}); + +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 0000000000..ad96b91822 --- /dev/null +++ b/packages/core/src/audioGroups.ts @@ -0,0 +1,59 @@ +/** + * 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"; + +export interface HfAudioGroup { + id: string; + /** `data-label`, falling back to the id when absent. */ + label: string; + /** Member element ids, in document order. */ + memberIds: string[]; +} + +/** + * Every group with at least one member, resolved from the live document. + * + * A group with members but no `` element still resolves + * (label = id) so a hand-authored composition degrades gracefully. Audio + * only in v1 — a `data-audio-group` on a `