@hyperframes/studio 0.7.106 → 0.7.108

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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * One effect in the FX rack: its header controls, and its knobs when open.
3
+ *
4
+ * An entry in the chain, as opposed to a composite module — the carve and the
5
+ * Tone EQ own several nodes each and have their own files.
6
+ *
7
+ * The row speaks the author's language, not the registry's. `EFFECT_COPY`
8
+ * supplies the name and every knob's name, `SUMMARY` the sentence under it, and
9
+ * the DSP name moves inside — it is a fact about the mechanism, so it belongs
10
+ * with the mechanism. See `plans/audio-fx-ux/README.md` §Decided.
11
+ */
12
+
13
+ import { useMemo, useState } from "react";
14
+ import {
15
+ defaultAudioFxParams,
16
+ getAudioFxDef,
17
+ type HfAudioFxDef,
18
+ type HfAudioFxNode,
19
+ type HfAudioFxParam,
20
+ type HfAudioFxParamValues,
21
+ } from "@hyperframes/core/audio-fx";
22
+ import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
23
+ import { trackNodeBypassed } from "./audioFxTelemetry.js";
24
+ import { getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
25
+ import { FX_FAMILY_TYPE, fxFamilyOf, fxFamilyTint } from "./propertyPanelFxFamily.js";
26
+ import { FxNodeOpenBody } from "./propertyPanelFxNodeOpenBody.js";
27
+
28
+ /**
29
+ * The one control that carries the module, if it has one.
30
+ *
31
+ * "Two faces": a module opens on its name, a line about what it is for, and one
32
+ * knob — the rest is one click away and never in the way. Nothing is hidden; it
33
+ * is ordered.
34
+ *
35
+ * `primary` is either a real parameter key or the string "strength", which means
36
+ * the module wants a single DERIVED control over several parameters — the
37
+ * `PROFILES` idea, whose figures are proposed rather than measured and which has
38
+ * not shipped. Until it does, those five effects (compressor, gate, saturate,
39
+ * reverb, bitcrush) open on all of their controls, which is honest: the one knob
40
+ * they want does not exist yet, and inventing one would be a knob that lies.
41
+ */
42
+ function primaryParamOf(def: HfAudioFxDef): string | null {
43
+ const primary = EFFECT_COPY[def.id]?.primary;
44
+ // "strength" names no real parameter, and for the five effects that declare it
45
+ // that is the point: their one knob is derived, and `profileRow` below builds
46
+ // it. Anything else has to be a parameter the effect actually has.
47
+ if (!primary || primary === "strength") return null;
48
+ return def.params.some((p) => p.key === primary) ? primary : null;
49
+ }
50
+
51
+ /**
52
+ * The derived knob, as a parameter the existing controls can render.
53
+ *
54
+ * A profile is not in the registry — it sets five real parameters and is none of
55
+ * them — so this fabricates the one row it needs rather than teaching `FxParams`
56
+ * about a second kind of control. 0..1 in hundredths, the same shape and the
57
+ * same feel as the carve's Strength.
58
+ */
59
+ function profileParam(type: string): HfAudioFxParam | null {
60
+ const profile = getAudioFxProfile(type);
61
+ if (!profile) return null;
62
+ return {
63
+ kind: "number",
64
+ key: "strength",
65
+ label: profile.label,
66
+ unit: "",
67
+ min: 0,
68
+ max: 1,
69
+ step: 0.01,
70
+ default: 0.5,
71
+ hint: `Sets ${profile.derives.length} settings at once. Open Details to see where they land.`,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The registry's definition with the plain names written over it.
77
+ *
78
+ * Over rather than instead of: the registry stays the authority on range, step,
79
+ * unit and what is automatable, and only the words change. A parameter with no
80
+ * copy keeps its own label rather than disappearing — `audioFxCopy.test.ts` is
81
+ * what makes sure there is never one.
82
+ */
83
+ function plainDef(def: HfAudioFxDef): HfAudioFxDef {
84
+ const copy = EFFECT_COPY[def.id];
85
+ if (!copy) return def;
86
+ return {
87
+ ...def,
88
+ params: def.params.map((param) => {
89
+ const plain = copy.params[param.key];
90
+ if (!plain) return param;
91
+ // The registry's hint explains the mechanism, which is still the better
92
+ // tooltip than none — but the plain one wins where it exists.
93
+ return { ...param, label: plain.label, ...(plain.hint ? { hint: plain.hint } : {}) };
94
+ }),
95
+ };
96
+ }
97
+
98
+ interface FxNodeRowProps {
99
+ node: HfAudioFxNode;
100
+ index: number;
101
+ /** Where it sits in the signal path, as the rack counts it. Absent means unnumbered. */
102
+ position?: number;
103
+ automatedTargets?: ReadonlySet<string>;
104
+ liveAutomationValues?: ReadonlyMap<string, number>;
105
+ onAutomateParam?(nodeId: string, paramKey: string): void;
106
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
107
+ open: boolean;
108
+ /** Last in the chain, so it cannot move further down. */
109
+ last: boolean;
110
+ disabled?: boolean;
111
+ onToggleOpen(): void;
112
+ onUpdate(index: number, patch: Partial<HfAudioFxNode>): void;
113
+ onMove(index: number, delta: number): void;
114
+ onRemove(index: number): void;
115
+ onPreview(index: number, params: HfAudioFxParamValues): void;
116
+ /** What the track reads as, carried onto this row own events. */
117
+ trackKind?: string;
118
+ }
119
+
120
+ /** Reorder arrow. Disabled at the end of the chain it would move past. */
121
+ function FxMoveButton({
122
+ label,
123
+ glyph,
124
+ disabled,
125
+ onClick,
126
+ }: {
127
+ label: string;
128
+ glyph: string;
129
+ disabled: boolean;
130
+ onClick(): void;
131
+ }) {
132
+ return (
133
+ <button
134
+ type="button"
135
+ className="hf-fx-move px-1 font-mono text-[10px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-25"
136
+ title={label}
137
+ disabled={disabled}
138
+ onClick={onClick}
139
+ >
140
+ {glyph}
141
+ </button>
142
+ );
143
+ }
144
+
145
+ /** Name, bypass, reorder and remove for one effect. */
146
+ function FxNodeHeader({
147
+ label,
148
+ family,
149
+ position,
150
+ open,
151
+ bypassed,
152
+ first,
153
+ last,
154
+ disabled,
155
+ onToggleOpen,
156
+ onToggleBypass,
157
+ onMove,
158
+ onRemove,
159
+ }: {
160
+ label: string;
161
+ /** How this family letters, so the KIND reads before the word does. */
162
+ family: string;
163
+ position?: number;
164
+ open: boolean;
165
+ bypassed: boolean;
166
+ first: boolean;
167
+ last: boolean;
168
+ disabled?: boolean;
169
+ onToggleOpen(): void;
170
+ onToggleBypass(): void;
171
+ onMove(delta: number): void;
172
+ onRemove(): void;
173
+ }) {
174
+ return (
175
+ <div className="hf-fx-node-head flex min-h-7 items-center gap-1 px-1.5">
176
+ {/* Two digits, because a rack reads as a path when its steps are numbered
177
+ and as a list when they are not — and the difference decides whether an
178
+ author thinks the order matters. It does; it is audible. */}
179
+ {position !== undefined ? (
180
+ <span className="hf-fx-node-index shrink-0 font-mono text-[9px] tabular-nums text-panel-text-2">
181
+ {String(position).padStart(2, "0")}
182
+ </span>
183
+ ) : null}
184
+ <button
185
+ type="button"
186
+ className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
187
+ aria-expanded={open}
188
+ onClick={onToggleOpen}
189
+ >
190
+ {label}
191
+ </button>
192
+ <button
193
+ type="button"
194
+ className="hf-fx-bypass rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
195
+ aria-pressed={bypassed}
196
+ title={bypassed ? "Enable" : "Bypass"}
197
+ disabled={disabled}
198
+ onClick={onToggleBypass}
199
+ >
200
+ {bypassed ? "Off" : "On"}
201
+ </button>
202
+ <FxMoveButton
203
+ label="Move up"
204
+ glyph="&uarr;"
205
+ disabled={Boolean(disabled) || first}
206
+ onClick={() => onMove(-1)}
207
+ />
208
+ <FxMoveButton
209
+ label="Move down"
210
+ glyph="&darr;"
211
+ disabled={Boolean(disabled) || last}
212
+ onClick={() => onMove(1)}
213
+ />
214
+ <button
215
+ type="button"
216
+ className="hf-fx-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
217
+ title="Remove"
218
+ disabled={disabled}
219
+ onClick={onRemove}
220
+ >
221
+ &times;
222
+ </button>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ /** One effect in the chain: its header controls, and its knobs when open. */
228
+ export function FxNodeRow({
229
+ node,
230
+ index,
231
+ position,
232
+ automatedTargets,
233
+ liveAutomationValues,
234
+ onAutomateParam,
235
+ onRemoveParamAutomation,
236
+ open,
237
+ last,
238
+ disabled,
239
+ onToggleOpen,
240
+ onUpdate,
241
+ onMove,
242
+ onRemove,
243
+ onPreview,
244
+ trackKind,
245
+ }: FxNodeRowProps) {
246
+ const registryDef = getAudioFxDef(node.type);
247
+ const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
248
+ const primary = registryDef ? primaryParamOf(registryDef) : null;
249
+ /**
250
+ * The derived knob, for a module with no real parameter that can be its face.
251
+ *
252
+ * It behaves like the primary one everywhere below — one control on the open
253
+ * face, everything it sets behind Details — so the two are the same shape and
254
+ * only their source differs.
255
+ */
256
+ const profile = getAudioFxProfile(node.type);
257
+ const derived = useMemo(() => profileParam(node.type), [node.type]);
258
+ /** The same def cut down to the one knob, so the open face reuses every wire. */
259
+ const onlyPrimary = useMemo(
260
+ () => (def && primary ? { ...def, params: def.params.filter((p) => p.key === primary) } : def),
261
+ [def, primary],
262
+ );
263
+ // Local, because nothing outside the row needs to know. Keyed by node id like
264
+ // the row itself, so it stays with its effect across a reorder.
265
+ const [details, setDetails] = useState(false);
266
+ if (!registryDef || !def || !onlyPrimary) return null;
267
+ const oneKnob = primary !== null || derived !== null;
268
+ const copy = EFFECT_COPY[node.type];
269
+ const bypassed = node.enabled === false;
270
+ const params = node.params ?? defaultAudioFxParams(node.type);
271
+ // What this effect is doing to the sound, as a sentence. The rack is read top
272
+ // to bottom far more often than any one module is opened, so this is the line
273
+ // that decides whether an author can follow their own mix.
274
+ const summary = SUMMARY[node.type]?.(params);
275
+ return (
276
+ <div
277
+ className={`hf-fx-node rounded-[4px] border border-l-2 border-panel-border-input${bypassed ? " opacity-50" : ""}`}
278
+ data-fx-node={node.type}
279
+ data-fx-family={fxFamilyOf(node)}
280
+ // The tint is on the edge rather than the text: the name already carries
281
+ // the family in its lettering, and colouring it too would fight the
282
+ // panel's own tokens for automated and bypassed.
283
+ style={{ borderLeftColor: fxFamilyTint(node) }}
284
+ >
285
+ <FxNodeHeader
286
+ family={FX_FAMILY_TYPE[fxFamilyOf(node)]}
287
+ position={position}
288
+ // The node's own job name when a preset gave it one, because that is the
289
+ // most specific truth available: a chain that cuts mud and then lifts
290
+ // clarity must not show the same name twice. Then the plain name, and
291
+ // the registry's only if an effect somehow has no copy.
292
+ label={node.label ?? EFFECT_COPY[node.type]?.title ?? registryDef.label}
293
+ open={open}
294
+ bypassed={bypassed}
295
+ first={index === 0}
296
+ last={last}
297
+ disabled={disabled}
298
+ onToggleOpen={onToggleOpen}
299
+ onToggleBypass={() => {
300
+ trackNodeBypassed(node.type, !bypassed, { trackKind });
301
+ onUpdate(index, { enabled: bypassed });
302
+ }}
303
+ onMove={(delta) => onMove(index, delta)}
304
+ onRemove={() => onRemove(index)}
305
+ />
306
+ {summary ? (
307
+ <p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2">
308
+ {summary}
309
+ </p>
310
+ ) : null}
311
+ {open ? (
312
+ <FxNodeOpenBody
313
+ node={node}
314
+ registryDef={registryDef}
315
+ def={def}
316
+ onlyPrimary={onlyPrimary}
317
+ primary={primary}
318
+ derived={derived}
319
+ profile={profile}
320
+ oneKnob={oneKnob}
321
+ details={details}
322
+ onToggleDetails={() => setDetails((was) => !was)}
323
+ copy={copy}
324
+ params={params}
325
+ index={index}
326
+ disabled={disabled}
327
+ bypassed={bypassed}
328
+ automatedTargets={automatedTargets}
329
+ liveAutomationValues={liveAutomationValues}
330
+ onUpdate={onUpdate}
331
+ onPreview={onPreview}
332
+ onAutomateParam={onAutomateParam}
333
+ onRemoveParamAutomation={onRemoveParamAutomation}
334
+ trackKind={trackKind}
335
+ />
336
+ ) : null}
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The preset shelf for the FX rack.
3
+ *
4
+ * Its own module rather than another block inside the section: the section is
5
+ * already the largest file in the panel, and this surface is going to grow —
6
+ * search, per-item descriptions and a preview of the chain each preset draws
7
+ * are all queued behind it.
8
+ */
9
+
10
+ import {
11
+ audioFxPresetsByFamily,
12
+ HF_AUDIO_FX_PRESET_FAMILIES,
13
+ type HfAudioFxPresetFamily,
14
+ } from "@hyperframes/core/audio-fx-presets";
15
+ import { PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
16
+ import { useRef } from "react";
17
+ import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
18
+
19
+ /**
20
+ * Shelf names in the author's language, which is deliberately not the effect
21
+ * registry's grouping. `group` says what an effect *is* (filter, dynamics);
22
+ * somebody reaching for Telephone is shopping for what they *want*, and
23
+ * Telephone is filters and saturation — nobody looks for it under either.
24
+ */
25
+ const FAMILY_LABEL: Record<HfAudioFxPresetFamily, string> = {
26
+ voice: "Voice",
27
+ repair: "Fix",
28
+ character: "Character",
29
+ space: "Space",
30
+ };
31
+
32
+ export interface FxPresetMenuProps {
33
+ /**
34
+ * What the track reads as, from its id and filename.
35
+ *
36
+ * Only a confident "music" or "sfx" hides anything. `unknown` keeps the whole
37
+ * shelf, on the same principle the carve's source picker follows: a name is a
38
+ * hint, and a shelf that hides what somebody came for is worse than a long
39
+ * one. Nothing here is irreversible either — the cost of a wrong guess is one
40
+ * shelf the author has to scroll past, against the cost of "My voice sounds
41
+ * amateur" sitting on a music bed.
42
+ */
43
+ trackKind?: HfAudioNameKind;
44
+ onPick(id: string): void;
45
+ /**
46
+ * Report that a preset was auditioned. Called at most once per preset for as
47
+ * long as this shelf stays mounted — a pointer crossing the column passes a
48
+ * dozen items in a second, and counting every crossing would describe mouse
49
+ * travel rather than interest, while drowning every other event in the rack.
50
+ */
51
+ onAuditionTracked?(id: string): void;
52
+ /**
53
+ * Play this preset on the running audio without persisting it, and revert on
54
+ * `null`. Absent when there is no preview channel to hear it through.
55
+ */
56
+ onAudition?(id: string | null): void;
57
+ }
58
+
59
+ /**
60
+ * Presets read as the complaint they answer, not as their own names.
61
+ *
62
+ * The name is a thing you have to already know — "Telephone", "Broadcast" — and
63
+ * the author arriving here does not know it; they know their voice sounds
64
+ * boomy. So the sentence leads and the name follows underneath, which is also
65
+ * how the name gets learned. The four shelves stay because eighteen sentences
66
+ * in a column is a wall, and they are already the author's grouping rather than
67
+ * the registry's. See `plans/audio-fx-ux/README.md` §Decided.
68
+ */
69
+ export function FxPresetMenu({
70
+ trackKind,
71
+ onPick,
72
+ onAudition,
73
+ onAuditionTracked,
74
+ }: FxPresetMenuProps) {
75
+ // Mounted when the shelf opens and thrown away when it closes, so "once per
76
+ // preset" resets each time the author comes back — a second visit is a second
77
+ // look, not a duplicate of the first.
78
+ const auditioned = useRef(new Set<string>());
79
+ const reportAudition = (id: string) => {
80
+ if (auditioned.current.has(id)) return;
81
+ auditioned.current.add(id);
82
+ onAuditionTracked?.(id);
83
+ };
84
+ // The voice presets all begin by cutting rumble out of a human voice and end
85
+ // in a compressor set for speech. On a music bed that is not a mild mismatch,
86
+ // it is the wrong instrument — and the shelf leads with the complaint, so it
87
+ // would be offering the author a problem they do not have.
88
+ const families =
89
+ trackKind === "music" || trackKind === "sfx"
90
+ ? HF_AUDIO_FX_PRESET_FAMILIES.filter((f) => f !== "voice")
91
+ : HF_AUDIO_FX_PRESET_FAMILIES;
92
+ return (
93
+ <div
94
+ className="hf-fx-preset-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
95
+ // One handler for the shelf rather than one per button: leaving any preset
96
+ // for the gap between two of them has to revert, and a per-button leave
97
+ // fires that on the way to the next one.
98
+ onMouseLeave={onAudition ? () => onAudition(null) : undefined}
99
+ // Focus leaving the shelf is the keyboard's version of the pointer leaving
100
+ // it. Moving between two buttons inside fires this and then the next
101
+ // button's focus, so it reverts and re-auditions rather than sticking.
102
+ onBlur={onAudition ? () => onAudition(null) : undefined}
103
+ >
104
+ {families.map((family) => (
105
+ <div key={family} className="hf-fx-preset-group space-y-0.5">
106
+ <span className="hf-fx-preset-group-label block font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
107
+ {FAMILY_LABEL[family]}
108
+ </span>
109
+ {audioFxPresetsByFamily(family).map((preset) => (
110
+ <button
111
+ key={preset.id}
112
+ type="button"
113
+ // `pr-8` so a long complaint does not run under the wave.
114
+ className="hf-fx-preset-item block w-full rounded-[3px] bg-panel-surface py-1 pl-1.5 pr-8 text-left text-panel-text-1 hover:text-panel-text-0"
115
+ // The description says what it does; the count is doing real work
116
+ // — it tells the author a preset IS a chain they can open and
117
+ // edit, rather than an opaque setting they cannot follow.
118
+ title={`${preset.description} (${preset.nodes.length} effect${
119
+ preset.nodes.length === 1 ? "" : "s"
120
+ })`}
121
+ onClick={() => onPick(preset.id)}
122
+ onMouseEnter={() => {
123
+ reportAudition(preset.id);
124
+ onAudition?.(preset.id);
125
+ }}
126
+ // Keyboard reaches this too: arrowing down the shelf auditions the
127
+ // same way hovering does, or the whole affordance is mouse-only.
128
+ onFocus={() => {
129
+ reportAudition(preset.id);
130
+ onAudition?.(preset.id);
131
+ }}
132
+ >
133
+ <span className="hf-fx-preset-problem block truncate text-[10px]">
134
+ {PRESET_PROBLEM[preset.id] ?? preset.description}
135
+ </span>
136
+ <span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-2">
137
+ {preset.label}
138
+ </span>
139
+ {/* Hovering a preset plays it, and playing is otherwise invisible:
140
+ the panel looks identical whether the audition is sounding or
141
+ the pointer is just resting there. Only rendered when there IS
142
+ an audition channel, so it never claims audio that is not
143
+ happening. Four bars because that reads as a level meter at
144
+ this size; three reads as an ellipsis. */}
145
+ {onAudition ? (
146
+ <span className="hf-fx-preset-wave" aria-hidden="true">
147
+ <span />
148
+ <span />
149
+ <span />
150
+ <span />
151
+ </span>
152
+ ) : null}
153
+ </button>
154
+ ))}
155
+ </div>
156
+ ))}
157
+ </div>
158
+ );
159
+ }