@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,818 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — hit-testing. Pure arithmetic over the painter's own layout, so the
4
+ * whole surface is covered here without a canvas or a browser.
5
+ *
6
+ * The fixture runs at 100px per second with no scroll, so x is exactly
7
+ * `t * 100`. The Y of each row is taken from the layout rather than written
8
+ * out — the rows are tall enough now to hold a filmstrip over a waveform, and
9
+ * hardcoded Y's would aim these probes at the gaps between them.
10
+ *
11
+ * row track 1 (overlays) — o0 spans x 200–400
12
+ * row track 0 (base, taller) — c0 x 0–500, c1 x 500–1000
13
+ * lane audio a0 x 100–600, the bar inset inside the lane
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import type { Project } from '../../../../types'
17
+ import { AUDIO_ITEM_INSET_PX, RULER_HEIGHT_PX, computeTimelineLayout } from '../draw'
18
+ import {
19
+ AUDIO_EDGE_TOLERANCE_PX,
20
+ FADE_GRIP_ZONE_HEIGHT_PX,
21
+ VISUAL_EDGE_TOLERANCE_PX,
22
+ hitTest,
23
+ isCaptionHit,
24
+ isEdgeHit,
25
+ isEmptyHit,
26
+ isItemHit,
27
+ itemsInRect,
28
+ normalizeRect,
29
+ } from '../hit-test'
30
+ import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX } from '../keyframe-strip'
31
+ import type { Viewport } from '../viewport'
32
+ import {
33
+ AUDIO_LANE_HEIGHT_PX,
34
+ BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
35
+ ROW_GAP_PX,
36
+ VISUAL_ROW_RENDER_HEIGHT_PX,
37
+ } from '../../timeline-model'
38
+
39
+ const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
40
+
41
+ const project = {
42
+ id: 'p',
43
+ tracks: [
44
+ [
45
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 },
46
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10 },
47
+ ],
48
+ [{ id: 'o0', type: 'overlay', start: 2, end: 4 }],
49
+ ],
50
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
51
+ } as unknown as Project
52
+
53
+ const layout = computeTimelineLayout(project)
54
+
55
+ const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
56
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
57
+ const lane0 = layout.lanes[0]
58
+
59
+ const OVERLAY_Y = Math.round(overlayRow.y + overlayRow.height / 2)
60
+ const BASE_Y = Math.round(baseRow.y + baseRow.height / 2)
61
+ const LANE_Y = Math.round(lane0.y + lane0.height / 2)
62
+ /** Inside the gap between two rows. */
63
+ const ROW_GAP_Y = overlayRow.y + overlayRow.height + 1
64
+ /** Inside the lane but above/below the inset audio bar. */
65
+ const LANE_ABOVE_BAR_Y = lane0.y + 1
66
+ const LANE_BELOW_BAR_Y = lane0.y + lane0.height - 2
67
+
68
+ function at(x: number, y: number) {
69
+ return hitTest({ x, y }, layout, VIEWPORT)
70
+ }
71
+
72
+ describe('hitTest — geometry sanity', () => {
73
+ it('the fixture lays out where the assertions below assume', () => {
74
+ // Rows begin under the ruler strip, which owns the top of the surface.
75
+ const contentTop = RULER_HEIGHT_PX + ROW_GAP_PX
76
+ expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
77
+ expect(layout.rows.map(r => [r.trackIdx, r.y, r.height])).toEqual([
78
+ [1, contentTop, VISUAL_ROW_RENDER_HEIGHT_PX],
79
+ [0, contentTop + VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX, BASE_VISUAL_ROW_RENDER_HEIGHT_PX],
80
+ ])
81
+ expect(layout.lanes.map(l => [l.laneIndex, l.y, l.height])).toEqual([[baseRow.y + baseRow.height + ROW_GAP_PX, AUDIO_LANE_HEIGHT_PX]].map(([y, h]) => [0, y, h]))
82
+ })
83
+ })
84
+
85
+ describe('hitTest — visual items', () => {
86
+ it('finds a clip body and reports its track', () => {
87
+ const hit = at(250, BASE_Y)
88
+ expect(hit.kind).toBe('item-body')
89
+ expect(hit.itemId).toBe('c0')
90
+ expect(hit.trackIdx).toBe(0)
91
+ expect(hit.item?.id).toBe('c0')
92
+ expect(hit.edge).toBeUndefined()
93
+ })
94
+
95
+ it('finds the in and out trim handles', () => {
96
+ expect(at(5, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c0', edge: 'in' })
97
+ expect(at(495, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c0', edge: 'out' })
98
+ })
99
+
100
+ it('honours the handle width exactly at its boundaries', () => {
101
+ // The handles are 10px wide and sit inside the clip: 0–10 and 490–500.
102
+ expect(at(10, BASE_Y).edge).toBe('in')
103
+ expect(at(10.5, BASE_Y).kind).toBe('item-body')
104
+ expect(at(490, BASE_Y).edge).toBe('out')
105
+ expect(at(489.5, BASE_Y).kind).toBe('item-body')
106
+ })
107
+
108
+ it('takes the tolerance from options when given', () => {
109
+ const hit = hitTest({ x: 30, y: BASE_Y }, layout, VIEWPORT, { visualEdgeTolerancePx: 40 })
110
+ expect(hit).toMatchObject({ kind: 'item-edge', edge: 'in' })
111
+ expect(VISUAL_EDGE_TOLERANCE_PX).toBe(10)
112
+ })
113
+
114
+ it('gives the out handle precedence when a clip is too narrow for two', () => {
115
+ // A 12px clip: the two 10px zones overlap, and the out handle wins — the
116
+ // DOM's right handle paints last and so hit-tests first.
117
+ const narrow = {
118
+ id: 'p', tracks: [[{ id: 'n0', type: 'video', src: 'n.mp4', start: 0, end: 0.12 }]],
119
+ } as unknown as Project
120
+ const narrowLayout = computeTimelineLayout(narrow)
121
+ const narrowY = narrowLayout.rows[0].y + 2
122
+ expect(hitTest({ x: 6, y: narrowY }, narrowLayout, VIEWPORT)).toMatchObject({ edge: 'out' })
123
+ expect(hitTest({ x: 1, y: narrowY }, narrowLayout, VIEWPORT)).toMatchObject({ edge: 'in' })
124
+ })
125
+
126
+ it('resolves a shared boundary to the clip painted on top', () => {
127
+ // c0 ends and c1 starts at t=5 → x=500. The later item in the array wins,
128
+ // and the point lands in its in-handle.
129
+ expect(at(500, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c1', edge: 'in' })
130
+ })
131
+
132
+ it('finds an overlay on the row above the base track', () => {
133
+ expect(at(300, OVERLAY_Y)).toMatchObject({ kind: 'item-body', itemId: 'o0', trackIdx: 1 })
134
+ })
135
+
136
+ it('reports empty track area with the row it belongs to', () => {
137
+ expect(at(700, OVERLAY_Y)).toMatchObject({ kind: 'empty-row', trackIdx: 1 })
138
+ })
139
+ })
140
+
141
+ describe('hitTest — overlapping items', () => {
142
+ // `a` runs 0–5 and `b` 3–9 on one track, so b covers a's whole trailing
143
+ // second. b is later in the array, i.e. drawn on top.
144
+ const overlapped = {
145
+ id: 'p',
146
+ tracks: [[
147
+ { id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 5 },
148
+ { id: 'b', type: 'video', src: 'b.mp4', start: 3, end: 9 },
149
+ ]],
150
+ audio: { tracks: [
151
+ { id: 'x', src: 'x.mp3', start: 0, end: 5, lane: 0 },
152
+ { id: 'y', src: 'y.mp3', start: 3, end: 9, lane: 0 },
153
+ ] },
154
+ } as unknown as Project
155
+ const olay = computeTimelineLayout(overlapped)
156
+ const row = olay.rows.find(r => r.trackIdx === 0)!
157
+ const rowY = Math.round(row.y + row.height / 2)
158
+ const lane = olay.lanes[0]
159
+ const laneY = Math.round(lane.y + lane.height / 2)
160
+ const hit = (x: number, y: number) => hitTest({ x, y }, olay, VIEWPORT)
161
+
162
+ it('reaches the buried out edge of the clip underneath', () => {
163
+ // THE BUG: x=498 is 2px from a's end and 198px into b. Topmost-wins
164
+ // returned b's body and stopped, so a's trailing edge was unreachable —
165
+ // you could see it and not trim it.
166
+ const r = hit(498, rowY)
167
+ expect(r.kind).toBe('item-edge')
168
+ expect(r.itemId).toBe('a')
169
+ expect(r.edge).toBe('out')
170
+ })
171
+
172
+ it('still gives the overlapping clip its own body away from any edge', () => {
173
+ // 6.5s: past a entirely, and 150px from either of b's edges.
174
+ expect(hit(650, rowY)).toMatchObject({ kind: 'item-body', itemId: 'b' })
175
+ })
176
+
177
+ it('gives the overlapping clip its in edge where that is the nearer one', () => {
178
+ // x=302 is 2px from b's start and 198px from a's end.
179
+ expect(hit(302, rowY)).toMatchObject({ kind: 'item-edge', itemId: 'b', edge: 'in' })
180
+ })
181
+
182
+ it('takes the nearest edge, not the topmost, when BOTH are in range', () => {
183
+ // A 5px overlap puts a's end (500px) and b's start (495px) inside one
184
+ // another's tolerance, so every point between them is a candidate for
185
+ // both and the tie-break is the whole rule.
186
+ const tiny = {
187
+ id: 'p',
188
+ tracks: [[
189
+ { id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 5 },
190
+ { id: 'b', type: 'video', src: 'b.mp4', start: 4.95, end: 9 },
191
+ ]],
192
+ } as unknown as Project
193
+ const tinyLayout = computeTimelineLayout(tiny)
194
+ const y = Math.round(tinyLayout.rows[0].y + tinyLayout.rows[0].height / 2)
195
+ const probe = (x: number) => hitTest({ x, y }, tinyLayout, VIEWPORT)
196
+
197
+ // 2px from b's start, 3px from a's end.
198
+ expect(probe(497)).toMatchObject({ itemId: 'b', edge: 'in' })
199
+ // 1px from a's end, 4px from b's start — topmost would still say b.
200
+ expect(probe(499)).toMatchObject({ itemId: 'a', edge: 'out' })
201
+ })
202
+
203
+ it('falls back to the body when no edge is within tolerance', () => {
204
+ // 4.0s: 100px from a's end, 100px from b's start. Neither edge is close,
205
+ // so the topmost body wins, exactly as it always did.
206
+ expect(hit(400, rowY)).toMatchObject({ kind: 'item-body', itemId: 'b' })
207
+ })
208
+
209
+ it('reaches a crossfaded audio bar\'s buried out edge too', () => {
210
+ // The permanent version of the same hole: bars in a lane overlap BY
211
+ // DESIGN, so the earlier bar's out edge was never reachable at all.
212
+ const r = hit(498, laneY)
213
+ expect(r.kind).toBe('audio-edge')
214
+ expect(r.itemId).toBe('x')
215
+ expect(r.edge).toBe('out')
216
+ })
217
+ })
218
+
219
+ describe('hitTest — audio lanes', () => {
220
+ it('finds a bar body and its lane', () => {
221
+ expect(at(300, LANE_Y)).toMatchObject({ kind: 'audio-body', itemId: 'a0', laneIdx: 0 })
222
+ expect(at(300, LANE_Y).track?.id).toBe('a0')
223
+ })
224
+
225
+ it('finds the bar trim handles at the narrower audio tolerance', () => {
226
+ expect(AUDIO_EDGE_TOLERANCE_PX).toBe(6)
227
+ expect(at(103, LANE_Y)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
228
+ expect(at(597, LANE_Y)).toMatchObject({ kind: 'audio-edge', edge: 'out' })
229
+ // 8px in from the start is past the 6px handle — that's bar body.
230
+ expect(at(108, LANE_Y).kind).toBe('audio-body')
231
+ })
232
+
233
+ it('treats the lane inset above and below the bar as lane background', () => {
234
+ expect(at(300, LANE_ABOVE_BAR_Y).kind).toBe('empty-lane')
235
+ expect(at(300, LANE_BELOW_BAR_Y).kind).toBe('empty-lane')
236
+ })
237
+
238
+ it('reports empty lane area', () => {
239
+ expect(at(800, LANE_Y)).toMatchObject({ kind: 'empty-lane', laneIdx: 0 })
240
+ })
241
+ })
242
+
243
+ describe('hitTest — audio fade grips', () => {
244
+ // a0 spans x 100–600 (1s–6s @ 100px/s) with no fadeIn/fadeOut set, so both
245
+ // grips sit at the bar's own corners — see `audioFadeGripZone`'s doc for
246
+ // the precedence this creates against the trim edge, which claims the SAME
247
+ // x but the bar's FULL height.
248
+ const barTop = lane0.y + AUDIO_ITEM_INSET_PX
249
+
250
+ it('finds the fade-IN grip at the bar′s own left corner when no fade is set', () => {
251
+ expect(at(100, barTop)).toMatchObject({ kind: 'audio-fade', itemId: 'a0', side: 'in', laneIdx: 0 })
252
+ })
253
+
254
+ it('finds the fade-OUT grip at the bar′s own right corner when no fade is set', () => {
255
+ expect(at(600, barTop)).toMatchObject({ kind: 'audio-fade', itemId: 'a0', side: 'out', laneIdx: 0 })
256
+ })
257
+
258
+ it('takes precedence over the trim edge at the exact corner, where both zones overlap', () => {
259
+ // (100, barTop) is also inside the audio-edge zone (±AUDIO_EDGE_TOLERANCE_PX
260
+ // of the corner) — confirm the GRIP wins there, not the edge.
261
+ expect(at(100, barTop).kind).toBe('audio-fade')
262
+ })
263
+
264
+ it('does NOT steal the trim edge below the grip′s small top zone — the full-height edge is still reachable further down', () => {
265
+ // Same x as the corner (well within AUDIO_EDGE_TOLERANCE_PX), but below
266
+ // FADE_GRIP_ZONE_HEIGHT_PX — resolves to the trim edge, not the grip.
267
+ const belowGripZone = barTop + FADE_GRIP_ZONE_HEIGHT_PX + 2
268
+ expect(at(100, belowGripZone)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
269
+ })
270
+
271
+ it('leaves the bar BODY, away from either grip, alone', () => {
272
+ expect(at(300, barTop)).toMatchObject({ kind: 'audio-body', itemId: 'a0' })
273
+ })
274
+
275
+ it('moves the grip zone to the fade′s INNER edge once a fade is set, not the corner', () => {
276
+ const withFade = {
277
+ id: 'p',
278
+ audio: { tracks: [{ id: 'f0', src: 'v.mp3', start: 1, end: 6, lane: 0, fadeIn: 1, fadeOut: 0.5 }] },
279
+ } as unknown as Project
280
+ const fadeLayout = computeTimelineLayout(withFade)
281
+ const fadeLane = fadeLayout.lanes[0]
282
+ const fadeBarTop = fadeLane.y + AUDIO_ITEM_INSET_PX
283
+ const fadeAt = (x: number, y: number) => hitTest({ x, y }, fadeLayout, VIEWPORT)
284
+
285
+ // Track spans x 100–600 (1s–6s). fadeIn=1s -> inner edge at 100+100=200.
286
+ // fadeOut=0.5s -> inner edge at 600-50=550.
287
+ expect(fadeAt(200, fadeBarTop)).toMatchObject({ kind: 'audio-fade', side: 'in' })
288
+ expect(fadeAt(550, fadeBarTop)).toMatchObject({ kind: 'audio-fade', side: 'out' })
289
+ // The track's own corner is no longer a grip — it's ordinary trim-edge
290
+ // territory now that the grip has moved off it.
291
+ expect(fadeAt(100, fadeBarTop)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
292
+ })
293
+ })
294
+
295
+ describe('hitTest — keyframe strip (SP9b T3.3)', () => {
296
+ // o0 (2s–4s, overlay) keyframed on offsetX at t=0, 0.5 and 1.5, and on
297
+ // opacity at t=0.5 ONLY — so the union of times (the merged strip, plan
298
+ // decision 2) is {0, 0.5, 1.5}, landing at x=200 (o0's own left edge, to
299
+ // probe precedence over item-edge), x=250 (a diamond TWO props share, to
300
+ // probe the "one diamond per shared instant" rule) and x=350 (offsetX
301
+ // alone) — all at 100px/s off o0's start (2s).
302
+ const keyframedProject = {
303
+ id: 'p',
304
+ tracks: [
305
+ [
306
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 },
307
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10 },
308
+ ],
309
+ [{
310
+ id: 'o0', type: 'overlay', start: 2, end: 4,
311
+ keyframes: [
312
+ { prop: 'offsetX', points: [{ t: 0, value: -5 }, { t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
313
+ { prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
314
+ ],
315
+ }],
316
+ ],
317
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
318
+ } as unknown as Project
319
+
320
+ const kfLayout = computeTimelineLayout(keyframedProject)
321
+ const kfRow = kfLayout.rows.find(r => r.trackIdx === 1)!
322
+ // Well inside the bottom strip zone, not at its very edge.
323
+ const KF_ZONE_Y = kfRow.y + kfRow.height - 2
324
+
325
+ function atKf(x: number, y: number, selectedIds: string[]) {
326
+ return hitTest({ x, y }, kfLayout, VIEWPORT, { selectedIds })
327
+ }
328
+
329
+ it('finds a diamond at a time only ONE prop has', () => {
330
+ expect(atKf(350, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
331
+ })
332
+
333
+ it('finds ONE diamond at a time TWO props share', () => {
334
+ expect(atKf(250, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 0.5 })
335
+ })
336
+
337
+ it('takes precedence over the item-edge trim zone at the exact corner, where both overlap', () => {
338
+ // x=200 is o0's own left edge — also inside VISUAL_EDGE_TOLERANCE_PX of
339
+ // it — but a keyframe sits exactly there too. The diamond must win.
340
+ expect(atKf(200, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 0 })
341
+ })
342
+
343
+ it("the clickable zone reaches all the way up to the diamond's own drawn top edge (FIX 7)", () => {
344
+ // The diamond is drawn KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
345
+ // (8 + 4 = 12px) up from the row's bottom (see drawKeyframeStrip / keyframe-
346
+ // strip.ts's own doc). Computed independently of KEYFRAME_STRIP_ZONE_HEIGHT_PX
347
+ // itself, so this would have failed against the old hand-picked 10px zone
348
+ // height, which fell 2px short of the diamond's actual top edge.
349
+ const diamondTopFromBottom = KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
350
+ const topOfDiamond = kfRow.y + kfRow.height - diamondTopFromBottom
351
+ expect(atKf(350, topOfDiamond, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
352
+ })
353
+
354
+ it('an in-span keyframe still hits normally (non-regression)', () => {
355
+ expect(atKf(350, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
356
+ })
357
+
358
+ it('a point far from any diamond in x, but still within the strip zone in y, does not produce a keyframe hit', () => {
359
+ // x=380 is well inside o0's body (200-400) but 30px from the nearest
360
+ // diamond (x=350, t=1.5) — outside KEYFRAME_HIT_HALF_WIDTH_PX (6px). Must
361
+ // fall through to the ordinary item-body hit, not stay stuck on 'keyframe'.
362
+ expect(atKf(380, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
363
+ })
364
+
365
+ it('an out-of-span keyframe produces NO keyframe hit — the item-body of the clip actually drawn there wins instead', () => {
366
+ // o0 (2s-4s, duration 2s) carries an OUT-OF-SPAN keyframe at t=3
367
+ // (t > duration). Unclamped, its screen x is (o0.start + 3) * 100 = 500 —
368
+ // which is nowhere near o0's own drawn rect (x 200-400): it lands exactly
369
+ // where the NEXT item on the same track, o1 (4s-6s), is actually drawn.
370
+ // Before the fix, keyframeStripZone had no span filter, so o0's phantom
371
+ // diamond at x=500 won the hit ahead of o1's real, visible body.
372
+ const overlapProject = {
373
+ id: 'p',
374
+ tracks: [
375
+ [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }],
376
+ [
377
+ {
378
+ id: 'o0', type: 'overlay', start: 2, end: 4,
379
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 3, value: 10 }] }],
380
+ },
381
+ { id: 'o1', type: 'overlay', start: 4, end: 6 },
382
+ ],
383
+ ],
384
+ } as unknown as Project
385
+ const overlapLayout = computeTimelineLayout(overlapProject)
386
+ const row = overlapLayout.rows.find(r => r.trackIdx === 1)!
387
+ const y = row.y + row.height - 2
388
+ expect(hitTest({ x: 500, y }, overlapLayout, VIEWPORT, { selectedIds: ['o0'] }))
389
+ .toMatchObject({ kind: 'item-body', itemId: 'o1' })
390
+ })
391
+
392
+ it('does not steal the row away from the diamond zone — item-body still resolves above/below it', () => {
393
+ // Same x as a diamond, but ABOVE KEYFRAME_STRIP_ZONE_HEIGHT_PX from the
394
+ // row's bottom: falls through to the ordinary clip body.
395
+ expect(KEYFRAME_STRIP_ZONE_HEIGHT_PX).toBeGreaterThan(0)
396
+ const aboveStrip = kfRow.y + 2
397
+ expect(atKf(250, aboveStrip, ['o0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
398
+ })
399
+
400
+ it('yields no keyframe hit when the overlay is NOT selected', () => {
401
+ expect(atKf(250, KF_ZONE_Y, [])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
402
+ })
403
+
404
+ it('yields no keyframe hit when a DIFFERENT item is selected', () => {
405
+ expect(atKf(250, KF_ZONE_Y, ['c0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
406
+ })
407
+
408
+ it('yields no keyframe hit on a selected but UN-keyframed overlay', () => {
409
+ const plain = { id: 'p', tracks: [[{ id: 'o1', type: 'overlay', start: 2, end: 4 }]] } as unknown as Project
410
+ const plainLayout = computeTimelineLayout(plain)
411
+ const row = plainLayout.rows[0]
412
+ const y = row.y + row.height - 2
413
+ expect(hitTest({ x: 250, y }, plainLayout, VIEWPORT, { selectedIds: ['o1'] })).toMatchObject({ kind: 'item-body', itemId: 'o1' })
414
+ })
415
+
416
+ it('YIELDS a keyframe hit on a selected, keyframed video clip (SP9d)', () => {
417
+ // Clips became keyframeable once the renderer could compile curves into
418
+ // ffmpeg expressions, so their diamonds must be grabbable like an
419
+ // overlay's. The gate is still `canKeyframe(item)`, not `isKeyframed`
420
+ // alone — the case below proves it is still a gate.
421
+ const videoKf = {
422
+ id: 'p',
423
+ tracks: [[{
424
+ id: 'c0', type: 'video', src: 'a.mp4', start: 2, end: 4,
425
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
426
+ }]],
427
+ } as unknown as Project
428
+ const videoLayout = computeTimelineLayout(videoKf)
429
+ const row = videoLayout.rows[0]
430
+ const y = row.y + row.height - 2
431
+ expect(hitTest({ x: 250, y }, videoLayout, VIEWPORT, { selectedIds: ['c0'] }).kind).toBe('keyframe')
432
+ })
433
+
434
+ it('still yields no keyframe hit on a kind that cannot be keyframed at all', () => {
435
+ const audioKf = {
436
+ id: 'p',
437
+ tracks: [[{
438
+ id: 'a0', type: 'audio', src: 'a.wav', start: 2, end: 4,
439
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
440
+ }]],
441
+ } as unknown as Project
442
+ const audioLayout = computeTimelineLayout(audioKf)
443
+ const row = audioLayout.rows[0]
444
+ const y = row.y + row.height - 2
445
+ expect(hitTest({ x: 250, y }, audioLayout, VIEWPORT, { selectedIds: ['a0'] }).kind).not.toBe('keyframe')
446
+ })
447
+
448
+ it('is backward compatible: omitting `selectedIds` entirely never produces a keyframe hit', () => {
449
+ expect(hitTest({ x: 250, y: KF_ZONE_Y }, kfLayout, VIEWPORT).kind).not.toBe('keyframe')
450
+ })
451
+ })
452
+
453
+ describe('hitTest — outside the rows', () => {
454
+ it('treats the gap between rows as background', () => {
455
+ expect(at(300, ROW_GAP_Y).kind).toBe('background')
456
+ })
457
+
458
+ it('treats anything below the last lane as background', () => {
459
+ expect(at(300, 500).kind).toBe('background')
460
+ })
461
+
462
+ it('treats negative y as the ruler, so a scrub survives drifting off the top', () => {
463
+ // Everything at or above the ruler's bottom edge is the ruler, including
464
+ // points off the surface entirely. A pointer that wanders above the strip
465
+ // mid-scrub is still scrubbing; reporting `background` there would drop the
466
+ // gesture the moment the hand rose.
467
+ expect(at(300, -5).kind).toBe('ruler')
468
+ expect(at(300, -5).t).toBeCloseTo(3)
469
+ })
470
+
471
+ it('still reports a time for points off either end of the surface', () => {
472
+ expect(at(-50, BASE_Y)).toMatchObject({ kind: 'empty-row', t: -0.5 })
473
+ expect(at(5000, BASE_Y)).toMatchObject({ kind: 'empty-row', t: 50 })
474
+ })
475
+ })
476
+
477
+ describe('hitTest — time', () => {
478
+ it('always reports the time under x, whatever was hit', () => {
479
+ expect(at(250, BASE_Y).t).toBeCloseTo(2.5)
480
+ expect(at(250, ROW_GAP_Y).t).toBeCloseTo(2.5)
481
+ expect(at(250, LANE_Y).t).toBeCloseTo(2.5)
482
+ })
483
+
484
+ it('accounts for scroll', () => {
485
+ const scrolled: Viewport = { ...VIEWPORT, scrollSeconds: 3 }
486
+ expect(hitTest({ x: 250, y: 60 }, layout, scrolled).t).toBeCloseTo(5.5)
487
+ })
488
+ })
489
+
490
+ describe('hitTest — predicates', () => {
491
+ it('classifies each kind', () => {
492
+ expect(isEdgeHit(at(5, BASE_Y))).toBe(true)
493
+ expect(isEdgeHit(at(250, BASE_Y))).toBe(false)
494
+ expect(isItemHit(at(250, BASE_Y))).toBe(true)
495
+ expect(isItemHit(at(300, LANE_Y))).toBe(true)
496
+ expect(isItemHit(at(700, OVERLAY_Y))).toBe(false)
497
+ expect(isEmptyHit(at(700, OVERLAY_Y))).toBe(true)
498
+ expect(isEmptyHit(at(300, ROW_GAP_Y))).toBe(true)
499
+ expect(isEmptyHit(at(800, LANE_Y))).toBe(true)
500
+ expect(isEmptyHit(at(250, BASE_Y))).toBe(false)
501
+ })
502
+ })
503
+
504
+ describe('hitTest — degenerate layouts', () => {
505
+ it('reports background for an empty project', () => {
506
+ const empty = computeTimelineLayout({ id: 'p' } as unknown as Project)
507
+ // Below the ruler: with no rows or lanes there is nothing but background.
508
+ expect(hitTest({ x: 100, y: RULER_HEIGHT_PX + 10 }, empty, VIEWPORT).kind).toBe('background')
509
+ // The ruler itself is still there, even with nothing to rule over.
510
+ expect(hitTest({ x: 100, y: 2 }, empty, VIEWPORT).kind).toBe('ruler')
511
+ })
512
+
513
+ it('survives a viewport with no scale yet', () => {
514
+ const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
515
+ const hit = hitTest({ x: 100, y: 60 }, layout, unscaled)
516
+ // Every clip collapses to x=0, so a point at x=100 is past all of them.
517
+ expect(hit.kind).toBe('empty-row')
518
+ expect(hit.t).toBe(0)
519
+ })
520
+ })
521
+
522
+ describe('hitTest — an audio bar with no declared window', () => {
523
+ // `groupAudioLanes` resolves the window, so the painter, the hit-test and the
524
+ // pointer machine all address the same bar. Without that, this lane's
525
+ // x-coordinates are NaN, and NaN loses every comparison in `spanZone` — so
526
+ // the bar reported a body hit at EVERY x in the lane, including well past
527
+ // its end, and the gesture that followed did NaN arithmetic. The contract is
528
+ // that the bar is grabbable where it is drawn and only there. Note which
529
+ // assertions actually guard it: a plain body hit does NOT, because NaN bounds
530
+ // fall through to 'body' everywhere. The in-handle and past-the-end cases do.
531
+ const p = {
532
+ id: 'p',
533
+ tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }]],
534
+ audio: { tracks: [{ id: 'bed', src: 'song.mp3' }] },
535
+ } as unknown as Project
536
+ const l = computeTimelineLayout(p)
537
+ const lane = l.lanes[0]
538
+ const LANE_MID_Y = lane.y + lane.height / 2
539
+
540
+ it('a bar with no declared start/end is still grabbable', () => {
541
+ // Vertically centred in the lane. The fixture viewport runs at 100px/s
542
+ // from t=0, so x=40 is t=0.4 — past the in handle, nowhere near the out.
543
+ const hit = hitTest({ x: 40, y: LANE_MID_Y }, l, VIEWPORT)
544
+ expect(hit.kind).toBe('audio-body')
545
+ expect(hit.itemId).toBe('bed')
546
+ })
547
+
548
+ it('and its in-handle is where the bar actually starts', () => {
549
+ // This is the half that FAILS on revert. The body assertion above does not:
550
+ // with NaN bounds every comparison in `spanZone` is false, so it falls
551
+ // through to 'body' at every x and reports a body hit anyway. Only a zone
552
+ // that NaN cannot produce proves the window was resolved — the in handle
553
+ // needs `x <= x0 + tolerance` to be TRUE, which NaN never satisfies.
554
+ const hit = hitTest({ x: 2, y: LANE_MID_Y }, l, VIEWPORT)
555
+ expect(hit.kind).toBe('audio-edge')
556
+ expect(hit.edge).toBe('in')
557
+ expect(hit.itemId).toBe('bed')
558
+ })
559
+
560
+ it('and is not grabbable past the end of the resolved window', () => {
561
+ // The window resolves to the project's content duration (10s → x=1000).
562
+ const hit = hitTest({ x: 1200, y: LANE_MID_Y }, l, VIEWPORT)
563
+ expect(hit.kind).toBe('empty-lane')
564
+ expect(hit.itemId).toBeUndefined()
565
+ })
566
+ })
567
+
568
+ // ── The caption band ──────────────────────────────────────────────────────
569
+ // Captions hit-test exactly like clips and audio bars now: the band is a
570
+ // rectangle in the same layout, and its blocks resolve through the same
571
+ // `resolveRow`. A separate fixture so the assertions above keep the geometry
572
+ // they were written against.
573
+ describe('hitTest — the caption band', () => {
574
+ const captioned = {
575
+ ...project,
576
+ captions: {
577
+ style: 'pop',
578
+ segments: [
579
+ // Deliberately NOT butted (a 1s gap): the gap is where "empty band"
580
+ // is probed, and it keeps each edge test aimed at one segment only.
581
+ { id: 's0', text: 'one', start: 0, end: 2 },
582
+ { id: 's1', text: 'two', start: 3, end: 5 },
583
+ // No id — before `backfillCaptionIds` has run. Draws, never hittable.
584
+ { text: 'three', start: 6, end: 8 },
585
+ ],
586
+ },
587
+ } as unknown as Project
588
+
589
+ const l = computeTimelineLayout(captioned)
590
+ const band = l.captions![0]
591
+ const BAND_Y = Math.round(band.y + band.height / 2)
592
+ const capBaseRow = l.rows.find(r => r.trackIdx === 0)!
593
+ const inBand = (x: number, y: number = BAND_Y) => hitTest({ x, y }, l, VIEWPORT)
594
+
595
+ it('sits immediately above the base video row', () => {
596
+ expect(band.y + band.height + ROW_GAP_PX).toBe(capBaseRow.y)
597
+ // …and below the overlay row, so it is genuinely between the two.
598
+ expect(band.y).toBeGreaterThan(l.rows.find(r => r.trackIdx === 1)!.y)
599
+ })
600
+
601
+ it('resolves a segment body', () => {
602
+ const hit = inBand(100)
603
+ expect(hit.kind).toBe('caption-body')
604
+ expect(hit.itemId).toBe('s0')
605
+ expect(hit.segment?.text).toBe('one')
606
+ expect(hit.t).toBeCloseTo(1)
607
+ })
608
+
609
+ it('resolves both trim handles', () => {
610
+ expect(inBand(2)).toMatchObject({ kind: 'caption-edge', itemId: 's0', edge: 'in' })
611
+ expect(inBand(198)).toMatchObject({ kind: 'caption-edge', itemId: 's0', edge: 'out' })
612
+ expect(inBand(302)).toMatchObject({ kind: 'caption-edge', itemId: 's1', edge: 'in' })
613
+ expect(inBand(498)).toMatchObject({ kind: 'caption-edge', itemId: 's1', edge: 'out' })
614
+ })
615
+
616
+ it('grabs handles at the AUDIO tolerance, not the visual one', () => {
617
+ // 7px in is body at 6px tolerance and would be the in-handle at 10px.
618
+ expect(AUDIO_EDGE_TOLERANCE_PX).toBeLessThan(VISUAL_EDGE_TOLERANCE_PX)
619
+ expect(inBand(AUDIO_EDGE_TOLERANCE_PX - 1).kind).toBe('caption-edge')
620
+ expect(inBand(AUDIO_EDGE_TOLERANCE_PX + 1).kind).toBe('caption-body')
621
+ })
622
+
623
+ it('reports background for the gaps between blocks, and past the last one', () => {
624
+ // Not a kind of its own: `background` already means marquee here, seek
625
+ // here, clear the selection — which is right for a gap in the band.
626
+ expect(inBand(250).kind).toBe('background')
627
+ expect(inBand(900).kind).toBe('background')
628
+ expect(isEmptyHit(inBand(250))).toBe(true)
629
+ })
630
+
631
+ it('never hits a segment that has no id yet', () => {
632
+ // x 600–800 is where the id-less segment is drawn.
633
+ expect(inBand(700).kind).toBe('background')
634
+ expect(inBand(602).kind).toBe('background')
635
+ })
636
+
637
+ it('hit-tests the FULL band height, inset and all', () => {
638
+ // The painter insets blocks by 4px top and bottom; the hit-test does not.
639
+ // A 40px row whose outer 8px silently missed would read as broken.
640
+ expect(inBand(100, band.y).kind).toBe('caption-body')
641
+ expect(inBand(100, band.y + band.height - 1).kind).toBe('caption-body')
642
+ // …and the pixel below the band belongs to the gap, not to the band.
643
+ expect(inBand(100, band.y + band.height).kind).not.toBe('caption-body')
644
+ })
645
+
646
+ it('reports the band lane on every caption hit — a single-band project is all lane 0', () => {
647
+ expect(inBand(100).captionLane).toBe(0)
648
+ expect(inBand(2).captionLane).toBe(0)
649
+ // Not a caption hit, so no lane to report.
650
+ expect(inBand(250).captionLane).toBeUndefined()
651
+ })
652
+
653
+ it('classifies caption hits through the predicates', () => {
654
+ expect(isCaptionHit(inBand(100))).toBe(true)
655
+ expect(isCaptionHit(inBand(2))).toBe(true)
656
+ expect(isCaptionHit(inBand(250))).toBe(false)
657
+ // Load-bearing: `grabsPlayhead` and the hover-handle pass both key off this.
658
+ expect(isEdgeHit(inBand(2))).toBe(true)
659
+ expect(isEdgeHit(inBand(100))).toBe(false)
660
+ // Deliberately excluded — see the predicate's own comment.
661
+ expect(isItemHit(inBand(100))).toBe(false)
662
+ })
663
+
664
+ it('leaves the rows and lanes below it exactly where they were', () => {
665
+ // The band pushes the base row down, so every other probe here has to keep
666
+ // reading its Y from the layout — this asserts the layout still resolves.
667
+ expect(hitTest({ x: 250, y: Math.round(capBaseRow.y + capBaseRow.height / 2) }, l, VIEWPORT))
668
+ .toMatchObject({ kind: 'item-body', itemId: 'c0' })
669
+ })
670
+
671
+ describe('itemsInRect', () => {
672
+ const caught = (r: { x: number; y: number; width: number; height: number }) =>
673
+ itemsInRect(r, l, VIEWPORT).sort()
674
+
675
+ it('catches caption ids when the box crosses the band', () => {
676
+ const rect = normalizeRect({ x: 100, y: band.y }, { x: 350, y: band.y + band.height })
677
+ expect(caught(rect)).toEqual(['s0', 's1'])
678
+ })
679
+
680
+ it('skips id-less segments the box crosses', () => {
681
+ const rect = normalizeRect({ x: 550, y: band.y }, { x: 900, y: band.y + band.height })
682
+ expect(caught(rect)).toEqual([])
683
+ })
684
+
685
+ it('catches captions alongside clips and bars in one tall box', () => {
686
+ const rect = normalizeRect(
687
+ { x: 98, y: l.rows.find(r => r.trackIdx === 1)!.y },
688
+ { x: 104, y: l.lanes[0].y + l.lanes[0].height },
689
+ )
690
+ // t=1: s0 (0–2), c0 (0–5) and a0 (1–6) are live; o0 (2–4) is not.
691
+ expect(caught(rect)).toEqual(['a0', 'c0', 's0'])
692
+ })
693
+
694
+ it('catches nothing from the band when the box stays clear of it', () => {
695
+ const rect = normalizeRect({ x: 0, y: capBaseRow.y }, { x: 1000, y: capBaseRow.y + 4 })
696
+ expect(caught(rect)).toEqual(['c0', 'c1'])
697
+ })
698
+ })
699
+ })
700
+
701
+ // ── Caption bands on more than one lane ────────────────────────────────────
702
+ // Bands are emitted in DESCENDING lane order, so lane 0 sits lowest (adjacent
703
+ // to the base video row) and higher lanes stack upward. A hit has to name the
704
+ // band it landed in, because that is where a vertical drag measures its lane
705
+ // delta from.
706
+ describe('hitTest — captions across several lanes', () => {
707
+ const laned = {
708
+ ...project,
709
+ captions: {
710
+ style: 'pop',
711
+ segments: [
712
+ { id: 'l0', text: 'ground', start: 0, end: 2 }, // lane 0 (absent ⇒ 0)
713
+ { id: 'l1', text: 'middle', start: 0, end: 2, lane: 1 }, // same span, one row up
714
+ { id: 'l2', text: 'top', start: 3, end: 5, lane: 2 },
715
+ ],
716
+ },
717
+ } as unknown as Project
718
+
719
+ const l = computeTimelineLayout(laned)
720
+ const bandFor = (lane: number) => l.captions!.find(b => b.lane === lane)!
721
+ const midY = (lane: number) => Math.round(bandFor(lane).y + bandFor(lane).height / 2)
722
+ const hit = (x: number, lane: number) => hitTest({ x, y: midY(lane) }, l, VIEWPORT)
723
+
724
+ it('stacks lane 0 lowest and higher lanes above it', () => {
725
+ expect(l.captions!.map(b => b.lane)).toEqual([2, 1, 0])
726
+ expect(bandFor(2).y).toBeLessThan(bandFor(1).y)
727
+ expect(bandFor(1).y).toBeLessThan(bandFor(0).y)
728
+ })
729
+
730
+ it('resolves each band to its own segment, and reports its lane', () => {
731
+ expect(hit(100, 0)).toMatchObject({ kind: 'caption-body', itemId: 'l0', captionLane: 0 })
732
+ expect(hit(100, 1)).toMatchObject({ kind: 'caption-body', itemId: 'l1', captionLane: 1 })
733
+ expect(hit(400, 2)).toMatchObject({ kind: 'caption-body', itemId: 'l2', captionLane: 2 })
734
+ })
735
+
736
+ it('does not let one lane catch another lane\'s segment at the same x', () => {
737
+ // l0 and l1 share the span 0–2s exactly. Only the band under the point
738
+ // answers; the identically-placed segment one row up is not reachable
739
+ // from lane 0's band.
740
+ expect(hit(100, 0).itemId).toBe('l0')
741
+ expect(hit(400, 0).kind).toBe('background') // l2's span, but l2 is on lane 2
742
+ expect(hit(100, 2).kind).toBe('background') // l0/l1's span, but they are lower
743
+ })
744
+
745
+ it('reports the lane on an EDGE hit too', () => {
746
+ expect(hit(2, 1)).toMatchObject({ kind: 'caption-edge', itemId: 'l1', edge: 'in', captionLane: 1 })
747
+ expect(hit(198, 1)).toMatchObject({ kind: 'caption-edge', itemId: 'l1', edge: 'out', captionLane: 1 })
748
+ })
749
+
750
+ it('never reports a caption lane through `laneIdx`, which is the AUDIO index', () => {
751
+ // Load-bearing: `laneIdx` is fed to `tieredBoundaries`, which ranks THAT
752
+ // audio lane's boundaries strong. A caption on lane 0 must not hand a drag
753
+ // audio lane 0's magnets.
754
+ expect(hit(100, 1).laneIdx).toBeUndefined()
755
+ expect(hit(100, 0).laneIdx).toBeUndefined()
756
+ })
757
+
758
+ it('still finds the rows and lanes pushed down by the extra bands', () => {
759
+ const pushedBase = l.rows.find(r => r.trackIdx === 0)!
760
+ expect(pushedBase.y).toBeGreaterThan(baseRow.y)
761
+ expect(hitTest({ x: 250, y: Math.round(pushedBase.y + pushedBase.height / 2) }, l, VIEWPORT))
762
+ .toMatchObject({ kind: 'item-body', itemId: 'c0' })
763
+ })
764
+
765
+ it('a marquee crossing several bands catches segments from every one of them', () => {
766
+ const rect = normalizeRect({ x: 50, y: bandFor(2).y }, { x: 450, y: bandFor(0).y + bandFor(0).height })
767
+ expect(itemsInRect(rect, l, VIEWPORT).sort()).toEqual(['l0', 'l1', 'l2'])
768
+ })
769
+
770
+ it('a marquee inside ONE band catches only that band', () => {
771
+ const rect = normalizeRect({ x: 50, y: bandFor(1).y }, { x: 450, y: bandFor(1).y + bandFor(1).height })
772
+ expect(itemsInRect(rect, l, VIEWPORT).sort()).toEqual(['l1'])
773
+ })
774
+ })
775
+
776
+ // ── itemsInRect: what a marquee box catches ────────────────────────────────
777
+ // The one piece the marquee has always leaned on but never tested directly.
778
+ // Intersection (touch), not containment; rows in surface space, spans in time.
779
+ describe('itemsInRect', () => {
780
+ const sorted = (r: { x: number; y: number; width: number; height: number }) =>
781
+ itemsInRect(r, layout, VIEWPORT).sort()
782
+
783
+ it('catches every clip a horizontal box crosses within one row', () => {
784
+ // A box dragged LEFT from the empty tail across the base row, kept in that
785
+ // row's own y-band: the reachable way to marquee a fully-tiled row.
786
+ const rect = normalizeRect({ x: 800, y: BASE_Y }, { x: 300, y: BASE_Y + 4 })
787
+ expect(sorted(rect)).toEqual(['c0', 'c1'])
788
+ })
789
+
790
+ it('selects a clip a box lands fully INSIDE — touch, not containment', () => {
791
+ // x 600–700 is wholly within c1 (x 500–1000) and reaches neither edge.
792
+ const rect = normalizeRect({ x: 600, y: BASE_Y }, { x: 700, y: BASE_Y + 4 })
793
+ expect(sorted(rect)).toEqual(['c1'])
794
+ })
795
+
796
+ it('a tall thin box down one instant selects across every row at that time', () => {
797
+ // At t=3 (x=300): o0 (2–4), c0 (0–5) and a0 (1–6) all live.
798
+ const rect = normalizeRect(
799
+ { x: 298, y: overlayRow.y },
800
+ { x: 304, y: lane0.y + lane0.height },
801
+ )
802
+ expect(sorted(rect)).toEqual(['a0', 'c0', 'o0'])
803
+ })
804
+
805
+ it('selects nothing when the box stays entirely below every row', () => {
806
+ const rect = normalizeRect(
807
+ { x: 0, y: lane0.y + lane0.height + 20 },
808
+ { x: 1000, y: lane0.y + lane0.height + 60 },
809
+ )
810
+ expect(sorted(rect)).toEqual([])
811
+ })
812
+
813
+ it('selects nothing when the box misses every clip in time', () => {
814
+ // Past the last clip (x 1050–1200 → t 10.5–12) but within the base row band.
815
+ const rect = normalizeRect({ x: 1050, y: BASE_Y }, { x: 1200, y: BASE_Y + 4 })
816
+ expect(sorted(rect)).toEqual([])
817
+ })
818
+ })