@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
@@ -0,0 +1,377 @@
1
+ // Clipboard pure ops: copy / paste / duplicate / paste-attributes over the
2
+ // SAME project shape `video/cuts.ts` transforms — visual items
3
+ // (`project.tracks[*].items`) and audio tracks (`project.audio.tracks`).
4
+ //
5
+ // SCOPE: visual items + audio tracks only. Caption segments are deliberately
6
+ // EXCLUDED from every op below — they carry word timings and have their own
7
+ // editing surface (the caption list / row UI), so duplicating or pasting one
8
+ // over the timeline is not a real workflow. A caption id passed in
9
+ // `selectedIds` is a no-op for that id: it never matches a visual item or an
10
+ // audio track, so it's skipped by construction rather than by an explicit
11
+ // check.
12
+ //
13
+ // CONVENTIONS (mirrors video/cuts.ts):
14
+ // • Every op returns the SAME project reference when it would change
15
+ // nothing — an empty/missing selection or clipboard, or a selection that
16
+ // names no real visual item/audio track (captions only, stale ids).
17
+ // • `mapTrackItems`/`normalizeTracks` (timeline/timeline-model.ts) are the
18
+ // only way this module reads or rebuilds `project.tracks`, so both the
19
+ // legacy array-of-arrays shape and the current `VisualTrack[]` object
20
+ // shape are handled the same way everywhere else in the codebase.
21
+ // • `EPSILON` float slop and the ripple:false "earliest gap that fits"
22
+ // placement below are the SAME tolerance and algorithm `insertClipAt`
23
+ // (cuts.ts) uses, reimplemented here over full copied/duplicated items
24
+ // rather than a single new-clip descriptor — see `pasteAt`'s doc comment
25
+ // for why this does not route through `insertClipAt` itself.
26
+ // • `newClipId()` (cuts.ts) mints every fresh id below; its double-random
27
+ // draw design makes minting several ids in one tick safe.
28
+
29
+ import type { Project } from '../types'
30
+ import type { VisualItem, AudioTrack, VisualTrack } from '../schema'
31
+ import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline/timeline-model'
32
+ import { newClipId } from './cuts'
33
+
34
+ /**
35
+ * Whether two audio tracks share a rendered LANE, per `groupAudioLanes`
36
+ * (timeline/timeline-model.ts) — audio tracks are PARALLEL rows, not one
37
+ * shared timeline, so only same-lane tracks can ever collide.
38
+ *
39
+ * `groupAudioLanes` assigns every lane-less track a lane strictly above the
40
+ * highest EXPLICIT lane in the whole array (a first pass finds that max, a
41
+ * second pass hands out `nextAutoLane++` to each lane-less track in order).
42
+ * Two consequences that make the direct comparison below exactly equivalent
43
+ * to running the real algorithm, without needing this module to carry a
44
+ * `contentDuration` to call it:
45
+ * - a lane-less track's auto-assigned lane is never shared with anything
46
+ * else, regardless of how many other lane-less tracks exist or their
47
+ * array order — so `lane == null` means "my own lane" unconditionally;
48
+ * - two EXPLICIT lanes collide iff their numbers are equal, which is just
49
+ * `===`.
50
+ * So "same lane" reduces to: both tracks declare a lane, and it's the same
51
+ * number.
52
+ */
53
+ function sameAudioLane(a: AudioTrack, b: AudioTrack): boolean {
54
+ return a.lane != null && a.lane === b.lane
55
+ }
56
+
57
+ /** Float slop for overlap/adjacency checks — mirrors the tolerance `cuts.ts`
58
+ * uses for the same purpose. */
59
+ const EPSILON = 0.001
60
+
61
+ // ── Clipboard payload ───────────────────────────────────────────────────────
62
+
63
+ /** A deep-cloned VisualItem snapshot plus the id of the track it was copied
64
+ * FROM, so `pasteAt` can try to land it back on that same track. */
65
+ export interface ClipboardVisualEntry {
66
+ item: VisualItem
67
+ sourceTrackId: string
68
+ }
69
+
70
+ /** Everything a copy captured. Both arrays may be non-empty at once (a mixed
71
+ * clip+audio selection); `copySelection` returns `null` instead of an
72
+ * all-empty payload so callers can no-op on a single falsy check. */
73
+ export interface ClipboardPayload {
74
+ items: ClipboardVisualEntry[]
75
+ audioTracks: AudioTrack[]
76
+ }
77
+
78
+ /**
79
+ * Snapshot every selected visual item and audio track into a clipboard
80
+ * payload. Each snapshot is a DEEP clone (`structuredClone`) — mutating the
81
+ * project afterward can never reach back into a copied payload.
82
+ *
83
+ * Caption segment ids in `selectedIds` are ignored (see the module header).
84
+ *
85
+ * Returns `null` when nothing in `selectedIds` names a real visual item or
86
+ * audio track — including an empty `selectedIds` — so every caller can
87
+ * no-op on `!payload` rather than checking two empty arrays.
88
+ */
89
+ export function copySelection(project: Project, selectedIds: readonly string[]): ClipboardPayload | null {
90
+ const targets = new Set(selectedIds)
91
+ if (targets.size === 0) return null
92
+
93
+ const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
94
+ const items: ClipboardVisualEntry[] = []
95
+ for (const track of tracks) {
96
+ for (const item of track.items) {
97
+ if (targets.has(item.id)) items.push({ item: structuredClone(item), sourceTrackId: track.id })
98
+ }
99
+ }
100
+
101
+ const audioTracks = (project.audio?.tracks ?? [])
102
+ .filter(t => targets.has(t.id))
103
+ .map(t => structuredClone(t))
104
+
105
+ if (items.length === 0 && audioTracks.length === 0) return null
106
+ return { items, audioTracks }
107
+ }
108
+
109
+ // ── Local placement (verbatim clone + ripple:false gap resolution) ─────────
110
+
111
+ /** Earliest `start >= desiredStart` whose `[start, start+duration)` window
112
+ * doesn't overlap anything in `existing` — the same ripple:false placement
113
+ * `insertClipAt` (cuts.ts) uses, reimplemented here over full items instead
114
+ * of a single new-clip descriptor. Candidates are the desired point itself
115
+ * and the end of every existing item at/after it; the last item's end always
116
+ * fits, since nothing follows it. */
117
+ function earliestFittingGap(
118
+ desiredStart: number,
119
+ duration: number,
120
+ existing: ReadonlyArray<{ start: number; end: number }>,
121
+ ): number {
122
+ const overlaps = (s: number, e: number) =>
123
+ existing.some(it => Math.min(e, it.end) - Math.max(s, it.start) > EPSILON)
124
+ const candidates = [
125
+ desiredStart,
126
+ ...existing.filter(it => it.end >= desiredStart - EPSILON).map(it => it.end),
127
+ ].sort((a, b) => a - b)
128
+ return candidates.find(s => !overlaps(s, s + duration)) ?? desiredStart
129
+ }
130
+
131
+ /**
132
+ * Paste a clipboard payload onto the timeline at `atTime`.
133
+ *
134
+ * Every copied `VisualItem` is cloned VERBATIM — type, `inPoint`/`outPoint`,
135
+ * every look field, the `moveItemAcrossTracks` (timeline-model.ts) precedent
136
+ * of carrying a full item across tracks — and given a fresh `newClipId()`.
137
+ * This deliberately does NOT route through `insertClipAt` (cuts.ts:854-914):
138
+ * that function takes a `NewClipInput` and unconditionally mints a fresh
139
+ * whole-source `type:'video'` item, which would un-trim a trimmed clip and
140
+ * corrupt an image/overlay item's fields. Placement here is local to this
141
+ * module instead.
142
+ *
143
+ * The group's relative layout is preserved across BOTH arrays together: every
144
+ * copied item/track keeps its original offset from the earliest-starting
145
+ * member of the whole payload, and that earliest member lands exactly at
146
+ * `atTime` (clamped to `>= 0`) — a multi-item copy pastes back in the same
147
+ * shape it was copied in, just moved as one rigid group.
148
+ *
149
+ * Each visual item lands back on its source track when that track id still
150
+ * exists in `project`; otherwise it falls back to track 0. If the target span
151
+ * collides with what's already on that track (including items already placed
152
+ * earlier in this same paste), the item shifts right to the earliest gap that
153
+ * fits (`earliestFittingGap` above).
154
+ *
155
+ * Audio tracks clone the full `AudioTrack` object the same verbatim way, with
156
+ * `start`/`end` shifted by the same delta and appended to
157
+ * `project.audio.tracks`, gap-resolved the same way against the existing
158
+ * tracks plus anything already pasted IN THE SAME LANE (`sameAudioLane`) —
159
+ * audio lanes are parallel rows, so a track in a different lane (e.g. a
160
+ * voiceover pasted under a much longer music bed) never affects placement.
161
+ *
162
+ * Returns the same project reference when the payload is `null` or captured
163
+ * nothing.
164
+ */
165
+ export function pasteAt<P extends Project>(project: P, payload: ClipboardPayload | null, atTime: number): P {
166
+ if (!payload || (payload.items.length === 0 && payload.audioTracks.length === 0)) return project
167
+
168
+ const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
169
+
170
+ const earliest = Math.min(
171
+ ...payload.items.map(e => e.item.start),
172
+ ...payload.audioTracks.map(t => t.start),
173
+ )
174
+ const delta = Math.max(0, atTime) - earliest
175
+
176
+ const byTrackId = new Map<string, VisualItem[]>(tracks.map(t => [t.id, t.items]))
177
+ const fallbackTrackId = tracks[0]?.id
178
+
179
+ for (const entry of payload.items) {
180
+ const targetId = byTrackId.has(entry.sourceTrackId) ? entry.sourceTrackId : fallbackTrackId
181
+ if (targetId === undefined) continue // no track anywhere in the project to land on
182
+ const existing = byTrackId.get(targetId)!
183
+ const duration = entry.item.end - entry.item.start
184
+ const start = earliestFittingGap(entry.item.start + delta, duration, existing)
185
+ const pasted: VisualItem = { ...entry.item, id: newClipId(), start, end: start + duration }
186
+ byTrackId.set(targetId, [...existing, pasted].sort((a, b) => a.start - b.start))
187
+ }
188
+
189
+ const newTracks = mapTrackItems(project, (items, i) => byTrackId.get(tracks[i].id) ?? items)
190
+
191
+ let audioTracks = project.audio?.tracks ?? []
192
+ for (const track of payload.audioTracks) {
193
+ const duration = track.end - track.start
194
+ // Gap-resolve against SAME-LANE tracks only — see `sameAudioLane` — so a
195
+ // longer clip in a different lane (e.g. a music bed under a voiceover)
196
+ // never shoves this paste past where it actually collides.
197
+ const laneMates = audioTracks.filter(t => sameAudioLane(t, track))
198
+ const start = earliestFittingGap(track.start + delta, duration, laneMates)
199
+ audioTracks = [...audioTracks, { ...track, id: newClipId(), start, end: start + duration }]
200
+ }
201
+
202
+ // Only attach `audio` when there's a reason to: the project already had one
203
+ // (preserve it, even if this paste added nothing to it), or this paste
204
+ // actually added audio tracks. Otherwise leave `project.audio` exactly as
205
+ // it was (`undefined` on an audio-less project) — a visual-only paste must
206
+ // not materialize an empty `audio: { tracks: [] }` and its undo/save entry.
207
+ const audio = project.audio
208
+ ? { ...project.audio, tracks: audioTracks }
209
+ : payload.audioTracks.length > 0
210
+ ? { tracks: audioTracks }
211
+ : project.audio
212
+
213
+ return { ...project, tracks: newTracks, audio }
214
+ }
215
+
216
+ /**
217
+ * Duplicate every selected visual item and audio track in place: each
218
+ * verbatim copy (fresh `newClipId()`, every field otherwise untouched) lands
219
+ * immediately after its original — at the original's own `end` — resolving
220
+ * any collision with `earliestFittingGap`, the same placement `pasteAt` uses.
221
+ *
222
+ * Selected items are duplicated in (track, start) order, one at a time, so a
223
+ * run of adjacent selected clips stacks its duplicates deterministically
224
+ * against each other instead of depending on `selectedIds`' incoming order.
225
+ *
226
+ * Caption segment ids in `selectedIds` are ignored (see the module header).
227
+ *
228
+ * Returns the same project reference when nothing in `selectedIds` names a
229
+ * real visual item or audio track.
230
+ */
231
+ export function duplicateSelection<P extends Project>(project: P, selectedIds: readonly string[]): P {
232
+ const targets = new Set(selectedIds)
233
+ if (targets.size === 0) return project
234
+
235
+ const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
236
+ const byTrackId = new Map<string, VisualItem[]>(tracks.map(t => [t.id, t.items]))
237
+ let changed = false
238
+
239
+ for (const track of tracks) {
240
+ const selected = track.items.filter(it => targets.has(it.id)).sort((a, b) => a.start - b.start)
241
+ for (const item of selected) {
242
+ const existing = byTrackId.get(track.id)!
243
+ const duration = item.end - item.start
244
+ const start = earliestFittingGap(item.end, duration, existing)
245
+ const dup: VisualItem = { ...item, id: newClipId(), start, end: start + duration }
246
+ byTrackId.set(track.id, [...existing, dup].sort((a, b) => a.start - b.start))
247
+ changed = true
248
+ }
249
+ }
250
+
251
+ const newTracks = mapTrackItems(project, (items, i) => byTrackId.get(tracks[i].id) ?? items)
252
+
253
+ const existingAudio = project.audio?.tracks ?? []
254
+ const selectedAudio = existingAudio.filter(t => targets.has(t.id)).sort((a, b) => a.start - b.start)
255
+ let audioTracks = existingAudio
256
+ for (const track of selectedAudio) {
257
+ const duration = track.end - track.start
258
+ // Same-lane scoping — see `sameAudioLane` and its use in `pasteAt` above.
259
+ const laneMates = audioTracks.filter(t => sameAudioLane(t, track))
260
+ const start = earliestFittingGap(track.end, duration, laneMates)
261
+ audioTracks = [...audioTracks, { ...track, id: newClipId(), start, end: start + duration }]
262
+ changed = true
263
+ }
264
+
265
+ if (!changed) return project
266
+ return {
267
+ ...project,
268
+ tracks: newTracks,
269
+ audio: project.audio ? { ...project.audio, tracks: audioTracks } : project.audio,
270
+ }
271
+ }
272
+
273
+ // ── Paste attributes (look fields only, type-gated) ─────────────────────────
274
+
275
+ /**
276
+ * The transferable visual "look" fields, gated on the TARGET item's type —
277
+ * decision 3 of the clipboard-ops spec. `sourceCrop` is deliberately EXCLUDED:
278
+ * it's a source-geometry field (0–1 fractions against THAT source), not a
279
+ * look, so it never belongs in this patch regardless of target type.
280
+ */
281
+ function visualAttributesPatch(source: VisualItem, targetType: VisualItem['type']): Partial<VisualItem> {
282
+ return {
283
+ ...(source.offsetX !== undefined ? { offsetX: source.offsetX } : {}),
284
+ ...(source.offsetY !== undefined ? { offsetY: source.offsetY } : {}),
285
+ ...(source.scale !== undefined ? { scale: source.scale } : {}),
286
+ ...(source.opacity !== undefined ? { opacity: source.opacity } : {}),
287
+ ...(source.rotation !== undefined ? { rotation: source.rotation } : {}),
288
+ ...(targetType === 'image' && source.fit !== undefined ? { fit: source.fit } : {}),
289
+ ...(targetType === 'video' && source.volume !== undefined ? { volume: source.volume } : {}),
290
+ ...(targetType === 'video' && source.muted !== undefined ? { muted: source.muted } : {}),
291
+ ...(targetType === 'video' && source.speed !== undefined ? { speed: source.speed } : {}),
292
+ }
293
+ }
294
+
295
+ /** The transferable audio "look" fields — decision 3's audio→audio set. */
296
+ function audioAttributesPatch(source: AudioTrack): Partial<AudioTrack> {
297
+ return {
298
+ ...(source.volume !== undefined ? { volume: source.volume } : {}),
299
+ ...(source.muted !== undefined ? { muted: source.muted } : {}),
300
+ ...(source.ducking !== undefined ? { ducking: source.ducking } : {}),
301
+ ...(source.fadeIn !== undefined ? { fadeIn: source.fadeIn } : {}),
302
+ ...(source.fadeOut !== undefined ? { fadeOut: source.fadeOut } : {}),
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Copy "look" attributes from the clipboard's FIRST copied visual item (for
308
+ * selected visual items) and FIRST copied audio track (for selected audio
309
+ * tracks) onto every id in `selectedIds`, type-gated per `visualAttributesPatch`
310
+ * / `audioAttributesPatch` above. A selected item never receives fields from
311
+ * the wrong source kind (e.g. a selected audio track never reads
312
+ * `payload.items[0]`) and never receives a field its own type doesn't carry
313
+ * (e.g. `fit` never lands on a video item).
314
+ *
315
+ * Caption segment ids in `selectedIds` are ignored (see the module header).
316
+ *
317
+ * Returns the same project reference when `selectedIds` is empty, the
318
+ * payload is `null`, the payload has neither a visual item nor an audio track
319
+ * to source from, or applying the gated patch would change nothing (e.g. the
320
+ * only selected items are captions, or the source's fields don't survive type
321
+ * gating for any selected target).
322
+ */
323
+ export function pasteAttributes<P extends Project>(
324
+ project: P,
325
+ payload: ClipboardPayload | null,
326
+ selectedIds: readonly string[],
327
+ ): P {
328
+ const targets = new Set(selectedIds)
329
+ if (targets.size === 0 || !payload) return project
330
+
331
+ const sourceVisual = payload.items[0]?.item
332
+ const sourceAudio = payload.audioTracks[0]
333
+ if (!sourceVisual && !sourceAudio) return project
334
+
335
+ let changed = false
336
+ let next = project
337
+
338
+ if (sourceVisual) {
339
+ const patchCache = new Map<VisualItem['type'], Partial<VisualItem>>()
340
+ const patchFor = (type: VisualItem['type']) => {
341
+ let patch = patchCache.get(type)
342
+ if (!patch) {
343
+ patch = visualAttributesPatch(sourceVisual, type)
344
+ patchCache.set(type, patch)
345
+ }
346
+ return patch
347
+ }
348
+
349
+ let visualChanged = false
350
+ const newTracks = mapTrackItems(next, items =>
351
+ items.map(item => {
352
+ if (!targets.has(item.id)) return item
353
+ const patch = patchFor(item.type)
354
+ if (Object.keys(patch).length === 0) return item
355
+ visualChanged = true
356
+ return { ...item, ...patch }
357
+ }),
358
+ )
359
+ if (visualChanged) {
360
+ next = { ...next, tracks: newTracks }
361
+ changed = true
362
+ }
363
+ }
364
+
365
+ if (sourceAudio) {
366
+ const patch = audioAttributesPatch(sourceAudio)
367
+ if (Object.keys(patch).length > 0) {
368
+ for (const track of next.audio?.tracks ?? []) {
369
+ if (!targets.has(track.id)) continue
370
+ next = updateAudioTrack(next, track.id, patch) as P
371
+ changed = true
372
+ }
373
+ }
374
+ }
375
+
376
+ return changed ? next : project
377
+ }