@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
|
@@ -4,8 +4,22 @@ import {
|
|
|
4
4
|
applyCutToItem,
|
|
5
5
|
collapseGaps,
|
|
6
6
|
splitAtTime,
|
|
7
|
+
rippleDelete,
|
|
8
|
+
rollEdit,
|
|
9
|
+
slipItem,
|
|
10
|
+
slideItem,
|
|
11
|
+
setClipSpeed,
|
|
7
12
|
} from '../cuts'
|
|
8
|
-
import type { EditorProject as Project } from '../../schema'
|
|
13
|
+
import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
|
|
14
|
+
import { audioTrackSourceWindow } from '../timeline/canvas/waveforms'
|
|
15
|
+
|
|
16
|
+
/** Object-shape visual tracks, carrying the ids `normalizeTracks` would hand
|
|
17
|
+
* out — so a fixture reads `vtracks([clipA, clipB], [overlay])`. The cut ops
|
|
18
|
+
* address tracks by INDEX, which the object shape leaves untouched, so every
|
|
19
|
+
* expectation below is the one the legacy `[[...]]` fixture asserted. */
|
|
20
|
+
function vtracks(...items: VisualItem[][]): VisualTrack[] {
|
|
21
|
+
return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
|
|
22
|
+
}
|
|
9
23
|
|
|
10
24
|
// Minimal project factory — only the fields the cut engine touches.
|
|
11
25
|
function makeProject(over: Partial<Project> = {}): Project {
|
|
@@ -13,45 +27,45 @@ function makeProject(over: Partial<Project> = {}): Project {
|
|
|
13
27
|
id: 'p1',
|
|
14
28
|
status: 'draft',
|
|
15
29
|
settings: { resolution: [1080, 1920] },
|
|
16
|
-
tracks: [
|
|
30
|
+
tracks: vtracks([]),
|
|
17
31
|
...over,
|
|
18
32
|
}
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
describe('applyCutToTracks (lift)', () => {
|
|
22
36
|
it('returns the project unchanged for a zero/negative-length cut', () => {
|
|
23
|
-
const p = makeProject({ tracks: [
|
|
37
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]) })
|
|
24
38
|
expect(applyCutToTracks(p, { start: 5, end: 5 })).toBe(p)
|
|
25
39
|
expect(applyCutToTracks(p, { start: 6, end: 3 })).toBe(p)
|
|
26
40
|
})
|
|
27
41
|
|
|
28
42
|
it('deletes a clip fully inside the cut and leaves later clips in place (no shift)', () => {
|
|
29
43
|
const p = makeProject({
|
|
30
|
-
tracks: [
|
|
44
|
+
tracks: vtracks([
|
|
31
45
|
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
32
46
|
{ id: 'b', type: 'video', start: 5, end: 10 },
|
|
33
|
-
]
|
|
47
|
+
]),
|
|
34
48
|
})
|
|
35
49
|
const out = applyCutToTracks(p, { start: 5, end: 10 })
|
|
36
|
-
const primary = out.tracks![0]
|
|
50
|
+
const primary = out.tracks![0].items
|
|
37
51
|
expect(primary.map(c => c.id)).toEqual(['a'])
|
|
38
52
|
expect(primary[0]).toMatchObject({ start: 0, end: 5 })
|
|
39
53
|
})
|
|
40
54
|
|
|
41
55
|
it('trims a clip overlapping the left edge of the cut', () => {
|
|
42
56
|
const p = makeProject({
|
|
43
|
-
tracks: [
|
|
57
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
|
|
44
58
|
})
|
|
45
59
|
const out = applyCutToTracks(p, { start: 6, end: 12 })
|
|
46
|
-
expect(out.tracks![0][0]).toMatchObject({ id: 'a', end: 6, outPoint: 6 })
|
|
60
|
+
expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', end: 6, outPoint: 6 })
|
|
47
61
|
})
|
|
48
62
|
|
|
49
63
|
it('splits a clip the cut spans into two fragments (lift positions)', () => {
|
|
50
64
|
const p = makeProject({
|
|
51
|
-
tracks: [
|
|
65
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
|
|
52
66
|
})
|
|
53
67
|
const out = applyCutToTracks(p, { start: 3, end: 7 })
|
|
54
|
-
const primary = out.tracks![0]
|
|
68
|
+
const primary = out.tracks![0].items
|
|
55
69
|
expect(primary).toHaveLength(2)
|
|
56
70
|
expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 3 })
|
|
57
71
|
expect(primary[1].start).toBe(7) // right fragment stays at original timeline pos
|
|
@@ -60,7 +74,7 @@ describe('applyCutToTracks (lift)', () => {
|
|
|
60
74
|
|
|
61
75
|
it('shifts captions after the cut by the cut duration', () => {
|
|
62
76
|
const p = makeProject({
|
|
63
|
-
tracks: [
|
|
77
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]),
|
|
64
78
|
captions: {
|
|
65
79
|
style: 'subtitle',
|
|
66
80
|
segments: [
|
|
@@ -78,65 +92,137 @@ describe('applyCutToTracks (lift)', () => {
|
|
|
78
92
|
it('leaves overlay tracks (tracks[1+]) untouched', () => {
|
|
79
93
|
const overlay = { id: 'o', type: 'overlay' as const, start: 4, end: 6 }
|
|
80
94
|
const p = makeProject({
|
|
81
|
-
tracks:
|
|
95
|
+
tracks: vtracks(
|
|
82
96
|
[{ id: 'a', type: 'video', start: 0, end: 10 }],
|
|
83
97
|
[overlay],
|
|
84
|
-
|
|
98
|
+
),
|
|
85
99
|
})
|
|
86
100
|
const out = applyCutToTracks(p, { start: 3, end: 7 })
|
|
87
|
-
expect(out.tracks![1][0]).toEqual(overlay)
|
|
101
|
+
expect(out.tracks![1].items[0]).toEqual(overlay)
|
|
88
102
|
})
|
|
89
103
|
})
|
|
90
104
|
|
|
91
105
|
describe('collapseGaps', () => {
|
|
92
106
|
it('returns the same reference when there are fewer than 2 clips', () => {
|
|
93
|
-
const p = makeProject({ tracks: [
|
|
107
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 5, end: 10 }]) })
|
|
94
108
|
expect(collapseGaps(p)).toBe(p)
|
|
95
109
|
})
|
|
96
110
|
|
|
97
111
|
it('returns the same reference when there are no gaps', () => {
|
|
98
112
|
const p = makeProject({
|
|
99
|
-
tracks: [
|
|
113
|
+
tracks: vtracks([
|
|
100
114
|
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
101
115
|
{ id: 'b', type: 'video', start: 5, end: 10 },
|
|
102
|
-
]
|
|
116
|
+
]),
|
|
103
117
|
})
|
|
104
118
|
expect(collapseGaps(p)).toBe(p)
|
|
105
119
|
})
|
|
106
120
|
|
|
107
121
|
it('shifts clips left to close gaps and remaps captions', () => {
|
|
108
122
|
const p = makeProject({
|
|
109
|
-
tracks: [
|
|
123
|
+
tracks: vtracks([
|
|
110
124
|
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
111
125
|
{ id: 'b', type: 'video', start: 8, end: 12 },
|
|
112
|
-
]
|
|
126
|
+
]),
|
|
113
127
|
captions: {
|
|
114
128
|
style: 'subtitle',
|
|
115
129
|
segments: [{ text: 'b-cap', start: 9, end: 11, words: [{ word: 'x', start: 9, end: 11 }] }],
|
|
116
130
|
},
|
|
117
131
|
})
|
|
118
132
|
const out = collapseGaps(p)
|
|
119
|
-
const primary = out.tracks![0]
|
|
133
|
+
const primary = out.tracks![0].items
|
|
120
134
|
expect(primary[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
|
|
121
135
|
expect(primary[1]).toMatchObject({ id: 'b', start: 5, end: 9 }) // shifted left by 3
|
|
122
136
|
const cap = out.captions!.segments[0]
|
|
123
137
|
expect(cap).toMatchObject({ start: 6, end: 8 })
|
|
124
138
|
expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
|
|
125
139
|
})
|
|
140
|
+
|
|
141
|
+
// The caption remap is opt-OUT: every existing call site passes no options and
|
|
142
|
+
// must keep behaving exactly as the test above pins. Only the audio-polish
|
|
143
|
+
// composition, where `applyCutToTracks` has already rippled the captions,
|
|
144
|
+
// passes `remapCaptions: false` — see the invariant comment in cuts.ts.
|
|
145
|
+
describe('remapCaptions option', () => {
|
|
146
|
+
/** Two clips with a 3s gap and one caption sitting over the LATER clip, so a
|
|
147
|
+
* caption remap is unambiguously visible (it moves 3s left) or absent. */
|
|
148
|
+
function gapped() {
|
|
149
|
+
return makeProject({
|
|
150
|
+
tracks: vtracks([
|
|
151
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
152
|
+
{ id: 'b', type: 'video', start: 8, end: 12 },
|
|
153
|
+
]),
|
|
154
|
+
captions: {
|
|
155
|
+
style: 'subtitle',
|
|
156
|
+
segments: [{ text: 'b-cap', start: 9, end: 11, words: [{ word: 'x', start: 9, end: 11 }] }],
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it('an empty options object is the same as no options at all', () => {
|
|
162
|
+
const p = gapped()
|
|
163
|
+
expect(collapseGaps(p, {})).toEqual(collapseGaps(p))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('remapCaptions: true is the default and remaps captions', () => {
|
|
167
|
+
const out = collapseGaps(gapped(), { remapCaptions: true })
|
|
168
|
+
expect(out.tracks![0].items[1]).toMatchObject({ id: 'b', start: 5, end: 9 })
|
|
169
|
+
const cap = out.captions!.segments[0]
|
|
170
|
+
expect(cap).toMatchObject({ start: 6, end: 8 })
|
|
171
|
+
expect(cap.words![0]).toMatchObject({ start: 6, end: 8 })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('remapCaptions: false closes clip gaps but leaves captions untouched', () => {
|
|
175
|
+
const p = gapped()
|
|
176
|
+
const out = collapseGaps(p, { remapCaptions: false })
|
|
177
|
+
expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
|
|
178
|
+
expect(out.tracks![0].items[1]).toMatchObject({ id: 'b', start: 5, end: 9 })
|
|
179
|
+
// The whole captions object is passed through by reference — no segment,
|
|
180
|
+
// and no word inside a segment, is rebuilt.
|
|
181
|
+
expect(out.captions).toBe(p.captions)
|
|
182
|
+
expect(out.captions!.segments[0]).toMatchObject({ start: 9, end: 11 })
|
|
183
|
+
expect(out.captions!.segments[0].words![0]).toMatchObject({ start: 9, end: 11 })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('remapCaptions: false still remaps OVERLAY tracks', () => {
|
|
187
|
+
const p = makeProject({
|
|
188
|
+
tracks: vtracks(
|
|
189
|
+
[
|
|
190
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
191
|
+
{ id: 'b', type: 'video', start: 8, end: 12 },
|
|
192
|
+
],
|
|
193
|
+
[{ id: 'o', type: 'overlay', start: 9, end: 11 }],
|
|
194
|
+
),
|
|
195
|
+
captions: { style: 'subtitle', segments: [{ text: 'b-cap', start: 9, end: 11 }] },
|
|
196
|
+
})
|
|
197
|
+
const out = collapseGaps(p, { remapCaptions: false })
|
|
198
|
+
expect(out.tracks![1].items[0]).toMatchObject({ id: 'o', start: 6, end: 8 })
|
|
199
|
+
expect(out.captions!.segments[0]).toMatchObject({ start: 9, end: 11 })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('remapCaptions: false still returns the same reference when there is no gap', () => {
|
|
203
|
+
const p = makeProject({
|
|
204
|
+
tracks: vtracks([
|
|
205
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
206
|
+
{ id: 'b', type: 'video', start: 5, end: 10 },
|
|
207
|
+
]),
|
|
208
|
+
})
|
|
209
|
+
expect(collapseGaps(p, { remapCaptions: false })).toBe(p)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
126
212
|
})
|
|
127
213
|
|
|
128
214
|
describe('applyCutToItem (collapse, single item)', () => {
|
|
129
215
|
it('returns the project unchanged when itemId is not found', () => {
|
|
130
|
-
const p = makeProject({ tracks: [
|
|
216
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10 }]) })
|
|
131
217
|
expect(applyCutToItem(p, 'nope', { start: 2, end: 4 })).toBe(p)
|
|
132
218
|
})
|
|
133
219
|
|
|
134
220
|
it('collapses a middle cut within a single primary clip (right fragment butts left)', () => {
|
|
135
221
|
const p = makeProject({
|
|
136
|
-
tracks: [
|
|
222
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
|
|
137
223
|
})
|
|
138
224
|
const out = applyCutToItem(p, 'a', { start: 3, end: 7 })
|
|
139
|
-
const primary = out.tracks![0]
|
|
225
|
+
const primary = out.tracks![0].items
|
|
140
226
|
expect(primary).toHaveLength(2)
|
|
141
227
|
expect(primary[0]).toMatchObject({ start: 0, end: 3, outPoint: 3 })
|
|
142
228
|
// right fragment collapses: starts at cut.start (3), duration = remaining 3s
|
|
@@ -145,10 +231,10 @@ describe('applyCutToItem (collapse, single item)', () => {
|
|
|
145
231
|
|
|
146
232
|
it('clamps the cut to the item bounds', () => {
|
|
147
233
|
const p = makeProject({
|
|
148
|
-
tracks: [
|
|
234
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 0, outPoint: 6 }]),
|
|
149
235
|
})
|
|
150
236
|
const out = applyCutToItem(p, 'a', { start: 0, end: 4 })
|
|
151
|
-
const primary = out.tracks![0]
|
|
237
|
+
const primary = out.tracks![0].items
|
|
152
238
|
// cut clamped to [2,4]; left fragment removed (nothing before 2), right collapses to start 2
|
|
153
239
|
expect(primary[0]).toMatchObject({ start: 2 })
|
|
154
240
|
})
|
|
@@ -156,16 +242,16 @@ describe('applyCutToItem (collapse, single item)', () => {
|
|
|
156
242
|
|
|
157
243
|
describe('splitAtTime', () => {
|
|
158
244
|
it('returns the same reference when nothing contains the playhead', () => {
|
|
159
|
-
const p = makeProject({ tracks: [
|
|
245
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
|
|
160
246
|
expect(splitAtTime(p, 8, null)).toBe(p)
|
|
161
247
|
})
|
|
162
248
|
|
|
163
249
|
it('splits every clip containing `at` when itemId is null', () => {
|
|
164
250
|
const p = makeProject({
|
|
165
|
-
tracks: [
|
|
251
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 }]),
|
|
166
252
|
})
|
|
167
253
|
const out = splitAtTime(p, 4, null)
|
|
168
|
-
const primary = out.tracks![0]
|
|
254
|
+
const primary = out.tracks![0].items
|
|
169
255
|
expect(primary).toHaveLength(2)
|
|
170
256
|
expect(primary[0]).toMatchObject({ start: 0, end: 4 })
|
|
171
257
|
expect(primary[1]).toMatchObject({ start: 4 })
|
|
@@ -173,21 +259,21 @@ describe('splitAtTime', () => {
|
|
|
173
259
|
|
|
174
260
|
it('splits only the named item when itemId is given', () => {
|
|
175
261
|
const p = makeProject({
|
|
176
|
-
tracks: [
|
|
262
|
+
tracks: vtracks([
|
|
177
263
|
{ id: 'a', type: 'video', start: 0, end: 10 },
|
|
178
264
|
{ id: 'b', type: 'video', start: 0, end: 10 },
|
|
179
|
-
]
|
|
265
|
+
]),
|
|
180
266
|
})
|
|
181
267
|
const out = splitAtTime(p, 5, 'a')
|
|
182
268
|
// 'a' split into 2 fragments (original id + split id); 'b' untouched.
|
|
183
|
-
expect(out.tracks![0].filter(c => c.id.startsWith('a'))).toHaveLength(2)
|
|
184
|
-
expect(out.tracks![0].filter(c => c.id === 'b')).toHaveLength(1)
|
|
185
|
-
expect(out.tracks![0]).toHaveLength(3)
|
|
269
|
+
expect(out.tracks![0].items.filter(c => c.id.startsWith('a'))).toHaveLength(2)
|
|
270
|
+
expect(out.tracks![0].items.filter(c => c.id === 'b')).toHaveLength(1)
|
|
271
|
+
expect(out.tracks![0].items).toHaveLength(3)
|
|
186
272
|
})
|
|
187
273
|
|
|
188
274
|
it('splits audio tracks containing `at`', () => {
|
|
189
275
|
const p = makeProject({
|
|
190
|
-
tracks: [
|
|
276
|
+
tracks: vtracks([]),
|
|
191
277
|
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10, inPoint: 0 }] },
|
|
192
278
|
})
|
|
193
279
|
const out = splitAtTime(p, 4, null)
|
|
@@ -195,4 +281,1097 @@ describe('splitAtTime', () => {
|
|
|
195
281
|
expect(out.audio!.tracks[0]).toMatchObject({ end: 4, outPoint: 4 })
|
|
196
282
|
expect(out.audio!.tracks[1]).toMatchObject({ start: 4, inPoint: 4 })
|
|
197
283
|
})
|
|
284
|
+
|
|
285
|
+
it('both split halves resolve a positive waveform window when the source has no outPoint/sourceDuration', () => {
|
|
286
|
+
// The bug: the new (right) fragment rendered as a solid block with no
|
|
287
|
+
// waveform. Its source window came out truncated — and, for a split PAST the
|
|
288
|
+
// midpoint, non-positive — so the waveform painter skipped it. Split a
|
|
289
|
+
// 0..10 track (no outPoint, no sourceDuration) at 7, well past the midpoint.
|
|
290
|
+
const p = makeProject({
|
|
291
|
+
tracks: vtracks([]),
|
|
292
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10 }] },
|
|
293
|
+
})
|
|
294
|
+
const [left, right] = splitAtTime(p, 7, null).audio!.tracks
|
|
295
|
+
|
|
296
|
+
// Both halves carry an explicit, fully-specified source window.
|
|
297
|
+
expect(left).toMatchObject({ start: 0, end: 7, inPoint: 0, outPoint: 7 })
|
|
298
|
+
expect(right).toMatchObject({ start: 7, end: 10, inPoint: 7, outPoint: 10 })
|
|
299
|
+
|
|
300
|
+
// Both halves resolve to a positive waveform window (the right one used to be
|
|
301
|
+
// negative here → painter drew nothing → blank block).
|
|
302
|
+
const lw = audioTrackSourceWindow(left)
|
|
303
|
+
const rw = audioTrackSourceWindow(right)
|
|
304
|
+
expect(lw).toEqual({ start: 0, duration: 7 })
|
|
305
|
+
expect(rw).toEqual({ start: 7, duration: 3 })
|
|
306
|
+
expect(rw.duration).toBeGreaterThan(0)
|
|
307
|
+
|
|
308
|
+
// The two windows tile the original source range [0,10] with no gap/overlap.
|
|
309
|
+
expect(lw.start + lw.duration).toBeCloseTo(rw.start)
|
|
310
|
+
expect(lw.duration + rw.duration).toBeCloseTo(10)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('carries an existing trim window across a split (both halves stay within source)', () => {
|
|
314
|
+
// A pre-trimmed track: source window [2,12] mapped onto timeline [0,10].
|
|
315
|
+
const p = makeProject({
|
|
316
|
+
tracks: vtracks([]),
|
|
317
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10, inPoint: 2, outPoint: 12 }] },
|
|
318
|
+
})
|
|
319
|
+
const [left, right] = splitAtTime(p, 6, null).audio!.tracks
|
|
320
|
+
// Split at timeline 6 → source 2 + 6 = 8.
|
|
321
|
+
expect(left).toMatchObject({ start: 0, end: 6, inPoint: 2, outPoint: 8 })
|
|
322
|
+
expect(right).toMatchObject({ start: 6, end: 10, inPoint: 8, outPoint: 12 })
|
|
323
|
+
expect(audioTrackSourceWindow(left)).toEqual({ start: 2, duration: 6 })
|
|
324
|
+
expect(audioTrackSourceWindow(right)).toEqual({ start: 8, duration: 4 })
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// SP9b terminator fix — splitClip and cutSingleItem (behind applyCutToItem)
|
|
329
|
+
// both used to spread `...item` for both fragments, so both inherited the
|
|
330
|
+
// SAME `keyframes` array object with unchanged item-relative `t`. The right
|
|
331
|
+
// fragment's `start` moves, so its curve restarted there and every point past
|
|
332
|
+
// its shortened span went dead. Shared by both describe blocks below —
|
|
333
|
+
// splitAtTime (lift: a point cut, right fragment stays at cut.end) and
|
|
334
|
+
// applyCutToItem (collapse: a ranged cut, right fragment moves to cut.start)
|
|
335
|
+
// — so the same fixture exercises both call sites of `splitKeyframeTracks`.
|
|
336
|
+
function keyframedOverlay(over: Partial<VisualItem> = {}): VisualItem {
|
|
337
|
+
return {
|
|
338
|
+
id: 'o0', type: 'overlay', start: 0, end: 10,
|
|
339
|
+
keyframes: [
|
|
340
|
+
{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 3, value: 30 }, { t: 7, value: 70 }, { t: 10, value: 100 }] },
|
|
341
|
+
{ prop: 'opacity', points: [{ t: 3, value: 1 }] },
|
|
342
|
+
],
|
|
343
|
+
...over,
|
|
344
|
+
} as VisualItem
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
describe('splitAtTime — overlay keyframes', () => {
|
|
348
|
+
it('gives each fragment its OWN keyframes array — never the same reference as the other fragment or the original item', () => {
|
|
349
|
+
const overlay = keyframedOverlay()
|
|
350
|
+
const p = makeProject({ tracks: vtracks([], [overlay]) })
|
|
351
|
+
const [left, right] = splitAtTime(p, 5, 'o0').tracks![1].items
|
|
352
|
+
|
|
353
|
+
expect(left.keyframes).toBeDefined()
|
|
354
|
+
expect(right.keyframes).toBeDefined()
|
|
355
|
+
expect(left.keyframes).not.toBe(right.keyframes)
|
|
356
|
+
expect(left.keyframes).not.toBe(overlay.keyframes)
|
|
357
|
+
expect(right.keyframes).not.toBe(overlay.keyframes)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("drops points outside each fragment's new span", () => {
|
|
361
|
+
const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
|
|
362
|
+
const [left, right] = splitAtTime(p, 5, 'o0').tracks![1].items
|
|
363
|
+
|
|
364
|
+
// Left is now [0,5): keeps t=0,3 (<=5); drops t=7,10.
|
|
365
|
+
const leftOffsetX = left.keyframes!.find(t => t.prop === 'offsetX')!
|
|
366
|
+
expect(leftOffsetX.points.map(pt => pt.t)).toEqual([0, 3])
|
|
367
|
+
|
|
368
|
+
// Right is now [5,10): keeps t=7,10 (>=5), re-anchored by -5; drops t=0,3.
|
|
369
|
+
const rightOffsetX = right.keyframes!.find(t => t.prop === 'offsetX')!
|
|
370
|
+
expect(rightOffsetX.points.map(pt => pt.t)).toEqual([2, 5])
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it("re-anchors the right fragment's remaining points so the animation lands at the same wall-clock instants as before the split", () => {
|
|
374
|
+
const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
|
|
375
|
+
const [, right] = splitAtTime(p, 5, 'o0').tracks![1].items
|
|
376
|
+
|
|
377
|
+
expect(right.start).toBe(5) // splitAtTime moves the right fragment's start to the cut
|
|
378
|
+
const rightOffsetX = right.keyframes!.find(t => t.prop === 'offsetX')!
|
|
379
|
+
// Original point at t=7 (wall-clock 0+7=7). On the new fragment it's t=2,
|
|
380
|
+
// and 2 + the fragment's own start (5) is still 7 — same instant, same value.
|
|
381
|
+
const pt = rightOffsetX.points.find(pt => pt.t === 2)!
|
|
382
|
+
expect(pt.value).toBe(70)
|
|
383
|
+
expect(right.start + pt.t).toBe(7)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('a point exactly at the split lands on BOTH fragments, keeping the curve continuous across the cut', () => {
|
|
387
|
+
const p = makeProject({ tracks: vtracks([], [keyframedOverlay()]) })
|
|
388
|
+
const [left, right] = splitAtTime(p, 3, 'o0').tracks![1].items
|
|
389
|
+
|
|
390
|
+
const leftOpacity = left.keyframes!.find(t => t.prop === 'opacity')!
|
|
391
|
+
const rightOpacity = right.keyframes!.find(t => t.prop === 'opacity')!
|
|
392
|
+
expect(leftOpacity.points).toEqual([{ t: 3, value: 1 }])
|
|
393
|
+
expect(rightOpacity.points).toEqual([{ t: 0, value: 1 }]) // re-anchored: 3 - 3 = 0
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('a keyframe-less overlay is unaffected — no stray `keyframes` field appears on either fragment', () => {
|
|
397
|
+
const p = makeProject({ tracks: vtracks([], [{ id: 'o1', type: 'overlay', start: 0, end: 10 } as VisualItem]) })
|
|
398
|
+
const [left, right] = splitAtTime(p, 5, 'o1').tracks![1].items
|
|
399
|
+
|
|
400
|
+
expect(left.keyframes).toBeUndefined()
|
|
401
|
+
expect(right.keyframes).toBeUndefined()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('a keyframed VIDEO clip splits and re-anchors like an overlay (SP9d)', () => {
|
|
405
|
+
// Was "keyframes stay overlay-only". Clips read `item.keyframes` now, so a
|
|
406
|
+
// shared array between fragments — or a right fragment whose points are
|
|
407
|
+
// still anchored to the ORIGINAL start — is a real bug, not a harmless
|
|
408
|
+
// stray. `canKeyframe` gates this code and widened, so it already applies.
|
|
409
|
+
const original = { prop: 'offsetX' as const, points: [{ t: 0, value: 0 }, { t: 8, value: 80 }] }
|
|
410
|
+
const p = makeProject({
|
|
411
|
+
tracks: vtracks([{
|
|
412
|
+
id: 'v0', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10,
|
|
413
|
+
keyframes: [original],
|
|
414
|
+
} as VisualItem]),
|
|
415
|
+
})
|
|
416
|
+
const [left, right] = splitAtTime(p, 5, 'v0').tracks![0].items
|
|
417
|
+
|
|
418
|
+
// Separate arrays — a shared one would let an edit to either fragment
|
|
419
|
+
// silently mutate the other.
|
|
420
|
+
expect(left.keyframes).not.toBe(right.keyframes)
|
|
421
|
+
// The right fragment's remaining point is re-anchored to ITS OWN start:
|
|
422
|
+
// t=8 on the original is t=3 once the fragment begins at 5.
|
|
423
|
+
const rightPoints = right.keyframes!.find(k => k.prop === 'offsetX')!.points
|
|
424
|
+
expect(rightPoints.some(pt => pt.t === 3)).toBe(true)
|
|
425
|
+
expect(rightPoints.some(pt => pt.t === 8)).toBe(false)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// `cutSingleItem` (behind applyCutToItem) had the IDENTICAL bare-`...item`
|
|
430
|
+
// spread `splitClip` did, so it shared one keyframes array between fragments
|
|
431
|
+
// and left the right fragment's points anchored to the original item's start.
|
|
432
|
+
// Fixing only one of two identical call sites would have been worse than
|
|
433
|
+
// fixing neither: cutting an overlay, rather than splitting it, would have
|
|
434
|
+
// silently produced a wrong animation.
|
|
435
|
+
describe('applyCutToItem — overlay keyframes', () => {
|
|
436
|
+
it('gives each fragment its own array and drops points inside the removed range', () => {
|
|
437
|
+
const overlay = keyframedOverlay()
|
|
438
|
+
const p = makeProject({ tracks: vtracks([], [overlay]) })
|
|
439
|
+
// Remove [4,6]. Left keeps t<=4 (0,3); right keeps t>=6 (7,10).
|
|
440
|
+
const items = applyCutToItem(p, 'o0', { start: 4, end: 6 }).tracks![1].items
|
|
441
|
+
expect(items).toHaveLength(2)
|
|
442
|
+
const [left, right] = items
|
|
443
|
+
|
|
444
|
+
expect(left.keyframes).not.toBe(right.keyframes)
|
|
445
|
+
expect(left.keyframes).not.toBe(overlay.keyframes)
|
|
446
|
+
expect(right.keyframes).not.toBe(overlay.keyframes)
|
|
447
|
+
|
|
448
|
+
const leftX = left.keyframes!.find(t => t.prop === 'offsetX')!
|
|
449
|
+
expect(leftX.points.map(pt => pt.t)).toEqual([0, 3])
|
|
450
|
+
// opacity's only point is t=3, which survives on the left only.
|
|
451
|
+
expect(left.keyframes!.some(t => t.prop === 'opacity')).toBe(true)
|
|
452
|
+
expect(right.keyframes!.some(t => t.prop === 'opacity')).toBe(false)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('re-anchors the right fragment so its points keep their wall-clock instants', () => {
|
|
456
|
+
const overlay = keyframedOverlay()
|
|
457
|
+
const p = makeProject({ tracks: vtracks([], [overlay]) })
|
|
458
|
+
const [, right] = applyCutToItem(p, 'o0', { start: 4, end: 6 }).tracks![1].items
|
|
459
|
+
|
|
460
|
+
// Right lifts to start=4 and shows original content from t=6 onward, so
|
|
461
|
+
// the original t=7 and t=10 points re-anchor to 1 and 4. Their absolute
|
|
462
|
+
// instants are then 4+1=5 and 4+4=8 — the same offsets into the surviving
|
|
463
|
+
// content they always had, now that 2s were removed.
|
|
464
|
+
const rightX = right.keyframes!.find(t => t.prop === 'offsetX')!
|
|
465
|
+
expect(right.start).toBe(4)
|
|
466
|
+
expect(rightX.points.map(pt => pt.t)).toEqual([1, 4])
|
|
467
|
+
expect(rightX.points.map(pt => pt.value)).toEqual([70, 100])
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('leaves a keyframe-less overlay and a non-overlay item untouched', () => {
|
|
471
|
+
const plain = { id: 'o1', type: 'overlay', start: 0, end: 10 } as VisualItem
|
|
472
|
+
const vid = { id: 'a', type: 'video', start: 0, end: 10, inPoint: 0, outPoint: 10 } as VisualItem
|
|
473
|
+
const p = makeProject({ tracks: vtracks([vid], [plain]) })
|
|
474
|
+
const out = applyCutToItem(p, 'o1', { start: 4, end: 6 })
|
|
475
|
+
for (const it of out.tracks![1].items) expect(it.keyframes).toBeUndefined()
|
|
476
|
+
const outVid = applyCutToItem(p, 'a', { start: 4, end: 6 })
|
|
477
|
+
for (const it of outVid.tracks![0].items) expect(it.keyframes).toBeUndefined()
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
describe('rippleDelete', () => {
|
|
482
|
+
it('returns the same reference when the item is not found', () => {
|
|
483
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
|
|
484
|
+
expect(rippleDelete(p, 'nope')).toBe(p)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('returns the same reference for an audio-track id (audio lanes are not ripple targets)', () => {
|
|
488
|
+
const p = makeProject({
|
|
489
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]),
|
|
490
|
+
audio: { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] },
|
|
491
|
+
})
|
|
492
|
+
expect(rippleDelete(p, 'au')).toBe(p)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('deletes the item and pulls later clips earlier by its duration', () => {
|
|
496
|
+
const p = makeProject({
|
|
497
|
+
tracks: vtracks([
|
|
498
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
499
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
500
|
+
{ id: 'c', type: 'video', start: 9, end: 14 },
|
|
501
|
+
]),
|
|
502
|
+
})
|
|
503
|
+
const out = rippleDelete(p, 'b')
|
|
504
|
+
expect(out.tracks![0].items.map(c => c.id)).toEqual(['a', 'c'])
|
|
505
|
+
expect(out.tracks![0].items[0]).toMatchObject({ start: 0, end: 5 })
|
|
506
|
+
expect(out.tracks![0].items[1]).toMatchObject({ start: 5, end: 10 })
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('shifts only content after the deletion point — earlier gaps survive (unlike collapseGaps)', () => {
|
|
510
|
+
const p = makeProject({
|
|
511
|
+
tracks: vtracks([
|
|
512
|
+
{ id: 'a', type: 'video', start: 0, end: 3 },
|
|
513
|
+
{ id: 'b', type: 'video', start: 6, end: 10 }, // 3s gap before b
|
|
514
|
+
{ id: 'c', type: 'video', start: 10, end: 15 },
|
|
515
|
+
]),
|
|
516
|
+
})
|
|
517
|
+
const out = rippleDelete(p, 'b')
|
|
518
|
+
expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 3 })
|
|
519
|
+
expect(out.tracks![0].items[1]).toMatchObject({ id: 'c', start: 6, end: 11 })
|
|
520
|
+
// collapseGaps would additionally have normalized the 3–6 gap away
|
|
521
|
+
expect(collapseGaps(out).tracks![0].items[1]).toMatchObject({ id: 'c', start: 3 })
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('deletes captions inside the removed window and shifts later ones', () => {
|
|
525
|
+
const p = makeProject({
|
|
526
|
+
tracks: vtracks([
|
|
527
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
528
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
529
|
+
{ id: 'c', type: 'video', start: 9, end: 14 },
|
|
530
|
+
]),
|
|
531
|
+
captions: {
|
|
532
|
+
style: 'subtitle',
|
|
533
|
+
segments: [
|
|
534
|
+
{ text: 'a-cap', start: 1, end: 3 },
|
|
535
|
+
{ text: 'b-cap', start: 6, end: 8 },
|
|
536
|
+
{ text: 'c-cap', start: 10, end: 12, words: [{ word: 'x', start: 10, end: 12 }] },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
const segs = rippleDelete(p, 'b').captions!.segments
|
|
541
|
+
expect(segs.map(s => s.text)).toEqual(['a-cap', 'c-cap'])
|
|
542
|
+
expect(segs[0]).toMatchObject({ start: 1, end: 3 })
|
|
543
|
+
expect(segs[1]).toMatchObject({ start: 6, end: 8 })
|
|
544
|
+
expect(segs[1].words![0]).toMatchObject({ start: 6, end: 8 })
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('shifts overlay-track items after the deletion point and leaves earlier ones put', () => {
|
|
548
|
+
const p = makeProject({
|
|
549
|
+
tracks: vtracks(
|
|
550
|
+
[
|
|
551
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
552
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
553
|
+
{ id: 'c', type: 'video', start: 9, end: 14 },
|
|
554
|
+
],
|
|
555
|
+
[
|
|
556
|
+
{ id: 'o1', type: 'overlay', start: 1, end: 3 },
|
|
557
|
+
{ id: 'o2', type: 'overlay', start: 10, end: 12 },
|
|
558
|
+
],
|
|
559
|
+
),
|
|
560
|
+
})
|
|
561
|
+
const out = rippleDelete(p, 'b')
|
|
562
|
+
expect(out.tracks![1].items[0]).toMatchObject({ id: 'o1', start: 1, end: 3 })
|
|
563
|
+
expect(out.tracks![1].items[1]).toMatchObject({ id: 'o2', start: 6, end: 8 })
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('leaves items that begin inside the deleted window put — only later ones are subsequent', () => {
|
|
567
|
+
const p = makeProject({
|
|
568
|
+
tracks: vtracks(
|
|
569
|
+
[
|
|
570
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
571
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
572
|
+
{ id: 'c', type: 'video', start: 9, end: 14 },
|
|
573
|
+
],
|
|
574
|
+
[
|
|
575
|
+
{ id: 'inside', type: 'overlay', start: 6, end: 8 }, // starts within [5,9)
|
|
576
|
+
{ id: 'atStart', type: 'overlay', start: 5, end: 11 }, // starts on the deletion point
|
|
577
|
+
{ id: 'atEnd', type: 'overlay', start: 9, end: 11 }, // starts on the deletion end
|
|
578
|
+
],
|
|
579
|
+
),
|
|
580
|
+
})
|
|
581
|
+
const overlays = rippleDelete(p, 'b').tracks![1].items
|
|
582
|
+
expect(overlays[0]).toMatchObject({ id: 'inside', start: 6, end: 8 })
|
|
583
|
+
expect(overlays[1]).toMatchObject({ id: 'atStart', start: 5, end: 11 })
|
|
584
|
+
expect(overlays[2]).toMatchObject({ id: 'atEnd', start: 5, end: 7 })
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('leaves audio tracks untouched (matching collapseGaps)', () => {
|
|
588
|
+
const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20, inPoint: 0 }] }
|
|
589
|
+
const p = makeProject({
|
|
590
|
+
tracks: vtracks([
|
|
591
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
592
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
593
|
+
]),
|
|
594
|
+
audio,
|
|
595
|
+
})
|
|
596
|
+
expect(rippleDelete(p, 'b').audio).toBe(audio)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('pulls the remaining clips to the front when the first item is deleted', () => {
|
|
600
|
+
const p = makeProject({
|
|
601
|
+
tracks: vtracks([
|
|
602
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
603
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
604
|
+
]),
|
|
605
|
+
})
|
|
606
|
+
const out = rippleDelete(p, 'a')
|
|
607
|
+
expect(out.tracks![0].items).toHaveLength(1)
|
|
608
|
+
expect(out.tracks![0].items[0]).toMatchObject({ id: 'b', start: 0, end: 4 })
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('only removes the item when it is last — there is nothing to shift', () => {
|
|
612
|
+
const p = makeProject({
|
|
613
|
+
tracks: vtracks([
|
|
614
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
615
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
616
|
+
]),
|
|
617
|
+
})
|
|
618
|
+
const out = rippleDelete(p, 'b')
|
|
619
|
+
expect(out.tracks![0].items).toHaveLength(1)
|
|
620
|
+
expect(out.tracks![0].items[0]).toMatchObject({ id: 'a', start: 0, end: 5 })
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('moves shifted clips on the timeline only — source points are preserved', () => {
|
|
624
|
+
const p = makeProject({
|
|
625
|
+
tracks: vtracks([
|
|
626
|
+
{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5 },
|
|
627
|
+
{ id: 'b', type: 'video', start: 5, end: 9, inPoint: 2, outPoint: 6, normalizedInPoint: 2 },
|
|
628
|
+
]),
|
|
629
|
+
})
|
|
630
|
+
expect(rippleDelete(p, 'a').tracks![0].items[0]).toMatchObject({
|
|
631
|
+
start: 0, end: 4, inPoint: 2, outPoint: 6, normalizedInPoint: 2,
|
|
632
|
+
})
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
describe('rollEdit', () => {
|
|
637
|
+
// left source window 10–15 of a 30s source; right window 20–25 of a 30s source.
|
|
638
|
+
// Both MIN_DURATION clamps bind before either source bound does.
|
|
639
|
+
const base = () => makeProject({
|
|
640
|
+
tracks: vtracks([
|
|
641
|
+
{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
|
|
642
|
+
{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 20, outPoint: 25, sourceDuration: 30 },
|
|
643
|
+
]),
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('returns the same reference when either id is missing', () => {
|
|
647
|
+
const p = base()
|
|
648
|
+
expect(rollEdit(p, 'l', 'nope', 1)).toBe(p)
|
|
649
|
+
expect(rollEdit(p, 'nope', 'r', 1)).toBe(p)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('returns the same reference when the clips are on different tracks', () => {
|
|
653
|
+
const p = makeProject({
|
|
654
|
+
tracks: vtracks(
|
|
655
|
+
[{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 }],
|
|
656
|
+
[{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 }],
|
|
657
|
+
),
|
|
658
|
+
})
|
|
659
|
+
expect(rollEdit(p, 'l', 'r', 1)).toBe(p)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('returns the same reference when the clips are not adjacent', () => {
|
|
663
|
+
const p = makeProject({
|
|
664
|
+
tracks: vtracks([
|
|
665
|
+
{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
666
|
+
{ id: 'r', type: 'video', start: 6, end: 10, inPoint: 0, outPoint: 4, sourceDuration: 30 },
|
|
667
|
+
]),
|
|
668
|
+
})
|
|
669
|
+
expect(rollEdit(p, 'l', 'r', 1)).toBe(p)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('returns the same reference when the ids are passed in the wrong order', () => {
|
|
673
|
+
const p = base()
|
|
674
|
+
expect(rollEdit(p, 'r', 'l', 1)).toBe(p)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('returns the same reference for a zero delta', () => {
|
|
678
|
+
const p = base()
|
|
679
|
+
expect(rollEdit(p, 'l', 'r', 0)).toBe(p)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('moves the shared boundary later without changing total duration', () => {
|
|
683
|
+
const [l, r] = rollEdit(base(), 'l', 'r', 2).tracks![0].items
|
|
684
|
+
expect(l).toMatchObject({ start: 0, end: 7, inPoint: 10, outPoint: 17 })
|
|
685
|
+
expect(r).toMatchObject({ start: 7, end: 10, inPoint: 22, outPoint: 25 })
|
|
686
|
+
expect(r.end - l.start).toBe(10)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('moves the shared boundary earlier without changing total duration', () => {
|
|
690
|
+
const [l, r] = rollEdit(base(), 'l', 'r', -2).tracks![0].items
|
|
691
|
+
expect(l).toMatchObject({ start: 0, end: 3, inPoint: 10, outPoint: 13 })
|
|
692
|
+
expect(r).toMatchObject({ start: 3, end: 10, inPoint: 18, outPoint: 25 })
|
|
693
|
+
expect(r.end - l.start).toBe(10)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it("clamps at the left clip's MIN_DURATION", () => {
|
|
697
|
+
const [l, r] = rollEdit(base(), 'l', 'r', -20).tracks![0].items
|
|
698
|
+
expect(l.end - l.start).toBeCloseTo(0.1)
|
|
699
|
+
expect(l.outPoint).toBeCloseTo(10.1)
|
|
700
|
+
expect(r.start).toBeCloseTo(0.1)
|
|
701
|
+
expect(r.inPoint).toBeCloseTo(15.1)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it("clamps at the right clip's MIN_DURATION", () => {
|
|
705
|
+
const [l, r] = rollEdit(base(), 'l', 'r', 20).tracks![0].items
|
|
706
|
+
expect(r.end - r.start).toBeCloseTo(0.1)
|
|
707
|
+
expect(l.end).toBeCloseTo(9.9)
|
|
708
|
+
expect(l.outPoint).toBeCloseTo(19.9)
|
|
709
|
+
expect(r.inPoint).toBeCloseTo(24.9)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it("clamps at the end of the left clip's source media", () => {
|
|
713
|
+
const p = makeProject({
|
|
714
|
+
tracks: vtracks([
|
|
715
|
+
{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 16 },
|
|
716
|
+
{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 20, outPoint: 25, sourceDuration: 30 },
|
|
717
|
+
]),
|
|
718
|
+
})
|
|
719
|
+
const [l, r] = rollEdit(p, 'l', 'r', 5).tracks![0].items
|
|
720
|
+
expect(l).toMatchObject({ end: 6, outPoint: 16 }) // outPoint stops at sourceDuration
|
|
721
|
+
expect(r).toMatchObject({ start: 6, inPoint: 21 })
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it("clamps at the start of the right clip's source media", () => {
|
|
725
|
+
const p = makeProject({
|
|
726
|
+
tracks: vtracks([
|
|
727
|
+
{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
|
|
728
|
+
{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 1, outPoint: 6, sourceDuration: 30 },
|
|
729
|
+
]),
|
|
730
|
+
})
|
|
731
|
+
const [l, r] = rollEdit(p, 'l', 'r', -5).tracks![0].items
|
|
732
|
+
expect(r).toMatchObject({ start: 4, inPoint: 0 }) // inPoint stops at 0
|
|
733
|
+
expect(l).toMatchObject({ end: 4, outPoint: 14 })
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('returns the same reference when the roll clamps to zero', () => {
|
|
737
|
+
const p = makeProject({
|
|
738
|
+
tracks: vtracks([
|
|
739
|
+
{ id: 'l', type: 'video', start: 0, end: 5, inPoint: 10, outPoint: 15, sourceDuration: 30 },
|
|
740
|
+
{ id: 'r', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
741
|
+
]),
|
|
742
|
+
})
|
|
743
|
+
expect(rollEdit(p, 'l', 'r', -3)).toBe(p) // right clip is already at its source start
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('leaves captions and audio untouched', () => {
|
|
747
|
+
const captions = { style: 'subtitle' as const, segments: [{ text: 'x', start: 4, end: 6 }] }
|
|
748
|
+
const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
|
|
749
|
+
const p = makeProject({ ...base(), captions, audio })
|
|
750
|
+
const out = rollEdit(p, 'l', 'r', 2)
|
|
751
|
+
expect(out.captions).toBe(captions)
|
|
752
|
+
expect(out.audio).toBe(audio)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('rolls non-video items geometrically without inventing source points', () => {
|
|
756
|
+
const p = makeProject({
|
|
757
|
+
tracks: vtracks([
|
|
758
|
+
{ id: 'l', type: 'image', start: 0, end: 5 },
|
|
759
|
+
{ id: 'r', type: 'image', start: 5, end: 10 },
|
|
760
|
+
]),
|
|
761
|
+
})
|
|
762
|
+
const [l, r] = rollEdit(p, 'l', 'r', 2).tracks![0].items
|
|
763
|
+
expect(l).toMatchObject({ end: 7 })
|
|
764
|
+
expect(r).toMatchObject({ start: 7 })
|
|
765
|
+
expect('outPoint' in l).toBe(false)
|
|
766
|
+
expect('inPoint' in r).toBe(false)
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
describe('slipItem', () => {
|
|
771
|
+
const base = () => makeProject({
|
|
772
|
+
tracks: vtracks([
|
|
773
|
+
{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 11, sourceDuration: 30, normalizedInPoint: 5 },
|
|
774
|
+
]),
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('returns the same reference when the item is not found', () => {
|
|
778
|
+
const p = base()
|
|
779
|
+
expect(slipItem(p, 'nope', 1)).toBe(p)
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it('returns the same reference for a zero delta', () => {
|
|
783
|
+
const p = base()
|
|
784
|
+
expect(slipItem(p, 'a', 0)).toBe(p)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('returns the same reference for a non-video item (no source media to slip)', () => {
|
|
788
|
+
const p = makeProject({ tracks: vtracks([{ id: 'o', type: 'overlay', start: 0, end: 5 }]) })
|
|
789
|
+
expect(slipItem(p, 'o', 1)).toBe(p)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('returns the same reference for a video item carrying no in/outPoint', () => {
|
|
793
|
+
const p = makeProject({ tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5 }]) })
|
|
794
|
+
expect(slipItem(p, 'a', 1)).toBe(p)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('shifts the source window while the timeline position stays fixed', () => {
|
|
798
|
+
expect(slipItem(base(), 'a', 3).tracks![0].items[0]).toMatchObject({
|
|
799
|
+
start: 2, end: 8, inPoint: 8, outPoint: 14,
|
|
800
|
+
})
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('never touches normalizedInPoint', () => {
|
|
804
|
+
expect(slipItem(base(), 'a', 3).tracks![0].items[0].normalizedInPoint).toBe(5)
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('clamps at the start of the source media', () => {
|
|
808
|
+
expect(slipItem(base(), 'a', -20).tracks![0].items[0]).toMatchObject({
|
|
809
|
+
start: 2, end: 8, inPoint: 0, outPoint: 6,
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('clamps at the end of the source media', () => {
|
|
814
|
+
expect(slipItem(base(), 'a', 40).tracks![0].items[0]).toMatchObject({
|
|
815
|
+
start: 2, end: 8, inPoint: 24, outPoint: 30,
|
|
816
|
+
})
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
it('returns the same reference when already at the source start and slipped earlier', () => {
|
|
820
|
+
const p = makeProject({
|
|
821
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 }]),
|
|
822
|
+
})
|
|
823
|
+
expect(slipItem(p, 'a', -2)).toBe(p)
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('leaves the upper clamp open when sourceDuration is unknown', () => {
|
|
827
|
+
const p = makeProject({
|
|
828
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 1, outPoint: 6 }]),
|
|
829
|
+
})
|
|
830
|
+
expect(slipItem(p, 'a', 100).tracks![0].items[0]).toMatchObject({ inPoint: 101, outPoint: 106 })
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('slips items on overlay tracks too, leaving captions and audio untouched', () => {
|
|
834
|
+
const captions = { style: 'subtitle' as const, segments: [{ text: 'x', start: 1, end: 3 }] }
|
|
835
|
+
const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
|
|
836
|
+
const p = makeProject({
|
|
837
|
+
tracks: vtracks(
|
|
838
|
+
[{ id: 'a', type: 'video', start: 0, end: 5 }],
|
|
839
|
+
[{ id: 'b', type: 'video', start: 1, end: 4, inPoint: 2, outPoint: 5, sourceDuration: 20 }],
|
|
840
|
+
),
|
|
841
|
+
captions,
|
|
842
|
+
audio,
|
|
843
|
+
})
|
|
844
|
+
const out = slipItem(p, 'b', 1)
|
|
845
|
+
expect(out.tracks![1].items[0]).toMatchObject({ start: 1, end: 4, inPoint: 3, outPoint: 6 })
|
|
846
|
+
expect(out.tracks![0].items[0]).toBe(p.tracks![0].items[0])
|
|
847
|
+
expect(out.captions).toBe(captions)
|
|
848
|
+
expect(out.audio).toBe(audio)
|
|
849
|
+
})
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
describe('slideItem', () => {
|
|
853
|
+
const base = () => makeProject({
|
|
854
|
+
tracks: vtracks([
|
|
855
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
856
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
857
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
858
|
+
]),
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
it('returns the same reference when the item is not found', () => {
|
|
862
|
+
const p = base()
|
|
863
|
+
expect(slideItem(p, 'nope', 1)).toBe(p)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('returns the same reference for a zero delta', () => {
|
|
867
|
+
const p = base()
|
|
868
|
+
expect(slideItem(p, 'm', 0)).toBe(p)
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('moves the item later and lets the neighbors absorb it', () => {
|
|
872
|
+
const [prev, item, next] = slideItem(base(), 'm', 2).tracks![0].items
|
|
873
|
+
expect(prev).toMatchObject({ id: 'p', start: 0, end: 7, inPoint: 0, outPoint: 7 })
|
|
874
|
+
expect(item).toMatchObject({ id: 'm', start: 7, end: 12, inPoint: 2, outPoint: 7 })
|
|
875
|
+
expect(next).toMatchObject({ id: 'n', start: 12, end: 15, inPoint: 5, outPoint: 8 })
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
it('moves the item earlier and lets the neighbors absorb it', () => {
|
|
879
|
+
const [prev, item, next] = slideItem(base(), 'm', -2).tracks![0].items
|
|
880
|
+
expect(prev).toMatchObject({ id: 'p', start: 0, end: 3, outPoint: 3 })
|
|
881
|
+
expect(item).toMatchObject({ id: 'm', start: 3, end: 8, inPoint: 2, outPoint: 7 })
|
|
882
|
+
expect(next).toMatchObject({ id: 'n', start: 8, end: 15, inPoint: 1 })
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it("clamps at the next neighbor's MIN_DURATION", () => {
|
|
886
|
+
const [prev, item, next] = slideItem(base(), 'm', 20).tracks![0].items
|
|
887
|
+
expect(next.end - next.start).toBeCloseTo(0.1)
|
|
888
|
+
expect(prev.end).toBeCloseTo(9.9)
|
|
889
|
+
expect(item.start).toBeCloseTo(9.9)
|
|
890
|
+
expect(item.end).toBeCloseTo(14.9)
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it("clamps at the next neighbor's source start", () => {
|
|
894
|
+
const [prev, item, next] = slideItem(base(), 'm', -20).tracks![0].items
|
|
895
|
+
expect(next).toMatchObject({ start: 7, inPoint: 0 }) // nextIn 3 → can only give back 3s
|
|
896
|
+
expect(prev).toMatchObject({ end: 2, outPoint: 2 })
|
|
897
|
+
expect(item).toMatchObject({ start: 2, end: 7 })
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it("clamps at the previous neighbor's MIN_DURATION", () => {
|
|
901
|
+
const p = makeProject({
|
|
902
|
+
tracks: vtracks([
|
|
903
|
+
{ id: 'p', type: 'video', start: 0, end: 2, inPoint: 0, outPoint: 2, sourceDuration: 30 },
|
|
904
|
+
{ id: 'm', type: 'video', start: 2, end: 7, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
905
|
+
{ id: 'n', type: 'video', start: 7, end: 12, inPoint: 10, outPoint: 15, sourceDuration: 30 },
|
|
906
|
+
]),
|
|
907
|
+
})
|
|
908
|
+
const [prev, item] = slideItem(p, 'm', -20).tracks![0].items
|
|
909
|
+
expect(prev.end - prev.start).toBeCloseTo(0.1)
|
|
910
|
+
expect(prev.outPoint).toBeCloseTo(0.1)
|
|
911
|
+
expect(item.start).toBeCloseTo(0.1)
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it("clamps at the previous neighbor's source end", () => {
|
|
915
|
+
const p = makeProject({
|
|
916
|
+
tracks: vtracks([
|
|
917
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 6 },
|
|
918
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
919
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
920
|
+
]),
|
|
921
|
+
})
|
|
922
|
+
const [prev, item, next] = slideItem(p, 'm', 10).tracks![0].items
|
|
923
|
+
expect(prev).toMatchObject({ end: 6, outPoint: 6 }) // outPoint stops at sourceDuration
|
|
924
|
+
expect(item).toMatchObject({ start: 6, end: 11 })
|
|
925
|
+
expect(next).toMatchObject({ start: 11, inPoint: 4 })
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('slides a first item with no previous neighbor, clamped at the timeline origin', () => {
|
|
929
|
+
const p = makeProject({
|
|
930
|
+
tracks: vtracks([
|
|
931
|
+
{ id: 'm', type: 'video', start: 2, end: 7, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
932
|
+
{ id: 'n', type: 'video', start: 7, end: 12, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
933
|
+
]),
|
|
934
|
+
})
|
|
935
|
+
expect(slideItem(p, 'm', 2).tracks![0].items[0]).toMatchObject({ start: 4, end: 9 })
|
|
936
|
+
// with no previous neighbor to shrink, the origin is what stops the slide
|
|
937
|
+
expect(slideItem(p, 'm', -20).tracks![0].items[0]).toMatchObject({ start: 0, end: 5 })
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
it('slides a last item with no next neighbor', () => {
|
|
941
|
+
const p = makeProject({
|
|
942
|
+
tracks: vtracks([
|
|
943
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
944
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
945
|
+
]),
|
|
946
|
+
})
|
|
947
|
+
const [prev, item] = slideItem(p, 'm', 2).tracks![0].items
|
|
948
|
+
expect(prev).toMatchObject({ end: 7, outPoint: 7 })
|
|
949
|
+
expect(item).toMatchObject({ start: 7, end: 12, inPoint: 2, outPoint: 7 })
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it('picks neighbors by timeline order, not array order', () => {
|
|
953
|
+
const p = makeProject({
|
|
954
|
+
tracks: vtracks([
|
|
955
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
956
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
957
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
958
|
+
]),
|
|
959
|
+
})
|
|
960
|
+
const [next, prev, item] = slideItem(p, 'm', 2).tracks![0].items
|
|
961
|
+
expect(prev).toMatchObject({ id: 'p', end: 7, outPoint: 7 })
|
|
962
|
+
expect(item).toMatchObject({ id: 'm', start: 7, end: 12 })
|
|
963
|
+
expect(next).toMatchObject({ id: 'n', start: 12, inPoint: 5 })
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('does not absorb into non-adjacent neighbors', () => {
|
|
967
|
+
const p = makeProject({
|
|
968
|
+
tracks: vtracks([
|
|
969
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
970
|
+
{ id: 'm', type: 'video', start: 8, end: 12, inPoint: 0, outPoint: 4, sourceDuration: 30 },
|
|
971
|
+
{ id: 'n', type: 'video', start: 15, end: 20, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
972
|
+
]),
|
|
973
|
+
})
|
|
974
|
+
const out = slideItem(p, 'm', 2)
|
|
975
|
+
expect(out.tracks![0].items[0]).toBe(p.tracks![0].items[0])
|
|
976
|
+
expect(out.tracks![0].items[2]).toBe(p.tracks![0].items[2])
|
|
977
|
+
expect(out.tracks![0].items[1]).toMatchObject({ start: 10, end: 14, inPoint: 0, outPoint: 4 })
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it("shifts only the captions sitting over the item's old window", () => {
|
|
981
|
+
const p = makeProject({
|
|
982
|
+
...base(),
|
|
983
|
+
captions: {
|
|
984
|
+
style: 'subtitle',
|
|
985
|
+
segments: [
|
|
986
|
+
{ text: 'p-cap', start: 1, end: 3 },
|
|
987
|
+
{ text: 'm-cap', start: 6, end: 9, words: [{ word: 'w', start: 6, end: 9 }] },
|
|
988
|
+
{ text: 'n-cap', start: 11, end: 13 },
|
|
989
|
+
],
|
|
990
|
+
},
|
|
991
|
+
})
|
|
992
|
+
const segs = slideItem(p, 'm', 2).captions!.segments
|
|
993
|
+
expect(segs[0]).toBe(p.captions!.segments[0])
|
|
994
|
+
expect(segs[1]).toMatchObject({ start: 8, end: 11 })
|
|
995
|
+
expect(segs[1].words![0]).toMatchObject({ start: 8, end: 11 })
|
|
996
|
+
expect(segs[2]).toBe(p.captions!.segments[2])
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
it('returns the same reference when the slide clamps to zero', () => {
|
|
1000
|
+
const p = makeProject({
|
|
1001
|
+
tracks: vtracks([
|
|
1002
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1003
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
1004
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1005
|
+
]),
|
|
1006
|
+
})
|
|
1007
|
+
expect(slideItem(p, 'm', -5)).toBe(p) // n is already at its source start
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
it('leaves audio tracks untouched', () => {
|
|
1011
|
+
const audio = { tracks: [{ id: 'au', src: 'm.mp3', start: 0, end: 20 }] }
|
|
1012
|
+
const p = makeProject({ ...base(), audio })
|
|
1013
|
+
expect(slideItem(p, 'm', 2).audio).toBe(audio)
|
|
1014
|
+
})
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
// ── Caption lane preservation (multi-row captions, Phase 1) ─────────────────
|
|
1018
|
+
//
|
|
1019
|
+
// Every caption-touching helper above rebuilds segments with `{ ...seg, ... }`
|
|
1020
|
+
// rather than a literal field list, so a segment's `lane` survives every cut
|
|
1021
|
+
// op by construction — none of these helpers has any lane-specific logic of
|
|
1022
|
+
// its own. These tests exist to keep it that way: if a future edit ever
|
|
1023
|
+
// rebuilds a caption segment without spreading the original, one of these
|
|
1024
|
+
// fails and says so explicitly rather than silently dropping `lane`.
|
|
1025
|
+
|
|
1026
|
+
describe('cuts.ts preserves CaptionSegment.lane', () => {
|
|
1027
|
+
it('applyCutToTracks (lift) preserves lane and removes the SAME duration from every lane', () => {
|
|
1028
|
+
const p = makeProject({
|
|
1029
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 20 }]),
|
|
1030
|
+
captions: {
|
|
1031
|
+
style: 'subtitle',
|
|
1032
|
+
segments: [
|
|
1033
|
+
{ text: 'lane0-after', start: 8, end: 10, lane: 0 },
|
|
1034
|
+
{ text: 'lane1-after', start: 8, end: 10, lane: 1 },
|
|
1035
|
+
{ text: 'lane2-before', start: 0, end: 2, lane: 2 },
|
|
1036
|
+
],
|
|
1037
|
+
},
|
|
1038
|
+
})
|
|
1039
|
+
const out = applyCutToTracks(p, { start: 3, end: 5 }) // 2s cut
|
|
1040
|
+
const byText = Object.fromEntries(out.captions!.segments.map(s => [s.text, s]))
|
|
1041
|
+
// Both lanes shift by the identical 2s cut duration — applyCutToCaptions
|
|
1042
|
+
// has no per-lane branch, it operates over the flat segment list.
|
|
1043
|
+
expect(byText['lane0-after']).toMatchObject({ start: 6, end: 8, lane: 0 })
|
|
1044
|
+
expect(byText['lane1-after']).toMatchObject({ start: 6, end: 8, lane: 1 })
|
|
1045
|
+
expect(byText['lane2-before']).toMatchObject({ start: 0, end: 2, lane: 2 })
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('collapseGaps preserves lane while remapping captions', () => {
|
|
1049
|
+
const p = makeProject({
|
|
1050
|
+
tracks: vtracks([
|
|
1051
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
1052
|
+
{ id: 'b', type: 'video', start: 8, end: 12 }, // 3s gap
|
|
1053
|
+
]),
|
|
1054
|
+
captions: {
|
|
1055
|
+
style: 'subtitle',
|
|
1056
|
+
segments: [{ text: 'b-cap', start: 9, end: 11, lane: 2 }],
|
|
1057
|
+
},
|
|
1058
|
+
})
|
|
1059
|
+
const out = collapseGaps(p)
|
|
1060
|
+
expect(out.captions!.segments[0]).toMatchObject({ start: 6, end: 8, lane: 2 })
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
it('rippleDelete preserves lane on both untouched and shifted captions', () => {
|
|
1064
|
+
const p = makeProject({
|
|
1065
|
+
tracks: vtracks([
|
|
1066
|
+
{ id: 'a', type: 'video', start: 0, end: 5 },
|
|
1067
|
+
{ id: 'b', type: 'video', start: 5, end: 9 },
|
|
1068
|
+
{ id: 'c', type: 'video', start: 9, end: 14 },
|
|
1069
|
+
]),
|
|
1070
|
+
captions: {
|
|
1071
|
+
style: 'subtitle',
|
|
1072
|
+
segments: [
|
|
1073
|
+
{ text: 'a-cap', start: 1, end: 3, lane: 1 },
|
|
1074
|
+
{ text: 'c-cap', start: 10, end: 12, lane: 3 },
|
|
1075
|
+
],
|
|
1076
|
+
},
|
|
1077
|
+
})
|
|
1078
|
+
const segs = rippleDelete(p, 'b').captions!.segments
|
|
1079
|
+
const byText = Object.fromEntries(segs.map(s => [s.text, s]))
|
|
1080
|
+
expect(byText['a-cap']).toMatchObject({ lane: 1 })
|
|
1081
|
+
expect(byText['c-cap']).toMatchObject({ start: 6, end: 8, lane: 3 })
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('slideItem (shiftCaptionsInWindow) preserves lane on the captions it shifts', () => {
|
|
1085
|
+
const p = makeProject({
|
|
1086
|
+
tracks: vtracks([
|
|
1087
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1088
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 30 },
|
|
1089
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 8, sourceDuration: 30 },
|
|
1090
|
+
]),
|
|
1091
|
+
captions: {
|
|
1092
|
+
style: 'subtitle',
|
|
1093
|
+
segments: [{ text: 'm-cap', start: 6, end: 9, lane: 4 }],
|
|
1094
|
+
},
|
|
1095
|
+
})
|
|
1096
|
+
const seg = slideItem(p, 'm', 2).captions!.segments[0]
|
|
1097
|
+
expect(seg).toMatchObject({ start: 8, end: 11, lane: 4 })
|
|
1098
|
+
})
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
// ── Track-level invariants ──────────────────────────────────────────────────
|
|
1102
|
+
//
|
|
1103
|
+
// Two properties every op in this file shares, both of which are easy to break
|
|
1104
|
+
// by rebuilding `tracks` as bare `{ id, items }` objects or by filtering out
|
|
1105
|
+
// the empties on the way past:
|
|
1106
|
+
//
|
|
1107
|
+
// • a track's id and its volume/muted/enabled survive the op;
|
|
1108
|
+
// • an emptied track STAYS, unlike `moveItemAcrossTracks`, which prunes.
|
|
1109
|
+
|
|
1110
|
+
describe('every op preserves track identity and settings', () => {
|
|
1111
|
+
/** Two tracks with settings only they should ever carry, plus a third that is
|
|
1112
|
+
* already empty and must still be there afterwards. */
|
|
1113
|
+
function settledProject(): Project {
|
|
1114
|
+
return makeProject({
|
|
1115
|
+
tracks: [
|
|
1116
|
+
{
|
|
1117
|
+
id: 'base', volume: 0.4, muted: false,
|
|
1118
|
+
items: [
|
|
1119
|
+
// a|b share a boundary (so `rollEdit` has one to move); the gap
|
|
1120
|
+
// before c is what gives `collapseGaps` something to close.
|
|
1121
|
+
{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1122
|
+
{ id: 'b', type: 'video', start: 5, end: 10, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1123
|
+
{ id: 'c', type: 'video', start: 11, end: 16, inPoint: 0, outPoint: 5, sourceDuration: 30 },
|
|
1124
|
+
],
|
|
1125
|
+
},
|
|
1126
|
+
{ id: 'ovl', enabled: false, items: [{ id: 'o', type: 'overlay', start: 1, end: 3 }] },
|
|
1127
|
+
{ id: 'spare', volume: 0.9, items: [] },
|
|
1128
|
+
],
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const settingsOf = (p: Project) =>
|
|
1133
|
+
p.tracks!.map(t => ({ id: t.id, volume: t.volume, muted: t.muted, enabled: t.enabled }))
|
|
1134
|
+
const expected = settingsOf(settledProject())
|
|
1135
|
+
|
|
1136
|
+
const ops: Array<[string, (p: Project) => Project]> = [
|
|
1137
|
+
['applyCutToTracks', p => applyCutToTracks(p, { start: 1, end: 2 })],
|
|
1138
|
+
['collapseGaps', p => collapseGaps(p)],
|
|
1139
|
+
['applyCutToItem (primary)', p => applyCutToItem(p, 'a', { start: 1, end: 2 })],
|
|
1140
|
+
['applyCutToItem (overlay)', p => applyCutToItem(p, 'o', { start: 1.5, end: 2 })],
|
|
1141
|
+
['splitAtTime', p => splitAtTime(p, 2, null)],
|
|
1142
|
+
['rippleDelete', p => rippleDelete(p, 'b')],
|
|
1143
|
+
['rollEdit', p => rollEdit(p, 'a', 'b', 0.5)],
|
|
1144
|
+
['slipItem', p => slipItem(p, 'a', 1)],
|
|
1145
|
+
['slideItem', p => slideItem(p, 'b', 1)],
|
|
1146
|
+
]
|
|
1147
|
+
|
|
1148
|
+
for (const [name, op] of ops) {
|
|
1149
|
+
it(`${name} keeps every track's id and settings, and keeps the empty track`, () => {
|
|
1150
|
+
const input = settledProject()
|
|
1151
|
+
const out = op(input)
|
|
1152
|
+
expect(out).not.toBe(input) // the op actually did something
|
|
1153
|
+
expect(settingsOf(out)).toEqual(expected)
|
|
1154
|
+
expect(out.tracks!.map(t => t.id)).toEqual(['base', 'ovl', 'spare'])
|
|
1155
|
+
expect(out.tracks![2].items).toEqual([]) // empty tracks are kept
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
// ── Per-clip speed (montaj/speed) ─────────────────────────────────────────────
|
|
1161
|
+
//
|
|
1162
|
+
// The invariant every op must preserve for a clip at speed S: the source-window
|
|
1163
|
+
// length is the timeline span times speed —
|
|
1164
|
+
//
|
|
1165
|
+
// outPoint − inPoint === S · (end − start)
|
|
1166
|
+
//
|
|
1167
|
+
// A clip at S=1/absent is byte-identical to the pre-speed op (every test above
|
|
1168
|
+
// exercises that path). These tests exercise S=2 (a 10s source window plays in
|
|
1169
|
+
// 5 timeline seconds) and S=0.5 (a 10s window is stretched over 20 seconds).
|
|
1170
|
+
|
|
1171
|
+
describe('per-clip speed', () => {
|
|
1172
|
+
/** outPoint − inPoint − S·(end − start); 0 when the invariant holds. */
|
|
1173
|
+
const invariantErr = (i: VisualItem) =>
|
|
1174
|
+
(i.outPoint! - i.inPoint!) - (i.speed ?? 1) * (i.end - i.start)
|
|
1175
|
+
const holdsFor = (i: VisualItem) => expect(invariantErr(i)).toBeCloseTo(0, 9)
|
|
1176
|
+
|
|
1177
|
+
describe('trim / cut ops keep outPoint − inPoint === S·(end − start)', () => {
|
|
1178
|
+
it('applyCutToTracks trims a sped clip end (overlaps-left) coherently', () => {
|
|
1179
|
+
const p = makeProject({
|
|
1180
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
|
|
1181
|
+
})
|
|
1182
|
+
const a = applyCutToTracks(p, { start: 3, end: 99 }).tracks![0].items[0]
|
|
1183
|
+
expect(a).toMatchObject({ end: 3, outPoint: 106 }) // 100 + (3−0)·2
|
|
1184
|
+
holdsFor(a)
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
it('applyCutToTracks trims a sped clip start (overlaps-right) coherently', () => {
|
|
1188
|
+
const p = makeProject({
|
|
1189
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 2, end: 7, inPoint: 100, outPoint: 110, speed: 2 }]),
|
|
1190
|
+
})
|
|
1191
|
+
const a = applyCutToTracks(p, { start: 0, end: 4 }).tracks![0].items[0]
|
|
1192
|
+
expect(a).toMatchObject({ start: 4, inPoint: 104 }) // 100 + (4−2)·2
|
|
1193
|
+
holdsFor(a)
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
it('splitAtTime splits a sped clip into two coherent fragments', () => {
|
|
1197
|
+
const p = makeProject({
|
|
1198
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
|
|
1199
|
+
})
|
|
1200
|
+
const [left, right] = splitAtTime(p, 3, 'a').tracks![0].items
|
|
1201
|
+
expect(left).toMatchObject({ end: 3, outPoint: 106 }) // 100 + 3·2
|
|
1202
|
+
expect(right).toMatchObject({ start: 3, inPoint: 106, end: 5, outPoint: 110 })
|
|
1203
|
+
holdsFor(left)
|
|
1204
|
+
holdsFor(right)
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
it('applyCutToItem (collapse) cuts a sped clip coherently, re-timing the right fragment by /S', () => {
|
|
1208
|
+
const p = makeProject({
|
|
1209
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, speed: 2 }]),
|
|
1210
|
+
})
|
|
1211
|
+
const [left, right] = applyCutToItem(p, 'a', { start: 1, end: 3 }).tracks![0].items
|
|
1212
|
+
expect(left).toMatchObject({ end: 1, outPoint: 102 }) // physStart 100 + 1·2
|
|
1213
|
+
// right: source [106, 110] is 4 src-seconds → 4/2 = 2 timeline seconds from cut.start 1.
|
|
1214
|
+
expect(right).toMatchObject({ start: 1, end: 3, inPoint: 106, outPoint: 110 })
|
|
1215
|
+
holdsFor(left)
|
|
1216
|
+
holdsFor(right)
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
it('rollEdit moves the shared boundary by d·S in each clip’s own source', () => {
|
|
1220
|
+
const p = makeProject({
|
|
1221
|
+
tracks: vtracks([
|
|
1222
|
+
{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, sourceDuration: 200, speed: 2 },
|
|
1223
|
+
{ id: 'b', type: 'video', start: 5, end: 9, inPoint: 20, outPoint: 22, sourceDuration: 200, speed: 0.5 },
|
|
1224
|
+
]),
|
|
1225
|
+
})
|
|
1226
|
+
const [a, b] = rollEdit(p, 'a', 'b', 1).tracks![0].items
|
|
1227
|
+
expect(a).toMatchObject({ end: 6, outPoint: 112 }) // 110 + 1·2
|
|
1228
|
+
expect(b).toMatchObject({ start: 6, inPoint: 20.5 }) // 20 + 1·0.5
|
|
1229
|
+
holdsFor(a)
|
|
1230
|
+
holdsFor(b)
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
it('rollEdit clamps a sped left clip at its source end by /S', () => {
|
|
1234
|
+
const p = makeProject({
|
|
1235
|
+
tracks: vtracks([
|
|
1236
|
+
{ id: 'a', type: 'video', start: 0, end: 5, inPoint: 100, outPoint: 110, sourceDuration: 111, speed: 2 },
|
|
1237
|
+
{ id: 'b', type: 'video', start: 5, end: 20, inPoint: 0, outPoint: 30, sourceDuration: 200, speed: 2 },
|
|
1238
|
+
]),
|
|
1239
|
+
})
|
|
1240
|
+
// left has only 1 source-second of headroom (111 − 110); at S=2 that is 0.5 timeline-seconds.
|
|
1241
|
+
const [a] = rollEdit(p, 'a', 'b', 10).tracks![0].items
|
|
1242
|
+
expect(a.outPoint).toBeCloseTo(111)
|
|
1243
|
+
expect(a.end).toBeCloseTo(5.5)
|
|
1244
|
+
holdsFor(a)
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
it('slipItem moves the source window by delta·S with the timeline fixed', () => {
|
|
1248
|
+
const p = makeProject({
|
|
1249
|
+
tracks: vtracks([
|
|
1250
|
+
{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 17, sourceDuration: 200, speed: 2 },
|
|
1251
|
+
]),
|
|
1252
|
+
})
|
|
1253
|
+
const a = slipItem(p, 'a', 3).tracks![0].items[0]
|
|
1254
|
+
expect(a).toMatchObject({ start: 2, end: 8, inPoint: 11, outPoint: 23 }) // ±3·2
|
|
1255
|
+
holdsFor(a)
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
it('slipItem clamps a sped clip at the source start by /S', () => {
|
|
1259
|
+
const p = makeProject({
|
|
1260
|
+
tracks: vtracks([
|
|
1261
|
+
{ id: 'a', type: 'video', start: 2, end: 8, inPoint: 5, outPoint: 17, sourceDuration: 200, speed: 2 },
|
|
1262
|
+
]),
|
|
1263
|
+
})
|
|
1264
|
+
// minDelta = −inPoint/S = −2.5; a −10 drag parks inPoint at exactly 0.
|
|
1265
|
+
const a = slipItem(p, 'a', -10).tracks![0].items[0]
|
|
1266
|
+
expect(a.inPoint).toBeCloseTo(0)
|
|
1267
|
+
expect(a.outPoint).toBeCloseTo(12)
|
|
1268
|
+
holdsFor(a)
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
it('slideItem re-times each neighbor by d·S in its own source', () => {
|
|
1272
|
+
const p = makeProject({
|
|
1273
|
+
tracks: vtracks([
|
|
1274
|
+
{ id: 'p', type: 'video', start: 0, end: 5, inPoint: 0, outPoint: 10, sourceDuration: 200, speed: 2 },
|
|
1275
|
+
{ id: 'm', type: 'video', start: 5, end: 10, inPoint: 100, outPoint: 105, sourceDuration: 200 },
|
|
1276
|
+
{ id: 'n', type: 'video', start: 10, end: 15, inPoint: 3, outPoint: 13, sourceDuration: 200, speed: 2 },
|
|
1277
|
+
]),
|
|
1278
|
+
})
|
|
1279
|
+
const [prev, item, next] = slideItem(p, 'm', 2).tracks![0].items
|
|
1280
|
+
expect(prev).toMatchObject({ id: 'p', end: 7, outPoint: 14 }) // 10 + 2·2
|
|
1281
|
+
expect(item).toMatchObject({ id: 'm', start: 7, end: 12, inPoint: 100, outPoint: 105 })
|
|
1282
|
+
expect(next).toMatchObject({ id: 'n', start: 12, inPoint: 7 }) // 3 + 2·2
|
|
1283
|
+
holdsFor(prev)
|
|
1284
|
+
holdsFor(next)
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
describe('setClipSpeed', () => {
|
|
1289
|
+
const oneClip = (over: Partial<VisualItem> = {}) => makeProject({
|
|
1290
|
+
tracks: vtracks([{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 100, outPoint: 110, ...over }]),
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
it('2× halves the timeline span, leaving inPoint/outPoint untouched', () => {
|
|
1294
|
+
const a = setClipSpeed(oneClip(), 'a', 2).tracks![0].items[0]
|
|
1295
|
+
expect(a).toMatchObject({ speed: 2, start: 0, end: 5, inPoint: 100, outPoint: 110 })
|
|
1296
|
+
holdsFor(a)
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
it('0.5× doubles the timeline span', () => {
|
|
1300
|
+
const a = setClipSpeed(oneClip(), 'a', 0.5).tracks![0].items[0]
|
|
1301
|
+
expect(a).toMatchObject({ speed: 0.5, end: 20, inPoint: 100, outPoint: 110 })
|
|
1302
|
+
holdsFor(a)
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
it('clamps speed above 4 to 4', () => {
|
|
1306
|
+
const a = setClipSpeed(oneClip(), 'a', 10).tracks![0].items[0]
|
|
1307
|
+
expect(a.speed).toBe(4)
|
|
1308
|
+
expect(a.end).toBeCloseTo(2.5) // 10 / 4
|
|
1309
|
+
holdsFor(a)
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
it('clamps speed below 0.25 to 0.25', () => {
|
|
1313
|
+
const a = setClipSpeed(oneClip(), 'a', 0.01).tracks![0].items[0]
|
|
1314
|
+
expect(a.speed).toBe(0.25)
|
|
1315
|
+
expect(a.end).toBeCloseTo(40) // 10 / 0.25
|
|
1316
|
+
holdsFor(a)
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
it('re-speeds an already-sped clip correctly (source length is speed-invariant)', () => {
|
|
1320
|
+
// Start at 2× (end already 5), then 4×, then back to 1× → original 10s span.
|
|
1321
|
+
const at2x = oneClip({ end: 5, speed: 2 })
|
|
1322
|
+
const at4x = setClipSpeed(at2x, 'a', 4).tracks![0].items[0]
|
|
1323
|
+
expect(at4x).toMatchObject({ speed: 4, end: 2.5 })
|
|
1324
|
+
holdsFor(at4x)
|
|
1325
|
+
|
|
1326
|
+
const back = setClipSpeed(
|
|
1327
|
+
makeProject({ tracks: vtracks([at4x]) }), 'a', 1,
|
|
1328
|
+
).tracks![0].items[0]
|
|
1329
|
+
expect(back).toMatchObject({ speed: 1, end: 10, inPoint: 100, outPoint: 110 })
|
|
1330
|
+
holdsFor(back)
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
it('re-fits a clip with no stored outPoint from its current span × speed', () => {
|
|
1334
|
+
// No outPoint: source window is (end − start)·S. At 2× the 10s span → 20 src-seconds…
|
|
1335
|
+
const at2x = setClipSpeed(oneClip({ inPoint: undefined, outPoint: undefined }), 'a', 2).tracks![0].items[0]
|
|
1336
|
+
expect(at2x).toMatchObject({ speed: 2, end: 5 }) // 20 src-seconds / 2
|
|
1337
|
+
// …and back to 1× recovers the original 10s span.
|
|
1338
|
+
const back = setClipSpeed(makeProject({ tracks: vtracks([at2x]) }), 'a', 1).tracks![0].items[0]
|
|
1339
|
+
expect(back).toMatchObject({ speed: 1, end: 10 })
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
it('does not close the gap it opens — the next clip stays put', () => {
|
|
1343
|
+
const p = makeProject({
|
|
1344
|
+
tracks: vtracks([
|
|
1345
|
+
{ id: 'a', type: 'video', start: 0, end: 10, inPoint: 100, outPoint: 110 },
|
|
1346
|
+
{ id: 'b', type: 'video', start: 10, end: 15, inPoint: 0, outPoint: 5 },
|
|
1347
|
+
]),
|
|
1348
|
+
})
|
|
1349
|
+
const out = setClipSpeed(p, 'a', 2)
|
|
1350
|
+
expect(out.tracks![0].items[0]).toMatchObject({ end: 5, speed: 2 })
|
|
1351
|
+
expect(out.tracks![0].items[1]).toMatchObject({ start: 10, end: 15 }) // gap at [5,10] left for the caller
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
it('returns the same reference when the clip is not found', () => {
|
|
1355
|
+
const p = oneClip()
|
|
1356
|
+
expect(setClipSpeed(p, 'nope', 2)).toBe(p)
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
it('is a no-op on a non-video clip — speed is video-only', () => {
|
|
1360
|
+
const p = makeProject({
|
|
1361
|
+
tracks: vtracks([{ id: 'a', type: 'image', start: 0, end: 10 }]),
|
|
1362
|
+
})
|
|
1363
|
+
const out = setClipSpeed(p, 'a', 2)
|
|
1364
|
+
expect(out).toBe(p)
|
|
1365
|
+
expect(out.tracks![0].items[0].speed).toBeUndefined()
|
|
1366
|
+
expect(out.tracks![0].items[0].end).toBe(10)
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
it('returns a new project (immutable) when it does change the clip', () => {
|
|
1370
|
+
const p = oneClip()
|
|
1371
|
+
const out = setClipSpeed(p, 'a', 2)
|
|
1372
|
+
expect(out).not.toBe(p)
|
|
1373
|
+
expect(p.tracks![0].items[0].end).toBe(10) // input untouched
|
|
1374
|
+
expect(p.tracks![0].items[0].speed).toBeUndefined()
|
|
1375
|
+
})
|
|
1376
|
+
})
|
|
198
1377
|
})
|