@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,501 @@
1
+ /**
2
+ * Canvas timeline hit-testing (SP5 T5) — point → target, and nothing else.
3
+ *
4
+ * The DOM timeline never needed this: each clip was an element, so the browser
5
+ * did the hit-testing and every gesture arrived pre-addressed. On canvas there
6
+ * is one surface and one pointer stream, so the address has to be computed.
7
+ *
8
+ * Two rules keep this honest:
9
+ *
10
+ * 1. **Layout is read, never re-derived.** Rows and lanes come from
11
+ * `computeTimelineLayout` (draw.ts) — the same rectangles the painter fills.
12
+ * A hit-test that computed its own geometry would drift from the picture the
13
+ * moment a row height changed, and the drift would show up as "I clicked the
14
+ * clip and nothing happened".
15
+ * 2. **No DOM.** The point is already surface-relative CSS pixels. Everything
16
+ * here is arithmetic over the layout and the viewport, so every case is a
17
+ * unit test rather than a browser session.
18
+ *
19
+ * Edge tolerances are the DOM resize-handle widths, so a trim grab that worked
20
+ * on the old timeline works here: `w-2.5` (10px) on VisualTrackRow's handles,
21
+ * `w-1.5` (6px) on AudioTrackRow's — and on the retired CaptionTrackRow's,
22
+ * which is why captions hit-test at the audio tolerance.
23
+ */
24
+
25
+ import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
26
+ import { canKeyframe, isKeyframed } from '../../keyframeOps'
27
+ import { AUDIO_ITEM_INSET_PX, type TimelineLayout } from './draw'
28
+ import { KEYFRAME_HIT_HALF_WIDTH_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
29
+ import { timeToX, xToTime, type Viewport } from './viewport'
30
+
31
+ export interface Point {
32
+ x: number
33
+ y: number
34
+ }
35
+
36
+ /** VisualTrackRow's `w-2.5` resize handles. */
37
+ export const VISUAL_EDGE_TOLERANCE_PX = 10
38
+
39
+ /** AudioTrackRow's `w-1.5` resize handles. */
40
+ export const AUDIO_EDGE_TOLERANCE_PX = 6
41
+
42
+ /** Grab band (half-width, px) around the 2px playhead line. Matches the audio
43
+ * trim-handle tolerance so the playhead is no harder to grab than an audio
44
+ * edge. A press within this many px of where the playhead is drawn starts a
45
+ * scrub, exactly as pressing the ruler does. */
46
+ export const PLAYHEAD_GRAB_PX = 6
47
+
48
+ /** Half-width (px) of an audio bar's fade-GRIP zone — small on purpose,
49
+ * matching `draw.ts`'s `FADE_GRIP_SIZE_PX` triangle it's the hit zone for.
50
+ * See `audioFadeGripZone`'s doc for the precedence this creates against the
51
+ * full-height trim edge. */
52
+ export const FADE_GRIP_HALF_WIDTH_PX = 5
53
+
54
+ /** How far down from the TOP of an audio bar the fade-grip zone reaches.
55
+ * Confines the grip to a small top-corner target rather than the bar's
56
+ * full height — which is what keeps it from swallowing the trim edge
57
+ * (`audio-edge`), grabbable across the bar's full height everywhere below
58
+ * this. */
59
+ export const FADE_GRIP_ZONE_HEIGHT_PX = 10
60
+
61
+ export interface HitTestOptions {
62
+ visualEdgeTolerancePx?: number
63
+ audioEdgeTolerancePx?: number
64
+ /** The current selection, ids only — needed for exactly one thing: a
65
+ * keyframe-strip diamond (`'keyframe'`) only exists to hit-test on a
66
+ * SELECTED overlay (plan decision 1; `drawKeyframeStrip` only PAINTS one
67
+ * there too). Absent or empty means no keyframe hits are possible, which
68
+ * is also what every caller that predates T3.3 gets — this option is
69
+ * purely additive. */
70
+ selectedIds?: readonly string[]
71
+ }
72
+
73
+ export type HitKind =
74
+ /** The time ruler strip along the top. Scrubbing lives here, and only here:
75
+ * the track area's empty space belongs to the marquee now. */
76
+ | 'ruler'
77
+ /** Inside a visual clip, away from its trim handles. */
78
+ | 'item-body'
79
+ /** Inside a visual clip's in/out trim handle. */
80
+ | 'item-edge'
81
+ /** On a keyframe-strip diamond (`drawKeyframeStrip` in draw.ts) — the small
82
+ * zone along the bottom of a SELECTED, keyframed overlay clip. Only ever
83
+ * produced for an item that satisfies all three (see `HitTestOptions.
84
+ * selectedIds`); takes precedence over `item-edge`/`item-body` within its
85
+ * own small zone, the same way `audio-fade` takes precedence over
86
+ * `audio-edge`/`audio-body`. */
87
+ | 'keyframe'
88
+ /** Inside an audio bar, away from its trim handles. */
89
+ | 'audio-body'
90
+ | 'audio-edge'
91
+ /** On an audio bar's fade-in or fade-out GRIP — a small zone at the TOP of
92
+ * the bar near the fade's inner edge (or the bar's own corner, when no
93
+ * fade is set). Takes precedence over `audio-edge`/`audio-body` within
94
+ * its own small zone; see `audioFadeGripZone`'s doc for the precedence
95
+ * this creates and why it doesn't swallow the trim edge. */
96
+ | 'audio-fade'
97
+ /** Inside a caption block, away from its trim handles. */
98
+ | 'caption-body'
99
+ /** Inside a caption block's in/out trim handle. */
100
+ | 'caption-edge'
101
+ /** A visual row, but no clip under the point. */
102
+ | 'empty-row'
103
+ /** An audio lane, but no bar under the point (includes the lane's inset). */
104
+ | 'empty-lane'
105
+ /** Outside every row: the gaps between rows, and anything past the last one. */
106
+ | 'background'
107
+
108
+ export interface HitResult {
109
+ kind: HitKind
110
+ /** Time at the point's x. Always present — every gesture needs it, and a
111
+ * point outside the rows is still a point in time. */
112
+ t: number
113
+ itemId?: string
114
+ /** Which trim handle, on the two `*-edge` kinds. `in` is the left/start
115
+ * handle, `out` the right/end one — the vocabulary `cuts.ts` uses for source
116
+ * windows, rather than the DOM hook's `start`/`end`. */
117
+ edge?: 'in' | 'out'
118
+ /** Which fade grip, on `audio-fade` hits. `in` is the fade-in grip
119
+ * (top-left corner or its inner edge), `out` the fade-out grip
120
+ * (top-right). A field of its own, deliberately NOT a reuse of `edge` —
121
+ * a fade grip is not a trim handle, and `isEdgeHit` (which several
122
+ * callers use to mean "this is a trim grab") must not go true for it. */
123
+ side?: 'in' | 'out'
124
+ /** The keyframe's item-relative time, on a `'keyframe'` hit — the SAME `t`
125
+ * every `keyframeOps` export addresses a point by. A field of its own,
126
+ * deliberately not a reuse of `t` (which is the point's ABSOLUTE timeline
127
+ * time, the same `t` every other hit kind reports). */
128
+ kfT?: number
129
+ /** Visual track index (project.tracks[trackIdx]) for row and item hits. */
130
+ trackIdx?: number
131
+ /** Audio lane index (the `lane` field / grouping index) for lane hits. */
132
+ laneIdx?: number
133
+ /** Which caption lane a caption hit resolved in — the `lane` of the band the
134
+ * point fell inside (see `CaptionRowLayout` in draw.ts).
135
+ *
136
+ * A field of its own, deliberately NOT a reuse of `laneIdx`. That one is an
137
+ * AUDIO lane index and is handed straight to `tieredBoundaries`, which ranks
138
+ * the boundaries of THAT audio lane STRONG for the whole gesture. A caption
139
+ * lane and an audio lane that happen to share a number have nothing to do
140
+ * with each other, so passing one through the other would give a caption
141
+ * drag the magnets of an unrelated row. */
142
+ captionLane?: number
143
+ /** The hit item itself, so callers don't re-scan the project. Captured at
144
+ * press time by the pointer machine and used as the gesture's origin. */
145
+ item?: VisualItem
146
+ track?: AudioTrack
147
+ /** The hit caption segment, for the same reason `item`/`track` are here: the
148
+ * pointer machine captures it at press time and reads its `start`/`end` for
149
+ * the whole gesture, rather than re-scanning `project.captions` per move. */
150
+ segment?: CaptionSegment
151
+ }
152
+
153
+ /** Which part of a horizontal span [x0, x1] a point falls in, given the handle
154
+ * width. Returns null when the point is outside the span entirely.
155
+ *
156
+ * Handles sit INSIDE the span, exactly as the DOM's absolutely-positioned
157
+ * `left-0`/`right-0` handle divs do. When a clip is narrower than two handles
158
+ * the two zones overlap, and the out handle wins — matching the DOM, where the
159
+ * right handle is painted last and therefore hit-tests first. */
160
+ function spanZone(x: number, x0: number, x1: number, tolerance: number): 'body' | 'in' | 'out' | null {
161
+ if (x < x0 || x > x1) return null
162
+ if (x >= x1 - tolerance) return 'out'
163
+ if (x <= x0 + tolerance) return 'in'
164
+ return 'body'
165
+ }
166
+
167
+ /** One row's worth of hit resolution, shared by the visual and audio scans.
168
+ *
169
+ * Edges beat bodies ACROSS the whole row, and the nearest edge wins.
170
+ *
171
+ * The obvious rule — topmost item wins, and within it an edge beats its own
172
+ * body — is what this replaces, and it made the trailing edge of an
173
+ * overlapped clip completely ungrabbable. Where clip B overlaps the end of
174
+ * clip A, every point near A's out edge is also inside B, and B is on top, so
175
+ * the scan returned B's body and stopped. You could see A's edge; you could
176
+ * not trim it. Crossfaded audio bars had the same hole, permanently.
177
+ *
178
+ * Nearest-edge-wins fixes it without disturbing the ordinary case: a point is
179
+ * only ever a candidate for items whose span actually contains it, and for
180
+ * clips that merely touch, the shared boundary is equidistant, so the tie
181
+ * falls to the topmost item exactly as before. Ties resolve to the topmost
182
+ * because the scan runs back-to-front and only a STRICTLY nearer edge
183
+ * displaces the incumbent.
184
+ */
185
+ function resolveRow<T extends { id: string; start: number; end: number }>(
186
+ x: number,
187
+ items: readonly T[],
188
+ viewport: Viewport,
189
+ tolerance: number,
190
+ ): { item: T; edge: 'in' | 'out' } | { item: T; edge: null } | null {
191
+ let bestEdge: { item: T; edge: 'in' | 'out'; dist: number } | null = null
192
+ let topBody: T | null = null
193
+
194
+ for (let i = items.length - 1; i >= 0; i--) {
195
+ const item = items[i]
196
+ const x0 = timeToX(item.start, viewport)
197
+ const x1 = timeToX(item.end, viewport)
198
+ const zone = spanZone(x, x0, x1, tolerance)
199
+ if (zone === null) continue
200
+ if (zone === 'body') {
201
+ if (topBody === null) topBody = item
202
+ continue
203
+ }
204
+ const dist = zone === 'in' ? x - x0 : x1 - x
205
+ if (bestEdge === null || dist < bestEdge.dist) bestEdge = { item, edge: zone, dist }
206
+ }
207
+
208
+ if (bestEdge !== null) return { item: bestEdge.item, edge: bestEdge.edge }
209
+ if (topBody !== null) return { item: topBody, edge: null }
210
+ return null
211
+ }
212
+
213
+ /** The screen x of an audio bar's fade GRIP for one side — at the fade's
214
+ * inner edge when a fade is set, or at the bar's own corner when it isn't
215
+ * (drag inward from there to create one). Expressed in TIME-then-`timeToX`,
216
+ * the same convention `resolveRow` uses for every other audio-lane zone:
217
+ * the gutter `clipBodyRect` insets the DRAWN body by is paint, not a dead
218
+ * zone, so hit-testing always works over the raw span. Shares this math
219
+ * with `draw.ts`'s own grip placement (there expressed in already-converted
220
+ * px, since the painter works from a `rect` rather than a `track`) only in
221
+ * spirit — the two are kept independently computed because they start from
222
+ * different inputs, not duplicated by accident. */
223
+ function audioFadeGripX(track: AudioTrack, side: 'in' | 'out', viewport: Viewport): number {
224
+ return side === 'in'
225
+ ? timeToX(track.start + Math.max(0, track.fadeIn ?? 0), viewport)
226
+ : timeToX(track.end - Math.max(0, track.fadeOut ?? 0), viewport)
227
+ }
228
+
229
+ /**
230
+ * Does `point` fall in one of `track`'s two fade-grip zones? Returns the
231
+ * matching side, or null.
232
+ *
233
+ * Confined to the TOP `FADE_GRIP_ZONE_HEIGHT_PX` of the bar (`barTop` is the
234
+ * same y the caller already computed for the bar's vertical inset) — this,
235
+ * not a separate x-tolerance trick, is what keeps the grip from swallowing
236
+ * the trim edge. `audio-edge` is grabbable across the bar's FULL height; a
237
+ * fade grip only claims a small target at the very top, near the same
238
+ * corner, so the two zones barely overlap and where they do (no fade set,
239
+ * grip sitting exactly at the corner) the grip wins — it is the smaller,
240
+ * more deliberate target, the same reasoning `grabsPlayhead` already gives
241
+ * trim edges over the playhead grab.
242
+ */
243
+ function audioFadeGripZone(
244
+ point: Point,
245
+ track: AudioTrack,
246
+ barTop: number,
247
+ viewport: Viewport,
248
+ ): 'in' | 'out' | null {
249
+ if (point.y < barTop || point.y > barTop + FADE_GRIP_ZONE_HEIGHT_PX) return null
250
+ const x0 = timeToX(track.start, viewport)
251
+ const x1 = timeToX(track.end, viewport)
252
+ if (point.x < x0 - FADE_GRIP_HALF_WIDTH_PX || point.x > x1 + FADE_GRIP_HALF_WIDTH_PX) return null
253
+ const inX = audioFadeGripX(track, 'in', viewport)
254
+ if (Math.abs(point.x - inX) <= FADE_GRIP_HALF_WIDTH_PX) return 'in'
255
+ const outX = audioFadeGripX(track, 'out', viewport)
256
+ if (Math.abs(point.x - outX) <= FADE_GRIP_HALF_WIDTH_PX) return 'out'
257
+ return null
258
+ }
259
+
260
+ /**
261
+ * Does `point` fall on one of `item`'s keyframe-strip diamonds? Returns the
262
+ * matching keyframe's item-relative `t`, or null.
263
+ *
264
+ * Confined to the bottom `KEYFRAME_STRIP_ZONE_HEIGHT_PX` of the row — the same
265
+ * "small dedicated target near where it's drawn" reasoning `audioFadeGripZone`
266
+ * uses to keep a grip from swallowing the trim edge below it; here what sits
267
+ * below is `item-body`/`item-edge`, not `audio-body`/`audio-edge`, but the
268
+ * precedence story is identical. Callers gate this on selection themselves
269
+ * (`hitTest` only calls it for ids in `opts.selectedIds`) — it does not check
270
+ * again, mirroring how `drawKeyframeStrip` is gated by ITS caller rather than
271
+ * internally.
272
+ *
273
+ * Out-of-span times (`t < 0` or `t > duration`) are skipped. `draw.ts`'s
274
+ * `drawKeyframeStrip` clips such a diamond away — it is never actually drawn
275
+ * anywhere — but `keyframeDiamondX` is happy to compute a screen x for it
276
+ * regardless, and that x can land ANYWHERE in the row, including on top of a
277
+ * different clip's body that IS drawn there. Without this filter that
278
+ * invisible zone would win over the real `item-body`/`item-edge` hit
279
+ * underneath it, purely because it happened to be checked first.
280
+ */
281
+ function keyframeStripZone(point: Point, item: VisualItem, rowY: number, rowHeight: number, viewport: Viewport): number | null {
282
+ const zoneTop = rowY + rowHeight - KEYFRAME_STRIP_ZONE_HEIGHT_PX
283
+ if (point.y < zoneTop || point.y >= rowY + rowHeight) return null
284
+ const duration = Math.max(0, item.end - item.start)
285
+ for (const t of keyframeUnionTimes(item)) {
286
+ if (t < 0 || t > duration) continue
287
+ if (Math.abs(point.x - keyframeDiamondX(item, t, viewport)) <= KEYFRAME_HIT_HALF_WIDTH_PX) return t
288
+ }
289
+ return null
290
+ }
291
+
292
+ /**
293
+ * Resolve a surface-space point to what sits under it.
294
+ *
295
+ * Items within a row are scanned back-to-front (last in the array first), which
296
+ * is the order the painter draws them and therefore the order the DOM stacked
297
+ * them: the clip drawn on top is the clip you grab. Row bounds are half-open on
298
+ * the bottom edge so the 4px inter-row gap belongs to neither neighbour.
299
+ */
300
+ export function hitTest(
301
+ point: Point,
302
+ layout: TimelineLayout,
303
+ viewport: Viewport,
304
+ opts: HitTestOptions = {},
305
+ ): HitResult {
306
+ const t = xToTime(point.x, viewport)
307
+ const visualTolerance = opts.visualEdgeTolerancePx ?? VISUAL_EDGE_TOLERANCE_PX
308
+ const audioTolerance = opts.audioEdgeTolerancePx ?? AUDIO_EDGE_TOLERANCE_PX
309
+
310
+ // Before the rows, and including everything above the strip: a pointer that
311
+ // has run off the top of the surface is still aiming at the ruler, and
312
+ // clamping it there keeps a scrub alive when the hand drifts upward
313
+ // mid-drag rather than dropping the gesture on the floor.
314
+ if (point.y < layout.ruler.y + layout.ruler.height) return { kind: 'ruler', t }
315
+
316
+ // The caption bands. Their y-ranges are disjoint from every row and lane
317
+ // (and from each other), so this could sit anywhere among the scans below;
318
+ // it goes here because it was a single optional rectangle like the ruler
319
+ // before multi-lane captions existed, and this keeps that spot.
320
+ //
321
+ // The loop finds WHICH band the point falls in and then runs the same
322
+ // single-band logic against that band, reporting the band's `lane` back as
323
+ // `captionLane` so a drag knows which row it started on. With exactly one
324
+ // band this is byte-for-byte the pre-lanes behaviour, plus a `captionLane`
325
+ // of 0 that nothing which doesn't care ever reads.
326
+ for (const caption of layout.captions ?? []) {
327
+ if (point.y < caption.y || point.y >= caption.y + caption.height) continue
328
+ // Id-less segments are never hittable. `backfillCaptionIds` mints an id
329
+ // shortly after captions arrive, but until it has, a segment has nothing
330
+ // to select BY — the same guard the retired CaptionTrackRow spelled `canInteract`.
331
+ const hittable = caption.segments.filter(
332
+ (seg): seg is CaptionSegment & { id: string } => typeof seg.id === 'string',
333
+ )
334
+ // Deliberately the FULL band height, unlike the audio-lane branch below,
335
+ // which treats its inset strip as lane background. The painter insets
336
+ // caption blocks by the same `AUDIO_ITEM_INSET_PX`, so this is a 4px
337
+ // cosmetic mismatch at the top and bottom — and that is much the lesser
338
+ // evil: a 40px row whose outer 8px silently miss reads as a broken row,
339
+ // while an audio LANE is tall enough that its inset is visibly gutter.
340
+ //
341
+ // The AUDIO tolerance, not a third one of its own: the retired DOM row's
342
+ // handles were `w-1.5` like AudioTrackRow's, and the painter draws
343
+ // caption handles at `AUDIO_HANDLE_WIDTH_PX`. Captions borrow the audio
344
+ // handle vocabulary wholesale, so they borrow its grab width too.
345
+ const hit = resolveRow(point.x, hittable, viewport, audioTolerance)
346
+ if (hit === null) {
347
+ // Not `empty-caption-row`. `background` already means exactly what the
348
+ // gaps between caption blocks should do — marquee from here, seek here,
349
+ // clear the selection — and a new kind would only force `isEmptyHit`
350
+ // and `resolveGesture` to learn about it for no behavioural gain.
351
+ return { kind: 'background', t }
352
+ }
353
+ if (hit.edge === null) return { kind: 'caption-body', t, itemId: hit.item.id, segment: hit.item, captionLane: caption.lane }
354
+ return { kind: 'caption-edge', t, itemId: hit.item.id, edge: hit.edge, segment: hit.item, captionLane: caption.lane }
355
+ }
356
+
357
+ const selectedIds = opts.selectedIds
358
+ for (const row of layout.rows) {
359
+ if (point.y < row.y || point.y >= row.y + row.height) continue
360
+ // Keyframe diamonds take precedence within their own small bottom-strip
361
+ // zone — selected, keyframed overlay items only (nothing else EVER draws
362
+ // a diamond; see `drawKeyframeStrip`). Scanned back-to-front like
363
+ // `resolveRow`, so a diamond on a higher-stacked item wins ties the same
364
+ // way a body/edge hit would.
365
+ if (selectedIds !== undefined && selectedIds.length > 0) {
366
+ for (let i = row.items.length - 1; i >= 0; i--) {
367
+ const item = row.items[i]
368
+ if (!canKeyframe(item) || !selectedIds.includes(item.id) || !isKeyframed(item)) continue
369
+ const kfT = keyframeStripZone(point, item, row.y, row.height, viewport)
370
+ if (kfT !== null) return { kind: 'keyframe', t, itemId: item.id, kfT, trackIdx: row.trackIdx, item }
371
+ }
372
+ }
373
+ const hit = resolveRow(point.x, row.items, viewport, visualTolerance)
374
+ if (hit === null) return { kind: 'empty-row', t, trackIdx: row.trackIdx }
375
+ if (hit.edge === null) return { kind: 'item-body', t, itemId: hit.item.id, trackIdx: row.trackIdx, item: hit.item }
376
+ return { kind: 'item-edge', t, itemId: hit.item.id, edge: hit.edge, trackIdx: row.trackIdx, item: hit.item }
377
+ }
378
+
379
+ for (const lane of layout.lanes) {
380
+ if (point.y < lane.y || point.y >= lane.y + lane.height) continue
381
+ // Audio bars are inset vertically (`top-1 bottom-1`); the inset strip is
382
+ // lane background in the DOM too, not part of the bar.
383
+ const barTop = lane.y + AUDIO_ITEM_INSET_PX
384
+ const barBottom = lane.y + lane.height - AUDIO_ITEM_INSET_PX
385
+ if (point.y >= barTop && point.y < barBottom) {
386
+ // Fade grips take precedence within their own small top-corner zone —
387
+ // see `audioFadeGripZone`'s doc for why. Scanned back-to-front like
388
+ // `resolveRow`, so a grip on a higher-stacked (later-drawn, crossfaded)
389
+ // bar wins ties the same way a body/edge hit would.
390
+ for (let i = lane.tracks.length - 1; i >= 0; i--) {
391
+ const track = lane.tracks[i]
392
+ const side = audioFadeGripZone(point, track, barTop, viewport)
393
+ if (side !== null) return { kind: 'audio-fade', t, itemId: track.id, side, laneIdx: lane.laneIndex, track }
394
+ }
395
+ const hit = resolveRow(point.x, lane.tracks, viewport, audioTolerance)
396
+ if (hit !== null) {
397
+ if (hit.edge === null) return { kind: 'audio-body', t, itemId: hit.item.id, laneIdx: lane.laneIndex, track: hit.item }
398
+ return { kind: 'audio-edge', t, itemId: hit.item.id, edge: hit.edge, laneIdx: lane.laneIndex, track: hit.item }
399
+ }
400
+ }
401
+ return { kind: 'empty-lane', t, laneIdx: lane.laneIndex }
402
+ }
403
+
404
+ return { kind: 'background', t }
405
+ }
406
+
407
+ export interface SurfaceRect { x: number; y: number; width: number; height: number }
408
+
409
+ /** A rect from two corners, in any drag direction. A marquee dragged up-left
410
+ * is the same box as one dragged down-right; normalizing here means every
411
+ * consumer can assume non-negative width and height. */
412
+ export function normalizeRect(a: Point, b: Point): SurfaceRect {
413
+ const x = Math.min(a.x, b.x)
414
+ const y = Math.min(a.y, b.y)
415
+ return { x, y, width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) }
416
+ }
417
+
418
+ function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
419
+ return aStart < bEnd && bStart < aEnd
420
+ }
421
+
422
+ /**
423
+ * Every item id the marquee box touches — visual clips and audio bars alike.
424
+ *
425
+ * Touch, not containment: a box only counts items it fully encloses forces you
426
+ * to drag past the ends of long clips to catch them, which on a zoomed-in
427
+ * timeline can mean dragging off-screen. Every NLE uses intersection, and so
428
+ * does the reference this was built from.
429
+ *
430
+ * Rows are tested against the box in surface space and spans in time space, so
431
+ * a tall thin box down one moment in time selects across every track at once —
432
+ * which is the gesture's most useful form.
433
+ */
434
+ export function itemsInRect(
435
+ rect: SurfaceRect,
436
+ layout: TimelineLayout,
437
+ viewport: Viewport,
438
+ ): string[] {
439
+ const ids: string[] = []
440
+ const left = xToTime(rect.x, viewport)
441
+ const right = xToTime(rect.x + rect.width, viewport)
442
+ const top = rect.y
443
+ const bottom = rect.y + rect.height
444
+
445
+ // Captions first, mirroring `hitTest`'s order. Id-less segments are skipped
446
+ // for the same reason they are unhittable: an id is what a selection is.
447
+ // One pass per band, same as the row/lane scans below — a marquee spanning
448
+ // several caption lanes picks up segments from every band it touches.
449
+ for (const caption of layout.captions ?? []) {
450
+ if (!overlaps(caption.y, caption.y + caption.height, top, bottom)) continue
451
+ for (const seg of caption.segments) {
452
+ if (typeof seg.id === 'string' && overlaps(seg.start, seg.end, left, right)) ids.push(seg.id)
453
+ }
454
+ }
455
+ for (const row of layout.rows) {
456
+ if (!overlaps(row.y, row.y + row.height, top, bottom)) continue
457
+ for (const item of row.items) {
458
+ if (overlaps(item.start, item.end, left, right)) ids.push(item.id)
459
+ }
460
+ }
461
+ for (const lane of layout.lanes) {
462
+ if (!overlaps(lane.y, lane.y + lane.height, top, bottom)) continue
463
+ for (const track of lane.tracks) {
464
+ if (overlaps(track.start, track.end, left, right)) ids.push(track.id)
465
+ }
466
+ }
467
+ return ids
468
+ }
469
+
470
+ /** Do the three `*-edge` kinds and their `edge` field describe a trim grab?
471
+ *
472
+ * Captions are in here deliberately, and it is load-bearing in both callers:
473
+ * `grabsPlayhead` lets a caption EDGE beat a playhead grab while a caption
474
+ * BODY yields to it, and TimelineCanvas's hover pass uses this to decide
475
+ * which trim handle to light up — handles the painter already draws on a
476
+ * selected caption. */
477
+ export function isEdgeHit(hit: HitResult): boolean {
478
+ return hit.kind === 'item-edge' || hit.kind === 'audio-edge' || hit.kind === 'caption-edge'
479
+ }
480
+
481
+ /** Is this a hit on a clip or an audio bar (either body or edge)?
482
+ *
483
+ * Captions are deliberately NOT folded in, unlike `isEdgeHit` above. This
484
+ * predicate has no non-test callers, so widening it would change a documented
485
+ * meaning that nothing currently reads, on a guess about what a future caller
486
+ * wants. Ask the caption question with `isCaptionHit`; a caller that wants
487
+ * "anything grabbable" can OR the two, and does so knowingly. */
488
+ export function isItemHit(hit: HitResult): boolean {
489
+ return hit.kind === 'item-body' || hit.kind === 'item-edge'
490
+ || hit.kind === 'audio-body' || hit.kind === 'audio-edge'
491
+ }
492
+
493
+ /** Is this a hit on a caption block (either body or edge)? */
494
+ export function isCaptionHit(hit: HitResult): boolean {
495
+ return hit.kind === 'caption-body' || hit.kind === 'caption-edge'
496
+ }
497
+
498
+ /** Nothing under the point but timeline: every kind that means "seek here". */
499
+ export function isEmptyHit(hit: HitResult): boolean {
500
+ return hit.kind === 'empty-row' || hit.kind === 'empty-lane' || hit.kind === 'background'
501
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Keyframe-strip geometry (SP9b T3.3) — the handful of pure helpers shared by
3
+ * the painter (`draw.ts`'s `drawKeyframeStrip`) and the hit-tester
4
+ * (`hit-test.ts`'s keyframe zone), so a diamond's clickable zone is always
5
+ * exactly where it is drawn. Mirrors why `fade-curve.ts` is its own module
6
+ * rather than living inside either caller: one function, two readers, no risk
7
+ * of the two positions drifting apart.
8
+ *
9
+ * The strip draws ONE diamond per DISTINCT keyframe TIME across ALL of an
10
+ * item's keyframe tracks — a merged strip, not one row per property (plan
11
+ * decision 2). `keyframeUnionTimes` is that union; `keyframeDiamondX` is
12
+ * where the diamond for one of those times lands on screen.
13
+ */
14
+
15
+ import type { VisualItem } from '../../../schema'
16
+ import { timeToX, type Viewport } from './viewport'
17
+
18
+ /** Edge-to-edge size (px) of a drawn diamond. Mirrors `FADE_GRIP_SIZE_PX`'s
19
+ * role for the fade grip: the one number both the painter and the
20
+ * hit-tester measure the shape by. */
21
+ export const KEYFRAME_DIAMOND_SIZE_PX = 8
22
+
23
+ /** Half-width (px) of a diamond's CLICKABLE zone — a little roomier than the
24
+ * diamond itself is drawn, the same margin `FADE_GRIP_HALF_WIDTH_PX` gives
25
+ * the fade grip over `FADE_GRIP_SIZE_PX`. */
26
+ export const KEYFRAME_HIT_HALF_WIDTH_PX = 6
27
+
28
+ /** Gap (px) between the diamond's own centre and the clip body's bottom
29
+ * edge, so it doesn't sit flush on the border. */
30
+ export const KEYFRAME_STRIP_BOTTOM_PAD_PX = 4
31
+
32
+ /** How tall the strip's clickable zone is, measured up from the BOTTOM of the
33
+ * row — mirrors `FADE_GRIP_ZONE_HEIGHT_PX` confining the fade grip to the
34
+ * bar's top. Confines diamonds to a thin band along the bottom so they never
35
+ * compete with the trim handles (grabbable across the row's full height) or
36
+ * the clip label (pinned to the top) anywhere but right at the diamond's own
37
+ * small target — the same "small dedicated zone wins locally, the rest of
38
+ * the edge is untouched" precedent `audioFadeGripZone` sets.
39
+ *
40
+ * DERIVED, not a hand-picked number, so it can never drift out of sync with
41
+ * where a diamond is actually drawn (`drawKeyframeStrip`'s `y = body.y +
42
+ * body.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX`,
43
+ * with the diamond's own half-height extending `KEYFRAME_DIAMOND_SIZE_PX / 2`
44
+ * above and below that centre). A previous hand-picked `10` put the zone at
45
+ * `[bottom-10, bottom)` against a diamond actually drawn at `[bottom-12,
46
+ * bottom-4]` — the diamond's own top 2px fell OUTSIDE the clickable zone,
47
+ * while 4px of empty space below the diamond fell INSIDE it. This value is
48
+ * exactly `KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX`, which
49
+ * puts the zone's own top edge exactly at the diamond's top edge (both
50
+ * measured up from the same `bottom` reference), so the diamond's full height
51
+ * is inside the zone with no gap. */
52
+ export const KEYFRAME_STRIP_ZONE_HEIGHT_PX = KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
53
+
54
+ /**
55
+ * Every DISTINCT keyframe time (item-relative seconds) across all of `item`'s
56
+ * keyframe tracks, ascending and de-duplicated. This IS the "union of times"
57
+ * plan decision 2 describes: several props sharing a `t` collapse to one
58
+ * diamond, so the strip stays a strip rather than becoming a curve editor.
59
+ */
60
+ export function keyframeUnionTimes(item: VisualItem): number[] {
61
+ const seen = new Set<number>()
62
+ for (const track of item.keyframes ?? []) {
63
+ for (const point of track.points) seen.add(point.t)
64
+ }
65
+ return [...seen].sort((a, b) => a - b)
66
+ }
67
+
68
+ /** Screen x for the diamond at item-relative time `t` on `item` — `item.start
69
+ * + t` run through the SAME seconds→px conversion every other draw/hit-test
70
+ * call on this surface uses (plan decision 3: never invent a parallel one). */
71
+ export function keyframeDiamondX(item: VisualItem, t: number, viewport: Viewport): number {
72
+ return timeToX(item.start + t, viewport)
73
+ }