@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,825 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react'
3
+ import type {
4
+ AnalyzeAudioPolishArgs,
5
+ AudioPolishAnalysis,
6
+ EditorAdapter,
7
+ ImageElement,
8
+ Project,
9
+ } from '../../types'
10
+ import type { VisualItem, VisualTrack } from '../../schema'
11
+ import { trackItems } from '../timeline/timeline-model'
12
+ import AudioPolishModal from '../AudioPolishModal'
13
+
14
+ afterEach(() => cleanup())
15
+
16
+ // ── Fixtures ─────────────────────────────────────────────────────────────────
17
+
18
+ function clip(over: Partial<VisualItem> = {}): VisualItem {
19
+ return { id: 'a', type: 'video', src: '/f/a.mov', start: 0, end: 20, inPoint: 0, outPoint: 20, ...over }
20
+ }
21
+
22
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
23
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
24
+ }
25
+
26
+ function makeProject(over: Partial<Project> = {}): Project {
27
+ return {
28
+ id: 'p1',
29
+ status: 'draft',
30
+ settings: { resolution: [1080, 1920] },
31
+ tracks: vtracks([clip()]),
32
+ ...over,
33
+ } as Project
34
+ }
35
+
36
+ type Handler = (args: AnalyzeAudioPolishArgs) => Promise<AudioPolishAnalysis>
37
+
38
+ /** Piece results the happy path returns. `silence-check` keeps `[0, 1]`, which
39
+ * does not overlap the silence removal at `[2, 3]`, so nothing is flagged. */
40
+ const defaultHandler: Handler = async args => {
41
+ switch (args.piece) {
42
+ case 'silence':
43
+ return { piece: 'silence', removals: [{ start: 2, end: 3 }] }
44
+ case 'fillers':
45
+ return { piece: 'fillers', removals: [{ start: 5, end: 5.4, text: 'um' }] }
46
+ case 'silence-check':
47
+ return { piece: 'silence-check', keeps: [[0, 1]] }
48
+ case 'loudness':
49
+ return { piece: 'loudness', measuredI: -20, measuredTP: -3, measuredLRA: 5, targetI: -14, gainDb: 6 }
50
+ case 'voice':
51
+ return { piece: 'voice', vocalsPath: '/f/a_vocals.wav', url: 'https://host/a_vocals.wav' }
52
+ }
53
+ }
54
+
55
+ interface Spy {
56
+ adapter: EditorAdapter<Project>
57
+ calls: AnalyzeAudioPolishArgs[]
58
+ maxInflight: number
59
+ }
60
+
61
+ function makeAdapter(handler: Handler = defaultHandler): Spy {
62
+ const spy: Spy = { calls: [], maxInflight: 0, adapter: null as unknown as EditorAdapter<Project> }
63
+ let inflight = 0
64
+ spy.adapter = {
65
+ loadProject: vi.fn(),
66
+ saveProject: vi.fn(),
67
+ subscribe: () => () => {},
68
+ render: async function* () {},
69
+ resolveImageSrc: (el: ImageElement) => el.src,
70
+ compileOverlay: vi.fn(async () => () => null),
71
+ listGlobalOverlays: vi.fn(async () => []),
72
+ listSystemOverlays: vi.fn(async () => []),
73
+ uploadFile: vi.fn(async () => ''),
74
+ fileUrl: (p: string) => `url:${p}`,
75
+ analyzeAudioPolish: async (args: AnalyzeAudioPolishArgs) => {
76
+ spy.calls.push(args)
77
+ inflight += 1
78
+ spy.maxInflight = Math.max(spy.maxInflight, inflight)
79
+ try {
80
+ // A few microtask turns so overlapping jobs are actually observable.
81
+ await Promise.resolve()
82
+ await Promise.resolve()
83
+ await Promise.resolve()
84
+ return await handler(args)
85
+ } finally {
86
+ inflight -= 1
87
+ }
88
+ },
89
+ } as unknown as EditorAdapter<Project>
90
+ return spy
91
+ }
92
+
93
+ interface Harness {
94
+ spy: Spy
95
+ /** The project the modal snapshotted on open. `buildDraft` returns it BY
96
+ * REFERENCE for an empty plan, so `toBe(baseline)` is an exact no-op test. */
97
+ baseline: Project
98
+ drafts: Project[]
99
+ last: () => Project
100
+ commit: ReturnType<typeof vi.fn>
101
+ discardTransient: ReturnType<typeof vi.fn>
102
+ onClose: ReturnType<typeof vi.fn>
103
+ mutateTransient: ReturnType<typeof vi.fn>
104
+ rerenderWithProject: (p: Project) => void
105
+ }
106
+
107
+ function renderModal(opts: {
108
+ project?: Project
109
+ handler?: Handler
110
+ selectionIds?: string[]
111
+ } = {}): Harness {
112
+ const baseline = opts.project ?? makeProject()
113
+ const spy = makeAdapter(opts.handler)
114
+ const drafts: Project[] = []
115
+ // The modal always pushes `() => draft`, so the argument it is applied to is
116
+ // irrelevant; passing the baseline mirrors what useProjectSync would do.
117
+ const mutateTransient = vi.fn((fn: (p: Project) => Project) => { drafts.push(fn(baseline)) })
118
+ const commit = vi.fn(async () => {})
119
+ const discardTransient = vi.fn()
120
+ const onClose = vi.fn()
121
+
122
+ const props = {
123
+ projectId: 'p1',
124
+ adapter: spy.adapter,
125
+ selectionIds: opts.selectionIds,
126
+ mutateTransient,
127
+ commit,
128
+ discardTransient,
129
+ onClose,
130
+ }
131
+
132
+ const { rerender } = render(<AudioPolishModal {...props} project={baseline} />)
133
+
134
+ return {
135
+ spy,
136
+ baseline,
137
+ drafts,
138
+ last: () => drafts[drafts.length - 1],
139
+ commit,
140
+ discardTransient,
141
+ onClose,
142
+ mutateTransient,
143
+ rerenderWithProject: (p: Project) => rerender(<AudioPolishModal {...props} project={p} />),
144
+ }
145
+ }
146
+
147
+ function setPiece(label: string, on: boolean) {
148
+ const box = screen.getByRole('checkbox', { name: label }) as HTMLInputElement
149
+ if (box.checked !== on) fireEvent.click(box)
150
+ }
151
+
152
+ async function analyse() {
153
+ fireEvent.click(screen.getByRole('button', { name: /^Analyse/ }))
154
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Analyse again' })).toBeTruthy())
155
+ }
156
+
157
+ function firstTrackItems(p: Project): VisualItem[] {
158
+ return trackItems(p)[0] ?? []
159
+ }
160
+
161
+ /** Total picture time on `tracks[0]`. A cut SPLITS a clip into fragments, so
162
+ * `items[0].end` is a fragment boundary, not the clip's end. */
163
+ function totalDuration(p: Project): number {
164
+ return firstTrackItems(p).reduce((n, i) => n + (i.end - i.start), 0)
165
+ }
166
+
167
+ // ── Tests ────────────────────────────────────────────────────────────────────
168
+
169
+ describe('AudioPolishModal — analysis', () => {
170
+ it('calls each enabled piece with its own arg shape, and skips disabled pieces', async () => {
171
+ const h = renderModal()
172
+ await analyse()
173
+
174
+ const byPiece = (p: string) => h.spy.calls.filter(c => c.piece === p)
175
+
176
+ expect(byPiece('silence')[0]).toEqual({
177
+ projectId: 'p1', piece: 'silence', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { language: 'en' },
178
+ })
179
+ expect(byPiece('fillers')[0]).toEqual({
180
+ projectId: 'p1', piece: 'fillers', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { language: 'en' },
181
+ })
182
+ expect(byPiece('loudness')[0]).toEqual({
183
+ projectId: 'p1', piece: 'loudness', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { targetLufs: -14 },
184
+ })
185
+ // The cross check is whole source: no window, no exposed options.
186
+ expect(byPiece('silence-check')[0]).toEqual({ projectId: 'p1', piece: 'silence-check', src: '/f/a.mov' })
187
+ // Voice is off by default.
188
+ expect(byPiece('voice')).toHaveLength(0)
189
+ })
190
+
191
+ it('sends the chosen language through to the speech pieces', async () => {
192
+ const h = renderModal()
193
+ fireEvent.change(screen.getByLabelText('Spoken language'), { target: { value: 'es' } })
194
+ await analyse()
195
+ for (const c of h.spy.calls.filter(x => x.piece === 'silence' || x.piece === 'fillers')) {
196
+ expect(c.options?.language).toBe('es')
197
+ }
198
+ })
199
+
200
+ it('prefers the proxy for speech detection and the original for loudness and voice', async () => {
201
+ const h = renderModal({
202
+ project: makeProject({ tracks: vtracks([clip({ proxySrc: '/f/a_proxy.mp4' })]) }),
203
+ })
204
+ setPiece('Isolate voice', true)
205
+ await analyse()
206
+
207
+ for (const c of h.spy.calls) {
208
+ if (c.piece === 'silence' || c.piece === 'fillers') expect(c.src).toBe('/f/a_proxy.mp4')
209
+ else expect(c.src).toBe('/f/a.mov')
210
+ }
211
+ })
212
+
213
+ it('derives the analysis window through the planner when outPoint is absent, speed included', async () => {
214
+ // The fallback path, which is the one a hand-rolled copy of the mapping
215
+ // would have owned. Trimmed AND sped up, so the speed factor has to be
216
+ // applied: source 30 to 30 + (15 - 5) * 2.
217
+ const noOutPoint: VisualItem = {
218
+ id: 'a', type: 'video', src: '/f/a.mov', start: 5, end: 15, inPoint: 30, speed: 2,
219
+ }
220
+ const h = renderModal({ project: makeProject({ tracks: vtracks([noOutPoint]) }) })
221
+ await analyse()
222
+
223
+ const windowed = h.spy.calls.filter(c => c.window)
224
+ expect(windowed.length).toBeGreaterThan(0)
225
+ for (const c of windowed) expect(c.window).toEqual({ in: 30, out: 50 })
226
+ })
227
+
228
+ it('ignores a stale outPoint that is not past the in-point, rather than inverting the window', async () => {
229
+ // `outPoint: 0` is finite, so a bare `??` would hand the step {in: 30, out: 0}.
230
+ // Same hazard timeline-model.ts guards with `outPt > inPt`.
231
+ const h = renderModal({
232
+ project: makeProject({
233
+ tracks: vtracks([clip({ start: 5, end: 15, inPoint: 30, outPoint: 0, speed: 2 })]),
234
+ }),
235
+ })
236
+ await analyse()
237
+
238
+ const windowed = h.spy.calls.filter(c => c.window)
239
+ expect(windowed.length).toBeGreaterThan(0)
240
+ for (const c of windowed) expect(c.window).toEqual({ in: 30, out: 50 })
241
+ })
242
+
243
+ it('prefers a stored outPoint, which is the real source window on a looping clip', async () => {
244
+ // A 10s source looped across 30s of timeline. Deriving from `clip.end` here
245
+ // would ask the step for source [0, 30] of a 10s file.
246
+ const h = renderModal({
247
+ project: makeProject({
248
+ tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true })]),
249
+ }),
250
+ })
251
+ await analyse()
252
+
253
+ const windowed = h.spy.calls.filter(c => c.window)
254
+ expect(windowed.length).toBeGreaterThan(0)
255
+ for (const c of windowed) expect(c.window).toEqual({ in: 0, out: 10 })
256
+ })
257
+
258
+ it('caps analysis at two step calls in flight', async () => {
259
+ const h = renderModal({
260
+ project: makeProject({
261
+ tracks: vtracks([
262
+ clip({ id: 'a', src: '/f/a.mov', start: 0, end: 20 }),
263
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40, inPoint: 0, outPoint: 20 }),
264
+ clip({ id: 'c', src: '/f/c.mov', start: 40, end: 60, inPoint: 0, outPoint: 20 }),
265
+ ]),
266
+ }),
267
+ })
268
+ setPiece('Isolate voice', true)
269
+ await analyse()
270
+
271
+ expect(h.spy.calls.length).toBeGreaterThan(4)
272
+ expect(h.spy.maxInflight).toBe(2)
273
+ })
274
+
275
+ it('runs the whole-source pieces once per distinct source, not once per clip', async () => {
276
+ const h = renderModal({
277
+ project: makeProject({
278
+ tracks: vtracks([
279
+ clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 }),
280
+ clip({ id: 'b', start: 10, end: 20, inPoint: 10, outPoint: 20 }),
281
+ ]),
282
+ }),
283
+ })
284
+ setPiece('Isolate voice', true)
285
+ await analyse()
286
+
287
+ expect(h.spy.calls.filter(c => c.piece === 'silence-check')).toHaveLength(1)
288
+ expect(h.spy.calls.filter(c => c.piece === 'voice')).toHaveLength(1)
289
+ expect(h.spy.calls.filter(c => c.piece === 'silence')).toHaveLength(2)
290
+ })
291
+ })
292
+
293
+ describe('AudioPolishModal — review defaults', () => {
294
+ it('ticks unflagged removals and leaves flagged ones unticked', async () => {
295
+ // The cross check hears sound across [2.5, 4], which overlaps the proposed
296
+ // silence removal at [2, 3] well past the tolerance.
297
+ renderModal({
298
+ handler: async args =>
299
+ args.piece === 'silence-check'
300
+ ? { piece: 'silence-check', keeps: [[2.5, 4]] }
301
+ : defaultHandler(args),
302
+ })
303
+ await analyse()
304
+
305
+ const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
306
+ const filler = screen.getByRole('checkbox', { name: /Remove filler "um" at/ }) as HTMLInputElement
307
+ expect(silence.checked).toBe(false)
308
+ expect(filler.checked).toBe(true)
309
+ })
310
+
311
+ it('shows the flag reason on the flagged removal', async () => {
312
+ renderModal({
313
+ handler: async args =>
314
+ args.piece === 'silence-check'
315
+ ? { piece: 'silence-check', keeps: [[2.5, 4]] }
316
+ : defaultHandler(args),
317
+ })
318
+ await analyse()
319
+ expect(screen.getByText(/The independent silence check heard sound here/)).toBeTruthy()
320
+ })
321
+
322
+ it('flags a removal that lands on a caption word and says which check fired', async () => {
323
+ renderModal({
324
+ project: makeProject({
325
+ captions: {
326
+ style: 'pop',
327
+ segments: [{ text: 'hello', start: 2.2, end: 2.6, words: [{ word: 'hello', start: 2.2, end: 2.6 }] }],
328
+ },
329
+ }),
330
+ })
331
+ await analyse()
332
+
333
+ const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
334
+ expect(silence.checked).toBe(false)
335
+ expect(screen.getByText(/Overlaps a caption word\./)).toBeTruthy()
336
+ })
337
+
338
+ it('says the caption check did not run when the project has no captions', async () => {
339
+ renderModal()
340
+ await analyse()
341
+ expect(screen.getByText(/no captions, so the caption word check did not run/)).toBeTruthy()
342
+ })
343
+
344
+ it('says the cross check did not run when it fails, and still offers the removals', async () => {
345
+ renderModal({
346
+ handler: async args => {
347
+ if (args.piece === 'silence-check') throw new Error('ffmpeg missing')
348
+ return defaultHandler(args)
349
+ },
350
+ })
351
+ await analyse()
352
+
353
+ expect(screen.getByText(/independent silence check could not run/)).toBeTruthy()
354
+ const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
355
+ expect(silence.checked).toBe(true)
356
+ })
357
+ })
358
+
359
+ describe('AudioPolishModal — the transient preview', () => {
360
+ it('pushes a draft with the clip material removed, and puts it back when unticked', async () => {
361
+ const h = renderModal()
362
+ setPiece('Remove filler words', false)
363
+ setPiece('Match loudness', false)
364
+ await analyse()
365
+
366
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
367
+ // [2, 3] removed, then the gap closed: a 20s clip becomes 19s.
368
+ expect(totalDuration(h.last())).toBeCloseTo(19, 5)
369
+
370
+ fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
371
+ await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
372
+ })
373
+
374
+ it('rebuilds from the baseline rather than the previous draft, so a re-tick is exact', async () => {
375
+ const h = renderModal()
376
+ setPiece('Remove filler words', false)
377
+ setPiece('Match loudness', false)
378
+ await analyse()
379
+
380
+ const box = () => screen.getByRole('checkbox', { name: /Remove silence at/ })
381
+ fireEvent.click(box())
382
+ await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
383
+ fireEvent.click(box())
384
+ await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(19, 5))
385
+ fireEvent.click(box())
386
+ await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
387
+ // One whole clip again, not a pair of fragments: a rebuild from the previous
388
+ // draft rather than from the baseline would leave the clip split.
389
+ expect(firstTrackItems(h.last())).toHaveLength(1)
390
+ })
391
+
392
+ it('rebuilds the draft when a piece is toggled off after analysis, with no re-analysis', async () => {
393
+ const h = renderModal()
394
+ await analyse()
395
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
396
+ expect(firstTrackItems(h.last())[0].volume).toBeDefined()
397
+
398
+ const before = h.spy.calls.length
399
+ setPiece('Match loudness', false)
400
+ await waitFor(() => expect(firstTrackItems(h.last())[0].volume).toBeUndefined())
401
+ expect(h.spy.calls.length).toBe(before)
402
+ })
403
+
404
+ it('re-derives the gain from the measurement when the loudness target changes, without re-analysing', async () => {
405
+ const h = renderModal()
406
+ setPiece('Remove silence', false)
407
+ setPiece('Remove filler words', false)
408
+ await analyse()
409
+
410
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
411
+ // min(target - measuredI, -1.5 - measuredTP) = min(-14 + 20, -1.5 + 3) = 1.5 dB.
412
+ const atYoutube = firstTrackItems(h.last())[0].volume!
413
+ const before = h.spy.calls.length
414
+
415
+ fireEvent.change(screen.getByLabelText('Loudness target'), { target: { value: 'broadcast' } })
416
+ await waitFor(() => expect(firstTrackItems(h.last())[0].volume).not.toBe(atYoutube))
417
+ expect(h.spy.calls.length).toBe(before)
418
+ })
419
+
420
+ it('re-pushes its draft when an external frame lands over the preview', async () => {
421
+ const h = renderModal()
422
+ await analyse()
423
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
424
+
425
+ const pushes = h.mutateTransient.mock.calls.length
426
+ const draftBefore = h.last()
427
+ h.rerenderWithProject(makeProject({ id: 'p1' }))
428
+ await waitFor(() => expect(h.mutateTransient.mock.calls.length).toBe(pushes + 1))
429
+ // The very same draft object goes back, not the incoming frame.
430
+ expect(h.last()).toBe(draftBefore)
431
+ })
432
+ })
433
+
434
+ describe('AudioPolishModal — voice isolation', () => {
435
+ it('mutes the clip and adds one stem track when voice is on', async () => {
436
+ const h = renderModal()
437
+ setPiece('Remove silence', false)
438
+ setPiece('Remove filler words', false)
439
+ setPiece('Isolate voice', true)
440
+ await analyse()
441
+
442
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
443
+ const d = h.last()
444
+ expect(firstTrackItems(d)[0].muted).toBe(true)
445
+ expect(d.audio?.tracks.map(t => t.src)).toEqual(['/f/a_vocals.wav'])
446
+ })
447
+
448
+ it('mints one stem per surviving fragment when cuts split the clip', async () => {
449
+ const h = renderModal()
450
+ setPiece('Isolate voice', true)
451
+ await analyse()
452
+
453
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
454
+ const d = h.last()
455
+ // Two cuts leave three fragments; one audio track cannot follow all three,
456
+ // so the planner mints one stem each.
457
+ expect(firstTrackItems(d)).toHaveLength(3)
458
+ expect(d.audio?.tracks ?? []).toHaveLength(3)
459
+ expect(firstTrackItems(d).every(i => i.muted)).toBe(true)
460
+ })
461
+
462
+ it('offers an A/B preview of the original and the isolated voice', async () => {
463
+ renderModal()
464
+ setPiece('Isolate voice', true)
465
+ await analyse()
466
+
467
+ expect(screen.getByLabelText('Original audio for a.mov').getAttribute('src')).toBe('url:/f/a.mov')
468
+ expect(screen.getByLabelText('Isolated voice for a.mov').getAttribute('src')).toBe('https://host/a_vocals.wav')
469
+ })
470
+
471
+ it('shows the skip badge on a sped-up clip and still offers the other three pieces', async () => {
472
+ const h = renderModal({
473
+ project: makeProject({ tracks: vtracks([clip({ speed: 2, outPoint: 40 })]) }),
474
+ })
475
+ setPiece('Isolate voice', true)
476
+ await analyse()
477
+
478
+ expect(screen.getByText('Voice isolation unavailable: this clip does not play at normal speed')).toBeTruthy()
479
+ // The other three are still on the table for this clip.
480
+ expect(screen.getByRole('checkbox', { name: /Remove silence at/ })).toBeTruthy()
481
+ expect(screen.getByRole('checkbox', { name: /Remove filler "um" at/ })).toBeTruthy()
482
+ expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
483
+
484
+ // And nothing was muted or stemmed on it.
485
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
486
+ expect(firstTrackItems(h.last()).some(i => i.muted)).toBe(false)
487
+ expect(h.last().audio?.tracks ?? []).toEqual([])
488
+ })
489
+ })
490
+
491
+ describe('AudioPolishModal — loudness column', () => {
492
+ it('reports the measurement and the resulting gain', async () => {
493
+ renderModal()
494
+ await analyse()
495
+ expect(screen.getByText(/measured -20.0 LUFS, true peak -3.0 dBTP\. Gain \+1.5 dB to reach -14 LUFS/)).toBeTruthy()
496
+ })
497
+
498
+ it('says so when the +6 dB ceiling clamps the gain', async () => {
499
+ renderModal({
500
+ handler: async args =>
501
+ args.piece === 'loudness'
502
+ ? { piece: 'loudness', measuredI: -40, measuredTP: -30, measuredLRA: 5, targetI: -14, gainDb: 26 }
503
+ : defaultHandler(args),
504
+ })
505
+ await analyse()
506
+ expect(screen.getByText(/Held at the \+6 dB ceiling/)).toBeTruthy()
507
+ })
508
+ })
509
+
510
+ describe('AudioPolishModal — warnings and failures', () => {
511
+ it('warns when a clip would lose more than 60% of its length', async () => {
512
+ renderModal({
513
+ handler: async args =>
514
+ args.piece === 'silence'
515
+ ? { piece: 'silence', removals: [{ start: 1, end: 16 }] }
516
+ : defaultHandler(args),
517
+ })
518
+ await analyse()
519
+ expect(screen.getByText(/would lose 77% of its length/)).toBeTruthy()
520
+ })
521
+
522
+ it('drops the warning again once enough removals are unticked', async () => {
523
+ renderModal({
524
+ handler: async args =>
525
+ args.piece === 'silence'
526
+ ? { piece: 'silence', removals: [{ start: 1, end: 16 }] }
527
+ : defaultHandler(args),
528
+ })
529
+ await analyse()
530
+ fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
531
+ await waitFor(() => expect(screen.queryByText(/would lose/)).toBeNull())
532
+ })
533
+
534
+ it('reports one piece failing inline and leaves the others usable', async () => {
535
+ const h = renderModal({
536
+ handler: async args => {
537
+ if (args.piece === 'fillers') throw new Error('whisper model missing')
538
+ return defaultHandler(args)
539
+ },
540
+ })
541
+ await analyse()
542
+
543
+ expect(screen.getByText(/fillers could not be analysed: whisper model missing/)).toBeTruthy()
544
+ // Silence and loudness both survived.
545
+ const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
546
+ expect(silence.checked).toBe(true)
547
+ expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
548
+ expect(screen.queryByRole('checkbox', { name: /Remove filler "/ })).toBeNull()
549
+
550
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
551
+ expect(firstTrackItems(h.last())[0].volume).toBeDefined()
552
+ })
553
+ })
554
+
555
+ describe('AudioPolishModal — close paths', () => {
556
+ it('Cancel discards the transient draft and never commits', async () => {
557
+ const h = renderModal()
558
+ await analyse()
559
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
560
+
561
+ const pushes = h.mutateTransient.mock.calls.length
562
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
563
+
564
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
565
+ expect(h.commit).not.toHaveBeenCalled()
566
+ expect(h.onClose).toHaveBeenCalledTimes(1)
567
+ expect(h.mutateTransient.mock.calls.length).toBe(pushes)
568
+ })
569
+
570
+ it('Cancel before any analysis touches nothing but discardTransient', () => {
571
+ const h = renderModal()
572
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
573
+ expect(h.mutateTransient).not.toHaveBeenCalled()
574
+ expect(h.commit).not.toHaveBeenCalled()
575
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
576
+ })
577
+
578
+ it('Escape discards, exactly like Cancel', () => {
579
+ const h = renderModal()
580
+ fireEvent.keyDown(document, { key: 'Escape' })
581
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
582
+ expect(h.commit).not.toHaveBeenCalled()
583
+ expect(h.onClose).toHaveBeenCalledTimes(1)
584
+ })
585
+
586
+ it('the header close button discards', () => {
587
+ const h = renderModal()
588
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
589
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
590
+ expect(h.commit).not.toHaveBeenCalled()
591
+ })
592
+
593
+ it('Apply commits exactly once, even on a double click', async () => {
594
+ const h = renderModal()
595
+ await analyse()
596
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
597
+
598
+ const apply = screen.getByRole('button', { name: 'Apply' })
599
+ fireEvent.click(apply)
600
+ fireEvent.click(apply)
601
+
602
+ expect(h.commit).toHaveBeenCalledTimes(1)
603
+ expect(h.discardTransient).not.toHaveBeenCalled()
604
+ expect(h.onClose).toHaveBeenCalledTimes(1)
605
+ })
606
+
607
+ it('Apply on a polish that changes nothing discards instead of committing', async () => {
608
+ // Nothing found, and the two field-writing pieces off: the plan is empty, so
609
+ // `buildDraft` hands back the baseline itself. Committing that would push an
610
+ // undo entry whose undo does nothing visible.
611
+ const h = renderModal({
612
+ handler: async args =>
613
+ args.piece === 'silence' || args.piece === 'fillers'
614
+ ? { piece: args.piece, removals: [] }
615
+ : defaultHandler(args),
616
+ })
617
+ setPiece('Match loudness', false)
618
+ setPiece('Isolate voice', false)
619
+ await analyse()
620
+
621
+ // The preview is the baseline, by reference.
622
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
623
+ expect(h.last()).toBe(h.baseline)
624
+
625
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
626
+ // `commit` is the ONLY path here that pushes an undo snapshot
627
+ // (use-project-sync.ts), so not calling it is what leaves canUndo unmoved.
628
+ expect(h.commit).not.toHaveBeenCalled()
629
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
630
+ expect(h.onClose).toHaveBeenCalledTimes(1)
631
+ })
632
+
633
+ it('Apply still commits when the polish does change something', async () => {
634
+ const h = renderModal()
635
+ await analyse()
636
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
637
+ expect(h.last()).not.toBe(h.baseline)
638
+
639
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
640
+ expect(h.commit).toHaveBeenCalledTimes(1)
641
+ expect(h.discardTransient).not.toHaveBeenCalled()
642
+ })
643
+
644
+ it('Apply after unticking every removal discards, not commits', async () => {
645
+ const h = renderModal()
646
+ setPiece('Match loudness', false)
647
+ setPiece('Isolate voice', false)
648
+ await analyse()
649
+
650
+ fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
651
+ fireEvent.click(screen.getByRole('checkbox', { name: /Remove filler "um" at/ }))
652
+ await waitFor(() => expect(h.last()).toBe(h.baseline))
653
+
654
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
655
+ expect(h.commit).not.toHaveBeenCalled()
656
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
657
+ })
658
+
659
+ it('Apply is unavailable until something has been analysed', () => {
660
+ renderModal()
661
+ expect((screen.getByRole('button', { name: 'Apply' }) as HTMLButtonElement).disabled).toBe(true)
662
+ })
663
+ })
664
+
665
+ describe('AudioPolishModal — UI copy', () => {
666
+ it('renders no em dash in the setup phase', () => {
667
+ renderModal()
668
+ expect(document.body.textContent).not.toContain('—')
669
+ })
670
+
671
+ it('renders no em dash in the review phase, flags and warnings included', async () => {
672
+ renderModal({
673
+ project: makeProject({
674
+ tracks: vtracks([clip({ speed: 2, outPoint: 40 })]),
675
+ captions: {
676
+ style: 'pop',
677
+ segments: [{ text: 'hello', start: 1, end: 1.4 }],
678
+ },
679
+ }),
680
+ handler: async args => {
681
+ if (args.piece === 'silence') return { piece: 'silence', removals: [{ start: 1, end: 16 }] }
682
+ if (args.piece === 'loudness') throw new Error('measure failed')
683
+ return defaultHandler(args)
684
+ },
685
+ })
686
+ setPiece('Isolate voice', true)
687
+ await analyse()
688
+
689
+ expect(screen.getByText(/could not be analysed/)).toBeTruthy()
690
+ expect(document.body.textContent).not.toContain('—')
691
+ })
692
+ })
693
+
694
+ describe('AudioPolishModal — scope', () => {
695
+ it('narrows to the selection when one is given', async () => {
696
+ const h = renderModal({
697
+ project: makeProject({
698
+ tracks: vtracks([
699
+ clip({ id: 'a', src: '/f/a.mov' }),
700
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40 }),
701
+ ]),
702
+ }),
703
+ selectionIds: ['b'],
704
+ })
705
+ await analyse()
706
+ expect(new Set(h.spy.calls.map(c => c.src))).toEqual(new Set(['/f/b.mov']))
707
+ })
708
+
709
+ it('groups the review list by clip', async () => {
710
+ renderModal({
711
+ project: makeProject({
712
+ tracks: vtracks([
713
+ clip({ id: 'a', src: '/f/a.mov' }),
714
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40 }),
715
+ ]),
716
+ }),
717
+ })
718
+ await analyse()
719
+
720
+ const dialog = screen.getByRole('dialog', { name: 'Polish audio' })
721
+ expect(within(dialog).getByText('a.mov')).toBeTruthy()
722
+ expect(within(dialog).getByText('b.mov')).toBeTruthy()
723
+ expect(within(dialog).getAllByRole('checkbox', { name: /Remove silence at/ })).toHaveLength(2)
724
+ })
725
+ })
726
+
727
+
728
+ // The four `pieceSupported` reasons, byte-exact. Asserting the literal here and
729
+ // not importing the constants is deliberate: these are user-visible strings, and
730
+ // a test that imports them would keep passing through a silent reword.
731
+ const VOICE_SPEED = 'Voice isolation unavailable: this clip does not play at normal speed'
732
+ const VOICE_LOOP = 'Voice isolation unavailable: this clip loops, so the isolated audio would drift out of sync'
733
+ const SILENCE_LOOP = 'Silence removal unavailable: this clip loops, so cuts would land in the wrong place'
734
+ const FILLERS_LOOP = 'Filler removal unavailable: this clip loops, so cuts would land in the wrong place'
735
+
736
+ describe('AudioPolishModal — looping clips', () => {
737
+ const looping = () => makeProject({
738
+ tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true })]),
739
+ })
740
+
741
+ it('badges all three excluded pieces and still offers loudness', async () => {
742
+ renderModal({ project: looping() })
743
+ setPiece('Isolate voice', true)
744
+ await analyse()
745
+
746
+ expect(screen.getByText(SILENCE_LOOP)).toBeTruthy()
747
+ expect(screen.getByText(FILLERS_LOOP)).toBeTruthy()
748
+ expect(screen.getByText(VOICE_LOOP)).toBeTruthy()
749
+ // Loudness needs no mapping, so it is not denied.
750
+ expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
751
+ expect(document.body.textContent).not.toContain('—')
752
+ })
753
+
754
+ it('issues no silence, filler, cross check or voice job for a looping clip', async () => {
755
+ const h = renderModal({ project: looping() })
756
+ setPiece('Isolate voice', true)
757
+ await analyse()
758
+
759
+ expect(h.spy.calls.map(c => c.piece)).toEqual(['loudness'])
760
+ })
761
+
762
+ it('still applies the loudness gain to a looping clip', async () => {
763
+ const h = renderModal({ project: looping() })
764
+ await analyse()
765
+
766
+ await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
767
+ expect(firstTrackItems(h.last())[0].volume).toBeDefined()
768
+ // No cuts, so the clip is untouched apart from its level.
769
+ expect(firstTrackItems(h.last())).toHaveLength(1)
770
+ expect(h.last().audio?.tracks ?? []).toEqual([])
771
+ })
772
+
773
+ it('proposes no removals, so nothing is ticked and Apply discards', async () => {
774
+ const h = renderModal({ project: looping() })
775
+ setPiece('Match loudness', false)
776
+ setPiece('Isolate voice', false)
777
+ await analyse()
778
+
779
+ expect(screen.queryByRole('checkbox', { name: /Remove silence at/ })).toBeNull()
780
+ expect(screen.queryByRole('checkbox', { name: /Remove filler "/ })).toBeNull()
781
+ await waitFor(() => expect(h.last()).toBe(h.baseline))
782
+
783
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
784
+ expect(h.commit).not.toHaveBeenCalled()
785
+ expect(h.discardTransient).toHaveBeenCalledTimes(1)
786
+ })
787
+
788
+ it('reports the LOOP reason on a clip that both loops and is sped up, never the speed one', async () => {
789
+ renderModal({
790
+ project: makeProject({
791
+ tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true, speed: 2 })]),
792
+ }),
793
+ })
794
+ setPiece('Isolate voice', true)
795
+ await analyse()
796
+
797
+ expect(screen.getByText(VOICE_LOOP)).toBeTruthy()
798
+ // The whole point of the precedence: the speed wording must not appear at
799
+ // all. Asserting only that the loop string is present would pass with both
800
+ // rendered, which is the failure mode being guarded.
801
+ expect(screen.queryByText(VOICE_SPEED)).toBeNull()
802
+ expect(document.body.textContent).not.toContain(VOICE_SPEED)
803
+ })
804
+
805
+ it('still runs the whole-source jobs when a non-looping clip shares the source', async () => {
806
+ // Availability is per clip, but `voice` and `silence-check` are whole-source
807
+ // and shared. One looping clip must not suppress the job the other needs.
808
+ const h = renderModal({
809
+ project: makeProject({
810
+ tracks: vtracks([
811
+ clip({ id: 'a', start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true }),
812
+ clip({ id: 'b', start: 30, end: 40, inPoint: 0, outPoint: 10 }),
813
+ ]),
814
+ }),
815
+ })
816
+ setPiece('Isolate voice', true)
817
+ await analyse()
818
+
819
+ expect(h.spy.calls.filter(c => c.piece === 'silence-check')).toHaveLength(1)
820
+ expect(h.spy.calls.filter(c => c.piece === 'voice')).toHaveLength(1)
821
+ // ...but only the non-looping clip gets the per-clip speech jobs.
822
+ expect(h.spy.calls.filter(c => c.piece === 'silence')).toHaveLength(1)
823
+ expect(h.spy.calls.filter(c => c.piece === 'fillers')).toHaveLength(1)
824
+ })
825
+ })