From 5850a0c978c4ee72928a0847799dd171f6d134f1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 16 Aug 2026 12:55:46 -0700 Subject: [PATCH] fix(studio,core): unify the audio id space, emit group elements, gate both canaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of the fifteen findings from the max-effort review of the audio groups / mute-solo / pitch-shift stack. Nothing in the stack is merged; this sits on top of wa-24-timeline-fx. The id space (findings 1-3). Studio addresses rows by buildTimelineElementKey's composite `#`; every audio predicate in core keys off the live document instead — resolveAudioGroups collects `member.id`, isAudibleUnderSolo compares `el.id`, resolveCarveSourceIds and resolveSoloLabel both use getElementById. Nobody checked the boundary, so: * solo put a composite key in the set the runtime matches against `el.id`, matching nothing and driving every gain to 0 — soloing silenced the whole preview; * the carve's auto-group resolved the picker's bare ids against composite keys, found no elements, wrote nothing, threw nothing, and still persisted `sources: []` for a group that was never created — a carve that quietly stopped ducking; * the two callers of onGroupClips disagreed about which space they were in. Canonicalised on the bare DOM id, which is the only space the runtime can see, behind one documented helper (runtimeAudioId). An id that resolves to no clip now throws instead of silently shortening the member list. The group element (finding 4). Group creation wrote `data-audio-group` on members but never emitted ``, while every group-level write — mute, the bus fader's data-volume, an FX preset — addresses the group by DOM id. Groups the product created were exactly the groups nothing could edit. Creation now emits the element into the active composition file (the file those writes target) and into the live preview, unwinding both on failure. Group ids are validated before being interpolated into markup. The canary leaks (findings 5-6). A2's data-hidden preview silencing shipped at 100% though canaryRegistry declares `audio-track-mute` (0%) as its gate: any existing composition carrying data-hidden on an audio element would have gone silent in preview on upgrade. Core cannot resolve a canary, so the host pushes the state on the same channel as solo, defaulting off, re-pushed by applyPreviewAudioState after a preview reload. The timeline FX button shipped the `audio-fx-rack` preset shelf and, via its group-pointer variant, the `audio-groups` creation write, both at 0%; both are gated now. Tests. Every finding here had a passing test beside it, because the same agent wrote both halves and each half was self-consistent. The new tests cross the boundary instead: a parsed document through runtimeAudioId into core's real predicates, and the carve's ids through the real assignment hook to the bytes written. Each was mutation-checked against the pre-fix code. Group creation moves to its own module — the additions pushed timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical, but they made the file read as binary to grep. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/runtime/init.test.ts | 37 +++ packages/core/src/runtime/init.ts | 21 +- packages/core/src/runtime/media.test.ts | 39 ++- packages/core/src/runtime/media.ts | 21 +- packages/core/src/runtime/window.d.ts | 6 + .../src/components/editor/useFxCarve.ts | Bin 28274 -> 28287 bytes .../src/hooks/timelineAudioGroupCreate.ts | 278 ++++++++++++++++++ .../src/hooks/timelineTrackVisibility.test.ts | 108 ++++++- .../src/hooks/timelineTrackVisibility.ts | 177 +---------- .../useAudioGroupCarveAssignment.test.tsx | 136 +++++++++ .../studio/src/hooks/useTimelineEditing.ts | 2 +- .../components/TimelineTrackHeader.test.tsx | 97 +++++- .../player/components/TimelineTrackHeader.tsx | 27 +- .../src/player/hooks/useTimelinePlayer.ts | 9 +- .../src/player/lib/runtimeAudioId.test.ts | 107 +++++++ .../src/player/lib/timelineElementHelpers.ts | 19 ++ .../src/player/lib/timelineIframeHelpers.ts | 34 +++ 17 files changed, 896 insertions(+), 222 deletions(-) create mode 100644 packages/studio/src/hooks/timelineAudioGroupCreate.ts create mode 100644 packages/studio/src/hooks/useAudioGroupCarveAssignment.test.tsx create mode 100644 packages/studio/src/player/lib/runtimeAudioId.test.ts 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 a47c9ef8fded7d44e95ddab3e098cb3e0b238054..f2b7da22183cd9dc9e2aad6a89b5baad876baf68 100644 GIT binary patch delta 80 zcmex#hw=X%#tr%ctQuSj3JR0;1jOWH43sn#6m4x464Odji}Z@~ON)|Ii}kYdGxIc* hVhkoH3TUtZ6>Dzp6qpgn3Q}!7*)dCUvt5!24*=)I7rOue delta 54 zcmex=hw;-L#tr%cVo9lK`9-OE#rdU0$*IM9S^1fH8cGb4eFUT#875~6Xl$M;Fe8wW KVY7Zx01p5|Qxcy5 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">