@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -1,5 +1,8 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import type { DraggedClipState } from "./useTimelineClipDrag";
3
+ // Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
4
+ // value-import edge stays acyclic.
5
+ import type { ZMirrorLaneMove } from "./timelineZMirror";
3
6
  import { classifyZone, normalizeToZones } from "./timelineZones";
4
7
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
5
8
  import { getTimelineEditCapabilities } from "./timelineEditing";
@@ -8,11 +11,19 @@ import {
8
11
  beginTimelineOptimisticGesture,
9
12
  isLatestTimelineOptimisticGesture,
10
13
  } from "./timelineOptimisticRevision";
14
+ import { runLaneZGesture } from "../../components/nle/zLaneGesture";
11
15
 
12
16
  type StartTrack = Pick<TimelineElement, "start" | "track">;
13
17
  export interface TimelineMoveEdit {
14
18
  element: TimelineElement;
15
19
  updates: StartTrack;
20
+ /**
21
+ * File-space track override for the persist. The store's `updates.track` is a
22
+ * DISPLAY lane; when the source file's numbering is sparse (authored tracks
23
+ * 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
24
+ * silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
25
+ */
26
+ persistTrack?: number;
16
27
  }
17
28
 
18
29
  export interface DragCommitDeps {
@@ -24,11 +35,14 @@ export interface DragCommitDeps {
24
35
  /** Atomic multi-clip persist (single undo) for lane changes + track inserts.
25
36
  * `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
26
37
  * history entry so it merges with the lane change's z-reorder entry (see the
27
- * lane-change branch below). */
38
+ * lane-change branch below). `coalesceMs` widens that entry's fold window
39
+ * (per-gesture-unique keys make an unbounded window safe) — required when a
40
+ * server round-trip sits between the gesture's two records. */
28
41
  onMoveElements?: (
29
42
  edits: TimelineMoveEdit[],
30
43
  coalesceKey?: string,
31
44
  operation?: TimelineMoveOperation,
45
+ coalesceMs?: number,
32
46
  ) => Promise<void> | void;
33
47
  /**
34
48
  * The current multi-selection (store.selectedElementIds). When the dragged
@@ -53,18 +67,13 @@ export interface DragCommitDeps {
53
67
  * the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in
54
68
  * research/STAGE3-NEEDED-WIRING.md.
55
69
  */
56
- onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void;
70
+ onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
57
71
  }
58
72
 
59
73
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
60
74
  const round3 = (v: number) => Math.round(v * 1000) / 1000;
61
75
 
62
- // One coalesce key per lane-change gesture, shared by the move-persist history
63
- // entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder
64
- // layers") so editHistory (pushEditHistoryEntry) folds the two consecutive
65
- // records into a single undo step. A monotonic counter — NOT Date.now() /
66
- // Math.random(), which the determinism rules forbid — suffices: the key only has
67
- // to be unique per gesture and identical across the gesture's two records.
76
+ // One deterministic coalesce key shared by both records in a lane-change gesture.
68
77
  let laneChangeGestureSeq = 0;
69
78
 
70
79
  /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
@@ -94,12 +103,17 @@ function canMoveElement(element: TimelineElement): boolean {
94
103
  * `false` so the caller also skips the z-sync (no orphaned z patch).
95
104
  *
96
105
  * The DOM is updated synchronously up front; the returned promise never rejects.
106
+ *
107
+ * Exported for reuse by non-drag batch time-moves (track gap closing — see
108
+ * timelineGapCommit.ts) so they share the same optimistic-apply, rollback, and
109
+ * atomic single-undo persist semantics as a drag commit.
97
110
  */
98
- function persistMoveEdits(
111
+ export function persistMoveEdits(
99
112
  edits: TimelineMoveEdit[],
100
113
  deps: DragCommitDeps,
101
114
  coalesceKey?: string,
102
115
  operation: TimelineMoveOperation = "timing",
116
+ coalesceMs?: number,
103
117
  ): Promise<boolean> {
104
118
  if (edits.length === 0) return Promise.resolve(true);
105
119
  const { updateElement, onMoveElement, onMoveElements } = deps;
@@ -114,21 +128,41 @@ function persistMoveEdits(
114
128
  key: keyOf(e.element),
115
129
  start: e.element.start,
116
130
  track: e.element.track,
131
+ authoredTrack: e.element.authoredTrack,
117
132
  }));
118
133
  const revision = beginTimelineOptimisticGesture(
119
134
  updateElement,
120
135
  edits.map((edit) => keyOf(edit.element)),
121
136
  );
122
- for (const e of edits) updateElement(keyOf(e.element), e.updates);
137
+ // The file write below targets `persistTrack` (authored space) when supplied,
138
+ // or `updates.track` on a genuine lane write (track insert renumber). Mirror
139
+ // that written value into the store's `authoredTrack` so a SECOND drag before
140
+ // any reload resolves authored tracks from what the file now says, not stale
141
+ // pre-edit data. Pure time-moves leave authoredTrack untouched.
142
+ for (const e of edits) {
143
+ const writtenTrack =
144
+ e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
145
+ updateElement(
146
+ keyOf(e.element),
147
+ writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
148
+ );
149
+ }
150
+ // The store above gets DISPLAY lanes; the file below gets the authored-space
151
+ // track when one was resolved (see TimelineMoveEdit.persistTrack).
152
+ const persistEdits = edits.map((e) =>
153
+ e.persistTrack == null || e.persistTrack === e.updates.track
154
+ ? e
155
+ : { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
156
+ );
123
157
  const persisted = onMoveElements
124
- ? onMoveElements(edits, coalesceKey, operation)
125
- : Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
158
+ ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
159
+ : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
126
160
  return Promise.resolve(persisted).then(
127
161
  () => true,
128
162
  (error) => {
129
163
  for (const p of prev) {
130
164
  if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
131
- updateElement(p.key, { start: p.start, track: p.track });
165
+ updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
132
166
  }
133
167
  }
134
168
  console.error("[Timeline] Failed to persist clip edits", error);
@@ -143,6 +177,59 @@ function persistMoveEdits(
143
177
  * then compacts it to a distinct integer lane between its neighbours, and the
144
178
  * clips at/below the insert shift down by one — the sanctioned index-renumber.
145
179
  */
180
+ /** Same-source-file predicate: authored track numbers only compare within ONE
181
+ * file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
182
+ * file, not the host timeline's). `undefined` means the active composition. */
183
+ export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
184
+ (a.sourceFile ?? null) === (b.sourceFile ?? null);
185
+
186
+ /**
187
+ * Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
188
+ * `dragged`. Occupants are consulted ONLY from the dragged clip's own source
189
+ * file — an occupant from a different file (e.g. an expanded sub-comp child, or
190
+ * a host clip next to expanded rows) carries authored values in a different
191
+ * coordinate space, and borrowing them would write a foreign file's numbering.
192
+ *
193
+ * Lane semantics after normalizeToZones: each distinct authored track owns one
194
+ * base lane, and time-overlapping same-track clips spill onto adjacent display
195
+ * sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
196
+ * trackOrder lists it): its occupants share the base lane's authored track by
197
+ * construction, so the same-file occupant lookup returns that authored track and
198
+ * the drop persists as a same-track join. The clip may then DISPLAY on a
199
+ * different sub-lane than it was dropped on — the spill re-packs
200
+ * deterministically by stable id, first-fit — but the persisted track is
201
+ * correct.
202
+ *
203
+ * Fallbacks when the lane has no same-file occupant (e.g. an expanded child
204
+ * dropped on a lane holding only other files' clips — the display-lane integer
205
+ * must NOT be persisted into a sparse file):
206
+ * 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
207
+ * preserving "one lane up = one authored track up" in the clip's own file.
208
+ * 2. No same-file peers at all → the lane value itself (single-clip files:
209
+ * display and authored spaces coincide for want of any other anchor).
210
+ * Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
211
+ * never here.
212
+ */
213
+ export function authoredTrackForLane(
214
+ lane: number,
215
+ elements: TimelineElement[],
216
+ dragged: TimelineElement,
217
+ ): number {
218
+ const dragKey = keyOf(dragged);
219
+ const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
220
+ const occupant = peers.find((e) => e.track === lane);
221
+ if (occupant) return occupant.authoredTrack ?? occupant.track;
222
+ let nearest: TimelineElement | null = null;
223
+ for (const p of peers) {
224
+ if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
225
+ }
226
+ if (!nearest) return lane;
227
+ // Rounded: expanded children live on FRACTIONAL synthetic display rows (see
228
+ // buildChildElements), so a lane distance measured against one can carry a
229
+ // fraction — an authored data-track-index must stay an integer.
230
+ return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
231
+ }
232
+
146
233
  function insertTrackValue(trackOrder: number[], insertRow: number): number {
147
234
  if (trackOrder.length === 0) return 0;
148
235
  if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -251,6 +338,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
251
338
  const dragEdit: TimelineMoveEdit = {
252
339
  element: drag.element,
253
340
  updates: { start: drag.previewStart, track: drag.previewTrack },
341
+ persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
254
342
  };
255
343
  const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
256
344
 
@@ -265,12 +353,21 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
265
353
  // The drop-intent set for the z-sync: the dragged clip at its new lane, others
266
354
  // as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
267
355
  // move; computeStackingPatches only compares lanes relatively.
268
- const candidate = elements.map((e) =>
269
- keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
270
- );
356
+ const candidate = elements.map((e) => {
357
+ if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
358
+ // Selection members shift in time with the drag — the z-sync must reason on
359
+ // their POST-move overlap sets, same as the insert branch's candidate.
360
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
361
+ return e;
362
+ });
271
363
  const multiKeys = multi ? multi.keys : null;
272
- void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
273
- if (moved && isVertical) {
364
+ if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
365
+ void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
366
+ return;
367
+ }
368
+ void runLaneZGesture({
369
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
370
+ commitZ: () =>
274
371
  syncStackingForEdit(
275
372
  candidate,
276
373
  dragKey,
@@ -279,67 +376,123 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
279
376
  multiKeys,
280
377
  deps,
281
378
  coalesceKey,
282
- );
283
- }
284
- });
379
+ ),
380
+ }).catch(() => undefined);
285
381
  }
286
382
 
287
- /**
288
- * Insert a new track at the drop's gap boundary. The dragged clip lands on the
289
- * fractional insert lane; normalizeToZones then compacts every lane to a contiguous
290
- * integer, which shifts the clips at/below the insert down by one. That +1
291
- * renumber is the ONLY sanctioned multi-clip write; it is index-only (never z).
292
- * The whole affected set is persisted atomically (single undo), and the deliberate
293
- * vertical move syncs the dragged clip's stacking afterwards.
294
- */
295
- function commitTrackInsert(
296
- drag: DraggedClipState,
297
- deps: DragCommitDeps,
383
+ /** Build the one sanctioned multi-clip write: atomically insert and compact a
384
+ * source-file zone, then let the caller sync the deliberate vertical stacking. */
385
+ // fallow-ignore-next-line complexity
386
+ function buildTrackInsertEdits(
387
+ element: TimelineElement,
388
+ previewStart: number,
389
+ insertRow: number,
298
390
  multi: {
299
391
  keys: ReadonlySet<string>;
300
392
  movedStart: (e: TimelineElement) => number;
301
393
  } | null,
302
- ): void {
394
+ deps: DragCommitDeps,
395
+ ): { candidate: TimelineElement[]; edits: TimelineMoveEdit[] } | null {
303
396
  const { elements, trackOrder } = deps;
304
- const dragKey = keyOf(drag.element);
305
- const targetTrack = insertTrackValue(trackOrder, drag.insertRow!);
306
- // Drop-intent set: dragged clip at the fractional insert lane (so it sorts
307
- // between its neighbours), selection members time-shifted, others as-is.
397
+ const editKey = keyOf(element);
398
+ // Expanded-child rows are synthetic host lanes, not source-file topology.
399
+ if (element.expandedParentStart != null) return null;
400
+ const targetTrack = insertTrackValue(trackOrder, insertRow);
308
401
  const candidate = elements.map((e) => {
309
- if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack };
402
+ if (keyOf(e) === editKey) return { ...e, start: previewStart, track: targetTrack };
310
403
  if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
311
404
  return e;
312
405
  });
313
- // normalizeToZones compacts the fractional lane to a contiguous integer, which
314
- // shifts the at/below clips down by one — the sanctioned +1 index renumber.
315
- const normalized = normalizeToZones(candidate);
406
+ // Foreign display rows and the opposite zone must not affect this topology.
407
+ const writableZone = classifyZone(element);
408
+ const writable = (src: TimelineElement): boolean =>
409
+ sameSourceFile(src, element) &&
410
+ classifyZone(src) === writableZone &&
411
+ src.expandedParentStart == null;
412
+ const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
413
+ (a, b) => a - b,
414
+ );
415
+ const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
416
+ const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
417
+ const normalized = normalizeToZones(
418
+ elements.filter(writable).map((e) => {
419
+ if (keyOf(e) === editKey) {
420
+ return { ...e, start: previewStart, track: topologyTargetTrack };
421
+ }
422
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
423
+ return e;
424
+ }),
425
+ );
316
426
  const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
427
+ // A partial zone renumber creates collisions; refuse a shifted locked row.
428
+ for (const norm of normalized) {
429
+ const src = bySrc.get(keyOf(norm));
430
+ if (
431
+ src &&
432
+ writable(src) &&
433
+ !canMoveElement(src) &&
434
+ norm.track !== (src.authoredTrack ?? src.track)
435
+ ) {
436
+ console.warn(
437
+ `[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
438
+ );
439
+ return null;
440
+ }
441
+ }
317
442
  const edits: TimelineMoveEdit[] = [];
443
+ if (multi) {
444
+ for (const src of elements) {
445
+ const srcKey = keyOf(src);
446
+ if (srcKey !== editKey && multi.keys.has(srcKey) && !writable(src) && canMoveElement(src)) {
447
+ edits.push({
448
+ element: src,
449
+ updates: { start: multi.movedStart(src), track: src.track },
450
+ persistTrack: src.authoredTrack,
451
+ });
452
+ }
453
+ }
454
+ }
318
455
  for (const norm of normalized) {
319
456
  const src = bySrc.get(keyOf(norm));
320
- if (!src) continue;
321
- // Capabilities gate: never write a locked/implicit clip, even one only swept
322
- // along by the renumber (not just a marquee member).
323
- if (!canMoveElement(src)) continue;
457
+ if (!src || !canMoveElement(src)) continue;
324
458
  const start =
325
- keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
326
- ? (multi?.movedStart(src) ?? drag.previewStart)
459
+ keyOf(norm) === editKey || multi?.keys.has(keyOf(norm))
460
+ ? (multi?.movedStart(src) ?? previewStart)
327
461
  : src.start;
328
462
  edits.push({ element: src, updates: { start, track: norm.track } });
329
463
  }
464
+ return { candidate, edits };
465
+ }
466
+
467
+ function commitTrackInsert(
468
+ drag: DraggedClipState,
469
+ deps: DragCommitDeps,
470
+ multi: {
471
+ keys: ReadonlySet<string>;
472
+ movedStart: (e: TimelineElement) => number;
473
+ } | null,
474
+ ): void {
475
+ const dragKey = keyOf(drag.element);
476
+ const built = buildTrackInsertEdits(
477
+ drag.element,
478
+ drag.previewStart,
479
+ drag.insertRow!,
480
+ multi,
481
+ deps,
482
+ );
483
+ if (!built) return;
484
+ const { candidate, edits } = built;
485
+ if (edits.length === 0) return;
330
486
 
331
487
  const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
332
- void persistMoveEdits(edits, deps, coalesceKey, "track-insert").then((moved) => {
333
- // Skip the z-sync when the insert produced NO move edits (e.g. every clip in
334
- // the set is locked/implicit and gets filtered out). persistMoveEdits resolves
335
- // `true` for an empty batch so the caller's serialization proceeds, but firing
336
- // the z-sync here would record an orphaned z-only history entry for a move that
337
- // never persisted.
338
- if (moved && edits.length > 0) {
339
- // Reason the z-sync on the drop-intent `candidate` (dragged clip at its
340
- // fractional insert lane) — NOT the re-normalized lanes — so the sync sees
341
- // the user's move. The guard lane is the aimed insert row (a boundary in
342
- // display-lane space, comparable to the clip's contiguous current lane).
488
+ if (!deps.readZIndex || !deps.onStackingPatches) {
489
+ void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
490
+ return;
491
+ }
492
+ void runLaneZGesture({
493
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
494
+ commitZ: () =>
495
+ // Sync from the fractional drop intent, not the normalized persisted lanes.
343
496
  syncStackingForEdit(
344
497
  candidate,
345
498
  dragKey,
@@ -348,9 +501,54 @@ function commitTrackInsert(
348
501
  multi ? multi.keys : null,
349
502
  deps,
350
503
  coalesceKey,
351
- );
352
- }
353
- });
504
+ ),
505
+ }).catch(() => undefined);
506
+ }
507
+
508
+ /**
509
+ * Commit the timeline lane move that MIRRORS a canvas z-order menu action
510
+ * (resolveZMirrorLaneMove's non-null result). Same machinery as a lane drag:
511
+ *
512
+ * - kind "move": persistMoveEdits with `{start: element.start, track: displayTrack}`
513
+ * + `persistTrack` — identical shape to commitDraggedClipMove's lane-change
514
+ * branch (optimistic store update, authoredTrack mirror, rollback on failure).
515
+ * - kind "insert": buildTrackInsertEdits — the SAME renumber core commitTrackInsert
516
+ * uses — then the same atomic persist.
517
+ *
518
+ * Deliberately NO syncStackingForEdit here: the z values were just set by the
519
+ * user's menu action, and the lane→z sync would recompute (and fight) them. The
520
+ * mirror caller also omits `readZIndex`/`onStackingPatches` from `deps`, so even
521
+ * a future call into the sync would no-op (double protection; see
522
+ * useCanvasZOrderTimelineMirror).
523
+ *
524
+ * `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
525
+ * so editHistory folds the z write and this track write into ONE undo entry, and
526
+ * `coalesceMs` MUST widen this record's fold window: the mirror only runs after
527
+ * the z persist's server round-trip resolved, so under real network latency the
528
+ * gap between the two records exceeds the reducer's 300ms default and the fold
529
+ * would never happen live. The key is unique per gesture, so an unbounded
530
+ * window can never merge distinct gestures.
531
+ *
532
+ * Resolves `true` once the move persisted, `false` on rollback / refused insert.
533
+ */
534
+ export function commitZMirrorLaneMove(
535
+ element: TimelineElement,
536
+ move: NonNullable<ZMirrorLaneMove>,
537
+ deps: DragCommitDeps,
538
+ coalesceKey: string,
539
+ coalesceMs?: number,
540
+ ): Promise<boolean> {
541
+ if (move.kind === "move") {
542
+ const edit: TimelineMoveEdit = {
543
+ element,
544
+ updates: { start: element.start, track: move.displayTrack },
545
+ persistTrack: move.persistTrack,
546
+ };
547
+ return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
548
+ }
549
+ const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
550
+ if (!built || built.edits.length === 0) return Promise.resolve(false);
551
+ return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
354
552
  }
355
553
 
356
554
  /**
@@ -371,18 +569,16 @@ function syncStackingForEdit(
371
569
  multiKeys: ReadonlySet<string> | null,
372
570
  deps: DragCommitDeps,
373
571
  coalesceKey?: string,
374
- ): void {
572
+ ): Promise<void> {
375
573
  const { readZIndex, onStackingPatches } = deps;
376
- if (!readZIndex || !onStackingPatches) return;
574
+ if (!readZIndex || !onStackingPatches) return Promise.resolve();
377
575
 
378
576
  // Aiming at the clip's OWN current display lane is not a relocation — never
379
577
  // touch z (guards the pure-time-move invariant even if a spurious topology call
380
578
  // slips through). Every real lane-realization drop aims at a DIFFERENT lane.
381
- if (aimedLane === currentLane) return;
579
+ if (aimedLane === currentLane) return Promise.resolve();
382
580
 
383
- // `candidate` is in discovery order, so its array index IS the DOM document
384
- // position. Equal-z clips paint by DOM order, so the sync needs it to decide
385
- // "is A above B" (see StackingElement.domIndex).
581
+ // Discovery order is DOM order, which breaks equal-z ties.
386
582
  const stackingEls = candidate.map((el, domIndex) => ({
387
583
  key: keyOf(el),
388
584
  start: el.start,
@@ -390,12 +586,15 @@ function syncStackingForEdit(
390
586
  track: el.track,
391
587
  zIndex: readZIndex(el),
392
588
  isAudio: classifyZone(el) === "audio",
589
+ sourceFile: el.sourceFile,
393
590
  domIndex,
591
+ stackingContextId: el.stackingContextId ?? null,
394
592
  }));
395
593
 
396
594
  const editedKeys = [dragKey];
397
595
  if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
398
596
 
399
597
  const patches = computeStackingPatches(stackingEls, editedKeys);
400
- if (patches.length > 0) onStackingPatches(patches, coalesceKey);
598
+ if (patches.length === 0) return Promise.resolve();
599
+ return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
401
600
  }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { DragCommitDeps } from "./timelineClipDragCommit";
4
+ import {
5
+ canShiftTrackGapClips,
6
+ commitCloseAllTrackGaps,
7
+ commitCloseTrackGap,
8
+ } from "./timelineGapCommit";
9
+ import { resolveAllTrackGaps } from "./timelineGaps";
10
+
11
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
12
+ // domId + video tag → getTimelineEditCapabilities(...).canMove === true
13
+ return { id, tag: "video", start, duration, track, domId: id };
14
+ }
15
+
16
+ function lockedEl(id: string, start: number, duration: number): TimelineElement {
17
+ return { ...el(id, start, duration), timelineLocked: true };
18
+ }
19
+
20
+ function makeDeps(laneElements: TimelineElement[]) {
21
+ const onMoveElements = vi.fn(() => Promise.resolve());
22
+ const updateElement = vi.fn();
23
+ const deps: DragCommitDeps = {
24
+ elements: laneElements,
25
+ trackOrder: [0],
26
+ updateElement,
27
+ onMoveElements,
28
+ };
29
+ return { deps, onMoveElements, updateElement };
30
+ }
31
+
32
+ /** Assert exactly ONE atomic persist batch; return its flattened edits + coalesce key. */
33
+ function singleBatch(onMoveElements: ReturnType<typeof vi.fn>) {
34
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
35
+ const [edits, coalesceKey] = onMoveElements.mock.calls[0] as unknown as [
36
+ Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
37
+ string,
38
+ ];
39
+ return { coalesceKey, edits: edits.map((e) => ({ id: e.element.id, ...e.updates })) };
40
+ }
41
+
42
+ describe("commitCloseTrackGap", () => {
43
+ it("persists ONE atomic batch shifting the next clip and every clip after it", () => {
44
+ const lane = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
45
+ const { deps, onMoveElements } = makeDeps(lane);
46
+
47
+ expect(commitCloseTrackGap(lane, 3, deps)).toBe(true);
48
+
49
+ const { edits, coalesceKey } = singleBatch(onMoveElements);
50
+ // Gap is [2, 5) → width 3; b and c shift left by 3, tracks unchanged.
51
+ expect(edits).toEqual([
52
+ { id: "b", start: 2, track: 0 },
53
+ { id: "c", start: 7, track: 0 },
54
+ ]);
55
+ expect(typeof coalesceKey).toBe("string");
56
+ expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
57
+ });
58
+
59
+ it("optimistically applies the same starts to the store", () => {
60
+ const lane = [el("a", 0, 2), el("b", 5, 3)];
61
+ const { deps, updateElement } = makeDeps(lane);
62
+ commitCloseTrackGap(lane, 3, deps);
63
+ expect(updateElement).toHaveBeenCalledWith("b", { start: 2, track: 0 });
64
+ });
65
+
66
+ it("closes the leading gap (first clip lands at 0)", () => {
67
+ const lane = [el("a", 2, 3), el("b", 6, 1)];
68
+ const { deps, onMoveElements } = makeDeps(lane);
69
+
70
+ expect(commitCloseTrackGap(lane, 1, deps)).toBe(true);
71
+ const [edits] = onMoveElements.mock.calls[0] as unknown as [
72
+ Array<{ element: TimelineElement; updates: { start: number; track: number } }>,
73
+ ];
74
+ expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
75
+ { id: "a", start: 0 },
76
+ { id: "b", start: 4 },
77
+ ]);
78
+ });
79
+
80
+ it("uses a fresh coalesce key per gesture", () => {
81
+ const lane = [el("a", 0, 2), el("b", 5, 3)];
82
+ const first = makeDeps(lane);
83
+ const second = makeDeps(lane);
84
+ commitCloseTrackGap(lane, 3, first.deps);
85
+ commitCloseTrackGap(lane, 3, second.deps);
86
+ const keyA = first.onMoveElements.mock.calls[0][1 as never];
87
+ const keyB = second.onMoveElements.mock.calls[0][1 as never];
88
+ expect(keyA).not.toEqual(keyB);
89
+ });
90
+
91
+ it("refuses (no write) when there is no clip right of the point", () => {
92
+ const lane = [el("a", 0, 2)];
93
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
94
+ expect(commitCloseTrackGap(lane, 5, deps)).toBe(false);
95
+ expect(onMoveElements).not.toHaveBeenCalled();
96
+ expect(updateElement).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("refuses (no partial compaction) when ANY shifting clip is unmovable", () => {
100
+ const lane = [el("a", 0, 2), el("b", 5, 3), lockedEl("c", 10, 1)];
101
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
102
+ expect(commitCloseTrackGap(lane, 3, deps)).toBe(false);
103
+ expect(onMoveElements).not.toHaveBeenCalled();
104
+ expect(updateElement).not.toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe("commitCloseAllTrackGaps", () => {
109
+ it("compacts the whole lane in ONE atomic batch (leading gap included)", () => {
110
+ const lane = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
111
+ const { deps, onMoveElements } = makeDeps(lane);
112
+
113
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
114
+
115
+ const { edits, coalesceKey } = singleBatch(onMoveElements);
116
+ expect(edits).toEqual([
117
+ { id: "a", start: 0, track: 0 },
118
+ { id: "b", start: 2, track: 0 },
119
+ { id: "c", start: 5, track: 0 },
120
+ ]);
121
+ expect(coalesceKey).toMatch(/^track-gap-close:\d+$/);
122
+ });
123
+
124
+ it("refuses when the track is already contiguous (no gaps)", () => {
125
+ const lane = [el("a", 0, 2), el("b", 2, 3)];
126
+ const { deps, onMoveElements } = makeDeps(lane);
127
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
128
+ expect(onMoveElements).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("refuses when any shifting clip is unmovable, even if others could move", () => {
132
+ const lane = [lockedEl("a", 1, 2), el("b", 5, 3)];
133
+ const { deps, onMoveElements, updateElement } = makeDeps(lane);
134
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(false);
135
+ expect(onMoveElements).not.toHaveBeenCalled();
136
+ expect(updateElement).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("proceeds when an unmovable clip does NOT need to shift", () => {
140
+ // Locked clip already sits flush at 0 — only movable clips shift.
141
+ const lane = [lockedEl("a", 0, 2), el("b", 4, 1)];
142
+ const { deps, onMoveElements } = makeDeps(lane);
143
+ expect(commitCloseAllTrackGaps(lane, deps)).toBe(true);
144
+ const [edits] = onMoveElements.mock.calls[0] as unknown as [
145
+ Array<{ element: TimelineElement; updates: { start: number } }>,
146
+ ];
147
+ expect(edits.map((e) => ({ id: e.element.id, start: e.updates.start }))).toEqual([
148
+ { id: "b", start: 2 },
149
+ ]);
150
+ });
151
+ });
152
+
153
+ describe("canShiftTrackGapClips", () => {
154
+ it("is true only when every named clip is movable", () => {
155
+ const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
156
+ expect(canShiftTrackGapClips(lane, [{ key: "a", newStart: 0 }])).toBe(true);
157
+ expect(canShiftTrackGapClips(lane, resolveAllTrackGaps(lane))).toBe(false);
158
+ });
159
+
160
+ it("is false for unknown keys", () => {
161
+ expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
162
+ });
163
+ });