@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,5 +1,6 @@
1
1
  import type { Project } from '../../types'
2
2
  import type { CaptionSegment } from '../../schema'
3
+ import { floorWordDurations } from '../captionWordFloor'
3
4
 
4
5
  /** Fields `makeCaptionEdit` can patch onto a single caption segment. */
5
6
  export type CaptionEditPatch = Partial<CaptionSegment>
@@ -40,11 +41,14 @@ export function makeCaptionEdit(
40
41
  const newWords = next.text.split(/\s+/).filter(Boolean)
41
42
  const segDur = next.end - next.start
42
43
  const wordDur = segDur / (newWords.length || 1)
43
- next.words = newWords.map((w, wi) => ({
44
+ const spreadWords = newWords.map((w, wi) => ({
44
45
  word: w,
45
46
  start: next.start + wi * wordDur,
46
47
  end: next.start + (wi + 1) * wordDur,
47
48
  }))
49
+ // Uniform spread has no minimum of its own — floor it so a short
50
+ // word never falls below a frame at any fps. See captionWordFloor.ts.
51
+ next.words = floorWordDurations(spreadWords, next.end)
48
52
  return next
49
53
  }),
50
54
  },
@@ -1,14 +1,19 @@
1
- // Multi-select project mutations shared by VisualTrackRow and AudioTrackRow.
1
+ // Multi-select project mutations shared by the canvas pointer machine
2
+ // (canvas/pointer-machine.ts) and Timeline.tsx.
2
3
  //
3
4
  // Selection IDs are unified — a single `selectedIds: string[]` covers visual
4
- // items (project.tracks[*][*]) and audio tracks (project.audio.tracks[*]).
5
+ // items (project.tracks[*][*]), audio tracks (project.audio.tracks[*]) and
6
+ // caption segments (project.captions.segments[*]).
5
7
  // Cross-type ops (e.g. resize a video clip + an audio track in one drag) work
6
- // because every id is globally unique within a Project.
8
+ // because every id is globally unique within a Project. Captions join only the
9
+ // MOVE op: mute, delete and resize are still clip/audio vocabulary.
7
10
 
8
- import type { VisualItem, AudioTrack } from '../../schema'
11
+ import type { VisualItem, AudioTrack, CaptionSegment, Captions } from '../../schema'
9
12
  import type { Project } from '../../types'
13
+ import { laneOf } from '../captionLanes'
14
+ import { mapTrackItems, normalizeTrackOrder, trackItems } from './timeline-model'
15
+ import { computeResizedItem, resizeWindowedItem, type Draggable } from './useItemDragDrop'
10
16
 
11
- const MIN_DURATION = 0.1
12
17
 
13
18
  export interface ResizeDeltas {
14
19
  /** Delta in seconds applied to start-edge resizes. 0 if edge !== 'start'. */
@@ -31,8 +36,8 @@ export function applyResizeDeltaToSelection(
31
36
  const targets = new Set(selectedIds.filter(id => id !== originatorId))
32
37
  if (targets.size === 0) return project
33
38
 
34
- const nextTracks = (project.tracks ?? []).map(track =>
35
- track.map(item => targets.has(item.id) ? resizeVisualItem(item, edge, deltas) : item)
39
+ const nextTracks = mapTrackItems(project, items =>
40
+ items.map(item => targets.has(item.id) ? resizeVisualItem(item, edge, deltas) : item)
36
41
  )
37
42
  const nextAudio = (project.audio?.tracks ?? []).map(t =>
38
43
  targets.has(t.id) ? resizeAudioTrack(t, edge, deltas) : t
@@ -45,54 +50,200 @@ export function applyResizeDeltaToSelection(
45
50
  }
46
51
  }
47
52
 
53
+ /** Delegates to `computeResizedItem` so the span/window invariant is enforced
54
+ * in ONE place. This used to hand-roll the same arithmetic with the same
55
+ * independent clamps on the edge and the window, and therefore the same bug:
56
+ * propagating a trim to the rest of a multi-selection could stretch a clip's
57
+ * timeline span past the source it actually has. The only thing added here is
58
+ * the timeline floor at t=0, applied to the REQUESTED time so the shared
59
+ * function still does all the clamping that matters. */
48
60
  function resizeVisualItem(item: VisualItem, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): VisualItem {
49
- if (edge === 'start') {
50
- const newStart = Math.max(0, Math.min(item.start + dStart, item.end - MIN_DURATION))
51
- if (item.type !== 'video') return { ...item, start: newStart }
52
- const inP = item.inPoint ?? 0
53
- const outP = item.outPoint ?? (inP + (item.end - item.start))
54
- const dActual = newStart - item.start
55
- return {
56
- ...item,
57
- start: newStart,
58
- inPoint: Math.max(0, Math.min(inP + dActual, outP - MIN_DURATION)),
59
- }
60
- } else {
61
- const newEnd = Math.max(item.start + MIN_DURATION, item.end + dEnd)
62
- if (item.type !== 'video') return { ...item, end: newEnd }
63
- const inP = item.inPoint ?? 0
64
- const outP = item.outPoint ?? (inP + (item.end - item.start))
65
- const dActual = newEnd - item.end
66
- return {
67
- ...item,
68
- end: newEnd,
69
- outPoint: Math.max(inP + MIN_DURATION, Math.min(outP + dActual, item.sourceDuration ?? Infinity)),
70
- }
71
- }
61
+ const requested = edge === 'start'
62
+ ? Math.max(0, item.start + dStart)
63
+ : item.end + dEnd
64
+ return computeResizedItem(item as Draggable, edge, requested) as VisualItem
72
65
  }
73
66
 
67
+ /** As `resizeVisualItem`, but an audio track is always source-windowed — there
68
+ * is no "no window" case to fall through to, so it goes straight to
69
+ * `resizeWindowedItem` rather than through the video/non-video branch. */
74
70
  function resizeAudioTrack(track: AudioTrack, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): AudioTrack {
75
- const inP = track.inPoint ?? 0
76
- const outP = track.outPoint ?? (inP + (track.end - track.start))
77
- const srcDur = track.sourceDuration ?? Infinity
78
-
79
- if (edge === 'start') {
80
- const newStart = Math.max(0, Math.min(track.start + dStart, track.end - MIN_DURATION))
81
- const dActual = newStart - track.start
82
- return {
83
- ...track,
84
- start: newStart,
85
- inPoint: Math.max(0, Math.min(inP + dActual, outP - MIN_DURATION)),
86
- }
87
- } else {
88
- const newEnd = Math.max(track.start + MIN_DURATION, track.end + dEnd)
89
- const dActual = newEnd - track.end
90
- return {
91
- ...track,
92
- end: newEnd,
93
- outPoint: Math.max(inP + MIN_DURATION, Math.min(outP + dActual, srcDur)),
71
+ const requested = edge === 'start'
72
+ ? Math.max(0, track.start + dStart)
73
+ : track.end + dEnd
74
+ return resizeWindowedItem(track as unknown as Draggable, edge, requested) as unknown as AudioTrack
75
+ }
76
+
77
+ /**
78
+ * How far the SELECTED captions, as a group, may travel before any one of them
79
+ * would cross a NON-selected caption's edge IN ITS OWN LANE.
80
+ *
81
+ * Captions must never overlap WITHIN A LANE. `activeCaptionSegments`
82
+ * (timeline-core) hands the preview and every render template EVERY segment
83
+ * live at `t`, ordered by lane ascending — a lane is a z-order and carries no
84
+ * vertical offset of its own — so two segments overlapping in one lane paint
85
+ * at the same place, one straight over the other, in preview AND in export.
86
+ * With transcripts back-to-back by default that is not an edge case. Segments
87
+ * on DIFFERENT lanes coinciding is the feature caption rows exist for (each
88
+ * carries its own `offsetX`/`offsetY`), so a segment in lane 1 is not a bound
89
+ * on a segment in lane 0 and must not stop it moving.
90
+ *
91
+ * For each selected segment this finds its nearest NON-selected same-lane
92
+ * neighbour on either side (greatest `end <= seg.start` on the left, smallest
93
+ * `start >= seg.end` on the right) and how much room that neighbour leaves.
94
+ * The group bound is the TIGHTEST of those per-segment rooms — same shape as
95
+ * the extent scan below: one number covers the whole body. Selected ids are
96
+ * excluded from the neighbour search on BOTH sides (a selected segment is
97
+ * never treated as another selected segment's neighbour, itself included);
98
+ * that is what lets a run of adjacent selected captions move rigidly, stopping
99
+ * only at the first caption not coming along for the ride, rather than jamming
100
+ * against each other one gap in. Absent neighbour reads as unbounded
101
+ * (`Infinity`) on that side.
102
+ *
103
+ * Lanes are read through `laneOf`, never off `seg.lane` directly, so an absent
104
+ * or hand-edited value collapses the same way it does everywhere else.
105
+ */
106
+ function captionNeighbourBounds(
107
+ captions: Captions | undefined,
108
+ targets: ReadonlySet<string>,
109
+ ): { maxLeft: number; maxRight: number } {
110
+ const segments = captions?.segments ?? []
111
+ let maxLeft = Infinity
112
+ let maxRight = Infinity
113
+ for (const seg of segments) {
114
+ if (seg.id === undefined || !targets.has(seg.id)) continue
115
+ const lane = laneOf(seg)
116
+ let leftEnd = -Infinity
117
+ let rightStart = Infinity
118
+ for (const other of segments) {
119
+ // Skips every selected segment, this one included — see the WHY above.
120
+ if (other.id !== undefined && targets.has(other.id)) continue
121
+ // …and every segment on another row: overlap across lanes is legal.
122
+ if (laneOf(other) !== lane) continue
123
+ if (other.end <= seg.start && other.end > leftEnd) leftEnd = other.end
124
+ if (other.start >= seg.end && other.start < rightStart) rightStart = other.start
94
125
  }
126
+ if (leftEnd > -Infinity) maxLeft = Math.min(maxLeft, seg.start - leftEnd)
127
+ if (rightStart < Infinity) maxRight = Math.min(maxRight, rightStart - seg.end)
95
128
  }
129
+ return { maxLeft, maxRight }
130
+ }
131
+
132
+ /**
133
+ * Shift every selected visual item, audio track and caption segment by the same
134
+ * timeline delta, preserving their relative positions — a rigid move of the
135
+ * whole selection.
136
+ *
137
+ * The delta is clamped ONCE, against the group as a body: the leftmost selected
138
+ * start cannot cross t=0 and the rightmost selected end cannot cross
139
+ * `totalDuration`, so the selection keeps its shape at the edges instead of
140
+ * bunching up when one member hits a wall. Cross-track (vertical) movement is
141
+ * deliberately not part of a group move — every item stays on its own
142
+ * track/lane, which is the predictable behaviour when several move at once.
143
+ *
144
+ * The SAME clamp also folds in `captionNeighbourBounds`: a selected caption may
145
+ * not cross a non-selected one SHARING ITS LANE, so a group that includes
146
+ * captions is clamped by whichever is tighter, the timeline edge or a
147
+ * same-lane caption neighbour. Vertical movement is not part of a group move
148
+ * for captions either — every segment keeps the lane it started in, so a
149
+ * multi-selection drag never changes rows however far the pointer travels up
150
+ * or down (`applyCaptionMove` in canvas/pointer-machine.ts owns the one gesture
151
+ * that does).
152
+ *
153
+ * The caller rebuilds from the press-time project on every move, so dragging
154
+ * back and forth cannot compound.
155
+ */
156
+ export function applyMoveDeltaToSelection(
157
+ project: Project,
158
+ selectedIds: readonly string[],
159
+ requestedDelta: number,
160
+ totalDuration: number,
161
+ ): Project {
162
+ const targets = new Set(selectedIds)
163
+ if (targets.size === 0) return project
164
+
165
+ // The group's extent, so one clamp keeps every member on the timeline.
166
+ let minStart = Infinity
167
+ let maxEnd = -Infinity
168
+ // `id` is optional only because `CaptionSegment.id` is (a segment briefly
169
+ // lacks one before `backfillCaptionIds` mints it). An id-less member can
170
+ // never be in `selectedIds`, so it simply never counts toward the extent.
171
+ const consider = (it: { id?: string; start: number; end: number }) => {
172
+ if (it.id === undefined || !targets.has(it.id)) return
173
+ if (it.start < minStart) minStart = it.start
174
+ if (it.end > maxEnd) maxEnd = it.end
175
+ }
176
+ for (const items of trackItems(project)) items.forEach(consider)
177
+ for (const t of project.audio?.tracks ?? []) consider(t)
178
+ // Captions join the SAME extent scan, so one clamp covers a mixed
179
+ // clip+caption selection rather than letting the captions slide past t=0
180
+ // while the clips hold.
181
+ for (const seg of project.captions?.segments ?? []) consider(seg)
182
+ // None of the selected ids are actually present (e.g. a stale selection).
183
+ if (!Number.isFinite(minStart)) return project
184
+
185
+ // The caption-neighbour bound folds into the SAME lo/hi the timeline edges
186
+ // use below — one range, not two clamps in sequence (a second clamp would
187
+ // undo the first, same reasoning `applyCaptionTrim` documents for its own
188
+ // lo/hi). `maxLeft`/`maxRight` are `Infinity` when nothing selected is a
189
+ // caption (or no neighbour exists), so `Math.max`/`Math.min` below are then
190
+ // no-ops and the plain timeline-edge bound is all that's left.
191
+ const { maxLeft, maxRight } = captionNeighbourBounds(project.captions, targets)
192
+
193
+ const lo = Math.max(-minStart, -maxLeft)
194
+ // Floored at 0: never push a member further out than it already is.
195
+ // `totalDuration` is derived from clips and audio only (timeline-model.ts),
196
+ // so a caption can legally outlast it (e.g. clips trimmed/deleted after
197
+ // captioning) — for clips/audio `maxEnd` never exceeds `totalDuration`, but
198
+ // an overhanging caption drives `maxEnd` past it, which without the floor
199
+ // sends `hi` negative and drags the whole group backwards on a rightward
200
+ // drag (or makes it un-movable when mixed with a clip at lo=0). Mirrors the
201
+ // `Math.max(ctx.totalDuration, seg.end)` ceiling `applyCaptionTrim` already
202
+ // uses for the same overhang case.
203
+ const hi = Math.min(Math.max(0, totalDuration - maxEnd), maxRight)
204
+ // A group already wider than the timeline (lo > hi) can't move without
205
+ // pushing a member off an edge, so it doesn't. Same short-circuit now also
206
+ // catches a group that can't move without crossing a caption neighbour.
207
+ const delta = lo > hi ? 0 : Math.max(lo, Math.min(hi, requestedDelta))
208
+ if (delta === 0) return project
209
+
210
+ const shift = <T extends { id: string; start: number; end: number }>(it: T): T =>
211
+ targets.has(it.id) ? { ...it, start: it.start + delta, end: it.end + delta } : it
212
+
213
+ const captions = shiftCaptions(project.captions, targets, delta)
214
+ return {
215
+ ...project,
216
+ tracks: mapTrackItems(project, items => items.map(shift)),
217
+ audio: project.audio
218
+ ? { ...project.audio, tracks: (project.audio.tracks ?? []).map(shift) }
219
+ : project.audio,
220
+ // Same reference back when no caption moved, so a clips-only group move
221
+ // leaves `project.captions` byte-for-byte what it was — and stays
222
+ // `undefined` on a project that has none.
223
+ captions,
224
+ }
225
+ }
226
+
227
+ /** The caption half of a group move. Word timings are ABSOLUTE seconds, so
228
+ * every word travels with its segment by the same delta; a move preserves
229
+ * duration, so nothing needs respreading. `text` is never touched, and
230
+ * neither is `lane` — it rides the spread untouched, which is what makes a
231
+ * group move horizontal-only for captions. */
232
+ function shiftCaptions(
233
+ captions: Captions | undefined,
234
+ targets: ReadonlySet<string>,
235
+ delta: number,
236
+ ): Captions | undefined {
237
+ if (!captions) return captions
238
+ let changed = false
239
+ const segments = captions.segments.map(seg => {
240
+ if (seg.id === undefined || !targets.has(seg.id)) return seg
241
+ changed = true
242
+ const next: CaptionSegment = { ...seg, start: seg.start + delta, end: seg.end + delta }
243
+ if (seg.words) next.words = seg.words.map(w => ({ ...w, start: w.start + delta, end: w.end + delta }))
244
+ return next
245
+ })
246
+ return changed ? { ...captions, segments } : captions
96
247
  }
97
248
 
98
249
  /** Set `muted` on every selected item to the given target value. Both visual
@@ -108,8 +259,8 @@ export function applyMuteToSelection(
108
259
 
109
260
  return {
110
261
  ...project,
111
- tracks: (project.tracks ?? []).map(track =>
112
- track.map(item => targets.has(item.id) ? { ...item, muted } : item)
262
+ tracks: mapTrackItems(project, items =>
263
+ items.map(item => targets.has(item.id) ? { ...item, muted } : item)
113
264
  ),
114
265
  audio: project.audio
115
266
  ? {
@@ -127,11 +278,13 @@ export function deleteSelection(project: Project, selectedIds: readonly string[]
127
278
  const targets = new Set(selectedIds)
128
279
  if (targets.size === 0) return project
129
280
 
130
- return {
281
+ const next: Project = {
131
282
  ...project,
132
- tracks: (project.tracks ?? [])
133
- .map(track => track.filter(item => !targets.has(item.id)))
134
- .filter(track => track.length > 0),
283
+ // Prune, not a plain map: an emptied track collapses. The surviving TRACK
284
+ // OBJECTS pass through the filter, so each keeps its own id and settings
285
+ // rather than inheriting the ones a shifted index used to point at.
286
+ tracks: mapTrackItems(project, items => items.filter(item => !targets.has(item.id)))
287
+ .filter(track => track.items.length > 0),
135
288
  audio: project.audio
136
289
  ? {
137
290
  ...project.audio,
@@ -139,6 +292,12 @@ export function deleteSelection(project: Project, selectedIds: readonly string[]
139
292
  }
140
293
  : project.audio,
141
294
  }
295
+ // Re-group (Part B): a prune can change a track's coarse kind — a mixed
296
+ // track that loses its only video item becomes overlay-kind — so the
297
+ // canonical video-block/overlay-block invariant (`normalizeTrackOrder`) is
298
+ // re-asserted here rather than assumed to survive every prune. A no-op
299
+ // (same reference) when it already does.
300
+ return normalizeTrackOrder(next)
142
301
  }
143
302
 
144
303
  /** Selection helper: toggle additive (shift-click) vs replace (plain click).
@@ -0,0 +1,282 @@
1
+ // The ONE placement rule shared by every entry point that drops brand-new
2
+ // footage onto the timeline — today the footage-bin drag in
3
+ // `canvas/TimelineCanvas.tsx`, and a filesystem drop landing in a later task.
4
+ // Both hand the pointer's drop time and (optionally) which row it released
5
+ // over to `placeDroppedClip` below and get back a project with the clip
6
+ // placed "where you dropped it, without stomping existing footage" — the
7
+ // exact rule lives in exactly one place instead of being reimplemented, and
8
+ // possibly drifting, at each drop site.
9
+ //
10
+ // Pure and DOM-free on purpose: no canvas, no DragEvent, no React — so the
11
+ // rule itself is unit-testable without simulating a real drag, and so a
12
+ // future THIRD drop entry point (there will be one) has nothing to
13
+ // reimplement either.
14
+
15
+ import type { Project } from '../../types'
16
+ import type { VisualTrack } from '../../schema'
17
+ import { insertClipAt, overlapsAny, type NewClipInput } from '../cuts'
18
+ import { normalizeTracks, nextVisualTrackId } from './timeline-model'
19
+
20
+ /** What a drop needs to tell `placeDroppedClip` about ITSELF — the pointer's
21
+ * drop time, which row (if any) it released over, and the magnet/snap state
22
+ * the caller already knows. Everything geometry-shaped (pixel→time, pixel→
23
+ * row) is the caller's job; this module only ever sees seconds and track
24
+ * indices. */
25
+ export interface DroppedClipPlacement {
26
+ /** Timeline time (seconds) the pointer released at. Clamped to >= 0. */
27
+ atTime: number
28
+ /** Index of the track the pointer released over, in the NORMALIZED track
29
+ * order (`normalizeTracks(project).tracks`). Pass -1 for "no preference"
30
+ * (e.g. released over the ruler, a caption band, or an audio lane). */
31
+ preferredTrackIndex: number
32
+ /** The footage to place. Its `sourceDuration` is the placed clip's length. */
33
+ clip: NewClipInput
34
+ /** Ripple/magnet mode — the editor's `rippleMode`. Default false. */
35
+ ripple?: boolean
36
+ /** Candidate snap targets in seconds (clip edges, the playhead), supplied by
37
+ * the caller because this module knows nothing about the viewport. */
38
+ snapTimes?: readonly number[]
39
+ /** Max distance (seconds) at which `atTime` is pulled to a `snapTimes`
40
+ * entry. Omitted / <= 0 disables snapping. */
41
+ snapToleranceSec?: number
42
+ }
43
+
44
+ /** What `placeDroppedClip` did. */
45
+ export interface PlacedClipResult<P extends Project> {
46
+ /** The updated project. The SAME reference as the input when nothing was
47
+ * placed (see `trackIndex`). */
48
+ project: P
49
+ /** Index of the track the clip landed on, in the RETURNED project's
50
+ * normalized order. `-1` means nothing was placed. */
51
+ trackIndex: number
52
+ /** The placed clip's start time on the timeline. `0` when nothing was placed. */
53
+ start: number
54
+ /** The placed item's id. Absent when nothing was placed. */
55
+ itemId?: string
56
+ /** True when a NEW video track had to be created to hold the clip. */
57
+ createdTrack: boolean
58
+ }
59
+
60
+ /** The no-op result every early return below hands back — same project
61
+ * reference, nothing placed. */
62
+ function nothingPlaced<P extends Project>(project: P): PlacedClipResult<P> {
63
+ return { project, trackIndex: -1, start: 0, createdTrack: false }
64
+ }
65
+
66
+ /**
67
+ * Snap `atTime` to the nearest `snapTimes` entry within `snapToleranceSec`,
68
+ * ties going to the SMALLER time (deterministic — two equidistant snap
69
+ * targets must resolve the same way on every call, not by array-scan order).
70
+ * Falls through to `atTime` unchanged when snapping is disabled (no
71
+ * `snapTimes`, or a tolerance that is absent/<= 0) or nothing is in range.
72
+ *
73
+ * Exported (and typed against a `Pick` rather than the full
74
+ * `DroppedClipPlacement`) so a caller that doesn't yet know WHAT is being
75
+ * placed can still snap the drop point itself — `TimelineCanvas`'s OS-file
76
+ * drop branch has no `clip` at drop time (the file hasn't been probed or
77
+ * ingested yet), but still wants the ghost band and the eventual placement to
78
+ * land on the same magnetized second a bin drop would.
79
+ */
80
+ export function resolveDropPoint({
81
+ atTime,
82
+ snapTimes,
83
+ snapToleranceSec,
84
+ }: Pick<DroppedClipPlacement, 'atTime' | 'snapTimes' | 'snapToleranceSec'>): number {
85
+ if (!snapTimes || snapTimes.length === 0 || !(snapToleranceSec! > 0)) return atTime
86
+
87
+ let best: number | null = null
88
+ let bestDist = Infinity
89
+ for (const t of snapTimes) {
90
+ const dist = Math.abs(t - atTime)
91
+ if (dist > snapToleranceSec!) continue
92
+ if (dist < bestDist || (dist === bestDist && (best === null || t < best))) {
93
+ best = t
94
+ bestDist = dist
95
+ }
96
+ }
97
+ return best ?? atTime
98
+ }
99
+
100
+ /**
101
+ * A track at `index` is a video-placement CANDIDATE when either it already
102
+ * carries a video item (`trackGroupKind`'s own rule, timeline-model.ts), or
103
+ * it is the empty base row (`index === 0` with no items at all) —
104
+ * `computeTimelineLayout` (canvas/draw.ts) already draws that row tall,
105
+ * i.e. treats it as video-kind, regardless of content. Overlay/image tracks
106
+ * are never candidates. Captions and audio don't live in `project.tracks` at
107
+ * all, so they can never be selected here — there is nothing to exclude them
108
+ * with, they simply aren't in the array this function scans.
109
+ */
110
+ function isVideoCandidate(track: VisualTrack, index: number): boolean {
111
+ return track.items.some(it => it.type === 'video') || (index === 0 && track.items.length === 0)
112
+ }
113
+
114
+ /**
115
+ * Pick the candidate index minimizing `(|i - ref|, i)` — closest to `ref`
116
+ * first, lower index breaking a tie. `candidates` must be non-empty.
117
+ *
118
+ * Index distance stands in for on-screen distance: rows differ in rendered
119
+ * height (120px for the base video row, 40px for the rest), but the video
120
+ * block is contiguous from index 0 after normalization (`normalizeTracks`),
121
+ * so ordering by index distance orders the candidates the same way the eye
122
+ * would order them by pixel distance.
123
+ */
124
+ function closestByIndex(candidates: readonly number[], ref: number): number {
125
+ return candidates.reduce((best, i) =>
126
+ Math.abs(i - ref) < Math.abs(best - ref) || (Math.abs(i - ref) === Math.abs(best - ref) && i < best)
127
+ ? i
128
+ : best,
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Insert `clip` onto the track `trackId` already names via `insertClipAt`,
134
+ * then read back WHERE it actually landed — `insertClipAt` mints the placed
135
+ * item's id itself and doesn't hand it back, so the placed item is found by
136
+ * diffing the track's item ids before/after the call.
137
+ *
138
+ * Always re-normalizes the result before reading it back (`normalizeTracks`
139
+ * is a no-op, same-reference return when order is already canonical — see
140
+ * its own doc — so this costs nothing on the common path). That matters for
141
+ * exactly one case here: dropping onto the empty base row (index 0) flips it
142
+ * from overlay-kind to video-kind the moment it gains a video item, which can
143
+ * move its position in the canonical video-block/overlay-block stack
144
+ * (`orderedTrackArray`) — so the track's index has to be re-resolved by id,
145
+ * never assumed stable across the insert.
146
+ */
147
+ function placeOnTrack<P extends Project>(
148
+ project: P,
149
+ trackId: string,
150
+ clip: NewClipInput,
151
+ dropAt: number,
152
+ ripple: boolean,
153
+ createdTrack: boolean,
154
+ ): PlacedClipResult<P> {
155
+ const before = new Set(
156
+ (normalizeTracks(project).tracks ?? []).find(t => t.id === trackId)?.items.map(it => it.id) ?? [],
157
+ )
158
+ const normalized = normalizeTracks(insertClipAt(project, trackId, clip, dropAt, { ripple }))
159
+ const nextTracks = normalized.tracks ?? []
160
+ const trackIndex = nextTracks.findIndex(t => t.id === trackId)
161
+ const placedItem = trackIndex >= 0 ? nextTracks[trackIndex].items.find(it => !before.has(it.id)) : undefined
162
+
163
+ return {
164
+ project: normalized,
165
+ trackIndex,
166
+ start: placedItem?.start ?? 0,
167
+ itemId: placedItem?.id,
168
+ createdTrack,
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Step 5 of the algorithm: no existing video track has room, so mint a fresh
174
+ * one and place the clip there. The new track is appended empty (overlay-kind
175
+ * by `trackGroupKind`, since it holds no items), then `placeOnTrack`'s own
176
+ * re-normalize after the insert is what promotes it into the video block —
177
+ * at the END of that block, i.e. the TOP-most video row, because
178
+ * `orderedTrackArray` is a STABLE partition: the new track was the very last
179
+ * element before the re-sort, so among video-kind tracks (which it now is)
180
+ * it is still the last one encountered, and "last in the video group" is the
181
+ * highest index in a contiguous video-first stack.
182
+ */
183
+ function placeOnNewTrack<P extends Project>(
184
+ project: P,
185
+ tracks: readonly VisualTrack[],
186
+ clip: NewClipInput,
187
+ dropAt: number,
188
+ ripple: boolean,
189
+ ): PlacedClipResult<P> {
190
+ const newId = nextVisualTrackId(tracks)
191
+ const withNewTrack: P = { ...project, tracks: [...tracks, { id: newId, items: [] }] } as P
192
+ return placeOnTrack(withNewTrack, newId, clip, dropAt, ripple, true)
193
+ }
194
+
195
+ /**
196
+ * Place a single newly-dropped clip on the timeline, following the shared
197
+ * drop rule (see the module doc comment above for who calls this).
198
+ *
199
+ * Pure: never mutates `project`, its tracks/items, or `placement.clip`.
200
+ *
201
+ * ALGORITHM
202
+ * 0. Guard — `clip.sourceDuration` must be a finite number > 0, or nothing is
203
+ * placed (mirrors the guard the canvas drop handler and the host's
204
+ * `FootagePanel.hasPlaceableDuration` already apply before a drag even
205
+ * starts).
206
+ * 1. Resolve the drop point: snap `atTime` to the nearest in-tolerance
207
+ * `snapTimes` entry (see `resolveDropPoint`), then clamp to `>= 0`.
208
+ * 2. Gather video-placement candidate tracks (see `isVideoCandidate`).
209
+ * 3. `ref` — the preferred index if given, else 0 — is the point every
210
+ * "closest" measurement below is relative to.
211
+ * 4a. Ripple ON: the preferred track if it's a candidate, else the closest
212
+ * candidate (ties → lower index), else mint a new track. Collision never
213
+ * disqualifies a candidate here — ripple means "push what's in the way",
214
+ * so there is nothing to collide with.
215
+ * 4b. Ripple OFF: the closest candidate (ties → lower index) whose
216
+ * `[dropAt, dropAt + sourceDuration]` window is free of existing items
217
+ * (`overlapsAny`, cuts.ts — same EPSILON tolerance every other placement
218
+ * op in this codebase uses). No free candidate → mint a new track.
219
+ * 5. New track: see `placeOnNewTrack`.
220
+ */
221
+ /**
222
+ * Where a dropped clip WOULD land, WITHOUT placing it — the resolved
223
+ * video-track index in normalized order, or `normalizeTracks(project).tracks.length`
224
+ * (a row that does not exist yet) when a NEW video track would be created.
225
+ *
226
+ * This is the EXACT selection `placeDroppedClip` makes (it calls this), so a
227
+ * caller can draw a pre-ingest ghost band on the row the real clip will land on
228
+ * instead of wherever the pointer happened to be — a filesystem drop that
229
+ * released over an overlay/image row must still ghost on a VIDEO row, because
230
+ * that is where the clip resolves to. `closestByIndex` here, and the ghost
231
+ * renderer's own "unknown index falls back to the base video row" (see
232
+ * `pendingDropBands`), together guarantee the ghost never sits on an overlay
233
+ * row. `clip.sourceDuration` matters only in the ripple-OFF free-gap test —
234
+ * pass the ghost's fast local probe.
235
+ */
236
+ export function resolveDropTrackIndex<P extends Project>(
237
+ project: P,
238
+ placement: Pick<DroppedClipPlacement, 'atTime' | 'preferredTrackIndex' | 'ripple' | 'snapTimes' | 'snapToleranceSec'>
239
+ & { clip: Pick<NewClipInput, 'sourceDuration'> },
240
+ ): number {
241
+ const { clip, ripple = false, preferredTrackIndex } = placement
242
+ const dropAt = Math.max(0, resolveDropPoint(placement))
243
+ const tracks = normalizeTracks(project).tracks ?? []
244
+ const candidateIdxs: number[] = []
245
+ tracks.forEach((t, i) => { if (isVideoCandidate(t, i)) candidateIdxs.push(i) })
246
+ const ref = preferredTrackIndex >= 0 ? preferredTrackIndex : 0
247
+
248
+ if (ripple) {
249
+ // Ripple ON — collision never disqualifies a candidate.
250
+ if (candidateIdxs.includes(preferredTrackIndex)) return preferredTrackIndex
251
+ if (candidateIdxs.length > 0) return closestByIndex(candidateIdxs, ref)
252
+ return tracks.length // no video row exists ⇒ a fresh one would be created
253
+ }
254
+
255
+ // Ripple OFF — only a candidate whose drop window is actually free.
256
+ const freeIdxs = candidateIdxs.filter(
257
+ i => !overlapsAny(dropAt, dropAt + clip.sourceDuration, tracks[i].items),
258
+ )
259
+ if (freeIdxs.length === 0) return tracks.length
260
+ return closestByIndex(freeIdxs, ref)
261
+ }
262
+
263
+ export function placeDroppedClip<P extends Project>(
264
+ project: P,
265
+ placement: DroppedClipPlacement,
266
+ ): PlacedClipResult<P> {
267
+ const { clip, ripple = false } = placement
268
+
269
+ // 0. Guard — an unplaceable clip places nothing, by reference.
270
+ if (!(Number.isFinite(clip.sourceDuration) && clip.sourceDuration > 0)) return nothingPlaced(project)
271
+
272
+ // 1. Drop point + target row. The row is resolved by `resolveDropTrackIndex`
273
+ // so placement and the pre-ingest ghost band can never diverge.
274
+ const dropAt = Math.max(0, resolveDropPoint(placement))
275
+ const tracks = normalizeTracks(project).tracks ?? []
276
+ const targetIdx = resolveDropTrackIndex(project, placement)
277
+
278
+ // targetIdx past the end of the (same, normalized) array ⇒ no existing video
279
+ // row fits ⇒ a new track is created.
280
+ if (targetIdx >= tracks.length) return placeOnNewTrack(project, tracks, clip, dropAt, ripple)
281
+ return placeOnTrack(project, tracks[targetIdx].id, clip, dropAt, ripple, false)
282
+ }