@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,2187 @@
1
+ /**
2
+ * Canvas timeline painter (SP5 T4) — pure draw functions and the scene
3
+ * composition that culls to the visible time range.
4
+ *
5
+ * Every export here is pure: no React, no DOM reads, no measuring. Each draw
6
+ * function takes a 2D-context-shaped object and explicit layout arguments, so
7
+ * tests drive them with a recording stub and assert on the call list. The
8
+ * component (`TimelineCanvas`) owns the surface, the schedule and the state;
9
+ * this module owns nothing but ink.
10
+ *
11
+ * The palette mirrors the DOM rows' Tailwind classes (VisualTrackRow's
12
+ * six-colour track cycle, AudioTrackRow's emerald bars) resolved to literal
13
+ * colours, because a canvas can't read a class. Keep them in sync by hand: the
14
+ * canvas is meant to look like the timeline users already know, not like a new
15
+ * design.
16
+ */
17
+
18
+ import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
19
+ import type { Project } from '../../../types'
20
+ import { groupCaptionLanes } from '../../captionLanes'
21
+ import {
22
+ AUDIO_LANE_HEIGHT_PX,
23
+ BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
24
+ CAPTION_ROW_HEIGHT_PX,
25
+ visualItemLabel,
26
+ ROW_GAP_PX,
27
+ VISUAL_ROW_RENDER_HEIGHT_PX,
28
+ computeDerivedTiming,
29
+ groupAudioLanes,
30
+ normalizeTracks,
31
+ trackItems,
32
+ } from '../timeline-model'
33
+ import { DEFAULT_FADE_CURVE, fadeGain, makeFadeGainAt, type FadeCurve } from './fade-curve'
34
+ import { canKeyframe, isKeyframed } from '../../keyframeOps'
35
+ import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
36
+ import type { SnapStrength } from './snap'
37
+ import { timeToX, visibleRange, type Viewport } from './viewport'
38
+ import {
39
+ LIGHT_WAVEFORM_COLORS,
40
+ WAVEFORM_COLORS,
41
+ drawAudioLaneWaveform,
42
+ drawClipWaveform,
43
+ type WaveformColors,
44
+ type WaveformSceneLookup,
45
+ } from './waveforms'
46
+ import { drawFilmstripTiles, type FilmstripSceneLookup } from './filmstrips'
47
+
48
+ // ── The context surface the painter needs ────────────────────────────────
49
+ // A structural subset of CanvasRenderingContext2D: a real context satisfies it,
50
+ // and a recording stub can too. Keeping it narrow is what makes the draw calls
51
+ // assertable.
52
+
53
+ export interface DrawContext {
54
+ save(): void
55
+ restore(): void
56
+ beginPath(): void
57
+ closePath(): void
58
+ moveTo(x: number, y: number): void
59
+ lineTo(x: number, y: number): void
60
+ /** Unused by any painter today — the fade-envelope curve
61
+ * (`drawFadeEnvelope`) was its one caller until fade shapes replaced the
62
+ * single quadratic ease with a sampled polyline. Kept on the interface
63
+ * since it's part of the real `CanvasRenderingContext2D` shape a live
64
+ * context satisfies for free, and a future curve is one candidate use. */
65
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
66
+ arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
67
+ rect(x: number, y: number, w: number, h: number): void
68
+ clip(): void
69
+ fill(): void
70
+ stroke(): void
71
+ fillRect(x: number, y: number, w: number, h: number): void
72
+ strokeRect(x: number, y: number, w: number, h: number): void
73
+ clearRect(x: number, y: number, w: number, h: number): void
74
+ fillText(text: string, x: number, y: number, maxWidth?: number): void
75
+ createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient
76
+ /** T7 — filmstrip tiles and the hover-scrub thumb are the only content
77
+ * drawn as images rather than shapes/text. */
78
+ drawImage(
79
+ image: CanvasImageSource,
80
+ sx: number, sy: number, sw: number, sh: number,
81
+ dx: number, dy: number, dw: number, dh: number,
82
+ ): void
83
+ fillStyle: string | CanvasGradient | CanvasPattern
84
+ strokeStyle: string | CanvasGradient | CanvasPattern
85
+ lineWidth: number
86
+ font: string
87
+ textBaseline: CanvasTextBaseline
88
+ globalAlpha: number
89
+ /** Set only around clip labels, which sit over filmstrip frames; `restore()`
90
+ * clears it so no shape painter inherits a shadow. */
91
+ shadowColor: string
92
+ shadowBlur: number
93
+ /** Dash pattern for subsequent strokes; `[]` is a solid line. Used by exactly
94
+ * one painter (`drawPendingDropBand`), because "dashed" is the one outline
95
+ * on this surface that means "not real yet". It is set INSIDE that painter's
96
+ * own save/restore pair — a dash left on the context leaks into every later
97
+ * stroke on the same layer, which on the overlay would mean a dashed
98
+ * playhead. */
99
+ setLineDash(segments: number[]): void
100
+ }
101
+
102
+ // ── Palette ──────────────────────────────────────────────────────────────
103
+
104
+ export interface TrackPalette {
105
+ fill: string
106
+ fillSelected: string
107
+ ring: string
108
+ border: string
109
+ text: string
110
+ }
111
+
112
+ /** VisualTrackRow's `trackColors`, resolved. Same order, same cycle length —
113
+ * a clip keeps its colour when the flag flips. */
114
+ export const TRACK_PALETTE: TrackPalette[] = [
115
+ { fill: 'rgba(71,85,105,0.8)', fillSelected: 'rgba(100,116,139,0.9)', ring: 'rgba(203,213,225,0.8)', border: 'rgba(148,163,184,0.5)', text: '#e2e8f0' }, // slate
116
+ { fill: 'rgba(3,105,161,0.8)', fillSelected: 'rgba(2,132,199,0.9)', ring: 'rgba(125,211,252,0.8)', border: 'rgba(56,189,248,0.5)', text: '#bae6fd' }, // sky
117
+ { fill: 'rgba(109,40,217,0.8)', fillSelected: 'rgba(124,58,237,0.9)', ring: 'rgba(196,181,253,0.8)', border: 'rgba(167,139,250,0.5)', text: '#ddd6fe' }, // violet
118
+ { fill: 'rgba(4,120,87,0.8)', fillSelected: 'rgba(5,150,105,0.9)', ring: 'rgba(110,231,183,0.8)', border: 'rgba(52,211,153,0.5)', text: '#a7f3d0' }, // emerald
119
+ { fill: 'rgba(190,18,60,0.8)', fillSelected: 'rgba(225,29,72,0.9)', ring: 'rgba(253,164,175,0.8)', border: 'rgba(251,113,133,0.5)', text: '#fecdd3' }, // rose
120
+ { fill: 'rgba(180,83,9,0.6)', fillSelected: 'rgba(217,119,6,0.8)', ring: 'rgba(251,191,36,0.8)', border: 'rgba(245,158,11,0.5)', text: '#fde68a' }, // amber
121
+ ]
122
+
123
+ /** `TRACK_PALETTE` for a LIGHT row.
124
+ *
125
+ * Same six hue identities in the same order — slate, sky, violet, emerald,
126
+ * rose, amber — because a clip's colour is how you recognize which track it
127
+ * came from, and that must not change when the host flips theme. What DOES
128
+ * change is which end of each hue's ramp is used: dark rows take a saturated
129
+ * 500/700-level fill with a 100/200-level LABEL on it, light rows take a
130
+ * 200/300-level fill with a 800/900-level label. The border/ring pair follows
131
+ * the same inversion (a 500-level hairline, a 700-level selection ring), so
132
+ * "the ring is brighter than the fill" becomes "the ring is DARKER than the
133
+ * fill" — the same statement about contrast, read on the other ground.
134
+ *
135
+ * Fills stay at high alpha (0.85/0.95 rather than the dark set's 0.8/0.9)
136
+ * because a pale tint at 0.8 over an already-pale row washes out into it; the
137
+ * amber entry keeps its own lower pair (0.7/0.9 against the dark set's
138
+ * 0.6/0.8) so it stays the quietest of the six here too. */
139
+ export const LIGHT_TRACK_PALETTE: TrackPalette[] = [
140
+ { fill: 'rgba(203,213,225,0.85)', fillSelected: 'rgba(148,163,184,0.95)', ring: 'rgba(51,65,85,0.85)', border: 'rgba(100,116,139,0.6)', text: '#1e293b' }, // slate
141
+ { fill: 'rgba(186,230,253,0.85)', fillSelected: 'rgba(125,211,252,0.95)', ring: 'rgba(3,105,161,0.85)', border: 'rgba(14,165,233,0.6)', text: '#0c4a6e' }, // sky
142
+ { fill: 'rgba(221,214,254,0.85)', fillSelected: 'rgba(196,181,253,0.95)', ring: 'rgba(109,40,217,0.85)', border: 'rgba(139,92,246,0.6)', text: '#4c1d95' }, // violet
143
+ { fill: 'rgba(167,243,208,0.85)', fillSelected: 'rgba(110,231,183,0.95)', ring: 'rgba(4,120,87,0.85)', border: 'rgba(16,185,129,0.6)', text: '#064e3b' }, // emerald
144
+ { fill: 'rgba(254,205,211,0.85)', fillSelected: 'rgba(253,164,175,0.95)', ring: 'rgba(190,18,60,0.85)', border: 'rgba(244,63,94,0.6)', text: '#881337' }, // rose
145
+ { fill: 'rgba(253,230,138,0.7)', fillSelected: 'rgba(252,211,77,0.9)', ring: 'rgba(180,83,9,0.85)', border: 'rgba(245,158,11,0.6)', text: '#78350f' }, // amber
146
+ ]
147
+
148
+ /** The caption block palette. Unlike `TRACK_PALETTE`, which cycles a hue per
149
+ * track, every caption block shares this ONE palette — captions are a single
150
+ * row, not several, so there is nothing to cycle. Deliberately CYAN, distinct
151
+ * from the overlay tracks' violet and the audio lane's emerald, so a caption
152
+ * reads as its own element type on the timeline rather than blending into an
153
+ * overlay. `fill`/`border` are the unselected state; `fillSelected`/`ring` the
154
+ * selected one; `text` is the label color. (The selected/unselected keyframe
155
+ * diamonds are white / amber, both legible on this cyan.) */
156
+ export const CAPTION_PALETTE: TrackPalette = {
157
+ fill: 'rgba(8,145,178,0.4)',
158
+ fillSelected: 'rgba(6,182,212,0.75)',
159
+ ring: 'rgba(165,243,252,0.85)',
160
+ border: 'rgba(34,211,238,0.5)',
161
+ text: '#cffafe',
162
+ }
163
+
164
+ /** `CAPTION_PALETTE` on a LIGHT row. Still CYAN — the whole point of that
165
+ * choice is that a caption is neither the overlays' violet nor the audio
166
+ * lane's emerald, and that separation has to survive the theme flip — read
167
+ * off the other end of the cyan ramp: a 200-level fill with a 900-level
168
+ * label, a 500-level border and a 700-level selection ring. The unselected
169
+ * fill keeps its deliberately-low alpha relative to the selected one (0.55 vs
170
+ * 0.85, mirroring the dark set's 0.4 vs 0.75), so selecting a caption is
171
+ * still a visible jump in weight rather than only a change of outline. */
172
+ export const LIGHT_CAPTION_PALETTE: TrackPalette = {
173
+ fill: 'rgba(165,243,252,0.55)',
174
+ fillSelected: 'rgba(103,232,249,0.85)',
175
+ ring: 'rgba(14,116,144,0.85)',
176
+ border: 'rgba(6,182,212,0.65)',
177
+ text: '#164e63',
178
+ }
179
+
180
+ /** TrackGutter's rail-cell accent for the caption row. Same cyan hue as
181
+ * `CAPTION_PALETTE.border`, at a higher alpha — a rail chip reads best brighter
182
+ * than a canvas fill. Exported beside `CAPTION_PALETTE`, the single source for
183
+ * the caption color, instead of letting TrackGutter hardcode its own copy with
184
+ * nothing tying the two together. */
185
+ export const CAPTION_RAIL_ACCENT = 'rgba(34,211,238,0.6)'
186
+
187
+ /** `CAPTION_RAIL_ACCENT` on the LIGHT rail. Same relationship to
188
+ * `LIGHT_CAPTION_PALETTE.border` the dark pair has to its own — a rail chip
189
+ * reads best with MORE weight than the canvas fill beside it — which on a
190
+ * near-white rail means stepping the hue DOWN the ramp (cyan-600) rather than
191
+ * up it, and nudging the alpha up to hold the 2px chip together. */
192
+ export const LIGHT_CAPTION_RAIL_ACCENT = 'rgba(8,145,178,0.75)'
193
+
194
+ export const TIMELINE_COLORS = {
195
+ /** `bg-gray-900` — the dark-mode row background both row kinds use. */
196
+ rowBackground: '#111827',
197
+ /** A hair lighter than `rowBackground`, alternated with it per row so
198
+ * adjacent track lanes read as distinct panels instead of one dark field. */
199
+ rowBackgroundAlt: '#161f30',
200
+ /** Subtle outline around every row band — the visible divider that separates
201
+ * one track lane from the next, rather than relying on the gap alone. */
202
+ rowDivider: 'rgba(148,163,184,0.16)',
203
+ /** `bg-emerald-500/40` + `border-emerald-500/60` on AudioTrackRow's bars. */
204
+ audioFill: 'rgba(16,185,129,0.4)',
205
+ audioBorder: 'rgba(16,185,129,0.6)',
206
+ /** `bg-white/10` — a muted audio bar. */
207
+ audioMutedFill: 'rgba(255,255,255,0.1)',
208
+ audioRing: 'rgba(110,231,183,0.8)',
209
+ audioText: '#a7f3d0',
210
+ /** Fade envelope: the dimmed tint across the WHOLE fade-width band
211
+ * (`drawFadeEnvelope`; both sides of the curve, not a wedge on one side),
212
+ * and the curve's own stroke drawn on top of it. Replaces a flat
213
+ * linear-gradient wash — the curve is what makes the RAMP, and its
214
+ * length, legible, not just "audio is quieter somewhere near here". */
215
+ fadeEnvelopeDim: 'rgba(0,0,0,0.35)',
216
+ fadeEnvelopeLine: 'rgba(255,255,255,0.55)',
217
+ /** The fade-grip triangle at an audio bar's top corner (`drawFadeGrip`).
218
+ * Subtle by default so it doesn't compete with the label; brighter when
219
+ * hovered or the bar is selected — the same "clearer when it matters"
220
+ * language the trim handles use. */
221
+ fadeGripSubtle: 'rgba(255,255,255,0.35)',
222
+ fadeGripActive: 'rgba(255,255,255,0.95)',
223
+ /** The keyframe-strip diamond (`drawKeyframeDiamond`). Amber rather than
224
+ * reusing white (the selection/handle vocabulary) or cyan (the snap
225
+ * guide) or yellow (`cursor`, the preview-axis line) — every other
226
+ * bright hue on this surface already means something else, and a
227
+ * keyframe is its own kind of mark. */
228
+ keyframeDiamondFill: '#fb923c',
229
+ /** Outline so the diamond reads as a shape rather than a blob against a
230
+ * filmstrip frame of any brightness — same trick `LABEL_SHADOW_COLOR`
231
+ * uses for the clip label. */
232
+ keyframeDiamondStroke: 'rgba(0,0,0,0.7)',
233
+ /** The ONE diamond matching the host's `selectedKeyframe`, drawn with this
234
+ * fill instead of `keyframeDiamondFill`. White, not a new hue: it is
235
+ * already the selection vocabulary on this surface (`clipSelectedOutline`,
236
+ * `handleFill`), and reads clearly against the amber every other diamond
237
+ * in the strip keeps. */
238
+ keyframeDiamondSelectedFill: '#ffffff',
239
+ /** Where two items on the same row overlap in time.
240
+ *
241
+ * These were 0.15 amber fill / 0.3 amber border. Two things were wrong with
242
+ * that. It was invisible over filmstrip frames — legible back when a clip
243
+ * was a flat colour block, gone the moment clips filled with picture. And
244
+ * amber was the wrong thing to say: yellow on a timeline reads as a
245
+ * warning, and an overlap is a fact about the edit, not a fault in it. It
246
+ * was also all but the same hue as the preview-axis cursor.
247
+ *
248
+ * Deliberately colourless now. White stripes over a dark under-stroke are
249
+ * the barber-pole "this region is special" pattern, and carry no status.
250
+ * The hatching is what does the work — a wash of any strength competes with
251
+ * the picture underneath, and diagonals do not. */
252
+ // 0.12, not the 0.1 that would read most naturally here: `audioMutedFill` is
253
+ // already exactly `rgba(255,255,255,0.1)`, and two different meanings sharing
254
+ // one literal is a trap for anything reasoning about the paint — a muted bar
255
+ // and an overlap band would have been indistinguishable by colour alone.
256
+ overlapFill: 'rgba(255,255,255,0.12)',
257
+ overlapHatch: 'rgba(255,255,255,0.75)',
258
+ /** Laid under each hatch line, a little wider, so the white has something to
259
+ * read against. White on a bright sky is nearly invisible and white on a
260
+ * dark frame is fine; a dark outline makes one treatment work on both, the
261
+ * same trick the clip labels use to survive an arbitrary frame. */
262
+ overlapHatchShadow: 'rgba(0,0,0,0.45)',
263
+ /** Translucent and thin, unlike the solid amber rules this replaces. White
264
+ * is already the selection vocabulary (the outline and the trim handles),
265
+ * and a hard white rule down each side of a band made an overlap read as a
266
+ * selected clip. Thin enough to bound the span, faint enough not to be
267
+ * mistaken for a border. */
268
+ overlapEdge: 'rgba(255,255,255,0.55)',
269
+ /** `bg-red-500` — the playhead, i.e. where playback/preview actually is. */
270
+ playhead: '#ef4444',
271
+ /** The selected clip's outline. White rather than the track's own hue: it is
272
+ * the one colour that stays obvious over an arbitrary video frame. */
273
+ clipSelectedOutline: '#ffffff',
274
+ /** `bg-yellow-400` — the preview-axis cursor: the line that tracks the
275
+ * pointer while the axis is on. Yellow so it can never be mistaken for the
276
+ * red playback line it moves independently of; it inherits the colour of
277
+ * the DOM hover indicator it replaces (Timeline.tsx's `bg-yellow-400/80`). */
278
+ cursor: '#facc15',
279
+ /** The trim-handle pill on a selected clip's in/out edge. Same white as the
280
+ * selection outline, deliberately: the handles read as thickenings of that
281
+ * border rather than as separate furniture. */
282
+ handleFill: 'rgba(255,255,255,0.92)',
283
+ /** The handle under the pointer. Opaque, so "this is the one you'd grab"
284
+ * survives a bright filmstrip frame behind it. */
285
+ handleFillHovered: '#ffffff',
286
+ /** The grip ticks inside the pill — dark on white, the universal
287
+ * drag-me-sideways glyph. */
288
+ handleGrip: 'rgba(15,23,42,0.55)',
289
+ handleGripHovered: 'rgba(15,23,42,0.9)',
290
+ /** The snap guide: the line marking the boundary a gesture is magnetized to.
291
+ * Cyan because every other line on this surface is spoken for — red is the
292
+ * playhead, yellow the preview axis, emerald the audio bars — and a guide
293
+ * that could be confused with the playhead would be worse than none. */
294
+ snapGuide: '#22d3ee',
295
+ /** A guide for a boundary on some OTHER track. Same hue so it is obviously
296
+ * the same idea, but faint and capless: a cross-track alignment is a hint,
297
+ * not the thing you were aiming at, and at full strength these were
298
+ * indistinguishable from the alignment that actually matters. */
299
+ snapGuideWeak: 'rgba(34,211,238,0.4)',
300
+ /** The time ruler across the top. A shade off `rowBackground` so the strip
301
+ * reads as chrome rather than as one more track you could drop a clip on. */
302
+ rulerBackground: '#0b1220',
303
+ /** Tick marks and their labels. Deliberately low-contrast: the ruler is a
304
+ * reference you consult, not something that should compete with the clips. */
305
+ rulerTick: 'rgba(148,163,184,0.55)',
306
+ rulerTickMinor: 'rgba(148,163,184,0.25)',
307
+ rulerText: 'rgba(148,163,184,0.9)',
308
+ /** The marquee (rubber-band) selection box. Same white as the selection
309
+ * vocabulary, since what it is doing IS selecting. */
310
+ marqueeFill: 'rgba(255,255,255,0.08)',
311
+ marqueeBorder: 'rgba(255,255,255,0.65)',
312
+ /** The ghost band for a file still being imported (`drawPendingDropBand`).
313
+ *
314
+ * Deliberately colourless — slate, the same neutral the row divider and the
315
+ * ruler ticks are drawn in. Every saturated hue on this surface is spoken
316
+ * for and each one is a claim: red is where playback is, cyan is an
317
+ * alignment, amber is a keyframe, emerald is audio, white is selection. A
318
+ * pending import is none of those; it is a placeholder, and a placeholder
319
+ * that borrowed any of those hues would be read as the thing that hue
320
+ * means. What says "not real yet" is the DASHED outline, not the colour.
321
+ *
322
+ * The fill is one step up in alpha from `rowDivider` (0.18 vs 0.16) so the
323
+ * two are never the same literal — a hairline divider and a whole band are
324
+ * different marks and must not be confusable by colour alone. */
325
+ pendingDropFill: 'rgba(148,163,184,0.18)',
326
+ /** Slate-200: brighter than the fill, so the dashes hold together as an
327
+ * outline over whatever row background the band lands on. */
328
+ pendingDropStroke: 'rgba(226,232,240,0.7)',
329
+ /** The filename inside the band. A shade brighter again than the stroke —
330
+ * the band is translucent, so the text is competing with the row (and any
331
+ * clip) showing through it. */
332
+ pendingDropText: 'rgba(241,245,249,0.9)',
333
+ } as const
334
+
335
+ /** The shape both palettes satisfy — `TIMELINE_COLORS`'s own keys, widened
336
+ * from its `as const` string literals to plain `string` so the light set can
337
+ * be typed against it and so no consumer accidentally depends on a specific
338
+ * literal being present at a key. */
339
+ export type TimelineColors = { [K in keyof typeof TIMELINE_COLORS]: string }
340
+
341
+ /**
342
+ * `TIMELINE_COLORS` for a LIGHT host theme.
343
+ *
344
+ * Every entry is the SEMANTIC of its dark counterpart re-derived against a
345
+ * light row, not the dark number lightened. Two rules do most of the work:
346
+ *
347
+ * 1. Anything whose dark rationale was "white, because it survives an
348
+ * arbitrary video frame / because white is the selection vocabulary here"
349
+ * becomes near-black (`rgba(15,23,42,…)` / `#0f172a`), because on this
350
+ * ground that is the colour that survives and that selection speaks. The
351
+ * ALPHAS are carried over verbatim wherever they were load-bearing — see
352
+ * `overlapFill` below.
353
+ * 2. Anything that was a dark scrim UNDER something white flips to a light
354
+ * scrim under something dark (`overlapHatchShadow`), for the same reason
355
+ * it existed at all: the mark needs a ground of the opposite value to read
356
+ * against, whatever is painted behind it.
357
+ *
358
+ * The exceptions are called out at their own keys.
359
+ */
360
+ export const LIGHT_TIMELINE_COLORS: TimelineColors = {
361
+ /** Slightly DARKER than the editor surface (`#ffffff`) and than the shell
362
+ * (`#f3f4f6`), so a row reads as a recessed field you drop clips into. The
363
+ * dark pair is raised out of a near-black shell; on a near-white one the
364
+ * only way to say "this is the track area" is to sink it. */
365
+ rowBackground: '#e9ecf1',
366
+ /** Alternated with `rowBackground` per row, a hair DARKER — the mirror of
367
+ * the dark pair's "a hair lighter", and the same size of step (~8/255), so
368
+ * adjacent lanes separate exactly as subtly as they do in dark. */
369
+ rowBackgroundAlt: '#e1e5ec',
370
+ /** Slate-600 rather than dark's slate-400, at a slightly higher alpha: a
371
+ * hairline needs more weight to hold together against a light ground than
372
+ * against a dark one. */
373
+ rowDivider: 'rgba(71,85,105,0.18)',
374
+ /** Emerald stays emerald — the audio lane's identity. Nudged up in alpha
375
+ * (0.45 from 0.4) and the border down the ramp to emerald-600, because a
376
+ * 0.4 wash of a mid-tone hue over a pale row is much closer to the row than
377
+ * the same wash over a near-black one. */
378
+ audioFill: 'rgba(16,185,129,0.45)',
379
+ audioBorder: 'rgba(5,150,105,0.75)',
380
+ /** A muted bar: dark uses white-at-0.1 to wash the emerald toward its
381
+ * background. Here the wash toward the background is a near-black one, at
382
+ * the SAME 0.1 — see `overlapFill`, which depends on this literal staying
383
+ * distinct from its own. */
384
+ audioMutedFill: 'rgba(15,23,42,0.1)',
385
+ /** Dark's ring is emerald-300 — brighter than the fill. Light's is
386
+ * emerald-700 — darker than the fill. Same sentence, other ground. */
387
+ audioRing: 'rgba(4,120,87,0.85)',
388
+ audioText: '#064e3b',
389
+ /** The fade band's tint. Dark DARKENS the band (black at 0.35); light
390
+ * LIGHTENS it, washing the emerald toward the row background — on a light
391
+ * ground "less signal" reads as "closer to empty", and a dark wash there
392
+ * would read as ink rather than as attenuation. The envelope line drawn on
393
+ * top of it is near-black, so the two still separate cleanly. */
394
+ fadeEnvelopeDim: 'rgba(255,255,255,0.5)',
395
+ fadeEnvelopeLine: 'rgba(15,23,42,0.65)',
396
+ /** Same subtle-vs-active pair, inverted: the grip is a dark triangle that
397
+ * goes from quiet to emphatic rather than a white one. */
398
+ fadeGripSubtle: 'rgba(15,23,42,0.4)',
399
+ fadeGripActive: 'rgba(15,23,42,0.9)',
400
+ /** IDENTICAL to dark, deliberately. Amber IS the keyframe mark — no other
401
+ * hue on this surface is free to mean it — and a diamond is painted over
402
+ * the clip's own content (filmstrip frames, waveform band), not over the
403
+ * row background, so it has to survive an arbitrary image in either mode
404
+ * rather than being tuned to the light row it happens to sit near. */
405
+ keyframeDiamondFill: '#fb923c',
406
+ /** The halo that makes the diamond read as a SHAPE rather than a blob. Dark
407
+ * lays a black one under an amber diamond sitting on dark content; light
408
+ * lays a white one, since the band under it here is pale and a black rim
409
+ * would merge with the dark bars of the waveform it sits in. */
410
+ keyframeDiamondStroke: 'rgba(255,255,255,0.85)',
411
+ /** The one selected diamond. Dark fills it white because white is already
412
+ * this surface's selection vocabulary; light fills it near-black for
413
+ * exactly the same reason — see `clipSelectedOutline` and `handleFill`,
414
+ * which moved the same way. White would also now collide with the halo. */
415
+ keyframeDiamondSelectedFill: '#0f172a',
416
+ // 0.12, not 0.1, for the SAME reason the dark set says 0.12: `audioMutedFill`
417
+ // above is exactly `rgba(15,23,42,0.1)`, and letting a muted bar and an
418
+ // overlap band share one literal would make them indistinguishable by colour
419
+ // to anything reasoning about the paint. The "no two meanings share one
420
+ // literal" property is preserved key-for-key across both modes.
421
+ overlapFill: 'rgba(15,23,42,0.12)',
422
+ overlapHatch: 'rgba(15,23,42,0.7)',
423
+ /** Laid under each hatch line, a little wider. Dark puts BLACK under white
424
+ * stripes; light puts WHITE under near-black ones — the under-stroke's
425
+ * whole job is to be the opposite value of the stripe, so that one hatch
426
+ * treatment survives both a bright and a dark filmstrip frame. */
427
+ overlapHatchShadow: 'rgba(255,255,255,0.6)',
428
+ overlapEdge: 'rgba(15,23,42,0.5)',
429
+ /** IDENTICAL to dark. Red-500 is unmistakable on either ground and there is
430
+ * no second thing on this surface it could be confused with. */
431
+ playhead: '#ef4444',
432
+ /** Dark's white outline is "the one colour that stays obvious over an
433
+ * arbitrary video frame"; on a light editor that colour is near-black. */
434
+ clipSelectedOutline: '#0f172a',
435
+ /** The preview-axis cursor. The ONE "keep it vivid" colour that had to move:
436
+ * yellow-400 against a near-white row is roughly 1.1:1 and simply is not
437
+ * there. Stepped to yellow-700 — still unmistakably the gold line, still
438
+ * impossible to confuse with the red playhead or the cyan snap guide, and
439
+ * actually visible (~4.7:1). */
440
+ cursor: '#a16207',
441
+ /** The trim pill, and the pill under the pointer. Same "the handles are
442
+ * thickenings of the selection border" rule, so these track
443
+ * `clipSelectedOutline` into near-black. */
444
+ handleFill: 'rgba(15,23,42,0.9)',
445
+ handleFillHovered: '#0f172a',
446
+ /** The grip ticks INSIDE the pill. Dark-on-white in dark mode; the pill is
447
+ * now dark, so the ticks are light-on-dark. The glyph is unchanged — it is
448
+ * the contrast against the pill that carries it, not the colour. */
449
+ handleGrip: 'rgba(248,250,252,0.6)',
450
+ handleGripHovered: 'rgba(248,250,252,0.95)',
451
+ /** Still cyan — every other line on this surface is spoken for in both modes
452
+ * — but stepped from cyan-400 to cyan-600, which is the shallowest step
453
+ * that clears "a 2px line you can actually see" on a light row. */
454
+ snapGuide: '#0891b2',
455
+ snapGuideWeak: 'rgba(8,145,178,0.45)',
456
+ /** A shade off `rowBackground` so the ruler reads as chrome rather than as a
457
+ * droppable track — the same relationship dark has, inverted: dark's ruler
458
+ * sinks BELOW its rows, light's lifts ABOVE them toward the surface. */
459
+ rulerBackground: '#f5f7fa',
460
+ /** Deliberately low-contrast, as in dark: slate-600/700 at the same alphas
461
+ * rather than the slate-400 that reads as low-contrast on a dark ground. */
462
+ rulerTick: 'rgba(71,85,105,0.55)',
463
+ rulerTickMinor: 'rgba(71,85,105,0.3)',
464
+ rulerText: 'rgba(51,65,85,0.85)',
465
+ /** The marquee. Selection vocabulary again, so it follows the outline and
466
+ * the handles into near-black — at the dark set's own alphas, which were
467
+ * already tuned to "a wash you can see through and a border you can't miss". */
468
+ marqueeFill: 'rgba(15,23,42,0.08)',
469
+ marqueeBorder: 'rgba(15,23,42,0.6)',
470
+ /** The pending-import ghost. Colourless in dark, colourless here — but the
471
+ * neutral has to come from the OTHER end of the slate ramp: on a pale row a
472
+ * slate-400 wash is barely a change of value at all, so the fill steps down
473
+ * to slate-600 and the outline/text to near-black, exactly the flip the
474
+ * marquee above makes. The alphas are carried over from the dark set, which
475
+ * is where the "a wash you can see through, an outline you can't miss"
476
+ * balance was tuned; only the hue's end of the ramp moves. */
477
+ pendingDropFill: 'rgba(71,85,105,0.18)',
478
+ pendingDropStroke: 'rgba(51,65,85,0.7)',
479
+ pendingDropText: 'rgba(15,23,42,0.9)',
480
+ }
481
+
482
+ /** Height of the time ruler strip at the top of the surface.
483
+ *
484
+ * The ruler exists because canvas mode removed the overview scrubber bar (see
485
+ * Scrubber.tsx), which left pressing the track area as the only way to seek —
486
+ * and that gesture is now the marquee. Giving scrubbing its own strip is what
487
+ * every NLE does, and it is what makes drag-to-select in the track area
488
+ * possible without losing drag-to-scrub. */
489
+ export const RULER_HEIGHT_PX = 18
490
+ /** Tick height for a labelled (major) tick and an unlabelled (minor) one. */
491
+ export const RULER_MAJOR_TICK_PX = 7
492
+ export const RULER_MINOR_TICK_PX = 4
493
+ /** Baseline for a ruler label, measured from the top of the strip. */
494
+ export const RULER_LABEL_BASELINE_PX = 9
495
+ /** Minimum px between labelled ticks. Below this the labels collide, so the
496
+ * step is promoted to the next entry in `RULER_STEPS_SECONDS`. */
497
+ export const RULER_MIN_LABEL_SPACING_PX = 64
498
+ /** The step ladder, in seconds. A ruler that picked a raw "nice" number could
499
+ * land on 2.5s or 7s; these are the intervals an editor actually thinks in,
500
+ * so the labels stay readable as times rather than as arithmetic. */
501
+ export const RULER_STEPS_SECONDS = [
502
+ 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30,
503
+ 60, 120, 300, 600, 900, 1800, 3600,
504
+ ] as const
505
+
506
+ export const ROW_RADIUS_PX = 4 // Tailwind `rounded`
507
+ /** Horizontal inset per side between a clip's time span and its drawn body —
508
+ * two touching clips therefore show twice this as a dark gutter. */
509
+ export const CLIP_GUTTER_PX = 2
510
+ /** Corner radius on a clip body. */
511
+ export const CLIP_RADIUS_PX = 4
512
+ /** Weight of the selected-clip outline. Thick on purpose: it has to read over
513
+ * filmstrip frames of any brightness. */
514
+ export const CLIP_SELECTED_BORDER_PX = 3
515
+ /** Drawn width of a visual clip's trim handle. Equal to
516
+ * `VISUAL_EDGE_TOLERANCE_PX` in hit-test.ts ON PURPOSE — the pill you see is
517
+ * exactly the strip that trims, so aiming at it is never a guess. Change one
518
+ * and change the other. */
519
+ export const CLIP_HANDLE_WIDTH_PX = 10
520
+ /** Same contract against `AUDIO_EDGE_TOLERANCE_PX`. */
521
+ export const AUDIO_HANDLE_WIDTH_PX = 6
522
+ /** Grip ticks per handle, their width, and the gap between them. */
523
+ export const HANDLE_GRIP_COUNT = 2
524
+ export const HANDLE_GRIP_WIDTH_PX = 1.5
525
+ export const HANDLE_GRIP_GAP_PX = 2.5
526
+ /** Fraction of the handle's height the ticks span, and the cap that keeps them
527
+ * from becoming a full-height stripe on the tall base video row. */
528
+ export const HANDLE_GRIP_HEIGHT_RATIO = 0.4
529
+ export const HANDLE_GRIP_MAX_HEIGHT_PX = 16
530
+ /** Below this the pill is thinner than its own grip and draws as a smear, so a
531
+ * very narrow clip gets no handles — the white selection outline is still
532
+ * there to say it's selected. */
533
+ export const MIN_HANDLE_WIDTH_PX = 3
534
+ export const SNAP_GUIDE_WIDTH_PX = 2
535
+ /** The weak guide is a hairline. Half the width and no caps — visible if you
536
+ * look for it, invisible if you aren't. */
537
+ export const SNAP_GUIDE_WEAK_WIDTH_PX = 1
538
+ /** The arrowheads at the guide's ends. They are what make it read as "this
539
+ * boundary", not as another playhead. */
540
+ export const SNAP_GUIDE_CAP_HALF_WIDTH_PX = 5
541
+ export const SNAP_GUIDE_CAP_HEIGHT_PX = 7
542
+ /** Gap between the diagonal hatch lines in an overlap band, their weight, and
543
+ * the weight of the solid rule down each side of it. */
544
+ export const OVERLAP_HATCH_SPACING_PX = 9
545
+ export const OVERLAP_HATCH_WIDTH_PX = 1.5
546
+ /** How much wider the shadow pass is than the amber it sits under. */
547
+ export const OVERLAP_HATCH_SHADOW_WIDTH_PX = 3
548
+ export const OVERLAP_EDGE_WIDTH_PX = 1.5
549
+ export const AUDIO_ITEM_RADIUS_PX = 4 // Tailwind `rounded` on the bar
550
+ export const AUDIO_ITEM_INSET_PX = 4 // `top-1 bottom-1` on the bar
551
+ /** Fade-grip triangle size (px), base and height alike. Kept small
552
+ * deliberately — see hit-test.ts's `FADE_GRIP_HALF_WIDTH_PX`/
553
+ * `FADE_GRIP_ZONE_HEIGHT_PX` for why the grip's CLICKABLE zone is a
554
+ * matching small top-corner target rather than the full bar height. */
555
+ export const FADE_GRIP_SIZE_PX = 6
556
+ export const PLAYHEAD_WIDTH_PX = 2 // `w-[2px]`
557
+ export const CURSOR_WIDTH_PX = 2 // matches the playhead's weight
558
+ export const LABEL_FONT = '10px ui-sans-serif, system-ui, sans-serif'
559
+ export const LABEL_PAD_PX = 6
560
+ /** Below this width a label is more smear than information, so it is skipped. */
561
+ export const MIN_LABEL_WIDTH_PX = 28
562
+ /** Middle-baseline offset from a clip's top edge — half the font's 10px plus a
563
+ * 4px margin, so the label rides just inside the clip's top border. */
564
+ export const LABEL_TOP_OFFSET_PX = 9
565
+ /** Drop shadow behind a clip label, so it stays readable over a filmstrip
566
+ * frame of any brightness without needing a solid plate behind it. */
567
+ export const LABEL_SHADOW_COLOR = 'rgba(0,0,0,0.85)'
568
+ /** `LABEL_SHADOW_COLOR` for a light theme. The shadow exists to give the label
569
+ * a ground of the OPPOSITE value to its own text, so it reads over a
570
+ * filmstrip frame of any brightness. A light theme's clip labels are dark
571
+ * (see `LIGHT_TRACK_PALETTE`'s `text`), so a black halo behind them would do
572
+ * nothing at all — the halo has to be white. */
573
+ export const LIGHT_LABEL_SHADOW_COLOR = 'rgba(255,255,255,0.9)'
574
+ export const LABEL_SHADOW_BLUR_PX = 3
575
+
576
+ // ── Mode resolution ──────────────────────────────────────────────────────
577
+
578
+ /** Which ground the surface is being painted on. Named for the HOST theme's
579
+ * light/dark classification (`isLightTheme` in theme.ts), not for a Montaj
580
+ * theme name, because that is the only thing this module needs to know. */
581
+ export type TimelineMode = 'light' | 'dark'
582
+
583
+ /**
584
+ * Everything a single draw pass needs to know about colour, resolved once.
585
+ *
586
+ * One object rather than a mode flag threaded down: the painters must never
587
+ * each re-derive "which mode am I?", or a future palette that resolves from
588
+ * something richer than a two-valued enum would have to be plumbed to every
589
+ * one of them again. They take the resolved set and read it.
590
+ *
591
+ * `waveform` rides along here too, even though `WAVEFORM_COLORS` lives in
592
+ * waveforms.ts, so the clip/audio waveform painters are handed the SAME
593
+ * resolution the rest of the pass is using instead of doing a second,
594
+ * independent mode lookup that could silently disagree with it.
595
+ */
596
+ export interface TimelinePalette {
597
+ mode: TimelineMode
598
+ colors: TimelineColors
599
+ /** Cycled per track index — see `TRACK_PALETTE`. */
600
+ tracks: TrackPalette[]
601
+ /** The single caption-block palette — see `CAPTION_PALETTE`. */
602
+ caption: TrackPalette
603
+ /** TrackGutter's caption rail chip — see `CAPTION_RAIL_ACCENT`. */
604
+ captionRailAccent: string
605
+ waveform: WaveformColors
606
+ /** Drop shadow behind a clip label — see `LABEL_SHADOW_COLOR`. */
607
+ labelShadow: string
608
+ }
609
+
610
+ /** The dark set, assembled from the module constants that have always held it.
611
+ * Every painter defaults to THIS when handed no palette, which is what keeps
612
+ * a caller (or a test) that predates modes drawing byte-identical pixels. */
613
+ export const DARK_TIMELINE_PALETTE: TimelinePalette = {
614
+ mode: 'dark',
615
+ colors: TIMELINE_COLORS,
616
+ tracks: TRACK_PALETTE,
617
+ caption: CAPTION_PALETTE,
618
+ captionRailAccent: CAPTION_RAIL_ACCENT,
619
+ waveform: WAVEFORM_COLORS,
620
+ labelShadow: LABEL_SHADOW_COLOR,
621
+ }
622
+
623
+ export const LIGHT_TIMELINE_PALETTE: TimelinePalette = {
624
+ mode: 'light',
625
+ colors: LIGHT_TIMELINE_COLORS,
626
+ tracks: LIGHT_TRACK_PALETTE,
627
+ caption: LIGHT_CAPTION_PALETTE,
628
+ captionRailAccent: LIGHT_CAPTION_RAIL_ACCENT,
629
+ waveform: LIGHT_WAVEFORM_COLORS,
630
+ labelShadow: LIGHT_LABEL_SHADOW_COLOR,
631
+ }
632
+
633
+ /** The palette for a mode. Returns one of two module-level objects rather than
634
+ * building one per call: this is read once per draw pass and once per
635
+ * TrackGutter render, and a fresh object each time would churn identity for
636
+ * anything that memoizes on it. */
637
+ export function timelinePalette(mode: TimelineMode): TimelinePalette {
638
+ return mode === 'light' ? LIGHT_TIMELINE_PALETTE : DARK_TIMELINE_PALETTE
639
+ }
640
+
641
+ // ── Row layout ───────────────────────────────────────────────────────────
642
+
643
+ export interface VisualRowLayout {
644
+ trackIdx: number
645
+ items: VisualItem[]
646
+ y: number
647
+ height: number
648
+ /** The track's `enabled === false` — the row still lays out and still takes
649
+ * its full height (you have to be able to see and re-enable it); the painter
650
+ * just dims it. */
651
+ disabled?: boolean
652
+ /** The track's `muted === true`. Item-level mute is folded in per item at
653
+ * draw time (mirrors `effectiveItemAudio`'s OR) since this flag alone can't
654
+ * see an individual item's own `muted`. */
655
+ trackMuted: boolean
656
+ }
657
+
658
+ export interface AudioLaneLayout {
659
+ laneIndex: number
660
+ tracks: AudioTrack[]
661
+ y: number
662
+ height: number
663
+ }
664
+
665
+ export interface CaptionRowLayout {
666
+ /** Which caption lane this band renders (see `CaptionSegment.lane` /
667
+ * `captionLanes.ts`). Lane 0 is the band adjacent to the base video row;
668
+ * higher lanes stack upward toward the overlays. */
669
+ lane: number
670
+ y: number
671
+ height: number
672
+ segments: CaptionSegment[]
673
+ }
674
+
675
+ export interface TimelineLayout {
676
+ /** The time ruler strip across the top. Always present and always at y=0;
677
+ * carried in the layout rather than assumed so hit-testing and painting
678
+ * read the same rectangle, the same contract the rows have. */
679
+ ruler: { y: number; height: number }
680
+ /** Visual rows in DRAW order (top of the surface first). */
681
+ rows: VisualRowLayout[]
682
+ lanes: AudioLaneLayout[]
683
+ /** The caption bands, one per lane (see `captionLanes.ts`'s
684
+ * `groupCaptionLanes`), carried the same way `ruler` is — a first-class set
685
+ * of rectangles the painter and hit-test both read, rather than each
686
+ * re-deriving them. In DRAW order (descending lane), so index 0 is the
687
+ * HIGHEST lane and the last entry is lane 0, immediately above the base
688
+ * video row (see `computeTimelineLayout`). Absent — not an empty array —
689
+ * when the project has no caption segments, so a caption-less project's
690
+ * layout is unchanged from before captions existed. */
691
+ captions?: CaptionRowLayout[]
692
+ /** Total surface height in CSS px, excluding trailing gap. */
693
+ height: number
694
+ }
695
+
696
+ /**
697
+ * Row rectangles for a project, in the order the DOM path stacks them:
698
+ * visual tracks reversed (highest index on top, so the base video track sits at
699
+ * the bottom and overlays stack above it), then audio lanes below, ascending.
700
+ * The base track is drawn taller (120px vs. 40px for the rest).
701
+ *
702
+ * T5's hit-testing should derive its rows from here rather than re-deriving
703
+ * geometry — one layout, two readers.
704
+ */
705
+ export function computeTimelineLayout(project: Project): TimelineLayout {
706
+ // Order-normalized (Part B): `trackItems` funnels through `normalizeTracks`,
707
+ // which now groups tracks into the canonical video-block/overlay-block
708
+ // stack (`normalizeTrackOrder`) before returning — so `allTracks` here is
709
+ // ALREADY in that order, and `trackSettings` below (also read off
710
+ // `normalizeTracks(project)`) agrees with it index-for-index by
711
+ // construction. Display, hit-test (which derives its rows from this same
712
+ // layout), and mutation (`moveItemAcrossTracks` re-groups its own result
713
+ // the same way) therefore can never disagree about what trackIdx N is.
714
+ const allTracks = trackItems(project)
715
+ // Settings live on the track object; `trackItems` deliberately returns only
716
+ // items, so read `enabled` off the normalized tracks alongside it. A
717
+ // legacy-shaped project has no settings, so every row is enabled.
718
+ const trackSettings = normalizeTracks(project).tracks ?? []
719
+ const rows: VisualRowLayout[] = []
720
+ // The ruler owns the top strip; everything else starts below it.
721
+ const ruler = { y: 0, height: RULER_HEIGHT_PX }
722
+ let y = RULER_HEIGHT_PX + ROW_GAP_PX
723
+
724
+ // Only stored when there is something to show — an empty `segments` (or no
725
+ // `project.captions` at all) means no bands at all, not an empty array, so a
726
+ // caption-less project's layout is byte-for-byte what it was before
727
+ // captions joined the canvas.
728
+ const captionSegments = project.captions?.segments ?? []
729
+ // One group per lane, `0..maxCaptionLane`, INCLUDING holes as empty groups
730
+ // (see `groupCaptionLanes`) — a hole still needs a band, because an empty
731
+ // band that keeps painting its row background is what stops the timeline
732
+ // jumping by a whole row height mid-drag when a caption that was alone in
733
+ // its lane moves out of it.
734
+ const captionGroups = captionSegments.length > 0 ? groupCaptionLanes(captionSegments) : []
735
+ const captions: CaptionRowLayout[] = []
736
+ const emitCaptionBands = () => {
737
+ // Descending lane order: the highest lane is emitted FIRST, so it lands
738
+ // closest to the overlay rows already pushed above (smaller y); lane 0 is
739
+ // emitted LAST, landing at the current `y` — exactly where the single
740
+ // band sat before multi-lane captions existed. Higher lanes therefore
741
+ // stack upward toward the overlays, per the caption-lane convention
742
+ // documented in `captionLanes.ts`.
743
+ for (let i = captionGroups.length - 1; i >= 0; i--) {
744
+ const group = captionGroups[i]
745
+ captions.push({ lane: group.lane, y, height: CAPTION_ROW_HEIGHT_PX, segments: group.segments })
746
+ y += CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX
747
+ }
748
+ }
749
+
750
+ // Highest trackIdx that holds a video item. Because `allTracks` is already
751
+ // order-normalized (video block first, contiguous from index 0), this is
752
+ // the TOP of that block — where the caption band(s) belong: directly above
753
+ // the video block, below any overlay tracks. `-1` when there are no video
754
+ // tracks at all, in which case captions fall back to sitting above
755
+ // trackIdx 0 — today's behaviour, unchanged, whatever kind that track is.
756
+ // For a project with exactly one video track, `topVideoIdx` is always 0
757
+ // (the base track), so `captionEmitIdx` is 0 either way and this whole
758
+ // change is a no-op for the single-video-track case.
759
+ let topVideoIdx = -1
760
+ for (let i = 0; i < allTracks.length; i++) {
761
+ if (allTracks[i].some(item => item.type === 'video')) topVideoIdx = i
762
+ }
763
+ const captionEmitIdx = topVideoIdx >= 0 ? topVideoIdx : 0
764
+
765
+ for (let reversedIdx = 0; reversedIdx < allTracks.length; reversedIdx++) {
766
+ const trackIdx = allTracks.length - 1 - reversedIdx
767
+ // The caption bands sit directly above the TOP of the video block —
768
+ // captions narrate that footage, so they read as adjacent to it, above
769
+ // whatever overlay tracks sit above the video block and below the video
770
+ // block itself. `trackIdx === captionEmitIdx` fires exactly once per
771
+ // layout, immediately before that row is pushed (rows are pushed in
772
+ // descending trackIdx order, i.e. top of screen first).
773
+ if (trackIdx === captionEmitIdx && captionGroups.length > 0) emitCaptionBands()
774
+ // Tall not just for the base track (trackIdx 0, always tall even when
775
+ // empty, so a blank base row doesn't jump size once footage lands) but
776
+ // for ANY video-kind track — a second video track carries the same
777
+ // filmstrip+waveform content as the base and is cramped without the
778
+ // room. Overlay/image tracks keep the short row.
779
+ const height = (trackIdx === 0 || allTracks[trackIdx][0]?.type === 'video')
780
+ ? BASE_VISUAL_ROW_RENDER_HEIGHT_PX
781
+ : VISUAL_ROW_RENDER_HEIGHT_PX
782
+ rows.push({
783
+ trackIdx,
784
+ items: allTracks[trackIdx],
785
+ y,
786
+ height,
787
+ disabled: trackSettings[trackIdx]?.enabled === false,
788
+ trackMuted: trackSettings[trackIdx]?.muted === true,
789
+ })
790
+ y += height + ROW_GAP_PX
791
+ }
792
+ // A project with captions but NO visual tracks at all never runs the loop
793
+ // above, so the hook that emits the bands right before `trackIdx ===
794
+ // captionEmitIdx` never fires. Emit them here instead — still above where
795
+ // the (absent) base row would sit, which is the same rule applied to zero
796
+ // rows.
797
+ if (allTracks.length === 0 && captionGroups.length > 0) emitCaptionBands()
798
+
799
+ // Resolved once per layout, not per track: an audio track may legally omit
800
+ // `start`/`end`, and `groupAudioLanes` needs a horizon to fall back to.
801
+ const { contentDuration } = computeDerivedTiming(project)
802
+ const lanes: AudioLaneLayout[] = groupAudioLanes(project.audio?.tracks ?? [], contentDuration).map(lane => {
803
+ const laneLayout: AudioLaneLayout = {
804
+ laneIndex: lane.laneIndex,
805
+ tracks: lane.tracks,
806
+ y,
807
+ height: AUDIO_LANE_HEIGHT_PX,
808
+ }
809
+ y += AUDIO_LANE_HEIGHT_PX + ROW_GAP_PX
810
+ return laneLayout
811
+ })
812
+
813
+ const layout: TimelineLayout = { ruler, rows, lanes, height: Math.max(0, y - ROW_GAP_PX) }
814
+ if (captions.length > 0) layout.captions = captions
815
+ return layout
816
+ }
817
+
818
+ // ── Primitives ───────────────────────────────────────────────────────────
819
+
820
+ /** Rounded-rect path. Hand-rolled rather than `ctx.roundRect` so the painter
821
+ * works on contexts (and stubs) that predate it. */
822
+ export function roundRectPath(ctx: DrawContext, x: number, y: number, w: number, h: number, r: number): void {
823
+ const radius = Math.max(0, Math.min(r, w / 2, h / 2))
824
+ ctx.beginPath()
825
+ ctx.moveTo(x + radius, y)
826
+ ctx.arcTo(x + w, y, x + w, y + h, radius)
827
+ ctx.arcTo(x + w, y + h, x, y + h, radius)
828
+ ctx.arcTo(x, y + h, x, y, radius)
829
+ ctx.arcTo(x, y, x + w, y, radius)
830
+ ctx.closePath()
831
+ }
832
+
833
+ export interface Rect { x: number; y: number; width: number; height: number }
834
+
835
+ /** Clamp a rect to the surface so a clip stretching far off-screen doesn't ask
836
+ * the rasterizer to fill a million-pixel-wide box. */
837
+ export function clampRectToSurface(rect: Rect, surfaceWidth: number): Rect {
838
+ const left = Math.max(rect.x, -1)
839
+ const right = Math.min(rect.x + rect.width, surfaceWidth + 1)
840
+ return { ...rect, x: left, width: Math.max(0, right - left) }
841
+ }
842
+
843
+ // ── Element painters ─────────────────────────────────────────────────────
844
+
845
+ /** `color` stays the THIRD parameter (callers and tests pass the alternating
846
+ * row shade there positionally); the palette is appended after it, and an
847
+ * omitted `color` falls back to that palette's own `rowBackground` rather
848
+ * than to the dark literal. */
849
+ export function drawRowBackground(
850
+ ctx: DrawContext,
851
+ rect: Rect,
852
+ color?: string,
853
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
854
+ ): void {
855
+ ctx.fillStyle = color ?? palette.colors.rowBackground
856
+ roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, ROW_RADIUS_PX)
857
+ ctx.fill()
858
+ // A faint outline so each lane reads as its own panel and the boundary
859
+ // between adjacent tracks is a visible divider, not just the gap. Uses
860
+ // strokeRect rather than a stroked roundRectPath so it adds no path
861
+ // (`moveTo`) calls — the row corners are only 4px, so a square outline reads
862
+ // as clean at this radius.
863
+ ctx.strokeStyle = palette.colors.rowDivider
864
+ ctx.lineWidth = 1
865
+ ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1))
866
+ }
867
+
868
+ export interface ClipDrawArgs {
869
+ rect: Rect
870
+ palette: TrackPalette
871
+ selected: boolean
872
+ label: string
873
+ /** A skipped track's clips are faded — the DOM path's `opacity-30`. */
874
+ dimmed?: boolean
875
+ /** Content layer drawn between the fill/border and the label — the T6
876
+ * per-clip waveform hooks in here (filmstrip, T7, will too). Receives the
877
+ * same rect as the clip itself. */
878
+ drawContent?: (ctx: DrawContext, rect: Rect) => void
879
+ }
880
+
881
+ /**
882
+ * One clip/overlay block. Mirrors the DOM item: flat coloured box, a right
883
+ * border separating touching clips, an inset ring when selected, and a small
884
+ * label. Content layers (waveform T6, filmstrip T7) draw between the fill and
885
+ * the label — pass through here once they exist.
886
+ */
887
+ /**
888
+ * The drawn body of a clip: inset from its true time span, so two clips that
889
+ * touch on the timeline are separated by a real gutter of row background
890
+ * rather than a 1px line. That line was legible while a clip was a flat
891
+ * colour; against a continuous ribbon of filmstrip frames it vanished, and
892
+ * twelve clips read as one.
893
+ *
894
+ * Hit-testing still uses the full span — the gutter is paint, not a dead zone.
895
+ * Exported because the content lookups (`clipColumns`, `clipTiles`) must size
896
+ * themselves to the SAME rect the painter will draw them into, or a filmstrip
897
+ * lays out its cells against a width two pixels wider than the clip it lands
898
+ * in.
899
+ */
900
+ export function clipBodyRect(rect: Rect): Rect {
901
+ const gutter = Math.min(CLIP_GUTTER_PX, Math.max(0, (rect.width - 1) / 2))
902
+ return {
903
+ x: rect.x + gutter,
904
+ y: rect.y,
905
+ width: Math.max(0, rect.width - gutter * 2),
906
+ height: rect.height,
907
+ }
908
+ }
909
+
910
+ export interface TrimHandleDrawArgs {
911
+ /** The BODY rect of the clip or bar — the rectangle actually painted, not
912
+ * the full time span. A handle drawn against the span would hang two pixels
913
+ * out over the gutter and touch its neighbour. */
914
+ rect: Rect
915
+ edge: 'in' | 'out'
916
+ /** Nominal width; narrowed automatically so two handles can never overlap on
917
+ * a short clip. */
918
+ width: number
919
+ hovered?: boolean
920
+ radius?: number
921
+ }
922
+
923
+ /**
924
+ * A trim handle: the grabbable pill on one end of a selected clip.
925
+ *
926
+ * This exists because canvas mode inherited the DOM path's *invisible* resize
927
+ * strips — a 10px zone with nothing drawn in it. That was survivable when a
928
+ * clip was a flat colour block with a visible 1px border; once clips filled
929
+ * with filmstrip frames there was no way to see where trimming started, and
930
+ * the only feedback was the cursor changing after you were already there.
931
+ *
932
+ * The drawn width is `VISUAL_EDGE_TOLERANCE_PX` (or the audio one), so the
933
+ * pill is a picture of the hit zone rather than a decoration near it.
934
+ *
935
+ * Handles are drawn for SELECTED items only. Trimming an unselected clip still
936
+ * works — hit-testing never consulted the selection and still doesn't — but
937
+ * showing a pair of pills on every clip on the timeline would bury the one
938
+ * thing selection is for.
939
+ */
940
+ export function drawTrimHandle(
941
+ ctx: DrawContext,
942
+ args: TrimHandleDrawArgs,
943
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
944
+ ): void {
945
+ const { rect, edge, hovered = false } = args
946
+ if (rect.width <= 0 || rect.height <= 0) return
947
+
948
+ // Two handles must never meet in the middle: on a clip narrower than twice
949
+ // the nominal width they each take half and stop.
950
+ const width = Math.min(args.width, rect.width / 2)
951
+ if (width < MIN_HANDLE_WIDTH_PX) return
952
+
953
+ const x = edge === 'in' ? rect.x : rect.x + rect.width - width
954
+ const radius = Math.min(args.radius ?? CLIP_RADIUS_PX, width / 2, rect.height / 2)
955
+
956
+ ctx.save()
957
+ ctx.fillStyle = hovered ? palette.colors.handleFillHovered : palette.colors.handleFill
958
+ roundRectPath(ctx, x, rect.y, width, rect.height, radius)
959
+ ctx.fill()
960
+
961
+ // Grip ticks, centred in the pill. Skipped when the pill has narrowed to the
962
+ // point where they'd fill it edge to edge and read as one solid block.
963
+ const gripSpan = HANDLE_GRIP_COUNT * HANDLE_GRIP_WIDTH_PX + (HANDLE_GRIP_COUNT - 1) * HANDLE_GRIP_GAP_PX
964
+ if (gripSpan <= width - 2) {
965
+ const gripHeight = Math.min(rect.height * HANDLE_GRIP_HEIGHT_RATIO, HANDLE_GRIP_MAX_HEIGHT_PX)
966
+ const gripY = rect.y + (rect.height - gripHeight) / 2
967
+ let gripX = x + (width - gripSpan) / 2
968
+ ctx.fillStyle = hovered ? palette.colors.handleGripHovered : palette.colors.handleGrip
969
+ for (let i = 0; i < HANDLE_GRIP_COUNT; i++) {
970
+ ctx.fillRect(gripX, gripY, HANDLE_GRIP_WIDTH_PX, gripHeight)
971
+ gripX += HANDLE_GRIP_WIDTH_PX + HANDLE_GRIP_GAP_PX
972
+ }
973
+ }
974
+ ctx.restore()
975
+ }
976
+
977
+ /**
978
+ * Both trim handles for one selected item.
979
+ *
980
+ * Split out of the item painters so the row painter can run it as a LAST pass,
981
+ * after every clip in the row is down. Handles are an affordance, not content:
982
+ * where two clips overlap, the one on top owns the picture, but the selected
983
+ * clip still owns its own edges and has to be able to show them. Painting them
984
+ * in stacking order meant a clip whose end was overlapped had a handle you
985
+ * could neither see nor — before the hit-test learned about this — grab.
986
+ */
987
+ export function drawItemHandles(
988
+ ctx: DrawContext,
989
+ rect: Rect,
990
+ width: number,
991
+ hoveredEdge?: 'in' | 'out' | null,
992
+ radius?: number,
993
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
994
+ ): void {
995
+ for (const edge of ['in', 'out'] as const) {
996
+ drawTrimHandle(ctx, { rect, edge, width, hovered: hoveredEdge === edge, radius }, palette)
997
+ }
998
+ }
999
+
1000
+ /** `args.palette` is the TRACK's own hue (already resolved by the caller from
1001
+ * the mode-appropriate cycle); the trailing `palette` is the surface-wide set
1002
+ * the mode-independent furniture — the selection outline, the label's halo —
1003
+ * comes from. */
1004
+ export function drawClipRect(
1005
+ ctx: DrawContext,
1006
+ args: ClipDrawArgs,
1007
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1008
+ ): void {
1009
+ const { rect, palette: trackPalette, selected, label, dimmed, drawContent } = args
1010
+ if (rect.width <= 0) return
1011
+
1012
+ const body = clipBodyRect(rect)
1013
+ if (body.width <= 0) return
1014
+
1015
+ const radius = Math.min(CLIP_RADIUS_PX, body.width / 2, body.height / 2)
1016
+
1017
+ ctx.save()
1018
+ if (dimmed) ctx.globalAlpha = 0.3
1019
+
1020
+ // Fill and content clipped to the rounded body, so filmstrip tiles and
1021
+ // waveform bars stop at the corners instead of squaring them off.
1022
+ ctx.save()
1023
+ roundRectPath(ctx, body.x, body.y, body.width, body.height, radius)
1024
+ ctx.clip()
1025
+ ctx.fillStyle = selected ? trackPalette.fillSelected : trackPalette.fill
1026
+ ctx.fillRect(body.x, body.y, body.width, body.height)
1027
+ drawContent?.(ctx, body)
1028
+ ctx.restore()
1029
+
1030
+ // The outline is the selection. A 1px inset ring in the track's own hue was
1031
+ // invisible once frames filled the clip; selection now reads as a thick
1032
+ // white border round the whole block, the one treatment that survives any
1033
+ // frame underneath it.
1034
+ if (selected) {
1035
+ const inset = CLIP_SELECTED_BORDER_PX / 2
1036
+ ctx.strokeStyle = palette.colors.clipSelectedOutline
1037
+ ctx.lineWidth = CLIP_SELECTED_BORDER_PX
1038
+ roundRectPath(ctx, body.x + inset, body.y + inset, Math.max(0, body.width - CLIP_SELECTED_BORDER_PX), Math.max(0, body.height - CLIP_SELECTED_BORDER_PX), radius)
1039
+ ctx.stroke()
1040
+ } else {
1041
+ ctx.strokeStyle = trackPalette.border
1042
+ ctx.lineWidth = 1
1043
+ roundRectPath(ctx, body.x + 0.5, body.y + 0.5, Math.max(0, body.width - 1), Math.max(0, body.height - 1), radius)
1044
+ ctx.stroke()
1045
+ }
1046
+
1047
+ // The handles themselves are NOT drawn here — the row painter puts them on
1048
+ // in a pass of its own once every clip is down (see `drawTimelineContent`),
1049
+ // because a clip that overlaps this one would otherwise bury them. Their
1050
+ // width still matters here: the label has to indent past where they will
1051
+ // land, or a pill drops onto the first glyph of a short overlay name.
1052
+ const handleWidth = selected ? Math.min(CLIP_HANDLE_WIDTH_PX, body.width / 2) : 0
1053
+
1054
+ // An empty label draws nothing at all — video clips carry none now that the
1055
+ // track rail names the row.
1056
+ if (label !== '' && body.width >= MIN_LABEL_WIDTH_PX) {
1057
+ ctx.save()
1058
+ ctx.beginPath()
1059
+ ctx.rect(body.x, body.y, body.width, body.height)
1060
+ ctx.clip()
1061
+ ctx.fillStyle = trackPalette.text
1062
+ ctx.font = LABEL_FONT
1063
+ ctx.textBaseline = 'middle'
1064
+ // Pinned to the TOP, not the vertical centre it used to sit at: a video
1065
+ // clip is now split into a frames band and a waveform band, and the centre
1066
+ // is the seam between them — the worst line on the clip for legibility.
1067
+ // The shadow is what lets it read over an arbitrary frame underneath;
1068
+ // `restore()` below drops it before anything else paints.
1069
+ ctx.shadowColor = palette.labelShadow
1070
+ ctx.shadowBlur = LABEL_SHADOW_BLUR_PX
1071
+ ctx.fillText(label, body.x + LABEL_PAD_PX + handleWidth, body.y + LABEL_TOP_OFFSET_PX)
1072
+ ctx.restore()
1073
+ }
1074
+
1075
+ ctx.restore()
1076
+ }
1077
+
1078
+ /** Segments the envelope polyline samples `fadeGain` at. 24 is smooth enough
1079
+ * that `exp`/`log`'s curvature reads clearly at any bar width the fade grip
1080
+ * can produce, without asking the rasterizer to stroke hundreds of tiny
1081
+ * segments on every repaint. Exported so tests can assert on the curve's
1082
+ * `lineTo` call count directly. */
1083
+ export const FADE_ENVELOPE_SEGMENTS = 24
1084
+
1085
+ /**
1086
+ * Vegas-style fade envelope: the WHOLE fade-width region — `x = spanX` to
1087
+ * `spanX + fadeInPx` for a fade-in, `spanX + spanWidth - fadeOutPx` to
1088
+ * `spanX + spanWidth` for a fade-out, measured off the clip's TRUE span (see
1089
+ * the note inside) so the fade tracks the clip under horizontal scroll — is
1090
+ * tinted FULL HEIGHT, top to bottom, as one band. The gain curve is then
1091
+ * stroked brightly on top of it, dividing the band into what's above and
1092
+ * below the curve — but BOTH halves stay tinted; this is not a wedge on one
1093
+ * side. The band is what says "this section is fading, full stop"; the curve
1094
+ * on top is what says exactly how much, at exactly which point. Replaces a
1095
+ * flat linear-gradient wash, which said "audio is quieter somewhere here"
1096
+ * but not how much quieter, where, or for how long — and replaced an earlier
1097
+ * one-sided wedge fill (shading only the region UNDER the curve) that read as
1098
+ * "this is the remaining signal" rather than "this is the fading section".
1099
+ *
1100
+ * The curve is drawn as a sampled polyline (`FADE_ENVELOPE_SEGMENTS` steps of
1101
+ * `fadeGain`), not a single curve primitive, because the shape is now a
1102
+ * per-fade CHOICE (`curve`) rather than one fixed ease — `fadeGain` is the
1103
+ * one source of truth for what each shape looks like, shared with the
1104
+ * waveform's own amplitude scaling (`waveforms.ts`) and the rendered mix
1105
+ * (`mix-audio.js`), and a polyline is the only primitive that can trace an
1106
+ * arbitrary one of them.
1107
+ */
1108
+ function drawFadeEnvelope(
1109
+ ctx: DrawContext,
1110
+ rect: Rect,
1111
+ edge: 'in' | 'out',
1112
+ widthPx: number,
1113
+ spanX: number,
1114
+ spanWidth: number,
1115
+ curve: FadeCurve,
1116
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1117
+ ): void {
1118
+ // `spanX`/`spanWidth` are the clip's TRUE horizontal span (its unclamped
1119
+ // body edges), NOT `rect` — the lane painter clamps `rect` to the visible
1120
+ // surface, and anchoring the curve there pinned the fade to a fixed screen
1121
+ // x so it stayed put while the clip scrolled under it (the same class of bug
1122
+ // the waveform once had). The envelope is drawn inside the bar's clip
1123
+ // region, so a span edge scrolled off-screen is harmless — it's clipped.
1124
+ const w = Math.min(Math.max(0, widthPx), spanWidth)
1125
+ if (w <= 0) return
1126
+ const top = rect.y
1127
+ const bottom = rect.y + rect.height
1128
+ const silentX = edge === 'in' ? spanX : spanX + spanWidth
1129
+ const fullX = edge === 'in' ? spanX + w : spanX + spanWidth - w
1130
+
1131
+ // Full-height tint across the whole fade-width band, both sides of the
1132
+ // curve — a plain rect, not a curve-bounded wedge, so `bandX` is just
1133
+ // whichever of the two x's is smaller regardless of edge direction.
1134
+ ctx.fillStyle = palette.colors.fadeEnvelopeDim
1135
+ ctx.fillRect(Math.min(silentX, fullX), top, w, bottom - top)
1136
+
1137
+ // `p` runs 0 (silentX, bottom — silent) → 1 (fullX, top — full volume), the
1138
+ // same convention `fadeGain` itself uses, so the first sample is always
1139
+ // exactly the silent corner and the last exactly the full-volume one.
1140
+ ctx.beginPath()
1141
+ for (let i = 0; i <= FADE_ENVELOPE_SEGMENTS; i++) {
1142
+ const p = i / FADE_ENVELOPE_SEGMENTS
1143
+ const x = silentX + (fullX - silentX) * p
1144
+ const y = bottom - fadeGain(p, curve) * (bottom - top)
1145
+ if (i === 0) ctx.moveTo(x, y)
1146
+ else ctx.lineTo(x, y)
1147
+ }
1148
+ ctx.strokeStyle = palette.colors.fadeEnvelopeLine
1149
+ ctx.lineWidth = 1.5
1150
+ ctx.stroke()
1151
+ }
1152
+
1153
+ /**
1154
+ * The small fade-grip triangle at one top corner of an audio bar — sitting
1155
+ * at the fade's inner edge when a fade is already set (drag it further in
1156
+ * or back out to the corner to adjust or remove the fade), or at the bar's
1157
+ * own corner when there is none yet (drag inward from there to create one).
1158
+ *
1159
+ * Drawn on EVERY bar, unlike the trim handles (`drawTrimHandle`), which are
1160
+ * selected-only: a fade grip has to be discoverable without selecting the
1161
+ * bar first, or it may as well not exist. `active` (hover, or the bar being
1162
+ * selected) brightens it — the same "clearer when it matters" language the
1163
+ * trim handles use, just without gating existence on selection too.
1164
+ */
1165
+ function drawFadeGrip(
1166
+ ctx: DrawContext,
1167
+ x: number,
1168
+ top: number,
1169
+ active: boolean,
1170
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1171
+ ): void {
1172
+ const half = FADE_GRIP_SIZE_PX / 2
1173
+ ctx.beginPath()
1174
+ ctx.moveTo(x - half, top)
1175
+ ctx.lineTo(x + half, top)
1176
+ ctx.lineTo(x, top + FADE_GRIP_SIZE_PX)
1177
+ ctx.closePath()
1178
+ ctx.fillStyle = active ? palette.colors.fadeGripActive : palette.colors.fadeGripSubtle
1179
+ ctx.fill()
1180
+ }
1181
+
1182
+ /**
1183
+ * One keyframe-strip diamond, centred at `(x, y)`.
1184
+ *
1185
+ * Unlike `drawFadeGrip` (drawn on every audio bar, selected or not — see its
1186
+ * own doc for why), a diamond is drawn ONLY for the selected, keyframed
1187
+ * overlay item: `drawTimelineContent`'s loop gates the whole strip on that,
1188
+ * not this function. A keyframe is per-property editing state, not a
1189
+ * persistent property of the clip the way a fade is, so showing it on every
1190
+ * overlay would be noise rather than an affordance (plan decision 1) — and
1191
+ * because the gate already limits diamonds to a selected item, there is no
1192
+ * separate "active/subtle" distinction to draw here the way the fade grip
1193
+ * needs for discoverability.
1194
+ *
1195
+ * `selected` is a DIFFERENT axis from that gate: which one diamond, among
1196
+ * however many the strip draws, is the host's `selectedKeyframe`. It swaps
1197
+ * the fill to `keyframeDiamondSelectedFill` and thickens the stroke, the
1198
+ * same "outline thickens" language `drawItemHandles` uses for a selected
1199
+ * clip's border.
1200
+ */
1201
+ function drawKeyframeDiamond(
1202
+ ctx: DrawContext,
1203
+ x: number,
1204
+ y: number,
1205
+ selected = false,
1206
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1207
+ ): void {
1208
+ const half = KEYFRAME_DIAMOND_SIZE_PX / 2
1209
+ ctx.beginPath()
1210
+ ctx.moveTo(x, y - half)
1211
+ ctx.lineTo(x + half, y)
1212
+ ctx.lineTo(x, y + half)
1213
+ ctx.lineTo(x - half, y)
1214
+ ctx.closePath()
1215
+ ctx.fillStyle = selected ? palette.colors.keyframeDiamondSelectedFill : palette.colors.keyframeDiamondFill
1216
+ ctx.fill()
1217
+ ctx.strokeStyle = palette.colors.keyframeDiamondStroke
1218
+ ctx.lineWidth = selected ? 2 : 1
1219
+ ctx.stroke()
1220
+ }
1221
+
1222
+ /**
1223
+ * The keyframe strip for one selected, keyframed overlay item: one diamond
1224
+ * per DISTINCT keyframe time across ALL of its tracks (`keyframeUnionTimes` —
1225
+ * plan decision 2's "union of times", not one row per property), positioned
1226
+ * along the BOTTOM of the clip's drawn body.
1227
+ *
1228
+ * Each diamond's x comes from `keyframeDiamondX`, which converts through the
1229
+ * SAME `timeToX` every other draw call on this surface uses — so unlike the
1230
+ * fade envelope (which has to be handed the clip's true, unclamped span to
1231
+ * survive horizontal scroll, see `drawFadeEnvelope`'s own note) a diamond
1232
+ * needs no such plumbing: its position is already absolute screen space.
1233
+ * What it DOES need is the clip to `body` (decision 3: never draw outside
1234
+ * the clip), which is why the whole strip is wrapped in one clip region
1235
+ * rather than each diamond clamping itself.
1236
+ *
1237
+ * The clip region is widened past `body`'s own edges, NOT set to `body`
1238
+ * verbatim. `body` is already inset from the clip's TRUE time span by
1239
+ * `CLIP_GUTTER_PX` (`clipBodyRect`), but a diamond's x is NOT computed from
1240
+ * `body` — `keyframeDiamondX` runs `item.start + t` through the same
1241
+ * `timeToX` the row loop used to get the clip's true, un-inset `rect` in the
1242
+ * first place. So an ENDPOINT diamond (t=0 or t=duration) is centred exactly
1243
+ * at `rect.x` / `rect.x + rect.width` — which is `CLIP_GUTTER_PX` further out
1244
+ * than `body`'s corresponding edge — and widening the clip by only half a
1245
+ * diamond from `body` (as it might look natural to do) still falls
1246
+ * `CLIP_GUTTER_PX` short of that centre, leaving a slimmer but still-real
1247
+ * sliver. The margin below is `half a diamond` **+ `CLIP_GUTTER_PX`**, which
1248
+ * puts the widened region's edge exactly back at `rect`'s true edge before
1249
+ * adding the half-diamond room a full diamond needs — so the endpoint diamond
1250
+ * survives WHOLE, not just mostly. That is not a corner case: `enableKeyframing`
1251
+ * seeds a keyframe at t=0 when the playhead sits at the item's own start, and
1252
+ * `applyKeyframeMove` lists 0 and duration as STRONG snap targets, so drags
1253
+ * are actively steered onto them. The extra margin never exposes anything
1254
+ * outside the clip's true span — a diamond's `t` is already clamped to
1255
+ * `[0, duration]` by every writer in `keyframeOps.ts` and, symmetrically, by
1256
+ * `hit-test.ts`'s `keyframeStripZone` filter — so "decision 3: never draw
1257
+ * outside the clip" still holds; only the definition of "the clip" grew back
1258
+ * out to `rect` (plus the diamond's own half-width) to match what decision 3
1259
+ * was actually protecting.
1260
+ *
1261
+ * `selectedT`, when given, is compared against each diamond's own `t` — not
1262
+ * carried as a separate "which index" — so the ONE diamond it names (if any
1263
+ * is currently on the strip at all) is the one `drawKeyframeDiamond` paints
1264
+ * `selected`. Absent or `null` means no diamond on this strip is selected,
1265
+ * the ordinary case for every item except the one holding the host's
1266
+ * `selectedKeyframe`.
1267
+ */
1268
+ export function drawKeyframeStrip(
1269
+ ctx: DrawContext,
1270
+ item: VisualItem,
1271
+ body: Rect,
1272
+ viewport: Viewport,
1273
+ selectedT?: number | null,
1274
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1275
+ ): void {
1276
+ const times = keyframeUnionTimes(item)
1277
+ if (times.length === 0 || body.width <= 0) return
1278
+
1279
+ const y = body.y + body.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX
1280
+ const margin = KEYFRAME_DIAMOND_SIZE_PX / 2 + CLIP_GUTTER_PX
1281
+ ctx.save()
1282
+ ctx.beginPath()
1283
+ ctx.rect(body.x - margin, body.y, body.width + margin * 2, body.height)
1284
+ ctx.clip()
1285
+ for (const t of times) {
1286
+ drawKeyframeDiamond(ctx, keyframeDiamondX(item, t, viewport), y, t === selectedT, palette)
1287
+ }
1288
+ ctx.restore()
1289
+ }
1290
+
1291
+ export interface AudioItemDrawArgs {
1292
+ rect: Rect
1293
+ selected: boolean
1294
+ muted: boolean
1295
+ label: string
1296
+ /** Fade widths in px, already converted from seconds. */
1297
+ fadeInPx?: number
1298
+ fadeOutPx?: number
1299
+ /** The clip's TRUE (unclamped) body span in surface x — left edge and
1300
+ * width. The fade envelope and grips anchor to THIS, not `rect`, which the
1301
+ * lane painter clamps to the visible surface; without it a fade stays
1302
+ * pinned to the viewport edge instead of scrolling with its clip. Default
1303
+ * to `rect.x`/`rect.width` when absent (an on-screen, unscrolled clip). */
1304
+ fadeSpanX?: number
1305
+ fadeSpanWidth?: number
1306
+ /** Envelope shape for each fade — see `fade-curve.ts`. Default
1307
+ * `DEFAULT_FADE_CURVE` ('exp'), the shape every fade had before curves
1308
+ * existed, so a bar whose track carries no explicit choice looks
1309
+ * unchanged. */
1310
+ fadeInCurve?: FadeCurve
1311
+ fadeOutCurve?: FadeCurve
1312
+ /** Content layer drawn inside the bar's clip region, beneath the fades and
1313
+ * label — the T6 audio-lane waveform hooks in here (mirrors the DOM
1314
+ * path's `AudioWaveformLayer`, which also paints behind the type label). */
1315
+ drawContent?: (ctx: DrawContext, rect: Rect) => void
1316
+ /** Which fade grip (if any) the pointer is resting on — brightens that one
1317
+ * grip the same way `hoveredEdge` brightens a trim handle. Absent/null
1318
+ * when nothing is hovered, which is also what a host that never wires up
1319
+ * hover detection gets: the grip still renders (just always at its
1320
+ * subtle-or-selected style), so the feature is fully usable without this
1321
+ * field ever being set. */
1322
+ hoveredFadeSide?: 'in' | 'out' | null
1323
+ }
1324
+
1325
+ export function drawAudioItem(
1326
+ ctx: DrawContext,
1327
+ args: AudioItemDrawArgs,
1328
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1329
+ ): void {
1330
+ const { rect, selected, muted, label, fadeInPx = 0, fadeOutPx = 0, drawContent, hoveredFadeSide } = args
1331
+ const fadeInCurve = args.fadeInCurve ?? DEFAULT_FADE_CURVE
1332
+ const fadeOutCurve = args.fadeOutCurve ?? DEFAULT_FADE_CURVE
1333
+ // Fades anchor to the clip's true span (scrolls with the clip); fall back to
1334
+ // the drawn rect for callers/tests that don't supply it (on-screen clip).
1335
+ const fadeSpanX = args.fadeSpanX ?? rect.x
1336
+ const fadeSpanWidth = args.fadeSpanWidth ?? rect.width
1337
+ const handleWidth = selected ? Math.min(AUDIO_HANDLE_WIDTH_PX, rect.width / 2) : 0
1338
+ if (rect.width <= 0) return
1339
+ ctx.save()
1340
+
1341
+ ctx.fillStyle = muted ? palette.colors.audioMutedFill : palette.colors.audioFill
1342
+ roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, AUDIO_ITEM_RADIUS_PX)
1343
+ ctx.fill()
1344
+ if (!muted) {
1345
+ ctx.strokeStyle = palette.colors.audioBorder
1346
+ ctx.lineWidth = 1
1347
+ ctx.stroke()
1348
+ }
1349
+
1350
+ // Clip the content/fades/label to the bar so none of them can bleed past
1351
+ // its edges.
1352
+ ctx.save()
1353
+ ctx.beginPath()
1354
+ ctx.rect(rect.x, rect.y, rect.width, rect.height)
1355
+ ctx.clip()
1356
+
1357
+ drawContent?.(ctx, rect)
1358
+
1359
+ if (fadeInPx > 0) drawFadeEnvelope(ctx, rect, 'in', fadeInPx, fadeSpanX, fadeSpanWidth, fadeInCurve, palette)
1360
+ if (fadeOutPx > 0) drawFadeEnvelope(ctx, rect, 'out', fadeOutPx, fadeSpanX, fadeSpanWidth, fadeOutCurve, palette)
1361
+
1362
+ if (rect.width >= MIN_LABEL_WIDTH_PX) {
1363
+ ctx.fillStyle = palette.colors.audioText
1364
+ ctx.font = LABEL_FONT
1365
+ ctx.textBaseline = 'middle'
1366
+ ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
1367
+ }
1368
+ ctx.restore()
1369
+
1370
+ if (selected) {
1371
+ ctx.strokeStyle = palette.colors.audioRing
1372
+ ctx.lineWidth = 1
1373
+ roundRectPath(ctx, rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1), AUDIO_ITEM_RADIUS_PX)
1374
+ ctx.stroke()
1375
+ // Handles come later, in the lane painter's own pass — crossfaded bars
1376
+ // overlap by design, so burying them here would be the normal case rather
1377
+ // than the exception.
1378
+ }
1379
+
1380
+ // Fade grips: on EVERY bar (not gated on `selected`, unlike the trim
1381
+ // handles above) — see `drawFadeGrip`'s own doc for why. Skipped only
1382
+ // when the bar is too narrow for the two grips to avoid sitting on top of
1383
+ // each other.
1384
+ if (fadeSpanWidth >= FADE_GRIP_SIZE_PX * 2) {
1385
+ const inX = fadeSpanX + Math.min(fadeInPx, fadeSpanWidth)
1386
+ const outX = fadeSpanX + fadeSpanWidth - Math.min(fadeOutPx, fadeSpanWidth)
1387
+ drawFadeGrip(ctx, inX, rect.y, selected || hoveredFadeSide === 'in', palette)
1388
+ drawFadeGrip(ctx, outX, rect.y, selected || hoveredFadeSide === 'out', palette)
1389
+ }
1390
+
1391
+ ctx.restore()
1392
+ }
1393
+
1394
+ export interface CaptionBlockDrawArgs {
1395
+ /** Already inset (see `AUDIO_ITEM_INSET_PX`) — same contract as
1396
+ * `drawAudioItem`'s `rect`, not the full row span. */
1397
+ rect: Rect
1398
+ selected: boolean
1399
+ label: string
1400
+ }
1401
+
1402
+ /**
1403
+ * One caption segment block. Structurally the closer sibling is
1404
+ * `drawAudioItem`, not `drawClipRect`: like an audio bar, a caption has no
1405
+ * content bands to split label position around, so its label rides centred;
1406
+ * like an audio bar, it takes its OWN hue on selection rather than the
1407
+ * global white clip-selection outline (`drawClipRect`'s override would be a
1408
+ * lie here — a caption is never confused with a video clip because nothing
1409
+ * else on this row looks like one).
1410
+ *
1411
+ * Unlike an audio bar, the FILL itself changes on selection too — mirrors
1412
+ * the retired DOM row's `bg-purple-700/40` → `bg-purple-600/70` swap — and
1413
+ * the border/ring pair is mutually exclusive (border unselected, ring
1414
+ * selected) rather than audio's "border always, ring layered on top",
1415
+ * because that is what the DOM classes did:
1416
+ * `border border-purple-500/40` XOR `ring-1 ring-inset ring-purple-300/80`.
1417
+ */
1418
+ export function drawCaptionBlock(
1419
+ ctx: DrawContext,
1420
+ args: CaptionBlockDrawArgs,
1421
+ timeline: TimelinePalette = DARK_TIMELINE_PALETTE,
1422
+ ): void {
1423
+ const { rect, selected, label } = args
1424
+ if (rect.width <= 0) return
1425
+
1426
+ // The block's own hue; the surrounding `timeline` palette carries nothing
1427
+ // else this painter needs (a caption takes its OWN ring on selection rather
1428
+ // than the global white/near-black clip outline — see the doc above).
1429
+ const palette = timeline.caption
1430
+ const radius = Math.min(AUDIO_ITEM_RADIUS_PX, rect.width / 2, rect.height / 2)
1431
+ const handleWidth = selected ? Math.min(AUDIO_HANDLE_WIDTH_PX, rect.width / 2) : 0
1432
+
1433
+ ctx.save()
1434
+ ctx.fillStyle = selected ? palette.fillSelected : palette.fill
1435
+ roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, radius)
1436
+ ctx.fill()
1437
+
1438
+ ctx.strokeStyle = selected ? palette.ring : palette.border
1439
+ ctx.lineWidth = 1
1440
+ roundRectPath(ctx, rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1), radius)
1441
+ ctx.stroke()
1442
+
1443
+ // Centred, clipped to the block — matching the DOM row's `flex items-center
1444
+ // overflow-hidden` rather than `drawClipRect`'s top pin, which exists only
1445
+ // to clear that clip's own frames/waveform split. A caption block has none.
1446
+ if (label !== '' && rect.width >= MIN_LABEL_WIDTH_PX) {
1447
+ ctx.save()
1448
+ ctx.beginPath()
1449
+ ctx.rect(rect.x, rect.y, rect.width, rect.height)
1450
+ ctx.clip()
1451
+ ctx.fillStyle = palette.text
1452
+ ctx.font = LABEL_FONT
1453
+ ctx.textBaseline = 'middle'
1454
+ ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
1455
+ ctx.restore()
1456
+ }
1457
+
1458
+ ctx.restore()
1459
+ }
1460
+
1461
+ /**
1462
+ * The span where two items on one row sit on top of each other.
1463
+ *
1464
+ * On an audio lane that is a crossfade and usually deliberate. On a video
1465
+ * track it usually is not: within a track the later clip simply wins, so an
1466
+ * overlap means one clip is invisible for that stretch — worth shouting about
1467
+ * rather than whispering.
1468
+ *
1469
+ * Neutral wash, diagonal hatching, and a thin rule down each side. The hatch
1470
+ * is the load-bearing part: a translucent wash strong enough to see over a
1471
+ * bright filmstrip frame is strong enough to hide the frame, whereas diagonals
1472
+ * stay legible over anything without obscuring what is underneath them. It is
1473
+ * also the convention — every NLE marks a transition region with stripes.
1474
+ */
1475
+ export function drawOverlapBand(
1476
+ ctx: DrawContext,
1477
+ rect: Rect,
1478
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1479
+ ): void {
1480
+ if (rect.width <= 0 || rect.height <= 0) return
1481
+
1482
+ ctx.save()
1483
+ // Clip first: the diagonals run past the band's corners by design, and the
1484
+ // clip is what turns them into a contained patch of hatching.
1485
+ ctx.beginPath()
1486
+ ctx.rect(rect.x, rect.y, rect.width, rect.height)
1487
+ ctx.clip()
1488
+
1489
+ ctx.fillStyle = palette.colors.overlapFill
1490
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
1491
+
1492
+ // Leaning the same way as a fade: bottom-left to top-right. Start far enough
1493
+ // left that the first line still crosses the band's bottom-left corner.
1494
+ ctx.beginPath()
1495
+ for (let offset = 0; offset <= rect.width + rect.height; offset += OVERLAP_HATCH_SPACING_PX) {
1496
+ ctx.moveTo(rect.x + offset, rect.y + rect.height)
1497
+ ctx.lineTo(rect.x + offset - rect.height, rect.y)
1498
+ }
1499
+ // One path, stroked twice: a wide dark pass, then the amber inside it.
1500
+ ctx.strokeStyle = palette.colors.overlapHatchShadow
1501
+ ctx.lineWidth = OVERLAP_HATCH_SHADOW_WIDTH_PX
1502
+ ctx.stroke()
1503
+ ctx.strokeStyle = palette.colors.overlapHatch
1504
+ ctx.lineWidth = OVERLAP_HATCH_WIDTH_PX
1505
+ ctx.stroke()
1506
+ ctx.restore()
1507
+
1508
+ // Edges last and unclipped: they are what says exactly where the overlap
1509
+ // starts and stops, which is the number an editor is actually trying to read
1510
+ // off the screen. Kept thin and translucent so the band never reads as the
1511
+ // white outline that means "selected".
1512
+ const edge = Math.min(OVERLAP_EDGE_WIDTH_PX, rect.width / 2)
1513
+ ctx.fillStyle = palette.colors.overlapEdge
1514
+ ctx.fillRect(rect.x, rect.y, edge, rect.height)
1515
+ ctx.fillRect(rect.x + rect.width - edge, rect.y, edge, rect.height)
1516
+ }
1517
+
1518
+ /**
1519
+ * Time spans where consecutive items on one row overlap.
1520
+ *
1521
+ * Consecutive in START order, which is what a crossfade is; a clip buried
1522
+ * under a much longer neighbour two positions along is a different problem and
1523
+ * not one a band can usefully describe. Pure so both row kinds can share it —
1524
+ * the visual rows had no overlap marking at all before this, and duplicating
1525
+ * the arithmetic is how they would drift apart again.
1526
+ */
1527
+ export function overlapBands(items: readonly { start: number; end: number }[]): Array<{ start: number; end: number }> {
1528
+ const sorted = [...items].sort((a, b) => a.start - b.start)
1529
+ const out: Array<{ start: number; end: number }> = []
1530
+ for (let i = 0; i < sorted.length - 1; i++) {
1531
+ const a = sorted[i]
1532
+ const b = sorted[i + 1]
1533
+ if (a.end <= b.start) continue
1534
+ out.push({ start: b.start, end: Math.min(a.end, b.end) })
1535
+ }
1536
+ return out
1537
+ }
1538
+
1539
+ export function drawPlayhead(
1540
+ ctx: DrawContext,
1541
+ x: number,
1542
+ top: number,
1543
+ bottom: number,
1544
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1545
+ ): void {
1546
+ ctx.fillStyle = palette.colors.playhead
1547
+ ctx.fillRect(x - PLAYHEAD_WIDTH_PX / 2, top, PLAYHEAD_WIDTH_PX, bottom - top)
1548
+ }
1549
+
1550
+ /**
1551
+ * The snap guide: the boundary a running gesture is currently magnetized to.
1552
+ *
1553
+ * A line plus an arrowhead at each end, pointing inward. The caps are load-
1554
+ * bearing, not decoration — a bare vertical line on this surface is already
1555
+ * two other things (the red playhead, the yellow preview axis), and the whole
1556
+ * job of this mark is to say "you are held HERE, and here is a real edge".
1557
+ *
1558
+ * Drawn last of everything on the overlay so it survives whatever it lands on.
1559
+ * When a gesture snaps to the playhead the cyan sits directly over the red,
1560
+ * which is exactly the right picture: that IS what happened.
1561
+ */
1562
+ export function drawSnapGuide(
1563
+ ctx: DrawContext,
1564
+ x: number,
1565
+ top: number,
1566
+ bottom: number,
1567
+ strength: SnapStrength = 'strong',
1568
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1569
+ ): void {
1570
+ ctx.save()
1571
+
1572
+ if (strength === 'weak') {
1573
+ ctx.fillStyle = palette.colors.snapGuideWeak
1574
+ ctx.fillRect(x - SNAP_GUIDE_WEAK_WIDTH_PX / 2, top, SNAP_GUIDE_WEAK_WIDTH_PX, bottom - top)
1575
+ ctx.restore()
1576
+ return
1577
+ }
1578
+
1579
+ ctx.fillStyle = palette.colors.snapGuide
1580
+ ctx.fillRect(x - SNAP_GUIDE_WIDTH_PX / 2, top, SNAP_GUIDE_WIDTH_PX, bottom - top)
1581
+
1582
+ const half = SNAP_GUIDE_CAP_HALF_WIDTH_PX
1583
+ const cap = SNAP_GUIDE_CAP_HEIGHT_PX
1584
+
1585
+ ctx.beginPath()
1586
+ ctx.moveTo(x - half, top)
1587
+ ctx.lineTo(x + half, top)
1588
+ ctx.lineTo(x, top + cap)
1589
+ ctx.closePath()
1590
+ ctx.fill()
1591
+
1592
+ ctx.beginPath()
1593
+ ctx.moveTo(x - half, bottom)
1594
+ ctx.lineTo(x + half, bottom)
1595
+ ctx.lineTo(x, bottom - cap)
1596
+ ctx.closePath()
1597
+ ctx.fill()
1598
+ ctx.restore()
1599
+ }
1600
+
1601
+ /** The preview-axis cursor. Drawn BEFORE the playhead by `drawTimelineOverlay`
1602
+ * so that where the two coincide the red playback line stays the one you see —
1603
+ * the playhead is the position that survives the pointer leaving. */
1604
+ export function drawCursorLine(
1605
+ ctx: DrawContext,
1606
+ x: number,
1607
+ top: number,
1608
+ bottom: number,
1609
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1610
+ ): void {
1611
+ ctx.fillStyle = palette.colors.cursor
1612
+ ctx.fillRect(x - CURSOR_WIDTH_PX / 2, top, CURSOR_WIDTH_PX, bottom - top)
1613
+ }
1614
+
1615
+ // ── Scene composition ────────────────────────────────────────────────────
1616
+
1617
+ export interface TimelineScene {
1618
+ project: Project
1619
+ viewport: Viewport
1620
+ layout: TimelineLayout
1621
+ /** Unified selection (visual items + audio tracks), as Timeline holds it. */
1622
+ selectedIds: string[]
1623
+ /** The trim handle the pointer is resting on, if any. Only ever set for a
1624
+ * SELECTED item, since unselected ones draw no handles to highlight. */
1625
+ hoveredHandle?: { itemId: string; edge: 'in' | 'out' } | null
1626
+ /** The selected keyframe diamond, if any (`pointer-machine.ts`'s
1627
+ * `KeyframeSelection`, structurally — this module doesn't import that
1628
+ * type, since `pointer-machine.ts` imports THIS module already, and the
1629
+ * reverse would be circular). `itemId`'s strip, if it draws a diamond at
1630
+ * `t` at all, paints that one `selected`; every other diamond on every
1631
+ * other strip paints ordinary. Mirrors `hoveredHandle`'s own inline shape
1632
+ * for the same reason. */
1633
+ selectedKeyframe?: { itemId: string; t: number } | null
1634
+ surfaceWidth: number
1635
+ surfaceHeight: number
1636
+ /** T6 waveform content-layer provider. Absent → no waveforms drawn, which
1637
+ * is what a host without a `getWaveformPeaks` adapter method gets. */
1638
+ waveforms?: WaveformSceneLookup
1639
+ /** T7 filmstrip content-layer provider (the background tile strip inside a
1640
+ * clip rect). Absent → no filmstrips drawn, same graceful omission as
1641
+ * `waveforms`. */
1642
+ filmstrips?: FilmstripSceneLookup
1643
+ /** Which ground to paint on, resolved from the host theme by `VideoEditor`.
1644
+ * Absent → `'dark'`, so every caller that predates light mode — and every
1645
+ * existing test — paints byte-identical pixels. */
1646
+ mode?: TimelineMode
1647
+ }
1648
+
1649
+ export interface DrawStats {
1650
+ visualItemsDrawn: number
1651
+ audioItemsDrawn: number
1652
+ itemsCulled: number
1653
+ /** Caption blocks drawn. Kept OUT of `visualItemsDrawn`/`audioItemsDrawn` —
1654
+ * every existing assertion built on those two ("drawn + culled === total
1655
+ * items") predates captions and counts only clips/overlays and audio bars;
1656
+ * folding captions in would silently change what those numbers mean for
1657
+ * every project that already has any. Culled captions still count toward
1658
+ * the shared `itemsCulled`, same as every other item kind. Required, not
1659
+ * optional: `drawTimelineContent` always initializes it to 0 and is the
1660
+ * only producer of a `DrawStats`, so every reader can rely on a number. */
1661
+ captionItemsDrawn: number
1662
+ }
1663
+
1664
+ /** Does [start, end] intersect the viewport's visible time range? */
1665
+ function intersectsRange(start: number, end: number, range: { start: number; end: number }): boolean {
1666
+ return end >= range.start && start <= range.end
1667
+ }
1668
+
1669
+ /**
1670
+ * Paint the content layer: rows, clips, audio bars and crossfade bands. The
1671
+ * playhead is NOT drawn here — it lives on its own layer so a 60fps playhead
1672
+ * never repaints the content (see `drawTimelineOverlay`).
1673
+ *
1674
+ * Culling is the whole point: items outside the visible time range are skipped
1675
+ * before any draw call, so the number of draw calls is bounded by what fits on
1676
+ * screen, not by how big the project is. Returned stats make that assertable.
1677
+ */
1678
+ /**
1679
+ * The labelled interval for a ruler at this scale.
1680
+ *
1681
+ * Walks `RULER_STEPS_SECONDS` for the first step whose on-screen width clears
1682
+ * `RULER_MIN_LABEL_SPACING_PX`, so zooming changes the DENSITY of labels rather
1683
+ * than letting them overlap. Falls back to the coarsest step, which at extreme
1684
+ * zoom-out means sparse labels rather than none.
1685
+ */
1686
+ export function rulerStepSeconds(pxPerSecond: number): number {
1687
+ if (!(pxPerSecond > 0)) return RULER_STEPS_SECONDS[RULER_STEPS_SECONDS.length - 1]
1688
+ for (const step of RULER_STEPS_SECONDS) {
1689
+ if (step * pxPerSecond >= RULER_MIN_LABEL_SPACING_PX) return step
1690
+ }
1691
+ return RULER_STEPS_SECONDS[RULER_STEPS_SECONDS.length - 1]
1692
+ }
1693
+
1694
+ /** `m:ss`, plus tenths only when the step is finer than a second — a ruler
1695
+ * labelled `0:03.0` every three seconds is noise, and one labelled `0:03`
1696
+ * every 250ms is four identical labels in a row. */
1697
+ export function formatRulerTime(seconds: number, step: number): string {
1698
+ const safe = Math.max(0, seconds)
1699
+ const mins = Math.floor(safe / 60)
1700
+ const secs = safe - mins * 60
1701
+ if (step < 1) return `${mins}:${secs.toFixed(1).padStart(4, '0')}`
1702
+ return `${mins}:${String(Math.round(secs)).padStart(2, '0')}`
1703
+ }
1704
+
1705
+ /**
1706
+ * The time ruler across the top of the surface — the strip you scrub on.
1707
+ *
1708
+ * Minor ticks subdivide each labelled interval into fifths, which is the
1709
+ * densest subdivision that still reads as ticks rather than as a grey band.
1710
+ */
1711
+ export function drawRuler(
1712
+ ctx: DrawContext,
1713
+ viewport: Viewport,
1714
+ rect: { y: number; height: number },
1715
+ surfaceWidth: number,
1716
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1717
+ ): void {
1718
+ ctx.save()
1719
+ ctx.fillStyle = palette.colors.rulerBackground
1720
+ ctx.fillRect(0, rect.y, surfaceWidth, rect.height)
1721
+
1722
+ const step = rulerStepSeconds(viewport.pxPerSecond)
1723
+ const minor = step / 5
1724
+ const bottom = rect.y + rect.height
1725
+ const range = visibleRange(viewport)
1726
+ const first = Math.floor(range.start / minor) * minor
1727
+ const last = range.end
1728
+
1729
+ ctx.font = LABEL_FONT
1730
+ ctx.textBaseline = 'alphabetic'
1731
+
1732
+ // Iterated in integer multiples of `minor` rather than by accumulating a
1733
+ // float, so a long timeline cannot drift the ticks off the labels.
1734
+ for (let i = 0; ; i++) {
1735
+ const t = first + i * minor
1736
+ if (t > last) break
1737
+ if (t < 0) continue
1738
+ const x = Math.round(timeToX(t, viewport)) + 0.5
1739
+ if (x < 0 || x > surfaceWidth) continue
1740
+ // Float multiples never land exactly on the step, so test the remainder
1741
+ // against a tolerance scaled to the step itself.
1742
+ const isMajor = Math.abs(t / step - Math.round(t / step)) < 1e-6
1743
+ const tickHeight = isMajor ? RULER_MAJOR_TICK_PX : RULER_MINOR_TICK_PX
1744
+ ctx.fillStyle = isMajor ? palette.colors.rulerTick : palette.colors.rulerTickMinor
1745
+ ctx.fillRect(x, bottom - tickHeight, 1, tickHeight)
1746
+
1747
+ if (isMajor) {
1748
+ ctx.fillStyle = palette.colors.rulerText
1749
+ ctx.fillText(formatRulerTime(t, step), x + 3, rect.y + RULER_LABEL_BASELINE_PX)
1750
+ }
1751
+ }
1752
+ ctx.restore()
1753
+ }
1754
+
1755
+ // ── Pending-drop ghost ───────────────────────────────────────────────────
1756
+
1757
+ /** Corner radius of a ghost band. The same 4px a clip uses — the band stands
1758
+ * where a clip is about to be, so it should be the same shape. */
1759
+ export const PENDING_DROP_RADIUS_PX = CLIP_RADIUS_PX
1760
+ /** Inset from the row's own rectangle, top and bottom, so the ghost reads as
1761
+ * sitting IN the row rather than replacing it. */
1762
+ export const PENDING_DROP_INSET_PX = 2
1763
+ /** Stroke width of the dashed outline. 1.5px, not 1: a dash is half gaps, so
1764
+ * at 1px the outline reads as a faint dotted smudge rather than a border. */
1765
+ export const PENDING_DROP_BORDER_PX = 1.5
1766
+ /** Dash pattern — 6 on, 4 off. Long enough that each dash is unmistakably a
1767
+ * dash (not a dot), short enough that a narrow band still shows several. */
1768
+ export const PENDING_DROP_DASH: readonly number[] = [6, 4]
1769
+
1770
+ /** One in-flight import's ghost band. `start`/`end` are TIMELINE SECONDS (the
1771
+ * painter converts them through the viewport, so a ghost pans and zooms with
1772
+ * everything else without the host resending it); `y`/`height` are surface
1773
+ * CSS pixels, resolved by the caller from the row rectangle in
1774
+ * `TimelineLayout.rows` — the same "read the layout, never re-derive geometry"
1775
+ * rule the hit-test states. `label` is the filename, drawn inside. */
1776
+ export interface PendingDropBand {
1777
+ start: number
1778
+ end: number
1779
+ y: number
1780
+ height: number
1781
+ label?: string
1782
+ }
1783
+
1784
+ /**
1785
+ * The ghost band for a file whose import is still in flight: a translucent
1786
+ * rounded rect with a DASHED outline, at the place the file was dropped.
1787
+ *
1788
+ * Dashed is the whole point. A solid band at clip opacity would read as a clip
1789
+ * that is already there, and the one thing this mark has to say is "this is
1790
+ * not real yet" — the outline is what says it, which is why it is dashed
1791
+ * rather than merely faint (a faint solid band just reads as a dimmed clip,
1792
+ * which on this surface already means a disabled track).
1793
+ *
1794
+ * `setLineDash` is scoped to this painter's own save/restore AND explicitly
1795
+ * reset before it returns. A real 2D context restores the dash with the rest
1796
+ * of its drawing state, but the stub contexts the tests drive do not, and a
1797
+ * leaked dash on the overlay layer means a dashed playhead — the single
1798
+ * easiest bug to ship here, so it is closed twice.
1799
+ */
1800
+ export function drawPendingDropBand(
1801
+ ctx: DrawContext,
1802
+ band: PendingDropBand,
1803
+ viewport: Viewport,
1804
+ surfaceWidth: number,
1805
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1806
+ ): void {
1807
+ const x = timeToX(band.start, viewport)
1808
+ const width = Math.max(0, (band.end - band.start) * viewport.pxPerSecond)
1809
+ const rect = clampRectToSurface({ x, y: band.y + PENDING_DROP_INSET_PX, width, height: Math.max(0, band.height - PENDING_DROP_INSET_PX * 2) }, surfaceWidth)
1810
+ if (rect.width <= 0 || rect.height <= 0) return
1811
+
1812
+ const radius = Math.min(PENDING_DROP_RADIUS_PX, rect.width / 2, rect.height / 2)
1813
+
1814
+ ctx.save()
1815
+ ctx.fillStyle = palette.colors.pendingDropFill
1816
+ roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, radius)
1817
+ ctx.fill()
1818
+
1819
+ // Inset by half the stroke so the dashes sit fully inside the band instead
1820
+ // of straddling its edge — same reason the marquee offsets by half a pixel.
1821
+ const inset = PENDING_DROP_BORDER_PX / 2
1822
+ ctx.strokeStyle = palette.colors.pendingDropStroke
1823
+ ctx.lineWidth = PENDING_DROP_BORDER_PX
1824
+ ctx.setLineDash(PENDING_DROP_DASH as number[])
1825
+ roundRectPath(
1826
+ ctx,
1827
+ rect.x + inset,
1828
+ rect.y + inset,
1829
+ Math.max(0, rect.width - PENDING_DROP_BORDER_PX),
1830
+ Math.max(0, rect.height - PENDING_DROP_BORDER_PX),
1831
+ radius,
1832
+ )
1833
+ ctx.stroke()
1834
+ ctx.setLineDash([])
1835
+
1836
+ // The filename, clipped to the band: an import is usually named after a
1837
+ // camera file, and those are long — without the clip a name would bleed out
1838
+ // over the clips either side of the ghost. Same MIN_LABEL_WIDTH_PX floor the
1839
+ // clip labels use, below which a label is more smear than information.
1840
+ if (band.label && rect.width >= MIN_LABEL_WIDTH_PX) {
1841
+ ctx.save()
1842
+ ctx.beginPath()
1843
+ ctx.rect(rect.x, rect.y, rect.width, rect.height)
1844
+ ctx.clip()
1845
+ ctx.fillStyle = palette.colors.pendingDropText
1846
+ ctx.font = LABEL_FONT
1847
+ ctx.textBaseline = 'middle'
1848
+ ctx.fillText(band.label, rect.x + LABEL_PAD_PX, rect.y + LABEL_TOP_OFFSET_PX)
1849
+ ctx.restore()
1850
+ }
1851
+
1852
+ ctx.restore()
1853
+ }
1854
+
1855
+ /** The rubber-band selection box. Drawn on the overlay layer because it changes
1856
+ * on every pointer move, exactly like the playhead. */
1857
+ export function drawMarquee(
1858
+ ctx: DrawContext,
1859
+ rect: Rect,
1860
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1861
+ ): void {
1862
+ ctx.save()
1863
+ ctx.fillStyle = palette.colors.marqueeFill
1864
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
1865
+ ctx.strokeStyle = palette.colors.marqueeBorder
1866
+ ctx.lineWidth = 1
1867
+ // Half-pixel offsets so a 1px stroke lands on a pixel instead of straddling
1868
+ // two and rendering as a 2px blur.
1869
+ ctx.strokeRect(
1870
+ Math.round(rect.x) + 0.5,
1871
+ Math.round(rect.y) + 0.5,
1872
+ Math.round(rect.width),
1873
+ Math.round(rect.height),
1874
+ )
1875
+ ctx.restore()
1876
+ }
1877
+
1878
+ export function drawTimelineContent(ctx: DrawContext, scene: TimelineScene): DrawStats {
1879
+ const { viewport, layout, selectedIds, selectedKeyframe, surfaceWidth, surfaceHeight, hoveredHandle } = scene
1880
+ // Resolved ONCE for the whole pass, not per row or per clip: this is read by
1881
+ // every painter below and a per-item lookup would repeat it hundreds of
1882
+ // times a frame for an answer that cannot change mid-pass.
1883
+ const themePalette = timelinePalette(scene.mode ?? 'dark')
1884
+ const range = visibleRange(viewport)
1885
+ const stats: DrawStats = { visualItemsDrawn: 0, audioItemsDrawn: 0, itemsCulled: 0, captionItemsDrawn: 0 }
1886
+
1887
+ ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
1888
+
1889
+ drawRuler(ctx, viewport, layout.ruler, surfaceWidth, themePalette)
1890
+
1891
+
1892
+ let rowShadeIdx = 0
1893
+ for (const row of layout.rows) {
1894
+ drawRowBackground(ctx, { x: 0, y: row.y, width: surfaceWidth, height: row.height }, rowShadeIdx++ % 2 === 0 ? themePalette.colors.rowBackground : themePalette.colors.rowBackgroundAlt, themePalette)
1895
+
1896
+ // A SKIPPED track is drawn faded: it still draws, and stays selectable and
1897
+ // editable; only playback and export leave it out.
1898
+ const dimmed = row.disabled === true
1899
+ const palette = themePalette.tracks[row.trackIdx % themePalette.tracks.length]
1900
+ // Bodies of the selected clips in this row, queued for the handle pass at
1901
+ // the bottom of the loop.
1902
+ const handleRects: Array<{ body: Rect; hoveredEdge: 'in' | 'out' | null }> = []
1903
+
1904
+ for (const item of row.items) {
1905
+ if (!intersectsRange(item.start, item.end, range)) { stats.itemsCulled++; continue }
1906
+ const x = timeToX(item.start, viewport)
1907
+ const rect = clampRectToSurface(
1908
+ { x, y: row.y, width: (item.end - item.start) * viewport.pxPerSecond, height: row.height },
1909
+ surfaceWidth,
1910
+ )
1911
+ if (rect.width <= 0) { stats.itemsCulled++; continue }
1912
+ // Both lookups size to the BODY, the rect `drawClipRect` actually paints
1913
+ // into — not the full span, which is two gutters wider.
1914
+ const body = clipBodyRect(rect)
1915
+ const clipWaveform = scene.waveforms?.clipColumns(item, body) ?? null
1916
+ const filmstripTiles = scene.filmstrips?.clipTiles(item, body) ?? null
1917
+ // Mirrors `effectiveItemAudio`'s track-or-item OR without needing a full
1918
+ // VisualTrack in scope here — `row.trackMuted` already folded in the
1919
+ // track's own `muted`, so only the item's is left to OR in.
1920
+ const itemMuted = row.trackMuted || item.muted === true
1921
+ // Composed in one hook, but painting into DISJOINT halves of the clip
1922
+ // (`clip-bands.ts`): tiles in the upper frames band, waveform in the
1923
+ // lower one. Order no longer matters for overlap — it is kept
1924
+ // frames-then-waveform so that if a band ever gains a translucent edge
1925
+ // the picture sits under the audio, not over it. Either half may be
1926
+ // absent (no proxy, no adapter, nothing fetched yet) and the other still
1927
+ // draws.
1928
+ const drawContent = (filmstripTiles || clipWaveform)
1929
+ ? (c: DrawContext, r: Rect) => {
1930
+ if (filmstripTiles) drawFilmstripTiles(c, filmstripTiles)
1931
+ if (clipWaveform) drawClipWaveform(c, r, clipWaveform, itemMuted, themePalette.waveform)
1932
+ }
1933
+ : undefined
1934
+ const itemSelected = selectedIds.includes(item.id)
1935
+ drawClipRect(ctx, {
1936
+ rect,
1937
+ palette,
1938
+ selected: itemSelected,
1939
+ label: visualItemLabel(item),
1940
+ dimmed,
1941
+ drawContent,
1942
+ }, themePalette)
1943
+ // The keyframe strip (SP9b T3.3): selected, keyframed overlays only —
1944
+ // see `drawKeyframeStrip`'s own doc for why this is gated here rather
1945
+ // than inside it. Drawn AFTER the clip's own content/label so a
1946
+ // diamond never sits under a filmstrip frame.
1947
+ if (itemSelected && canKeyframe(item) && isKeyframed(item)) {
1948
+ drawKeyframeStrip(ctx, item, body, viewport, selectedKeyframe?.itemId === item.id ? selectedKeyframe.t : null, themePalette)
1949
+ }
1950
+ if (itemSelected) {
1951
+ handleRects.push({ body, hoveredEdge: hoveredHandle?.itemId === item.id ? hoveredHandle.edge : null })
1952
+ }
1953
+ stats.visualItemsDrawn++
1954
+ }
1955
+
1956
+ // Overlaps on a visual row, drawn over the clips they span. New here: the
1957
+ // DOM timeline never marked these, and on canvas the only sign of one was
1958
+ // the clip band's dark wash landing twice — a faintly lighter box that
1959
+ // read as a rendering quirk rather than as two clips on top of each other.
1960
+ // A skipped row's band fades with the rest of it.
1961
+ ctx.save()
1962
+ if (dimmed) ctx.globalAlpha = 0.3
1963
+ for (const band of overlapBands(row.items)) {
1964
+ if (!intersectsRange(band.start, band.end, range)) continue
1965
+ drawOverlapBand(ctx, clampRectToSurface(
1966
+ { x: timeToX(band.start, viewport), y: row.y, width: (band.end - band.start) * viewport.pxPerSecond, height: row.height },
1967
+ surfaceWidth,
1968
+ ), themePalette)
1969
+ }
1970
+
1971
+ // Handles last of all, over the clips AND over any overlap band. They are
1972
+ // the control, so nothing gets to sit on top of them: a selected clip
1973
+ // whose end is buried under an overlapping neighbour still shows the edge
1974
+ // you can grab, which is what the hit-test now hands you there.
1975
+ for (const { body, hoveredEdge } of handleRects) {
1976
+ drawItemHandles(ctx, body, CLIP_HANDLE_WIDTH_PX, hoveredEdge, Math.min(CLIP_RADIUS_PX, body.width / 2, body.height / 2), themePalette)
1977
+ }
1978
+ ctx.restore()
1979
+ }
1980
+
1981
+ // The caption bands — absent entirely on a project with no caption segments
1982
+ // (see `computeTimelineLayout`), so this whole pass is a no-op for every
1983
+ // project that predates captions moving into the canvas. A hole lane still
1984
+ // gets a background-only band: nothing in its (empty) `segments` loop below
1985
+ // draws a block, but the row painted at its `y` is what keeps every OTHER
1986
+ // band from jumping position mid-drag.
1987
+ for (const caption of layout.captions ?? []) {
1988
+ drawRowBackground(ctx, { x: 0, y: caption.y, width: surfaceWidth, height: caption.height }, undefined, themePalette)
1989
+ const handleRects: Array<{ rect: Rect; hoveredEdge: 'in' | 'out' | null }> = []
1990
+
1991
+ for (const seg of caption.segments) {
1992
+ if (!intersectsRange(seg.start, seg.end, range)) { stats.itemsCulled++; continue }
1993
+ const x = timeToX(seg.start, viewport)
1994
+ const rect = clampRectToSurface(
1995
+ {
1996
+ x,
1997
+ y: caption.y + AUDIO_ITEM_INSET_PX,
1998
+ width: (seg.end - seg.start) * viewport.pxPerSecond,
1999
+ height: caption.height - AUDIO_ITEM_INSET_PX * 2,
2000
+ },
2001
+ surfaceWidth,
2002
+ )
2003
+ if (rect.width <= 0) { stats.itemsCulled++; continue }
2004
+ // A segment briefly has no `id` — before `backfillCaptionIds` mints one
2005
+ // (see VideoEditor.tsx) — so it draws, but can never be selected or
2006
+ // handled. `segId` (not `seg.id`) is what the rest of this block reads,
2007
+ // since `seg.id`'s `string | undefined` would otherwise let a bare
2008
+ // `hoveredHandle?.itemId === seg.id` go true on "both sides undefined"
2009
+ // without `hoveredHandle` itself being set.
2010
+ const segId = typeof seg.id === 'string' ? seg.id : null
2011
+ const selected = segId !== null && selectedIds.includes(segId)
2012
+ drawCaptionBlock(ctx, { rect, selected, label: seg.text }, themePalette)
2013
+ if (selected && segId !== null) {
2014
+ const hoveredEdge = hoveredHandle?.itemId === segId ? hoveredHandle.edge : null
2015
+ handleRects.push({ rect, hoveredEdge })
2016
+ }
2017
+ stats.captionItemsDrawn++
2018
+ }
2019
+
2020
+ // Handles last, over every block in the band — same "the control always
2021
+ // wins the stacking order" rule the visual rows and audio lanes follow.
2022
+ for (const { rect, hoveredEdge } of handleRects) {
2023
+ drawItemHandles(ctx, rect, AUDIO_HANDLE_WIDTH_PX, hoveredEdge, AUDIO_ITEM_RADIUS_PX, themePalette)
2024
+ }
2025
+ }
2026
+
2027
+ for (const lane of layout.lanes) {
2028
+ drawRowBackground(ctx, { x: 0, y: lane.y, width: surfaceWidth, height: lane.height }, undefined, themePalette)
2029
+ const laneHandleRects: Array<{ rect: Rect; hoveredEdge: 'in' | 'out' | null }> = []
2030
+
2031
+ for (const track of lane.tracks) {
2032
+ if (!intersectsRange(track.start, track.end, range)) { stats.itemsCulled++; continue }
2033
+ const x = timeToX(track.start, viewport)
2034
+ const rect = clampRectToSurface(
2035
+ {
2036
+ x,
2037
+ y: lane.y + AUDIO_ITEM_INSET_PX,
2038
+ width: (track.end - track.start) * viewport.pxPerSecond,
2039
+ height: lane.height - AUDIO_ITEM_INSET_PX * 2,
2040
+ },
2041
+ surfaceWidth,
2042
+ )
2043
+ if (rect.width <= 0) { stats.itemsCulled++; continue }
2044
+ // Horizontal gutter, same as a video clip's `clipBodyRect` — two
2045
+ // touching audio bars therefore show the same uniform CLIP_GUTTER_PX
2046
+ // gap video clips do, rather than a seam that depends on where exactly
2047
+ // the bars' edges land.
2048
+ const body = clipBodyRect(rect)
2049
+ // The clip's TRUE body span, before surface-clamping, so the fade
2050
+ // envelope/grips anchor to the clip and scroll with it (see
2051
+ // `fadeSpanX`/`fadeSpanWidth`). `rect`/`body` above are already clamped
2052
+ // to the visible surface for the bar fill and waveform.
2053
+ const fullBody = clipBodyRect({ x, y: body.y, width: (track.end - track.start) * viewport.pxPerSecond, height: body.height })
2054
+ const audioWaveform = scene.waveforms?.audioColumns(track, body) ?? null
2055
+ const fadeInPx = (track.fadeIn ?? 0) * viewport.pxPerSecond
2056
+ const fadeOutPx = (track.fadeOut ?? 0) * viewport.pxPerSecond
2057
+ const fadeInCurve = track.fadeInCurve ?? DEFAULT_FADE_CURVE
2058
+ const fadeOutCurve = track.fadeOutCurve ?? DEFAULT_FADE_CURVE
2059
+ // One gain-at-x lookup per bar, built against its TRUE span (mirrors
2060
+ // fadeSpanX/fadeSpanWidth below) so the waveform's amplitude scaling
2061
+ // agrees with the envelope drawn over it — same anchor, same curves.
2062
+ const gainAt = makeFadeGainAt(fullBody.x, fullBody.width, fadeInPx, fadeOutPx, fadeInCurve, fadeOutCurve)
2063
+ drawAudioItem(ctx, {
2064
+ rect: body,
2065
+ selected: selectedIds.includes(track.id),
2066
+ muted: !!track.muted,
2067
+ label: audioLabel(track),
2068
+ fadeInPx,
2069
+ fadeOutPx,
2070
+ fadeInCurve,
2071
+ fadeOutCurve,
2072
+ fadeSpanX: fullBody.x,
2073
+ fadeSpanWidth: fullBody.width,
2074
+ drawContent: audioWaveform ? (c) => drawAudioLaneWaveform(c, audioWaveform.rect, audioWaveform.columns, gainAt, themePalette.waveform) : undefined,
2075
+ }, themePalette)
2076
+ if (selectedIds.includes(track.id)) {
2077
+ laneHandleRects.push({ rect: body, hoveredEdge: hoveredHandle?.itemId === track.id ? hoveredHandle.edge : null })
2078
+ }
2079
+ stats.audioItemsDrawn++
2080
+ }
2081
+
2082
+ // Overlap bands between consecutive unmuted bars in the lane. A muted bar
2083
+ // is not crossfading with anything, so it is left out of the pairing.
2084
+ for (const band of overlapBands(lane.tracks.filter(t => !t.muted))) {
2085
+ if (!intersectsRange(band.start, band.end, range)) continue
2086
+ drawOverlapBand(ctx, clampRectToSurface(
2087
+ { x: timeToX(band.start, viewport), y: lane.y, width: (band.end - band.start) * viewport.pxPerSecond, height: lane.height },
2088
+ surfaceWidth,
2089
+ ), themePalette)
2090
+ }
2091
+
2092
+ // As the visual rows: last, over everything. Crossfaded bars overlap by
2093
+ // design, so on a lane this is the normal case rather than the exception.
2094
+ for (const { rect, hoveredEdge } of laneHandleRects) {
2095
+ drawItemHandles(ctx, rect, AUDIO_HANDLE_WIDTH_PX, hoveredEdge, AUDIO_ITEM_RADIUS_PX, themePalette)
2096
+ }
2097
+ }
2098
+
2099
+ return stats
2100
+ }
2101
+
2102
+ export interface OverlayScene {
2103
+ viewport: Viewport
2104
+ currentTime: number
2105
+ surfaceWidth: number
2106
+ surfaceHeight: number
2107
+ /** Where the preview-axis cursor sits, or null/absent when the axis is off
2108
+ * or the pointer is elsewhere. Independent of `currentTime`: while the axis
2109
+ * is on the preview follows THIS while the playhead stays put, which is the
2110
+ * entire point of the toggle. */
2111
+ cursorTime?: number | null
2112
+ /** The boundary a running gesture is snapped to, or null when it is running
2113
+ * free (or no gesture is running). Emitted by the pointer machine, which is
2114
+ * the only thing that knows which edge of a dragged span actually caught. */
2115
+ snapTime?: number | null
2116
+ /** Which tier is holding it — a same-track magnet draws boldly, a
2117
+ * cross-track one as a hairline. Defaults to strong. */
2118
+ snapStrength?: SnapStrength | null
2119
+ /** The rubber-band selection box while one is being dragged, else null. */
2120
+ marquee?: Rect | null
2121
+ /** Ghost bands for files the host is still importing (`PendingDrop` in
2122
+ * types.ts, resolved to rectangles by the canvas). On the OVERLAY layer,
2123
+ * not the content one, for the same reason the marquee is: they come and go
2124
+ * on host events unrelated to any project edit, and a ghost must be able to
2125
+ * appear and be retracted without repainting every clip and filmstrip
2126
+ * underneath it. Absent or empty → nothing is drawn, and this layer's paint
2127
+ * is byte-identical to what it was before ghosts existed. */
2128
+ pendingDrops?: readonly PendingDropBand[]
2129
+ /** Which ground to paint on — see `TimelineScene.mode`. Carried on BOTH
2130
+ * scenes rather than only the content one: the playhead, the axis cursor
2131
+ * and the marquee all live on this layer, and a mode change has to be able
2132
+ * to repaint them without waiting for a content edit. */
2133
+ mode?: TimelineMode
2134
+ }
2135
+
2136
+ /** Paint the playhead layer. Kept separate from the content so playback — which
2137
+ * moves the playhead ~60 times a second — repaints two `fillRect`s, not the
2138
+ * whole timeline. The preview-axis cursor rides here for the same reason:
2139
+ * tracking the pointer must not force a content repaint. */
2140
+ export function drawTimelineOverlay(ctx: DrawContext, scene: OverlayScene): void {
2141
+ const { viewport, currentTime, surfaceWidth, surfaceHeight, cursorTime, snapTime, snapStrength } = scene
2142
+ // Once per pass, same rule as `drawTimelineContent` — this layer repaints at
2143
+ // ~60Hz during playback, so it is the one that least wants a lookup per mark.
2144
+ const themePalette = timelinePalette(scene.mode ?? 'dark')
2145
+ ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
2146
+ // First, so the playhead and any guide stay legible over it — the marquee is
2147
+ // a translucent wash and would otherwise dull both.
2148
+ if (scene.marquee) drawMarquee(ctx, scene.marquee, themePalette)
2149
+ // After the marquee (a ghost is a solid-ish band; under the wash it would be
2150
+ // dulled the same way the lines are), and before every LINE below it. The
2151
+ // playhead in particular has to stay legible over a ghost: an import that
2152
+ // happens to land under the playhead must not hide where playback is, and a
2153
+ // band is far wider than any of these marks, so it can only ever go beneath
2154
+ // them. Each band culls itself the way the playhead and cursor do — the
2155
+ // painter converts to x and returns on a rect that clamps away to nothing.
2156
+ if (scene.pendingDrops) {
2157
+ for (const band of scene.pendingDrops) {
2158
+ drawPendingDropBand(ctx, band, viewport, surfaceWidth, themePalette)
2159
+ }
2160
+ }
2161
+ if (cursorTime !== undefined && cursorTime !== null) {
2162
+ const cx = timeToX(cursorTime, viewport)
2163
+ if (!(cx < -CURSOR_WIDTH_PX || cx > surfaceWidth + CURSOR_WIDTH_PX)) {
2164
+ drawCursorLine(ctx, cx, 0, surfaceHeight, themePalette)
2165
+ }
2166
+ }
2167
+ const x = timeToX(currentTime, viewport)
2168
+ if (!(x < -PLAYHEAD_WIDTH_PX || x > surfaceWidth + PLAYHEAD_WIDTH_PX)) {
2169
+ drawPlayhead(ctx, x, 0, surfaceHeight, themePalette)
2170
+ }
2171
+ // Last, so it wins over both lines. A gesture snapped to the playhead should
2172
+ // look snapped, not hidden behind the thing it snapped to.
2173
+ if (snapTime !== undefined && snapTime !== null) {
2174
+ const sx = timeToX(snapTime, viewport)
2175
+ if (!(sx < -SNAP_GUIDE_CAP_HALF_WIDTH_PX || sx > surfaceWidth + SNAP_GUIDE_CAP_HALF_WIDTH_PX)) {
2176
+ drawSnapGuide(ctx, sx, 0, surfaceHeight, snapStrength ?? 'strong', themePalette)
2177
+ }
2178
+ }
2179
+ }
2180
+
2181
+ /** AudioTrackRow's label rule: the type name, else the track label, else the
2182
+ * source filename. */
2183
+ export function audioLabel(track: AudioTrack): string {
2184
+ if (track.type === 'voiceover') return 'Voiceover'
2185
+ if (track.type === 'music') return 'Music'
2186
+ return track.label ?? track.src.split('/').pop() ?? 'audio'
2187
+ }