@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
package/src/video/cuts.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { Project } from '../types'
2
- import type { VisualItem, AudioTrack, CaptionSegment, Word } from '../schema'
2
+ import type { VisualItem, AudioTrack, CaptionSegment, Word, VisualTrack, KeyframeTrack } from '../schema'
3
+ import { mapTrackItems, trackItems, normalizeTracks } from './timeline/timeline-model'
4
+ import { normalizeTrack } from '@bycrux/timeline-core'
5
+ import { canKeyframe } from './keyframeOps'
3
6
 
4
7
  /** A time range to excise from the timeline. */
5
8
  export interface Cut {
@@ -7,6 +10,14 @@ export interface Cut {
7
10
  end: number
8
11
  }
9
12
 
13
+ /** Shortest a clip may be left by a trim op. Mirrors the timeline drag clamp in
14
+ * `timeline/multiSelectOps.ts`, which owns the same constant module-privately. */
15
+ const MIN_DURATION = 0.1
16
+
17
+ /** Float slop for adjacency and no-op checks — matches the tolerance already
18
+ * used when deciding whether a collapse fragment is worth keeping. */
19
+ const EPSILON = 0.001
20
+
10
21
  // ── ID generation ───────────────────────────────────────────────────────────
11
22
 
12
23
  function uniqueId(base: string): string {
@@ -16,18 +27,22 @@ function uniqueId(base: string): string {
16
27
  // ── Single base-clip helpers ────────────────────────────────────────────────
17
28
 
18
29
  function trimClipEnd(item: VisualItem, at: number): VisualItem {
30
+ // A timeline delta maps to a SOURCE delta of `·S` (montaj/speed); `·1` is exact
31
+ // so a clip at S=1/absent is byte-identical to the pre-speed op.
32
+ const s = item.speed ?? 1
19
33
  const trimmedDur = at - item.start
20
34
  return {
21
35
  ...item,
22
36
  end: at,
23
- ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur } : {}),
37
+ ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur * s } : {}),
24
38
  }
25
39
  }
26
40
 
27
41
  function trimClipStart(item: VisualItem, at: number): VisualItem {
28
- // Lift-style: clip start advances to the cut end; inPoint advances by the same amount.
42
+ // Lift-style: clip start advances to the cut end; inPoint advances by the same
43
+ // timeline distance converted to SOURCE seconds (`·S`, montaj/speed).
29
44
  // The resulting gap before the clip's new start position is intentional.
30
- const sourceOffset = at - item.start
45
+ const sourceOffset = (at - item.start) * (item.speed ?? 1)
31
46
  return {
32
47
  ...item,
33
48
  start: at,
@@ -35,14 +50,71 @@ function trimClipStart(item: VisualItem, at: number): VisualItem {
35
50
  }
36
51
  }
37
52
 
53
+ /**
54
+ * Split `item.keyframes` (overlay-only; see schema.ts) into the two tracks
55
+ * each fragment of a cut should carry. Shared by BOTH `splitClip` (lift: the
56
+ * right fragment stays at `cut.end`, a gap opens where the cut was) and
57
+ * `cutSingleItem` (collapse: the right fragment moves to `cut.start`, no gap)
58
+ * — the two disagree about WHERE the right fragment ends up on the timeline,
59
+ * but that disagreement never reaches this function. `t` is item-relative
60
+ * TIMELINE seconds (docs/schemas/project.md), unrelated to `speed`'s source
61
+ * scaling — unlike `outPoint`/`inPoint` elsewhere in this file, keyframe
62
+ * times need no `·s` conversion, and this function never reads `item.speed`.
63
+ *
64
+ * Left keeps every point at or before the cut (its own new span is
65
+ * `[item.start, cut.start)`, so `t` is unchanged — the left fragment's start
66
+ * didn't move, in EITHER caller). Right keeps every point at or after the cut
67
+ * and RE-ANCHORS it by subtracting `cut.end − item.start`, so `t` stays
68
+ * relative to the right fragment's own new `start` — wherever that new
69
+ * `start` physically lands, the point is still "this far past the cut" in
70
+ * the ORIGINAL animation curve, which is the one thing both callers need.
71
+ * (For `splitClip` specifically, where the right fragment's new `start` IS
72
+ * `cut.end`, this re-anchoring also happens to preserve the original
73
+ * ABSOLUTE wall-clock instant — a stronger property that only holds for a
74
+ * lift, not a collapse, but follows for free from the same subtraction.) A
75
+ * point exactly at a zero-width split (`splitAtTime`, where `cut.start ===
76
+ * cut.end`) is legitimately kept by BOTH fragments — as the last point of
77
+ * left's curve and the first of right's — so the animation stays continuous
78
+ * across the cut rather than only one side inheriting the boundary value.
79
+ *
80
+ * Each returned track is a fresh array (`normalizeTrack` always allocates),
81
+ * so the two fragments never share a reference with each other or with
82
+ * `item.keyframes` — the bug this exists to fix. Returns `undefined` for a
83
+ * side with no points left, matching `keyframeOps.ts`'s `withTrack`
84
+ * invariant: no lingering empty-`points` track, no lingering empty array.
85
+ */
86
+ function splitKeyframeTracks(item: VisualItem, cut: Cut): { left?: KeyframeTrack[]; right?: KeyframeTrack[] } {
87
+ const tracks = item.keyframes
88
+ if (!tracks || tracks.length === 0) return {}
89
+
90
+ const leftDur = cut.start - item.start
91
+ const rightOffset = cut.end - item.start
92
+
93
+ const left = tracks
94
+ .map(track => normalizeTrack({ prop: track.prop, points: track.points.filter(p => p.t <= leftDur) }))
95
+ .filter((track): track is KeyframeTrack => !!track && track.points.length > 0)
96
+ const right = tracks
97
+ .map(track => normalizeTrack({
98
+ prop: track.prop,
99
+ points: track.points.filter(p => p.t >= rightOffset).map(p => ({ ...p, t: p.t - rightOffset })),
100
+ }))
101
+ .filter((track): track is KeyframeTrack => !!track && track.points.length > 0)
102
+
103
+ return {
104
+ left: left.length > 0 ? left : undefined,
105
+ right: right.length > 0 ? right : undefined,
106
+ }
107
+ }
108
+
38
109
  function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
110
+ const s = item.speed ?? 1
39
111
  const leftDur = cut.start - item.start
40
- const rightSourceOffset = cut.end - item.start
112
+ const rightSourceOffset = (cut.end - item.start) * s
41
113
 
42
114
  const left: VisualItem = {
43
115
  ...item,
44
116
  end: cut.start,
45
- ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur } : {}),
117
+ ...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur * s } : {}),
46
118
  }
47
119
  const right: VisualItem = {
48
120
  ...item,
@@ -50,6 +122,18 @@ function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
50
122
  start: cut.end, // lift: right fragment stays at its original timeline position
51
123
  ...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + rightSourceOffset } : {}),
52
124
  }
125
+
126
+ // Overlay-only (schema.ts). Every OTHER item type either never carries
127
+ // `keyframes` or has it ignored (docs/schemas/project.md), so this never
128
+ // runs for a video/image clip even if one somehow had a stray array.
129
+ if (canKeyframe(item) && item.keyframes && item.keyframes.length > 0) {
130
+ const { left: leftKf, right: rightKf } = splitKeyframeTracks(item, cut)
131
+ if (leftKf) left.keyframes = leftKf
132
+ else delete left.keyframes
133
+ if (rightKf) right.keyframes = rightKf
134
+ else delete right.keyframes
135
+ }
136
+
53
137
  return [left, right]
54
138
  }
55
139
 
@@ -119,29 +203,61 @@ function applyCutToCaptions(segments: CaptionSegment[], cut: Cut): CaptionSegmen
119
203
  * Right fragment starts at cut.start (item shrinks; gap appears at item tail).
120
204
  */
121
205
  function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
206
+ // Timeline↔source conversions carry the clip's speed S (montaj/speed): a
207
+ // timeline delta is `·S` source-seconds, and a source length is `/S` timeline
208
+ // seconds. `·1`/`/1` are exact, so S=1/absent is byte-identical.
209
+ const s = item.speed ?? 1
122
210
  const inPoint = item.inPoint ?? 0
123
- const outPoint = item.outPoint ?? (inPoint + (item.end - item.start))
211
+ const outPoint = item.outPoint ?? (inPoint + (item.end - item.start) * s)
124
212
 
125
- const physStart = inPoint + (cut.start - item.start)
126
- const physEnd = inPoint + (cut.end - item.start)
213
+ const physStart = inPoint + (cut.start - item.start) * s
214
+ const physEnd = inPoint + (cut.end - item.start) * s
127
215
 
128
216
  const result: VisualItem[] = []
129
217
 
218
+ // Same overlay-keyframe split `splitClip` performs, for the same reason: a
219
+ // bare `...item` spread would hand BOTH fragments the same `keyframes` array
220
+ // object, and leave the right fragment's points anchored to the ORIGINAL
221
+ // item's start even though its own `start` has moved — so its animation would
222
+ // play at the wrong times, and every point inside the removed range would sit
223
+ // dead in the data.
224
+ //
225
+ // `splitKeyframeTracks` applies unchanged despite this function COLLAPSING
226
+ // the right fragment onto `cut.start` (where `splitClip`'s LIFT leaves it at
227
+ // `cut.end`, unmoved): a keyframe's `t` is relative to its own fragment's
228
+ // `start`, wherever that new `start` physically lands, so the collapse vs.
229
+ // lift distinction cancels out and the re-anchor is `t - (cut.end -
230
+ // item.start)` either way. It also holds at any speed, since `(outPoint -
231
+ // physEnd) / s` reduces to `item.end - cut.end`, exactly the timeline span
232
+ // the re-anchored points cover.
233
+ const keyed = canKeyframe(item) && !!item.keyframes && item.keyframes.length > 0
234
+ const { left: leftKf, right: rightKf } = keyed ? splitKeyframeTracks(item, cut) : {}
235
+
130
236
  if (physStart > inPoint) {
131
- result.push({
237
+ const left: VisualItem = {
132
238
  ...item,
133
239
  end: cut.start,
134
240
  ...(item.outPoint !== undefined ? { outPoint: physStart } : {}),
135
- })
241
+ }
242
+ if (keyed) {
243
+ if (leftKf) left.keyframes = leftKf
244
+ else delete left.keyframes
245
+ }
246
+ result.push(left)
136
247
  }
137
248
  if (outPoint - physEnd > 0.001) {
138
- result.push({
249
+ const right: VisualItem = {
139
250
  ...item,
140
251
  id: uniqueId(item.id),
141
252
  start: cut.start,
142
- end: cut.start + (outPoint - physEnd),
253
+ end: cut.start + (outPoint - physEnd) / s,
143
254
  ...(item.inPoint !== undefined ? { inPoint: physEnd } : {}),
144
- })
255
+ }
256
+ if (keyed) {
257
+ if (rightKf) right.keyframes = rightKf
258
+ else delete right.keyframes
259
+ }
260
+ result.push(right)
145
261
  }
146
262
 
147
263
  return result
@@ -161,14 +277,22 @@ function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
161
277
  export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
162
278
  if (cut.end <= cut.start) return project
163
279
 
164
- const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
165
- const newPrimaryTrack = primaryTrack.flatMap(item => applyCutToBaseClip(item, cut))
280
+ const newTracks = mapTrackItems(project, (items, i) =>
281
+ i === 0 ? items.flatMap(item => applyCutToBaseClip(item, cut)) : items,
282
+ )
166
283
 
167
284
  const newCaptions = project.captions
168
285
  ? { ...project.captions, segments: applyCutToCaptions(project.captions.segments, cut) }
169
286
  : project.captions
170
287
 
171
- return { ...project, tracks: [newPrimaryTrack, ...overlayTracks], captions: newCaptions }
288
+ return { ...project, tracks: newTracks, captions: newCaptions }
289
+ }
290
+
291
+ /** Options for `collapseGaps`. */
292
+ export interface CollapseGapsOptions {
293
+ /** Remap `project.captions` with the same shifts the clips get. Default true
294
+ * — see the "exactly one caption-mover" note on `collapseGaps`. */
295
+ remapCaptions?: boolean
172
296
  }
173
297
 
174
298
  /**
@@ -180,9 +304,25 @@ export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
180
304
  * tracks[0] if no video track exists.
181
305
  *
182
306
  * Returns the same project reference if no gaps exist (safe to call always).
307
+ *
308
+ * EXACTLY ONE CAPTION-MOVER PER COMPOSITION. `applyCutToTracks` ripples captions
309
+ * itself (via `applyCutToCaptions`), and `collapseGaps` remaps them too — so
310
+ * composing those two shifts every caption TWICE. That is why the audio-polish
311
+ * path (`video/audioPolish.ts`) passes `remapCaptions: false`. By contrast
312
+ * `deleteSelection` is tracks-and-audio vocabulary only and never touches
313
+ * `project.captions`, which is why `deleteSelection` + `collapseGaps`
314
+ * (Timeline.tsx's delete keymap) is safe with the default. Naming both the
315
+ * unsafe and the safe composition is what makes this checkable.
316
+ *
317
+ * `remapCaptions` therefore defaults to TRUE — today's behaviour, byte-identical
318
+ * for every call site that passes no options — and is opted OUT of only by a
319
+ * caller that has already moved the captions itself.
183
320
  */
184
- export function collapseGaps<P extends Project>(project: P): P {
185
- const tracks = project.tracks ?? []
321
+ export function collapseGaps<P extends Project>(
322
+ project: P,
323
+ { remapCaptions = true }: CollapseGapsOptions = {},
324
+ ): P {
325
+ const tracks = trackItems(project)
186
326
 
187
327
  const primaryIdx = tracks.findIndex(t => t.some(c => c.type === 'video'))
188
328
  const effectiveIdx = primaryIdx >= 0 ? primaryIdx : 0
@@ -214,9 +354,9 @@ export function collapseGaps<P extends Project>(project: P): P {
214
354
  return entry?.delta ?? 0
215
355
  }
216
356
 
217
- const newTracks = tracks.map((track, i) => {
357
+ const newTracks = mapTrackItems(project, (items, i) => {
218
358
  if (i === effectiveIdx) return compacted
219
- return track.map(clip => {
359
+ return items.map(clip => {
220
360
  const d = applyShift(clip.start, clip.end)
221
361
  if (d === 0) return clip
222
362
  return { ...clip, start: clip.start + d, end: clip.end + d }
@@ -224,7 +364,7 @@ export function collapseGaps<P extends Project>(project: P): P {
224
364
  })
225
365
 
226
366
  let newCaptions = project.captions
227
- if (newCaptions) {
367
+ if (remapCaptions && newCaptions) {
228
368
  const segments = newCaptions.segments.map(seg => {
229
369
  const d = applyShift(seg.start, seg.end)
230
370
  if (d === 0) return seg
@@ -255,7 +395,8 @@ export function collapseGaps<P extends Project>(project: P): P {
255
395
  export function applyCutToItem<P extends Project>(project: P, itemId: string, cut: Cut): P {
256
396
  if (cut.end <= cut.start) return project
257
397
 
258
- const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
398
+ const tracks = trackItems(project)
399
+ const primaryTrack = tracks[0] ?? []
259
400
 
260
401
  // ── Primary track ──
261
402
  const primaryIdx = primaryTrack.findIndex(item => item.id === itemId)
@@ -283,12 +424,19 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
283
424
  newCaptions = { ...newCaptions, segments: merged }
284
425
  }
285
426
 
286
- return { ...project, tracks: [newPrimary, ...overlayTracks], captions: newCaptions }
427
+ return {
428
+ ...project,
429
+ tracks: mapTrackItems(project, (items, i) => (i === 0 ? newPrimary : items)),
430
+ captions: newCaptions,
431
+ }
287
432
  }
288
433
 
289
434
  // ── Overlay tracks ──
290
- for (let ti = 0; ti < overlayTracks.length; ti++) {
291
- const track = overlayTracks[ti]
435
+ // `ti` is an ABSOLUTE track index (the loop starts at 1, just above the
436
+ // primary track), so the rebuild below addresses the same track the item was
437
+ // found on without an off-by-one.
438
+ for (let ti = 1; ti < tracks.length; ti++) {
439
+ const track = tracks[ti]
292
440
  const itemIdx = track.findIndex(item => item.id === itemId)
293
441
  if (itemIdx === -1) continue
294
442
 
@@ -303,8 +451,7 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
303
451
  ...cutSingleItem(item, clamped),
304
452
  ...track.slice(itemIdx + 1),
305
453
  ]
306
- const newOverlays = overlayTracks.map((t, i) => (i === ti ? newTrack : t))
307
- return { ...project, tracks: [primaryTrack, ...newOverlays] }
454
+ return { ...project, tracks: mapTrackItems(project, (items, i) => (i === ti ? newTrack : items)) }
308
455
  }
309
456
 
310
457
  return project // itemId not found
@@ -313,19 +460,39 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
313
460
  // ── Audio split helper ────────────────────────────────────────────────────
314
461
 
315
462
  function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack] {
316
- const inPoint = track.inPoint ?? 0
317
- const sourceOffset = at - track.start
463
+ // Resolve the ORIGINAL track's effective source window the same way the canvas
464
+ // waveform painter (`audioTrackSourceWindow`) and the DOM `AudioWaveformLayer`
465
+ // default it — `inPoint ?? 0`, `outPoint ?? sourceDuration ?? span` — then hand
466
+ // BOTH halves a fully-specified [inPoint, outPoint] window. Audio always runs at
467
+ // speed 1, so the split point's source offset equals its timeline offset.
468
+ //
469
+ // The right half MUST carry an explicit `outPoint`. Previously it only inherited
470
+ // whatever the source had, so a track with no `outPoint`/`sourceDuration` left
471
+ // the fragment's source end unknown; `audioTrackSourceWindow` then fell back to
472
+ // the fragment's own `end - start` (its timeline span, not the source end),
473
+ // yielding a truncated window — and, for any split past the track's midpoint, a
474
+ // non-positive one that the painter skips entirely. That is why the new fragment
475
+ // rendered as a solid block with no waveform while the left half (which already
476
+ // got an explicit `outPoint`) kept its bars. Slicing by src+window is the
477
+ // codebase's design (peaks are fetched per source window and cached by
478
+ // src+window+bucket), so the fix is to give the fragment the correct window; no
479
+ // extra decode beyond the one cheap windowed peaks fetch each new half needs.
480
+ const inPoint = track.inPoint ?? 0
481
+ const outPoint = track.outPoint ?? track.sourceDuration ?? (track.end - track.start)
482
+ const splitPoint = inPoint + (at - track.start)
318
483
 
319
484
  const left: AudioTrack = {
320
485
  ...track,
321
486
  end: at,
322
- outPoint: inPoint + sourceOffset,
487
+ inPoint,
488
+ outPoint: splitPoint,
323
489
  }
324
490
  const right: AudioTrack = {
325
491
  ...track,
326
492
  id: uniqueId(track.id),
327
493
  start: at,
328
- inPoint: inPoint + sourceOffset,
494
+ inPoint: splitPoint,
495
+ outPoint,
329
496
  }
330
497
  return [left, right]
331
498
  }
@@ -341,15 +508,14 @@ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack
341
508
  export function splitAtTime<P extends Project>(project: P, at: number, itemId: string | null): P {
342
509
  let changed = false
343
510
 
344
- const newTracks = (project.tracks ?? []).map(track => {
345
- const next = track.flatMap(item => {
511
+ const newTracks = mapTrackItems(project, items =>
512
+ items.flatMap(item => {
346
513
  if (itemId !== null && item.id !== itemId) return [item]
347
514
  if (at <= item.start || at >= item.end) return [item] // playhead not inside this clip
348
515
  changed = true
349
516
  return splitClip(item, { start: at, end: at })
350
- })
351
- return next
352
- })
517
+ }),
518
+ )
353
519
 
354
520
  // Also split audio tracks
355
521
  const audioTracks = project.audio?.tracks ?? []
@@ -367,3 +533,513 @@ export function splitAtTime<P extends Project>(project: P, at: number, itemId: s
367
533
  audio: { ...project.audio, tracks: newAudioTracks },
368
534
  }
369
535
  }
536
+
537
+ // ── Trim ops (ripple / roll / slip / slide) ─────────────────────────────────
538
+ //
539
+ // Four pure editing ops over the same data the cut engine above works on. Rules
540
+ // shared by all four, matching the ops above:
541
+ //
542
+ // • in/outPoints are ORIGINAL SOURCE coordinates; `normalizedInPoint` is a
543
+ // cache origin and is never rewritten here (see schema.ts).
544
+ // • Source points are written only when the item already carries them, so an
545
+ // op never invents an inPoint/outPoint on an item that had none.
546
+ // • A clip's per-clip speed S (`item.speed ?? 1`, montaj/speed) is the timeline
547
+ // ↔source scale: `outPoint − inPoint === S·(end − start)`. A timeline delta
548
+ // converts to a SOURCE delta by `·S`, and a source length back to a timeline
549
+ // length by `/S`. `·1`/`/1` are exact, so every op is byte-identical at
550
+ // S=1/absent. MIN_DURATION clamps stay in TIMELINE terms (clip spans are
551
+ // timeline); only the source-media bounds (`inPoint ≥ 0`, `outPoint ≤
552
+ // sourceDuration`) pick up the `/S` factor, so a sped clip can't be trimmed
553
+ // to a negative or oversized source window.
554
+ // • `sourceDuration` absent ⇒ the source end is unknown ⇒ no upper clamp,
555
+ // the same `?? Infinity` convention `timeline/multiSelectOps.ts` uses.
556
+ // • Every op returns the SAME project reference when it would change nothing.
557
+ //
558
+ // AUDIO COUPLING: `project.audio.tracks` is never moved by these ops, matching
559
+ // `collapseGaps` — music beds and voiceover are timed independently of the
560
+ // visual track, so rippling video must not desync them. `splitAtTime` is the
561
+ // only op in this file that reaches into audio, and it splits rather than
562
+ // shifts. Ripple targets are visual items only; an audio-track id is a no-op.
563
+
564
+ /** Locate an item by id in a project's ITEMS — the `trackItems(project)` view,
565
+ * not `project.tracks` — so `{ti, ii}` indexes straight back into that view.
566
+ * `ti` is also the track's index in `project.tracks`: `trackItems` preserves
567
+ * track order, so a `mapTrackItems` rebuild addresses the same track. */
568
+ function findItem(tracks: VisualItem[][], itemId: string): { ti: number; ii: number } | null {
569
+ for (let ti = 0; ti < tracks.length; ti++) {
570
+ const ii = tracks[ti].findIndex(item => item.id === itemId)
571
+ if (ii !== -1) return { ti, ii }
572
+ }
573
+ return null
574
+ }
575
+
576
+ /** Source window of an item in original-source coordinates, defaulted the same
577
+ * way `cutSingleItem` defaults it — the synthesized length carries speed S
578
+ * (`(end − start)·S`) so `outPoint − inPoint === S·(end − start)` holds whether
579
+ * outPoint was stored or defaulted. */
580
+ function sourceWindow(item: VisualItem): { inPoint: number; outPoint: number } {
581
+ const inPoint = item.inPoint ?? 0
582
+ return { inPoint, outPoint: item.outPoint ?? (inPoint + (item.end - item.start) * (item.speed ?? 1)) }
583
+ }
584
+
585
+ /** Shift the segments whose midpoint falls inside `window` by `delta`, leaving
586
+ * every other segment at the same reference. Midpoint ownership is the rule
587
+ * `collapseGaps` already uses to decide which clip a caption belongs to. */
588
+ function shiftCaptionsInWindow(
589
+ segments: CaptionSegment[],
590
+ window: { start: number; end: number },
591
+ delta: number,
592
+ ): CaptionSegment[] {
593
+ return segments.map(seg => {
594
+ const mid = (seg.start + seg.end) / 2
595
+ if (mid < window.start || mid >= window.end) return seg
596
+ return {
597
+ ...seg,
598
+ start: seg.start + delta,
599
+ end: seg.end + delta,
600
+ words: seg.words?.map(w => ({ ...w, start: w.start + delta, end: w.end + delta })),
601
+ }
602
+ })
603
+ }
604
+
605
+ /**
606
+ * Delete an item and close the gap it leaves by pulling later timeline content
607
+ * earlier by its duration.
608
+ *
609
+ * Contrast with `collapseGaps`, which normalizes EVERY gap in the primary track:
610
+ * ripple-delete shifts only content that starts at or after the deletion point,
611
+ * so gaps the editor placed deliberately earlier in the timeline survive.
612
+ *
613
+ * - Items in every track (primary and overlay) whose `start` is at/after the
614
+ * deleted item's `end` shift earlier; items overlapping the deleted window are
615
+ * not "subsequent" and stay put. Shifted items move on the timeline only —
616
+ * their source windows are untouched.
617
+ * - Captions are remapped with the same `applyCutToCaptions` rules the lift cut
618
+ * uses: segments inside the deleted window are dropped, partials are trimmed,
619
+ * and later segments (and their words) shift by the deleted duration.
620
+ * - Audio tracks are untouched (see AUDIO COUPLING above).
621
+ * - Empty tracks are kept, as everywhere else in this file.
622
+ * - Returns the same project reference if `itemId` names no visual item.
623
+ */
624
+ export function rippleDelete<P extends Project>(project: P, itemId: string): P {
625
+ const tracks = trackItems(project)
626
+ const found = findItem(tracks, itemId)
627
+ if (!found) return project
628
+
629
+ const item = tracks[found.ti][found.ii]
630
+ const duration = item.end - item.start
631
+
632
+ const newTracks = mapTrackItems(project, items =>
633
+ items
634
+ .filter(other => other.id !== itemId)
635
+ .map(other =>
636
+ duration > EPSILON && other.start >= item.end - EPSILON
637
+ ? { ...other, start: other.start - duration, end: other.end - duration }
638
+ : other,
639
+ ),
640
+ )
641
+
642
+ const newCaptions = project.captions && duration > EPSILON
643
+ ? {
644
+ ...project.captions,
645
+ segments: applyCutToCaptions(project.captions.segments, { start: item.start, end: item.end }),
646
+ }
647
+ : project.captions
648
+
649
+ return { ...project, tracks: newTracks, captions: newCaptions }
650
+ }
651
+
652
+ /**
653
+ * Move the boundary shared by two adjacent clips: the left clip's outPoint and
654
+ * the right clip's inPoint travel together, so the pair's combined duration and
655
+ * every other item on the timeline stay exactly where they are.
656
+ *
657
+ * `delta` is the boundary movement in seconds (positive = later) and is clamped,
658
+ * not rejected, so a drag past a limit parks the boundary at that limit:
659
+ * - neither clip may drop below MIN_DURATION;
660
+ * - the left clip may not run past the end of its source media;
661
+ * - the right clip may not start before the start of its source media.
662
+ * Source clamps apply to video items only; images/overlays roll geometrically.
663
+ *
664
+ * Captions and audio are untouched — a roll swaps which source frames play at
665
+ * the boundary without moving anything on the timeline.
666
+ *
667
+ * Returns the same project reference when either id is missing, the two clips
668
+ * are on different tracks, they are not adjacent (which is also what a reversed
669
+ * argument pair looks like), or the clamped movement is zero.
670
+ */
671
+ export function rollEdit<P extends Project>(
672
+ project: P,
673
+ leftItemId: string,
674
+ rightItemId: string,
675
+ delta: number,
676
+ ): P {
677
+ const tracks = trackItems(project)
678
+ const l = findItem(tracks, leftItemId)
679
+ const r = findItem(tracks, rightItemId)
680
+ if (!l || !r) return project
681
+ if (l.ti !== r.ti) return project // a boundary only exists within one track
682
+
683
+ const left = tracks[l.ti][l.ii]
684
+ const right = tracks[r.ti][r.ii]
685
+ if (Math.abs(left.end - right.start) > EPSILON) return project // not adjacent
686
+
687
+ const { outPoint: leftOut } = sourceWindow(left)
688
+ const { inPoint: rightIn } = sourceWindow(right)
689
+ // The two clips may run at different speeds: the boundary moves `d` on the
690
+ // TIMELINE, so each clip's source point moves by `d·S` in ITS OWN source, and
691
+ // each source-media clamp on `d` is that clip's source room `/S`.
692
+ const sLeft = left.speed ?? 1
693
+ const sRight = right.speed ?? 1
694
+
695
+ let minDelta = left.start + MIN_DURATION - left.end
696
+ let maxDelta = right.end - MIN_DURATION - right.start
697
+ if (left.type === 'video') maxDelta = Math.min(maxDelta, ((left.sourceDuration ?? Infinity) - leftOut) / sLeft)
698
+ if (right.type === 'video') minDelta = Math.max(minDelta, -rightIn / sRight)
699
+ if (minDelta > maxDelta) return project // already past both limits — nothing safe to do
700
+
701
+ const d = Math.max(minDelta, Math.min(delta, maxDelta))
702
+ if (Math.abs(d) <= EPSILON) return project
703
+
704
+ const newLeft: VisualItem = {
705
+ ...left,
706
+ end: left.end + d,
707
+ ...(left.outPoint !== undefined ? { outPoint: left.outPoint + d * sLeft } : {}),
708
+ }
709
+ const newRight: VisualItem = {
710
+ ...right,
711
+ start: right.start + d,
712
+ ...(right.inPoint !== undefined ? { inPoint: right.inPoint + d * sRight } : {}),
713
+ }
714
+ const newTrack = tracks[l.ti].map(item =>
715
+ item.id === left.id ? newLeft : item.id === right.id ? newRight : item,
716
+ )
717
+
718
+ return { ...project, tracks: mapTrackItems(project, (items, i) => (i === l.ti ? newTrack : items)) }
719
+ }
720
+
721
+ /**
722
+ * Slide an item's source window through its media while the item keeps its exact
723
+ * timeline position: for a `delta`-second timeline drag `inPoint` and `outPoint`
724
+ * both move by `delta·S` in source (montaj/speed), `start` and `end` do not. The
725
+ * window length never changes, so MIN_DURATION cannot bind — only the
726
+ * source-media bounds do (`inPoint` >= 0, `outPoint` <= sourceDuration), and each
727
+ * bound on `delta` is that room `/S`.
728
+ *
729
+ * Nothing else on the timeline is affected, so captions and audio are untouched.
730
+ *
731
+ * Returns the same project reference when the item is missing, is not a video
732
+ * clip, carries no source window to slip, or the clamped movement is zero.
733
+ */
734
+ export function slipItem<P extends Project>(project: P, itemId: string, delta: number): P {
735
+ const tracks = trackItems(project)
736
+ const found = findItem(tracks, itemId)
737
+ if (!found) return project
738
+
739
+ const item = tracks[found.ti][found.ii]
740
+ if (item.type !== 'video') return project // no source media
741
+ if (item.inPoint === undefined && item.outPoint === undefined) return project
742
+
743
+ const s = item.speed ?? 1
744
+ const { inPoint, outPoint } = sourceWindow(item)
745
+ const minDelta = -inPoint / s
746
+ const maxDelta = ((item.sourceDuration ?? Infinity) - outPoint) / s
747
+ if (minDelta > maxDelta) return project
748
+
749
+ const d = Math.max(minDelta, Math.min(delta, maxDelta))
750
+ if (Math.abs(d) <= EPSILON) return project
751
+
752
+ const newItem: VisualItem = {
753
+ ...item,
754
+ ...(item.inPoint !== undefined ? { inPoint: item.inPoint + d * s } : {}),
755
+ ...(item.outPoint !== undefined ? { outPoint: item.outPoint + d * s } : {}),
756
+ }
757
+ const newTrack = tracks[found.ti].map(other => (other.id === item.id ? newItem : other))
758
+
759
+ return { ...project, tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)) }
760
+ }
761
+
762
+ /**
763
+ * Move an item along the timeline with its source window unchanged, letting its
764
+ * adjacent neighbors absorb the movement: the previous neighbor's outPoint
765
+ * extends or shrinks to meet the item's new start, and the next neighbor's
766
+ * inPoint does the same at its new end. The three-clip span therefore keeps its
767
+ * total duration and nothing outside it moves.
768
+ *
769
+ * `delta` is clamped rather than rejected:
770
+ * - neither neighbor may drop below MIN_DURATION;
771
+ * - the previous neighbor may not extend past the end of its source media;
772
+ * - the next neighbor may not extend before the start of its source media;
773
+ * - the item may not cross the timeline origin.
774
+ * A neighbor that is absent or separated by a gap absorbs nothing and imposes no
775
+ * limit — the item simply moves through the empty space.
776
+ *
777
+ * Captions whose midpoint sits over the item's OLD window travel with it, the
778
+ * same midpoint-ownership rule `collapseGaps` uses; captions over the neighbors
779
+ * do not move, because the neighbors' existing content does not move either.
780
+ * Audio is untouched.
781
+ *
782
+ * Returns the same project reference when the item is missing or the clamped
783
+ * movement is zero.
784
+ */
785
+ export function slideItem<P extends Project>(project: P, itemId: string, delta: number): P {
786
+ const tracks = trackItems(project)
787
+ const found = findItem(tracks, itemId)
788
+ if (!found) return project
789
+
790
+ const track = tracks[found.ti]
791
+ const item = track[found.ii]
792
+
793
+ // Neighbors are the items either side of this one in TIMELINE order, which is
794
+ // not necessarily array order.
795
+ const sorted = [...track].sort((a, b) => a.start - b.start)
796
+ const pos = sorted.findIndex(other => other.id === itemId)
797
+ const before = pos > 0 ? sorted[pos - 1] : undefined
798
+ const after = pos < sorted.length - 1 ? sorted[pos + 1] : undefined
799
+ const prev = before && Math.abs(before.end - item.start) <= EPSILON ? before : undefined
800
+ const next = after && Math.abs(after.start - item.end) <= EPSILON ? after : undefined
801
+
802
+ // A neighbor absorbs the move by re-timing its source window: the previous
803
+ // neighbor's outPoint and the next neighbor's inPoint travel `d·S` in each
804
+ // neighbor's OWN source (montaj/speed), so each neighbor's source-media bound
805
+ // on `d` is its room `/S`. The timeline MIN_DURATION clamps are unaffected.
806
+ const sPrev = prev?.speed ?? 1
807
+ const sNext = next?.speed ?? 1
808
+ let minDelta = -item.start
809
+ let maxDelta = Infinity
810
+ if (prev) {
811
+ minDelta = Math.max(minDelta, MIN_DURATION - (prev.end - prev.start))
812
+ if (prev.type === 'video') {
813
+ maxDelta = Math.min(maxDelta, ((prev.sourceDuration ?? Infinity) - sourceWindow(prev).outPoint) / sPrev)
814
+ }
815
+ }
816
+ if (next) {
817
+ maxDelta = Math.min(maxDelta, (next.end - next.start) - MIN_DURATION)
818
+ if (next.type === 'video') minDelta = Math.max(minDelta, -sourceWindow(next).inPoint / sNext)
819
+ }
820
+ if (minDelta > maxDelta) return project
821
+
822
+ const d = Math.max(minDelta, Math.min(delta, maxDelta))
823
+ if (Math.abs(d) <= EPSILON) return project
824
+
825
+ const moved = new Map<string, VisualItem>()
826
+ moved.set(item.id, { ...item, start: item.start + d, end: item.end + d })
827
+ if (prev) {
828
+ moved.set(prev.id, {
829
+ ...prev,
830
+ end: prev.end + d,
831
+ ...(prev.outPoint !== undefined ? { outPoint: prev.outPoint + d * sPrev } : {}),
832
+ })
833
+ }
834
+ if (next) {
835
+ moved.set(next.id, {
836
+ ...next,
837
+ start: next.start + d,
838
+ ...(next.inPoint !== undefined ? { inPoint: next.inPoint + d * sNext } : {}),
839
+ })
840
+ }
841
+ const newTrack = track.map(other => moved.get(other.id) ?? other)
842
+
843
+ const newCaptions = project.captions
844
+ ? {
845
+ ...project.captions,
846
+ segments: shiftCaptionsInWindow(
847
+ project.captions.segments,
848
+ { start: item.start, end: item.end },
849
+ d,
850
+ ),
851
+ }
852
+ : project.captions
853
+
854
+ return {
855
+ ...project,
856
+ tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)),
857
+ captions: newCaptions,
858
+ }
859
+ }
860
+
861
+ // ── Speed ────────────────────────────────────────────────────────────────────
862
+
863
+ /** Speed bounds (montaj/speed). Mirrors the schema note on `VisualItem.speed`
864
+ * and `engine/validate.py`'s range check. */
865
+ const MIN_SPEED = 0.25
866
+ const MAX_SPEED = 4
867
+
868
+ /**
869
+ * Set a clip's per-clip playback speed and re-fit its timeline span to the same
870
+ * source range at the new rate.
871
+ *
872
+ * `speed` is clamped to [MIN_SPEED, MAX_SPEED]. `inPoint`/`outPoint` are
873
+ * speed-independent ORIGINAL-source coordinates and are left untouched; only the
874
+ * timeline `end` moves:
875
+ *
876
+ * end = start + (effectiveOutPoint − effectiveInPoint) / speed
877
+ *
878
+ * The effective window comes from the same `sourceWindow` helper the trim ops
879
+ * use, so the source length is read consistently whether `outPoint` was stored
880
+ * or synthesized — which makes a re-speed from ANY prior S correct, since the
881
+ * source length is speed-invariant. The op re-times the ONE clip only; it
882
+ * deliberately does not close the gap it opens (speeding up) or the overlap it
883
+ * creates (slowing down) — the caller decides based on the magnet toggle
884
+ * (`collapseGaps`).
885
+ *
886
+ * Returns a new Project. Same reference back when `clipId` names no item, or
887
+ * the item is not a video clip — speed is video-only per the schema, so an
888
+ * image/overlay must not pick up a `speed` field or a rescaled `end`.
889
+ */
890
+ export function setClipSpeed<P extends Project>(project: P, clipId: string, speed: number): P {
891
+ const tracks = trackItems(project)
892
+ const found = findItem(tracks, clipId)
893
+ if (!found) return project
894
+
895
+ const item = tracks[found.ti][found.ii]
896
+ if (item.type !== 'video') return project
897
+
898
+ const clamped = Math.min(MAX_SPEED, Math.max(MIN_SPEED, speed))
899
+ const { inPoint, outPoint } = sourceWindow(item)
900
+
901
+ const newItem: VisualItem = {
902
+ ...item,
903
+ speed: clamped,
904
+ end: item.start + (outPoint - inPoint) / clamped,
905
+ }
906
+ const newTrack = tracks[found.ti].map(other => (other.id === clipId ? newItem : other))
907
+
908
+ return { ...project, tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)) }
909
+ }
910
+
911
+ // ── Insert (place a new clip from the media bin) ──────────────────────────────
912
+
913
+ /** The footage a media-bin drop hands to `insertClipAt`: enough to place a
914
+ * whole-source video item, nothing more. `sourceDuration` is required — it is
915
+ * both the placed clip's timeline length and its source window's end. */
916
+ export interface NewClipInput {
917
+ src: string
918
+ proxySrc?: string
919
+ sourceDuration: number
920
+ sourceWidth?: number
921
+ sourceHeight?: number
922
+ }
923
+
924
+ /**
925
+ * A generic, collision-resistant timeline-item id — distinct in SHAPE from the
926
+ * `clip-<n>` ids init hands source clips, and from the `<base>_split_…` ids
927
+ * `uniqueId` mints for split fragments, so a bin placement can never be mistaken
928
+ * for either. Date.now()+random is fine in app runtime code (this is not a pure
929
+ * transform of the project).
930
+ */
931
+ export function newClipId(): string {
932
+ // Two independent random draws (each ~52 bits, base36) plus the timestamp:
933
+ // a single 4-char draw is only ~1.7M-wide, so a tight burst of ids minted in
934
+ // the same millisecond (Date.now() constant) collides at a birthday rate that
935
+ // is small but real. Doubling the random tail makes a collision negligible.
936
+ const rand = Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 8)
937
+ return `clip_${Date.now().toString(36)}${rand}`
938
+ }
939
+
940
+ /** True when [s, e) overlaps any item's window by more than float slop. Touching
941
+ * edges (butt-adjacency) are NOT an overlap, matching the EPSILON tolerance the
942
+ * rest of this file uses for adjacency. Exported because `timeline/placement.ts`
943
+ * reuses it for its own free-window check — the butt-adjacency tolerance
944
+ * (`EPSILON`) is defined exactly once, here, rather than redeclared. */
945
+ export function overlapsAny(s: number, e: number, items: VisualItem[]): boolean {
946
+ return items.some(it => Math.min(e, it.end) - Math.max(s, it.start) > EPSILON)
947
+ }
948
+
949
+ /**
950
+ * Place a NEW whole-source video clip on the track identified by `trackId`,
951
+ * returning a new Project (inputs — the project, its tracks/items, and the
952
+ * `clip` descriptor — are never mutated).
953
+ *
954
+ * The built item is a whole-source window: `inPoint 0 → outPoint sourceDuration`,
955
+ * `start → end` spanning exactly `sourceDuration` on the timeline, with a fresh
956
+ * `newClipId()`. `atTime` is clamped to `>= 0`.
957
+ *
958
+ * Placement follows the magnet, exactly as the other timeline ops treat it:
959
+ *
960
+ * • `ripple: true` — shift-right insert. If the drop point lands inside an
961
+ * existing item's span (straddles it), the effective insertion point snaps
962
+ * to that item's NEAREST edge — start or end, ties going to START — so the
963
+ * new clip never lands inside another clip's window. Every existing item
964
+ * whose `start` is at/after the (possibly snapped) insertion point then
965
+ * moves right by the new clip's length, and the new clip takes the freed
966
+ * slot at the insertion point. This leaves NO overlaps and preserves order;
967
+ * splitting the straddling clip is intentionally NOT done here.
968
+ *
969
+ * The tie-break is Sam's product call (2026-08-25): "never split a clip,
970
+ * always keep them whole — if the drop is over 50% into the clip, make
971
+ * room to the RIGHT of it (push everything else right); at or before 50%,
972
+ * make room to the LEFT of it." A drop in the first half of a clip reads
973
+ * as "put the new clip before this one", so an exact-midpoint drop (the
974
+ * tie) resolves the same way — to the clip's start, not its end.
975
+ *
976
+ * • `ripple: false` — place at the drop point WITHOUT moving anything, but
977
+ * never overlapping: if the drop window collides, the new clip snaps to the
978
+ * nearest free gap at/after the drop point that fits it, and if no such gap
979
+ * fits it is appended after the last item on the track. No overlaps result.
980
+ *
981
+ * The returned track's items are sorted by `start`. An unknown `trackId` returns
982
+ * the project unchanged (matching how the sibling ops no-op on a missing id).
983
+ */
984
+ export function insertClipAt<P extends Project>(
985
+ project: P,
986
+ trackId: string,
987
+ clip: NewClipInput,
988
+ atTime: number,
989
+ opts: { ripple: boolean },
990
+ ): P {
991
+ const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
992
+ const trackIdx = tracks.findIndex(t => t.id === trackId)
993
+ if (trackIdx === -1) return project
994
+
995
+ const len = clip.sourceDuration
996
+ const dropAt = Math.max(0, atTime)
997
+ const existing = tracks[trackIdx].items
998
+
999
+ // Where the new clip's timeline window actually lands.
1000
+ let start: number
1001
+ let shifted = existing
1002
+ if (opts.ripple) {
1003
+ // If the drop point lands inside an existing item's span, snap the
1004
+ // effective insertion point to that item's nearest edge (ties → START —
1005
+ // Sam's call, see this function's doc comment) so the new clip never
1006
+ // lands inside another clip's window. At most one item can straddle a
1007
+ // point on a non-overlapping track.
1008
+ const straddler = existing.find(it => it.start < dropAt - EPSILON && it.end > dropAt + EPSILON)
1009
+ const insertAt = straddler
1010
+ ? (dropAt - straddler.start <= straddler.end - dropAt ? straddler.start : straddler.end)
1011
+ : dropAt
1012
+
1013
+ start = insertAt
1014
+ shifted = existing.map(it =>
1015
+ it.start >= insertAt - EPSILON ? { ...it, start: it.start + len, end: it.end + len } : it,
1016
+ )
1017
+ } else {
1018
+ // Earliest start >= dropAt whose [start, start+len] window is free. The
1019
+ // candidate starts are the drop point itself and the end of every item
1020
+ // at/after it; the last item's end always fits (nothing follows it).
1021
+ const candidates = [
1022
+ dropAt,
1023
+ ...existing.filter(it => it.end >= dropAt - EPSILON).map(it => it.end),
1024
+ ].sort((a, b) => a - b)
1025
+ start = candidates.find(s => !overlapsAny(s, s + len, existing)) ?? dropAt
1026
+ }
1027
+
1028
+ const placed: VisualItem = {
1029
+ id: newClipId(),
1030
+ type: 'video',
1031
+ src: clip.src,
1032
+ start,
1033
+ end: start + len,
1034
+ inPoint: 0,
1035
+ outPoint: len,
1036
+ sourceDuration: len,
1037
+ ...(clip.proxySrc !== undefined ? { proxySrc: clip.proxySrc } : {}),
1038
+ ...(clip.sourceWidth !== undefined ? { sourceWidth: clip.sourceWidth } : {}),
1039
+ ...(clip.sourceHeight !== undefined ? { sourceHeight: clip.sourceHeight } : {}),
1040
+ }
1041
+
1042
+ const newItems = [...shifted, placed].sort((a, b) => a.start - b.start)
1043
+
1044
+ return { ...project, tracks: mapTrackItems(project, (items, i) => (i === trackIdx ? newItems : items)) }
1045
+ }