@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
3
 
4
4
  /**
5
5
  * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
6
- * — layering is CSS z-index (the renderer ignores track index), so the timeline's
7
- * only job is to keep visual clips grouped above audio clips.
6
+ * — canvas layering is CSS z-index (the renderer ignores track index), so the
7
+ * timeline's only job is to keep visual clips grouped above audio clips.
8
8
  */
9
9
  export type TrackZone = "visual" | "audio";
10
10
 
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
16
16
 
17
17
  const keyOf = (el: TimelineElement) => el.key ?? el.id;
18
18
 
19
- /** Stacking order for a clip: missing / "auto" z is treated as 0. */
20
- const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
21
-
22
19
  const EPS = 1e-6;
23
20
 
24
21
  /** Two clips overlap when their half-open [start, end) intervals intersect. */
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
26
23
  return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
27
24
  }
28
25
 
29
- /** A clip paired with its position in the discovery/document (input) order. */
30
- interface IndexedClip {
31
- el: TimelineElement;
32
- /** Index in the input `elements` array = discovery/DOM order. */
33
- domIndex: number;
34
- }
35
-
36
- /** One display lane: the clips packed onto it, in placement order. */
37
- interface Lane {
38
- occupants: IndexedClip[];
39
- /** The single authored track all occupants share, or null once mixed (never
40
- * happens — we only ever add same-track clips to an existing lane). */
41
- track: number;
42
- }
43
-
44
- /**
45
- * Lowest lane index a clip may occupy: strictly above every already-placed lane
46
- * holding a clip it overlaps in time (all of which out-stack it by the z-desc
47
- * placement order).
48
- */
49
- function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
50
- let minLane = 0;
51
- for (let i = 0; i < lanes.length; i++) {
52
- if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
53
- }
54
- return minLane;
55
- }
56
-
57
- /**
58
- * First lane at index ≥ minLane that holds solely this clip's authored track and
59
- * nothing overlapping (so sequential same-track clips share a lane); -1 when none
60
- * qualifies and a fresh lane must open.
61
- */
62
- function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
63
- for (let i = minLane; i < lanes.length; i++) {
64
- const lane = lanes[i];
65
- if (lane.track !== item.el.track) continue;
66
- if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
67
- return i;
68
- }
69
- return -1;
26
+ /** Deterministic order on the stable clip id (never the mutated lane/track). */
27
+ function byStableId(a: TimelineElement, b: TimelineElement): number {
28
+ const ka = keyOf(a);
29
+ const kb = keyOf(b);
30
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
70
31
  }
71
32
 
72
33
  /**
73
- * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
74
- * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
75
- * equals canvas stacking order. This replaces the old two-stage
76
- * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
77
- * tracks by their MAX z and so lifted a low-z clip above a clip that covers it
78
- * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
79
- * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
80
- * whole-track mapping can fix that; the mapping must be per-clip.
34
+ * Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
35
+ * share a lane. Clips are ordered by their STABLE id (a function of the input, not
36
+ * of the lane being computed the historical oscillation bug tie-broke on the
37
+ * mutated track) and placed first-fit, so sequential (non-overlapping) clips
38
+ * collapse onto a single lane and only genuine time overlaps spill onto adjacent
39
+ * sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
40
+ * number of lanes used (≥ 1 when non-empty).
81
41
  *
82
- * Algorithm:
83
- * 1. Order clips by z DESC; z-tie INPUT-ARRAY-INDEX (DOM order) DESC (CSS
84
- * paints equal-z siblings by DOM order LATER in DOM paints on top, so it
85
- * must place first / upper); final tie → stable key. NEVER tie-break on the
86
- * mutated lane/track index (historic oscillation bug — the tie-break must be
87
- * a stable function of the input, not of the output being computed).
88
- * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
89
- * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
90
- * clip out-stacks this one (higher z, or equal z but later in DOM), so this
91
- * guarantees lane order == stacking order for every overlapping pair.
92
- * 3. To preserve the "distinct authored tracks stay distinct / sequential
93
- * same-track clips share a lane" feel, reuse an existing lane at index ≥ that
94
- * minimum ONLY when the lane's occupants are all from the SAME authored track
95
- * AND none overlaps this clip in time; otherwise open a fresh lane.
42
+ * The editor enforces no per-track time overlap, so the spill only fires on legacy
43
+ * files. It is DISPLAY-ONLY a drag commit persists just the dragged clip, never
44
+ * this re-laneso it never rewrites the source.
96
45
  *
97
- * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
98
- * returns the number of lanes used (≥ 1 when non-empty).
46
+ * Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
47
+ * display lane). Because every occupant of a sub-lane shares the base lane's
48
+ * authored track by construction, dropping a clip onto one persists that shared
49
+ * authored track — a legitimate same-track join. On the next normalize the
50
+ * joined track re-packs (stable-id first-fit), so the clip may display on a
51
+ * DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
52
+ * the persisted source value is correct either way.
99
53
  */
100
- function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
101
- const ordered = [...clips].sort(
102
- (a, b) =>
103
- zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
104
- );
105
- const lanes: Lane[] = [];
106
- for (const item of ordered) {
107
- const minLane = lowestAllowedLane(lanes, item);
108
- let placed = findReusableLane(lanes, minLane, item);
109
- if (placed === -1) {
110
- placed = lanes.length;
111
- lanes.push({ occupants: [], track: item.el.track });
54
+ function packTrackLanes(
55
+ clips: TimelineElement[],
56
+ base: number,
57
+ laneOf: Map<string, number>,
58
+ ): number {
59
+ const ordered = [...clips].sort(byStableId);
60
+ const lanes: TimelineElement[][] = [];
61
+ for (const el of ordered) {
62
+ let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
63
+ if (sub === -1) {
64
+ sub = lanes.length;
65
+ lanes.push([]);
112
66
  }
113
- lanes[placed].occupants.push(item);
114
- laneOf.set(keyOf(item.el), base + placed);
67
+ lanes[sub].push(el);
68
+ laneOf.set(keyOf(el), base + sub);
115
69
  }
116
- return lanes.length;
70
+ return Math.max(1, lanes.length);
117
71
  }
118
72
 
119
73
  /**
120
- * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
121
- * authored track's clips onto sub-lanes so no two overlap in time sequential
122
- * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
123
- * start (then stable key) so the layout is deterministic and idempotent. Returns
124
- * the number of lanes used (≥ 1 when non-empty).
74
+ * Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
75
+ * follow the authored `data-track-index` (ASCENDING; ties by stable id)NEVER a
76
+ * z-rank. Each distinct authored track owns its own lane (in ascending order);
77
+ * sequential same-track clips share it; time-overlapping same-track clips spill to
78
+ * adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
79
+ *
80
+ * This REPLACES the old global-z-rank interval pack. That pack ordered visual
81
+ * lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
82
+ * whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
83
+ * product decision is the opposite: a clip's lane is its track, period — z is
84
+ * canvas paint order only, and lane assignment must ignore it.
125
85
  */
126
- function packAudioTrackLanes(
127
- clips: IndexedClip[],
86
+ function packZoneLanes(
87
+ clips: TimelineElement[],
128
88
  base: number,
129
89
  laneOf: Map<string, number>,
130
90
  ): number {
131
- const ordered = [...clips].sort(
132
- (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
133
- );
134
- const lanes: IndexedClip[][] = [];
135
- for (const item of ordered) {
136
- let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
137
- if (sub === -1) {
138
- sub = lanes.length;
139
- lanes.push([]);
140
- }
141
- lanes[sub].push(item);
142
- laneOf.set(keyOf(item.el), base + sub);
91
+ const byTrack = new Map<number, TimelineElement[]>();
92
+ for (const el of clips) {
93
+ const list = byTrack.get(el.track);
94
+ if (list) list.push(el);
95
+ else byTrack.set(el.track, [el]);
143
96
  }
144
- return Math.max(1, lanes.length);
97
+ let used = 0;
98
+ for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
99
+ used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
100
+ }
101
+ return used;
145
102
  }
146
103
 
147
104
  /**
148
105
  * Assign display lanes for the timeline: visual lanes on top, audio lanes below.
149
106
  *
150
- * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
151
- * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
152
- * low-z clip sinks below a clip that covers it even if it shares an authored track
153
- * with a higher-z clip. Time-overlapping clips still split onto separate lanes
154
- * (standard NLE), sequential same-track clips still share a lane, and distinct
155
- * authored tracks stay distinct.
156
- *
157
- * The AUDIO zone keeps the original behavior — authored-track order, per-track
158
- * interval packing — because audio has no z / stacking semantics.
107
+ * Both zones are packed the SAME way by authored track-index, ascending (see
108
+ * packZoneLanes) so the timeline's vertical order follows each clip's track and
109
+ * nothing else. z-index does not participate in lane assignment (it is canvas
110
+ * paint order only; the lane z stacking sync in timelineStackingSync runs the
111
+ * other direction, only on a deliberate vertical edit). Time-overlapping same-track
112
+ * clips still split onto separate sub-lanes (legacy files only — the editor forbids
113
+ * per-track overlap), and that split is display-only, never persisted.
159
114
  *
160
115
  * Pure — returns a new array; unchanged clips keep their identity. Display-only
161
116
  * (runs on discovery); it does not rewrite the source. Idempotent (running it on
162
- * its own output is a fixed point).
117
+ * its own output is a fixed point): the lanes it emits are contiguous integers in
118
+ * ascending track order, and re-running groups by those same integers unchanged.
163
119
  */
164
120
  export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
165
121
  if (elements.length === 0) return elements;
166
122
 
167
123
  const laneOf = new Map<string, number>();
168
- let nextLane = 0;
169
-
170
- const visual: IndexedClip[] = [];
171
- const audio: IndexedClip[] = [];
172
- elements.forEach((el, domIndex) => {
173
- (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
174
- });
124
+ const visual: TimelineElement[] = [];
125
+ const audio: TimelineElement[] = [];
126
+ for (const el of elements) {
127
+ (classifyZone(el) === "audio" ? audio : visual).push(el);
128
+ }
175
129
 
130
+ let nextLane = 0;
176
131
  nextLane += packZoneLanes(visual, nextLane, laneOf);
177
-
178
- // Audio: preserve legacy behavior — group by authored track (ascending), pack
179
- // each track's overlapping clips onto sub-lanes.
180
- const audioByTrack = new Map<number, IndexedClip[]>();
181
- for (const item of audio) {
182
- const list = audioByTrack.get(item.el.track);
183
- if (list) list.push(item);
184
- else audioByTrack.set(item.el.track, [item]);
185
- }
186
- for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
187
- nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
188
- }
132
+ packZoneLanes(audio, nextLane, laneOf);
189
133
 
190
134
  let changed = false;
191
135
  const remapped = elements.map((el) => {
192
136
  const lane = laneOf.get(keyOf(el));
193
137
  if (lane == null || lane === el.track) return el;
194
138
  changed = true;
195
- return { ...el, track: lane };
139
+ // Record the source-file track the first time a clip is remapped so lane
140
+ // edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
141
+ // Re-normalizing already-remapped elements must keep the original value.
142
+ return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
196
143
  });
197
144
  return changed ? remapped : elements;
198
145
  }
@@ -6,7 +6,12 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
6
  // the rest of the callback bag still comes from TimelineEditContext.
7
7
  export type TimelineEditOverrides = Pick<
8
8
  TimelineEditCallbacks,
9
- "onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
9
+ | "onMoveElement"
10
+ | "onMoveElements"
11
+ | "onResizeElement"
12
+ | "onResizeElements"
13
+ | "onBlockedEditAttempt"
14
+ | "onSplitElement"
10
15
  >;
11
16
 
12
17
  // Merge any prop overrides over the context callbacks. Used so NLELayout can
@@ -16,15 +21,32 @@ export function useResolvedTimelineEditCallbacks(
16
21
  overrides: TimelineEditOverrides,
17
22
  ): TimelineEditCallbacks {
18
23
  const ctx = useTimelineEditContextOptional();
19
- const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
24
+ const {
25
+ onMoveElement,
26
+ onMoveElements,
27
+ onResizeElement,
28
+ onResizeElements,
29
+ onBlockedEditAttempt,
30
+ onSplitElement,
31
+ } = overrides;
20
32
  return useMemo(
21
33
  () => ({
22
34
  ...ctx,
23
35
  onMoveElement: onMoveElement ?? ctx.onMoveElement,
36
+ onMoveElements: onMoveElements ?? ctx.onMoveElements,
24
37
  onResizeElement: onResizeElement ?? ctx.onResizeElement,
38
+ onResizeElements: onResizeElements ?? ctx.onResizeElements,
25
39
  onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
26
40
  onSplitElement: onSplitElement ?? ctx.onSplitElement,
27
41
  }),
28
- [ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
42
+ [
43
+ ctx,
44
+ onMoveElement,
45
+ onMoveElements,
46
+ onResizeElement,
47
+ onResizeElements,
48
+ onBlockedEditAttempt,
49
+ onSplitElement,
50
+ ],
29
51
  );
30
52
  }
@@ -0,0 +1,241 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import type { ResizingClipState } from "./useTimelineClipDrag";
8
+ import { useTimelineClipDrag } from "./useTimelineClipDrag";
9
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
14
+ return {
15
+ id,
16
+ key: id,
17
+ tag: "video",
18
+ start: 0,
19
+ duration: 2,
20
+ track: 0,
21
+ domId: id,
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ afterEach(() => {
27
+ document.body.innerHTML = "";
28
+ usePlayerStore.getState().reset();
29
+ });
30
+
31
+ function renderResizeHarness(
32
+ elements: TimelineElement[],
33
+ selected: string[],
34
+ options: { wireGroupResize?: boolean } = {},
35
+ ) {
36
+ usePlayerStore.getState().setElements(elements);
37
+ usePlayerStore.setState({ timelineSnapEnabled: false });
38
+ usePlayerStore.getState().setSelectedElementIds(new Set(selected));
39
+
40
+ const scroll = document.createElement("div");
41
+ document.body.append(scroll);
42
+ const onResizeElement = vi.fn();
43
+ const onResizeElements = vi.fn().mockResolvedValue(undefined);
44
+ let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
45
+
46
+ function Harness() {
47
+ const hook = useTimelineClipDrag({
48
+ scrollRef: { current: scroll },
49
+ ppsRef: { current: 100 },
50
+ durationRef: { current: 100 },
51
+ trackOrderRef: { current: [0, 1] },
52
+ onResizeElement,
53
+ onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
54
+ setShowPopover: vi.fn(),
55
+ setRangeSelectionRef: { current: vi.fn() },
56
+ });
57
+ setResizingClip = hook.setResizingClip;
58
+ return null;
59
+ }
60
+
61
+ const root = mountReactHarness(<Harness />);
62
+ const apply = setResizingClip!;
63
+
64
+ return {
65
+ onResizeElement,
66
+ onResizeElements,
67
+ storeById(id: string) {
68
+ return usePlayerStore.getState().elements.find((e) => e.id === id)!;
69
+ },
70
+ startResize(element: TimelineElement, edge: "start" | "end") {
71
+ act(() => {
72
+ apply({
73
+ element,
74
+ edge,
75
+ originClientX: 0,
76
+ previewStart: element.start,
77
+ previewDuration: element.duration,
78
+ previewPlaybackStart: element.playbackStart,
79
+ started: false,
80
+ });
81
+ });
82
+ },
83
+ movePointer(clientX: number) {
84
+ act(() => {
85
+ window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
86
+ });
87
+ },
88
+ async dropPointer() {
89
+ await act(async () => {
90
+ window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
91
+ });
92
+ },
93
+ pressEscape() {
94
+ act(() => {
95
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
96
+ });
97
+ },
98
+ unmount() {
99
+ act(() => root.unmount());
100
+ },
101
+ };
102
+ }
103
+
104
+ // Two clips a(0,2) + b(5,3) selected as a group, with a's END edge grabbed and
105
+ // dragged +0.5s (50px @ 100pps). `bOver` customizes b (e.g. locking it).
106
+ function startGroupResize(bOver: Partial<TimelineElement> = {}) {
107
+ const a = el("a", { start: 0, duration: 2 });
108
+ const b = el("b", { start: 5, duration: 3, ...bOver });
109
+ const h = renderResizeHarness([a, b], ["a", "b"]);
110
+ h.startResize(a, "end");
111
+ h.movePointer(50);
112
+ return { a, b, h };
113
+ }
114
+
115
+ // Drop the pointer and assert exactly one resize persisted: the grabbed clip `a`
116
+ // grown to 2.5s (the +0.5 gesture), with no other member patched.
117
+ async function expectSingleResizeToA(
118
+ h: ReturnType<typeof renderResizeHarness>,
119
+ a: TimelineElement,
120
+ ): Promise<void> {
121
+ await h.dropPointer();
122
+ expect(h.onResizeElement).toHaveBeenCalledTimes(1);
123
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 }));
124
+ }
125
+
126
+ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
127
+ it("resizes only the grabbed clip and persists once", async () => {
128
+ const a = el("a", { start: 0, duration: 2 });
129
+ const b = el("b", { start: 5, duration: 2 });
130
+ const h = renderResizeHarness([a, b], []); // no multi-selection
131
+
132
+ h.startResize(a, "end");
133
+ h.movePointer(50); // +0.5s at 100 pps
134
+ await expectSingleResizeToA(h, a);
135
+ expect(h.storeById("a").duration).toBe(2.5);
136
+ expect(h.storeById("b").duration).toBe(2); // untouched
137
+ h.unmount();
138
+ });
139
+
140
+ it("does not let an older rejected resize roll back a newer optimistic gesture", async () => {
141
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
142
+ const a = el("a", { start: 0, duration: 2 });
143
+ const h = renderResizeHarness([a], []);
144
+ let rejectFirst!: (error: Error) => void;
145
+ h.onResizeElement
146
+ .mockImplementationOnce(() => new Promise<void>((_resolve, reject) => (rejectFirst = reject)))
147
+ .mockResolvedValueOnce(undefined);
148
+
149
+ h.startResize(a, "end");
150
+ h.movePointer(50);
151
+ await h.dropPointer();
152
+ h.startResize(a, "end");
153
+ h.movePointer(100);
154
+ await h.dropPointer();
155
+ rejectFirst(new Error("older resize failed"));
156
+ await act(async () => Promise.resolve());
157
+
158
+ expect(h.storeById("a").duration).toBe(3);
159
+ errorSpy.mockRestore();
160
+ h.unmount();
161
+ });
162
+ });
163
+
164
+ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
165
+ it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
166
+ const { h } = startGroupResize(); // grabbed asks +0.5
167
+
168
+ // Non-grabbed member is previewed in the store; the grabbed clip renders from
169
+ // resizingClip state, so its store value is still the original until commit.
170
+ expect(h.storeById("b").duration).toBe(3.5);
171
+ expect(h.storeById("a").duration).toBe(2);
172
+ h.unmount();
173
+ });
174
+
175
+ it("commits the whole group — persists every member by the shared delta", async () => {
176
+ const { a, b, h } = startGroupResize();
177
+ await h.dropPointer();
178
+
179
+ expect(h.onResizeElement).not.toHaveBeenCalled();
180
+ expect(h.onResizeElements).toHaveBeenCalledTimes(1);
181
+ expect(h.onResizeElements).toHaveBeenCalledWith(
182
+ [
183
+ expect.objectContaining({ element: a, duration: 2.5 }),
184
+ expect.objectContaining({ element: b, duration: 3.5 }),
185
+ ],
186
+ { coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
187
+ );
188
+ expect(h.storeById("a").duration).toBe(2.5);
189
+ expect(h.storeById("b").duration).toBe(3.5);
190
+ h.unmount();
191
+ });
192
+
193
+ it("rolls the whole group back when the atomic resize batch rejects", async () => {
194
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
195
+ const { h } = startGroupResize();
196
+ h.onResizeElements.mockRejectedValueOnce(new Error("batch failed"));
197
+ await h.dropPointer();
198
+ await act(async () => Promise.resolve());
199
+ expect(h.onResizeElements).toHaveBeenCalledTimes(1);
200
+ expect(h.onResizeElement).not.toHaveBeenCalled();
201
+ expect(h.storeById("a").duration).toBe(2);
202
+ expect(h.storeById("b").duration).toBe(3);
203
+ expect(errorSpy).toHaveBeenCalledTimes(1);
204
+ errorSpy.mockRestore();
205
+ h.unmount();
206
+ });
207
+
208
+ it("rolls the whole group back when no atomic resize callback is wired", async () => {
209
+ const a = el("a", { start: 0, duration: 2 });
210
+ const b = el("b", { start: 5, duration: 3 });
211
+ const h = renderResizeHarness([a, b], ["a", "b"], {
212
+ wireGroupResize: false,
213
+ });
214
+ h.startResize(a, "end");
215
+ h.movePointer(50);
216
+ expect(h.storeById("b").duration).toBe(3.5);
217
+
218
+ await h.dropPointer();
219
+ expect(h.storeById("a").duration).toBe(2);
220
+ expect(h.storeById("b").duration).toBe(3);
221
+ expect(h.onResizeElement).not.toHaveBeenCalled();
222
+ h.unmount();
223
+ });
224
+
225
+ it("degrades to single-clip when a selected member is locked — locked clip untouched", async () => {
226
+ const { a, h } = startGroupResize({ timelineLocked: true });
227
+ await expectSingleResizeToA(h, a);
228
+ expect(h.storeById("b").duration).toBe(3); // locked member never patched
229
+ h.unmount();
230
+ });
231
+
232
+ it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
233
+ const { h } = startGroupResize();
234
+ expect(h.storeById("b").duration).toBe(3.5); // previewed
235
+
236
+ h.pressEscape();
237
+ expect(h.storeById("b").duration).toBe(3); // restored
238
+ expect(h.onResizeElement).not.toHaveBeenCalled();
239
+ h.unmount();
240
+ });
241
+ });