@hyperframes/studio 0.8.50 → 0.8.51

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 (215) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-DUAIWxkI.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-Dt3qBI-9.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +18 -13
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1867 -3335
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +128 -141
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.tsx +4 -7
  20. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  21. package/src/components/LintModal.tsx +6 -16
  22. package/src/components/MediaPreview.tsx +2 -2
  23. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  24. package/src/components/StudioHeader.tsx +2 -62
  25. package/src/components/StudioLeftSidebar.tsx +7 -4
  26. package/src/components/StudioOverlays.tsx +1 -1
  27. package/src/components/StudioRightPanel.tsx +4 -4
  28. package/src/components/StudioToast.tsx +2 -2
  29. package/src/components/TimelineToolbar.tsx +7 -7
  30. package/src/components/dock/Dock.test.tsx +192 -0
  31. package/src/components/dock/Dock.tsx +264 -0
  32. package/src/components/dock/dock.css +23 -0
  33. package/src/components/dock/dockLayout.ts +58 -0
  34. package/src/components/dock/dockLayoutSchema.ts +66 -0
  35. package/src/components/dock/dockLayoutStore.ts +91 -0
  36. package/src/components/dock/panelRegistry.ts +67 -0
  37. package/src/components/editor/AnimationCard.tsx +4 -4
  38. package/src/components/editor/AnimationCardParts.tsx +6 -6
  39. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  40. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  41. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  42. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  43. package/src/components/editor/DomEditOverlay.tsx +1 -1
  44. package/src/components/editor/EaseCurveSection.tsx +1 -1
  45. package/src/components/editor/EaseParamFields.tsx +5 -5
  46. package/src/components/editor/FileTree.tsx +4 -4
  47. package/src/components/editor/FileTreeIcons.tsx +1 -1
  48. package/src/components/editor/FileTreeNodes.tsx +13 -13
  49. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  50. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  51. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  52. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  53. package/src/components/editor/LayersPanel.tsx +3 -3
  54. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  55. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  56. package/src/components/editor/PromotableControl.tsx +2 -2
  57. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  58. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  59. package/src/components/editor/SnapToolbar.tsx +2 -2
  60. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  61. package/src/components/editor/TopologyLens.tsx +4 -4
  62. package/src/components/editor/Transform3DCube.tsx +2 -2
  63. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  64. package/src/components/editor/domEditing.test.ts +1 -1
  65. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  66. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  67. package/src/components/editor/propertyPanelColor.tsx +11 -11
  68. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  69. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  70. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  71. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  72. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  73. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  74. package/src/components/editor/propertyPanelFill.tsx +4 -4
  75. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  76. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  77. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  78. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  79. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  80. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  81. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  82. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  83. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  84. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  85. package/src/components/editor/propertyPanelFont.tsx +5 -5
  86. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  87. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  88. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  89. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  90. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  91. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  92. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  93. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  94. package/src/components/editor/propertyPanelSections.tsx +8 -8
  95. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  96. package/src/components/feedback/feedbackTrigger.ts +0 -1
  97. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  98. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  99. package/src/components/nle/NLEPreview.tsx +3 -3
  100. package/src/components/nle/PreviewOverlays.tsx +6 -6
  101. package/src/components/nle/PreviewPane.tsx +2 -2
  102. package/src/components/nle/TimelinePane.tsx +3 -3
  103. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  104. package/src/components/panels/SlideshowPanel.tsx +2 -2
  105. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  106. package/src/components/panels/VariablesBindElement.tsx +2 -2
  107. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  108. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  109. package/src/components/panels/VariablesPanel.tsx +3 -3
  110. package/src/components/panels/VariablesValueControls.tsx +2 -2
  111. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  112. package/src/components/renders/RenderQueue.test.tsx +7 -0
  113. package/src/components/renders/RenderQueue.tsx +6 -6
  114. package/src/components/renders/RenderQueueItem.tsx +6 -6
  115. package/src/components/sidebar/AssetCard.tsx +8 -8
  116. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  117. package/src/components/sidebar/AssetsTab.tsx +2 -2
  118. package/src/components/sidebar/AudioRow.tsx +6 -6
  119. package/src/components/sidebar/BlocksTab.tsx +6 -6
  120. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  121. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  122. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  123. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  124. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  125. package/src/components/ui/Button.test.tsx +60 -0
  126. package/src/components/ui/Button.tsx +4 -4
  127. package/src/components/ui/SearchInput.tsx +2 -2
  128. package/src/components/ui/Tooltip.tsx +1 -1
  129. package/src/hooks/useAppHotkeys.ts +1 -1
  130. package/src/hooks/useLintModal.test.tsx +65 -0
  131. package/src/hooks/useLintModal.ts +3 -0
  132. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  133. package/src/hooks/useSdkSession.ts +43 -10
  134. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  135. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  136. package/src/main.tsx +9 -3
  137. package/src/player/components/AudioWaveform.tsx +1 -1
  138. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  139. package/src/player/components/ClipContextMenu.tsx +3 -3
  140. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  141. package/src/player/components/CompositionThumbnail.tsx +3 -4
  142. package/src/player/components/EditModal.tsx +1 -1
  143. package/src/player/components/ImageThumbnail.tsx +1 -1
  144. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  145. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  146. package/src/player/components/PlayerControls.tsx +1 -1
  147. package/src/player/components/ShortcutsPanel.tsx +9 -9
  148. package/src/player/components/SpeedMenu.tsx +3 -5
  149. package/src/player/components/Timeline.tsx +2 -1
  150. package/src/player/components/TimelineCanvas.tsx +17 -10
  151. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  152. package/src/player/components/TimelineEmptyState.tsx +5 -5
  153. package/src/player/components/TimelineFxButton.tsx +6 -6
  154. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  155. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  156. package/src/player/components/TimelineGroupRow.tsx +1 -1
  157. package/src/player/components/TimelineLanes.test.tsx +32 -0
  158. package/src/player/components/TimelineLanes.tsx +2 -5
  159. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  160. package/src/player/components/TimelineRuler.tsx +1 -1
  161. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  162. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  163. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  164. package/src/player/components/TimelineTypes.ts +2 -1
  165. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  166. package/src/player/components/VideoThumbnail.tsx +4 -2
  167. package/src/player/components/VolumeControl.tsx +2 -2
  168. package/src/player/components/timelineCallbacks.ts +8 -8
  169. package/src/player/components/timelineClipDragPreview.ts +51 -27
  170. package/src/player/components/timelineClipDragTypes.ts +3 -0
  171. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  172. package/src/player/components/timelineDragDrop.ts +57 -15
  173. package/src/player/components/timelineLaneProps.ts +3 -0
  174. package/src/player/components/timelineSnapping.test.ts +27 -0
  175. package/src/player/components/timelineSnapping.ts +14 -1
  176. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  177. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  178. package/src/player/components/useTimelineClipDrag.ts +30 -3
  179. package/src/player/lib/describeClips.ts +3 -54
  180. package/src/styles/iconTokens.ts +20 -0
  181. package/src/styles/palette-reset.css +6 -0
  182. package/src/styles/studio.css +24 -21
  183. package/src/styles/tailwind-preset.shared.js +3 -0
  184. package/src/styles/theme.css +213 -0
  185. package/src/styles/theme.test.ts +196 -0
  186. package/src/utils/studioUiPreferences.ts +7 -0
  187. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  188. package/src/utils/timelineDropTrackInsert.ts +83 -0
  189. package/dist/assets/index-n2ml4tUn.js +0 -442
  190. package/dist/assets/index-pO6p0H27.css +0 -1
  191. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  192. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  193. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  194. package/src/components/storyboard/FramePoster.tsx +0 -86
  195. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  196. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  197. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  198. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  199. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  200. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  201. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  202. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  203. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  204. package/src/components/storyboard/StoryboardView.tsx +0 -169
  205. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  206. package/src/components/storyboard/frameComments.test.ts +0 -95
  207. package/src/components/storyboard/frameComments.ts +0 -115
  208. package/src/components/storyboard/frameStatus.ts +0 -36
  209. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  210. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  211. package/src/components/storyboard/useFrameComments.ts +0 -98
  212. package/src/contexts/ViewModeContext.tsx +0 -142
  213. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  214. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  215. package/src/hooks/useStoryboard.ts +0 -99
@@ -12,6 +12,7 @@ import {
12
12
  snapMoveToTargets,
13
13
  snapTimelineTime,
14
14
  type TimelineSnapTarget,
15
+ type TimelineSnapType,
15
16
  } from "./timelineSnapping";
16
17
  import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
17
18
  import {
@@ -21,11 +22,13 @@ import {
21
22
  import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
22
23
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
23
24
  import { resolveDragLandingStart } from "./timelineDragLanding";
25
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
24
26
 
25
27
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
26
28
  type BuildSnapTargets = (
27
29
  excludeElementKey: string | null,
28
30
  includeBeats: boolean,
31
+ includePlayhead?: boolean,
29
32
  ) => TimelineSnapTarget[];
30
33
 
31
34
  export interface DragPreviewContext {
@@ -223,6 +226,14 @@ export function computeDragPreview(
223
226
  };
224
227
  }
225
228
 
229
+ /** One frame: the last visible frame of a clip sits just before its end time. */
230
+ const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;
231
+
232
+ /** The composition time whose frame a trim shows: the edge being dragged. */
233
+ export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number {
234
+ return edge === "start" ? start : Math.max(start, start + duration - TRIM_END_FRAME_LEAD_S);
235
+ }
236
+
226
237
  export interface ResizePreviewContext {
227
238
  scroll: HTMLDivElement | null;
228
239
  pps: number;
@@ -234,6 +245,9 @@ export interface ResizePreviewResult {
234
245
  previewStart: number;
235
246
  previewDuration: number;
236
247
  previewPlaybackStart?: number;
248
+ /** The target the trimmed edge snapped to; null when the edge is free. */
249
+ snapTime: number | null;
250
+ snapType: TimelineSnapType | null;
237
251
  }
238
252
 
239
253
  /** Compute the trim preview for a pointer x (pure — the hook applies the state). */
@@ -287,28 +301,31 @@ export function computeResizePreview(
287
301
  effectiveClientX,
288
302
  );
289
303
 
290
- // Snap edge to unified targets (beats + clip edges + playhead) when available.
291
- // The snap must stay inside the same limits resolveTimelineResize enforces, or
292
- // it would push the edge past the available source media / composition end.
293
- // The music track defines the beats, so it must not snap to them — but it
294
- // still snaps to the playhead and other clip edges.
304
+ // Snap to beats and clip edges, never the playhead (the dragged edge drives
305
+ // its own preview seek, so that would be circular). Stay inside the same
306
+ // limits resolveTimelineResize enforces. The music track defines the
307
+ // beats, so it must not snap to them, but still snaps to clip edges.
295
308
  const trimTargets = buildSnapTargets(
296
309
  resize.element.key ?? resize.element.id,
297
310
  !isMusicTrack(resize.element),
311
+ false,
298
312
  );
313
+ let snap: TimelineSnapTarget | null = null;
299
314
  if (trimTargets.length > 0) {
300
315
  const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
301
316
  if (resize.edge === "end") {
302
317
  const edgeTime = nextResize.start + nextResize.duration;
303
- const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time;
318
+ const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
304
319
  // Stay within [start+minDuration, maxEnd] so the snap can't create a
305
320
  // degenerate clip or run past the source/composition limit.
306
321
  const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
307
- if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
308
- nextResize = { ...nextResize, duration: snappedDuration };
322
+ if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
323
+ // An edge already on the target still owns the guide; only move it when off.
324
+ if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
325
+ snap = target;
309
326
  }
310
327
  } else {
311
- const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time;
328
+ const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
312
329
  const delta = nextResize.start - snapped; // >0 when snapping left
313
330
  // Leftward snap reveals more source; cap so playbackStart can't go < 0.
314
331
  const maxLeftDelta =
@@ -318,22 +335,20 @@ export function computeResizePreview(
318
335
  // Also require the resulting duration to stay >= minDuration so a rightward
319
336
  // snap (delta < 0) can't collapse the clip to zero/negative.
320
337
  const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
321
- if (
322
- snapped !== nextResize.start &&
323
- snapped >= 0 &&
324
- delta <= maxLeftDelta + 1e-6 &&
325
- snappedDuration >= 0.05
326
- ) {
327
- nextResize = {
328
- ...nextResize,
329
- start: snapped,
330
- duration: snappedDuration,
331
- playbackStart:
332
- nextResize.playbackStart != null
333
- ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
334
- 1000
335
- : undefined,
336
- };
338
+ if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
339
+ if (snapped !== nextResize.start) {
340
+ nextResize = {
341
+ ...nextResize,
342
+ start: snapped,
343
+ duration: snappedDuration,
344
+ playbackStart:
345
+ nextResize.playbackStart != null
346
+ ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
347
+ 1000
348
+ : undefined,
349
+ };
350
+ }
351
+ snap = target;
337
352
  }
338
353
  }
339
354
  }
@@ -343,6 +358,8 @@ export function computeResizePreview(
343
358
  previewStart: nextResize.start,
344
359
  previewDuration: nextResize.duration,
345
360
  previewPlaybackStart: nextResize.playbackStart,
361
+ snapTime: snap?.time ?? null,
362
+ snapType: snap?.type ?? null,
346
363
  };
347
364
  }
348
365
 
@@ -359,11 +376,18 @@ export function previewGroupResize(
359
376
  ) => void,
360
377
  ): void {
361
378
  const grabbedChange = applyTimelineGroupResizePreview(session, next);
379
+ const previewStart = grabbedChange?.start ?? next.previewStart;
380
+ const previewDuration = grabbedChange?.duration ?? next.previewDuration;
381
+ // A member clamp can pull the grabbed edge off the raw snap target; then no guide.
382
+ const edgeTime = session.edge === "end" ? previewStart + previewDuration : previewStart;
383
+ const stillSnapped = next.snapTime != null && Math.abs(edgeTime - next.snapTime) < 1e-3;
362
384
  setResizeState({
363
385
  originScrollLeft: next.originScrollLeft,
364
- previewStart: grabbedChange?.start ?? next.previewStart,
365
- previewDuration: grabbedChange?.duration ?? next.previewDuration,
386
+ previewStart,
387
+ previewDuration,
366
388
  previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
389
+ snapTime: stillSnapped ? next.snapTime : null,
390
+ snapType: stillSnapped ? next.snapType : null,
367
391
  groupPreview: session.changes,
368
392
  });
369
393
  }
@@ -57,6 +57,9 @@ export interface ResizingClipState {
57
57
  previewStart: number;
58
58
  previewDuration: number;
59
59
  previewPlaybackStart?: number;
60
+ /** Snap target the trimmed edge landed on, for the guide highlight. */
61
+ snapTime?: number | null;
62
+ snapType?: TimelineSnapType | null;
60
63
  /** Coordinator-owned group projection; canonical elements change only on commit. */
61
64
  groupPreview?: readonly {
62
65
  key: string;
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
6
6
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
8
  import { createTimelineRowGeometry } from "./timelineLayout";
9
- import { useTimelineAssetDrop } from "./timelineDragDrop";
9
+ import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop";
10
+ import { getTimelineRowTop, TRACK_H } from "./timelineLayout";
10
11
  import { configureTimelineTestViewport } from "./timelineTestViewport";
11
12
 
12
13
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -252,3 +253,62 @@ describe("useTimelineAssetDrop", () => {
252
253
  act(() => view.root.unmount());
253
254
  });
254
255
  });
256
+
257
+ describe("resolveDropInsertRow", () => {
258
+ const rows = [TRACK_H, TRACK_H, TRACK_H];
259
+ it("arms a new track at the boundary between two lanes", () => {
260
+ expect(resolveDropInsertRow(getTimelineRowTop(1, rows), rows, 3)).toBe(1);
261
+ });
262
+ it("arms a new track above the first lane", () => {
263
+ expect(resolveDropInsertRow(getTimelineRowTop(0, rows) - 4, rows, 3)).toBe(0);
264
+ });
265
+ it("stays on the lane when the pointer is over its middle", () => {
266
+ expect(resolveDropInsertRow(getTimelineRowTop(1, rows) + TRACK_H / 2, rows, 3)).toBeNull();
267
+ });
268
+ it("leaves the area below the last lane to the append path", () => {
269
+ expect(resolveDropInsertRow(getTimelineRowTop(2, rows) + TRACK_H + 4, rows, 3)).toBeNull();
270
+ });
271
+ it("never arms on an empty timeline", () => {
272
+ expect(resolveDropInsertRow(80, [], 0)).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe("useTimelineAssetDrop new-track drops", () => {
277
+ it("previews and commits an insert row for an asset dropped between lanes", () => {
278
+ const onAssetDrop = vi.fn();
279
+ const view = renderHarness(onAssetDrop);
280
+ const transfer = assetTransfer(JSON.stringify({ path: "assets/a.png" }));
281
+ const y = getTimelineRowTop(1) + 1;
282
+
283
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, y)));
284
+ expect(view.api.dropPreview).toMatchObject({ start: 10, insertRow: 1 });
285
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, y)));
286
+ expect(onAssetDrop).toHaveBeenCalledWith(
287
+ "assets/a.png",
288
+ expect.objectContaining({
289
+ start: 10,
290
+ insertRow: 1,
291
+ trackOrder: Array.from({ length: 100 }, (_, index) => index),
292
+ }),
293
+ );
294
+ act(() => view.root.unmount());
295
+ });
296
+
297
+ it("does not ask a block drop to insert a track", () => {
298
+ const onBlockDrop = vi.fn();
299
+ const view = renderHarness(vi.fn(), 1, { onBlockDrop });
300
+ const transfer: DropTransfer = {
301
+ types: [TIMELINE_BLOCK_MIME],
302
+ files: [],
303
+ dropEffect: "none",
304
+ getData: (type) => (type === TIMELINE_BLOCK_MIME ? JSON.stringify({ name: "b" }) : ""),
305
+ };
306
+ const y = getTimelineRowTop(1) + 1;
307
+ act(() => {
308
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, y));
309
+ view.api.handleAssetDrop(dragEvent(transfer, 400, y));
310
+ });
311
+ expect(onBlockDrop).toHaveBeenCalledWith("b", { start: 10, track: 1 });
312
+ act(() => view.root.unmount());
313
+ });
314
+ });
@@ -4,8 +4,14 @@ import {
4
4
  parseTimelineCompositionPayload,
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
- import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
8
- import type { TimelineDropCallbacks } from "./timelineCallbacks";
7
+ import {
8
+ getTimelineInsertBoundaryBand,
9
+ getTimelineRowPositionFromY,
10
+ resolveTimelineAssetDrop,
11
+ type TimelineRowGeometry,
12
+ } from "./timelineLayout";
13
+ import { resolveInsertRow } from "./timelineCollision";
14
+ import type { TimelineDropCallbacks, TimelineDropPlacement } from "./timelineCallbacks";
9
15
  import {
10
16
  applyTimelineAutoScrollStep,
11
17
  resolveTimelineAutoScrollLoopAction,
@@ -20,8 +26,6 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
20
26
  sessionEpoch: number;
21
27
  }
22
28
 
23
- type TimelinePlacement = { start: number; track: number };
24
-
25
29
  /**
26
30
  * Parse a JSON drag payload and, if it yields a value, forward it to the drop
27
31
  * callback. Malformed payloads are ignored. Shared by the asset + block paths so
@@ -30,8 +34,8 @@ type TimelinePlacement = { start: number; track: number };
30
34
  function applyJsonDropPayload(
31
35
  raw: string,
32
36
  pick: (parsed: Record<string, string | undefined>) => string | undefined,
33
- apply: (value: string, placement: TimelinePlacement) => void,
34
- placement: TimelinePlacement,
37
+ apply: (value: string, placement: TimelineDropPlacement) => void,
38
+ placement: TimelineDropPlacement,
35
39
  ): boolean {
36
40
  try {
37
41
  const value = pick(JSON.parse(raw) as Record<string, string | undefined>);
@@ -43,6 +47,40 @@ function applyJsonDropPayload(
43
47
  }
44
48
  }
45
49
 
50
+ /** Only file and asset drops are written by the asset op that can open a track. */
51
+ function canInsertTrackFor(types: readonly string[]): boolean {
52
+ return types.includes("Files") || types.includes(TIMELINE_ASSET_MIME);
53
+ }
54
+
55
+ /** The row boundary a pointer at `contentY` arms for a new track, or null over a lane's middle. */
56
+ export function resolveDropInsertRow(
57
+ contentY: number,
58
+ rowHeights: readonly number[] | undefined,
59
+ trackCount: number,
60
+ ): number | null {
61
+ if (trackCount === 0) return null;
62
+ const { rowFloat, rowHeight } = getTimelineRowPositionFromY(contentY, rowHeights);
63
+ // Past the last lane the drop already appends a track (getDefaultDroppedTrack).
64
+ if (rowFloat >= trackCount) return null;
65
+ return resolveInsertRow(rowFloat, trackCount, getTimelineInsertBoundaryBand(rowHeight));
66
+ }
67
+
68
+ function placeDrop(
69
+ geometry: Parameters<typeof resolveTimelineAssetDrop>[0],
70
+ clientX: number,
71
+ clientY: number,
72
+ // Blocks and compositions are written by their own installers, which only take a track.
73
+ canInsertTrack: boolean,
74
+ ): TimelineDropPlacement {
75
+ const placement = resolveTimelineAssetDrop(geometry, clientX, clientY);
76
+ if (!canInsertTrack) return placement;
77
+ const contentY = clientY - geometry.rectTop + geometry.scrollTop;
78
+ const insertRow = resolveDropInsertRow(contentY, geometry.rowHeights, geometry.trackOrder.length);
79
+ return insertRow == null
80
+ ? placement
81
+ : { ...placement, insertRow, trackOrder: geometry.trackOrder };
82
+ }
83
+
46
84
  function invokeDropCallback(callback: () => Promise<void> | void): void {
47
85
  try {
48
86
  void Promise.resolve(callback()).catch(() => undefined);
@@ -54,7 +92,7 @@ function invokeDropCallback(callback: () => Promise<void> | void): void {
54
92
  function applyFileDrop(
55
93
  transfer: DataTransfer,
56
94
  onFileDrop: TimelineDropCallbacks["onFileDrop"],
57
- placement: TimelinePlacement,
95
+ placement: TimelineDropPlacement,
58
96
  ): boolean {
59
97
  if (!onFileDrop || transfer.files.length === 0) return false;
60
98
  invokeDropCallback(() => onFileDrop(Array.from(transfer.files), placement));
@@ -65,8 +103,8 @@ function applyTypedJsonDrop(
65
103
  transfer: DataTransfer,
66
104
  mime: string,
67
105
  field: "name" | "path",
68
- apply: ((value: string, placement: TimelinePlacement) => Promise<void> | void) | undefined,
69
- placement: TimelinePlacement,
106
+ apply: ((value: string, placement: TimelineDropPlacement) => Promise<void> | void) | undefined,
107
+ placement: TimelineDropPlacement,
70
108
  ): boolean {
71
109
  if (!apply || !Array.from(transfer.types).includes(mime)) return false;
72
110
  const payload = transfer.getData(mime);
@@ -98,7 +136,7 @@ export function useTimelineAssetDrop({
98
136
  sessionEpoch,
99
137
  }: UseTimelineAssetDropOptions) {
100
138
  const [isDragOver, setIsDragOver] = useState(false);
101
- const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
139
+ const [dropPreview, setDropPreview] = useState<TimelineDropPlacement | null>(null);
102
140
  const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
103
141
  null,
104
142
  );
@@ -144,10 +182,10 @@ export function useTimelineAssetDrop({
144
182
  );
145
183
 
146
184
  const resolveDropPlacement = useCallback(
147
- (clientX: number, clientY: number): TimelinePlacement => {
185
+ (clientX: number, clientY: number, canInsertTrack: boolean): TimelineDropPlacement => {
148
186
  const scroll = scrollRef.current;
149
187
  const rect = scroll?.getBoundingClientRect();
150
- return resolveTimelineAssetDrop(
188
+ return placeDrop(
151
189
  {
152
190
  rectLeft: rect?.left ?? 0,
153
191
  rectTop: rect?.top ?? 0,
@@ -160,6 +198,7 @@ export function useTimelineAssetDrop({
160
198
  },
161
199
  clientX,
162
200
  clientY,
201
+ canInsertTrack,
163
202
  );
164
203
  },
165
204
  [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
@@ -177,9 +216,11 @@ export function useTimelineAssetDrop({
177
216
  e.dataTransfer.dropEffect = "copy";
178
217
  activeDropEpochRef.current = sessionEpoch;
179
218
  setIsDragOver(true);
180
- const next = resolveDropPlacement(e.clientX, e.clientY);
219
+ const next = resolveDropPlacement(e.clientX, e.clientY, canInsertTrackFor(types));
181
220
  setDropPreview((prev) =>
182
- prev?.start === next.start && prev.track === next.track ? prev : next,
221
+ prev?.start === next.start && prev.track === next.track && prev.insertRow === next.insertRow
222
+ ? prev
223
+ : next,
183
224
  );
184
225
  syncAutoScroll(e.clientX, e.clientY);
185
226
  },
@@ -208,7 +249,8 @@ export function useTimelineAssetDrop({
208
249
  const canCommit = activeDropEpochRef.current === sessionEpoch;
209
250
  clearDropPreview();
210
251
  if (!canCommit) return;
211
- const placement = resolveDropPlacement(e.clientX, e.clientY);
252
+ const types = Array.from(e.dataTransfer.types);
253
+ const placement = resolveDropPlacement(e.clientX, e.clientY, canInsertTrackFor(types));
212
254
 
213
255
  const compositionPayload = parseTimelineCompositionPayload(
214
256
  e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
@@ -8,6 +8,7 @@ import type { TrackVisualStyle } from "./timelineIcons";
8
8
  import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
9
9
  import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
10
10
  import type { TimelineRowGeometry } from "./timelineLayout";
11
+ import type { TimelineSnapTarget } from "./timelineSnapping";
11
12
  import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
12
13
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
14
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -112,6 +113,8 @@ export interface TimelineLaneBaseProps {
112
113
  export interface TimelineLanesProps extends TimelineLaneBaseProps {
113
114
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
114
115
  draggedElement: TimelineElement | null;
116
+ /** Live move or trim snap target, resolved once by TimelineCanvas. */
117
+ snapGuide: TimelineSnapTarget | null;
115
118
  multiDragPreview: MultiDragPreviewInput | null;
116
119
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
117
120
  onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
@@ -4,6 +4,7 @@ import {
4
4
  collectTimelineSnapTargets,
5
5
  snapMoveToTargets,
6
6
  snapTimelineTime,
7
+ resolveSnapGuide,
7
8
  } from "./timelineSnapping";
8
9
 
9
10
  describe("collectTimelineSnapTargets", () => {
@@ -26,6 +27,18 @@ describe("collectTimelineSnapTargets", () => {
26
27
  expect(targets).toContainEqual({ time: 0.5, type: "beat" });
27
28
  });
28
29
 
30
+ it("omits the playhead when includePlayhead is false, for a trim", () => {
31
+ const targets = collectTimelineSnapTargets({
32
+ elements,
33
+ playheadTime: 7.25,
34
+ beatTimes: [0.5],
35
+ includePlayhead: false,
36
+ });
37
+ expect(targets.some((t) => t.type === "playhead")).toBe(false);
38
+ expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
39
+ expect(targets).toContainEqual({ time: 0.5, type: "beat" });
40
+ });
41
+
29
42
  it("excludes the dragged element's own edges", () => {
30
43
  const targets = collectTimelineSnapTargets({
31
44
  elements,
@@ -132,3 +145,17 @@ describe("snapMoveToTargets", () => {
132
145
  expect(r.snapTime).toBeNull();
133
146
  });
134
147
  });
148
+
149
+ describe("resolveSnapGuide", () => {
150
+ it("prefers a started move, falls back to a trim, and is null when neither snapped", () => {
151
+ const move = { started: true, snapTime: 2, snapType: "playhead" as const };
152
+ const trim = { snapTime: 5, snapType: "clip-edge" as const };
153
+ expect(resolveSnapGuide(move, trim)).toEqual({ time: 2, type: "playhead" });
154
+ expect(resolveSnapGuide({ ...move, started: false }, trim)).toEqual({
155
+ time: 5,
156
+ type: "clip-edge",
157
+ });
158
+ expect(resolveSnapGuide(null, { snapTime: null, snapType: null })).toBeNull();
159
+ expect(resolveSnapGuide(null, null)).toBeNull();
160
+ });
161
+ });
@@ -7,6 +7,16 @@ export interface TimelineSnapTarget {
7
7
  type: TimelineSnapType;
8
8
  }
9
9
 
10
+ /** The guide the canvas draws: the live move's snap target, else the live trim's. */
11
+ export function resolveSnapGuide(
12
+ moving: { started: boolean; snapTime: number | null; snapType: TimelineSnapType | null } | null,
13
+ trimming: { snapTime?: number | null; snapType?: TimelineSnapType | null } | null,
14
+ ): TimelineSnapTarget | null {
15
+ const source = moving?.started ? moving : trimming;
16
+ if (source?.snapTime == null || source.snapType == null) return null;
17
+ return { time: source.snapTime, type: source.snapType };
18
+ }
19
+
10
20
  /** Pixel radius within which a time snaps to a target (matches historical beat snap). */
11
21
  export const TIMELINE_SNAP_PX = 8;
12
22
 
@@ -21,6 +31,8 @@ export function collectTimelineSnapTargets(input: {
21
31
  playheadTime: number | null;
22
32
  beatTimes: readonly number[];
23
33
  excludeElementKey?: string | null;
34
+ /** A trim excludes the playhead: the dragged edge drives it, so snapping to it is circular. */
35
+ includePlayhead?: boolean;
24
36
  }): TimelineSnapTarget[] {
25
37
  const byTime = new Map<number, TimelineSnapTarget>();
26
38
  const add = (time: number, type: TimelineSnapType) => {
@@ -38,7 +50,8 @@ export function collectTimelineSnapTargets(input: {
38
50
  add(el.start, "clip-edge");
39
51
  add(el.start + el.duration, "clip-edge");
40
52
  }
41
- if (input.playheadTime != null) add(input.playheadTime, "playhead");
53
+ if (input.playheadTime != null && input.includePlayhead !== false)
54
+ add(input.playheadTime, "playhead");
42
55
 
43
56
  return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
44
57
  }
@@ -39,7 +39,7 @@ function PropertyGroupNavigation({
39
39
  // bigger box; focus-visible matches every other control in this header.
40
40
  const CHEVRON_BUTTON_CLASS =
41
41
  "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
42
- "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
42
+ "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
43
43
  "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
44
44
  "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
45
45
  const seekTo = (keyframe: { percentage: number } | null) => {
@@ -154,7 +154,7 @@ export function PropertyGroupHeaderRow({
154
154
  aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
155
155
  title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
156
156
  // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
157
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
157
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
158
158
  onClick={(event) => {
159
159
  // Same as the disclosure caret and the eye: a control in the label
160
160
  // column owns its click, it does not also hit the track row behind it.
@@ -262,7 +262,7 @@ export function AutomationLaneHeaderRow({
262
262
  aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
263
263
  title={onReveal ? `Show ${label} in the effect rack` : label}
264
264
  disabled={!onReveal}
265
- className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
265
+ className="flex min-w-0 flex-1 flex-col justify-center rounded-sm border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
266
266
  onPointerDown={(event) => event.stopPropagation()}
267
267
  onClick={(event) => {
268
268
  // The label column owns its click; it does not also fall through to
@@ -311,7 +311,7 @@ export function AutomationLaneHeaderRow({
311
311
  aria-label={`Remove ${label} automation`}
312
312
  title={`Remove ${label} automation`}
313
313
  // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
314
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
314
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
315
315
  onPointerDown={(event) => event.stopPropagation()}
316
316
  onClick={(event) => {
317
317
  // A control in the label column owns its click; it does not also hit