@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,983 @@
1
+ /**
2
+ * AudioPolishModal — the one "Polish audio" surface over the four audio
3
+ * cleanups (silence, fillers, loudness, voice isolation).
4
+ *
5
+ * WHAT THIS FILE IS NOT. It is not a planner. Every piece of maths — source ↔
6
+ * timeline mapping, the two safety guards, the loudness gain, the stem track,
7
+ * the apply order — lives in `audioPolish.ts` and is called from here. This file
8
+ * owns three things and nothing else: which step calls to make, what the
9
+ * operator sees, and what the operator has approved.
10
+ *
11
+ * ── PREVIEW IS NOT A RENDERING MODE ─────────────────────────────────────────
12
+ *
13
+ * The modal snapshots the project on open (`baselineRef`) and, on EVERY change
14
+ * to a toggle or a checkbox, rebuilds the whole draft from that snapshot with
15
+ * `buildDraft` and pushes it through `mutateTransient`. The timeline, the
16
+ * preview player and the audio path all read the project, so they all show the
17
+ * polished result with no preview-specific code anywhere. Rebuilding from the
18
+ * baseline (never mutating the previous draft) is what makes unticking a
19
+ * removal put that clip material straight back.
20
+ *
21
+ * Cancel / Escape / close → `discardTransient()`. No save, no undo entry.
22
+ * Apply → `commit()`. One save, ONE undo step.
23
+ *
24
+ * ── ANALYSIS IS TRIGGERED BY THE OPERATOR, NOT BY MOUNT ─────────────────────
25
+ *
26
+ * Unlike `CaptionRegenModal`, the sidecar work here does not start on mount: the
27
+ * language select must be chosen BEFORE speech detection runs, because a wrong
28
+ * language silently corrupts detection instead of failing (the documented
29
+ * incident where Spanish speech was mistaken for silence and 37 to 86% of real
30
+ * speech was cut). So there is an explicit Analyse action.
31
+ *
32
+ * The StrictMode deferred-teardown pattern from `CaptionRegenModal` is still
33
+ * required and is implemented below, for the same underlying reason: React
34
+ * StrictMode fires mount → cleanup → mount synchronously, and this component's
35
+ * teardown is NOT idempotent either — it discards the operator's transient draft
36
+ * and it stops in-flight step calls from landing. Treating a StrictMode
37
+ * remount as a real unmount would throw away a running analysis.
38
+ */
39
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
40
+ import { createPortal } from 'react-dom'
41
+ import { NumberField, stepValue } from '../ui'
42
+ import type { AudioPolishAnalysis, EditorAdapter, Project } from '../types'
43
+ import type { VisualItem, VisualTrack } from '../schema'
44
+ import { normalizeTracks } from './timeline/timeline-model'
45
+ import {
46
+ buildDraft,
47
+ captionWordsInSourceTime,
48
+ flagRemovals,
49
+ loudnessGainDb,
50
+ mapRemovals,
51
+ pieceSupported,
52
+ targetClips,
53
+ timelineToSource,
54
+ volumeCeilingClamps,
55
+ type ClipPlan,
56
+ type PolishPiece,
57
+ type PolishPlan,
58
+ type Removal,
59
+ } from './audioPolish'
60
+
61
+ // ── Constants ───────────────────────────────────────────────────────────────
62
+
63
+ /** In-flight step calls. Each one is a real sidecar job; two at a time keeps the
64
+ * machine responsive while still overlapping the wait. */
65
+ const MAX_INFLIGHT = 2
66
+
67
+ /** Total proposed removal above this fraction of a clip's duration raises the
68
+ * banner. Not a block: the operator may well mean it. */
69
+ const OVERCUT_FRACTION = 0.6
70
+
71
+ type Piece = 'silence' | 'fillers' | 'loudness' | 'voice'
72
+ type SpeechPiece = Extract<Piece, 'silence' | 'fillers'>
73
+
74
+ const SPEECH_PIECES: SpeechPiece[] = ['silence', 'fillers']
75
+
76
+ const PIECES: Array<{ id: Piece; label: string; blurb: string }> = [
77
+ { id: 'silence', label: 'Remove silence', blurb: 'Cut the gaps where nobody is speaking.' },
78
+ { id: 'fillers', label: 'Remove filler words', blurb: 'Cut the ums, uhs and likes.' },
79
+ { id: 'loudness', label: 'Match loudness', blurb: 'Level every clip to one target.' },
80
+ { id: 'voice', label: 'Isolate voice', blurb: 'Separate the voice from what is behind it.' },
81
+ ]
82
+
83
+ /** Voice isolation is off by default: it is the slowest of the four (a full
84
+ * Demucs pass over the source) and it mutes the clip's own audio, which is the
85
+ * most opinionated thing this modal can do. The other three are cheap and
86
+ * reversible, so they start on. */
87
+ const DEFAULT_PIECES: Record<Piece, boolean> = {
88
+ silence: true,
89
+ fillers: true,
90
+ loudness: true,
91
+ voice: false,
92
+ }
93
+
94
+ const LOUDNESS_TARGETS = [
95
+ { id: 'youtube', label: 'YouTube, -14 LUFS', lufs: -14 },
96
+ { id: 'podcast', label: 'Podcast, -16 LUFS', lufs: -16 },
97
+ { id: 'broadcast', label: 'Broadcast, -23 LUFS', lufs: -23 },
98
+ { id: 'custom', label: 'Custom', lufs: -14 },
99
+ ] as const
100
+
101
+ type TargetId = (typeof LOUDNESS_TARGETS)[number]['id']
102
+
103
+ /** Speech-recognition language hint. Visible, never buried: see the module
104
+ * header for what a wrong one does. */
105
+ const LANGUAGES = [
106
+ { id: 'en', label: 'English' },
107
+ { id: 'es', label: 'Spanish' },
108
+ { id: 'pt', label: 'Portuguese' },
109
+ { id: 'fr', label: 'French' },
110
+ { id: 'de', label: 'German' },
111
+ { id: 'it', label: 'Italian' },
112
+ { id: 'nl', label: 'Dutch' },
113
+ { id: 'ja', label: 'Japanese' },
114
+ { id: 'ko', label: 'Korean' },
115
+ { id: 'zh', label: 'Chinese' },
116
+ { id: 'hi', label: 'Hindi' },
117
+ { id: 'ar', label: 'Arabic' },
118
+ { id: 'ru', label: 'Russian' },
119
+ ]
120
+
121
+ const NO_CAPTIONS_NOTE =
122
+ 'This project has no captions, so the caption word check did not run on these removals.'
123
+ const NO_SILENCE_CHECK_NOTE =
124
+ 'The independent silence check could not run, so these removals were not cross checked.'
125
+
126
+ // ── Local view types ────────────────────────────────────────────────────────
127
+
128
+ /** One proposed removal as the operator sees it. Carries BOTH coordinate
129
+ * spaces on purpose: `source` is what the plan needs (the planner maps it),
130
+ * `tlStart`/`tlEnd` are what the operator is shown and are already clipped to
131
+ * the clip's span by `mapRemovals`, so they are exactly what will be cut. */
132
+ interface RemovalRow {
133
+ key: string
134
+ piece: SpeechPiece
135
+ /** SOURCE time. Goes straight into `ClipPlan.removals`. */
136
+ source: Removal
137
+ /** TIMELINE time, clipped to the clip. Display only. */
138
+ tlStart: number
139
+ tlEnd: number
140
+ text?: string
141
+ flagged: boolean
142
+ reason?: string
143
+ }
144
+
145
+ interface ClipAnalysis {
146
+ rows: RemovalRow[]
147
+ /** Target-independent, so changing the loudness target rebuilds the draft
148
+ * without re-analysing anything. */
149
+ loudness?: { measuredI: number; measuredTP: number }
150
+ vocalsPath?: string
151
+ vocalsUrl?: string
152
+ /** Per-piece failures, reported inline. A piece failing here leaves every
153
+ * other piece on this clip fully usable. */
154
+ errors: Partial<Record<Piece, string>>
155
+ /** Set when a guard could not run, so the UI says so rather than implying a
156
+ * check happened. */
157
+ notes: string[]
158
+ }
159
+
160
+ interface Progress {
161
+ done: number
162
+ total: number
163
+ label: string
164
+ }
165
+
166
+ export interface AudioPolishModalProps<P extends Project = Project> {
167
+ projectId: string
168
+ /** Adapter driving the four analyses. Must implement `analyzeAudioPolish` —
169
+ * callers gate rendering on its presence, exactly as with
170
+ * `generateCaptions`. */
171
+ adapter: EditorAdapter<P>
172
+ /** The live project. Snapshotted once on mount as the polish baseline, and
173
+ * watched afterwards so an external (SSE) frame landing mid-preview does not
174
+ * silently drop the draft. Pass `sync.project`. */
175
+ project: P
176
+ /** Current multi-selection. Empty or absent means every video clip on
177
+ * `tracks[0]`. */
178
+ selectionIds?: readonly string[]
179
+ /** `sync.mutateTransient` — local-only apply, no save, no undo push. */
180
+ mutateTransient: (fn: (p: P) => P) => void
181
+ /** `sync.commit` — one queued save, one undo step. */
182
+ commit: () => Promise<void>
183
+ /** `sync.discardTransient` — drop the draft, no save, no undo entry. */
184
+ discardTransient: () => void
185
+ onClose: () => void
186
+ /** Editor theme mode — light/dark. The panel follows `--editor-surface`, so
187
+ * warning/error hues need to darken in light mode. Absent -> dark,
188
+ * matching every existing caller. */
189
+ mode?: 'light' | 'dark'
190
+ }
191
+
192
+ // ── Small pure helpers (display only) ───────────────────────────────────────
193
+
194
+ /** `m:ss.s`. Display only. */
195
+ function clock(sec: number): string {
196
+ const t = Math.max(0, sec)
197
+ const m = Math.floor(t / 60)
198
+ return `${m}:${(t - m * 60).toFixed(1).padStart(4, '0')}`
199
+ }
200
+
201
+ function basename(p: string): string {
202
+ return p.split('/').pop() ?? p
203
+ }
204
+
205
+ function clipLabel(clip: VisualItem): string {
206
+ return clip.src ? basename(clip.src) : clip.id
207
+ }
208
+
209
+ function signedDb(db: number): string {
210
+ return `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`
211
+ }
212
+
213
+ function messageOf(e: unknown): string {
214
+ return e instanceof Error ? e.message : String(e)
215
+ }
216
+
217
+ /**
218
+ * Whether `piece` may run on `clip`.
219
+ *
220
+ * Every site that needs to know asks `pieceSupported` and nothing else: never
221
+ * `clip.loop`, never `clip.speed`, never `voiceTrackFor` directly. The rules
222
+ * (voice needs speed 1, the three mapping-dependent pieces need a non-looping
223
+ * clip) and the wording that explains them both live in `audioPolish.ts`, so
224
+ * neither can drift from the other.
225
+ */
226
+ function supports(clip: VisualItem, piece: PolishPiece): boolean {
227
+ return pieceSupported(clip, piece).available
228
+ }
229
+
230
+ /**
231
+ * The clip's window into its source file, for the windowed step calls.
232
+ *
233
+ * The `out` fallback is `timelineToSource`, the planner's own mapping, NEVER a
234
+ * second hand-written copy of `inPoint + (end - start) * speed`. `mapRemovals`
235
+ * maps the results back through that same relation, so a private copy here that
236
+ * drifted from it would window the analysis somewhere other than where the
237
+ * removals it returns can be applied, on a feature whose whole job is deciding
238
+ * what audio to delete.
239
+ *
240
+ * A stored `outPoint` still wins when there is one, matching the convention
241
+ * `cuts.ts`'s own `sourceWindow` already sets. That is not just deference to the
242
+ * schema: on a LOOPING clip `outPoint` IS the source window and `clip.end` is
243
+ * many loops past it (`scheduler.ts`'s loop arithmetic reads exactly this pair),
244
+ * so deriving from `clip.end` there would ask the step for a span far past the
245
+ * end of the file.
246
+ *
247
+ * It wins only when it is actually past the in-point, though. A stale
248
+ * `outPoint: 0` is a real state in this tree, and it is finite, so a bare `??`
249
+ * would let it through and hand the step an inverted window. This is the same
250
+ * guard, for the same reason, as the one on `outPt > inPt` in
251
+ * `timeline-model.ts`'s audio-track sizing.
252
+ */
253
+ function sourceWindow(clip: VisualItem): { in: number; out: number } {
254
+ const inPoint = clip.inPoint ?? 0
255
+ const stored = clip.outPoint
256
+ return {
257
+ in: inPoint,
258
+ out: stored !== undefined && stored > inPoint ? stored : timelineToSource(clip, clip.end),
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Speech detection reads the proxy when there is one: it is full-source and
264
+ * never windowed, so its timeline is 1:1 with the original and the timings come
265
+ * back needing no rebasing, and it decodes far faster.
266
+ *
267
+ * Loudness measurement, stem separation and the silence cross-check all read
268
+ * `src` instead — fidelity matters for the first two, and the third is only
269
+ * worth anything as an INDEPENDENT check of the real audio.
270
+ */
271
+ function speechSrc(clip: VisualItem): string {
272
+ return clip.proxySrc ?? clip.src ?? ''
273
+ }
274
+
275
+ /** Run `jobs` with at most `limit` in flight. Each job owns its own error
276
+ * handling; this never rejects. */
277
+ async function runPool(jobs: Array<() => Promise<void>>, limit: number): Promise<void> {
278
+ let next = 0
279
+ const worker = async (): Promise<void> => {
280
+ for (;;) {
281
+ const i = next++
282
+ if (i >= jobs.length) return
283
+ await jobs[i]()
284
+ }
285
+ }
286
+ await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker))
287
+ }
288
+
289
+ // ── Component ───────────────────────────────────────────────────────────────
290
+
291
+ export default function AudioPolishModal<P extends Project = Project>({
292
+ projectId,
293
+ adapter,
294
+ project,
295
+ selectionIds,
296
+ mutateTransient,
297
+ commit,
298
+ discardTransient,
299
+ onClose,
300
+ mode = 'dark',
301
+ }: AudioPolishModalProps<P>) {
302
+ // The polish baseline: the project as it was when the modal opened. Every
303
+ // draft is rebuilt from this, never from the previous draft.
304
+ const baselineRef = useRef<P>(project)
305
+ const baseline = baselineRef.current
306
+
307
+ const [pieces, setPieces] = useState<Record<Piece, boolean>>(DEFAULT_PIECES)
308
+ const [language, setLanguage] = useState('en')
309
+ const [targetId, setTargetId] = useState<TargetId>('youtube')
310
+ const [customLufs, setCustomLufs] = useState(-14)
311
+ const [phase, setPhase] = useState<'setup' | 'analysing' | 'review'>('setup')
312
+ const [progress, setProgress] = useState<Progress>({ done: 0, total: 0, label: '' })
313
+ const [analysis, setAnalysis] = useState<Record<string, ClipAnalysis>>({})
314
+ const [analysedPieces, setAnalysedPieces] = useState<Record<Piece, boolean>>({
315
+ silence: false,
316
+ fillers: false,
317
+ loudness: false,
318
+ voice: false,
319
+ })
320
+ const [approved, setApproved] = useState<Record<string, boolean>>({})
321
+
322
+ const runningRef = useRef(false)
323
+ const closingRef = useRef(false)
324
+ const unmountedRef = useRef(false)
325
+ const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
326
+ const lastPushedRef = useRef<P | null>(null)
327
+ // Mirrored so the mount effect below can keep empty deps: it must run exactly
328
+ // once per REAL mount, whatever the host does with function identity.
329
+ const discardRef = useRef(discardTransient)
330
+ discardRef.current = discardTransient
331
+
332
+ const targetLufs = targetId === 'custom' ? customLufs : LOUDNESS_TARGETS.find(t => t.id === targetId)!.lufs
333
+
334
+ // Deliberately keyed on the selection's CONTENT rather than the array's
335
+ // identity: a host that rebuilds `selectionIds` every render would otherwise
336
+ // rebuild the target list, the plan and the whole draft with it.
337
+ const selectionIdsRef = useRef(selectionIds)
338
+ selectionIdsRef.current = selectionIds
339
+ const selectionKey = (selectionIds ?? []).join(' ')
340
+ const targets = useMemo(
341
+ () => targetClips(baseline, selectionIdsRef.current ?? []).filter(c => Boolean(c.src)),
342
+ [baseline, selectionKey],
343
+ )
344
+
345
+ const trackVolume = useMemo(
346
+ () => ((normalizeTracks(baseline).tracks ?? []) as VisualTrack[])[0]?.volume,
347
+ [baseline],
348
+ )
349
+
350
+ // ── StrictMode-safe lifecycle ─────────────────────────────────────────────
351
+ //
352
+ // Teardown here is destructive twice over: it stops in-flight analysis from
353
+ // landing, and it discards the operator's unsaved draft. StrictMode's
354
+ // synchronous mount → cleanup → mount would do both to a live modal, so the
355
+ // teardown is deferred a tick and rescued if a remount arrives first. Same
356
+ // pattern as CaptionRegenModal and RenderModal.
357
+ useEffect(() => {
358
+ if (cleanupTimerRef.current !== null) {
359
+ clearTimeout(cleanupTimerRef.current)
360
+ cleanupTimerRef.current = null
361
+ unmountedRef.current = false
362
+ return scheduleCleanup
363
+ }
364
+ unmountedRef.current = false
365
+ return scheduleCleanup
366
+
367
+ function scheduleCleanup() {
368
+ cleanupTimerRef.current = setTimeout(() => {
369
+ cleanupTimerRef.current = null
370
+ unmountedRef.current = true
371
+ // An unmount that did not come through Cancel or Apply (a project
372
+ // switch, a host teardown) would otherwise strand the transient draft
373
+ // on screen, unsaved and undiscardable. A no-op after Apply: commit()
374
+ // clears the transient baseline.
375
+ if (!closingRef.current) discardRef.current()
376
+ }, 0)
377
+ }
378
+ }, [])
379
+
380
+ // ── The plan, the draft, and the push ─────────────────────────────────────
381
+
382
+ const plan: PolishPlan = useMemo(() => {
383
+ const clips: Record<string, ClipPlan> = {}
384
+ for (const clip of targets) {
385
+ const a = analysis[clip.id]
386
+ if (!a) continue
387
+
388
+ const removals: Removal[] = []
389
+ for (const row of a.rows) {
390
+ if (!pieces[row.piece] || !approved[row.key]) continue
391
+ // A piece the planner refuses on this clip can never reach the plan,
392
+ // whatever is ticked. Analysis does not schedule those jobs, so there
393
+ // should be no such row; this is the gate that makes that a guarantee
394
+ // rather than a coincidence of scheduling.
395
+ if (!supports(clip, row.piece)) continue
396
+ removals.push(row.source)
397
+ }
398
+
399
+ const cp: ClipPlan = {}
400
+ if (removals.length > 0) cp.removals = removals
401
+ if (pieces.loudness && a.loudness) cp.gainDb = loudnessGainDb(a.loudness, targetLufs)
402
+ // Ineligible clips are left out of the plan entirely rather than handed a
403
+ // path the planner will refuse: a clip listed as an owner but minting no
404
+ // stem would drop stems a previous run left on it.
405
+ if (pieces.voice && a.vocalsPath && supports(clip, 'voice')) cp.vocalsPath = a.vocalsPath
406
+
407
+ if (cp.removals || cp.gainDb !== undefined || cp.vocalsPath !== undefined) clips[clip.id] = cp
408
+ }
409
+ return { clips }
410
+ }, [targets, analysis, pieces, approved, targetLufs])
411
+
412
+ const draft = useMemo(
413
+ () => (phase === 'review' ? buildDraft(baseline, plan) : baseline),
414
+ [baseline, plan, phase],
415
+ )
416
+
417
+ // Every toggle and every checkbox lands here: rebuild from the baseline, push
418
+ // the whole thing, no incremental mutation of the previous draft.
419
+ useEffect(() => {
420
+ if (phase !== 'review' || closingRef.current) return
421
+ lastPushedRef.current = draft
422
+ mutateTransient(() => draft)
423
+ }, [draft, phase, mutateTransient])
424
+
425
+ // Watch item from the plan: an external frame (SSE, refetch) arriving while
426
+ // the modal is open would `applyExternal` straight over the draft, leaving the
427
+ // operator reviewing a preview that is no longer on screen. Re-push instead of
428
+ // assuming it cannot happen. Identity is the test, and our own pushes come
429
+ // back as the same object, so this cannot loop.
430
+ useEffect(() => {
431
+ const pushed = lastPushedRef.current
432
+ if (pushed === null || closingRef.current || project === pushed) return
433
+ mutateTransient(() => pushed)
434
+ }, [project, mutateTransient])
435
+
436
+ // ── Close paths ───────────────────────────────────────────────────────────
437
+
438
+ const handleCancel = useCallback(() => {
439
+ if (closingRef.current) return
440
+ closingRef.current = true
441
+ discardTransient()
442
+ onClose()
443
+ }, [discardTransient, onClose])
444
+
445
+ const handleApply = useCallback(() => {
446
+ if (closingRef.current) return
447
+ closingRef.current = true
448
+ // A polish that changes nothing is discarded, not committed. `buildDraft`
449
+ // returns the baseline BY REFERENCE when the plan is empty (every removal
450
+ // unticked, every piece off, or nothing found), so identity is an exact test
451
+ // for it. Committing that would still push one undo entry, and the operator
452
+ // pressing undo on it would see nothing happen: that reads as broken undo
453
+ // rather than as an empty change.
454
+ if (draft === baseline) discardTransient()
455
+ else void commit()
456
+ onClose()
457
+ }, [commit, discardTransient, draft, baseline, onClose])
458
+
459
+ useEffect(() => {
460
+ const onKey = (e: KeyboardEvent) => {
461
+ if (e.key === 'Escape') handleCancel()
462
+ }
463
+ document.addEventListener('keydown', onKey)
464
+ return () => document.removeEventListener('keydown', onKey)
465
+ }, [handleCancel])
466
+
467
+ // ── Analysis ──────────────────────────────────────────────────────────────
468
+
469
+ const runAnalysis = useCallback(async () => {
470
+ const analyze = adapter.analyzeAudioPolish
471
+ if (!analyze || runningRef.current) return
472
+
473
+ const enabled = PIECES.filter(p => pieces[p.id]).map(p => p.id)
474
+ if (enabled.length === 0 || targets.length === 0) return
475
+
476
+ runningRef.current = true
477
+ setPhase('analysing')
478
+
479
+ // Raw, per clip and per source. Rows are derived from this AFTER the pool
480
+ // drains, because a silence removal cannot be flagged until the whole-source
481
+ // cross-check for its src has come back.
482
+ const raw: Record<string, { silence?: Removal[]; fillers?: Removal[]; loudness?: { measuredI: number; measuredTP: number }; errors: Partial<Record<Piece, string>> }> = {}
483
+ for (const clip of targets) raw[clip.id] = { errors: {} }
484
+
485
+ const bySrc: Record<string, { keeps?: Array<[number, number]>; checkFailed?: boolean; vocalsPath?: string; url?: string; error?: string }> = {}
486
+ const srcOf = (clip: VisualItem) => clip.src ?? ''
487
+ for (const clip of targets) bySrc[srcOf(clip)] ??= {}
488
+
489
+ const jobs: Array<() => Promise<void>> = []
490
+ let done = 0
491
+ const bump = (label: string) => {
492
+ done += 1
493
+ if (!unmountedRef.current) setProgress(p => ({ ...p, done, label }))
494
+ }
495
+
496
+ const push = (label: string, body: () => Promise<void>) => {
497
+ jobs.push(async () => {
498
+ if (unmountedRef.current || closingRef.current) return
499
+ setProgress(p => ({ ...p, label }))
500
+ try {
501
+ await body()
502
+ } finally {
503
+ bump(label)
504
+ }
505
+ })
506
+ }
507
+
508
+ // Per-clip, windowed calls.
509
+ for (const clip of targets) {
510
+ const window = sourceWindow(clip)
511
+ for (const piece of SPEECH_PIECES) {
512
+ if (!pieces[piece] || !supports(clip, piece)) continue
513
+ push(`${clipLabel(clip)}: ${piece === 'silence' ? 'silence' : 'filler words'}`, async () => {
514
+ try {
515
+ const res: AudioPolishAnalysis = await analyze({
516
+ projectId, piece, src: speechSrc(clip), window, options: { language },
517
+ })
518
+ if (res.piece === piece) raw[clip.id][piece] = res.removals
519
+ } catch (e) {
520
+ raw[clip.id].errors[piece] = messageOf(e)
521
+ }
522
+ })
523
+ }
524
+ if (pieces.loudness && supports(clip, 'loudness')) {
525
+ push(`${clipLabel(clip)}: loudness`, async () => {
526
+ try {
527
+ const res: AudioPolishAnalysis = await analyze({
528
+ projectId, piece: 'loudness', src: srcOf(clip), window, options: { targetLufs },
529
+ })
530
+ if (res.piece === 'loudness') {
531
+ raw[clip.id].loudness = { measuredI: res.measuredI, measuredTP: res.measuredTP }
532
+ }
533
+ } catch (e) {
534
+ raw[clip.id].errors.loudness = messageOf(e)
535
+ }
536
+ })
537
+ }
538
+ }
539
+
540
+ // Whole-source calls, once per distinct source. Both are expensive and both
541
+ // are 1:1 with the whole file, so two clips off one source share a result.
542
+ for (const src of Object.keys(bySrc)) {
543
+ if (pieces.silence && targets.some(c => srcOf(c) === src && supports(c, 'silence-check'))) {
544
+ push(`${basename(src)}: silence cross check`, async () => {
545
+ try {
546
+ const res: AudioPolishAnalysis = await analyze({ projectId, piece: 'silence-check', src })
547
+ if (res.piece === 'silence-check') bySrc[src].keeps = res.keeps
548
+ } catch {
549
+ // Not a piece failure: the removals are still valid, they are just
550
+ // uncrosschecked. Said plainly in the UI rather than passed off as
551
+ // a check that ran.
552
+ bySrc[src].checkFailed = true
553
+ }
554
+ })
555
+ }
556
+ if (pieces.voice && targets.some(c => srcOf(c) === src && supports(c, 'voice'))) {
557
+ push(`${basename(src)}: isolate voice`, async () => {
558
+ try {
559
+ const res: AudioPolishAnalysis = await analyze({ projectId, piece: 'voice', src })
560
+ if (res.piece === 'voice') {
561
+ bySrc[src].vocalsPath = res.vocalsPath
562
+ bySrc[src].url = res.url
563
+ }
564
+ } catch (e) {
565
+ bySrc[src].error = messageOf(e)
566
+ }
567
+ })
568
+ }
569
+ }
570
+
571
+ setProgress({ done: 0, total: jobs.length, label: '' })
572
+ await runPool(jobs, MAX_INFLIGHT)
573
+
574
+ if (unmountedRef.current || closingRef.current) {
575
+ runningRef.current = false
576
+ return
577
+ }
578
+
579
+ // Rows, guards, and defaults. Everything below is source time until
580
+ // `mapRemovals`; see audioPolish.ts's coordinate note.
581
+ const hasCaptions = (baseline.captions?.segments?.length ?? 0) > 0
582
+ const nextAnalysis: Record<string, ClipAnalysis> = {}
583
+ const nextApproved: Record<string, boolean> = {}
584
+
585
+ for (const clip of targets) {
586
+ const src = srcOf(clip)
587
+ const captionWords = hasCaptions ? captionWordsInSourceTime(baseline, clip) : undefined
588
+ const rows: RemovalRow[] = []
589
+
590
+ for (const piece of SPEECH_PIECES) {
591
+ const list = raw[clip.id][piece]
592
+ if (!list) continue
593
+ const guards = piece === 'silence'
594
+ ? { captionWords, silenceKeeps: bySrc[src]?.keeps }
595
+ : { captionWords }
596
+ flagRemovals(list, guards).forEach((f, i) => {
597
+ const [mapped] = mapRemovals(clip, [f])
598
+ if (!mapped) return // survives as nothing on this clip: nothing to approve
599
+ const key = `${clip.id}::${piece}::${i}`
600
+ rows.push({
601
+ key,
602
+ piece,
603
+ source: f.text !== undefined ? { start: f.start, end: f.end, text: f.text } : { start: f.start, end: f.end },
604
+ tlStart: mapped.start,
605
+ tlEnd: mapped.end,
606
+ text: f.text,
607
+ flagged: f.flagged,
608
+ reason: f.reason,
609
+ })
610
+ // Flagged removals require a deliberate tick.
611
+ nextApproved[key] = !f.flagged
612
+ })
613
+ }
614
+
615
+ const notes: string[] = []
616
+ if (rows.length > 0 && !hasCaptions) notes.push(NO_CAPTIONS_NOTE)
617
+ if (pieces.silence && supports(clip, 'silence') && bySrc[src]?.checkFailed) notes.push(NO_SILENCE_CHECK_NOTE)
618
+
619
+ const errors = { ...raw[clip.id].errors }
620
+ if (pieces.voice && supports(clip, 'voice') && bySrc[src]?.error) errors.voice = bySrc[src].error
621
+
622
+ nextAnalysis[clip.id] = {
623
+ rows,
624
+ loudness: raw[clip.id].loudness,
625
+ vocalsPath: bySrc[src]?.vocalsPath,
626
+ vocalsUrl: bySrc[src]?.url,
627
+ errors,
628
+ notes,
629
+ }
630
+ }
631
+
632
+ // Approvals are rebuilt, not merged: a re-run at different settings returns
633
+ // a different set of removals, and carrying stale ticks across would silently
634
+ // approve something the operator never saw.
635
+ setAnalysis(nextAnalysis)
636
+ setApproved(nextApproved)
637
+ setAnalysedPieces({
638
+ silence: pieces.silence,
639
+ fillers: pieces.fillers,
640
+ loudness: pieces.loudness,
641
+ voice: pieces.voice,
642
+ })
643
+ setPhase('review')
644
+ runningRef.current = false
645
+ }, [adapter, projectId, targets, pieces, language, targetLufs, baseline])
646
+
647
+ // ── Derived display state ─────────────────────────────────────────────────
648
+
649
+ /** Per clip: the timeline seconds this plan currently removes, and that as a
650
+ * fraction of the clip. Overlapping silence and filler ranges would be
651
+ * double counted here; they are disjoint by construction (fillers sit inside
652
+ * speech, silence sits outside it), and this drives a warning, not a cut. */
653
+ const overcut = useMemo(() => {
654
+ const out: Array<{ clip: VisualItem; removed: number; fraction: number }> = []
655
+ for (const clip of targets) {
656
+ const a = analysis[clip.id]
657
+ if (!a) continue
658
+ let removed = 0
659
+ for (const row of a.rows) {
660
+ if (!pieces[row.piece] || !approved[row.key]) continue
661
+ removed += row.tlEnd - row.tlStart
662
+ }
663
+ const span = clip.end - clip.start
664
+ const fraction = span > 0 ? removed / span : 0
665
+ if (fraction > OVERCUT_FRACTION) out.push({ clip, removed, fraction })
666
+ }
667
+ return out
668
+ }, [targets, analysis, pieces, approved])
669
+
670
+ const totalApproved = useMemo(
671
+ () => Object.values(analysis).reduce(
672
+ (n, a) => n + a.rows.filter(r => pieces[r.piece] && approved[r.key]).length,
673
+ 0,
674
+ ),
675
+ [analysis, pieces, approved],
676
+ )
677
+
678
+ const anyPiece = PIECES.some(p => pieces[p.id])
679
+ const busy = phase === 'analysing'
680
+
681
+ // ── Render ────────────────────────────────────────────────────────────────
682
+
683
+ const panel = (
684
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
685
+ <div
686
+ role="dialog"
687
+ aria-label="Polish audio"
688
+ className="w-full max-w-4xl max-h-[90vh] bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden"
689
+ >
690
+ {/* Header */}
691
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
692
+ <div className="flex flex-col gap-0.5">
693
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">Polish audio</h2>
694
+ <p className="text-[11px] text-[var(--editor-text)]/55">
695
+ {targets.length === 1
696
+ ? '1 clip in scope. Nothing is saved until you press Apply.'
697
+ : `${targets.length} clips in scope. Nothing is saved until you press Apply.`}
698
+ </p>
699
+ </div>
700
+ <button
701
+ onClick={handleCancel}
702
+ aria-label="Close"
703
+ className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
704
+ >
705
+ ×
706
+ </button>
707
+ </div>
708
+
709
+ {/* Settings */}
710
+ <div className="px-5 py-4 border-b border-[var(--editor-border)] flex flex-col gap-4">
711
+ <div className="grid grid-cols-2 gap-2">
712
+ {PIECES.map(p => (
713
+ <label
714
+ key={p.id}
715
+ className="flex items-start gap-2 rounded-lg border border-[var(--editor-border)] p-2.5 cursor-pointer hover:bg-[var(--editor-text)]/5 transition-colors"
716
+ >
717
+ <input
718
+ type="checkbox"
719
+ checked={pieces[p.id]}
720
+ aria-label={p.label}
721
+ onChange={() => setPieces(s => ({ ...s, [p.id]: !s[p.id] }))}
722
+ className="mt-0.5"
723
+ />
724
+ <span className="flex flex-col gap-0.5 min-w-0">
725
+ <span className="text-xs font-semibold text-[var(--editor-text)]">{p.label}</span>
726
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{p.blurb}</span>
727
+ {phase === 'review' && pieces[p.id] && !analysedPieces[p.id] && (
728
+ <span className={`text-[10px] ${mode === 'light' ? 'text-amber-700' : 'text-amber-400'}`}>Not analysed yet. Run Analyse again.</span>
729
+ )}
730
+ </span>
731
+ </label>
732
+ ))}
733
+ </div>
734
+
735
+ <div className="flex flex-wrap items-end gap-4">
736
+ <label className="flex flex-col gap-1.5">
737
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
738
+ Spoken language
739
+ </span>
740
+ <select
741
+ value={language}
742
+ aria-label="Spoken language"
743
+ onChange={e => setLanguage(e.target.value)}
744
+ className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]"
745
+ >
746
+ {LANGUAGES.map(l => (
747
+ <option key={l.id} value={l.id}>{l.label}</option>
748
+ ))}
749
+ </select>
750
+ <span className="text-[10px] text-[var(--editor-text)]/45 max-w-[15rem] leading-snug">
751
+ Set this before analysing. The wrong language makes real speech look like silence.
752
+ </span>
753
+ </label>
754
+
755
+ <label className="flex flex-col gap-1.5">
756
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
757
+ Loudness target
758
+ </span>
759
+ <select
760
+ value={targetId}
761
+ aria-label="Loudness target"
762
+ onChange={e => setTargetId(e.target.value as TargetId)}
763
+ className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]"
764
+ >
765
+ {LOUDNESS_TARGETS.map(t => (
766
+ <option key={t.id} value={t.id}>{t.label}</option>
767
+ ))}
768
+ </select>
769
+ </label>
770
+
771
+ {targetId === 'custom' && (
772
+ <label className="flex flex-col gap-1.5">
773
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
774
+ Custom LUFS
775
+ </span>
776
+ <NumberField
777
+ name="Custom LUFS"
778
+ value={customLufs}
779
+ // Local modal state with no separate save step, exactly like
780
+ // FontSizePicker — every valid edit writes straight through,
781
+ // so both callbacks point at the same setter. LUFS values are
782
+ // negative (e.g. -14): no `min` is passed, deliberately, so a
783
+ // typed `-` and negative numbers pass through untouched (see
784
+ // NumberField's contract — a typed value is never clamped).
785
+ // Same reasoning for the stepper's own `stepValue` call below:
786
+ // no `min`/`max` there either, so nudging past a round target
787
+ // like -14 keeps working in both directions.
788
+ onPreview={setCustomLufs}
789
+ onCommit={setCustomLufs}
790
+ step={0.5}
791
+ onStep={d => setCustomLufs(stepValue(customLufs, d, { step: 0.5 }))}
792
+ className="w-24"
793
+ />
794
+ </label>
795
+ )}
796
+
797
+ <div className="flex flex-col gap-1.5">
798
+ <button
799
+ onClick={() => void runAnalysis()}
800
+ disabled={busy || !anyPiece || targets.length === 0}
801
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] disabled:opacity-40 hover:opacity-90 transition-opacity"
802
+ >
803
+ {busy ? 'Analysing…' : phase === 'review' ? 'Analyse again' : 'Analyse'}
804
+ </button>
805
+ {busy && (
806
+ <span className="text-[10px] text-[var(--editor-text)]/55">
807
+ {`Analysing ${Math.min(progress.done + 1, progress.total)} of ${progress.total}. ${progress.label}`}
808
+ </span>
809
+ )}
810
+ </div>
811
+ </div>
812
+ </div>
813
+
814
+ {/* Over-cut warning */}
815
+ {overcut.length > 0 && (
816
+ <div className={`px-5 py-2 text-[11px] leading-snug ${mode === 'light' ? 'text-amber-700' : 'text-amber-400/90'} border-b border-[var(--editor-border)] flex flex-col gap-0.5`}>
817
+ {overcut.map(o => (
818
+ <span key={o.clip.id}>
819
+ {`Warning: ${clipLabel(o.clip)} would lose ${Math.round(o.fraction * 100)}% of its length, ${o.removed.toFixed(1)}s. Check the ticked removals below.`}
820
+ </span>
821
+ ))}
822
+ </div>
823
+ )}
824
+
825
+ {/* Review */}
826
+ <div className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-5 min-h-[12rem]">
827
+ {phase === 'setup' && (
828
+ <p className="text-xs text-[var(--editor-text)]/50 italic">
829
+ Pick the cleanups you want, set the language, then press Analyse. You see every proposed
830
+ change on the timeline before anything is saved.
831
+ </p>
832
+ )}
833
+
834
+ {phase !== 'setup' && targets.map(clip => {
835
+ const a = analysis[clip.id]
836
+ const rows = (a?.rows ?? []).filter(r => pieces[r.piece])
837
+ const gainDb = a?.loudness ? loudnessGainDb(a.loudness, targetLufs) : undefined
838
+ const clamped = gainDb !== undefined && volumeCeilingClamps(gainDb, trackVolume)
839
+
840
+ return (
841
+ <section key={clip.id} className="flex flex-col gap-2">
842
+ <header className="flex items-baseline justify-between gap-3 border-b border-[var(--editor-border)] pb-1">
843
+ <span className="text-xs font-semibold text-[var(--editor-text)] truncate">{clipLabel(clip)}</span>
844
+ <span className="text-[10px] font-mono text-[var(--editor-text)]/45 shrink-0">
845
+ {`${clock(clip.start)} to ${clock(clip.end)}`}
846
+ </span>
847
+ </header>
848
+
849
+ {/* Per-piece failures. One piece failing leaves the rest usable. */}
850
+ {a && Object.entries(a.errors).map(([piece, msg]) => (
851
+ <p key={piece} className={`text-[11px] leading-snug ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
852
+ {`${piece} could not be analysed: ${msg}`}
853
+ </p>
854
+ ))}
855
+
856
+ {a?.notes.map(note => (
857
+ <p key={note} className={`text-[11px] leading-snug ${mode === 'light' ? 'text-amber-700' : 'text-amber-400/90'}`}>{note}</p>
858
+ ))}
859
+
860
+ {/* Loudness */}
861
+ {pieces.loudness && a?.loudness && gainDb !== undefined && (
862
+ <p className="text-[11px] text-[var(--editor-text)]/70">
863
+ {`Loudness: measured ${a.loudness.measuredI.toFixed(1)} LUFS, true peak ${a.loudness.measuredTP.toFixed(1)} dBTP. Gain ${signedDb(gainDb)} to reach ${targetLufs} LUFS.`}
864
+ {clamped && (
865
+ <span className={mode === 'light' ? 'text-amber-700' : 'text-amber-400'}>
866
+ {' '}Held at the +6 dB ceiling, so this clip stays under the target.
867
+ </span>
868
+ )}
869
+ </p>
870
+ )}
871
+
872
+ {/* Every enabled piece this clip cannot take, with the planner's
873
+ own wording. Rendered verbatim: no period appended, no
874
+ rephrasing. Loudness never appears here, it runs on any clip. */}
875
+ {PIECES.filter(p => pieces[p.id] && !supports(clip, p.id)).map(p => (
876
+ <p
877
+ key={p.id}
878
+ className={`self-start text-[11px] px-2 py-0.5 rounded ${mode === 'light' ? 'bg-amber-50 border border-amber-300 text-amber-800' : 'bg-amber-400/10 border border-amber-400/40 text-amber-300'}`}
879
+ >
880
+ {pieceSupported(clip, p.id).reason}
881
+ </p>
882
+ ))}
883
+
884
+ {/* Voice isolation A/B, when this clip can take it */}
885
+ {pieces.voice && supports(clip, 'voice') && (
886
+ a?.vocalsUrl ? (
887
+ <div className="flex flex-col gap-1.5">
888
+ <span className="text-[11px] text-[var(--editor-text)]/70">
889
+ Isolated voice. Compare the two before you apply.
890
+ </span>
891
+ <div className="flex flex-wrap items-center gap-3">
892
+ <span className="flex items-center gap-1.5">
893
+ <span className="text-[10px] text-[var(--editor-text)]/55">Original</span>
894
+ <audio
895
+ controls
896
+ preload="none"
897
+ src={adapter.fileUrl(clip.src ?? '')}
898
+ aria-label={`Original audio for ${clipLabel(clip)}`}
899
+ />
900
+ </span>
901
+ <span className="flex items-center gap-1.5">
902
+ <span className="text-[10px] text-[var(--editor-text)]/55">Isolated voice</span>
903
+ <audio
904
+ controls
905
+ preload="none"
906
+ src={a.vocalsUrl}
907
+ aria-label={`Isolated voice for ${clipLabel(clip)}`}
908
+ />
909
+ </span>
910
+ </div>
911
+ </div>
912
+ ) : null
913
+ )}
914
+
915
+ {/* Removals */}
916
+ {rows.length === 0 ? (
917
+ <p className="text-[11px] text-[var(--editor-text)]/45 italic">No removals proposed for this clip.</p>
918
+ ) : (
919
+ <ul className="flex flex-col gap-1">
920
+ {rows.map(row => (
921
+ <li key={row.key}>
922
+ <label className="flex items-start gap-2 text-[11px] text-[var(--editor-text)]/80 cursor-pointer hover:bg-[var(--editor-text)]/5 rounded px-1 py-0.5">
923
+ <input
924
+ type="checkbox"
925
+ checked={Boolean(approved[row.key])}
926
+ aria-label={`${row.piece === 'silence' ? 'Remove silence' : `Remove filler ${row.text ? `"${row.text}"` : ''}`.trim()} at ${clock(row.tlStart)}`}
927
+ onChange={() => setApproved(s => ({ ...s, [row.key]: !s[row.key] }))}
928
+ className="mt-0.5"
929
+ />
930
+ <span className="font-mono shrink-0">{clock(row.tlStart)}</span>
931
+ <span className="font-mono text-[var(--editor-text)]/50 shrink-0">
932
+ {`${(row.tlEnd - row.tlStart).toFixed(2)}s`}
933
+ </span>
934
+ <span className="text-[var(--editor-text)]/50 shrink-0">
935
+ {row.piece === 'silence' ? 'silence' : 'filler'}
936
+ </span>
937
+ {row.text && <span className="italic truncate">{`"${row.text}"`}</span>}
938
+ {row.flagged && (
939
+ <span className={`ml-auto text-[10px] px-1.5 py-0.5 rounded leading-snug ${mode === 'light' ? 'bg-amber-50 border border-amber-300 text-amber-800' : 'bg-amber-400/10 border border-amber-400/40 text-amber-300'}`}>
940
+ {`Flagged. ${row.reason ?? ''}`.trim()}
941
+ </span>
942
+ )}
943
+ </label>
944
+ </li>
945
+ ))}
946
+ </ul>
947
+ )}
948
+ </section>
949
+ )
950
+ })}
951
+ </div>
952
+
953
+ {/* Footer */}
954
+ <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
955
+ <span className="text-[11px] text-[var(--editor-text)]/50">
956
+ {phase === 'review'
957
+ ? `${totalApproved} removal${totalApproved === 1 ? '' : 's'} ticked. The timeline is showing the result.`
958
+ : 'Nothing is saved until you press Apply.'}
959
+ </span>
960
+ <div className="flex items-center gap-2">
961
+ <button
962
+ onClick={handleCancel}
963
+ className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
964
+ >
965
+ Cancel
966
+ </button>
967
+ <button
968
+ onClick={handleApply}
969
+ disabled={phase !== 'review' || busy}
970
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] disabled:opacity-40 hover:opacity-90 transition-opacity"
971
+ >
972
+ Apply
973
+ </button>
974
+ </div>
975
+ </div>
976
+ </div>
977
+ </div>
978
+ )
979
+
980
+ // Portal to document.body so a transformed host ancestor cannot trap this
981
+ // `fixed` overlay and push the panel off-screen (see RenderModal).
982
+ return createPortal(panel, document.body)
983
+ }