@hyperframes/studio 0.7.61 → 0.7.62

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 (84) hide show
  1. package/dist/assets/{hyperframes-player-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
  2. package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3504 -2955
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -10
  12. package/src/components/EditorShell.tsx +12 -0
  13. package/src/components/StudioLeftSidebar.tsx +3 -0
  14. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  15. package/src/components/editor/PropertyPanelFlat.tsx +6 -0
  16. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  17. package/src/components/editor/domEditing.test.ts +34 -0
  18. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  19. package/src/components/editor/propertyPanelColor.tsx +150 -28
  20. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
  23. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
  24. package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
  25. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  26. package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
  27. package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
  28. package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
  29. package/src/components/editor/propertyPanelSections.tsx +2 -2
  30. package/src/components/editor/propertyPanelTypes.ts +2 -0
  31. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  33. package/src/components/nle/TimelinePane.tsx +6 -0
  34. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  35. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  36. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  37. package/src/contexts/StudioContext.tsx +4 -0
  38. package/src/hooks/timelineEditingHelpers.ts +10 -4
  39. package/src/hooks/usePreviewPersistence.ts +7 -1
  40. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  41. package/src/hooks/useRazorSplit.test.tsx +20 -9
  42. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  43. package/src/hooks/useRazorSplit.ts +78 -350
  44. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  45. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  46. package/src/hooks/useTimelineEditing.ts +19 -17
  47. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  48. package/src/player/components/Timeline.tsx +12 -1
  49. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  50. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  51. package/src/player/components/timelineCallbacks.ts +4 -0
  52. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  53. package/src/player/components/timelineClipDragCommit.ts +77 -105
  54. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  55. package/src/player/components/timelineClipDragPreview.ts +2 -1
  56. package/src/player/components/timelineCollision.test.ts +18 -0
  57. package/src/player/components/timelineCollision.ts +12 -9
  58. package/src/player/components/timelineDragDrop.ts +28 -7
  59. package/src/player/components/timelineEditCapabilities.ts +4 -1
  60. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  61. package/src/player/components/timelineGroupEditing.ts +4 -3
  62. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  63. package/src/player/components/timelineLayout.ts +3 -1
  64. package/src/player/components/timelineZMirror.ts +1 -1
  65. package/src/player/components/useTimelineClipDrag.ts +5 -0
  66. package/src/player/components/useTimelineEditPinning.ts +12 -0
  67. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  68. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  69. package/src/player/lib/playbackTypes.ts +2 -0
  70. package/src/player/lib/timelineDOM.test.ts +58 -0
  71. package/src/player/lib/timelineDOM.ts +18 -1
  72. package/src/player/lib/timelineElementHelpers.ts +21 -11
  73. package/src/player/store/playerStore.ts +2 -1
  74. package/src/utils/razorSplitTransaction.test.ts +175 -0
  75. package/src/utils/razorSplitTransaction.ts +200 -0
  76. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  77. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  78. package/src/utils/timelineCompositionDrop.ts +16 -0
  79. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  80. package/src/utils/timelineCompositionInsert.ts +90 -0
  81. package/src/utils/timelineElementSplit.test.ts +14 -2
  82. package/src/utils/timelineElementSplit.ts +5 -1
  83. package/dist/assets/index-2mxh_HSy.js +0 -426
  84. package/dist/assets/index-BhWig0mx.css +0 -1
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
28
28
  import type { TimelineProps } from "./TimelineTypes";
29
29
  import { useTrackGapMenu } from "./useTrackGapMenu";
30
30
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
31
+ import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
31
32
 
32
33
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
33
34
  export {
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
52
53
  onFileDrop,
53
54
  onAssetDrop,
54
55
  onBlockDrop,
56
+ onCompositionDrop,
55
57
  onDeleteElement: _onDeleteElement,
56
58
  onMoveElement: onMoveElementOverride,
57
59
  onMoveElements: onMoveElementsOverride,
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
84
86
  onSplitElement: onSplitElementOverride,
85
87
  });
86
88
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
89
+ const playbackContext = useStudioPlaybackContextOptional();
90
+ const setRefreshKey = playbackContext?.setRefreshKey;
91
+ const refreshAfterLaneMove = useCallback(() => {
92
+ setRefreshKey?.((key) => key + 1);
93
+ }, [setRefreshKey]);
87
94
  useMusicBeatAnalysis();
88
95
  const rawElements = usePlayerStore((s) => s.elements);
89
96
  const expandedElements = useExpandedTimelineElements();
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
169
176
  pinnedOnFileDrop,
170
177
  pinnedOnAssetDrop,
171
178
  pinnedOnBlockDrop,
179
+ pinnedOnCompositionDrop,
172
180
  } = useTimelineEditPinning({
173
181
  ppsRef,
174
182
  fitPpsRef,
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
179
187
  onFileDrop,
180
188
  onAssetDrop,
181
189
  onBlockDrop,
190
+ onCompositionDrop,
182
191
  });
183
192
 
184
193
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
223
232
  setRangeSelectionRef,
224
233
  readZIndex: zSyncEnabled ? readClipZIndex : undefined,
225
234
  onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
235
+ refreshAfterLaneMove,
226
236
  });
227
237
 
228
238
  const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
234
244
  onFileDrop: pinnedOnFileDrop,
235
245
  onAssetDrop: pinnedOnAssetDrop,
236
246
  onBlockDrop: pinnedOnBlockDrop,
247
+ onCompositionDrop: pinnedOnCompositionDrop,
237
248
  });
238
249
 
239
250
  const displayTrackOrder = useMemo(() => {
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
399
410
  <div
400
411
  ref={setContainerRef}
401
412
  aria-label="Timeline"
402
- className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
413
+ className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
403
414
  onMouseMove={(e) => {
404
415
  if (activeTool === "razor" && scrollRef.current) {
405
416
  const rect = scrollRef.current.getBoundingClientRect();
@@ -0,0 +1,77 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
+ import { getTimelineEditCapabilities } from "./timelineEditing";
4
+ import type { DraggedClipState } from "./timelineClipDragTypes";
5
+
6
+ /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
7
+ export function canMoveTimelineElement(element: TimelineElement): boolean {
8
+ return getTimelineEditCapabilities({
9
+ tag: element.tag,
10
+ kind: element.kind,
11
+ duration: element.duration,
12
+ domId: element.domId,
13
+ selector: element.selector,
14
+ compositionSrc: element.compositionSrc,
15
+ playbackStart: element.playbackStart,
16
+ playbackStartAttr: element.playbackStartAttr,
17
+ sourceDuration: element.sourceDuration,
18
+ timingSource: element.timingSource,
19
+ timelineLocked: element.timelineLocked,
20
+ }).canMove;
21
+ }
22
+
23
+ interface ExpandedHostAliasDeps {
24
+ elements: TimelineElement[];
25
+ selectedKeys?: ReadonlySet<string> | null;
26
+ }
27
+
28
+ /**
29
+ * Expanded children keep their own source-file identity for direct edits, but a
30
+ * selection can briefly contain both a composition host and one of its visible
31
+ * expanded children. That pair is one authored move target, not two. Resolve it
32
+ * to the host before time/lane/collision commit so ordinary clip placement stays
33
+ * the single owner of the gesture semantics.
34
+ */
35
+ export function resolveExpandedHostAlias(
36
+ drag: DraggedClipState,
37
+ deps: ExpandedHostAliasDeps,
38
+ ): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
39
+ const selectedKeys = deps.selectedKeys;
40
+ if (!selectedKeys) return null;
41
+
42
+ const collapsedKeys = new Set(selectedKeys);
43
+ const candidates = deps.elements.includes(drag.element)
44
+ ? deps.elements
45
+ : [...deps.elements, drag.element];
46
+ for (const element of candidates) {
47
+ const hostKey = element.expandedHostKey;
48
+ const childKey = getTimelineElementIdentity(element);
49
+ if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
50
+ collapsedKeys.delete(childKey);
51
+ }
52
+ }
53
+
54
+ const hostKey = drag.element.expandedHostKey;
55
+ const childKey = getTimelineElementIdentity(drag.element);
56
+ if (!hostKey || collapsedKeys.has(childKey)) {
57
+ if (collapsedKeys.size === selectedKeys.size) return null;
58
+ return { drag, selectedKeys: collapsedKeys };
59
+ }
60
+
61
+ const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
62
+ if (!host || !canMoveTimelineElement(host)) return null;
63
+
64
+ const delta = drag.previewStart - drag.element.start;
65
+ const mapTrack = (track: number | undefined): number | undefined =>
66
+ track === drag.element.track ? host.track : track;
67
+ return {
68
+ drag: {
69
+ ...drag,
70
+ element: host,
71
+ previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
72
+ previewTrack: mapTrack(drag.previewTrack) ?? host.track,
73
+ desiredTrack: mapTrack(drag.desiredTrack),
74
+ },
75
+ selectedKeys: collapsedKeys,
76
+ };
77
+ }
@@ -0,0 +1,29 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ const keyOf = (element: TimelineElement) => element.key ?? element.id;
4
+
5
+ /** Authored track numbers only compare within one source file. */
6
+ export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
7
+ (a.sourceFile ?? null) === (b.sourceFile ?? null);
8
+
9
+ /** Translate a display lane into the source-file track to persist. */
10
+ export function authoredTrackForLane(
11
+ lane: number,
12
+ elements: TimelineElement[],
13
+ dragged: TimelineElement,
14
+ ): number {
15
+ const dragKey = keyOf(dragged);
16
+ const peers = elements.filter((element) => {
17
+ return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
18
+ });
19
+ const occupant = peers.find((element) => element.track === lane);
20
+ if (occupant) return occupant.authoredTrack ?? occupant.track;
21
+
22
+ let nearest: TimelineElement | null = null;
23
+ for (const peer of peers) {
24
+ if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
25
+ }
26
+ if (!nearest) return lane;
27
+ // Synthetic expanded-child display rows can be fractional; authored tracks cannot.
28
+ return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
29
+ }
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
22
22
  blockName: string,
23
23
  placement: { start: number; track: number },
24
24
  ) => Promise<void> | void;
25
+ onCompositionDrop?: (
26
+ sourcePath: string,
27
+ placement: { start: number; track: number },
28
+ ) => Promise<void> | void;
25
29
  }
26
30
 
27
31
  export interface TimelineEditCallbacks {
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
4
4
  import {
5
5
  commitDraggedClipMove,
6
6
  commitZMirrorLaneMove,
7
+ persistMoveEdits,
7
8
  type DragCommitDeps,
8
9
  type TimelineMoveEdit,
9
10
  } from "./timelineClipDragCommit";
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
314
315
  expect(map.c).toBeUndefined(); // unselected clips untouched
315
316
  });
316
317
 
318
+ it("collapses a selected expanded child onto its authored composition host", () => {
319
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
320
+ const child = {
321
+ ...el("scene.html#title", 0.25, 12, 2),
322
+ sourceFile: "scene.html",
323
+ expandedParentStart: 10,
324
+ expandedHostKey: "host",
325
+ };
326
+ const { updateElement, onMoveElement, onMoveElements } = runClipMove(
327
+ drag(child, { previewStart: 15, previewTrack: child.track }),
328
+ {
329
+ elements: [host],
330
+ trackOrder: [0, child.track],
331
+ selectedKeys: new Set(["host", "scene.html#title"]),
332
+ },
333
+ );
334
+
335
+ expect(onMoveElements).not.toHaveBeenCalled();
336
+ expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
337
+ expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
338
+ expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
339
+ });
340
+
341
+ it("drops a selected expanded child alias when the authored host initiates the drag", () => {
342
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
343
+ const child = {
344
+ ...el("scene.html#title", 0.25, 12, 2),
345
+ sourceFile: "scene.html",
346
+ expandedParentStart: 10,
347
+ expandedHostKey: "host",
348
+ };
349
+ const { onMoveElement, onMoveElements } = runClipMove(
350
+ drag(host, { previewStart: 13, previewTrack: host.track }),
351
+ {
352
+ elements: [host, child],
353
+ trackOrder: [0, child.track],
354
+ selectedKeys: new Set(["host", "scene.html#title"]),
355
+ },
356
+ );
357
+
358
+ expect(onMoveElements).not.toHaveBeenCalled();
359
+ expect(onMoveElement).toHaveBeenCalledOnce();
360
+ expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
361
+ });
362
+
363
+ it("keeps an expanded child as the edit target when its host is not selected", () => {
364
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
365
+ const child = {
366
+ ...el("scene.html#title", 0.25, 12, 2),
367
+ sourceFile: "scene.html",
368
+ expandedParentStart: 10,
369
+ expandedHostKey: "host",
370
+ };
371
+ const { onMoveElement, onMoveElements } = runClipMove(
372
+ drag(child, { previewStart: 15, previewTrack: child.track }),
373
+ {
374
+ elements: [host],
375
+ trackOrder: [0, child.track],
376
+ selectedKeys: new Set(["scene.html#title"]),
377
+ },
378
+ );
379
+
380
+ expect(onMoveElements).not.toHaveBeenCalled();
381
+ expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
382
+ });
383
+
384
+ it("moves a host alias and an ordinary selected clip once each in one batch", () => {
385
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
386
+ const ordinary = el("ordinary", 1, 20, 3);
387
+ const child = {
388
+ ...el("scene.html#title", 0.25, 12, 2),
389
+ sourceFile: "scene.html",
390
+ expandedParentStart: 10,
391
+ expandedHostKey: "host",
392
+ };
393
+ const { onMoveElement, onMoveElements } = runClipMove(
394
+ drag(child, { previewStart: 14, previewTrack: child.track }),
395
+ {
396
+ elements: [host, ordinary],
397
+ trackOrder: [0, child.track, 1],
398
+ selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
399
+ },
400
+ );
401
+
402
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
403
+ expect(map).toEqual({
404
+ host: { start: 12, track: 0 },
405
+ ordinary: { start: 22, track: 1 },
406
+ });
407
+ });
408
+
409
+ it("applies an expanded-child vertical drag to the selected host lane", () => {
410
+ const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
411
+ const child = {
412
+ ...el("scene.html#title", 0.25, 12, 2),
413
+ sourceFile: "scene.html",
414
+ expandedParentStart: 10,
415
+ expandedHostKey: "host",
416
+ };
417
+ const { onMoveElement, onMoveElements } = runClipMove(
418
+ drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
419
+ {
420
+ elements: [host],
421
+ trackOrder: [0, child.track, 1],
422
+ selectedKeys: new Set(["host", "scene.html#title"]),
423
+ },
424
+ );
425
+
426
+ const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
427
+ expect(map).toEqual({ host: { start: 10, track: 1 } });
428
+ });
429
+
317
430
  it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
318
431
  const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
319
432
  // Drag 'a' −5s: b would land at −3 → clamps to 0.
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
959
1072
  expectZLiftedToSix(onStackingPatches);
960
1073
  });
961
1074
 
1075
+ it("refreshes the preview only after the complete lane and z transaction", async () => {
1076
+ const order: string[] = [];
1077
+ commitInsertAbove(overlapping(), {
1078
+ onMoveElements: vi.fn(async () => {
1079
+ order.push("lane");
1080
+ }),
1081
+ onStackingPatches: vi.fn(async () => {
1082
+ order.push("z");
1083
+ }),
1084
+ refreshAfterLaneMove: () => order.push("refresh"),
1085
+ });
1086
+
1087
+ await flushMicrotasks();
1088
+
1089
+ expect(order).toEqual(["lane", "z", "refresh"]);
1090
+ });
1091
+
962
1092
  it("rolls back the move and skips the z-sync when the persist fails", async () => {
963
1093
  const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
964
1094
  const elements = overlapping();
965
1095
  const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
966
1096
  const onStackingPatches = vi.fn();
1097
+ const refreshAfterLaneMove = vi.fn();
967
1098
  const updateElement = vi.fn();
968
1099
  commitInsertAbove(elements, {
969
1100
  updateElement,
970
1101
  onMoveElements,
971
1102
  onStackingPatches,
1103
+ refreshAfterLaneMove,
972
1104
  });
973
1105
  await flushMicrotasks();
974
1106
  // Failed move → z patch never issued (no orphaned z change left behind)...
975
1107
  expect(onStackingPatches).not.toHaveBeenCalled();
1108
+ expect(refreshAfterLaneMove).not.toHaveBeenCalled();
976
1109
  // ...and the optimistic start/track edit for the dragged clip is rolled back.
977
1110
  expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
978
1111
  errSpy.mockRestore();
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
1300
1433
  expect(onMoveElements).not.toHaveBeenCalled();
1301
1434
  });
1302
1435
  });
1436
+
1437
+ describe("persistMoveEdits convergence", () => {
1438
+ it("reasserts a saved lane after a stale runtime sync", async () => {
1439
+ const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
1440
+ let releaseSave: (() => void) | undefined;
1441
+ const pendingSave = new Promise<void>((resolve) => {
1442
+ releaseSave = resolve;
1443
+ });
1444
+ let liveTrack = clip.track;
1445
+ let liveAuthoredTrack = clip.authoredTrack;
1446
+ const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
1447
+ if (updates.track != null) liveTrack = updates.track;
1448
+ if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
1449
+ });
1450
+
1451
+ const persisted = persistMoveEdits(
1452
+ [
1453
+ {
1454
+ element: clip,
1455
+ updates: { start: clip.start, track: 0 },
1456
+ persistTrack: 0,
1457
+ },
1458
+ ],
1459
+ {
1460
+ elements: [clip],
1461
+ trackOrder: [0, 1, 2],
1462
+ updateElement,
1463
+ onMoveElements: () => pendingSave,
1464
+ },
1465
+ );
1466
+ expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
1467
+
1468
+ // Reproduce the real failure: the preview emits its cached pre-drag lane
1469
+ // while the file write is still pending.
1470
+ liveTrack = 2;
1471
+ liveAuthoredTrack = 2;
1472
+ releaseSave?.();
1473
+
1474
+ await expect(persisted).resolves.toBe(true);
1475
+ expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
1476
+ expect(updateElement).toHaveBeenCalledTimes(2);
1477
+ });
1478
+ });
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
5
5
  import type { ZMirrorLaneMove } from "./timelineZMirror";
6
6
  import { classifyZone, normalizeToZones } from "./timelineZones";
7
7
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
8
- import { getTimelineEditCapabilities } from "./timelineEditing";
8
+ import {
9
+ canMoveTimelineElement as canMoveElement,
10
+ resolveExpandedHostAlias,
11
+ } from "./timelineAuthoredMoveTarget";
9
12
  import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
10
13
  import {
11
14
  beginTimelineOptimisticGesture,
12
15
  isLatestTimelineOptimisticGesture,
13
16
  } from "./timelineOptimisticRevision";
14
17
  import { runLaneZGesture } from "../../components/nle/zLaneGesture";
18
+ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
19
+ import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
15
20
 
16
21
  type StartTrack = Pick<TimelineElement, "start" | "track">;
17
22
  export interface TimelineMoveEdit {
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
68
73
  * research/STAGE3-NEEDED-WIRING.md.
69
74
  */
70
75
  onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
76
+ /** Converge the preview manifest after the complete lane + z transaction. */
77
+ refreshAfterLaneMove?: () => void;
71
78
  }
72
79
 
73
80
  const keyOf = (e: TimelineElement) => e.key ?? e.id;
74
81
  const round3 = (v: number) => Math.round(v * 1000) / 1000;
75
-
76
82
  // One deterministic coalesce key shared by both records in a lane-change gesture.
77
83
  let laneChangeGestureSeq = 0;
78
84
 
79
- /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
80
- function canMoveElement(element: TimelineElement): boolean {
81
- return getTimelineEditCapabilities({
82
- tag: element.tag,
83
- duration: element.duration,
84
- domId: element.domId,
85
- selector: element.selector,
86
- compositionSrc: element.compositionSrc,
87
- playbackStart: element.playbackStart,
88
- playbackStartAttr: element.playbackStartAttr,
89
- sourceDuration: element.sourceDuration,
90
- timingSource: element.timingSource,
91
- timelineLocked: element.timelineLocked,
92
- }).canMove;
93
- }
94
-
95
85
  /**
96
86
  * Optimistically apply + persist a batch of moves with rollback on failure.
97
87
  *
@@ -139,14 +129,15 @@ export function persistMoveEdits(
139
129
  // that written value into the store's `authoredTrack` so a SECOND drag before
140
130
  // any reload resolves authored tracks from what the file now says, not stale
141
131
  // pre-edit data. Pure time-moves leave authoredTrack untouched.
142
- for (const e of edits) {
132
+ const applyEdit = (e: TimelineMoveEdit) => {
143
133
  const writtenTrack =
144
134
  e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
145
135
  updateElement(
146
136
  keyOf(e.element),
147
137
  writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
148
138
  );
149
- }
139
+ };
140
+ for (const e of edits) applyEdit(e);
150
141
  // The store above gets DISPLAY lanes; the file below gets the authored-space
151
142
  // track when one was resolved (see TimelineMoveEdit.persistTrack).
152
143
  const persistEdits = edits.map((e) =>
@@ -158,7 +149,17 @@ export function persistMoveEdits(
158
149
  ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
159
150
  : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
160
151
  return Promise.resolve(persisted).then(
161
- () => true,
152
+ () => {
153
+ // Runtime timeline messages can arrive while the save is in flight and
154
+ // restore the preview manifest's pre-gesture lane. Reassert the durable
155
+ // result after persistence, but only while this remains the latest
156
+ // optimistic gesture so an older save can never clobber a newer drag.
157
+ for (const e of edits) {
158
+ const key = keyOf(e.element);
159
+ if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
160
+ }
161
+ return true;
162
+ },
162
163
  (error) => {
163
164
  for (const p of prev) {
164
165
  if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
@@ -177,59 +178,6 @@ export function persistMoveEdits(
177
178
  * then compacts it to a distinct integer lane between its neighbours, and the
178
179
  * clips at/below the insert shift down by one — the sanctioned index-renumber.
179
180
  */
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
-
233
181
  function insertTrackValue(trackOrder: number[], insertRow: number): number {
234
182
  if (trackOrder.length === 0) return 0;
235
183
  if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -284,6 +232,12 @@ function resolveMultiSelection(
284
232
  */
285
233
  // fallow-ignore-next-line complexity
286
234
  export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
235
+ const hostAlias = resolveExpandedHostAlias(drag, deps);
236
+ if (hostAlias) {
237
+ commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
238
+ return;
239
+ }
240
+
287
241
  const { elements, updateElement, onMoveElement } = deps;
288
242
  const dragKey = keyOf(drag.element);
289
243
  const isInsert = drag.insertRow != null;
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
362
316
  });
363
317
  const multiKeys = multi ? multi.keys : null;
364
318
  if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
365
- void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
319
+ void refreshAfterDurableLaneMove(
320
+ persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
321
+ deps,
322
+ );
366
323
  return;
367
324
  }
368
- void runLaneZGesture({
369
- commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
370
- commitZ: () =>
371
- syncStackingForEdit(
372
- candidate,
373
- dragKey,
374
- drag.element.track,
375
- drag.previewTrack,
376
- multiKeys,
377
- deps,
378
- coalesceKey,
379
- ),
380
- }).catch(() => undefined);
325
+ void refreshAfterDurableLaneMove(
326
+ runLaneZGesture({
327
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
328
+ commitZ: () =>
329
+ syncStackingForEdit(
330
+ candidate,
331
+ dragKey,
332
+ drag.element.track,
333
+ drag.previewTrack,
334
+ multiKeys,
335
+ deps,
336
+ coalesceKey,
337
+ ),
338
+ }),
339
+ deps,
340
+ ).catch(() => undefined);
381
341
  }
382
342
 
383
343
  /** Build the one sanctioned multi-clip write: atomically insert and compact a
@@ -486,23 +446,29 @@ function commitTrackInsert(
486
446
 
487
447
  const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
488
448
  if (!deps.readZIndex || !deps.onStackingPatches) {
489
- void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
449
+ void refreshAfterDurableLaneMove(
450
+ persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
451
+ deps,
452
+ );
490
453
  return;
491
454
  }
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.
496
- syncStackingForEdit(
497
- candidate,
498
- dragKey,
499
- drag.element.track,
500
- drag.insertRow!,
501
- multi ? multi.keys : null,
502
- deps,
503
- coalesceKey,
504
- ),
505
- }).catch(() => undefined);
455
+ void refreshAfterDurableLaneMove(
456
+ runLaneZGesture({
457
+ commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
458
+ commitZ: () =>
459
+ // Sync from the fractional drop intent, not the normalized persisted lanes.
460
+ syncStackingForEdit(
461
+ candidate,
462
+ dragKey,
463
+ drag.element.track,
464
+ drag.insertRow!,
465
+ multi ? multi.keys : null,
466
+ deps,
467
+ coalesceKey,
468
+ ),
469
+ }),
470
+ deps,
471
+ ).catch(() => undefined);
506
472
  }
507
473
 
508
474
  /**
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
544
510
  updates: { start: element.start, track: move.displayTrack },
545
511
  persistTrack: move.persistTrack,
546
512
  };
547
- return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
513
+ return refreshAfterDurableLaneMove(
514
+ persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
515
+ deps,
516
+ );
548
517
  }
549
518
  const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
550
519
  if (!built || built.edits.length === 0) return Promise.resolve(false);
551
- return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
520
+ return refreshAfterDurableLaneMove(
521
+ persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
522
+ deps,
523
+ );
552
524
  }
553
525
 
554
526
  /**