@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,258 @@
1
+ /**
2
+ * Reading an element's automation, shared by the lane UI and the row layout.
3
+ *
4
+ * The attributes are carried on TimelineElement verbatim rather than parsed at
5
+ * the manifest boundary: the lane reads and writes them, and round-tripping
6
+ * through the attribute is what keeps the lane, the property panel and the
7
+ * running audio graph on one source of truth.
8
+ *
9
+ * Both parse the same two attributes: the layout needs the lane count to
10
+ * reserve height, the lanes need the lanes themselves. Parsing is cached by the
11
+ * attribute text so the identity only changes when the text does — the lane's
12
+ * drag draft compares against that identity, and a fresh object on every
13
+ * playhead tick would throw away the drag in progress.
14
+ */
15
+
16
+ import {
17
+ parseAutomation,
18
+ parseAutomationTarget,
19
+ resolveAutomation,
20
+ resolveAutomationRange,
21
+ type HfAutomation,
22
+ type HfAutomationLane,
23
+ } from "@hyperframes/core/audio-automation";
24
+ import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
25
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
26
+ import type { TimelineElement } from "../store/playerStore";
27
+
28
+ const EMPTY: HfAutomation = { version: 1, lanes: [] };
29
+
30
+ const chainCache = new Map<string, HfAudioFxChain | null>();
31
+ const automationCache = new Map<string, HfAutomation>();
32
+ const CACHE_LIMIT = 64;
33
+
34
+ /**
35
+ * Parse once per distinct attribute text, keeping the same object until the text
36
+ * changes — the lane compares its drag draft against that identity.
37
+ *
38
+ * Eviction drops the oldest entry rather than clearing the map: clearing would
39
+ * change the identity of every lane's automation at once, and any lane mid-drag
40
+ * would release its draft and jump back to the stored value.
41
+ */
42
+ function cached<T>(store: Map<string, T>, key: string, build: () => T): T {
43
+ const hit = store.get(key);
44
+ if (hit !== undefined) {
45
+ // Re-insert so the entry counts as recently used.
46
+ store.delete(key);
47
+ store.set(key, hit);
48
+ return hit;
49
+ }
50
+ const value = build();
51
+ if (store.size >= CACHE_LIMIT) {
52
+ const oldest = store.keys().next();
53
+ if (!oldest.done) store.delete(oldest.value);
54
+ }
55
+ store.set(key, value);
56
+ return value;
57
+ }
58
+
59
+ /** The element's FX chain, or null when it has none or it is unreadable. */
60
+ export function elementFxChain(element: TimelineElement): HfAudioFxChain | null {
61
+ const raw = element.fxChain;
62
+ if (!raw) return null;
63
+ return cached(chainCache, raw, () => {
64
+ try {
65
+ return parseAudioFxChain(raw);
66
+ } catch {
67
+ return null;
68
+ }
69
+ });
70
+ }
71
+
72
+ /**
73
+ * The element's automation, bound to its chain the same way preview and the
74
+ * render bind it: a lane whose effect has been deleted is dropped rather than
75
+ * drawn on the wrong axis.
76
+ */
77
+ export function elementAutomation(element: TimelineElement): HfAutomation {
78
+ const raw = element.automation;
79
+ if (!raw) return EMPTY;
80
+ const chain = elementFxChain(element);
81
+ // Both texts key the entry: the resolved lanes depend on the chain too. Joined
82
+ // through a separator no attribute can contain.
83
+ return cached(automationCache, `${raw}\u0000${element.fxChain ?? ""}`, () => {
84
+ try {
85
+ const resolved = resolveAutomation(parseAutomation(raw), chain ?? undefined);
86
+ return { ...resolved, lanes: orderLanes(resolved.lanes, chain) };
87
+ } catch {
88
+ // Unreadable automation draws no lanes rather than breaking the row.
89
+ return EMPTY;
90
+ }
91
+ });
92
+ }
93
+
94
+ /** Lanes in the order they are drawn, one row each. */
95
+ export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
96
+ return elementAutomation(element).lanes;
97
+ }
98
+
99
+ /** The frequency the lane's effect sits at, when it has one. */
100
+ function laneFrequency(target: string, chain: HfAudioFxChain | null): number | null {
101
+ const parsed = parseAutomationTarget(target);
102
+ if (!parsed || parsed.kind !== "fx") return null;
103
+ const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
104
+ const freq = node?.params?.["frequency"];
105
+ return typeof freq === "number" ? freq : null;
106
+ }
107
+
108
+ /**
109
+ * Lane order: the audible spectrum, top down, then everything else.
110
+ *
111
+ * A stack of EQ bands is read as a spectrum, so it has to be laid out like one —
112
+ * high at the top, the way every analyser and every EQ curve is drawn. Attribute
113
+ * order is whatever minted the nodes, which for a carve is ascending: exactly
114
+ * upside down. Lanes with no frequency to place them — a level stage, the track's
115
+ * own volume — keep their written order and sit under the bands, like a fader
116
+ * below the EQ section of a channel strip.
117
+ *
118
+ * Sorted here, in the one function both the canvas lanes and the label column
119
+ * read, because a label whose row disagrees with the envelope it names is worse
120
+ * than either order.
121
+ */
122
+ function orderLanes(lanes: HfAutomationLane[], chain: HfAudioFxChain | null): HfAutomationLane[] {
123
+ const withFreq: { lane: HfAutomationLane; freq: number }[] = [];
124
+ const rest: HfAutomationLane[] = [];
125
+ for (const lane of lanes) {
126
+ const freq = laneFrequency(lane.target, chain);
127
+ if (freq === null) rest.push(lane);
128
+ else withFreq.push({ lane, freq });
129
+ }
130
+ withFreq.sort((a, b) => b.freq - a.freq);
131
+ return [...withFreq.map((e) => e.lane), ...rest];
132
+ }
133
+
134
+ /** A frequency as an author reads it: 400 Hz, 1.6 kHz, 10 kHz. */
135
+ export function formatHz(freq: number): string {
136
+ if (freq < 1000) return `${Math.round(freq)} Hz`;
137
+ const k = freq / 1000;
138
+ return `${k >= 10 ? Math.round(k) : Number(k.toFixed(1))} kHz`;
139
+ }
140
+
141
+ /**
142
+ * What a lane is called in the timeline, as its two lines.
143
+ *
144
+ * `name` is the effect and, when it has one, the frequency it sits at: "Peaking
145
+ * EQ 1.6 kHz". The frequency is what tells two bands apart — three lanes all
146
+ * reading "Peaking EQ" say nothing about which is which — and the effect still
147
+ * has to be named, since a chain mixes filter types and a bare frequency does not
148
+ * say whether it is a bell or a shelf.
149
+ *
150
+ * `param` is which knob the envelope drives, on its own line: a band can carry a
151
+ * gain lane and a Q lane, and stacking the two lines is what keeps a name legible
152
+ * in a column this narrow instead of truncating mid-word.
153
+ *
154
+ * Null when the target does not resolve against the chain — the same condition
155
+ * that stops the lane being drawn at all.
156
+ */
157
+ export function automationLaneLabelParts(
158
+ target: string,
159
+ chain: HfAudioFxChain | null,
160
+ ): { name: string; param: string } | null {
161
+ const range = resolveAutomationRange(target, chain ?? undefined);
162
+ if (!range) return null;
163
+ // The registry's label is "<effect> · <param>", or just "<param>" for volume.
164
+ const parts = range.label.split(" · ");
165
+ const param = parts.at(-1) ?? range.label;
166
+ const effect = parts.length > 1 ? parts.slice(0, -1).join(" · ") : null;
167
+ const freq = laneFrequency(target, chain);
168
+ const name = [effect, freq === null ? null : formatHz(freq)].filter(Boolean).join(" ");
169
+ return { name: name || param, param: name ? param : "" };
170
+ }
171
+
172
+ /**
173
+ * What makes two lanes, on two different clips, the same lane row.
174
+ *
175
+ * A lane is a property over time, not a clip's private strip: four narration slices
176
+ * on one track that each automate a 1 kHz peaking Q belong in ONE row, each drawing
177
+ * its envelope over its own span.
178
+ *
179
+ * The key cannot be the lane target. Targets are `fx.<nodeId>.<param>` and node ids
180
+ * are minted per chain, so `fx.n1.q` on one clip and `fx.n1.q` on another may be
181
+ * different effects entirely — grouping by target would put unrelated envelopes in
182
+ * one row and split matching ones apart. So the key is what identifies the parameter
183
+ * to a reader: the effect, whatever distinguishes it from its siblings (a filter's
184
+ * frequency), and the parameter. Which is exactly what the label already says, so
185
+ * the row's identity and its name cannot drift apart.
186
+ *
187
+ * Null when the target does not resolve, the same condition that stops it drawing.
188
+ */
189
+ export function laneGroupKey(target: string, chain: HfAudioFxChain | null): string | null {
190
+ return automationLaneLabel(target, chain);
191
+ }
192
+
193
+ /** One clip's envelope inside a shared row: the clip, and the lane it draws. */
194
+ export interface AutomationLaneGroupEntry {
195
+ element: TimelineElement;
196
+ lane: HfAutomationLane;
197
+ }
198
+
199
+ /** A lane row on a track: one property, and every clip that automates it. */
200
+ export interface AutomationLaneGroup {
201
+ /** {@link laneGroupKey} — the row's identity, and also its whole label. */
202
+ key: string;
203
+ /** The label's two lines, as {@link automationLaneLabelParts} splits them. */
204
+ name: string;
205
+ param: string;
206
+ /** In track order. A clip that does not automate this property is simply
207
+ * absent, leaving its stretch of the row empty. */
208
+ entries: AutomationLaneGroupEntry[];
209
+ }
210
+
211
+ /**
212
+ * The lane rows a TRACK shows, unioned over the clips sharing it.
213
+ *
214
+ * Several clips on one row (four narration slices, say) each carry their own
215
+ * chain and their own envelopes. Drawing only the selected clip's made a per-clip
216
+ * lane read as governing the whole row, and swapped which envelopes were visible
217
+ * whenever the selection moved. So the row is keyed by the property — a clip
218
+ * draws into the row for `Peaking EQ 1 kHz · Q` over its own span, and clips that
219
+ * automate nothing there leave it empty.
220
+ *
221
+ * Row order is first-seen: each clip's lanes are already in draw order (the
222
+ * spectrum, top down), so the first clip to carry a property fixes its row and
223
+ * later clips only append properties nobody has shown yet.
224
+ *
225
+ * Non-audio elements contribute nothing, matching `automationLaneCountOf` — the
226
+ * row's reserved height and its drawn lanes have to count the same clips.
227
+ */
228
+ export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
229
+ const groups = new Map<string, AutomationLaneGroup>();
230
+ for (const element of elements) {
231
+ if (!isAudioTimelineElement(element)) continue;
232
+ const chain = elementFxChain(element);
233
+ for (const lane of elementAutomationLanes(element)) {
234
+ const key = laneGroupKey(lane.target, chain);
235
+ const parts = automationLaneLabelParts(lane.target, chain);
236
+ // Null on both together: an unresolvable target draws no lane either.
237
+ if (!key || !parts) continue;
238
+ const group = groups.get(key);
239
+ if (group) group.entries.push({ element, lane });
240
+ else {
241
+ groups.set(key, {
242
+ key,
243
+ name: parts.name,
244
+ param: parts.param,
245
+ entries: [{ element, lane }],
246
+ });
247
+ }
248
+ }
249
+ }
250
+ return [...groups.values()];
251
+ }
252
+
253
+ /** The whole label on one line, for a tooltip or an accessible name. */
254
+ export function automationLaneLabel(target: string, chain: HfAudioFxChain | null): string | null {
255
+ const parts = automationLaneLabelParts(target, chain);
256
+ if (!parts) return null;
257
+ return parts.param ? `${parts.name} · ${parts.param}` : parts.name;
258
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Pure position math for the two multi-point drags on an automation lane: a
3
+ * group move (dragging a whole box-selected set by one delta) and a single
4
+ * point's move (the modifiers and neighbour clamp one breakpoint honours).
5
+ *
6
+ * Split out of useAutomationLaneGestures.ts so the hook's own onPointerDown
7
+ * and movePoint stay orchestration — read the pointer, call one of these,
8
+ * apply the result — rather than carrying this branching themselves.
9
+ */
10
+
11
+ import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
12
+ import {
13
+ applyShiftConstraint,
14
+ dominantDragAxis,
15
+ MIN_POINT_GAP_SEC,
16
+ snapLaneTime,
17
+ } from "./automationLaneGeometry";
18
+ import { pointInSelection } from "./automationLaneSelection";
19
+
20
+ /** Snap radius in clip seconds, shared with the single-point and group drags. */
21
+ export const SNAP_SEC = 0.04;
22
+
23
+ export type AutomationSelectionBox = { t0: number; t1: number; v0: number; v1: number };
24
+
25
+ /** Which points a press on `pressed` should drag together: the whole box-selected
26
+ * set if it contains more than one member, otherwise none (an ordinary
27
+ * single-point drag, selection or no). */
28
+ function resolveGroupDragIndices(
29
+ lane: HfAutomationLane,
30
+ pressed: HfAutomationLane["points"][number] | undefined,
31
+ rangeSelection: AutomationSelectionBox | null | undefined,
32
+ ): number[] {
33
+ if (!rangeSelection || !pressed || !pointInSelection(pressed, rangeSelection)) return [];
34
+ return lane.points.flatMap((p, i) => (pointInSelection(p, rangeSelection) ? [i] : []));
35
+ }
36
+
37
+ export interface GroupDragSnapshot {
38
+ points: HfAutomationLane["points"];
39
+ indices: number[];
40
+ anchor: { t: number; v: number };
41
+ selection: AutomationSelectionBox;
42
+ }
43
+
44
+ /** Snapshot to arm a group drag from a press on `pressed`, or null when the
45
+ * press does not land on a multi-point selection — an ordinary single-point
46
+ * drag, selection or no. */
47
+ export function armGroupDrag(
48
+ lane: HfAutomationLane,
49
+ pressed: HfAutomationLane["points"][number] | undefined,
50
+ rangeSelection: AutomationSelectionBox | null | undefined,
51
+ ): GroupDragSnapshot | null {
52
+ const indices = resolveGroupDragIndices(lane, pressed, rangeSelection);
53
+ if (indices.length <= 1 || !pressed) return null;
54
+ return {
55
+ points: lane.points.map((p) => ({ ...p })),
56
+ indices,
57
+ anchor: { t: pressed.t, v: pressed.v },
58
+ selection: rangeSelection ? { ...rangeSelection } : { t0: 0, t1: 0, v0: 0, v1: 0 },
59
+ };
60
+ }
61
+
62
+ export interface GroupMoveResult {
63
+ points: HfAutomationLane["points"];
64
+ selection: AutomationSelectionBox;
65
+ hint: string;
66
+ }
67
+
68
+ /**
69
+ * Move a selected set by one delta, taken from the point under the pointer.
70
+ *
71
+ * The whole group has to stop when its first member reaches a boundary, not
72
+ * each point on its own: clamping individually squashes the shape flat against
73
+ * the edge, and the gesture is meant to preserve it. Deltas are in the
74
+ * parameter's own units, so on a logarithmic axis a group moves by Hz rather
75
+ * than by octaves — the same as dragging one point does.
76
+ */
77
+ export function computeGroupMove(input: {
78
+ group: GroupDragSnapshot;
79
+ raw: { t: number; v: number };
80
+ shiftKey: boolean;
81
+ altKey: boolean;
82
+ range: AutomationRange;
83
+ duration: number;
84
+ snapTimes: readonly number[] | undefined;
85
+ xOf(t: number): number;
86
+ yOf(v: number): number;
87
+ }): GroupMoveResult {
88
+ const { group, raw, shiftKey, altKey, range, duration, snapTimes, xOf, yOf } = input;
89
+ const moving = group.indices.map((i) => group.points[i]!);
90
+ let dt = raw.t - group.anchor.t;
91
+ let dv = raw.v - group.anchor.v;
92
+ if (shiftKey) {
93
+ // The axis lock, as a single-point drag has it: keep whichever the pointer
94
+ // has travelled further along and drop the other.
95
+ const alongTime = Math.abs(dt * (xOf(1) - xOf(0)));
96
+ const alongValue = Math.abs((dv * (yOf(range.min) - yOf(range.max))) / (range.max - range.min));
97
+ if (alongTime >= alongValue) dv = 0;
98
+ else dt = 0;
99
+ } else if (!altKey) {
100
+ // Snap the point under the pointer, and move the set by that same amount.
101
+ const others = group.points.filter((_, i) => !group.indices.includes(i)).map((p) => p.t);
102
+ dt =
103
+ snapLaneTime(group.anchor.t + dt, [...(snapTimes ?? []), ...others], SNAP_SEC) -
104
+ group.anchor.t;
105
+ }
106
+ const times = moving.map((p) => p.t);
107
+ const values = moving.map((p) => p.v);
108
+ // No member may cross a point that is staying put. Only stationary
109
+ // neighbours constrain: two selected points travel together, so the gap
110
+ // between them never changes. Per member rather than per end of the group,
111
+ // because a box can select a non-contiguous set — the peaks of an envelope
112
+ // and not the dip between them.
113
+ const gapTo = (step: 1 | -1): number[] =>
114
+ group.indices.flatMap((i) => {
115
+ const neighbour = group.points[i + step];
116
+ if (!neighbour || group.indices.includes(i + step)) return [];
117
+ // Short of the neighbour, not onto it — the lane collapses points that
118
+ // share a time, and a group drag must not consume what it runs into.
119
+ return [Math.max(0, Math.abs(neighbour.t - group.points[i]!.t) - MIN_POINT_GAP_SEC)];
120
+ });
121
+ dt = Math.min(
122
+ Math.max(dt, -Math.min(Math.min(...times), ...gapTo(-1))),
123
+ Math.min(duration - Math.max(...times), ...gapTo(1)),
124
+ );
125
+ dv = Math.min(Math.max(dv, range.min - Math.min(...values)), range.max - Math.max(...values));
126
+
127
+ // No re-sort: clamped to the neighbours, the lane's order cannot change
128
+ // under a drag, so the point under the pointer keeps its index.
129
+ const points = group.points.map((p, i) =>
130
+ group.indices.includes(i) ? { ...p, t: p.t + dt, v: p.v + dv } : p,
131
+ );
132
+ return {
133
+ points,
134
+ // The box travels with the points — both axes, or a vertical nudge would
135
+ // slide its own points out of the box that caught them and a second nudge
136
+ // would move fewer of them.
137
+ selection: {
138
+ t0: group.selection.t0 + dt,
139
+ t1: group.selection.t1 + dt,
140
+ v0: group.selection.v0 + dv,
141
+ v1: group.selection.v1 + dv,
142
+ },
143
+ hint: `${group.indices.length} points ${dt >= 0 ? "+" : ""}${dt.toFixed(2)}s`,
144
+ };
145
+ }
146
+
147
+ /** Where Shift last locked a single-point drag's axis. */
148
+ export type ShiftAxis = "time" | "value" | null;
149
+
150
+ /** The clip-local time and value a single dragged point should land on,
151
+ * honouring the modifiers held with it and the neighbours it cannot cross. */
152
+ export function computeSinglePointMove(input: {
153
+ raw: { t: number; v: number };
154
+ origin: { t: number; v: number } | null;
155
+ shiftKey: boolean;
156
+ altKey: boolean;
157
+ shiftAxis: ShiftAxis;
158
+ range: AutomationRange;
159
+ duration: number;
160
+ snapTimes: readonly number[] | undefined;
161
+ lane: HfAutomationLane;
162
+ dragIndex: number;
163
+ xOf(t: number): number;
164
+ yOf(v: number): number;
165
+ }): { t: number; v: number; shiftAxis: ShiftAxis } {
166
+ const { raw, origin, shiftKey, altKey, range, duration, snapTimes, lane, dragIndex, xOf, yOf } =
167
+ input;
168
+ let shiftAxis = shiftKey ? input.shiftAxis : null;
169
+ let { t, v } = raw;
170
+ if (shiftKey && origin) {
171
+ shiftAxis ??= dominantDragAxis({ origin, raw, xOf, yOf });
172
+ ({ t, v } = applyShiftConstraint({ range, origin, raw, xOf, yOf, axis: shiftAxis }));
173
+ }
174
+ // Shift is a deliberate free-hand move as much as Alt is, so neither snaps.
175
+ if (!altKey && !shiftKey) {
176
+ const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t);
177
+ t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC);
178
+ }
179
+ // A breakpoint cannot cross another in time, and cannot land exactly on one
180
+ // either: the lane collapses points that share a `t`, so arriving on top of a
181
+ // neighbour deletes it. It stops a hair short instead, which reads as touching
182
+ // and keeps both points. Applied after the snap, which can itself put the
183
+ // point on a beat past a neighbour.
184
+ const floor = (lane.points[dragIndex - 1]?.t ?? -Infinity) + MIN_POINT_GAP_SEC;
185
+ const ceiling = (lane.points[dragIndex + 1]?.t ?? Infinity) - MIN_POINT_GAP_SEC;
186
+ const held = lane.points[dragIndex]?.t ?? t;
187
+ t =
188
+ ceiling >= floor
189
+ ? Math.min(ceiling, Math.max(floor, Math.min(duration, Math.max(0, t))))
190
+ : // Neighbours closer together than the gap leave nowhere to go, so the
191
+ // point stays where it is rather than being flung to one side.
192
+ held;
193
+ return { t, v, shiftAxis };
194
+ }
@@ -0,0 +1,239 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ applyShiftConstraint,
5
+ automationTargets,
6
+ curveForDrag,
7
+ fromUnit,
8
+ snapLaneTime,
9
+ toUnit,
10
+ } from "./automationLaneGeometry";
11
+ import { applyCurve, sampleAutomationLane } from "@hyperframes/core/audio-automation";
12
+ import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
13
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
14
+
15
+ const chain: HfAudioFxChain = {
16
+ version: 1,
17
+ nodes: [
18
+ { type: "lowpass", id: "n1", enabled: true, params: {} },
19
+ // No id: the panel has not touched it, so nothing can address it.
20
+ { type: "peaking", enabled: true, params: {} },
21
+ // Worklet-backed: no AudioParams to schedule.
22
+ { type: "compressor", id: "n3", enabled: true, params: {} },
23
+ ],
24
+ };
25
+
26
+ describe("automationTargets", () => {
27
+ it("offers volume plus every addressable automatable knob", () => {
28
+ const targets = automationTargets(chain).map((t) => t.target);
29
+ expect(targets[0]).toBe("volume");
30
+ expect(targets).toContain("fx.n1.frequency");
31
+ expect(targets).toContain("fx.n1.q");
32
+ });
33
+
34
+ it("skips a node with no id — a lane could not address it stably", () => {
35
+ expect(automationTargets(chain).some((t) => t.target.includes("peaking"))).toBe(false);
36
+ });
37
+
38
+ it("skips a worklet effect, which exposes no AudioParams", () => {
39
+ expect(automationTargets(chain).some((t) => t.target.startsWith("fx.n3."))).toBe(false);
40
+ });
41
+
42
+ it("offers just the fader for a track with no chain", () => {
43
+ expect(automationTargets(null).map((t) => t.target)).toEqual(["volume"]);
44
+ });
45
+
46
+ it("labels an fx target with its effect and knob", () => {
47
+ const found = automationTargets(chain).find((t) => t.target === "fx.n1.frequency");
48
+ expect(found?.label).toMatch(/Cutoff/);
49
+ expect(found?.range.scale).toBe("log");
50
+ });
51
+ });
52
+
53
+ describe("value ↔ lane position", () => {
54
+ it("maps a linear range straight onto the lane", () => {
55
+ expect(toUnit(VOLUME_RANGE, 0)).toBe(0);
56
+ expect(toUnit(VOLUME_RANGE, 1)).toBe(1);
57
+ expect(toUnit(VOLUME_RANGE, 0.25)).toBeCloseTo(0.25, 10);
58
+ });
59
+
60
+ it("maps a log-read knob on its own scale, so its middle is geometric", () => {
61
+ const range = resolveAutomationRange("fx.n1.frequency", chain)!;
62
+ const mid = fromUnit(range, 0.5);
63
+ expect(mid).toBeCloseTo(Math.sqrt(range.min * range.max), 4);
64
+ // Round trip: a value put in comes back out.
65
+ expect(fromUnit(range, toUnit(range, 900))).toBeCloseTo(900, 6);
66
+ });
67
+
68
+ it("clamps a pointer that has left the lane", () => {
69
+ expect(fromUnit(VOLUME_RANGE, -3)).toBe(0);
70
+ expect(fromUnit(VOLUME_RANGE, 4)).toBe(1);
71
+ });
72
+
73
+ it("reads a zero-width range as the bottom rather than dividing by zero", () => {
74
+ expect(toUnit({ ...VOLUME_RANGE, min: 1, max: 1 }, 1)).toBe(0);
75
+ });
76
+ });
77
+
78
+ describe("curveForDrag", () => {
79
+ const a = { t: 0, v: 1 };
80
+ const b = { t: 4, v: 0 };
81
+
82
+ /** The segment the drag describes, as the model would store it. */
83
+ const bentLane = (bend: { viaX: number; viaY: number } | null) => ({
84
+ target: "volume",
85
+ points: [{ ...a, ...(bend ?? {}) }, b],
86
+ });
87
+
88
+ it("puts the curved segment through the point that was dragged", () => {
89
+ // The whole contract: sampling the segment at the dragged time gives the dragged
90
+ // value back, so the line never runs away from the pointer. Anywhere in the
91
+ // segment, at any depth.
92
+ for (const [t, v] of [
93
+ [0.4, 0.75],
94
+ [1.6, 0.65],
95
+ [2, 0.6],
96
+ [2, 0.1],
97
+ [2.8, 0.4],
98
+ [3.6, 0.5],
99
+ ] as const) {
100
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t, v });
101
+ expect(bend).not.toBeNull();
102
+ expect(sampleAutomationLane(bentLane(bend), t, "linear")).toBeCloseTo(v, 2);
103
+ }
104
+ });
105
+
106
+ it("follows the pointer into the corner of a segment, as deep as it is dragged", () => {
107
+ // The extreme: 10% along, pulled almost to the floor of a falling ramp. The old
108
+ // exponent saturated a third of the segment away from the pointer, and a later
109
+ // slope cap stopped following it too. Reached exactly now, and the shape it draws
110
+ // stays one smooth arc — checked by sampling it, not by trusting it.
111
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.4, v: 0.05 });
112
+ expect(bend).not.toBeNull();
113
+ const drawn = bentLane(bend);
114
+ expect(sampleAutomationLane(drawn, 0.4, "linear")).toBeCloseTo(0.05, 2);
115
+
116
+ let previous: number | null = null;
117
+ let worst = 1;
118
+ for (let i = 1; i < 60; i += 1) {
119
+ const t = (4 * i) / 60;
120
+ const h = 0.01;
121
+ const slope =
122
+ (sampleAutomationLane(drawn, t + h, "linear") -
123
+ sampleAutomationLane(drawn, t - h, "linear")) /
124
+ (2 * h);
125
+ if (previous !== null && Math.abs(previous) > 1e-6) {
126
+ const ratio = Math.abs(slope) > Math.abs(previous) ? slope / previous : previous / slope;
127
+ worst = Math.max(worst, Math.abs(ratio));
128
+ }
129
+ previous = slope;
130
+ }
131
+ // Gradual: a crease would show up here as a step change in slope.
132
+ expect(worst).toBeLessThan(3);
133
+ });
134
+
135
+ it("biases the bend toward whichever point the pointer is nearer", () => {
136
+ // The behaviour a single exponent could not give: grabbing the line near the
137
+ // right-hand point has to bulge it on the RIGHT. Measured as where the curve
138
+ // deviates furthest from the straight line it replaced.
139
+ const apexOf = (bend: { viaX: number; viaY: number } | null): number => {
140
+ const lane = bentLane(bend);
141
+ let best = 0;
142
+ let at = 0;
143
+ for (let i = 1; i < 40; i++) {
144
+ const t = (4 * i) / 40;
145
+ const straight = 1 - t / 4;
146
+ const gap = Math.abs(sampleAutomationLane(lane, t, "linear") - straight);
147
+ if (gap > best) {
148
+ best = gap;
149
+ at = t;
150
+ }
151
+ }
152
+ return at;
153
+ };
154
+ const nearA = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.6, v: 0.55 });
155
+ const nearB = curveForDrag({ range: VOLUME_RANGE, a, b, t: 3.4, v: 0.45 });
156
+ expect(apexOf(nearA)).toBeLessThan(1.6);
157
+ expect(apexOf(nearB)).toBeGreaterThan(2.4);
158
+ // And the two sit on opposite sides of centre, which is the whole complaint:
159
+ // every bend used to land on the same side whatever the pointer did.
160
+ expect(apexOf(nearA)).toBeLessThan(apexOf(nearB));
161
+ });
162
+
163
+ it("stays inside the normalised segment the model will accept", () => {
164
+ // Both coordinates are clamped clear of the ends on parse, so a drag right up
165
+ // against a breakpoint has to describe an interior point, not the breakpoint.
166
+ const bend = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.05, v: 0.02 });
167
+ expect(bend).not.toBeNull();
168
+ expect(bend?.viaX).toBeGreaterThan(0);
169
+ expect(bend?.viaX).toBeLessThan(1);
170
+ expect(bend?.viaY).toBeGreaterThan(0);
171
+ expect(bend?.viaY).toBeLessThan(1);
172
+ expect(applyCurve(0.5, 0)).toBe(0.5);
173
+ });
174
+
175
+ it("declines a segment with no room to bend", () => {
176
+ // Flat: every curve draws the same line, so there is nothing to solve.
177
+ expect(curveForDrag({ range: VOLUME_RANGE, a, b: { t: 4, v: 1 }, t: 2, v: 0.5 })).toBeNull();
178
+ // At the very ends the exponent divides by zero.
179
+ expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 0, v: 1 })).toBeNull();
180
+ expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 4, v: 0 })).toBeNull();
181
+ });
182
+ });
183
+
184
+ describe("applyShiftConstraint", () => {
185
+ const origin = { t: 1, v: 0.5 };
186
+ const xOf = (t: number) => t * 100;
187
+ const yOf = (v: number) => (1 - v) * 40;
188
+
189
+ it("holds the value when the gesture is mostly sideways", () => {
190
+ const out = applyShiftConstraint({
191
+ range: VOLUME_RANGE,
192
+ origin,
193
+ raw: { t: 3, v: 0.55 },
194
+ xOf,
195
+ yOf,
196
+ });
197
+ expect(out).toEqual({ t: 3, v: 0.5 });
198
+ });
199
+
200
+ it("holds the time and fines the value when it is mostly vertical", () => {
201
+ const out = applyShiftConstraint({
202
+ range: VOLUME_RANGE,
203
+ origin,
204
+ raw: { t: 1.05, v: 0.9 },
205
+ xOf,
206
+ yOf,
207
+ });
208
+ expect(out.t).toBe(1);
209
+ // A quarter of the travel: 0.5 + (0.9 - 0.5) / 4.
210
+ expect(out.v).toBeCloseTo(0.6, 5);
211
+ });
212
+
213
+ it("decides which axis won in pixels, not in units", () => {
214
+ // 0.2 s against 0.2 of a fader are not comparable numbers; at this zoom the
215
+ // horizontal move is 20px and the vertical one is 8px.
216
+ const out = applyShiftConstraint({
217
+ range: VOLUME_RANGE,
218
+ origin,
219
+ raw: { t: 1.2, v: 0.7 },
220
+ xOf,
221
+ yOf,
222
+ });
223
+ expect(out.v).toBe(0.5);
224
+ });
225
+ });
226
+
227
+ describe("snapLaneTime", () => {
228
+ it("takes the nearest target inside the threshold", () => {
229
+ expect(snapLaneTime(2.02, [1, 2, 3], 0.04)).toBe(2);
230
+ });
231
+
232
+ it("leaves a time alone when nothing is close enough", () => {
233
+ expect(snapLaneTime(2.5, [1, 2, 3], 0.04)).toBe(2.5);
234
+ });
235
+
236
+ it("has nothing to snap to on an empty grid", () => {
237
+ expect(snapLaneTime(2.5, [], 0.04)).toBe(2.5);
238
+ });
239
+ });