@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
@@ -0,0 +1,52 @@
1
+ export interface TimelineEditCapabilities {
2
+ canMove: boolean;
3
+ canTrimStart: boolean;
4
+ canTrimEnd: boolean;
5
+ }
6
+
7
+ function isDeterministicTimelineWindow(input: {
8
+ tag: string;
9
+ compositionSrc?: string;
10
+ playbackStartAttr?: "media-start" | "playback-start";
11
+ sourceDuration?: number;
12
+ }): boolean {
13
+ if (input.compositionSrc || input.playbackStartAttr != null) return true;
14
+ if (
15
+ input.sourceDuration != null &&
16
+ Number.isFinite(input.sourceDuration) &&
17
+ input.sourceDuration > 0
18
+ ) {
19
+ return true;
20
+ }
21
+ return ["video", "audio", "img"].includes(input.tag.toLowerCase());
22
+ }
23
+
24
+ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
25
+ return Boolean(input.domId || input.selector);
26
+ }
27
+
28
+ export function getTimelineEditCapabilities(input: {
29
+ tag: string;
30
+ duration: number;
31
+ domId?: string;
32
+ selector?: string;
33
+ compositionSrc?: string;
34
+ playbackStart?: number;
35
+ playbackStartAttr?: "media-start" | "playback-start";
36
+ sourceDuration?: number;
37
+ timingSource?: "authored" | "implicit";
38
+ timelineLocked?: boolean;
39
+ }): TimelineEditCapabilities {
40
+ if (input.timingSource === "implicit" || input.timelineLocked) {
41
+ return { canMove: false, canTrimStart: false, canTrimEnd: false };
42
+ }
43
+
44
+ const canPatch = hasPatchableTimelineTarget(input);
45
+ const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
46
+ const hasDeterministicWindow = isDeterministicTimelineWindow(input);
47
+ return {
48
+ canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
49
+ canTrimEnd: canPatch && hasFiniteDuration,
50
+ canTrimStart: canPatch && hasFiniteDuration,
51
+ };
52
+ }
@@ -693,7 +693,7 @@ describe("buildTimelineAgentPrompt", () => {
693
693
  prompt: "Move the title later and lower the music",
694
694
  });
695
695
 
696
- expect(text).toContain("Time range: 0:01 - 0:04");
696
+ expect(text).toContain("Time range: 00:01 - 00:04");
697
697
  expect(text).toContain("#title (div)");
698
698
  expect(text).toContain("#music (audio)");
699
699
  expect(text).toContain("Move the title later and lower the music");
@@ -4,6 +4,13 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
4
4
  import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
5
5
  import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
6
6
  import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
7
+ import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
8
+
9
+ export {
10
+ getTimelineEditCapabilities,
11
+ hasPatchableTimelineTarget,
12
+ } from "./timelineEditCapabilities";
13
+ export type { TimelineEditCapabilities } from "./timelineEditCapabilities";
7
14
 
8
15
  import {
9
16
  applyClipStartTrimDelta,
@@ -245,12 +252,6 @@ export interface TimelinePromptElement {
245
252
  track: number;
246
253
  }
247
254
 
248
- export interface TimelineEditCapabilities {
249
- canMove: boolean;
250
- canTrimStart: boolean;
251
- canTrimEnd: boolean;
252
- }
253
-
254
255
  export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
255
256
 
256
257
  export interface TimelineRangeSelection {
@@ -362,59 +363,6 @@ export function selectTimelineElementsInMarquee({
362
363
  return selected;
363
364
  }
364
365
 
365
- function isDeterministicTimelineWindow(input: {
366
- tag: string;
367
- compositionSrc?: string;
368
- playbackStartAttr?: "media-start" | "playback-start";
369
- sourceDuration?: number;
370
- }): boolean {
371
- if (input.compositionSrc) return true;
372
- if (input.playbackStartAttr != null) return true;
373
- if (
374
- input.sourceDuration != null &&
375
- Number.isFinite(input.sourceDuration) &&
376
- input.sourceDuration > 0
377
- ) {
378
- return true;
379
- }
380
- const normalizedTag = input.tag.toLowerCase();
381
- return ["video", "audio", "img"].includes(normalizedTag);
382
- }
383
-
384
- export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
385
- return Boolean(input.domId || input.selector);
386
- }
387
-
388
- export function getTimelineEditCapabilities(input: {
389
- tag: string;
390
- duration: number;
391
- domId?: string;
392
- selector?: string;
393
- compositionSrc?: string;
394
- playbackStart?: number;
395
- playbackStartAttr?: "media-start" | "playback-start";
396
- sourceDuration?: number;
397
- timingSource?: "authored" | "implicit";
398
- timelineLocked?: boolean;
399
- }): TimelineEditCapabilities {
400
- if (input.timingSource === "implicit" || input.timelineLocked) {
401
- return {
402
- canMove: false,
403
- canTrimStart: false,
404
- canTrimEnd: false,
405
- };
406
- }
407
-
408
- const canPatch = hasPatchableTimelineTarget(input);
409
- const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
410
- const hasDeterministicWindow = isDeterministicTimelineWindow(input);
411
- return {
412
- canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
413
- canTrimEnd: canPatch && hasFiniteDuration,
414
- canTrimStart: canPatch && hasFiniteDuration,
415
- };
416
- }
417
-
418
366
  export function resolveBlockedTimelineEditIntent(input: {
419
367
  width: number;
420
368
  offsetX: number;
@@ -525,3 +473,72 @@ export function buildTimelineElementAgentPrompt(element: {
525
473
  export function formatTimelineAttributeNumber(value: number): string {
526
474
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
527
475
  }
476
+
477
+ /**
478
+ * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
479
+ * pointer is in, clamped to the scrollable range. Returns true when the
480
+ * container actually moved (the caller keeps its RAF running and re-runs its
481
+ * live preview), false when the pointer is outside the edge zones or the scroll
482
+ * is already clamped (the caller stops).
483
+ */
484
+ export function applyTimelineAutoScrollStep(
485
+ scroll: HTMLElement,
486
+ clientX: number,
487
+ clientY: number,
488
+ ): boolean {
489
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
490
+ if (delta.x === 0 && delta.y === 0) return false;
491
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
492
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
493
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
494
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
495
+ if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
496
+ scroll.scrollLeft = nextScrollLeft;
497
+ scroll.scrollTop = nextScrollTop;
498
+ return true;
499
+ }
500
+
501
+ /**
502
+ * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
503
+ * for the current pointer: "start" when the pointer is in an edge zone and no
504
+ * loop is running, "stop" when it left the zones while a loop is running,
505
+ * "none" otherwise.
506
+ */
507
+ export function resolveTimelineAutoScrollLoopAction(
508
+ scroll: HTMLElement | null,
509
+ clientX: number,
510
+ clientY: number,
511
+ rafActive: boolean,
512
+ ): "start" | "stop" | "none" {
513
+ if (!scroll) return "none";
514
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
515
+ if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
516
+ return rafActive ? "none" : "start";
517
+ }
518
+
519
+ /**
520
+ * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
521
+ * no undo entry — the previews live only in the gesture state, so clearing it
522
+ * restores the pre-drag timeline. `suppressClick` arms the click suppressor
523
+ * only when the gesture actually started, so the click generated by the
524
+ * eventual pointerup can't reselect or split the clip.
525
+ */
526
+ export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
527
+ cancel: boolean;
528
+ suppressClick: boolean;
529
+ } {
530
+ if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
531
+ return { cancel: false, suppressClick: false };
532
+ }
533
+ return {
534
+ cancel: true,
535
+ suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
536
+ };
537
+ }
538
+
539
+ export interface TimelineDragEscapeInput {
540
+ key: string;
541
+ drag: { started: boolean } | null;
542
+ resize: { started: boolean } | null;
543
+ blocked: { started: boolean } | null;
544
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import {
4
+ buildTimelineGroupResizeMembers,
5
+ resolveTimelineGroupResize,
6
+ resolveTimelineGroupResizeChanges,
7
+ } from "./timelineGroupEditing";
8
+
9
+ function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
10
+ // domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
11
+ return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
12
+ }
13
+
14
+ function keys(...ids: string[]): ReadonlySet<string> {
15
+ return new Set(ids);
16
+ }
17
+
18
+ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
19
+ it("returns null for a single-clip selection (no group forms)", () => {
20
+ const a = el("a");
21
+ expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
22
+ expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
23
+ });
24
+
25
+ it("returns null when the grabbed clip is not part of the selection", () => {
26
+ const a = el("a");
27
+ const b = el("b");
28
+ expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
29
+ });
30
+
31
+ it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
32
+ const a = el("a");
33
+ const locked = el("b", { timelineLocked: true });
34
+ expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
35
+ });
36
+
37
+ it("degrades when a member is implicitly timed or has no patch target", () => {
38
+ const a = el("a");
39
+ const implicit = el("b", { timingSource: "implicit" });
40
+ const noTarget = el("c", { domId: undefined, selector: undefined });
41
+ expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
42
+ expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
43
+ });
44
+
45
+ it("snapshots members and seeds start-edge media playbackStart to 0", () => {
46
+ const grabbed = el("a", { start: 2, duration: 2 });
47
+ const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
48
+ const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
49
+ expect(members).not.toBeNull();
50
+ expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
51
+ ["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
52
+ ["b", 5, 3, 0],
53
+ ]);
54
+ });
55
+
56
+ it("does not seed playbackStart on the END edge", () => {
57
+ const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
58
+ const b = el("b", { tag: "audio", start: 3, duration: 2 });
59
+ const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
60
+ expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
65
+ it("END edge: extends every member's duration by the shared delta", () => {
66
+ const members = buildTimelineGroupResizeMembers(
67
+ [el("a", { duration: 2 }), el("b", { duration: 3 })],
68
+ keys("a", "b"),
69
+ "a",
70
+ "end",
71
+ )!;
72
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
73
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
74
+ ["a", 0, 2.5],
75
+ ["b", 0, 3.5],
76
+ ]);
77
+ });
78
+
79
+ it("END edge: rigid — the most-constrained member clamps the whole group", () => {
80
+ // b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
81
+ // whole shrinks 0.1 even though the grabbed clip asked for -0.5.
82
+ const members = buildTimelineGroupResizeMembers(
83
+ [el("a", { duration: 2 }), el("b", { duration: 0.2 })],
84
+ keys("a", "b"),
85
+ "a",
86
+ "end",
87
+ )!;
88
+ const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
89
+ expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
90
+ });
91
+
92
+ it("START edge: shifts start + duration together across the group", () => {
93
+ const members = buildTimelineGroupResizeMembers(
94
+ [
95
+ el("a", { tag: "text", start: 2, duration: 2 }),
96
+ el("b", { tag: "text", start: 5, duration: 3 }),
97
+ ],
98
+ keys("a", "b"),
99
+ "a",
100
+ "start",
101
+ )!;
102
+ const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
103
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
104
+ ["a", 1.5, 2.5],
105
+ ["b", 4.5, 3.5],
106
+ ]);
107
+ });
108
+
109
+ it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
110
+ const members = buildTimelineGroupResizeMembers(
111
+ [el("a", { duration: 2 }), el("b", { duration: 4 })],
112
+ keys("a", "b"),
113
+ "a",
114
+ "end",
115
+ )!;
116
+ const raw = resolveTimelineGroupResize(members, "end", 0.75);
117
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
118
+ expect(
119
+ changes.map((c) => ({
120
+ start: c.start,
121
+ duration: c.duration,
122
+ playbackStart: c.playbackStart,
123
+ })),
124
+ ).toEqual(raw.members);
125
+ });
126
+ });
@@ -1,4 +1,6 @@
1
1
  import { roundToCenti } from "../../utils/rounding";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
2
4
 
3
5
  const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
4
6
  const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
@@ -157,3 +159,134 @@ export function resolveTimelineGroupResize(
157
159
  }),
158
160
  };
159
161
  }
162
+
163
+ /* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */
164
+
165
+ /** A selected clip snapshot captured at group-resize gesture start. */
166
+ export interface TimelineGroupResizeMember extends TimelineGroupTimingMember {
167
+ element: TimelineElement;
168
+ key: string;
169
+ }
170
+
171
+ /** Per-member timing patch produced by a group resize (grabbed clip included). */
172
+ export interface TimelineGroupResizeChange {
173
+ element: TimelineElement;
174
+ key: string;
175
+ start: number;
176
+ duration: number;
177
+ playbackStart?: number;
178
+ }
179
+
180
+ function elementKey(element: TimelineElement): string {
181
+ return element.key ?? element.id;
182
+ }
183
+
184
+ function isMediaTimelineElement(element: TimelineElement): boolean {
185
+ const tag = element.tag.toLowerCase();
186
+ return tag === "audio" || tag === "video";
187
+ }
188
+
189
+ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
190
+ const caps = getTimelineEditCapabilities({
191
+ tag: element.tag,
192
+ duration: element.duration,
193
+ domId: element.domId,
194
+ selector: element.selector,
195
+ compositionSrc: element.compositionSrc,
196
+ playbackStart: element.playbackStart,
197
+ playbackStartAttr: element.playbackStartAttr,
198
+ sourceDuration: element.sourceDuration,
199
+ timingSource: element.timingSource,
200
+ timelineLocked: element.timelineLocked,
201
+ });
202
+ return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
203
+ }
204
+
205
+ /**
206
+ * Snapshot the members for a group resize, or `null` when the gesture must stay a
207
+ * single-clip resize. Legacy semantics (main 36413da7f): a group forms only when
208
+ * the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member
209
+ * can take THIS edge's trim. It is all-or-nothing — if any member is locked or
210
+ * implicitly-timed the group does not form, so a locked member is never patched
211
+ * (the grabbed clip then resizes on its own). Start-edge media members seed
212
+ * `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy
213
+ * `resizeMember`.
214
+ */
215
+ export function buildTimelineGroupResizeMembers(
216
+ elements: readonly TimelineElement[],
217
+ selectedKeys: ReadonlySet<string>,
218
+ grabbedKey: string,
219
+ edge: TimelineGroupResizeEdge,
220
+ ): TimelineGroupResizeMember[] | null {
221
+ if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null;
222
+ const selected = elements.filter((element) => selectedKeys.has(elementKey(element)));
223
+ if (selected.length <= 1) return null;
224
+ if (!selected.every((element) => canTrimEdge(element, edge))) return null;
225
+ return selected.map((element) => ({
226
+ element,
227
+ key: elementKey(element),
228
+ start: element.start,
229
+ duration: element.duration,
230
+ playbackStart:
231
+ edge === "start" && isMediaTimelineElement(element)
232
+ ? (element.playbackStart ?? 0)
233
+ : element.playbackStart,
234
+ playbackRate: element.playbackRate,
235
+ }));
236
+ }
237
+
238
+ /**
239
+ * Resolve the per-member timing patches for a group resize from the grabbed
240
+ * clip's raw edge delta. Rigid group: one shared delta, clamped by the most
241
+ * constrained member (see resolveTimelineGroupResize), applied to every member.
242
+ */
243
+ export function resolveTimelineGroupResizeChanges(
244
+ members: readonly TimelineGroupResizeMember[],
245
+ edge: TimelineGroupResizeEdge,
246
+ rawDelta: number,
247
+ minDuration = resolveTimelineMinDuration(),
248
+ ): TimelineGroupResizeChange[] {
249
+ const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration);
250
+ return result.members.map((member, index) => ({
251
+ element: members[index]!.element,
252
+ key: members[index]!.key,
253
+ start: member.start,
254
+ duration: member.duration,
255
+ playbackStart: member.playbackStart,
256
+ }));
257
+ }
258
+
259
+ /** In-flight multi-select group-resize gesture (owned by the drag hook). */
260
+ export interface TimelineGroupResizeSession {
261
+ grabbedKey: string;
262
+ edge: TimelineGroupResizeEdge;
263
+ members: TimelineGroupResizeMember[];
264
+ changes: TimelineGroupResizeChange[];
265
+ hasChanged: boolean;
266
+ }
267
+
268
+ /**
269
+ * Fold the grabbed clip's single-clip preview into the group: derive the raw
270
+ * edge delta from it, resolve the rigid per-member changes onto the session
271
+ * (updating `changes` + `hasChanged`), and return the grabbed clip's own change
272
+ * so the caller can render it from the resize state.
273
+ */
274
+ export function applyTimelineGroupResizePreview(
275
+ session: TimelineGroupResizeSession,
276
+ grabbedPreview: { previewStart: number; previewDuration: number },
277
+ ): TimelineGroupResizeChange | undefined {
278
+ const grabbed = session.members.find((m) => m.key === session.grabbedKey);
279
+ const rawDelta =
280
+ session.edge === "start"
281
+ ? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart)
282
+ : grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration);
283
+ const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta);
284
+ session.changes = changes;
285
+ session.hasChanged = changes.some(
286
+ (c, i) =>
287
+ c.start !== session.members[i]!.start ||
288
+ c.duration !== session.members[i]!.duration ||
289
+ c.playbackStart !== session.members[i]!.playbackStart,
290
+ );
291
+ return changes.find((c) => c.key === session.grabbedKey);
292
+ }
@@ -0,0 +1,54 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
3
+ import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
4
+ import {
5
+ beginTimelineOptimisticGesture,
6
+ rollbackLatestTimelineOptimisticGesture,
7
+ } from "./timelineOptimisticRevision";
8
+
9
+ export function commitTimelineGroupResize(
10
+ session: TimelineGroupResizeSession,
11
+ updateElement: (key: string, updates: Partial<TimelineElement>) => void,
12
+ persist: TimelineEditCallbacks["onResizeElements"],
13
+ ): void {
14
+ if (!session.hasChanged) return;
15
+ const changes = session.changes;
16
+ const revision = beginTimelineOptimisticGesture(
17
+ updateElement,
18
+ changes.map((change) => change.key),
19
+ );
20
+ for (const change of changes) {
21
+ updateElement(change.key, {
22
+ start: change.start,
23
+ duration: change.duration,
24
+ playbackStart: change.playbackStart,
25
+ });
26
+ }
27
+ if (!persist) {
28
+ rollbackLatestTimelineOptimisticGesture(
29
+ updateElement,
30
+ revision,
31
+ session.members.map((member) => ({ key: member.key, updates: member })),
32
+ );
33
+ return;
34
+ }
35
+ const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
36
+ Promise.resolve(
37
+ persist(
38
+ changes.map((change) => ({
39
+ element: change.element,
40
+ start: change.start,
41
+ duration: change.duration,
42
+ playbackStart: change.playbackStart,
43
+ })),
44
+ { coalesceKey },
45
+ ),
46
+ ).catch((error) => {
47
+ rollbackLatestTimelineOptimisticGesture(
48
+ updateElement,
49
+ revision,
50
+ session.members.map((member) => ({ key: member.key, updates: member })),
51
+ );
52
+ console.error("[Timeline] Failed to persist group clip resize", error);
53
+ });
54
+ }
@@ -1,34 +1,106 @@
1
- import { describe, expect, it } from "vitest";
2
- import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout";
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ RULER_H,
4
+ TRACK_H,
5
+ TRACKS_TOP_PAD,
6
+ TRACKS_BOTTOM_PAD,
7
+ GUTTER,
8
+ getTimelineRowTop,
9
+ getTimelineRowFromY,
10
+ getTimelineCanvasHeight,
11
+ resolveTimelineAssetDrop,
12
+ } from "./timelineLayout";
3
13
 
4
- describe("computeTimelineBasisDuration", () => {
5
- it("uses the root duration when it exceeds every clip end", () => {
6
- expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12);
7
- });
14
+ describe("track-area breathing pad y-math", () => {
15
+ describe("getTimelineRowTop", () => {
16
+ it("offsets the first lane below the ruler by the top pad", () => {
17
+ expect(getTimelineRowTop(0)).toBe(RULER_H + TRACKS_TOP_PAD);
18
+ });
8
19
 
9
- it("grows to the furthest committed clip end past the root duration", () => {
10
- expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18);
11
- });
20
+ it("advances by one track height per row, keeping the pad", () => {
21
+ expect(getTimelineRowTop(1)).toBe(RULER_H + TRACKS_TOP_PAD + TRACK_H);
22
+ expect(getTimelineRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H);
23
+ });
12
24
 
13
- it("falls back to the root duration with no clips / non-finite ends", () => {
14
- expect(computeTimelineBasisDuration(10, [])).toBe(10);
15
- expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0);
25
+ it("is a strict positive shift from the pre-pad formula (pad is non-zero)", () => {
26
+ expect(TRACKS_TOP_PAD).toBeGreaterThan(0);
27
+ expect(getTimelineRowTop(2)).toBe(RULER_H + 2 * TRACK_H + TRACKS_TOP_PAD);
28
+ });
16
29
  });
17
- });
18
30
 
19
- describe("computeTimelineEffectiveDuration", () => {
20
- it("returns the basis when there is no active preview", () => {
21
- expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12);
31
+ describe("getTimelineRowFromY", () => {
32
+ it("is the exact inverse of getTimelineRowTop at lane boundaries", () => {
33
+ for (const row of [0, 1, 2, 7]) {
34
+ expect(getTimelineRowFromY(getTimelineRowTop(row))).toBeCloseTo(row, 10);
35
+ }
36
+ });
37
+
38
+ it("floors a y inside the top pad (above lane 0) to a negative fraction", () => {
39
+ // A drop in the pad between the ruler and lane 0 sits at row < 0, so a
40
+ // floor lands it on row -1 → getDefaultDroppedTrack floors to the top lane.
41
+ const yInPad = RULER_H + TRACKS_TOP_PAD / 2;
42
+ expect(getTimelineRowFromY(yInPad)).toBeLessThan(0);
43
+ });
44
+
45
+ it("maps a y in the middle of lane 1 into [1,2)", () => {
46
+ const yMidLane1 = getTimelineRowTop(1) + TRACK_H / 2;
47
+ const row = getTimelineRowFromY(yMidLane1);
48
+ expect(row).toBeGreaterThanOrEqual(1);
49
+ expect(row).toBeLessThan(2);
50
+ });
22
51
  });
23
52
 
24
- it("extends to a drag/resize preview end beyond the basis", () => {
25
- expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20);
26
- expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16);
53
+ describe("getTimelineCanvasHeight", () => {
54
+ it("reserves ruler + top pad + lanes + bottom pad", () => {
55
+ expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
56
+ expect(getTimelineCanvasHeight(3)).toBe(
57
+ RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
58
+ );
59
+ });
60
+
61
+ it("clamps a negative track count to zero lanes", () => {
62
+ expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
63
+ });
64
+
65
+ it("leaves room below the last lane for a drag-into-void new track", () => {
66
+ // The gap below the final lane must be at least a full track height so a
67
+ // clip can be dropped there to create a new bottom track.
68
+ const oneLane = getTimelineCanvasHeight(1);
69
+ const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
70
+ expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
71
+ });
27
72
  });
28
73
 
29
- it("never shrinks below the basis for a preview inside the current length", () => {
30
- // The invariant behind the jump fix: the basis (which drives zoom) is
31
- // independent of the preview, and a smaller preview end can't reduce it.
32
- expect(computeTimelineEffectiveDuration(12, [8])).toBe(12);
74
+ describe("resolveTimelineAssetDrop honours the top pad", () => {
75
+ const base = {
76
+ rectLeft: 0,
77
+ rectTop: 0,
78
+ scrollLeft: 0,
79
+ scrollTop: 0,
80
+ pixelsPerSecond: 100,
81
+ duration: 60,
82
+ trackHeight: TRACK_H,
83
+ trackOrder: [0, 1, 2],
84
+ };
85
+
86
+ it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
87
+ const clientY = getTimelineRowTop(0) + TRACK_H / 2;
88
+ const clientX = GUTTER + 100; // t = 1s
89
+ const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
90
+ expect(track).toBe(0);
91
+ expect(start).toBe(1);
92
+ });
93
+
94
+ it("drops into the top pad → floors to the first lane (row < 0)", () => {
95
+ const clientY = RULER_H + TRACKS_TOP_PAD / 2; // inside the pad, above lane 0
96
+ const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
97
+ expect(track).toBe(0);
98
+ });
99
+
100
+ it("drops below the last lane → appends a new track", () => {
101
+ const clientY = getTimelineRowTop(2) + TRACK_H + 4; // in the bottom pad
102
+ const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
103
+ expect(track).toBe(3); // max(trackOrder)+1
104
+ });
33
105
  });
34
106
  });