@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Canvas timeline snapping (SP5 T5) — ONE magnetic model for every gesture.
3
+ *
4
+ * The DOM timeline snapped in three different ways: the Scrubber's playhead
5
+ * drag used attract/release hysteresis (18px in, 28px out), while clip drags
6
+ * and edge trims used a flat 8px "nearest boundary wins" test with no memory.
7
+ * The flat version has the classic problem — right at the threshold the value
8
+ * flickers between snapped and free as the hand jitters — and the two radii
9
+ * disagreed about how strong a magnet feels. Plan decision 7 keeps the good one
10
+ * and retires the others. (It once applied to the canvas surface only, while
11
+ * the DOM rows kept the flat test; those rows are gone, so this is simply how
12
+ * snapping works.)
13
+ *
14
+ * The hysteresis is what makes it feel deliberate: a point attracts from one
15
+ * radius but holds until the cursor clears a wider one. The asymmetry is the
16
+ * whole trick — with one radius, "just past the edge of the magnet" is an
17
+ * unstable place to stand. See `DEFAULT_SNAP_CONFIG` for the two numbers and
18
+ * why they are as far apart as they are.
19
+ *
20
+ * Pixels, not seconds, are the unit of feel: a magnet must cover the same
21
+ * distance on screen whether the timeline shows ten seconds or ten minutes, so
22
+ * the radii convert through `pxPerSecond` on every call.
23
+ *
24
+ * Points come in two strengths. A boundary on the gesture's own track pulls
25
+ * hard; a boundary on some other track barely pulls at all, and loses to a
26
+ * same-track point outright when both are in range. Without that split, one
27
+ * busy overlay row is enough to make the base video track undraggable — see
28
+ * `SnapStrength`.
29
+ *
30
+ * Pure and stateless-by-value: `applySnap` takes a state and returns the next
31
+ * one, so a gesture threads it and a test drives a whole approach/release
32
+ * sequence without a component.
33
+ */
34
+
35
+ import type { Viewport } from './viewport'
36
+
37
+ /**
38
+ * How hard a given snap point pulls.
39
+ *
40
+ * `strong` is a boundary on the gesture's OWN track — the next clip along, the
41
+ * cut you are butting up against. That is the alignment an editor actually
42
+ * means, and it gets the full magnet.
43
+ *
44
+ * `weak` is everything on some other track: an overlay two rows up, an audio
45
+ * bar below. Those alignments are occasionally useful and constantly in the
46
+ * way — a busy overlay track puts a boundary every few pixels, and at full
47
+ * strength they turn a simple drag into a fight. They keep a radius small
48
+ * enough to catch a deliberate aim and too small to catch a passing one.
49
+ */
50
+ export type SnapStrength = 'strong' | 'weak'
51
+
52
+ export interface SnapPoint {
53
+ time: number
54
+ strength: SnapStrength
55
+ }
56
+
57
+ export interface SnapConfig {
58
+ /** Distance at which an unsnapped gesture is captured by a strong point. */
59
+ attractPx: number
60
+ /** Distance a captured strong point must travel to break free. Must exceed
61
+ * `attractPx`, or the magnet has no hysteresis at all. */
62
+ releasePx: number
63
+ /** The same pair for weak points. */
64
+ weakAttractPx: number
65
+ weakReleasePx: number
66
+ }
67
+
68
+ /**
69
+ * The magnet's strength.
70
+ *
71
+ * These started as Scrubber.tsx's numbers (18/28) and were widened after using
72
+ * the canvas timeline in anger. Two separate complaints, one fix each:
73
+ *
74
+ * - `attractPx` 18 → 20. A cut you are aiming at should catch you slightly
75
+ * before you land on it, so the common case (butt this clip against that
76
+ * one) needs no precision at all.
77
+ * - `releasePx` 28 → 44. This is the one that changes how the timeline FEELS.
78
+ * A 28px release meant a snapped gesture came free about as easily as it was
79
+ * caught, so dragging PAST a boundary — a deliberate act — was
80
+ * indistinguishable from drifting off one. At 44px breaking out is a
81
+ * decision: the guide holds, you push through it, it lets go. The ratio
82
+ * (2.2× attract) is what reads as a detent rather than as friction.
83
+ *
84
+ * Widening the release radius is only tolerable because the snap is now VISIBLE
85
+ * — `drawSnapGuide` puts a line on the boundary you are held to. A strong
86
+ * magnet with no indicator is just a timeline that refuses to go where you put
87
+ * it; with the guide, the same magnet reads as help.
88
+ */
89
+ export const DEFAULT_SNAP_CONFIG: SnapConfig = {
90
+ attractPx: 20,
91
+ releasePx: 44,
92
+ // A third of the strong radius, and barely wider than it is deep. A
93
+ // cross-track boundary should be catchable when you aim at it and
94
+ // effectively invisible when you don't — you can drag straight through one
95
+ // without noticing, which is the point.
96
+ weakAttractPx: 6,
97
+ weakReleasePx: 10,
98
+ }
99
+
100
+ export interface SnapState {
101
+ /** The point currently held, or null when the gesture is running free. */
102
+ readonly snappedTo: number | null
103
+ /** How hard the held point pulls — it decides which release radius applies,
104
+ * so a weak capture stays as easy to leave as it was to enter. */
105
+ readonly strength: SnapStrength | null
106
+ }
107
+
108
+ const FREE: SnapState = { snappedTo: null, strength: null }
109
+
110
+ export function createSnapState(): SnapState {
111
+ return FREE
112
+ }
113
+
114
+ export interface SnapResult {
115
+ /** The value to use: the snap point when caught, the candidate otherwise. */
116
+ time: number
117
+ snappedTo: number | null
118
+ /** Which tier caught it, so the caller can draw a guide that matches how
119
+ * hard it is holding. Null whenever `snappedTo` is. */
120
+ strength: SnapStrength | null
121
+ /** Thread this into the next call of the same gesture. */
122
+ state: SnapState
123
+ }
124
+
125
+ /**
126
+ * Magnetize `candidateT` to the nearest snap point, with hysteresis.
127
+ *
128
+ * Tie-break is **nearest wins**, ties going to the earlier entry — deliberately
129
+ * unlike the Scrubber, which took the first point in array order that happened
130
+ * to be within range and so gave different answers for different orderings of
131
+ * the same boundary set. Nearest is what the flat implementations already did
132
+ * and what a user means by "it snapped to the cut".
133
+ *
134
+ * Snapping is off entirely when the viewport has no scale yet (before first
135
+ * layout), since pixel radii are meaningless without one.
136
+ */
137
+ export function applySnap(
138
+ candidateT: number,
139
+ snapPoints: readonly SnapPoint[],
140
+ viewport: Viewport,
141
+ state: SnapState = FREE,
142
+ config: SnapConfig = DEFAULT_SNAP_CONFIG,
143
+ ): SnapResult {
144
+ const pxPerSecond = viewport.pxPerSecond
145
+ if (!(pxPerSecond > 0) || snapPoints.length === 0) {
146
+ return { time: candidateT, snappedTo: null, strength: null, state: FREE }
147
+ }
148
+
149
+ const attract = { strong: config.attractPx / pxPerSecond, weak: config.weakAttractPx / pxPerSecond }
150
+ const release = { strong: config.releasePx / pxPerSecond, weak: config.weakReleasePx / pxPerSecond }
151
+
152
+ // Held: stay put until the cursor escapes the (wider) release radius of the
153
+ // tier that caught it. A weak capture is let go of on weak terms.
154
+ if (state.snappedTo !== null && state.strength !== null) {
155
+ if (Math.abs(candidateT - state.snappedTo) < release[state.strength]) {
156
+ return { time: state.snappedTo, snappedTo: state.snappedTo, strength: state.strength, state }
157
+ }
158
+ // Broken free. Fall through to a fresh scan — the point just released is
159
+ // by definition beyond its own `release` > `attract`, so it cannot
160
+ // re-capture at the same tier.
161
+ }
162
+
163
+ // Nearest wins WITHIN a tier; a strong point beats a weak one outright, even
164
+ // a nearer weak one. That precedence is the whole feature: cross-track
165
+ // boundaries must never steal a gesture away from the cut it is aiming at on
166
+ // its own track, and with plain nearest-wins they routinely would.
167
+ let bestStrong: number | null = null
168
+ let bestStrongDist = attract.strong
169
+ let bestWeak: number | null = null
170
+ let bestWeakDist = attract.weak
171
+
172
+ for (const point of snapPoints) {
173
+ const dist = Math.abs(candidateT - point.time)
174
+ if (point.strength === 'strong') {
175
+ if (dist < bestStrongDist) { bestStrongDist = dist; bestStrong = point.time }
176
+ } else if (dist < bestWeakDist) {
177
+ bestWeakDist = dist
178
+ bestWeak = point.time
179
+ }
180
+ }
181
+
182
+ const best = bestStrong ?? bestWeak
183
+ if (best === null) return { time: candidateT, snappedTo: null, strength: null, state: FREE }
184
+ const strength: SnapStrength = bestStrong !== null ? 'strong' : 'weak'
185
+ return { time: best, snappedTo: best, strength, state: { snappedTo: best, strength } }
186
+ }
187
+
188
+ /**
189
+ * Snap points for dragging a whole item of length `duration`, expressed as
190
+ * candidate START positions: a point can catch the item's leading edge (start
191
+ * lands on it) or its trailing edge (start lands one duration earlier). Feeding
192
+ * these to `applySnap` with the raw start makes both edges magnetic through the
193
+ * same code path — and, because the state is a start position rather than an
194
+ * "edge + point" pair, hysteresis works across the pair for free.
195
+ */
196
+ export function snapPointsForSpan(points: readonly SnapPoint[], duration: number): SnapPoint[] {
197
+ const out: SnapPoint[] = []
198
+ for (const p of points) out.push(p)
199
+ for (const p of points) out.push({ time: p.time - duration, strength: p.strength })
200
+ return out
201
+ }
202
+
203
+ const EPSILON = 1e-6
204
+
205
+ /**
206
+ * Drop points that coincide with values the gesture is itself moving, and
207
+ * de-duplicate the rest.
208
+ *
209
+ * Every item's own start and end are in the boundary set, so without this an
210
+ * edge trim (or a clip drag) would be captured by the position it started from
211
+ * and refuse to move until the cursor cleared the 28px release radius. The DOM
212
+ * paths lived with it because an 8px flat magnet is easy to pull out of; at
213
+ * these radii it reads as a stuck gesture.
214
+ */
215
+ export function snapPointsExcluding(points: readonly SnapPoint[], exclude: readonly number[]): SnapPoint[] {
216
+ const out: SnapPoint[] = []
217
+ for (const p of points) {
218
+ if (!Number.isFinite(p.time)) continue
219
+ if (exclude.some(e => Math.abs(p.time - e) <= EPSILON)) continue
220
+ const dupeAt = out.findIndex(kept => Math.abs(kept.time - p.time) <= EPSILON)
221
+ if (dupeAt >= 0) {
222
+ // One time, two tiers: a cut on this track that happens to line up with
223
+ // one two rows up is a STRONG point, not a weak one. Upgrading on
224
+ // collision keeps the strong tier complete no matter what order the
225
+ // rows were walked in.
226
+ if (p.strength === 'strong') out[dupeAt] = p
227
+ continue
228
+ }
229
+ out.push(p)
230
+ }
231
+ return out
232
+ }
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Canvas timeline viewport (SP5 T4) — the scroll/zoom model the canvas surface
3
+ * draws through, plus the DPR-crisp rendering plumbing.
4
+ *
5
+ * ── Why a new model ──────────────────────────────────────────────────────
6
+ * The DOM timeline zooms by widening a container to `zoom × 100%` and letting
7
+ * the browser scroll it, so its zoom is "multiples of container width" against
8
+ * a content-dependent `totalDuration`. That model can't zoom out past fit, and
9
+ * its px/second silently changes whenever a clip moves (totalDuration is
10
+ * derived from content). The canvas uses the model every NLE uses instead:
11
+ *
12
+ * pxPerSecond how many CSS pixels one second occupies
13
+ * scrollSeconds the time at the left edge of the surface
14
+ *
15
+ * Time↔pixel conversion is then a pure affine map, independent of content.
16
+ * The zoom badge reports a fit-relative multiple (`zoomMultiple`) so the
17
+ * numbers users knew from the old DOM path's zoom stay recognizable.
18
+ *
19
+ * Everything above `createViewportStore` is pure math with no DOM access, so
20
+ * it is unit-testable and reusable by hit-testing (T5) and the content layers
21
+ * (T6/T7).
22
+ */
23
+
24
+ import { useRef, useSyncExternalStore } from 'react'
25
+
26
+ export interface Viewport {
27
+ /** CSS pixels per second of timeline. */
28
+ pxPerSecond: number
29
+ /** Time at x=0 of the drawing surface, in seconds. */
30
+ scrollSeconds: number
31
+ /** Width of the drawing surface in CSS pixels (0 before first layout). */
32
+ widthPx: number
33
+ }
34
+
35
+ /** Absolute floor on zoom-out. One pixel per second is already an hour of
36
+ * timeline across a 3600px surface — further out is not a useful view. */
37
+ export const MIN_PX_PER_SECOND = 1
38
+
39
+ /** Absolute ceiling on zoom-in: ~66px per frame at 30fps, which is as far as
40
+ * any frame-accurate trim needs. */
41
+ export const MAX_PX_PER_SECOND = 2000
42
+
43
+ /** How far below "fit the whole project" zoom-out is allowed to go, as a
44
+ * fraction of the fit scale. The DOM model bottomed out AT fit; pulling back
45
+ * from the content is a deliberate improvement (it gives drop room past the
46
+ * last clip) — but only so far. At `0.5` the content bottoms out filling HALF
47
+ * the surface width (one content-length of drop room past the end), instead of
48
+ * shrinking to a sliver: CapCut caps its max zoom-out the same way, and a
49
+ * quarter-width timeline (the old `0.25`) is more empty canvas than anyone
50
+ * needs to reach the end. `MIN_PX_PER_SECOND` still floors this for a project
51
+ * long enough that half-width would fall below it. */
52
+ export const MIN_FIT_MULTIPLE = 0.5
53
+
54
+ /** Extra scroll past the end of the content, as a fraction of one screenful,
55
+ * so the rightmost item can always be dragged/trimmed further out. */
56
+ export const OVERSCROLL_FRACTION = 0.25
57
+
58
+ /** Multiplicative step for the chrome's +/− zoom buttons. */
59
+ export const ZOOM_BUTTON_FACTOR = 1.5
60
+
61
+ // ── Pure math ────────────────────────────────────────────────────────────
62
+
63
+ /** px/second at which `totalDuration` exactly fills `widthPx`. 0 when either
64
+ * is degenerate (no surface yet, or an empty project). */
65
+ export function fitPxPerSecond(widthPx: number, totalDuration: number): number {
66
+ if (widthPx <= 0 || totalDuration <= 0) return 0
67
+ return widthPx / totalDuration
68
+ }
69
+
70
+ export function clampPxPerSecond(pxPerSecond: number, widthPx: number, totalDuration: number): number {
71
+ const fit = fitPxPerSecond(widthPx, totalDuration)
72
+ const lower = Math.min(
73
+ fit > 0 ? Math.max(MIN_PX_PER_SECOND, fit * MIN_FIT_MULTIPLE) : MIN_PX_PER_SECOND,
74
+ MAX_PX_PER_SECOND,
75
+ )
76
+ if (!Number.isFinite(pxPerSecond)) return lower
77
+ return Math.min(MAX_PX_PER_SECOND, Math.max(lower, pxPerSecond))
78
+ }
79
+
80
+ /** Seconds visible across the surface at the current scale. */
81
+ export function visibleDuration(vp: Viewport): number {
82
+ return vp.pxPerSecond > 0 ? vp.widthPx / vp.pxPerSecond : 0
83
+ }
84
+
85
+ export function visibleRange(vp: Viewport): { start: number; end: number } {
86
+ return { start: vp.scrollSeconds, end: vp.scrollSeconds + visibleDuration(vp) }
87
+ }
88
+
89
+ /** Surface x (CSS px) for a timeline time. */
90
+ export function timeToX(t: number, vp: Viewport): number {
91
+ return (t - vp.scrollSeconds) * vp.pxPerSecond
92
+ }
93
+
94
+ /** Timeline time for a surface x (CSS px, relative to the surface's left). */
95
+ export function xToTime(x: number, vp: Viewport): number {
96
+ return vp.pxPerSecond > 0 ? vp.scrollSeconds + x / vp.pxPerSecond : vp.scrollSeconds
97
+ }
98
+
99
+ /** Rightmost legal `scrollSeconds`: the end of the content, less a screenful,
100
+ * plus overscroll headroom. Zero whenever the content already fits. */
101
+ export function maxScrollSeconds(vp: Viewport, totalDuration: number): number {
102
+ const visible = visibleDuration(vp)
103
+ return Math.max(0, totalDuration - visible + visible * OVERSCROLL_FRACTION)
104
+ }
105
+
106
+ /** Clamp to [0, maxScroll]. Scrolling left of t=0 is never allowed — blank
107
+ * space before the start of the timeline reads as a bug, not headroom. */
108
+ export function clampScrollSeconds(scrollSeconds: number, vp: Viewport, totalDuration: number): number {
109
+ if (!Number.isFinite(scrollSeconds)) return 0
110
+ return Math.max(0, Math.min(maxScrollSeconds(vp, totalDuration), scrollSeconds))
111
+ }
112
+
113
+ /** How close to the surface's left/right edge a drag's pointer must sit before
114
+ * edge auto-scroll starts panning the view to follow it (CSS px). This is
115
+ * only the TRIGGER band — how far the pointer must intrude before panning
116
+ * starts at all. It says nothing about how fast that panning ramps up; see
117
+ * `EDGE_SCROLL_RAMP_PX` for that. */
118
+ export const EDGE_SCROLL_ZONE_PX = 40
119
+
120
+ /** Panning speed once the ramp is fully spent (`EDGE_SCROLL_RAMP_PX` past the
121
+ * zone's outer boundary), in content CSS px/second — roughly a screen-width
122
+ * per second, fast enough to cross a long timeline without feeling like a
123
+ * jump. Reachable at all only because the ramp now keeps climbing past the
124
+ * surface edge itself (see `edgeScrollDelta`); a pointer merely parked at the
125
+ * zone's threshold stays well under this. */
126
+ export const EDGE_SCROLL_MAX_PX_PER_SEC = 1400
127
+
128
+ /** Total depth, from the edge zone's outer boundary outward, over which
129
+ * `edgeScrollDelta`'s speed ramps from 0 up to `EDGE_SCROLL_MAX_PX_PER_SEC`.
130
+ * Bigger than `EDGE_SCROLL_ZONE_PX` on purpose: a drag is driven by
131
+ * document-level listeners, so shoving the cursor to the surface edge is
132
+ * effortless and instantaneous, and a ramp that maxed out AT the edge would
133
+ * make every such shove feel identical — one flat speed, no matter how hard
134
+ * the user pushed. Letting the ramp keep climbing for the 30px beyond the
135
+ * zone (`70 - 40`) means the felt speed keeps responding to how far past the
136
+ * edge the pointer travels, which is the only room document-level tracking
137
+ * gives a user to express "faster." */
138
+ export const EDGE_SCROLL_RAMP_PX = 70
139
+
140
+ /**
141
+ * How many seconds to pan the viewport for one animation frame of an active
142
+ * drag whose pointer sits at `pointerX` (CSS px from the surface's left edge —
143
+ * the same `Point.x` a gesture already works in). Zero outside the
144
+ * `edgeZonePx` band at either edge; from the zone's outer boundary, speed
145
+ * ramps up over `rampPx` of further depth via `(depth / rampPx) ^ 1.5` — an
146
+ * ease-in curve, not linear — reaching `maxPxPerSec` once the pointer has
147
+ * travelled the full ramp distance past that boundary, and holding there for
148
+ * anything beyond (a drag driven by document-level listeners, not native
149
+ * pointer capture, can report an x arbitrarily outside [0, canvasWidth] once
150
+ * the real cursor leaves the surface, so depth must clamp rather than keep
151
+ * growing forever). The exponent keeps the curve gentle right at the trigger
152
+ * boundary — where a barely-crossed pointer would otherwise feel like it
153
+ * suddenly grabbed the view — while still reaching a punchy speed by the time
154
+ * the user has shoved the cursor visibly past the edge.
155
+ *
156
+ * Negative = pan left/earlier, positive = pan right/later, 0 = the pointer is
157
+ * not near either edge. Framerate-independent: the ramped speed is scaled by
158
+ * `dtSeconds` and converted from content px/second to timeline seconds via
159
+ * `pxPerSecond`, so callers can feed it a real frame delta and get a
160
+ * consistent pan regardless of the display's refresh rate.
161
+ */
162
+ export function edgeScrollDelta(
163
+ pointerX: number,
164
+ canvasWidth: number,
165
+ pxPerSecond: number,
166
+ dtSeconds: number,
167
+ edgeZonePx: number,
168
+ maxPxPerSec: number,
169
+ rampPx: number,
170
+ ): number {
171
+ if (canvasWidth <= 0 || pxPerSecond <= 0 || edgeZonePx <= 0 || dtSeconds <= 0 || rampPx <= 0) return 0
172
+
173
+ const leftBoundary = edgeZonePx
174
+ const rightBoundary = canvasWidth - edgeZonePx
175
+
176
+ let depthPx: number
177
+ let direction: -1 | 1
178
+ if (pointerX < leftBoundary) {
179
+ depthPx = leftBoundary - pointerX
180
+ direction = -1
181
+ } else if (pointerX > rightBoundary) {
182
+ depthPx = pointerX - rightBoundary
183
+ direction = 1
184
+ } else {
185
+ return 0
186
+ }
187
+
188
+ const ratio = Math.min(1, depthPx / rampPx)
189
+ const contentPxPerSecond = maxPxPerSec * Math.pow(ratio, 1.5)
190
+ return (direction * contentPxPerSecond * dtSeconds) / pxPerSecond
191
+ }
192
+
193
+ /**
194
+ * Zoom by `factor`, keeping the time under `pivotX` pinned to `pivotX`. Same
195
+ * semantics as the DOM path's `useTimelineZoom.zoomTo` (which pins a pivot
196
+ * percentage through a pending scrollLeft write) expressed directly in the
197
+ * px/second model: solve `pivotTime = scroll' + pivotX / pps'` for `scroll'`.
198
+ *
199
+ * The pivot only drifts when the new scroll would leave the legal range —
200
+ * i.e. at the ends of the timeline, exactly where the DOM path drifts too.
201
+ */
202
+ export function zoomAtPivot(vp: Viewport, factor: number, pivotX: number, totalDuration: number): Viewport {
203
+ const pivotTime = xToTime(pivotX, vp)
204
+ const pxPerSecond = clampPxPerSecond(vp.pxPerSecond * factor, vp.widthPx, totalDuration)
205
+ const zoomed: Viewport = { ...vp, pxPerSecond }
206
+ return { ...zoomed, scrollSeconds: clampScrollSeconds(pivotTime - pivotX / pxPerSecond, zoomed, totalDuration) }
207
+ }
208
+
209
+ /** Horizontal pan by a pixel delta (positive = content moves left / later). */
210
+ export function panByPixels(vp: Viewport, deltaPx: number, totalDuration: number): Viewport {
211
+ if (vp.pxPerSecond <= 0) return vp
212
+ return { ...vp, scrollSeconds: clampScrollSeconds(vp.scrollSeconds + deltaPx / vp.pxPerSecond, vp, totalDuration) }
213
+ }
214
+
215
+ /** Scale so the whole project fills the surface, scrolled to the start. */
216
+ export function fitViewport(vp: Viewport, totalDuration: number): Viewport {
217
+ return {
218
+ ...vp,
219
+ pxPerSecond: clampPxPerSecond(fitPxPerSecond(vp.widthPx, totalDuration), vp.widthPx, totalDuration),
220
+ scrollSeconds: 0,
221
+ }
222
+ }
223
+
224
+ /** The zoom number the chrome shows: 1× is fit, 2× is twice as close in. Keeps
225
+ * continuity with the DOM path's badge, whose 1× also meant fit. */
226
+ export function zoomMultiple(vp: Viewport, totalDuration: number): number {
227
+ const fit = fitPxPerSecond(vp.widthPx, totalDuration)
228
+ return fit > 0 ? vp.pxPerSecond / fit : 1
229
+ }
230
+
231
+ export function formatZoomMultiple(multiple: number): string {
232
+ if (!Number.isFinite(multiple) || multiple <= 0) return '1'
233
+ if (multiple >= 10) return String(Math.round(multiple))
234
+ return multiple.toFixed(1)
235
+ }
236
+
237
+ /** Re-derive the viewport for a new surface width. Scale is preserved (a wider
238
+ * window shows more time, it does not rescale the content) except on the very
239
+ * first layout, where there is no scale yet and we start fitted. */
240
+ export function withSurfaceWidth(vp: Viewport, widthPx: number, totalDuration: number): Viewport {
241
+ if (widthPx <= 0) return vp.widthPx === widthPx ? vp : { ...vp, widthPx }
242
+ const sized: Viewport = { ...vp, widthPx }
243
+ if (vp.pxPerSecond <= 0) return fitViewport(sized, totalDuration)
244
+ const rescaled: Viewport = { ...sized, pxPerSecond: clampPxPerSecond(sized.pxPerSecond, widthPx, totalDuration) }
245
+ return { ...rescaled, scrollSeconds: clampScrollSeconds(rescaled.scrollSeconds, rescaled, totalDuration) }
246
+ }
247
+
248
+ /** Re-clamp after the project's duration changed (clips added/moved/deleted). */
249
+ export function reclampForDuration(vp: Viewport, totalDuration: number): Viewport {
250
+ if (vp.widthPx <= 0) return vp
251
+ if (vp.pxPerSecond <= 0) return fitViewport(vp, totalDuration)
252
+ const rescaled: Viewport = { ...vp, pxPerSecond: clampPxPerSecond(vp.pxPerSecond, vp.widthPx, totalDuration) }
253
+ return { ...rescaled, scrollSeconds: clampScrollSeconds(rescaled.scrollSeconds, rescaled, totalDuration) }
254
+ }
255
+
256
+ export function viewportsEqual(a: Viewport, b: Viewport): boolean {
257
+ return a.pxPerSecond === b.pxPerSecond && a.scrollSeconds === b.scrollSeconds && a.widthPx === b.widthPx
258
+ }
259
+
260
+ // ── Wheel semantics (ported from useTimelineZoom) ────────────────────────
261
+
262
+ export type WheelIntent =
263
+ | { kind: 'zoom'; factor: number; pivotX: number }
264
+ | { kind: 'pan'; deltaPx: number }
265
+ | { kind: 'none' }
266
+
267
+ export interface WheelLike {
268
+ deltaX: number
269
+ deltaY: number
270
+ ctrlKey: boolean
271
+ metaKey: boolean
272
+ altKey: boolean
273
+ }
274
+
275
+ /**
276
+ * What a wheel event means to the canvas timeline. Ported from the DOM path:
277
+ *
278
+ * ⌘/Ctrl + wheel zoom, multiplicatively, pivoted at the cursor. The
279
+ * `exp(-deltaY * 0.002)` step is the DOM path's verbatim, so
280
+ * a wheel tick (deltaY ≈ 100) is the same ≈18% either side.
281
+ * Alt + wheel horizontal pan by deltaY pixels.
282
+ * plain wheel NOT intercepted — the page/container must keep scrolling
283
+ * vertically. The one exception is a horizontally-dominant
284
+ * plain wheel (trackpad two-finger swipe), which the DOM
285
+ * path's `overflow-x: auto` container consumed for free and
286
+ * the canvas must consume explicitly or lose the gesture.
287
+ */
288
+ export function wheelIntent(e: WheelLike, pivotX: number): WheelIntent {
289
+ if (e.ctrlKey || e.metaKey) return { kind: 'zoom', factor: Math.exp(-e.deltaY * 0.002), pivotX }
290
+ if (e.altKey) return { kind: 'pan', deltaPx: e.deltaY }
291
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return { kind: 'pan', deltaPx: e.deltaX }
292
+ return { kind: 'none' }
293
+ }
294
+
295
+ export function applyWheelIntent(vp: Viewport, intent: WheelIntent, totalDuration: number): Viewport {
296
+ switch (intent.kind) {
297
+ case 'zoom': return zoomAtPivot(vp, intent.factor, intent.pivotX, totalDuration)
298
+ case 'pan': return panByPixels(vp, intent.deltaPx, totalDuration)
299
+ default: return vp
300
+ }
301
+ }
302
+
303
+ // ── DPR-crisp surface plumbing ───────────────────────────────────────────
304
+ // No precedent existed in this repo for a resolution-independent 2D canvas
305
+ // (the engine's painter draws into a fixed-size video canvas), so this is the
306
+ // one place that knows about devicePixelRatio. Everything else works in CSS
307
+ // pixels and never sees the backing store.
308
+
309
+ export interface SurfaceMetrics {
310
+ cssWidth: number
311
+ cssHeight: number
312
+ dpr: number
313
+ }
314
+
315
+ /** Backing-store pixel size for a CSS box at a given DPR. Rounded, never 0 —
316
+ * a zero-sized canvas throws on some `getContext` paths. */
317
+ export function backingStoreSize(cssWidth: number, cssHeight: number, dpr: number): { width: number; height: number } {
318
+ const scale = dpr > 0 ? dpr : 1
319
+ return {
320
+ width: Math.max(1, Math.round(cssWidth * scale)),
321
+ height: Math.max(1, Math.round(cssHeight * scale)),
322
+ }
323
+ }
324
+
325
+ /** Size a canvas' backing store for its CSS box. Returns true when it changed
326
+ * (writing `canvas.width` clears the canvas, so callers redraw on true). */
327
+ export function syncCanvasBackingStore(canvas: HTMLCanvasElement, metrics: SurfaceMetrics): boolean {
328
+ const { width, height } = backingStoreSize(metrics.cssWidth, metrics.cssHeight, metrics.dpr)
329
+ if (canvas.width === width && canvas.height === height) return false
330
+ canvas.width = width
331
+ canvas.height = height
332
+ return true
333
+ }
334
+
335
+ /** Put the context in CSS-pixel space. `setTransform` rather than `scale` so
336
+ * it is idempotent — a per-frame `scale(dpr, dpr)` would compound. */
337
+ export function scaleContextToDpr(ctx: CanvasRenderingContext2D, dpr: number): void {
338
+ const scale = dpr > 0 ? dpr : 1
339
+ ctx.setTransform(scale, 0, 0, scale, 0, 0)
340
+ }
341
+
342
+ export function currentDpr(): number {
343
+ if (typeof window === 'undefined') return 1
344
+ return window.devicePixelRatio || 1
345
+ }
346
+
347
+ /**
348
+ * Watch an element's CSS size AND the window's device-pixel ratio, calling
349
+ * back with both. The DPR half matters when the window is dragged between a
350
+ * Retina and a non-Retina display: no resize fires, but every backing store is
351
+ * now the wrong size and text renders blurry (or over-sharp). `matchMedia` on
352
+ * a `resolution` query is the only event-driven DPR signal browsers give, and
353
+ * it must be re-armed after each change because the query pins the old value.
354
+ *
355
+ * Returns a disposer. Degrades to a window-resize fallback where
356
+ * ResizeObserver is unavailable (jsdom), and to nothing where matchMedia is.
357
+ */
358
+ export function observeSurface(el: HTMLElement, onChange: (metrics: SurfaceMetrics) => void): () => void {
359
+ const emit = () => {
360
+ const rect = el.getBoundingClientRect()
361
+ onChange({ cssWidth: rect.width, cssHeight: rect.height, dpr: currentDpr() })
362
+ }
363
+
364
+ const disposers: Array<() => void> = []
365
+
366
+ if (typeof ResizeObserver !== 'undefined') {
367
+ const ro = new ResizeObserver(() => emit())
368
+ ro.observe(el)
369
+ disposers.push(() => ro.disconnect())
370
+ } else if (typeof window !== 'undefined') {
371
+ const onResize = () => emit()
372
+ window.addEventListener('resize', onResize)
373
+ disposers.push(() => window.removeEventListener('resize', onResize))
374
+ }
375
+
376
+ disposers.push(watchDpr(emit))
377
+ emit()
378
+
379
+ return () => { for (const d of disposers) d() }
380
+ }
381
+
382
+ /** Fire `onChange` whenever devicePixelRatio changes, re-arming the query. */
383
+ export function watchDpr(onChange: () => void): () => void {
384
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}
385
+ let disposed = false
386
+ let detach: () => void = () => {}
387
+
388
+ const arm = () => {
389
+ if (disposed) return
390
+ const mql = window.matchMedia(`(resolution: ${currentDpr()}dppx)`)
391
+ const handler = () => {
392
+ detach()
393
+ arm()
394
+ onChange()
395
+ }
396
+ // Safari < 14 only has the deprecated addListener/removeListener pair.
397
+ if (typeof mql.addEventListener === 'function') {
398
+ mql.addEventListener('change', handler)
399
+ detach = () => mql.removeEventListener('change', handler)
400
+ } else if (typeof mql.addListener === 'function') {
401
+ mql.addListener(handler)
402
+ detach = () => mql.removeListener(handler)
403
+ } else {
404
+ detach = () => {}
405
+ }
406
+ }
407
+
408
+ arm()
409
+ return () => { disposed = true; detach() }
410
+ }
411
+
412
+ // ── Store ────────────────────────────────────────────────────────────────
413
+ // The viewport is an external store, not React state, for the same reason the
414
+ // playhead is (see playback-clock.ts): a wheel-zoom gesture fires dozens of
415
+ // updates a second, and re-rendering Timeline — which owns the caption row's
416
+ // hundreds of DOM nodes — on each one is exactly the cost the canvas exists to
417
+ // avoid. The canvas redraws imperatively from `get()`; only the zoom badge
418
+ // subscribes for rendering.
419
+
420
+ export interface ViewportStore {
421
+ get(): Viewport
422
+ set(next: Viewport | ((prev: Viewport) => Viewport)): void
423
+ subscribe(cb: () => void): () => void
424
+ }
425
+
426
+ export const INITIAL_VIEWPORT: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
427
+
428
+ export function createViewportStore(initial: Viewport = INITIAL_VIEWPORT): ViewportStore {
429
+ let value = initial
430
+ const subs = new Set<() => void>()
431
+ return {
432
+ get: () => value,
433
+ set(next) {
434
+ const resolved = typeof next === 'function' ? next(value) : next
435
+ if (viewportsEqual(resolved, value)) return
436
+ value = resolved
437
+ subs.forEach(cb => cb())
438
+ },
439
+ subscribe(cb) {
440
+ subs.add(cb)
441
+ return () => { subs.delete(cb) }
442
+ },
443
+ }
444
+ }
445
+
446
+ /** A store whose identity is stable for the lifetime of the component. */
447
+ export function useViewportStore(): ViewportStore {
448
+ const ref = useRef<ViewportStore | null>(null)
449
+ if (ref.current === null) ref.current = createViewportStore()
450
+ return ref.current
451
+ }
452
+
453
+ /** Subscribe a component to the viewport. Only display chrome (the zoom badge)
454
+ * should use this — the canvas itself reads `store.get()` inside its draw. */
455
+ export function useViewportValue(store: ViewportStore): Viewport {
456
+ return useSyncExternalStore(store.subscribe, store.get, store.get)
457
+ }