@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,550 @@
1
+ /**
2
+ * The FX section for an audio element: the chain, plus the voiceover carve.
3
+ *
4
+ * The carve is its own module — see `propertyPanelFxCarveModule.tsx` for why it
5
+ * is not an entry in the chain.
6
+ */
7
+
8
+ import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
9
+ import {
10
+ defaultAudioFxParams,
11
+ mintAudioFxNodeId,
12
+ type HfAudioFxChain,
13
+ type HfAudioFxNode,
14
+ type HfAudioFxParamValues,
15
+ } from "@hyperframes/core/audio-fx";
16
+ import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
17
+ import {
18
+ addAudioEq,
19
+ audioEqIds,
20
+ removeAudioEq,
21
+ setAudioEqBandGain,
22
+ } from "@hyperframes/core/audio-fx-eq";
23
+ import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
24
+ import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
25
+ import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
26
+ import { FxRackChain } from "./propertyPanelFxRackChain.js";
27
+ import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
28
+ import { useFxAudition } from "./useFxAudition.js";
29
+ import {
30
+ nodeOrigin,
31
+ trackNodeAdded,
32
+ trackNodeMoved,
33
+ trackNodeRemoved,
34
+ trackPresetApplied,
35
+ trackPresetAuditioned,
36
+ trackPresetAutomated,
37
+ trackPresetRemoved,
38
+ } from "./audioFxTelemetry.js";
39
+ import type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
40
+
41
+ export type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
42
+
43
+ /**
44
+ * One effect appended, at the values its module opens on.
45
+ *
46
+ * For most effects that is the registry's defaults. For the five with a
47
+ * derived knob it is NOT: the registry defaults are not a point on the
48
+ * profile's curve, so the module opened reading a strength it was not set to —
49
+ * a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB,
50
+ * which is the "quieter as you turn it up" bug the profiles exist to prevent,
51
+ * on the very first frame. Seeding through the profile puts the knob and the
52
+ * mechanism in agreement from the start.
53
+ */
54
+ function withEffect(base: HfAudioFxChain, type: string): HfAudioFxChain {
55
+ return {
56
+ ...base,
57
+ nodes: [
58
+ ...base.nodes,
59
+ {
60
+ type,
61
+ id: mintAudioFxNodeId(base),
62
+ enabled: true,
63
+ params: getAudioFxProfile(type)
64
+ ? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type))
65
+ : defaultAudioFxParams(type),
66
+ },
67
+ ],
68
+ };
69
+ }
70
+
71
+ /** The same, for a job — an ordinary node that arrives already named and aimed. */
72
+ function withJob(base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain {
73
+ return { ...base, nodes: [...base.nodes, audioFxJobNode(job, base)] };
74
+ }
75
+
76
+ // The preset-run card, the add shelf and the audition machinery are already
77
+ // their own files; what is left is the section deciding which of them to show.
78
+ // fallow-ignore-next-line complexity
79
+ export function FxSection({
80
+ chain,
81
+ automatedTargets,
82
+ liveAutomationValues,
83
+ onAutomateParam,
84
+ onRemoveParamAutomation,
85
+ onRemoveNodeAutomation,
86
+ onRemoveNodesAutomation,
87
+ onChainChange,
88
+ onChainPreview,
89
+ carve,
90
+ carvedAgainstBy,
91
+ onCarveChange,
92
+ onCarvePreview,
93
+ sourceOptions,
94
+ trackKind,
95
+ analysing,
96
+ disabled,
97
+ onLevel,
98
+ onRemoveLevel,
99
+ levelled,
100
+ onAuditionLevel,
101
+ auditioningLevel,
102
+ onAutomatePreset,
103
+ onRemovePresetAutomation,
104
+ automatedPresets,
105
+ onAuditionTransport,
106
+ }: FxSectionProps) {
107
+ const presetAutomated = automatedPresets ?? new Set<string>();
108
+ // Falls back to the persisting write when no preview handler is supplied, which
109
+ // keeps the control working rather than going dead.
110
+ const previewCarve = onCarvePreview ?? onCarveChange;
111
+
112
+ // Nothing to carve against means nothing to show — see the block below.
113
+ // Not offered on the voice another track is already carving against — that
114
+ // track is the far end of someone else's relationship, and a carve of its own
115
+ // could only name a source it must not.
116
+ const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
117
+
118
+ const [adding, setAdding] = useState(false);
119
+ const [picking, setPicking] = useState(false);
120
+ const [openNode, setOpenNode] = useState<number | null>(0);
121
+
122
+ const mutate = useCallback(
123
+ (nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }),
124
+ [chain, onChainChange],
125
+ );
126
+
127
+ // Dragging a knob previews without persisting; releasing it commits once.
128
+ const previewNode = useCallback(
129
+ (index: number, params: HfAudioFxParamValues) =>
130
+ onChainPreview?.({
131
+ ...chain,
132
+ nodes: chain.nodes.map((n, i) => (i === index ? { ...n, params } : n)),
133
+ }),
134
+ [chain, onChainPreview],
135
+ );
136
+
137
+ const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
138
+
139
+ const applyPreset = useCallback(
140
+ (id: string) => {
141
+ const preset = getAudioFxPreset(id);
142
+ if (!preset) return;
143
+ // Appends. Stacking a character preset onto an already-cleaned voice is a
144
+ // real thing to want, and replacing silently would throw work away — so
145
+ // the destructive option is a separate gesture, not the default one.
146
+ const next = applyAudioFxPreset(chain, preset);
147
+ // Re-applying replaces this preset's own nodes in place rather than
148
+ // appending a second copy, and the two are different decisions — worth
149
+ // telling apart in the numbers.
150
+ const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
151
+ trackPresetApplied(
152
+ preset.id,
153
+ preset.family,
154
+ preset.nodes.length,
155
+ reapply ? "reapply" : "append",
156
+ { trackKind },
157
+ );
158
+ // The audition WAS this, so there is nothing to put back — and putting the
159
+ // old chain back over the write that just landed is a race the author
160
+ // hears as the preset arriving and then leaving again.
161
+ clearAudition();
162
+ mutate(next.nodes);
163
+ // Land on the first node the preset wrote, so the author can hear what
164
+ // arrived and immediately see what it is made of.
165
+ setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id));
166
+ setPicking(false);
167
+ },
168
+ [chain, mutate, clearAudition, trackKind],
169
+ );
170
+
171
+ const addJob = useCallback(
172
+ (job: HfAudioFxJob) => {
173
+ clearAudition();
174
+ trackNodeAdded(job.type, "job", job.id, { trackKind });
175
+ mutate(withJob(chain, job).nodes);
176
+ setOpenNode(chain.nodes.length);
177
+ setAdding(false);
178
+ },
179
+ [chain, mutate, clearAudition, trackKind],
180
+ );
181
+
182
+ const addEffect = useCallback(
183
+ (type: string) => {
184
+ clearAudition();
185
+ trackNodeAdded(type, "effect", null, { trackKind });
186
+ mutate(withEffect(chain, type).nodes);
187
+ setOpenNode(chain.nodes.length);
188
+ setAdding(false);
189
+ },
190
+ [chain, mutate, clearAudition, trackKind],
191
+ );
192
+
193
+ const updateNode = useCallback(
194
+ (index: number, patch: Partial<HfAudioFxNode>) =>
195
+ mutate(chain.nodes.map((n, i) => (i === index ? { ...n, ...patch } : n))),
196
+ [chain.nodes, mutate],
197
+ );
198
+
199
+ /**
200
+ * How much of a preset is applied, 0..1.
201
+ *
202
+ * The switch and the lane are the same value, not two ways of silencing a
203
+ * preset: `presetAmount` drives the wet/dry blend the graph wraps the run in,
204
+ * so Off is amount 0 and a lane ramping 0 → 1 is the same control moving
205
+ * continuously. Writing `enabled` instead would take the nodes out of the
206
+ * graph, which a lane cannot do part-way and cannot do without a rebuild.
207
+ *
208
+ * On every node of the run because that is where the chain can hold it — see
209
+ * `HfAudioFxNode.presetAmount`.
210
+ */
211
+ const setRunAmount = useCallback(
212
+ (items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => {
213
+ const slots = new Set(items.map((item) => item.i));
214
+ const next = {
215
+ ...chain,
216
+ nodes: chain.nodes.map((n, i) => (slots.has(i) ? { ...n, presetAmount: amount } : n)),
217
+ };
218
+ if (persist) mutate(next.nodes);
219
+ else onChainPreview?.(next);
220
+ },
221
+ [chain, mutate, onChainPreview],
222
+ );
223
+
224
+ /**
225
+ * Take a preset back out whole, lanes and all.
226
+ *
227
+ * Same contract as removing one node — an orphaned lane keeps driving a
228
+ * parameter that is no longer in the graph, and with ids minted lowest-free
229
+ * the next effect added inherits it.
230
+ */
231
+ const removeRun = useCallback(
232
+ (items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
233
+ // One call, not a loop: every write is computed from the same snapshot and
234
+ // replaces the whole attribute, so a loop kept only its last write and left
235
+ // the other nodes' lanes behind as orphans. The preset id goes with it —
236
+ // the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
237
+ // so nothing else would ever collect it, and re-applying the preset later
238
+ // resurrected the old ramp.
239
+ const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
240
+ if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
241
+ if (presetId) trackPresetRemoved(presetId, { trackKind });
242
+ const slots = new Set(items.map((item) => item.i));
243
+ mutate(chain.nodes.filter((_, i) => !slots.has(i)));
244
+ setOpenNode(null);
245
+ },
246
+ [chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
247
+ );
248
+
249
+ const removeNode = useCallback(
250
+ (index: number) => {
251
+ // The node's lanes go with it. `resolveAutomation` only hides an orphan at
252
+ // read time; left in the attribute, and with ids minted lowest-free, the
253
+ // next effect added takes the same id and inherits the dead envelope —
254
+ // arriving with its control disabled and "Automated" without the author
255
+ // ever automating it, and baked into the render.
256
+ const removed = chain.nodes[index];
257
+ const removedId = removed?.id;
258
+ if (removedId) onRemoveNodeAutomation?.(removedId);
259
+ if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
260
+ mutate(chain.nodes.filter((_, i) => i !== index));
261
+ setOpenNode(null);
262
+ },
263
+ [chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
264
+ );
265
+
266
+ // Open by default: the module is the carve's whole control surface now, and a
267
+ // collapsed card would hide the knob the author came here for.
268
+ const [carveOpen, setCarveOpen] = useState(true);
269
+ const carveNodes = useMemo(() => chain.nodes.filter((n) => n.fromCarve), [chain.nodes]);
270
+ /** Everything the author added, with the chain index every edit addresses. */
271
+ const handBuilt = useMemo(
272
+ () =>
273
+ chain.nodes
274
+ .map((node, i) => ({ node, i }))
275
+ // Carve and EQ bands belong to their own modules; showing them here too
276
+ // would put the same filter on screen twice with two ways to edit it.
277
+ .filter(({ node }) => !node.fromCarve && !node.fromEq),
278
+ [chain.nodes],
279
+ );
280
+
281
+ /**
282
+ * The hand-built list cut into runs, so a preset reads as one thing.
283
+ *
284
+ * Applying a preset drops five rows into the rack with nothing saying they
285
+ * arrived together — which is the same failure the carve module was built to
286
+ * fix, one level down. Consecutive only: a preset whose nodes have been pulled
287
+ * apart by a reorder is no longer a unit, and drawing a bracket around the gap
288
+ * would claim an adjacency the signal path does not have.
289
+ */
290
+ const runs = useMemo(() => {
291
+ const out: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[] = [];
292
+ for (const item of handBuilt) {
293
+ const preset = item.node.fromPreset;
294
+ const last = out.at(-1);
295
+ if (last && last.preset === preset) last.items.push(item);
296
+ else out.push({ ...(preset ? { preset } : {}), items: [item] });
297
+ }
298
+ return out;
299
+ }, [handBuilt]);
300
+
301
+ /**
302
+ * Preset runs the author has folded shut.
303
+ *
304
+ * A preset is one thing they added, and once it is set the seven modules
305
+ * inside are detail — a rack with two presets in it was thirteen cards deep
306
+ * before anything hand-built appeared. Collapsed by id rather than by index so
307
+ * it survives a reorder, and open by default: a preset that arrives already
308
+ * hidden is one nobody learns is a chain they can edit.
309
+ */
310
+ const [collapsedRuns, setCollapsedRuns] = useState<ReadonlySet<string>>(new Set());
311
+
312
+ const eqIds = useMemo(() => audioEqIds(chain), [chain]);
313
+
314
+ /**
315
+ * The number each row wears, counted over what the rack actually shows.
316
+ *
317
+ * Not the chain index: the carve's filters and an EQ's bands are inside their
318
+ * own modules, so counting raw nodes would leave the visible rack jumping from
319
+ * 02 to 07 and the numbers would look like a bug rather than a position.
320
+ */
321
+ const positions = useMemo(() => {
322
+ const map = new Map<number, number>();
323
+ let at = (showCarve ? 1 : 0) + eqIds.length;
324
+ for (const { i } of handBuilt) map.set(i, ++at);
325
+ return map;
326
+ }, [handBuilt, eqIds.length, showCarve]);
327
+ const [openEq, setOpenEq] = useState<string | null>(null);
328
+
329
+ const addEq = useCallback(() => {
330
+ clearAudition();
331
+ const { chain: next, eqId } = addAudioEq(chain);
332
+ trackNodeAdded("eq", "eq", null, { trackKind });
333
+ mutate(next.nodes);
334
+ setOpenEq(eqId);
335
+ setAdding(false);
336
+ }, [chain, mutate, clearAudition, trackKind]);
337
+
338
+ // Dragging a fader is heard immediately and written once on release, the same
339
+ // split every other control in the rack uses.
340
+ const previewEqBand = useCallback(
341
+ (eqId: string, band: string, gain: number) =>
342
+ onChainPreview?.(setAudioEqBandGain(chain, eqId, band, gain)),
343
+ [chain, onChainPreview],
344
+ );
345
+ const commitEqBand = useCallback(
346
+ (eqId: string, band: string, gain: number) =>
347
+ mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes),
348
+ [chain, mutate],
349
+ );
350
+ const removeEq = useCallback(
351
+ (eqId: string) => {
352
+ // Batched for the same reason as `removeRun` — this loop had the identical
353
+ // last-write-wins bug and was only unreachable because an EQ band row
354
+ // offers no automation toggle today.
355
+ const ids = chain.nodes
356
+ .filter((node) => node.fromEq === eqId)
357
+ .map((node) => node.id)
358
+ .filter((id): id is string => Boolean(id));
359
+ if (ids.length > 0) onRemoveNodesAutomation?.(ids);
360
+ mutate(removeAudioEq(chain, eqId).nodes);
361
+ },
362
+ [chain, mutate, onRemoveNodesAutomation],
363
+ );
364
+
365
+ const moveNode = useCallback(
366
+ (index: number, delta: number) => {
367
+ const target = index + delta;
368
+ if (target < 0 || target >= chain.nodes.length) return;
369
+ const next = [...chain.nodes];
370
+ const [moved] = next.splice(index, 1);
371
+ next.splice(target, 0, moved!);
372
+ if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
373
+ mutate(next);
374
+ setOpenNode(target);
375
+ },
376
+ [chain.nodes, mutate, trackKind],
377
+ );
378
+
379
+ /**
380
+ * Escape closes whichever menu is open.
381
+ *
382
+ * The first thing anyone reaches for, and on a surface that covers the rack it
383
+ * is the one that needs no discovering. Bound on the section rather than the
384
+ * window: a keystroke aimed at the timeline is not aimed at this.
385
+ */
386
+ const closeMenus = useCallback(
387
+ (event: KeyboardEvent) => {
388
+ if (event.key !== "Escape" || (!adding && !picking)) return;
389
+ // Stops the panel's own Escape handling from also firing — closing a menu
390
+ // and deselecting the clip on one keystroke loses the author their place.
391
+ event.stopPropagation();
392
+ audition(null);
393
+ onAuditionLevel?.(false);
394
+ setAdding(false);
395
+ setPicking(false);
396
+ },
397
+ [adding, picking, audition, onAuditionLevel],
398
+ );
399
+
400
+ /** Seed a lane for a run's preset amount, or omit the control when one already exists. */
401
+ const presetAutomateHandler = (
402
+ presetId: string | undefined,
403
+ amount: number,
404
+ ): (() => void) | undefined => {
405
+ if (!presetId || !onAutomatePreset || presetAutomated.has(presetId)) return undefined;
406
+ return () => {
407
+ trackPresetAutomated(presetId, true, { trackKind });
408
+ onAutomatePreset(presetId, amount);
409
+ };
410
+ };
411
+
412
+ /** Delete a run's preset-amount lane, or omit the control when there is none. */
413
+ const presetRemoveAutomationHandler = (
414
+ presetId: string | undefined,
415
+ ): (() => void) | undefined => {
416
+ if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
417
+ return () => onRemovePresetAutomation(presetId);
418
+ };
419
+
420
+ return (
421
+ <div
422
+ className="hf-fx-section space-y-2"
423
+ // Focus lives on the buttons and menu items inside, so the keystroke
424
+ // bubbles to here without the section needing focus of its own.
425
+ onKeyDown={closeMenus}
426
+ >
427
+ <FxRackChain
428
+ chain={chain}
429
+ showCarve={showCarve}
430
+ carveNodes={carveNodes}
431
+ carve={carve}
432
+ sourceOptions={sourceOptions}
433
+ automatedTargets={automatedTargets}
434
+ liveAutomationValues={liveAutomationValues}
435
+ carveOpen={carveOpen}
436
+ disabled={disabled}
437
+ analysing={analysing}
438
+ onToggleCarveOpen={() => setCarveOpen((was) => !was)}
439
+ onCarveChange={onCarveChange}
440
+ onCarvePreview={previewCarve}
441
+ eqIds={eqIds}
442
+ openEq={openEq}
443
+ onToggleEq={(eqId) => setOpenEq((was) => (was === eqId ? null : eqId))}
444
+ onPreviewEqBand={previewEqBand}
445
+ onCommitEqBand={commitEqBand}
446
+ onRemoveEq={removeEq}
447
+ handBuiltCount={handBuilt.length}
448
+ runs={runs}
449
+ positions={positions}
450
+ openNode={openNode}
451
+ onToggleOpenNode={(i) => setOpenNode(openNode === i ? null : i)}
452
+ onUpdateNode={updateNode}
453
+ onMoveNode={moveNode}
454
+ onRemoveNode={removeNode}
455
+ onPreviewNode={previewNode}
456
+ trackKind={trackKind}
457
+ collapsedRuns={collapsedRuns}
458
+ onToggleCollapse={(runKey) =>
459
+ setCollapsedRuns((was) => {
460
+ const next = new Set(was);
461
+ if (was.has(runKey)) next.delete(runKey);
462
+ else next.add(runKey);
463
+ return next;
464
+ })
465
+ }
466
+ onSetRunAmount={setRunAmount}
467
+ onRemoveRun={removeRun}
468
+ onAutomateParam={onAutomateParam}
469
+ onRemoveParamAutomation={onRemoveParamAutomation}
470
+ presetAutomated={presetAutomated}
471
+ presetAutomateHandler={presetAutomateHandler}
472
+ presetRemoveAutomationHandler={presetRemoveAutomationHandler}
473
+ />
474
+
475
+ {adding ? (
476
+ <FxAddMenu
477
+ disabled={disabled}
478
+ analysing={analysing}
479
+ levelled={levelled}
480
+ auditioningLevel={auditioningLevel}
481
+ onLevel={onLevel}
482
+ onRemoveLevel={onRemoveLevel}
483
+ onEq={addEq}
484
+ onJob={addJob}
485
+ onEffect={addEffect}
486
+ onClose={() => setAdding(false)}
487
+ audition={audition}
488
+ onAuditionLevel={onAuditionLevel}
489
+ withJob={withJob}
490
+ withEffect={withEffect}
491
+ />
492
+ ) : null}
493
+
494
+ {picking ? (
495
+ <FxPresetMenu
496
+ trackKind={trackKind}
497
+ onPick={applyPreset}
498
+ onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
499
+ onAudition={
500
+ onChainPreview
501
+ ? (id) => {
502
+ const preset = id ? getAudioFxPreset(id) : null;
503
+ audition(preset ? (base) => applyAudioFxPreset(base, preset) : null);
504
+ }
505
+ : undefined
506
+ }
507
+ />
508
+ ) : null}
509
+
510
+ {/* The buttons stay while their menu is open, and close it — an author who
511
+ opened one and changed their mind had no way back: picking something
512
+ was the only thing that set these false, so the only exits were adding
513
+ an effect they did not want or deselecting the clip. */}
514
+ <div className="flex gap-1">
515
+ <button
516
+ type="button"
517
+ className="hf-fx-preset w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
518
+ aria-expanded={picking}
519
+ disabled={disabled}
520
+ onClick={() => {
521
+ // Leaving the shelf by closing it is still leaving it, and an
522
+ // audition left playing is audible over a chain the document does
523
+ // not have.
524
+ if (picking) audition(null);
525
+ setPicking(!picking);
526
+ setAdding(false);
527
+ }}
528
+ >
529
+ {picking ? "Close" : "Presets"}
530
+ </button>
531
+ <button
532
+ type="button"
533
+ className="hf-fx-add w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
534
+ aria-expanded={adding}
535
+ disabled={disabled}
536
+ onClick={() => {
537
+ if (adding) {
538
+ audition(null);
539
+ onAuditionLevel?.(false);
540
+ }
541
+ setAdding(!adding);
542
+ setPicking(false);
543
+ }}
544
+ >
545
+ {adding ? "Close" : "Add effect"}
546
+ </button>
547
+ </div>
548
+ </div>
549
+ );
550
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `FxSection`'s prop contract, split out of `propertyPanelFxSection.tsx` so the
3
+ * component file is mostly logic and JSX rather than documentation.
4
+ */
5
+
6
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
7
+ import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
8
+ import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
9
+
10
+ export interface FxSectionProps {
11
+ chain: HfAudioFxChain;
12
+ /** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
13
+ automatedTargets?: ReadonlySet<string>;
14
+ /**
15
+ * What each automated target is worth at the playhead, by the same key.
16
+ *
17
+ * An automated parameter's stored number is only the seed the lane replaced, so
18
+ * a rack that shows it stands still while the carve is audibly working. Absent,
19
+ * or missing a key, means there is no playhead over this clip and the stored
20
+ * value is the honest one.
21
+ */
22
+ liveAutomationValues?: ReadonlyMap<string, number>;
23
+ /** Add a lane for one effect parameter, seeded at its current value. */
24
+ onAutomateParam?(nodeId: string, paramKey: string): void;
25
+ /** Delete one effect parameter's lane. */
26
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
27
+ /** Delete every lane belonging to a node that is being removed. */
28
+ onRemoveNodeAutomation?(nodeId: string): void;
29
+ /**
30
+ * Delete the lanes of SEVERAL nodes at once, plus the whole-preset lane when
31
+ * a preset id is given. One call, because each write is computed from the same
32
+ * snapshot and replaces the whole attribute — a loop keeps only its last write.
33
+ */
34
+ onRemoveNodesAutomation?(nodeIds: readonly string[], presetId?: string): void;
35
+ /** Add a lane for a whole preset's amount, seeded where it sits now. */
36
+ onAutomatePreset?(presetId: string, amount: number): void;
37
+ /** Delete that lane. */
38
+ onRemovePresetAutomation?(presetId: string): void;
39
+ /** Presets whose amount a lane already drives. */
40
+ automatedPresets?: ReadonlySet<string>;
41
+ /** Measure this track and write the levelling lane. Absent when unavailable. */
42
+ onLevel?(): void;
43
+ /** Take the levelling stage and its lane back out. */
44
+ onRemoveLevel?(): void;
45
+ /** Whether a levelling stage is already on the track. */
46
+ levelled?: boolean;
47
+ /**
48
+ * Hover-audition of the levelling script: measure this track and play the
49
+ * result without persisting it, and put it back on `false`.
50
+ *
51
+ * Separate from `onChainPreview` because it is the one audition that cannot be
52
+ * synthesised from the chain in hand — the numbers do not exist until the
53
+ * audio has been decoded and measured.
54
+ */
55
+ onAuditionLevel?(on: boolean): void;
56
+ /** Whether that measurement is running, so the button can say so. */
57
+ auditioningLevel?: boolean;
58
+ /**
59
+ * Start the transport for an audition, and stop it on the way out.
60
+ *
61
+ * An audition is written to the running graph, which is silent while the
62
+ * transport is paused — so without this, hovering a preset does nothing at all
63
+ * for a paused author.
64
+ */
65
+ onAuditionTransport?(on: boolean): void;
66
+ /** Structural edits and gesture-end writes; this is the one that persists. */
67
+ onChainChange(chain: HfAudioFxChain): void;
68
+ /** Continuous updates while a control is being dragged. */
69
+ onChainPreview?(chain: HfAudioFxChain): void;
70
+ carve: HfCarveSettings | null;
71
+ /** Gesture-end write; this is the one that persists. */
72
+ onCarveChange(carve: HfCarveSettings | null): void;
73
+ /** Continuous updates while a carve slider is dragged. Without this every
74
+ * pointermove patched the source file and resynced the selection. */
75
+ onCarvePreview?(carve: HfCarveSettings): void;
76
+ /**
77
+ * Set when another track's carve listens to this one, naming it. The carve block
78
+ * is then not offered here at all: this track is the voice, not the bed.
79
+ */
80
+ carvedAgainstBy?: string | null;
81
+ /** Other audio elements that could act as the carve source. */
82
+ sourceOptions: AudioTrackOption[];
83
+ /**
84
+ * What this track reads as, from its id and filename. Passed through to the
85
+ * preset shelf, which hides the Voice family on a track that is plainly music
86
+ * or an effect. Absent means unknown, and unknown keeps everything.
87
+ */
88
+ trackKind?: HfAudioNameKind;
89
+ analysing?: boolean;
90
+ disabled?: boolean;
91
+ }
@@ -58,6 +58,11 @@ export interface PropertyPanelProps {
58
58
  value: string | null,
59
59
  onSettled?: (ok: boolean) => void,
60
60
  ) => void | Promise<void>;
61
+ /** Persists without reloading the preview, but re-reads the selection after —
62
+ * for attributes the runtime applies to the live graph itself, where a reload
63
+ * would only interrupt playback, and where the panel still has to see the
64
+ * value it just wrote to compute the next edit from. */
65
+ onSetAttributeQuiet?: (attr: string, value: string | null) => void | Promise<void>;
61
66
  onApplyColorGradingScope?: (
62
67
  scope: "source-file" | "project",
63
68
  value: string | null,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
- import { isEditableTarget } from "../../utils/timelineDiscovery";
10
+ import { isTypingTarget } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
12
  import type { DomEditSelection } from "./domEditing";
13
13
  import {
@@ -130,7 +130,7 @@ function resolveSingleNudgeTarget(
130
130
  function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
131
131
  if (!p.allowCanvasMovement || event.defaultPrevented) return true;
132
132
  if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
133
- return isEditableTarget(event.target);
133
+ return isTypingTarget(event.target);
134
134
  }
135
135
 
136
136
  export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {