@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Where in the timeline an audible scrub position lands.
3
+ *
4
+ * The scrubber ({@link ./scrub-source.ts `createScrubSource`}) must composite
5
+ * over the engine's own decode plan without re-deriving it — same
6
+ * `resolveAt(..., {variant:'preview'})`, same earliest-start-wins tiebreak on
7
+ * track-0 video items, same proxy-only src gate. Duplicating any of that risks
8
+ * drift the moment either side changes, so this builder reuses the exact
9
+ * helpers `scheduler.planTick` calls:
10
+ * • `resolveAt` (timeline-core) — the scene the resolver hands the engine
11
+ * (scheduler.ts:543).
12
+ * • `engineSrcFor` (scheduler.ts:527) — the "engine-decodable proxy" gate,
13
+ * which BOTH catches a missing `proxySrc` AND catches a higher-precedence
14
+ * preview src the engine cannot decode (e.g. `nobg_preview_src`, VP9
15
+ * WebM-with-alpha — SP4 T1). That second half is why the `<video>`
16
+ * fallback is excluded here for free: a fallback project's clips either
17
+ * lack `proxySrc` outright or resolve to a src the engine can't open.
18
+ * • `placeInSource` (scheduler.ts:324) — project-time → source-media
19
+ * seconds, including per-clip speed and loop wrap.
20
+ *
21
+ * Returned `null` (the scrubber stays silent) covers the four cases the plan
22
+ * names: gap between clips, canvas project (no track-0 video items), a clip
23
+ * whose `engineSrcFor` is blocked, and by extension a `<video>`-fallback
24
+ * project.
25
+ *
26
+ * The builder takes a project GETTER, not a bare project. `createScrubSource`
27
+ * has no `setResolve`, so the resolver must stay stable across edits; a
28
+ * closure that reads the latest project on each call keeps the same scrub
29
+ * source instance live as the timeline mutates.
30
+ */
31
+ import { resolveAt } from '@bycrux/timeline-core'
32
+ import type { EditorProject as Project, VisualItem } from '../schema'
33
+ import { withEnabledItemTracks } from '../video/timeline/timeline-model'
34
+ import { engineSrcFor, placeInSource } from './scheduler'
35
+ import type { ScrubTarget } from './scrub-source'
36
+
37
+ export type ScrubResolver = (projectS: number) => ScrubTarget | null
38
+
39
+ export function createScrubResolver(getProject: () => Project | null | undefined): ScrubResolver {
40
+ return (projectS) => {
41
+ const project = getProject()
42
+ if (!project) return null
43
+ const scene = resolveAt(withEnabledItemTracks(project), projectS, { variant: 'preview' })
44
+
45
+ // Mirror `planTick`'s active-clip loop (scheduler.ts:566-587): filter to
46
+ // track-0 video items with a resolved window, then keep the
47
+ // earliest-start on overlap. `resolveAt` returns items in document order,
48
+ // so the tiebreak has to be explicit.
49
+ let bestItem: VisualItem | null = null
50
+ let bestWindow: NonNullable<(typeof scene.items)[number]['window']> | null = null
51
+ for (const resolved of scene.items) {
52
+ if (resolved.trackIdx !== 0 || resolved.kind !== 'video' || !resolved.window) continue
53
+ const item = resolved.item as unknown as VisualItem
54
+ if (bestItem && (bestItem.start ?? 0) <= (item.start ?? 0)) continue
55
+ bestItem = item
56
+ bestWindow = resolved.window
57
+ }
58
+ if (!bestItem || !bestWindow) return null
59
+
60
+ const usable = engineSrcFor(bestItem, bestWindow)
61
+ if (usable.blocked) return null
62
+
63
+ const placement = placeInSource(bestItem, bestWindow, projectS)
64
+ return { src: usable.src, mediaS: placement.mediaS }
65
+ }
66
+ }
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Audible drag-scrub source, grain-per-move.
3
+ *
4
+ * Dragging the playhead (the yellow hover cursor, tracked in
5
+ * `../video/hover-scrub.ts`) makes sound, the way a tape jog-wheel does. This
6
+ * module composites over the engine's own graph without disturbing it — it
7
+ * never pushes into the worklet ring.
8
+ *
9
+ * ── Why this does NOT drive the engine's worklet ring ───────────────────────
10
+ * The master clock's audible fast-forward (`audio-clock.ts`) posts stretched
11
+ * PCM into an `AudioWorklet` FIFO kept `RING_SECONDS` (=2s) deep, and that ring
12
+ * only drains while the transport is PLAYING. A drag-scrub happens while the
13
+ * transport is PAUSED, and re-using that ring would mean flushing up to 2s of
14
+ * already-buffered audio on every pointer move — the worst possible latency for
15
+ * a gesture that must feel instant. So each grain plays through a throwaway
16
+ * `AudioBufferSourceNode` on the SAME shared `AudioContext`: a `start()`
17
+ * reaches the speakers in `baseLatency + outputLatency`, the tightest the
18
+ * platform allows.
19
+ *
20
+ * ── Grain-per-move, not rate-follow ─────────────────────────────────────────
21
+ * On each new scrub position we fire ONE short Hann-windowed grain of the
22
+ * source audio at that position, played at natural pitch. Fast drags overlap
23
+ * grains (they simply sum on the shared context); a stationary hover fires
24
+ * nothing. This needs no WSOLA time-stretch — grains are natural-rate windows
25
+ * — so it skips the streaming stretcher's ~43ms block-fill latency entirely,
26
+ * which is exactly why it is tighter than driving the variable-rate path.
27
+ *
28
+ * ── Click-free ──────────────────────────────────────────────────────────────
29
+ * Every grain is multiplied by a full Hann window before playback, so it fades
30
+ * in from and out to zero — no edge discontinuity, no click, whatever position
31
+ * it starts at. A per-grain `GainNode` lets stop-on-release ramp to silence.
32
+ *
33
+ * ── Shared demux LRU ────────────────────────────────────────────────────────
34
+ * Demuxing is expensive; the engine already keeps a per-`src` LRU behind
35
+ * `Engine.acquireDemux` (`./index.ts`). The scrubber holds AT MOST ONE pin at
36
+ * a time — the current src — and swaps it via release-then-acquire when the
37
+ * hover crosses a cut. A src the scheduler is also using is a warm cache hit
38
+ * on both sides; a src only the scrubber touches stays pinned until the next
39
+ * cut releases it, then falls out of the LRU on its own.
40
+ */
41
+ import { getSharedAudioContext, latencySeconds } from '../video/preview/audio-context'
42
+ import { sampleAtOrBefore, type ChunkSource } from './demux'
43
+ import { normalizeAudioCodec, audioTrackIsDecodable } from './audio-clock'
44
+ import type { AcquiredDemux } from './index'
45
+ import type { HoverScrub } from '../video/hover-scrub'
46
+
47
+ // ── Tuning ───────────────────────────────────────────────────────────────────
48
+
49
+ /** Grain length in source-audio packets (~20ms each for libopus) → ~80ms grain. */
50
+ const GRAIN_PACKETS = 4
51
+ /** Don't fire grains faster than this; a fast drag overlaps them instead. Exported for scrub-source.test.ts. */
52
+ export const THROTTLE_MS = 32
53
+ /** Ignore a scrub move smaller than this (seconds) — a stationary hover is silent. Exported for scrub-source.test.ts. */
54
+ export const MOVE_EPSILON_S = 0.004
55
+ /** Release fade (seconds) so stop-on-release never clicks. Exported for scrub-source.test.ts. */
56
+ export const RELEASE_FADE_S = 0.02
57
+ /** Fallbacks when a container omits its audio params (Opus is 48k by definition). */
58
+ const DEFAULT_RATE = 48000
59
+ const DEFAULT_CHANNELS = 2
60
+ /**
61
+ * Bluetooth output commonly reports 100-300ms of combined output+base
62
+ * latency (see `latencySeconds` in `audio-context.ts`) — a physical wall, not
63
+ * something software can close. Past ~80-100ms a grain reaches the ear so
64
+ * long after the pointer moved that "scrub by ear" no longer reads as
65
+ * instant, so auto-disable rather than ship a version of the feature that
66
+ * feels broken over Bluetooth.
67
+ */
68
+ const SCRUB_LATENCY_THRESHOLD_S = 0.08
69
+ /**
70
+ * Above this drag speed (media-seconds crossed per wall-clock-second),
71
+ * consecutive grains stop sharing source content: the ~80ms grain
72
+ * (GRAIN_PACKETS) fired every THROTTLE_MS only overlaps its predecessor up to
73
+ * GRAIN_MS/THROTTLE_MS ≈ 80/32 = 2.5x; faster than that each grain samples
74
+ * unrelated media, which is what reads as buzz on a fast drag.
75
+ */
76
+ const FAST_DRAG_VELOCITY_S_PER_S = 2.5
77
+ /** Never widen the gap past this — even the fastest drag should still jog audibly. */
78
+ const MAX_THROTTLE_MS = 96
79
+
80
+ /** Where in the timeline a scrub position lands, resolved by the host wiring. */
81
+ export interface ScrubTarget {
82
+ /** The clip's editing proxy — exactly what the engine would decode (`item.proxySrc`). */
83
+ src: string
84
+ /** Position inside that source's own timeline, seconds (the `<video>.currentTime` analog). */
85
+ mediaS: number
86
+ }
87
+
88
+ export interface ScrubSourceOptions {
89
+ /**
90
+ * Acquire a shared, pinned demuxed source from the engine's demux LRU. The
91
+ * scrubber releases the returned handle exactly once (on src change or
92
+ * `dispose`), which lets the LRU evict it. Same shape as the scheduler's
93
+ * own reader: a hover across a cut hits a warm cache instead of re-demuxing.
94
+ */
95
+ acquireDemux: (src: string) => Promise<AcquiredDemux>
96
+ /**
97
+ * Map a project-time scrub position to a decodable source + media position, or
98
+ * `null` when nothing audible is there (a gap, a canvas project, a clip with no
99
+ * engine-decodable proxy — this is where the `<video>` fallback is EXCLUDED).
100
+ * The wiring builds this from timeline-core's `resolveAt`/`sourceWindow`,
101
+ * mirroring `scheduler.ts`'s `planTick` — see `./scrub-resolve.ts`.
102
+ */
103
+ resolve: (projectS: number) => ScrubTarget | null
104
+ /** Advisory errors (decode/fetch). None stop the scrubber. */
105
+ onError?: (message: string) => void
106
+ }
107
+
108
+ export interface ScrubSource {
109
+ /** Turn audible scrubbing on/off. Off by default; caller toggles it. */
110
+ setEnabled(on: boolean): void
111
+ enabled(): boolean
112
+ /** Subscribe to a hover-scrub store; returns an unsubscribe. */
113
+ attach(hover: HoverScrub): () => void
114
+ /** Silence any ringing grains immediately. */
115
+ stop(): void
116
+ dispose(): void
117
+ }
118
+
119
+ /** One demuxed proxy's audio track, plus its shared-demux pin. */
120
+ interface CachedAudio {
121
+ src: string
122
+ pin: AcquiredDemux
123
+ audio: ChunkSource
124
+ decoder: AudioDecoder
125
+ rate: number
126
+ channels: number
127
+ /**
128
+ * Mirrors audio-clock's `feedFailed`: set once this src's decoder throws or
129
+ * reports an error, so a wedged decoder can't hang subsequent grains on the
130
+ * same src — cleared implicitly by `ensureAudio` building a fresh entry
131
+ * (and its own decoder) rather than by resetting the flag in place.
132
+ */
133
+ decodeFailed: boolean
134
+ }
135
+
136
+ /** Accumulator for the grain currently being decoded — guarded by `seq`. */
137
+ interface PendingGrain {
138
+ seq: number
139
+ planes: Float32Array[][] // per-AudioData: array of channel planes
140
+ frames: number
141
+ }
142
+
143
+ /**
144
+ * Hann window, one full cycle across `n` samples: 0 at the leading edge, 1 at
145
+ * the midpoint. This is the PERIODIC form (`/n`, not `/(n-1)`), so the last
146
+ * sample is NOT zero — it lands wherever `w[1]` does. Module-level and
147
+ * exported so scrub-source.test.ts can assert on it directly without a
148
+ * WebCodecs decode pipeline.
149
+ */
150
+ export function hannWindow(n: number): Float32Array {
151
+ const w = new Float32Array(n)
152
+ for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / n))
153
+ return w
154
+ }
155
+
156
+ /**
157
+ * Ramp `gain` to zero over `RELEASE_FADE_S` from `now` and schedule `src` to
158
+ * stop at the ramp's end. Pulled out of `stop()`'s loop body and exported so
159
+ * scrub-source.test.ts can assert the release-ramp math on spy nodes without
160
+ * needing a real in-flight grain (which requires a WebCodecs decode).
161
+ */
162
+ export function releaseGrain(
163
+ src: Pick<AudioBufferSourceNode, 'stop'>,
164
+ gain: Pick<GainNode, 'gain'>,
165
+ now: number,
166
+ ): void {
167
+ gain.gain.setValueAtTime(gain.gain.value, now)
168
+ gain.gain.linearRampToValueAtTime(0, now + RELEASE_FADE_S)
169
+ src.stop(now + RELEASE_FADE_S)
170
+ }
171
+
172
+ export function createScrubSource(options: ScrubSourceOptions): ScrubSource {
173
+ const { acquireDemux, resolve, onError } = options
174
+ const ctx = getSharedAudioContext()
175
+
176
+ let on = false
177
+ let disposed = false
178
+ let lastFireMs = 0
179
+ let lastFiredMediaS = Number.NEGATIVE_INFINITY
180
+ let seq = 0
181
+ /** So the Bluetooth-latency guard emits `onError` once per violation, not once per grain. */
182
+ let latencyWarned = false
183
+
184
+ /** The one src whose demux we currently hold. `null` between swaps. */
185
+ let entry: CachedAudio | null = null
186
+ /**
187
+ * An in-flight `acquireDemux` we haven't decided the outcome of yet, plus
188
+ * the src it was for. If a later `ensureAudio(other)` supersedes it, we
189
+ * release the resolved handle rather than swapping it in. Coalesces
190
+ * concurrent grain fires on the same src into one acquire.
191
+ */
192
+ let pendingLoad: Promise<CachedAudio | null> | null = null
193
+ let pendingSrc: string | null = null
194
+
195
+ /** Grains currently ringing, so `stop()`/release can fade them. */
196
+ const live = new Set<{ src: AudioBufferSourceNode; gain: GainNode }>()
197
+
198
+ function retire(cur: CachedAudio): void {
199
+ try {
200
+ cur.decoder.close()
201
+ } catch {
202
+ /* already closed */
203
+ }
204
+ cur.pin.release()
205
+ }
206
+
207
+ async function ensureAudio(src: string): Promise<CachedAudio | null> {
208
+ if (entry && entry.src === src) return entry
209
+ if (pendingLoad && pendingSrc === src) return pendingLoad
210
+
211
+ pendingSrc = src
212
+ const build = (async (): Promise<CachedAudio | null> => {
213
+ let acquired: AcquiredDemux | null = null
214
+ try {
215
+ acquired = await acquireDemux(src)
216
+ // Superseded (or torn down) while we awaited: give the pin straight back.
217
+ if (disposed || pendingSrc !== src) {
218
+ acquired.release()
219
+ return null
220
+ }
221
+ const audio = acquired.source.audio
222
+ if (!audio || audio.samples.length === 0 || !audioTrackIsDecodable(audio)) {
223
+ acquired.release()
224
+ return null
225
+ }
226
+ const rate = audio.audio?.sampleRate || DEFAULT_RATE
227
+ const channels = audio.audio?.channelCount || DEFAULT_CHANNELS
228
+ const built: CachedAudio = {
229
+ src,
230
+ pin: acquired,
231
+ audio,
232
+ rate,
233
+ channels,
234
+ decoder: null as unknown as AudioDecoder,
235
+ decodeFailed: false,
236
+ }
237
+ const decoder = new AudioDecoder({
238
+ output: (frame) => onDecoded(built, frame),
239
+ error: (err) => {
240
+ // Same wedge signal as a synchronous decode() throw below — a
241
+ // decoder that reports an error mid-flush can't be trusted for
242
+ // the next grain either.
243
+ built.decodeFailed = true
244
+ onError?.(`scrub-source decode: ${err instanceof Error ? err.message : String(err)}`)
245
+ },
246
+ })
247
+ decoder.configure({
248
+ codec: normalizeAudioCodec(audio.codec),
249
+ description: audio.description,
250
+ sampleRate: rate,
251
+ numberOfChannels: channels,
252
+ })
253
+ built.decoder = decoder
254
+ // Swap in: the old entry's src is no longer the one we want, so its
255
+ // pin has to go before another acquire could evict from the LRU.
256
+ if (entry) retire(entry)
257
+ entry = built
258
+ return built
259
+ } catch (err) {
260
+ if (acquired) acquired.release()
261
+ onError?.(`scrub-source demux: ${err instanceof Error ? err.message : String(err)}`)
262
+ return null
263
+ } finally {
264
+ if (pendingSrc === src) {
265
+ pendingLoad = null
266
+ pendingSrc = null
267
+ }
268
+ }
269
+ })()
270
+ pendingLoad = build
271
+ return build
272
+ }
273
+
274
+ /** The grain currently being filled by decoder output, or null between grains. */
275
+ let pending: PendingGrain | null = null
276
+
277
+ function onDecoded(cur: CachedAudio, frame: AudioData): void {
278
+ try {
279
+ if (!pending) return
280
+ // A late frame from an already-retired decoder must not land in the
281
+ // grain of a newer src.
282
+ if (cur !== entry) return
283
+ const planeCount = frame.numberOfChannels
284
+ const frames = frame.numberOfFrames
285
+ const planes: Float32Array[] = []
286
+ for (let ch = 0; ch < planeCount; ch++) {
287
+ const plane = new Float32Array(frames)
288
+ frame.copyTo(plane, { planeIndex: ch, format: 'f32-planar' })
289
+ planes.push(plane)
290
+ }
291
+ pending.planes.push(planes)
292
+ pending.frames += frames
293
+ } finally {
294
+ frame.close()
295
+ }
296
+ }
297
+
298
+ async function fireGrain(target: ScrubTarget): Promise<void> {
299
+ const cur = await ensureAudio(target.src)
300
+ if (disposed || !on || !cur) return
301
+ // Src may have moved on while we awaited the acquire.
302
+ if (cur !== entry) return
303
+ // Wedged decoder for this src — bail rather than pile another decode/
304
+ // flush cycle onto it (see `CachedAudio.decodeFailed`).
305
+ if (cur.decodeFailed) return
306
+
307
+ const { audio, decoder, rate, channels } = cur
308
+ const mediaTsUs = audio.firstPresentationTsUs + target.mediaS * 1_000_000
309
+ const startIdx = sampleAtOrBefore(audio, mediaTsUs)
310
+ const endIdx = Math.min(startIdx + GRAIN_PACKETS, audio.samples.length)
311
+ if (endIdx <= startIdx) return
312
+
313
+ // Make the packet bytes resident (no-op on the whole-file path).
314
+ const ready = audio.ensure?.(startIdx, endIdx)
315
+ if (ready) {
316
+ try {
317
+ await ready
318
+ } catch (err) {
319
+ onError?.(`scrub-source fetch: ${err instanceof Error ? err.message : String(err)}`)
320
+ return
321
+ }
322
+ if (disposed || !on || cur !== entry) return
323
+ }
324
+
325
+ const mySeq = ++seq
326
+ pending = { seq: mySeq, planes: [], frames: 0 }
327
+ try {
328
+ for (let i = startIdx; i < endIdx; i++) {
329
+ const s = audio.samples[i]
330
+ decoder.decode(
331
+ new EncodedAudioChunk({ type: 'key', timestamp: s.tsUs, duration: s.durUs, data: s.data }),
332
+ )
333
+ }
334
+ await decoder.flush()
335
+ } catch (err) {
336
+ cur.decodeFailed = true
337
+ onError?.(`scrub-source decode: ${err instanceof Error ? err.message : String(err)}`)
338
+ return
339
+ }
340
+ // Superseded by a newer grain while we awaited flush, or turned off.
341
+ if (disposed || !on || !pending || pending.seq !== mySeq) return
342
+ // Or the src changed under us (decoder we flushed is now retired).
343
+ if (cur !== entry) return
344
+ const grain = pending
345
+ pending = null
346
+ if (grain.frames === 0) return
347
+
348
+ play(grain, channels, rate)
349
+ }
350
+
351
+ function play(grain: PendingGrain, channels: number, rate: number): void {
352
+ const total = grain.frames
353
+ const buffer = ctx.createBuffer(channels, total, rate)
354
+ for (let ch = 0; ch < channels; ch++) {
355
+ const dst = buffer.getChannelData(ch)
356
+ let at = 0
357
+ for (const planes of grain.planes) {
358
+ const plane = planes[Math.min(ch, planes.length - 1)]
359
+ dst.set(plane, at)
360
+ at += plane.length
361
+ }
362
+ }
363
+ // Hann across the whole grain → fades in and out to zero, click-free.
364
+ const win = hannWindow(total)
365
+ for (let ch = 0; ch < channels; ch++) {
366
+ const d = buffer.getChannelData(ch)
367
+ for (let i = 0; i < total; i++) d[i] *= win[i]
368
+ }
369
+
370
+ const src = ctx.createBufferSource()
371
+ src.buffer = buffer
372
+ const gain = ctx.createGain()
373
+ src.connect(gain)
374
+ gain.connect(ctx.destination)
375
+ const handle = { src, gain }
376
+ live.add(handle)
377
+ src.onended = () => {
378
+ try {
379
+ gain.disconnect()
380
+ } catch {
381
+ /* already gone */
382
+ }
383
+ live.delete(handle)
384
+ }
385
+ try {
386
+ src.start()
387
+ } catch {
388
+ live.delete(handle)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Bluetooth/high-latency guard: read live (a device can change mid-session)
394
+ * rather than only at `setEnabled(true)` time, since a per-grain read is
395
+ * cheap. Warns once per violation via `onError` rather than once per grain.
396
+ */
397
+ function latencyOk(): boolean {
398
+ const ok = latencySeconds(ctx) <= SCRUB_LATENCY_THRESHOLD_S
399
+ if (ok) {
400
+ latencyWarned = false
401
+ } else if (!latencyWarned) {
402
+ latencyWarned = true
403
+ onError?.('scrub-source: output latency too high, scrub audio disabled (Bluetooth?)')
404
+ }
405
+ return ok
406
+ }
407
+
408
+ function onScrub(t: number | null): void {
409
+ if (!on || disposed) return
410
+ if (t === null) {
411
+ stop()
412
+ return
413
+ }
414
+ if (!latencyOk()) return
415
+ const nowMs = performance.now()
416
+ const dtMs = nowMs - lastFireMs
417
+ if (dtMs < THROTTLE_MS) return
418
+ const moveS = Math.abs(t - lastFiredMediaS)
419
+ if (moveS < MOVE_EPSILON_S) return
420
+ // Velocity-scale the throttle: a fast drag crossing many media-seconds
421
+ // per wall-clock-second widens the gap so fewer, better-spaced grains
422
+ // fire instead of stacking decorrelated ones into buzz (derivation on
423
+ // `FAST_DRAG_VELOCITY_S_PER_S` above).
424
+ const velocity = moveS / (dtMs / 1000)
425
+ if (velocity > FAST_DRAG_VELOCITY_S_PER_S) {
426
+ const scaledThrottle = Math.min(
427
+ MAX_THROTTLE_MS,
428
+ THROTTLE_MS * (velocity / FAST_DRAG_VELOCITY_S_PER_S),
429
+ )
430
+ if (dtMs < scaledThrottle) return
431
+ }
432
+ const target = resolve(t)
433
+ if (!target) return
434
+ lastFireMs = nowMs
435
+ lastFiredMediaS = t
436
+ void fireGrain(target)
437
+ }
438
+
439
+ function stop(): void {
440
+ const now = ctx.currentTime
441
+ for (const { src, gain } of live) {
442
+ try {
443
+ releaseGrain(src, gain, now)
444
+ } catch {
445
+ /* already stopped */
446
+ }
447
+ }
448
+ lastFiredMediaS = Number.NEGATIVE_INFINITY
449
+ }
450
+
451
+ return {
452
+ setEnabled(next: boolean) {
453
+ on = next
454
+ if (!next) {
455
+ stop()
456
+ return
457
+ }
458
+ // Surface the Bluetooth/high-latency hint immediately on enable rather
459
+ // than waiting for the first scrub move to discover it.
460
+ latencyOk()
461
+ },
462
+ enabled: () => on,
463
+ attach(hover: HoverScrub) {
464
+ return hover.subscribe(() => onScrub(hover.get()))
465
+ },
466
+ stop,
467
+ dispose() {
468
+ if (disposed) return
469
+ disposed = true
470
+ on = false
471
+ stop()
472
+ if (entry) {
473
+ retire(entry)
474
+ entry = null
475
+ }
476
+ // A load in flight will land in `build`'s superseded branch and
477
+ // release its own pin; nothing else to do here.
478
+ pendingLoad = null
479
+ pendingSrc = null
480
+ },
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Devtools bootstrap. Once the wiring has called this, the user can toggle
486
+ * audible scrubbing live from the console:
487
+ *
488
+ * window.__montajScrubSource.setEnabled(true)
489
+ * window.__montajScrubSource.setEnabled(false)
490
+ *
491
+ * Kept separate from `createScrubSource` so the factory stays testable without
492
+ * a global.
493
+ */
494
+ export function installScrubSource(source: ScrubSource): void {
495
+ ;(window as unknown as { __montajScrubSource?: ScrubSource }).__montajScrubSource = source
496
+ }