@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,825 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react'
|
|
3
|
+
import type {
|
|
4
|
+
AnalyzeAudioPolishArgs,
|
|
5
|
+
AudioPolishAnalysis,
|
|
6
|
+
EditorAdapter,
|
|
7
|
+
ImageElement,
|
|
8
|
+
Project,
|
|
9
|
+
} from '../../types'
|
|
10
|
+
import type { VisualItem, VisualTrack } from '../../schema'
|
|
11
|
+
import { trackItems } from '../timeline/timeline-model'
|
|
12
|
+
import AudioPolishModal from '../AudioPolishModal'
|
|
13
|
+
|
|
14
|
+
afterEach(() => cleanup())
|
|
15
|
+
|
|
16
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function clip(over: Partial<VisualItem> = {}): VisualItem {
|
|
19
|
+
return { id: 'a', type: 'video', src: '/f/a.mov', start: 0, end: 20, inPoint: 0, outPoint: 20, ...over }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function vtracks(...items: VisualItem[][]): VisualTrack[] {
|
|
23
|
+
return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeProject(over: Partial<Project> = {}): Project {
|
|
27
|
+
return {
|
|
28
|
+
id: 'p1',
|
|
29
|
+
status: 'draft',
|
|
30
|
+
settings: { resolution: [1080, 1920] },
|
|
31
|
+
tracks: vtracks([clip()]),
|
|
32
|
+
...over,
|
|
33
|
+
} as Project
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Handler = (args: AnalyzeAudioPolishArgs) => Promise<AudioPolishAnalysis>
|
|
37
|
+
|
|
38
|
+
/** Piece results the happy path returns. `silence-check` keeps `[0, 1]`, which
|
|
39
|
+
* does not overlap the silence removal at `[2, 3]`, so nothing is flagged. */
|
|
40
|
+
const defaultHandler: Handler = async args => {
|
|
41
|
+
switch (args.piece) {
|
|
42
|
+
case 'silence':
|
|
43
|
+
return { piece: 'silence', removals: [{ start: 2, end: 3 }] }
|
|
44
|
+
case 'fillers':
|
|
45
|
+
return { piece: 'fillers', removals: [{ start: 5, end: 5.4, text: 'um' }] }
|
|
46
|
+
case 'silence-check':
|
|
47
|
+
return { piece: 'silence-check', keeps: [[0, 1]] }
|
|
48
|
+
case 'loudness':
|
|
49
|
+
return { piece: 'loudness', measuredI: -20, measuredTP: -3, measuredLRA: 5, targetI: -14, gainDb: 6 }
|
|
50
|
+
case 'voice':
|
|
51
|
+
return { piece: 'voice', vocalsPath: '/f/a_vocals.wav', url: 'https://host/a_vocals.wav' }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Spy {
|
|
56
|
+
adapter: EditorAdapter<Project>
|
|
57
|
+
calls: AnalyzeAudioPolishArgs[]
|
|
58
|
+
maxInflight: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeAdapter(handler: Handler = defaultHandler): Spy {
|
|
62
|
+
const spy: Spy = { calls: [], maxInflight: 0, adapter: null as unknown as EditorAdapter<Project> }
|
|
63
|
+
let inflight = 0
|
|
64
|
+
spy.adapter = {
|
|
65
|
+
loadProject: vi.fn(),
|
|
66
|
+
saveProject: vi.fn(),
|
|
67
|
+
subscribe: () => () => {},
|
|
68
|
+
render: async function* () {},
|
|
69
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
70
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
71
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
72
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
73
|
+
uploadFile: vi.fn(async () => ''),
|
|
74
|
+
fileUrl: (p: string) => `url:${p}`,
|
|
75
|
+
analyzeAudioPolish: async (args: AnalyzeAudioPolishArgs) => {
|
|
76
|
+
spy.calls.push(args)
|
|
77
|
+
inflight += 1
|
|
78
|
+
spy.maxInflight = Math.max(spy.maxInflight, inflight)
|
|
79
|
+
try {
|
|
80
|
+
// A few microtask turns so overlapping jobs are actually observable.
|
|
81
|
+
await Promise.resolve()
|
|
82
|
+
await Promise.resolve()
|
|
83
|
+
await Promise.resolve()
|
|
84
|
+
return await handler(args)
|
|
85
|
+
} finally {
|
|
86
|
+
inflight -= 1
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
} as unknown as EditorAdapter<Project>
|
|
90
|
+
return spy
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Harness {
|
|
94
|
+
spy: Spy
|
|
95
|
+
/** The project the modal snapshotted on open. `buildDraft` returns it BY
|
|
96
|
+
* REFERENCE for an empty plan, so `toBe(baseline)` is an exact no-op test. */
|
|
97
|
+
baseline: Project
|
|
98
|
+
drafts: Project[]
|
|
99
|
+
last: () => Project
|
|
100
|
+
commit: ReturnType<typeof vi.fn>
|
|
101
|
+
discardTransient: ReturnType<typeof vi.fn>
|
|
102
|
+
onClose: ReturnType<typeof vi.fn>
|
|
103
|
+
mutateTransient: ReturnType<typeof vi.fn>
|
|
104
|
+
rerenderWithProject: (p: Project) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderModal(opts: {
|
|
108
|
+
project?: Project
|
|
109
|
+
handler?: Handler
|
|
110
|
+
selectionIds?: string[]
|
|
111
|
+
} = {}): Harness {
|
|
112
|
+
const baseline = opts.project ?? makeProject()
|
|
113
|
+
const spy = makeAdapter(opts.handler)
|
|
114
|
+
const drafts: Project[] = []
|
|
115
|
+
// The modal always pushes `() => draft`, so the argument it is applied to is
|
|
116
|
+
// irrelevant; passing the baseline mirrors what useProjectSync would do.
|
|
117
|
+
const mutateTransient = vi.fn((fn: (p: Project) => Project) => { drafts.push(fn(baseline)) })
|
|
118
|
+
const commit = vi.fn(async () => {})
|
|
119
|
+
const discardTransient = vi.fn()
|
|
120
|
+
const onClose = vi.fn()
|
|
121
|
+
|
|
122
|
+
const props = {
|
|
123
|
+
projectId: 'p1',
|
|
124
|
+
adapter: spy.adapter,
|
|
125
|
+
selectionIds: opts.selectionIds,
|
|
126
|
+
mutateTransient,
|
|
127
|
+
commit,
|
|
128
|
+
discardTransient,
|
|
129
|
+
onClose,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { rerender } = render(<AudioPolishModal {...props} project={baseline} />)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
spy,
|
|
136
|
+
baseline,
|
|
137
|
+
drafts,
|
|
138
|
+
last: () => drafts[drafts.length - 1],
|
|
139
|
+
commit,
|
|
140
|
+
discardTransient,
|
|
141
|
+
onClose,
|
|
142
|
+
mutateTransient,
|
|
143
|
+
rerenderWithProject: (p: Project) => rerender(<AudioPolishModal {...props} project={p} />),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setPiece(label: string, on: boolean) {
|
|
148
|
+
const box = screen.getByRole('checkbox', { name: label }) as HTMLInputElement
|
|
149
|
+
if (box.checked !== on) fireEvent.click(box)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function analyse() {
|
|
153
|
+
fireEvent.click(screen.getByRole('button', { name: /^Analyse/ }))
|
|
154
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Analyse again' })).toBeTruthy())
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function firstTrackItems(p: Project): VisualItem[] {
|
|
158
|
+
return trackItems(p)[0] ?? []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Total picture time on `tracks[0]`. A cut SPLITS a clip into fragments, so
|
|
162
|
+
* `items[0].end` is a fragment boundary, not the clip's end. */
|
|
163
|
+
function totalDuration(p: Project): number {
|
|
164
|
+
return firstTrackItems(p).reduce((n, i) => n + (i.end - i.start), 0)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
describe('AudioPolishModal — analysis', () => {
|
|
170
|
+
it('calls each enabled piece with its own arg shape, and skips disabled pieces', async () => {
|
|
171
|
+
const h = renderModal()
|
|
172
|
+
await analyse()
|
|
173
|
+
|
|
174
|
+
const byPiece = (p: string) => h.spy.calls.filter(c => c.piece === p)
|
|
175
|
+
|
|
176
|
+
expect(byPiece('silence')[0]).toEqual({
|
|
177
|
+
projectId: 'p1', piece: 'silence', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { language: 'en' },
|
|
178
|
+
})
|
|
179
|
+
expect(byPiece('fillers')[0]).toEqual({
|
|
180
|
+
projectId: 'p1', piece: 'fillers', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { language: 'en' },
|
|
181
|
+
})
|
|
182
|
+
expect(byPiece('loudness')[0]).toEqual({
|
|
183
|
+
projectId: 'p1', piece: 'loudness', src: '/f/a.mov', window: { in: 0, out: 20 }, options: { targetLufs: -14 },
|
|
184
|
+
})
|
|
185
|
+
// The cross check is whole source: no window, no exposed options.
|
|
186
|
+
expect(byPiece('silence-check')[0]).toEqual({ projectId: 'p1', piece: 'silence-check', src: '/f/a.mov' })
|
|
187
|
+
// Voice is off by default.
|
|
188
|
+
expect(byPiece('voice')).toHaveLength(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('sends the chosen language through to the speech pieces', async () => {
|
|
192
|
+
const h = renderModal()
|
|
193
|
+
fireEvent.change(screen.getByLabelText('Spoken language'), { target: { value: 'es' } })
|
|
194
|
+
await analyse()
|
|
195
|
+
for (const c of h.spy.calls.filter(x => x.piece === 'silence' || x.piece === 'fillers')) {
|
|
196
|
+
expect(c.options?.language).toBe('es')
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('prefers the proxy for speech detection and the original for loudness and voice', async () => {
|
|
201
|
+
const h = renderModal({
|
|
202
|
+
project: makeProject({ tracks: vtracks([clip({ proxySrc: '/f/a_proxy.mp4' })]) }),
|
|
203
|
+
})
|
|
204
|
+
setPiece('Isolate voice', true)
|
|
205
|
+
await analyse()
|
|
206
|
+
|
|
207
|
+
for (const c of h.spy.calls) {
|
|
208
|
+
if (c.piece === 'silence' || c.piece === 'fillers') expect(c.src).toBe('/f/a_proxy.mp4')
|
|
209
|
+
else expect(c.src).toBe('/f/a.mov')
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('derives the analysis window through the planner when outPoint is absent, speed included', async () => {
|
|
214
|
+
// The fallback path, which is the one a hand-rolled copy of the mapping
|
|
215
|
+
// would have owned. Trimmed AND sped up, so the speed factor has to be
|
|
216
|
+
// applied: source 30 to 30 + (15 - 5) * 2.
|
|
217
|
+
const noOutPoint: VisualItem = {
|
|
218
|
+
id: 'a', type: 'video', src: '/f/a.mov', start: 5, end: 15, inPoint: 30, speed: 2,
|
|
219
|
+
}
|
|
220
|
+
const h = renderModal({ project: makeProject({ tracks: vtracks([noOutPoint]) }) })
|
|
221
|
+
await analyse()
|
|
222
|
+
|
|
223
|
+
const windowed = h.spy.calls.filter(c => c.window)
|
|
224
|
+
expect(windowed.length).toBeGreaterThan(0)
|
|
225
|
+
for (const c of windowed) expect(c.window).toEqual({ in: 30, out: 50 })
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('ignores a stale outPoint that is not past the in-point, rather than inverting the window', async () => {
|
|
229
|
+
// `outPoint: 0` is finite, so a bare `??` would hand the step {in: 30, out: 0}.
|
|
230
|
+
// Same hazard timeline-model.ts guards with `outPt > inPt`.
|
|
231
|
+
const h = renderModal({
|
|
232
|
+
project: makeProject({
|
|
233
|
+
tracks: vtracks([clip({ start: 5, end: 15, inPoint: 30, outPoint: 0, speed: 2 })]),
|
|
234
|
+
}),
|
|
235
|
+
})
|
|
236
|
+
await analyse()
|
|
237
|
+
|
|
238
|
+
const windowed = h.spy.calls.filter(c => c.window)
|
|
239
|
+
expect(windowed.length).toBeGreaterThan(0)
|
|
240
|
+
for (const c of windowed) expect(c.window).toEqual({ in: 30, out: 50 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('prefers a stored outPoint, which is the real source window on a looping clip', async () => {
|
|
244
|
+
// A 10s source looped across 30s of timeline. Deriving from `clip.end` here
|
|
245
|
+
// would ask the step for source [0, 30] of a 10s file.
|
|
246
|
+
const h = renderModal({
|
|
247
|
+
project: makeProject({
|
|
248
|
+
tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true })]),
|
|
249
|
+
}),
|
|
250
|
+
})
|
|
251
|
+
await analyse()
|
|
252
|
+
|
|
253
|
+
const windowed = h.spy.calls.filter(c => c.window)
|
|
254
|
+
expect(windowed.length).toBeGreaterThan(0)
|
|
255
|
+
for (const c of windowed) expect(c.window).toEqual({ in: 0, out: 10 })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('caps analysis at two step calls in flight', async () => {
|
|
259
|
+
const h = renderModal({
|
|
260
|
+
project: makeProject({
|
|
261
|
+
tracks: vtracks([
|
|
262
|
+
clip({ id: 'a', src: '/f/a.mov', start: 0, end: 20 }),
|
|
263
|
+
clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40, inPoint: 0, outPoint: 20 }),
|
|
264
|
+
clip({ id: 'c', src: '/f/c.mov', start: 40, end: 60, inPoint: 0, outPoint: 20 }),
|
|
265
|
+
]),
|
|
266
|
+
}),
|
|
267
|
+
})
|
|
268
|
+
setPiece('Isolate voice', true)
|
|
269
|
+
await analyse()
|
|
270
|
+
|
|
271
|
+
expect(h.spy.calls.length).toBeGreaterThan(4)
|
|
272
|
+
expect(h.spy.maxInflight).toBe(2)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('runs the whole-source pieces once per distinct source, not once per clip', async () => {
|
|
276
|
+
const h = renderModal({
|
|
277
|
+
project: makeProject({
|
|
278
|
+
tracks: vtracks([
|
|
279
|
+
clip({ id: 'a', start: 0, end: 10, inPoint: 0, outPoint: 10 }),
|
|
280
|
+
clip({ id: 'b', start: 10, end: 20, inPoint: 10, outPoint: 20 }),
|
|
281
|
+
]),
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
setPiece('Isolate voice', true)
|
|
285
|
+
await analyse()
|
|
286
|
+
|
|
287
|
+
expect(h.spy.calls.filter(c => c.piece === 'silence-check')).toHaveLength(1)
|
|
288
|
+
expect(h.spy.calls.filter(c => c.piece === 'voice')).toHaveLength(1)
|
|
289
|
+
expect(h.spy.calls.filter(c => c.piece === 'silence')).toHaveLength(2)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('AudioPolishModal — review defaults', () => {
|
|
294
|
+
it('ticks unflagged removals and leaves flagged ones unticked', async () => {
|
|
295
|
+
// The cross check hears sound across [2.5, 4], which overlaps the proposed
|
|
296
|
+
// silence removal at [2, 3] well past the tolerance.
|
|
297
|
+
renderModal({
|
|
298
|
+
handler: async args =>
|
|
299
|
+
args.piece === 'silence-check'
|
|
300
|
+
? { piece: 'silence-check', keeps: [[2.5, 4]] }
|
|
301
|
+
: defaultHandler(args),
|
|
302
|
+
})
|
|
303
|
+
await analyse()
|
|
304
|
+
|
|
305
|
+
const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
|
|
306
|
+
const filler = screen.getByRole('checkbox', { name: /Remove filler "um" at/ }) as HTMLInputElement
|
|
307
|
+
expect(silence.checked).toBe(false)
|
|
308
|
+
expect(filler.checked).toBe(true)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('shows the flag reason on the flagged removal', async () => {
|
|
312
|
+
renderModal({
|
|
313
|
+
handler: async args =>
|
|
314
|
+
args.piece === 'silence-check'
|
|
315
|
+
? { piece: 'silence-check', keeps: [[2.5, 4]] }
|
|
316
|
+
: defaultHandler(args),
|
|
317
|
+
})
|
|
318
|
+
await analyse()
|
|
319
|
+
expect(screen.getByText(/The independent silence check heard sound here/)).toBeTruthy()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('flags a removal that lands on a caption word and says which check fired', async () => {
|
|
323
|
+
renderModal({
|
|
324
|
+
project: makeProject({
|
|
325
|
+
captions: {
|
|
326
|
+
style: 'pop',
|
|
327
|
+
segments: [{ text: 'hello', start: 2.2, end: 2.6, words: [{ word: 'hello', start: 2.2, end: 2.6 }] }],
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
})
|
|
331
|
+
await analyse()
|
|
332
|
+
|
|
333
|
+
const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
|
|
334
|
+
expect(silence.checked).toBe(false)
|
|
335
|
+
expect(screen.getByText(/Overlaps a caption word\./)).toBeTruthy()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('says the caption check did not run when the project has no captions', async () => {
|
|
339
|
+
renderModal()
|
|
340
|
+
await analyse()
|
|
341
|
+
expect(screen.getByText(/no captions, so the caption word check did not run/)).toBeTruthy()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('says the cross check did not run when it fails, and still offers the removals', async () => {
|
|
345
|
+
renderModal({
|
|
346
|
+
handler: async args => {
|
|
347
|
+
if (args.piece === 'silence-check') throw new Error('ffmpeg missing')
|
|
348
|
+
return defaultHandler(args)
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
await analyse()
|
|
352
|
+
|
|
353
|
+
expect(screen.getByText(/independent silence check could not run/)).toBeTruthy()
|
|
354
|
+
const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
|
|
355
|
+
expect(silence.checked).toBe(true)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('AudioPolishModal — the transient preview', () => {
|
|
360
|
+
it('pushes a draft with the clip material removed, and puts it back when unticked', async () => {
|
|
361
|
+
const h = renderModal()
|
|
362
|
+
setPiece('Remove filler words', false)
|
|
363
|
+
setPiece('Match loudness', false)
|
|
364
|
+
await analyse()
|
|
365
|
+
|
|
366
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
367
|
+
// [2, 3] removed, then the gap closed: a 20s clip becomes 19s.
|
|
368
|
+
expect(totalDuration(h.last())).toBeCloseTo(19, 5)
|
|
369
|
+
|
|
370
|
+
fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
|
|
371
|
+
await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('rebuilds from the baseline rather than the previous draft, so a re-tick is exact', async () => {
|
|
375
|
+
const h = renderModal()
|
|
376
|
+
setPiece('Remove filler words', false)
|
|
377
|
+
setPiece('Match loudness', false)
|
|
378
|
+
await analyse()
|
|
379
|
+
|
|
380
|
+
const box = () => screen.getByRole('checkbox', { name: /Remove silence at/ })
|
|
381
|
+
fireEvent.click(box())
|
|
382
|
+
await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
|
|
383
|
+
fireEvent.click(box())
|
|
384
|
+
await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(19, 5))
|
|
385
|
+
fireEvent.click(box())
|
|
386
|
+
await waitFor(() => expect(totalDuration(h.last())).toBeCloseTo(20, 5))
|
|
387
|
+
// One whole clip again, not a pair of fragments: a rebuild from the previous
|
|
388
|
+
// draft rather than from the baseline would leave the clip split.
|
|
389
|
+
expect(firstTrackItems(h.last())).toHaveLength(1)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('rebuilds the draft when a piece is toggled off after analysis, with no re-analysis', async () => {
|
|
393
|
+
const h = renderModal()
|
|
394
|
+
await analyse()
|
|
395
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
396
|
+
expect(firstTrackItems(h.last())[0].volume).toBeDefined()
|
|
397
|
+
|
|
398
|
+
const before = h.spy.calls.length
|
|
399
|
+
setPiece('Match loudness', false)
|
|
400
|
+
await waitFor(() => expect(firstTrackItems(h.last())[0].volume).toBeUndefined())
|
|
401
|
+
expect(h.spy.calls.length).toBe(before)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('re-derives the gain from the measurement when the loudness target changes, without re-analysing', async () => {
|
|
405
|
+
const h = renderModal()
|
|
406
|
+
setPiece('Remove silence', false)
|
|
407
|
+
setPiece('Remove filler words', false)
|
|
408
|
+
await analyse()
|
|
409
|
+
|
|
410
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
411
|
+
// min(target - measuredI, -1.5 - measuredTP) = min(-14 + 20, -1.5 + 3) = 1.5 dB.
|
|
412
|
+
const atYoutube = firstTrackItems(h.last())[0].volume!
|
|
413
|
+
const before = h.spy.calls.length
|
|
414
|
+
|
|
415
|
+
fireEvent.change(screen.getByLabelText('Loudness target'), { target: { value: 'broadcast' } })
|
|
416
|
+
await waitFor(() => expect(firstTrackItems(h.last())[0].volume).not.toBe(atYoutube))
|
|
417
|
+
expect(h.spy.calls.length).toBe(before)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('re-pushes its draft when an external frame lands over the preview', async () => {
|
|
421
|
+
const h = renderModal()
|
|
422
|
+
await analyse()
|
|
423
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
424
|
+
|
|
425
|
+
const pushes = h.mutateTransient.mock.calls.length
|
|
426
|
+
const draftBefore = h.last()
|
|
427
|
+
h.rerenderWithProject(makeProject({ id: 'p1' }))
|
|
428
|
+
await waitFor(() => expect(h.mutateTransient.mock.calls.length).toBe(pushes + 1))
|
|
429
|
+
// The very same draft object goes back, not the incoming frame.
|
|
430
|
+
expect(h.last()).toBe(draftBefore)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
describe('AudioPolishModal — voice isolation', () => {
|
|
435
|
+
it('mutes the clip and adds one stem track when voice is on', async () => {
|
|
436
|
+
const h = renderModal()
|
|
437
|
+
setPiece('Remove silence', false)
|
|
438
|
+
setPiece('Remove filler words', false)
|
|
439
|
+
setPiece('Isolate voice', true)
|
|
440
|
+
await analyse()
|
|
441
|
+
|
|
442
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
443
|
+
const d = h.last()
|
|
444
|
+
expect(firstTrackItems(d)[0].muted).toBe(true)
|
|
445
|
+
expect(d.audio?.tracks.map(t => t.src)).toEqual(['/f/a_vocals.wav'])
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('mints one stem per surviving fragment when cuts split the clip', async () => {
|
|
449
|
+
const h = renderModal()
|
|
450
|
+
setPiece('Isolate voice', true)
|
|
451
|
+
await analyse()
|
|
452
|
+
|
|
453
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
454
|
+
const d = h.last()
|
|
455
|
+
// Two cuts leave three fragments; one audio track cannot follow all three,
|
|
456
|
+
// so the planner mints one stem each.
|
|
457
|
+
expect(firstTrackItems(d)).toHaveLength(3)
|
|
458
|
+
expect(d.audio?.tracks ?? []).toHaveLength(3)
|
|
459
|
+
expect(firstTrackItems(d).every(i => i.muted)).toBe(true)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('offers an A/B preview of the original and the isolated voice', async () => {
|
|
463
|
+
renderModal()
|
|
464
|
+
setPiece('Isolate voice', true)
|
|
465
|
+
await analyse()
|
|
466
|
+
|
|
467
|
+
expect(screen.getByLabelText('Original audio for a.mov').getAttribute('src')).toBe('url:/f/a.mov')
|
|
468
|
+
expect(screen.getByLabelText('Isolated voice for a.mov').getAttribute('src')).toBe('https://host/a_vocals.wav')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('shows the skip badge on a sped-up clip and still offers the other three pieces', async () => {
|
|
472
|
+
const h = renderModal({
|
|
473
|
+
project: makeProject({ tracks: vtracks([clip({ speed: 2, outPoint: 40 })]) }),
|
|
474
|
+
})
|
|
475
|
+
setPiece('Isolate voice', true)
|
|
476
|
+
await analyse()
|
|
477
|
+
|
|
478
|
+
expect(screen.getByText('Voice isolation unavailable: this clip does not play at normal speed')).toBeTruthy()
|
|
479
|
+
// The other three are still on the table for this clip.
|
|
480
|
+
expect(screen.getByRole('checkbox', { name: /Remove silence at/ })).toBeTruthy()
|
|
481
|
+
expect(screen.getByRole('checkbox', { name: /Remove filler "um" at/ })).toBeTruthy()
|
|
482
|
+
expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
|
|
483
|
+
|
|
484
|
+
// And nothing was muted or stemmed on it.
|
|
485
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
486
|
+
expect(firstTrackItems(h.last()).some(i => i.muted)).toBe(false)
|
|
487
|
+
expect(h.last().audio?.tracks ?? []).toEqual([])
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('AudioPolishModal — loudness column', () => {
|
|
492
|
+
it('reports the measurement and the resulting gain', async () => {
|
|
493
|
+
renderModal()
|
|
494
|
+
await analyse()
|
|
495
|
+
expect(screen.getByText(/measured -20.0 LUFS, true peak -3.0 dBTP\. Gain \+1.5 dB to reach -14 LUFS/)).toBeTruthy()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('says so when the +6 dB ceiling clamps the gain', async () => {
|
|
499
|
+
renderModal({
|
|
500
|
+
handler: async args =>
|
|
501
|
+
args.piece === 'loudness'
|
|
502
|
+
? { piece: 'loudness', measuredI: -40, measuredTP: -30, measuredLRA: 5, targetI: -14, gainDb: 26 }
|
|
503
|
+
: defaultHandler(args),
|
|
504
|
+
})
|
|
505
|
+
await analyse()
|
|
506
|
+
expect(screen.getByText(/Held at the \+6 dB ceiling/)).toBeTruthy()
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('AudioPolishModal — warnings and failures', () => {
|
|
511
|
+
it('warns when a clip would lose more than 60% of its length', async () => {
|
|
512
|
+
renderModal({
|
|
513
|
+
handler: async args =>
|
|
514
|
+
args.piece === 'silence'
|
|
515
|
+
? { piece: 'silence', removals: [{ start: 1, end: 16 }] }
|
|
516
|
+
: defaultHandler(args),
|
|
517
|
+
})
|
|
518
|
+
await analyse()
|
|
519
|
+
expect(screen.getByText(/would lose 77% of its length/)).toBeTruthy()
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('drops the warning again once enough removals are unticked', async () => {
|
|
523
|
+
renderModal({
|
|
524
|
+
handler: async args =>
|
|
525
|
+
args.piece === 'silence'
|
|
526
|
+
? { piece: 'silence', removals: [{ start: 1, end: 16 }] }
|
|
527
|
+
: defaultHandler(args),
|
|
528
|
+
})
|
|
529
|
+
await analyse()
|
|
530
|
+
fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
|
|
531
|
+
await waitFor(() => expect(screen.queryByText(/would lose/)).toBeNull())
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('reports one piece failing inline and leaves the others usable', async () => {
|
|
535
|
+
const h = renderModal({
|
|
536
|
+
handler: async args => {
|
|
537
|
+
if (args.piece === 'fillers') throw new Error('whisper model missing')
|
|
538
|
+
return defaultHandler(args)
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
await analyse()
|
|
542
|
+
|
|
543
|
+
expect(screen.getByText(/fillers could not be analysed: whisper model missing/)).toBeTruthy()
|
|
544
|
+
// Silence and loudness both survived.
|
|
545
|
+
const silence = screen.getByRole('checkbox', { name: /Remove silence at/ }) as HTMLInputElement
|
|
546
|
+
expect(silence.checked).toBe(true)
|
|
547
|
+
expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
|
|
548
|
+
expect(screen.queryByRole('checkbox', { name: /Remove filler "/ })).toBeNull()
|
|
549
|
+
|
|
550
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
551
|
+
expect(firstTrackItems(h.last())[0].volume).toBeDefined()
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
describe('AudioPolishModal — close paths', () => {
|
|
556
|
+
it('Cancel discards the transient draft and never commits', async () => {
|
|
557
|
+
const h = renderModal()
|
|
558
|
+
await analyse()
|
|
559
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
560
|
+
|
|
561
|
+
const pushes = h.mutateTransient.mock.calls.length
|
|
562
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
563
|
+
|
|
564
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
565
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
566
|
+
expect(h.onClose).toHaveBeenCalledTimes(1)
|
|
567
|
+
expect(h.mutateTransient.mock.calls.length).toBe(pushes)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('Cancel before any analysis touches nothing but discardTransient', () => {
|
|
571
|
+
const h = renderModal()
|
|
572
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
573
|
+
expect(h.mutateTransient).not.toHaveBeenCalled()
|
|
574
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
575
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('Escape discards, exactly like Cancel', () => {
|
|
579
|
+
const h = renderModal()
|
|
580
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
581
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
582
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
583
|
+
expect(h.onClose).toHaveBeenCalledTimes(1)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('the header close button discards', () => {
|
|
587
|
+
const h = renderModal()
|
|
588
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
589
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
590
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('Apply commits exactly once, even on a double click', async () => {
|
|
594
|
+
const h = renderModal()
|
|
595
|
+
await analyse()
|
|
596
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
597
|
+
|
|
598
|
+
const apply = screen.getByRole('button', { name: 'Apply' })
|
|
599
|
+
fireEvent.click(apply)
|
|
600
|
+
fireEvent.click(apply)
|
|
601
|
+
|
|
602
|
+
expect(h.commit).toHaveBeenCalledTimes(1)
|
|
603
|
+
expect(h.discardTransient).not.toHaveBeenCalled()
|
|
604
|
+
expect(h.onClose).toHaveBeenCalledTimes(1)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it('Apply on a polish that changes nothing discards instead of committing', async () => {
|
|
608
|
+
// Nothing found, and the two field-writing pieces off: the plan is empty, so
|
|
609
|
+
// `buildDraft` hands back the baseline itself. Committing that would push an
|
|
610
|
+
// undo entry whose undo does nothing visible.
|
|
611
|
+
const h = renderModal({
|
|
612
|
+
handler: async args =>
|
|
613
|
+
args.piece === 'silence' || args.piece === 'fillers'
|
|
614
|
+
? { piece: args.piece, removals: [] }
|
|
615
|
+
: defaultHandler(args),
|
|
616
|
+
})
|
|
617
|
+
setPiece('Match loudness', false)
|
|
618
|
+
setPiece('Isolate voice', false)
|
|
619
|
+
await analyse()
|
|
620
|
+
|
|
621
|
+
// The preview is the baseline, by reference.
|
|
622
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
623
|
+
expect(h.last()).toBe(h.baseline)
|
|
624
|
+
|
|
625
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
|
626
|
+
// `commit` is the ONLY path here that pushes an undo snapshot
|
|
627
|
+
// (use-project-sync.ts), so not calling it is what leaves canUndo unmoved.
|
|
628
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
629
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
630
|
+
expect(h.onClose).toHaveBeenCalledTimes(1)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('Apply still commits when the polish does change something', async () => {
|
|
634
|
+
const h = renderModal()
|
|
635
|
+
await analyse()
|
|
636
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
637
|
+
expect(h.last()).not.toBe(h.baseline)
|
|
638
|
+
|
|
639
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
|
640
|
+
expect(h.commit).toHaveBeenCalledTimes(1)
|
|
641
|
+
expect(h.discardTransient).not.toHaveBeenCalled()
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('Apply after unticking every removal discards, not commits', async () => {
|
|
645
|
+
const h = renderModal()
|
|
646
|
+
setPiece('Match loudness', false)
|
|
647
|
+
setPiece('Isolate voice', false)
|
|
648
|
+
await analyse()
|
|
649
|
+
|
|
650
|
+
fireEvent.click(screen.getByRole('checkbox', { name: /Remove silence at/ }))
|
|
651
|
+
fireEvent.click(screen.getByRole('checkbox', { name: /Remove filler "um" at/ }))
|
|
652
|
+
await waitFor(() => expect(h.last()).toBe(h.baseline))
|
|
653
|
+
|
|
654
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
|
655
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
656
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('Apply is unavailable until something has been analysed', () => {
|
|
660
|
+
renderModal()
|
|
661
|
+
expect((screen.getByRole('button', { name: 'Apply' }) as HTMLButtonElement).disabled).toBe(true)
|
|
662
|
+
})
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
describe('AudioPolishModal — UI copy', () => {
|
|
666
|
+
it('renders no em dash in the setup phase', () => {
|
|
667
|
+
renderModal()
|
|
668
|
+
expect(document.body.textContent).not.toContain('—')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('renders no em dash in the review phase, flags and warnings included', async () => {
|
|
672
|
+
renderModal({
|
|
673
|
+
project: makeProject({
|
|
674
|
+
tracks: vtracks([clip({ speed: 2, outPoint: 40 })]),
|
|
675
|
+
captions: {
|
|
676
|
+
style: 'pop',
|
|
677
|
+
segments: [{ text: 'hello', start: 1, end: 1.4 }],
|
|
678
|
+
},
|
|
679
|
+
}),
|
|
680
|
+
handler: async args => {
|
|
681
|
+
if (args.piece === 'silence') return { piece: 'silence', removals: [{ start: 1, end: 16 }] }
|
|
682
|
+
if (args.piece === 'loudness') throw new Error('measure failed')
|
|
683
|
+
return defaultHandler(args)
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
setPiece('Isolate voice', true)
|
|
687
|
+
await analyse()
|
|
688
|
+
|
|
689
|
+
expect(screen.getByText(/could not be analysed/)).toBeTruthy()
|
|
690
|
+
expect(document.body.textContent).not.toContain('—')
|
|
691
|
+
})
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
describe('AudioPolishModal — scope', () => {
|
|
695
|
+
it('narrows to the selection when one is given', async () => {
|
|
696
|
+
const h = renderModal({
|
|
697
|
+
project: makeProject({
|
|
698
|
+
tracks: vtracks([
|
|
699
|
+
clip({ id: 'a', src: '/f/a.mov' }),
|
|
700
|
+
clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40 }),
|
|
701
|
+
]),
|
|
702
|
+
}),
|
|
703
|
+
selectionIds: ['b'],
|
|
704
|
+
})
|
|
705
|
+
await analyse()
|
|
706
|
+
expect(new Set(h.spy.calls.map(c => c.src))).toEqual(new Set(['/f/b.mov']))
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('groups the review list by clip', async () => {
|
|
710
|
+
renderModal({
|
|
711
|
+
project: makeProject({
|
|
712
|
+
tracks: vtracks([
|
|
713
|
+
clip({ id: 'a', src: '/f/a.mov' }),
|
|
714
|
+
clip({ id: 'b', src: '/f/b.mov', start: 20, end: 40 }),
|
|
715
|
+
]),
|
|
716
|
+
}),
|
|
717
|
+
})
|
|
718
|
+
await analyse()
|
|
719
|
+
|
|
720
|
+
const dialog = screen.getByRole('dialog', { name: 'Polish audio' })
|
|
721
|
+
expect(within(dialog).getByText('a.mov')).toBeTruthy()
|
|
722
|
+
expect(within(dialog).getByText('b.mov')).toBeTruthy()
|
|
723
|
+
expect(within(dialog).getAllByRole('checkbox', { name: /Remove silence at/ })).toHaveLength(2)
|
|
724
|
+
})
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
// The four `pieceSupported` reasons, byte-exact. Asserting the literal here and
|
|
729
|
+
// not importing the constants is deliberate: these are user-visible strings, and
|
|
730
|
+
// a test that imports them would keep passing through a silent reword.
|
|
731
|
+
const VOICE_SPEED = 'Voice isolation unavailable: this clip does not play at normal speed'
|
|
732
|
+
const VOICE_LOOP = 'Voice isolation unavailable: this clip loops, so the isolated audio would drift out of sync'
|
|
733
|
+
const SILENCE_LOOP = 'Silence removal unavailable: this clip loops, so cuts would land in the wrong place'
|
|
734
|
+
const FILLERS_LOOP = 'Filler removal unavailable: this clip loops, so cuts would land in the wrong place'
|
|
735
|
+
|
|
736
|
+
describe('AudioPolishModal — looping clips', () => {
|
|
737
|
+
const looping = () => makeProject({
|
|
738
|
+
tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true })]),
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('badges all three excluded pieces and still offers loudness', async () => {
|
|
742
|
+
renderModal({ project: looping() })
|
|
743
|
+
setPiece('Isolate voice', true)
|
|
744
|
+
await analyse()
|
|
745
|
+
|
|
746
|
+
expect(screen.getByText(SILENCE_LOOP)).toBeTruthy()
|
|
747
|
+
expect(screen.getByText(FILLERS_LOOP)).toBeTruthy()
|
|
748
|
+
expect(screen.getByText(VOICE_LOOP)).toBeTruthy()
|
|
749
|
+
// Loudness needs no mapping, so it is not denied.
|
|
750
|
+
expect(screen.getByText(/Loudness: measured -20.0 LUFS/)).toBeTruthy()
|
|
751
|
+
expect(document.body.textContent).not.toContain('—')
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it('issues no silence, filler, cross check or voice job for a looping clip', async () => {
|
|
755
|
+
const h = renderModal({ project: looping() })
|
|
756
|
+
setPiece('Isolate voice', true)
|
|
757
|
+
await analyse()
|
|
758
|
+
|
|
759
|
+
expect(h.spy.calls.map(c => c.piece)).toEqual(['loudness'])
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('still applies the loudness gain to a looping clip', async () => {
|
|
763
|
+
const h = renderModal({ project: looping() })
|
|
764
|
+
await analyse()
|
|
765
|
+
|
|
766
|
+
await waitFor(() => expect(h.drafts.length).toBeGreaterThan(0))
|
|
767
|
+
expect(firstTrackItems(h.last())[0].volume).toBeDefined()
|
|
768
|
+
// No cuts, so the clip is untouched apart from its level.
|
|
769
|
+
expect(firstTrackItems(h.last())).toHaveLength(1)
|
|
770
|
+
expect(h.last().audio?.tracks ?? []).toEqual([])
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it('proposes no removals, so nothing is ticked and Apply discards', async () => {
|
|
774
|
+
const h = renderModal({ project: looping() })
|
|
775
|
+
setPiece('Match loudness', false)
|
|
776
|
+
setPiece('Isolate voice', false)
|
|
777
|
+
await analyse()
|
|
778
|
+
|
|
779
|
+
expect(screen.queryByRole('checkbox', { name: /Remove silence at/ })).toBeNull()
|
|
780
|
+
expect(screen.queryByRole('checkbox', { name: /Remove filler "/ })).toBeNull()
|
|
781
|
+
await waitFor(() => expect(h.last()).toBe(h.baseline))
|
|
782
|
+
|
|
783
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
|
784
|
+
expect(h.commit).not.toHaveBeenCalled()
|
|
785
|
+
expect(h.discardTransient).toHaveBeenCalledTimes(1)
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it('reports the LOOP reason on a clip that both loops and is sped up, never the speed one', async () => {
|
|
789
|
+
renderModal({
|
|
790
|
+
project: makeProject({
|
|
791
|
+
tracks: vtracks([clip({ start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true, speed: 2 })]),
|
|
792
|
+
}),
|
|
793
|
+
})
|
|
794
|
+
setPiece('Isolate voice', true)
|
|
795
|
+
await analyse()
|
|
796
|
+
|
|
797
|
+
expect(screen.getByText(VOICE_LOOP)).toBeTruthy()
|
|
798
|
+
// The whole point of the precedence: the speed wording must not appear at
|
|
799
|
+
// all. Asserting only that the loop string is present would pass with both
|
|
800
|
+
// rendered, which is the failure mode being guarded.
|
|
801
|
+
expect(screen.queryByText(VOICE_SPEED)).toBeNull()
|
|
802
|
+
expect(document.body.textContent).not.toContain(VOICE_SPEED)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('still runs the whole-source jobs when a non-looping clip shares the source', async () => {
|
|
806
|
+
// Availability is per clip, but `voice` and `silence-check` are whole-source
|
|
807
|
+
// and shared. One looping clip must not suppress the job the other needs.
|
|
808
|
+
const h = renderModal({
|
|
809
|
+
project: makeProject({
|
|
810
|
+
tracks: vtracks([
|
|
811
|
+
clip({ id: 'a', start: 0, end: 30, inPoint: 0, outPoint: 10, loop: true }),
|
|
812
|
+
clip({ id: 'b', start: 30, end: 40, inPoint: 0, outPoint: 10 }),
|
|
813
|
+
]),
|
|
814
|
+
}),
|
|
815
|
+
})
|
|
816
|
+
setPiece('Isolate voice', true)
|
|
817
|
+
await analyse()
|
|
818
|
+
|
|
819
|
+
expect(h.spy.calls.filter(c => c.piece === 'silence-check')).toHaveLength(1)
|
|
820
|
+
expect(h.spy.calls.filter(c => c.piece === 'voice')).toHaveLength(1)
|
|
821
|
+
// ...but only the non-looping clip gets the per-clip speech jobs.
|
|
822
|
+
expect(h.spy.calls.filter(c => c.piece === 'silence')).toHaveLength(1)
|
|
823
|
+
expect(h.spy.calls.filter(c => c.piece === 'fillers')).toHaveLength(1)
|
|
824
|
+
})
|
|
825
|
+
})
|