@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,277 @@
1
+ /**
2
+ * FilmstripScrubber — a self-contained hover-scrub thumbnail for one source
3
+ * clip, built for the footage-bin media panel (`docs/plans/footage-bin-media-panel.md`,
4
+ * Phase 2). Unlike the timeline's own `FilmstripStore` (`timeline/canvas/filmstrips.ts`),
5
+ * which tiles the VISIBLE portion of a placed clip across the whole timeline
6
+ * viewport, this component draws exactly ONE tile at a time into a single
7
+ * `<canvas>` sized to its container: the poster frame (source time 0) at rest,
8
+ * and whichever tile is nearest the pointer's horizontal fraction while
9
+ * hovering. It reuses that module's pure tile-selection and blit primitives
10
+ * (`flattenTiles`, `nearestTile`, `tileSourceRect`, `centerCropTo`,
11
+ * `drawFilmstripTiles`, `defaultSheetImageLoader`) rather than duplicating them.
12
+ *
13
+ * `getFilmstrip` is proxy-only and tiles the WHOLE source (no time window), so
14
+ * the index is fetched exactly once per `(projectId, proxySrc)` pair — never
15
+ * re-fetched on hover. Decoded sheet images are cached in a ref, keyed by
16
+ * sheet path, so scrubbing across many tiles from the same sheet never
17
+ * re-decodes it.
18
+ */
19
+
20
+ import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
21
+ import type { FilmstripIndex, FilmstripSheet, GetFilmstripArgs } from '../types'
22
+ import {
23
+ centerCropTo,
24
+ defaultSheetImageLoader,
25
+ drawFilmstripTiles,
26
+ flattenTiles,
27
+ nearestTile,
28
+ tileSourceRect,
29
+ type FlatTile,
30
+ type LoadedSheetImage,
31
+ } from '../video/timeline/canvas/filmstrips'
32
+ import { Loader } from '../ui/Loader'
33
+
34
+ export interface FilmstripScrubberProps {
35
+ projectId: string
36
+ /** The source's proxySrc — `getFilmstrip` is proxy-only, per the timeline's
37
+ * own filmstrip fetch policy (see `filmstrips.ts`'s module doc). */
38
+ proxySrc: string
39
+ /** Seconds, for mapping a hover fraction to a source-file time. */
40
+ sourceDuration: number
41
+ getFilmstrip: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
42
+ fileUrl: (path: string) => string
43
+ className?: string
44
+ ariaLabel?: string
45
+ /**
46
+ * Whether hovering scrubs. Default `true` (the footage-bin thumbnail). When
47
+ * `false`, no pointer-move handler is attached and only the static poster
48
+ * frame (source time 0) is drawn — for a card that owns its own hover
49
+ * handling and drives the main preview instead.
50
+ */
51
+ interactive?: boolean
52
+ /**
53
+ * How the tile fills the canvas. Default `'cover'` (center-crop, unchanged for
54
+ * existing callers). `'contain'` letterboxes the whole tile — preserving its
55
+ * aspect ratio, centered, the remaining area left to the container's neutral
56
+ * background — for an aspect-correct poster.
57
+ */
58
+ fit?: 'cover' | 'contain'
59
+ }
60
+
61
+ type Phase = 'loading' | 'ready' | 'error'
62
+
63
+ /** Clamp `v` into `[lo, hi]`. */
64
+ function clamp(v: number, lo: number, hi: number): number {
65
+ return Math.min(hi, Math.max(lo, v))
66
+ }
67
+
68
+ export default function FilmstripScrubber({
69
+ projectId,
70
+ proxySrc,
71
+ sourceDuration,
72
+ getFilmstrip,
73
+ fileUrl,
74
+ className = '',
75
+ ariaLabel = 'Footage preview',
76
+ interactive = true,
77
+ fit = 'cover',
78
+ }: FilmstripScrubberProps) {
79
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
80
+
81
+ // Keep the injected callbacks in refs so the fetch effect below can depend
82
+ // only on `[projectId, proxySrc]` — a fresh closure identity on every host
83
+ // render (common for inline adapter callbacks) must not trigger a re-fetch.
84
+ const getFilmstripRef = useRef(getFilmstrip)
85
+ useEffect(() => { getFilmstripRef.current = getFilmstrip }, [getFilmstrip])
86
+ const fileUrlRef = useRef(fileUrl)
87
+ useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
88
+
89
+ const [phase, setPhase] = useState<Phase>('loading')
90
+ const indexRef = useRef<FilmstripIndex | null>(null)
91
+ const tilesRef = useRef<FlatTile[]>([])
92
+ const imageCacheRef = useRef(new Map<string, LoadedSheetImage>())
93
+ const lastTileRef = useRef<FlatTile | null>(null)
94
+ // Monotonic guard so a slow-resolving image load from an earlier hover
95
+ // position can never paint over a newer one that already loaded/painted.
96
+ const drawRequestIdRef = useRef(0)
97
+
98
+ // ── Fetch the index once per (projectId, proxySrc) ──────────────────────
99
+ useEffect(() => {
100
+ let cancelled = false
101
+ setPhase('loading')
102
+ indexRef.current = null
103
+ tilesRef.current = []
104
+ lastTileRef.current = null
105
+
106
+ getFilmstripRef.current({ projectId, src: proxySrc })
107
+ .then(index => {
108
+ if (cancelled) return
109
+ indexRef.current = index
110
+ tilesRef.current = flattenTiles(index)
111
+ setPhase('ready')
112
+ })
113
+ .catch(() => {
114
+ if (cancelled) return
115
+ setPhase('error')
116
+ })
117
+
118
+ return () => { cancelled = true }
119
+ }, [projectId, proxySrc])
120
+
121
+ // ── Canvas backing-store sizing (DPR-aware) ──────────────────────────────
122
+ useEffect(() => {
123
+ const canvas = canvasRef.current
124
+ if (!canvas) return
125
+
126
+ const resize = () => {
127
+ const rect = canvas.getBoundingClientRect()
128
+ const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
129
+ const width = Math.max(1, Math.round(rect.width * dpr))
130
+ const height = Math.max(1, Math.round(rect.height * dpr))
131
+ if (canvas.width === width && canvas.height === height) return
132
+ canvas.width = width
133
+ canvas.height = height
134
+ // Writing width/height clears the canvas; repaint whatever was showing.
135
+ if (lastTileRef.current) drawTile(lastTileRef.current)
136
+ }
137
+
138
+ resize()
139
+
140
+ if (typeof ResizeObserver !== 'undefined') {
141
+ const ro = new ResizeObserver(() => resize())
142
+ ro.observe(canvas)
143
+ return () => ro.disconnect()
144
+ }
145
+ return undefined
146
+ // Runs once: the observer (where available) handles every subsequent
147
+ // layout change itself, independent of `phase`.
148
+ }, [])
149
+
150
+ // ── Draw the poster frame once the index is ready ───────────────────────
151
+ useEffect(() => {
152
+ if (phase !== 'ready') return
153
+ const tile = nearestTile(tilesRef.current, 0)
154
+ if (tile) drawTile(tile)
155
+ }, [phase])
156
+
157
+ /** Paint one tile into the canvas — center-cropped to fill (`cover`), or
158
+ * letterboxed to preserve aspect (`contain`). */
159
+ function paintTile(sheet: FilmstripSheet, tile: FlatTile, img: LoadedSheetImage) {
160
+ const canvas = canvasRef.current
161
+ if (!canvas) return
162
+ const ctx = canvas.getContext('2d')
163
+ if (!ctx) return
164
+ const destWidth = canvas.width
165
+ const destHeight = canvas.height
166
+ if (destWidth <= 0 || destHeight <= 0) return
167
+
168
+ ctx.clearRect(0, 0, destWidth, destHeight)
169
+
170
+ if (fit === 'contain') {
171
+ // Whole tile, scaled to fit inside the canvas, centered. The remaining
172
+ // area is left cleared (transparent) so the container's neutral surface
173
+ // shows through as letterbox bars.
174
+ const full = tileSourceRect(sheet, tile, img.width, img.height)
175
+ if (full.sw > 0 && full.sh > 0) {
176
+ const srcAspect = full.sw / full.sh
177
+ const destAspect = destWidth / destHeight
178
+ const dw = srcAspect > destAspect ? destWidth : destHeight * srcAspect
179
+ const dh = srcAspect > destAspect ? destWidth / srcAspect : destHeight
180
+ drawFilmstripTiles(ctx, [{
181
+ image: img.image,
182
+ sx: full.sx,
183
+ sy: full.sy,
184
+ sw: full.sw,
185
+ sh: full.sh,
186
+ rect: { x: (destWidth - dw) / 2, y: (destHeight - dh) / 2, width: dw, height: dh },
187
+ }])
188
+ }
189
+ lastTileRef.current = tile
190
+ return
191
+ }
192
+
193
+ const src = centerCropTo(
194
+ tileSourceRect(sheet, tile, img.width, img.height),
195
+ destWidth / destHeight,
196
+ )
197
+ drawFilmstripTiles(ctx, [{
198
+ image: img.image,
199
+ sx: src.sx,
200
+ sy: src.sy,
201
+ sw: src.sw,
202
+ sh: src.sh,
203
+ rect: { x: 0, y: 0, width: destWidth, height: destHeight },
204
+ }])
205
+ lastTileRef.current = tile
206
+ }
207
+
208
+ /** Resolve `tile`'s sheet image (cache or load) and paint it. Stale loads
209
+ * (superseded by a later call before they resolve) are dropped. */
210
+ function drawTile(tile: FlatTile) {
211
+ const index = indexRef.current
212
+ if (!index) return
213
+ const sheet = index.sheets[tile.sheetIdx]
214
+ if (!sheet) return
215
+
216
+ const cached = imageCacheRef.current.get(sheet.path)
217
+ if (cached) {
218
+ paintTile(sheet, tile, cached)
219
+ return
220
+ }
221
+
222
+ const requestId = ++drawRequestIdRef.current
223
+ defaultSheetImageLoader(fileUrlRef.current(sheet.path))
224
+ .then(img => {
225
+ imageCacheRef.current.set(sheet.path, img)
226
+ if (drawRequestIdRef.current !== requestId) return
227
+ paintTile(sheet, tile, img)
228
+ })
229
+ .catch(() => {
230
+ // Quiet failure: the canvas keeps showing whatever it last painted
231
+ // (or the neutral placeholder if nothing has painted yet).
232
+ })
233
+ }
234
+
235
+ const posterTile = useMemo(() => nearestTile(tilesRef.current, 0), [phase])
236
+
237
+ function handlePointerMove(e: ReactPointerEvent<HTMLCanvasElement>) {
238
+ if (phase !== 'ready' || tilesRef.current.length === 0) return
239
+ const rect = e.currentTarget.getBoundingClientRect()
240
+ if (rect.width <= 0) return
241
+ const fraction = clamp((e.clientX - rect.left) / rect.width, 0, 1)
242
+ const sourceT = fraction * sourceDuration
243
+ const tile = nearestTile(tilesRef.current, sourceT)
244
+ if (tile) drawTile(tile)
245
+ }
246
+
247
+ function handlePointerLeave() {
248
+ if (phase !== 'ready') return
249
+ if (posterTile) drawTile(posterTile)
250
+ }
251
+
252
+ return (
253
+ <div className={`relative overflow-hidden bg-[var(--editor-surface)] ${className}`}>
254
+ <canvas
255
+ ref={canvasRef}
256
+ role="img"
257
+ aria-label={ariaLabel}
258
+ className="block h-full w-full"
259
+ onPointerMove={interactive ? handlePointerMove : undefined}
260
+ onPointerLeave={interactive ? handlePointerLeave : undefined}
261
+ />
262
+ {phase === 'loading' && (
263
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
264
+ <Loader size="sm" />
265
+ </div>
266
+ )}
267
+ {phase === 'error' && (
268
+ <div
269
+ className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[var(--editor-text)]/40"
270
+ role="status"
271
+ >
272
+ No preview
273
+ </div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
@@ -0,0 +1,216 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * FilmstripScrubber — smoke test. jsdom has no 2D canvas and never fires a
4
+ * real `Image` load event, so both are stubbed (same shape as
5
+ * `TimelineCanvas.test.tsx`'s recorder and `ReadOnlySlide.test.tsx`'s
6
+ * ResizeObserver mock). What matters here: the index is fetched exactly once
7
+ * with `{ projectId, src: proxySrc }`, the component renders a canvas without
8
+ * throwing, and a resolved index eventually clears the loading state.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
+ import { render, waitFor, cleanup, fireEvent } from '@testing-library/react'
12
+ import type { FilmstripIndex } from '../../types'
13
+ import FilmstripScrubber from '../FilmstripScrubber'
14
+
15
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
16
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
17
+ let realImage: typeof Image
18
+
19
+ const drawImageCalls: unknown[][] = []
20
+
21
+ class FakeImage {
22
+ onload: (() => void) | null = null
23
+ onerror: (() => void) | null = null
24
+ naturalWidth = 320
25
+ naturalHeight = 320
26
+ width = 320
27
+ height = 320
28
+ private _src = ''
29
+ get src() { return this._src }
30
+ set src(v: string) {
31
+ this._src = v
32
+ // Simulate an async decode, same as a real <img> firing `load` later.
33
+ queueMicrotask(() => this.onload?.())
34
+ }
35
+ }
36
+
37
+ beforeEach(() => {
38
+ drawImageCalls.length = 0
39
+
40
+ realGetContext = HTMLCanvasElement.prototype.getContext
41
+ HTMLCanvasElement.prototype.getContext = function () {
42
+ return {
43
+ clearRect: () => {},
44
+ drawImage: (...args: unknown[]) => { drawImageCalls.push(args) },
45
+ } as unknown as CanvasRenderingContext2D
46
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
47
+
48
+ realGetRect = Element.prototype.getBoundingClientRect
49
+ Element.prototype.getBoundingClientRect = function () {
50
+ return { x: 0, y: 0, top: 0, left: 0, right: 200, bottom: 120, width: 200, height: 120, toJSON: () => ({}) } as DOMRect
51
+ }
52
+
53
+ realImage = globalThis.Image
54
+ ;(globalThis as unknown as { Image: unknown }).Image = FakeImage
55
+ })
56
+
57
+ afterEach(() => {
58
+ cleanup()
59
+ vi.restoreAllMocks()
60
+ HTMLCanvasElement.prototype.getContext = realGetContext
61
+ Element.prototype.getBoundingClientRect = realGetRect
62
+ ;(globalThis as unknown as { Image: unknown }).Image = realImage
63
+ })
64
+
65
+ const fakeIndex: FilmstripIndex = {
66
+ sheets: [{
67
+ path: 'proj1/filmstrips/proxy-0.jpg',
68
+ cols: 2,
69
+ rows: 1,
70
+ tiles: [{ t: 0, row: 0, col: 0 }, { t: 5, row: 0, col: 1 }],
71
+ }],
72
+ interval: 5,
73
+ tileWidth: 160,
74
+ }
75
+
76
+ describe('FilmstripScrubber', () => {
77
+ it('fetches the filmstrip index once with { projectId, src: proxySrc } and renders a canvas', async () => {
78
+ const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
79
+ const fileUrl = vi.fn((path: string) => `https://cdn.example/${path}`)
80
+
81
+ const { container } = render(
82
+ <FilmstripScrubber
83
+ projectId="proj1"
84
+ proxySrc="proxy.mp4"
85
+ sourceDuration={10}
86
+ getFilmstrip={getFilmstrip}
87
+ fileUrl={fileUrl}
88
+ />,
89
+ )
90
+
91
+ expect(getFilmstrip).toHaveBeenCalledTimes(1)
92
+ expect(getFilmstrip).toHaveBeenCalledWith({ projectId: 'proj1', src: 'proxy.mp4' })
93
+
94
+ const canvas = container.querySelector('canvas')
95
+ expect(canvas).toBeTruthy()
96
+
97
+ // Poster frame paints once the index resolves and the sheet image decodes.
98
+ await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
99
+ expect(fileUrl).toHaveBeenCalledWith('proj1/filmstrips/proxy-0.jpg')
100
+
101
+ // The loading placeholder is gone once the index has resolved.
102
+ await waitFor(() => expect(container.querySelector('[role="status"]')).toBeNull())
103
+ })
104
+
105
+ it('does not crash and shows a neutral state when getFilmstrip rejects', async () => {
106
+ const getFilmstrip = vi.fn().mockRejectedValue(new Error('no filmstrip'))
107
+ const fileUrl = vi.fn((path: string) => path)
108
+
109
+ const { container } = render(
110
+ <FilmstripScrubber
111
+ projectId="proj1"
112
+ proxySrc="proxy.mp4"
113
+ sourceDuration={10}
114
+ getFilmstrip={getFilmstrip}
115
+ fileUrl={fileUrl}
116
+ />,
117
+ )
118
+
119
+ expect(container.querySelector('canvas')).toBeTruthy()
120
+ await waitFor(() => expect(container.querySelector('[role="status"]')?.textContent).toBe('No preview'))
121
+ })
122
+
123
+ it('re-fetches when proxySrc changes but not on a mere callback-identity change', async () => {
124
+ const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
125
+ const fileUrl = (path: string) => path
126
+
127
+ const { rerender } = render(
128
+ <FilmstripScrubber
129
+ projectId="proj1"
130
+ proxySrc="proxy-a.mp4"
131
+ sourceDuration={10}
132
+ getFilmstrip={getFilmstrip}
133
+ fileUrl={fileUrl}
134
+ />,
135
+ )
136
+ await waitFor(() => expect(getFilmstrip).toHaveBeenCalledTimes(1))
137
+
138
+ // Fresh closure identity, same proxySrc: must NOT trigger a re-fetch.
139
+ const getFilmstrip2 = vi.fn().mockResolvedValue(fakeIndex)
140
+ rerender(
141
+ <FilmstripScrubber
142
+ projectId="proj1"
143
+ proxySrc="proxy-a.mp4"
144
+ sourceDuration={10}
145
+ getFilmstrip={getFilmstrip2}
146
+ fileUrl={fileUrl}
147
+ />,
148
+ )
149
+ expect(getFilmstrip2).not.toHaveBeenCalled()
150
+
151
+ // A real proxySrc change fetches again (via whichever callback is current).
152
+ rerender(
153
+ <FilmstripScrubber
154
+ projectId="proj1"
155
+ proxySrc="proxy-b.mp4"
156
+ sourceDuration={10}
157
+ getFilmstrip={getFilmstrip2}
158
+ fileUrl={fileUrl}
159
+ />,
160
+ )
161
+ await waitFor(() => expect(getFilmstrip2).toHaveBeenCalledWith({ projectId: 'proj1', src: 'proxy-b.mp4' }))
162
+ })
163
+
164
+ it('attaches no scrub handler when interactive={false}', async () => {
165
+ const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
166
+ const fileUrl = (path: string) => path
167
+
168
+ const { container } = render(
169
+ <FilmstripScrubber
170
+ projectId="proj1"
171
+ proxySrc="proxy.mp4"
172
+ sourceDuration={10}
173
+ getFilmstrip={getFilmstrip}
174
+ fileUrl={fileUrl}
175
+ interactive={false}
176
+ />,
177
+ )
178
+
179
+ const canvas = container.querySelector('canvas')!
180
+ // The static poster still paints once the index/sheet resolve.
181
+ await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
182
+ const afterPoster = drawImageCalls.length
183
+
184
+ // A pointer move must NOT scrub (no handler attached) — no extra draw.
185
+ fireEvent.pointerMove(canvas, { clientX: 150 })
186
+ expect(drawImageCalls.length).toBe(afterPoster)
187
+ })
188
+
189
+ it('letterboxes the poster when fit="contain"', async () => {
190
+ const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
191
+ const fileUrl = (path: string) => path
192
+
193
+ render(
194
+ <FilmstripScrubber
195
+ projectId="proj1"
196
+ proxySrc="proxy.mp4"
197
+ sourceDuration={10}
198
+ getFilmstrip={getFilmstrip}
199
+ fileUrl={fileUrl}
200
+ interactive={false}
201
+ fit="contain"
202
+ />,
203
+ )
204
+
205
+ await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
206
+ const last = drawImageCalls[drawImageCalls.length - 1]
207
+ // Canvas is 200×120 (mocked rect, dpr 1). The poster tile is one column of a
208
+ // 320×320 two-col sheet → a 160×320 portrait rect (aspect 0.5). Contained in
209
+ // the landscape canvas it pillarboxes to a 60×120 box centered at x=70.
210
+ // args = [image, sx, sy, sw, sh, destX, destY, destW, destH].
211
+ expect(last[7]).toBe(60) // dest width
212
+ expect(last[8]).toBe(120) // dest height
213
+ expect(last[5]).toBe(70) // dest x (centered)
214
+ expect(last[6]).toBe(0) // dest y
215
+ })
216
+ })