@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,664 @@
1
+ /**
2
+ * Canvas waveform rendering (SP5 T6) — turns fetched `PeaksData` into pixel
3
+ * columns and paints them as the content layer `drawClipRect`/`drawAudioItem`
4
+ * (draw.ts) call between the fill/border and the label.
5
+ *
6
+ * ── Two render targets, one pipeline ─────────────────────────────────────
7
+ * - Audio lanes: a full-bar waveform, replacing the retired DOM path's fixed
8
+ * PNG chunks (`AudioWaveformLayer.tsx`, since deleted).
9
+ * `audioTrackSourceWindow` / `sourceTimeToBarFraction` below
10
+ * are ports of that component's trim-alignment math (lines 75–89): same
11
+ * inputs, same positioning, just expressed as 0–1 fractions instead of CSS
12
+ * percentages so the canvas painter can multiply by `rect.width` directly.
13
+ * - Visual-track clips: the LOWER HALF of the clip rect (`clip-bands.ts`),
14
+ * with the filmstrip taking the upper half. This began as a 10px strip
15
+ * under a "new UI, stay understated" brief; it grew because the strip was
16
+ * too small to cut against — at half a clip the waveform is the primary
17
+ * thing you read on the base track, so it gets a darkened band and enough
18
+ * alpha to be legible rather than merely present.
19
+ *
20
+ * ── Input-selection + fetch policy (SP5 plan decision) ───────────────────
21
+ * Clips fetch from `item.proxySrc` ONLY — no fallback to `item.src` — so a
22
+ * clip with no proxy yet simply renders no waveform (graceful, no spinner,
23
+ * no error) until the import pipeline delivers one. Audio lanes fetch from
24
+ * `track.src` (audio tracks have no proxies), windowed to the track's own
25
+ * trim.
26
+ *
27
+ * Clips are NOT windowed at fetch time. The editing proxy is the WHOLE
28
+ * source, never trimmed server-side, so it is shared, unwindowed data every
29
+ * clip pointing at that `proxySrc` can reuse regardless of which clip, or
30
+ * which trim, is asking — `clipColumns` fetches (and caches) it ONCE, keyed
31
+ * by src alone, and slices the clip's own [inPoint, outPoint] window out of
32
+ * it locally. Before this, the fetch itself was windowed and keyed by
33
+ * `(item.id, window)`, so splitting a clip (new ids) or trimming it (a new
34
+ * window) changed the key, forced a re-decode, and flashed the waveform
35
+ * blank for a frame — even though the underlying proxy data hadn't changed
36
+ * at all.
37
+ *
38
+
39
+ * ── Zoom-crisp resolution (bucket scheme) ─────────────────────────────────
40
+ * `resolveBucket` picks 50/200/800 from the current px/second. `resolve()`
41
+ * (private, on `WaveformPeaksStore`) fetches the needed bucket only when
42
+ * nothing at that bucket-or-higher is already ready/loading — so zooming
43
+ * OUT never re-fetches (a higher bucket already cached renders fine
44
+ * downsampled; see `resamplePeaksToColumns`), and zooming IN fetches exactly
45
+ * once per bucket crossed, keeping the previous bucket on screen until the
46
+ * new one resolves.
47
+ */
48
+ import type { AudioTrack, VisualItem } from '../../../schema'
49
+ import type { GetWaveformPeaksArgs, PeaksData, PeaksResolution } from '../../../types'
50
+ import { clipBands } from './clip-bands'
51
+ import type { DrawContext, Rect } from './draw'
52
+ import { timeToX, type Viewport } from './viewport'
53
+
54
+ // ── Resolution buckets ───────────────────────────────────────────────────
55
+
56
+ export const PEAKS_BUCKETS: PeaksResolution[] = [50, 200, 800]
57
+
58
+ /**
59
+ * Resolution bucket for the current zoom: 50 while `pxPerSecond` ≤ 50, 200
60
+ * while ≤ 200, 800 above that. Blockiness stays ≤1px up to 800px/s; beyond
61
+ * that — up to the viewport's `MAX_PX_PER_SECOND` (2000) — mild blockiness
62
+ * is accepted since there is no higher bucket to fetch.
63
+ */
64
+ export function resolveBucket(pxPerSecond: number): PeaksResolution {
65
+ if (pxPerSecond <= 50) return 50
66
+ if (pxPerSecond <= 200) return 200
67
+ return 800
68
+ }
69
+
70
+ // ── Trim-alignment math (ported from AudioWaveformLayer.tsx:75-89) ───────
71
+
72
+ /**
73
+ * The source-time window `getWaveformPeaks` should fetch for an audio
74
+ * track's lane waveform, and the window `sourceTimeToBarFraction` positions
75
+ * against. `duration <= 0` is the fully-collapsed-trim case — callers skip
76
+ * drawing rather than fetch/render a degenerate window.
77
+ */
78
+ export function audioTrackSourceWindow(track: AudioTrack): { start: number; duration: number } {
79
+ const start = track.inPoint ?? 0
80
+ // outPoint present → exact window. Absent → for 1:1 audio the clip's timeline
81
+ // span IS its source-window duration; using end-start avoids treating
82
+ // sourceDuration (a length) as an absolute out-point, which goes negative for
83
+ // a split clip whose right half has inPoint>0 and no outPoint (blank waveform).
84
+ const duration = track.outPoint != null
85
+ ? Math.max(0, track.outPoint - start)
86
+ : Math.max(0, track.end - track.start)
87
+ return { start, duration }
88
+ }
89
+
90
+ /**
91
+ * Fraction of the bar [0,1] a source-time point maps to, given the track's
92
+ * trim window. Ported from `AudioWaveformLayer.tsx`'s `leftPct` (lines
93
+ * 88–89: `(chunk.start - inPt) / visibleSpan`) — identical formula, just
94
+ * returned as a 0–1 fraction instead of `${...}%` so the canvas painter
95
+ * multiplies by `rect.width` instead of writing `element.style.left`.
96
+ */
97
+ export function sourceTimeToBarFraction(sourceTime: number, window: { start: number; duration: number }): number {
98
+ if (window.duration <= 0) return 0
99
+ return (sourceTime - window.start) / window.duration
100
+ }
101
+
102
+ /**
103
+ * The source-time window to fetch for a clip's per-item waveform.
104
+ * `undefined` means "no windowing — fetch the whole proxy": with neither
105
+ * `inPoint` nor `outPoint` set there is no trim to narrow to, and the clip's
106
+ * own project-timeline duration is not a reliable stand-in for the proxy's
107
+ * source duration, so the request omits `start`/`duration` entirely rather
108
+ * than guess. Present `inPoint`/`outPoint` narrow it exactly, mirroring the
109
+ * audio-lane window above. A degenerate `outPoint <= inPoint` yields
110
+ * `duration: 0`; callers skip drawing on that, same as the audio path.
111
+ */
112
+ export function clipSourceWindow(item: VisualItem): { start: number; duration: number } | undefined {
113
+ if (item.inPoint == null && item.outPoint == null) return undefined
114
+ const start = item.inPoint ?? 0
115
+ const outPoint = item.outPoint ?? item.sourceDuration ?? start + Math.max(0, item.end - item.start)
116
+ return { start, duration: Math.max(0, outPoint - start) }
117
+ }
118
+
119
+ // ── Resample: peaks → pixel columns ───────────────────────────────────────
120
+
121
+ export interface WaveformColumn {
122
+ /** Normalized to roughly [-1, 1] (int16 value / 32768). */
123
+ min: number
124
+ max: number
125
+ }
126
+
127
+ /** `PeaksData.peaks` values are in int16 range. */
128
+ const INT16_SCALE = 32768
129
+
130
+ /**
131
+ * Resample an interleaved `[min, max, min, max, ...]` peaks array to exactly
132
+ * `columns` pixel-column min/max pairs. One formula covers both zoom
133
+ * regimes the bucket scheme describes:
134
+ *
135
+ * samples-per-px > 1 (zoomed out relative to the data): each column
136
+ * aggregates (min of mins, max of maxes) over more than one source sample.
137
+ *
138
+ * samples-per-px < 1 (zoomed in past the data's resolution): consecutive
139
+ * columns share the same source sample (`Math.floor` repeats it), giving
140
+ * the accepted "mild blockiness" at the top of the zoom range rather than
141
+ * a smoothed/interpolated curve — matching the bucket scheme's own
142
+ * blockiness language rather than inventing a shape the data doesn't have.
143
+ *
144
+ * Pure: no knowledge of seconds, buckets, or DOM — just an array and a
145
+ * column count, so it is trivial to hand known fixtures in tests.
146
+ */
147
+ export function resamplePeaksToColumns(peaks: number[], columns: number): WaveformColumn[] {
148
+ const totalSamples = Math.floor(peaks.length / 2)
149
+ if (columns <= 0 || totalSamples <= 0) return []
150
+
151
+ const samplesPerColumn = totalSamples / columns
152
+ const result: WaveformColumn[] = []
153
+ for (let i = 0; i < columns; i++) {
154
+ let startIdx = Math.floor(i * samplesPerColumn)
155
+ if (startIdx >= totalSamples) startIdx = totalSamples - 1
156
+ let endIdx = Math.floor((i + 1) * samplesPerColumn)
157
+ if (endIdx <= startIdx) endIdx = startIdx + 1
158
+ if (endIdx > totalSamples) endIdx = totalSamples
159
+
160
+ let min = Infinity
161
+ let max = -Infinity
162
+ for (let s = startIdx; s < endIdx; s++) {
163
+ const mn = peaks[s * 2]
164
+ const mx = peaks[s * 2 + 1]
165
+ if (mn < min) min = mn
166
+ if (mx > max) max = mx
167
+ }
168
+ result.push({ min: min / INT16_SCALE, max: max / INT16_SCALE })
169
+ }
170
+ return result
171
+ }
172
+
173
+ // ── Drawing primitives ───────────────────────────────────────────────────
174
+
175
+ export const WAVEFORM_COLORS = {
176
+ /** Translucent white so the bars read over any of `TRACK_PALETTE`'s six clip
177
+ * hues without needing a per-palette variant. Raised from the original
178
+ * 0.35: at 10px the strip was a texture and low alpha kept it from
179
+ * competing with the label, but across half a clip it is the thing you cut
180
+ * against and needs to read as a waveform. */
181
+ clip: 'rgba(255,255,255,0.6)',
182
+ /** Darkens the waveform band relative to the clip fill so the lower half
183
+ * reads as its own lane rather than bars floating on the clip — the
184
+ * separation CapCut gets from a distinct band colour. */
185
+ clipBand: 'rgba(0,0,0,0.22)',
186
+ /** Sits inside `AudioTrackRow`'s emerald bar; brighter than the fill so it
187
+ * reads as drawn "on top of" it, echoing `TIMELINE_COLORS.audioRing`. */
188
+ audioLane: 'rgba(167,243,208,0.75)',
189
+ /** Muted video-track clip bars: dimmer than `clip`, but still readable so
190
+ * the waveform shape survives on top of the darker muted scrim below. */
191
+ clipMuted: 'rgba(255,255,255,0.3)',
192
+ /** Muted band: a distinctly DARKER scrim than `clipBand` (not the old faint
193
+ * lighten, which read almost identical to an unmuted clip), so a muted
194
+ * clip's audio region visibly dims and reads as "off" at a glance. */
195
+ clipBandMuted: 'rgba(0,0,0,0.45)',
196
+ } as const
197
+
198
+ /** The shape both waveform sets satisfy — `WAVEFORM_COLORS`'s keys widened
199
+ * from its `as const` literals to plain `string`. Carried on
200
+ * `TimelinePalette.waveform` (draw.ts) so a draw pass resolves waveform
201
+ * colours from the same object as everything else it paints. */
202
+ export type WaveformColors = { [K in keyof typeof WAVEFORM_COLORS]: string }
203
+
204
+ /**
205
+ * `WAVEFORM_COLORS` for a LIGHT clip fill.
206
+ *
207
+ * The dark set is white bars over a saturated clip hue, sitting in a band
208
+ * DARKENED relative to that hue. Light inverts both halves, and they have to
209
+ * invert together or the bars stop separating from their own band:
210
+ *
211
+ * - the bars go near-black, since `LIGHT_TRACK_PALETTE`'s fills are pale
212
+ * tints and white bars would be gone;
213
+ * - the band goes LIGHTER than the clip fill rather than darker. Keeping the
214
+ * black scrim would have worked — dark bars on a mid-grey band do read —
215
+ * but it makes the busiest part of the clip its heaviest, and washing the
216
+ * band toward white instead gives the dark bars the maximum contrast while
217
+ * still marking the waveform half as its own lane.
218
+ *
219
+ * The mute pair keeps its meaning by keeping its DIRECTION: dark mutes by
220
+ * making the band darker still, light mutes by washing it further out. Either
221
+ * way a muted clip's audio region visibly recedes from an unmuted one.
222
+ */
223
+ export const LIGHT_WAVEFORM_COLORS: WaveformColors = {
224
+ clip: 'rgba(15,23,42,0.6)',
225
+ clipBand: 'rgba(255,255,255,0.45)',
226
+ /** Sits inside the light emerald bar; DARKER than the fill so it reads as
227
+ * drawn "on top of" it — the mirror of dark's "brighter than the fill",
228
+ * and echoing `LIGHT_TIMELINE_COLORS.audioRing` the same way. */
229
+ audioLane: 'rgba(6,95,70,0.75)',
230
+ clipMuted: 'rgba(15,23,42,0.3)',
231
+ clipBandMuted: 'rgba(255,255,255,0.72)',
232
+ }
233
+
234
+ /** Paint one row of min/max columns as vertical bars centered in `rect`,
235
+ * amplitude scaled to `rect.height / 2`. The shared primitive both
236
+ * `drawClipWaveform` and `drawAudioLaneWaveform` style differently.
237
+ *
238
+ * `gainAt`, when given, is sampled at each column's CENTER x and scales
239
+ * that column's `min`/`max` before the bar is measured — the Vegas
240
+ * behaviour of a waveform shrinking to zero through a fade-out and growing
241
+ * from zero through a fade-in, using the fade's own shape curve
242
+ * (`fade-curve.ts`'s `fadeGain`, via `makeFadeGainAt`). Absent — the
243
+ * default — draws exactly as before: full amplitude everywhere, the
244
+ * contract every existing caller (clip waveforms, an audio bar with no
245
+ * fade) still gets. */
246
+ export function drawWaveformBars(
247
+ ctx: DrawContext,
248
+ rect: Rect,
249
+ columns: WaveformColumn[],
250
+ color: string,
251
+ gainAt?: (x: number) => number,
252
+ ): void {
253
+ if (rect.width <= 0 || rect.height <= 0 || columns.length === 0) return
254
+ const centerY = rect.y + rect.height / 2
255
+ const halfHeight = rect.height / 2
256
+ const colWidth = rect.width / columns.length
257
+
258
+ ctx.fillStyle = color
259
+ for (let i = 0; i < columns.length; i++) {
260
+ const { min, max } = columns[i]
261
+ const x = rect.x + i * colWidth
262
+ const gain = gainAt ? gainAt(x + colWidth / 2) : 1
263
+ const hi = Math.max(min, max) * gain
264
+ const lo = Math.min(min, max) * gain
265
+ const top = centerY - hi * halfHeight
266
+ const bottom = centerY - lo * halfHeight
267
+ ctx.fillRect(x, top, Math.max(1, colWidth), Math.max(1, bottom - top))
268
+ }
269
+ }
270
+
271
+ /** The lower half of a clip rect the waveform draws into — `clip-bands.ts` owns
272
+ * the split, so this is a pass-through kept as the name the painter and its
273
+ * tests already reach for. */
274
+ export function clipWaveformBand(rect: Rect): Rect {
275
+ return clipBands(rect).waveform
276
+ }
277
+
278
+ /** Paint the clip's waveform half: a darkened band, then the bars centered in
279
+ * it. The band is painted even where the bars are silent, so a quiet passage
280
+ * reads as "no audio here" rather than as missing data. `muted` swaps in the
281
+ * grayed `clipMuted`/`clipBandMuted` pair — the same mute cue `drawAudioItem`
282
+ * already gives audio-lane bars, extended to video-track clip waveforms. */
283
+ export function drawClipWaveform(
284
+ ctx: DrawContext,
285
+ rect: Rect,
286
+ columns: WaveformColumn[],
287
+ muted = false,
288
+ colors: WaveformColors = WAVEFORM_COLORS,
289
+ ): void {
290
+ const band = clipWaveformBand(rect)
291
+ if (band.height <= 0 || band.width <= 0) return
292
+ ctx.fillStyle = muted ? colors.clipBandMuted : colors.clipBand
293
+ ctx.fillRect(band.x, band.y, band.width, band.height)
294
+ drawWaveformBars(ctx, band, columns, muted ? colors.clipMuted : colors.clip)
295
+ }
296
+
297
+ /** Full-bar audio-lane waveform. `rect` is already the fetched window's
298
+ * bar-relative sub-rect (see `WaveformPeaksStore.audioColumns`), so this
299
+ * paints edge-to-edge of whatever span the data actually covers.
300
+ *
301
+ * `gainAt` — see `drawWaveformBars` — is a lookup over the BAR's own true
302
+ * span in surface x (built once per bar by the painter, from the track's
303
+ * fades and curves), not over `rect`, which can be a scrolled/clamped
304
+ * sub-span of it; passing it straight through is what lets a column keep
305
+ * the right gain regardless of which slice of the bar `rect` happens to be. */
306
+ export function drawAudioLaneWaveform(
307
+ ctx: DrawContext,
308
+ rect: Rect,
309
+ columns: WaveformColumn[],
310
+ gainAt?: (x: number) => number,
311
+ colors: WaveformColors = WAVEFORM_COLORS,
312
+ ): void {
313
+ drawWaveformBars(ctx, rect, columns, colors.audioLane, gainAt)
314
+ }
315
+
316
+ // ── Fetch-state store ────────────────────────────────────────────────────
317
+
318
+ /** How long an errored fetch stays in cooldown before the next call may retry
319
+ * it. Both canvas stores are polled from paint (the overlay layer repaints at
320
+ * ~60Hz during playback; the content layer once per pointer move during a
321
+ * drag), so retry-on-next-call alone turns a persistent failure into a
322
+ * request storm. Shared with filmstrips.ts. */
323
+ export const FETCH_RETRY_COOLDOWN_MS = 5000
324
+
325
+ type EntryStatus = 'loading' | 'ready' | 'error'
326
+
327
+ interface Entry {
328
+ status: EntryStatus
329
+ data: PeaksData | null
330
+ erroredAt?: number
331
+ }
332
+
333
+ export interface WaveformQueryContext {
334
+ projectId: string
335
+ /** Absent → no waveforms anywhere (host adapter doesn't implement it). */
336
+ getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
337
+ pxPerSecond: number
338
+ /** Needed by `clipColumns` to recover a clip's FULL on-screen span
339
+ * (`item.start`..`item.end` mapped through this, unclamped) — the `rect`
340
+ * it's handed is already clamped to the visible surface and inset by the
341
+ * clip-body gutter (see `visibleClipSampleRange`), so `pxPerSecond` alone
342
+ * isn't enough; `scrollSeconds` is what says WHICH slice of a long clip is
343
+ * currently on screen. Not consulted by `audioColumns`, which positions
344
+ * its fetched window within the bar rect it's handed directly rather than
345
+ * against the clip's own full span. */
346
+ viewport: Viewport
347
+ /** Called once when new data lands for a key that was previously
348
+ * loading/absent — the caller's cue to `requestRedraw('content')`. */
349
+ onReady: () => void
350
+ }
351
+
352
+ /** What `drawTimelineContent` (draw.ts) needs from T6, kept as an interface
353
+ * so draw.ts only takes a type-only dependency on this module. */
354
+ export interface WaveformSceneLookup {
355
+ clipColumns(item: VisualItem, rect: Rect): WaveformColumn[] | null
356
+ audioColumns(track: AudioTrack, rect: Rect): { rect: Rect; columns: WaveformColumn[] } | null
357
+ }
358
+
359
+ /**
360
+ * The on-screen [left, left+width) span of a start..end timeline range,
361
+ * unclamped — a clip's or audio track's own true screen extent, NOT the
362
+ * surface-clamped rect the row/lane painter actually draws into. Shared by
363
+ * `visibleSpanFraction` below and (for audio bars) `audioColumns` directly,
364
+ * which needs it a second time to POSITION the visible slice it draws.
365
+ *
366
+ * `null` for a degenerate (non-positive) span — nothing to divide by.
367
+ */
368
+ function fullSpanX(start: number, end: number, viewport: Viewport): { left: number; width: number } | null {
369
+ const left = timeToX(start, viewport)
370
+ const width = timeToX(end, viewport) - left
371
+ return width > 0 ? { left, width } : null
372
+ }
373
+
374
+ /**
375
+ * Fraction [f0, f1] of a start..end timeline range's own FULL on-screen span
376
+ * (see `fullSpanX`) that the CLAMPED `rect` it was actually handed to draw
377
+ * into covers. Shared by `visibleClipSampleRange` (where this fraction maps
378
+ * directly onto the fetched peaks array) and `audioColumns` (where it maps
379
+ * onto the trim WINDOW first — one step further removed from the sample
380
+ * array, because the fetched data can itself be a clamped sub-range of that
381
+ * window; see `sourceTimeToBarFraction`).
382
+ *
383
+ * Before either caller accounted for this, they resampled/positioned using
384
+ * the WHOLE clip/track's data against whatever width `rect` happened to be —
385
+ * correct only while the whole clip/track fit on screen, and pinned to "the
386
+ * whole thing" at every scroll position once it didn't (long clip or track:
387
+ * the waveform never changed as you scrolled through it, only the filmstrip
388
+ * or ruler above it did).
389
+ *
390
+ * `null` for a degenerate (zero-width) full span — nothing to divide by.
391
+ */
392
+ function visibleSpanFraction(start: number, end: number, rect: Rect, viewport: Viewport): { f0: number; f1: number } | null {
393
+ const span = fullSpanX(start, end, viewport)
394
+ if (!span) return null
395
+ // Clamped to [0, 1]: `rect` is the drawn body, which can only ever be a
396
+ // sub-span of the full extent, but float rounding at the very edges could
397
+ // otherwise nudge a fraction a hair outside that range.
398
+ const f0 = Math.max(0, Math.min(1, (rect.x - span.left) / span.width))
399
+ const f1 = Math.max(0, Math.min(1, (rect.x + rect.width - span.left) / span.width))
400
+ return { f0, f1 }
401
+ }
402
+
403
+ /**
404
+ * The [s0, s1) sample-index sub-range of a clip's peaks that the CLAMPED,
405
+ * gutter-inset `rect` actually shows on screen. Mirrors `filmstrips.ts`'s
406
+ * `clipTiles`, which derives its tile positions from the clip's full
407
+ * on-screen span rather than from whatever clamped rect the row painter
408
+ * happened to hand it.
409
+ *
410
+ * `null` for a degenerate (zero-width) full span or empty peaks — nothing to
411
+ * divide by; the caller falls back to treating the whole array as visible.
412
+ */
413
+ export function visibleClipSampleRange(
414
+ item: VisualItem,
415
+ rect: Rect,
416
+ viewport: Viewport,
417
+ totalSamples: number,
418
+ ): { s0: number; s1: number } | null {
419
+ if (totalSamples <= 0) return null
420
+ const frac = visibleSpanFraction(item.start, item.end, rect, viewport)
421
+ if (!frac) return null
422
+
423
+ const s0 = Math.min(totalSamples, Math.floor(frac.f0 * totalSamples))
424
+ const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(frac.f1 * totalSamples)))
425
+ return { s0, s1 }
426
+ }
427
+
428
+ /**
429
+ * Slice a WHOLE-proxy peaks array down to a clip's own trim window —
430
+ * `[window.start, window.start + window.duration]` expressed as a fraction
431
+ * of the proxy's own total duration — before any viewport slicing happens.
432
+ * `clipColumns` applies two nested fractions in sequence: proxy → clip trim
433
+ * window (this function) → visible viewport slice (`visibleClipSampleRange`,
434
+ * applied afterward, on the RESULT of this one).
435
+ *
436
+ * `null` when `proxyDuration` isn't usable (`<= 0`) — nothing to divide by,
437
+ * and the caller falls back to fetching this clip's own windowed peaks
438
+ * rather than guess a fraction against an unknown total.
439
+ */
440
+ function sliceToSourceWindow(
441
+ peaks: number[],
442
+ proxyDuration: number,
443
+ window: { start: number; duration: number },
444
+ ): number[] | null {
445
+ if (!(proxyDuration > 0)) return null
446
+ const totalSamples = Math.floor(peaks.length / 2)
447
+ if (totalSamples <= 0) return peaks
448
+ const f0 = Math.max(0, Math.min(1, window.start / proxyDuration))
449
+ const f1 = Math.max(0, Math.min(1, (window.start + window.duration) / proxyDuration))
450
+ const s0 = Math.min(totalSamples, Math.floor(f0 * totalSamples))
451
+ const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(f1 * totalSamples)))
452
+ return peaks.slice(s0 * 2, s1 * 2)
453
+ }
454
+
455
+ /**
456
+ * Fetch state, keyed as `projectId|ownerId|src|start|duration|bucket` — the
457
+ * same shape the montaj adapter's own peaks cache uses
458
+ * (`projectId:src:start:duration:bucket`, see `montajAdapter.ts`'s
459
+ * `getWaveformPeaks`), plus an owner id since one store instance serves
460
+ * every clip and audio track on the surface.
461
+ *
462
+ * `ownerId`/`start`/`duration` are OMITTED from a CLIP's key — see
463
+ * `clipColumns` — because the editing proxy is shared, unwindowed data every
464
+ * clip pointing at that `proxySrc` can reuse regardless of which clip or
465
+ * which trim is asking; splitting or trimming a clip must not change its
466
+ * key. Audio-lane fetches keep the FULL key (owner + window): audio tracks
467
+ * have their own per-take sources, never a shared proxy, so there is nothing
468
+ * to gain by dropping either field there and doing so would only make an
469
+ * audio track's own trim stop invalidating its own cache entry.
470
+ *
471
+ * One instance lives for the lifetime of a mounted `TimelineCanvas` (held in
472
+ * a ref), so fetches already in flight or resolved survive across redraws.
473
+ *
474
+ * Rendering always prefers the highest-resolution READY entry across ALL
475
+ * three buckets for a key, not just the one matching the current zoom's
476
+ * target bucket — that is what makes zoom-OUT free (a previously-fetched
477
+ * 800-bucket entry renders fine downsampled by `resamplePeaksToColumns`, no
478
+ * new fetch) while zoom-IN still fetches the next bucket up exactly once.
479
+ */
480
+ export class WaveformPeaksStore {
481
+ private entries = new Map<string, Entry>()
482
+
483
+ /**
484
+ * Clip waveforms fetch from `item.proxySrc` ONLY (never `item.src`) and
485
+ * only for `type: 'video'` items — images/overlays have no audio. Absent
486
+ * proxy, absent adapter method, or a non-positive window all resolve to
487
+ * `null` (no waveform yet), never an error.
488
+ *
489
+ * The fetch itself is UNWINDOWED — the whole proxy — and keyed by src
490
+ * alone (see `WaveformPeaksStore`'s own doc), so every clip, and every
491
+ * split/trim of every clip, sharing one `proxySrc` resolves from the SAME
492
+ * cache entry: no re-decode, no blank-frame flash when a clip is split or
493
+ * trimmed. The clip's own [inPoint, outPoint] window is then sliced out of
494
+ * that shared data LOCALLY (`sliceToSourceWindow`), and the existing
495
+ * viewport slice (`visibleClipSampleRange`) applied on top of THAT — two
496
+ * nested fractions, proxy → clip window → visible span.
497
+ */
498
+ clipColumns(item: VisualItem, rect: Rect, ctx: WaveformQueryContext): WaveformColumn[] | null {
499
+ if (item.type !== 'video' || !item.proxySrc || !ctx.getWaveformPeaks) return null
500
+ if (rect.width <= 0) return null
501
+ const window = clipSourceWindow(item)
502
+ if (window && window.duration <= 0) return null
503
+
504
+ const whole = this.resolve({ src: item.proxySrc }, ctx)
505
+ if (!whole) return null
506
+
507
+ const columns = Math.max(1, Math.round(rect.width))
508
+ let peaks = whole.peaks
509
+ if (window) {
510
+ // Prefer the fetch's OWN duration — since this is an unwindowed
511
+ // request, it should already be the proxy's true total length —
512
+ // falling back to the item's own record of it only if that's
513
+ // unusable (e.g. a step that doesn't report duration on an
514
+ // unwindowed fetch).
515
+ const proxyDuration = whole.duration > 0 ? whole.duration : (item.sourceDuration ?? 0)
516
+ const sliced = sliceToSourceWindow(whole.peaks, proxyDuration, window)
517
+ if (sliced) {
518
+ peaks = sliced
519
+ } else {
520
+ // Duration unknowable from either the fetch or the item — no way to
521
+ // map the trim window onto the whole-proxy peaks reliably. Falls
522
+ // back to the pre-fix behaviour: fetch (and cache) this clip's OWN
523
+ // windowed peaks, keyed by id + window. No cross-clip sharing in
524
+ // this rare case, but still the correct picture rather than a
525
+ // guess.
526
+ const windowed = this.resolve({ ownerId: item.id, src: item.proxySrc, start: window.start, duration: window.duration }, ctx)
527
+ if (!windowed) return null
528
+ peaks = windowed.peaks
529
+ }
530
+ }
531
+
532
+ const totalSamples = Math.floor(peaks.length / 2)
533
+ const span = visibleClipSampleRange(item, rect, ctx.viewport, totalSamples)
534
+ // No full span to divide by (e.g. zero pxPerSecond) — fall back to the
535
+ // pre-fix behaviour, the whole (already window-sliced) clip resampled
536
+ // into `columns`, rather than drawing nothing.
537
+ if (!span) return resamplePeaksToColumns(peaks, columns)
538
+ const slice = peaks.slice(span.s0 * 2, span.s1 * 2)
539
+ return resamplePeaksToColumns(slice, columns)
540
+ }
541
+
542
+ /**
543
+ * Audio-lane waveforms fetch from `track.src` (audio tracks have no
544
+ * proxies), windowed to the track's own trim (`audioTrackSourceWindow`) —
545
+ * NOT to whatever is currently visible, so scrolling never re-fetches.
546
+ * Returns the VISIBLE slice's own bar-relative sub-rect alongside its
547
+ * columns, so it draws in the right place rather than stretched across
548
+ * whatever rect the caller happened to hand in.
549
+ *
550
+ * Two things had to be reconciled to get there, both expressed as
551
+ * fractions of the track's trim WINDOW so they compose with one `Math.max`/
552
+ * `Math.min`:
553
+ * - `dataX0..dataX1` (ported from before this fix): the fraction of the
554
+ * window the FETCHED data actually covers — the step can return less
555
+ * than requested (e.g. clamped near the end of a source file).
556
+ * - `visible.f0..f1` (new): the fraction of the track's own FULL
557
+ * on-screen span (`track.start`..`track.end` through `viewport`,
558
+ * unclamped) that the CLAMPED `rect` we were handed to draw into
559
+ * actually shows. Before this existed, `rect` was taken at face value
560
+ * as "the whole bar" — fine while the bar fit on screen, but once a
561
+ * long track ran off both edges, the ENTIRE window got squeezed into
562
+ * whatever sliver was visible, unchanged as you scrolled through it
563
+ * (the same bug `visibleClipSampleRange` fixed for clip waveforms).
564
+ * Only their overlap is fetched data AND currently visible — anything else
565
+ * is either data we don't have or off screen, neither worth drawing.
566
+ */
567
+ audioColumns(track: AudioTrack, rect: Rect, ctx: WaveformQueryContext): { rect: Rect; columns: WaveformColumn[] } | null {
568
+ if (!ctx.getWaveformPeaks || rect.width <= 0) return null
569
+ const window = audioTrackSourceWindow(track)
570
+ if (window.duration <= 0) return null
571
+
572
+ const data = this.resolve({ ownerId: track.id, src: track.src, start: window.start, duration: window.duration }, ctx)
573
+ if (!data) return null
574
+
575
+ const dataX0 = sourceTimeToBarFraction(data.start, window)
576
+ const dataX1 = sourceTimeToBarFraction(data.start + data.duration, window)
577
+ // A degenerate/absent full span (e.g. `track.end <= track.start`) falls
578
+ // back to "the whole window is visible" — the pre-fix assumption — rather
579
+ // than drawing nothing over what may still be a perfectly good `rect`.
580
+ const visible = visibleSpanFraction(track.start, track.end, rect, ctx.viewport) ?? { f0: 0, f1: 1 }
581
+
582
+ const f0 = Math.max(dataX0, visible.f0)
583
+ const f1 = Math.min(dataX1, visible.f1)
584
+ if (f1 <= f0) return null
585
+
586
+ // Position the visible slice against the bar's TRUE full on-screen span,
587
+ // not the clamped `rect` — that's what lets it land at the right x once
588
+ // the bar runs off an edge. Falls back to `rect` itself alongside the
589
+ // same fallback `visible` used above.
590
+ const full = fullSpanX(track.start, track.end, ctx.viewport)
591
+ const subRect: Rect = full
592
+ ? { x: full.left + f0 * full.width, y: rect.y, width: Math.max(0, (f1 - f0) * full.width), height: rect.height }
593
+ : { x: rect.x + f0 * rect.width, y: rect.y, width: Math.max(0, (f1 - f0) * rect.width), height: rect.height }
594
+ if (subRect.width <= 0) return null
595
+
596
+ // Slice the fetched peaks down to just the visible fraction of the
597
+ // fetched span (`f0..f1` relative to `dataX0..dataX1`) before resampling
598
+ // — only ever the columns actually drawn get computed, mirroring
599
+ // `visibleClipSampleRange`.
600
+ const totalSamples = Math.floor(data.peaks.length / 2)
601
+ const dataSpan = dataX1 - dataX0
602
+ const df0 = dataSpan > 0 ? (f0 - dataX0) / dataSpan : 0
603
+ const df1 = dataSpan > 0 ? (f1 - dataX0) / dataSpan : 1
604
+ const s0 = Math.max(0, Math.min(totalSamples, Math.floor(df0 * totalSamples)))
605
+ const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(df1 * totalSamples)))
606
+ const slice = data.peaks.slice(s0 * 2, s1 * 2)
607
+
608
+ const columns = resamplePeaksToColumns(slice, Math.max(1, Math.round(subRect.width)))
609
+ return { rect: subRect, columns }
610
+ }
611
+
612
+ /**
613
+ * Look up the best already-fetched data for `key` and, if nothing at the
614
+ * needed bucket or higher is ready/loading, kick off a fetch for it.
615
+ * Errors are swallowed onto the entry (`status: 'error'`) — no throw, no
616
+ * console noise, no retry loop of our own; because an errored entry is
617
+ * neither `ready` nor `loading`, the NEXT call that still needs this key
618
+ * (e.g. the next redraw for an unrelated reason) will attempt the fetch
619
+ * again, same as the adapter's own evict-on-reject cache does one layer
620
+ * down.
621
+ */
622
+ private resolve(
623
+ key: { ownerId?: string; src: string; start?: number; duration?: number },
624
+ ctx: WaveformQueryContext,
625
+ ): PeaksData | null {
626
+ const bucket = resolveBucket(ctx.pxPerSecond)
627
+ const base = `${ctx.projectId}|${key.ownerId ?? ''}|${key.src}|${key.start ?? ''}|${key.duration ?? ''}`
628
+
629
+ let best: PeaksData | null = null
630
+ let covered = false
631
+ for (const b of PEAKS_BUCKETS) {
632
+ const entry = this.entries.get(`${base}|${b}`)
633
+ if (entry?.status === 'ready' && entry.data) {
634
+ // Compare by the DATA's actual samplesPerSecond, not the requested
635
+ // bucket label — the step's total-samples clamp can step a request
636
+ // down, and the clamp amount depends on window length, not which
637
+ // bucket was asked for, so the requested label alone isn't a
638
+ // reliable resolution ordering.
639
+ if (!best || entry.data.samplesPerSecond > best.samplesPerSecond) best = entry.data
640
+ }
641
+ if (b >= bucket && (entry?.status === 'ready' || entry?.status === 'loading')) covered = true
642
+ if (b >= bucket && entry?.status === 'error'
643
+ && Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS) covered = true
644
+ }
645
+
646
+ if (!covered) {
647
+ const fetchKey = `${base}|${bucket}`
648
+ const fetcher = ctx.getWaveformPeaks
649
+ if (fetcher) {
650
+ this.entries.set(fetchKey, { status: 'loading', data: null })
651
+ fetcher({ projectId: ctx.projectId, src: key.src, samplesPerSecond: bucket, start: key.start, duration: key.duration })
652
+ .then(data => {
653
+ this.entries.set(fetchKey, { status: 'ready', data })
654
+ ctx.onReady()
655
+ })
656
+ .catch(() => {
657
+ this.entries.set(fetchKey, { status: 'error', data: null, erroredAt: Date.now() })
658
+ })
659
+ }
660
+ }
661
+
662
+ return best
663
+ }
664
+ }