diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index dea771ca9..3746d6b1a 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1349,6 +1349,10 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); + // Behind the `audio-track-mute` canary — off until the host pushes it, so a + // composition that already carries data-hidden on an audio element keeps + // playing in preview for anyone not enrolled. + window.__hf?.setAudioMuteHidden?.(true); const decodeSpy = vi .spyOn(WebAudioTransport.prototype, "decodeAudioElement") @@ -1362,6 +1366,39 @@ describe("initSandboxRuntimeModular", () => { expect(decodeSpy.mock.calls[0]?.[0]).toBe(audibleAudio); }); + it("still schedules a data-hidden audio clip when the host has not opted in", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const hiddenAudio = document.createElement("audio"); + hiddenAudio.setAttribute("data-start", "0"); + hiddenAudio.setAttribute("data-duration", "10"); + hiddenAudio.setAttribute("data-hidden", ""); + hiddenAudio.load = () => {}; + hiddenAudio.play = vi.fn(() => Promise.resolve()); + root.appendChild(hiddenAudio); + + window.__timelines = { main: createMockTimeline(10) }; + initSandboxRuntimeModular(); + + const decodeSpy = vi + .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + .mockResolvedValue(null); + + const player = window.__player; + player?.play(); + player?.seek(0); + + expect(decodeSpy).toHaveBeenCalledTimes(1); + expect(decodeSpy.mock.calls[0]?.[0]).toBe(hiddenAudio); + }); + it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c0b3abf0a..161ac30f1 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -192,6 +192,22 @@ export function initSandboxRuntimeModular(): void { soloedIds = new Set(ids); webAudio.setSolo(soloedIds); }; + // A2's preview/export parity fix — silencing `data-hidden` audio the way the + // render already does — behind the `audio-track-mute` canary, which is what + // that canary was declared for. Core cannot read the registry (canaries are + // resolved from the studio's install id), so the host pushes the resolved + // state on the same channel as solo. Default OFF = the shipped behaviour: a + // composition carrying `data-hidden` on an audio element keeps playing in + // preview until its author is enrolled. Non-studio hosts (CLI preview, the + // bare player) never push, so they stay on the old behaviour too. + let silenceHiddenAudio = false; + window.__hf.setAudioMuteHidden = (enabled) => { + if (silenceHiddenAudio === enabled) return; + silenceHiddenAudio = enabled; + // The active-clip set is built with this predicate baked in, so a flip + // mid-session has to rebuild it — same reason a `data-hidden` toggle does. + if (clock.isPlaying()) scheduleWebAudioForActiveClips(); + }; // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the // parser reads back), NOT an animatable property. Register it as a no-op GSAP // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — @@ -2080,6 +2096,7 @@ export function initSandboxRuntimeModular(): void { isWebAudioOwned: (el) => webAudio.ownsElement(el), isWebAudioRouted: (el) => webAudio.routesElement(el), isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)), + silenceHiddenAudio, onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; state.mediaAutoplayBlockedPosted = true; @@ -2982,7 +2999,7 @@ export function initSandboxRuntimeModular(): void { let foundActive = false; for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - if (rawEl.closest("[data-hidden]")) continue; + if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue; const start = Number.parseFloat(rawEl.dataset.start ?? ""); const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration); const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity; @@ -3090,7 +3107,7 @@ export function initSandboxRuntimeModular(): void { const audioEls = document.querySelectorAll("audio[data-start]"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - if (rawEl.closest("[data-hidden]")) continue; + if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue; const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); if (!Number.isFinite(compStart)) continue; const mediaStart = readElementPlaybackStart(rawEl); diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index f264b638a..c4e10753a 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -574,38 +574,57 @@ describe("syncRuntimeMedia", () => { }); describe("data-hidden silences preview volume", () => { - it("zeroes effective volume for a clip under a data-hidden ancestor", () => { + const hiddenClip = () => { const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); const hiddenAncestor = document.createElement("div"); hiddenAncestor.setAttribute("data-hidden", ""); document.body.appendChild(hiddenAncestor); hiddenAncestor.appendChild(clip.el); - + return clip; + }; + const volumeSeen = (clip: ReturnType, silenceHiddenAudio?: boolean) => { let seen = -1; syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1, + ...(silenceHiddenAudio === undefined ? {} : { silenceHiddenAudio }), onElementVolume: (_el, v) => { seen = v; }, }); + return seen; + }; - expect(seen).toBe(0); + it("zeroes effective volume for a clip under a data-hidden ancestor", () => { + expect(volumeSeen(hiddenClip(), true)).toBe(0); + }); + + // The `audio-track-mute` canary sits at 0%: an existing composition that + // carries data-hidden on an audio element must keep playing in preview + // until its author is enrolled, or the upgrade silences them with no way + // back short of a revert. + it("leaves a hidden clip audible when the host has not opted in", () => { + expect(volumeSeen(hiddenClip(), false)).toBe(0.8); + }); + + it("defaults to audible when the flag is absent entirely", () => { + expect(volumeSeen(hiddenClip())).toBe(0.8); }); it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => { - const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); - Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); - const hiddenAncestor = document.createElement("div"); - hiddenAncestor.setAttribute("data-hidden", ""); - document.body.appendChild(hiddenAncestor); - hiddenAncestor.appendChild(clip.el); + const clip = hiddenClip(); clip.el.muted = false; - syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1 }); + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 1, + playing: true, + playbackRate: 1, + silenceHiddenAudio: true, + }); expect(clip.el.muted).toBe(false); }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index ec5be4718..4e44a1dbc 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -222,6 +222,11 @@ export function syncRuntimeMedia(params: { * solo gain instead — see `WebAudioTransport.setSolo`). Absent when solo * isn't wired up at all, which reads as "always audible". */ isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean; + /** Silence media under a `data-hidden` ancestor, matching the render. Opt-in: + * the host pushes it via `__hf.setAudioMuteHidden` when the `audio-track-mute` + * canary is on. Absent/false = the shipped behaviour (hidden audio still + * plays in preview). */ + silenceHiddenAudio?: boolean; forceSync?: boolean; }): void { const forceMuteAll = !!(params.outputMuted || params.userMuted); @@ -321,13 +326,19 @@ export function syncRuntimeMedia(params: { } // A data-hidden ancestor is silent in the export (audioMixer.ts drops - // it); preview must match. Folded into the per-tick volume, not - // el.muted (RULES trap: el.muted is the transport's ownership flag). - // Solo rides the same fold for the same reason — never el.muted, and - // never touching any attribute (it is session-only, unlike hidden). + // it); preview matches once the host opts in (`silenceHiddenAudio`, the + // `audio-track-mute` canary — see init.ts). Folded into the per-tick + // volume, not el.muted (RULES trap: el.muted is the transport's ownership + // flag). Solo rides the same fold for the same reason — never el.muted, + // and never touching any attribute (it is session-only, unlike hidden) — + // but is NOT gated: it is a session control with no shipped behaviour to + // preserve. + const silencedByHidden = params.silenceHiddenAudio + ? el.closest("[data-hidden]") !== null + : false; const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false; const effectiveVolume = - el.closest("[data-hidden]") || silencedBySolo ? 0 : clampVolume(authorVolume * userVol); + silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol); el.volume = effectiveVolume; lastRuntimeAppliedVolume.set(el, effectiveVolume); params.onElementVolume?.(el, effectiveVolume, authorVolume); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 2a0d5d23f..706c3fa0c 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -43,6 +43,12 @@ declare global { * read from or written to any document attribute. */ setAudioSolo?: (ids: readonly string[]) => void; + /** + * Studio's `audio-track-mute` canary state: silence audio under a + * `data-hidden` ancestor in preview, the way the render already does. + * Off until pushed — core cannot resolve a canary itself. + */ + setAudioMuteHidden?: (enabled: boolean) => void; }; __playerReady?: boolean; __renderReady?: boolean; diff --git a/packages/studio/src/components/editor/useFxCarve.ts b/packages/studio/src/components/editor/useFxCarve.ts index a47c9ef8f..f2b7da221 100644 Binary files a/packages/studio/src/components/editor/useFxCarve.ts and b/packages/studio/src/components/editor/useFxCarve.ts differ diff --git a/packages/studio/src/hooks/timelineAudioGroupCreate.ts b/packages/studio/src/hooks/timelineAudioGroupCreate.ts new file mode 100644 index 000000000..de1d12c2c --- /dev/null +++ b/packages/studio/src/hooks/timelineAudioGroupCreate.ts @@ -0,0 +1,278 @@ +/** + * Creating an audio group: the member sweep, the group element, and the + * carve's auto-group write-back. + * + * Split out of `timelineTrackVisibility.ts`, which owns the hidden/mute writes + * these mirror and had reached the 600-line studio ceiling. + */ + +import { useCallback } from "react"; +import { usePlayerStore, type TimelineElement } from "../player"; +import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups"; +import { runtimeAudioId } from "../player/lib/timelineElementHelpers"; +import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher"; +import { + applyPatchByTarget, + buildPatchTarget, + findTimelineElementInIframe, + readFileContent, + type RecordEditInput, +} from "./timelineEditingHelpers"; +import { + groupElementsByTargetPath, + reseekPreviewRuntime, + type MutableRef, + type UseTimelineElementVisibilityEditingInput, +} from "./timelineTrackVisibility"; + +function patchLiveAudioGroupState( + iframe: HTMLIFrameElement | null, + elements: readonly TimelineElement[], + groupId: string | null, + activeCompPath: string | null, +): void { + for (const element of elements) { + const target = findTimelineElementInIframe(iframe, element, activeCompPath); + if (!target) continue; + if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId); + else target.removeAttribute(HF_AUDIO_GROUP_ATTR); + } +} + +/** Group ids are interpolated into markup and into a render-side filename, so + * they stay in the character set an HTML id and a path can both carry. */ +const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** + * The group's own `` element, appended before `` when it + * is not already in the file. + * + * Membership alone is enough for `resolveAudioGroups` to see the group, but + * every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset — + * addresses the group by its DOM id (`setAudioGroupAttribute` → + * `buildPatchTarget({ domId: groupId })`), so without an element of its own a + * group is created and then cannot be edited at all. + * + * Written to the active composition file rather than beside the members, which + * can live in a sub-composition: that is the file the group's later writes + * target, and `resolveAudioGroups` reads the flattened document, so co-location + * buys nothing. + */ +function insertGroupElement(html: string, groupId: string): string { + if (readTagSnippetByTarget(html, { id: groupId }) !== undefined) return html; + const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}">`; + const closeBody = html.lastIndexOf(""); + if (closeBody < 0) return `${html}\n${tag}\n`; + return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`; +} + +/** The same element in the live preview, so the group is editable before the + * next reload. Returns true when it created one (only then may the unwind + * remove it — a pre-existing group element is not ours to delete). */ +function patchLiveGroupElement(iframe: HTMLIFrameElement | null, groupId: string): boolean { + const doc = iframe?.contentDocument; + if (!doc?.body || doc.getElementById(groupId)) return false; + const el = doc.createElement(HF_AUDIO_GROUP_TAG); + el.id = groupId; + doc.body.appendChild(el); + return true; +} + +interface CreateAudioGroupAndAssignMembersInput { + projectId: string; + activeCompPath: string | null; + elements: readonly TimelineElement[]; + groupId: string; + previewIframe: HTMLIFrameElement | null; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: MutableRef; + pendingTimelineEditPathRef: MutableRef>; +} + +/** + * Group two or more voice clips: write `data-audio-group=""` on + * every one of them, atomically, one undo entry — the same multi-target shape + * `setElementsHidden` uses for mute — plus the group's own `` + * element, which every later group-level write addresses by DOM id. No naming + * dialog: the id is the default name, the way `resolveAudioGroups` reads it. + */ +// fallow-ignore-next-line complexity +export async function createAudioGroupAndAssignMembers({ + projectId, + activeCompPath, + elements, + groupId, + previewIframe, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, +}: CreateAudioGroupAndAssignMembersInput): Promise { + // Throws rather than returning empty: the carve's auto-group awaits this and + // then persists `sources: [groupId]` on success, so a quiet no-op leaves the + // carve aimed at a group that does not exist. + if (elements.length < 2) { + throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`); + } + if (!GROUP_ID_PATTERN.test(groupId)) { + throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`); + } + + patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath); + const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId); + reseekPreviewRuntime(previewIframe); + + const groupOperation: PatchOperation = { + type: "attribute", + property: HF_AUDIO_GROUP_ATTR, + value: groupId, + }; + const originalByPath = new Map(); + const files: Record = {}; + + try { + for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) { + let patchedContent = await readFileContent(projectId, targetPath); + originalByPath.set(targetPath, patchedContent); + + for (const element of fileElements) { + const patchTarget = buildPatchTarget(element); + if (!patchTarget) { + throw new Error(`Timeline element ${element.id} is missing a patchable target`); + } + if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) { + throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`); + } + patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation); + } + + files[targetPath] = patchedContent; + pendingTimelineEditPathRef.current.add(targetPath); + } + + const groupPath = activeCompPath || "index.html"; + let groupContent = files[groupPath]; + if (groupContent === undefined) { + groupContent = await readFileContent(projectId, groupPath); + originalByPath.set(groupPath, groupContent); + } + const withGroupElement = insertGroupElement(groupContent, groupId); + if (withGroupElement !== groupContent) { + files[groupPath] = withGroupElement; + pendingTimelineEditPathRef.current.add(groupPath); + } + + domEditSaveTimestampRef.current = Date.now(); + const changedPaths = await saveProjectFilesWithHistory({ + projectId, + label: `Group ${elements.length} voice clips`, + kind: "timeline", + files, + readFile: async (path) => { + const original = originalByPath.get(path); + if (original !== undefined) return original; + return readFileContent(projectId, path); + }, + writeFile: writeProjectFile, + recordEdit, + }); + domEditSaveTimestampRef.current = Date.now(); + for (const element of elements) { + usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId }); + } + return changedPaths; + } catch (error) { + // Mirrors setElementsHidden's failure path: the optimistic live patch + // already ran, so a save failure has to be unwound or the preview shows a + // grouping that never made it to disk. + patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath); + if (createdLiveGroupElement) { + previewIframe?.contentDocument?.getElementById(groupId)?.remove(); + } + reseekPreviewRuntime(previewIframe); + throw error; + } +} + +/** + * The write behind B6's auto-group: pick two or more voice clips in the carve + * picker and they land in a group instead of naming each other by id. Same + * expanded-rows resolution as element-visibility, for the same reason — a + * nested sub-composition child has no entry in the raw store list. + */ +export function useAudioGroupCarveAssignment({ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + previewIframeRef, + pendingTimelineEditPathRef, + isRecordingRef, +}: UseTimelineElementVisibilityEditingInput): ( + clipIds: readonly string[], + groupId: string, +) => Promise { + const expandedElements = useExpandedTimelineElements(); + return useCallback( + async (clipIds: readonly string[], groupId: string) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } + const pid = projectIdRef.current; + if (!pid) return; + // DOM ids, not store keys: both callers (the carve picker and the + // timeline's group-pointer button) name clips the way the document does, + // because that is the only space `resolveAudioGroups` reads back. + const wanted = new Set(clipIds); + const elements = expandedElements.filter((item) => { + const domId = runtimeAudioId(item); + return domId !== null && wanted.has(domId); + }); + try { + // Loud, not silent: an unresolved id used to leave `elements` short, + // `createAudioGroupAndAssignMembers` returning early with no write, and + // the carve still persisting `sources: [groupId]` for a group that was + // never created — a carve pointing at nothing, silently not ducking. + if (elements.length !== wanted.size) { + const missing = [...wanted].filter( + (id) => !elements.some((item) => runtimeAudioId(item) === id), + ); + throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`); + } + await createAudioGroupAndAssignMembers({ + projectId: pid, + activeCompPath, + elements, + groupId, + previewIframe: previewIframeRef.current, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + }); + } catch (error) { + console.error("[Timeline] Failed to group voice clips", error); + const message = error instanceof Error ? error.message : "Failed to group voice clips"; + showToast(message); + } + }, + [ + activeCompPath, + expandedElements, + previewIframeRef, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + isRecordingRef, + showToast, + projectIdRef, + ], + ); +} diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts index 7ef0b04c8..0df930779 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts @@ -2,11 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore, type TimelineElement } from "../player"; -import { - createAudioGroupAndAssignMembers, - toggleTimelineElementHidden, - toggleTimelineTrackHidden, -} from "./timelineTrackVisibility"; +import { resolveAudioGroups } from "@hyperframes/core/audio-groups"; +import { readTagSnippetByTarget } from "../utils/sourcePatcher"; +import { createAudioGroupAndAssignMembers } from "./timelineAudioGroupCreate"; +import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility"; afterEach(() => { document.body.innerHTML = ""; @@ -482,23 +481,106 @@ describe("createAudioGroupAndAssignMembers", () => { ).toBe("voiceover"); }); - it("does nothing for fewer than two elements — grouping is a plural concept", async () => { - const recordEdit = vi.fn(); - const changedPaths = await createAudioGroupAndAssignMembers({ + // The group element is what every LATER group write addresses — mute, the bus + // fader's data-volume, an FX preset all go through + // `buildPatchTarget({ domId: groupId })`. Membership alone parses, but leaves + // a group nothing can edit. + it("emits the group's own element, patchable by its DOM id", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + if (iframe.contentDocument) { + iframe.contentDocument.body.innerHTML = ` + + + `; + } + const files = new Map([ + [ + "index.html", + ` + + +`, + ], + ]); + stubProjectFiles(files); + + const narration = element({ id: "narration", domId: "narration", track: 0 }); + const guest = element({ id: "interview-guest", domId: "interview-guest", track: 1 }); + usePlayerStore.getState().setElements([narration, guest]); + + const writes = new Map(); + await createAudioGroupAndAssignMembers({ projectId: "project-1", activeCompPath: "index.html", - elements: [element({ id: "narration", domId: "narration" })], + elements: [narration, guest], groupId: "voiceover", - previewIframe: null, - writeProjectFile: async () => {}, - recordEdit, + previewIframe: iframe, + writeProjectFile: async (path, content) => { + writes.set(path, content); + }, + recordEdit: vi.fn(), domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); - expect(changedPaths).toEqual([]); + + const written = writes.get("index.html") ?? ""; + expect(written).toContain(''); + // The actual contract: the group-attribute writer can now find a target. + // This is the read that threw "Unable to patch element in index.html". + expect(readTagSnippetByTarget(written, { id: "voiceover" })).toBeDefined(); + // ...and in the live preview, which is what patchLiveGroupAttribute reads + // before the next reload. + expect(iframe.contentDocument?.getElementById("voiceover")?.tagName.toLowerCase()).toBe( + "hf-audio-group", + ); + // Both members still resolve into it. + expect(resolveAudioGroups(iframe.contentDocument as Document)[0]).toMatchObject({ + id: "voiceover", + memberIds: ["narration", "interview-guest"], + }); + }); + + // Rejects rather than resolving empty: the carve's auto-group persists + // `sources: [groupId]` once this resolves, so a silent no-op leaves the carve + // pointing at a group that was never written — and stops ducking. + it("rejects for fewer than two elements — grouping is a plural concept", async () => { + const recordEdit = vi.fn(); + await expect( + createAudioGroupAndAssignMembers({ + projectId: "project-1", + activeCompPath: "index.html", + elements: [element({ id: "narration", domId: "narration" })], + groupId: "voiceover", + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit, + domEditSaveTimestampRef: { current: 0 }, + pendingTimelineEditPathRef: { current: new Set() }, + }), + ).rejects.toThrow("a group needs at least two"); expect(recordEdit).not.toHaveBeenCalled(); }); + it("rejects a group id that is not safe to interpolate into markup or a path", async () => { + await expect( + createAudioGroupAndAssignMembers({ + projectId: "project-1", + activeCompPath: "index.html", + elements: [ + element({ id: "narration", domId: "narration" }), + element({ id: "guest", domId: "guest" }), + ], + groupId: '../x">