@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,1202 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ targetClips,
4
+ sourceToTimeline,
5
+ timelineToSource,
6
+ mapRemovals,
7
+ captionWordsInSourceTime,
8
+ flagRemovals,
9
+ loudnessGainDb,
10
+ itemVolumeFor,
11
+ volumeCeilingClamps,
12
+ voiceTrackFor,
13
+ pieceSupported,
14
+ buildDraft,
15
+ } from '../audioPolish'
16
+ import type { PolishPlan, PolishPiece } from '../audioPolish'
17
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
18
+
19
+ /** Object-shape visual tracks carrying the ids `normalizeTracks` hands out —
20
+ * same fixture helper `cuts.test.ts` uses, so expectations read the same. */
21
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
22
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
23
+ }
24
+
25
+ function makeProject(over: Partial<Project> = {}): Project {
26
+ return {
27
+ id: 'p1',
28
+ status: 'draft',
29
+ settings: { resolution: [1080, 1920] },
30
+ tracks: vtracks([]),
31
+ ...over,
32
+ }
33
+ }
34
+
35
+ /** A whole-source clip: timeline == source, speed 1. */
36
+ function clip(over: Partial<VisualItem> = {}): VisualItem {
37
+ return { id: 'a', type: 'video', src: '/f/a.mov', start: 0, end: 20, inPoint: 0, outPoint: 20, ...over }
38
+ }
39
+
40
+ // ── Scope ────────────────────────────────────────────────────────────────────
41
+
42
+ describe('targetClips', () => {
43
+ const p = makeProject({
44
+ tracks: vtracks(
45
+ [
46
+ clip({ id: 'a', start: 0, end: 5 }),
47
+ { id: 'img', type: 'image', start: 5, end: 7 },
48
+ clip({ id: 'b', start: 7, end: 12 }),
49
+ ],
50
+ [{ id: 'o', type: 'overlay', start: 1, end: 2 }],
51
+ ),
52
+ })
53
+
54
+ it('returns every video clip on tracks[0] when the selection is empty', () => {
55
+ expect(targetClips(p, []).map(c => c.id)).toEqual(['a', 'b'])
56
+ })
57
+
58
+ it('defaults to the whole track when no selection argument is given at all', () => {
59
+ expect(targetClips(p).map(c => c.id)).toEqual(['a', 'b'])
60
+ })
61
+
62
+ it('narrows to the selection when one exists', () => {
63
+ expect(targetClips(p, ['b']).map(c => c.id)).toEqual(['b'])
64
+ })
65
+
66
+ it('ignores selected ids that are not video clips on tracks[0]', () => {
67
+ expect(targetClips(p, ['img', 'o', 'nope']).map(c => c.id)).toEqual([])
68
+ })
69
+
70
+ it('returns nothing for a project with no tracks', () => {
71
+ expect(targetClips(makeProject({ tracks: [] }))).toEqual([])
72
+ })
73
+ })
74
+
75
+ // ── Coordinate mapping ───────────────────────────────────────────────────────
76
+
77
+ describe('sourceToTimeline / timelineToSource', () => {
78
+ it('maps through start and inPoint at speed 1', () => {
79
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
80
+ expect(sourceToTimeline(c, 100)).toBe(10)
81
+ expect(sourceToTimeline(c, 105)).toBe(15)
82
+ expect(timelineToSource(c, 15)).toBe(105)
83
+ })
84
+
85
+ it('carries the clip speed in both directions', () => {
86
+ // 10s of source (100→110) plays in 5 timeline seconds at S=2.
87
+ const c = clip({ start: 10, end: 15, inPoint: 100, outPoint: 110, speed: 2 })
88
+ expect(sourceToTimeline(c, 110)).toBe(15)
89
+ expect(sourceToTimeline(c, 104)).toBe(12)
90
+ expect(timelineToSource(c, 12)).toBe(104)
91
+ })
92
+
93
+ it('treats an absent inPoint and an absent speed as 0 and 1', () => {
94
+ const c: VisualItem = { id: 'a', type: 'video', src: '/f/a.mov', start: 4, end: 9 }
95
+ expect(sourceToTimeline(c, 3)).toBe(7)
96
+ expect(timelineToSource(c, 7)).toBe(3)
97
+ })
98
+
99
+ it('round-trips at a fractional speed', () => {
100
+ const c = clip({ start: 3, end: 23, inPoint: 7, outPoint: 17, speed: 0.5 })
101
+ for (const t of [7, 9.25, 17]) expect(timelineToSource(c, sourceToTimeline(c, t))).toBeCloseTo(t, 9)
102
+ })
103
+ })
104
+
105
+ describe('mapRemovals', () => {
106
+ it('maps source removals onto the timeline and keeps their text', () => {
107
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
108
+ expect(mapRemovals(c, [{ start: 102, end: 103, text: 'um' }])).toEqual([
109
+ { start: 12, end: 13, text: 'um' },
110
+ ])
111
+ })
112
+
113
+ it('carries speed into the mapping', () => {
114
+ const c = clip({ start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 })
115
+ expect(mapRemovals(c, [{ start: 102, end: 106 }])).toEqual([{ start: 1, end: 3 }])
116
+ })
117
+
118
+ it('clips a removal straddling the clip start to the clip span', () => {
119
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
120
+ expect(mapRemovals(c, [{ start: 95, end: 102 }])).toEqual([{ start: 10, end: 12 }])
121
+ })
122
+
123
+ it('clips a removal straddling the clip end to the clip span', () => {
124
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
125
+ expect(mapRemovals(c, [{ start: 108, end: 130 }])).toEqual([{ start: 18, end: 20 }])
126
+ })
127
+
128
+ it('drops removals that fall entirely outside the clip', () => {
129
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
130
+ expect(mapRemovals(c, [{ start: 80, end: 90 }, { start: 120, end: 130 }])).toEqual([])
131
+ })
132
+
133
+ it('drops a removal that clips down to nothing at an edge', () => {
134
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
135
+ expect(mapRemovals(c, [{ start: 90, end: 100 }, { start: 110, end: 120 }])).toEqual([])
136
+ })
137
+
138
+ it('works on a clip with no inPoint', () => {
139
+ const c: VisualItem = { id: 'a', type: 'video', src: '/f/a.mov', start: 4, end: 9 }
140
+ expect(mapRemovals(c, [{ start: 1, end: 2 }])).toEqual([{ start: 5, end: 6 }])
141
+ })
142
+ })
143
+
144
+ // ── Guard inputs ─────────────────────────────────────────────────────────────
145
+
146
+ describe('captionWordsInSourceTime', () => {
147
+ const c = clip({ start: 10, end: 20, inPoint: 100, outPoint: 110 })
148
+
149
+ it('inverse-maps the words overlapping the clip into its source time', () => {
150
+ const p = makeProject({
151
+ tracks: vtracks([c]),
152
+ captions: {
153
+ style: 'subtitle',
154
+ segments: [
155
+ { text: 'hello there', start: 11, end: 13, words: [
156
+ { word: 'hello', start: 11, end: 12 },
157
+ { word: 'there', start: 12, end: 13 },
158
+ ] },
159
+ ],
160
+ },
161
+ })
162
+ expect(captionWordsInSourceTime(p, c)).toEqual([
163
+ { word: 'hello', start: 101, end: 102, precision: 'word' },
164
+ { word: 'there', start: 102, end: 103, precision: 'word' },
165
+ ])
166
+ })
167
+
168
+ it('carries speed into the inverse mapping', () => {
169
+ const fast = clip({ start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 })
170
+ const p = makeProject({
171
+ tracks: vtracks([fast]),
172
+ captions: {
173
+ style: 'subtitle',
174
+ segments: [{ text: 'x', start: 1, end: 3, words: [{ word: 'x', start: 1, end: 3 }] }],
175
+ },
176
+ })
177
+ expect(captionWordsInSourceTime(p, fast)).toEqual([
178
+ { word: 'x', start: 102, end: 106, precision: 'word' },
179
+ ])
180
+ })
181
+
182
+ it('excludes words that do not overlap the clip span', () => {
183
+ const p = makeProject({
184
+ tracks: vtracks([c]),
185
+ captions: {
186
+ style: 'subtitle',
187
+ segments: [
188
+ { text: 'before', start: 1, end: 2, words: [{ word: 'before', start: 1, end: 2 }] },
189
+ { text: 'after', start: 30, end: 31, words: [{ word: 'after', start: 30, end: 31 }] },
190
+ ],
191
+ },
192
+ })
193
+ expect(captionWordsInSourceTime(p, c)).toEqual([])
194
+ })
195
+
196
+ it('returns nothing when the project has no captions', () => {
197
+ expect(captionWordsInSourceTime(makeProject({ tracks: vtracks([c]) }), c)).toEqual([])
198
+ })
199
+
200
+ it('falls back to the segment span for a segment carrying no word timings', () => {
201
+ const p = makeProject({
202
+ tracks: vtracks([c]),
203
+ captions: { style: 'subtitle', segments: [{ text: 'no words here', start: 12, end: 14 }] },
204
+ })
205
+ expect(captionWordsInSourceTime(p, c)).toEqual([
206
+ { word: 'no words here', start: 102, end: 104, precision: 'segment' },
207
+ ])
208
+ })
209
+
210
+ it('marks each entry with the precision it was derived at, segment by segment', () => {
211
+ const p = makeProject({
212
+ tracks: vtracks([c]),
213
+ captions: {
214
+ style: 'subtitle',
215
+ segments: [
216
+ { text: 'timed', start: 11, end: 12, words: [{ word: 'timed', start: 11, end: 12 }] },
217
+ { text: 'untimed', start: 13, end: 14 },
218
+ // An empty `words` array is as useless as an absent one.
219
+ { text: 'empty', start: 15, end: 16, words: [] },
220
+ ],
221
+ },
222
+ })
223
+ expect(captionWordsInSourceTime(p, c).map(w => [w.word, w.precision])).toEqual([
224
+ ['timed', 'word'],
225
+ ['untimed', 'segment'],
226
+ ['empty', 'segment'],
227
+ ])
228
+ })
229
+ })
230
+
231
+ // ── The two guards ───────────────────────────────────────────────────────────
232
+
233
+ describe('flagRemovals', () => {
234
+ /** A word-level caption word in source time. */
235
+ const w = (word: string, start: number, end: number) =>
236
+ ({ word, start, end, precision: 'word' }) as const
237
+ /** The whole-segment fallback, for a segment with no word timings. */
238
+ const seg = (word: string, start: number, end: number) =>
239
+ ({ word, start, end, precision: 'segment' }) as const
240
+
241
+ it('flags a removal overlapping a caption word', () => {
242
+ const out = flagRemovals([{ start: 100, end: 101 }], { captionWords: [w('hi', 100.5, 101.5)] })
243
+ expect(out[0].flagged).toBe(true)
244
+ expect(out[0].reason).toBe('Overlaps a caption word.')
245
+ })
246
+
247
+ it('says the check was less precise when the hit is a whole-segment fallback', () => {
248
+ const out = flagRemovals([{ start: 100, end: 101 }], { captionWords: [seg('hi', 100.5, 104)] })
249
+ expect(out[0].flagged).toBe(true)
250
+ expect(out[0].reason).toBe(
251
+ 'Overlaps a caption segment. This clip has no word timings, so the check is less precise.',
252
+ )
253
+ })
254
+
255
+ it('reports the tighter word-level reason when a removal hits both kinds', () => {
256
+ const out = flagRemovals([{ start: 100, end: 101 }], {
257
+ captionWords: [seg('loose', 99, 104), w('tight', 100.4, 100.6)],
258
+ })
259
+ expect(out[0].reason).toBe('Overlaps a caption word.')
260
+ })
261
+
262
+ it('does not flag a removal that only touches a word edge', () => {
263
+ const out = flagRemovals([{ start: 100, end: 101 }], { captionWords: [w('hi', 101, 102)] })
264
+ expect(out[0]).toMatchObject({ flagged: false })
265
+ expect(out[0].reason).toBeUndefined()
266
+ })
267
+
268
+ it('does not flag when no word is anywhere near', () => {
269
+ const out = flagRemovals([{ start: 100, end: 101 }], { captionWords: [w('hi', 200, 201)] })
270
+ expect(out[0].flagged).toBe(false)
271
+ })
272
+
273
+ it('flags a silence removal that overlaps a waveform_trim keep range', () => {
274
+ const out = flagRemovals([{ start: 10, end: 12 }], { silenceKeeps: [[11, 20]] })
275
+ expect(out[0].flagged).toBe(true)
276
+ expect(out[0].reason).toMatch(/silence/i)
277
+ })
278
+
279
+ it('tolerates a hairline overlap with a keep range', () => {
280
+ const out = flagRemovals([{ start: 10, end: 12 }], { silenceKeeps: [[11.98, 20]] })
281
+ expect(out[0].flagged).toBe(false)
282
+ })
283
+
284
+ it('does not run the silence cross-check when no keeps are supplied (the filler piece)', () => {
285
+ const out = flagRemovals([{ start: 10, end: 12 }], {})
286
+ expect(out[0].flagged).toBe(false)
287
+ })
288
+
289
+ it('reports the caption reason when both guards fire', () => {
290
+ const out = flagRemovals([{ start: 10, end: 12 }], {
291
+ captionWords: [w('hi', 10.5, 11)],
292
+ silenceKeeps: [[11, 20]],
293
+ })
294
+ expect(out[0].flagged).toBe(true)
295
+ expect(out[0].reason).toMatch(/caption/i)
296
+ })
297
+
298
+ it('passes through every input field, including the filler word text', () => {
299
+ const out = flagRemovals([{ start: 10, end: 12, text: 'um' }], {})
300
+ expect(out[0]).toEqual({ start: 10, end: 12, text: 'um', flagged: false })
301
+ })
302
+
303
+ it('renders no em dash in any reason (UI copy rule)', () => {
304
+ const reasons = [
305
+ ...flagRemovals([{ start: 10, end: 12 }], { captionWords: [w('x', 10, 12)] }),
306
+ ...flagRemovals([{ start: 10, end: 12 }], { captionWords: [seg('x', 10, 12)] }),
307
+ ...flagRemovals([{ start: 10, end: 12 }], { silenceKeeps: [[10, 12]] }),
308
+ ].map(r => r.reason ?? '')
309
+ expect(reasons).toHaveLength(3)
310
+ expect(reasons.every(r => r.length > 0)).toBe(true)
311
+ for (const r of reasons) expect(r).not.toMatch(/[—–]/)
312
+ })
313
+ })
314
+
315
+ // ── Loudness ─────────────────────────────────────────────────────────────────
316
+
317
+ describe('loudnessGainDb', () => {
318
+ it('returns the plain level change when true peak has headroom', () => {
319
+ // −14 target from −20 measured wants +6 dB; TP at −20 allows +18.5.
320
+ expect(loudnessGainDb({ measuredI: -20, measuredTP: -20 }, -14)).toBeCloseTo(6, 9)
321
+ })
322
+
323
+ it('is held back by the true-peak ceiling when the level change would distort', () => {
324
+ // Wants +6 dB, but TP at −3 leaves only 1.5 dB before −1.5 dBTP.
325
+ expect(loudnessGainDb({ measuredI: -20, measuredTP: -3 }, -14)).toBeCloseTo(1.5, 9)
326
+ })
327
+
328
+ it('returns a negative gain when the clip is already too loud', () => {
329
+ expect(loudnessGainDb({ measuredI: -8, measuredTP: -6 }, -14)).toBeCloseTo(-6, 9)
330
+ })
331
+ })
332
+
333
+ describe('itemVolumeFor', () => {
334
+ it('converts dB to a linear multiplier', () => {
335
+ expect(itemVolumeFor(0)).toBeCloseTo(1, 9)
336
+ expect(itemVolumeFor(6.0206)).toBeCloseTo(2, 4)
337
+ expect(itemVolumeFor(-6.0206)).toBeCloseTo(0.5, 4)
338
+ })
339
+
340
+ it('divides the track gain back out so the folded result is the requested one', () => {
341
+ // The engine multiplies item.volume by the track volume, so a 0.5 track
342
+ // needs double the item volume to land on the same absolute level.
343
+ expect(itemVolumeFor(0, 0.5)).toBeCloseTo(2, 9)
344
+ expect(itemVolumeFor(-6.0206, 0.5)).toBeCloseTo(1, 4)
345
+ })
346
+
347
+ it('treats an absent track volume as unity', () => {
348
+ expect(itemVolumeFor(0, undefined)).toBe(itemVolumeFor(0, 1))
349
+ })
350
+
351
+ it('clamps at the schema ceiling of 2.0', () => {
352
+ expect(itemVolumeFor(20)).toBe(2)
353
+ expect(itemVolumeFor(0, 0.1)).toBe(2)
354
+ })
355
+
356
+ it('never returns a negative volume', () => {
357
+ expect(itemVolumeFor(-200)).toBeGreaterThanOrEqual(0)
358
+ })
359
+ })
360
+
361
+ describe('volumeCeilingClamps', () => {
362
+ it('is true exactly when the requested gain exceeds what 2.0 can deliver', () => {
363
+ expect(volumeCeilingClamps(20)).toBe(true)
364
+ expect(volumeCeilingClamps(0)).toBe(false)
365
+ expect(volumeCeilingClamps(0, 0.1)).toBe(true) // folding a quiet track pushes past 2.0
366
+ expect(volumeCeilingClamps(0, 2)).toBe(false)
367
+ })
368
+ })
369
+
370
+ // ── Voice isolation ──────────────────────────────────────────────────────────
371
+
372
+ describe('voiceTrackFor', () => {
373
+ it('builds a stem track spanning the clip with a stable id', () => {
374
+ const c = clip({ id: 'a', start: 10, end: 20, inPoint: 100, outPoint: 110 })
375
+ expect(voiceTrackFor(c, '/f/a_stems/vocals.wav')).toEqual({
376
+ id: 'polish_voice_a',
377
+ type: 'voiceover',
378
+ src: '/f/a_stems/vocals.wav',
379
+ start: 10,
380
+ end: 20,
381
+ inPoint: 100,
382
+ outPoint: 110,
383
+ label: 'Voice: a.mov',
384
+ })
385
+ })
386
+
387
+ it('renders no em dash in the track label (UI copy rule)', () => {
388
+ // `label` is drawn in the timeline gutter (AudioTrackRow, canvas/draw), so
389
+ // it is a user-facing string like any other.
390
+ expect(voiceTrackFor(clip(), '/f/v.wav')!.label).not.toMatch(/[—–]/)
391
+ })
392
+
393
+ it('takes an explicit id, so a caller can key a fragment deterministically', () => {
394
+ expect(voiceTrackFor(clip(), '/f/v.wav', 'polish_voice_a__2')!.id).toBe('polish_voice_a__2')
395
+ })
396
+
397
+ it('treats an absent inPoint as 0, matching what the clip itself plays', () => {
398
+ const c: VisualItem = { id: 'z', type: 'video', src: '/f/z.mp4', start: 4, end: 9 }
399
+ expect(voiceTrackFor(c, '/f/v.wav')).toMatchObject({ inPoint: 0, outPoint: 5 })
400
+ })
401
+
402
+ it('falls back to the clip id when the clip has no src to name', () => {
403
+ const c: VisualItem = { id: 'z', type: 'video', start: 0, end: 1 }
404
+ expect(voiceTrackFor(c, '/f/v.wav')!.label).toBe('Voice: z')
405
+ })
406
+
407
+ it('refuses a sped-up clip — audio tracks have no tempo', () => {
408
+ expect(voiceTrackFor(clip({ speed: 2 }), '/f/v.wav')).toBeNull()
409
+ expect(voiceTrackFor(clip({ speed: 0.5 }), '/f/v.wav')).toBeNull()
410
+ })
411
+
412
+ it('accepts an explicit speed of 1', () => {
413
+ expect(voiceTrackFor(clip({ speed: 1 }), '/f/v.wav')).not.toBeNull()
414
+ })
415
+
416
+ it('refuses a looping clip — a stem plays its window once, the clip does not', () => {
417
+ expect(voiceTrackFor(clip({ loop: true }), '/f/v.wav')).toBeNull()
418
+ })
419
+
420
+ it('refuses a clip that is both looping and sped up', () => {
421
+ expect(voiceTrackFor(clip({ loop: true, speed: 2 }), '/f/v.wav')).toBeNull()
422
+ })
423
+
424
+ it('accepts an explicit loop of false', () => {
425
+ expect(voiceTrackFor(clip({ loop: false }), '/f/v.wav')).not.toBeNull()
426
+ })
427
+ })
428
+
429
+ // ── Piece eligibility ────────────────────────────────────────────────────────
430
+
431
+ describe('pieceSupported', () => {
432
+ const PIECES = ['silence', 'silence-check', 'fillers', 'loudness', 'voice'] as const
433
+
434
+ it('supports every piece on a plain clip, with no reason attached', () => {
435
+ const c = clip()
436
+ for (const piece of PIECES) expect(pieceSupported(c, piece)).toEqual({ available: true })
437
+ })
438
+
439
+ it('excludes silence, silence-check, fillers and voice on a looping clip, but not loudness', () => {
440
+ const c = clip({ loop: true })
441
+ expect(pieceSupported(c, 'silence').available).toBe(false)
442
+ expect(pieceSupported(c, 'silence-check').available).toBe(false)
443
+ expect(pieceSupported(c, 'fillers').available).toBe(false)
444
+ expect(pieceSupported(c, 'voice').available).toBe(false)
445
+ expect(pieceSupported(c, 'loudness')).toEqual({ available: true })
446
+ })
447
+
448
+ it('excludes only voice on a sped-up clip', () => {
449
+ const c = clip({ speed: 2 })
450
+ expect(pieceSupported(c, 'silence')).toEqual({ available: true })
451
+ expect(pieceSupported(c, 'silence-check')).toEqual({ available: true })
452
+ expect(pieceSupported(c, 'fillers')).toEqual({ available: true })
453
+ expect(pieceSupported(c, 'voice').available).toBe(false)
454
+ expect(pieceSupported(c, 'loudness')).toEqual({ available: true })
455
+ })
456
+
457
+ it('excludes silence, silence-check, fillers and voice on a clip that is both looping and sped up', () => {
458
+ const c = clip({ loop: true, speed: 2 })
459
+ expect(pieceSupported(c, 'silence').available).toBe(false)
460
+ expect(pieceSupported(c, 'silence-check').available).toBe(false)
461
+ expect(pieceSupported(c, 'fillers').available).toBe(false)
462
+ expect(pieceSupported(c, 'voice').available).toBe(false)
463
+ expect(pieceSupported(c, 'loudness')).toEqual({ available: true })
464
+ })
465
+
466
+ // ── Reasons: rendered verbatim, so the exact text is pinned ──
467
+ it('gives the exact voice-speed reason on a sped-up clip', () => {
468
+ expect(pieceSupported(clip({ speed: 2 }), 'voice')).toEqual({
469
+ available: false,
470
+ reason: 'Voice isolation unavailable: this clip does not play at normal speed',
471
+ })
472
+ })
473
+
474
+ // The rule refuses ANY non-1x speed, not just speeding up, so the reason
475
+ // must not claim a direction. This is the mirror of the case above: a
476
+ // slowed-down clip gets the exact same reason, not a "slowed down" variant.
477
+ it('gives the exact voice-speed reason on a slowed-down clip too', () => {
478
+ expect(pieceSupported(clip({ speed: 0.5 }), 'voice')).toEqual({
479
+ available: false,
480
+ reason: 'Voice isolation unavailable: this clip does not play at normal speed',
481
+ })
482
+ })
483
+
484
+ it('gives the exact voice-loop reason', () => {
485
+ expect(pieceSupported(clip({ loop: true }), 'voice')).toEqual({
486
+ available: false,
487
+ reason: 'Voice isolation unavailable: this clip loops, so the isolated audio would drift out of sync',
488
+ })
489
+ })
490
+
491
+ it('gives the exact silence-loop reason, and the same one for silence-check', () => {
492
+ const c = clip({ loop: true })
493
+ const expected = {
494
+ available: false,
495
+ reason: 'Silence removal unavailable: this clip loops, so cuts would land in the wrong place',
496
+ }
497
+ expect(pieceSupported(c, 'silence')).toEqual(expected)
498
+ expect(pieceSupported(c, 'silence-check')).toEqual(expected)
499
+ })
500
+
501
+ it('gives the exact fillers-loop reason', () => {
502
+ expect(pieceSupported(clip({ loop: true }), 'fillers')).toEqual({
503
+ available: false,
504
+ reason: 'Filler removal unavailable: this clip loops, so cuts would land in the wrong place',
505
+ })
506
+ })
507
+
508
+ it('renders no em dash in any reason (UI copy rule)', () => {
509
+ const cases: Array<[VisualItem, PolishPiece]> = [
510
+ [clip({ speed: 2 }), 'voice'],
511
+ [clip({ loop: true }), 'voice'],
512
+ [clip({ loop: true }), 'silence'],
513
+ [clip({ loop: true }), 'silence-check'],
514
+ [clip({ loop: true }), 'fillers'],
515
+ ]
516
+ for (const [c, piece] of cases) {
517
+ const { reason } = pieceSupported(c, piece)
518
+ expect(reason).toBeDefined()
519
+ expect(reason).not.toMatch(/[—–]/)
520
+ }
521
+ })
522
+
523
+ // ── Precedence: a clip that is both looping and sped up ──
524
+ //
525
+ // `loop` wins voice's reason over `speed`, decided on the merits (see the
526
+ // doc comment on `pieceSupported`): `loop` alone already excludes silence,
527
+ // fillers AND voice on this clip, so it is the one explanation that
528
+ // actually accounts for everything unavailable here, and the one thing
529
+ // that has to change before voice can come back regardless of speed.
530
+ it('reports the loop reason for voice, not the speed reason, when a clip is both', () => {
531
+ const c = clip({ loop: true, speed: 2 })
532
+ expect(pieceSupported(c, 'voice')).toEqual({
533
+ available: false,
534
+ reason: 'Voice isolation unavailable: this clip loops, so the isolated audio would drift out of sync',
535
+ })
536
+ })
537
+ })
538
+
539
+ // ── buildDraft ───────────────────────────────────────────────────────────────
540
+
541
+ /** The source frame playing at timeline time `t`, or null when `t` is in a gap.
542
+ * This is what "the caption still describes the same footage" means. */
543
+ function sourceUnder(p: Project, t: number): { src?: string; source: number } | null {
544
+ const items = p.tracks![0].items
545
+ const it = items.find(i => t >= i.start - 1e-9 && t < i.end - 1e-9)
546
+ if (!it) return null
547
+ return { src: it.src, source: (t - it.start) * (it.speed ?? 1) + (it.inPoint ?? 0) }
548
+ }
549
+
550
+ const mid = (s: { start: number; end: number }) => (s.start + s.end) / 2
551
+
552
+ describe('buildDraft', () => {
553
+ it('returns the baseline untouched for an empty plan', () => {
554
+ const p = makeProject({ tracks: vtracks([clip()]) })
555
+ expect(buildDraft(p, { clips: {} })).toBe(p)
556
+ })
557
+
558
+ it('ignores plan entries naming a clip that is not on the primary track', () => {
559
+ const p = makeProject({ tracks: vtracks([clip()]) })
560
+ expect(buildDraft(p, { clips: { ghost: { removals: [{ start: 1, end: 2 }] } } })).toBe(p)
561
+ })
562
+
563
+ // ── Assertion 1: the worked example ──
564
+ it('lands the worked example caption at [6,8], NOT [5,7]', () => {
565
+ const p = makeProject({
566
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
567
+ captions: {
568
+ style: 'subtitle',
569
+ segments: [{ text: 'cap', start: 7, end: 9, words: [{ word: 'cap', start: 7, end: 9 }] }],
570
+ },
571
+ })
572
+ const plan: PolishPlan = {
573
+ clips: { a: { removals: [{ start: 5, end: 6 }, { start: 11, end: 12 }] } },
574
+ }
575
+ const out = buildDraft(p, plan)
576
+ const cap = out.captions!.segments[0]
577
+ expect(cap).toMatchObject({ start: 6, end: 8 })
578
+ expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
579
+ // and the clip gaps ARE closed: 20s of source minus 2s of cuts = 18s.
580
+ const items = out.tracks![0].items
581
+ expect(items.map(i => [i.start, i.end])).toEqual([[0, 5], [5, 10], [10, 18]])
582
+ })
583
+
584
+ // ── Assertion 2: captions still describe the footage under them ──
585
+ it('leaves every surviving caption over the same source footage it described', () => {
586
+ const p = makeProject({
587
+ tracks: vtracks([
588
+ clip({ id: 'a', src: '/f/a.mov', start: 0, end: 20, inPoint: 0, outPoint: 20 }),
589
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40, inPoint: 100, outPoint: 120 }),
590
+ ]),
591
+ captions: {
592
+ style: 'subtitle',
593
+ segments: [
594
+ { text: 'a1', start: 1, end: 3 },
595
+ { text: 'a2', start: 7, end: 9 },
596
+ { text: 'a3', start: 14, end: 16 },
597
+ { text: 'b1', start: 21, end: 23 },
598
+ { text: 'b2', start: 30, end: 32 },
599
+ { text: 'b3', start: 37, end: 39 },
600
+ ],
601
+ },
602
+ })
603
+ const plan: PolishPlan = {
604
+ clips: {
605
+ a: { removals: [{ start: 5, end: 6 }, { start: 11, end: 12 }] },
606
+ b: { removals: [{ start: 104, end: 105 }, { start: 115, end: 116 }] },
607
+ },
608
+ }
609
+ const before = Object.fromEntries(
610
+ p.captions!.segments.map(s => [s.text, sourceUnder(p, mid(s))]),
611
+ )
612
+ const out = buildDraft(p, plan)
613
+
614
+ expect(out.captions!.segments).toHaveLength(6) // none of them sat in a cut
615
+ for (const seg of out.captions!.segments) {
616
+ expect(sourceUnder(out, mid(seg))).toEqual(before[seg.text])
617
+ }
618
+ })
619
+
620
+ it('pools removals across clips and applies them descending', () => {
621
+ // Two clips, one removal each, and a caption sitting INSIDE the later
622
+ // removal. Back to front, that caption is deleted by its own cut and `tail`
623
+ // ends up 4s earlier. Front to back, the first cut drags the doomed caption
624
+ // clear of the second cut so it wrongly survives, and `tail` gets swept into
625
+ // the second cut and deleted instead. The two orders disagree about which
626
+ // caption exists, not merely about where it sits.
627
+ const p = makeProject({
628
+ tracks: vtracks([
629
+ clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 }),
630
+ clip({ id: 'b', src: '/f/b.mov', start: 10, end: 20, inPoint: 0, outPoint: 10 }),
631
+ ]),
632
+ captions: {
633
+ style: 'subtitle',
634
+ segments: [
635
+ { text: 'inside-b-cut', start: 15.5, end: 16.5 },
636
+ { text: 'tail', start: 18, end: 19 },
637
+ ],
638
+ },
639
+ })
640
+ const out = buildDraft(p, {
641
+ clips: { a: { removals: [{ start: 2, end: 4 }] }, b: { removals: [{ start: 5, end: 7 }] } },
642
+ })
643
+ expect(out.captions!.segments.map(s => s.text)).toEqual(['tail'])
644
+ // 2s removed from a and 2s from b, both before `tail` ⇒ exactly 4s left.
645
+ expect(out.captions!.segments[0]).toMatchObject({ start: 14, end: 15 })
646
+ })
647
+
648
+ it('merges overlapping pooled removals so captions are not shifted twice', () => {
649
+ const p = makeProject({
650
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
651
+ captions: { style: 'subtitle', segments: [{ text: 'tail', start: 15, end: 16 }] },
652
+ })
653
+ // Two removals overlapping by 1s. Their UNION is [9,12], so exactly 3s of
654
+ // material goes and the caption must land 3s earlier, at [12,13]. Applying
655
+ // them as two separate cuts gets the CLIPS right (they lift) but ripples the
656
+ // captions by the SUM of the two durations, 4s, putting the caption at
657
+ // [11,12] — a drift of exactly the overlap, and the same bug class this
658
+ // whole task exists to kill, one level down.
659
+ const out = buildDraft(p, {
660
+ clips: { a: { removals: [{ start: 9, end: 11 }, { start: 10, end: 12 }] } },
661
+ })
662
+ expect(out.captions!.segments[0]).toMatchObject({ start: 12, end: 13 })
663
+ expect(out.captions!.segments[0]).not.toMatchObject({ start: 11, end: 12 })
664
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 9], [9, 17]])
665
+ })
666
+
667
+ it('deletes a caption that sat entirely inside a removal', () => {
668
+ const p = makeProject({
669
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
670
+ captions: {
671
+ style: 'subtitle',
672
+ segments: [{ text: 'gone', start: 5.2, end: 5.8 }, { text: 'kept', start: 10, end: 11 }],
673
+ },
674
+ })
675
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 5, end: 6 }] } } })
676
+ expect(out.captions!.segments.map(s => s.text)).toEqual(['kept'])
677
+ })
678
+
679
+ it('maps removals through a sped clip before cutting', () => {
680
+ // S=2: source 102→106 is 2 timeline seconds at 1→3.
681
+ const p = makeProject({
682
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 })]),
683
+ })
684
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 102, end: 106 }] } } })
685
+ const items = out.tracks![0].items
686
+ expect(items.map(i => [i.start, i.end])).toEqual([[0, 1], [1, 3]])
687
+ expect(items[0]).toMatchObject({ inPoint: 100, outPoint: 102 })
688
+ expect(items[1]).toMatchObject({ inPoint: 106, outPoint: 110 })
689
+ })
690
+
691
+ it('does not close pre-existing gaps when the plan makes no cuts', () => {
692
+ const p = makeProject({
693
+ tracks: vtracks([
694
+ clip({ id: 'a', start: 0, end: 5, inPoint: 0, outPoint: 5 }),
695
+ clip({ id: 'b', start: 8, end: 12, inPoint: 0, outPoint: 4 }),
696
+ ]),
697
+ })
698
+ const out = buildDraft(p, { clips: { a: { gainDb: 0 }, b: { gainDb: 0 } } })
699
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 5], [8, 12]])
700
+ })
701
+
702
+ // ── Loudness ──
703
+ it('assigns item.volume from the gain, folding in the track volume', () => {
704
+ const p = makeProject({
705
+ tracks: [{ id: 'base', volume: 0.5, items: [clip({ id: 'a' }), clip({ id: 'b', start: 20, end: 30 })] }],
706
+ })
707
+ const out = buildDraft(p, { clips: { a: { gainDb: -6.0206 } } })
708
+ expect(out.tracks![0].items[0].volume).toBeCloseTo(1, 4)
709
+ expect(out.tracks![0].items[1].volume).toBeUndefined() // not in the plan
710
+ })
711
+
712
+ it('ASSIGNS the volume rather than multiplying it, so a re-run converges', () => {
713
+ const p = makeProject({ tracks: vtracks([clip({ id: 'a', volume: 1.7 })]) })
714
+ const once = buildDraft(p, { clips: { a: { gainDb: 0 } } })
715
+ const twice = buildDraft(once, { clips: { a: { gainDb: 0 } } })
716
+ expect(once.tracks![0].items[0].volume).toBeCloseTo(1, 9)
717
+ expect(twice.tracks![0].items[0].volume).toBeCloseTo(1, 9)
718
+ })
719
+
720
+ it('carries the gain onto every fragment a cut split the clip into', () => {
721
+ const p = makeProject({ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]) })
722
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 5, end: 6 }], gainDb: 0 } } })
723
+ const items = out.tracks![0].items
724
+ expect(items).toHaveLength(2)
725
+ for (const i of items) expect(i.volume).toBeCloseTo(1, 9)
726
+ })
727
+
728
+ // ── Voice isolation ──
729
+ it('adds a stem track for the clip and mutes the clip', () => {
730
+ const p = makeProject({ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]) })
731
+ const out = buildDraft(p, { clips: { a: { vocalsPath: '/f/a_stems/vocals.wav' } } })
732
+ expect(out.audio!.tracks).toEqual([
733
+ {
734
+ id: 'polish_voice_a',
735
+ type: 'voiceover',
736
+ src: '/f/a_stems/vocals.wav',
737
+ start: 0,
738
+ end: 20,
739
+ inPoint: 0,
740
+ outPoint: 20,
741
+ label: 'Voice: a.mov',
742
+ },
743
+ ])
744
+ expect(out.tracks![0].items[0].muted).toBe(true)
745
+ })
746
+
747
+ it('builds the stem track against the POST-cut geometry, one per surviving fragment', () => {
748
+ const p = makeProject({ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]) })
749
+ const out = buildDraft(p, {
750
+ clips: { a: { removals: [{ start: 5, end: 6 }], vocalsPath: '/f/v.wav' } },
751
+ })
752
+ const items = out.tracks![0].items
753
+ expect(items.map(i => [i.start, i.end])).toEqual([[0, 5], [5, 19]])
754
+ // Both fragments are muted and both are covered by a stem slice whose
755
+ // source window matches the picture under it.
756
+ expect(items.every(i => i.muted === true)).toBe(true)
757
+ const stems = out.audio!.tracks
758
+ expect(stems).toHaveLength(2)
759
+ expect(stems.map(t => [t.start, t.end, t.inPoint, t.outPoint])).toEqual([
760
+ [0, 5, 0, 5],
761
+ [5, 19, 6, 20],
762
+ ])
763
+ expect(stems.every(t => t.src === '/f/v.wav')).toBe(true)
764
+ // Ids are derived from the BASELINE clip and the fragment's timeline
765
+ // position, never from the fragment's own `Date.now()`-derived id.
766
+ expect(stems.map(t => t.id)).toEqual(['polish_voice_a', 'polish_voice_a__2'])
767
+ })
768
+
769
+ it('mints identical fragment stem ids on a rebuild, even though the split ids differ', () => {
770
+ const p = makeProject({ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]) })
771
+ const plan: PolishPlan = {
772
+ clips: { a: { removals: [{ start: 5, end: 6 }, { start: 12, end: 13 }], vocalsPath: '/f/v.wav' } },
773
+ }
774
+ const first = buildDraft(p, plan)
775
+ const second = buildDraft(p, plan)
776
+ // The clip fragments themselves get fresh random ids each rebuild...
777
+ expect(first.tracks![0].items.map(i => i.id)).not.toEqual(second.tracks![0].items.map(i => i.id))
778
+ // ...but the stem tracks, which must REPLACE rather than duplicate on a
779
+ // re-run, are byte-identical.
780
+ expect(first.audio).toEqual(second.audio)
781
+ expect(first.audio!.tracks.map(t => t.id)).toEqual([
782
+ 'polish_voice_a', 'polish_voice_a__2', 'polish_voice_a__3',
783
+ ])
784
+ })
785
+
786
+ it('never leaves a fragment muted without a stem track under it', () => {
787
+ const p = makeProject({
788
+ tracks: vtracks([
789
+ clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 }),
790
+ clip({ id: 'fast', src: '/f/f.mov', start: 20, end: 25, inPoint: 0, outPoint: 10, speed: 2 }),
791
+ ]),
792
+ })
793
+ const out = buildDraft(p, {
794
+ clips: {
795
+ a: { removals: [{ start: 5, end: 6 }], vocalsPath: '/f/a.wav' },
796
+ fast: { vocalsPath: '/f/f.wav' },
797
+ },
798
+ })
799
+ const muted = out.tracks![0].items.filter(i => i.muted)
800
+ const stemSpans = out.audio!.tracks.map(t => `${t.start}-${t.end}`)
801
+ expect(muted).toHaveLength(2) // the sped clip is NOT muted
802
+ for (const i of muted) expect(stemSpans).toContain(`${i.start}-${i.end}`)
803
+ })
804
+
805
+ it('drops a stale higher-ordinal stem when a re-run leaves fewer fragments', () => {
806
+ // The previous run split `a` in two and left both stems behind. This run
807
+ // makes no cut, so only `polish_voice_a` is minted and `__2` must not
808
+ // survive as an orphan pointing at footage that no longer exists.
809
+ const base = makeProject({
810
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
811
+ audio: { tracks: [
812
+ { id: 'music', src: '/f/bed.mp3', start: 0, end: 20 },
813
+ { id: 'polish_voice_a', type: 'voiceover', src: '/f/v.wav', start: 0, end: 5 },
814
+ { id: 'polish_voice_a__2', type: 'voiceover', src: '/f/v.wav', start: 5, end: 19 },
815
+ ] },
816
+ })
817
+ const out = buildDraft(base, { clips: { a: { vocalsPath: '/f/v.wav' } } })
818
+ expect(out.audio!.tracks.map(t => t.id)).toEqual(['music', 'polish_voice_a'])
819
+ })
820
+
821
+ it('leaves stems belonging to clips outside the plan alone', () => {
822
+ const base = makeProject({
823
+ tracks: vtracks([
824
+ clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 }),
825
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 30, inPoint: 0, outPoint: 10 }),
826
+ ]),
827
+ audio: { tracks: [
828
+ { id: 'polish_voice_b', type: 'voiceover', src: '/f/b.wav', start: 20, end: 30 },
829
+ { id: 'polish_voice_b__2', type: 'voiceover', src: '/f/b.wav', start: 30, end: 32 },
830
+ ] },
831
+ })
832
+ const out = buildDraft(base, { clips: { a: { vocalsPath: '/f/a.wav' } } })
833
+ expect(out.audio!.tracks.map(t => t.id)).toEqual([
834
+ 'polish_voice_b', 'polish_voice_b__2', 'polish_voice_a',
835
+ ])
836
+ })
837
+
838
+ it('replaces an existing stem track rather than duplicating it', () => {
839
+ const base = makeProject({
840
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
841
+ audio: { tracks: [
842
+ { id: 'music', src: '/f/bed.mp3', start: 0, end: 20 },
843
+ { id: 'polish_voice_a', type: 'voiceover', src: '/f/OLD.wav', start: 0, end: 20 },
844
+ ] },
845
+ })
846
+ const out = buildDraft(base, { clips: { a: { vocalsPath: '/f/NEW.wav' } } })
847
+ expect(out.audio!.tracks.map(t => t.id)).toEqual(['music', 'polish_voice_a'])
848
+ expect(out.audio!.tracks.find(t => t.id === 'polish_voice_a')!.src).toBe('/f/NEW.wav')
849
+ })
850
+
851
+ it('leaves the music bed exactly where it is (cuts never ripple audio tracks)', () => {
852
+ const bed = { id: 'music', src: '/f/bed.mp3', start: 0, end: 40 }
853
+ const p = makeProject({
854
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
855
+ audio: { tracks: [bed] },
856
+ })
857
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 5, end: 6 }] } } })
858
+ expect(out.audio!.tracks).toEqual([bed])
859
+ })
860
+
861
+ it('skips voice isolation on a sped clip and leaves it unmuted', () => {
862
+ const p = makeProject({
863
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 })]),
864
+ })
865
+ const out = buildDraft(p, { clips: { a: { vocalsPath: '/f/v.wav', gainDb: 0 } } })
866
+ expect(out.audio).toBeUndefined()
867
+ expect(out.tracks![0].items[0].muted).toBeUndefined()
868
+ expect(out.tracks![0].items[0].volume).toBeCloseTo(1, 9) // the other pieces still apply
869
+ })
870
+
871
+ // ── Looping clips ──
872
+ //
873
+ // A looping clip replays its `[inPoint, outPoint)` source window repeatedly
874
+ // across the whole clip span, so `sourceToTimeline` no longer picks out one
875
+ // correct timeline position for a source-time removal. See the LOOPING
876
+ // CLIPS note at the top of `audioPolish.ts`.
877
+ describe('excludes looping clips from cuts and voice, but not loudness', () => {
878
+ /** A clip that loops a 5s source window across a 20s timeline span. */
879
+ const loopingClip = (over: Partial<VisualItem> = {}) =>
880
+ clip({ loop: true, start: 0, end: 20, inPoint: 0, outPoint: 5, ...over })
881
+
882
+ it('drops silence/filler removals for a looping clip: buildDraft is a no-op', () => {
883
+ const p = makeProject({ tracks: vtracks([loopingClip({ id: 'a' })]) })
884
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 1, end: 2 }] } } })
885
+ // No other piece was requested either, so the whole draft collapses back
886
+ // to the untouched baseline — the strongest statement that the removal
887
+ // never reached a cut.
888
+ expect(out).toBe(p)
889
+ })
890
+
891
+ it('mints no stem and does not mute a looping clip', () => {
892
+ const p = makeProject({ tracks: vtracks([loopingClip({ id: 'a' })]) })
893
+ const out = buildDraft(p, { clips: { a: { vocalsPath: '/f/v.wav' } } })
894
+ expect(out.audio).toBeUndefined()
895
+ expect(out.tracks![0].items[0].muted).toBeUndefined()
896
+ })
897
+
898
+ it('still applies loudness to a looping clip: the one exempt piece', () => {
899
+ const p = makeProject({ tracks: vtracks([loopingClip({ id: 'a' })]) })
900
+ const out = buildDraft(p, { clips: { a: { gainDb: -6.0206 } } })
901
+ expect(out.tracks![0].items[0].volume).toBeCloseTo(0.5, 4)
902
+ })
903
+
904
+ it('applies loudness while dropping the removal and the stem, all in one draft', () => {
905
+ const p = makeProject({ tracks: vtracks([loopingClip({ id: 'a' })]) })
906
+ const out = buildDraft(p, {
907
+ clips: { a: { removals: [{ start: 1, end: 2 }], gainDb: 0, vocalsPath: '/f/v.wav' } },
908
+ })
909
+ expect(out.tracks![0].items).toHaveLength(1) // no cut happened
910
+ expect(out.tracks![0].items[0]).toMatchObject({ start: 0, end: 20 })
911
+ expect(out.tracks![0].items[0].volume).toBeCloseTo(1, 9) // loudness still applied
912
+ expect(out.tracks![0].items[0].muted).toBeUndefined() // voice still skipped
913
+ expect(out.audio).toBeUndefined()
914
+ })
915
+
916
+ it('is per-clip: a looping clip is excluded but a normal clip in the same plan still gets cut', () => {
917
+ const p = makeProject({
918
+ tracks: vtracks([
919
+ loopingClip({ id: 'a' }),
920
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40, inPoint: 0, outPoint: 20 }),
921
+ ]),
922
+ })
923
+ const out = buildDraft(p, {
924
+ clips: {
925
+ a: { removals: [{ start: 1, end: 2 }] },
926
+ b: { removals: [{ start: 5, end: 6 }] },
927
+ },
928
+ })
929
+ const items = out.tracks![0].items
930
+ // 'a' is untouched by its own (dropped) removal: still one whole fragment.
931
+ const aFragments = items.filter(i => i.id === 'a' || i.id.startsWith('a_split_'))
932
+ expect(aFragments).toEqual([expect.objectContaining({ id: 'a', start: 0, end: 20 })])
933
+ // 'b' got its cut: split into two fragments totalling 19s (20 minus the
934
+ // 1s removed), so the exclusion did not leak across clips.
935
+ const bFragments = items.filter(i => i.id === 'b' || i.id.startsWith('b_split_'))
936
+ expect(bFragments).toHaveLength(2)
937
+ const bTotal = bFragments.reduce((s, i) => s + (i.end - i.start), 0)
938
+ expect(bTotal).toBeCloseTo(19, 9)
939
+ })
940
+ })
941
+
942
+ // ── Order and idempotence ──
943
+ it('applies the pieces in order: cuts, gap close, loudness, voice', () => {
944
+ // A 6s removal out of the middle. If the stem were minted before the cuts
945
+ // it would span the whole [0,20]; if before the gap close, the tail slice
946
+ // would sit at its pre-collapse [8,20] instead of [2,14].
947
+ const p = makeProject({
948
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
949
+ })
950
+ const out = buildDraft(p, {
951
+ clips: { a: { removals: [{ start: 2, end: 8 }], gainDb: 0, vocalsPath: '/f/v.wav' } },
952
+ })
953
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 2], [2, 14]])
954
+ expect(out.audio!.tracks.map(t => [t.start, t.end, t.inPoint, t.outPoint])).toEqual([
955
+ [0, 2, 0, 2],
956
+ [2, 14, 8, 20],
957
+ ])
958
+ })
959
+
960
+ // ── The leading gap ──
961
+ //
962
+ // `collapseGaps` compacts from `sorted[0].start`, so it cannot close a gap at
963
+ // the HEAD of the timeline. Left alone, a removal at the start of the first
964
+ // clip gives the operator black frames there and — because
965
+ // `applyCutToCaptions` has already rippled the captions past that cut —
966
+ // leaves every caption adrift from its footage by the width of the gap.
967
+ // `closeLeadGap` removes exactly the offset the polish introduced.
968
+ describe('closes the leading gap it opened', () => {
969
+ it('moves clips, overlays and audio up, and leaves no gap at the head', () => {
970
+ const p = makeProject({
971
+ tracks: vtracks(
972
+ [clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })],
973
+ [{ id: 'o', type: 'overlay', start: 10, end: 12 }],
974
+ ),
975
+ audio: { tracks: [{ id: 'bed', src: '/f/bed.mp3', start: 8, end: 20 }] },
976
+ })
977
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 6 }] } } })
978
+
979
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 14]])
980
+ expect(out.tracks![0].items[0].inPoint).toBe(6) // source window unchanged
981
+ expect(out.tracks![1].items[0]).toMatchObject({ id: 'o', start: 4, end: 6 })
982
+ expect(out.audio!.tracks[0]).toMatchObject({ id: 'bed', start: 2, end: 14 })
983
+ // The overlay still sits over the same source frames: timeline 4 on a
984
+ // clip starting at 0 with inPoint 6 is source 10, where it began.
985
+ expect(sourceUnder(out, 4)).toEqual({ src: '/f/a.mov', source: 10 })
986
+ })
987
+
988
+ // THE MINIMAL REPRODUCTION. One clip, one head cut, one caption after it —
989
+ // the smallest case that shows the whole mechanism, and the first thing to
990
+ // read when this behaviour is ever in question. Step by step:
991
+ //
992
+ // baseline clip [0,20] inPoint 0 caption [8,10] over source 8-10
993
+ // cut [0,6] clip [6,20] inPoint 6 caption [2,4] (rippled 6s)
994
+ // collapse unchanged — one clip, so it returns early, and it compacts
995
+ // from sorted[0].start anyway, so it can never close a head gap
996
+ // lead close clip [0,14] inPoint 6 caption [2,4] (NOT moved)
997
+ //
998
+ // The caption's midpoint 3 maps through inPoint 6 to source 9, exactly where
999
+ // it started. Move the caption as well and it lands at [-4,-2].
1000
+ //
1001
+ // This is "captions must be shifted exactly once" one level up. The delta is
1002
+ // what the CLIPS still owe: the cuts already rippled the captions, the clips
1003
+ // lifted and stayed put, and this closes the difference.
1004
+ it('case A, the minimal repro: clips catch up to the captions, which do not move', () => {
1005
+ const p = makeProject({
1006
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
1007
+ captions: {
1008
+ style: 'subtitle',
1009
+ segments: [{ text: 'cap', start: 8, end: 10, words: [{ word: 'cap', start: 8, end: 10 }] }],
1010
+ },
1011
+ })
1012
+ const before = sourceUnder(p, mid(p.captions!.segments[0]))
1013
+ expect(before).toEqual({ src: '/f/a.mov', source: 9 })
1014
+
1015
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 6 }] } } })
1016
+
1017
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 14]])
1018
+ expect(out.tracks![0].items[0].inPoint).toBe(6)
1019
+ const cap = out.captions!.segments[0]
1020
+ expect(cap).toMatchObject({ start: 2, end: 4 })
1021
+ expect(cap.words![0]).toMatchObject({ start: 2, end: 4 })
1022
+ expect(sourceUnder(out, mid(cap))).toEqual(before)
1023
+ })
1024
+
1025
+ // The Σ counts only cuts with `cut.start < leadAfter`. If it swept in the
1026
+ // interior cut too the delta would be 8 rather than 6, and the first clip
1027
+ // would land at -2: a negative start, from a rule whose whole job is to
1028
+ // remove exactly the offset the polish introduced at the head.
1029
+ it('counts the head cut but not an interior cut on the same clip', () => {
1030
+ const p = makeProject({
1031
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
1032
+ captions: {
1033
+ style: 'subtitle',
1034
+ segments: [
1035
+ { text: 'early', start: 8, end: 9 },
1036
+ { text: 'late', start: 15, end: 16 },
1037
+ ],
1038
+ },
1039
+ })
1040
+ const before = p.captions!.segments.map(s => sourceUnder(p, mid(s)))
1041
+ const out = buildDraft(p, {
1042
+ clips: { a: { removals: [{ start: 0, end: 6 }, { start: 10, end: 12 }] } },
1043
+ })
1044
+
1045
+ // 6s off the head and 2s from the middle: two fragments, head at 0.
1046
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 4], [4, 12]])
1047
+ expect(out.tracks![0].items.map(i => i.inPoint)).toEqual([6, 12])
1048
+ expect(out.captions!.segments.map(s => [s.start, s.end])).toEqual([[2, 3], [7, 8]])
1049
+ expect(out.captions!.segments.map(s => sourceUnder(out, mid(s)))).toEqual(before)
1050
+ })
1051
+
1052
+ it('preserves a leading offset the operator placed deliberately', () => {
1053
+ const p = makeProject({
1054
+ tracks: vtracks([clip({ id: 'a', start: 3, end: 20, inPoint: 0, outPoint: 17 })]),
1055
+ })
1056
+ // Source 0 to 2 is timeline 3 to 5 — the head of the clip, not of the
1057
+ // timeline. Only those 2s go; the deliberate 3s offset stays.
1058
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 2 }] } } })
1059
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[3, 18]])
1060
+ expect(out.tracks![0].items[0].inPoint).toBe(2)
1061
+ })
1062
+
1063
+ it('does not fire at all when no removal touches the head', () => {
1064
+ const p = makeProject({
1065
+ tracks: vtracks([clip({ id: 'a', start: 3, end: 20, inPoint: 0, outPoint: 17 })]),
1066
+ audio: { tracks: [{ id: 'bed', src: '/f/bed.mp3', start: 0, end: 30 }] },
1067
+ })
1068
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 4, end: 5 }] } } })
1069
+ // Interior cut only: the 3s head offset survives untouched, and the bed,
1070
+ // which `collapseGaps` deliberately never moves, is left alone too.
1071
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[3, 7], [7, 19]])
1072
+ expect(out.audio!.tracks[0]).toMatchObject({ start: 0, end: 30 })
1073
+ })
1074
+
1075
+ // ── The first clip emptied entirely ──
1076
+
1077
+ it('pulls the next clip up when the emptied first clip butted against it', () => {
1078
+ const p = makeProject({
1079
+ tracks: vtracks([
1080
+ clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 }),
1081
+ clip({ id: 'b', src: '/f/b.mov', start: 10, end: 20, inPoint: 0, outPoint: 10 }),
1082
+ ]),
1083
+ captions: { style: 'subtitle', segments: [{ text: 'b-cap', start: 12, end: 14 }] },
1084
+ })
1085
+ const before = sourceUnder(p, 13)
1086
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 10 }] } } })
1087
+
1088
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[0, 10]])
1089
+ expect(out.captions!.segments[0]).toMatchObject({ start: 2, end: 4 })
1090
+ expect(sourceUnder(out, mid(out.captions!.segments[0]))).toEqual(before)
1091
+ })
1092
+
1093
+ // The case that separates "cut material in the head" from the positional
1094
+ // `leadAfter - leadBefore`. Here they differ by the 10s gap that sat after
1095
+ // the emptied clip: the positional delta would swallow it, overshoot the
1096
+ // captions by 10s, and land `b-cap` on clip C instead of clip B.
1097
+ it('counts only cut material when a gap followed the emptied first clip', () => {
1098
+ const p = makeProject({
1099
+ tracks: vtracks([
1100
+ clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 }),
1101
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 30, inPoint: 0, outPoint: 10 }),
1102
+ clip({ id: 'c', src: '/f/c.mov', start: 35, end: 40, inPoint: 0, outPoint: 5 }),
1103
+ ]),
1104
+ captions: { style: 'subtitle', segments: [{ text: 'b-cap', start: 22, end: 24 }] },
1105
+ })
1106
+ const before = sourceUnder(p, 23)
1107
+ expect(before).toEqual({ src: '/f/b.mov', source: 3 })
1108
+
1109
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 10 }] } } })
1110
+
1111
+ // 10s of cut material removed, so everything moves 10s. The operator's
1112
+ // own 10s gap survives as the new head offset rather than being eaten.
1113
+ expect(out.tracks![0].items.map(i => [i.start, i.end])).toEqual([[10, 20], [20, 25]])
1114
+ expect(sourceUnder(out, mid(out.captions!.segments[0]))).toEqual(before)
1115
+ })
1116
+
1117
+ it('is a no-op when the plan empties every clip on the primary track', () => {
1118
+ const p = makeProject({
1119
+ tracks: vtracks(
1120
+ [clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 })],
1121
+ [{ id: 'o', type: 'overlay', start: 20, end: 22 }],
1122
+ ),
1123
+ // Placed AFTER the removed region on purpose: without the
1124
+ // no-survivors guard this bed is dragged 10s earlier even though no
1125
+ // picture is left to stay in sync with. (`Math.min()` of no clips is
1126
+ // Infinity, which the `min(cut.end, leadAfter)` clamp quietly tames
1127
+ // into a finite delta, so the failure is a silent wrong answer rather
1128
+ // than an obvious one.)
1129
+ audio: { tracks: [{ id: 'bed', src: '/f/bed.mp3', start: 20, end: 30 }] },
1130
+ })
1131
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 10 }] } } })
1132
+ expect(out.tracks![0].items).toEqual([])
1133
+ expect(out.tracks![1].items[0]).toMatchObject({ start: 20, end: 22 })
1134
+ expect(out.audio!.tracks[0]).toMatchObject({ start: 20, end: 30 })
1135
+ })
1136
+
1137
+ // ── Nothing is ever pushed before time 0 ──
1138
+
1139
+ it('head-trims an audio track that would go negative, keeping it in sync', () => {
1140
+ const p = makeProject({
1141
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
1142
+ audio: { tracks: [{ id: 'bed', src: '/f/bed.mp3', start: 0, end: 20, inPoint: 0, outPoint: 20 }] },
1143
+ })
1144
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 6 }] } } })
1145
+ // Source 6 of the bed now plays at timeline 0, exactly where the clip's
1146
+ // own source 6 does: the bed loses its head instead of its sync.
1147
+ expect(out.audio!.tracks[0]).toMatchObject({ start: 0, end: 14, inPoint: 6, outPoint: 20 })
1148
+ expect(out.tracks![0].items[0]).toMatchObject({ start: 0, end: 14, inPoint: 6 })
1149
+ })
1150
+
1151
+ it('leaves content that sat entirely inside the removed head where it is', () => {
1152
+ const p = makeProject({
1153
+ tracks: vtracks(
1154
+ [clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })],
1155
+ [{ id: 'o', type: 'overlay', start: 0, end: 2 }],
1156
+ ),
1157
+ audio: { tracks: [{ id: 'sting', src: '/f/sting.mp3', start: 0, end: 3 }] },
1158
+ })
1159
+ const out = buildDraft(p, { clips: { a: { removals: [{ start: 0, end: 6 }] } } })
1160
+ // Neither has anything left to trim to, and a negative start is not
1161
+ // representable downstream, so both stay put rather than being deleted.
1162
+ expect(out.tracks![1].items[0]).toMatchObject({ id: 'o', start: 0, end: 2 })
1163
+ expect(out.audio!.tracks[0]).toMatchObject({ id: 'sting', start: 0, end: 3 })
1164
+ })
1165
+ })
1166
+
1167
+ it('is idempotent: two drafts from the same baseline are deep-equal', () => {
1168
+ const p = makeProject({
1169
+ tracks: [{ id: 'base', volume: 0.8, items: [
1170
+ clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 }),
1171
+ clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40, inPoint: 0, outPoint: 20 }),
1172
+ ] }],
1173
+ captions: {
1174
+ style: 'subtitle',
1175
+ segments: [{ text: 'one', start: 2, end: 3 }, { text: 'two', start: 25, end: 26 }],
1176
+ },
1177
+ audio: { tracks: [{ id: 'music', src: '/f/bed.mp3', start: 0, end: 40 }] },
1178
+ })
1179
+ // Edge trims only, so `cuts.ts` mints no `Date.now()`-derived split ids and
1180
+ // the two drafts are comparable field for field.
1181
+ const plan: PolishPlan = {
1182
+ clips: {
1183
+ a: { removals: [{ start: 18, end: 20 }], gainDb: -3, vocalsPath: '/f/a.wav' },
1184
+ b: { removals: [{ start: 0, end: 1 }], gainDb: 2, vocalsPath: '/f/b.wav' },
1185
+ },
1186
+ }
1187
+ expect(buildDraft(p, plan)).toEqual(buildDraft(p, plan))
1188
+ })
1189
+
1190
+ it('never mutates the baseline it was handed', () => {
1191
+ const p = makeProject({
1192
+ tracks: vtracks([clip({ id: 'a', start: 0, end: 20, inPoint: 0, outPoint: 20 })]),
1193
+ captions: { style: 'subtitle', segments: [{ text: 'cap', start: 8, end: 9 }] },
1194
+ audio: { tracks: [{ id: 'music', src: '/f/bed.mp3', start: 0, end: 20 }] },
1195
+ })
1196
+ const snapshot = structuredClone(p)
1197
+ buildDraft(p, {
1198
+ clips: { a: { removals: [{ start: 5, end: 6 }], gainDb: 1, vocalsPath: '/f/v.wav' } },
1199
+ })
1200
+ expect(p).toEqual(snapshot)
1201
+ })
1202
+ })