@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,439 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Footage-bin drop wiring — the browser-owned half of dragging a clip from the
4
+ * (host-side) footage bin onto the canvas timeline.
5
+ *
6
+ * The placement math itself (ripple vs snap, whole-source window, id) is proven
7
+ * as pure data over `insertClipAt` in `cuts.insert.test.ts`, and the target-row
8
+ * rule over `placeDroppedClip` in `placement.test.ts`. What can only be shown
9
+ * with a mounted surface is here: that a `drop` reaches the drop target in
10
+ * surface coordinates, that a bin drop lands a new clip at the time AND on the
11
+ * row under the cursor and commits through the same
12
+ * `onProjectChange`+`onOverlayEdit` pair a discrete edit uses, and that a drop
13
+ * carrying neither our MIME nor files is left entirely alone.
14
+ *
15
+ * The second half covers the OS-FILE drop: the package cannot place a `File`
16
+ * itself (no duration, no proxy, no host path), so it only reports where the
17
+ * drop landed through `onImportFilesToTimeline` — and, crucially, does not
18
+ * claim the drag AT ALL when the host passes no such hook, which is the
19
+ * inert-for-Hub/LP guarantee.
20
+ *
21
+ * jsdom has no 2D canvas, no `DragEvent`, and lays everything out at 0×0. The
22
+ * canvas and the rect are stubbed (the same harness the pointer-wiring suite
23
+ * uses); a file drag is simulated by putting `files` and `types: ['Files']` on
24
+ * the DataTransfer stub, which is exactly the pair the handler reads.
25
+ */
26
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
27
+ import { render, act, cleanup } from '@testing-library/react'
28
+ import { createPlaybackClock } from '../../../playback-clock'
29
+ import type { Project } from '../../../../types'
30
+ import { FOOTAGE_DND_MIME, type FootageDropPayload } from '../../../../types'
31
+ import TimelineCanvas from '../TimelineCanvas'
32
+ import { createViewportStore } from '../viewport'
33
+ import { computeTimelineLayout } from '../draw'
34
+ import { trackItems } from '../../timeline-model'
35
+
36
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
37
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
38
+
39
+ function stubContext() {
40
+ return new Proxy({}, {
41
+ get(_t, prop: string) {
42
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
43
+ return () => {}
44
+ },
45
+ set() { return true },
46
+ })
47
+ }
48
+
49
+ beforeEach(() => {
50
+ realGetContext = HTMLCanvasElement.prototype.getContext
51
+ realGetRect = Element.prototype.getBoundingClientRect
52
+ HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
53
+ Element.prototype.getBoundingClientRect = function (this: Element) {
54
+ return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
55
+ }
56
+ vi.useFakeTimers()
57
+ })
58
+
59
+ afterEach(() => {
60
+ cleanup()
61
+ vi.useRealTimers()
62
+ HTMLCanvasElement.prototype.getContext = realGetContext
63
+ Element.prototype.getBoundingClientRect = realGetRect
64
+ })
65
+
66
+ // One video track with two butted clips: c0 [0,4], c1 [4,8].
67
+ const project = {
68
+ id: 'p',
69
+ tracks: [{
70
+ id: 'trk-0',
71
+ items: [
72
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
73
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 8, inPoint: 0, outPoint: 4, sourceDuration: 20 },
74
+ ],
75
+ }],
76
+ audio: { tracks: [] },
77
+ } as unknown as Project
78
+
79
+ const TOTAL_DURATION = 13
80
+
81
+ const PAYLOAD: FootageDropPayload = {
82
+ src: 'new.mp4',
83
+ proxySrc: 'new.proxy.mp4',
84
+ sourceDuration: 2,
85
+ sourceWidth: 1920,
86
+ sourceHeight: 1080,
87
+ name: 'New Clip',
88
+ }
89
+
90
+ function mount(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
91
+ const store = createViewportStore()
92
+ const clock = createPlaybackClock()
93
+ const handlers = {
94
+ onSelectItem: vi.fn(),
95
+ onProjectChange: vi.fn(),
96
+ onOverlayEdit: vi.fn(),
97
+ onInspectClip: vi.fn(),
98
+ onInspectAudio: vi.fn(),
99
+ onHoverScrub: vi.fn(),
100
+ }
101
+
102
+ const utils = render(
103
+ <TimelineCanvas
104
+ project={project}
105
+ clock={clock}
106
+ store={store}
107
+ totalDuration={TOTAL_DURATION}
108
+ fps={30}
109
+ selectedIds={[]}
110
+ {...handlers}
111
+ {...overrides}
112
+ />,
113
+ )
114
+ act(() => { vi.advanceTimersByTime(32) })
115
+ // Pin the scale so the assertions can talk in whole seconds: x = t × 100.
116
+ act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
117
+ act(() => { vi.advanceTimersByTime(32) })
118
+
119
+ return {
120
+ ...utils,
121
+ store,
122
+ clock,
123
+ ...handlers,
124
+ surface: utils.container.querySelector('[data-timeline-canvas]') as HTMLElement,
125
+ }
126
+ }
127
+
128
+ /** A footage-bin drag payload wrapped in a minimal DataTransfer stub — jsdom
129
+ * has no DragEvent, so a `drop` MouseEvent carries the stub directly. */
130
+ function makeDataTransfer(entries: Record<string, string>) {
131
+ return {
132
+ types: Object.keys(entries),
133
+ dropEffect: 'none',
134
+ getData: (t: string) => entries[t] ?? '',
135
+ setData: () => {},
136
+ }
137
+ }
138
+
139
+ /** An OS-file drag. jsdom has no `DragEvent` and no real file list, so the
140
+ * stub carries the two things the handler actually reads: `types: ['Files']`
141
+ * during `dragover` (where `getData` is unreadable by design) and `files` on
142
+ * the drop itself. Deliberately carries NO footage MIME. */
143
+ function makeFileDataTransfer(files: File[]) {
144
+ return {
145
+ types: ['Files'],
146
+ files,
147
+ dropEffect: 'none',
148
+ getData: () => '',
149
+ setData: () => {},
150
+ }
151
+ }
152
+
153
+ /** `y` defaults to 20 — with the stubbed rect that is the 4px gap between the
154
+ * ruler (0..18) and the first row, i.e. "no preferred track", which is what
155
+ * every pre-existing case here dropped onto. */
156
+ function dragEvent(type: string, x: number, dataTransfer: unknown, y = 20) {
157
+ const e = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true })
158
+ Object.defineProperty(e, 'dataTransfer', { value: dataTransfer })
159
+ return e
160
+ }
161
+
162
+ // Two video tracks, so a drop's y actually chooses between rows: trk-0 is
163
+ // occupied from 0..10, trk-1 only from 0..2.
164
+ const twoTrackProject = {
165
+ id: 'p2',
166
+ tracks: [
167
+ {
168
+ id: 'trk-0',
169
+ items: [
170
+ { id: 'a0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 },
171
+ ],
172
+ },
173
+ {
174
+ id: 'trk-1',
175
+ items: [
176
+ { id: 'b0', type: 'video', src: 'b.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2, sourceDuration: 20 },
177
+ ],
178
+ },
179
+ ],
180
+ audio: { tracks: [] },
181
+ } as unknown as Project
182
+
183
+ /** The vertical centre of the row drawn for `trackIdx`, read from the layout
184
+ * the canvas itself draws from rather than hard-coded — that is the whole
185
+ * point: the test says WHY this y maps to that track. */
186
+ function rowCenterY(p: Project, trackIdx: number): number {
187
+ const row = computeTimelineLayout(p).rows.find(r => r.trackIdx === trackIdx)
188
+ if (!row) throw new Error(`no row for trackIdx ${trackIdx}`)
189
+ return row.y + row.height / 2
190
+ }
191
+
192
+ describe('TimelineCanvas — footage-bin drop', () => {
193
+ it('inserts a clip on the main video track at the time under the cursor', () => {
194
+ const { surface, onProjectChange, onOverlayEdit } = mount()
195
+ // x=900 → t=9, past c1's end (8); a 2s clip lands free at [9, 11].
196
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
197
+ act(() => { surface.dispatchEvent(dragEvent('drop', 900, dt)) })
198
+
199
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
200
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
201
+
202
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
203
+ const items = trackItems(committed)[0]
204
+ expect(items).toHaveLength(3)
205
+ const placed = items.find(i => i.src === 'new.mp4')
206
+ expect(placed).toMatchObject({
207
+ type: 'video',
208
+ start: 9,
209
+ end: 11,
210
+ inPoint: 0,
211
+ outPoint: 2,
212
+ sourceDuration: 2,
213
+ proxySrc: 'new.proxy.mp4',
214
+ })
215
+ // The two originals are untouched.
216
+ expect(items.filter(i => i.id === 'c0' || i.id === 'c1')).toHaveLength(2)
217
+ })
218
+
219
+ it('places by the same x→time map at a second position', () => {
220
+ const { surface, onOverlayEdit } = mount()
221
+ // x=1000 → t=10; a 2s clip lands free at [10, 12].
222
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
223
+ act(() => { surface.dispatchEvent(dragEvent('drop', 1000, dt)) })
224
+ const placed = trackItems(onOverlayEdit.mock.calls[0][0] as Project)[0].find(i => i.src === 'new.mp4')
225
+ expect(placed).toMatchObject({ start: 10, end: 12 })
226
+ })
227
+
228
+ it('ripples later clips right by the new clip length when the magnet is on', () => {
229
+ const { surface, onOverlayEdit } = mount({ rippleMode: true })
230
+ // Drop at t=0: with ripple on, everything at/after shifts right by len (2).
231
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
232
+ act(() => { surface.dispatchEvent(dragEvent('drop', 0, dt)) })
233
+
234
+ const items = trackItems(onOverlayEdit.mock.calls[0][0] as Project)[0]
235
+ expect(items).toHaveLength(3)
236
+ expect(items.find(i => i.src === 'new.mp4')).toMatchObject({ start: 0, end: 2 })
237
+ expect(items.find(i => i.id === 'c0')).toMatchObject({ start: 2, end: 6 })
238
+ expect(items.find(i => i.id === 'c1')).toMatchObject({ start: 6, end: 10 })
239
+ })
240
+
241
+ it('does nothing on a drop that carries no footage MIME', () => {
242
+ const { surface, onProjectChange, onOverlayEdit } = mount()
243
+ const dt = makeDataTransfer({ 'text/plain': 'hello' })
244
+ const e = dragEvent('drop', 900, dt)
245
+ act(() => { surface.dispatchEvent(e) })
246
+ expect(onProjectChange).not.toHaveBeenCalled()
247
+ expect(onOverlayEdit).not.toHaveBeenCalled()
248
+ // A non-footage drop is left for the browser — not intercepted.
249
+ expect(e.defaultPrevented).toBe(false)
250
+ })
251
+
252
+ it('ignores a malformed payload without throwing', () => {
253
+ const { surface, onProjectChange, onOverlayEdit } = mount()
254
+ const bad = makeDataTransfer({ [FOOTAGE_DND_MIME]: '{ not json' })
255
+ const missing = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify({ name: 'x' }) })
256
+ expect(() => {
257
+ act(() => { surface.dispatchEvent(dragEvent('drop', 900, bad)) })
258
+ act(() => { surface.dispatchEvent(dragEvent('drop', 900, missing)) })
259
+ }).not.toThrow()
260
+ expect(onProjectChange).not.toHaveBeenCalled()
261
+ expect(onOverlayEdit).not.toHaveBeenCalled()
262
+ })
263
+
264
+ it('accepts the drag on dragover, so the browser will fire a drop', () => {
265
+ const { surface } = mount()
266
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
267
+ const e = dragEvent('dragover', 500, dt)
268
+ act(() => { surface.dispatchEvent(e) })
269
+ expect(e.defaultPrevented).toBe(true)
270
+ expect(dt.dropEffect).toBe('copy')
271
+ })
272
+
273
+ it('leaves an unrelated dragover alone', () => {
274
+ const { surface } = mount()
275
+ const dt = makeDataTransfer({ 'text/plain': 'x' })
276
+ const e = dragEvent('dragover', 500, dt)
277
+ act(() => { surface.dispatchEvent(e) })
278
+ expect(e.defaultPrevented).toBe(false)
279
+ expect(dt.dropEffect).toBe('none')
280
+ })
281
+
282
+ // ── The target row is now the one under the cursor, via `placeDroppedClip` ──
283
+
284
+ it('lands on a DIFFERENT video track when the preferred row is occupied there', () => {
285
+ // Aimed at trk-0 (the row the drop is over), whose clip spans 0..10 — so
286
+ // the 2s clip cannot go where it was dropped. It moves to the nearest
287
+ // video row with room, trk-1, at the SAME time. The old rule pinned every
288
+ // bin drop to the first video track and would have overlapped a0.
289
+ const { surface, onOverlayEdit } = mount({ project: twoTrackProject })
290
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
291
+ act(() => {
292
+ surface.dispatchEvent(dragEvent('drop', 900, dt, rowCenterY(twoTrackProject, 0)))
293
+ })
294
+
295
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
296
+ const tracks = trackItems(committed)
297
+ expect(tracks[0].map(i => i.id)).toEqual(['a0'])
298
+ const placed = tracks[1].find(i => i.src === 'new.mp4')
299
+ expect(placed).toMatchObject({ start: 9, end: 11 })
300
+ })
301
+
302
+ it('honours the row under the cursor when that row has room', () => {
303
+ // Same project, same time — dropped over trk-1 this time, which is free
304
+ // there, so it stays put rather than being pulled to the base row.
305
+ const { surface, onOverlayEdit } = mount({ project: twoTrackProject })
306
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
307
+ act(() => {
308
+ surface.dispatchEvent(dragEvent('drop', 900, dt, rowCenterY(twoTrackProject, 1)))
309
+ })
310
+ const tracks = trackItems(onOverlayEdit.mock.calls[0][0] as Project)
311
+ expect(tracks[1].find(i => i.src === 'new.mp4')).toMatchObject({ start: 9, end: 11 })
312
+ })
313
+ })
314
+
315
+ describe('TimelineCanvas — OS-file drop', () => {
316
+ function fileList() {
317
+ return [new File(['x'], 'C0042.MP4', { type: 'video/mp4' })]
318
+ }
319
+
320
+ it('reports the files, the drop time and the row released over', () => {
321
+ const onImportFilesToTimeline = vi.fn()
322
+ const { surface } = mount({ project: twoTrackProject, onImportFilesToTimeline })
323
+ const files = fileList()
324
+ // x=900 → t=9 (the pinned 100px/s scale). y is the CENTRE of the row the
325
+ // layout draws for trackIdx 1, so this asserts a real, non-default
326
+ // preferredTrackIndex rather than the -1 the ruler/gap would give.
327
+ act(() => {
328
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(files), rowCenterY(twoTrackProject, 1)))
329
+ })
330
+
331
+ expect(onImportFilesToTimeline).toHaveBeenCalledTimes(1)
332
+ const [gotFiles, placement] = onImportFilesToTimeline.mock.calls[0]
333
+ expect(gotFiles).toEqual(files)
334
+ expect(placement).toEqual({ atTime: 9, preferredTrackIndex: 1, ripple: false })
335
+ })
336
+
337
+ // The magnet is editor-internal state with no other route to the host, and
338
+ // the host places the clip seconds later — so the value that reaches the
339
+ // callback has to be the one in force during the GESTURE. Both values are
340
+ // asserted: a test that only ever saw the default would prove nothing.
341
+ it.each([true, false])('carries the ripple mode in force at drop time (%s)', (rippleMode) => {
342
+ const onImportFilesToTimeline = vi.fn()
343
+ const { surface } = mount({ onImportFilesToTimeline, rippleMode })
344
+ act(() => {
345
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(fileList())))
346
+ })
347
+ expect(onImportFilesToTimeline.mock.calls[0][1].ripple).toBe(rippleMode)
348
+ })
349
+
350
+ it('reports ripple: false when the host never set the mode at all', () => {
351
+ const onImportFilesToTimeline = vi.fn()
352
+ const { surface } = mount({ onImportFilesToTimeline })
353
+ act(() => {
354
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(fileList())))
355
+ })
356
+ expect(onImportFilesToTimeline.mock.calls[0][1].ripple).toBe(false)
357
+ })
358
+
359
+ it('reports -1 for a drop that lands on no video row at all', () => {
360
+ const onImportFilesToTimeline = vi.fn()
361
+ const { surface } = mount({ project: twoTrackProject, onImportFilesToTimeline })
362
+ // y=20 is the gap between the ruler and the first row — no preference.
363
+ act(() => {
364
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(fileList())))
365
+ })
366
+ expect(onImportFilesToTimeline.mock.calls[0][1].preferredTrackIndex).toBe(-1)
367
+ })
368
+
369
+ // ── The magnet applies to a filesystem drop too, same rule as the bin drag ──
370
+
371
+ it('snaps a filesystem drop\'s atTime to a nearby boundary, the same magnet the bin drop uses', () => {
372
+ const onImportFilesToTimeline = vi.fn()
373
+ // x=995 → t=9.95, within the pinned scale's 20px/100px-per-sec = 0.2s
374
+ // tolerance of the boundary at t=10.
375
+ const { surface } = mount({ onImportFilesToTimeline, snapBoundaries: [10] })
376
+ act(() => {
377
+ surface.dispatchEvent(dragEvent('drop', 995, makeFileDataTransfer(fileList())))
378
+ })
379
+ expect(onImportFilesToTimeline.mock.calls[0][1].atTime).toBe(10)
380
+ })
381
+
382
+ it('leaves atTime alone when no boundary is within the magnet tolerance', () => {
383
+ const onImportFilesToTimeline = vi.fn()
384
+ // x=900 → t=9, a full second from the boundary at 10 — outside the 0.2s
385
+ // tolerance, so the drop point is reported unsnapped.
386
+ const { surface } = mount({ onImportFilesToTimeline, snapBoundaries: [10] })
387
+ act(() => {
388
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(fileList())))
389
+ })
390
+ expect(onImportFilesToTimeline.mock.calls[0][1].atTime).toBe(9)
391
+ })
392
+
393
+ it('does NOT touch the project — placing a file is the host\'s job', () => {
394
+ const onImportFilesToTimeline = vi.fn()
395
+ const { surface, onProjectChange, onOverlayEdit } = mount({ onImportFilesToTimeline })
396
+ act(() => {
397
+ surface.dispatchEvent(dragEvent('drop', 900, makeFileDataTransfer(fileList())))
398
+ })
399
+ expect(onImportFilesToTimeline).toHaveBeenCalledTimes(1)
400
+ expect(onProjectChange).not.toHaveBeenCalled()
401
+ expect(onOverlayEdit).not.toHaveBeenCalled()
402
+ })
403
+
404
+ it('does not fire on a footage-bin drop — the two paths never cross', () => {
405
+ const onImportFilesToTimeline = vi.fn()
406
+ const { surface, onOverlayEdit } = mount({ onImportFilesToTimeline })
407
+ const dt = makeDataTransfer({ [FOOTAGE_DND_MIME]: JSON.stringify(PAYLOAD) })
408
+ act(() => { surface.dispatchEvent(dragEvent('drop', 900, dt)) })
409
+ expect(onImportFilesToTimeline).not.toHaveBeenCalled()
410
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
411
+ })
412
+
413
+ it('accepts a Files dragover — but only because a hook was supplied', () => {
414
+ const { surface } = mount({ onImportFilesToTimeline: vi.fn() })
415
+ const dt = makeFileDataTransfer(fileList())
416
+ const e = dragEvent('dragover', 500, dt)
417
+ act(() => { surface.dispatchEvent(e) })
418
+ expect(e.defaultPrevented).toBe(true)
419
+ expect(dt.dropEffect).toBe('copy')
420
+ })
421
+
422
+ it('leaves a Files dragover to the browser with no hook — the inert guarantee', () => {
423
+ const { surface } = mount()
424
+ const dt = makeFileDataTransfer(fileList())
425
+ const e = dragEvent('dragover', 500, dt)
426
+ act(() => { surface.dispatchEvent(e) })
427
+ expect(e.defaultPrevented).toBe(false)
428
+ expect(dt.dropEffect).toBe('none')
429
+ })
430
+
431
+ it('is a complete no-op on a file DROP with no hook', () => {
432
+ const { surface, onProjectChange, onOverlayEdit } = mount()
433
+ const e = dragEvent('drop', 900, makeFileDataTransfer(fileList()))
434
+ act(() => { surface.dispatchEvent(e) })
435
+ expect(e.defaultPrevented).toBe(false)
436
+ expect(onProjectChange).not.toHaveBeenCalled()
437
+ expect(onOverlayEdit).not.toHaveBeenCalled()
438
+ })
439
+ })