@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,430 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { copySelection, pasteAt, duplicateSelection, pasteAttributes } from '../clipboard-ops'
3
+ import type { EditorProject as Project, VisualItem, VisualTrack, AudioTrack } from '../../schema'
4
+
5
+ /** Object-shape visual tracks, carrying the ids `normalizeTracks` would hand
6
+ * out — mirrors `cuts.test.ts`'s `vtracks` helper. */
7
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
8
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
9
+ }
10
+
11
+ // Minimal project factory — only the fields the clipboard ops touch.
12
+ function makeProject(over: Partial<Project> = {}): Project {
13
+ return {
14
+ id: 'p1',
15
+ status: 'draft',
16
+ settings: { resolution: [1080, 1920] },
17
+ tracks: vtracks([]),
18
+ ...over,
19
+ }
20
+ }
21
+
22
+ describe('copySelection', () => {
23
+ it('returns null for an empty selection', () => {
24
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
25
+ expect(copySelection(p, [])).toBeNull()
26
+ })
27
+
28
+ it('returns null when selectedIds names only caption ids', () => {
29
+ const p = makeProject({
30
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
31
+ captions: { style: 'subtitle', segments: [{ id: 'c1', text: 'hi', start: 0, end: 1 }] },
32
+ })
33
+ expect(copySelection(p, ['c1'])).toBeNull()
34
+ })
35
+
36
+ it('captures visual items with their source track id, deep-cloned', () => {
37
+ const item: VisualItem = { id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5 }
38
+ const p = makeProject({ tracks: vtracks([item]) })
39
+ const payload = copySelection(p, ['a'])
40
+ expect(payload).not.toBeNull()
41
+ expect(payload!.items).toEqual([{ item, sourceTrackId: 'trk-0' }])
42
+ expect(payload!.items[0].item).not.toBe(item) // deep clone, not the same reference
43
+ })
44
+
45
+ it('captures audio tracks, deep-cloned', () => {
46
+ const track: AudioTrack = { id: 'aud1', src: 'x.mp3', start: 0, end: 5 }
47
+ const p = makeProject({ audio: { tracks: [track] } })
48
+ const payload = copySelection(p, ['aud1'])
49
+ expect(payload!.audioTracks).toEqual([track])
50
+ expect(payload!.audioTracks[0]).not.toBe(track)
51
+ })
52
+
53
+ it('drops caption ids from a mixed selection, keeping only the visual item', () => {
54
+ const p = makeProject({
55
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
56
+ captions: { style: 'subtitle', segments: [{ id: 'c1', text: 'hi', start: 0, end: 1 }] },
57
+ })
58
+ const payload = copySelection(p, ['a', 'c1'])!
59
+ expect(payload.items).toHaveLength(1)
60
+ expect(payload.items[0].item.id).toBe('a')
61
+ expect(payload.audioTracks).toHaveLength(0)
62
+ })
63
+ })
64
+
65
+ describe('pasteAt', () => {
66
+ it('returns the same project reference for a null payload', () => {
67
+ const p = makeProject()
68
+ expect(pasteAt(p, null, 5)).toBe(p)
69
+ })
70
+
71
+ it('returns the same project reference for an empty payload', () => {
72
+ const p = makeProject()
73
+ expect(pasteAt(p, { items: [], audioTracks: [] }, 5)).toBe(p)
74
+ })
75
+
76
+ it('pastes a single item verbatim at the anchor with a fresh id', () => {
77
+ const p = makeProject({
78
+ tracks: vtracks([
79
+ { id: 'a', type: 'video', start: 0, end: 5, inPoint: 2, outPoint: 7, volume: 0.5 },
80
+ ]),
81
+ })
82
+ const payload = copySelection(p, ['a'])!
83
+ const out = pasteAt(p, payload, 20)
84
+ const items = out.tracks![0].items
85
+ expect(items).toHaveLength(2)
86
+ const pasted = items.find(i => i.id !== 'a')!
87
+ expect(pasted.id).not.toBe('a')
88
+ expect(pasted).toMatchObject({ type: 'video', start: 20, end: 25, inPoint: 2, outPoint: 7, volume: 0.5 })
89
+ })
90
+
91
+ it('preserves relative offsets across multiple pasted items, earliest at the anchor', () => {
92
+ const p = makeProject({
93
+ tracks: vtracks([
94
+ { id: 'a', type: 'video', start: 0, end: 5 },
95
+ { id: 'b', type: 'video', start: 10, end: 12 },
96
+ ]),
97
+ })
98
+ const payload = copySelection(p, ['a', 'b'])!
99
+ const out = pasteAt(p, payload, 100)
100
+ const items = out.tracks![0].items
101
+ expect(items.some(i => i.id !== 'a' && i.id !== 'b' && i.start === 100 && i.end === 105)).toBe(true)
102
+ expect(items.some(i => i.id !== 'a' && i.id !== 'b' && i.start === 110 && i.end === 112)).toBe(true)
103
+ })
104
+
105
+ it('lands back on the source track when it still exists', () => {
106
+ const p = makeProject({
107
+ tracks: vtracks(
108
+ [{ id: 'a', type: 'video', start: 0, end: 5 }],
109
+ [{ id: 'b', type: 'overlay', start: 0, end: 5 }],
110
+ ),
111
+ })
112
+ const payload = copySelection(p, ['b'])!
113
+ const out = pasteAt(p, payload, 20)
114
+ expect(out.tracks![0].items).toHaveLength(1) // track 0 untouched
115
+ expect(out.tracks![1].items).toHaveLength(2) // pasted back onto trk-1
116
+ })
117
+
118
+ it('falls back to track 0 when the source track no longer exists', () => {
119
+ const item: VisualItem = { id: 'a', type: 'video', start: 0, end: 5 }
120
+ const source = makeProject({ tracks: vtracks([], [item]) })
121
+ const payload = copySelection(source, ['a'])!
122
+ expect(payload.items[0].sourceTrackId).toBe('trk-1')
123
+
124
+ // Paste into a DIFFERENT project that has no trk-1 at all.
125
+ const target = makeProject({ tracks: vtracks([{ id: 'x', type: 'video', start: 0, end: 3 }]) })
126
+ const out = pasteAt(target, payload, 50)
127
+ expect(out.tracks).toHaveLength(1)
128
+ const ids = out.tracks![0].items.map(i => i.id)
129
+ expect(ids).toContain('x')
130
+ expect(out.tracks![0].items).toHaveLength(2)
131
+ })
132
+
133
+ it('shifts right to the earliest gap that fits when the target span collides', () => {
134
+ const p = makeProject({
135
+ tracks: vtracks([
136
+ { id: 'a', type: 'video', start: 0, end: 5 },
137
+ { id: 'blocker', type: 'video', start: 10, end: 13 },
138
+ ]),
139
+ })
140
+ const payload = copySelection(p, ['a'])! // duration 5
141
+ const out = pasteAt(p, payload, 10) // collides with blocker (10-13)
142
+ const pasted = out.tracks![0].items.find(i => i.id !== 'a' && i.id !== 'blocker')!
143
+ expect(pasted.start).toBe(13) // earliest fitting gap: blocker's end
144
+ expect(pasted.end).toBe(18)
145
+ })
146
+
147
+ it('does not materialize an empty `audio` object when pasting visual-only items into an audio-less project', () => {
148
+ const p = makeProject({
149
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
150
+ // No `audio` key at all.
151
+ })
152
+ expect(p.audio).toBeUndefined()
153
+ const payload = copySelection(p, ['a'])!
154
+ const out = pasteAt(p, payload, 20)
155
+ expect(out.audio).toBeUndefined()
156
+ })
157
+
158
+ it('still attaches `audio` when the paste itself adds audio tracks, even starting from an audio-less project', () => {
159
+ const source = makeProject({ audio: { tracks: [{ id: 'aud1', src: 'x.mp3', start: 0, end: 4 }] } })
160
+ const payload = copySelection(source, ['aud1'])!
161
+ const target = makeProject() // no `audio` key
162
+ const out = pasteAt(target, payload, 10)
163
+ expect(out.audio!.tracks).toHaveLength(1)
164
+ expect(out.audio!.tracks[0]).toMatchObject({ src: 'x.mp3', start: 10, end: 14 })
165
+ })
166
+
167
+ it('preserves an existing (possibly empty-after-paste) `audio` object rather than dropping it', () => {
168
+ const p = makeProject({
169
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
170
+ audio: { tracks: [] },
171
+ })
172
+ const payload = copySelection(p, ['a'])!
173
+ const out = pasteAt(p, payload, 20)
174
+ expect(out.audio).toEqual({ tracks: [] })
175
+ })
176
+
177
+ it('clones an audio track verbatim with a fresh id, shifted to the anchor', () => {
178
+ const track: AudioTrack = { id: 'aud1', src: 'x.mp3', start: 0, end: 4, volume: 0.8, muted: true }
179
+ const p = makeProject({ audio: { tracks: [track] } })
180
+ const payload = copySelection(p, ['aud1'])!
181
+ const out = pasteAt(p, payload, 30)
182
+ const pasted = out.audio!.tracks.find(t => t.id !== 'aud1')!
183
+ expect(pasted).toMatchObject({ src: 'x.mp3', start: 30, end: 34, volume: 0.8, muted: true })
184
+ expect(pasted.id).not.toBe('aud1')
185
+ })
186
+
187
+ it('gap-resolves a pasted audio track against its OWN lane only, not every audio track flattened', () => {
188
+ // Music bed (lane 0) runs the whole timeline; voiceover (lane 1) is much
189
+ // shorter and overlaps it freely, as parallel lanes are meant to. Pasting
190
+ // a copy of the voiceover back at t=0 collides with the ORIGINAL voiceover
191
+ // (same lane, still on the timeline) and must resolve to its end (30) —
192
+ // NOT past the bed's end (60), which the old flattened check wrongly used.
193
+ const bed: AudioTrack = { id: 'bed', src: 'bed.mp3', start: 0, end: 60, lane: 0 }
194
+ const vo: AudioTrack = { id: 'vo', src: 'vo.mp3', start: 0, end: 30, lane: 1 }
195
+ const p = makeProject({ audio: { tracks: [bed, vo] } })
196
+ const payload = copySelection(p, ['vo'])!
197
+ const out = pasteAt(p, payload, 0)
198
+ const pasted = out.audio!.tracks.find(t => t.id !== 'bed' && t.id !== 'vo')!
199
+ expect(pasted.start).toBe(30) // shifted past the original voiceover, lane 1's only occupant
200
+ expect(pasted.end).toBe(60)
201
+ expect(pasted.lane).toBe(1)
202
+ })
203
+
204
+ it('preserves the original relative offset between a visual item and an audio track pasted together, earliest landing exactly on the anchor', () => {
205
+ const p = makeProject({
206
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
207
+ audio: { tracks: [{ id: 'aud1', src: 'x.mp3', start: 20, end: 23 }] }, // 20s gap from the visual item
208
+ })
209
+ const payload = copySelection(p, ['a', 'aud1'])!
210
+ const out = pasteAt(p, payload, 100)
211
+
212
+ const pastedVisual = out.tracks![0].items.find(i => i.id !== 'a')!
213
+ const pastedAudio = out.audio!.tracks.find(t => t.id !== 'aud1')!
214
+
215
+ expect(pastedVisual.start).toBe(100) // earliest member lands exactly on the anchor
216
+ expect(pastedAudio.start).toBe(120) // later member keeps its original 20s offset from the anchor
217
+ expect(pastedAudio.end - pastedAudio.start).toBe(3) // duration preserved
218
+ })
219
+ })
220
+
221
+ describe('duplicateSelection', () => {
222
+ it('returns the same project reference for an empty selection', () => {
223
+ const p = makeProject()
224
+ expect(duplicateSelection(p, [])).toBe(p)
225
+ })
226
+
227
+ it('returns the same project reference when selectedIds names only captions', () => {
228
+ const p = makeProject({
229
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
230
+ captions: { style: 'subtitle', segments: [{ id: 'c1', text: 'hi', start: 0, end: 1 }] },
231
+ })
232
+ expect(duplicateSelection(p, ['c1'])).toBe(p)
233
+ })
234
+
235
+ it('duplicates a visual item verbatim at its own end', () => {
236
+ const p = makeProject({
237
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5 }]),
238
+ })
239
+ const out = duplicateSelection(p, ['a'])
240
+ const items = out.tracks![0].items
241
+ expect(items).toHaveLength(2)
242
+ const dup = items.find(i => i.id !== 'a')!
243
+ expect(dup).toMatchObject({ type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5 })
244
+ expect(dup.id).not.toBe('a')
245
+ })
246
+
247
+ it('gap-resolves a duplicate that collides with a following clip', () => {
248
+ const p = makeProject({
249
+ tracks: vtracks([
250
+ { id: 'a', type: 'video', start: 0, end: 5 },
251
+ { id: 'b', type: 'video', start: 5, end: 10 },
252
+ ]),
253
+ })
254
+ const out = duplicateSelection(p, ['a'])
255
+ const dup = out.tracks![0].items.find(i => i.id !== 'a' && i.id !== 'b')!
256
+ expect(dup.start).toBe(10) // shifted past 'b'
257
+ expect(dup.end).toBe(15)
258
+ })
259
+
260
+ it('duplicates an audio track verbatim at its own end', () => {
261
+ const track: AudioTrack = { id: 'aud1', src: 'x.mp3', start: 0, end: 4, volume: 0.6 }
262
+ const p = makeProject({ audio: { tracks: [track] } })
263
+ const out = duplicateSelection(p, ['aud1'])
264
+ const dup = out.audio!.tracks.find(t => t.id !== 'aud1')!
265
+ expect(dup).toMatchObject({ src: 'x.mp3', start: 4, end: 8, volume: 0.6 })
266
+ })
267
+
268
+ it('gap-resolves a duplicated audio track against its OWN lane only, not every audio track flattened', () => {
269
+ // Music bed [0,60] in lane 0 + voiceover [0,30] in lane 1. Duplicating the
270
+ // voiceover must land the copy at [30,60] in lane 1 (right after the
271
+ // original, overlapping the bed is fine) — NOT shoved to [60,90] by
272
+ // wrongly treating the longer bed as a same-lane collision.
273
+ const bed: AudioTrack = { id: 'bed', src: 'bed.mp3', start: 0, end: 60, lane: 0 }
274
+ const vo: AudioTrack = { id: 'vo', src: 'vo.mp3', start: 0, end: 30, lane: 1 }
275
+ const p = makeProject({ audio: { tracks: [bed, vo] } })
276
+ const out = duplicateSelection(p, ['vo'])
277
+ const dup = out.audio!.tracks.find(t => t.id !== 'bed' && t.id !== 'vo')!
278
+ expect(dup.start).toBe(30)
279
+ expect(dup.end).toBe(60)
280
+ expect(dup.lane).toBe(1)
281
+ })
282
+
283
+ it('mints fresh, unique ids for every duplicated item, colliding with nothing', () => {
284
+ const p = makeProject({
285
+ tracks: vtracks([
286
+ { id: 'a', type: 'video', start: 0, end: 2 },
287
+ { id: 'b', type: 'video', start: 2, end: 4 },
288
+ { id: 'c', type: 'video', start: 4, end: 6 },
289
+ ]),
290
+ })
291
+ const out = duplicateSelection(p, ['a', 'b', 'c'])
292
+ const ids = out.tracks![0].items.map(i => i.id)
293
+ expect(ids).toHaveLength(6)
294
+ expect(new Set(ids).size).toBe(6)
295
+ })
296
+ })
297
+
298
+ describe('pasteAttributes', () => {
299
+ it('returns the same project reference for an empty selection', () => {
300
+ const p = makeProject()
301
+ expect(pasteAttributes(p, { items: [], audioTracks: [] }, [])).toBe(p)
302
+ })
303
+
304
+ it('returns the same project reference for a null payload', () => {
305
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
306
+ expect(pasteAttributes(p, null, ['a'])).toBe(p)
307
+ })
308
+
309
+ it('returns the same project reference when selectedIds names only captions', () => {
310
+ const source: VisualItem = { id: 's', type: 'video', start: 0, end: 5, offsetX: 10 }
311
+ const p = makeProject({
312
+ tracks: vtracks([source]),
313
+ captions: { style: 'subtitle', segments: [{ id: 'c1', text: 'hi', start: 0, end: 1 }] },
314
+ })
315
+ const payload = copySelection(p, ['s'])!
316
+ expect(pasteAttributes(p, payload, ['c1'])).toBe(p)
317
+ })
318
+
319
+ it('returns the same project reference when the gated patch is empty for every target', () => {
320
+ // source carries only video-only fields; target is an overlay, which qualifies for none of them.
321
+ const source: VisualItem = { id: 's', type: 'video', start: 0, end: 5, volume: 0.5, speed: 2, muted: true }
322
+ const target: VisualItem = { id: 't', type: 'overlay', start: 10, end: 15 }
323
+ const p = makeProject({ tracks: vtracks([source, target]) })
324
+ const payload = copySelection(p, ['s'])!
325
+ expect(pasteAttributes(p, payload, ['t'])).toBe(p)
326
+ })
327
+
328
+ it('transfers common look fields to every visual item type', () => {
329
+ const source: VisualItem = {
330
+ id: 's', type: 'video', start: 0, end: 5,
331
+ offsetX: 10, offsetY: 20, scale: 1.5, opacity: 0.9, rotation: 45,
332
+ }
333
+ const target: VisualItem = { id: 't', type: 'overlay', start: 10, end: 15 }
334
+ const p = makeProject({ tracks: vtracks([source, target]) })
335
+ const payload = copySelection(p, ['s'])!
336
+ const out = pasteAttributes(p, payload, ['t'])
337
+ const patched = out.tracks![0].items.find(i => i.id === 't')!
338
+ expect(patched).toMatchObject({ offsetX: 10, offsetY: 20, scale: 1.5, opacity: 0.9, rotation: 45 })
339
+ })
340
+
341
+ it('never lands image-only `fit` on a video target', () => {
342
+ const source: VisualItem = { id: 's', type: 'image', start: 0, end: 5, fit: 'contain' }
343
+ const target: VisualItem = { id: 't', type: 'video', start: 10, end: 15 }
344
+ const p = makeProject({ tracks: vtracks([source, target]) })
345
+ const payload = copySelection(p, ['s'])!
346
+ const out = pasteAttributes(p, payload, ['t'])
347
+ expect(out.tracks![0].items.find(i => i.id === 't')!.fit).toBeUndefined()
348
+ })
349
+
350
+ it('lands `fit` only on an image target', () => {
351
+ const source: VisualItem = { id: 's', type: 'image', start: 0, end: 5, fit: 'contain' }
352
+ const target: VisualItem = { id: 't', type: 'image', start: 10, end: 15 }
353
+ const p = makeProject({ tracks: vtracks([source, target]) })
354
+ const payload = copySelection(p, ['s'])!
355
+ const out = pasteAttributes(p, payload, ['t'])
356
+ expect(out.tracks![0].items.find(i => i.id === 't')!.fit).toBe('contain')
357
+ })
358
+
359
+ it('never lands video-only `speed` on an image or overlay target', () => {
360
+ const source: VisualItem = { id: 's', type: 'video', start: 0, end: 5, speed: 2 }
361
+ const image: VisualItem = { id: 'img', type: 'image', start: 10, end: 15 }
362
+ const overlay: VisualItem = { id: 'ov', type: 'overlay', start: 20, end: 25 }
363
+ const p = makeProject({ tracks: vtracks([source, image, overlay]) })
364
+ const payload = copySelection(p, ['s'])!
365
+ const out = pasteAttributes(p, payload, ['img', 'ov'])
366
+ const items = out.tracks![0].items
367
+ expect(items.find(i => i.id === 'img')!.speed).toBeUndefined()
368
+ expect(items.find(i => i.id === 'ov')!.speed).toBeUndefined()
369
+ })
370
+
371
+ it('lands volume/muted/speed on a video target', () => {
372
+ const source: VisualItem = { id: 's', type: 'video', start: 0, end: 5, volume: 0.4, muted: true, speed: 1.5 }
373
+ const target: VisualItem = { id: 't', type: 'video', start: 10, end: 15 }
374
+ const p = makeProject({ tracks: vtracks([source, target]) })
375
+ const payload = copySelection(p, ['s'])!
376
+ const out = pasteAttributes(p, payload, ['t'])
377
+ expect(out.tracks![0].items.find(i => i.id === 't')).toMatchObject({ volume: 0.4, muted: true, speed: 1.5 })
378
+ })
379
+
380
+ it('never transfers sourceCrop', () => {
381
+ const source: VisualItem = {
382
+ id: 's', type: 'video', start: 0, end: 5, sourceCrop: { x: 0.1, y: 0.1, w: 0.5, h: 0.5 },
383
+ }
384
+ const target: VisualItem = { id: 't', type: 'video', start: 10, end: 15 }
385
+ const p = makeProject({ tracks: vtracks([source, target]) })
386
+ const payload = copySelection(p, ['s'])!
387
+ const out = pasteAttributes(p, payload, ['t'])
388
+ expect(out.tracks![0].items.find(i => i.id === 't')!.sourceCrop).toBeUndefined()
389
+ })
390
+
391
+ it('transfers audio look fields from the first copied audio track to selected audio targets', () => {
392
+ const source: AudioTrack = {
393
+ id: 's', src: 'a.mp3', start: 0, end: 5, volume: 0.3, muted: true, fadeIn: 1, fadeOut: 2,
394
+ }
395
+ const target: AudioTrack = { id: 't', src: 'b.mp3', start: 10, end: 15 }
396
+ const p = makeProject({ audio: { tracks: [source, target] } })
397
+ const payload = copySelection(p, ['s'])!
398
+ const out = pasteAttributes(p, payload, ['t'])
399
+ expect(out.audio!.tracks.find(t => t.id === 't')).toMatchObject({
400
+ volume: 0.3, muted: true, fadeIn: 1, fadeOut: 2,
401
+ })
402
+ })
403
+ })
404
+
405
+ describe('verbatim clone (type + inPoint/outPoint survive copy/paste and duplicate)', () => {
406
+ it('never coerces type to video, and preserves inPoint/outPoint on a trimmed clip, through both pasteAt and duplicateSelection', () => {
407
+ // The reason `pasteAt`/`duplicateSelection` don't route through `insertClipAt`
408
+ // (cuts.ts) is that it unconditionally mints a whole-source `type:'video'`
409
+ // item, which would coerce an image/overlay's type and un-trim a video clip.
410
+ // This pins the negative: none of that happens here.
411
+ const image: VisualItem = { id: 'img', type: 'image', start: 0, end: 3, offsetX: 5 }
412
+ const overlay: VisualItem = { id: 'ov', type: 'overlay', start: 3, end: 6, props: { text: 'hi' } }
413
+ const trimmedVideo: VisualItem = { id: 'v', type: 'video', start: 6, end: 9, inPoint: 2, outPoint: 5 }
414
+ const p = makeProject({ tracks: vtracks([image, overlay, trimmedVideo]) })
415
+ const originalIds = new Set(['img', 'ov', 'v'])
416
+
417
+ const payload = copySelection(p, ['img', 'ov', 'v'])!
418
+ const pasted = pasteAt(p, payload, 100)
419
+ const pastedItems = pasted.tracks![0].items.filter(i => !originalIds.has(i.id))
420
+ expect(pastedItems.find(i => i.type === 'image')).toMatchObject({ type: 'image', offsetX: 5 })
421
+ expect(pastedItems.find(i => i.type === 'overlay')).toMatchObject({ type: 'overlay', props: { text: 'hi' } })
422
+ expect(pastedItems.find(i => i.type === 'video')).toMatchObject({ type: 'video', inPoint: 2, outPoint: 5 })
423
+
424
+ const duplicated = duplicateSelection(p, ['img', 'ov', 'v'])
425
+ const dupItems = duplicated.tracks![0].items.filter(i => !originalIds.has(i.id))
426
+ expect(dupItems.find(i => i.type === 'image')).toMatchObject({ type: 'image', offsetX: 5 })
427
+ expect(dupItems.find(i => i.type === 'overlay')).toMatchObject({ type: 'overlay', props: { text: 'hi' } })
428
+ expect(dupItems.find(i => i.type === 'video')).toMatchObject({ type: 'video', inPoint: 2, outPoint: 5 })
429
+ })
430
+ })
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { insertClipAt, newClipId } from '../cuts'
3
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
4
+
5
+ /** Object-shape visual tracks carrying the ids `normalizeTracks` would hand out
6
+ * — mirrors the fixture helper in `cuts.test.ts`. */
7
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
8
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
9
+ }
10
+
11
+ // Minimal project factory — only the fields the insert helper touches.
12
+ function makeProject(over: Partial<Project> = {}): Project {
13
+ return {
14
+ id: 'p1',
15
+ status: 'draft',
16
+ settings: { resolution: [1080, 1920] },
17
+ tracks: vtracks([]),
18
+ ...over,
19
+ }
20
+ }
21
+
22
+ const EPS = 1e-6
23
+
24
+ /** No two items on a track overlap by more than float slop. */
25
+ function assertNoOverlaps(items: VisualItem[]): void {
26
+ const sorted = [...items].sort((a, b) => a.start - b.start)
27
+ for (let i = 1; i < sorted.length; i++) {
28
+ expect(sorted[i].start).toBeGreaterThanOrEqual(sorted[i - 1].end - EPS)
29
+ }
30
+ }
31
+
32
+ /** Every id present except the known source ids — the freshly inserted clip. */
33
+ function inserted(items: VisualItem[], sourceIds: string[]): VisualItem {
34
+ const found = items.find(i => !sourceIds.includes(i.id))
35
+ expect(found).toBeDefined()
36
+ return found!
37
+ }
38
+
39
+ describe('insertClipAt — ripple', () => {
40
+ it('shifts later clips right by exactly the new length and drops at the drop time', () => {
41
+ const p = makeProject({
42
+ tracks: vtracks([
43
+ { id: 'a', type: 'video', start: 0, end: 5 },
44
+ { id: 'b', type: 'video', start: 5, end: 10 },
45
+ { id: 'c', type: 'video', start: 10, end: 15 },
46
+ ]),
47
+ })
48
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 3 }, 5, { ripple: true })
49
+ const items = out.tracks![0].items
50
+
51
+ expect(items).toHaveLength(4)
52
+ expect(items.find(i => i.id === 'a')).toMatchObject({ start: 0, end: 5 }) // before the drop — unchanged
53
+ expect(items.find(i => i.id === 'b')).toMatchObject({ start: 8, end: 13 }) // shifted right by 3
54
+ expect(items.find(i => i.id === 'c')).toMatchObject({ start: 13, end: 18 }) // shifted right by 3
55
+
56
+ const clip = inserted(items, ['a', 'b', 'c'])
57
+ expect(clip).toMatchObject({ start: 5, end: 8 })
58
+
59
+ assertNoOverlaps(items)
60
+ // returned items are sorted by start
61
+ const starts = items.map(i => i.start)
62
+ expect(starts).toEqual([...starts].sort((x, y) => x - y))
63
+ })
64
+
65
+ it('snaps to the nearest edge of a straddling clip instead of dropping inside it', () => {
66
+ const p = makeProject({
67
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]),
68
+ })
69
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 3 }, 5, { ripple: true })
70
+ const items = out.tracks![0].items
71
+
72
+ expect(items).toHaveLength(2)
73
+ assertNoOverlaps(items)
74
+
75
+ const clip = inserted(items, ['a'])
76
+ // the new clip must sit at a boundary of the straddled clip, never inside it
77
+ expect([0, 10]).toContain(clip.start)
78
+ })
79
+
80
+ it('snaps a straddled drop to the START at/before the midpoint (including exactly 50%), and to the END past it', () => {
81
+ // Sam's product call (2026-08-25): never split a clip. A drop in the
82
+ // first half of a clip — including dead center — means "put the new
83
+ // clip before this one"; only a drop strictly past the midpoint pushes
84
+ // the new clip after it. See insertClipAt's doc comment.
85
+ const straddler = (): VisualItem[] => [{ id: 'a', type: 'video', start: 0, end: 10 }]
86
+
87
+ const before = insertClipAt(
88
+ makeProject({ tracks: vtracks(straddler()) }), 'trk-0', { src: 'n.mp4', sourceDuration: 2 }, 4, { ripple: true },
89
+ )
90
+ expect(inserted(before.tracks![0].items, ['a']).start).toBe(0)
91
+
92
+ const tie = insertClipAt(
93
+ makeProject({ tracks: vtracks(straddler()) }), 'trk-0', { src: 'n.mp4', sourceDuration: 2 }, 5, { ripple: true },
94
+ )
95
+ expect(inserted(tie.tracks![0].items, ['a']).start).toBe(0)
96
+
97
+ const after = insertClipAt(
98
+ makeProject({ tracks: vtracks(straddler()) }), 'trk-0', { src: 'n.mp4', sourceDuration: 2 }, 6, { ripple: true },
99
+ )
100
+ expect(inserted(after.tracks![0].items, ['a']).start).toBe(10)
101
+ })
102
+
103
+ it('snaps to the nearest edge when the drop point straddles the second of two clips', () => {
104
+ const p = makeProject({
105
+ tracks: vtracks([
106
+ { id: 'a', type: 'video', start: 0, end: 10 },
107
+ { id: 'b', type: 'video', start: 10, end: 20 },
108
+ ]),
109
+ })
110
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 4 }, 15, { ripple: true })
111
+ const items = out.tracks![0].items
112
+
113
+ expect(items).toHaveLength(3)
114
+ assertNoOverlaps(items)
115
+
116
+ // order preserved: 'a' still precedes 'b' on the timeline
117
+ const a = items.find(i => i.id === 'a')!
118
+ const b = items.find(i => i.id === 'b')!
119
+ expect(a.start).toBeLessThan(b.start)
120
+
121
+ const clip = inserted(items, ['a', 'b'])
122
+ // the new clip must sit at a boundary of the straddled clip 'b', never inside it
123
+ expect([10, 20]).toContain(clip.start)
124
+ })
125
+ })
126
+
127
+ describe('insertClipAt — non-ripple', () => {
128
+ it('places at the drop time inside a free gap and leaves others untouched', () => {
129
+ const p = makeProject({
130
+ tracks: vtracks([
131
+ { id: 'a', type: 'video', start: 0, end: 5 },
132
+ { id: 'b', type: 'video', start: 10, end: 15 },
133
+ ]),
134
+ })
135
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 2 }, 6, { ripple: false })
136
+ const items = out.tracks![0].items
137
+
138
+ expect(items.find(i => i.id === 'a')).toMatchObject({ start: 0, end: 5 })
139
+ expect(items.find(i => i.id === 'b')).toMatchObject({ start: 10, end: 15 })
140
+ expect(inserted(items, ['a', 'b'])).toMatchObject({ start: 6, end: 8 })
141
+ assertNoOverlaps(items)
142
+ })
143
+
144
+ it('snaps into the nearest fitting gap when the drop window overlaps a clip', () => {
145
+ const p = makeProject({
146
+ tracks: vtracks([
147
+ { id: 'a', type: 'video', start: 0, end: 5 },
148
+ { id: 'b', type: 'video', start: 6, end: 20 },
149
+ ]),
150
+ })
151
+ // Drop at 3 overlaps 'a'; the [5,6] gap fits a length-1 clip exactly.
152
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 1 }, 3, { ripple: false })
153
+ const items = out.tracks![0].items
154
+
155
+ expect(items.find(i => i.id === 'a')).toMatchObject({ start: 0, end: 5 })
156
+ expect(items.find(i => i.id === 'b')).toMatchObject({ start: 6, end: 20 })
157
+ expect(inserted(items, ['a', 'b'])).toMatchObject({ start: 5, end: 6 })
158
+ assertNoOverlaps(items)
159
+ })
160
+
161
+ it('appends after the last item when no gap at/after the drop fits', () => {
162
+ const p = makeProject({
163
+ tracks: vtracks([
164
+ { id: 'a', type: 'video', start: 0, end: 5 },
165
+ { id: 'b', type: 'video', start: 5, end: 10 },
166
+ ]),
167
+ })
168
+ // Drop at 3, length 4: overlaps both, [5,10] gap too small → appended at 10.
169
+ const out = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 4 }, 3, { ripple: false })
170
+ const items = out.tracks![0].items
171
+
172
+ expect(items.find(i => i.id === 'a')).toMatchObject({ start: 0, end: 5 })
173
+ expect(items.find(i => i.id === 'b')).toMatchObject({ start: 5, end: 10 })
174
+ expect(inserted(items, ['a', 'b'])).toMatchObject({ start: 10, end: 14 })
175
+ assertNoOverlaps(items)
176
+ })
177
+ })
178
+
179
+ describe('insertClipAt — the built item', () => {
180
+ it('is a whole-source video item with inPoint 0, outPoint sourceDuration, and a distinct id', () => {
181
+ const p = makeProject({ tracks: vtracks([{ id: 'clip-0', type: 'video', start: 0, end: 4 }]) })
182
+ const out = insertClipAt(
183
+ p,
184
+ 'trk-0',
185
+ { src: 'n.mp4', proxySrc: 'n_proxy.mp4', sourceDuration: 7, sourceWidth: 1920, sourceHeight: 1080 },
186
+ 100, // beyond all content → placed at the drop point
187
+ { ripple: false },
188
+ )
189
+ const clip = inserted(out.tracks![0].items, ['clip-0'])
190
+
191
+ expect(clip.type).toBe('video')
192
+ expect(clip.src).toBe('n.mp4')
193
+ expect(clip.proxySrc).toBe('n_proxy.mp4')
194
+ expect(clip.inPoint).toBe(0)
195
+ expect(clip.outPoint).toBe(7)
196
+ expect(clip.end - clip.start).toBe(7)
197
+ expect(clip.sourceDuration).toBe(7)
198
+ expect(clip.sourceWidth).toBe(1920)
199
+ expect(clip.sourceHeight).toBe(1080)
200
+ expect(clip.id).not.toBe('clip-0')
201
+ })
202
+
203
+ it('omits proxySrc / sourceWidth / sourceHeight when not provided', () => {
204
+ const p = makeProject({ tracks: vtracks([]) })
205
+ const clip = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 2 }, 0, { ripple: false })
206
+ .tracks![0].items[0]
207
+
208
+ expect('proxySrc' in clip).toBe(false)
209
+ expect('sourceWidth' in clip).toBe(false)
210
+ expect('sourceHeight' in clip).toBe(false)
211
+ })
212
+ })
213
+
214
+ describe('insertClipAt — misc', () => {
215
+ it('clamps a negative drop time to 0', () => {
216
+ const p = makeProject({ tracks: vtracks([]) })
217
+ const clip = insertClipAt(p, 'trk-0', { src: 'n.mp4', sourceDuration: 3 }, -5, { ripple: false })
218
+ .tracks![0].items[0]
219
+ expect(clip).toMatchObject({ start: 0, end: 3 })
220
+ })
221
+
222
+ it('returns the project unchanged when the track id is unknown', () => {
223
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
224
+ expect(insertClipAt(p, 'nope', { src: 'n.mp4', sourceDuration: 2 }, 1, { ripple: true })).toBe(p)
225
+ })
226
+
227
+ it('does not mutate the input project, its items, or the clip descriptor', () => {
228
+ const clipInput = { src: 'n.mp4', sourceDuration: 2 }
229
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
230
+ const before = JSON.stringify(p)
231
+ insertClipAt(p, 'trk-0', clipInput, 1, { ripple: true })
232
+ expect(JSON.stringify(p)).toBe(before)
233
+ expect(clipInput).toEqual({ src: 'n.mp4', sourceDuration: 2 })
234
+ })
235
+ })
236
+
237
+ describe('newClipId', () => {
238
+ it('returns distinct ids that never look like a clip-<n> source id', () => {
239
+ const ids = new Set<string>()
240
+ for (let i = 0; i < 500; i++) ids.add(newClipId())
241
+ expect(ids.size).toBe(500)
242
+ for (const id of ids) expect(id).not.toMatch(/^clip-\d+$/)
243
+ })
244
+ })