@hyperframes/studio 0.8.79 → 0.8.80

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 (74) hide show
  1. package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
  2. package/dist/assets/{index-DBKySIo7.js → index-CsG24Jub.js} +190 -187
  3. package/dist/assets/{index-CJP92F4-.js → index-DZGVvqDT.js} +1 -1
  4. package/dist/assets/{index-ORZXYB0k.js → index-J9HXe7mU.js} +1 -1
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1491 -1177
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +5 -1
  11. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  12. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  13. package/src/components/editor/manualEdits.ts +1 -0
  14. package/src/components/editor/manualEditsParsing.ts +4 -0
  15. package/src/components/editor/useFxAudition.ts +3 -3
  16. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  17. package/src/contexts/DomEditContext.tsx +4 -0
  18. package/src/contexts/TimelineEditContext.tsx +2 -0
  19. package/src/hooks/liveLanes.ts +147 -0
  20. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  21. package/src/hooks/timelineEditPermission.ts +9 -1
  22. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  23. package/src/hooks/timelineEditingHelpers.ts +69 -43
  24. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  25. package/src/hooks/useAppHotkeys.ts +1 -1
  26. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  27. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  28. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  29. package/src/hooks/useBlockHandlers.ts +7 -4
  30. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  31. package/src/hooks/useDomEditSession.ts +1 -0
  32. package/src/hooks/useEditHistoryActions.ts +4 -3
  33. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  34. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  35. package/src/hooks/useTimelineEditing.ts +19 -16
  36. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  37. package/src/hooks/useToast.ts +2 -1
  38. package/src/player/components/ImageThumbnail.tsx +3 -12
  39. package/src/player/components/Player.test.ts +23 -0
  40. package/src/player/components/Player.tsx +4 -2
  41. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  42. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  43. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  44. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  45. package/src/player/components/TimelineClipFades.tsx +5 -2
  46. package/src/player/components/TimelineFxButton.tsx +1 -1
  47. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  48. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  49. package/src/player/components/TimelineGroupRow.tsx +17 -11
  50. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  51. package/src/player/components/groupAutomationElement.ts +9 -1
  52. package/src/player/components/timelineCallbacks.ts +2 -0
  53. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  54. package/src/player/components/timelineClipDragPreview.ts +15 -24
  55. package/src/player/components/timelineEditing.test.ts +99 -1
  56. package/src/player/components/timelineEditing.ts +5 -8
  57. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  58. package/src/player/components/timelineGroupEditing.ts +18 -15
  59. package/src/player/components/timelineStackingSync.ts +5 -5
  60. package/src/player/components/timelineZones.ts +2 -3
  61. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  62. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  63. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  64. package/src/player/components/useAutomationLanes.ts +58 -74
  65. package/src/player/lib/automationStoreSync.ts +30 -3
  66. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  67. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  68. package/src/player/lib/timelineDOM.test.ts +28 -8
  69. package/src/player/lib/timelineElementHelpers.ts +3 -4
  70. package/src/player/lib/timelineGroupInfo.ts +23 -23
  71. package/src/utils/studioFileHistory.ts +36 -30
  72. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  73. package/src/player/components/automationGestureKeys.test.ts +0 -49
  74. package/src/player/components/automationGestureKeys.ts +0 -49
@@ -8,6 +8,7 @@ import {
8
8
  } from "../utils/sourcePatcher";
9
9
  import {
10
10
  formatTimelineAttributeNumber,
11
+ formatTimelineMediaOffset,
11
12
  type TimelineStackingReorderIntent,
12
13
  } from "../player/components/timelineEditing";
13
14
  import { getElementZIndex } from "../player/lib/layerOrdering";
@@ -15,7 +16,12 @@ import {
15
16
  furthestClipEndFromSource,
16
17
  getTimelineElementIdentity,
17
18
  } from "../player/lib/timelineElementHelpers";
18
- import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
19
+ import {
20
+ saveProjectFilesWithHistory,
21
+ writeProjectFilesWithHistoryInQueue,
22
+ type RecordEditInput,
23
+ } from "../utils/studioFileHistory";
24
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
19
25
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
20
26
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
21
27
  import { readFileContent } from "./timelineTimingSync";
@@ -279,7 +285,7 @@ export function buildTimelineResizeTimingPatch(
279
285
  patched = applyPatchByTarget(patched, target, {
280
286
  type: "attribute",
281
287
  property: pbs.attrName,
282
- value: formatTimelineAttributeNumber(pbs.value),
288
+ value: formatTimelineMediaOffset(pbs.value),
283
289
  });
284
290
  }
285
291
  // Content-driven duration from the PATCHED SOURCE (raw data-duration) —
@@ -396,7 +402,32 @@ export async function persistTimelineBatchEdit(
396
402
  });
397
403
  }
398
404
 
399
- export { applyPatchByTarget, formatTimelineAttributeNumber };
405
+ /** What the file holds for `attr` once queued writes to it land; undefined when unreadable. */
406
+ export async function readSavedAttribute(
407
+ projectId: string | null,
408
+ targetPath: string,
409
+ patchTarget: PatchTarget | null,
410
+ attr: string,
411
+ writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
412
+ ): Promise<string | null | undefined> {
413
+ if (!projectId || !patchTarget) return undefined;
414
+ const html = await serializeStudioFileMutations(writeFile, [targetPath], () =>
415
+ readFileContent(projectId, targetPath),
416
+ ).catch(() => null);
417
+ return html === null ? undefined : readTargetAttribute(html, patchTarget, attr);
418
+ }
419
+
420
+ /** What `html` holds for `attr` on the target; undefined when the target is not in it. */
421
+ export function readTargetAttribute(
422
+ html: string,
423
+ patchTarget: PatchTarget,
424
+ attr: string,
425
+ ): string | null | undefined {
426
+ if (readTagSnippetByTarget(html, patchTarget) === undefined) return undefined;
427
+ return readAttributeByTarget(html, patchTarget, attr) ?? null;
428
+ }
429
+
430
+ export { applyPatchByTarget, formatTimelineAttributeNumber, formatTimelineMediaOffset };
400
431
 
401
432
  export { patchDocumentRootDuration } from "./timelineEditingGsap";
402
433
 
@@ -412,6 +443,8 @@ export interface PersistElementAttributeInput {
412
443
  pendingTimelineEditPathRef: { current: Set<string> };
413
444
  /** Write the attribute directly on the live preview DOM node. */
414
445
  patchLive: (value: string | null) => void;
446
+ /** What the file held for the attribute, read inside the queue before this write. */
447
+ onFileRead: (value: string | null) => void;
415
448
  }
416
449
 
417
450
  /**
@@ -433,47 +466,40 @@ export async function persistElementAttribute({
433
466
  recordEdit,
434
467
  pendingTimelineEditPathRef,
435
468
  patchLive,
469
+ onFileRead,
436
470
  }: PersistElementAttributeInput): Promise<string[]> {
437
- // Resolve the target BEFORE patching the live DOM. The optimistic patch used
438
- // to run first, and only the save was wrapped in the unwind — so an
439
- // unresolvable target threw with the live preview (and, through the callers'
440
- // catch, the store mirrored off it) holding a value that never reached disk.
441
- // The write then read as successful until a reload dropped it.
442
- const before = await readFileContent(projectId, targetPath);
443
- if (readTagSnippetByTarget(before, patchTarget) === undefined) {
444
- throw new Error(`Unable to patch element in ${targetPath}`);
445
- }
446
- // The unwind value comes from the FILE, not from `readLive()`.
447
- //
448
- // Every live-write caller patches the DOM before committing — a fader drag is
449
- // `setLive` per frame, hovering a preset auditions the whole chain — so by the
450
- // time this runs the live DOM already holds the in-progress value. Reading it
451
- // here made `previousValue === value`, so the unwind below was a no-op, and
452
- // `setQuiet`'s catch (which deliberately re-mirrors the store from the live
453
- // DOM) then mirrored that same never-saved value. The group audibly had the
454
- // preset, the panel agreed, and a reload dropped it — the failure class the
455
- // target check above was added to close, still open on the live-write path.
456
- const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
457
- patchLive(value);
471
+ // Joins the file's mutation queue before reading, so saves land in the order they start
472
+ // and each patches what the save before it wrote. Resolve the target before patching
473
+ // the live DOM, so an unresolvable target never leaves an unsaved preview.
474
+ return serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
475
+ const before = await readFileContent(projectId, targetPath);
476
+ if (readTagSnippetByTarget(before, patchTarget) === undefined) {
477
+ throw new Error(`Unable to patch element in ${targetPath}`);
478
+ }
479
+ // Unwind to the file's value: live writers already patched the DOM, so `readLive()`
480
+ // would equal `value` and a failed save would keep a never-saved preview.
481
+ const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
482
+ onFileRead(previousValue);
483
+ patchLive(value);
458
484
 
459
- const operation: PatchOperation = { type: "attribute", property: attr, value };
460
- const patched = applyPatchByTarget(before, patchTarget, operation);
485
+ const operation: PatchOperation = { type: "attribute", property: attr, value };
486
+ const patched = applyPatchByTarget(before, patchTarget, operation);
461
487
 
462
- pendingTimelineEditPathRef.current.add(targetPath);
463
- try {
464
- const changedPaths = await saveProjectFilesWithHistory({
465
- projectId,
466
- label,
467
- files: { [targetPath]: patched },
468
- readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
469
- writeFile: writeProjectFile,
470
- recordEdit,
471
- });
472
- return changedPaths;
473
- } catch (error) {
474
- // The optimistic live write already ran; unwind it on a save failure so
475
- // the preview doesn't show a value that never reached disk.
476
- patchLive(previousValue);
477
- throw error;
478
- }
488
+ pendingTimelineEditPathRef.current.add(targetPath);
489
+ try {
490
+ return await writeProjectFilesWithHistoryInQueue({
491
+ projectId,
492
+ label,
493
+ files: { [targetPath]: patched },
494
+ readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
495
+ writeFile: writeProjectFile,
496
+ recordEdit,
497
+ });
498
+ } catch (error) {
499
+ // The optimistic live write already ran; unwind it on a save failure so
500
+ // the preview doesn't show a value that never reached disk.
501
+ patchLive(previousValue);
502
+ throw error;
503
+ }
504
+ });
479
505
  }
@@ -7,18 +7,25 @@
7
7
  * shared core `setAudioGroupAttribute` also uses.
8
8
  */
9
9
 
10
- import { useCallback, useRef } from "react";
10
+ import { useCallback } from "react";
11
11
  import type { TimelineElement } from "../player";
12
12
  import {
13
13
  buildPatchTarget,
14
14
  findTimelineElementInIframe,
15
15
  persistElementAttribute,
16
+ readSavedAttribute,
16
17
  } from "./timelineEditingHelpers";
18
+ import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
17
19
  import type {
18
20
  MutableRef,
19
21
  UseTimelineElementVisibilityEditingInput,
20
22
  } from "./timelineTrackVisibility";
21
- import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
23
+ import {
24
+ failedTimelineSave,
25
+ projectForTimelineSave,
26
+ type TimelineEditOutcome,
27
+ } from "./timelineEditPermission";
28
+ import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
22
29
 
23
30
  function patchLiveElementAttribute(
24
31
  iframe: HTMLIFrameElement | null,
@@ -41,6 +48,22 @@ function elementAttributeLiveKey(
41
48
  return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
42
49
  }
43
50
 
51
+ function elementSaveTarget(element: TimelineElement, activeCompPath: string | null) {
52
+ return {
53
+ targetPath: element.sourceFile || activeCompPath || "index.html",
54
+ patchTarget: buildPatchTarget(element),
55
+ };
56
+ }
57
+
58
+ function elementLiveSource(
59
+ element: TimelineElement,
60
+ activeCompPath: string | null,
61
+ attr: string,
62
+ ): LiveLaneSource {
63
+ const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
64
+ return { path: targetPath, target: patchTarget, attr };
65
+ }
66
+
44
67
  interface SetElementAttributeInput {
45
68
  projectId: string;
46
69
  activeCompPath: string | null;
@@ -48,7 +71,8 @@ interface SetElementAttributeInput {
48
71
  attr: string;
49
72
  value: string | null;
50
73
  label: string;
51
- previewIframe: HTMLIFrameElement | null;
74
+ patchLive: (value: string | null) => void;
75
+ onFileRead: (value: string | null) => void;
52
76
  writeProjectFile: (path: string, content: string) => Promise<void>;
53
77
  recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
54
78
  pendingTimelineEditPathRef: MutableRef<Set<string>>;
@@ -61,13 +85,13 @@ async function setElementAttribute({
61
85
  attr,
62
86
  value,
63
87
  label,
64
- previewIframe,
88
+ patchLive,
89
+ onFileRead,
65
90
  writeProjectFile,
66
91
  recordEdit,
67
92
  pendingTimelineEditPathRef,
68
93
  }: SetElementAttributeInput): Promise<string[] | null> {
69
- const targetPath = element.sourceFile || activeCompPath || "index.html";
70
- const patchTarget = buildPatchTarget(element);
94
+ const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
71
95
  if (!patchTarget) return null;
72
96
 
73
97
  return persistElementAttribute({
@@ -80,7 +104,8 @@ async function setElementAttribute({
80
104
  writeProjectFile,
81
105
  recordEdit,
82
106
  pendingTimelineEditPathRef,
83
- patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
107
+ patchLive,
108
+ onFileRead,
84
109
  });
85
110
  }
86
111
 
@@ -102,33 +127,46 @@ export function useSetElementAttribute({
102
127
  label: string,
103
128
  ) => Promise<TimelineEditOutcome>;
104
129
  revertLive: (element: TimelineElement, attr: string) => void;
130
+ restoreLive: (restore: LiveLaneRestore) => void;
105
131
  } {
106
- const liveBeforeRef = useRef(new Map<string, string | null>());
132
+ const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
107
133
  const setLive = useCallback(
108
134
  (element: TimelineElement, attr: string, value: string | null) => {
109
135
  const key = elementAttributeLiveKey(element, activeCompPath, attr);
110
136
  const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
111
- if (!liveBeforeRef.current.has(key)) {
112
- liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
113
- }
137
+ liveLanes.preview(
138
+ key,
139
+ () => target?.getAttribute(attr) ?? null,
140
+ elementLiveSource(element, activeCompPath, attr),
141
+ );
114
142
  patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
115
143
  },
144
+ [liveLanes, previewIframeRef, activeCompPath],
145
+ );
146
+ const laneApply = useCallback(
147
+ (element: TimelineElement, attr: string) => ({
148
+ preview: (value: string | null) =>
149
+ patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath),
150
+ store: (value: string | null) => syncStoredElementAttribute(element, attr, value),
151
+ }),
116
152
  [previewIframeRef, activeCompPath],
117
153
  );
154
+ const claimLive = useCallback(
155
+ (element: TimelineElement, attr: string) =>
156
+ liveLanes.claim(
157
+ elementAttributeLiveKey(element, activeCompPath, attr),
158
+ laneApply(element, attr),
159
+ elementLiveSource(element, activeCompPath, attr),
160
+ ),
161
+ [liveLanes, laneApply, activeCompPath],
162
+ );
118
163
  const revertLive = useCallback(
119
- (element: TimelineElement, attr: string) => {
120
- const key = elementAttributeLiveKey(element, activeCompPath, attr);
121
- if (!liveBeforeRef.current.has(key)) return;
122
- patchLiveElementAttribute(
123
- previewIframeRef.current,
124
- element,
125
- attr,
126
- liveBeforeRef.current.get(key) ?? null,
127
- activeCompPath,
128
- );
129
- liveBeforeRef.current.delete(key);
130
- },
131
- [previewIframeRef, activeCompPath],
164
+ (element: TimelineElement, attr: string) =>
165
+ liveLanes.revert(
166
+ elementAttributeLiveKey(element, activeCompPath, attr),
167
+ laneApply(element, attr),
168
+ ),
169
+ [liveLanes, laneApply, activeCompPath],
132
170
  );
133
171
  const setQuiet = useCallback(
134
172
  async (
@@ -137,9 +175,17 @@ export function useSetElementAttribute({
137
175
  value: string | null,
138
176
  label: string,
139
177
  ): Promise<TimelineEditOutcome> => {
140
- const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
141
- if (typeof pid !== "string") return pid;
142
- const liveKey = elementAttributeLiveKey(element, activeCompPath, attr);
178
+ const project = projectIdRef.current;
179
+ const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast);
180
+ const live = claimLive(element, attr);
181
+ const unsaved = async (outcome: TimelineEditOutcome): Promise<TimelineEditOutcome> => {
182
+ const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
183
+ live.settle(
184
+ await readSavedAttribute(project, targetPath, patchTarget, attr, writeProjectFile),
185
+ );
186
+ return outcome;
187
+ };
188
+ if (typeof pid !== "string") return unsaved(pid);
143
189
  try {
144
190
  const written = await setElementAttribute({
145
191
  projectId: pid,
@@ -148,25 +194,25 @@ export function useSetElementAttribute({
148
194
  attr,
149
195
  value,
150
196
  label,
151
- previewIframe: previewIframeRef.current,
197
+ patchLive: live.preview,
198
+ onFileRead: live.read,
152
199
  writeProjectFile,
153
200
  recordEdit,
154
201
  pendingTimelineEditPathRef,
155
202
  });
156
- liveBeforeRef.current.delete(liveKey);
157
- if (written) return { status: "saved" };
158
- return { status: "failed", reason: "This clip has no id to save it by" };
203
+ if (!written)
204
+ return unsaved(failedTimelineSave("This clip has no id to save it by", showToast));
205
+ live.settle(value);
206
+ return { status: "saved" };
159
207
  } catch (error) {
160
208
  console.error("[Timeline] Failed to set element attribute", error);
161
209
  const message = error instanceof Error ? error.message : "Failed to update effect";
162
- showToast(message);
163
- liveBeforeRef.current.delete(liveKey);
164
- return { status: "failed", reason: message };
210
+ return unsaved(failedTimelineSave(message, showToast));
165
211
  }
166
212
  },
167
213
  [
214
+ claimLive,
168
215
  activeCompPath,
169
- previewIframeRef,
170
216
  writeProjectFile,
171
217
  recordEdit,
172
218
  pendingTimelineEditPathRef,
@@ -175,5 +221,5 @@ export function useSetElementAttribute({
175
221
  projectIdRef,
176
222
  ],
177
223
  );
178
- return { setLive, setQuiet, revertLive };
224
+ return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
179
225
  }
@@ -93,7 +93,7 @@ interface UseAppHotkeysParams {
93
93
  handleDuplicate: () => Promise<boolean>;
94
94
  onResetKeyframes: () => boolean;
95
95
  onDeleteSelectedKeyframes: () => void;
96
- onAfterUndoRedo?: () => void;
96
+ onAfterUndoRedo?: UseEditHistoryActionsOptions["onAfterUndoRedo"];
97
97
  onToggleRecording?: () => void;
98
98
  /** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
99
99
  onGroupSelection?: () => void;
@@ -77,7 +77,7 @@ describe("useAutomationSelectionKeyboard", () => {
77
77
  });
78
78
 
79
79
  const setup = (binding: Partial<AutomationLaneBinding>) => {
80
- const onCommit = vi.fn();
80
+ const onCommit = vi.fn().mockResolvedValue(undefined);
81
81
  const automation = {
82
82
  version: 1,
83
83
  lanes: [
@@ -102,7 +102,6 @@ describe("useAutomationSelectionKeyboard", () => {
102
102
  onCommit,
103
103
  onSelect: vi.fn(),
104
104
  readOnly: false,
105
- commitTargetKey: "bgm",
106
105
  selection: null,
107
106
  onRangeSelect: vi.fn(),
108
107
  onRangeClear: vi.fn(),
@@ -230,7 +229,7 @@ describe("useAutomationSelectionKeyboard", () => {
230
229
  expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
231
230
  });
232
231
 
233
- it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
232
+ it("Cmd+V with no selection pastes at the playhead and selects the pasted span", async () => {
234
233
  clearAutomationClipboard();
235
234
  // Duration wide enough that the playhead (5s) is not clamped down by the
236
235
  // 0..duration-span bound — this is a paste-at-playhead test, not a
@@ -255,6 +254,7 @@ describe("useAutomationSelectionKeyboard", () => {
255
254
  expect(times).toContain(7); // + clipboard span 2
256
255
 
257
256
  // Pasting again immediately should land right after the first paste.
257
+ await act(async () => {});
258
258
  expect(usePlayerStore.getState().automationSelection).toEqual({
259
259
  elementKey: "bgm",
260
260
  target: "volume",
@@ -268,7 +268,124 @@ describe("useAutomationSelectionKeyboard", () => {
268
268
  });
269
269
  });
270
270
 
271
- it("chains a second Cmd+V after the first instead of overwriting it", () => {
271
+ it("marks nothing when the paste's save is refused, even for two quick presses", async () => {
272
+ clearAutomationClipboard();
273
+ usePlayerStore.setState({
274
+ elements: [{ ...bgmElement, duration: 10 }],
275
+ selectedElementId: "bgm",
276
+ });
277
+ const original = wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
278
+ usePlayerStore.getState().setAutomationSelection(original);
279
+ const onCommit = vi.fn().mockResolvedValue({ status: "refused", reason: "Locked" });
280
+ setup({ onCommit });
281
+ combo("c");
282
+ combo("v");
283
+ combo("v");
284
+ await act(async () => {});
285
+ expect(usePlayerStore.getState().automationSelection).toEqual(original);
286
+ combo("v");
287
+ const again = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
288
+ (p: { t: number }) => p.t,
289
+ );
290
+ expect(again).toContain(2);
291
+ expect(again).not.toContain(6);
292
+ });
293
+
294
+ it.each([{ status: "saved" as const }, { status: "refused" as const, reason: "Locked" }])(
295
+ "keeps a selection drawn while a paste was saving ($status)",
296
+ async (outcome) => {
297
+ clearAutomationClipboard();
298
+ usePlayerStore.setState({
299
+ elements: [{ ...bgmElement, duration: 10 }],
300
+ selectedElementId: "bgm",
301
+ });
302
+ usePlayerStore
303
+ .getState()
304
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
305
+ let land = () => {};
306
+ const onCommit = vi.fn(
307
+ () =>
308
+ new Promise<typeof outcome>((resolve) => {
309
+ land = () => resolve(outcome);
310
+ }),
311
+ );
312
+ setup({ onCommit });
313
+ combo("c");
314
+ combo("v");
315
+ const drawn = wholeAxis({ elementKey: "bgm", target: "volume", t0: 6, t1: 8 });
316
+ usePlayerStore.getState().setAutomationSelection(drawn);
317
+ land();
318
+ await act(async () => {});
319
+ expect(usePlayerStore.getState().automationSelection).toEqual(drawn);
320
+ },
321
+ );
322
+
323
+ it.each(["in order", "reversed"])(
324
+ "marks the latest paste when pastes on two clips resolve %s",
325
+ async (order) => {
326
+ clearAutomationClipboard();
327
+ usePlayerStore.setState({
328
+ elements: [
329
+ { ...bgmElement, duration: 10 },
330
+ { ...bgmElement, id: "vo", key: "vo", duration: 10 },
331
+ ],
332
+ selectedElementId: "bgm",
333
+ currentTime: 1,
334
+ });
335
+ usePlayerStore
336
+ .getState()
337
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
338
+ const lands: Array<() => void> = [];
339
+ const onCommit = vi.fn(
340
+ () =>
341
+ new Promise<{ status: "saved" }>((resolve) =>
342
+ lands.push(() => resolve({ status: "saved" })),
343
+ ),
344
+ );
345
+ setup({ onCommit });
346
+ combo("c");
347
+ usePlayerStore.getState().clearAutomationSelection();
348
+ combo("v");
349
+ usePlayerStore.setState({ selectedElementId: "vo" });
350
+ combo("v");
351
+ const order_ = order === "in order" ? [0, 1] : [1, 0];
352
+ for (const i of order_) lands[i]?.();
353
+ await act(async () => {});
354
+ expect(usePlayerStore.getState().automationSelection?.elementKey).toBe("vo");
355
+ },
356
+ );
357
+
358
+ it("does not mark a paste whose clip was left before it landed", async () => {
359
+ clearAutomationClipboard();
360
+ usePlayerStore.setState({
361
+ elements: [
362
+ { ...bgmElement, duration: 10 },
363
+ { ...bgmElement, id: "vo", key: "vo", duration: 10 },
364
+ ],
365
+ selectedElementId: "bgm",
366
+ currentTime: 1,
367
+ });
368
+ usePlayerStore
369
+ .getState()
370
+ .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
371
+ let land = () => {};
372
+ const onCommit = vi.fn(
373
+ () =>
374
+ new Promise<{ status: "saved" }>((resolve) => {
375
+ land = () => resolve({ status: "saved" });
376
+ }),
377
+ );
378
+ setup({ onCommit });
379
+ combo("c");
380
+ usePlayerStore.getState().clearAutomationSelection();
381
+ combo("v");
382
+ usePlayerStore.setState({ selectedElementId: "vo" });
383
+ land();
384
+ await act(async () => {});
385
+ expect(usePlayerStore.getState().automationSelection).toBeNull();
386
+ });
387
+
388
+ it("chains a second Cmd+V after the first instead of overwriting it", async () => {
272
389
  // The regression this pins: paste leaves its own span selected, so anchoring
273
390
  // at sel.t0 unconditionally made every later press recompute the same atT.
274
391
  clearAutomationClipboard();
@@ -288,6 +405,7 @@ describe("useAutomationSelectionKeyboard", () => {
288
405
  );
289
406
  expect(first).toContain(2);
290
407
  expect(first).toContain(4);
408
+ await act(async () => {});
291
409
 
292
410
  combo("v");
293
411
  const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
@@ -295,6 +413,7 @@ describe("useAutomationSelectionKeyboard", () => {
295
413
  );
296
414
  expect(second).toContain(4);
297
415
  expect(second).toContain(6);
416
+ await act(async () => {});
298
417
  expect(usePlayerStore.getState().automationSelection).toEqual({
299
418
  elementKey: "bgm",
300
419
  target: "volume",
@@ -308,7 +427,7 @@ describe("useAutomationSelectionKeyboard", () => {
308
427
  });
309
428
  });
310
429
 
311
- it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
430
+ it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", async () => {
312
431
  // The playhead branch already clamps to duration - span; the
313
432
  // selection-start branch didn't, so pasting a 2s clip at a selection
314
433
  // sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
@@ -334,6 +453,7 @@ describe("useAutomationSelectionKeyboard", () => {
334
453
  expect(t).toBeLessThanOrEqual(bgmElement.duration);
335
454
  }
336
455
  // Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
456
+ await act(async () => {});
337
457
  expect(usePlayerStore.getState().automationSelection).toEqual({
338
458
  elementKey: "bgm",
339
459
  target: "volume",
@@ -347,22 +467,6 @@ describe("useAutomationSelectionKeyboard", () => {
347
467
  });
348
468
  });
349
469
 
350
- it("refuses to paste when the dom-edit layer would write to a different clip", () => {
351
- // selectedElementId says "bgm" but the commit channel is still on the
352
- // previously selected clip — writing here would serialize bgm's automation
353
- // onto that other clip and leave bgm untouched.
354
- clearAutomationClipboard();
355
- copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
356
- usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
357
- usePlayerStore
358
- .getState()
359
- .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
360
- const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
361
- const e = combo("v");
362
- expect(e.defaultPrevented).toBe(false);
363
- expect(onCommit).not.toHaveBeenCalled();
364
- });
365
-
366
470
  it("does not paste from a playhead outside the clip", () => {
367
471
  // No selection on this clip, and the playhead is past its end — there is no
368
472
  // anchor. This used to collapse to the clip's own t=0.