@hyperframes/studio 0.8.11 → 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-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-1vMlv05L.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 +6431 -5515
  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 +167 -21
  90. package/src/player/components/TimelineFxButton.tsx +156 -31
  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-DoW5v5eS.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
@@ -164,43 +164,9 @@ export function FxCarveModule({
164
164
  onCarveChange(carve: HfCarveSettings): void;
165
165
  onCarvePreview(carve: HfCarveSettings): void;
166
166
  }) {
167
- const bands = nodes.filter((n) => n.type === "peaking").length;
168
- const hasLevel = nodes.some((n) => n.type === "gain");
169
167
  const on = carve.enabled;
170
- /**
171
- * The only track this bed could be listening to, when there is exactly one.
172
- *
173
- * A picker with one entry is a question with one answer: it asks the author to
174
- * confirm something already decided. So the voice reads out instead.
175
- *
176
- * Not when the stored source is some OTHER track, though — a name that no longer
177
- * classifies as a voice, or a track since renamed. Reading out the one remaining
178
- * candidate there would quietly claim the carve listens to something it does not,
179
- * so the picker comes back and shows the mismatch.
180
- */
181
- const soleVoice =
182
- sourceOptions.length === 1 &&
183
- (carve.sources.length === 0 ||
184
- (carve.sources.length === 1 && carve.sources[0] === sourceOptions[0]?.id))
185
- ? sourceOptions[0]
186
- : null;
187
- // What the module is worth right now, in the head, so a collapsed card still
188
- // says whether it is doing anything: the analysis it produced, or why not.
189
- const summary = !on
190
- ? "off"
191
- : analysing
192
- ? "analysing…"
193
- : bands > 0
194
- ? [
195
- `${bands} band${bands === 1 ? "" : "s"}`,
196
- ...(hasLevel ? ["level"] : []),
197
- // Worth saying when it is more than one: the cuts follow whoever is
198
- // speaking, and that is not obvious from a band count.
199
- ...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
200
- ].join(" + ")
201
- : carve.sources.length > 0
202
- ? "no analysis yet"
203
- : "pick a voice";
168
+ const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
169
+ const summary = carveSummary({ nodes, carve, analysing });
204
170
  // The carve's own colour, used three ways: the module's left edge, the title,
205
171
  // and the wash behind it. A preset gets a title treatment because it is a
206
172
  // character; the carve gets one because it is the only module in the rack
@@ -230,6 +196,8 @@ export function FxCarveModule({
230
196
  // sets `tracking-normal`, and two Tailwind tracking utilities on one
231
197
  // element resolve by stylesheet order, not by the order written.
232
198
  style={{ color: tint, letterSpacing: "0.16em" }}
199
+ // Truncates in a narrow panel like every other name in the rack.
200
+ title="Voiceover carve"
233
201
  aria-expanded={open}
234
202
  onClick={onToggleOpen}
235
203
  >
@@ -255,50 +223,13 @@ export function FxCarveModule({
255
223
  {open && on ? (
256
224
  <div className="hf-fx-carve-body border-t border-panel-border-input">
257
225
  <div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
258
- <div className="hf-fx-row flex min-h-6 items-center gap-2">
259
- <span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
260
- Listen to
261
- </span>
262
- {soleVoice ? (
263
- <span
264
- className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
265
- data-carve-source={soleVoice.id}
266
- >
267
- {soleVoice.label}
268
- </span>
269
- ) : (
270
- /* Every voice, not one of them. A bed usually runs under a whole
271
- sequence — a narrator, an answer, a second presenter — and they are
272
- analysed together, so the cuts follow whoever is speaking. Which
273
- makes this a set of things to include, not a choice between them. */
274
- <div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
275
- {sourceOptions.map((o) => (
276
- <label
277
- key={o.id}
278
- className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
279
- title={`Make room for ${o.label}`}
280
- >
281
- <input
282
- type="checkbox"
283
- className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
284
- data-carve-source={o.id}
285
- checked={carve.sources.includes(o.id)}
286
- disabled={disabled}
287
- onChange={(e) =>
288
- onCarveChange({
289
- ...carve,
290
- sources: e.target.checked
291
- ? [...carve.sources, o.id]
292
- : carve.sources.filter((id) => id !== o.id),
293
- })
294
- }
295
- />
296
- <span className="truncate">{o.label}</span>
297
- </label>
298
- ))}
299
- </div>
300
- )}
301
- </div>
226
+ <CarveSourceRow
227
+ carve={carve}
228
+ sourceOptions={sourceOptions}
229
+ soleVoice={soleVoice}
230
+ disabled={disabled}
231
+ onCarveChange={onCarveChange}
232
+ />
302
233
  {/* One knob for the whole effect. Depth, band count, width, the
303
234
  intelligibility weighting and both level-match numbers move together
304
235
  anyway — a gentle carve is shallow in few bands with little ducking, a
@@ -330,53 +261,200 @@ export function FxCarveModule({
330
261
  change re-derives all of them — so leaving them up reads as the
331
262
  settings that are in force when they are already history, and the one
332
263
  honest thing to say is that the work is happening. */}
333
- {analysing ? (
334
- <p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
335
- <svg
336
- className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
337
- viewBox="0 0 24 24"
338
- fill="none"
339
- aria-hidden="true"
340
- >
341
- <circle
342
- className="opacity-25"
343
- cx="12"
344
- cy="12"
345
- r="10"
346
- stroke="currentColor"
347
- strokeWidth="4"
348
- />
349
- <path
350
- className="opacity-75"
351
- fill="currentColor"
352
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
353
- />
354
- </svg>
355
- Analysing…
356
- </p>
357
- ) : nodes.length > 0 ? (
358
- <div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
359
- <div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
360
- analysed
361
- </div>
362
- {nodes.map((node, i) => (
363
- <FxCarveMember
364
- key={node.id ?? `${node.type}-${i}`}
365
- node={node}
366
- automatedTargets={automatedTargets}
367
- liveAutomationValues={liveAutomationValues}
368
- />
369
- ))}
370
- </div>
371
- ) : (
372
- <p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
373
- {carve.sources.length > 0
374
- ? "Nothing analysed yet."
375
- : "Pick the voices this bed should make room for."}
376
- </p>
377
- )}
264
+ <CarveAnalysis
265
+ nodes={nodes}
266
+ analysing={analysing}
267
+ hasSources={carve.sources.length > 0}
268
+ automatedTargets={automatedTargets}
269
+ liveAutomationValues={liveAutomationValues}
270
+ />
378
271
  </div>
379
272
  ) : null}
380
273
  </div>
381
274
  );
382
275
  }
276
+
277
+ /**
278
+ * The only track this bed could be listening to, when there is exactly one.
279
+ *
280
+ * A picker with one entry is a question with one answer: it asks the author to
281
+ * confirm something already decided. So the voice reads out instead.
282
+ *
283
+ * Not when the stored source is some OTHER track, though — a name that no longer
284
+ * classifies as a voice, or a track since renamed. Reading out the one remaining
285
+ * candidate there would quietly claim the carve listens to something it does not,
286
+ * so the picker comes back and shows the mismatch.
287
+ */
288
+ function soleCarveVoice(
289
+ sourceOptions: AudioTrackOption[],
290
+ sources: readonly string[],
291
+ ): AudioTrackOption | null {
292
+ if (sourceOptions.length !== 1) return null;
293
+ const only = sourceOptions[0];
294
+ if (!only) return null;
295
+ if (sources.length === 0) return only;
296
+ return sources.length === 1 && sources[0] === only.id ? only : null;
297
+ }
298
+
299
+ /**
300
+ * What the module is worth right now, for its head, so a collapsed card still
301
+ * says whether it is doing anything: the analysis it produced, or why not.
302
+ */
303
+ function carveSummary(input: {
304
+ nodes: HfAudioFxNode[];
305
+ carve: HfCarveSettings;
306
+ analysing?: boolean;
307
+ }): string {
308
+ const { nodes, carve, analysing } = input;
309
+ if (!carve.enabled) return "off";
310
+ if (analysing) return "analysing…";
311
+ const bands = nodes.filter((n) => n.type === "peaking").length;
312
+ if (bands === 0) return carve.sources.length > 0 ? "no analysis yet" : "pick a voice";
313
+ return [
314
+ `${bands} band${bands === 1 ? "" : "s"}`,
315
+ ...(nodes.some((n) => n.type === "gain") ? ["level"] : []),
316
+ // Worth saying when it is more than one: the cuts follow whoever is
317
+ // speaking, and that is not obvious from a band count.
318
+ ...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
319
+ ].join(" + ");
320
+ }
321
+
322
+ /** Which voices the bed makes room for: a readout when there is only one to
323
+ * choose, otherwise a set of checkboxes. */
324
+ function CarveSourceRow({
325
+ carve,
326
+ sourceOptions,
327
+ soleVoice,
328
+ disabled,
329
+ onCarveChange,
330
+ }: {
331
+ carve: HfCarveSettings;
332
+ sourceOptions: AudioTrackOption[];
333
+ soleVoice: AudioTrackOption | null;
334
+ disabled?: boolean;
335
+ onCarveChange(carve: HfCarveSettings): void;
336
+ }) {
337
+ return (
338
+ <div className="hf-fx-row flex min-h-6 items-center gap-2">
339
+ {/* Wraps like every other name in this column (see FxParamRow) — one
340
+ truncating row beside wrapping ones reads as a rendering bug. */}
341
+ <span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
342
+ Listen to
343
+ </span>
344
+ {soleVoice ? (
345
+ <span
346
+ className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
347
+ data-carve-source={soleVoice.id}
348
+ >
349
+ {soleVoice.label}
350
+ </span>
351
+ ) : (
352
+ /* Every voice, not one of them. A bed usually runs under a whole
353
+ sequence — a narrator, an answer, a second presenter — and they are
354
+ analysed together, so the cuts follow whoever is speaking. Which
355
+ makes this a set of things to include, not a choice between them. */
356
+ <div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
357
+ {sourceOptions.map((o) => (
358
+ <label
359
+ key={o.id}
360
+ className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
361
+ title={`Make room for ${o.label}`}
362
+ >
363
+ <input
364
+ type="checkbox"
365
+ className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
366
+ data-carve-source={o.id}
367
+ checked={carve.sources.includes(o.id)}
368
+ disabled={disabled}
369
+ onChange={(e) =>
370
+ onCarveChange({
371
+ ...carve,
372
+ sources: e.target.checked
373
+ ? [...carve.sources, o.id]
374
+ : carve.sources.filter((id) => id !== o.id),
375
+ })
376
+ }
377
+ />
378
+ <span className="truncate">{o.label}</span>
379
+ </label>
380
+ ))}
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ /**
388
+ * What the analysis made of all that. Divided rather than boxed: these are parts
389
+ * of one module, and a border around each would read as the separate effects
390
+ * this replaced.
391
+ *
392
+ * While the analysis runs the previous filters are gone rather than stale. Every
393
+ * number in that list is about to be replaced — a strength change re-derives all
394
+ * of them — so leaving them up reads as the settings that are in force when they
395
+ * are already history, and the one honest thing to say is that the work is
396
+ * happening.
397
+ */
398
+ function CarveAnalysis({
399
+ nodes,
400
+ analysing,
401
+ hasSources,
402
+ automatedTargets,
403
+ liveAutomationValues,
404
+ }: {
405
+ nodes: HfAudioFxNode[];
406
+ analysing?: boolean;
407
+ hasSources: boolean;
408
+ automatedTargets?: ReadonlySet<string>;
409
+ liveAutomationValues?: ReadonlyMap<string, number>;
410
+ }) {
411
+ if (analysing) {
412
+ return (
413
+ <p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
414
+ <svg
415
+ className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
416
+ viewBox="0 0 24 24"
417
+ fill="none"
418
+ aria-hidden="true"
419
+ >
420
+ <circle
421
+ className="opacity-25"
422
+ cx="12"
423
+ cy="12"
424
+ r="10"
425
+ stroke="currentColor"
426
+ strokeWidth="4"
427
+ />
428
+ <path
429
+ className="opacity-75"
430
+ fill="currentColor"
431
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
432
+ />
433
+ </svg>
434
+ Analysing…
435
+ </p>
436
+ );
437
+ }
438
+ if (nodes.length === 0) {
439
+ return (
440
+ <p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
441
+ {hasSources ? "Nothing analysed yet." : "Pick the voices this bed should make room for."}
442
+ </p>
443
+ );
444
+ }
445
+ return (
446
+ <div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
447
+ <div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
448
+ analysed
449
+ </div>
450
+ {nodes.map((node, i) => (
451
+ <FxCarveMember
452
+ key={node.id ?? `${node.type}-${i}`}
453
+ node={node}
454
+ automatedTargets={automatedTargets}
455
+ liveAutomationValues={liveAutomationValues}
456
+ />
457
+ ))}
458
+ </div>
459
+ );
460
+ }
@@ -113,3 +113,53 @@ describe("FxParamRow commit", () => {
113
113
  expect(onCommit).not.toHaveBeenCalled();
114
114
  });
115
115
  });
116
+
117
+ describe("FxParamRow label tooltip", () => {
118
+ // The label column is a fixed 86px and truncates: "Gap between repeats" reads
119
+ // as "Gap between re…", "How many repeats" as "How many repe…". The row
120
+ // already carried a `title`, but that is the HINT — what the knob does — so a
121
+ // truncated name had no way to be read in full.
122
+ const LONG: HfAudioFxNumberParam = {
123
+ kind: "number",
124
+ key: "time",
125
+ label: "Gap between repeats",
126
+ min: 0,
127
+ max: 1000,
128
+ step: 1,
129
+ default: 250,
130
+ unit: "ms",
131
+ hint: "How long before the echo comes back.",
132
+ };
133
+
134
+ it("wraps the full name rather than truncating it, and leaves the hint on the row", () => {
135
+ const host = document.createElement("div");
136
+ document.body.append(host);
137
+ act(() => {
138
+ createRoot(host).render(<FxParamRow param={LONG} value={250} onChange={vi.fn()} />);
139
+ });
140
+ const label = host.querySelector<HTMLElement>(".hf-fx-label");
141
+ // The whole name is present and allowed to wrap. No `title`: a tooltip
142
+ // answers one row on hover, wrapping answers the column at rest.
143
+ expect(label?.textContent).toBe("Gap between repeats");
144
+ expect(label?.className).toContain("break-words");
145
+ expect(label?.className).not.toContain("truncate");
146
+ expect(label?.getAttribute("title")).toBeNull();
147
+ // The hint stays where it was: the two are different questions, and the
148
+ // name is not a substitute for the explanation either.
149
+ expect(host.querySelector<HTMLElement>(".hf-fx-row")?.getAttribute("title")).toBe(
150
+ "How long before the echo comes back.",
151
+ );
152
+ });
153
+
154
+ it("wraps it the same way with no hint to fall back on", () => {
155
+ const host = document.createElement("div");
156
+ document.body.append(host);
157
+ const { hint: _hint, ...noHint } = LONG;
158
+ act(() => {
159
+ createRoot(host).render(<FxParamRow param={noHint} value={250} onChange={vi.fn()} />);
160
+ });
161
+ const label = host.querySelector<HTMLElement>(".hf-fx-label");
162
+ expect(label?.textContent).toBe("Gap between repeats");
163
+ expect(label?.getAttribute("title")).toBeNull();
164
+ });
165
+ });
@@ -188,7 +188,14 @@ export function FxParamRow({
188
188
  if (param.kind === "enum") {
189
189
  return (
190
190
  <label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
191
- <span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
191
+ {/* Wraps rather than truncating. These names are whole questions — "How
192
+ big the space is" — so 86px of truncation left "How big the sp…", and
193
+ three rows of that read as the same word four times. A title only
194
+ answers it on hover, one row at a time; wrapping answers it for the
195
+ whole column at rest. `break-words` so a long single token breaks
196
+ instead of widening the column. The row keeps `title={param.hint}`:
197
+ the name and the explanation are different questions. */}
198
+ <span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
192
199
  {param.label}
193
200
  </span>
194
201
  <select
@@ -227,8 +234,9 @@ export function FxParamRow({
227
234
  title={param.hint}
228
235
  data-automated={automated ? "" : undefined}
229
236
  >
237
+ {/* See the enum row above for why the name wraps instead of truncating. */}
230
238
  <span
231
- className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
239
+ className={`hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight ${
232
240
  automated ? "text-panel-accent" : "text-panel-text-2"
233
241
  }`}
234
242
  >
@@ -125,6 +125,7 @@ function Fader({
125
125
  }
126
126
 
127
127
  export function FxEqModule({
128
+ eqId,
128
129
  bands,
129
130
  open,
130
131
  disabled,
@@ -141,6 +142,8 @@ export function FxEqModule({
141
142
  className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-l-2 border-panel-border-input"
142
143
  data-fx-node="eq"
143
144
  data-fx-family="smart"
145
+ // Scroll anchor for a revealed EQ-band automation lane.
146
+ data-fx-eq={eqId}
144
147
  // Smart, with the carve and the leveller: three bands the author sets by
145
148
  // ear on a control surface, not three filters they configured.
146
149
  style={{ borderLeftColor: fxFamilyTint({ type: "eq", fromEq: "eq" }) }}
@@ -184,6 +184,9 @@ function FxNodeHeader({
184
184
  <button
185
185
  type="button"
186
186
  className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
187
+ // Truncated in the same narrow column as the param labels below, so it
188
+ // needs the same fallback to the full text on hover.
189
+ title={label}
187
190
  aria-expanded={open}
188
191
  onClick={onToggleOpen}
189
192
  >
@@ -277,6 +280,10 @@ export function FxNodeRow({
277
280
  className={`hf-fx-node rounded-[4px] border border-l-2 border-panel-border-input${bypassed ? " opacity-50" : ""}`}
278
281
  data-fx-node={node.type}
279
282
  data-fx-family={fxFamilyOf(node)}
283
+ // The scroll anchor a revealed automation lane lands on. Keyed by node id
284
+ // rather than by parameter: every param of one effect lives in this row,
285
+ // so the row is the smallest thing worth scrolling to.
286
+ data-fx-node-id={node.id}
280
287
  // The tint is on the edge rather than the text: the name already carries
281
288
  // the family in its lettering, and colouring it too would fight the
282
289
  // panel's own tokens for automated and bypassed.
@@ -304,7 +311,10 @@ export function FxNodeRow({
304
311
  onRemove={() => onRemove(index)}
305
312
  />
306
313
  {summary ? (
307
- <p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2">
314
+ <p
315
+ className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2"
316
+ title={summary}
317
+ >
308
318
  {summary}
309
319
  </p>
310
320
  ) : null}
@@ -133,8 +133,18 @@ export function FxPresetMenu({
133
133
  <span className="hf-fx-preset-problem block truncate text-[10px]">
134
134
  {PRESET_PROBLEM[preset.id] ?? preset.description}
135
135
  </span>
136
- <span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-2">
137
- {preset.label}
136
+ {/* "Clean Voice · 5 effects" — the count is on the row in the
137
+ designs, not hidden in a tooltip. It is doing real work there:
138
+ it tells the author a preset IS a chain they can open and edit,
139
+ rather than an opaque setting they cannot follow. The count is
140
+ its own span so `.hf-fx-preset-name` stays the NAME — several
141
+ tests read it as the preset's identity. */}
142
+ <span className="block truncate font-mono text-[9px] text-panel-text-2">
143
+ <span className="hf-fx-preset-name">{preset.label}</span>
144
+ <span className="hf-fx-preset-count">
145
+ {" · "}
146
+ {preset.nodes.length} effect{preset.nodes.length === 1 ? "" : "s"}
147
+ </span>
138
148
  </span>
139
149
  {/* Hovering a preset plays it, and playing is otherwise invisible:
140
150
  the panel looks identical whether the audition is sounding or
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The rack's own signal path: the carve, the Tone EQ modules, and every
3
- * hand-built effect or preset run in between — bracketed by the "In this
4
- * track" / "Out to mix" labels that say the order is the point.
3
+ * hand-built effect or preset run in between — bracketed by the `In` / `Out`
4
+ * labels that say the order is the point. What those two name depends on what
5
+ * is selected; see `audioFxSignalPath`.
5
6
  *
6
7
  * Split out of `propertyPanelFxSection.tsx`, which owned this whole chain
7
8
  * before the file grew past a size where the chain and the add/pick menus
@@ -15,8 +16,11 @@ import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js";
15
16
  import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
16
17
  import { FxEqModule } from "./propertyPanelFxEqModule.js";
17
18
  import { FxPresetRun } from "./propertyPanelFxPresetRun.js";
19
+ import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
18
20
 
19
21
  export interface FxRackChainProps {
22
+ /** What the `In`/`Out` lines name — a clip's answer differs from a bus's. */
23
+ signalPath: AudioFxSignalPath;
20
24
  chain: HfAudioFxChain;
21
25
  showCarve: boolean;
22
26
  carveNodes: HfAudioFxNode[];
@@ -103,6 +107,7 @@ export function FxRackChain({
103
107
  presetAutomated,
104
108
  presetAutomateHandler,
105
109
  presetRemoveAutomationHandler,
110
+ signalPath,
106
111
  }: FxRackChainProps) {
107
112
  return (
108
113
  <div className="hf-fx-chain space-y-1">
@@ -111,7 +116,7 @@ export function FxRackChain({
111
116
  "move up" look cosmetic — it is the most consequential control here. */}
112
117
  <p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
113
118
  <span className="hf-fx-term-cap text-panel-text-1">In</span>
114
- <span>this track</span>
119
+ <span>{signalPath.inLabel}</span>
115
120
  </p>
116
121
  {/* Carve leads the rack, which is also where its effects sit in the signal
117
122
  path — corrective work before anything the author added. Present
@@ -151,7 +156,9 @@ export function FxRackChain({
151
156
  ))}
152
157
  {handBuiltCount === 0 && eqIds.length === 0 ? (
153
158
  <p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
154
- {showCarve ? "No other effects on this track." : "No effects on this track."}
159
+ {showCarve
160
+ ? `No other effects on this ${signalPath.subject}.`
161
+ : `No effects on this ${signalPath.subject}.`}
155
162
  </p>
156
163
  ) : (
157
164
  runs.map((run) => {
@@ -195,7 +202,7 @@ export function FxRackChain({
195
202
  )}
196
203
  <p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
197
204
  <span className="hf-fx-term-cap text-panel-text-1">Out</span>
198
- <span>to mix</span>
205
+ <span>{signalPath.outLabel}</span>
199
206
  </p>
200
207
  </div>
201
208
  );
@@ -162,6 +162,48 @@ afterEach(() => {
162
162
  });
163
163
 
164
164
  describe("FxSection chain", () => {
165
+ it("scrolls again when the same open parameter is revealed twice", () => {
166
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView");
167
+ const scrollIntoView = vi.fn();
168
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
169
+ configurable: true,
170
+ value: scrollIntoView,
171
+ });
172
+ const chain: HfAudioFxChain = {
173
+ version: 1,
174
+ nodes: [
175
+ {
176
+ type: "lowpass",
177
+ id: "filter-1",
178
+ enabled: true,
179
+ params: defaultAudioFxParams("lowpass"),
180
+ },
181
+ ],
182
+ };
183
+ const renderFxSection = (revealNonce: number) => (
184
+ <FxSection
185
+ chain={chain}
186
+ onChainChange={vi.fn()}
187
+ onCarveChange={vi.fn()}
188
+ carve={null}
189
+ sourceOptions={[]}
190
+ revealTarget="fx.filter-1.frequency"
191
+ revealNonce={revealNonce}
192
+ />
193
+ );
194
+
195
+ try {
196
+ const { root } = renderInto(renderFxSection(1));
197
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
198
+
199
+ act(() => root.render(renderFxSection(2)));
200
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
201
+ } finally {
202
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
203
+ else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
204
+ }
205
+ });
206
+
165
207
  it("says so when the track has no effects", () => {
166
208
  const { host } = mount();
167
209
  // "other", because the carve module is in the rack whenever a voice exists.