@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,447 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * `applyMoveDeltaToSelection` — the rigid group move behind every multi-select
4
+ * drag, now that caption segments join clips and audio bars in the SAME
5
+ * `selectedIds` array.
6
+ *
7
+ * The fixture keeps every kind at a different place on the timeline so a clamp
8
+ * can be attributed to exactly one member:
9
+ *
10
+ * clips c0 0–5, c1 5–10
11
+ * audio a0 1–6
12
+ * captions s0 1–2 (with word timings), s1 3–4, and one segment with no id
13
+ *
14
+ * Word timings are absolute seconds, which is the whole reason they have to
15
+ * travel with the segment rather than being left to a respread.
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import type { Project } from '../../../types'
19
+ import { applyMoveDeltaToSelection, deleteSelection } from '../multiSelectOps'
20
+ import { trackItems } from '../timeline-model'
21
+
22
+ const TOTAL_DURATION = 20
23
+
24
+ function project(): Project {
25
+ return {
26
+ id: 'p',
27
+ tracks: [{
28
+ id: 'trk-0',
29
+ items: [
30
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 },
31
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10 },
32
+ ],
33
+ }],
34
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
35
+ captions: {
36
+ style: 'pop',
37
+ segments: [
38
+ {
39
+ id: 's0',
40
+ text: 'hello there',
41
+ start: 1,
42
+ end: 2,
43
+ words: [
44
+ { word: 'hello', start: 1, end: 1.6 },
45
+ { word: 'there', start: 1.6, end: 2 },
46
+ ],
47
+ },
48
+ { id: 's1', text: 'second', start: 3, end: 4 },
49
+ // No id yet — `backfillCaptionIds` has not run. Can never be selected.
50
+ { text: 'unminted', start: 8, end: 9 },
51
+ ],
52
+ },
53
+ } as unknown as Project
54
+ }
55
+
56
+ const segments = (p: Project) => p.captions!.segments
57
+ const seg = (p: Project, id: string) => segments(p).find(s => s.id === id)!
58
+ const visual = (p: Project, id: string) => trackItems(p).flat().find(i => i.id === id)!
59
+
60
+ describe('applyMoveDeltaToSelection — caption segments', () => {
61
+ it('shifts start, end and every word by the same delta', () => {
62
+ const before = project()
63
+ const after = applyMoveDeltaToSelection(before, ['s0'], 0.5, TOTAL_DURATION)
64
+ expect(seg(after, 's0')).toMatchObject({ start: 1.5, end: 2.5 })
65
+ expect(seg(after, 's0').words).toEqual([
66
+ { word: 'hello', start: 1.5, end: 2.1 },
67
+ { word: 'there', start: 2.1, end: 2.5 },
68
+ ])
69
+ })
70
+
71
+ it('leaves the text alone — a move is a retime, not an edit', () => {
72
+ const after = applyMoveDeltaToSelection(project(), ['s0'], 0.5, TOTAL_DURATION)
73
+ expect(seg(after, 's0').text).toBe('hello there')
74
+ })
75
+
76
+ it('leaves a segment with no words a segment with no words', () => {
77
+ const after = applyMoveDeltaToSelection(project(), ['s1'], 0.5, TOTAL_DURATION)
78
+ expect(seg(after, 's1')).toMatchObject({ start: 3.5, end: 4.5 })
79
+ expect(seg(after, 's1').words).toBeUndefined()
80
+ })
81
+
82
+ it('does not touch an unselected segment, id-less or not', () => {
83
+ const before = project()
84
+ const after = applyMoveDeltaToSelection(before, ['s0'], 0.5, TOTAL_DURATION)
85
+ // Same object references, not merely equal values.
86
+ expect(segments(after)[1]).toBe(segments(before)[1])
87
+ expect(segments(after)[2]).toBe(segments(before)[2])
88
+ })
89
+
90
+ it('moves captions and clips together under one delta', () => {
91
+ const after = applyMoveDeltaToSelection(project(), ['c0', 's0'], 1, TOTAL_DURATION)
92
+ expect(visual(after, 'c0')).toMatchObject({ start: 1, end: 6 })
93
+ expect(seg(after, 's0')).toMatchObject({ start: 2, end: 3 })
94
+ // Nothing outside the selection moves.
95
+ expect(visual(after, 'c1')).toMatchObject({ start: 5, end: 10 })
96
+ })
97
+
98
+ it('counts a caption in the group extent, so one clamp holds the whole body', () => {
99
+ // s0 starts at 1 and c1 at 5, so the CAPTION is the leftmost member: the
100
+ // group can only travel 1s left. Without captions in the extent scan the
101
+ // clamp would allow -5 and drag s0 to -4.
102
+ const after = applyMoveDeltaToSelection(project(), ['c1', 's0'], -5, TOTAL_DURATION)
103
+ expect(seg(after, 's0')).toMatchObject({ start: 0, end: 1 })
104
+ expect(visual(after, 'c1')).toMatchObject({ start: 4, end: 9 })
105
+ })
106
+
107
+ it('counts a caption at the right-hand extreme too', () => {
108
+ // Push s1's end (4) out to the horizon: the group may travel 16s. Isolated
109
+ // to a project with only s1's caption present — `project()`'s own s0 and
110
+ // id-less segment would otherwise trip the caption-neighbour clamp (see
111
+ // the dedicated describe block below) before this ever reaches the
112
+ // timeline edge under test here.
113
+ const before = { ...project(), captions: { style: 'pop', segments: [{ id: 's1', text: 'second', start: 3, end: 4 }] } } as unknown as Project
114
+ const after = applyMoveDeltaToSelection(before, ['s1'], 99, TOTAL_DURATION)
115
+ expect(seg(after, 's1')).toMatchObject({ start: 19, end: 20 })
116
+ })
117
+
118
+ it('moves a caption whose id is the only one selected', () => {
119
+ // The shape `applyCaptionMove` leans on when a drag starts on an
120
+ // UNSELECTED caption: a one-id selection still moves. Isolated to a
121
+ // project with only s0's caption present, for the same reason as above —
122
+ // `project()`'s s1 sits only 1s away and would clamp this delta to 1
123
+ // rather than the full 2 this test means to demonstrate.
124
+ const before = { ...project(), captions: { style: 'pop', segments: [{ id: 's0', text: 'hello there', start: 1, end: 2 }] } } as unknown as Project
125
+ const after = applyMoveDeltaToSelection(before, ['s0'], 2, TOTAL_DURATION)
126
+ expect(seg(after, 's0')).toMatchObject({ start: 3, end: 4 })
127
+ })
128
+
129
+ it('returns the same project reference on a no-op', () => {
130
+ const before = project()
131
+ expect(applyMoveDeltaToSelection(before, ['s0'], 0, TOTAL_DURATION)).toBe(before)
132
+ expect(applyMoveDeltaToSelection(before, [], 1, TOTAL_DURATION)).toBe(before)
133
+ // A selection of ids that aren't in the project at all is also a no-op.
134
+ expect(applyMoveDeltaToSelection(before, ['gone'], 1, TOTAL_DURATION)).toBe(before)
135
+ })
136
+
137
+ it('builds no new captions object when no caption moved', () => {
138
+ const before = project()
139
+ const after = applyMoveDeltaToSelection(before, ['c0'], 1, TOTAL_DURATION)
140
+ expect(after).not.toBe(before)
141
+ expect(after.captions).toBe(before.captions)
142
+ })
143
+
144
+ it('leaves captions undefined on a project that has none', () => {
145
+ const before = { ...project(), captions: undefined } as unknown as Project
146
+ const after = applyMoveDeltaToSelection(before, ['c0'], 1, TOTAL_DURATION)
147
+ expect(after.captions).toBeUndefined()
148
+ expect(visual(after, 'c0')).toMatchObject({ start: 1, end: 6 })
149
+ })
150
+ })
151
+
152
+ // ── Caption-neighbour clamp ─────────────────────────────────────────────
153
+ //
154
+ // Captions may never overlap WITHIN A LANE: `activeCaptionSegments`
155
+ // (timeline-core) hands the preview and every render template EVERY live
156
+ // segment, lane-ascending, and a lane is a z-order with no vertical offset of
157
+ // its own — so two segments sharing a lane and an instant paint one straight
158
+ // over the other. A selected caption may therefore not cross a NON-selected
159
+ // SAME-LANE caption's edge, folded into the same lo/hi the timeline-edge clamp
160
+ // already uses (see `captionNeighbourBounds`). The per-lane half of that rule
161
+ // has its own describe block below.
162
+
163
+ function neighbourProject(): Project {
164
+ return {
165
+ id: 'p',
166
+ captions: {
167
+ style: 'pop',
168
+ segments: [
169
+ { id: 's0', text: 'hello there', start: 1, end: 2 },
170
+ { id: 's1', text: 'second', start: 3, end: 4 },
171
+ ],
172
+ },
173
+ } as unknown as Project
174
+ }
175
+
176
+ describe('applyMoveDeltaToSelection — caption-neighbour clamp', () => {
177
+ it('does not let a single selected caption cross its previous (left) neighbour', () => {
178
+ // s1 (3–4) may travel left only until its start meets s0's end (2) — a 1s
179
+ // gap — not the -5 requested, and not the -3 a bare timeline-edge clamp
180
+ // (t=0) alone would allow.
181
+ const after = applyMoveDeltaToSelection(neighbourProject(), ['s1'], -5, TOTAL_DURATION)
182
+ expect(seg(after, 's1')).toMatchObject({ start: 2, end: 3 })
183
+ })
184
+
185
+ it('does not let a single selected caption cross its next (right) neighbour', () => {
186
+ // s0 (1–2) may travel right only until its end meets s1's start (3).
187
+ const after = applyMoveDeltaToSelection(neighbourProject(), ['s0'], 5, TOTAL_DURATION)
188
+ expect(seg(after, 's0')).toMatchObject({ start: 2, end: 3 })
189
+ })
190
+
191
+ it('preserves duration when the neighbour clamp bites', () => {
192
+ // The clamp shifts the whole delta, not just one edge — a clamped move is
193
+ // still a rigid shift, never a shrink. Pinned against the exact clamped
194
+ // position (not just the duration, which a differently-clamped result —
195
+ // e.g. the timeline-edge clamp alone — would also happen to preserve):
196
+ // duration survives a clamp landing AT the neighbour bound specifically.
197
+ const after = applyMoveDeltaToSelection(neighbourProject(), ['s0'], 5, TOTAL_DURATION)
198
+ const clamped = seg(after, 's0')
199
+ expect(clamped).toMatchObject({ start: 2, end: 3 })
200
+ expect(clamped.end - clamped.start).toBe(1)
201
+ })
202
+
203
+ it('moves an adjacent run of selected captions rigidly, without clamping the pair against each other', () => {
204
+ const before: Project = {
205
+ id: 'p',
206
+ captions: {
207
+ style: 'pop',
208
+ segments: [
209
+ { id: 'a', text: 'a', start: 1, end: 2 },
210
+ { id: 'b', text: 'b', start: 2, end: 3 }, // touches a — both selected
211
+ { id: 'c', text: 'c', start: 4, end: 5 }, // NOT selected — the real stop
212
+ ],
213
+ },
214
+ } as unknown as Project
215
+
216
+ // Small delta, nowhere near c: both move by the full amount and stay
217
+ // exactly as adjacent as they started. If the neighbour search didn't
218
+ // exclude selected ids, b would see a as a "left neighbour" (or vice
219
+ // versa) and refuse to move at all.
220
+ const nudged = applyMoveDeltaToSelection(before, ['a', 'b'], 0.5, TOTAL_DURATION)
221
+ expect(seg(nudged, 'a')).toMatchObject({ start: 1.5, end: 2.5 })
222
+ expect(seg(nudged, 'b')).toMatchObject({ start: 2.5, end: 3.5 })
223
+
224
+ // Large delta: the pair travels together and stops the instant b's end
225
+ // reaches c's start (4) — 1s of the 5 requested — never crossing into c.
226
+ const stopped = applyMoveDeltaToSelection(before, ['a', 'b'], 5, TOTAL_DURATION)
227
+ expect(seg(stopped, 'a')).toMatchObject({ start: 2, end: 3 })
228
+ expect(seg(stopped, 'b')).toMatchObject({ start: 3, end: 4 })
229
+ })
230
+
231
+ it('leaves a clips-only selection unaffected by any caption, however it overlaps in time', () => {
232
+ const before: Project = {
233
+ id: 'p',
234
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 }] }],
235
+ captions: {
236
+ style: 'pop',
237
+ // Overlaps c0's whole span in time — irrelevant, since no caption is
238
+ // selected, `captionNeighbourBounds` never even looks at it.
239
+ segments: [{ id: 's0', text: 'hello', start: 0.5, end: 4.5 }],
240
+ },
241
+ } as unknown as Project
242
+ const after = applyMoveDeltaToSelection(before, ['c0'], 10, TOTAL_DURATION)
243
+ expect(visual(after, 'c0')).toMatchObject({ start: 10, end: 15 })
244
+ })
245
+ })
246
+
247
+ // ── The neighbour clamp is PER LANE ────────────────────────────────────────
248
+ //
249
+ // Captions on different rows may overlap and all render — that is what caption
250
+ // rows are for — so only a segment sharing the dragged one's lane is a bound.
251
+ // A track-wide clamp (what this was before caption lanes existed) would stop a
252
+ // lane-0 caption dead at a lane-1 caption it can legally pass straight under.
253
+
254
+ describe('applyMoveDeltaToSelection — the caption-neighbour clamp is per lane', () => {
255
+ function twoLanes(): Project {
256
+ return {
257
+ id: 'p',
258
+ captions: {
259
+ style: 'pop',
260
+ segments: [
261
+ { id: 'ground', text: 'ground', start: 1, end: 2 }, // lane 0 (absent ⇒ 0)
262
+ { id: 'upstairs', text: 'upstairs', start: 3, end: 4, lane: 1 },
263
+ ],
264
+ },
265
+ } as unknown as Project
266
+ }
267
+
268
+ it('lets a caption pass clean under a caption on another lane', () => {
269
+ // Track-wide, `upstairs` (3–4) would stop `ground` at a delta of 1. Per
270
+ // lane it is not a neighbour at all, so the full 5s delta lands.
271
+ const after = applyMoveDeltaToSelection(twoLanes(), ['ground'], 5, TOTAL_DURATION)
272
+ expect(seg(after, 'ground')).toMatchObject({ start: 6, end: 7 })
273
+ })
274
+
275
+ it('still stops at a neighbour that DOES share the lane', () => {
276
+ const sameLane = {
277
+ ...twoLanes(),
278
+ captions: {
279
+ style: 'pop',
280
+ segments: [
281
+ { id: 'ground', text: 'ground', start: 1, end: 2 },
282
+ { id: 'upstairs', text: 'upstairs', start: 3, end: 4 }, // now lane 0 too
283
+ ],
284
+ },
285
+ } as unknown as Project
286
+ const after = applyMoveDeltaToSelection(sameLane, ['ground'], 5, TOTAL_DURATION)
287
+ expect(seg(after, 'ground')).toMatchObject({ start: 2, end: 3 })
288
+ })
289
+
290
+ it('treats an absent lane and an explicit `lane: 0` as the same row', () => {
291
+ // `laneOf` collapses both to 0, so these two must clamp against each other.
292
+ const mixed = {
293
+ id: 'p',
294
+ captions: {
295
+ style: 'pop',
296
+ segments: [
297
+ { id: 'ground', text: 'ground', start: 1, end: 2 },
298
+ { id: 'explicit', text: 'explicit', start: 3, end: 4, lane: 0 },
299
+ ],
300
+ },
301
+ } as unknown as Project
302
+ expect(seg(applyMoveDeltaToSelection(mixed, ['ground'], 5, TOTAL_DURATION), 'ground'))
303
+ .toMatchObject({ start: 2, end: 3 })
304
+ })
305
+
306
+ it('takes the TIGHTEST bound across the lanes the selection actually occupies', () => {
307
+ // Two selected captions, one per lane, each with its own unselected
308
+ // same-lane neighbour: lane 0 leaves 3s of room, lane 1 leaves 4s. The
309
+ // group moves by the tighter of the two, 3s, and both keep their lanes.
310
+ //
311
+ // `decoy` is the discriminating part: it sits 0.5s to the right of both
312
+ // selected captions but on a lane NEITHER of them is on, so it bounds
313
+ // nothing. Track-wide it was the tightest bound of all and pinned the
314
+ // whole group to 0.5s.
315
+ const before: Project = {
316
+ id: 'p',
317
+ captions: {
318
+ style: 'pop',
319
+ segments: [
320
+ { id: 'a', text: 'a', start: 1, end: 2 },
321
+ { id: 'blockA', text: 'blockA', start: 5, end: 6 }, // 3s of room for a
322
+ { id: 'b', text: 'b', start: 1, end: 2, lane: 1 },
323
+ { id: 'blockB', text: 'blockB', start: 6, end: 7, lane: 1 }, // 4s of room for b
324
+ { id: 'decoy', text: 'decoy', start: 2.5, end: 3.5, lane: 2 },
325
+ ],
326
+ },
327
+ } as unknown as Project
328
+ const after = applyMoveDeltaToSelection(before, ['a', 'b'], 5, TOTAL_DURATION)
329
+ expect(seg(after, 'a')).toMatchObject({ start: 4, end: 5 })
330
+ expect(seg(after, 'b')).toMatchObject({ start: 4, end: 5 })
331
+ expect(seg(after, 'decoy')).toMatchObject({ start: 2.5, end: 3.5 })
332
+ })
333
+
334
+ it('carries `lane` through a group move untouched — a group move is horizontal only', () => {
335
+ const after = applyMoveDeltaToSelection(twoLanes(), ['ground', 'upstairs'], 1, TOTAL_DURATION)
336
+ expect(seg(after, 'ground').lane).toBeUndefined() // absent stays absent, not written to 0
337
+ expect(seg(after, 'upstairs').lane).toBe(1)
338
+ expect(seg(after, 'upstairs')).toMatchObject({ start: 4, end: 5 })
339
+ })
340
+ })
341
+
342
+ // ── A caption overhanging totalDuration ────────────────────────────────────
343
+ //
344
+ // `totalDuration` is derived from clips and audio only (timeline-model.ts), so
345
+ // a caption can legally end past it once clips are trimmed or deleted after
346
+ // captioning. Mirrors the overhang fixture in pointer-machine.test.ts's caption
347
+ // trim describe: `hi` must be floored at 0 so an already-overhanging member
348
+ // can't be pushed further out — an unfloored `hi` goes negative there and
349
+ // (via `Math.min(hi, requestedDelta)`) drags the whole group backwards on a
350
+ // RIGHTWARD drag instead of refusing to move it.
351
+
352
+ const OVERHANG_TOTAL_DURATION = 15
353
+
354
+ function overhangProject(): Project {
355
+ return {
356
+ id: 'p',
357
+ tracks: [{
358
+ id: 'trk-0',
359
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 }],
360
+ }],
361
+ captions: {
362
+ style: 'pop',
363
+ segments: [{ id: 'over', text: 'trailing', start: 16, end: 18 }],
364
+ },
365
+ } as unknown as Project
366
+ }
367
+
368
+ describe('applyMoveDeltaToSelection — a caption overhanging totalDuration', () => {
369
+ it('does not move a lone overhanging caption right, and does not teleport it either', () => {
370
+ // Unfloored: lo=-16, hi=15-18=-3, lo>hi is false, so delta clamps to
371
+ // min(-3, 0.5) = -3 — a 3s jump LEFT on a rightward drag. Floored: hi=0,
372
+ // delta clamps to 0.
373
+ const before = overhangProject()
374
+ const after = applyMoveDeltaToSelection(before, ['over'], 0.5, OVERHANG_TOTAL_DURATION)
375
+ expect(after).toBe(before)
376
+ })
377
+
378
+ it('still moves a lone overhanging caption left by the requested amount', () => {
379
+ // hi=0 does not touch `lo`, so a leftward drag is unaffected by the floor.
380
+ const after = applyMoveDeltaToSelection(overhangProject(), ['over'], -2, OVERHANG_TOTAL_DURATION)
381
+ expect(seg(after, 'over')).toMatchObject({ start: 14, end: 16 })
382
+ })
383
+
384
+ it('leaves a clip mixed with an overhanging caption immovable, not dragged backwards', () => {
385
+ // The clip pins lo at 0 (already at t=0) and the overhanging caption pins
386
+ // hi at 0 (already past totalDuration), so the group cannot move either
387
+ // way — a real "can't move" case, not the old sign-flip bug (which also
388
+ // produced delta 0 here, via `lo > hi`, but for the wrong reason: `hi` was
389
+ // -3, not 0).
390
+ const before = overhangProject()
391
+ const after = applyMoveDeltaToSelection(before, ['c0', 'over'], 0.5, OVERHANG_TOTAL_DURATION)
392
+ expect(after).toBe(before)
393
+ })
394
+ })
395
+
396
+ // ── deleteSelection — track grouping survives a prune (Part B) ─────────────
397
+ //
398
+ // `deleteSelection` re-groups its result (`normalizeTrackOrder`) after
399
+ // pruning emptied tracks, because a prune can change a SURVIVING track's
400
+ // coarse kind: a mixed track that loses its only video item becomes
401
+ // overlay-kind, and the canonical video-block/overlay-block stack has to
402
+ // re-absorb it rather than leave it stranded where it used to sit.
403
+
404
+ describe('deleteSelection', () => {
405
+ function trackProject(
406
+ tracks: Array<{ id: string; items: Array<{ id: string; type: string; start: number; end: number }> }>,
407
+ ): Project {
408
+ return { id: 'p', tracks } as unknown as Project
409
+ }
410
+
411
+ it('returns the project unchanged when nothing is selected', () => {
412
+ const p = trackProject([{ id: 'trk-0', items: [{ id: 'v0', type: 'video', start: 0, end: 5 }] }])
413
+ expect(deleteSelection(p, [])).toBe(p)
414
+ })
415
+
416
+ it('prunes a track left empty by the delete (baseline, unaffected by grouping)', () => {
417
+ const p = trackProject([
418
+ { id: 'trk-0', items: [{ id: 'v0', type: 'video', start: 0, end: 5 }] },
419
+ { id: 'trk-1', items: [{ id: 'o0', type: 'overlay', start: 0, end: 5 }] },
420
+ ])
421
+ const next = deleteSelection(p, ['o0'])
422
+ expect(next.tracks!.map(t => t.id)).toEqual(['trk-0'])
423
+ })
424
+
425
+ it('re-groups a track whose coarse kind changed after the prune — losing its only video item drops it out of the video block', () => {
426
+ const p = trackProject([
427
+ {
428
+ id: 'trk-mixed',
429
+ items: [
430
+ { id: 'v-mixed', type: 'video', start: 0, end: 5 },
431
+ { id: 'o-mixed', type: 'overlay', start: 0, end: 5 },
432
+ ],
433
+ }, // mixed — video-kind today (has a video item), so it's in the video
434
+ // group AHEAD of trk-video, matching their raw array order.
435
+ { id: 'trk-video', items: [{ id: 'v0', type: 'video', start: 0, end: 5 }] },
436
+ { id: 'trk-overlay', items: [{ id: 'o0', type: 'overlay', start: 0, end: 5 }] },
437
+ ])
438
+
439
+ const next = deleteSelection(p, ['v-mixed'])
440
+
441
+ // trk-mixed lost its only video item and is now overlay-kind, so it
442
+ // drops OUT of the video block — trk-video (still video-kind) moves
443
+ // ahead of it, even though trk-mixed came first in the raw array.
444
+ expect(next.tracks!.map(t => t.id)).toEqual(['trk-video', 'trk-mixed', 'trk-overlay'])
445
+ expect(next.tracks!.find(t => t.id === 'trk-mixed')!.items.map(i => i.id)).toEqual(['o-mixed'])
446
+ })
447
+ })