@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
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import type { DraggedClipState } from "./useTimelineClipDrag";
4
4
  import {
5
5
  commitDraggedClipMove,
6
+ commitZMirrorLaneMove,
6
7
  type DragCommitDeps,
7
8
  type TimelineMoveEdit,
8
9
  } from "./timelineClipDragCommit";
@@ -12,6 +13,7 @@ import {
12
13
  pushEditHistoryEntry,
13
14
  } from "../../utils/editHistory";
14
15
  import { normalizeToZones } from "./timelineZones";
16
+ import { resolveZMirrorLaneMove } from "./timelineZMirror";
15
17
  import type { StackingPatch } from "./timelineStackingSync";
16
18
 
17
19
  function el(
@@ -29,9 +31,7 @@ function el(
29
31
  /** Flush the microtask chain: the z-sync now fires only after the move persist
30
32
  * promise resolves (serialized), so tests asserting on it must await. */
31
33
  async function flushMicrotasks(): Promise<void> {
32
- await Promise.resolve();
33
- await Promise.resolve();
34
- await Promise.resolve();
34
+ for (let step = 0; step < 8; step += 1) await Promise.resolve();
35
35
  }
36
36
 
37
37
  function drag(
@@ -219,6 +219,88 @@ describe("commitDraggedClipMove", () => {
219
219
  expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
220
220
  });
221
221
 
222
+ it("persists a lane change in AUTHORED track space when the file is sparse", () => {
223
+ // Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
224
+ // (authoredTrack records the file value). Moving 'a' onto b's lane must
225
+ // write b's AUTHORED track (2) — writing the display lane (1) would target
226
+ // a's own authored row and silently no-op in the file.
227
+ const elements = [
228
+ { ...el("a", 0, 0, 3), authoredTrack: 1 },
229
+ { ...el("b", 1, 10, 3), authoredTrack: 2 },
230
+ ];
231
+ const { updateElement, onMoveElements } = runClipMove(
232
+ drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
233
+ { elements, trackOrder: [0, 1] },
234
+ );
235
+ // Store stays in display-lane space, but authoredTrack is refreshed to the
236
+ // value just written to the file so a SECOND drag before any reload resolves
237
+ // authored tracks from current data, not the stale pre-edit value.
238
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
239
+ // ...while the persist is translated to the target lane's authored track.
240
+ const map = editMap(onMoveElements.mock.calls[0][0]);
241
+ expect(map.a).toEqual({ start: 20, track: 2 });
242
+ });
243
+
244
+ it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
245
+ // Expanded sub-comp children live on synthetic display lanes next to host
246
+ // clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
247
+ // occupied by a clip from a DIFFERENT file must never lend its authored
248
+ // value. Here lane 1 holds both a host-file clip (authored 12) and a
249
+ // same-file sibling (authored 7): the sibling answers.
250
+ const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
251
+ const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
252
+ const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
253
+ const elements = [child3, child7, hostClip];
254
+ const { onMoveElements } = runClipMove(
255
+ drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
256
+ { elements, trackOrder: [0, 1] },
257
+ );
258
+ const map = editMap(onMoveElements.mock.calls[0][0]);
259
+ expect(map.c3).toEqual({ start: 0, track: 7 });
260
+ });
261
+
262
+ it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
263
+ // Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
264
+ // and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
265
+ // only another file's clip. Persisting 6 (the display integer) or 12 (the
266
+ // foreign authored value) would corrupt the sparse file; the fallback
267
+ // offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
268
+ // one lane further down → 8.
269
+ const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
270
+ const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
271
+ const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
272
+ const elements = [child3, child7, foreign];
273
+ const { onMoveElements } = runClipMove(
274
+ drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
275
+ { elements, trackOrder: [4, 5, 6] },
276
+ );
277
+ const map = editMap(onMoveElements.mock.calls[0][0]);
278
+ expect(map.c3.track).not.toBe(6); // not the display-lane integer
279
+ expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
280
+ expect(map.c3).toEqual({ start: 0, track: 8 });
281
+ });
282
+
283
+ it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
284
+ // Authored track 2 holds two time-overlapping clips, which packTrackLanes
285
+ // spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
286
+ // 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
287
+ // persisted value is the shared authored track (2), even though the clip
288
+ // may re-pack onto a different sub-lane on the next normalize.
289
+ const elements = normalizeToZones([
290
+ { ...el("a", 0, 30, 3), authoredTrack: 1 },
291
+ { ...el("b1", 2, 0, 5), authoredTrack: 2 },
292
+ { ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
293
+ ]);
294
+ expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
295
+ const spillLane = elements.find((e) => e.id === "b2")!.track;
296
+ const { onMoveElements } = runClipMove(
297
+ drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
298
+ { elements, trackOrder: [0, 1, 2] },
299
+ );
300
+ const map = editMap(onMoveElements.mock.calls[0][0]);
301
+ expect(map.a).toEqual({ start: 30, track: 2 });
302
+ });
303
+
222
304
  it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
223
305
  const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
224
306
  // Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
@@ -283,8 +365,76 @@ describe("commitDraggedClipMove", () => {
283
365
  const map = editMap(onMoveElements.mock.calls[0][0]);
284
366
  // Lanes are contiguous and distinct (no two overlapping clips share a lane).
285
367
  expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
286
- // The z-aware normalization may reverse the authored lane numbers, but the
287
- // insert must still leave three distinct, contiguous visual lanes.
368
+ expect(map.a.track).toBe(0); // above the insert unchanged
369
+ expect(map.c.track).toBe(1); // dragged clip lands on the new lane
370
+ expect(map.b.track).toBe(2); // at/below the insert → +1 shift
371
+ });
372
+
373
+ it("a selected audio passenger moves in time during a visual insert without being renumbered", () => {
374
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
375
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
376
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
377
+ const audio = {
378
+ ...el("audio", 2, 4, 20, "audio"),
379
+ sourceFile: "scene.html",
380
+ authoredTrack: 7,
381
+ };
382
+
383
+ const { onMoveElements } = runClipMove(
384
+ drag(t, { previewStart: 5, previewTrack: 1, insertRow: 1 }),
385
+ {
386
+ elements: [a, b, t, audio],
387
+ trackOrder: [0, 1, 2],
388
+ selectedKeys: new Set(["t", "audio"]),
389
+ },
390
+ );
391
+
392
+ const map = editMap(onMoveElements.mock.calls[0][0]);
393
+ expect(map.audio).toEqual({ start: 9, track: 7 });
394
+ });
395
+
396
+ it("an audio insert persists zone-local tracks without rewriting visual lanes", () => {
397
+ const v0 = el("v0", 0, 0, 5);
398
+ const v1 = el("v1", 1, 0, 5);
399
+ const a = { ...el("a", 2, 0, 5, "audio"), authoredTrack: 0 };
400
+ const b = { ...el("b", 3, 10, 5, "audio"), authoredTrack: 1 };
401
+ const t = { ...el("t", 3, 0, 5, "audio"), authoredTrack: 1 };
402
+
403
+ const { onMoveElements } = runClipMove(
404
+ drag(t, { previewStart: 0, previewTrack: 3, insertRow: 3 }),
405
+ { elements: [v0, v1, a, b, t], trackOrder: [0, 1, 2, 3] },
406
+ );
407
+
408
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
409
+ a: { start: 0, track: 0 },
410
+ t: { start: 0, track: 1 },
411
+ b: { start: 10, track: 2 },
412
+ });
413
+ });
414
+
415
+ it("refuses an audio insert when it would renumber a locked authored row", () => {
416
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
417
+ const visual = el("visual", 0, 0, 5);
418
+ const locked = {
419
+ ...el("locked", 1, 0, 5, "audio"),
420
+ authoredTrack: 0,
421
+ timelineLocked: true,
422
+ };
423
+ const target = { ...el("target", 2, 0, 5, "audio"), authoredTrack: 1 };
424
+
425
+ try {
426
+ const { onMoveElements } = runClipMove(
427
+ drag(target, { previewStart: 0, previewTrack: 2, insertRow: 1 }),
428
+ { elements: [visual, locked, target], trackOrder: [0, 1, 2] },
429
+ );
430
+
431
+ expect(onMoveElements).not.toHaveBeenCalled();
432
+ expect(warnSpy).toHaveBeenCalledWith(
433
+ expect.stringContaining("locked clip locked would need renumbering"),
434
+ );
435
+ } finally {
436
+ warnSpy.mockRestore();
437
+ }
288
438
  });
289
439
 
290
440
  describe("lane ↔ stacking sync", () => {
@@ -440,9 +590,9 @@ describe("commitDraggedClipMove", () => {
440
590
  drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
441
591
  [0, 1],
442
592
  );
443
- // Non-overlapping clips retain their authored/z-derived ordering; a lane
444
- // gesture cannot invent a DOM stacking relationship where none overlaps.
445
- expect(lane.dragged).toBe(2);
593
+ expect(lane.dragged).toBe(0); // aimed at the very top
594
+ expect(lane.top).toBe(1);
595
+ expect(lane.mid).toBe(2);
446
596
  });
447
597
 
448
598
  it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
@@ -453,8 +603,8 @@ describe("commitDraggedClipMove", () => {
453
603
  [0, 1, 2],
454
604
  );
455
605
  expect(lane.a).toBe(0);
456
- expect(lane.b).toBe(1);
457
- expect(lane.x).toBe(2);
606
+ expect(lane.x).toBe(1); // between a and b, as aimed
607
+ expect(lane.b).toBe(2);
458
608
  });
459
609
 
460
610
  it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
@@ -466,7 +616,7 @@ describe("commitDraggedClipMove", () => {
466
616
  drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
467
617
  [0, 1, 2],
468
618
  );
469
- expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
619
+ expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
470
620
  });
471
621
 
472
622
  it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
@@ -941,3 +1091,212 @@ describe("commitDraggedClipMove", () => {
941
1091
  });
942
1092
  });
943
1093
  });
1094
+
1095
+ describe("commitZMirrorLaneMove", () => {
1096
+ const mirrorDeps = (elements: TimelineElement[], trackOrder: number[]) => {
1097
+ const updateElement = vi.fn();
1098
+ const onMoveElements = vi.fn();
1099
+ return {
1100
+ updateElement,
1101
+ onMoveElements,
1102
+ deps: { elements, trackOrder, updateElement, onMoveElements } as DragCommitDeps,
1103
+ };
1104
+ };
1105
+
1106
+ it("kind:move persists start + display lane with the persistTrack override (same shape as a lane drag)", async () => {
1107
+ // t sits at lane 2 in a sparse file (authored 7); the mirror lands it on
1108
+ // lane 0 whose authored track is 3.
1109
+ const t = { ...el("t", 2, 0, 10), authoredTrack: 7 };
1110
+ const elements = [{ ...el("a", 0, 20, 5), authoredTrack: 3 }, el("b", 1, 0, 10), t];
1111
+ const { updateElement, onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1112
+ const moved = await commitZMirrorLaneMove(
1113
+ t,
1114
+ { kind: "move", displayTrack: 0, persistTrack: 3 },
1115
+ deps,
1116
+ "z-reorder:bring-forward:t",
1117
+ );
1118
+ expect(moved).toBe(true);
1119
+ // Optimistic store update: DISPLAY lane + the written authoredTrack mirror.
1120
+ expect(updateElement).toHaveBeenCalledWith("t", {
1121
+ start: 0,
1122
+ track: 0,
1123
+ authoredTrack: 3,
1124
+ });
1125
+ // Persist: authored-space track, the z persist's coalesce key, lane-reorder op.
1126
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1127
+ const [persistEdits, coalesceKey, operation] = onMoveElements.mock.calls[0];
1128
+ expect(editMap(persistEdits)).toEqual({ t: { start: 0, track: 3 } });
1129
+ expect(coalesceKey).toBe("z-reorder:bring-forward:t");
1130
+ expect(operation).toBe("lane-reorder");
1131
+ });
1132
+
1133
+ it("kind:insert reuses the track-insert renumber core (+1 shift below the new lane)", async () => {
1134
+ // a,b,c mutually overlapping on lanes 0/1/2. Mirror-insert c at row 1: c
1135
+ // lands on the new lane, b (at/below) shifts down — identical to the drag
1136
+ // insert test above, proving the shared core (no duplicated renumber logic).
1137
+ const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
1138
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1139
+ const moved = await commitZMirrorLaneMove(
1140
+ elements[2],
1141
+ { kind: "insert", insertRow: 1 },
1142
+ deps,
1143
+ "z-reorder:bring-forward:c",
1144
+ );
1145
+ expect(moved).toBe(true);
1146
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1147
+ expect(onMoveElements.mock.calls[0][1]).toBe("z-reorder:bring-forward:c");
1148
+ expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
1149
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1150
+ expect(map.a.track).toBe(0);
1151
+ expect(map.c.track).toBe(1);
1152
+ expect(map.b.track).toBe(2);
1153
+ });
1154
+
1155
+ it("visual mirror inserts never persist or renumber same-file audio", async () => {
1156
+ // b and t share a visual lane because they do not overlap. Inserting t between
1157
+ // a and b creates one extra visual lane, so whole-timeline normalization moves
1158
+ // audio from display lane 2 to 3. That display-only shift must not be written.
1159
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
1160
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
1161
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
1162
+ const audio = { ...el("audio", 2, 0, 20, "audio"), sourceFile: "scene.html" };
1163
+ const { onMoveElements, deps } = mirrorDeps([a, b, t, audio], [0, 1, 2]);
1164
+
1165
+ const moved = await commitZMirrorLaneMove(
1166
+ t,
1167
+ { kind: "insert", insertRow: 1 },
1168
+ deps,
1169
+ "z-reorder:bring-forward:t",
1170
+ );
1171
+
1172
+ expect(moved).toBe(true);
1173
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1174
+ expect(map.audio).toBeUndefined();
1175
+ expect(map).toEqual({
1176
+ a: { start: 0, track: 0 },
1177
+ b: { start: 10, track: 2 },
1178
+ t: { start: 0, track: 1 },
1179
+ });
1180
+ });
1181
+
1182
+ it("foreign expanded rows never distort the root-file insert topology", async () => {
1183
+ const host = { ...el("host", 0, 0, 5), sourceFile: "index.html" };
1184
+ const child1 = {
1185
+ ...el("child-1", 0.25, 0, 5),
1186
+ sourceFile: "scene.html",
1187
+ expandedParentStart: 0,
1188
+ };
1189
+ const child2 = {
1190
+ ...el("child-2", 0.5, 0, 5),
1191
+ sourceFile: "scene.html",
1192
+ expandedParentStart: 0,
1193
+ };
1194
+ const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
1195
+ const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
1196
+ const elements = [host, child1, child2, b, t];
1197
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 0.25, 0.5, 1, 2]);
1198
+
1199
+ const moved = await commitZMirrorLaneMove(
1200
+ t,
1201
+ { kind: "insert", insertRow: 1 },
1202
+ deps,
1203
+ "z-reorder:bring-forward:t",
1204
+ );
1205
+
1206
+ expect(moved).toBe(true);
1207
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
1208
+ host: { start: 0, track: 0 },
1209
+ t: { start: 0, track: 1 },
1210
+ b: { start: 0, track: 2 },
1211
+ });
1212
+ });
1213
+
1214
+ it("never triggers the lane→z stacking sync (it would fight the just-set z values)", async () => {
1215
+ // Even with BOTH z-sync deps supplied (the drag paths would engage them for
1216
+ // a vertical move like this), the mirror commit must not emit stacking
1217
+ // patches — the z values were just written by the user's menu action.
1218
+ const t = el("t", 2, 0, 10);
1219
+ const elements = [el("a", 0, 20, 5), el("b", 1, 0, 10), t];
1220
+ const { deps } = mirrorDeps(elements, [0, 1, 2]);
1221
+ const onStackingPatches = vi.fn();
1222
+ const moved = await commitZMirrorLaneMove(
1223
+ t,
1224
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1225
+ { ...deps, readZIndex: () => 0, onStackingPatches },
1226
+ "z-reorder:bring-forward:t",
1227
+ );
1228
+ await flushMicrotasks();
1229
+ expect(moved).toBe(true);
1230
+ expect(onStackingPatches).not.toHaveBeenCalled();
1231
+ });
1232
+
1233
+ it("resolves false and rolls the store back when the persist rejects", async () => {
1234
+ const t = el("t", 1, 0, 10);
1235
+ const elements = [el("a", 0, 20, 5), t];
1236
+ const updateElement = vi.fn();
1237
+ const onMoveElements = vi.fn().mockRejectedValue(new Error("boom"));
1238
+ const moved = await commitZMirrorLaneMove(
1239
+ t,
1240
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1241
+ { elements, trackOrder: [0, 1], updateElement, onMoveElements },
1242
+ "z-reorder:send-backward:t",
1243
+ );
1244
+ expect(moved).toBe(false);
1245
+ // Optimistic write then rollback to the original lane.
1246
+ expect(updateElement).toHaveBeenLastCalledWith("t", {
1247
+ start: 0,
1248
+ track: 1,
1249
+ authoredTrack: undefined,
1250
+ });
1251
+ });
1252
+
1253
+ it("END-TO-END one-element step: resolver insertRow renumbers the clip strictly between the two neighbors", async () => {
1254
+ // 3 stacked back-to-back clips + a free lane beyond the far one. Send t
1255
+ // (top) backward past b: the resolver must bound at c and produce the
1256
+ // insert row IMMEDIATELY below b, and commitZMirrorLaneMove's renumber must
1257
+ // land t strictly between b and c — never on the farther free lane 3.
1258
+ const t = el("t", 0, 0, 10);
1259
+ const b = el("b", 1, 0, 10);
1260
+ const c = el("c", 2, 0, 10);
1261
+ const far = el("far", 3, 20, 5); // free over t's span, beyond c
1262
+ const elements = [t, b, c, far];
1263
+ const move = resolveZMirrorLaneMove({
1264
+ action: "send-backward",
1265
+ element: t,
1266
+ elements,
1267
+ crossedKey: "b",
1268
+ });
1269
+ expect(move).toEqual({ kind: "insert", insertRow: 2 });
1270
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2, 3]);
1271
+ const moved = await commitZMirrorLaneMove(t, move!, deps, "z-reorder:send-backward:t");
1272
+ expect(moved).toBe(true);
1273
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1274
+ // The renumber compacts t's vacated top lane, so the whole set shifts up by
1275
+ // one while t lands on the b/c boundary — strictly between the two.
1276
+ expect(map).toEqual({
1277
+ b: { start: 0, track: 0 },
1278
+ t: { start: 0, track: 1 },
1279
+ c: { start: 0, track: 2 },
1280
+ far: { start: 20, track: 3 },
1281
+ });
1282
+ expect(map.b.track).toBeLessThan(map.t.track);
1283
+ expect(map.t.track).toBeLessThan(map.c.track);
1284
+ });
1285
+
1286
+ it("resolves false for a refused insert (locked clip would need renumbering)", async () => {
1287
+ // b is locked and sits at/below the insert row, so the whole-set renumber
1288
+ // is refused — no persist call.
1289
+ const a = el("a", 0, 0, 5);
1290
+ const b: TimelineElement = { ...el("b", 1, 0, 5), timelineLocked: true };
1291
+ const c = el("c", 2, 0, 5);
1292
+ const { onMoveElements, deps } = mirrorDeps([a, b, c], [0, 1, 2]);
1293
+ const moved = await commitZMirrorLaneMove(
1294
+ c,
1295
+ { kind: "insert", insertRow: 1 },
1296
+ deps,
1297
+ "z-reorder:bring-forward:c",
1298
+ );
1299
+ expect(moved).toBe(false);
1300
+ expect(onMoveElements).not.toHaveBeenCalled();
1301
+ });
1302
+ });