@hyperframes/studio 0.7.107 → 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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.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/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,429 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard";
7
+ import {
8
+ clearAutomationClipboard,
9
+ copyRange,
10
+ readClipboard,
11
+ } from "../player/components/automationClipboard";
12
+ import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
13
+ import type {
14
+ AutomationLaneBinding,
15
+ UseAutomationLanesResult,
16
+ } from "../player/components/useAutomationLanes";
17
+ import type { TimelineElement } from "../player/store/timelineElement";
18
+
19
+ /**
20
+ * A selection box spanning the lane's whole value axis.
21
+ *
22
+ * What almost every test here is about is the time span — which breakpoints a
23
+ * Delete or a copy covers. The box's value bounds have their own tests; giving
24
+ * these an unbounded axis keeps them testing the one thing they name.
25
+ */
26
+ function wholeAxis<T extends { t0: number; t1: number }>(sel: T): T & { v0: number; v1: number } {
27
+ return { ...sel, v0: Number.NEGATIVE_INFINITY, v1: Number.POSITIVE_INFINITY };
28
+ }
29
+
30
+ /** Minimal valid fixture — TimelineElement only requires these five fields. */
31
+ const bgmElement: TimelineElement = {
32
+ id: "bgm",
33
+ key: "bgm",
34
+ tag: "audio",
35
+ start: 0,
36
+ duration: 6,
37
+ track: 0,
38
+ };
39
+
40
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
41
+
42
+ function Host({ lanes }: { lanes: UseAutomationLanesResult }) {
43
+ useAutomationSelectionKeyboard({ lanes });
44
+ return null;
45
+ }
46
+
47
+ const key = (k: string) => {
48
+ const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true });
49
+ act(() => void document.dispatchEvent(e));
50
+ };
51
+
52
+ /** Cmd/Ctrl-modified key combo, returning the event so tests can inspect
53
+ * `defaultPrevented` for the "falls through" cases. */
54
+ const combo = (k: string) => {
55
+ const e = new KeyboardEvent("keydown", {
56
+ key: k,
57
+ metaKey: true,
58
+ bubbles: true,
59
+ cancelable: true,
60
+ });
61
+ act(() => void document.dispatchEvent(e));
62
+ return e;
63
+ };
64
+
65
+ describe("useAutomationSelectionKeyboard", () => {
66
+ // Each setup() mounts a Host whose effect adds a document-level keydown
67
+ // listener. Without unmounting the previous one, listeners from earlier
68
+ // tests linger and can consume later tests' events first (stopping
69
+ // propagation before the current test's own listener ever runs) — so this
70
+ // must run before every test, not just the ones that call setup() twice.
71
+ let mountedRoot: { root: Root; host: HTMLElement } | null = null;
72
+ afterEach(() => {
73
+ if (!mountedRoot) return;
74
+ act(() => mountedRoot?.root.unmount());
75
+ mountedRoot.host.remove();
76
+ mountedRoot = null;
77
+ });
78
+
79
+ const setup = (binding: Partial<AutomationLaneBinding>) => {
80
+ const onCommit = vi.fn();
81
+ const automation = {
82
+ version: 1,
83
+ lanes: [
84
+ {
85
+ target: "volume",
86
+ points: [
87
+ { t: 0, v: 1 },
88
+ { t: 2, v: 0.5 },
89
+ { t: 4, v: 0 },
90
+ ],
91
+ },
92
+ ],
93
+ };
94
+ const lanes: UseAutomationLanesResult = {
95
+ bind: () => ({
96
+ automation,
97
+ // Same list as `automation.lanes`, matching useAutomationLanes' real
98
+ // binding — the paste fallback (no active selection) reads this.
99
+ lanes: automation.lanes,
100
+ chain: null,
101
+ onPreview: vi.fn(),
102
+ onCommit,
103
+ onSelect: vi.fn(),
104
+ readOnly: false,
105
+ commitTargetKey: "bgm",
106
+ selection: null,
107
+ onRangeSelect: vi.fn(),
108
+ onRangeClear: vi.fn(),
109
+ ...binding,
110
+ }),
111
+ };
112
+ const host = document.createElement("div");
113
+ document.body.append(host);
114
+ const root = createRoot(host);
115
+ act(() => root.render(<Host lanes={lanes} />));
116
+ mountedRoot = { root, host };
117
+ return { onCommit };
118
+ };
119
+
120
+ it("Delete removes every breakpoint the selection covers", () => {
121
+ // Deleted, not emptied. Pinning anchors at the selection's edges keeps the
122
+ // envelope either side from moving, which is right for a shape insert or a
123
+ // paste — but answering "delete these points" with two NEW points at the edges
124
+ // reads as the delete not having worked.
125
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
126
+ usePlayerStore
127
+ .getState()
128
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
129
+ const { onCommit } = setup({});
130
+ key("Delete");
131
+ const written = onCommit.mock.calls.at(-1)?.[0];
132
+ const points = written?.lanes?.[0]?.points ?? [];
133
+ // The fixture lane is 0, 2, 4: only t=2 was inside.
134
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0, 4]);
135
+ });
136
+
137
+ it("Delete leaves a point the box's value bounds exclude", () => {
138
+ // The box spans the whole clip but only its top, so Delete takes the one
139
+ // breakpoint up there and nothing else. A time range could not express this.
140
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
141
+ usePlayerStore.getState().setAutomationSelection({
142
+ elementKey: "bgm",
143
+ target: "volume",
144
+ t0: 0,
145
+ t1: 4,
146
+ v0: 0.9,
147
+ v1: 1,
148
+ });
149
+ const { onCommit } = setup({});
150
+ key("Delete");
151
+ const written = onCommit.mock.calls.at(-1)?.[0];
152
+ const points = written?.lanes?.[0]?.points ?? [];
153
+ // Fixture is (0, v=1), (2, v=0.5), (4, v=0): only the first was in the box.
154
+ expect(points.map((p: { t: number }) => p.t)).toEqual([2, 4]);
155
+ });
156
+
157
+ it("Delete takes points sitting exactly on the selection's edges", () => {
158
+ // Endpoint-inclusive, matching the copy path: a point the selection was dragged
159
+ // over is inside it, edge or not. Every range operation leaves a breakpoint
160
+ // exactly on an edge, so excluding them would leave those behind every time.
161
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
162
+ usePlayerStore
163
+ .getState()
164
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
165
+ const { onCommit } = setup({});
166
+ key("Delete");
167
+ const points = onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? [];
168
+ expect(points.map((p: { t: number }) => p.t)).toEqual([0]);
169
+ });
170
+
171
+ it("Delete over a stretch with no breakpoints writes nothing at all", () => {
172
+ // A no-op rather than a write: emptying a span that had nothing in it used to
173
+ // push an undo entry that changed nothing but the anchors it invented.
174
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
175
+ usePlayerStore
176
+ .getState()
177
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2.5, t1: 3.5 }));
178
+ const { onCommit } = setup({});
179
+ const e = new KeyboardEvent("keydown", { key: "Delete", bubbles: true, cancelable: true });
180
+ act(() => void document.dispatchEvent(e));
181
+ expect(onCommit).not.toHaveBeenCalled();
182
+ expect(e.defaultPrevented).toBe(false);
183
+ });
184
+
185
+ it("Delete clears the lane when the selection covers all of it", () => {
186
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
187
+ usePlayerStore
188
+ .getState()
189
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 0, t1: 6 }));
190
+ const { onCommit } = setup({});
191
+ key("Delete");
192
+ const written = onCommit.mock.calls.at(-1)?.[0];
193
+ // withLane drops a lane with no points left, so the attribute goes empty and
194
+ // the clip is back to its plain data-volume.
195
+ expect(written?.lanes ?? []).toEqual([]);
196
+ });
197
+
198
+ it("Escape clears the selection", () => {
199
+ usePlayerStore
200
+ .getState()
201
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
202
+ setup({});
203
+ key("Escape");
204
+ expect(usePlayerStore.getState().automationSelection).toBeNull();
205
+ });
206
+
207
+ it("is inert while a text input has focus", () => {
208
+ usePlayerStore
209
+ .getState()
210
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
211
+ const { onCommit } = setup({});
212
+ const input = document.createElement("input");
213
+ document.body.append(input);
214
+ input.focus();
215
+ key("Delete");
216
+ expect(onCommit).not.toHaveBeenCalled();
217
+ input.remove();
218
+ });
219
+
220
+ it("Cmd+C copies the active selection", () => {
221
+ clearAutomationClipboard();
222
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
223
+ usePlayerStore
224
+ .getState()
225
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
226
+ setup({});
227
+ combo("c");
228
+ const entry = readClipboard(null);
229
+ expect(entry?.span).toBe(2);
230
+ expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
231
+ });
232
+
233
+ it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
234
+ clearAutomationClipboard();
235
+ // Duration wide enough that the playhead (5s) is not clamped down by the
236
+ // 0..duration-span bound — this is a paste-at-playhead test, not a
237
+ // clamp-boundary test.
238
+ usePlayerStore.setState({
239
+ elements: [{ ...bgmElement, duration: 10 }],
240
+ selectedElementId: "bgm",
241
+ currentTime: 5,
242
+ });
243
+ usePlayerStore
244
+ .getState()
245
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
246
+ const { onCommit } = setup({});
247
+ combo("c");
248
+ expect(readClipboard(null)?.span).toBe(2);
249
+ usePlayerStore.getState().clearAutomationSelection();
250
+
251
+ combo("v");
252
+ const written = onCommit.mock.calls.at(-1)?.[0];
253
+ const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
254
+ expect(times).toContain(5); // playhead 5s − element start 0
255
+ expect(times).toContain(7); // + clipboard span 2
256
+
257
+ // Pasting again immediately should land right after the first paste.
258
+ expect(usePlayerStore.getState().automationSelection).toEqual({
259
+ elementKey: "bgm",
260
+ target: "volume",
261
+ t0: 5,
262
+ t1: 7,
263
+ // Full height: everything the paste landed is selected, so Delete straight
264
+ // after undoes it in one press.
265
+ v0: 0,
266
+ v1: 1,
267
+ });
268
+ });
269
+
270
+ it("chains a second Cmd+V after the first instead of overwriting it", () => {
271
+ // The regression this pins: paste leaves its own span selected, so anchoring
272
+ // at sel.t0 unconditionally made every later press recompute the same atT.
273
+ clearAutomationClipboard();
274
+ usePlayerStore.setState({
275
+ elements: [{ ...bgmElement, duration: 10 }],
276
+ selectedElementId: "bgm",
277
+ });
278
+ usePlayerStore
279
+ .getState()
280
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
281
+ const { onCommit } = setup({});
282
+ combo("c");
283
+
284
+ combo("v");
285
+ const first = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
286
+ (p: { t: number }) => p.t,
287
+ );
288
+ expect(first).toContain(2);
289
+ expect(first).toContain(4);
290
+
291
+ combo("v");
292
+ const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
293
+ (p: { t: number }) => p.t,
294
+ );
295
+ expect(second).toContain(4);
296
+ expect(second).toContain(6);
297
+ expect(usePlayerStore.getState().automationSelection).toEqual({
298
+ elementKey: "bgm",
299
+ target: "volume",
300
+ t0: 4,
301
+ t1: 6,
302
+ // Full height: everything the paste landed is selected, so Delete straight
303
+ // after undoes it in one press.
304
+ v0: 0,
305
+ v1: 1,
306
+ });
307
+ });
308
+
309
+ it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
310
+ // The playhead branch already clamps to duration - span; the
311
+ // selection-start branch didn't, so pasting a 2s clip at a selection
312
+ // sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
313
+ // past element.duration — and leave the selection itself out of bounds.
314
+ clearAutomationClipboard();
315
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
316
+ usePlayerStore
317
+ .getState()
318
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
319
+ const { onCommit } = setup({});
320
+ combo("c");
321
+ expect(readClipboard(null)?.span).toBe(2);
322
+
323
+ // A 0.1s-wide selection right near the clip's 6s end.
324
+ usePlayerStore
325
+ .getState()
326
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 }));
327
+ combo("v");
328
+ const written = onCommit.mock.calls.at(-1)?.[0];
329
+ const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
330
+ for (const t of times) {
331
+ expect(t).toBeGreaterThanOrEqual(0);
332
+ expect(t).toBeLessThanOrEqual(bgmElement.duration);
333
+ }
334
+ // Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
335
+ expect(usePlayerStore.getState().automationSelection).toEqual({
336
+ elementKey: "bgm",
337
+ target: "volume",
338
+ t0: 4,
339
+ t1: 6,
340
+ // Full height: everything the paste landed is selected, so Delete straight
341
+ // after undoes it in one press.
342
+ v0: 0,
343
+ v1: 1,
344
+ });
345
+ });
346
+
347
+ it("refuses to paste when the dom-edit layer would write to a different clip", () => {
348
+ // selectedElementId says "bgm" but the commit channel is still on the
349
+ // previously selected clip — writing here would serialize bgm's automation
350
+ // onto that other clip and leave bgm untouched.
351
+ clearAutomationClipboard();
352
+ copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
353
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
354
+ usePlayerStore
355
+ .getState()
356
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
357
+ const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
358
+ const e = combo("v");
359
+ expect(e.defaultPrevented).toBe(false);
360
+ expect(onCommit).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it("does not paste from a playhead outside the clip", () => {
364
+ // No selection on this clip, and the playhead is past its end — there is no
365
+ // anchor. This used to collapse to the clip's own t=0.
366
+ clearAutomationClipboard();
367
+ usePlayerStore.setState({
368
+ elements: [bgmElement],
369
+ selectedElementId: "bgm",
370
+ currentTime: 2,
371
+ });
372
+ usePlayerStore
373
+ .getState()
374
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
375
+ const { onCommit } = setup({});
376
+ combo("c");
377
+ usePlayerStore.getState().clearAutomationSelection();
378
+ usePlayerStore.setState({ currentTime: 50 });
379
+ onCommit.mockClear();
380
+
381
+ const e = combo("v");
382
+ expect(e.defaultPrevented).toBe(false);
383
+ expect(onCommit).not.toHaveBeenCalled();
384
+ });
385
+
386
+ it("Cmd+C over an empty lane leaves an earlier clipboard alone", () => {
387
+ // An empty capture is byte-identical to the Delete payload, so arming the
388
+ // clipboard with it turns every later Cmd+V into a destructive flatten.
389
+ clearAutomationClipboard();
390
+ copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 3);
391
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
392
+ usePlayerStore
393
+ .getState()
394
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }));
395
+ setup({ automation: { version: 1, lanes: [{ target: "volume", points: [] }] } });
396
+
397
+ const e = combo("c");
398
+ expect(e.defaultPrevented).toBe(false);
399
+ expect(readClipboard(null)?.span).toBe(3);
400
+ });
401
+
402
+ it("pastes with CapsLock on, where e.key is an uppercase V", () => {
403
+ clearAutomationClipboard();
404
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
405
+ usePlayerStore
406
+ .getState()
407
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
408
+ const { onCommit } = setup({});
409
+ combo("C");
410
+ expect(readClipboard(null)?.span).toBe(2);
411
+
412
+ onCommit.mockClear();
413
+ const e = combo("V");
414
+ expect(e.defaultPrevented).toBe(true);
415
+ expect(onCommit).toHaveBeenCalled();
416
+ });
417
+
418
+ it("Cmd+V with clipboard content but no resolvable element falls through", () => {
419
+ clearAutomationClipboard();
420
+ copyRange(null, { target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1);
421
+ expect(readClipboard(null)).not.toBeNull();
422
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
423
+ usePlayerStore.getState().clearAutomationSelection();
424
+ const { onCommit } = setup({});
425
+ const e = combo("v");
426
+ expect(e.defaultPrevented).toBe(false);
427
+ expect(onCommit).not.toHaveBeenCalled();
428
+ });
429
+ });