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