@hyperframes/studio 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6440 -5514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Creating an audio group: the member sweep, the group element, and the
3
+ * carve's auto-group write-back.
4
+ *
5
+ * Split out of `timelineTrackVisibility.ts`, which owns the hidden/mute writes
6
+ * these mirror and had reached the 600-line studio ceiling.
7
+ */
8
+
9
+ import { useCallback } from "react";
10
+ import { usePlayerStore, type TimelineElement } from "../player";
11
+ import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
12
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
13
+ import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
14
+ import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
15
+ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
16
+ import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
17
+ import {
18
+ applyPatchByTarget,
19
+ buildPatchTarget,
20
+ findTimelineElementInIframe,
21
+ readFileContent,
22
+ type RecordEditInput,
23
+ } from "./timelineEditingHelpers";
24
+ import {
25
+ groupElementsByTargetPath,
26
+ reseekPreviewRuntime,
27
+ type MutableRef,
28
+ type UseTimelineElementVisibilityEditingInput,
29
+ } from "./timelineTrackVisibility";
30
+
31
+ /**
32
+ * Assign (or restore) `data-audio-group` across a set of members.
33
+ *
34
+ * `restore` carries each member's PRIOR value so the unwind can put back a
35
+ * membership that already existed, rather than removing the attribute outright.
36
+ * `setElementsHidden`, which this mirrors, gets away with a plain `!hidden`
37
+ * because hidden is boolean; group membership is an arbitrary id, and the carve
38
+ * path does not check whether a clip is already grouped — so a failed save
39
+ * could silently un-group clips that belonged to another group before it.
40
+ */
41
+ function patchLiveAudioGroupState(
42
+ iframe: HTMLIFrameElement | null,
43
+ elements: readonly TimelineElement[],
44
+ groupId: string | null,
45
+ activeCompPath: string | null,
46
+ restore?: ReadonlyMap<TimelineElement, string | null>,
47
+ ): void {
48
+ for (const element of elements) {
49
+ const target = findTimelineElementInIframe(iframe, element, activeCompPath);
50
+ if (!target) continue;
51
+ const next = restore ? (restore.get(element) ?? null) : groupId;
52
+ if (next) target.setAttribute(HF_AUDIO_GROUP_ATTR, next);
53
+ else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
54
+ }
55
+ invalidateGroupInfoCache(iframe?.contentDocument);
56
+ }
57
+
58
+ /** Each member's `data-audio-group` before this write, for the unwind. */
59
+ function captureAudioGroupState(
60
+ iframe: HTMLIFrameElement | null,
61
+ elements: readonly TimelineElement[],
62
+ activeCompPath: string | null,
63
+ ): Map<TimelineElement, string | null> {
64
+ const prior = new Map<TimelineElement, string | null>();
65
+ for (const element of elements) {
66
+ const target = findTimelineElementInIframe(iframe, element, activeCompPath);
67
+ prior.set(element, target?.getAttribute(HF_AUDIO_GROUP_ATTR) ?? null);
68
+ }
69
+ return prior;
70
+ }
71
+
72
+ /** Group ids are interpolated into markup and into a render-side filename, so
73
+ * they stay in the character set an HTML id and a path can both carry. */
74
+ const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
75
+
76
+ /**
77
+ * The group's own `<hf-audio-group>` element, appended before `</body>` when it
78
+ * is not already in the file.
79
+ *
80
+ * Membership alone is enough for `resolveAudioGroups` to see the group, but
81
+ * every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset —
82
+ * addresses the group by its DOM id (`setAudioGroupAttribute` →
83
+ * `buildPatchTarget({ domId: groupId })`), so without an element of its own a
84
+ * group is created and then cannot be edited at all.
85
+ *
86
+ * Written to the active composition file rather than beside the members, which
87
+ * can live in a sub-composition: that is the file the group's later writes
88
+ * target, and `resolveAudioGroups` reads the flattened document, so co-location
89
+ * buys nothing.
90
+ */
91
+ /** Attribute-safe, for a name the author typed. */
92
+ function escapeAttr(value: string): string {
93
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
94
+ }
95
+
96
+ function insertGroupElement(html: string, groupId: string, label?: string): string {
97
+ const existing = readTagSnippetByTarget(html, { id: groupId });
98
+ if (existing !== undefined) {
99
+ // Only OUR tag counts as "already there". The id was minted against the
100
+ // live preview document, which does not contain markup that is on disk but
101
+ // not rendered (inside a `<template>`, or an unloaded sub-composition) — so
102
+ // an unrelated element can already own it. Writing nothing there would aim
103
+ // every later group write (`buildPatchTarget({ domId })`) at that element,
104
+ // stamping data-volume / data-hidden / data-fx-chain onto it.
105
+ if (new RegExp(`^<\\s*${HF_AUDIO_GROUP_TAG}\\b`, "i").test(existing)) return html;
106
+ throw new Error(`Cannot create audio group: id ${groupId} is already used in this file`);
107
+ }
108
+ // The author's name for the group, from the naming dialog (groups doc §5).
109
+ // Without it the timeline falls back to the minted id, which is the one thing
110
+ // the dialog exists to stop an author having to read.
111
+ const labelAttr = label ? ` data-label="${escapeAttr(label)}"` : "";
112
+ const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"${labelAttr}></${HF_AUDIO_GROUP_TAG}>`;
113
+ const closeBody = html.lastIndexOf("</body>");
114
+ if (closeBody < 0) return `${html}\n${tag}\n`;
115
+ return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`;
116
+ }
117
+
118
+ /** The same element in the live preview, so the group is editable before the
119
+ * next reload. Returns true when it created one (only then may the unwind
120
+ * remove it — a pre-existing group element is not ours to delete). */
121
+ function patchLiveGroupElement(
122
+ iframe: HTMLIFrameElement | null,
123
+ groupId: string,
124
+ label?: string,
125
+ ): boolean {
126
+ const doc = iframe?.contentDocument;
127
+ if (!doc?.body || doc.getElementById(groupId)) return false;
128
+ const el = doc.createElement(HF_AUDIO_GROUP_TAG);
129
+ el.id = groupId;
130
+ if (label) el.setAttribute("data-label", label);
131
+ doc.body.appendChild(el);
132
+ invalidateGroupInfoCache(doc);
133
+ return true;
134
+ }
135
+
136
+ interface CreateAudioGroupAndAssignMembersInput {
137
+ projectId: string;
138
+ activeCompPath: string | null;
139
+ elements: readonly TimelineElement[];
140
+ groupId: string;
141
+ /** The author's name for it, from the naming dialog (groups doc §5). */
142
+ groupLabel?: string;
143
+ previewIframe: HTMLIFrameElement | null;
144
+ writeProjectFile: (path: string, content: string) => Promise<void>;
145
+ recordEdit: (input: RecordEditInput) => Promise<void>;
146
+ domEditSaveTimestampRef: MutableRef<number>;
147
+ pendingTimelineEditPathRef: MutableRef<Set<string>>;
148
+ }
149
+
150
+ /**
151
+ * Group two or more voice clips: write `data-audio-group="<groupId>"` on
152
+ * every one of them, atomically, one undo entry — the same multi-target shape
153
+ * `setElementsHidden` uses for mute — plus the group's own `<hf-audio-group>`
154
+ * element, which every later group-level write addresses by DOM id. No naming
155
+ * dialog: the id is the default name, the way `resolveAudioGroups` reads it.
156
+ */
157
+ // fallow-ignore-next-line complexity
158
+ export async function createAudioGroupAndAssignMembers({
159
+ projectId,
160
+ activeCompPath,
161
+ elements,
162
+ groupId,
163
+ groupLabel,
164
+ previewIframe,
165
+ writeProjectFile,
166
+ recordEdit,
167
+ domEditSaveTimestampRef,
168
+ pendingTimelineEditPathRef,
169
+ }: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
170
+ // Throws rather than returning empty: the carve's auto-group awaits this and
171
+ // then persists `sources: [groupId]` on success, so a quiet no-op leaves the
172
+ // carve aimed at a group that does not exist.
173
+ if (elements.length < 2) {
174
+ throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`);
175
+ }
176
+ if (!GROUP_ID_PATTERN.test(groupId)) {
177
+ throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`);
178
+ }
179
+
180
+ const priorGroups = captureAudioGroupState(previewIframe, elements, activeCompPath);
181
+ patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
182
+ const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId, groupLabel);
183
+ reseekPreviewRuntime(previewIframe);
184
+
185
+ const groupOperation: PatchOperation = {
186
+ type: "attribute",
187
+ property: HF_AUDIO_GROUP_ATTR,
188
+ value: groupId,
189
+ };
190
+ const originalByPath = new Map<string, string>();
191
+ const files: Record<string, string> = {};
192
+
193
+ try {
194
+ for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
195
+ let patchedContent = await readFileContent(projectId, targetPath);
196
+ originalByPath.set(targetPath, patchedContent);
197
+
198
+ for (const element of fileElements) {
199
+ const patchTarget = buildPatchTarget(element);
200
+ if (!patchTarget) {
201
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
202
+ }
203
+ if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
204
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
205
+ }
206
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
207
+ }
208
+
209
+ files[targetPath] = patchedContent;
210
+ pendingTimelineEditPathRef.current.add(targetPath);
211
+ }
212
+
213
+ const groupPath = activeCompPath || "index.html";
214
+ let groupContent = files[groupPath];
215
+ if (groupContent === undefined) {
216
+ groupContent = await readFileContent(projectId, groupPath);
217
+ originalByPath.set(groupPath, groupContent);
218
+ }
219
+ const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
220
+ if (withGroupElement !== groupContent) {
221
+ files[groupPath] = withGroupElement;
222
+ pendingTimelineEditPathRef.current.add(groupPath);
223
+ }
224
+
225
+ domEditSaveTimestampRef.current = Date.now();
226
+ const changedPaths = await saveProjectFilesWithHistory({
227
+ projectId,
228
+ label: groupLabel
229
+ ? `Group ${elements.length} clips as ${groupLabel}`
230
+ : `Group ${elements.length} voice clips`,
231
+ kind: "timeline",
232
+ files,
233
+ readFile: async (path) => {
234
+ const original = originalByPath.get(path);
235
+ if (original !== undefined) return original;
236
+ return readFileContent(projectId, path);
237
+ },
238
+ writeFile: writeProjectFile,
239
+ recordEdit,
240
+ });
241
+ domEditSaveTimestampRef.current = Date.now();
242
+ for (const element of elements) {
243
+ usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
244
+ }
245
+ return changedPaths;
246
+ } catch (error) {
247
+ // Mirrors setElementsHidden's failure path: the optimistic live patch
248
+ // already ran, so a save failure has to be unwound or the preview shows a
249
+ // grouping that never made it to disk.
250
+ patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath, priorGroups);
251
+ if (createdLiveGroupElement) {
252
+ previewIframe?.contentDocument?.getElementById(groupId)?.remove();
253
+ }
254
+ reseekPreviewRuntime(previewIframe);
255
+ throw error;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The write behind B6's auto-group: pick two or more voice clips in the carve
261
+ * picker and they land in a group instead of naming each other by id. Same
262
+ * expanded-rows resolution as element-visibility, for the same reason — a
263
+ * nested sub-composition child has no entry in the raw store list.
264
+ */
265
+ export function useAudioGroupCarveAssignment({
266
+ projectIdRef,
267
+ activeCompPath,
268
+ showToast,
269
+ writeProjectFile,
270
+ recordEdit,
271
+ domEditSaveTimestampRef,
272
+ previewIframeRef,
273
+ pendingTimelineEditPathRef,
274
+ isRecordingRef,
275
+ }: UseTimelineElementVisibilityEditingInput): (
276
+ clipIds: readonly string[],
277
+ groupId: string,
278
+ groupLabel?: string,
279
+ ) => Promise<void> {
280
+ const expandedElements = useExpandedTimelineElements();
281
+ return useCallback(
282
+ async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
283
+ if (isRecordingRef?.current) {
284
+ showToast("Cannot edit timeline while recording", "error");
285
+ return;
286
+ }
287
+ const pid = projectIdRef.current;
288
+ if (!pid) return;
289
+ // DOM ids, not store keys: both callers (the carve picker and the
290
+ // timeline's group-pointer button) name clips the way the document does,
291
+ // because that is the only space `resolveAudioGroups` reads back.
292
+ const wanted = new Set(clipIds);
293
+ const elements = expandedElements.filter((item) => {
294
+ const domId = runtimeAudioId(item);
295
+ return domId !== null && wanted.has(domId);
296
+ });
297
+ try {
298
+ // Loud, not silent: an unresolved id used to leave `elements` short,
299
+ // `createAudioGroupAndAssignMembers` returning early with no write, and
300
+ // the carve still persisting `sources: [groupId]` for a group that was
301
+ // never created — a carve pointing at nothing, silently not ducking.
302
+ if (elements.length !== wanted.size) {
303
+ const missing = [...wanted].filter(
304
+ (id) => !elements.some((item) => runtimeAudioId(item) === id),
305
+ );
306
+ throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`);
307
+ }
308
+ await createAudioGroupAndAssignMembers({
309
+ groupLabel,
310
+ projectId: pid,
311
+ activeCompPath,
312
+ elements,
313
+ groupId,
314
+ previewIframe: previewIframeRef.current,
315
+ writeProjectFile,
316
+ recordEdit,
317
+ domEditSaveTimestampRef,
318
+ pendingTimelineEditPathRef,
319
+ });
320
+ } catch (error) {
321
+ console.error("[Timeline] Failed to group voice clips", error);
322
+ const message = error instanceof Error ? error.message : "Failed to group voice clips";
323
+ showToast(message);
324
+ // Rethrown, not just reported: the carve's auto-group chains
325
+ // `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
326
+ // swallowing here let it persist a carve pointing at a group that was
327
+ // never written — the exact silent no-op the throw inside
328
+ // `createAudioGroupAndAssignMembers` exists to prevent.
329
+ throw error;
330
+ }
331
+ },
332
+ [
333
+ activeCompPath,
334
+ expandedElements,
335
+ previewIframeRef,
336
+ writeProjectFile,
337
+ recordEdit,
338
+ domEditSaveTimestampRef,
339
+ pendingTimelineEditPathRef,
340
+ isRecordingRef,
341
+ showToast,
342
+ projectIdRef,
343
+ ],
344
+ );
345
+ }
@@ -0,0 +1,204 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * A group write has to reach the STORE, not just the file and the live DOM.
5
+ *
6
+ * The timeline derives a group row's label, fader, mute and chain from the
7
+ * `audioGroup*` fields mirrored onto its members — so a write that lands
8
+ * everywhere except there leaves the header rendering whatever it parsed at
9
+ * load. Observed in the studio: muting a group wrote `data-hidden` to disk and
10
+ * to the preview, and the button stayed "Mute group Voiceover", re-writing the
11
+ * same attribute on every click with no way to unmute.
12
+ *
13
+ * Invalidating the parse cache is necessary but not sufficient — it only makes
14
+ * the NEXT parse honest, and a live attribute patch never triggers one.
15
+ */
16
+
17
+ import { afterEach, describe, expect, it } from "vitest";
18
+ import { usePlayerStore, type TimelineElement } from "../player";
19
+ import { resolveGroupSourceFile, useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
20
+
21
+ afterEach(() => {
22
+ usePlayerStore.getState().reset();
23
+ });
24
+
25
+ function member(domId: string, track: number): TimelineElement {
26
+ return {
27
+ id: domId,
28
+ key: `index.html#${domId}`,
29
+ domId,
30
+ tag: "audio",
31
+ start: 0,
32
+ duration: 5,
33
+ track,
34
+ audioGroup: "voiceover",
35
+ audioGroupHidden: false,
36
+ audioGroupVolume: 1,
37
+ };
38
+ }
39
+
40
+ /** The hook without React — it only closes over refs and callbacks. */
41
+ function makeSetter() {
42
+ const input = {
43
+ projectIdRef: { current: "project-1" },
44
+ activeCompPath: "index.html",
45
+ showToast: () => {},
46
+ writeProjectFile: async () => {},
47
+ recordEdit: async () => {},
48
+ domEditSaveTimestampRef: { current: 0 },
49
+ pendingTimelineEditPathRef: { current: new Set<string>() },
50
+ previewIframeRef: { current: null },
51
+ };
52
+ // `setLive` takes no async path and touches only the preview DOM + store, so
53
+ // it can be exercised directly; `setQuiet` additionally persists, which this
54
+ // test deliberately does not cover (that is timelineTrackVisibility's job).
55
+ let setter: ReturnType<typeof useSetAudioGroupAttribute> | null = null;
56
+ const Probe = () => {
57
+ setter = useSetAudioGroupAttribute(input as never);
58
+ return null;
59
+ };
60
+ // Minimal hook harness: call the component function directly. It uses only
61
+ // useCallback, which React allows outside a renderer when the result is used
62
+ // immediately and never re-rendered.
63
+ return { Probe, get: () => setter };
64
+ }
65
+
66
+ describe("group attribute writes reach the store", () => {
67
+ it("mirrors data-hidden onto every member so the header can flip", async () => {
68
+ const react = await import("react");
69
+ const { renderToStaticMarkup } = await import("react-dom/server");
70
+ const harness = makeSetter();
71
+ renderToStaticMarkup(react.createElement(harness.Probe));
72
+ const setter = harness.get();
73
+ expect(setter).not.toBeNull();
74
+
75
+ usePlayerStore.getState().setElements([member("voice-1", 0), member("voice-2", 1)]);
76
+
77
+ setter?.setLive("voiceover", "data-hidden", "");
78
+ expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === true)).toBe(
79
+ true,
80
+ );
81
+
82
+ setter?.setLive("voiceover", "data-hidden", null);
83
+ expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === false)).toBe(
84
+ true,
85
+ );
86
+ });
87
+
88
+ // `Number(null)` and `Number("")` are both 0 AND finite, so the obvious
89
+ // isFinite check mirrored "silent" for a removed attribute while core's
90
+ // `readAudioGroupVolume` reads the same absence as unity — a parse divergence
91
+ // inside the mirror whose entire job is to prevent one.
92
+ it("reads a removed data-volume as unity, the way core does", async () => {
93
+ const react = await import("react");
94
+ const { renderToStaticMarkup } = await import("react-dom/server");
95
+ const harness = makeSetter();
96
+ renderToStaticMarkup(react.createElement(harness.Probe));
97
+ const setter = harness.get();
98
+
99
+ usePlayerStore.getState().setElements([member("voice-1", 0)]);
100
+
101
+ setter?.setLive("voiceover", "data-volume", "0.3");
102
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBeCloseTo(0.3, 6);
103
+
104
+ setter?.setLive("voiceover", "data-volume", null);
105
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
106
+
107
+ setter?.setLive("voiceover", "data-volume", "");
108
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
109
+
110
+ setter?.setLive("voiceover", "data-volume", "nonsense");
111
+ expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
112
+ });
113
+
114
+ it("mirrors data-volume, and leaves other groups alone", async () => {
115
+ const react = await import("react");
116
+ const { renderToStaticMarkup } = await import("react-dom/server");
117
+ const harness = makeSetter();
118
+ renderToStaticMarkup(react.createElement(harness.Probe));
119
+ const setter = harness.get();
120
+
121
+ const other: TimelineElement = { ...member("sfx", 2), audioGroup: "effects" };
122
+ usePlayerStore.getState().setElements([member("voice-1", 0), other]);
123
+
124
+ setter?.setLive("voiceover", "data-volume", "0.4");
125
+
126
+ const byId = new Map(usePlayerStore.getState().elements.map((el) => [el.id, el]));
127
+ expect(byId.get("voice-1")?.audioGroupVolume).toBeCloseTo(0.4, 6);
128
+ expect(byId.get("sfx")?.audioGroupVolume).toBe(1);
129
+ });
130
+ });
131
+
132
+ /**
133
+ * The ancestor shape here is COPIED FROM A LIVE PREVIEW, not imagined.
134
+ *
135
+ * The first attempt at this fix used `getTimelineElementSourceFile` and a
136
+ * fixture in which the sub-composition root carried `data-composition-file`.
137
+ * The test passed; the studio still threw "Unable to patch element in
138
+ * index.html", because the runtime inlines a sub-comp as its own root element
139
+ * that carries only the composition ID — the FILE is on the host above it.
140
+ */
141
+ describe("the mirror reaches sub-composition members", () => {
142
+ /**
143
+ * A group declared inside a sub-composition has no FLAT member to mirror onto
144
+ * — `childGroupState` keeps those members out of `elements` — so mirroring
145
+ * only `elements` made this whole function a no-op for it: the header kept the
146
+ * pre-write chain and `laneCount` stayed 0, so the lane disclosure never
147
+ * appeared for automation that now existed.
148
+ */
149
+ it("mirrors a group write onto DomClipChild members as well as flat ones", async () => {
150
+ const react = await import("react");
151
+ const { renderToStaticMarkup } = await import("react-dom/server");
152
+ const harness = makeSetter();
153
+ renderToStaticMarkup(react.createElement(harness.Probe));
154
+ const setter = harness.get();
155
+ usePlayerStore.getState().setElements([member("flat-1", 0)]);
156
+ usePlayerStore.getState().setDomClipChildren([
157
+ { id: "sub-1", parentId: "host", hostId: "host", label: "Sub 1", audioGroup: "voiceover" },
158
+ { id: "other", parentId: "host", hostId: "host", label: "Other", audioGroup: "sfx" },
159
+ ]);
160
+
161
+ setter?.setLive("voiceover", "data-label", "Voices");
162
+
163
+ const children = usePlayerStore.getState().domClipChildren;
164
+ expect(children.find((c) => c.id === "sub-1")?.audioGroupLabel).toBe("Voices");
165
+ // A member of another group is untouched.
166
+ expect(children.find((c) => c.id === "other")?.audioGroupLabel).toBeUndefined();
167
+ });
168
+ });
169
+
170
+ describe("resolveGroupSourceFile", () => {
171
+ function livePreviewShape(): Document {
172
+ const doc = document.implementation.createHTMLDocument("preview");
173
+ doc.body.setAttribute("data-composition-id", "subcomp-group-qa");
174
+ doc.body.innerHTML = `
175
+ <div id="voices-host" data-composition-id="voices-host" data-composition-file="compositions/voices.html">
176
+ <section id="voices-root" data-composition-id="voices">
177
+ <hf-audio-group id="voiceover"></hf-audio-group>
178
+ </section>
179
+ </div>
180
+ <hf-audio-group id="root-group"></hf-audio-group>
181
+ `;
182
+ return doc;
183
+ }
184
+
185
+ it("climbs past the sub-comp root to the host that names the file", () => {
186
+ const doc = livePreviewShape();
187
+ expect(resolveGroupSourceFile(doc.getElementById("voiceover"))).toBe(
188
+ "compositions/voices.html",
189
+ );
190
+ });
191
+
192
+ // A group in the root composition: body names an id but no file, so the
193
+ // caller falls back to activeCompPath. Returning body's id here would route
194
+ // every root-composition group write at a path that does not exist.
195
+ it("returns undefined for a group in the root composition", () => {
196
+ const doc = livePreviewShape();
197
+ expect(resolveGroupSourceFile(doc.getElementById("root-group"))).toBeUndefined();
198
+ });
199
+
200
+ it("is safe on a detached or missing element", () => {
201
+ expect(resolveGroupSourceFile(null)).toBeUndefined();
202
+ expect(resolveGroupSourceFile(document.createElement("hf-audio-group"))).toBeUndefined();
203
+ });
204
+ });