@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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- 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
|
+
}
|