@hyperframes/studio 0.8.11 → 0.8.13

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
  2. package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
  3. package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
  4. package/dist/assets/index-CQMHN4nk.js +431 -0
  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 +7646 -6514
  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/domSelectionTimelineMirror.ts +12 -3
  64. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  65. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  66. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  67. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  68. package/src/hooks/timelineEditingHelpers.ts +19 -7
  69. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  70. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  71. package/src/hooks/timelineTrackVisibility.ts +6 -182
  72. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  73. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  74. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  75. package/src/hooks/useDomEditSession.test.tsx +57 -2
  76. package/src/hooks/useDomEditSession.ts +23 -24
  77. package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
  78. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  79. package/src/hooks/useLivePlayheadTime.ts +3 -1
  80. package/src/hooks/useRemoveBackground.ts +70 -0
  81. package/src/hooks/useTimelineEditing.ts +5 -19
  82. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  83. package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
  84. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  85. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  86. package/src/player/components/PlayerControls.tsx +0 -38
  87. package/src/player/components/Timeline.test.ts +40 -1
  88. package/src/player/components/Timeline.tsx +5 -2
  89. package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
  90. package/src/player/components/TimelineAutomationLane.tsx +129 -36
  91. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  92. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  93. package/src/player/components/TimelineFxButton.tsx +156 -31
  94. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  95. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  96. package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
  97. package/src/player/components/TimelineGroupRow.test.tsx +222 -0
  98. package/src/player/components/TimelineGroupRow.tsx +140 -71
  99. package/src/player/components/TimelineLanes.test.tsx +3 -3
  100. package/src/player/components/TimelineLanes.tsx +25 -23
  101. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  102. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  103. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  104. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  105. package/src/player/components/automationLaneData.test.ts +94 -0
  106. package/src/player/components/automationLaneData.ts +23 -0
  107. package/src/player/components/automationLaneDragMath.ts +28 -0
  108. package/src/player/components/automationLaneGeometry.ts +52 -13
  109. package/src/player/components/groupAutomationElement.test.ts +51 -0
  110. package/src/player/components/groupAutomationElement.ts +37 -0
  111. package/src/player/components/timelineCallbacks.ts +6 -2
  112. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  113. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  114. package/src/player/components/timelineLayout.ts +0 -1
  115. package/src/player/components/timelineViewModel.ts +30 -1
  116. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  117. package/src/player/components/useAutomationLaneGestures.ts +66 -18
  118. package/src/player/components/useAutomationSegmentDrag.ts +110 -0
  119. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  120. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  121. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  122. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  123. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  124. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  125. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  126. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  127. package/src/player/hooks/previewMessageRouter.ts +4 -11
  128. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  129. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  130. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  131. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  132. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  133. package/src/player/lib/automationStoreSync.test.ts +26 -0
  134. package/src/player/lib/automationStoreSync.ts +28 -4
  135. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  136. package/src/player/lib/timelineDOM.test.ts +115 -0
  137. package/src/player/lib/timelineDOM.ts +3 -32
  138. package/src/player/lib/timelineElementHelpers.ts +27 -0
  139. package/src/player/lib/timelineGroupInfo.ts +134 -0
  140. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  141. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  142. package/src/player/store/keyframeSlice.ts +79 -5
  143. package/src/player/store/playerStore.ts +37 -47
  144. package/src/player/store/playerStoreDevHandle.ts +22 -0
  145. package/src/player/store/playerStoreSelection.ts +41 -0
  146. package/src/player/store/timelineElement.ts +32 -0
  147. package/src/utils/hmrStableContext.ts +64 -0
  148. package/src/utils/timelineInspector.test.ts +35 -1
  149. package/src/utils/timelineInspector.ts +38 -0
  150. package/dist/assets/index-Ba9zbpT9.css +0 -1
  151. package/dist/assets/index-DoW5v5eS.js +0 -428
  152. package/src/hooks/useAudioSoloBridge.ts +0 -61
  153. package/src/hooks/useGroupLevel.ts +0 -36
  154. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  155. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  156. package/src/player/components/TimelineSoloButton.tsx +0 -32
  157. package/src/player/store/audioSoloSlice.test.ts +0 -113
  158. package/src/player/store/audioSoloSlice.ts +0 -43
  159. package/src/player/store/groupLevels.ts +0 -33
  160. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  161. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useShallow } from "zustand/react/shallow";
4
4
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
5
5
  import { slugifyDesignInput } from "../../utils/designInputTracking";
@@ -7,15 +7,18 @@ import { isTextEditableSelection } from "./domEditing";
7
7
  import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
8
8
  import { formatPxMetricValue } from "./propertyPanelHelpers";
9
9
  import { audioFxSummary } from "./audioFxSummary";
10
+ import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
10
11
  import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
11
12
  import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
13
+ import { closedGroupHeader, isSelectionHidden } from "./propertyPanelFlatClosedGroup";
12
14
  import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
13
15
  import { FlatTextSection } from "./propertyPanelFlatTextSection";
14
16
  import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
15
17
  import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
16
- import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
18
+ import { FlatMotionSection, motionSectionLabel } from "./propertyPanelFlatMotionSection";
17
19
  import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
18
20
  import { useVolumeAutomation } from "./useVolumeAutomation";
21
+ import { useAudioFxRevealSection } from "./useAudioFxRevealSection";
19
22
  import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
20
23
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
21
24
  import { createGsapLivePreview } from "./gsapLivePreview";
@@ -40,6 +43,7 @@ import {
40
43
  EMPTY_GSAP_EFFECT_HANDLERS,
41
44
  type FlatGroupDescriptor,
42
45
  } from "./propertyPanelFlatDescriptors";
46
+ import { isAudioDomElement } from "../../utils/timelineInspector";
43
47
 
44
48
  /** The flat inspector shell with one shared open-group state. */
45
49
  // fallow-ignore-next-line complexity
@@ -135,7 +139,13 @@ export function PropertyPanelFlat({
135
139
  ? "style"
136
140
  : sections.media
137
141
  ? "media"
138
- : "layout",
142
+ : // An `<hf-audio-group>` has no style, no layout and no media — its
143
+ // chain is the only reason to select one. Without this the fallback
144
+ // landed on "layout", a section a bus does not render, so opening the
145
+ // rack on a group produced a panel with everything collapsed.
146
+ sections.audioFx
147
+ ? "audio-fx"
148
+ : "layout",
139
149
  );
140
150
 
141
151
  // Tracks which group(s) are actively transitioning this toggle cycle, so
@@ -161,6 +171,7 @@ export function PropertyPanelFlat({
161
171
  timelineSessionEpoch: state.timelineSessionEpoch,
162
172
  })),
163
173
  );
174
+ const storeElements = usePlayerStore((state) => state.elements);
164
175
  // Identity of the element THIS panel actually renders (not the store's
165
176
  // selectedElementId, which flips synchronously on selection while the panel
166
177
  // still renders the previous element during async DOM-selection resolution):
@@ -187,6 +198,25 @@ export function PropertyPanelFlat({
187
198
  if (focusesThisPanel) setOpenGroupId("motion");
188
199
  }
189
200
 
201
+ /**
202
+ * A lane's reveal request opens the Audio FX section, the same way a focused
203
+ * ease segment opens Motion.
204
+ *
205
+ * Without this the request reached a collapsed section: the rack — and the
206
+ * module the request names — is not mounted while it is closed, so the click
207
+ * selected the clip and then appeared to do nothing.
208
+ */
209
+ const hiddenNow = isSelectionHidden(selectedElementHidden, element);
210
+
211
+ const reveal = useAudioFxRevealSection({
212
+ elementId: element?.id,
213
+ hasAudioFxSection: Boolean(sections.audioFx),
214
+ });
215
+ if (reveal.revealNonce !== null) {
216
+ reveal.consume(reveal.revealNonce);
217
+ setOpenGroupId("audio-fx");
218
+ }
219
+
190
220
  const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
191
221
  const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
192
222
  const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -254,11 +284,30 @@ export function PropertyPanelFlat({
254
284
  onSetAllKeyframeEases,
255
285
  }
256
286
  : null;
257
- const showMotionEffects = gsapEffectHandlers !== null;
287
+ const audioSelection = isAudioDomElement(element.element);
288
+ // Handlers being wired is necessary but not sufficient: App.tsx always passes
289
+ // them, so this alone showed the tween editor for every selection — including
290
+ // an `<audio>` clip and an `<hf-audio-group>` bus, neither of which has a
291
+ // transform, an opacity or a box for a tween to move. Gated on the TAG, not on
292
+ // `sections.animation` (`animationCount > 0`): a div with no tweens yet must
293
+ // still offer "+ Add", so "has none" and "can have none" are different
294
+ // questions and only the second one belongs here.
295
+ const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
258
296
  const showMotionGroup = showMotionTiming || showMotionEffects;
259
297
 
260
298
  const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
261
299
 
300
+ // The group this clip belongs to, if any — the Audio FX summary reads
301
+ // "in Voiceover" for a member (see `audioFxSummary`). Membership lives on the
302
+ // members, so resolve the owning label from the live document.
303
+ const audioGroupLabel = useMemo((): string | undefined => {
304
+ const doc = element.element?.ownerDocument;
305
+ const id = element.id;
306
+ if (!doc || !id) return undefined;
307
+ return resolveAudioGroups(doc).find((group) => group.memberIds.includes(id))?.label;
308
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- store replacement signals live group membership changed
309
+ }, [element, storeElements]);
310
+
262
311
  const groups: FlatGroupDescriptor[] = [];
263
312
  if (isTextEditable) {
264
313
  groups.push({
@@ -346,8 +395,12 @@ export function PropertyPanelFlat({
346
395
  if (showMotionGroup) {
347
396
  groups.push({
348
397
  id: "motion",
349
- title: "Motion",
350
- summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`,
398
+ ...motionSectionLabel({
399
+ timingOnly: audioSelection,
400
+ start: elStart,
401
+ duration: elDuration,
402
+ effectCount: gsapAnimations.length,
403
+ }),
351
404
  content: (
352
405
  <FlatMotionSection
353
406
  element={element}
@@ -431,7 +484,7 @@ export function PropertyPanelFlat({
431
484
  groups.push({
432
485
  id: "audio-fx",
433
486
  title: "Audio FX",
434
- summary: audioFxSummary(element),
487
+ summary: audioFxSummary(element, audioGroupLabel),
435
488
  content: (
436
489
  <AudioFxGroup
437
490
  element={element}
@@ -466,17 +519,8 @@ export function PropertyPanelFlat({
466
519
  const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
467
520
  const openGroup = openIndex === -1 ? null : groups[openIndex];
468
521
  const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
469
- const renderClosedGroup = (group: FlatGroupDescriptor) => (
470
- <DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
471
- <FlatGroupHeader
472
- title={group.title}
473
- isOpen={false}
474
- onToggleOpen={() => toggleOpen(group.id)}
475
- summary={group.summary}
476
- animateEntrance={justToggledIds.includes(group.id)}
477
- />
478
- </DesignPanelInputProvider>
479
- );
522
+ const renderClosedGroup = (group: FlatGroupDescriptor) =>
523
+ closedGroupHeader(group, toggleOpen, justToggledIds);
480
524
 
481
525
  return (
482
526
  <DesignPanelInputProvider ui="flat">
@@ -486,10 +530,27 @@ export function PropertyPanelFlat({
486
530
  name={element.label}
487
531
  meta={`${sourceLabel} · ${element.tagName}`}
488
532
  elementKind={elementKind}
489
- hidden={selectedElementHidden}
533
+ hidden={hiddenNow}
534
+ // Audio gets no hide control here. On an audio track "hidden" and
535
+ // "muted" are not similar operations, they are the SAME operation
536
+ // with two names (groups doc §2.1) — which is why the timeline's eye
537
+ // BECAME the mute rather than growing a sibling. A second copy in
538
+ // the panel, still called "Hide element", is exactly what that step
539
+ // set out to remove: "Two controls that silence a track, sitting
540
+ // next to each other, differing only in a distinction the author
541
+ // cannot see." An `<hf-audio-group>` has no visual to hide at all.
542
+ //
543
+ // EXCEPT while it is already hidden — the same door-from-the-inside
544
+ // the timeline's eye keeps for an audio track
545
+ // (`TimelineTrackPlainHeader`). Withholding it unconditionally
546
+ // withheld the only way back: a `data-hidden` group is silent in
547
+ // preview (the bus's mute gain) and absent from the render (every
548
+ // member dropped), and the group header carries no visibility
549
+ // control of its own now that mute and solo are gone. Only
550
+ // hand-editing the HTML brought the audio back.
490
551
  onToggleHidden={
491
- selectedElementId && onToggleElementHidden
492
- ? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
552
+ selectedElementId && onToggleElementHidden && (!audioSelection || hiddenNow)
553
+ ? () => void onToggleElementHidden(selectedElementId, !hiddenNow)
493
554
  : undefined
494
555
  }
495
556
  copied={clipboardCopied}
@@ -76,4 +76,25 @@ describe("PropertyPanelFlatHeader", () => {
76
76
  const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() });
77
77
  expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
78
78
  });
79
+
80
+ // The panel's hide control is withheld for audio by its caller — on an audio
81
+ // track "hidden" and "muted" are the same operation with two names (groups
82
+ // doc §2.1), and the timeline already carries it, correctly labelled. This
83
+ // pins the header's half of that contract: no handler, no button.
84
+ it("renders no visibility control when its caller withholds the handler", () => {
85
+ const { host } = renderHeader({ onToggleHidden: undefined });
86
+ const labels = Array.from(host.querySelectorAll("button")).map((b) =>
87
+ b.getAttribute("aria-label"),
88
+ );
89
+ expect(labels).not.toContain("Hide element");
90
+ expect(labels).not.toContain("Show element");
91
+ });
92
+
93
+ it("renders it when the handler is supplied", () => {
94
+ const { host } = renderHeader({ onToggleHidden: vi.fn() });
95
+ const labels = Array.from(host.querySelectorAll("button")).map((b) =>
96
+ b.getAttribute("aria-label"),
97
+ );
98
+ expect(labels).toContain("Hide element");
99
+ });
79
100
  });
@@ -11,9 +11,11 @@
11
11
  import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
12
12
  import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
13
13
  import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
14
+ import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
14
15
  import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
15
16
  import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
16
17
  import { useFxAudition } from "./useFxAudition.js";
18
+ import { trackPresetAuditioned } from "./audioFxTelemetry.js";
17
19
 
18
20
  const POPOVER_WIDTH = 260;
19
21
  const VIEWPORT_MARGIN = 8;
@@ -80,6 +82,13 @@ export interface TimelineFxPopoverProps {
80
82
  onOpenRack: () => void;
81
83
  }
82
84
 
85
+ /** A preset applied for AUDITION only: no telemetry, since nothing was chosen.
86
+ * `applyPresetToChain` is the tracked path and belongs to `onPick`. */
87
+ function auditionPresetChain(base: HfAudioFxChain, presetId: string): HfAudioFxChain {
88
+ const preset = getAudioFxPreset(presetId);
89
+ return preset ? applyAudioFxPreset(base, preset) : base;
90
+ }
91
+
83
92
  export function TimelineFxPopover({
84
93
  anchorRect,
85
94
  chain,
@@ -91,7 +100,11 @@ export function TimelineFxPopover({
91
100
  onOpenRack,
92
101
  }: TimelineFxPopoverProps) {
93
102
  const rootRef = useRef<HTMLDivElement | null>(null);
94
- const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
103
+ const { audition, clearAudition, storedChain } = useFxAudition(
104
+ chain,
105
+ onChainPreview,
106
+ onAuditionTransport,
107
+ );
95
108
 
96
109
  // Outside click dismisses like any other popover; the button itself is
97
110
  // excluded by pointerdown timing (the button's own click hasn't happened yet).
@@ -104,7 +117,8 @@ export function TimelineFxPopover({
104
117
  }, [onClose]);
105
118
 
106
119
  const applyPreset = (id: string) => {
107
- const next = applyPresetToChain(chain, id, trackKind);
120
+ // The stored chain, not the auditioned one — see `storedChain`.
121
+ const next = applyPresetToChain(storedChain(), id, trackKind);
108
122
  if (!next) return;
109
123
  clearAudition();
110
124
  onChainChange(next);
@@ -137,12 +151,18 @@ export function TimelineFxPopover({
137
151
  <FxPresetMenu
138
152
  trackKind={trackKind}
139
153
  onPick={applyPreset}
154
+ // The RAW apply, not `applyPresetToChain` — that helper fires
155
+ // `trackPresetApplied` on every call, so auditioning a 12-preset shelf
156
+ // by hover or arrow key emitted 12 `preset_applied` events and the
157
+ // numbers could not tell an audition from a decision. `FxSection`
158
+ // makes exactly this split, with `onAuditionTracked` carrying the
159
+ // honest event.
140
160
  onAudition={
141
161
  onChainPreview
142
- ? (id) =>
143
- audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
162
+ ? (id) => audition(id ? (base) => auditionPresetChain(base, id) : null)
144
163
  : undefined
145
164
  }
165
+ onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
146
166
  />
147
167
  </div>
148
168
  <div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { audioFxRevealTarget } from "./audioFxRevealTarget";
3
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
4
+
5
+ const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
6
+
7
+ describe("audioFxRevealTarget", () => {
8
+ it("points a hand-built effect's lane at its own row, by chain index", () => {
9
+ const c = chain([
10
+ { type: "highpass", id: "n1", params: {} },
11
+ { type: "peaking", id: "n2", params: {} },
12
+ ]);
13
+ expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" });
14
+ });
15
+
16
+ // The case that made this a resolver rather than an index lookup: a carve's
17
+ // bands are filtered out of the rack's node list, so opening `openNode` on
18
+ // one opens nothing. The carve module is what renders them.
19
+ it("points a carve band's lane at the carve module", () => {
20
+ const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]);
21
+ expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" });
22
+ });
23
+
24
+ it("points an EQ band's lane at its EQ module", () => {
25
+ const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]);
26
+ expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" });
27
+ });
28
+
29
+ it("points a preset node's lane at its run, keyed like collapsedRuns", () => {
30
+ const c = chain([
31
+ { type: "highpass", id: "n1", params: {} },
32
+ { type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} },
33
+ { type: "gain", id: "n3", fromPreset: "clean-voice", params: {} },
34
+ ]);
35
+ // Keyed by the run's FIRST node, not the automated one.
36
+ expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({
37
+ kind: "preset",
38
+ runKey: "clean-voice-1",
39
+ });
40
+ });
41
+
42
+ it("resolves a preset-level lane through the preset it names", () => {
43
+ const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]);
44
+ expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({
45
+ kind: "preset",
46
+ runKey: "clean-voice-0",
47
+ });
48
+ });
49
+
50
+ it("treats the track's own volume as the rack itself", () => {
51
+ expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" });
52
+ });
53
+
54
+ it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => {
55
+ expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull();
56
+ expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull();
57
+ expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Where in the rack an automation lane's parameter actually lives.
3
+ *
4
+ * A lane names `fx.<nodeId>.<param>`, but the rack does not show one flat list
5
+ * of nodes: the carve is one module standing for the filters it compiled, EQ
6
+ * bands are folded into their own module, and preset runs are collapsible
7
+ * groups. A node id therefore resolves to one of several surfaces, and the
8
+ * caller has to open the RIGHT one — opening `openNode` on a carve band, whose
9
+ * row is filtered out of `handBuilt`, would open nothing at all and read as the
10
+ * click doing nothing.
11
+ */
12
+
13
+ import { parseAutomationTarget } from "@hyperframes/core/audio-automation";
14
+ import { escapeCssString } from "./domEditingDom";
15
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
16
+
17
+ export type AudioFxRevealTarget =
18
+ /** A hand-built effect, addressed by its index in the chain. */
19
+ | { kind: "node"; index: number; nodeId: string }
20
+ /** An EQ module, addressed by the id its bands share. */
21
+ | { kind: "eq"; eqId: string }
22
+ /** A preset run, addressed the way `collapsedRuns` keys it. */
23
+ | { kind: "preset"; runKey: string }
24
+ /** The carve module, which owns every `fromCarve` node collectively. */
25
+ | { kind: "carve" }
26
+ /** The track's own volume — no rack row; the reveal is the rack itself. */
27
+ | { kind: "volume" };
28
+
29
+ /**
30
+ * Resolve a lane target to the surface that shows it, or null when the chain
31
+ * does not contain it (a stale lane, or one whose effect was removed).
32
+ */
33
+ export function audioFxRevealTarget(
34
+ target: string,
35
+ chain: HfAudioFxChain | null,
36
+ ): AudioFxRevealTarget | null {
37
+ const parsed = parseAutomationTarget(target);
38
+ if (!parsed) return null;
39
+ if (parsed.kind === "volume") return { kind: "volume" };
40
+ if (parsed.kind === "preset") {
41
+ // A preset-level lane names the preset, not a node inside it: find its run
42
+ // by the first node that belongs to it, which is how `runKey` is built.
43
+ const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId);
44
+ return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null;
45
+ }
46
+ const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId);
47
+ const node = index >= 0 ? chain?.nodes[index] : undefined;
48
+ if (!node) return null;
49
+ // Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and
50
+ // the carve module is the one that actually renders it.
51
+ if (node.fromCarve) return { kind: "carve" };
52
+ if (node.fromEq) return { kind: "eq", eqId: node.fromEq };
53
+ if (node.fromPreset) {
54
+ const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset);
55
+ return { kind: "preset", runKey: `${node.fromPreset}-${first}` };
56
+ }
57
+ return { kind: "node", index, nodeId: parsed.nodeId };
58
+ }
59
+
60
+ /**
61
+ * The DOM selector for the row a reveal target lives in, or null when the target
62
+ * names no surface this panel renders.
63
+ *
64
+ * A preset run is keyed by the preset id the run element actually exposes, not by
65
+ * `runKey`, which is the collapse map's key and carries an index suffix.
66
+ */
67
+ function revealRowSelector(where: AudioFxRevealTarget | null): string | null {
68
+ if (!where) return null;
69
+ switch (where.kind) {
70
+ case "node":
71
+ return where.nodeId ? `[data-fx-node-id="${escapeCssString(where.nodeId)}"]` : null;
72
+ case "eq":
73
+ return `[data-fx-eq="${escapeCssString(where.eqId)}"]`;
74
+ case "carve":
75
+ return ".hf-fx-carve-module";
76
+ case "preset":
77
+ return `[data-fx-preset="${escapeCssString(where.runKey.replace(/-\d+$/, ""))}"]`;
78
+ default:
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Scroll the row that owns `target` into view, and report whether it was found.
85
+ *
86
+ * The target is resolved against the chain again rather than remembered: which
87
+ * surface owns a parameter is a fact about the chain, and the chain may have
88
+ * been edited between the reveal request and the pass that can act on it. A
89
+ * false return means the row has not mounted yet, so the caller keeps the
90
+ * request pending.
91
+ */
92
+ export function scrollRevealedRowIntoView(
93
+ root: HTMLElement | null,
94
+ target: string,
95
+ chain: HfAudioFxChain,
96
+ ): boolean {
97
+ const selector = revealRowSelector(audioFxRevealTarget(target, chain));
98
+ // `querySelector` throws SyntaxError on a malformed selector, and this runs
99
+ // inside a render-phase effect — an unescapable id would take the whole
100
+ // property panel down instead of leaving the request pending, which is what
101
+ // returning false means. `parseAudioFxNode` accepts any non-empty string as
102
+ // an id and `parseAutomationTarget` only splits on `.`, so a hand- or
103
+ // LLM-authored chain can carry one.
104
+ let row: HTMLElement | null = null;
105
+ try {
106
+ row = selector ? (root?.querySelector<HTMLElement>(selector) ?? null) : null;
107
+ } catch {
108
+ return false;
109
+ }
110
+ if (!row) return false;
111
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" });
112
+ return true;
113
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
3
+ import { audioFxSignalPath } from "./audioFxSignalPath";
4
+
5
+ const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
6
+ id: "voiceover",
7
+ label: "Voiceover",
8
+ memberIds: ["vo-1", "vo-2"],
9
+ volume: 1,
10
+ hidden: false,
11
+ ...over,
12
+ });
13
+
14
+ describe("audioFxSignalPath", () => {
15
+ // The design doc's §5 mockup, both columns.
16
+ // Copy taken from the rendered designs, which are more specific than the
17
+ // ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
18
+ // Voiceover", not "vo-1, vo-2" / "to Voiceover".
19
+ it("names what a group sums, and sends it to the mix", () => {
20
+ expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
21
+ inLabel: "vo-1 and vo-2, together",
22
+ outLabel: "to mix",
23
+ subject: "group",
24
+ });
25
+ });
26
+
27
+ it("names the group a member feeds, so routing reads from either end", () => {
28
+ expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
29
+ inLabel: "this track",
30
+ outLabel: "into Voiceover",
31
+ subject: "track",
32
+ });
33
+ });
34
+
35
+ it("leaves an ungrouped clip on the shipped clip labels", () => {
36
+ expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({
37
+ inLabel: "this track",
38
+ outLabel: "to mix",
39
+ subject: "track",
40
+ });
41
+ });
42
+
43
+ // The state an author is in the instant after making a group. It must not
44
+ // read as a failure to resolve.
45
+ it("says a memberless group holds nothing yet", () => {
46
+ expect(
47
+ audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]),
48
+ ).toMatchObject({ inLabel: "nothing yet", subject: "group" });
49
+ });
50
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What the rack's `IN` and `OUT` lines say, per selected element.
3
+ *
4
+ * The rack brackets its chain with the signal path because the ORDER is the
5
+ * point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a
6
+ * clip's answer — "in this track", "out to mix" — which is wrong at both ends
7
+ * once groups exist, and the design doc's §5 mockup spells out both:
8
+ *
9
+ * GROUP: Voiceover CLIP: vo-1
10
+ * IN vo-1, vo-2 IN this track
11
+ * OUT to mix OUT to Voiceover
12
+ *
13
+ * A group's IN names what it sums, which is the only thing on screen that says
14
+ * a bus is a sum rather than a copy of the chain on each member. A member's OUT
15
+ * names the group it feeds, "so the routing is readable from either end".
16
+ */
17
+
18
+ import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
19
+
20
+ export interface AudioFxSignalPath {
21
+ /** After the word "In". */
22
+ inLabel: string;
23
+ /** After the word "Out". */
24
+ outLabel: string;
25
+ /** The thing the empty-state sentence is about: "No effects on this …". */
26
+ subject: string;
27
+ }
28
+
29
+ /** What a plain, ungrouped clip has always said, and the default everywhere. */
30
+ export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
31
+ inLabel: "this track",
32
+ outLabel: "to mix",
33
+ subject: "track",
34
+ };
35
+
36
+ /**
37
+ * `groups` is the resolved set from the composition; `elementId` and `tag` come
38
+ * from the selection. Pure so the labels can be asserted without a DOM.
39
+ */
40
+ /** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
41
+ function joinNatural(items: readonly string[]): string {
42
+ if (items.length <= 1) return items[0] ?? "";
43
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
44
+ }
45
+
46
+ export function audioFxSignalPath(
47
+ tag: string | undefined,
48
+ elementId: string | undefined,
49
+ groups: readonly HfAudioGroup[],
50
+ ): AudioFxSignalPath {
51
+ if (tag === "hf-audio-group") {
52
+ const group = groups.find((g) => g.id === elementId);
53
+ // A group with no members yet still reads as a group — "nothing yet" is the
54
+ // honest answer, and it is also the state the author is in right after
55
+ // making one, so it must not look like a bug.
56
+ const members = group?.memberIds ?? [];
57
+ return {
58
+ // "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
59
+ // comma list. The trailing "together" is the point: it says the group is
60
+ // ONE signal hearing both, which is the thing two separate copies of a
61
+ // chain cannot do, and it says it without "sum" or "bus".
62
+ inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet",
63
+ outLabel: "to mix",
64
+ subject: "group",
65
+ };
66
+ }
67
+ const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
68
+ // "into Voiceover", not "to" — a member feeds the group, and the design uses
69
+ // the preposition that says so.
70
+ return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
71
+ }
@@ -8,6 +8,14 @@ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
8
8
  const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
9
9
 
10
10
  describe("audioFxSummary", () => {
11
+ // The designs give a member's rack the summary "in Voiceover" — it answers
12
+ // "where does this go?" before anything is opened, the same job the rack's
13
+ // OUT does from the other end, and it outranks the effect count because a
14
+ // member with no effects of its own is still in the group.
15
+ it("names the group a clip belongs to, ahead of any effect count", () => {
16
+ expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover");
17
+ });
18
+
11
19
  it("counts a carve as one module, not as the filters behind it", () => {
12
20
  // Six bands and a level stage reading "7 effects" is the misreading the
13
21
  // grouping exists to prevent.
@@ -10,27 +10,39 @@
10
10
  import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
11
11
  import type { DomEditSelection } from "./domEditingTypes";
12
12
 
13
- export function audioFxSummary(element: DomEditSelection): string {
14
- const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
15
- const carveAttr = element.dataAttributes?.["fx-carve"];
16
- let handBuilt = 0;
17
- let carveNodes = 0;
18
- if (raw) {
19
- try {
20
- for (const node of parseAudioFxChain(raw).nodes) {
21
- if (node.enabled === false) continue;
22
- if (node.fromCarve) carveNodes += 1;
23
- else handBuilt += 1;
24
- }
25
- } catch {
26
- return "unreadable";
13
+ /** Enabled nodes split by who authored them, or null when the chain won't parse. */
14
+ function countEnabledNodes(raw: string | undefined): { handBuilt: number; carve: number } | null {
15
+ if (!raw) return { handBuilt: 0, carve: 0 };
16
+ try {
17
+ let handBuilt = 0;
18
+ let carve = 0;
19
+ for (const node of parseAudioFxChain(raw).nodes) {
20
+ if (node.enabled === false) continue;
21
+ if (node.fromCarve) carve += 1;
22
+ else handBuilt += 1;
27
23
  }
24
+ return { handBuilt, carve };
25
+ } catch {
26
+ return null;
28
27
  }
28
+ }
29
+
30
+ export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string {
31
+ // A clip inside a group reads "in Voiceover" — the designs use this line to
32
+ // answer "where does this go?" before the author opens anything, which is
33
+ // the same job the rack's OUT does from the other end. It outranks the effect
34
+ // count: a member with no effects of its own is still IN the group, and that
35
+ // is the more useful thing to say about it.
36
+ if (groupLabel) return `in ${groupLabel}`;
37
+ const counts = countEnabledNodes(element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]);
38
+ if (!counts) return "unreadable";
29
39
  const parts: string[] = [];
30
- if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
40
+ if (counts.handBuilt > 0) {
41
+ parts.push(`${counts.handBuilt} effect${counts.handBuilt === 1 ? "" : "s"}`);
42
+ }
31
43
  // One name for the module however many filters are behind it. Named when the
32
44
  // carve is switched on at all, because the control is in this section whether or
33
45
  // not it has compiled to anything yet.
34
- if (carveNodes > 0 || carveAttr) parts.push("carve");
46
+ if (counts.carve > 0 || element.dataAttributes?.["fx-carve"]) parts.push("carve");
35
47
  return parts.length > 0 ? parts.join(" + ") : "none";
36
48
  }