@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,111 @@
1
+ /**
2
+ * Fade-envelope shapes (Vegas Pro's first three: linear, logarithmic,
3
+ * exponential) — the pure math shared by three consumers that must never
4
+ * disagree about what a given shape sounds/looks like: the envelope curve
5
+ * drawn on an audio bar (`draw.ts`'s `drawFadeEnvelope`), the waveform's own
6
+ * amplitude scaling under a fade (`waveforms.ts`'s `drawWaveformBars`), and
7
+ * the rendered mix (`mix-audio.js`'s ffmpeg `afade` filter, via a name
8
+ * mapping kept in that file since ffmpeg's vocabulary — `tri`/`exp`/`log` —
9
+ * is render-only and has no reason to leak into the editor).
10
+ *
11
+ * `p` is always position ACROSS THE FADE, 0 at the silent edge (the clip's
12
+ * own start for a fade-in, its own end for a fade-out) and 1 at the fade's
13
+ * full-volume inner edge — never raw pixels or seconds, so one function
14
+ * serves both a fade-in and a fade-out by construction.
15
+ */
16
+
17
+ export type FadeCurve = 'linear' | 'log' | 'exp'
18
+
19
+ /** Exponential was the only shape before curves existed (`drawFadeEnvelope`'s
20
+ * quadratic ease) — keeping it the default means every project on disk today
21
+ * looks exactly as it did until an operator picks a different shape. */
22
+ export const DEFAULT_FADE_CURVE: FadeCurve = 'exp'
23
+
24
+ /**
25
+ * Gain at position `p` (0 = silent edge, 1 = full-volume edge), for one of
26
+ * the three shapes. Clamped to [0, 1] first so a caller doesn't have to —
27
+ * every consumer here computes `p` from a division that can overshoot by a
28
+ * hair at the exact edges.
29
+ *
30
+ * - `linear`: a straight ramp — Vegas' "Linear".
31
+ * - `exp`: `t²` — slow to leave the silent edge, fast into the full-volume
32
+ * one (concave up). This is the shape the old single quadratic ease
33
+ * approximated, so it is the DEFAULT (above) rather than `linear`.
34
+ * - `log`: `t(2-t)` — the mirror of `exp`, fast to leave silence and slow
35
+ * into full volume (concave down). Vegas' "Fast" logarithmic curve family;
36
+ * picked over a true `log()` curve (which blows up at t=0) for the same
37
+ * reason `exp` uses `t²` rather than an exponential blowing up at t=1 —
38
+ * both need to be exactly 0 and 1 at their endpoints with no asymptote to
39
+ * clip.
40
+ */
41
+ export function fadeGain(p: number, curve: FadeCurve): number {
42
+ const t = p < 0 ? 0 : p > 1 ? 1 : p
43
+ if (curve === 'linear') return t
44
+ if (curve === 'exp') return t * t
45
+ return t * (2 - t)
46
+ }
47
+
48
+ /**
49
+ * A gain-at-surface-x function for one audio bar, folding both fade regions
50
+ * (each with its own curve) into one lookup — the shape `drawWaveformBars`
51
+ * and `drawFadeEnvelope`'s sampling both want: call it with a column's x and
52
+ * get back how much amplitude survives there.
53
+ *
54
+ * `spanX`/`spanWidth` are the bar's TRUE (unclamped) span in surface x —
55
+ * same contract as `AudioItemDrawArgs.fadeSpanX`/`fadeSpanWidth` in draw.ts —
56
+ * so the returned function keeps working under horizontal scroll without the
57
+ * caller re-deriving anything.
58
+ *
59
+ * Where the two fades overlap (a bar shorter than fadeInPx + fadeOutPx), the
60
+ * lower of the two gains wins — `Math.min` — matching the clamp `applyAudioFade`
61
+ * already enforces (fadeIn + fadeOut never exceeds the bar's own duration) but
62
+ * defensively correct even if that invariant is ever violated on disk.
63
+ */
64
+ export function makeFadeGainAt(
65
+ spanX: number,
66
+ spanWidth: number,
67
+ fadeInPx: number,
68
+ fadeOutPx: number,
69
+ inCurve: FadeCurve,
70
+ outCurve: FadeCurve,
71
+ ): (x: number) => number {
72
+ return (x: number) => {
73
+ const local = x - spanX
74
+ let g = 1
75
+ if (fadeInPx > 0 && local < fadeInPx) g = Math.min(g, fadeGain(local / fadeInPx, inCurve))
76
+ if (fadeOutPx > 0 && local > spanWidth - fadeOutPx) {
77
+ g = Math.min(g, fadeGain((spanWidth - local) / fadeOutPx, outCurve))
78
+ }
79
+ return g < 0 ? 0 : g > 1 ? 1 : g
80
+ }
81
+ }
82
+
83
+ /** One point of a curve's icon polyline, in SVG-style coordinates (y grows
84
+ * DOWNWARD) — see `fadeCurveIconPoints`. */
85
+ export interface FadeCurveIconPoint {
86
+ x: number
87
+ y: number
88
+ }
89
+
90
+ /**
91
+ * Sampled points for a small ICON depicting `curve`'s shape — the fade-shape
92
+ * picker (Timeline.tsx) draws these as an inline SVG polyline so each menu
93
+ * option shows what the shape actually looks like instead of naming it.
94
+ * Reuses `fadeGain` directly (the SAME function the real envelope and
95
+ * waveform scaling sample), so the icon is a faithful miniature of the
96
+ * actual curve, never a decorative approximation drawn by hand.
97
+ *
98
+ * `p` (0→1) maps to `x` (0→`width`, left→right); gain (0→1) maps to `y`
99
+ * (`height`→0, bottom→top) — SVG's y-axis grows downward, so full gain (1)
100
+ * is the SMALLEST y. Silent is therefore the bottom-left corner, full volume
101
+ * the top-right — Vegas' own fade-icon convention, and the same corners
102
+ * `drawFadeEnvelope` uses for a fade-in's silent/full ends.
103
+ */
104
+ export function fadeCurveIconPoints(curve: FadeCurve, width: number, height: number, segments = 24): FadeCurveIconPoint[] {
105
+ const points: FadeCurveIconPoint[] = []
106
+ for (let i = 0; i <= segments; i++) {
107
+ const p = i / segments
108
+ points.push({ x: p * width, y: height - fadeGain(p, curve) * height })
109
+ }
110
+ return points
111
+ }
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Canvas filmstrip rendering (SP5 T7) — lazy tile-sheet fetch by visible
3
+ * range, drawn as the UPPER BAND of each clip (`clip-bands.ts` splits it with
4
+ * the waveform).
5
+ *
6
+ * Mirrors `waveforms.ts` (T6)'s shape and conventions throughout: a small
7
+ * fetch-state store held for the lifetime of a mounted `TimelineCanvas`,
8
+ * idle/loading/ready/error per key, quiet failure with retry-on-next-call,
9
+ * `onReady` as the redraw signal. Two differences follow directly from what a
10
+ * filmstrip actually is:
11
+ *
12
+ * - Peaks are one fetch per clip; a filmstrip is TWO fetch layers — the
13
+ * index (JSON: which sheets exist, which tile is at which `t`) and the
14
+ * sheet IMAGES themselves (JPEGs, decoded for `drawImage`). Both are
15
+ * cached here, independently, so a clip whose index is ready but whose
16
+ * sheet hasn't finished decoding degrades to "no tiles yet" rather than an
17
+ * error.
18
+ *
19
+ * ── Fetch policy (SP5 plan decision 6 + T7 task) ──────────────────────────
20
+ * Proxy-only: input is `item.proxySrc` ONLY, never `item.src` — no proxy, no
21
+ * filmstrip, no fallback decode of the original, no error, no spinner (same
22
+ * shape as T6's clip waveforms). Fetch identity includes `proxySrc`, so an
23
+ * SSE-delivered proxy arriving mid-session re-keys and fetches automatically.
24
+ * On top of that, the index fetch is gated on the clip actually intersecting
25
+ * the viewport's visible time range.
26
+ *
27
+ * There is no longer a ZOOM gate. Filmstrips used to fetch and draw only above
28
+ * 160 px/s, because a cell was sized at one tile-interval and below that
29
+ * threshold every tile rendered as a sliver of itself. Cells are now sized by
30
+ * the frames band instead (see `clipTiles`), so zooming out yields fewer,
31
+ * full-size frames rather than many squeezed ones and the threshold has
32
+ * nothing left to protect. The cost this trades away is real: opening a
33
+ * project now runs the `filmstrip` step for every visible clip instead of only
34
+ * once someone zooms in. It is cached per proxy after the first run.
35
+ *
36
+ * ── Alignment ──────────────────────────────────────────────────────────
37
+ * Filmstrip tiles are indexed by ABSOLUTE source-file time (the step tiles
38
+ * the whole proxy, unwindowed — unlike peaks, `GetFilmstripArgs` has no
39
+ * `start`/`duration`). A trimmed clip's timeline time is mapped to source
40
+ * time via `clipTimeToSourceTime`, the same inPoint-anchored alignment T6's
41
+ * `clipSourceWindow` uses, assuming the same 1:1 timeline-to-source rate T6
42
+ * assumes (no speed ramps).
43
+ */
44
+ import type { VisualItem } from '../../../schema'
45
+ import type { FilmstripIndex, FilmstripSheet, GetFilmstripArgs } from '../../../types'
46
+ import { clipBands } from './clip-bands'
47
+ import type { DrawContext, Rect } from './draw'
48
+ import { timeToX, visibleRange, xToTime, type Viewport } from './viewport'
49
+ import { FETCH_RETRY_COOLDOWN_MS } from './waveforms'
50
+
51
+ // ── Cell sizing ──────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Aspect ratio of one filmstrip cell, width ÷ height. Square: the footage this
55
+ * edits is 9:16, and a band-height-tall cell at the source's own aspect would
56
+ * be a ~27px sliver at every zoom. A square cell center-cropped out of the
57
+ * frame (see `centerCropTo`) trades the top and bottom of the picture for a
58
+ * thumbnail wide enough to recognize a shot from.
59
+ */
60
+ export const FILMSTRIP_CELL_ASPECT = 1
61
+
62
+ /** Floor on a cell's pixel width, so a degenerate (zero/negative) band height
63
+ * can't turn the draw loop into a huge iteration. Normal cells are sized from
64
+ * the frames band, which is tens of px tall. */
65
+ const MIN_CELL_PX = 4
66
+
67
+ // ── Source-time alignment ────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Map a timeline time within `item`'s span to the corresponding SOURCE-file
71
+ * time. Filmstrip tiles are indexed by absolute source time (the whole proxy,
72
+ * unwindowed), so — unlike `waveforms.ts`'s `clipSourceWindow`, which can
73
+ * return "no window" — this is always defined: `item.inPoint ?? 0` anchors
74
+ * the clip's own start, and the speed factor carries every other timeline time
75
+ * out from there.
76
+ *
77
+ * `·(item.speed ?? 1)` converts elapsed TIMELINE-seconds into SOURCE-seconds:
78
+ * a clip at speed S consumes S× the source per project second. This mirrors
79
+ * `timeline-core`'s `seekTime`, the canonical form the playback engine and the
80
+ * renderer both apply. Strict no-op at S=1, so a project with no speeds set
81
+ * draws exactly as it did.
82
+ *
83
+ * It was 1:1 before, which was correct only while speed did not exist. Once it
84
+ * did, the filmstrip and the per-clip waveform told two different stories
85
+ * about the same clip: the waveform windows to `inPoint..outPoint`, which is
86
+ * speed-correct by construction, while the frames marched through the source
87
+ * at 1× — running off the end of a slowed clip and freezing on the last tile
88
+ * it could find, or repeating themselves on a sped-up one.
89
+ */
90
+ export function clipTimeToSourceTime(item: VisualItem, timelineTime: number): number {
91
+ const inPoint = item.inPoint ?? 0
92
+ return inPoint + (item.speed ?? 1) * (timelineTime - item.start)
93
+ }
94
+
95
+ // ── Tile selection ───────────────────────────────────────────────────────
96
+
97
+ /** One tile, flattened out of its sheet with the sheet's index folded in —
98
+ * the shape `nearestTile`'s binary search and the draw loop both work over,
99
+ * independent of how many sheets an index actually has. */
100
+ export interface FlatTile {
101
+ t: number
102
+ sheetIdx: number
103
+ row: number
104
+ col: number
105
+ }
106
+
107
+ /** Flatten every sheet's tiles into one ascending-`t` list. Sheets and their
108
+ * tiles are already produced in time order by the uniform-grid `filmstrip`
109
+ * step, so concatenation in sheet order preserves the ordering
110
+ * `nearestTile`'s binary search relies on. */
111
+ export function flattenTiles(index: FilmstripIndex): FlatTile[] {
112
+ const out: FlatTile[] = []
113
+ index.sheets.forEach((sheet, sheetIdx) => {
114
+ for (const tile of sheet.tiles) out.push({ t: tile.t, sheetIdx, row: tile.row, col: tile.col })
115
+ })
116
+ return out
117
+ }
118
+
119
+ /**
120
+ * The tile whose `t` is closest to `sourceTime`, by binary search over the
121
+ * ascending list `flattenTiles` produces. `null` only for an empty index.
122
+ * Clamps naturally at both ends: a `sourceTime` before the first tile or
123
+ * after the last returns that end tile, exactly like a scrub bar's nearest
124
+ * frame at either edge of the source.
125
+ */
126
+ export function nearestTile(tiles: readonly FlatTile[], sourceTime: number): FlatTile | null {
127
+ if (tiles.length === 0) return null
128
+ let lo = 0
129
+ let hi = tiles.length - 1
130
+ while (lo < hi) {
131
+ const mid = (lo + hi) >> 1
132
+ if (tiles[mid].t < sourceTime) lo = mid + 1
133
+ else hi = mid
134
+ }
135
+ if (lo > 0) {
136
+ const prev = tiles[lo - 1]
137
+ const curr = tiles[lo]
138
+ if (Math.abs(prev.t - sourceTime) <= Math.abs(curr.t - sourceTime)) return prev
139
+ }
140
+ return tiles[lo]
141
+ }
142
+
143
+ // ── Source-rect math ─────────────────────────────────────────────────────
144
+
145
+ export interface TileSourceRect { sx: number; sy: number; sw: number; sh: number }
146
+
147
+ /**
148
+ * The tile's sub-rect within its sheet IMAGE (not the nominal `tileWidth` —
149
+ * the loaded image's own natural width/height divided by the grid, so
150
+ * rounding in the step's actual output never drifts from what's drawn).
151
+ */
152
+ export function tileSourceRect(
153
+ sheet: FilmstripSheet,
154
+ tile: { row: number; col: number },
155
+ sheetImageWidth: number,
156
+ sheetImageHeight: number,
157
+ ): TileSourceRect {
158
+ const sw = sheet.cols > 0 ? sheetImageWidth / sheet.cols : sheetImageWidth
159
+ const sh = sheet.rows > 0 ? sheetImageHeight / sheet.rows : sheetImageHeight
160
+ return { sx: tile.col * sw, sy: tile.row * sh, sw, sh }
161
+ }
162
+
163
+ /**
164
+ * Narrow `src` to the largest centered sub-rect matching `destAspect`
165
+ * (width ÷ height), so a tile drawn into that destination fills it without
166
+ * distortion. Vertical 9:16 footage into a square cell loses the top and
167
+ * bottom; wide footage into the same cell loses the sides.
168
+ *
169
+ * Applied per CELL rather than once per tile because the cells at a clip's
170
+ * left and right edges are clipped narrower than a full one — cropping to the
171
+ * destination's actual aspect keeps those partial cells undistorted too,
172
+ * which cropping to a fixed square would not.
173
+ *
174
+ * A non-positive aspect or a degenerate source returns `src` unchanged: the
175
+ * caller's own `width <= 0` guards already skip those, and silently returning
176
+ * the uncropped rect is safer than emitting NaNs into `drawImage`.
177
+ */
178
+ export function centerCropTo(src: TileSourceRect, destAspect: number): TileSourceRect {
179
+ if (!(destAspect > 0) || src.sw <= 0 || src.sh <= 0) return src
180
+ const srcAspect = src.sw / src.sh
181
+ if (srcAspect > destAspect) {
182
+ const sw = src.sh * destAspect
183
+ return { sx: src.sx + (src.sw - sw) / 2, sy: src.sy, sw, sh: src.sh }
184
+ }
185
+ const sh = src.sw / destAspect
186
+ return { sx: src.sx, sy: src.sy + (src.sh - sh) / 2, sw: src.sw, sh }
187
+ }
188
+
189
+ // ── Injectable image loading ─────────────────────────────────────────────
190
+
191
+ export interface LoadedSheetImage {
192
+ image: CanvasImageSource
193
+ width: number
194
+ height: number
195
+ }
196
+
197
+ export type SheetImageLoader = (url: string) => Promise<LoadedSheetImage>
198
+
199
+ /**
200
+ * Default loader: a plain `Image()` element. Kept deliberately simple (no
201
+ * `fetch`+`createImageBitmap` round trip) since `img.src` already handles
202
+ * the host's URL scheme (auth cookies, relative paths) the same way every
203
+ * other image in the editor loads. Tests inject a fake via
204
+ * `FilmstripQueryContext.loader` — jsdom never fires a real `load` event.
205
+ */
206
+ export const defaultSheetImageLoader: SheetImageLoader = (url) =>
207
+ new Promise((resolve, reject) => {
208
+ const img = new Image()
209
+ img.onload = () => resolve({ image: img, width: img.naturalWidth || img.width, height: img.naturalHeight || img.height })
210
+ img.onerror = () => reject(new Error(`filmstrip sheet failed to load: ${url}`))
211
+ img.src = url
212
+ })
213
+
214
+ // ── Draw primitives ──────────────────────────────────────────────────────
215
+
216
+ /** One tile positioned at its destination rect, ready for `ctx.drawImage`. */
217
+ export interface FilmstripTileDraw {
218
+ image: CanvasImageSource
219
+ sx: number
220
+ sy: number
221
+ sw: number
222
+ sh: number
223
+ rect: Rect
224
+ }
225
+
226
+ /** Paint the tile strip. Destination rects already sit in the clip's frames
227
+ * band (`clipTiles` places them), so this just blits — composed with the
228
+ * waveform inside `drawTimelineContent`'s single `drawContent` hook (draw.ts),
229
+ * which paints the two bands into disjoint halves of the clip. */
230
+ export function drawFilmstripTiles(ctx: DrawContext, tiles: readonly FilmstripTileDraw[]): void {
231
+ for (const tile of tiles) {
232
+ if (tile.rect.width <= 0 || tile.rect.height <= 0) continue
233
+ ctx.drawImage(tile.image, tile.sx, tile.sy, tile.sw, tile.sh, tile.rect.x, tile.rect.y, tile.rect.width, tile.rect.height)
234
+ }
235
+ }
236
+
237
+ // ── Fetch-state store ─────────────────────────────────────────────────────
238
+
239
+ type EntryStatus = 'loading' | 'ready' | 'error'
240
+
241
+ interface IndexEntry {
242
+ status: EntryStatus
243
+ data: FilmstripIndex | null
244
+ tiles: FlatTile[]
245
+ erroredAt?: number
246
+ }
247
+
248
+ interface ImageEntry {
249
+ status: EntryStatus
250
+ image: LoadedSheetImage | null
251
+ erroredAt?: number
252
+ }
253
+
254
+ /**
255
+ * Everything a lookup needs for one call. One instance is built per paint
256
+ * (viewport/onReady can both have moved since the last one); the store
257
+ * itself is what persists across paints.
258
+ *
259
+ * `onReady` invalidates the content layer, the only consumer of filmstrip
260
+ * data. Note that only the call which ORIGINATES a fetch gets its `onReady`
261
+ * attached to that fetch's promise (see `resolveIndex`/`resolveSheetImage`
262
+ * below) — every other clip waiting on the same sheet is repainted by that one
263
+ * callback, since a content redraw repaints them all.
264
+ */
265
+ export interface FilmstripQueryContext {
266
+ projectId: string
267
+ /** Absent → no filmstrips anywhere (host adapter doesn't implement it). */
268
+ getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
269
+ /** Resolves a sheet's host path to a displayable URL — the SAME mechanism
270
+ * `WaveformChunk.path` uses (`adapter.fileUrl`, threaded through Timeline
271
+ * as `resolveFilePath`). Absent → no filmstrips (nothing to load images
272
+ * through). */
273
+ fileUrl?: (path: string) => string
274
+ viewport: Viewport
275
+ onReady: () => void
276
+ /** Injectable image decoder; defaults to `defaultSheetImageLoader`. */
277
+ loader?: SheetImageLoader
278
+ /** Grid params passed straight through to `getFilmstrip` and folded into
279
+ * the fetch/cache key — omit to use the step's own defaults. */
280
+ maxTiles?: number
281
+ minInterval?: number
282
+ tileWidth?: number
283
+ }
284
+
285
+ /** What `drawTimelineContent` (draw.ts) needs from T7, kept as an interface
286
+ * so draw.ts only takes a type-only dependency on this module — mirrors
287
+ * T6's `WaveformSceneLookup`. */
288
+ export interface FilmstripSceneLookup {
289
+ clipTiles(item: VisualItem, rect: Rect): FilmstripTileDraw[] | null
290
+ }
291
+
292
+ export class FilmstripStore {
293
+ private indexEntries = new Map<string, IndexEntry>()
294
+ private imageEntries = new Map<string, ImageEntry>()
295
+
296
+ /**
297
+ * Background tile strip for a clip's rect. `null` whenever there's nothing
298
+ * to draw: non-video items, no proxy yet, no adapter support, off the
299
+ * visible range, a zero-height frames band, or the index/sheets not ready
300
+ * yet — all graceful, never an error state the caller has to handle
301
+ * specially.
302
+ */
303
+ clipTiles(item: VisualItem, rect: Rect, ctx: FilmstripQueryContext): FilmstripTileDraw[] | null {
304
+ if (item.type !== 'video' || !item.proxySrc || !ctx.getFilmstrip || !ctx.fileUrl) return null
305
+ if (rect.width <= 0 || rect.height <= 0) return null
306
+ const range = visibleRange(ctx.viewport)
307
+ if (item.end < range.start || item.start > range.end) return null
308
+
309
+ const band = clipBands(rect).frames
310
+ if (band.height <= 0) return null
311
+
312
+ const resolved = this.resolveIndex(item.proxySrc, ctx)
313
+ if (!resolved || resolved.tiles.length === 0) return null
314
+ const { data: index, tiles } = resolved
315
+
316
+ // Cell width comes from the BAND HEIGHT, not from the index's tile
317
+ // interval — that is what makes the strip re-derive itself as you zoom.
318
+ // Each cell is one fixed-aspect thumbnail, so the clip's pixel width alone
319
+ // decides how many frames fit: zoom in and the same span grows more cells,
320
+ // each landing on a nearer tile; zoom out and it collapses to a few
321
+ // widely-spaced frames. Sizing cells by `interval * pxPerSecond` instead
322
+ // (the original) pinned one cell per tile at every zoom, which meant cells
323
+ // narrower than a tile when zoomed out and stretched frames when zoomed in.
324
+ const cellPx = Math.max(MIN_CELL_PX, band.height * FILMSTRIP_CELL_ASPECT)
325
+
326
+ // Only the portion of the clip's cells actually on screen ever touches a
327
+ // sheet — a long base-track clip can have hundreds of cells; a sliver of
328
+ // it visible after a scroll should load one sheet, not all of them.
329
+ const clipLeftX = timeToX(item.start, ctx.viewport)
330
+ const clipRightX = timeToX(item.end, ctx.viewport)
331
+ const drawLeft = Math.max(rect.x, clipLeftX)
332
+ const drawRight = Math.min(rect.x + rect.width, clipRightX)
333
+ if (drawRight <= drawLeft) return null
334
+
335
+ // Cell grid aligned to the CLIP's own start (not the visible window), so
336
+ // panning never shifts which frame lands in which cell.
337
+ const firstCellIdx = Math.floor((drawLeft - clipLeftX) / cellPx)
338
+ const out: FilmstripTileDraw[] = []
339
+ for (let x = clipLeftX + firstCellIdx * cellPx; x < drawRight; x += cellPx) {
340
+ const cellLeft = Math.max(x, drawLeft)
341
+ const cellRight = Math.min(x + cellPx, drawRight)
342
+ if (cellRight <= cellLeft) continue
343
+
344
+ const cellCenterTime = xToTime((cellLeft + cellRight) / 2, ctx.viewport)
345
+ const sourceTime = clipTimeToSourceTime(item, cellCenterTime)
346
+ const tile = nearestTile(tiles, sourceTime)
347
+ if (!tile) continue
348
+
349
+ const sheet = index.sheets[tile.sheetIdx]
350
+ const img = this.resolveSheetImage(sheet.path, ctx)
351
+ if (!img) continue // sheet not decoded yet — this cell just stays blank until onReady fires
352
+
353
+ const dest: Rect = { x: cellLeft, y: band.y, width: cellRight - cellLeft, height: band.height }
354
+ const src = centerCropTo(tileSourceRect(sheet, tile, img.width, img.height), dest.width / dest.height)
355
+ out.push({ image: img.image, sx: src.sx, sy: src.sy, sw: src.sw, sh: src.sh, rect: dest })
356
+ }
357
+ return out.length > 0 ? out : null
358
+ }
359
+
360
+
361
+ /** Look up the index for `src`, kicking off a fetch when absent. Never
362
+ * re-fetches while loading or once ready; an errored entry is retried on
363
+ * the next call that needs it (same quiet-retry shape as T6). */
364
+ private resolveIndex(src: string, ctx: FilmstripQueryContext): { data: FilmstripIndex; tiles: FlatTile[] } | null {
365
+ const key = `${ctx.projectId}|${src}|${ctx.maxTiles ?? ''}|${ctx.minInterval ?? ''}|${ctx.tileWidth ?? ''}`
366
+ const entry = this.indexEntries.get(key)
367
+ if (entry?.status === 'ready' && entry.data) return { data: entry.data, tiles: entry.tiles }
368
+ // Fetch when there's no entry yet OR the last attempt errored — an
369
+ // 'error' entry is neither ready nor in flight, so (unlike a bare
370
+ // `!entry` check) it must still be retriable on the next call.
371
+ //
372
+ // An errored entry stays retriable, but not on the very next paint: the
373
+ // overlay layer repaints at ~60Hz during playback, which would turn a
374
+ // persistently-failing fetch into a request storm.
375
+ const inCooldown = entry?.status === 'error'
376
+ && Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS
377
+ if (entry?.status !== 'loading' && !inCooldown) {
378
+ const fetcher = ctx.getFilmstrip
379
+ if (fetcher) {
380
+ this.indexEntries.set(key, { status: 'loading', data: null, tiles: [] })
381
+ fetcher({ projectId: ctx.projectId, src, maxTiles: ctx.maxTiles, minInterval: ctx.minInterval, tileWidth: ctx.tileWidth })
382
+ .then(data => {
383
+ this.indexEntries.set(key, { status: 'ready', data, tiles: flattenTiles(data) })
384
+ ctx.onReady()
385
+ })
386
+ .catch(() => {
387
+ this.indexEntries.set(key, { status: 'error', data: null, tiles: [], erroredAt: Date.now() })
388
+ })
389
+ }
390
+ }
391
+ return null
392
+ }
393
+
394
+ /** Look up a decoded sheet image by its host path, kicking off a load when
395
+ * absent. Same quiet-retry shape as `resolveIndex`. */
396
+ private resolveSheetImage(path: string, ctx: FilmstripQueryContext): LoadedSheetImage | null {
397
+ const entry = this.imageEntries.get(path)
398
+ if (entry?.status === 'ready' && entry.image) return entry.image
399
+ const inCooldown = entry?.status === 'error'
400
+ && Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS
401
+ if (entry?.status !== 'loading' && !inCooldown) {
402
+ const fileUrl = ctx.fileUrl
403
+ if (fileUrl) {
404
+ const loader = ctx.loader ?? defaultSheetImageLoader
405
+ this.imageEntries.set(path, { status: 'loading', image: null })
406
+ loader(fileUrl(path))
407
+ .then(image => {
408
+ this.imageEntries.set(path, { status: 'ready', image })
409
+ ctx.onReady()
410
+ })
411
+ .catch(() => {
412
+ this.imageEntries.set(path, { status: 'error', image: null, erroredAt: Date.now() })
413
+ })
414
+ }
415
+ }
416
+ return null
417
+ }
418
+ }