@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
@@ -4,8 +4,22 @@ import {
4
4
  applyCutToItem,
5
5
  collapseGaps,
6
6
  splitAtTime,
7
+ rippleDelete,
8
+ rollEdit,
9
+ slipItem,
10
+ slideItem,
11
+ setClipSpeed,
7
12
  } from '../cuts'
8
- import type { EditorProject as Project } from '../../schema'
13
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
14
+ import { audioTrackSourceWindow } from '../timeline/canvas/waveforms'
15
+
16
+ /** Object-shape visual tracks, carrying the ids `normalizeTracks` would hand
17
+ * out — so a fixture reads `vtracks([clipA, clipB], [overlay])`. The cut ops
18
+ * address tracks by INDEX, which the object shape leaves untouched, so every
19
+ * expectation below is the one the legacy `[[...]]` fixture asserted. */
20
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
21
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
22
+ }
9
23
 
10
24
  // Minimal project factory — only the fields the cut engine touches.
11
25
  function makeProject(over: Partial<Project> = {}): Project {
@@ -13,45 +27,45 @@ function makeProject(over: Partial<Project> = {}): Project {
13
27
  id: 'p1',
14
28
  status: 'draft',
15
29
  settings: { resolution: [1080, 1920] },
16
- tracks: [[]],
30
+ tracks: vtracks([]),
17
31
  ...over,
18
32
  }
19
33
  }
20
34
 
21
35
  describe('applyCutToTracks (lift)', () => {
22
36
  it('returns the project unchanged for a zero/negative-length cut', () => {
23
- const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]] })
37
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]) })
24
38
  expect(applyCutToTracks(p, { start: 5, end: 5 })).toBe(p)
25
39
  expect(applyCutToTracks(p, { start: 6, end: 3 })).toBe(p)
26
40
  })
27
41
 
28
42
  it('deletes a clip fully inside the cut and leaves later clips in place (no shift)', () => {
29
43
  const p = makeProject({
30
- tracks: [[
44
+ tracks: vtracks([
31
45
  { id: 'a', type: 'video', start: 0, end: 5 },
32
46
  { id: 'b', type: 'video', start: 5, end: 10 },
33
- ]],
47
+ ]),
34
48
  })
35
49
  const out = applyCutToTracks(p, { start: 5, end: 10 })
36
- const primary = out.tracks![0]
50
+ const primary = out.tracks![0].items
37
51
  expect(primary.map(c => c.id)).toEqual(['a'])
38
52
  expect(primary[0]).toMatchObject({ start: 0, end: 5 })
39
53
  })
40
54
 
41
55
  it('trims a clip overlapping the left edge of the cut', () => {
42
56
  const p = makeProject({
43
- tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
57
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
44
58
  })
45
59
  const out = applyCutToTracks(p, { start: 6, end: 12 })
46
- expect(out.tracks![0][0]).toMatchObject({ id: 'a', end: 6, outPoint: 6 })
60
+ expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', end: 6, outPoint: 6 })
47
61
  })
48
62
 
49
63
  it('splits a clip the cut spans into two fragments (lift positions)', () => {
50
64
  const p = makeProject({
51
- tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
65
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
52
66
  })
53
67
  const out = applyCutToTracks(p, { start: 3, end: 7 })
54
- const primary = out.tracks![0]
68
+ const primary = out.tracks![0].items
55
69
  expect(primary).toHaveLength(2)
56
70
  expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 3 })
57
71
  expect(primary[1].start).toBe(7) // right fragment stays at original timeline pos
@@ -60,7 +74,7 @@ describe('applyCutToTracks (lift)', () => {
60
74
 
61
75
  it('shifts captions after the cut by the cut duration', () => {
62
76
  const p = makeProject({
63
- tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]],
77
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]),
64
78
  captions: {
65
79
  style: 'subtitle',
66
80
  segments: [
@@ -78,65 +92,137 @@ describe('applyCutToTracks (lift)', () => {
78
92
  it('leaves overlay tracks (tracks[1+]) untouched', () => {
79
93
  const overlay = { id: 'o', type: 'overlay' as const, start: 4, end: 6 }
80
94
  const p = makeProject({
81
- tracks: [
95
+ tracks: vtracks(
82
96
  [{ id: 'a', type: 'video', start: 0, end: 10 }],
83
97
  [overlay],
84
- ],
98
+ ),
85
99
  })
86
100
  const out = applyCutToTracks(p, { start: 3, end: 7 })
87
- expect(out.tracks![1][0]).toEqual(overlay)
101
+ expect(out.tracks![1].items[0]).toEqual(overlay)
88
102
  })
89
103
  })
90
104
 
91
105
  describe('collapseGaps', () => {
92
106
  it('returns the same reference when there are fewer than 2 clips', () => {
93
- const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 5, end: 10 }]] })
107
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 5, end: 10 }]) })
94
108
  expect(collapseGaps(p)).toBe(p)
95
109
  })
96
110
 
97
111
  it('returns the same reference when there are no gaps', () => {
98
112
  const p = makeProject({
99
- tracks: [[
113
+ tracks: vtracks([
100
114
  { id: 'a', type: 'video', start: 0, end: 5 },
101
115
  { id: 'b', type: 'video', start: 5, end: 10 },
102
- ]],
116
+ ]),
103
117
  })
104
118
  expect(collapseGaps(p)).toBe(p)
105
119
  })
106
120
 
107
121
  it('shifts clips left to close gaps and remaps captions', () => {
108
122
  const p = makeProject({
109
- tracks: [[
123
+ tracks: vtracks([
110
124
  { id: 'a', type: 'video', start: 0, end: 5 },
111
125
  { id: 'b', type: 'video', start: 8, end: 12 },
112
- ]],
126
+ ]),
113
127
  captions: {
114
128
  style: 'subtitle',
115
129
  segments: [{ text: 'b-cap', start: 9, end: 11, words: [{ word: 'x', start: 9, end: 11 }] }],
116
130
  },
117
131
  })
118
132
  const out = collapseGaps(p)
119
- const primary = out.tracks![0]
133
+ const primary = out.tracks![0].items
120
134
  expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
121
135
  expect(primary[1]).toMatchObject({ id: 'b', start: 5, end: 9 }) // shifted left by 3
122
136
  const cap = out.captions!.segments[0]
123
137
  expect(cap).toMatchObject({ start: 6, end: 8 })
124
138
  expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
125
139
  })
140
+
141
+ // The caption remap is opt-OUT: every existing call site passes no options and
142
+ // must keep behaving exactly as the test above pins. Only the audio-polish
143
+ // composition, where `applyCutToTracks` has already rippled the captions,
144
+ // passes `remapCaptions: false` — see the invariant comment in cuts.ts.
145
+ describe('remapCaptions option', () => {
146
+ /** Two clips with a 3s gap and one caption sitting over the LATER clip, so a
147
+ * caption remap is unambiguously visible (it moves 3s left) or absent. */
148
+ function gapped() {
149
+ return makeProject({
150
+ tracks: vtracks([
151
+ { id: 'a', type: 'video', start: 0, end: 5 },
152
+ { id: 'b', type: 'video', start: 8, end: 12 },
153
+ ]),
154
+ captions: {
155
+ style: 'subtitle',
156
+ segments: [{ text: 'b-cap', start: 9, end: 11, words: [{ word: 'x', start: 9, end: 11 }] }],
157
+ },
158
+ })
159
+ }
160
+
161
+ it('an empty options object is the same as no options at all', () => {
162
+ const p = gapped()
163
+ expect(collapseGaps(p, {})).toEqual(collapseGaps(p))
164
+ })
165
+
166
+ it('remapCaptions: true is the default and remaps captions', () => {
167
+ const out = collapseGaps(gapped(), { remapCaptions: true })
168
+ expect(out.tracks![0].items[1]).toMatchObject({ id: 'b', start: 5, end: 9 })
169
+ const cap = out.captions!.segments[0]
170
+ expect(cap).toMatchObject({ start: 6, end: 8 })
171
+ expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
172
+ })
173
+
174
+ it('remapCaptions: false closes clip gaps but leaves captions untouched', () => {
175
+ const p = gapped()
176
+ const out = collapseGaps(p, { remapCaptions: false })
177
+ expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
178
+ expect(out.tracks![0].items[1]).toMatchObject({ id: 'b', start: 5, end: 9 })
179
+ // The whole captions object is passed through by reference — no segment,
180
+ // and no word inside a segment, is rebuilt.
181
+ expect(out.captions).toBe(p.captions)
182
+ expect(out.captions!.segments[0]).toMatchObject({ start: 9, end: 11 })
183
+ expect(out.captions!.segments[0].words![0]).toMatchObject({ start: 9, end: 11 })
184
+ })
185
+
186
+ it('remapCaptions: false still remaps OVERLAY tracks', () => {
187
+ const p = makeProject({
188
+ tracks: vtracks(
189
+ [
190
+ { id: 'a', type: 'video', start: 0, end: 5 },
191
+ { id: 'b', type: 'video', start: 8, end: 12 },
192
+ ],
193
+ [{ id: 'o', type: 'overlay', start: 9, end: 11 }],
194
+ ),
195
+ captions: { style: 'subtitle', segments: [{ text: 'b-cap', start: 9, end: 11 }] },
196
+ })
197
+ const out = collapseGaps(p, { remapCaptions: false })
198
+ expect(out.tracks![1].items[0]).toMatchObject({ id: 'o', start: 6, end: 8 })
199
+ expect(out.captions!.segments[0]).toMatchObject({ start: 9, end: 11 })
200
+ })
201
+
202
+ it('remapCaptions: false still returns the same reference when there is no gap', () => {
203
+ const p = makeProject({
204
+ tracks: vtracks([
205
+ { id: 'a', type: 'video', start: 0, end: 5 },
206
+ { id: 'b', type: 'video', start: 5, end: 10 },
207
+ ]),
208
+ })
209
+ expect(collapseGaps(p, { remapCaptions: false })).toBe(p)
210
+ })
211
+ })
126
212
  })
127
213
 
128
214
  describe('applyCutToItem (collapse, single item)', () => {
129
215
  it('returns the project unchanged when itemId is not found', () => {
130
- const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 10 }]] })
216
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]) })
131
217
  expect(applyCutToItem(p, 'nope', { start: 2, end: 4 })).toBe(p)
132
218
  })
133
219
 
134
220
  it('collapses a middle cut within a single primary clip (right fragment butts left)', () => {
135
221
  const p = makeProject({
136
- tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
222
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
137
223
  })
138
224
  const out = applyCutToItem(p, 'a', { start: 3, end: 7 })
139
- const primary = out.tracks![0]
225
+ const primary = out.tracks![0].items
140
226
  expect(primary).toHaveLength(2)
141
227
  expect(primary[0]).toMatchObject({ start: 0, end: 3, outPoint: 3 })
142
228
  // right fragment collapses: starts at cut.start (3), duration = remaining 3s
@@ -145,10 +231,10 @@ describe('applyCutToItem (collapse, single item)', () => {
145
231
 
146
232
  it('clamps the cut to the item bounds', () => {
147
233
  const p = makeProject({
148
- tracks: [[{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 0, outPoint: 6 }]],
234
+ tracks: vtracks([{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 0, outPoint: 6 }]),
149
235
  })
150
236
  const out = applyCutToItem(p, 'a', { start: 0, end: 4 })
151
- const primary = out.tracks![0]
237
+ const primary = out.tracks![0].items
152
238
  // cut clamped to [2,4]; left fragment removed (nothing before 2), right collapses to start 2
153
239
  expect(primary[0]).toMatchObject({ start: 2 })
154
240
  })
@@ -156,16 +242,16 @@ describe('applyCutToItem (collapse, single item)', () => {
156
242
 
157
243
  describe('splitAtTime', () => {
158
244
  it('returns the same reference when nothing contains the playhead', () => {
159
- const p = makeProject({ tracks: [[{ id: 'a', type: 'video', start: 0, end: 5 }]] })
245
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
160
246
  expect(splitAtTime(p, 8, null)).toBe(p)
161
247
  })
162
248
 
163
249
  it('splits every clip containing `at` when itemId is null', () => {
164
250
  const p = makeProject({
165
- tracks: [[{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
251
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
166
252
  })
167
253
  const out = splitAtTime(p, 4, null)
168
- const primary = out.tracks![0]
254
+ const primary = out.tracks![0].items
169
255
  expect(primary).toHaveLength(2)
170
256
  expect(primary[0]).toMatchObject({ start: 0, end: 4 })
171
257
  expect(primary[1]).toMatchObject({ start: 4 })
@@ -173,21 +259,21 @@ describe('splitAtTime', () => {
173
259
 
174
260
  it('splits only the named item when itemId is given', () => {
175
261
  const p = makeProject({
176
- tracks: [[
262
+ tracks: vtracks([
177
263
  { id: 'a', type: 'video', start: 0, end: 10 },
178
264
  { id: 'b', type: 'video', start: 0, end: 10 },
179
- ]],
265
+ ]),
180
266
  })
181
267
  const out = splitAtTime(p, 5, 'a')
182
268
  // 'a' split into 2 fragments (original id + split id); 'b' untouched.
183
- expect(out.tracks![0].filter(c => c.id.startsWith('a'))).toHaveLength(2)
184
- expect(out.tracks![0].filter(c => c.id === 'b')).toHaveLength(1)
185
- expect(out.tracks![0]).toHaveLength(3)
269
+ expect(out.tracks![0].items.filter(c => c.id.startsWith('a'))).toHaveLength(2)
270
+ expect(out.tracks![0].items.filter(c => c.id === 'b')).toHaveLength(1)
271
+ expect(out.tracks![0].items).toHaveLength(3)
186
272
  })
187
273
 
188
274
  it('splits audio tracks containing `at`', () => {
189
275
  const p = makeProject({
190
- tracks: [[]],
276
+ tracks: vtracks([]),
191
277
  audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10, inPoint: 0 }] },
192
278
  })
193
279
  const out = splitAtTime(p, 4, null)
@@ -195,4 +281,1097 @@ describe('splitAtTime', () => {
195
281
  expect(out.audio!.tracks[0]).toMatchObject({ end: 4, outPoint: 4 })
196
282
  expect(out.audio!.tracks[1]).toMatchObject({ start: 4, inPoint: 4 })
197
283
  })
284
+
285
+ it('both split halves resolve a positive waveform window when the source has no outPoint/sourceDuration', () => {
286
+ // The bug: the new (right) fragment rendered as a solid block with no
287
+ // waveform. Its source window came out truncated — and, for a split PAST the
288
+ // midpoint, non-positive — so the waveform painter skipped it. Split a
289
+ // 0..10 track (no outPoint, no sourceDuration) at 7, well past the midpoint.
290
+ const p = makeProject({
291
+ tracks: vtracks([]),
292
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10 }] },
293
+ })
294
+ const [left, right] = splitAtTime(p, 7, null).audio!.tracks
295
+
296
+ // Both halves carry an explicit, fully-specified source window.
297
+ expect(left).toMatchObject({ start: 0, end: 7, inPoint: 0, outPoint: 7 })
298
+ expect(right).toMatchObject({ start: 7, end: 10, inPoint: 7, outPoint: 10 })
299
+
300
+ // Both halves resolve to a positive waveform window (the right one used to be
301
+ // negative here → painter drew nothing → blank block).
302
+ const lw = audioTrackSourceWindow(left)
303
+ const rw = audioTrackSourceWindow(right)
304
+ expect(lw).toEqual({ start: 0, duration: 7 })
305
+ expect(rw).toEqual({ start: 7, duration: 3 })
306
+ expect(rw.duration).toBeGreaterThan(0)
307
+
308
+ // The two windows tile the original source range [0,10] with no gap/overlap.
309
+ expect(lw.start + lw.duration).toBeCloseTo(rw.start)
310
+ expect(lw.duration + rw.duration).toBeCloseTo(10)
311
+ })
312
+
313
+ it('carries an existing trim window across a split (both halves stay within source)', () => {
314
+ // A pre-trimmed track: source window [2,12] mapped onto timeline [0,10].
315
+ const p = makeProject({
316
+ tracks: vtracks([]),
317
+ audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10, inPoint: 2, outPoint: 12 }] },
318
+ })
319
+ const [left, right] = splitAtTime(p, 6, null).audio!.tracks
320
+ // Split at timeline 6 → source 2 + 6 = 8.
321
+ expect(left).toMatchObject({ start: 0, end: 6, inPoint: 2, outPoint: 8 })
322
+ expect(right).toMatchObject({ start: 6, end: 10, inPoint: 8, outPoint: 12 })
323
+ expect(audioTrackSourceWindow(left)).toEqual({ start: 2, duration: 6 })
324
+ expect(audioTrackSourceWindow(right)).toEqual({ start: 8, duration: 4 })
325
+ })
326
+ })
327
+
328
+ // SP9b terminator fix — splitClip and cutSingleItem (behind applyCutToItem)
329
+ // both used to spread `...item` for both fragments, so both inherited the
330
+ // SAME `keyframes` array object with unchanged item-relative `t`. The right
331
+ // fragment's `start` moves, so its curve restarted there and every point past
332
+ // its shortened span went dead. Shared by both describe blocks below —
333
+ // splitAtTime (lift: a point cut, right fragment stays at cut.end) and
334
+ // applyCutToItem (collapse: a ranged cut, right fragment moves to cut.start)
335
+ // — so the same fixture exercises both call sites of `splitKeyframeTracks`.
336
+ function keyframedOverlay(over: Partial<VisualItem> = {}): VisualItem {
337
+ return {
338
+ id: 'o0', type: 'overlay', start: 0, end: 10,
339
+ keyframes: [
340
+ { prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 3, value: 30 }, { t: 7, value: 70 }, { t: 10, value: 100 }] },
341
+ { prop: 'opacity', points: [{ t: 3, value: 1 }] },
342
+ ],
343
+ ...over,
344
+ } as VisualItem
345
+ }
346
+
347
+ describe('splitAtTime — overlay keyframes', () => {
348
+ it('gives each fragment its OWN keyframes array — never the same reference as the other fragment or the original item', () => {
349
+ const overlay = keyframedOverlay()
350
+ const p = makeProject({ tracks: vtracks([], [overlay]) })
351
+ const [left, right] = splitAtTime(p, 5, 'o0').tracks![1].items
352
+
353
+ expect(left.keyframes).toBeDefined()
354
+ expect(right.keyframes).toBeDefined()
355
+ expect(left.keyframes).not.toBe(right.keyframes)
356
+ expect(left.keyframes).not.toBe(overlay.keyframes)
357
+ expect(right.keyframes).not.toBe(overlay.keyframes)
358
+ })
359
+
360
+ it("drops points outside each fragment's new span", () => {
361
+ const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
362
+ const [left, right] = splitAtTime(p, 5, 'o0').tracks![1].items
363
+
364
+ // Left is now [0,5): keeps t=0,3 (<=5); drops t=7,10.
365
+ const leftOffsetX = left.keyframes!.find(t => t.prop === 'offsetX')!
366
+ expect(leftOffsetX.points.map(pt => pt.t)).toEqual([0, 3])
367
+
368
+ // Right is now [5,10): keeps t=7,10 (>=5), re-anchored by -5; drops t=0,3.
369
+ const rightOffsetX = right.keyframes!.find(t => t.prop === 'offsetX')!
370
+ expect(rightOffsetX.points.map(pt => pt.t)).toEqual([2, 5])
371
+ })
372
+
373
+ it("re-anchors the right fragment's remaining points so the animation lands at the same wall-clock instants as before the split", () => {
374
+ const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
375
+ const [, right] = splitAtTime(p, 5, 'o0').tracks![1].items
376
+
377
+ expect(right.start).toBe(5) // splitAtTime moves the right fragment's start to the cut
378
+ const rightOffsetX = right.keyframes!.find(t => t.prop === 'offsetX')!
379
+ // Original point at t=7 (wall-clock 0+7=7). On the new fragment it's t=2,
380
+ // and 2 + the fragment's own start (5) is still 7 — same instant, same value.
381
+ const pt = rightOffsetX.points.find(pt => pt.t === 2)!
382
+ expect(pt.value).toBe(70)
383
+ expect(right.start + pt.t).toBe(7)
384
+ })
385
+
386
+ it('a point exactly at the split lands on BOTH fragments, keeping the curve continuous across the cut', () => {
387
+ const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
388
+ const [left, right] = splitAtTime(p, 3, 'o0').tracks![1].items
389
+
390
+ const leftOpacity = left.keyframes!.find(t => t.prop === 'opacity')!
391
+ const rightOpacity = right.keyframes!.find(t => t.prop === 'opacity')!
392
+ expect(leftOpacity.points).toEqual([{ t: 3, value: 1 }])
393
+ expect(rightOpacity.points).toEqual([{ t: 0, value: 1 }]) // re-anchored: 3 - 3 = 0
394
+ })
395
+
396
+ it('a keyframe-less overlay is unaffected — no stray `keyframes` field appears on either fragment', () => {
397
+ const p = makeProject({ tracks: vtracks([], [{ id: 'o1', type: 'overlay', start: 0, end: 10 } as VisualItem]) })
398
+ const [left, right] = splitAtTime(p, 5, 'o1').tracks![1].items
399
+
400
+ expect(left.keyframes).toBeUndefined()
401
+ expect(right.keyframes).toBeUndefined()
402
+ })
403
+
404
+ it('a keyframed VIDEO clip splits and re-anchors like an overlay (SP9d)', () => {
405
+ // Was "keyframes stay overlay-only". Clips read `item.keyframes` now, so a
406
+ // shared array between fragments — or a right fragment whose points are
407
+ // still anchored to the ORIGINAL start — is a real bug, not a harmless
408
+ // stray. `canKeyframe` gates this code and widened, so it already applies.
409
+ const original = { prop: 'offsetX' as const, points: [{ t: 0, value: 0 }, { t: 8, value: 80 }] }
410
+ const p = makeProject({
411
+ tracks: vtracks([{
412
+ id: 'v0', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10,
413
+ keyframes: [original],
414
+ } as VisualItem]),
415
+ })
416
+ const [left, right] = splitAtTime(p, 5, 'v0').tracks![0].items
417
+
418
+ // Separate arrays — a shared one would let an edit to either fragment
419
+ // silently mutate the other.
420
+ expect(left.keyframes).not.toBe(right.keyframes)
421
+ // The right fragment's remaining point is re-anchored to ITS OWN start:
422
+ // t=8 on the original is t=3 once the fragment begins at 5.
423
+ const rightPoints = right.keyframes!.find(k => k.prop === 'offsetX')!.points
424
+ expect(rightPoints.some(pt => pt.t === 3)).toBe(true)
425
+ expect(rightPoints.some(pt => pt.t === 8)).toBe(false)
426
+ })
427
+ })
428
+
429
+ // `cutSingleItem` (behind applyCutToItem) had the IDENTICAL bare-`...item`
430
+ // spread `splitClip` did, so it shared one keyframes array between fragments
431
+ // and left the right fragment's points anchored to the original item's start.
432
+ // Fixing only one of two identical call sites would have been worse than
433
+ // fixing neither: cutting an overlay, rather than splitting it, would have
434
+ // silently produced a wrong animation.
435
+ describe('applyCutToItem — overlay keyframes', () => {
436
+ it('gives each fragment its own array and drops points inside the removed range', () => {
437
+ const overlay = keyframedOverlay()
438
+ const p = makeProject({ tracks: vtracks([], [overlay]) })
439
+ // Remove [4,6]. Left keeps t<=4 (0,3); right keeps t>=6 (7,10).
440
+ const items = applyCutToItem(p, 'o0', { start: 4, end: 6 }).tracks![1].items
441
+ expect(items).toHaveLength(2)
442
+ const [left, right] = items
443
+
444
+ expect(left.keyframes).not.toBe(right.keyframes)
445
+ expect(left.keyframes).not.toBe(overlay.keyframes)
446
+ expect(right.keyframes).not.toBe(overlay.keyframes)
447
+
448
+ const leftX = left.keyframes!.find(t => t.prop === 'offsetX')!
449
+ expect(leftX.points.map(pt => pt.t)).toEqual([0, 3])
450
+ // opacity's only point is t=3, which survives on the left only.
451
+ expect(left.keyframes!.some(t => t.prop === 'opacity')).toBe(true)
452
+ expect(right.keyframes!.some(t => t.prop === 'opacity')).toBe(false)
453
+ })
454
+
455
+ it('re-anchors the right fragment so its points keep their wall-clock instants', () => {
456
+ const overlay = keyframedOverlay()
457
+ const p = makeProject({ tracks: vtracks([], [overlay]) })
458
+ const [, right] = applyCutToItem(p, 'o0', { start: 4, end: 6 }).tracks![1].items
459
+
460
+ // Right lifts to start=4 and shows original content from t=6 onward, so
461
+ // the original t=7 and t=10 points re-anchor to 1 and 4. Their absolute
462
+ // instants are then 4+1=5 and 4+4=8 — the same offsets into the surviving
463
+ // content they always had, now that 2s were removed.
464
+ const rightX = right.keyframes!.find(t => t.prop === 'offsetX')!
465
+ expect(right.start).toBe(4)
466
+ expect(rightX.points.map(pt => pt.t)).toEqual([1, 4])
467
+ expect(rightX.points.map(pt => pt.value)).toEqual([70, 100])
468
+ })
469
+
470
+ it('leaves a keyframe-less overlay and a non-overlay item untouched', () => {
471
+ const plain = { id: 'o1', type: 'overlay', start: 0, end: 10 } as VisualItem
472
+ const vid = { id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 } as VisualItem
473
+ const p = makeProject({ tracks: vtracks([vid], [plain]) })
474
+ const out = applyCutToItem(p, 'o1', { start: 4, end: 6 })
475
+ for (const it of out.tracks![1].items) expect(it.keyframes).toBeUndefined()
476
+ const outVid = applyCutToItem(p, 'a', { start: 4, end: 6 })
477
+ for (const it of outVid.tracks![0].items) expect(it.keyframes).toBeUndefined()
478
+ })
479
+ })
480
+
481
+ describe('rippleDelete', () => {
482
+ it('returns the same reference when the item is not found', () => {
483
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
484
+ expect(rippleDelete(p, 'nope')).toBe(p)
485
+ })
486
+
487
+ it('returns the same reference for an audio-track id (audio lanes are not ripple targets)', () => {
488
+ const p = makeProject({
489
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
490
+ audio: { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] },
491
+ })
492
+ expect(rippleDelete(p, 'au')).toBe(p)
493
+ })
494
+
495
+ it('deletes the item and pulls later clips earlier by its duration', () => {
496
+ const p = makeProject({
497
+ tracks: vtracks([
498
+ { id: 'a', type: 'video', start: 0, end: 5 },
499
+ { id: 'b', type: 'video', start: 5, end: 9 },
500
+ { id: 'c', type: 'video', start: 9, end: 14 },
501
+ ]),
502
+ })
503
+ const out = rippleDelete(p, 'b')
504
+ expect(out.tracks![0].items.map(c => c.id)).toEqual(['a', 'c'])
505
+ expect(out.tracks![0].items[0]).toMatchObject({ start: 0, end: 5 })
506
+ expect(out.tracks![0].items[1]).toMatchObject({ start: 5, end: 10 })
507
+ })
508
+
509
+ it('shifts only content after the deletion point — earlier gaps survive (unlike collapseGaps)', () => {
510
+ const p = makeProject({
511
+ tracks: vtracks([
512
+ { id: 'a', type: 'video', start: 0, end: 3 },
513
+ { id: 'b', type: 'video', start: 6, end: 10 }, // 3s gap before b
514
+ { id: 'c', type: 'video', start: 10, end: 15 },
515
+ ]),
516
+ })
517
+ const out = rippleDelete(p, 'b')
518
+ expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 3 })
519
+ expect(out.tracks![0].items[1]).toMatchObject({ id: 'c', start: 6, end: 11 })
520
+ // collapseGaps would additionally have normalized the 3–6 gap away
521
+ expect(collapseGaps(out).tracks![0].items[1]).toMatchObject({ id: 'c', start: 3 })
522
+ })
523
+
524
+ it('deletes captions inside the removed window and shifts later ones', () => {
525
+ const p = makeProject({
526
+ tracks: vtracks([
527
+ { id: 'a', type: 'video', start: 0, end: 5 },
528
+ { id: 'b', type: 'video', start: 5, end: 9 },
529
+ { id: 'c', type: 'video', start: 9, end: 14 },
530
+ ]),
531
+ captions: {
532
+ style: 'subtitle',
533
+ segments: [
534
+ { text: 'a-cap', start: 1, end: 3 },
535
+ { text: 'b-cap', start: 6, end: 8 },
536
+ { text: 'c-cap', start: 10, end: 12, words: [{ word: 'x', start: 10, end: 12 }] },
537
+ ],
538
+ },
539
+ })
540
+ const segs = rippleDelete(p, 'b').captions!.segments
541
+ expect(segs.map(s => s.text)).toEqual(['a-cap', 'c-cap'])
542
+ expect(segs[0]).toMatchObject({ start: 1, end: 3 })
543
+ expect(segs[1]).toMatchObject({ start: 6, end: 8 })
544
+ expect(segs[1].words![0]).toMatchObject({ start: 6, end: 8 })
545
+ })
546
+
547
+ it('shifts overlay-track items after the deletion point and leaves earlier ones put', () => {
548
+ const p = makeProject({
549
+ tracks: vtracks(
550
+ [
551
+ { id: 'a', type: 'video', start: 0, end: 5 },
552
+ { id: 'b', type: 'video', start: 5, end: 9 },
553
+ { id: 'c', type: 'video', start: 9, end: 14 },
554
+ ],
555
+ [
556
+ { id: 'o1', type: 'overlay', start: 1, end: 3 },
557
+ { id: 'o2', type: 'overlay', start: 10, end: 12 },
558
+ ],
559
+ ),
560
+ })
561
+ const out = rippleDelete(p, 'b')
562
+ expect(out.tracks![1].items[0]).toMatchObject({ id: 'o1', start: 1, end: 3 })
563
+ expect(out.tracks![1].items[1]).toMatchObject({ id: 'o2', start: 6, end: 8 })
564
+ })
565
+
566
+ it('leaves items that begin inside the deleted window put — only later ones are subsequent', () => {
567
+ const p = makeProject({
568
+ tracks: vtracks(
569
+ [
570
+ { id: 'a', type: 'video', start: 0, end: 5 },
571
+ { id: 'b', type: 'video', start: 5, end: 9 },
572
+ { id: 'c', type: 'video', start: 9, end: 14 },
573
+ ],
574
+ [
575
+ { id: 'inside', type: 'overlay', start: 6, end: 8 }, // starts within [5,9)
576
+ { id: 'atStart', type: 'overlay', start: 5, end: 11 }, // starts on the deletion point
577
+ { id: 'atEnd', type: 'overlay', start: 9, end: 11 }, // starts on the deletion end
578
+ ],
579
+ ),
580
+ })
581
+ const overlays = rippleDelete(p, 'b').tracks![1].items
582
+ expect(overlays[0]).toMatchObject({ id: 'inside', start: 6, end: 8 })
583
+ expect(overlays[1]).toMatchObject({ id: 'atStart', start: 5, end: 11 })
584
+ expect(overlays[2]).toMatchObject({ id: 'atEnd', start: 5, end: 7 })
585
+ })
586
+
587
+ it('leaves audio tracks untouched (matching collapseGaps)', () => {
588
+ const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20, inPoint: 0 }] }
589
+ const p = makeProject({
590
+ tracks: vtracks([
591
+ { id: 'a', type: 'video', start: 0, end: 5 },
592
+ { id: 'b', type: 'video', start: 5, end: 9 },
593
+ ]),
594
+ audio,
595
+ })
596
+ expect(rippleDelete(p, 'b').audio).toBe(audio)
597
+ })
598
+
599
+ it('pulls the remaining clips to the front when the first item is deleted', () => {
600
+ const p = makeProject({
601
+ tracks: vtracks([
602
+ { id: 'a', type: 'video', start: 0, end: 5 },
603
+ { id: 'b', type: 'video', start: 5, end: 9 },
604
+ ]),
605
+ })
606
+ const out = rippleDelete(p, 'a')
607
+ expect(out.tracks![0].items).toHaveLength(1)
608
+ expect(out.tracks![0].items[0]).toMatchObject({ id: 'b', start: 0, end: 4 })
609
+ })
610
+
611
+ it('only removes the item when it is last — there is nothing to shift', () => {
612
+ const p = makeProject({
613
+ tracks: vtracks([
614
+ { id: 'a', type: 'video', start: 0, end: 5 },
615
+ { id: 'b', type: 'video', start: 5, end: 9 },
616
+ ]),
617
+ })
618
+ const out = rippleDelete(p, 'b')
619
+ expect(out.tracks![0].items).toHaveLength(1)
620
+ expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
621
+ })
622
+
623
+ it('moves shifted clips on the timeline only — source points are preserved', () => {
624
+ const p = makeProject({
625
+ tracks: vtracks([
626
+ { id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5 },
627
+ { id: 'b', type: 'video', start: 5, end: 9, inPoint: 2, outPoint: 6, normalizedInPoint: 2 },
628
+ ]),
629
+ })
630
+ expect(rippleDelete(p, 'a').tracks![0].items[0]).toMatchObject({
631
+ start: 0, end: 4, inPoint: 2, outPoint: 6, normalizedInPoint: 2,
632
+ })
633
+ })
634
+ })
635
+
636
+ describe('rollEdit', () => {
637
+ // left source window 10–15 of a 30s source; right window 20–25 of a 30s source.
638
+ // Both MIN_DURATION clamps bind before either source bound does.
639
+ const base = () => makeProject({
640
+ tracks: vtracks([
641
+ { id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
642
+ { id: 'r', type: 'video', start: 5, end: 10, inPoint: 20, outPoint: 25, sourceDuration: 30 },
643
+ ]),
644
+ })
645
+
646
+ it('returns the same reference when either id is missing', () => {
647
+ const p = base()
648
+ expect(rollEdit(p, 'l', 'nope', 1)).toBe(p)
649
+ expect(rollEdit(p, 'nope', 'r', 1)).toBe(p)
650
+ })
651
+
652
+ it('returns the same reference when the clips are on different tracks', () => {
653
+ const p = makeProject({
654
+ tracks: vtracks(
655
+ [{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 }],
656
+ [{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 }],
657
+ ),
658
+ })
659
+ expect(rollEdit(p, 'l', 'r', 1)).toBe(p)
660
+ })
661
+
662
+ it('returns the same reference when the clips are not adjacent', () => {
663
+ const p = makeProject({
664
+ tracks: vtracks([
665
+ { id: 'l', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
666
+ { id: 'r', type: 'video', start: 6, end: 10, inPoint: 0, outPoint: 4, sourceDuration: 30 },
667
+ ]),
668
+ })
669
+ expect(rollEdit(p, 'l', 'r', 1)).toBe(p)
670
+ })
671
+
672
+ it('returns the same reference when the ids are passed in the wrong order', () => {
673
+ const p = base()
674
+ expect(rollEdit(p, 'r', 'l', 1)).toBe(p)
675
+ })
676
+
677
+ it('returns the same reference for a zero delta', () => {
678
+ const p = base()
679
+ expect(rollEdit(p, 'l', 'r', 0)).toBe(p)
680
+ })
681
+
682
+ it('moves the shared boundary later without changing total duration', () => {
683
+ const [l, r] = rollEdit(base(), 'l', 'r', 2).tracks![0].items
684
+ expect(l).toMatchObject({ start: 0, end: 7, inPoint: 10, outPoint: 17 })
685
+ expect(r).toMatchObject({ start: 7, end: 10, inPoint: 22, outPoint: 25 })
686
+ expect(r.end - l.start).toBe(10)
687
+ })
688
+
689
+ it('moves the shared boundary earlier without changing total duration', () => {
690
+ const [l, r] = rollEdit(base(), 'l', 'r', -2).tracks![0].items
691
+ expect(l).toMatchObject({ start: 0, end: 3, inPoint: 10, outPoint: 13 })
692
+ expect(r).toMatchObject({ start: 3, end: 10, inPoint: 18, outPoint: 25 })
693
+ expect(r.end - l.start).toBe(10)
694
+ })
695
+
696
+ it("clamps at the left clip's MIN_DURATION", () => {
697
+ const [l, r] = rollEdit(base(), 'l', 'r', -20).tracks![0].items
698
+ expect(l.end - l.start).toBeCloseTo(0.1)
699
+ expect(l.outPoint).toBeCloseTo(10.1)
700
+ expect(r.start).toBeCloseTo(0.1)
701
+ expect(r.inPoint).toBeCloseTo(15.1)
702
+ })
703
+
704
+ it("clamps at the right clip's MIN_DURATION", () => {
705
+ const [l, r] = rollEdit(base(), 'l', 'r', 20).tracks![0].items
706
+ expect(r.end - r.start).toBeCloseTo(0.1)
707
+ expect(l.end).toBeCloseTo(9.9)
708
+ expect(l.outPoint).toBeCloseTo(19.9)
709
+ expect(r.inPoint).toBeCloseTo(24.9)
710
+ })
711
+
712
+ it("clamps at the end of the left clip's source media", () => {
713
+ const p = makeProject({
714
+ tracks: vtracks([
715
+ { id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 16 },
716
+ { id: 'r', type: 'video', start: 5, end: 10, inPoint: 20, outPoint: 25, sourceDuration: 30 },
717
+ ]),
718
+ })
719
+ const [l, r] = rollEdit(p, 'l', 'r', 5).tracks![0].items
720
+ expect(l).toMatchObject({ end: 6, outPoint: 16 }) // outPoint stops at sourceDuration
721
+ expect(r).toMatchObject({ start: 6, inPoint: 21 })
722
+ })
723
+
724
+ it("clamps at the start of the right clip's source media", () => {
725
+ const p = makeProject({
726
+ tracks: vtracks([
727
+ { id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
728
+ { id: 'r', type: 'video', start: 5, end: 10, inPoint: 1, outPoint: 6, sourceDuration: 30 },
729
+ ]),
730
+ })
731
+ const [l, r] = rollEdit(p, 'l', 'r', -5).tracks![0].items
732
+ expect(r).toMatchObject({ start: 4, inPoint: 0 }) // inPoint stops at 0
733
+ expect(l).toMatchObject({ end: 4, outPoint: 14 })
734
+ })
735
+
736
+ it('returns the same reference when the roll clamps to zero', () => {
737
+ const p = makeProject({
738
+ tracks: vtracks([
739
+ { id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
740
+ { id: 'r', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 },
741
+ ]),
742
+ })
743
+ expect(rollEdit(p, 'l', 'r', -3)).toBe(p) // right clip is already at its source start
744
+ })
745
+
746
+ it('leaves captions and audio untouched', () => {
747
+ const captions = { style: 'subtitle' as const, segments: [{ text: 'x', start: 4, end: 6 }] }
748
+ const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
749
+ const p = makeProject({ ...base(), captions, audio })
750
+ const out = rollEdit(p, 'l', 'r', 2)
751
+ expect(out.captions).toBe(captions)
752
+ expect(out.audio).toBe(audio)
753
+ })
754
+
755
+ it('rolls non-video items geometrically without inventing source points', () => {
756
+ const p = makeProject({
757
+ tracks: vtracks([
758
+ { id: 'l', type: 'image', start: 0, end: 5 },
759
+ { id: 'r', type: 'image', start: 5, end: 10 },
760
+ ]),
761
+ })
762
+ const [l, r] = rollEdit(p, 'l', 'r', 2).tracks![0].items
763
+ expect(l).toMatchObject({ end: 7 })
764
+ expect(r).toMatchObject({ start: 7 })
765
+ expect('outPoint' in l).toBe(false)
766
+ expect('inPoint' in r).toBe(false)
767
+ })
768
+ })
769
+
770
+ describe('slipItem', () => {
771
+ const base = () => makeProject({
772
+ tracks: vtracks([
773
+ { id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 11, sourceDuration: 30, normalizedInPoint: 5 },
774
+ ]),
775
+ })
776
+
777
+ it('returns the same reference when the item is not found', () => {
778
+ const p = base()
779
+ expect(slipItem(p, 'nope', 1)).toBe(p)
780
+ })
781
+
782
+ it('returns the same reference for a zero delta', () => {
783
+ const p = base()
784
+ expect(slipItem(p, 'a', 0)).toBe(p)
785
+ })
786
+
787
+ it('returns the same reference for a non-video item (no source media to slip)', () => {
788
+ const p = makeProject({ tracks: vtracks([{ id: 'o', type: 'overlay', start: 0, end: 5 }]) })
789
+ expect(slipItem(p, 'o', 1)).toBe(p)
790
+ })
791
+
792
+ it('returns the same reference for a video item carrying no in/outPoint', () => {
793
+ const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
794
+ expect(slipItem(p, 'a', 1)).toBe(p)
795
+ })
796
+
797
+ it('shifts the source window while the timeline position stays fixed', () => {
798
+ expect(slipItem(base(), 'a', 3).tracks![0].items[0]).toMatchObject({
799
+ start: 2, end: 8, inPoint: 8, outPoint: 14,
800
+ })
801
+ })
802
+
803
+ it('never touches normalizedInPoint', () => {
804
+ expect(slipItem(base(), 'a', 3).tracks![0].items[0].normalizedInPoint).toBe(5)
805
+ })
806
+
807
+ it('clamps at the start of the source media', () => {
808
+ expect(slipItem(base(), 'a', -20).tracks![0].items[0]).toMatchObject({
809
+ start: 2, end: 8, inPoint: 0, outPoint: 6,
810
+ })
811
+ })
812
+
813
+ it('clamps at the end of the source media', () => {
814
+ expect(slipItem(base(), 'a', 40).tracks![0].items[0]).toMatchObject({
815
+ start: 2, end: 8, inPoint: 24, outPoint: 30,
816
+ })
817
+ })
818
+
819
+ it('returns the same reference when already at the source start and slipped earlier', () => {
820
+ const p = makeProject({
821
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 }]),
822
+ })
823
+ expect(slipItem(p, 'a', -2)).toBe(p)
824
+ })
825
+
826
+ it('leaves the upper clamp open when sourceDuration is unknown', () => {
827
+ const p = makeProject({
828
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 1, outPoint: 6 }]),
829
+ })
830
+ expect(slipItem(p, 'a', 100).tracks![0].items[0]).toMatchObject({ inPoint: 101, outPoint: 106 })
831
+ })
832
+
833
+ it('slips items on overlay tracks too, leaving captions and audio untouched', () => {
834
+ const captions = { style: 'subtitle' as const, segments: [{ text: 'x', start: 1, end: 3 }] }
835
+ const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
836
+ const p = makeProject({
837
+ tracks: vtracks(
838
+ [{ id: 'a', type: 'video', start: 0, end: 5 }],
839
+ [{ id: 'b', type: 'video', start: 1, end: 4, inPoint: 2, outPoint: 5, sourceDuration: 20 }],
840
+ ),
841
+ captions,
842
+ audio,
843
+ })
844
+ const out = slipItem(p, 'b', 1)
845
+ expect(out.tracks![1].items[0]).toMatchObject({ start: 1, end: 4, inPoint: 3, outPoint: 6 })
846
+ expect(out.tracks![0].items[0]).toBe(p.tracks![0].items[0])
847
+ expect(out.captions).toBe(captions)
848
+ expect(out.audio).toBe(audio)
849
+ })
850
+ })
851
+
852
+ describe('slideItem', () => {
853
+ const base = () => makeProject({
854
+ tracks: vtracks([
855
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
856
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
857
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
858
+ ]),
859
+ })
860
+
861
+ it('returns the same reference when the item is not found', () => {
862
+ const p = base()
863
+ expect(slideItem(p, 'nope', 1)).toBe(p)
864
+ })
865
+
866
+ it('returns the same reference for a zero delta', () => {
867
+ const p = base()
868
+ expect(slideItem(p, 'm', 0)).toBe(p)
869
+ })
870
+
871
+ it('moves the item later and lets the neighbors absorb it', () => {
872
+ const [prev, item, next] = slideItem(base(), 'm', 2).tracks![0].items
873
+ expect(prev).toMatchObject({ id: 'p', start: 0, end: 7, inPoint: 0, outPoint: 7 })
874
+ expect(item).toMatchObject({ id: 'm', start: 7, end: 12, inPoint: 2, outPoint: 7 })
875
+ expect(next).toMatchObject({ id: 'n', start: 12, end: 15, inPoint: 5, outPoint: 8 })
876
+ })
877
+
878
+ it('moves the item earlier and lets the neighbors absorb it', () => {
879
+ const [prev, item, next] = slideItem(base(), 'm', -2).tracks![0].items
880
+ expect(prev).toMatchObject({ id: 'p', start: 0, end: 3, outPoint: 3 })
881
+ expect(item).toMatchObject({ id: 'm', start: 3, end: 8, inPoint: 2, outPoint: 7 })
882
+ expect(next).toMatchObject({ id: 'n', start: 8, end: 15, inPoint: 1 })
883
+ })
884
+
885
+ it("clamps at the next neighbor's MIN_DURATION", () => {
886
+ const [prev, item, next] = slideItem(base(), 'm', 20).tracks![0].items
887
+ expect(next.end - next.start).toBeCloseTo(0.1)
888
+ expect(prev.end).toBeCloseTo(9.9)
889
+ expect(item.start).toBeCloseTo(9.9)
890
+ expect(item.end).toBeCloseTo(14.9)
891
+ })
892
+
893
+ it("clamps at the next neighbor's source start", () => {
894
+ const [prev, item, next] = slideItem(base(), 'm', -20).tracks![0].items
895
+ expect(next).toMatchObject({ start: 7, inPoint: 0 }) // nextIn 3 → can only give back 3s
896
+ expect(prev).toMatchObject({ end: 2, outPoint: 2 })
897
+ expect(item).toMatchObject({ start: 2, end: 7 })
898
+ })
899
+
900
+ it("clamps at the previous neighbor's MIN_DURATION", () => {
901
+ const p = makeProject({
902
+ tracks: vtracks([
903
+ { id: 'p', type: 'video', start: 0, end: 2, inPoint: 0, outPoint: 2, sourceDuration: 30 },
904
+ { id: 'm', type: 'video', start: 2, end: 7, inPoint: 2, outPoint: 7, sourceDuration: 30 },
905
+ { id: 'n', type: 'video', start: 7, end: 12, inPoint: 10, outPoint: 15, sourceDuration: 30 },
906
+ ]),
907
+ })
908
+ const [prev, item] = slideItem(p, 'm', -20).tracks![0].items
909
+ expect(prev.end - prev.start).toBeCloseTo(0.1)
910
+ expect(prev.outPoint).toBeCloseTo(0.1)
911
+ expect(item.start).toBeCloseTo(0.1)
912
+ })
913
+
914
+ it("clamps at the previous neighbor's source end", () => {
915
+ const p = makeProject({
916
+ tracks: vtracks([
917
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 6 },
918
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
919
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
920
+ ]),
921
+ })
922
+ const [prev, item, next] = slideItem(p, 'm', 10).tracks![0].items
923
+ expect(prev).toMatchObject({ end: 6, outPoint: 6 }) // outPoint stops at sourceDuration
924
+ expect(item).toMatchObject({ start: 6, end: 11 })
925
+ expect(next).toMatchObject({ start: 11, inPoint: 4 })
926
+ })
927
+
928
+ it('slides a first item with no previous neighbor, clamped at the timeline origin', () => {
929
+ const p = makeProject({
930
+ tracks: vtracks([
931
+ { id: 'm', type: 'video', start: 2, end: 7, inPoint: 0, outPoint: 5, sourceDuration: 30 },
932
+ { id: 'n', type: 'video', start: 7, end: 12, inPoint: 3, outPoint: 8, sourceDuration: 30 },
933
+ ]),
934
+ })
935
+ expect(slideItem(p, 'm', 2).tracks![0].items[0]).toMatchObject({ start: 4, end: 9 })
936
+ // with no previous neighbor to shrink, the origin is what stops the slide
937
+ expect(slideItem(p, 'm', -20).tracks![0].items[0]).toMatchObject({ start: 0, end: 5 })
938
+ })
939
+
940
+ it('slides a last item with no next neighbor', () => {
941
+ const p = makeProject({
942
+ tracks: vtracks([
943
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
944
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
945
+ ]),
946
+ })
947
+ const [prev, item] = slideItem(p, 'm', 2).tracks![0].items
948
+ expect(prev).toMatchObject({ end: 7, outPoint: 7 })
949
+ expect(item).toMatchObject({ start: 7, end: 12, inPoint: 2, outPoint: 7 })
950
+ })
951
+
952
+ it('picks neighbors by timeline order, not array order', () => {
953
+ const p = makeProject({
954
+ tracks: vtracks([
955
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
956
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
957
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
958
+ ]),
959
+ })
960
+ const [next, prev, item] = slideItem(p, 'm', 2).tracks![0].items
961
+ expect(prev).toMatchObject({ id: 'p', end: 7, outPoint: 7 })
962
+ expect(item).toMatchObject({ id: 'm', start: 7, end: 12 })
963
+ expect(next).toMatchObject({ id: 'n', start: 12, inPoint: 5 })
964
+ })
965
+
966
+ it('does not absorb into non-adjacent neighbors', () => {
967
+ const p = makeProject({
968
+ tracks: vtracks([
969
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
970
+ { id: 'm', type: 'video', start: 8, end: 12, inPoint: 0, outPoint: 4, sourceDuration: 30 },
971
+ { id: 'n', type: 'video', start: 15, end: 20, inPoint: 3, outPoint: 8, sourceDuration: 30 },
972
+ ]),
973
+ })
974
+ const out = slideItem(p, 'm', 2)
975
+ expect(out.tracks![0].items[0]).toBe(p.tracks![0].items[0])
976
+ expect(out.tracks![0].items[2]).toBe(p.tracks![0].items[2])
977
+ expect(out.tracks![0].items[1]).toMatchObject({ start: 10, end: 14, inPoint: 0, outPoint: 4 })
978
+ })
979
+
980
+ it("shifts only the captions sitting over the item's old window", () => {
981
+ const p = makeProject({
982
+ ...base(),
983
+ captions: {
984
+ style: 'subtitle',
985
+ segments: [
986
+ { text: 'p-cap', start: 1, end: 3 },
987
+ { text: 'm-cap', start: 6, end: 9, words: [{ word: 'w', start: 6, end: 9 }] },
988
+ { text: 'n-cap', start: 11, end: 13 },
989
+ ],
990
+ },
991
+ })
992
+ const segs = slideItem(p, 'm', 2).captions!.segments
993
+ expect(segs[0]).toBe(p.captions!.segments[0])
994
+ expect(segs[1]).toMatchObject({ start: 8, end: 11 })
995
+ expect(segs[1].words![0]).toMatchObject({ start: 8, end: 11 })
996
+ expect(segs[2]).toBe(p.captions!.segments[2])
997
+ })
998
+
999
+ it('returns the same reference when the slide clamps to zero', () => {
1000
+ const p = makeProject({
1001
+ tracks: vtracks([
1002
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1003
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
1004
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1005
+ ]),
1006
+ })
1007
+ expect(slideItem(p, 'm', -5)).toBe(p) // n is already at its source start
1008
+ })
1009
+
1010
+ it('leaves audio tracks untouched', () => {
1011
+ const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
1012
+ const p = makeProject({ ...base(), audio })
1013
+ expect(slideItem(p, 'm', 2).audio).toBe(audio)
1014
+ })
1015
+ })
1016
+
1017
+ // ── Caption lane preservation (multi-row captions, Phase 1) ─────────────────
1018
+ //
1019
+ // Every caption-touching helper above rebuilds segments with `{ ...seg, ... }`
1020
+ // rather than a literal field list, so a segment's `lane` survives every cut
1021
+ // op by construction — none of these helpers has any lane-specific logic of
1022
+ // its own. These tests exist to keep it that way: if a future edit ever
1023
+ // rebuilds a caption segment without spreading the original, one of these
1024
+ // fails and says so explicitly rather than silently dropping `lane`.
1025
+
1026
+ describe('cuts.ts preserves CaptionSegment.lane', () => {
1027
+ it('applyCutToTracks (lift) preserves lane and removes the SAME duration from every lane', () => {
1028
+ const p = makeProject({
1029
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 20 }]),
1030
+ captions: {
1031
+ style: 'subtitle',
1032
+ segments: [
1033
+ { text: 'lane0-after', start: 8, end: 10, lane: 0 },
1034
+ { text: 'lane1-after', start: 8, end: 10, lane: 1 },
1035
+ { text: 'lane2-before', start: 0, end: 2, lane: 2 },
1036
+ ],
1037
+ },
1038
+ })
1039
+ const out = applyCutToTracks(p, { start: 3, end: 5 }) // 2s cut
1040
+ const byText = Object.fromEntries(out.captions!.segments.map(s => [s.text, s]))
1041
+ // Both lanes shift by the identical 2s cut duration — applyCutToCaptions
1042
+ // has no per-lane branch, it operates over the flat segment list.
1043
+ expect(byText['lane0-after']).toMatchObject({ start: 6, end: 8, lane: 0 })
1044
+ expect(byText['lane1-after']).toMatchObject({ start: 6, end: 8, lane: 1 })
1045
+ expect(byText['lane2-before']).toMatchObject({ start: 0, end: 2, lane: 2 })
1046
+ })
1047
+
1048
+ it('collapseGaps preserves lane while remapping captions', () => {
1049
+ const p = makeProject({
1050
+ tracks: vtracks([
1051
+ { id: 'a', type: 'video', start: 0, end: 5 },
1052
+ { id: 'b', type: 'video', start: 8, end: 12 }, // 3s gap
1053
+ ]),
1054
+ captions: {
1055
+ style: 'subtitle',
1056
+ segments: [{ text: 'b-cap', start: 9, end: 11, lane: 2 }],
1057
+ },
1058
+ })
1059
+ const out = collapseGaps(p)
1060
+ expect(out.captions!.segments[0]).toMatchObject({ start: 6, end: 8, lane: 2 })
1061
+ })
1062
+
1063
+ it('rippleDelete preserves lane on both untouched and shifted captions', () => {
1064
+ const p = makeProject({
1065
+ tracks: vtracks([
1066
+ { id: 'a', type: 'video', start: 0, end: 5 },
1067
+ { id: 'b', type: 'video', start: 5, end: 9 },
1068
+ { id: 'c', type: 'video', start: 9, end: 14 },
1069
+ ]),
1070
+ captions: {
1071
+ style: 'subtitle',
1072
+ segments: [
1073
+ { text: 'a-cap', start: 1, end: 3, lane: 1 },
1074
+ { text: 'c-cap', start: 10, end: 12, lane: 3 },
1075
+ ],
1076
+ },
1077
+ })
1078
+ const segs = rippleDelete(p, 'b').captions!.segments
1079
+ const byText = Object.fromEntries(segs.map(s => [s.text, s]))
1080
+ expect(byText['a-cap']).toMatchObject({ lane: 1 })
1081
+ expect(byText['c-cap']).toMatchObject({ start: 6, end: 8, lane: 3 })
1082
+ })
1083
+
1084
+ it('slideItem (shiftCaptionsInWindow) preserves lane on the captions it shifts', () => {
1085
+ const p = makeProject({
1086
+ tracks: vtracks([
1087
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1088
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
1089
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
1090
+ ]),
1091
+ captions: {
1092
+ style: 'subtitle',
1093
+ segments: [{ text: 'm-cap', start: 6, end: 9, lane: 4 }],
1094
+ },
1095
+ })
1096
+ const seg = slideItem(p, 'm', 2).captions!.segments[0]
1097
+ expect(seg).toMatchObject({ start: 8, end: 11, lane: 4 })
1098
+ })
1099
+ })
1100
+
1101
+ // ── Track-level invariants ──────────────────────────────────────────────────
1102
+ //
1103
+ // Two properties every op in this file shares, both of which are easy to break
1104
+ // by rebuilding `tracks` as bare `{ id, items }` objects or by filtering out
1105
+ // the empties on the way past:
1106
+ //
1107
+ // • a track's id and its volume/muted/enabled survive the op;
1108
+ // • an emptied track STAYS, unlike `moveItemAcrossTracks`, which prunes.
1109
+
1110
+ describe('every op preserves track identity and settings', () => {
1111
+ /** Two tracks with settings only they should ever carry, plus a third that is
1112
+ * already empty and must still be there afterwards. */
1113
+ function settledProject(): Project {
1114
+ return makeProject({
1115
+ tracks: [
1116
+ {
1117
+ id: 'base', volume: 0.4, muted: false,
1118
+ items: [
1119
+ // a|b share a boundary (so `rollEdit` has one to move); the gap
1120
+ // before c is what gives `collapseGaps` something to close.
1121
+ { id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1122
+ { id: 'b', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1123
+ { id: 'c', type: 'video', start: 11, end: 16, inPoint: 0, outPoint: 5, sourceDuration: 30 },
1124
+ ],
1125
+ },
1126
+ { id: 'ovl', enabled: false, items: [{ id: 'o', type: 'overlay', start: 1, end: 3 }] },
1127
+ { id: 'spare', volume: 0.9, items: [] },
1128
+ ],
1129
+ })
1130
+ }
1131
+
1132
+ const settingsOf = (p: Project) =>
1133
+ p.tracks!.map(t => ({ id: t.id, volume: t.volume, muted: t.muted, enabled: t.enabled }))
1134
+ const expected = settingsOf(settledProject())
1135
+
1136
+ const ops: Array<[string, (p: Project) => Project]> = [
1137
+ ['applyCutToTracks', p => applyCutToTracks(p, { start: 1, end: 2 })],
1138
+ ['collapseGaps', p => collapseGaps(p)],
1139
+ ['applyCutToItem (primary)', p => applyCutToItem(p, 'a', { start: 1, end: 2 })],
1140
+ ['applyCutToItem (overlay)', p => applyCutToItem(p, 'o', { start: 1.5, end: 2 })],
1141
+ ['splitAtTime', p => splitAtTime(p, 2, null)],
1142
+ ['rippleDelete', p => rippleDelete(p, 'b')],
1143
+ ['rollEdit', p => rollEdit(p, 'a', 'b', 0.5)],
1144
+ ['slipItem', p => slipItem(p, 'a', 1)],
1145
+ ['slideItem', p => slideItem(p, 'b', 1)],
1146
+ ]
1147
+
1148
+ for (const [name, op] of ops) {
1149
+ it(`${name} keeps every track's id and settings, and keeps the empty track`, () => {
1150
+ const input = settledProject()
1151
+ const out = op(input)
1152
+ expect(out).not.toBe(input) // the op actually did something
1153
+ expect(settingsOf(out)).toEqual(expected)
1154
+ expect(out.tracks!.map(t => t.id)).toEqual(['base', 'ovl', 'spare'])
1155
+ expect(out.tracks![2].items).toEqual([]) // empty tracks are kept
1156
+ })
1157
+ }
1158
+ })
1159
+
1160
+ // ── Per-clip speed (montaj/speed) ─────────────────────────────────────────────
1161
+ //
1162
+ // The invariant every op must preserve for a clip at speed S: the source-window
1163
+ // length is the timeline span times speed —
1164
+ //
1165
+ // outPoint − inPoint === S · (end − start)
1166
+ //
1167
+ // A clip at S=1/absent is byte-identical to the pre-speed op (every test above
1168
+ // exercises that path). These tests exercise S=2 (a 10s source window plays in
1169
+ // 5 timeline seconds) and S=0.5 (a 10s window is stretched over 20 seconds).
1170
+
1171
+ describe('per-clip speed', () => {
1172
+ /** outPoint − inPoint − S·(end − start); 0 when the invariant holds. */
1173
+ const invariantErr = (i: VisualItem) =>
1174
+ (i.outPoint! - i.inPoint!) - (i.speed ?? 1) * (i.end - i.start)
1175
+ const holdsFor = (i: VisualItem) => expect(invariantErr(i)).toBeCloseTo(0, 9)
1176
+
1177
+ describe('trim / cut ops keep outPoint − inPoint === S·(end − start)', () => {
1178
+ it('applyCutToTracks trims a sped clip end (overlaps-left) coherently', () => {
1179
+ const p = makeProject({
1180
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
1181
+ })
1182
+ const a = applyCutToTracks(p, { start: 3, end: 99 }).tracks![0].items[0]
1183
+ expect(a).toMatchObject({ end: 3, outPoint: 106 }) // 100 + (3−0)·2
1184
+ holdsFor(a)
1185
+ })
1186
+
1187
+ it('applyCutToTracks trims a sped clip start (overlaps-right) coherently', () => {
1188
+ const p = makeProject({
1189
+ tracks: vtracks([{ id: 'a', type: 'video', start: 2, end: 7, inPoint: 100, outPoint: 110, speed: 2 }]),
1190
+ })
1191
+ const a = applyCutToTracks(p, { start: 0, end: 4 }).tracks![0].items[0]
1192
+ expect(a).toMatchObject({ start: 4, inPoint: 104 }) // 100 + (4−2)·2
1193
+ holdsFor(a)
1194
+ })
1195
+
1196
+ it('splitAtTime splits a sped clip into two coherent fragments', () => {
1197
+ const p = makeProject({
1198
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
1199
+ })
1200
+ const [left, right] = splitAtTime(p, 3, 'a').tracks![0].items
1201
+ expect(left).toMatchObject({ end: 3, outPoint: 106 }) // 100 + 3·2
1202
+ expect(right).toMatchObject({ start: 3, inPoint: 106, end: 5, outPoint: 110 })
1203
+ holdsFor(left)
1204
+ holdsFor(right)
1205
+ })
1206
+
1207
+ it('applyCutToItem (collapse) cuts a sped clip coherently, re-timing the right fragment by /S', () => {
1208
+ const p = makeProject({
1209
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
1210
+ })
1211
+ const [left, right] = applyCutToItem(p, 'a', { start: 1, end: 3 }).tracks![0].items
1212
+ expect(left).toMatchObject({ end: 1, outPoint: 102 }) // physStart 100 + 1·2
1213
+ // right: source [106, 110] is 4 src-seconds → 4/2 = 2 timeline seconds from cut.start 1.
1214
+ expect(right).toMatchObject({ start: 1, end: 3, inPoint: 106, outPoint: 110 })
1215
+ holdsFor(left)
1216
+ holdsFor(right)
1217
+ })
1218
+
1219
+ it('rollEdit moves the shared boundary by d·S in each clip’s own source', () => {
1220
+ const p = makeProject({
1221
+ tracks: vtracks([
1222
+ { id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, sourceDuration: 200, speed: 2 },
1223
+ { id: 'b', type: 'video', start: 5, end: 9, inPoint: 20, outPoint: 22, sourceDuration: 200, speed: 0.5 },
1224
+ ]),
1225
+ })
1226
+ const [a, b] = rollEdit(p, 'a', 'b', 1).tracks![0].items
1227
+ expect(a).toMatchObject({ end: 6, outPoint: 112 }) // 110 + 1·2
1228
+ expect(b).toMatchObject({ start: 6, inPoint: 20.5 }) // 20 + 1·0.5
1229
+ holdsFor(a)
1230
+ holdsFor(b)
1231
+ })
1232
+
1233
+ it('rollEdit clamps a sped left clip at its source end by /S', () => {
1234
+ const p = makeProject({
1235
+ tracks: vtracks([
1236
+ { id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, sourceDuration: 111, speed: 2 },
1237
+ { id: 'b', type: 'video', start: 5, end: 20, inPoint: 0, outPoint: 30, sourceDuration: 200, speed: 2 },
1238
+ ]),
1239
+ })
1240
+ // left has only 1 source-second of headroom (111 − 110); at S=2 that is 0.5 timeline-seconds.
1241
+ const [a] = rollEdit(p, 'a', 'b', 10).tracks![0].items
1242
+ expect(a.outPoint).toBeCloseTo(111)
1243
+ expect(a.end).toBeCloseTo(5.5)
1244
+ holdsFor(a)
1245
+ })
1246
+
1247
+ it('slipItem moves the source window by delta·S with the timeline fixed', () => {
1248
+ const p = makeProject({
1249
+ tracks: vtracks([
1250
+ { id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 17, sourceDuration: 200, speed: 2 },
1251
+ ]),
1252
+ })
1253
+ const a = slipItem(p, 'a', 3).tracks![0].items[0]
1254
+ expect(a).toMatchObject({ start: 2, end: 8, inPoint: 11, outPoint: 23 }) // ±3·2
1255
+ holdsFor(a)
1256
+ })
1257
+
1258
+ it('slipItem clamps a sped clip at the source start by /S', () => {
1259
+ const p = makeProject({
1260
+ tracks: vtracks([
1261
+ { id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 17, sourceDuration: 200, speed: 2 },
1262
+ ]),
1263
+ })
1264
+ // minDelta = −inPoint/S = −2.5; a −10 drag parks inPoint at exactly 0.
1265
+ const a = slipItem(p, 'a', -10).tracks![0].items[0]
1266
+ expect(a.inPoint).toBeCloseTo(0)
1267
+ expect(a.outPoint).toBeCloseTo(12)
1268
+ holdsFor(a)
1269
+ })
1270
+
1271
+ it('slideItem re-times each neighbor by d·S in its own source', () => {
1272
+ const p = makeProject({
1273
+ tracks: vtracks([
1274
+ { id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 10, sourceDuration: 200, speed: 2 },
1275
+ { id: 'm', type: 'video', start: 5, end: 10, inPoint: 100, outPoint: 105, sourceDuration: 200 },
1276
+ { id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 13, sourceDuration: 200, speed: 2 },
1277
+ ]),
1278
+ })
1279
+ const [prev, item, next] = slideItem(p, 'm', 2).tracks![0].items
1280
+ expect(prev).toMatchObject({ id: 'p', end: 7, outPoint: 14 }) // 10 + 2·2
1281
+ expect(item).toMatchObject({ id: 'm', start: 7, end: 12, inPoint: 100, outPoint: 105 })
1282
+ expect(next).toMatchObject({ id: 'n', start: 12, inPoint: 7 }) // 3 + 2·2
1283
+ holdsFor(prev)
1284
+ holdsFor(next)
1285
+ })
1286
+ })
1287
+
1288
+ describe('setClipSpeed', () => {
1289
+ const oneClip = (over: Partial<VisualItem> = {}) => makeProject({
1290
+ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 100, outPoint: 110, ...over }]),
1291
+ })
1292
+
1293
+ it('2× halves the timeline span, leaving inPoint/outPoint untouched', () => {
1294
+ const a = setClipSpeed(oneClip(), 'a', 2).tracks![0].items[0]
1295
+ expect(a).toMatchObject({ speed: 2, start: 0, end: 5, inPoint: 100, outPoint: 110 })
1296
+ holdsFor(a)
1297
+ })
1298
+
1299
+ it('0.5× doubles the timeline span', () => {
1300
+ const a = setClipSpeed(oneClip(), 'a', 0.5).tracks![0].items[0]
1301
+ expect(a).toMatchObject({ speed: 0.5, end: 20, inPoint: 100, outPoint: 110 })
1302
+ holdsFor(a)
1303
+ })
1304
+
1305
+ it('clamps speed above 4 to 4', () => {
1306
+ const a = setClipSpeed(oneClip(), 'a', 10).tracks![0].items[0]
1307
+ expect(a.speed).toBe(4)
1308
+ expect(a.end).toBeCloseTo(2.5) // 10 / 4
1309
+ holdsFor(a)
1310
+ })
1311
+
1312
+ it('clamps speed below 0.25 to 0.25', () => {
1313
+ const a = setClipSpeed(oneClip(), 'a', 0.01).tracks![0].items[0]
1314
+ expect(a.speed).toBe(0.25)
1315
+ expect(a.end).toBeCloseTo(40) // 10 / 0.25
1316
+ holdsFor(a)
1317
+ })
1318
+
1319
+ it('re-speeds an already-sped clip correctly (source length is speed-invariant)', () => {
1320
+ // Start at 2× (end already 5), then 4×, then back to 1× → original 10s span.
1321
+ const at2x = oneClip({ end: 5, speed: 2 })
1322
+ const at4x = setClipSpeed(at2x, 'a', 4).tracks![0].items[0]
1323
+ expect(at4x).toMatchObject({ speed: 4, end: 2.5 })
1324
+ holdsFor(at4x)
1325
+
1326
+ const back = setClipSpeed(
1327
+ makeProject({ tracks: vtracks([at4x]) }), 'a', 1,
1328
+ ).tracks![0].items[0]
1329
+ expect(back).toMatchObject({ speed: 1, end: 10, inPoint: 100, outPoint: 110 })
1330
+ holdsFor(back)
1331
+ })
1332
+
1333
+ it('re-fits a clip with no stored outPoint from its current span × speed', () => {
1334
+ // No outPoint: source window is (end − start)·S. At 2× the 10s span → 20 src-seconds…
1335
+ const at2x = setClipSpeed(oneClip({ inPoint: undefined, outPoint: undefined }), 'a', 2).tracks![0].items[0]
1336
+ expect(at2x).toMatchObject({ speed: 2, end: 5 }) // 20 src-seconds / 2
1337
+ // …and back to 1× recovers the original 10s span.
1338
+ const back = setClipSpeed(makeProject({ tracks: vtracks([at2x]) }), 'a', 1).tracks![0].items[0]
1339
+ expect(back).toMatchObject({ speed: 1, end: 10 })
1340
+ })
1341
+
1342
+ it('does not close the gap it opens — the next clip stays put', () => {
1343
+ const p = makeProject({
1344
+ tracks: vtracks([
1345
+ { id: 'a', type: 'video', start: 0, end: 10, inPoint: 100, outPoint: 110 },
1346
+ { id: 'b', type: 'video', start: 10, end: 15, inPoint: 0, outPoint: 5 },
1347
+ ]),
1348
+ })
1349
+ const out = setClipSpeed(p, 'a', 2)
1350
+ expect(out.tracks![0].items[0]).toMatchObject({ end: 5, speed: 2 })
1351
+ expect(out.tracks![0].items[1]).toMatchObject({ start: 10, end: 15 }) // gap at [5,10] left for the caller
1352
+ })
1353
+
1354
+ it('returns the same reference when the clip is not found', () => {
1355
+ const p = oneClip()
1356
+ expect(setClipSpeed(p, 'nope', 2)).toBe(p)
1357
+ })
1358
+
1359
+ it('is a no-op on a non-video clip — speed is video-only', () => {
1360
+ const p = makeProject({
1361
+ tracks: vtracks([{ id: 'a', type: 'image', start: 0, end: 10 }]),
1362
+ })
1363
+ const out = setClipSpeed(p, 'a', 2)
1364
+ expect(out).toBe(p)
1365
+ expect(out.tracks![0].items[0].speed).toBeUndefined()
1366
+ expect(out.tracks![0].items[0].end).toBe(10)
1367
+ })
1368
+
1369
+ it('returns a new project (immutable) when it does change the clip', () => {
1370
+ const p = oneClip()
1371
+ const out = setClipSpeed(p, 'a', 2)
1372
+ expect(out).not.toBe(p)
1373
+ expect(p.tracks![0].items[0].end).toBe(10) // input untouched
1374
+ expect(p.tracks![0].items[0].speed).toBeUndefined()
1375
+ })
1376
+ })
198
1377
  })