@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -2,6 +2,7 @@ import { memo, type CSSProperties, type ReactNode } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
4
4
  import type { TimelineEditCapabilities } from "./timelineEditing";
5
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
5
6
 
6
7
  interface TimelineClipProps {
7
8
  el: TimelineElement;
@@ -62,6 +63,7 @@ export const TimelineClip = memo(function TimelineClip({
62
63
  isHovered ? "is-hovered" : "",
63
64
  isDragging ? "is-dragging" : "",
64
65
  showDefaultText ? "" : "is-micro",
66
+ isAudioTimelineElement(el) ? "is-audio" : "",
65
67
  ]
66
68
  .filter((className) => className.length > 0)
67
69
  .join(" ");
@@ -72,7 +74,8 @@ export const TimelineClip = memo(function TimelineClip({
72
74
  bottom: clipY,
73
75
  borderRadius: theme.clipRadius,
74
76
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
75
- cursor: capabilities.canMove ? "grab" : "default",
77
+ // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
78
+ cursor: "default",
76
79
  transform: isDragging ? "translateY(-1px)" : undefined,
77
80
  };
78
81
 
@@ -1,12 +1,10 @@
1
1
  import { memo, useRef, useState } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
3
  import {
4
- clampToNeighbors,
5
4
  KEYFRAME_DRAG_THRESHOLD_PX,
6
5
  previewClipPct,
7
6
  resolveKeyframeDrag,
8
7
  } from "../../components/editor/keyframeDrag";
9
- import { snapKeyframePctToBeat } from "./timelineEditing";
10
8
 
11
9
  interface KeyframeEntry {
12
10
  percentage: number;
@@ -30,14 +28,6 @@ interface TimelineClipDiamondsProps {
30
28
  /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
31
29
  * the bottom half so they clear the strip at the top. */
32
30
  beatsActive?: boolean;
33
- /** Composition-time beat positions (same source the beat strip renders from).
34
- * When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */
35
- beatTimes?: number[];
36
- /** Clip start / duration (seconds) + pixels-per-second, needed to map a
37
- * dragged keyframe's clip-% to composition time for beat snapping. */
38
- clipStart?: number;
39
- clipDurationSeconds?: number;
40
- pixelsPerSecond?: number;
41
31
  accentColor: string;
42
32
  isSelected: boolean;
43
33
  currentPercentage: number;
@@ -81,10 +71,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
81
71
  clipWidthPx,
82
72
  clipHeightPx,
83
73
  beatsActive,
84
- beatTimes,
85
- clipStart = 0,
86
- clipDurationSeconds = 0,
87
- pixelsPerSecond = 1,
88
74
  accentColor,
89
75
  isSelected,
90
76
  currentPercentage,
@@ -135,20 +121,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
135
121
  const baseOpacity = isSelected ? 0.4 : 0.25;
136
122
  const canDrag = isSelected && !!onMoveKeyframe;
137
123
 
138
- // Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then
139
- // re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op
140
- // when the beat strip isn't active for this track or no beats are loaded.
141
- const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => {
142
- if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct;
143
- const snapped = snapKeyframePctToBeat(
144
- { start: clipStart, duration: clipDurationSeconds },
145
- clipPct,
146
- beatTimes,
147
- pixelsPerSecond,
148
- );
149
- return clampToNeighbors(snapped, sortedClipPcts, draggedIndex);
150
- };
151
-
152
124
  return (
153
125
  <div
154
126
  className="absolute inset-0"
@@ -223,17 +195,14 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
223
195
  if (d.moved) {
224
196
  setPreview({
225
197
  kfKey,
226
- clipPct: snapClipPctToBeat(
227
- previewClipPct({
228
- pointerDownX: d.startX,
229
- pointerMoveX: e.clientX,
230
- clipWidthPx,
231
- draggedClipPct: d.fromClipPct,
232
- draggedIndex: i,
233
- sortedClipPcts,
234
- }),
235
- i,
236
- ),
198
+ clipPct: previewClipPct({
199
+ pointerDownX: d.startX,
200
+ pointerMoveX: e.clientX,
201
+ clipWidthPx,
202
+ draggedClipPct: d.fromClipPct,
203
+ draggedIndex: i,
204
+ sortedClipPcts,
205
+ }),
237
206
  });
238
207
  }
239
208
  };
@@ -269,7 +238,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
269
238
  if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
270
239
  else onClickKeyframe?.(kf.percentage);
271
240
  } else if (res.kind === "move" && res.toClipPct != null) {
272
- onMoveKeyframe?.(elementId, d.fromClipPct, snapClipPctToBeat(res.toClipPct, i));
241
+ onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
273
242
  // A retime still targeted this exact diamond — park/select it at its
274
243
  // new position, same as a plain click, or a drag that actually moved
275
244
  // something looks identical to one that silently did nothing.
@@ -37,7 +37,9 @@ export function TimelineEmptyState({
37
37
  style={{ position: "absolute", left: GUTTER + s * 14 }}
38
38
  >
39
39
  <span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
40
- {`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
40
+ {`${Math.floor(s / 60)
41
+ .toString()
42
+ .padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`}
41
43
  </span>
42
44
  <div className="w-px h-[5px] bg-neutral-700/40" />
43
45
  </div>
@@ -0,0 +1,487 @@
1
+ import { type ReactNode } from "react";
2
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
3
+ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
4
+ import { TimelineClip } from "./TimelineClip";
5
+ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
+ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
7
+ import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
8
+ import type { TimelineTheme } from "./timelineTheme";
9
+ import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
10
+ import {
11
+ usePlayerStore,
12
+ type TimelineElement,
13
+ type KeyframeCacheEntry,
14
+ } from "../store/playerStore";
15
+ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
16
+ import {
17
+ isMultiDragPassenger,
18
+ multiDragPassengerOffsetPx,
19
+ type MultiDragPreviewInput,
20
+ } from "./timelineMultiDragPreview";
21
+ import type { TrackVisualStyle } from "./timelineIcons";
22
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
23
+ import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
24
+ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
25
+ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
26
+ import { Music } from "../../icons/SystemIcons";
27
+ import { renderClipChildren } from "./timelineClipChildren";
28
+
29
+ /**
30
+ * Props shared by the scroll container ({@link TimelineCanvas}) and the lane
31
+ * renderer below. TimelineCanvas passes these straight through via spread, so
32
+ * they are declared once here and both prop types compose from this base — no
33
+ * duplicated prop list.
34
+ */
35
+ export interface TimelineLaneBaseProps {
36
+ pps: number;
37
+ trackContentWidth: number;
38
+ theme: TimelineTheme;
39
+ displayTrackOrder: number[];
40
+ trackOrder: number[];
41
+ tracks: [number, TimelineElement[]][];
42
+ trackStyles: Map<number, TrackVisualStyle>;
43
+ selectedElementId: string | null;
44
+ selectedElementIds: Set<string>;
45
+ hoveredClip: string | null;
46
+ draggedClip: DraggedClipState | null;
47
+ blockedClipRef: React.RefObject<BlockedClipState | null>;
48
+ suppressClickRef: React.RefObject<boolean>;
49
+ scrollRef: React.RefObject<HTMLDivElement | null>;
50
+ renderClipContent?: (
51
+ element: TimelineElement,
52
+ style: { clip: string; label: string },
53
+ ) => ReactNode;
54
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
55
+ onDrillDown?: (element: TimelineElement) => void;
56
+ onSelectElement?: (element: TimelineElement | null) => void;
57
+ setHoveredClip: (key: string | null) => void;
58
+ setShowPopover: (v: boolean) => void;
59
+ setRangeSelection: (v: null) => void;
60
+ setResizingClip: (v: ResizingClipState | null) => void;
61
+ setDraggedClip: (v: DraggedClipState | null) => void;
62
+ setSelectedElementId: (id: string | null) => void;
63
+ syncClipDragAutoScroll: (x: number, y: number) => void;
64
+ shiftClickClipRef: React.RefObject<{
65
+ element: TimelineElement;
66
+ anchorX: number;
67
+ anchorY: number;
68
+ } | null>;
69
+ getPreviewElement: (element: TimelineElement) => TimelineElement;
70
+ getTrackStyle: (tag: string) => TrackVisualStyle;
71
+ keyframeCache?: Map<string, KeyframeCacheEntry>;
72
+ selectedKeyframes: Set<string>;
73
+ currentTime: number;
74
+ onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
75
+ onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
76
+ onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
77
+ onMoveKeyframe?: (
78
+ elementId: string,
79
+ fromClipPercentage: number,
80
+ toClipPercentage: number,
81
+ ) => void;
82
+ onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
83
+ beatAnalysis?: MusicBeatAnalysis | null;
84
+ }
85
+
86
+ interface TimelineLanesProps extends TimelineLaneBaseProps {
87
+ /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
88
+ draggedElement: TimelineElement | null;
89
+ multiDragPreview: MultiDragPreviewInput | null;
90
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
91
+ onResizeElement: TimelineEditCallbacks["onResizeElement"];
92
+ onMoveElement: TimelineEditCallbacks["onMoveElement"];
93
+ onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
94
+ onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
95
+ }
96
+
97
+ export function TimelineLanes({
98
+ pps,
99
+ trackContentWidth,
100
+ theme,
101
+ displayTrackOrder,
102
+ trackOrder,
103
+ tracks,
104
+ trackStyles,
105
+ selectedElementId,
106
+ selectedElementIds,
107
+ hoveredClip,
108
+ draggedClip,
109
+ draggedElement,
110
+ multiDragPreview,
111
+ blockedClipRef,
112
+ suppressClickRef,
113
+ scrollRef,
114
+ renderClipContent,
115
+ renderClipOverlay,
116
+ onDrillDown,
117
+ onSelectElement,
118
+ setHoveredClip,
119
+ setShowPopover,
120
+ setRangeSelection,
121
+ setResizingClip,
122
+ setDraggedClip,
123
+ setSelectedElementId,
124
+ syncClipDragAutoScroll,
125
+ shiftClickClipRef,
126
+ getPreviewElement,
127
+ getTrackStyle,
128
+ keyframeCache,
129
+ selectedKeyframes,
130
+ currentTime,
131
+ onClickKeyframe,
132
+ onShiftClickKeyframe,
133
+ onContextMenuKeyframe,
134
+ onMoveKeyframe,
135
+ onContextMenuClip,
136
+ beatAnalysis,
137
+ onToggleTrackHidden,
138
+ onResizeElement,
139
+ onMoveElement,
140
+ onRazorSplit,
141
+ onRazorSplitAll,
142
+ }: TimelineLanesProps) {
143
+ return (
144
+ <>
145
+ {
146
+ // NOTE (deliberate no-virtualization): lanes and their clips render via a
147
+ // plain `.map()` inside the scroll container rather than a windowing/virtualized
148
+ // list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
149
+ // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
150
+ // in a virtualizer if editorial workflows ever push very high clip counts.
151
+ // fallow-ignore-next-line complexity
152
+ displayTrackOrder.map((trackNum) => {
153
+ const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
154
+ const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
155
+ const isPendingTrack =
156
+ draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
157
+ // All lanes use the same uniform color — no alternating stripes.
158
+ const rowBackground = theme.rowBackground;
159
+ // The beat-dot strip occupies the top of this track's lane (active track,
160
+ // or the music track when nothing is selected). When shown, keyframe
161
+ // diamonds shrink + drop to the bottom half so they don't collide with it.
162
+ const beatStripOnTrack =
163
+ (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
164
+ (selectedElementId
165
+ ? els.some((e) => (e.key ?? e.id) === selectedElementId)
166
+ : els.some(isMusicTrack));
167
+ const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
168
+ const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
169
+ return (
170
+ <div
171
+ key={trackNum}
172
+ className="relative flex"
173
+ style={{
174
+ height: TRACK_H,
175
+ background: rowBackground,
176
+ borderBottom: `1px solid ${theme.rowBorder}`,
177
+ }}
178
+ >
179
+ <div
180
+ className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
181
+ style={{
182
+ width: GUTTER,
183
+ background: theme.gutterBackground,
184
+ borderRight: `1px solid ${theme.gutterBorder}`,
185
+ }}
186
+ >
187
+ {isAudioTrack && (
188
+ <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
189
+ )}
190
+ <button
191
+ type="button"
192
+ aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
193
+ title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
194
+ className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
195
+ isTrackHidden
196
+ ? "text-[#3CE6AC] hover:text-white"
197
+ : "text-white/35 hover:text-white/75"
198
+ }`}
199
+ onPointerDown={(e) => {
200
+ e.stopPropagation();
201
+ }}
202
+ onClick={(e) => {
203
+ e.stopPropagation();
204
+ void onToggleTrackHidden?.(trackNum, !isTrackHidden);
205
+ }}
206
+ >
207
+ {isTrackHidden ? (
208
+ <EyeSlash size={14} weight="bold" aria-hidden="true" />
209
+ ) : (
210
+ <Eye size={14} weight="bold" aria-hidden="true" />
211
+ )}
212
+ </button>
213
+ </div>
214
+ <div
215
+ style={{
216
+ width: trackContentWidth,
217
+ opacity: isTrackHidden ? 0.35 : 1,
218
+ transition: "opacity 120ms ease",
219
+ }}
220
+ className="relative"
221
+ >
222
+ {/* Faint beat lines in every track's background (behind the clips);
223
+ the active move-snap target is highlighted. */}
224
+ <BeatBackgroundLines
225
+ beatTimes={beatAnalysis?.beatTimes}
226
+ beatStrengths={beatAnalysis?.beatStrengths}
227
+ pps={pps}
228
+ highlightTime={
229
+ draggedClip?.started && draggedClip.snapType === "beat"
230
+ ? draggedClip.snapTime
231
+ : null
232
+ }
233
+ />
234
+ {/* Beat dots on the active track (the one holding the selection),
235
+ falling back to the music track when nothing is selected. */}
236
+ {beatStripOnTrack && (
237
+ <BeatStrip
238
+ beatTimes={beatAnalysis?.beatTimes}
239
+ beatStrengths={beatAnalysis?.beatStrengths}
240
+ pps={pps}
241
+ />
242
+ )}
243
+ {isPendingTrack && (
244
+ <div
245
+ className="absolute inset-0 flex items-center"
246
+ style={{
247
+ paddingLeft: 16,
248
+ color: ts.label,
249
+ fontSize: 11,
250
+ letterSpacing: "0.06em",
251
+ textTransform: "uppercase",
252
+ opacity: 0.5,
253
+ }}
254
+ >
255
+ New track
256
+ </div>
257
+ )}
258
+ {
259
+ // fallow-ignore-next-line complexity
260
+ els.map((el) => {
261
+ const clipStyle = getTrackStyle(el.tag);
262
+ const elementKey = el.key ?? el.id;
263
+ const capabilities = getTimelineEditCapabilities(el);
264
+ const isSelected =
265
+ selectedElementId === elementKey || selectedElementIds.has(elementKey);
266
+ const isComposition = !!el.compositionSrc;
267
+ // elementKey (el.key ?? el.id) is already unique per clip; do NOT
268
+ // fold in the map index, or a splice/reorder remounts every clip
269
+ // at/after the change (DOM flash, drag interruption).
270
+ const clipKey = elementKey;
271
+ const isDraggingClip =
272
+ draggedClip?.started === true &&
273
+ (draggedElement?.key ?? draggedElement?.id) === elementKey;
274
+ if (isDraggingClip) return null;
275
+ const previewElement = getPreviewElement(el);
276
+ // Passenger of a live multi-drag: slide by the SAME formation
277
+ // delta (the grabbed clip's group-clamped delta) via a
278
+ // compositor transform on a same-geometry wrapper (absolute
279
+ // inset-0 → identical offset parent, so the clip's own
280
+ // left/top are preserved), plus the ghost's elevated z/opacity.
281
+ const isPassenger =
282
+ multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
283
+ const passengerOffsetPx = isPassenger
284
+ ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
285
+ : 0;
286
+ const clip = (
287
+ <TimelineClip
288
+ key={clipKey}
289
+ onContextMenu={(e: React.MouseEvent) => {
290
+ e.preventDefault();
291
+ onContextMenuClip?.(e, el);
292
+ }}
293
+ el={previewElement}
294
+ pps={pps}
295
+ clipY={CLIP_Y}
296
+ isSelected={isSelected}
297
+ isHovered={hoveredClip === clipKey}
298
+ isDragging={false}
299
+ hasCustomContent={!!renderClipContent}
300
+ capabilities={capabilities}
301
+ theme={theme}
302
+ isComposition={isComposition}
303
+ onHoverStart={() => setHoveredClip(clipKey)}
304
+ onHoverEnd={() => setHoveredClip(null)}
305
+ onResizeStart={
306
+ // fallow-ignore-next-line complexity
307
+ (edge, e) => {
308
+ if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
309
+ if (edge === "start" && !capabilities.canTrimStart) return;
310
+ if (edge === "end" && !capabilities.canTrimEnd) return;
311
+ e.stopPropagation();
312
+ blockedClipRef.current = null;
313
+ setShowPopover(false);
314
+ setRangeSelection(null);
315
+ setResizingClip({
316
+ element: el,
317
+ edge,
318
+ originClientX: e.clientX,
319
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
320
+ previewStart: el.start,
321
+ previewDuration: el.duration,
322
+ previewPlaybackStart: el.playbackStart,
323
+ started: false,
324
+ });
325
+ }
326
+ }
327
+ onPointerDown={
328
+ // fallow-ignore-next-line complexity
329
+ (e) => {
330
+ if (e.button !== 0) return;
331
+ if (usePlayerStore.getState().activeTool === "razor") return;
332
+ if (e.shiftKey) {
333
+ shiftClickClipRef.current = {
334
+ element: el,
335
+ anchorX: e.clientX,
336
+ anchorY: e.clientY,
337
+ };
338
+ return;
339
+ }
340
+ const target = e.currentTarget as HTMLElement;
341
+ const rect = target.getBoundingClientRect();
342
+ const blockedIntent = resolveBlockedTimelineEditIntent({
343
+ width: rect.width,
344
+ offsetX: e.clientX - rect.left,
345
+ handleWidth: CLIP_HANDLE_W,
346
+ capabilities,
347
+ });
348
+ if (
349
+ blockedIntent &&
350
+ ((blockedIntent === "move" && onMoveElement) ||
351
+ (blockedIntent !== "move" && onResizeElement))
352
+ ) {
353
+ blockedClipRef.current = {
354
+ element: el,
355
+ intent: blockedIntent,
356
+ originClientX: e.clientX,
357
+ originClientY: e.clientY,
358
+ started: false,
359
+ };
360
+ return;
361
+ }
362
+ if (!onMoveElement || !capabilities.canMove) return;
363
+ blockedClipRef.current = null;
364
+ setShowPopover(false);
365
+ setRangeSelection(null);
366
+ setDraggedClip({
367
+ element: el,
368
+ originClientX: e.clientX,
369
+ originClientY: e.clientY,
370
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
371
+ originScrollTop: scrollRef.current?.scrollTop ?? 0,
372
+ pointerClientX: e.clientX,
373
+ pointerClientY: e.clientY,
374
+ pointerOffsetX: e.clientX - rect.left,
375
+ pointerOffsetY: e.clientY - rect.top,
376
+ previewStart: el.start,
377
+ previewTrack: el.track,
378
+ desiredTrack: el.track,
379
+ insertRow: null,
380
+ snapTime: null,
381
+ snapType: null,
382
+ started: false,
383
+ });
384
+ syncClipDragAutoScroll(e.clientX, e.clientY);
385
+ }
386
+ }
387
+ onClick={
388
+ // fallow-ignore-next-line complexity
389
+ (e) => {
390
+ e.stopPropagation();
391
+ if (suppressClickRef.current) return;
392
+ const { activeTool } = usePlayerStore.getState();
393
+ if (activeTool === "razor" && onRazorSplit) {
394
+ const clipRect = (
395
+ e.currentTarget as HTMLElement
396
+ ).getBoundingClientRect();
397
+ const clickOffsetX = e.clientX - clipRect.left;
398
+ const splitTime = previewElement.start + clickOffsetX / pps;
399
+ const clampedTime = Math.max(
400
+ previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
401
+ Math.min(
402
+ previewElement.start +
403
+ previewElement.duration -
404
+ SPLIT_BOUNDARY_EPSILON_S,
405
+ splitTime,
406
+ ),
407
+ );
408
+ if (e.shiftKey && onRazorSplitAll) {
409
+ onRazorSplitAll(clampedTime);
410
+ } else {
411
+ onRazorSplit(el, clampedTime);
412
+ }
413
+ return;
414
+ }
415
+ // Plain click single-selects: drop any marquee multi-selection.
416
+ // Only a click on the PRIMARY selection toggles it off — a click
417
+ // on a marquee-selected clip narrows the selection to that clip.
418
+ const hadMultiSelection = selectedElementIds.size > 0;
419
+ usePlayerStore.getState().clearSelectedElementIds();
420
+ const nextElement =
421
+ selectedElementId === elementKey && !hadMultiSelection ? null : el;
422
+ setSelectedElementId(nextElement ? elementKey : null);
423
+ onSelectElement?.(nextElement);
424
+ }
425
+ }
426
+ onDoubleClick={(e) => {
427
+ e.stopPropagation();
428
+ if (suppressClickRef.current) return;
429
+ if (isComposition && onDrillDown) onDrillDown(el);
430
+ }}
431
+ >
432
+ {renderClipChildren(
433
+ previewElement,
434
+ clipStyle,
435
+ renderClipContent,
436
+ renderClipOverlay,
437
+ )}
438
+ {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
439
+ <TimelineClipDiamonds
440
+ keyframesData={keyframeCache.get(elementKey)!}
441
+ clipWidthPx={Math.max(previewElement.duration * pps, 4)}
442
+ clipHeightPx={TRACK_H - 2 * CLIP_Y}
443
+ beatsActive={beatStripOnTrack}
444
+ accentColor={clipStyle.accent}
445
+ isSelected={isSelected}
446
+ currentPercentage={
447
+ previewElement.duration > 0
448
+ ? ((currentTime - previewElement.start) / previewElement.duration) *
449
+ 100
450
+ : 0
451
+ }
452
+ elementId={elementKey}
453
+ selectedKeyframes={selectedKeyframes}
454
+ onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
455
+ onShiftClickKeyframe={onShiftClickKeyframe}
456
+ onContextMenuKeyframe={onContextMenuKeyframe}
457
+ onMoveKeyframe={onMoveKeyframe}
458
+ suppressClickRef={suppressClickRef}
459
+ />
460
+ )}
461
+ </TimelineClip>
462
+ );
463
+ if (!isPassenger) return clip;
464
+ return (
465
+ <div
466
+ key={clipKey}
467
+ className="absolute inset-0"
468
+ style={{
469
+ transform: `translateX(${passengerOffsetPx}px)`,
470
+ opacity: 0.85,
471
+ zIndex: 20,
472
+ pointerEvents: "none",
473
+ }}
474
+ >
475
+ {clip}
476
+ </div>
477
+ );
478
+ })
479
+ }
480
+ </div>
481
+ </div>
482
+ );
483
+ })
484
+ }
485
+ </>
486
+ );
487
+ }