@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
@@ -12,7 +12,7 @@ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core
12
12
  import { TimelineAutomationLane } from "./TimelineAutomationLane";
13
13
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
14
  import { getTimelineLaneTop } from "./timelineLayout";
15
- import { groupAutomationLanes } from "./automationLaneData";
15
+ import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
16
16
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
17
17
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
18
18
  import type { TimelineElement } from "../store/playerStore";
@@ -100,7 +100,21 @@ function ClipAutomationLanes({
100
100
  onCommit={bound.onCommit}
101
101
  onSelect={bound.onSelect}
102
102
  snapTimes={snapTimes}
103
- readOnly={bound.readOnly}
103
+ // The carve owns its own envelopes and rewrites them on every
104
+ // re-run, so a drag would be silently discarded — shown, but not
105
+ // editable. Per LANE, not per binding: a carved bed can carry the
106
+ // author's own volume curve beside the carve's bands.
107
+ readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}
108
+ // Two distinct reasons, so two notes. The carve one names the way
109
+ // out — change strength, or switch the carve off and own the chain
110
+ // — because "not editable" without that reads as broken.
111
+ readOnlyNote={
112
+ isCarveLane(lane.target, bound.chain)
113
+ ? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
114
+ : bound.readOnly
115
+ ? "Read-only here."
116
+ : undefined
117
+ }
104
118
  rangeSelection={
105
119
  bound.selection?.target === lane.target
106
120
  ? {
@@ -128,6 +142,10 @@ export interface TimelineAutomationLaneSlotProps {
128
142
  pps: number;
129
143
  /** Keyframe lanes already stacked above, which automation sits under. */
130
144
  laneCount: number;
145
+ /** Exact y for the first lane, overriding `laneCount`. A group's lanes sit
146
+ * directly under its header row rather than under a stack of keyframe
147
+ * lanes, so it cannot be said in `laneCount`. */
148
+ topOffset?: number;
131
149
  accentColor: string;
132
150
  /** Composition-time playhead; the slot converts it to clip-local. */
133
151
  currentTime: number;
@@ -151,6 +169,7 @@ export function TimelineAutomationLaneSlot({
151
169
  lanes,
152
170
  pps,
153
171
  laneCount,
172
+ topOffset,
154
173
  accentColor,
155
174
  currentTime,
156
175
  beatTimes,
@@ -165,7 +184,7 @@ export function TimelineAutomationLaneSlot({
165
184
  else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
166
185
  }
167
186
  });
168
- const top = getTimelineLaneTop(laneCount);
187
+ const top = topOffset ?? getTimelineLaneTop(laneCount);
169
188
  return (
170
189
  <>
171
190
  {clips.map((element) => (
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { act } from "react";
2
+ import React, { act } from "react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
@@ -68,14 +68,191 @@ describe("TimelineFxButton", () => {
68
68
  expect(document.querySelector('[role="dialog"]')).toBeTruthy();
69
69
  });
70
70
 
71
- it("group-pointer variant offers Group instead of a popover", () => {
71
+ // A muted target auditions anyway the mute is lifted on the running graph
72
+ // for the hover and put back on the way out. The read has to happen on the
73
+ // way IN: the live unmute flows back into this component's props, so a
74
+ // restore that re-read `isMuted` would find it false and never re-mute.
75
+ it("borrows a muted target's mute for the audition and returns it", () => {
76
+ const onSetMutedLive = vi.fn();
77
+ const host = mount(
78
+ <TimelineFxButton
79
+ variant="chain"
80
+ fxChainRaw={undefined}
81
+ isMuted
82
+ onSetMutedLive={onSetMutedLive}
83
+ onChainChange={vi.fn()}
84
+ onChainPreview={vi.fn()}
85
+ onOpenRack={vi.fn()}
86
+ />,
87
+ );
88
+ act(() => byTextButton(host, "FX")?.click());
89
+ const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
90
+ act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
91
+ expect(onSetMutedLive).toHaveBeenLastCalledWith(false);
92
+ const shelf = document.querySelector(".hf-fx-preset-menu");
93
+ act(() => shelf?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })));
94
+ expect(onSetMutedLive).toHaveBeenLastCalledWith(true);
95
+ });
96
+
97
+ it("leaves an unmuted target's mute alone", () => {
98
+ const onSetMutedLive = vi.fn();
99
+ const host = mount(
100
+ <TimelineFxButton
101
+ variant="chain"
102
+ fxChainRaw={undefined}
103
+ onSetMutedLive={onSetMutedLive}
104
+ onChainChange={vi.fn()}
105
+ onChainPreview={vi.fn()}
106
+ onOpenRack={vi.fn()}
107
+ />,
108
+ );
109
+ act(() => byTextButton(host, "FX")?.click());
110
+ const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
111
+ act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
112
+ expect(onSetMutedLive).not.toHaveBeenCalled();
113
+ });
114
+
115
+ // Hovering writes the preset through the preview channel, and the chain prop
116
+ // is read back from that same live attribute. Applying while a DIFFERENT
117
+ // preset is being auditioned used to save both — heard as the effect running
118
+ // twice — so the apply has to land on the stored chain.
119
+ it("applies onto the stored chain, not the one being auditioned", () => {
120
+ const onChainChange = vi.fn();
121
+ // The write-back the real timeline does: a preview patches the live
122
+ // attribute, and the row re-reads it into `fxChainRaw`. Without this the
123
+ // prop never moves and the bug cannot show.
124
+ function Harness() {
125
+ const [raw, setRaw] = React.useState<string | undefined>(undefined);
126
+ return (
127
+ <TimelineFxButton
128
+ variant="chain"
129
+ fxChainRaw={raw}
130
+ onChainChange={onChainChange}
131
+ onChainPreview={(next) => setRaw(serializeAudioFxChain(next))}
132
+ onOpenRack={vi.fn()}
133
+ />
134
+ );
135
+ }
136
+ const host = mount(<Harness />);
137
+ act(() => byTextButton(host, "FX")?.click());
138
+ const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".hf-fx-preset-item"));
139
+ const [hovered, clicked] = [items[0], items[1]];
140
+ act(() => hovered?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
141
+ act(() => clicked?.click());
142
+ const saved = onChainChange.mock.calls.at(-1)?.[0];
143
+ const presets = new Set(saved?.nodes.map((n: { fromPreset?: string }) => n.fromPreset));
144
+ expect(presets.size).toBe(1);
145
+ });
146
+
147
+ // The design doc calls the sentence this dialog carries "the highest-leverage
148
+ // copy in this plan": it is the concept of a submix bus delivered without the
149
+ // word, to an author who has never met one. The old pointer auto-named the
150
+ // group on one click and never mentioned the shared volume.
151
+ it("group-pointer variant names the group and explains what one is", () => {
72
152
  const onGroupClips = vi.fn();
73
- const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
153
+ const host = mount(
154
+ <TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
155
+ );
74
156
  act(() => byTextButton(host, "FX")?.click());
75
- const groupButton = document.body.querySelectorAll("button");
76
- const group = Array.from(groupButton).find((b) => b.textContent === "Group");
77
- expect(group).toBeDefined();
157
+ const dialog = document.querySelector('[role="dialog"]');
158
+ expect(dialog?.textContent).toContain(
159
+ "Effects you add to the group apply to both clips at once, and they share one volume.",
160
+ );
161
+ const group = Array.from(document.body.querySelectorAll("button")).find(
162
+ (b) => b.textContent === "Group",
163
+ );
164
+ act(() => group?.click());
165
+ expect(onGroupClips).toHaveBeenCalledWith("Voiceover");
166
+ });
167
+
168
+ // Three or more must not read "both".
169
+ it("counts the clips in the explanation", () => {
170
+ mount(<TimelineFxButton variant="group-pointer" clipCount={3} onGroupClips={vi.fn()} />);
171
+ act(() => byTextButton(document.body as HTMLElement, "FX")?.click());
172
+ expect(document.querySelector('[role="dialog"]')?.textContent).toContain("all 3 clips at once");
173
+ });
174
+
175
+ // §1.4 keeps groups audio-only in v1, and §5 is explicit that a deliberate
176
+ // limit must be stated: "silent ones just send authors hunting for something
177
+ // that was never built."
178
+ it("states the video limit instead of offering a name field", () => {
179
+ const onGroupClips = vi.fn();
180
+ const host = mount(
181
+ <TimelineFxButton
182
+ variant="group-pointer"
183
+ clipCount={2}
184
+ refusal="Video audio can't be grouped yet — only audio clips can join a group."
185
+ onGroupClips={onGroupClips}
186
+ />,
187
+ );
188
+ act(() => byTextButton(host, "FX")?.click());
189
+ const dialog = document.querySelector('[role="dialog"]');
190
+ expect(dialog?.textContent).toContain("Video audio can't be grouped yet");
191
+ expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
192
+ expect(
193
+ Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
194
+ ).toBe(false);
195
+ });
196
+
197
+ // #3421: the dialog positioned itself at `anchorRect.bottom + 4` with no flip
198
+ // and no clamp, and this button lives in a track header at the bottom of the
199
+ // studio window — so it opened past the viewport edge and was reported as
200
+ // "the grouping button did nothing". Numbers derive from GROUP_DIALOG_SIZE
201
+ // (256x160) against happy-dom's 1024x768 window and a 12px margin.
202
+ it("flips the group dialog above the anchor when there is no room below", () => {
203
+ const host = mount(
204
+ <TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
205
+ );
206
+ const fx = byTextButton(host, "FX");
207
+ fx!.getBoundingClientRect = () =>
208
+ ({ left: 300, top: 760, right: 320, bottom: 776, width: 20, height: 16 }) as DOMRect;
209
+ act(() => fx?.click());
210
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
211
+ expect(dialog).toBeTruthy();
212
+ const top = Number.parseFloat(dialog.style.top);
213
+ // Fully inside the viewport on both edges, which the unclamped form was not.
214
+ expect(top).toBeGreaterThanOrEqual(12);
215
+ expect(top + 160).toBeLessThanOrEqual(768 - 12);
216
+ });
217
+
218
+ it("keeps the group dialog inside the right edge of the window", () => {
219
+ const host = mount(
220
+ <TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
221
+ );
222
+ const fx = byTextButton(host, "FX");
223
+ fx!.getBoundingClientRect = () =>
224
+ ({ left: 1010, top: 100, right: 1024, bottom: 116, width: 14, height: 16 }) as DOMRect;
225
+ act(() => fx?.click());
226
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
227
+ const left = Number.parseFloat(dialog.style.left);
228
+ expect(left).toBeGreaterThanOrEqual(12);
229
+ expect(left + 256).toBeLessThanOrEqual(1024 - 12);
230
+ });
231
+
232
+ it("carries the typed name into the group it creates", () => {
233
+ const onGroupClips = vi.fn();
234
+ const host = mount(
235
+ <TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
236
+ );
237
+ act(() => byTextButton(host, "FX")?.click());
238
+ const input = document.querySelector<HTMLInputElement>('input[aria-label="Group name"]');
239
+ expect(input).not.toBeNull();
240
+ // React tracks the input's value on the node, so assigning `.value`
241
+ // directly is swallowed — the native setter is what makes onChange fire.
242
+ const setValue = Object.getOwnPropertyDescriptor(
243
+ window.HTMLInputElement.prototype,
244
+ "value",
245
+ )?.set;
246
+ act(() => {
247
+ if (input && setValue) {
248
+ setValue.call(input, "SFX");
249
+ input.dispatchEvent(new Event("input", { bubbles: true }));
250
+ }
251
+ });
252
+ const group = Array.from(document.body.querySelectorAll("button")).find(
253
+ (b) => b.textContent === "Group",
254
+ );
78
255
  act(() => group?.click());
79
- expect(onGroupClips).toHaveBeenCalledTimes(1);
256
+ expect(onGroupClips).toHaveBeenCalledWith("SFX");
80
257
  });
81
258
  });
@@ -9,7 +9,7 @@
9
9
  * it gets the grouping pointer instead of the popover).
10
10
  */
11
11
 
12
- import { useRef, useState } from "react";
12
+ import { useEffect, useRef, useState } from "react";
13
13
  import { createPortal } from "react-dom";
14
14
  import {
15
15
  enabledAudioFxNodes,
@@ -18,6 +18,129 @@ import {
18
18
  } from "@hyperframes/core/audio-fx";
19
19
  import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
20
20
  import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
21
+ import { resolveFloatingPanelPosition } from "../../components/editor/floatingPanel.js";
22
+ import {
23
+ useAuditionTransport,
24
+ type AuditionSpan,
25
+ } from "../../components/editor/useAuditionTransport.js";
26
+
27
+ /**
28
+ * Naming a group is the moment the whole feature is explained.
29
+ *
30
+ * The design doc calls the sentence below "the highest-leverage copy in this
31
+ * plan and it should be written before the routing is" — it is the concept of a
32
+ * submix bus delivered without the word, to an author who has never met one.
33
+ * The old pointer skipped both the name and the sentence: it made an
34
+ * auto-named group on one click and said only "Group these clips to add effects
35
+ * to all of them", which leaves out the shared volume entirely.
36
+ */
37
+ // Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-64` is exact, and only
38
+ // the flip decision uses the height — the clamp keeps the dialog on screen
39
+ // either way.
40
+ const GROUP_DIALOG_SIZE = { width: 256, height: 160 };
41
+
42
+ /** Where the grouping dialog goes: flipped above the anchor when there is no
43
+ * room below, and clamped so neither edge leaves the viewport. This button
44
+ * lives in a track header at the BOTTOM of the studio window, so an unclamped
45
+ * `anchorRect.bottom + 4` opened it past the viewport edge and read as "the
46
+ * grouping button did nothing". */
47
+ function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number } {
48
+ const { left, top } = resolveFloatingPanelPosition(
49
+ anchorRect,
50
+ { width: window.innerWidth, height: window.innerHeight },
51
+ GROUP_DIALOG_SIZE,
52
+ { offset: 4 },
53
+ );
54
+ return { left, top };
55
+ }
56
+
57
+ function GroupNameDialog({
58
+ anchorRect,
59
+ clipCount,
60
+ defaultLabel,
61
+ refusal,
62
+ onCancel,
63
+ onConfirm,
64
+ }: {
65
+ anchorRect: DOMRect;
66
+ clipCount: number;
67
+ defaultLabel?: string;
68
+ refusal?: string;
69
+ onCancel: () => void;
70
+ onConfirm: (label: string) => void;
71
+ }) {
72
+ const [label, setLabel] = useState(defaultLabel ?? "Voiceover");
73
+ const inputRef = useRef<HTMLInputElement | null>(null);
74
+ // Focused on open so the name can be typed over without a second click —
75
+ // the field is the only thing here that wants input.
76
+ useEffect(() => inputRef.current?.select(), []);
77
+ if (refusal) {
78
+ return (
79
+ <div
80
+ role="dialog"
81
+ aria-label="This track cannot be grouped"
82
+ className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] leading-snug text-white/75 shadow-xl"
83
+ style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
84
+ onPointerDown={(event) => event.stopPropagation()}
85
+ onKeyDown={(event) => {
86
+ if (event.key !== "Escape") return;
87
+ event.stopPropagation();
88
+ onCancel();
89
+ }}
90
+ >
91
+ <p>{refusal}</p>
92
+ </div>
93
+ );
94
+ }
95
+ const confirm = () => onConfirm(label.trim() || defaultLabel || "Voiceover");
96
+ return (
97
+ <div
98
+ role="dialog"
99
+ aria-label="Name this group"
100
+ className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] text-white/75 shadow-xl"
101
+ style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
102
+ onPointerDown={(event) => event.stopPropagation()}
103
+ onKeyDown={(event) => {
104
+ if (event.key === "Escape") {
105
+ event.stopPropagation();
106
+ onCancel();
107
+ }
108
+ if (event.key === "Enter") confirm();
109
+ }}
110
+ >
111
+ <p className="mb-1.5 font-medium text-white">Name this group</p>
112
+ <input
113
+ ref={inputRef}
114
+ type="text"
115
+ aria-label="Group name"
116
+ value={label}
117
+ onChange={(event) => setLabel(event.currentTarget.value)}
118
+ className="w-full rounded border border-white/20 bg-black/30 px-1.5 py-1 text-[11px] text-white outline-none focus:border-[#3CE6AC]"
119
+ />
120
+ {/* The sentence. No jargon, and it names both things a bus does. */}
121
+ <p className="mt-2 leading-snug">
122
+ Effects you add to the group apply to {clipCount === 2 ? "both" : `all ${clipCount}`} clips
123
+ at once, and they share one volume.
124
+ </p>
125
+ <div className="mt-2.5 flex justify-end gap-1.5">
126
+ <button
127
+ type="button"
128
+ className="rounded border border-white/20 px-2 py-1 text-[10px] text-white/75 hover:bg-white/10"
129
+ onClick={onCancel}
130
+ >
131
+ Cancel
132
+ </button>
133
+ <button
134
+ type="button"
135
+ className="rounded border border-[#3CE6AC] bg-[#3CE6AC]/15 px-2 py-1 text-[10px] font-semibold text-[#3CE6AC] hover:bg-[#3CE6AC]/25"
136
+ onClick={confirm}
137
+ >
138
+ Group
139
+ </button>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
21
144
 
22
145
  function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
23
146
  if (!raw) return { version: 1, nodes: [] };
@@ -35,12 +158,31 @@ interface TimelineFxButtonChainProps {
35
158
  fxChainRaw: string | undefined;
36
159
  onChainChange: (next: HfAudioFxChain) => void;
37
160
  onChainPreview?: (next: HfAudioFxChain) => void;
38
- onAuditionTransport?: (on: boolean) => void;
161
+ /** The clips this chain is heard through, so an audition starts where they
162
+ * actually sound rather than from a playhead parked before the first. */
163
+ auditionSpans?: readonly AuditionSpan[];
164
+ /** Whether this target is muted right now. */
165
+ isMuted?: boolean;
166
+ /** Set this target's mute on the running graph WITHOUT touching the document,
167
+ * so an audition can lift a mute and put it back. Hovering a preset on a
168
+ * muted bus is a question about the preset, not about the mute. */
169
+ onSetMutedLive?: (muted: boolean) => void;
39
170
  }
40
171
 
41
172
  interface TimelineFxButtonGroupPointerProps {
42
173
  variant: "group-pointer";
43
- onGroupClips: () => void;
174
+ /** Create the group under this name. */
175
+ onGroupClips: (label: string) => void;
176
+ /** How many clips are about to be grouped, for the copy that explains it. */
177
+ clipCount: number;
178
+ /** Seeded into the name field — "Voiceover" per the design's own mockup. */
179
+ defaultLabel?: string;
180
+ /** Why this track cannot be grouped at all. Present, the dialog states the
181
+ * limit instead of offering a name field — groups are audio-only in v1
182
+ * (§1.4), and the doc is explicit that a deliberate limit has to be said:
183
+ * "silent ones just send authors hunting for something that was never
184
+ * built." */
185
+ refusal?: string;
44
186
  }
45
187
 
46
188
  type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
@@ -49,6 +191,12 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
49
191
  const [open, setOpen] = useState(false);
50
192
  const buttonRef = useRef<HTMLButtonElement | null>(null);
51
193
  const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
194
+ // Owned here rather than threaded from each caller: both timeline call sites
195
+ // want the same thing, and neither passed one, so hovering a preset in this
196
+ // popover was silent unless the transport already happened to be running.
197
+ const transport = useAuditionTransport();
198
+ /** Whether THIS audition lifted a mute, so only it puts one back. */
199
+ const borrowedMute = useRef(false);
52
200
 
53
201
  const openAt = () => {
54
202
  setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
@@ -76,25 +224,17 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
76
224
  {open &&
77
225
  anchorRect &&
78
226
  createPortal(
79
- <div
80
- role="dialog"
81
- aria-label="Group these clips to add effects"
82
- className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
83
- style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
84
- onPointerDown={(event) => event.stopPropagation()}
85
- >
86
- <p>Group these clips to add effects to all of them.</p>
87
- <button
88
- type="button"
89
- className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
90
- onClick={() => {
91
- setOpen(false);
92
- props.onGroupClips();
93
- }}
94
- >
95
- Group
96
- </button>
97
- </div>,
227
+ <GroupNameDialog
228
+ refusal={props.refusal}
229
+ anchorRect={anchorRect}
230
+ clipCount={props.clipCount}
231
+ defaultLabel={props.defaultLabel}
232
+ onCancel={() => setOpen(false)}
233
+ onConfirm={(label) => {
234
+ setOpen(false);
235
+ props.onGroupClips(label);
236
+ }}
237
+ />,
98
238
  document.body,
99
239
  )}
100
240
  </div>
@@ -133,7 +273,15 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
133
273
  onClose={() => setOpen(false)}
134
274
  onChainChange={props.onChainChange}
135
275
  onChainPreview={props.onChainPreview}
136
- onAuditionTransport={props.onAuditionTransport}
276
+ onAuditionTransport={(on) => {
277
+ // Read on the way IN and remembered: the live unmute flows back
278
+ // into the row's props, so by the time the audition ends the
279
+ // target no longer looks muted and the mute would never return.
280
+ if (on) borrowedMute.current = props.isMuted === true;
281
+ if (borrowedMute.current) props.onSetMutedLive?.(!on);
282
+ if (!on) borrowedMute.current = false;
283
+ transport(on, props.auditionSpans);
284
+ }}
137
285
  onOpenRack={props.onOpenRack}
138
286
  />,
139
287
  document.body,
@@ -23,11 +23,6 @@ function renderHeader(
23
23
  laneCount={0}
24
24
  isLaneOpen={false}
25
25
  onToggleLanes={vi.fn()}
26
- hidden={false}
27
- onToggleHidden={vi.fn()}
28
- isSoloed={false}
29
- isHalfLitSolo={false}
30
- onToggleSolo={vi.fn()}
31
26
  onFxChainChange={vi.fn()}
32
27
  onOpenFxRack={vi.fn()}
33
28
  columnWidth={220}