@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,407 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T4 — the canvas surface's wiring. The painter and the viewport math are
4
+ * covered as pure units elsewhere; what's asserted here is the part only a
5
+ * mounted component can show: that redraws happen imperatively.
6
+ *
7
+ * jsdom has no 2D canvas and no ResizeObserver, so both are stubbed — the
8
+ * component's own scheduling, subscriptions and layer split are what's under
9
+ * test, not the rasterizer.
10
+ */
11
+ import { StrictMode } from 'react'
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
13
+ import { render, act, cleanup } from '@testing-library/react'
14
+ import { createPlaybackClock } from '../../../playback-clock'
15
+ import type { Project } from '../../../../types'
16
+ import TimelineCanvas from '../TimelineCanvas'
17
+ import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, type TimelineMode } from '../draw'
18
+ import { createViewportStore, type ViewportStore } from '../viewport'
19
+
20
+ interface RecordedCall { method: string; args: unknown[] }
21
+
22
+ const recorders = new Map<HTMLCanvasElement, RecordedCall[]>()
23
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
24
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
25
+
26
+ function recorderFor(canvas: HTMLCanvasElement): RecordedCall[] {
27
+ const existing = recorders.get(canvas)
28
+ if (existing) return existing
29
+ const calls: RecordedCall[] = []
30
+ recorders.set(canvas, calls)
31
+ return calls
32
+ }
33
+
34
+ function contextFor(canvas: HTMLCanvasElement) {
35
+ const calls = recorderFor(canvas)
36
+ const props: Record<string, unknown> = {}
37
+ return new Proxy({}, {
38
+ get(_t, prop: string) {
39
+ if (prop in props) return props[prop]
40
+ if (prop === 'createLinearGradient') {
41
+ return () => ({ addColorStop: () => {} })
42
+ }
43
+ return (...args: unknown[]) => { calls.push({ method: prop, args }) }
44
+ },
45
+ set(_t, prop: string, value: unknown) {
46
+ props[prop] = value
47
+ calls.push({ method: `set:${prop}`, args: [value] })
48
+ return true
49
+ },
50
+ })
51
+ }
52
+
53
+ beforeEach(() => {
54
+ recorders.clear()
55
+ realGetContext = HTMLCanvasElement.prototype.getContext
56
+ realGetRect = Element.prototype.getBoundingClientRect
57
+ HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement) {
58
+ return contextFor(this)
59
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
60
+ // jsdom lays everything out at 0×0; the surface needs a size to paint into.
61
+ Element.prototype.getBoundingClientRect = function (this: Element) {
62
+ return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 120, width: 1000, height: 120, toJSON: () => ({}) } as DOMRect
63
+ }
64
+ vi.useFakeTimers()
65
+ })
66
+
67
+ afterEach(() => {
68
+ cleanup()
69
+ vi.useRealTimers()
70
+ HTMLCanvasElement.prototype.getContext = realGetContext
71
+ Element.prototype.getBoundingClientRect = realGetRect
72
+ })
73
+
74
+ const project = {
75
+ id: 'p1',
76
+ tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8 }]],
77
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5 }] },
78
+ } as unknown as Project
79
+
80
+ // Stable identity: the content layer's redraw effect keys on the `selectedIds`
81
+ // prop, so a fresh literal on every rerender would repaint the content and
82
+ // make the guide's layer-isolation assertion meaningless. Timeline passes
83
+ // React state here, which has exactly this stability.
84
+ const NO_SELECTION: string[] = []
85
+
86
+ function mount(
87
+ store: ViewportStore,
88
+ clock = createPlaybackClock(),
89
+ onRender?: () => void,
90
+ selectedIds: string[] = NO_SELECTION,
91
+ ) {
92
+ function Sibling() { onRender?.(); return null }
93
+ function Surface({ previewAxis = false }: { previewAxis?: boolean }) {
94
+ return (
95
+ <>
96
+ <TimelineCanvas
97
+ project={project}
98
+ clock={clock}
99
+ store={store}
100
+ totalDuration={20}
101
+ fps={30}
102
+ selectedIds={selectedIds}
103
+ previewAxis={previewAxis}
104
+ />
105
+ <Sibling />
106
+ </>
107
+ )
108
+ }
109
+ const utils = render(<Surface />)
110
+ act(() => { vi.advanceTimersByTime(32) })
111
+ const canvases = utils.container.querySelectorAll('canvas')
112
+ return {
113
+ ...utils,
114
+ container: utils.container,
115
+ setAxis: (on: boolean) => utils.rerender(<Surface previewAxis={on} />),
116
+ clock,
117
+ content: recorderFor(canvases[0] as HTMLCanvasElement),
118
+ overlay: recorderFor(canvases[1] as HTMLCanvasElement),
119
+ }
120
+ }
121
+
122
+ describe('TimelineCanvas', () => {
123
+ it('paints both layers on mount and sizes the viewport from the surface', () => {
124
+ const store = createViewportStore()
125
+ const { content, overlay } = mount(store)
126
+
127
+ expect(content.some(c => c.method === 'clearRect')).toBe(true)
128
+ expect(overlay.some(c => c.method === 'clearRect')).toBe(true)
129
+ // First layout starts fitted: 20s across 1000px.
130
+ expect(store.get()).toMatchObject({ widthPx: 1000, pxPerSecond: 50, scrollSeconds: 0 })
131
+ // Backing stores follow the CSS box (dpr 1 in jsdom).
132
+ expect(document.querySelector('canvas')!.getAttribute('width')).toBe('1000')
133
+ })
134
+
135
+ it('moves the playhead without repainting the content or re-rendering React', () => {
136
+ const store = createViewportStore()
137
+ const renders = vi.fn()
138
+ const { content, overlay, clock } = mount(store, createPlaybackClock(), renders)
139
+
140
+ const contentCallsBefore = content.length
141
+ const rendersBefore = renders.mock.calls.length
142
+ overlay.length = 0
143
+
144
+ act(() => { clock.set(3) })
145
+ act(() => { vi.advanceTimersByTime(32) })
146
+
147
+ expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
148
+ expect(content.length).toBe(contentCallsBefore)
149
+ expect(renders.mock.calls.length).toBe(rendersBefore)
150
+ })
151
+
152
+ it('repaints only the overlay when the axis cursor moves — it shares the playhead layer', () => {
153
+ // Tracking the pointer must cost what moving the playhead costs: one
154
+ // overlay repaint, no content repaint. A cursor line on the content layer
155
+ // would repaint every clip on every mousemove, which is the exact cost this
156
+ // surface exists to avoid — this is the test that would catch it.
157
+ const store = createViewportStore()
158
+ const { content, overlay, container, setAxis } = mount(store)
159
+ act(() => { setAxis(true) })
160
+ act(() => { vi.advanceTimersByTime(32) })
161
+
162
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
163
+ const contentCallsBefore = content.length
164
+ overlay.length = 0
165
+
166
+ act(() => {
167
+ surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 400, clientY: 20, bubbles: true }))
168
+ })
169
+ act(() => { vi.advanceTimersByTime(32) })
170
+
171
+ expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.cursor)).toBe(true)
172
+ expect(overlay.filter(c => c.method === 'clearRect')).toHaveLength(1)
173
+ expect(content.length).toBe(contentCallsBefore)
174
+ })
175
+
176
+ // ── Trim-handle hover: the one part of the affordance that needs a mount ──
177
+ //
178
+ // `drawTrimHandle` is covered as a pure unit in draw.test.ts. What only a
179
+ // mounted surface can show is that resting the pointer on a handle reaches
180
+ // the painter at all — the hit-test, the change-detection and the content
181
+ // repaint are three separate places this can silently not happen.
182
+
183
+ it('lights the handle under the pointer, repainting the content once', () => {
184
+ const store = createViewportStore()
185
+ const { content, container } = mount(store, createPlaybackClock(), undefined, ['c0'])
186
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
187
+ content.length = 0
188
+
189
+ // c0 ends at 8s; at 50px/s that is x=400, so x=395 is inside its out handle.
190
+ act(() => {
191
+ surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true }))
192
+ })
193
+ act(() => { vi.advanceTimersByTime(32) })
194
+
195
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(true)
196
+ // The other handle stays at its resting fill — only one is ever lit.
197
+ expect(content.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(1)
198
+ expect(content.filter(c => c.method === 'clearRect')).toHaveLength(1)
199
+ })
200
+
201
+ it('takes the light off again when the pointer moves onto the clip body', () => {
202
+ const store = createViewportStore()
203
+ const { content, container } = mount(store, createPlaybackClock(), undefined, ['c0'])
204
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
205
+
206
+ act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true })) })
207
+ act(() => { vi.advanceTimersByTime(32) })
208
+ content.length = 0
209
+
210
+ act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 200, clientY: 60, bubbles: true })) })
211
+ act(() => { vi.advanceTimersByTime(32) })
212
+
213
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(false)
214
+ expect(content.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
215
+ })
216
+
217
+ it('costs no content repaint at all when nothing is selected', () => {
218
+ // Handles are drawn on selected items only, so with an empty selection
219
+ // there is nothing to light and hovering must stay as cheap as it was.
220
+ const store = createViewportStore()
221
+ const { content, container } = mount(store)
222
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
223
+ const before = content.length
224
+
225
+ act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true })) })
226
+ act(() => { vi.advanceTimersByTime(32) })
227
+
228
+ expect(content.length).toBe(before)
229
+ })
230
+
231
+ it('never rings itself when the pointer focuses it and the keyboard drives it', () => {
232
+ // The surface is focused by mouse (so Delete/Enter reach Timeline's root
233
+ // guard) and then driven by keyboard — space to play/pause — which is the
234
+ // exact sequence `:focus-visible` exists to catch. Left alone, the first
235
+ // keypress drew a focus ring around every track at once.
236
+ const store = createViewportStore()
237
+ const { container } = mount(store)
238
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
239
+
240
+ expect(surface.className).toContain('outline-none')
241
+ // And it stays unreachable by tab, which is what makes suppressing the
242
+ // ring free rather than a keyboard-navigation regression.
243
+ expect(surface.getAttribute('tabindex')).toBe('-1')
244
+ })
245
+
246
+ it('coalesces a burst of clock ticks into a single frame', () => {
247
+ const store = createViewportStore()
248
+ const { overlay, clock } = mount(store)
249
+ overlay.length = 0
250
+
251
+ act(() => { for (let t = 1; t <= 10; t++) clock.set(t) })
252
+ act(() => { vi.advanceTimersByTime(32) })
253
+
254
+ expect(overlay.filter(c => c.method === 'clearRect')).toHaveLength(1)
255
+ })
256
+
257
+ it('zooms at the cursor on ⌘/Ctrl-wheel without re-rendering React', () => {
258
+ const store = createViewportStore()
259
+ const renders = vi.fn()
260
+ const { container } = mount(store, createPlaybackClock(), renders)
261
+ const rendersBefore = renders.mock.calls.length
262
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
263
+
264
+ const before = store.get()
265
+ act(() => {
266
+ surface.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, ctrlKey: true, clientX: 500, bubbles: true, cancelable: true }))
267
+ })
268
+
269
+ expect(store.get().pxPerSecond).toBeCloseTo(before.pxPerSecond * Math.exp(0.2), 6)
270
+ expect(renders.mock.calls.length).toBe(rendersBefore)
271
+ })
272
+
273
+ it('leaves a plain wheel to the page', () => {
274
+ const store = createViewportStore()
275
+ const { container } = mount(store)
276
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
277
+ const before = store.get()
278
+
279
+ const event = new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })
280
+ act(() => { surface.dispatchEvent(event) })
281
+
282
+ expect(event.defaultPrevented).toBe(false)
283
+ expect(store.get()).toEqual(before)
284
+ })
285
+
286
+ it('repaints the content when the project changes', () => {
287
+ const store = createViewportStore()
288
+ const clock = createPlaybackClock()
289
+ const { rerender, container } = render(
290
+ <TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />,
291
+ )
292
+ act(() => { vi.advanceTimersByTime(32) })
293
+ const content = recorderFor(container.querySelectorAll('canvas')[0] as HTMLCanvasElement)
294
+ content.length = 0
295
+
296
+ const edited = { ...project, tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 2, end: 9 }]] } as unknown as Project
297
+ rerender(<TimelineCanvas project={edited} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />)
298
+ act(() => { vi.advanceTimersByTime(32) })
299
+
300
+ expect(content.some(c => c.method === 'clearRect')).toBe(true)
301
+ })
302
+
303
+ it('keeps redrawing after a StrictMode mount/cleanup/remount cycle', () => {
304
+ // Dev-only regression: StrictMode runs mount → cleanup → mount on the
305
+ // same instance. If the unmount cleanup cancels the pending frame
306
+ // without resetting `frameRef`, every redraw after the replay is
307
+ // silently dropped — the canvas never paints again.
308
+ const store = createViewportStore()
309
+ const clock = createPlaybackClock()
310
+ const { rerender, container } = render(
311
+ <StrictMode>
312
+ <TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />
313
+ </StrictMode>,
314
+ )
315
+ act(() => { vi.advanceTimersByTime(32) })
316
+ const content = recorderFor(container.querySelectorAll('canvas')[0] as HTMLCanvasElement)
317
+ content.length = 0
318
+
319
+ rerender(
320
+ <StrictMode>
321
+ <TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={['c0']} />
322
+ </StrictMode>,
323
+ )
324
+ act(() => { vi.advanceTimersByTime(32) })
325
+
326
+ expect(content.some(c => c.method === 'clearRect')).toBe(true)
327
+ })
328
+ })
329
+
330
+ describe('TimelineCanvas — theme mode', () => {
331
+ // The canvas coalesces every redraw into one rAF and repaints only the
332
+ // layers marked dirty, so a theme flip that isn't wired into that
333
+ // dirty-marking leaves BOTH layers showing the previous theme's pixels until
334
+ // something unrelated happens to touch them — and on a paused, untouched
335
+ // timeline the overlay layer is touched by nothing at all. These tests are
336
+ // about that wiring, not about the colours (draw.test.ts owns those).
337
+ function mountWithMode(initial: TimelineMode) {
338
+ const store = createViewportStore()
339
+ const clock = createPlaybackClock()
340
+ function Surface({ mode }: { mode: TimelineMode }) {
341
+ return (
342
+ <TimelineCanvas
343
+ project={project}
344
+ clock={clock}
345
+ store={store}
346
+ totalDuration={20}
347
+ fps={30}
348
+ selectedIds={NO_SELECTION}
349
+ mode={mode}
350
+ />
351
+ )
352
+ }
353
+ const utils = render(<Surface mode={initial} />)
354
+ act(() => { vi.advanceTimersByTime(32) })
355
+ const canvases = utils.container.querySelectorAll('canvas')
356
+ return {
357
+ content: recorderFor(canvases[0] as HTMLCanvasElement),
358
+ overlay: recorderFor(canvases[1] as HTMLCanvasElement),
359
+ setMode: (mode: TimelineMode) => {
360
+ utils.rerender(<Surface mode={mode} />)
361
+ act(() => { vi.advanceTimersByTime(32) })
362
+ },
363
+ }
364
+ }
365
+
366
+ it('repaints BOTH layers with the new palette when the mode flips', () => {
367
+ const { content, overlay, setMode } = mountWithMode('dark')
368
+
369
+ // Baseline: the surface really did come up dark.
370
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
371
+ content.length = 0
372
+ overlay.length = 0
373
+
374
+ setMode('light')
375
+
376
+ // Content: repainted, and repainted LIGHT — not merely repainted.
377
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(true)
378
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(false)
379
+ // Overlay: nothing else on this idle surface would ever repaint it, so a
380
+ // fresh clear+playhead pass here is the whole proof that the flip marked
381
+ // it dirty too. (Its one mark, the playhead, is red in both modes by
382
+ // design — see draw.test.ts — so the colour can't be the tell.)
383
+ expect(overlay.some(c => c.method === 'clearRect')).toBe(true)
384
+ expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
385
+ })
386
+
387
+ it('flips back to dark, so the dependency is the mode and not "changed once"', () => {
388
+ const { content, setMode } = mountWithMode('light')
389
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(true)
390
+ content.length = 0
391
+
392
+ setMode('dark')
393
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
394
+ expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(false)
395
+ })
396
+
397
+ it('does not repaint when a rerender leaves the mode alone', () => {
398
+ // The other half of the contract: a host re-rendering for any other reason
399
+ // must not cost a full two-layer repaint.
400
+ const { content, overlay, setMode } = mountWithMode('dark')
401
+ content.length = 0
402
+ overlay.length = 0
403
+ setMode('dark')
404
+ expect(content).toHaveLength(0)
405
+ expect(overlay).toHaveLength(0)
406
+ })
407
+ })
@@ -0,0 +1,77 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The frames/waveform split inside a video clip. Small, but two painters read
4
+ * it independently — if the bands ever overlap or leave a gap, the filmstrip
5
+ * paints over the waveform (or a stripe of clip fill shows through the seam)
6
+ * at every zoom and on every project.
7
+ */
8
+ import { describe, it, expect } from 'vitest'
9
+ import { CLIP_BAND_INSET_PX, CLIP_WAVEFORM_FRACTION, clipBands } from '../clip-bands'
10
+ import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../../timeline-model'
11
+ import type { Rect } from '../draw'
12
+
13
+ function rect(over: Partial<Rect> = {}): Rect {
14
+ return { x: 0, y: 0, width: 200, height: BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ...over }
15
+ }
16
+
17
+ describe('clipBands', () => {
18
+ it('splits a base-track clip evenly between frames and waveform', () => {
19
+ const { frames, waveform } = clipBands(rect())
20
+ expect(frames.height).toBe(59) // (120 - 2 inset) / 2
21
+ expect(waveform.height).toBe(59)
22
+ })
23
+
24
+ it('stacks the waveform directly under the frames, with no seam gap', () => {
25
+ const { frames, waveform } = clipBands(rect())
26
+ expect(waveform.y).toBe(frames.y + frames.height)
27
+ })
28
+
29
+ it('keeps both bands inside the clip, clear of the border the painter strokes', () => {
30
+ const r = rect({ y: 40 })
31
+ const { frames, waveform } = clipBands(r)
32
+ expect(frames.y).toBe(r.y + CLIP_BAND_INSET_PX)
33
+ expect(waveform.y + waveform.height).toBe(r.y + r.height - CLIP_BAND_INSET_PX)
34
+ })
35
+
36
+ it('conserves the full inset height at an odd height, rounding into the frames band', () => {
37
+ // 41 - 2 = 39 inner: the waveform rounds to 20 and frames absorb the
38
+ // remaining 19, rather than both rounding to 20 and overflowing the clip.
39
+ const { frames, waveform } = clipBands(rect({ height: 41 }))
40
+ expect(frames.height + waveform.height).toBe(39)
41
+ expect(waveform.height).toBe(20)
42
+ expect(frames.height).toBe(19)
43
+ })
44
+
45
+ it('spans the clip\'s full width in both bands', () => {
46
+ const r = rect({ x: 17, width: 340 })
47
+ const { frames, waveform } = clipBands(r)
48
+ for (const band of [frames, waveform]) {
49
+ expect(band.x).toBe(r.x)
50
+ expect(band.width).toBe(r.width)
51
+ }
52
+ })
53
+
54
+ it('gives a non-base row the same even split at its shorter height', () => {
55
+ // Overlay rows stayed at the DOM height — they carry no waveform and no
56
+ // filmstrip, so the split is defined there but never drawn into.
57
+ const { frames, waveform } = clipBands(rect({ height: VISUAL_ROW_RENDER_HEIGHT_PX }))
58
+ expect(frames.height).toBe(19) // (40 - 2) / 2
59
+ expect(waveform.height).toBe(19)
60
+ })
61
+
62
+ it('drops the inset rather than inverting it on a rect too short to hold one', () => {
63
+ const { frames, waveform } = clipBands(rect({ height: 3 }))
64
+ expect(frames.y).toBe(0)
65
+ expect(frames.height + waveform.height).toBe(3)
66
+ })
67
+
68
+ it('yields zero-height bands for a zero-height rect, which every painter skips', () => {
69
+ const { frames, waveform } = clipBands(rect({ height: 0 }))
70
+ expect(frames.height).toBe(0)
71
+ expect(waveform.height).toBe(0)
72
+ })
73
+
74
+ it('is an even split — the fraction the two painters agree on', () => {
75
+ expect(CLIP_WAVEFORM_FRACTION).toBe(0.5)
76
+ })
77
+ })