From c8e87cd48fbb702022f4210d8815732e9440d5ba Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 14 Aug 2026 13:40:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20the=20audio=20group=20model=20?= =?UTF-8?q?=E2=80=94=20element,=20membership,=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces and data-audio-group as the group model B2–B7 and C1 build on: a non-rendering group element carries a label and (later) an FX chain, membership lives on the member's own data-audio-group attribute rather than DOM nesting, so a track removed from the document simply drops out of the group on the next resolve — nothing dangles. Groups do not nest: data-audio-group on the group element itself is ignored. A group with members but no element still resolves, label falling back to the id, so hand-authored HTML degrades gracefully. Audio only in v1 — video members are ignored. Parse-only: nothing routes or sums audio yet (B3/B4). Adds the audio-groups canary at percentage: 0 gating the future Studio UI; the element and attribute parse and play regardless of enrollment. Verified rather than assumed per this plan's standing rule: the timeline's clip-collection selector ([data-start], [data-track-index], [data-composition-id], video, audio, img) already excludes the group element with zero changes, and no lint rule flags unknown elements or data-* attributes, so neither needed touching — confirmed by grep and by running `hyperframes lint` against a fixture containing the element (0 findings referencing it). The step doc's suggested display:none injection point (an existing base stylesheet in the runtime) does not exist in this codebase; skipped rather than inventing new infrastructure, since an empty, childless custom element already renders as a zero-size inline box with no visible output — the same reasoning the lint check above confirms empirically. Co-Authored-By: Claude Sonnet 5 --- packages/core/package-subpaths.json | 6 ++ packages/core/package.json | 10 +++ packages/core/src/audioGroups.test.ts | 71 +++++++++++++++++++ packages/core/src/audioGroups.ts | 59 +++++++++++++++ packages/core/src/canaryRegistry.ts | 11 +++ .../src/hooks/useDomEditCommits.test.tsx | 24 +++++++ 6 files changed, 181 insertions(+) create mode 100644 packages/core/src/audioGroups.test.ts create mode 100644 packages/core/src/audioGroups.ts 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 `