@hyperframes/studio 0.7.107 → 0.7.109

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The maths behind an automation lane: which parameters it can offer, how a
3
+ * value maps to a position in the lane, and how a lane is edited.
4
+ *
5
+ * Pure — no React, no DOM. Split from the lane component so the geometry can be
6
+ * tested on its own, and so the component is left with the parts that genuinely
7
+ * need a pointer and a render.
8
+ */
9
+
10
+ import {
11
+ fxAutomationTarget,
12
+ resolveAutomationRange,
13
+ sampleAutomationLane,
14
+ steadyViaPoint,
15
+ VOLUME_RANGE,
16
+ VOLUME_TARGET,
17
+ type AutomationRange,
18
+ type HfAutomation,
19
+ type HfAutomationLane,
20
+ } from "@hyperframes/core/audio-automation";
21
+ import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
22
+
23
+ /** Points nearer than this in clip seconds are the same point, not two. */
24
+ export const POINT_MERGE_SEC = 0.02;
25
+
26
+ /**
27
+ * Closest two breakpoints may sit in clip seconds while still being two points.
28
+ *
29
+ * A drag clamps to this short of its neighbour rather than onto it. Landing on the
30
+ * exact same time is not a step, it is a deletion: the lane's own normalisation
31
+ * collapses points that share a `t`, keeping the later one — so dragging a point
32
+ * fully into its neighbour used to consume that neighbour. A millisecond is under a
33
+ * pixel at any zoom the lane offers, so the two still read as touching.
34
+ */
35
+ export const MIN_POINT_GAP_SEC = 0.001;
36
+ /** Hit radius for grabbing a point, in px. */
37
+ export const GRAB_PX = 7;
38
+ /** Samples used to draw a segment the eye should see as curved. */
39
+ export const DRAW_SAMPLES = 64;
40
+ /**
41
+ * Slack on each side of the envelope, so a point sitting exactly at the clip's
42
+ * start or end is drawn whole instead of half outside the lane. Wide enough for
43
+ * the grab circle plus its stroke.
44
+ */
45
+ export const PAD_X = GRAB_PX + 2;
46
+
47
+ export interface AutomationTargetOption {
48
+ target: string;
49
+ label: string;
50
+ range: AutomationRange;
51
+ }
52
+
53
+ /**
54
+ * Everything this clip could automate: its fader, then each automatable knob of
55
+ * each effect in its chain. Effects with no chain node id are skipped — a lane
56
+ * has nothing stable to address them by (the panel mints ids as it adds nodes).
57
+ */
58
+ export function automationTargets(chain: HfAudioFxChain | null): AutomationTargetOption[] {
59
+ const out: AutomationTargetOption[] = [
60
+ { target: VOLUME_TARGET, label: "Volume", range: VOLUME_RANGE },
61
+ ];
62
+ for (const node of chain?.nodes ?? []) {
63
+ out.push(...nodeTargets(node, chain));
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** One effect's automatable knobs. Empty for a node no lane could address. */
69
+ function nodeTargets(
70
+ node: HfAudioFxChain["nodes"][number],
71
+ chain: HfAudioFxChain | null,
72
+ ): AutomationTargetOption[] {
73
+ const nodeId = node.id;
74
+ const def = nodeId ? getAudioFxDef(node.type) : undefined;
75
+ if (!nodeId || !def) return [];
76
+ const out: AutomationTargetOption[] = [];
77
+ for (const param of def.params) {
78
+ if (param.kind !== "number" || !param.automatable) continue;
79
+ const target = fxAutomationTarget(nodeId, param.key);
80
+ const range = resolveAutomationRange(target, chain ?? undefined);
81
+ if (range) out.push({ target, label: range.label, range });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Value → 0..1 up the lane, honouring a log-read knob's own scale. */
87
+ export function toUnit(range: AutomationRange, value: number): number {
88
+ const { min, max } = range;
89
+ if (max <= min) return 0;
90
+ if (range.scale === "log" && min > 0 && value > 0) {
91
+ return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min));
92
+ }
93
+ return (value - min) / (max - min);
94
+ }
95
+
96
+ export function fromUnit(range: AutomationRange, unit: number): number {
97
+ const t = Math.min(1, Math.max(0, unit));
98
+ const { min, max } = range;
99
+ if (range.scale === "log" && min > 0) {
100
+ return Math.exp(Math.log(min) + t * (Math.log(max) - Math.log(min)));
101
+ }
102
+ return min + t * (max - min);
103
+ }
104
+
105
+ export function formatValue(range: AutomationRange, value: number): string {
106
+ const decimals = range.step >= 1 ? 0 : range.step >= 0.1 ? 1 : 2;
107
+ const shown =
108
+ range.unit === "" && range.max === 1 ? `${Math.round(value * 100)}%` : value.toFixed(decimals);
109
+ return range.unit ? `${shown} ${range.unit}` : shown;
110
+ }
111
+
112
+ /**
113
+ * The via point that bends a segment through a dragged pointer: the pointer's own
114
+ * position in the segment's normalised space, which is all the model needs.
115
+ *
116
+ * There is nothing to solve any more, and that is the point. This used to fit an
117
+ * exponent — `x^e = f`, so `curve = log2(ln f / ln x) / 2` — and an exponent is
118
+ * one knob for two questions. It spent it on the wrong one: every upward bend it
119
+ * could draw deviated most inside the first fifth of the segment, so grabbing the
120
+ * line near its right-hand breakpoint still bulged it on the left. And reaching a
121
+ * pointer near either end needed an exponent the model refuses — `e` runs past 15
122
+ * at `x = 0.9`, clamped to 4 — so the line stopped following the pointer
123
+ * altogether, missing it by up to a third of the segment's height. Naming the
124
+ * point the curve passes through says both things at once, exactly, anywhere.
125
+ *
126
+ * Null when the segment cannot take a bend: a flat segment draws the same line
127
+ * whatever the shape, and a pointer at the very ends is the ends.
128
+ */
129
+ export function curveForDrag(input: {
130
+ range: AutomationRange;
131
+ a: { t: number; v: number };
132
+ b: { t: number; v: number };
133
+ t: number;
134
+ v: number;
135
+ }): { viaX: number; viaY: number } | null {
136
+ const { range, a, b, t, v } = input;
137
+ const span = b.t - a.t;
138
+ if (span <= 0) return null;
139
+ const x = (t - a.t) / span;
140
+ if (x <= 0.001 || x >= 0.999) return null;
141
+ const ua = toUnit(range, a.v);
142
+ const ub = toUnit(range, b.v);
143
+ if (Math.abs(ub - ua) < 0.001) return null;
144
+ const f = (toUnit(range, v) - ua) / (ub - ua);
145
+ if (f <= 0.001 || f >= 0.999) return null;
146
+ // Reported as the model will honour it, not as the pointer asked. A bend is held
147
+ // to a steady curve, so a pointer dragged past that stops being followed — and
148
+ // the write, the preview and the readout all have to say the same thing about
149
+ // where the line actually went.
150
+ return steadyViaPoint(x, f);
151
+ }
152
+
153
+ /**
154
+ * A point drag with Shift held: one axis at a time, whichever the gesture
155
+ * committed to, and a quarter of the vertical travel for a value that has to
156
+ * land on a number.
157
+ *
158
+ * Which axis "won" is decided in pixels, not in seconds and dB — those are
159
+ * different units and comparing them would make the lock depend on the zoom.
160
+ */
161
+ /** Which way a gesture is going, in pixels — the only comparable unit. */
162
+ export function dominantDragAxis(input: {
163
+ origin: { t: number; v: number };
164
+ raw: { t: number; v: number };
165
+ xOf(t: number): number;
166
+ yOf(v: number): number;
167
+ }): "time" | "value" {
168
+ const { origin, raw, xOf, yOf } = input;
169
+ return Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))
170
+ ? "time"
171
+ : "value";
172
+ }
173
+
174
+ /**
175
+ * The pointer, constrained to one axis.
176
+ *
177
+ * The axis is handed in rather than worked out here, because it has to be decided
178
+ * once for the gesture and held. Recomputed per event it followed whichever way
179
+ * the last move happened to lean, so a hand drifting sideways during a vertical
180
+ * drag flipped the lock and the point moved in both — which is indistinguishable
181
+ * from no lock at all.
182
+ *
183
+ * Locking to time holds the value exactly. Locking to value holds the time and
184
+ * moves the value at a quarter speed: the same gesture is the fine adjustment,
185
+ * because a fader spanning 60px of lane has no other way to be set precisely.
186
+ */
187
+ export function applyShiftConstraint(input: {
188
+ range: AutomationRange;
189
+ origin: { t: number; v: number };
190
+ raw: { t: number; v: number };
191
+ /** Same projections the lane draws with, so the comparison is on screen. */
192
+ xOf(t: number): number;
193
+ yOf(v: number): number;
194
+ /** Decided on the gesture's first travel; worked out here when absent. */
195
+ axis?: "time" | "value";
196
+ }): { t: number; v: number } {
197
+ const { range, origin, raw } = input;
198
+ const axis = input.axis ?? dominantDragAxis(input);
199
+ if (axis === "time") return { t: raw.t, v: origin.v };
200
+ const from = toUnit(range, origin.v);
201
+ return { t: origin.t, v: fromUnit(range, from + (toUnit(range, raw.v) - from) * 0.25) };
202
+ }
203
+
204
+ /**
205
+ * Nearest snap target within the threshold, else the time unchanged.
206
+ *
207
+ * A breakpoint is placed by eye, and by eye "on the beat" and "three
208
+ * milliseconds off the beat" look identical — so the lane snaps to the beat grid
209
+ * and to its own neighbouring points, the two things an envelope is usually
210
+ * aligned against.
211
+ */
212
+ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec: number): number {
213
+ let best = t;
214
+ let bestDist = thresholdSec;
215
+ for (const target of targets) {
216
+ const d = Math.abs(target - t);
217
+ if (d < bestDist) {
218
+ bestDist = d;
219
+ best = target;
220
+ }
221
+ }
222
+ return best;
223
+ }
224
+
225
+ /**
226
+ * The svg path for one lane's envelope.
227
+ *
228
+ * A flat line at the parameter's own default stands in for a lane with no
229
+ * points, so the first double-click has something to land on. Straight segments
230
+ * are drawn as one line each; a curved or log-read segment is sampled, because
231
+ * drawing it straight would lie about the envelope the audio thread is going to
232
+ * play.
233
+ */
234
+ export function envelopePath(input: {
235
+ lane: HfAutomationLane;
236
+ range: AutomationRange;
237
+ widthPx: number;
238
+ xOf(t: number): number;
239
+ yOf(v: number): number;
240
+ }): string {
241
+ const { lane, range, widthPx, xOf, yOf } = input;
242
+ const first = lane.points[0];
243
+ const last = lane.points[lane.points.length - 1];
244
+ if (!first || !last) {
245
+ const y = yOf(range.default ?? (range.min + range.max) / 2);
246
+ return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`;
247
+ }
248
+ const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
249
+ for (let i = 0; i + 1 < lane.points.length; i += 1) {
250
+ const a = lane.points[i];
251
+ const b = lane.points[i + 1];
252
+ if (!a || !b) continue;
253
+ // A via point bends the segment with no `curve` of its own, so the
254
+ // straight-line shortcut has to rule out both.
255
+ if (!a.curve && a.viaX === undefined && range.scale === "linear") {
256
+ pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
257
+ continue;
258
+ }
259
+ for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
260
+ const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
261
+ pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
262
+ }
263
+ }
264
+ pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
265
+ return pts.join(" ");
266
+ }
267
+
268
+ export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
269
+ return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
270
+ }
271
+
272
+ /**
273
+ * Replace one lane in place, dropping it when it has no points left.
274
+ *
275
+ * Order is preserved deliberately. A lane with no explicitly chosen parameter
276
+ * shows whichever comes first, so moving the edited one to the end would switch
277
+ * the lane out from under the pointer on the first edit.
278
+ */
279
+ export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation {
280
+ const empty = lane.points.length === 0;
281
+ const exists = automation.lanes.some((l) => l.target === lane.target);
282
+ const lanes = automation.lanes
283
+ .map((l) => (l.target === lane.target ? lane : l))
284
+ .filter((l) => l.points.length > 0);
285
+ if (!exists && !empty) lanes.push(lane);
286
+ return { version: 1, lanes };
287
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Height of one audio automation lane.
3
+ *
4
+ * Its own module because both the row layout and the lane itself need it, and
5
+ * putting it in either would have the layout importing a component or the
6
+ * component's constant living somewhere it is not used.
7
+ *
8
+ * Taller than a keyframe lane because it carries a value axis rather than a row
9
+ * of diamonds: a fader envelope drawn 28px high cannot be aimed. 48 was still
10
+ * too tight in use — the drawing area is the height minus 6px of padding either
11
+ * side, so 48 left 36px for the whole 0..1 fader and a breakpoint's 11px grab
12
+ * disc covered a third of the axis. 72 leaves 60px, which is what makes a value
13
+ * aimable and two points at similar values separately grabbable.
14
+ */
15
+ export const AUTOMATION_LANE_H = 72;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Pointer capture for a lane gesture. Its own module only because both gesture
3
+ * hooks need it and `automationLaneGeometry` is deliberately DOM-free.
4
+ */
5
+
6
+ /**
7
+ * Keep the rest of a gesture even if the pointer leaves the lane. Without it a
8
+ * drag that strays outside the svg stops sending moves and the point sticks.
9
+ *
10
+ * Captured on the svg the handler is bound to, NOT on `e.target`: the target is
11
+ * whatever child the press happened to land on — a breakpoint circle, a
12
+ * selection line — and a child that unmounts mid-drag takes the capture with it,
13
+ * silently and with no `pointercancel` to notice it by. The svg outlives every
14
+ * gesture on it.
15
+ *
16
+ * Structurally typed so this stays testable without React's event types.
17
+ */
18
+ export function capturePointer(e: { currentTarget: Element; pointerId: number }): void {
19
+ e.currentTarget.setPointerCapture?.(e.pointerId);
20
+ }
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pointsIn, replaceRange, retimeRange } from "./automationLaneSelection";
3
+ import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
4
+ import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
5
+
6
+ const ramp: HfAutomationLane = {
7
+ target: "volume",
8
+ points: [
9
+ { t: 0, v: 1 },
10
+ { t: 2, v: 0.6 },
11
+ { t: 3, v: 0.4 },
12
+ { t: 6, v: 0 },
13
+ ],
14
+ };
15
+
16
+ describe("pointsIn", () => {
17
+ it("returns only the points inside the range, endpoints inclusive", () => {
18
+ expect(pointsIn(ramp, 2, 3).map((p) => p.t)).toEqual([2, 3]);
19
+ expect(pointsIn(ramp, 2.1, 2.9)).toEqual([]);
20
+ });
21
+ });
22
+
23
+ describe("replaceRange", () => {
24
+ it("never moves the envelope outside the selection", () => {
25
+ // THE invariant. Deleting the middle of a ramp must not reshape the rest.
26
+ const next: HfAutomationLane = {
27
+ target: "volume",
28
+ points: replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }),
29
+ };
30
+ for (const t of [0, 0.5, 1.0, 1.5, 3.5, 4, 5, 6]) {
31
+ expect(sampleAutomationLane(next, t, "linear")).toBeCloseTo(
32
+ sampleAutomationLane(ramp, t, "linear"),
33
+ 5,
34
+ );
35
+ }
36
+ });
37
+
38
+ it("pins anchors at both edges when the interior empties", () => {
39
+ const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] });
40
+ const times = pts.map((p) => p.t);
41
+ expect(times).toContain(1.5);
42
+ expect(times).toContain(3.5);
43
+ expect(times).not.toContain(2);
44
+ expect(times).not.toContain(3);
45
+ });
46
+
47
+ it("lets inner points at the edges stand in for the anchors", () => {
48
+ // A ramp generator emits its own boundary points; pinning a second anchor
49
+ // at the same time would fight it.
50
+ const pts = replaceRange({
51
+ lane: ramp,
52
+ range: VOLUME_RANGE,
53
+ t0: 2,
54
+ t1: 3,
55
+ inner: [
56
+ { t: 2, v: 0 },
57
+ { t: 3, v: 1 },
58
+ ],
59
+ });
60
+ expect(pts.filter((p) => p.t === 2)).toHaveLength(1);
61
+ expect(pts.find((p) => p.t === 2)?.v).toBe(0);
62
+ });
63
+
64
+ it("sorts and respects the point cap", () => {
65
+ const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
66
+ const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
67
+ expect(pts.length).toBeLessThanOrEqual(512);
68
+ expect([...pts].sort((a, b) => a.t - b.t)).toEqual(pts);
69
+ });
70
+
71
+ it("keeps a constant flat when the lane has no points", () => {
72
+ const empty: HfAutomationLane = { target: "volume", points: [] };
73
+ const pts = replaceRange({ lane: empty, range: VOLUME_RANGE, t0: 1, t1: 2, inner: [] });
74
+ // Nothing to preserve, nothing to pin: an empty lane stays empty.
75
+ expect(pts).toEqual([]);
76
+ });
77
+
78
+ it("keeps the far anchor and every outside point when inner would overflow the cap", () => {
79
+ const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
80
+ const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
81
+ const times = pts.map((p) => p.t);
82
+ expect(times).toContain(1.5); // near anchor
83
+ expect(times).toContain(3.5); // far anchor — this is what the bug dropped
84
+ expect(times).toContain(0); // outside point before the range
85
+ expect(times).toContain(6); // outside point after the range
86
+ expect(pts.length).toBeLessThanOrEqual(512);
87
+ });
88
+
89
+ it("thins the interior evenly rather than dropping its tail", () => {
90
+ const dense = Array.from({ length: 2001 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 }));
91
+ const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense });
92
+ const innerTimes = pts.map((p) => p.t).filter((t) => t > 1.5 && t < 3.5);
93
+ // Evenly spread across the range, not clustered at the start.
94
+ expect(Math.max(...innerTimes)).toBeGreaterThan(3.0);
95
+ });
96
+ });
97
+
98
+ describe("retimeRange", () => {
99
+ it("scales interior points proportionally into the new span", () => {
100
+ const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
101
+ const moved = pts.find((p) => p.v === 0.4); // the t=3 point
102
+ expect(moved?.t).toBe(5);
103
+ });
104
+
105
+ it("preserves the envelope outside the union of old and new spans", () => {
106
+ const before: HfAutomationLane = { target: "volume", points: ramp.points };
107
+ const after: HfAutomationLane = {
108
+ target: "volume",
109
+ points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }),
110
+ };
111
+ // Nothing to the left of t0=2 moved (newT0 === t0 here), so sampled
112
+ // continuity holds all the way up to the edited region.
113
+ for (const t of [0, 1, 1.9]) {
114
+ expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
115
+ sampleAutomationLane(before, t, "linear"),
116
+ 5,
117
+ );
118
+ }
119
+ // The next real breakpoint past the edited region keeps its own exact value.
120
+ const farPoint = after.points.find((p) => p.t === 6);
121
+ expect(farPoint).toEqual({ t: 6, v: 0 });
122
+ });
123
+
124
+ it("preserves the envelope on BOTH sides when no breakpoint sits on the moved edge", () => {
125
+ // The right side is where the invariant is worth asserting — `newT0 === t0`
126
+ // makes the left side of the test above trivially true, and an earlier
127
+ // right-side probe at t=5.1 was DELETED as inherent when it was reporting
128
+ // the real behaviour below. With the selection's edges off any breakpoint,
129
+ // the guarantee holds exactly, in both directions.
130
+ const before: HfAutomationLane = { target: "volume", points: ramp.points };
131
+ const after: HfAutomationLane = {
132
+ target: "volume",
133
+ points: retimeRange({
134
+ lane: ramp,
135
+ range: VOLUME_RANGE,
136
+ t0: 2.2,
137
+ t1: 2.9,
138
+ newT0: 2.2,
139
+ newT1: 4,
140
+ }),
141
+ };
142
+ for (const t of [0, 1, 2, 4.5, 5, 5.5, 6]) {
143
+ expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo(
144
+ sampleAutomationLane(before, t, "linear"),
145
+ 5,
146
+ );
147
+ }
148
+ });
149
+
150
+ it("moves a breakpoint sitting exactly on the dragged edge, reshaping the segment past it", () => {
151
+ // The design decision this pins, because it is not free either way.
152
+ // `pointsIn` is endpoint-inclusive, so a breakpoint ON the edge is interior
153
+ // and travels with the stretch. It has to: every range operation leaves a
154
+ // breakpoint exactly on the edge it created, so treating that point as an
155
+ // anchor instead would make the commonest stretch — grabbing the edge to
156
+ // drag that very point outward — delete it and flatten the span.
157
+ //
158
+ // The cost is that the retimed point lands ON the union's boundary, where a
159
+ // preservation anchor would also go, and `anchor()` stands down within a
160
+ // merge radius. Two different values cannot occupy one time; the segment
161
+ // leaving the union reshapes, which is the "envelope outside the selection
162
+ // never moves" invariant bending exactly here and nowhere else.
163
+ const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 });
164
+ expect(pts).toEqual([
165
+ { t: 0, v: 1 },
166
+ { t: 2, v: 0.6 },
167
+ { t: 5, v: 0.4 }, // the t=3 point, retimed onto the new edge
168
+ { t: 6, v: 0 },
169
+ ]);
170
+ // The old 3→6 segment sloped -0.133/s; the new 5→6 slopes -0.4/s, so the
171
+ // envelope past the union genuinely moves. Asserted, not tolerated: if this
172
+ // number changes, the decision above changed with it.
173
+ const after: HfAutomationLane = { target: "volume", points: pts };
174
+ expect(sampleAutomationLane(after, 5.5, "linear")).toBeCloseTo(0.2, 5);
175
+ expect(sampleAutomationLane(ramp, 5.5, "linear")).toBeCloseTo(0.0667, 4);
176
+ });
177
+
178
+ it("rejects a degenerate span", () => {
179
+ expect(
180
+ retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 4, newT1: 4 }),
181
+ ).toEqual(ramp.points);
182
+ });
183
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Range operations over one automation lane.
3
+ *
4
+ * `replaceRange` is the only mutator every range feature (delete, shapes,
5
+ * paste) composes, and it carries the invariant that makes them safe:
6
+ * the envelope OUTSIDE the selection never moves. It samples the lane at both
7
+ * edges first and pins anchor points there, so cutting the middle out of a
8
+ * ramp cannot reshape the rest of the clip.
9
+ *
10
+ * Exact for linear segments. A curved segment straddling an edge keeps its
11
+ * edge VALUE but reshapes slightly between its own start and the anchor — the
12
+ * curve exponent now runs over a shorter span. Accepted: the alternative is
13
+ * splitting curves analytically for a difference the ear cannot place.
14
+ */
15
+
16
+ import {
17
+ MAX_AUTOMATION_POINTS,
18
+ sampleAutomationLane,
19
+ type AutomationRange,
20
+ type HfAutomationLane,
21
+ type HfAutomationPoint,
22
+ } from "@hyperframes/core/audio-automation";
23
+ import { POINT_MERGE_SEC } from "./automationLaneGeometry";
24
+
25
+ /** Points inside [t0, t1], endpoints inclusive. */
26
+ export function pointsIn(lane: HfAutomationLane, t0: number, t1: number): HfAutomationPoint[] {
27
+ return lane.points.filter((p) => p.t >= t0 && p.t <= t1);
28
+ }
29
+
30
+ /** An anchor, unless `inner` already provides the edge within the merge radius. */
31
+ function anchor(
32
+ lane: HfAutomationLane,
33
+ range: AutomationRange,
34
+ t: number,
35
+ inner: readonly HfAutomationPoint[],
36
+ ): HfAutomationPoint[] {
37
+ if (inner.some((p) => Math.abs(p.t - t) <= POINT_MERGE_SEC)) return [];
38
+ return [{ t, v: sampleAutomationLane(lane, t, range.scale) }];
39
+ }
40
+
41
+ /** Evenly subsample items to a budget, preserving first and last. */
42
+ function decimateEvenly<T>(items: readonly T[], budget: number): T[] {
43
+ if (budget <= 0) return [];
44
+ if (items.length <= budget) return [...items];
45
+ if (budget === 1) {
46
+ const item = items[0];
47
+ return item ? [item] : [];
48
+ }
49
+ const out: T[] = [];
50
+ const step = (items.length - 1) / (budget - 1);
51
+ for (let i = 0; i < budget; i += 1) {
52
+ const item = items[Math.round(i * step)];
53
+ if (item) out.push(item);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function replaceRange(input: {
59
+ lane: HfAutomationLane;
60
+ range: AutomationRange;
61
+ t0: number;
62
+ t1: number;
63
+ inner: HfAutomationPoint[];
64
+ }): HfAutomationPoint[] {
65
+ const { lane, range, t0, t1, inner } = input;
66
+ // An empty lane draws a flat default; there is nothing to preserve, and
67
+ // pinning anchors would turn "no automation" into a constant lane.
68
+ if (lane.points.length === 0 && inner.length === 0) return [];
69
+ const outside = lane.points.filter((p) => p.t < t0 || p.t > t1);
70
+ const edges =
71
+ lane.points.length === 0
72
+ ? []
73
+ : [...anchor(lane, range, t0, inner), ...anchor(lane, range, t1, inner)];
74
+ const budget = Math.max(0, MAX_AUTOMATION_POINTS - outside.length - edges.length);
75
+ const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget);
76
+ return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t);
77
+ }
78
+
79
+ /**
80
+ * Whether a breakpoint falls inside the selection box, edges included.
81
+ *
82
+ * The one rule three places need: what Delete removes, what the lane rings, and
83
+ * what a group drag moves. They have to agree — a point drawn as caught but left
84
+ * behind by the drag is worse than either answer.
85
+ *
86
+ * Both axes, which is what makes a selection a box: a point at the right time but
87
+ * the wrong value is not in it. Values compare in the parameter's own units, and
88
+ * that is correct on a logarithmic axis too — the mapping to screen is monotonic,
89
+ * so a box drawn around some pixels holds exactly the values it looks like it does.
90
+ */
91
+ export function pointInSelection(
92
+ point: { t: number; v: number },
93
+ box: { t0: number; t1: number; v0: number; v1: number },
94
+ ): boolean {
95
+ return point.t >= box.t0 && point.t <= box.t1 && point.v >= box.v0 && point.v <= box.v1;
96
+ }
97
+ /**
98
+ * Retime a selection: interior points scale proportionally into the new span,
99
+ * then replaceRange runs over the UNION of old and new spans — growing eats
100
+ * whatever it covers, shrinking pins anchors where the envelope re-enters.
101
+ *
102
+ * Interior is `pointsIn`, so a breakpoint sitting exactly ON an edge travels
103
+ * with the stretch. Deliberate: every range operation leaves a breakpoint on the
104
+ * edge it created, so treating that point as a fixed anchor would make the
105
+ * commonest stretch of all — grabbing the edge to drag that point outward —
106
+ * delete it instead. The price is that such a point lands on the union's own
107
+ * boundary, where `anchor` then stands down (one time cannot hold two values),
108
+ * so the segment leaving the union reshapes. That is the ONE place
109
+ * `replaceRange`'s outside-never-moves invariant bends, and it is pinned by name
110
+ * in automationLaneSelection.test.ts.
111
+ */
112
+ export function retimeRange(input: {
113
+ lane: HfAutomationLane;
114
+ range: AutomationRange;
115
+ t0: number;
116
+ t1: number;
117
+ newT0: number;
118
+ newT1: number;
119
+ }): HfAutomationPoint[] {
120
+ const { lane, range, t0, t1, newT0, newT1 } = input;
121
+ const oldSpan = t1 - t0;
122
+ const newSpan = newT1 - newT0;
123
+ if (oldSpan <= 0 || newSpan <= 0) return lane.points;
124
+ const inner = pointsIn(lane, t0, t1).map((p) => ({
125
+ ...p,
126
+ t: newT0 + ((p.t - t0) * newSpan) / oldSpan,
127
+ }));
128
+ return replaceRange({
129
+ lane,
130
+ range,
131
+ t0: Math.min(t0, newT0),
132
+ t1: Math.max(t1, newT1),
133
+ inner,
134
+ });
135
+ }