@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,96 @@
1
+ /**
2
+ * The scrubber's resolver mirrors `planTick`'s track-0 precedence, so its
3
+ * null branches are the ones worth pinning: gap, canvas project, missing
4
+ * proxy, higher-precedence src the engine can't decode. The happy path
5
+ * (proxied track-0 clip) is covered indirectly by `scheduler.test.ts` and
6
+ * directly by the T5 wiring tests.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import type { EditorProject as Project, VisualItem } from '../../schema'
10
+ import { createScrubResolver } from '../scrub-resolve'
11
+
12
+ function clip(id: string, start: number, end: number, extra: Partial<VisualItem> = {}): VisualItem {
13
+ return {
14
+ id,
15
+ type: 'video',
16
+ src: `/media/${id}.mov`,
17
+ proxySrc: `/proxies/${id}_proxy.mp4`,
18
+ start,
19
+ end,
20
+ inPoint: 0,
21
+ outPoint: end - start,
22
+ ...extra,
23
+ }
24
+ }
25
+
26
+ function overlay(id: string, start: number, end: number): VisualItem {
27
+ return { id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }
28
+ }
29
+
30
+ function project(track0: VisualItem[], overlays: VisualItem[] = []): Project {
31
+ return {
32
+ id: 'p1',
33
+ status: 'draft',
34
+ settings: { resolution: [1080, 1920], fps: 30 },
35
+ tracks: overlays.length > 0 ? [track0, overlays] : [track0],
36
+ } as unknown as Project
37
+ }
38
+
39
+ describe('createScrubResolver', () => {
40
+ it('returns null when the getter yields no project', () => {
41
+ const resolve = createScrubResolver(() => null)
42
+ expect(resolve(0)).toBeNull()
43
+ })
44
+
45
+ it('returns null in a gap between track-0 clips', () => {
46
+ const p = project([clip('a', 0, 2), clip('b', 5, 7)])
47
+ const resolve = createScrubResolver(() => p)
48
+ expect(resolve(3)).toBeNull()
49
+ })
50
+
51
+ it('returns null on a canvas project (no track-0 video items)', () => {
52
+ const p = project([], [overlay('o', 0, 5)])
53
+ const resolve = createScrubResolver(() => p)
54
+ expect(resolve(1)).toBeNull()
55
+ })
56
+
57
+ it('returns null for a clip with no proxySrc yet', () => {
58
+ const p = project([clip('a', 0, 4, { proxySrc: undefined })])
59
+ const resolve = createScrubResolver(() => p)
60
+ expect(resolve(1)).toBeNull()
61
+ })
62
+
63
+ it('returns null when a higher-precedence preview src outranks the proxy', () => {
64
+ // `nobg_preview_src` beats `proxySrc` in the preview chain; the engine's
65
+ // demuxer is MP4-only, so `engineSrcFor` blocks it and the scrubber goes
66
+ // silent — same rule that keeps the whole project on <video> fallback.
67
+ const p = project([clip('a', 0, 4, { nobg_preview_src: '/proxies/a_nobg.webm' })])
68
+ const resolve = createScrubResolver(() => p)
69
+ expect(resolve(1)).toBeNull()
70
+ })
71
+
72
+ it('resolves to the proxy src and source-media time on the happy path', () => {
73
+ const c = clip('a', 2, 6, { inPoint: 10, outPoint: 14 })
74
+ const p = project([c])
75
+ const resolve = createScrubResolver(() => p)
76
+ // projectS=3 is 1s into the clip; mediaS = inPoint + 1 = 11.
77
+ expect(resolve(3)).toEqual({ src: c.proxySrc, mediaS: 11 })
78
+ })
79
+
80
+ it('picks the earliest-start clip on overlap (mirrors planTick tiebreak)', () => {
81
+ const early = clip('early', 0, 5)
82
+ const late = clip('late', 3, 8)
83
+ const p = project([early, late])
84
+ const resolve = createScrubResolver(() => p)
85
+ const out = resolve(4)
86
+ expect(out?.src).toBe(early.proxySrc)
87
+ })
88
+
89
+ it('re-reads the project on every call so edits are observed', () => {
90
+ let current: Project = project([clip('a', 0, 4)])
91
+ const resolve = createScrubResolver(() => current)
92
+ expect(resolve(1)).not.toBeNull()
93
+ current = project([])
94
+ expect(resolve(1)).toBeNull()
95
+ })
96
+ })
@@ -0,0 +1,195 @@
1
+ /**
2
+ * T8 pure-fn coverage for scrub-source.ts's grain machinery: Hann-window
3
+ * sample math, throttle+epsilon silence, release-ramp-to-zero, and the T7
4
+ * Bluetooth-latency guard. No WebCodecs mock — every test here is driven only
5
+ * as far as the `resolve()` gate (or a standalone exported helper), which is
6
+ * as far as these tests need to reach; the decode → grain-playback pipeline
7
+ * is out of scope per the plan.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { createHoverScrub } from '../../video/hover-scrub'
11
+ import type { AcquiredDemux } from '../index'
12
+ import {
13
+ MOVE_EPSILON_S,
14
+ RELEASE_FADE_S,
15
+ THROTTLE_MS,
16
+ createScrubSource,
17
+ hannWindow,
18
+ releaseGrain,
19
+ type ScrubTarget,
20
+ } from '../scrub-source'
21
+
22
+ // ── hannWindow — pure sample math ────────────────────────────────────────────
23
+
24
+ describe('hannWindow', () => {
25
+ it('starts at 0', () => {
26
+ expect(hannWindow(8)[0]).toBe(0)
27
+ })
28
+
29
+ it('peaks at 1 at the midpoint', () => {
30
+ expect(hannWindow(8)[4]).toBeCloseTo(1, 10)
31
+ })
32
+
33
+ it('is symmetric around the midpoint (w[i] === w[n-i])', () => {
34
+ const w = hannWindow(16)
35
+ for (let i = 1; i < 16; i++) expect(w[i]).toBeCloseTo(w[16 - i], 10)
36
+ })
37
+
38
+ it('is the PERIODIC form: the last sample is not zero, it mirrors w[1]', () => {
39
+ // /n (not /(n-1)) means w[n-1] = 0.5*(1-cos(2π/n)), same as w[1] — small,
40
+ // but nonzero. A test asserting w[n-1]===0 would be asserting the wrong
41
+ // (symmetric, zero-ended) window shape.
42
+ const w = hannWindow(8)
43
+ expect(w[7]).toBeCloseTo(w[1], 10)
44
+ expect(w[7]).not.toBeCloseTo(0, 3)
45
+ })
46
+ })
47
+
48
+ // ── releaseGrain — the release-ramp math `stop()` applies to every live grain
49
+
50
+ describe('releaseGrain', () => {
51
+ it('ramps gain to zero over RELEASE_FADE_S and schedules stop at the ramp end', () => {
52
+ const gain = { gain: { value: 0.9, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() } }
53
+ const src = { stop: vi.fn() }
54
+ releaseGrain(src as unknown as AudioBufferSourceNode, gain as unknown as GainNode, 10)
55
+
56
+ expect(gain.gain.setValueAtTime).toHaveBeenCalledWith(0.9, 10)
57
+ expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith(0, 10 + RELEASE_FADE_S)
58
+ expect(src.stop).toHaveBeenCalledWith(10 + RELEASE_FADE_S)
59
+ })
60
+ })
61
+
62
+ // ── createScrubSource — throttle, epsilon and the T7 Bluetooth guard ────────
63
+
64
+ interface FakeCtx {
65
+ state: 'running' | 'suspended' | 'closed'
66
+ outputLatency: number
67
+ baseLatency: number
68
+ currentTime: number
69
+ }
70
+
71
+ function fakeCtx(over: Partial<FakeCtx> = {}): FakeCtx {
72
+ return { state: 'running', outputLatency: 0, baseLatency: 0, currentTime: 0, ...over }
73
+ }
74
+
75
+ function installCtx(ctx: FakeCtx) {
76
+ ;(window as unknown as { __montajSharedCtx?: FakeCtx }).__montajSharedCtx = ctx
77
+ }
78
+
79
+ /** Never resolves — `fireGrain` awaits this and then hangs, so no code past it (the WebCodecs decode) ever runs. */
80
+ const hangingAcquireDemux: (src: string) => Promise<AcquiredDemux> = vi.fn(
81
+ () => new Promise<AcquiredDemux>(() => {}),
82
+ )
83
+
84
+ /** Never called — used where `resolve` is expected to be gated out before it would ever run. */
85
+ const notCalledAcquireDemux: (src: string) => Promise<AcquiredDemux> = vi.fn(async () => {
86
+ throw new Error('acquireDemux should not be called in this test')
87
+ })
88
+
89
+ /**
90
+ * A target `resolve` can return truthily. `onScrub` only updates its own
91
+ * throttle/epsilon state (`lastFireMs`/`lastFiredMediaS`) AFTER `resolve`
92
+ * returns non-null — a `resolve` that always returns `null` would never let
93
+ * that state advance, which would make every later call look "unthrottled"
94
+ * for the wrong reason. So the throttle/epsilon tests below resolve to a
95
+ * real target (and let `acquireDemux` hang forever) rather than short-
96
+ * circuiting on `null`.
97
+ */
98
+ function fixedTarget(mediaS: number): ScrubTarget {
99
+ return { src: '/proxies/a_proxy.mp4', mediaS }
100
+ }
101
+
102
+ let nowSpy: ReturnType<typeof vi.spyOn> | undefined
103
+
104
+ /** One `performance.now()` reading per onScrub call, in order. */
105
+ function scriptNow(times: number[]) {
106
+ let i = 0
107
+ nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => times[Math.min(i++, times.length - 1)])
108
+ }
109
+
110
+ afterEach(() => {
111
+ nowSpy?.mockRestore()
112
+ nowSpy = undefined
113
+ delete (window as unknown as { __montajSharedCtx?: FakeCtx }).__montajSharedCtx
114
+ })
115
+
116
+ describe('throttle + epsilon — stationary hover is silent', () => {
117
+ beforeEach(() => installCtx(fakeCtx()))
118
+
119
+ it('does not fire a second grain within THROTTLE_MS of the last, but does once past it', () => {
120
+ scriptNow([1000, 1000 + THROTTLE_MS / 2, 1000 + THROTTLE_MS + 200])
121
+ const resolve = vi.fn((t: number) => fixedTarget(t))
122
+ const source = createScrubSource({ acquireDemux: hangingAcquireDemux, resolve })
123
+ source.setEnabled(true)
124
+ const hover = createHoverScrub()
125
+ source.attach(hover)
126
+
127
+ hover.set(1.0) // t=1000ms: first move always fires
128
+ expect(resolve).toHaveBeenCalledTimes(1)
129
+
130
+ hover.set(1.05) // t=1000+16ms: inside THROTTLE_MS — silent
131
+ expect(resolve).toHaveBeenCalledTimes(1)
132
+
133
+ hover.set(1.1) // well past THROTTLE_MS and past MOVE_EPSILON_S — fires again
134
+ expect(resolve).toHaveBeenCalledTimes(2)
135
+
136
+ source.dispose()
137
+ })
138
+
139
+ it('does not fire when the move is smaller than MOVE_EPSILON_S, even after THROTTLE_MS has passed', () => {
140
+ scriptNow([1000, 1000 + THROTTLE_MS + 100])
141
+ const resolve = vi.fn((t: number) => fixedTarget(t))
142
+ const source = createScrubSource({ acquireDemux: hangingAcquireDemux, resolve })
143
+ source.setEnabled(true)
144
+ const hover = createHoverScrub()
145
+ source.attach(hover)
146
+
147
+ hover.set(1.0)
148
+ expect(resolve).toHaveBeenCalledTimes(1)
149
+
150
+ hover.set(1.0 + MOVE_EPSILON_S / 2) // past the throttle window, but the move itself is sub-epsilon
151
+ expect(resolve).toHaveBeenCalledTimes(1)
152
+
153
+ source.dispose()
154
+ })
155
+
156
+ it('goes silent (stop) on a null hover position without touching resolve', () => {
157
+ scriptNow([1000])
158
+ const resolve = vi.fn((t: number) => fixedTarget(t))
159
+ const source = createScrubSource({ acquireDemux: hangingAcquireDemux, resolve })
160
+ source.setEnabled(true)
161
+ const hover = createHoverScrub()
162
+ source.attach(hover)
163
+
164
+ hover.set(1.0)
165
+ expect(resolve).toHaveBeenCalledTimes(1)
166
+
167
+ hover.set(null)
168
+ expect(resolve).toHaveBeenCalledTimes(1)
169
+
170
+ source.dispose()
171
+ })
172
+ })
173
+
174
+ describe('T7 guard — Bluetooth/high-latency auto-disable', () => {
175
+ it('blocks every scrub above SCRUB_LATENCY_THRESHOLD_S and warns via onError exactly once', () => {
176
+ installCtx(fakeCtx({ outputLatency: 0.15 })) // > the 0.08s threshold
177
+ const resolve = vi.fn(() => null)
178
+ const onError = vi.fn()
179
+ const source = createScrubSource({ acquireDemux: notCalledAcquireDemux, resolve, onError })
180
+
181
+ source.setEnabled(true) // the guard also fires its one-time warning right on enable
182
+ expect(onError).toHaveBeenCalledTimes(1)
183
+ expect(onError.mock.calls[0][0]).toMatch(/latency too high/i)
184
+
185
+ const hover = createHoverScrub()
186
+ source.attach(hover)
187
+ hover.set(1.0)
188
+ hover.set(2.0)
189
+
190
+ expect(resolve).not.toHaveBeenCalled()
191
+ expect(onError).toHaveBeenCalledTimes(1) // still just the one warning, not one per attempt
192
+
193
+ source.dispose()
194
+ })
195
+ })