@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,774 @@
1
+ // The audio-polish planner. Pure: no React, no I/O, no clock, no randomness —
2
+ // every function here is a transform of data the caller already has, so the
3
+ // modal can rebuild a whole draft on every checkbox tick and the whole thing is
4
+ // unit-testable without a DOM.
5
+ //
6
+ // The four pieces it plans for, and how each is expressed in the project:
7
+ //
8
+ // silence / fillers → `applyCutToTracks` per approved range, then ONE
9
+ // `collapseGaps` (see APPLY ORDER below)
10
+ // loudness → `item.volume`
11
+ // voice isolation → a new `project.audio.tracks[]` entry + `item.muted`
12
+ //
13
+ // No new schema field, no render change: every field written here is already
14
+ // honoured by both the preview and the renderer.
15
+ //
16
+ // ── TWO COORDINATE SPACES, AND THE RULE FOR KEEPING THEM APART ──────────────
17
+ //
18
+ // The analysis steps speak SOURCE time — an offset into the file named by the
19
+ // clip's `src`. The timeline speaks TIMELINE time. For a clip they are related
20
+ // by `start`, `inPoint` and `speed`:
21
+ //
22
+ // sourceToTimeline(clip, t) = clip.start + (t - (clip.inPoint ?? 0)) / (clip.speed ?? 1)
23
+ // timelineToSource(clip, t) = (t - clip.start) * (clip.speed ?? 1) + (clip.inPoint ?? 0)
24
+ //
25
+ // BOTH GUARDS RUN IN SOURCE TIME. Two of their three inputs arrive that way
26
+ // already (step removals, and `waveform_trim`'s keep ranges); the odd one out is
27
+ // caption words, which are stored in TIMELINE time — which is exactly what
28
+ // `captionWordsInSourceTime` exists to fix, and why `flagRemovals` takes every
29
+ // argument in one clip's source time. Mixing the two spaces here silently
30
+ // produces garbage for every clip whose `start` and `inPoint` differ, which is
31
+ // most of them, and the damage is invisible: removals still look plausible, they
32
+ // just land on the wrong audio.
33
+ //
34
+ // Timeline mapping happens exactly ONCE afterwards, in `buildDraft`.
35
+ //
36
+ // ── APPLY ORDER (load-bearing) ──────────────────────────────────────────────
37
+ //
38
+ // speech cuts (pooled, descending)
39
+ // → gap close with `remapCaptions: false`
40
+ // → loudness
41
+ // → voice isolation
42
+ //
43
+ // `applyCutToTracks` and `collapseGaps` mutate `tracks[0]`, `tracks[1+]` and
44
+ // `captions` only; they deliberately do NOT shift `project.audio.tracks` (a
45
+ // music bed must not ripple when you trim a take). A stem track minted before
46
+ // the cuts would therefore end up misaligned. Cuts and the gap close run first,
47
+ // so clip geometry is final before stem tracks are built against it. Loudness
48
+ // sits in the middle because it is per-clip and order-insensitive.
49
+ //
50
+ // `remapCaptions: false` is not an optimisation — see the invariant comment
51
+ // above `collapseGaps` in cuts.ts. `applyCutToTracks` has ALREADY rippled the
52
+ // captions; letting `collapseGaps` remap them too shifts every one of them
53
+ // twice.
54
+ //
55
+ // ── LOOPING CLIPS ARE EXCLUDED FROM THE CUT AND VOICE PIECES ────────────────
56
+ //
57
+ // `VisualItem.loop` (`schema.ts`) replays a clip's `[inPoint, outPoint)` source
58
+ // window repeatedly across the whole clip's TIMELINE span (`scheduler.ts`'s
59
+ // `placeInSource`). That breaks the one-to-one relation `sourceToTimeline` /
60
+ // `timelineToSource` assume above: a removal detected once in SOURCE time
61
+ // actually recurs at every wrap, and mapping it through either function picks
62
+ // exactly one of those timeline positions and calls it correct — silently, the
63
+ // removal still looks plausible, it just lands on the wrong repetition. Silence
64
+ // and filler removals depend on that one-to-one relation directly; so does the
65
+ // voice stem, because it is attached as a plain `AudioTrack` with a single
66
+ // `inPoint`/`outPoint` window and nothing plays an audio track more than once
67
+ // (see the restriction on `voiceTrackFor` below). `buildDraft` drops removals,
68
+ // and refuses to mint a stem, for a looping clip rather than act on either
69
+ // wrong. Loudness has NO coordinate mapping at all — it only assigns
70
+ // `item.volume` — so it is unaffected and stays available on a looping clip.
71
+ //
72
+ // Making the mapping loop-aware (one source removal fanning out to several
73
+ // timeline removals) is a real feature, not a bug fix: it touches cut
74
+ // ordering, caption ripple and stem windows, and is deliberately left undone
75
+ // here rather than half-built. See `pieceSupported` for the query surface and
76
+ // the cut-pooling step in `buildDraft` for the enforcement.
77
+
78
+ import type { Project } from '../types'
79
+ import type { AudioTrack, VisualItem, VisualTrack, Word } from '../schema'
80
+ import { mapTrackItems, normalizeTracks, trackItems } from './timeline/timeline-model'
81
+ import { applyCutToTracks, collapseGaps, type Cut } from './cuts'
82
+
83
+ // ── Constants ───────────────────────────────────────────────────────────────
84
+
85
+ /** Float slop for overlap and no-op checks. Matches `cuts.ts`'s own EPSILON, so
86
+ * a range that butts against a boundary here butts against it there too. */
87
+ const EPSILON = 0.001
88
+
89
+ /** How much a proposed silence removal may overlap an independently-detected
90
+ * KEEP range before the cross-check calls it out. `waveform_trim`'s energy
91
+ * thresholding has coarse edges, so a few tens of milliseconds of overlap at a
92
+ * boundary is normal; a real disagreement is far larger than this. */
93
+ const SILENCE_KEEP_TOLERANCE = 0.05
94
+
95
+ /** True-peak ceiling the gain is held under, in dBTP. Nothing distorts at or
96
+ * below this. */
97
+ const TRUE_PEAK_CEILING_DBTP = -1.5
98
+
99
+ /** Schema ceiling on `VisualItem.volume` (≈ +6 dB). */
100
+ const VOLUME_MAX = 2
101
+
102
+ // Flag reasons are rendered verbatim in the review list, so they follow the
103
+ // UI copy rule: no em dashes.
104
+ //
105
+ // The two caption reasons differ by the PRECISION of the evidence, and the
106
+ // operator is told which they are looking at rather than left to infer it: a
107
+ // word-level hit on a 200ms word is strong evidence, a whole-segment hit on a
108
+ // four-second segment is much weaker. See `captionWordsInSourceTime`.
109
+ const CAPTION_WORD_REASON = 'Overlaps a caption word.'
110
+ const CAPTION_SEGMENT_REASON =
111
+ 'Overlaps a caption segment. This clip has no word timings, so the check is less precise.'
112
+ const SILENCE_REASON = 'The independent silence check heard sound here. This may not be silence.'
113
+
114
+ // `pieceSupported`'s reasons, rendered verbatim as the "why is this greyed
115
+ // out" badge. No em dashes (UI copy rule), and — unlike the flag reasons
116
+ // above — NO TRAILING PERIOD, so the badge family reads as one voice.
117
+ const VOICE_SPEED_REASON = 'Voice isolation unavailable: this clip does not play at normal speed'
118
+ const VOICE_LOOP_REASON =
119
+ 'Voice isolation unavailable: this clip loops, so the isolated audio would drift out of sync'
120
+ const SILENCE_LOOP_REASON =
121
+ 'Silence removal unavailable: this clip loops, so cuts would land in the wrong place'
122
+ const FILLERS_LOOP_REASON =
123
+ 'Filler removal unavailable: this clip loops, so cuts would land in the wrong place'
124
+
125
+ // ── Types ───────────────────────────────────────────────────────────────────
126
+
127
+ /** A span the analysis proposes to remove. `text` is the recognized words, set
128
+ * by the filler piece. Times are in whichever space the producer works in —
129
+ * SOURCE for anything coming back from a step, TIMELINE once `mapRemovals` has
130
+ * been through it. */
131
+ export interface Removal {
132
+ start: number
133
+ end: number
134
+ text?: string
135
+ }
136
+
137
+ /** A removal plus the guards' verdict. `flagged` removals default to OFF in the
138
+ * UI and require a deliberate tick. */
139
+ export interface FlaggedRemoval extends Removal {
140
+ flagged: boolean
141
+ /** Present only when `flagged`; user-facing. */
142
+ reason?: string
143
+ }
144
+
145
+ /** A caption word pulled into a clip's source time, carrying HOW precisely it is
146
+ * known: `'word'` from a real word timing, `'segment'` from the whole-segment
147
+ * fallback (see `captionWordsInSourceTime`). Structurally still a `Word`. */
148
+ export interface SourceCaptionWord extends Word {
149
+ precision: 'word' | 'segment'
150
+ }
151
+
152
+ /** The slice of `normalize --measure-only`'s pass-1 output the gain needs, in
153
+ * dB. Structurally satisfied by the `'loudness'` arm of `AudioPolishAnalysis`. */
154
+ export interface LoudnessStats {
155
+ measuredI: number
156
+ measuredTP: number
157
+ }
158
+
159
+ /** What the operator approved for one clip. Every field is independent: a piece
160
+ * that is switched off simply leaves its field absent. */
161
+ export interface ClipPlan {
162
+ /** Approved removals, in this clip's SOURCE time — `buildDraft` maps them. */
163
+ removals?: readonly Removal[]
164
+ /** Gain in dB from `loudnessGainDb`. `buildDraft` folds in the track volume. */
165
+ gainDb?: number
166
+ /** Path to the isolated vocals WAV for this clip's source. */
167
+ vocalsPath?: string
168
+ }
169
+
170
+ /** Everything the modal has decided, keyed by `VisualItem.id`. Clips absent
171
+ * from `clips` are left exactly as they are. */
172
+ export interface PolishPlan {
173
+ clips: Readonly<Record<string, ClipPlan>>
174
+ }
175
+
176
+ // ── Scope ───────────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * The video clips the polish will act on: the current multi-selection when there
180
+ * is one, otherwise every video clip on `tracks[0]`, in track order.
181
+ *
182
+ * Selected ids that are not video clips on `tracks[0]` (images, overlays, items
183
+ * on a higher track) are dropped rather than widening the scope — an explicit
184
+ * selection of nothing polishable means nothing to polish.
185
+ */
186
+ export function targetClips<P extends Project>(
187
+ project: P,
188
+ selectionIds: readonly string[] = [],
189
+ ): VisualItem[] {
190
+ const items = (trackItems(project)[0] ?? []).filter(item => item.type === 'video')
191
+ if (selectionIds.length === 0) return items
192
+ const wanted = new Set(selectionIds)
193
+ return items.filter(item => wanted.has(item.id))
194
+ }
195
+
196
+ // ── Coordinate mapping ──────────────────────────────────────────────────────
197
+
198
+ /** SOURCE seconds → TIMELINE seconds for one clip. */
199
+ export function sourceToTimeline(clip: VisualItem, t: number): number {
200
+ return clip.start + (t - (clip.inPoint ?? 0)) / (clip.speed ?? 1)
201
+ }
202
+
203
+ /** TIMELINE seconds → SOURCE seconds for one clip. The inverse of the above,
204
+ * and what pulls caption words into source time for the guards. */
205
+ export function timelineToSource(clip: VisualItem, t: number): number {
206
+ return (t - clip.start) * (clip.speed ?? 1) + (clip.inPoint ?? 0)
207
+ }
208
+
209
+ /**
210
+ * Map source-time removals onto the timeline and clip them to `[clip.start,
211
+ * clip.end]`, dropping anything that survives as nothing.
212
+ *
213
+ * Clipping is what makes a removal straddling a clip edge safe: the step
214
+ * analysed a window of the source, but only the part of that window this clip
215
+ * actually plays may be cut.
216
+ */
217
+ export function mapRemovals(clip: VisualItem, removals: readonly Removal[]): Removal[] {
218
+ const out: Removal[] = []
219
+ for (const r of removals) {
220
+ const start = Math.max(sourceToTimeline(clip, r.start), clip.start)
221
+ const end = Math.min(sourceToTimeline(clip, r.end), clip.end)
222
+ if (end - start <= EPSILON) continue
223
+ out.push(r.text !== undefined ? { start, end, text: r.text } : { start, end })
224
+ }
225
+ return out
226
+ }
227
+
228
+ // ── Guard inputs ────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * The caption words overlapping this clip's timeline span, inverse-mapped into
232
+ * the clip's SOURCE time so `flagRemovals` can compare them against step
233
+ * removals without a second coordinate conversion.
234
+ *
235
+ * A segment carrying no `words` falls back to the segment's own span, marked
236
+ * `precision: 'segment'`. Word-level timings are the normal case, but a segment
237
+ * without them is still known-spoken audio, and checking `words[]` strictly
238
+ * would leave such a project with zero protection while the modal reported the
239
+ * guard as available — the worst failure mode there is for a guard that exists
240
+ * to enforce never-cut-spoken-words. Over-flagging is the safe direction: a
241
+ * flagged removal is not deleted, it is shown to the operator switched off. The
242
+ * `precision` field is what lets the reason string say which kind of hit it was,
243
+ * so weaker evidence reads as weaker.
244
+ *
245
+ * Words are NOT clamped to the clip span. The mapping is exact and monotone, so
246
+ * clamping cannot change any overlap verdict for a removal that is itself inside
247
+ * the clip, and leaving a word whole keeps its reported timing honest.
248
+ */
249
+ export function captionWordsInSourceTime<P extends Project>(
250
+ project: P,
251
+ clip: VisualItem,
252
+ ): SourceCaptionWord[] {
253
+ const segments = project.captions?.segments ?? []
254
+ const out: SourceCaptionWord[] = []
255
+ for (const seg of segments) {
256
+ const hasWords = Boolean(seg.words?.length)
257
+ const words: Word[] = hasWords
258
+ ? seg.words!
259
+ : [{ word: seg.text, start: seg.start, end: seg.end }]
260
+ for (const w of words) {
261
+ if (w.end <= clip.start || w.start >= clip.end) continue
262
+ out.push({
263
+ word: w.word,
264
+ start: timelineToSource(clip, w.start),
265
+ end: timelineToSource(clip, w.end),
266
+ precision: hasWords ? 'word' : 'segment',
267
+ })
268
+ }
269
+ }
270
+ return out
271
+ }
272
+
273
+ // ── The two guards ──────────────────────────────────────────────────────────
274
+
275
+ /** Overlap of two closed intervals; <= 0 when they merely touch or miss. */
276
+ function overlap(aStart: number, aEnd: number, bStart: number, bEnd: number): number {
277
+ return Math.min(aEnd, bEnd) - Math.max(aStart, bStart)
278
+ }
279
+
280
+ /**
281
+ * Run both safety guards over one clip's removals. **EVERY ARGUMENT IS IN THAT
282
+ * CLIP'S SOURCE TIME** — see the coordinate note at the top of this file.
283
+ *
284
+ * 1. **Caption-word overlap.** A removal intersecting any caption word is
285
+ * claiming to delete something that was said. This is the mechanical form of
286
+ * the never-cut-spoken-words rule. Pass `captionWords` from
287
+ * `captionWordsInSourceTime`; an empty list or an absent one means the guard
288
+ * finds nothing (the modal decides whether to describe that as "no captions"
289
+ * rather than implying a check ran). A hit on a real word outranks a hit on
290
+ * a whole-segment fallback, so a removal that touches both reports the
291
+ * tighter, stronger reason.
292
+ *
293
+ * 2. **Independent silence cross-check.** `silenceKeeps` comes from
294
+ * `waveform_trim` — plain ffmpeg energy thresholding, which knows nothing
295
+ * about words, models or languages. A removal overlapping a KEEP range by
296
+ * more than `SILENCE_KEEP_TOLERANCE` is claiming that audibly non-silent
297
+ * audio is silence, which is the exact shape of the documented failure where
298
+ * a wrong-language transcription made real speech look like silence.
299
+ * **Silence piece only** — pass `silenceKeeps` only for that piece; filler
300
+ * removals are word-targeted by construction and are guarded by (1) plus the
301
+ * visible word text.
302
+ *
303
+ * When both fire the caption reason is reported: it is the stronger rule and the
304
+ * one the operator needs to see.
305
+ */
306
+ export function flagRemovals(
307
+ removals: readonly Removal[],
308
+ guards: {
309
+ captionWords?: readonly SourceCaptionWord[]
310
+ silenceKeeps?: readonly (readonly [number, number])[]
311
+ },
312
+ ): FlaggedRemoval[] {
313
+ const { captionWords, silenceKeeps } = guards
314
+ return removals.map(r => {
315
+ const hits = captionWords?.filter(w => overlap(r.start, r.end, w.start, w.end) > EPSILON) ?? []
316
+ if (hits.length > 0) {
317
+ const reason = hits.some(w => w.precision === 'word')
318
+ ? CAPTION_WORD_REASON
319
+ : CAPTION_SEGMENT_REASON
320
+ return { ...r, flagged: true, reason }
321
+ }
322
+ if (silenceKeeps?.some(k => overlap(r.start, r.end, k[0], k[1]) > SILENCE_KEEP_TOLERANCE)) {
323
+ return { ...r, flagged: true, reason: SILENCE_REASON }
324
+ }
325
+ return { ...r, flagged: false }
326
+ })
327
+ }
328
+
329
+ // ── Loudness ────────────────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * The gain, in dB, to put this clip at `targetI` LUFS without distorting:
333
+ *
334
+ * gainDb = min(targetI - measuredI, TRUE_PEAK_CEILING - measuredTP)
335
+ *
336
+ * The first term is the level change `loudnorm` would make; the second holds it
337
+ * back so true peak stays at or under −1.5 dBTP.
338
+ *
339
+ * NAMED LIMITATION: this is a level change plus a peak guard, not `loudnorm`'s
340
+ * dynamic pass, and it levels each clip rather than mastering the finished mix.
341
+ * Whole-mix mastering is a render change and is deliberately out of scope.
342
+ */
343
+ export function loudnessGainDb(stats: LoudnessStats, targetI: number): number {
344
+ return Math.min(targetI - stats.measuredI, TRUE_PEAK_CEILING_DBTP - stats.measuredTP)
345
+ }
346
+
347
+ /**
348
+ * The `item.volume` that delivers `gainDb`, given the track's own gain:
349
+ *
350
+ * item.volume = clamp(10^(gainDb/20) / (track.volume ?? 1), 0, 2)
351
+ *
352
+ * The engine folds item and track volume together before playback
353
+ * (`effectiveItemAudio`), so the track gain is divided back out here to land on
354
+ * the absolute level that was asked for.
355
+ *
356
+ * IDEMPOTENT BY CONSTRUCTION: the measurement behind `gainDb` reads the FILE,
357
+ * never the project, and this result is ASSIGNED to `item.volume` rather than
358
+ * multiplied into it, so re-running the polish converges instead of compounding.
359
+ */
360
+ export function itemVolumeFor(gainDb: number, trackVolume?: number): number {
361
+ return Math.min(VOLUME_MAX, Math.max(0, rawVolumeFor(gainDb, trackVolume)))
362
+ }
363
+
364
+ /** True when the requested gain needs more than the schema's 2.0 ceiling can
365
+ * deliver, so the UI can say the clip is under-lifted instead of silently
366
+ * under-delivering. */
367
+ export function volumeCeilingClamps(gainDb: number, trackVolume?: number): boolean {
368
+ return rawVolumeFor(gainDb, trackVolume) > VOLUME_MAX
369
+ }
370
+
371
+ function rawVolumeFor(gainDb: number, trackVolume?: number): number {
372
+ return Math.pow(10, gainDb / 20) / (trackVolume ?? 1)
373
+ }
374
+
375
+ // ── Voice isolation ─────────────────────────────────────────────────────────
376
+
377
+ function basename(p: string): string {
378
+ return p.split('/').pop() ?? p
379
+ }
380
+
381
+ /**
382
+ * The stem id for the `ordinal`-th surviving fragment of the planned clip
383
+ * `baseId`, counting in timeline order.
384
+ *
385
+ * The whole point of the stable-id rule is that a re-run REPLACES its own tracks
386
+ * instead of stacking duplicates beside them, so the id must depend only on the
387
+ * baseline clip and the fragment's position — never on the fragment's OWN id,
388
+ * which `cuts.ts` mints from `Date.now()` and `Math.random()` and which
389
+ * therefore differs on every rebuild of the same draft.
390
+ *
391
+ * Ordinal 0 is the bare `polish_voice_<id>` the spec names, so the common
392
+ * one-fragment case is unchanged. The `__<n>` suffix on the rest is a shape
393
+ * neither `uniqueId` (`<base>_split_<…>`) nor `newClipId` (`clip_<…>`) nor
394
+ * init's `clip-<n>` can produce, so a suffixed id cannot collide with a stem
395
+ * named after some other real clip.
396
+ */
397
+ function stemIdFor(baseId: string, ordinal: number): string {
398
+ return ordinal === 0 ? `polish_voice_${baseId}` : `polish_voice_${baseId}__${ordinal + 1}`
399
+ }
400
+
401
+ /** Whether `trackId` is a stem this run owns on behalf of planned clip `baseId`
402
+ * — i.e. one `stemIdFor(baseId, …)` could have minted. */
403
+ function ownsStem(trackId: string, baseId: string): boolean {
404
+ const bare = `polish_voice_${baseId}`
405
+ if (trackId === bare) return true
406
+ const suffix = trackId.startsWith(`${bare}__`) ? trackId.slice(bare.length + 2) : ''
407
+ return suffix.length > 0 && /^\d+$/.test(suffix)
408
+ }
409
+
410
+ /**
411
+ * The stem track for one clip, or `null` when the clip is not eligible.
412
+ *
413
+ * The vocals WAV is separated from the WHOLE source and stays 1:1 with it, so a
414
+ * clip's slice of it is just the clip's own source window. `id` defaults to the
415
+ * clip's own `polish_voice_<id>`; `buildDraft` passes a `stemIdFor` id instead,
416
+ * because a fragment's id is not stable across rebuilds.
417
+ *
418
+ * RESTRICTION — SPEED 1 ONLY. `AudioTrack` has no tempo field and nothing plays
419
+ * audio tracks at anything but native tempo: the renderer seeks with `-ss`/`-to`
420
+ * and applies only `adelay`/`volume`/`afade`, and neither preview path
421
+ * stretches. A clip's own inline audio does get an `atempo` chain, but that path
422
+ * is not available to an audio track. So on a 2× clip the stem would play at
423
+ * native tempo, overrun its slot and drift against the picture, silently.
424
+ * Returning `null` lets the caller report the clip as skipped WITH the reason
425
+ * instead. This is also why the `outPoint` formula below carries no `* speed`
426
+ * factor: at speed 1 it is exact, and no other speed reaches it.
427
+ *
428
+ * RESTRICTION — NOT LOOPING EITHER, for the same underlying reason: `AudioTrack`
429
+ * has no loop field either, so a stem placed at a single `[inPoint, outPoint)`
430
+ * window plays that window once. A looping clip repeats that same source window
431
+ * across its whole timeline span (`scheduler.ts`'s `placeInSource`), so a stem
432
+ * built for it would track the picture for the first pass only and go silent,
433
+ * or keep replaying the first pass, under every wrap after that. See the
434
+ * LOOPING CLIPS note at the top of this file.
435
+ *
436
+ * Pass the FINAL, post-cut item — a clip that a cut split into fragments needs
437
+ * one stem track per fragment, because the fragments are contiguous on the
438
+ * timeline but discontiguous in the source and one track cannot follow both.
439
+ */
440
+ export function voiceTrackFor(
441
+ item: VisualItem,
442
+ vocalsPath: string,
443
+ id: string = stemIdFor(item.id, 0),
444
+ ): AudioTrack | null {
445
+ if ((item.speed ?? 1) !== 1) return null
446
+ if (item.loop) return null
447
+ const inPoint = item.inPoint ?? 0
448
+ return {
449
+ id,
450
+ type: 'voiceover',
451
+ src: vocalsPath,
452
+ start: item.start,
453
+ end: item.end,
454
+ inPoint,
455
+ outPoint: inPoint + (item.end - item.start),
456
+ label: `Voice: ${item.src ? basename(item.src) : item.id}`,
457
+ }
458
+ }
459
+
460
+ // ── Piece eligibility ───────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * The audio-polish pieces `pieceSupported` can be asked about. Matches
464
+ * `AnalyzeAudioPolishArgs['piece']` (`types.ts`) exactly, including
465
+ * `'silence-check'` — the whole-source `waveform_trim` dry run behind the
466
+ * silence guard in `flagRemovals`, which is a distinct scheduled job from the
467
+ * `'silence'` removal piece itself even though the two rise and fall together
468
+ * (see the `'silence-check'` case below).
469
+ */
470
+ export type PolishPiece = 'silence' | 'fillers' | 'loudness' | 'voice' | 'silence-check'
471
+
472
+ /** `pieceSupported`'s answer for one (clip, piece) pair. `reason` is present
473
+ * exactly when `available` is false, and is the badge string verbatim — the
474
+ * UI renders it, it does not reconstruct it from a boolean. */
475
+ export interface PieceSupport {
476
+ available: boolean
477
+ reason?: string
478
+ }
479
+
480
+ /**
481
+ * Whether `piece` may run on `clip` at all, and — when it may not — the
482
+ * user-facing reason why, so the modal has one thing to ask instead of
483
+ * hardcoding the visible half of this module's rules itself (as it does
484
+ * today for the speed badge). Every site that needs to know — the
485
+ * voice-eligibility probe, the plan builder's per-piece gates, analysis
486
+ * scheduling, the review-list badge, the per-piece error mapping — asks this,
487
+ * never `clip.loop` or `clip.speed` directly. `buildDraft` enforces the same
488
+ * answer on its own regardless of what a caller asks first.
489
+ *
490
+ * `'voice'` defers its AVAILABILITY to `voiceTrackFor` rather than re-testing
491
+ * `loop` or `speed` here, so each restriction keeps exactly one home — the
492
+ * same pattern the modal already uses for its own `voiceEligible`.
493
+ * `voiceTrackFor` returning `null` says only THAT voice is blocked, not WHY,
494
+ * so the reason is chosen here.
495
+ *
496
+ * PRECEDENCE, when a clip is both looping and sped up: voice reports the LOOP
497
+ * reason, not the speed one. This is decided on the merits, not to match any
498
+ * one caller's wording. `loop` excludes THREE pieces on this clip (silence,
499
+ * fillers, voice); `speed` excludes only voice. Reporting "sped up" would give
500
+ * the operator two different explanations for what reads as one broken clip —
501
+ * and the speed one is individually misleading, since un-sped-up-ing the clip
502
+ * would still leave voice blocked by `loop`. Reporting "loops" names the one
503
+ * thing that actually has to change before ANY of the three pieces come back,
504
+ * and — a weaker, secondary point — `loop` is the more consequential failure
505
+ * of the two: ignored, it cuts the wrong audio outright, where `speed` only
506
+ * desyncs a stem.
507
+ */
508
+ export function pieceSupported(clip: VisualItem, piece: PolishPiece): PieceSupport {
509
+ switch (piece) {
510
+ // `'silence-check'` answers exactly as `'silence'` does: it is a pure
511
+ // dry run that only ever FLAGS removals, never makes one, and with
512
+ // `'silence'` itself excluded on a looping clip there is nothing left to
513
+ // flag — running it would spend a real sidecar job on a check whose
514
+ // result the operator will never see used.
515
+ case 'silence':
516
+ case 'silence-check':
517
+ return clip.loop ? { available: false, reason: SILENCE_LOOP_REASON } : { available: true }
518
+ case 'fillers':
519
+ return clip.loop ? { available: false, reason: FILLERS_LOOP_REASON } : { available: true }
520
+ case 'loudness':
521
+ // No source window, no coordinate mapping — correct on every clip,
522
+ // looping or not. Deliberately exempt: see the LOOPING CLIPS note at
523
+ // the top of this file.
524
+ return { available: true }
525
+ case 'voice': {
526
+ if (voiceTrackFor(clip, '') !== null) return { available: true }
527
+ return { available: false, reason: clip.loop ? VOICE_LOOP_REASON : VOICE_SPEED_REASON }
528
+ }
529
+ }
530
+ }
531
+
532
+ // ── The apply ───────────────────────────────────────────────────────────────
533
+
534
+ /** Merge overlapping and touching ranges. Descending application is exact for
535
+ * DISJOINT ranges only: two overlapping cuts still leave the clips right (they
536
+ * lift) but ripple the captions by the sum of their durations instead of by the
537
+ * duration of their union. Silence and filler removals are disjoint by
538
+ * construction, so this is a no-op in the normal case and a cheap guarantee in
539
+ * every other one. */
540
+ function mergeRanges(ranges: readonly Cut[]): Cut[] {
541
+ const out: Cut[] = []
542
+ for (const r of [...ranges].sort((a, b) => a.start - b.start)) {
543
+ const last = out[out.length - 1]
544
+ if (last && r.start <= last.end + EPSILON) last.end = Math.max(last.end, r.end)
545
+ else out.push({ start: r.start, end: r.end })
546
+ }
547
+ return out
548
+ }
549
+
550
+ /**
551
+ * The planned clip a surviving item descends from.
552
+ *
553
+ * A cut splits a clip into fragments whose ids `cuts.ts` mints as
554
+ * `` `${originalId}_split_…` `` (nested splits stack the suffix), so an item
555
+ * belongs to the planned clip that is either its exact id or a `_split_` prefix
556
+ * of it. The LONGEST such match wins, so a project that was already cut once —
557
+ * where both `a` and `a_split_x` can be planned clips in their own right —
558
+ * resolves each fragment to its nearest ancestor rather than to `a`.
559
+ */
560
+ function ancestorOf(itemId: string, plannedIds: readonly string[]): string | undefined {
561
+ let best: string | undefined
562
+ for (const id of plannedIds) {
563
+ if (itemId !== id && !itemId.startsWith(`${id}_split_`)) continue
564
+ if (best === undefined || id.length > best.length) best = id
565
+ }
566
+ return best
567
+ }
568
+
569
+ /**
570
+ * Close the leading gap the polish itself opened, leaving one the operator
571
+ * placed exactly where it was.
572
+ *
573
+ * WHY THIS EXISTS. `collapseGaps` compacts from `sorted[0].start`, so it never
574
+ * touches the head of the timeline. Remove silence from the start of the first
575
+ * clip and that clip's `start` advances with nothing to pull it back. The
576
+ * operator gets black frames at the head — and, far worse, the captions have
577
+ * ALREADY been rippled left past that cut by `applyCutToCaptions`, so every
578
+ * caption ends up adrift from the footage it describes by exactly the width of
579
+ * the gap.
580
+ *
581
+ * WHICH IS WHY CAPTIONS DO NOT MOVE HERE. This is not "slide the whole timeline
582
+ * left". It is the CLIPS catching up to where the captions already are: the
583
+ * cuts rippled the captions, the clips lifted and stayed put, and this closes
584
+ * the difference. Shifting the captions again would double-count it — the same
585
+ * bug the `remapCaptions` flag exists to prevent (see the invariant comment
586
+ * above `collapseGaps` in cuts.ts), one level up.
587
+ *
588
+ * WHY THE DELTA IS MEASURED IN CUT MATERIAL rather than as
589
+ * `leadAfter - leadBefore`. The two agree whenever the first clip survives its
590
+ * cut, which is the ordinary case. They diverge when the plan empties the first
591
+ * clip entirely AND a gap followed it: the positional difference then also
592
+ * swallows that gap, while `applyCutToCaptions` only ever rippled by the CUT
593
+ * duration, so the clips would overshoot the captions by the gap's width and
594
+ * land every caption on the wrong clip. Counting the cut material that fell in
595
+ * the head region is the exact statement of the rule — remove the offset the
596
+ * polish introduced and nothing else. A project deliberately starting at 3s
597
+ * still starts at 3s, and a gap the operator placed survives as a gap.
598
+ *
599
+ * WHY AUDIO TRACKS MOVE HERE THOUGH `collapseGaps` LEAVES THEM ALONE. Different
600
+ * operations, different correct answers. `collapseGaps` closes an INTERIOR gap,
601
+ * where a music bed should hold its own timing while a take is trimmed under
602
+ * it. This re-bases the timeline's ORIGIN, where a bed that did not move would
603
+ * desync against every frame after it.
604
+ * TRADE, accepted: a bed timed against the original start moves too. Right
605
+ * when the head was a false start, wrong when the bed was deliberately
606
+ * offset. The former is the common case.
607
+ * A track that would be pushed before time 0 is head-TRIMMED instead (`start`
608
+ * clamped to 0, `inPoint` advanced by the overshoot), which keeps it in perfect
609
+ * sync with the picture and simply drops the part that sat under the removed
610
+ * head. Negative start times are not representable downstream — `mix-audio.js`
611
+ * delays with `adelay` off `start ?? 0`. A track that ends entirely inside the
612
+ * removed head has nothing left to trim to, so it is left where it is rather
613
+ * than deleted: losing an audio track is not this operation's call to make.
614
+ *
615
+ * Overlay items STARTING before the new head are likewise left where they are,
616
+ * which is how `collapseGaps` already treats an overlay over material a cut
617
+ * removed. Everything at or after the new head travels with the clips.
618
+ */
619
+ function closeLeadGap<P extends Project>(draft: P, baseline: P, cuts: readonly Cut[]): P {
620
+ const baseClips = trackItems(baseline)[0] ?? []
621
+ const survivors = trackItems(draft)[0] ?? []
622
+ // `Math.min()` of an empty list is Infinity, which would shift the whole
623
+ // project to -Infinity. Every clip emptied, or none to begin with, is a no-op.
624
+ if (baseClips.length === 0 || survivors.length === 0) return draft
625
+
626
+ const leadBefore = Math.min(...baseClips.map(c => c.start))
627
+ const leadAfter = Math.min(...survivors.map(c => c.start))
628
+
629
+ let delta = 0
630
+ for (const cut of cuts) {
631
+ if (cut.start >= leadAfter) continue
632
+ delta += Math.min(cut.end, leadAfter) - Math.max(cut.start, leadBefore)
633
+ }
634
+ if (delta <= EPSILON) return draft
635
+
636
+ const tracks = mapTrackItems(draft, (items, i) =>
637
+ items.map(item =>
638
+ i !== 0 && item.start < leadAfter - EPSILON
639
+ ? item
640
+ : { ...item, start: item.start - delta, end: item.end - delta },
641
+ ),
642
+ )
643
+
644
+ const audio = draft.audio
645
+ ? { ...draft.audio, tracks: draft.audio.tracks.map(t => shiftAudioTrack(t, delta)) }
646
+ : draft.audio
647
+
648
+ return { ...draft, tracks, audio }
649
+ }
650
+
651
+ /** One audio track moved `delta` earlier, head-trimmed instead of going
652
+ * negative. See `closeLeadGap`. */
653
+ function shiftAudioTrack(track: AudioTrack, delta: number): AudioTrack {
654
+ const start = track.start - delta
655
+ const end = track.end - delta
656
+ if (start >= 0) return { ...track, start, end }
657
+ if (end <= EPSILON) return track // entirely inside the removed head
658
+ return { ...track, start: 0, end, inPoint: (track.inPoint ?? 0) - start }
659
+ }
660
+
661
+ /**
662
+ * Apply a whole plan to a baseline project, returning a NEW project. The
663
+ * baseline is never mutated, so the modal can rebuild the draft from the same
664
+ * snapshot on every toggle.
665
+ *
666
+ * The order is the one documented at the top of this file and it is
667
+ * load-bearing. Two details worth knowing at the call site:
668
+ *
669
+ * - Removals arrive in each clip's SOURCE time and are mapped, clipped, then
670
+ * pooled across EVERY targeted clip into one global list which is applied
671
+ * back to front. Pooling globally is required: per-clip batching lets an
672
+ * earlier clip's cuts ripple the captions out from under a later clip's
673
+ * still-unmapped ranges. Descending order is required because
674
+ * `applyCutToCaptions` ripples later captions left, so a front-to-back pass
675
+ * would invalidate every not-yet-applied range.
676
+ * - The gap close runs only when at least one cut was made, so switching on
677
+ * the loudness or voice piece alone cannot silently close gaps the operator
678
+ * placed by hand. It is `collapseGaps`, so it closes pre-existing gaps
679
+ * between clips too, and it preserves the timeline's leading offset.
680
+ */
681
+ export function buildDraft<P extends Project>(baseline: P, plan: PolishPlan): P {
682
+ const plannedIds = Object.keys(plan.clips)
683
+ if (plannedIds.length === 0) return baseline
684
+
685
+ const byId = new Map((trackItems(baseline)[0] ?? []).map(item => [item.id, item]))
686
+
687
+ // 1 — speech cuts, pooled across every clip and applied back to front.
688
+ // A looping clip's removals are dropped here regardless of what the caller
689
+ // asked for: `sourceToTimeline` would map each one onto exactly one of its
690
+ // several timeline repetitions and call that correct. See the LOOPING CLIPS
691
+ // note at the top of this file and `pieceSupported`.
692
+ const pooled: Cut[] = []
693
+ for (const id of plannedIds) {
694
+ const item = byId.get(id)
695
+ const removals = plan.clips[id].removals
696
+ if (!item || !removals?.length || item.loop) continue
697
+ for (const r of mapRemovals(item, removals)) pooled.push({ start: r.start, end: r.end })
698
+ }
699
+ const cuts = mergeRanges(pooled).sort((a, b) => b.start - a.start)
700
+
701
+ let draft = baseline
702
+ for (const cut of cuts) draft = applyCutToTracks(draft, cut)
703
+
704
+ // 2 — one gap close, then the head. `collapseGaps` compacts from the first
705
+ // clip's start and so cannot close a LEADING gap; `closeLeadGap` finishes the
706
+ // job. Both run before loudness and voice, so clip geometry is final before
707
+ // stem tracks are built against it. The cuts have already put the captions
708
+ // where they belong, and neither of these moves them.
709
+ if (cuts.length > 0) {
710
+ draft = collapseGaps(draft, { remapCaptions: false })
711
+ draft = closeLeadGap(draft, baseline, cuts)
712
+ }
713
+
714
+ const needsItemPass = plannedIds.some(id => {
715
+ const cp = plan.clips[id]
716
+ return byId.has(id) && (cp.gainDb !== undefined || cp.vocalsPath !== undefined)
717
+ })
718
+ if (!needsItemPass) return draft
719
+
720
+ // 3 and 4 — loudness, then voice, both against the now-final clip geometry.
721
+ const trackVolume = ((normalizeTracks(draft).tracks ?? []) as VisualTrack[])[0]?.volume
722
+ const survivors = trackItems(draft)[0] ?? []
723
+
724
+ // Each surviving fragment's ordinal within its planned clip, counted in
725
+ // TIMELINE order so a stem id depends only on (baseline, plan) and never on
726
+ // array order or on the fragment's own generated id.
727
+ const ordinal = new Map<string, number>()
728
+ const counted = new Map<string, number>()
729
+ for (const item of [...survivors].sort((a, b) => a.start - b.start)) {
730
+ const baseId = ancestorOf(item.id, plannedIds)
731
+ if (baseId === undefined) continue
732
+ const n = counted.get(baseId) ?? 0
733
+ ordinal.set(item.id, n)
734
+ counted.set(baseId, n + 1)
735
+ }
736
+
737
+ const voiceTracks: AudioTrack[] = []
738
+ const newItems = survivors.map(item => {
739
+ const baseId = ancestorOf(item.id, plannedIds)
740
+ if (baseId === undefined) return item
741
+ const { gainDb, vocalsPath } = plan.clips[baseId]
742
+
743
+ let next = item
744
+ if (gainDb !== undefined) next = { ...next, volume: itemVolumeFor(gainDb, trackVolume) }
745
+ if (vocalsPath !== undefined) {
746
+ // Mint and mute together or do neither: a fragment muted without a stem
747
+ // track under it is a silent clip, which is the one outcome worse than
748
+ // not isolating at all.
749
+ const stem = voiceTrackFor(next, vocalsPath, stemIdFor(baseId, ordinal.get(item.id) ?? 0))
750
+ if (stem) {
751
+ voiceTracks.push(stem)
752
+ next = { ...next, muted: true }
753
+ }
754
+ }
755
+ return next
756
+ })
757
+
758
+ const withItems: P = {
759
+ ...draft,
760
+ tracks: mapTrackItems(draft, (items, i) => (i === 0 ? newItems : items)),
761
+ }
762
+
763
+ // This run OWNS the stems of every clip it was asked to isolate: drop that
764
+ // clip's existing ones wholesale, then add the freshly minted set. Replacing
765
+ // by id alone would strand a `__2` behind whenever a re-run's cuts leave the
766
+ // clip in fewer fragments than the previous run did. Every audio track this
767
+ // run does not own is left exactly as it was.
768
+ const existing = draft.audio?.tracks ?? []
769
+ const owners = plannedIds.filter(id => plan.clips[id].vocalsPath !== undefined)
770
+ const kept = existing.filter(t => !owners.some(baseId => ownsStem(t.id, baseId)))
771
+ if (voiceTracks.length === 0 && kept.length === existing.length) return withItems
772
+
773
+ return { ...withItems, audio: { ...draft.audio, tracks: [...kept, ...voiceTracks] } }
774
+ }