@bycrux/editor 0.12.0 → 1.0.2

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 +161 -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,561 @@
1
+ /**
2
+ * SP5 T7 — canvas filmstrip rendering: the visibility fetch gate, the
3
+ * ported sheet-index fetch-state store, tile selection + source-rect math,
4
+ * and the draw hooks wired into `drawClipRect`/`drawTimelineContent` (draw.ts,
5
+ * composed with T6's waveform). Follows waveforms.test.ts's (T6) conventions
6
+ * throughout.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest'
9
+ import type { VisualItem } from '../../../../schema'
10
+ import type { FilmstripIndex, FilmstripSheet, Project } from '../../../../types'
11
+ import {
12
+ FILMSTRIP_CELL_ASPECT,
13
+ FilmstripStore,
14
+ centerCropTo,
15
+ clipTimeToSourceTime,
16
+ defaultSheetImageLoader,
17
+ drawFilmstripTiles,
18
+ flattenTiles,
19
+ nearestTile,
20
+ tileSourceRect,
21
+ type FilmstripQueryContext,
22
+ type FilmstripTileDraw,
23
+ type LoadedSheetImage,
24
+ type SheetImageLoader,
25
+ } from '../filmstrips'
26
+ import {
27
+ computeTimelineLayout,
28
+ drawTimelineContent,
29
+ type DrawContext,
30
+ type Rect,
31
+ type TimelineScene,
32
+ } from '../draw'
33
+ import { FETCH_RETRY_COOLDOWN_MS, type WaveformColumn } from '../waveforms'
34
+ import { clipBands } from '../clip-bands'
35
+ import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX } from '../../timeline-model'
36
+ import type { Viewport } from '../viewport'
37
+
38
+ // ── Recording context (mirrors waveforms.test.ts's convention) ───────────
39
+
40
+ interface RecordedCall { method: string; args: unknown[] }
41
+
42
+ function recordingContext() {
43
+ const calls: RecordedCall[] = []
44
+ const props: Record<string, unknown> = {}
45
+ const proxy = new Proxy({}, {
46
+ get(_t, prop: string) {
47
+ if (prop in props) return props[prop]
48
+ if (prop === 'createLinearGradient') {
49
+ return (...args: unknown[]) => {
50
+ calls.push({ method: 'createLinearGradient', args })
51
+ return { addColorStop: (...s: unknown[]) => calls.push({ method: 'addColorStop', args: s }) } as unknown as CanvasGradient
52
+ }
53
+ }
54
+ return (...args: unknown[]) => { calls.push({ method: prop, args }) }
55
+ },
56
+ set(_t, prop: string, value: unknown) {
57
+ props[prop] = value
58
+ calls.push({ method: `set:${prop}`, args: [value] })
59
+ return true
60
+ },
61
+ }) as unknown as DrawContext
62
+ return { ctx: proxy, calls, of: (m: string) => calls.filter(c => c.method === m) }
63
+ }
64
+
65
+ // ── Fixtures ─────────────────────────────────────────────────────────────
66
+
67
+ function clip(over: Partial<VisualItem> = {}): VisualItem {
68
+ return { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, ...over }
69
+ }
70
+
71
+ function rect(over: Partial<Rect> = {}): Rect {
72
+ return { x: 0, y: 0, width: 100, height: BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ...over }
73
+ }
74
+
75
+ function filmstripIndexFixture(over: Partial<FilmstripIndex> = {}): FilmstripIndex {
76
+ return {
77
+ interval: 1,
78
+ tileWidth: 160,
79
+ sheets: [
80
+ {
81
+ path: 'sheet0.jpg',
82
+ cols: 5,
83
+ rows: 2,
84
+ tiles: Array.from({ length: 10 }, (_, i) => ({ t: i, row: Math.floor(i / 5), col: i % 5 })),
85
+ },
86
+ ],
87
+ ...over,
88
+ }
89
+ }
90
+
91
+ function loadedImage(width = 800, height = 320): LoadedSheetImage {
92
+ return { image: {} as CanvasImageSource, width, height }
93
+ }
94
+
95
+ function fakeLoader(image: LoadedSheetImage = loadedImage()): SheetImageLoader {
96
+ return vi.fn().mockResolvedValue(image)
97
+ }
98
+
99
+ function queryCtx(over: Partial<FilmstripQueryContext> = {}): FilmstripQueryContext {
100
+ return {
101
+ projectId: 'p1',
102
+ getFilmstrip: vi.fn().mockResolvedValue(filmstripIndexFixture()),
103
+ fileUrl: (p: string) => `/files/${p}`,
104
+ viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 },
105
+ onReady: vi.fn(),
106
+ loader: fakeLoader(),
107
+ ...over,
108
+ }
109
+ }
110
+
111
+ /** Flush the microtask queue so a mocked fetcher's `.then()`/`.catch()` runs. */
112
+ function flush(): Promise<void> {
113
+ return new Promise(resolve => setTimeout(resolve, 0))
114
+ }
115
+
116
+ // ── Cell sizing ────────────────────────────────────────────────────────────
117
+
118
+ describe('FILMSTRIP_CELL_ASPECT', () => {
119
+ it('is square — the 9:16 footage this edits would otherwise draw as slivers', () => {
120
+ expect(FILMSTRIP_CELL_ASPECT).toBe(1)
121
+ })
122
+ })
123
+
124
+ describe('centerCropTo', () => {
125
+ it('crops a tall tile top and bottom to fill a square cell', () => {
126
+ // 9:16 source into a 1:1 destination: full width, a centered square of height.
127
+ expect(centerCropTo({ sx: 0, sy: 0, sw: 90, sh: 160 }, 1)).toEqual({ sx: 0, sy: 35, sw: 90, sh: 90 })
128
+ })
129
+
130
+ it('crops a wide tile left and right to fill a square cell', () => {
131
+ expect(centerCropTo({ sx: 0, sy: 0, sw: 160, sh: 90 }, 1)).toEqual({ sx: 35, sy: 0, sw: 90, sh: 90 })
132
+ })
133
+
134
+ it('keeps the tile\'s offset within its sheet, cropping relative to it', () => {
135
+ // The crop must move the sub-rect INSIDE the tile, never back toward the
136
+ // sheet origin, or every cell would show a slice of its neighbour.
137
+ expect(centerCropTo({ sx: 320, sy: 90, sw: 160, sh: 90 }, 1)).toEqual({ sx: 355, sy: 90, sw: 90, sh: 90 })
138
+ })
139
+
140
+ it('leaves an already-matching aspect untouched', () => {
141
+ const src = { sx: 10, sy: 20, sw: 80, sh: 40 }
142
+ expect(centerCropTo(src, 2)).toEqual(src)
143
+ })
144
+
145
+ it('returns the source unchanged for a degenerate aspect or size, rather than emitting NaNs', () => {
146
+ const src = { sx: 0, sy: 0, sw: 100, sh: 50 }
147
+ expect(centerCropTo(src, 0)).toEqual(src)
148
+ expect(centerCropTo(src, Number.NaN)).toEqual(src)
149
+ expect(centerCropTo({ sx: 0, sy: 0, sw: 0, sh: 0 }, 1)).toEqual({ sx: 0, sy: 0, sw: 0, sh: 0 })
150
+ })
151
+ })
152
+
153
+ // ── Source-time alignment ────────────────────────────────────────────────
154
+
155
+ describe('clipTimeToSourceTime', () => {
156
+ it('anchors the clip\'s own start at source time 0 when inPoint is absent', () => {
157
+ expect(clipTimeToSourceTime(clip({ start: 2 }), 5)).toBe(3)
158
+ })
159
+
160
+ it('anchors at inPoint when present, offsetting 1:1 from there', () => {
161
+ expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10 }), 5)).toBe(13)
162
+ })
163
+
164
+ // ── Per-clip speed ──
165
+ //
166
+ // A clip at speed S eats S× the source per project second. Without this the
167
+ // frames and the per-clip waveform described the same clip differently: the
168
+ // waveform windows to inPoint..outPoint, which is speed-correct by
169
+ // construction, while the frames walked the source at 1×.
170
+
171
+ it('eats source faster on a sped-up clip', () => {
172
+ // 3s into a 2x clip is 6s into the source.
173
+ expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 2 }), 5)).toBe(16)
174
+ })
175
+
176
+ it('eats source slower on a slowed clip', () => {
177
+ // 3s into a 0.5x clip is only 1.5s of source.
178
+ expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 0.5 }), 5)).toBe(11.5)
179
+ })
180
+
181
+ it('is a strict no-op at speed 1 and at no speed at all', () => {
182
+ expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 1 }), 5))
183
+ .toBe(clipTimeToSourceTime(clip({ start: 2, inPoint: 10 }), 5))
184
+ })
185
+
186
+ it('lands on outPoint at the clip\'s end, which is what makes it agree with the waveform', () => {
187
+ // The invariant the two renderers share: at the last frame of the clip,
188
+ // the frames have consumed exactly the window the waveform drew. A clip
189
+ // slowed to 0.5x runs 4s of timeline off 2s of source.
190
+ const item = clip({ start: 2, end: 6, inPoint: 10, outPoint: 12, speed: 0.5 })
191
+ expect(clipTimeToSourceTime(item, item.end)).toBe(item.outPoint)
192
+ })
193
+ })
194
+
195
+ // ── Tile selection ───────────────────────────────────────────────────────
196
+
197
+ describe('flattenTiles', () => {
198
+ it('flattens multiple sheets in order, tagging each tile with its sheet index', () => {
199
+ const index = filmstripIndexFixture({
200
+ sheets: [
201
+ { path: 's0.jpg', cols: 2, rows: 1, tiles: [{ t: 0, row: 0, col: 0 }, { t: 1, row: 0, col: 1 }] },
202
+ { path: 's1.jpg', cols: 2, rows: 1, tiles: [{ t: 2, row: 0, col: 0 }, { t: 3, row: 0, col: 1 }] },
203
+ ],
204
+ })
205
+ expect(flattenTiles(index)).toEqual([
206
+ { t: 0, sheetIdx: 0, row: 0, col: 0 },
207
+ { t: 1, sheetIdx: 0, row: 0, col: 1 },
208
+ { t: 2, sheetIdx: 1, row: 0, col: 0 },
209
+ { t: 3, sheetIdx: 1, row: 0, col: 1 },
210
+ ])
211
+ })
212
+ })
213
+
214
+ describe('nearestTile', () => {
215
+ const tiles = flattenTiles(filmstripIndexFixture({
216
+ sheets: [{ path: 's0.jpg', cols: 3, rows: 1, tiles: [{ t: 0, row: 0, col: 0 }, { t: 2, row: 0, col: 1 }, { t: 4, row: 0, col: 2 }] }],
217
+ }))
218
+
219
+ it('returns null for an empty list', () => {
220
+ expect(nearestTile([], 5)).toBeNull()
221
+ })
222
+
223
+ it('matches an exact t', () => {
224
+ expect(nearestTile(tiles, 2)).toMatchObject({ t: 2 })
225
+ })
226
+
227
+ it('picks the nearer neighbour on either side', () => {
228
+ expect(nearestTile(tiles, 2.9)).toMatchObject({ t: 2 })
229
+ expect(nearestTile(tiles, 3.1)).toMatchObject({ t: 4 })
230
+ })
231
+
232
+ it('ties toward the earlier tile', () => {
233
+ expect(nearestTile(tiles, 3)).toMatchObject({ t: 2 })
234
+ })
235
+
236
+ it('clamps to the end tiles before the first and after the last', () => {
237
+ expect(nearestTile(tiles, -5)).toMatchObject({ t: 0 })
238
+ expect(nearestTile(tiles, 100)).toMatchObject({ t: 4 })
239
+ })
240
+ })
241
+
242
+ // ── Source-rect math ─────────────────────────────────────────────────────
243
+
244
+ describe('tileSourceRect', () => {
245
+ it('divides the loaded sheet image evenly by the sheet\'s cols/rows', () => {
246
+ const sheet: FilmstripSheet = { path: 's.jpg', cols: 5, rows: 2, tiles: [] }
247
+ expect(tileSourceRect(sheet, { row: 1, col: 3 }, 800, 320)).toEqual({ sx: 480, sy: 160, sw: 160, sh: 160 })
248
+ })
249
+
250
+ it('uses the loaded image\'s own dimensions, not a nominal tileWidth', () => {
251
+ const sheet: FilmstripSheet = { path: 's.jpg', cols: 4, rows: 1, tiles: [] }
252
+ // A sheet whose actual pixels don't divide evenly by a nominal 160 tileWidth.
253
+ expect(tileSourceRect(sheet, { row: 0, col: 2 }, 601, 150)).toEqual({ sx: 300.5, sy: 0, sw: 150.25, sh: 150 })
254
+ })
255
+ })
256
+
257
+ // ── Default loader ───────────────────────────────────────────────────────
258
+
259
+ describe('defaultSheetImageLoader', () => {
260
+ it('is exported as the store\'s default (injectable) loader', () => {
261
+ expect(typeof defaultSheetImageLoader).toBe('function')
262
+ })
263
+ })
264
+
265
+ // ── Draw primitives ──────────────────────────────────────────────────────
266
+
267
+ describe('drawFilmstripTiles', () => {
268
+ it('emits one drawImage per tile at its own destination rect', () => {
269
+ const r = recordingContext()
270
+ const tiles: FilmstripTileDraw[] = [
271
+ { image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 160, sh: 90, rect: { x: 10, y: 5, width: 40, height: 56 } },
272
+ { image: {} as CanvasImageSource, sx: 160, sy: 0, sw: 160, sh: 90, rect: { x: 50, y: 5, width: 40, height: 56 } },
273
+ ]
274
+ drawFilmstripTiles(r.ctx, tiles)
275
+
276
+ const calls = r.of('drawImage')
277
+ expect(calls).toHaveLength(2)
278
+ expect(calls[0].args.slice(5)).toEqual([10, 5, 40, 56])
279
+ expect(calls[1].args.slice(5)).toEqual([50, 5, 40, 56])
280
+ })
281
+
282
+ it('skips a degenerate destination rect (zero width or height)', () => {
283
+ const r = recordingContext()
284
+ drawFilmstripTiles(r.ctx, [
285
+ { image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 10, sh: 10, rect: { x: 0, y: 0, width: 0, height: 40 } },
286
+ { image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 10, sh: 10, rect: { x: 0, y: 0, width: 40, height: 0 } },
287
+ ])
288
+ expect(r.of('drawImage')).toHaveLength(0)
289
+ })
290
+ })
291
+
292
+ describe('FilmstripStore.clipTiles fetch policy', () => {
293
+ const zoomedOut: Viewport = { pxPerSecond: 12, scrollSeconds: 0, widthPx: 1000 }
294
+ const zoomedIn: Viewport = { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } // visible range [0, 5]
295
+
296
+ it('fetches when zoomed out, where the old 160px/s threshold used to suppress it', () => {
297
+ // The gate is gone on purpose: cells are sized by the frames band now, so
298
+ // zooming out yields fewer full-size frames rather than a row of slivers.
299
+ const store = new FilmstripStore()
300
+ const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
301
+ store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedOut }))
302
+ expect(fetcher).toHaveBeenCalledTimes(1)
303
+ })
304
+
305
+ it('still does not fetch when the clip is off the visible range', () => {
306
+ const store = new FilmstripStore()
307
+ const fetcher = vi.fn()
308
+ store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 100, end: 105 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
309
+ expect(fetcher).not.toHaveBeenCalled()
310
+ })
311
+
312
+ it('fetches once the clip is visible', () => {
313
+ const store = new FilmstripStore()
314
+ const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
315
+ store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
316
+ expect(fetcher).toHaveBeenCalledTimes(1)
317
+ })
318
+
319
+ it('does not fetch for a clip whose frames band has no height', () => {
320
+ const store = new FilmstripStore()
321
+ const fetcher = vi.fn()
322
+ store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000, height: 0 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
323
+ expect(fetcher).not.toHaveBeenCalled()
324
+ })
325
+
326
+ it('never fetches when the item has no proxySrc', () => {
327
+ const store = new FilmstripStore()
328
+ const fetcher = vi.fn()
329
+ store.clipTiles(clip({ start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
330
+ expect(fetcher).not.toHaveBeenCalled()
331
+ })
332
+
333
+ it('never fetches for a non-video item even with a proxySrc set', () => {
334
+ const store = new FilmstripStore()
335
+ const fetcher = vi.fn()
336
+ const imageItem = clip({ type: 'image', proxySrc: 'proxy.mp4', start: 0, end: 5 } as Partial<VisualItem> as VisualItem)
337
+ store.clipTiles(imageItem, rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
338
+ expect(fetcher).not.toHaveBeenCalled()
339
+ })
340
+
341
+ it('fires a fetch once a proxySrc arrives (SSE import completion), since the key changes', () => {
342
+ const store = new FilmstripStore()
343
+ const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
344
+
345
+ store.clipTiles(clip({ start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
346
+ expect(fetcher).not.toHaveBeenCalled()
347
+
348
+ store.clipTiles(clip({ start: 0, end: 5, proxySrc: 'proxy.mp4' }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
349
+ expect(fetcher).toHaveBeenCalledTimes(1)
350
+ })
351
+
352
+ it('stays inert (never fetches, never throws) when the adapter has no getFilmstrip', () => {
353
+ const store = new FilmstripStore()
354
+ expect(() =>
355
+ store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: undefined, viewport: zoomedIn })),
356
+ ).not.toThrow()
357
+ })
358
+ })
359
+
360
+ // ── Sheet-load laziness ────────────────────────────────────────────────────
361
+
362
+ describe('FilmstripStore.clipTiles geometry', () => {
363
+ /** Drive the store to the point where its index AND sheet image are both
364
+ * resolved, then return the tiles it lays out for a clip drawn at `pxPerSecond`. */
365
+ async function laidOut(pxPerSecond: number, image?: LoadedSheetImage) {
366
+ const store = new FilmstripStore()
367
+ const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 10 })
368
+ const viewport: Viewport = { pxPerSecond, scrollSeconds: 0, widthPx: 1000 }
369
+ // The rect the painter would compute: the clip's own on-screen width.
370
+ const r = rect({ width: (item.end - item.start) * pxPerSecond })
371
+ const ctx = () => queryCtx(image ? { viewport, loader: fakeLoader(image) } : { viewport })
372
+
373
+ store.clipTiles(item, r, ctx()) // starts the index fetch
374
+ await flush()
375
+ store.clipTiles(item, r, ctx()) // index ready — starts the sheet fetch
376
+ await flush()
377
+ return { tiles: store.clipTiles(item, r, ctx()) ?? [], rect: r }
378
+ }
379
+
380
+ it('lays every tile inside the clip\'s frames band, never over the waveform half', async () => {
381
+ const { tiles, rect: r } = await laidOut(60)
382
+ const band = clipBands(r).frames
383
+
384
+ expect(tiles.length).toBeGreaterThan(0)
385
+ for (const tile of tiles) {
386
+ expect(tile.rect.y).toBe(band.y)
387
+ expect(tile.rect.height).toBe(band.height)
388
+ }
389
+ })
390
+
391
+ it('sizes each full cell square to the band height, so frames are never stretched', async () => {
392
+ const { tiles, rect: r } = await laidOut(60)
393
+ const band = clipBands(r).frames
394
+
395
+ // The final cell is clipped by the clip's right edge; every other one is a
396
+ // full square.
397
+ for (const tile of tiles.slice(0, -1)) expect(tile.rect.width).toBe(band.height)
398
+ expect(tiles[tiles.length - 1].rect.width).toBeLessThanOrEqual(band.height)
399
+ })
400
+
401
+ it('crops each tile to its cell rather than squeezing a 9:16 frame into it', async () => {
402
+ // A 450x1600 sheet over 5 cols x 2 rows gives 90x800 tiles — vertical
403
+ // footage, the shape this editor actually cuts. Into a square cell the
404
+ // crop must keep the full width and take a centered square of the height.
405
+ const { tiles } = await laidOut(60, loadedImage(450, 1600))
406
+ const first = tiles[0]
407
+
408
+ expect(first.sw).toBe(90) // full tile width, untouched
409
+ expect(first.sh).toBe(90) // 800 cropped down to a square
410
+ expect(first.sy).toBe(355) // centered: (800 - 90) / 2
411
+ })
412
+
413
+ it('walks further through the source per cell as you zoom out', async () => {
414
+ // The point of sizing cells by the band instead of by the tile interval:
415
+ // the same strip re-derives which frames it shows at every zoom. Zoomed
416
+ // out, each cell spans seconds and lands on a different tile; zoomed in,
417
+ // cells span fractions of a second and several share the nearest tile.
418
+ const outCells = (await laidOut(12)).tiles
419
+ const inCells = (await laidOut(200)).tiles
420
+ const distinct = (tiles: typeof outCells) => new Set(tiles.map(t => `${t.sx},${t.sy}`)).size
421
+
422
+ expect(distinct(outCells)).toBe(outCells.length)
423
+ expect(distinct(inCells)).toBeLessThan(inCells.length)
424
+ })
425
+ })
426
+
427
+ describe('FilmstripStore sheet-load laziness', () => {
428
+ it('only requests the sheet(s) whose tiles are needed for the visible range', async () => {
429
+ const store = new FilmstripStore()
430
+ const index = filmstripIndexFixture({
431
+ interval: 1,
432
+ sheets: [
433
+ { path: 'sheet0.jpg', cols: 5, rows: 2, tiles: Array.from({ length: 10 }, (_, i) => ({ t: i, row: Math.floor(i / 5), col: i % 5 })) },
434
+ { path: 'sheet1.jpg', cols: 5, rows: 2, tiles: Array.from({ length: 10 }, (_, i) => ({ t: 10 + i, row: Math.floor(i / 5), col: i % 5 })) },
435
+ ],
436
+ })
437
+ const urls: string[] = []
438
+ const loader: SheetImageLoader = vi.fn((url: string) => { urls.push(url); return Promise.resolve(loadedImage()) })
439
+ const item = clip({ start: 0, end: 20, proxySrc: 'proxy.mp4' })
440
+ // Only the first 1000px (5s at 200px/s) of the 20s clip is "on screen" —
441
+ // the shape a `clampRectToSurface`d rect has for a clip scrolled partly
442
+ // off the right edge.
443
+ const onScreenRect = rect({ x: 0, width: 1000, height: 56 })
444
+ const ctx = queryCtx({ getFilmstrip: vi.fn().mockResolvedValue(index), loader, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
445
+
446
+ store.clipTiles(item, onScreenRect, ctx)
447
+ await flush()
448
+ store.clipTiles(item, onScreenRect, ctx) // index now ready — this call requests the needed sheet(s)
449
+
450
+ expect(urls.some(u => u.includes('sheet0'))).toBe(true)
451
+ expect(urls.some(u => u.includes('sheet1'))).toBe(false)
452
+ })
453
+ })
454
+
455
+ // ── Quiet retry ──────────────────────────────────────────────────────────
456
+
457
+ describe('FilmstripStore quiet retry', () => {
458
+ it('swallows a rejected index fetch quietly and retries on the next call once cooldown elapses', async () => {
459
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
460
+ const store = new FilmstripStore()
461
+ const onReady = vi.fn()
462
+ const fetcher = vi.fn()
463
+ .mockRejectedValueOnce(new Error('boom'))
464
+ .mockResolvedValueOnce(filmstripIndexFixture())
465
+ const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 })
466
+ const ctx = () => queryCtx({ getFilmstrip: fetcher, onReady, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
467
+
468
+ expect(() => store.clipTiles(item, rect({ width: 1000 }), ctx())).not.toThrow()
469
+ await flush()
470
+ expect(onReady).not.toHaveBeenCalled()
471
+ expect(fetcher).toHaveBeenCalledTimes(1)
472
+
473
+ nowSpy.mockReturnValue(FETCH_RETRY_COOLDOWN_MS + 1)
474
+ store.clipTiles(item, rect({ width: 1000 }), ctx())
475
+ await flush()
476
+ expect(fetcher).toHaveBeenCalledTimes(2)
477
+ expect(onReady).toHaveBeenCalledTimes(1)
478
+ nowSpy.mockRestore()
479
+ })
480
+
481
+ it('does not retry a still-cooling-down error on the very next call (paint-rate request storm)', async () => {
482
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
483
+ const store = new FilmstripStore()
484
+ const onReady = vi.fn()
485
+ const fetcher = vi.fn().mockRejectedValueOnce(new Error('boom'))
486
+ const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 })
487
+ const ctx = () => queryCtx({ getFilmstrip: fetcher, onReady, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
488
+
489
+ store.clipTiles(item, rect({ width: 1000 }), ctx())
490
+ await flush()
491
+ expect(fetcher).toHaveBeenCalledTimes(1)
492
+
493
+ // Same mocked Date.now() as the error — still in cooldown, no refetch.
494
+ store.clipTiles(item, rect({ width: 1000 }), ctx())
495
+ await flush()
496
+ expect(fetcher).toHaveBeenCalledTimes(1)
497
+ nowSpy.mockRestore()
498
+ })
499
+ })
500
+
501
+ // ── Draw integration via drawTimelineContent (draw.ts) ────────────────────
502
+
503
+ function project(over: Partial<Project> = {}): Project {
504
+ return { id: 'p1', ...over } as unknown as Project
505
+ }
506
+
507
+ function viewport(over: Partial<Viewport> = {}): Viewport {
508
+ return { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000, ...over }
509
+ }
510
+
511
+ function scene(over: Partial<TimelineScene> = {}): TimelineScene {
512
+ const p = over.project ?? project()
513
+ return {
514
+ project: p,
515
+ viewport: viewport(),
516
+ layout: computeTimelineLayout(p),
517
+ selectedIds: [],
518
+ surfaceWidth: 1000,
519
+ surfaceHeight: 200,
520
+ ...over,
521
+ }
522
+ }
523
+
524
+ describe('drawTimelineContent filmstrip + waveform composition', () => {
525
+ it('draws the filmstrip background and the waveform overlay together, in one drawContent hook', () => {
526
+ const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4, proxySrc: 'proxy.mp4' })] }] })
527
+ const r = recordingContext()
528
+ const tiles: FilmstripTileDraw[] = [{ image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 160, sh: 90, rect: { x: 0, y: 0, width: 800, height: 56 } }]
529
+ const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -0.5, max: 0.5 }]
530
+
531
+ drawTimelineContent(r.ctx, scene({
532
+ project: p,
533
+ layout: computeTimelineLayout(p),
534
+ filmstrips: { clipTiles: (item) => (item.id === 'c0' ? tiles : null) },
535
+ waveforms: { clipColumns: (item) => (item.id === 'c0' ? columns : null), audioColumns: () => null },
536
+ }))
537
+
538
+ expect(r.of('drawImage')).toHaveLength(1) // the filmstrip tile
539
+ // fill + border + 2 waveform bars, at minimum.
540
+ expect(r.of('fillRect').length).toBeGreaterThanOrEqual(4)
541
+ })
542
+
543
+ it('degrades to exactly T6\'s original waveform-only draw-call count when no filmstrip provider is set', () => {
544
+ const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4 })] }] })
545
+ const withScene = recordingContext()
546
+ drawTimelineContent(withScene.ctx, scene({
547
+ project: p,
548
+ layout: computeTimelineLayout(p),
549
+ waveforms: { clipColumns: () => null, audioColumns: () => null },
550
+ }))
551
+ const withoutFilmstripsKey = recordingContext()
552
+ drawTimelineContent(withoutFilmstripsKey.ctx, scene({
553
+ project: p,
554
+ layout: computeTimelineLayout(p),
555
+ filmstrips: undefined,
556
+ waveforms: { clipColumns: () => null, audioColumns: () => null },
557
+ }))
558
+ expect(withScene.calls.length).toBe(withoutFilmstripsKey.calls.length)
559
+ expect(withScene.of('drawImage')).toHaveLength(0)
560
+ })
561
+ })