@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
@@ -1,4 +1,17 @@
1
- import type React from 'react'
1
+ /**
2
+ * The drag/resize arithmetic the timeline's gestures are built out of.
3
+ *
4
+ * This module once also held `useItemDragDrop`, a React hook that wired these
5
+ * functions to mouse events for the DOM track rows. Those rows are retired
6
+ * and the hook went with them: the canvas timeline's pointer machine
7
+ * (`canvas/pointer-machine.ts`) owns gesture handling now, and calls straight
8
+ * into three of the five pure functions below — `DRAG_THRESHOLD_PX`,
9
+ * `computeResizedItem`, `resizeWindowedItem` — with times it derived from its
10
+ * own viewport and snapped with hysteresis (`canvas/snap.ts`). The other two,
11
+ * `snapToBoundaries` and `snapMovedSpan`, have no production caller any more
12
+ * (canvas snapping lives in `canvas/snap.ts`) and are kept deliberately,
13
+ * test-covered only. Nothing here touches the DOM or React any more.
14
+ */
2
15
 
3
16
  export interface Draggable {
4
17
  id: string
@@ -9,199 +22,159 @@ export interface Draggable {
9
22
  normalizedInPoint?: number
10
23
  type?: string
11
24
  sourceDuration?: number
25
+ /** Per-clip playback speed S (video only). A clip at S consumes S source
26
+ * seconds per timeline second, so every timeline↔source conversion here
27
+ * carries it. Absent/1 is a strict no-op. */
28
+ speed?: number
12
29
  }
13
30
 
14
- export interface DragEventContext {
15
- /** The item with updated start/end (and inPoint/outPoint for resize) */
16
- item: Draggable
17
- /** Horizontal delta in timeline seconds from initial position */
18
- dx: number
19
- /** Vertical delta in raw pixels from initial position */
20
- dy: number
21
- }
22
-
23
- export interface UseItemDragDropConfig {
24
- totalDuration: number
25
- snapBoundaries: number[]
26
- snapThresholdPx?: number // default 8
27
- /** Element used for getBoundingClientRect() → pixel-to-time conversion */
28
- scrollRef: React.RefObject<HTMLDivElement | null>
29
- /** Timeline zoom factor — content width is zoom × scrollRef.width. Required so
30
- * pixel-to-time conversion matches the visible scale (without this, drag/resize
31
- * is `zoom`× too sensitive when zoomed in). */
32
- zoomRef?: React.RefObject<number>
33
- draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
34
- }
31
+ /** The floor on an item's timeline duration. At speed S the source window
32
+ * floor follows from it (`MIN_DURATION · S`), so only this one is needed.
33
+ * Module-local: it constrains the two resize functions below and nothing
34
+ * outside this file (`cuts.ts` has a same-valued constant of its own). */
35
+ const MIN_DURATION = 0.1
35
36
 
36
37
  /** A press must travel at least this many pixels before it becomes a drag.
37
38
  * Below it the gesture stays a click. Without this threshold, a single pixel of
38
39
  * pointer drift during a click flips the drag flag, and the timeline suppresses
39
40
  * click-to-select — so clips/overlays can't be selected (or deleted), which
40
- * bites constantly on trackpads and touchscreens where clicks always jitter. */
41
- const DRAG_THRESHOLD_PX = 4
42
-
43
- export function useItemDragDrop(config: UseItemDragDropConfig) {
44
- const {
45
- totalDuration,
46
- snapBoundaries,
47
- snapThresholdPx = 8,
48
- scrollRef,
49
- zoomRef,
50
- draggedFlagRef,
51
- } = config
52
-
53
- function beginResize(
54
- e: React.MouseEvent,
55
- item: Draggable,
56
- edge: 'start' | 'end',
57
- callbacks: {
58
- onLivePreview: (ctx: DragEventContext) => void
59
- onCommit: () => void
60
- },
61
- ): void {
62
- e.stopPropagation()
63
- e.preventDefault()
64
-
65
- const initX = e.clientX
66
- const initTime = edge === 'start' ? item.start : item.end
67
-
68
- function computeResized(moveE: MouseEvent): Draggable {
69
- if (!scrollRef.current) return item
70
- const rect = scrollRef.current.getBoundingClientRect()
71
- const contentWidth = rect.width * (zoomRef?.current ?? 1)
72
- const dt = ((moveE.clientX - initX) / contentWidth) * totalDuration
73
- const raw = Math.max(0, Math.min(totalDuration, initTime + dt))
74
-
75
- // Snap to any boundary within threshold
76
- const snapThreshold = (snapThresholdPx / contentWidth) * totalDuration
77
- let t = raw
78
- let bestDist = snapThreshold
79
- for (const b of snapBoundaries) {
80
- const d = Math.abs(raw - b)
81
- if (d < bestDist) {
82
- bestDist = d
83
- t = b
84
- }
85
- }
86
-
87
- if (edge === 'start') {
88
- const newStart = Math.min(t, item.end - 0.1)
89
- if (item.type !== 'video') return { ...item, start: newStart }
90
- const dtActual = newStart - item.start
91
- return {
92
- ...item,
93
- start: newStart,
94
- inPoint: Math.min(
95
- Math.max(0, (item.inPoint ?? 0) + dtActual),
96
- (item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)) - 0.1,
97
- ),
98
- }
99
- } else {
100
- const newEnd = Math.max(t, item.start + 0.1)
101
- if (item.type !== 'video') return { ...item, end: newEnd }
102
- const origOut = item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)
103
- const dtActual = newEnd - item.end
104
- return {
105
- ...item,
106
- end: newEnd,
107
- outPoint: Math.max(
108
- (item.inPoint ?? 0) + 0.1,
109
- Math.min(origOut + dtActual, item.sourceDuration ?? Infinity),
110
- ),
111
- }
112
- }
41
+ * bites constantly on trackpads and touchscreens where clicks always jitter.
42
+ *
43
+ * Exported for the canvas pointer machine (SP5 T5), which has the same
44
+ * click-vs-drag decision to make and must make it at the same distance. */
45
+ export const DRAG_THRESHOLD_PX = 4
46
+
47
+ // ── Pure drag/resize math ────────────────────────────────────────────────
48
+ // Extracted from the retired hook's body so the canvas timeline's pointer
49
+ // machine (SP5 T5) reuses the exact trim arithmetic rather than
50
+ // reimplementing it, and so the arithmetic is testable without a component.
51
+
52
+ /** Nearest boundary within `threshold` seconds of `raw`, else `raw` itself.
53
+ * Ties keep the earlier entry — `<` is strict, as in the original loop. */
54
+ export function snapToBoundaries(raw: number, boundaries: readonly number[], threshold: number): number {
55
+ let t = raw
56
+ let bestDist = threshold
57
+ for (const b of boundaries) {
58
+ const d = Math.abs(raw - b)
59
+ if (d < bestDist) {
60
+ bestDist = d
61
+ t = b
113
62
  }
63
+ }
64
+ return t
65
+ }
114
66
 
115
- function onMove(moveE: MouseEvent) {
116
- const resized = computeResized(moveE)
117
- callbacks.onLivePreview({ item: resized, dx: 0, dy: 0 })
67
+ /** Snap a moving span of length `duration` by either of its edges, nearest
68
+ * first. The end edge is tested before the start edge and both share one
69
+ * running best distance, so an exact tie is resolved in favour of the START
70
+ * edge — preserved verbatim from the hook's original loop. Returns the raw
71
+ * span unchanged when nothing is in range; the caller re-clamps. */
72
+ export function snapMovedSpan(
73
+ rawStart: number,
74
+ duration: number,
75
+ boundaries: readonly number[],
76
+ threshold: number,
77
+ ): { start: number; end: number } {
78
+ const rawEnd = rawStart + duration
79
+ let start = rawStart
80
+ let end = rawEnd
81
+ let bestDist = threshold
82
+ for (const b of boundaries) {
83
+ const dEnd = Math.abs(rawEnd - b)
84
+ if (dEnd < bestDist) {
85
+ bestDist = dEnd
86
+ start = b - duration
87
+ end = b
118
88
  }
119
- function onUp() {
120
- callbacks.onCommit()
121
- document.removeEventListener('mousemove', onMove)
122
- document.removeEventListener('mouseup', onUp)
89
+ const dStart = Math.abs(rawStart - b)
90
+ if (dStart < bestDist) {
91
+ bestDist = dStart
92
+ start = b
93
+ end = b + duration
123
94
  }
124
- document.addEventListener('mousemove', onMove)
125
- document.addEventListener('mouseup', onUp)
126
95
  }
96
+ return { start, end }
97
+ }
127
98
 
128
- function beginDrag(
129
- e: React.MouseEvent,
130
- item: Draggable,
131
- callbacks: {
132
- onLivePreview: (ctx: DragEventContext) => void
133
- onCommit: () => void
134
- },
135
- ): void {
136
- e.stopPropagation()
137
-
138
- const initX = e.clientX
139
- const initY = e.clientY
140
- const initStart = item.start
141
- const initEnd = item.end
142
- const duration = initEnd - initStart
143
- let dragStarted = false
144
-
145
- function onMove(moveE: MouseEvent) {
146
- // Ignore sub-threshold pointer drift — it's a click, not a drag. Only once
147
- // the press travels past DRAG_THRESHOLD_PX do we set the drag flag (which
148
- // suppresses click-to-select) and start moving the item.
149
- if (!dragStarted) {
150
- const moved = Math.hypot(moveE.clientX - initX, moveE.clientY - initY)
151
- if (moved < DRAG_THRESHOLD_PX) return
152
- dragStarted = true
153
- }
154
- if (draggedFlagRef) draggedFlagRef.current = true
155
-
156
- const rect = scrollRef.current?.getBoundingClientRect()
157
- const contentWidth = rect ? rect.width * (zoomRef?.current ?? 1) : 0
158
- const dx = contentWidth ? ((moveE.clientX - initX) / contentWidth) * totalDuration : 0
159
- const dy = moveE.clientY - initY
160
-
161
- const rawStart = Math.max(0, Math.min(totalDuration - duration, initStart + dx))
162
- const rawEnd = rawStart + duration
99
+ /** A clip's speed, defended against a project carrying 0, a negative, or a
100
+ * non-finite value — every conversion below divides by it. */
101
+ function speedOf(item: Draggable): number {
102
+ const s = item.speed ?? 1
103
+ return Number.isFinite(s) && s > 0 ? s : 1
104
+ }
163
105
 
164
- // Snap leading/trailing edge to nearest item edge within threshold
165
- const snapThreshold = contentWidth ? (snapThresholdPx / contentWidth) * totalDuration : 0
166
- let newStart = rawStart
167
- let newEnd = rawEnd
168
- let bestDist = snapThreshold
169
- for (const b of snapBoundaries) {
170
- const dEnd = Math.abs(rawEnd - b)
171
- if (dEnd < bestDist) {
172
- bestDist = dEnd
173
- newStart = b - duration
174
- newEnd = b
175
- }
176
- const dStart = Math.abs(rawStart - b)
177
- if (dStart < bestDist) {
178
- bestDist = dStart
179
- newStart = b
180
- newEnd = b + duration
181
- }
182
- }
183
- newStart = Math.max(0, Math.min(totalDuration - duration, newStart))
184
- newEnd = newStart + duration
106
+ function clamp(v: number, lo: number, hi: number): number {
107
+ // `hi` last, so a degenerate lo > hi keeps the min-duration constraint
108
+ // rather than inverting the item.
109
+ return Math.min(Math.max(v, lo), hi)
110
+ }
185
111
 
186
- const movedItem = { ...item, start: newStart, end: newEnd }
187
- callbacks.onLivePreview({ item: movedItem, dx, dy })
188
- }
112
+ /**
113
+ * Move one edge of a source-windowed item to time `t`.
114
+ *
115
+ * THE INVARIANT: the timeline span and the source window stay in lockstep —
116
+ * `outPoint − inPoint === (end − start) · speed` — for every input, always.
117
+ * That is what lets the frames, the waveform, the preview and the export all
118
+ * agree about what a clip is showing.
119
+ *
120
+ * The way it is kept is the whole point of this function. The timeline edge is
121
+ * clamped FIRST, against every limit there is (the item's minimum duration and
122
+ * the media actually available past the window), and the source window is then
123
+ * DERIVED from how far the edge really travelled. There is exactly one clamp,
124
+ * on one value.
125
+ *
126
+ * The previous version clamped the two independently — `newStart` against
127
+ * `end − 0.1`, `inPoint` against `[0, outPoint − 0.1]` — and whichever limit
128
+ * bit first left the other free to keep going. Drag the in edge of a clip that
129
+ * already starts at the head of its media and `start` slid left while
130
+ * `inPoint` stayed pinned at 0; drag the out edge of a clip that already runs
131
+ * to the end of its media and `end` grew while `outPoint` stayed at
132
+ * `sourceDuration`. Either way the clip ended up claiming more timeline than
133
+ * it had source for, silently. Downstream, the filmstrip walked off the end of
134
+ * the media and froze on the last frame it could find while the waveform,
135
+ * which windows to inPoint..outPoint, drew the real (shorter) window stretched
136
+ * across the whole clip — two halves of one clip disagreeing, with no error
137
+ * anywhere. Trimming past the media now simply stops at the media.
138
+ */
139
+ export function resizeWindowedItem<T extends Draggable>(item: T, edge: 'start' | 'end', t: number): T {
140
+ const s = speedOf(item)
141
+ const inPoint = item.inPoint ?? 0
142
+ const outPoint = item.outPoint ?? inPoint + (item.end - item.start) * s
143
+
144
+ // BOTH window bounds come back written out, even the one this edge did not
145
+ // move. They were implicit before (an end-trim wrote `outPoint` and left
146
+ // `inPoint` undefined), and an implicit half of the window is what made this
147
+ // class of bug invisible: you cannot check `outPoint − inPoint === span · S`
148
+ // against a field that isn't there.
149
+ if (edge === 'start') {
150
+ // Leftward travel is limited by the source ahead of `inPoint`: that is
151
+ // `inPoint` seconds of media, which is `inPoint / s` of timeline.
152
+ const newStart = clamp(t, item.start - inPoint / s, item.end - MIN_DURATION)
153
+ return { ...item, start: newStart, inPoint: inPoint + (newStart - item.start) * s, outPoint }
154
+ }
189
155
 
190
- function onUp() {
191
- document.removeEventListener('mousemove', onMove)
192
- document.removeEventListener('mouseup', onUp)
193
- if (draggedFlagRef && draggedFlagRef.current) {
194
- callbacks.onCommit()
195
- // reset after click event fires
196
- setTimeout(() => {
197
- draggedFlagRef!.current = false
198
- }, 0)
199
- }
200
- }
156
+ // Rightward travel is limited by the source after `outPoint`. With no
157
+ // `sourceDuration` there is no known tail, so the edge stays unbounded —
158
+ // the same benefit of the doubt the old code gave it.
159
+ const tail = item.sourceDuration == null ? Infinity : item.sourceDuration - outPoint
160
+ const newEnd = clamp(t, item.start + MIN_DURATION, item.end + tail / s)
161
+ return { ...item, end: newEnd, inPoint, outPoint: outPoint + (newEnd - item.end) * s }
162
+ }
201
163
 
202
- document.addEventListener('mousemove', onMove)
203
- document.addEventListener('mouseup', onUp)
164
+ /**
165
+ * Move one edge of an item to time `t`, keeping its source window consistent.
166
+ *
167
+ * `t` is expected to be already clamped to the timeline and already snapped —
168
+ * this function only enforces the item's own invariants. Non-video items
169
+ * (images, overlays) carry no source window and only move their timeline edge;
170
+ * video clips go through `resizeWindowedItem`, which holds the span/window
171
+ * invariant documented there.
172
+ */
173
+ export function computeResizedItem(item: Draggable, edge: 'start' | 'end', t: number): Draggable {
174
+ if (item.type !== 'video') {
175
+ return edge === 'start'
176
+ ? { ...item, start: Math.min(t, item.end - MIN_DURATION) }
177
+ : { ...item, end: Math.max(t, item.start + MIN_DURATION) }
204
178
  }
205
-
206
- return { beginDrag, beginResize }
179
+ return resizeWindowedItem(item, edge, t)
207
180
  }
@@ -1,63 +1,28 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react'
1
+ import { useRef } from 'react'
2
2
 
3
- export function useTimelineZoom(totalDuration: number) {
4
- const [zoom, setZoom] = useState(1)
5
- const zoomRef = useRef(zoom)
6
- zoomRef.current = zoom
3
+ /**
4
+ * The timeline column's horizontal scroll container.
5
+ *
6
+ * All this hook has left is the ref. It used to own a whole zoom model: a
7
+ * `zoom` multiplier, a pivot-preserving `zoomTo` that wrote `scrollLeft` in the
8
+ * same layout pass as the new width, and a non-passive wheel listener for
9
+ * ⌘/Ctrl+wheel zoom and Alt+wheel pan. That model existed because the DOM
10
+ * track rows laid items out as percentages of the whole project, so the only
11
+ * way to zoom them was to widen their container and scroll over it.
12
+ *
13
+ * The canvas surface zooms a px-per-second viewport held in an external store
14
+ * (`canvas/viewport.ts`) instead, and owns its own wheel handling — it even
15
+ * `stopPropagation`s the wheel events it consumes, specifically so this
16
+ * container could not zoom its multiplier off the same gesture. With the rows
17
+ * gone there is no second zoom model left to drive: the surface always fits
18
+ * the width it is given, so the container never overflows and its scroll
19
+ * position is always 0.
20
+ *
21
+ * It is kept as a hook (rather than inlined) because the ref is what makes the
22
+ * container addressable, and a wide child appearing here should scroll rather
23
+ * than stretch the page.
24
+ */
25
+ export function useTimelineZoom() {
7
26
  const scrollRef = useRef<HTMLDivElement>(null)
8
- const pendingScrollRef = useRef<number | null>(null)
9
-
10
- useLayoutEffect(() => {
11
- if (pendingScrollRef.current !== null && scrollRef.current) {
12
- scrollRef.current.scrollLeft = pendingScrollRef.current
13
- pendingScrollRef.current = null
14
- }
15
- })
16
-
17
- function zoomTo(newZoom: number, pivotClientX?: number) {
18
- const clamped = Math.max(1, Math.min(20, newZoom))
19
- if (!scrollRef.current || totalDuration === 0) { setZoom(clamped); return }
20
- const container = scrollRef.current
21
- const containerWidth = container.clientWidth
22
- const currentScrollLeft = container.scrollLeft
23
- const rect = container.getBoundingClientRect()
24
- const pivotOffset = pivotClientX !== undefined
25
- ? Math.max(0, Math.min(containerWidth, pivotClientX - rect.left))
26
- : containerWidth / 2
27
- const pivotPct = Math.max(0, Math.min(1,
28
- (currentScrollLeft + pivotOffset) / (containerWidth * zoomRef.current)
29
- ))
30
- pendingScrollRef.current = Math.max(0, pivotPct * containerWidth * clamped - pivotOffset)
31
- setZoom(clamped)
32
- }
33
-
34
- // ⌘/Ctrl + wheel = zoom; Alt + wheel = horizontal scroll. Both call
35
- // preventDefault, so the listener MUST be non-passive. React's onWheel prop is
36
- // registered passive at the root, which makes preventDefault a no-op and spams
37
- // "Unable to preventDefault inside passive event listener" — so attach a native
38
- // listener with { passive: false } instead. A ref to the latest handler keeps
39
- // it current without re-binding on every render.
40
- const wheelHandlerRef = useRef<(e: WheelEvent) => void>(() => {})
41
- wheelHandlerRef.current = (e: WheelEvent) => {
42
- if (e.ctrlKey || e.metaKey) {
43
- e.preventDefault()
44
- // Multiplicative step so perceived speed is consistent across zoom levels.
45
- // deltaY ≈ 100 per mouse-wheel tick → factor ≈ 0.82 (≈ 18% zoom change).
46
- const factor = Math.exp(-e.deltaY * 0.002)
47
- zoomTo(zoomRef.current * factor, e.clientX)
48
- } else if (e.altKey) {
49
- e.preventDefault()
50
- if (scrollRef.current) scrollRef.current.scrollLeft += e.deltaY
51
- }
52
- }
53
-
54
- useEffect(() => {
55
- const el = scrollRef.current
56
- if (!el) return
57
- const onWheel = (e: WheelEvent) => wheelHandlerRef.current(e)
58
- el.addEventListener('wheel', onWheel, { passive: false })
59
- return () => el.removeEventListener('wheel', onWheel)
60
- }, [])
61
-
62
- return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo }
27
+ return { scrollRef }
63
28
  }
@@ -3,15 +3,3 @@ export function formatTime(s: number): string {
3
3
  const sec = (s % 60).toFixed(1)
4
4
  return `${m}:${sec.padStart(4, '0')}`
5
5
  }
6
-
7
- export function pct(t: number, totalDuration: number): number {
8
- return totalDuration > 0 ? (t / totalDuration) * 100 : 0
9
- }
10
-
11
- export function ratioFromClientX(clientX: number, scrubberRect: DOMRect): number {
12
- const x = clientX - scrubberRect.left
13
- return Math.max(0, Math.min(1, x / scrubberRect.width))
14
- }
15
-
16
- export const trackRow = 'relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'
17
- export const trackRowTall = 'relative h-14 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'
@@ -0,0 +1,75 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import type { EditorAdapter, EditorContext } from '../types'
4
+ import type { PlaybackClock } from './playback-clock'
5
+
6
+ /**
7
+ * How often the playhead is reported during playback.
8
+ *
9
+ * The clock is written ~60Hz; reporting every tick would be 60 POSTs/sec for
10
+ * information no agent can consume that fast. One per second is well inside
11
+ * serve's CONTEXT_TTL_SEC while costing effectively nothing.
12
+ *
13
+ * Note this is an unconditional heartbeat, not change detection: a paused
14
+ * editor keeps reporting the same playhead once a second, and must. The TTL
15
+ * governs freshness, so going quiet while parked would make serve answer
16
+ * "no editor open" about an editor that is plainly open.
17
+ */
18
+ export const REPORT_INTERVAL_MS = 1000
19
+
20
+ interface Options {
21
+ adapter: EditorAdapter
22
+ projectId: string
23
+ clock: PlaybackClock
24
+ selectedIds: string[]
25
+ selectedCaptionId: string | null
26
+ }
27
+
28
+ /**
29
+ * Report playhead + selection to the host on a throttle.
30
+ *
31
+ * The throttle is deliberately asymmetric. The PLAYHEAD is sampled on an
32
+ * interval — it moves continuously and an agent only needs roughly where it
33
+ * is. SELECTION is reported the moment it changes — it is discrete, it is
34
+ * usually the more meaningful half of "this section", and waiting up to a
35
+ * second to report a click would make the agent answer about the previous
36
+ * selection.
37
+ */
38
+ export function useReportContext({
39
+ adapter, projectId, clock, selectedIds, selectedCaptionId,
40
+ }: Options): void {
41
+ // Kept in a ref so the interval effect never re-subscribes on a selection
42
+ // change — it always reads the latest values without depending on them.
43
+ const latest = useRef({ selectedIds, selectedCaptionId })
44
+ latest.current = { selectedIds, selectedCaptionId }
45
+
46
+ const send = useRef<(ctx: EditorContext) => void>(() => {})
47
+ send.current = (ctx: EditorContext) => {
48
+ // Fire and forget. A down serve, a 404, an offline host — none of it is
49
+ // the editor's problem, and none of it reaches the user.
50
+ void adapter.reportContext?.(projectId, ctx)?.catch(() => {})
51
+ }
52
+
53
+ // Playhead, on an interval. Reports on mount so an agent asked something the
54
+ // instant the editor opens still gets an answer.
55
+ useEffect(() => {
56
+ if (!adapter.reportContext) return
57
+ const emit = () => send.current({
58
+ playheadSec: clock.get(),
59
+ selectedIds: latest.current.selectedIds,
60
+ selectedCaptionId: latest.current.selectedCaptionId,
61
+ })
62
+ emit()
63
+ const id = setInterval(emit, REPORT_INTERVAL_MS)
64
+ return () => clearInterval(id)
65
+ }, [adapter, projectId, clock])
66
+
67
+ // Selection, immediately on change. Skips the mount emit above by comparing
68
+ // against what the interval effect already sent.
69
+ const mounted = useRef(false)
70
+ useEffect(() => {
71
+ if (!adapter.reportContext) return
72
+ if (!mounted.current) { mounted.current = true; return }
73
+ send.current({ playheadSec: clock.get(), selectedIds, selectedCaptionId })
74
+ }, [adapter, clock, selectedIds, selectedCaptionId])
75
+ }