@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,187 @@
1
+ /**
2
+ * Fade-shape math (`fade-curve.ts`) — the one source of truth for what
3
+ * linear/log/exp look and sound like, shared by the envelope curve
4
+ * (draw.ts), the waveform's amplitude scaling (waveforms.ts), and — via a
5
+ * name mapping kept there — the rendered mix (mix-audio.js).
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { DEFAULT_FADE_CURVE, fadeCurveIconPoints, fadeGain, makeFadeGainAt } from '../fade-curve'
9
+
10
+ describe('DEFAULT_FADE_CURVE', () => {
11
+ it('is exp — the shape every fade had before curves existed', () => {
12
+ expect(DEFAULT_FADE_CURVE).toBe('exp')
13
+ })
14
+ })
15
+
16
+ describe('fadeGain', () => {
17
+ it('is 0 at p=0 (the silent edge) and 1 at p=1 (the full-volume edge), for every shape', () => {
18
+ for (const curve of ['linear', 'log', 'exp'] as const) {
19
+ expect(fadeGain(0, curve)).toBe(0)
20
+ expect(fadeGain(1, curve)).toBe(1)
21
+ }
22
+ })
23
+
24
+ it('linear is a straight ramp: gain(p) === p', () => {
25
+ expect(fadeGain(0.25, 'linear')).toBeCloseTo(0.25)
26
+ expect(fadeGain(0.5, 'linear')).toBeCloseTo(0.5)
27
+ expect(fadeGain(0.75, 'linear')).toBeCloseTo(0.75)
28
+ })
29
+
30
+ it('exp (t²) sits BELOW the linear ramp everywhere in (0,1) — slow start, fast finish', () => {
31
+ for (const p of [0.1, 0.25, 0.5, 0.75, 0.9]) {
32
+ expect(fadeGain(p, 'exp')).toBeLessThan(p)
33
+ }
34
+ expect(fadeGain(0.5, 'exp')).toBeCloseTo(0.25)
35
+ })
36
+
37
+ it('log (t(2-t)) sits ABOVE the linear ramp everywhere in (0,1) — fast start, slow finish', () => {
38
+ for (const p of [0.1, 0.25, 0.5, 0.75, 0.9]) {
39
+ expect(fadeGain(p, 'log')).toBeGreaterThan(p)
40
+ }
41
+ expect(fadeGain(0.5, 'log')).toBeCloseTo(0.75)
42
+ })
43
+
44
+ it('log is the mirror of exp: log(p) === 1 - exp(1 - p)', () => {
45
+ for (const p of [0.1, 0.3, 0.5, 0.7, 0.9]) {
46
+ expect(fadeGain(p, 'log')).toBeCloseTo(1 - fadeGain(1 - p, 'exp'), 10)
47
+ }
48
+ })
49
+
50
+ it('clamps p outside [0, 1] rather than extrapolating', () => {
51
+ expect(fadeGain(-0.5, 'linear')).toBe(0)
52
+ expect(fadeGain(1.5, 'linear')).toBe(1)
53
+ expect(fadeGain(-1, 'exp')).toBe(0)
54
+ expect(fadeGain(2, 'log')).toBe(1)
55
+ })
56
+
57
+ it('every shape is monotonically non-decreasing across [0, 1]', () => {
58
+ for (const curve of ['linear', 'log', 'exp'] as const) {
59
+ let prev = -Infinity
60
+ for (let p = 0; p <= 1; p += 0.05) {
61
+ const g = fadeGain(p, curve)
62
+ expect(g).toBeGreaterThanOrEqual(prev - 1e-9)
63
+ prev = g
64
+ }
65
+ }
66
+ })
67
+ })
68
+
69
+ describe('makeFadeGainAt', () => {
70
+ it('is 1 (full gain) everywhere when neither fade is set', () => {
71
+ const gainAt = makeFadeGainAt(0, 100, 0, 0, 'exp', 'exp')
72
+ expect(gainAt(0)).toBe(1)
73
+ expect(gainAt(50)).toBe(1)
74
+ expect(gainAt(100)).toBe(1)
75
+ })
76
+
77
+ it('fade-in only: 0 at the span start, 1 at (and past) the fade-in edge', () => {
78
+ const gainAt = makeFadeGainAt(0, 100, 20, 0, 'linear', 'exp')
79
+ expect(gainAt(0)).toBeCloseTo(0)
80
+ expect(gainAt(10)).toBeCloseTo(0.5) // linear, halfway through a 20px fade-in
81
+ expect(gainAt(20)).toBeCloseTo(1)
82
+ expect(gainAt(80)).toBeCloseTo(1) // well past the fade-in, unaffected
83
+ })
84
+
85
+ it('fade-out only: 1 up to the fade-out edge, 0 at the span end', () => {
86
+ const gainAt = makeFadeGainAt(0, 100, 0, 20, 'exp', 'linear')
87
+ expect(gainAt(0)).toBeCloseTo(1)
88
+ expect(gainAt(79)).toBeCloseTo(1, 0) // just before the fade-out region starts
89
+ expect(gainAt(90)).toBeCloseTo(0.5) // linear, halfway through a 20px fade-out
90
+ expect(gainAt(100)).toBeCloseTo(0)
91
+ })
92
+
93
+ it('each side samples its OWN curve independently', () => {
94
+ const gainAt = makeFadeGainAt(0, 100, 20, 20, 'linear', 'exp')
95
+ // Midpoint of the fade-in (p=0.5): linear → 0.5.
96
+ expect(gainAt(10)).toBeCloseTo(0.5)
97
+ // Midpoint of the fade-out (p=0.5 from the silent end): exp → 0.25.
98
+ expect(gainAt(90)).toBeCloseTo(0.25)
99
+ })
100
+
101
+ it('overlapping fades (a bar shorter than fadeIn + fadeOut): the lower gain wins', () => {
102
+ // A 30px-wide span with a 20px fade-in AND a 20px fade-out overlapping
103
+ // across its whole width — every point should take whichever fade pulls
104
+ // it lower, so gain never exceeds what either side alone would allow.
105
+ const gainAt = makeFadeGainAt(0, 30, 20, 20, 'linear', 'linear')
106
+ for (let x = 0; x <= 30; x += 3) {
107
+ const g = gainAt(x)
108
+ expect(g).toBeGreaterThanOrEqual(0)
109
+ expect(g).toBeLessThanOrEqual(1)
110
+ }
111
+ // The very center should be pulled down by BOTH fades, not sitting at 1.
112
+ expect(gainAt(15)).toBeLessThan(1)
113
+ })
114
+
115
+ it('anchors to spanX, not x=0 — a scrolled/offset span still fades correctly', () => {
116
+ const gainAt = makeFadeGainAt(-100, 300, 40, 30, 'linear', 'linear')
117
+ expect(gainAt(-100)).toBeCloseTo(0) // span start: silent
118
+ expect(gainAt(-60)).toBeCloseTo(1) // fade-in's own inner edge
119
+ expect(gainAt(170)).toBeCloseTo(1) // fade-out's own inner edge
120
+ expect(gainAt(200)).toBeCloseTo(0) // span end: silent
121
+ })
122
+
123
+ it('clamps the result to [0, 1]', () => {
124
+ const gainAt = makeFadeGainAt(0, 100, 20, 20, 'linear', 'linear')
125
+ expect(gainAt(-50)).toBeGreaterThanOrEqual(0)
126
+ expect(gainAt(500)).toBeGreaterThanOrEqual(0)
127
+ })
128
+ })
129
+
130
+ describe('fadeCurveIconPoints', () => {
131
+ const W = 40
132
+ const H = 28
133
+
134
+ it('returns segments+1 points, defaulting to 24 segments', () => {
135
+ expect(fadeCurveIconPoints('linear', W, H)).toHaveLength(25)
136
+ expect(fadeCurveIconPoints('linear', W, H, 10)).toHaveLength(11)
137
+ })
138
+
139
+ it('starts at the bottom-left (silent) and ends at the top-right (full volume), for every shape', () => {
140
+ for (const curve of ['linear', 'log', 'exp'] as const) {
141
+ const points = fadeCurveIconPoints(curve, W, H)
142
+ expect(points[0]).toEqual({ x: 0, y: H }) // p=0, gain=0 → bottom-left
143
+ expect(points[points.length - 1]).toEqual({ x: W, y: 0 }) // p=1, gain=1 → top-right
144
+ }
145
+ })
146
+
147
+ it('x runs left→right in equal steps, independent of the curve', () => {
148
+ const points = fadeCurveIconPoints('exp', W, H, 4)
149
+ expect(points.map(p => p.x)).toEqual([0, W / 4, W / 2, (3 * W) / 4, W])
150
+ })
151
+
152
+ it('linear is a straight diagonal: y is an exact linear function of x', () => {
153
+ const points = fadeCurveIconPoints('linear', W, H)
154
+ for (const { x, y } of points) {
155
+ expect(y).toBeCloseTo(H - (x / W) * H)
156
+ }
157
+ })
158
+
159
+ it('exp is concave-up: stays LOW (near the bottom) through the midpoint, then rises sharply', () => {
160
+ // Reuses `fadeGain` directly, so this is really just checking the icon
161
+ // wires the same math through — see fadeGain's own concave-up test for
162
+ // the underlying claim.
163
+ const points = fadeCurveIconPoints('exp', W, H)
164
+ const mid = points[Math.round(points.length / 2) - 1]
165
+ const linearMidY = H - 0.5 * H
166
+ // "Stays low" in a y-down SVG means a LARGER y (closer to the bottom)
167
+ // than the straight-line midpoint.
168
+ expect(mid.y).toBeGreaterThan(linearMidY)
169
+ })
170
+
171
+ it('log is concave-down: rises fast, sitting HIGH (near the top) by the midpoint', () => {
172
+ const points = fadeCurveIconPoints('log', W, H)
173
+ const mid = points[Math.round(points.length / 2) - 1]
174
+ const linearMidY = H - 0.5 * H
175
+ // "Levels near the top" means a SMALLER y than the straight-line midpoint.
176
+ expect(mid.y).toBeLessThan(linearMidY)
177
+ })
178
+
179
+ it('every shape is monotonically non-increasing in y as x increases (gain only ever rises)', () => {
180
+ for (const curve of ['linear', 'log', 'exp'] as const) {
181
+ const points = fadeCurveIconPoints(curve, W, H)
182
+ for (let i = 1; i < points.length; i++) {
183
+ expect(points[i].y).toBeLessThanOrEqual(points[i - 1].y + 1e-9)
184
+ }
185
+ }
186
+ })
187
+ })