mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(core): the audio group model — element, membership, helpers
Introduces <hf-audio-group> 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 <hf-audio-group> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6c128620e9
commit
975b079473
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `
|
||||
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
||||
<audio id="vo-1" data-audio-group="voiceover"></audio>
|
||||
<audio id="vo-2" data-audio-group="voiceover"></audio>
|
||||
<audio id="sfx-1"></audio>
|
||||
`;
|
||||
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 = `
|
||||
<audio id="vo-1" data-audio-group="narration"></audio>
|
||||
`;
|
||||
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 = `
|
||||
<hf-audio-group id="outer" data-audio-group="outer"></hf-audio-group>
|
||||
<audio id="vo-1" data-audio-group="outer"></audio>
|
||||
`;
|
||||
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 = `
|
||||
<audio id="vo-1" data-audio-group="voiceover"></audio>
|
||||
<audio id="vo-2" data-audio-group="voiceover"></audio>
|
||||
`;
|
||||
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 = `<video id="v-1" data-audio-group="voiceover"></video>`;
|
||||
expect(resolveAudioGroups(document)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioGroupOf", () => {
|
||||
it("reads the member's group id", () => {
|
||||
document.body.innerHTML = `<audio id="vo-1" data-audio-group="voiceover"></audio>`;
|
||||
expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBe("voiceover");
|
||||
});
|
||||
|
||||
it("returns null when the attribute is absent", () => {
|
||||
document.body.innerHTML = `<audio id="vo-1"></audio>`;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<hf-audio-group>` element still resolves
|
||||
* (label = id) so a hand-authored composition degrades gracefully. Audio
|
||||
* only in v1 — a `data-audio-group` on a `<video>` is ignored.
|
||||
*/
|
||||
export function resolveAudioGroups(root: ParentNode): HfAudioGroup[] {
|
||||
const membersByGroup = new Map<string, string[]>();
|
||||
for (const member of root.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)) {
|
||||
const groupId = member.getAttribute(HF_AUDIO_GROUP_ATTR);
|
||||
if (!groupId || !member.id) continue;
|
||||
const members = membersByGroup.get(groupId);
|
||||
if (members) members.push(member.id);
|
||||
else membersByGroup.set(groupId, [member.id]);
|
||||
}
|
||||
|
||||
const groupElements = new Map<string, Element>();
|
||||
for (const el of root.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
|
||||
if (el.id) groupElements.set(el.id, el);
|
||||
}
|
||||
|
||||
const groups: HfAudioGroup[] = [];
|
||||
for (const [id, memberIds] of membersByGroup) {
|
||||
const el = groupElements.get(id);
|
||||
const label = el?.getAttribute("data-label") || id;
|
||||
groups.push({ id, label, memberIds });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The group a member belongs to, or null. Groups do not nest — this ignores
|
||||
* `data-audio-group` on an `<hf-audio-group>` element itself. */
|
||||
export function audioGroupOf(el: Element): string | null {
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
|
||||
return el.getAttribute(HF_AUDIO_GROUP_ATTR);
|
||||
}
|
||||
@@ -99,6 +99,17 @@ export const CANARIES: readonly CanaryDefinition[] = [
|
||||
owner: "vance",
|
||||
sunsetAfter: "2026-12-15",
|
||||
},
|
||||
{
|
||||
name: "audio-groups",
|
||||
percentage: 0,
|
||||
description:
|
||||
"Group audio tracks under a shared label, FX chain, and automation " +
|
||||
"clock. Gates the Studio UI for creating and managing groups; the " +
|
||||
"underlying <hf-audio-group> element and data-audio-group membership " +
|
||||
"parse and play regardless of enrollment.",
|
||||
owner: "vance",
|
||||
sunsetAfter: "2027-01-15",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function findCanary(name: string): CanaryDefinition | undefined {
|
||||
|
||||
@@ -1376,6 +1376,30 @@ describe("useDomEditCommits attribute persist handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sets and removes data-audio-group like any other data attribute", async () => {
|
||||
stubPatchFetch({ ok: true, changed: true, matched: true });
|
||||
const { iframe, element } = createPreviewElement();
|
||||
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "voiceover", undefined, {
|
||||
previewOnly: true,
|
||||
});
|
||||
});
|
||||
expect(element.getAttribute("data-audio-group")).toBe("voiceover");
|
||||
|
||||
await act(async () => {
|
||||
await rendered.hook.handleDomAttributeLiveCommit("audio-group", "", undefined, {
|
||||
previewOnly: true,
|
||||
});
|
||||
});
|
||||
expect(element.getAttribute("data-audio-group")).toBeNull();
|
||||
} finally {
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a data-attribute commit on success", async () => {
|
||||
stubPatchFetch({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user