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