@bycrux/editor 0.12.0 → 1.0.2
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 +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- 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 +586 -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 +161 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- 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 +2202 -251
- 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/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,164 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The accessor split that IS the skip feature: editing surfaces see every
|
|
4
|
+
* track, playback and export see only the enabled ones. If these two ever
|
|
5
|
+
* return the same thing, either skip does nothing or a skipped track becomes
|
|
6
|
+
* uneditable — both are silent failures on screen.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import {
|
|
10
|
+
enabledTrackItems,
|
|
11
|
+
enabledTracks,
|
|
12
|
+
trackItems,
|
|
13
|
+
withEnabledItemTracks,
|
|
14
|
+
withItemTracks,
|
|
15
|
+
} from '../timeline-model'
|
|
16
|
+
import type { VisualItem } from '../../../schema'
|
|
17
|
+
|
|
18
|
+
const item = (id: string): VisualItem =>
|
|
19
|
+
({ id, type: 'video', src: `${id}.mp4`, start: 0, end: 1 }) as VisualItem
|
|
20
|
+
|
|
21
|
+
const project = {
|
|
22
|
+
id: 'p',
|
|
23
|
+
tracks: [
|
|
24
|
+
{ id: 'trk-0', items: [item('a')] },
|
|
25
|
+
{ id: 'trk-1', items: [item('b')], enabled: false },
|
|
26
|
+
{ id: 'trk-2', items: [item('c')], enabled: true },
|
|
27
|
+
],
|
|
28
|
+
} as never
|
|
29
|
+
|
|
30
|
+
describe('enabledTrackItems', () => {
|
|
31
|
+
it('empties tracks marked enabled:false, in place', () => {
|
|
32
|
+
// A map, not a filter: the skipped track KEEPS its slot (index 1) so every
|
|
33
|
+
// positional consumer downstream ([0] = base track, .slice(1) = overlay
|
|
34
|
+
// tracks) still lines up.
|
|
35
|
+
expect(enabledTrackItems(project).map(t => t.map(i => i.id))).toEqual([['a'], [], ['c']])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not renumber later tracks when an earlier one is skipped', () => {
|
|
39
|
+
// Reproduction of the bug this shape guards against: with track 0 skipped
|
|
40
|
+
// and two overlay tracks, a FILTER would promote trk-1 into slot 0 and
|
|
41
|
+
// drop trk-2 out of the overlay set (`.slice(1)`) entirely.
|
|
42
|
+
const p = {
|
|
43
|
+
tracks: [
|
|
44
|
+
{ id: 'trk-0', items: [item('base')], enabled: false },
|
|
45
|
+
{ id: 'trk-1', items: [item('o1')] },
|
|
46
|
+
{ id: 'trk-2', items: [item('o2')] },
|
|
47
|
+
],
|
|
48
|
+
} as never
|
|
49
|
+
const out = enabledTrackItems(p)
|
|
50
|
+
expect(out.map(t => t.map(i => i.id))).toEqual([[], ['o1'], ['o2']])
|
|
51
|
+
expect(out.slice(1).map(t => t.map(i => i.id))).toEqual([['o1'], ['o2']])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps every track when none is disabled', () => {
|
|
55
|
+
const all = { id: 'p', tracks: [{ id: 't0', items: [item('a')] }, { id: 't1', items: [item('b')] }] } as never
|
|
56
|
+
expect(enabledTrackItems(all)).toHaveLength(2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('treats an absent `enabled` as enabled — untouched projects are unaffected', () => {
|
|
60
|
+
// The default matters: nothing writes `enabled` in, so every project that
|
|
61
|
+
// predates this feature has it missing on every track.
|
|
62
|
+
const legacyish = { id: 'p', tracks: [{ id: 't0', items: [item('a')] }] } as never
|
|
63
|
+
expect(enabledTrackItems(legacyish)).toEqual([[item('a')]])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('accepts the legacy array-of-arrays shape, with every track enabled', () => {
|
|
67
|
+
expect(enabledTrackItems({ tracks: [[item('a')], [item('b')]] } as never)).toHaveLength(2)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns one slot per track, same as the editing accessor — only a skipped slot is emptied', () => {
|
|
71
|
+
// The other half of the invariant — a skipped track must stay visible and
|
|
72
|
+
// editable on the timeline, and its slot must not disappear from either view.
|
|
73
|
+
expect(trackItems(project)).toHaveLength(3)
|
|
74
|
+
expect(enabledTrackItems(project)).toHaveLength(3)
|
|
75
|
+
expect(enabledTrackItems(project)[1]).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('shares item objects by reference, never copies', () => {
|
|
79
|
+
// Load-bearing: the renderer's `resolveProjectPaths` rewrites `item.src` in
|
|
80
|
+
// place through this view. A defensive copy here breaks path resolution in
|
|
81
|
+
// the renderer with nothing failing.
|
|
82
|
+
const src = item('a')
|
|
83
|
+
const p = { tracks: [{ id: 't0', items: [src] }] } as never
|
|
84
|
+
expect(enabledTrackItems(p)[0][0]).toBe(src)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('survives absent, empty and malformed tracks instead of throwing', () => {
|
|
88
|
+
expect(enabledTrackItems(undefined)).toEqual([])
|
|
89
|
+
expect(enabledTrackItems(null)).toEqual([])
|
|
90
|
+
expect(enabledTrackItems({} as never)).toEqual([])
|
|
91
|
+
expect(enabledTrackItems({ tracks: 'nonsense' } as never)).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('enabledTracks', () => {
|
|
96
|
+
// The object-shape sibling. Preview needs it because a track's `volume`/
|
|
97
|
+
// `muted` fold into every clip on it, and the item-array view has nowhere to
|
|
98
|
+
// carry them.
|
|
99
|
+
it('returns the track OBJECTS, so their own settings survive the read', () => {
|
|
100
|
+
const p = { tracks: [{ id: 't0', items: [item('a')], volume: 0.5, muted: true }] } as never
|
|
101
|
+
expect(enabledTracks(p)[0]).toMatchObject({ id: 't0', volume: 0.5, muted: true })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('applies the same skip test and order as enabledTrackItems — index i is index i', () => {
|
|
105
|
+
// The whole point of having two accessors with one skip test: if they ever
|
|
106
|
+
// disagree about which track is at [n], the preview folds the WRONG
|
|
107
|
+
// track's fader into its clips and nothing throws.
|
|
108
|
+
expect(enabledTracks(project).map(t => t.id)).toEqual(['trk-0', 'trk-1', 'trk-2'])
|
|
109
|
+
expect(enabledTracks(project).map(t => t.items)).toEqual(enabledTrackItems(project))
|
|
110
|
+
expect(enabledTracks(project)[1].items).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('treats an absent `enabled` as enabled', () => {
|
|
114
|
+
expect(enabledTracks({ tracks: [{ id: 't0', items: [] }] } as never)).toHaveLength(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('normalizes the legacy array-of-arrays shape into settingless tracks', () => {
|
|
118
|
+
const out = enabledTracks({ tracks: [[item('a')], [item('b')]] } as never)
|
|
119
|
+
expect(out).toHaveLength(2)
|
|
120
|
+
// No settings to fold — a legacy project sounds exactly as it always did.
|
|
121
|
+
expect(out[0].volume).toBeUndefined()
|
|
122
|
+
expect(out[0].muted).toBeUndefined()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('shares item objects by reference, never copies', () => {
|
|
126
|
+
const src = item('a')
|
|
127
|
+
const p = { tracks: [{ id: 't0', items: [src] }] } as never
|
|
128
|
+
expect(enabledTracks(p)[0].items[0]).toBe(src)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('survives absent, empty and malformed tracks instead of throwing', () => {
|
|
132
|
+
expect(enabledTracks(undefined)).toEqual([])
|
|
133
|
+
expect(enabledTracks(null)).toEqual([])
|
|
134
|
+
expect(enabledTracks({} as never)).toEqual([])
|
|
135
|
+
expect(enabledTracks({ tracks: 'nonsense' } as never)).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('withEnabledItemTracks', () => {
|
|
140
|
+
it('hands timeline-core bare item arrays, not track objects', () => {
|
|
141
|
+
// `@bycrux/timeline-core` reads `tracks` as an array of item arrays and
|
|
142
|
+
// throws `TypeError: object is not iterable` on the object shape. The
|
|
143
|
+
// unwrap is why this shim exists at all.
|
|
144
|
+
const out = withEnabledItemTracks(project)
|
|
145
|
+
expect(Array.isArray(out.tracks[0])).toBe(true)
|
|
146
|
+
expect(out.tracks.map(t => t.map(i => i.id))).toEqual([['a'], [], ['c']])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('differs from withItemTracks by exactly the disabled tracks\' content, not the slot count', () => {
|
|
150
|
+
// Filtering without the unwrap re-breaks timeline-core; unwrapping without
|
|
151
|
+
// the filter ignores skip. Both halves, or neither works. Both adapters
|
|
152
|
+
// keep one slot per track — timeline-core resolves `trackIdx` positionally
|
|
153
|
+
// — so the difference shows up as an EMPTIED slot, not a shorter array.
|
|
154
|
+
expect(withItemTracks(project).tracks).toHaveLength(3)
|
|
155
|
+
expect(withEnabledItemTracks(project).tracks).toHaveLength(3)
|
|
156
|
+
expect(withItemTracks(project).tracks[1]).toEqual([item('b')])
|
|
157
|
+
expect(withEnabledItemTracks(project).tracks[1]).toEqual([])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('passes every other project field through untouched', () => {
|
|
161
|
+
const out = withEnabledItemTracks({ ...(project as object), name: 'Keep me' } as { tracks?: unknown; name: string })
|
|
162
|
+
expect(out.name).toBe('Keep me')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — the cross-track placement search, extracted out of VisualTrackRow so
|
|
4
|
+
* the canvas pointer machine lands a dragged clip on the same track the DOM
|
|
5
|
+
* rows would have. VisualTrackRow and its DOM control arm are retired, so this
|
|
6
|
+
* file is now the only coverage for this placement search.
|
|
7
|
+
*
|
|
8
|
+
* One vertical track step is 24px (VISUAL_ROW_HEIGHT_PX) and DOWNWARD travel
|
|
9
|
+
* LOWERS the index, because track 0 is the base video track at the bottom of
|
|
10
|
+
* the stack.
|
|
11
|
+
*
|
|
12
|
+
* Tracks are `VisualTrack` OBJECTS. Most tests here only care about which items
|
|
13
|
+
* end up on which track, so they build fixtures through `stack()` and read
|
|
14
|
+
* results through `ids()`; the settings-preservation suite at the bottom is the
|
|
15
|
+
* one that looks at the track objects themselves.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import type { VisualItem, VisualTrack } from '../../../schema'
|
|
19
|
+
import { moveItemAcrossTracks } from '../timeline-model'
|
|
20
|
+
|
|
21
|
+
const clip = (id: string, start: number, end: number): VisualItem =>
|
|
22
|
+
({ id, type: 'video', src: `${id}.mp4`, start, end }) as VisualItem
|
|
23
|
+
|
|
24
|
+
/** An overlay-kind item — everything below cares only about `type`, which is
|
|
25
|
+
* the whole axis the kind-lock tests are about. */
|
|
26
|
+
const overlay = (id: string, start: number, end: number): VisualItem =>
|
|
27
|
+
({ id, type: 'overlay', start, end }) as VisualItem
|
|
28
|
+
|
|
29
|
+
const dragged = clip('d', 0, 10)
|
|
30
|
+
|
|
31
|
+
/** Tracks from bare item arrays, ids matching what the normalizer would give. */
|
|
32
|
+
function stack(...items: VisualItem[][]): VisualTrack[] {
|
|
33
|
+
return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ids(tracks: VisualTrack[]): string[][] {
|
|
37
|
+
return tracks.map(t => t.items.map(i => i.id))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('moveItemAcrossTracks — vertical mapping', () => {
|
|
41
|
+
it('keeps the item on its own track when the drag is horizontal', () => {
|
|
42
|
+
const moved = moveItemAcrossTracks({ tracks: stack([dragged], []), item: dragged, start: 2, end: 12, sourceTrackIdx: 0, dy: 0 })
|
|
43
|
+
expect(ids(moved)).toEqual([['d']])
|
|
44
|
+
expect(moved[0].items[0]).toMatchObject({ start: 2, end: 12 })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('sends the item UP a track for upward travel', () => {
|
|
48
|
+
const moved = moveItemAcrossTracks({ tracks: stack([dragged], []), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
|
|
49
|
+
expect(ids(moved)).toEqual([['d']]) // track 0 emptied and pruned
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('needs half a step (12px) of travel to change track', () => {
|
|
53
|
+
// Rounding means 11px is still the same track, 13px is the next one. The
|
|
54
|
+
// second track is occupied by a clip far enough away not to block.
|
|
55
|
+
const stacked = () => stack([dragged], [clip('x', 50, 60)])
|
|
56
|
+
expect(ids(moveItemAcrossTracks({ tracks: stacked(), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -11 })))
|
|
57
|
+
.toEqual([['d'], ['x']])
|
|
58
|
+
expect(ids(moveItemAcrossTracks({ tracks: stacked(), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -13 })))
|
|
59
|
+
.toEqual([['x', 'd']])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never goes below track 0', () => {
|
|
63
|
+
const moved = moveItemAcrossTracks({ tracks: stack([clip('a', 20, 30)], [dragged]), item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 500 })
|
|
64
|
+
expect(ids(moved)).toEqual([['a', 'd']])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('moveItemAcrossTracks — collision avoidance', () => {
|
|
69
|
+
it('rejects a target track where it would overlap by more than 30% of itself', () => {
|
|
70
|
+
// The dragged clip is 10s, so more than 3s of overlap is a collision.
|
|
71
|
+
const tracks = stack([clip('blocker', 5, 20)], [dragged])
|
|
72
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
|
|
73
|
+
expect(ids(moved)).toEqual([['blocker'], ['d']])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('accepts a brush past a neighbour', () => {
|
|
77
|
+
// Only 2s of overlap — inside the 3s tolerance.
|
|
78
|
+
const tracks = stack([clip('blocker', 8, 20)], [dragged])
|
|
79
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
|
|
80
|
+
expect(ids(moved)).toEqual([['blocker', 'd']])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('searches outward from the target, taking the first free track', () => {
|
|
84
|
+
// Target is track 1; it collides, so the search tries 0 then 2.
|
|
85
|
+
const tracks = stack([clip('lo', 0, 10)], [clip('mid', 0, 10)], [], [dragged])
|
|
86
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 3, dy: 48 })
|
|
87
|
+
expect(ids(moved)).toEqual([['lo'], ['mid'], ['d']])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('creates a new track above the stack when the drag points past the top', () => {
|
|
91
|
+
const tracks = stack([dragged, clip('stay', 50, 60)])
|
|
92
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
|
|
93
|
+
expect(ids(moved)).toEqual([['stay'], ['d']])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('ignores the dragged item when testing its own track for collisions', () => {
|
|
97
|
+
const tracks = stack([dragged, clip('far', 50, 60)])
|
|
98
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 1, end: 11, sourceTrackIdx: 0, dy: 0 })
|
|
99
|
+
expect(ids(moved)).toEqual([['far', 'd']])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ── Kind lock: a video item and an overlay/image item are different worlds —
|
|
104
|
+
// an overlay composites on top of the video underneath it, it doesn't splice
|
|
105
|
+
// into that track's timeline. Neither kind may cross onto the other's track,
|
|
106
|
+
// even when there'd be no time-overlap. (This is only the "can't cross
|
|
107
|
+
// kinds" half; the fuller "video tracks form their own block below
|
|
108
|
+
// overlays" reorganization is separate follow-up work.)
|
|
109
|
+
|
|
110
|
+
describe('moveItemAcrossTracks — kind lock (video vs overlay)', () => {
|
|
111
|
+
it('does not let a video item land on an overlay track — skips it for a same-kind track', () => {
|
|
112
|
+
const videoDragged = clip('d', 0, 10)
|
|
113
|
+
const tracks: VisualTrack[] = [
|
|
114
|
+
{ id: 'trk-0', items: [videoDragged, clip('stay', 50, 60)] },
|
|
115
|
+
{ id: 'trk-1', items: [overlay('ov', 50, 60)] },
|
|
116
|
+
]
|
|
117
|
+
// One step up from track 0 targets track 1 — the overlay track. There is
|
|
118
|
+
// no time-overlap with `ov`, so the OLD (kind-blind) search would have
|
|
119
|
+
// placed the video item there; it must instead fall back to its own
|
|
120
|
+
// (video) track.
|
|
121
|
+
const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
|
|
122
|
+
expect(ids(moved)).toEqual([['stay', 'd'], ['ov']])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does not let an overlay item land on a video track — skips it for a same-kind track', () => {
|
|
126
|
+
const ovDragged = overlay('d', 0, 10)
|
|
127
|
+
const tracks: VisualTrack[] = [
|
|
128
|
+
{ id: 'trk-0', items: [clip('vid', 50, 60)] },
|
|
129
|
+
{ id: 'trk-1', items: [ovDragged, overlay('stay', 50, 60)] },
|
|
130
|
+
]
|
|
131
|
+
// One step down from track 1 targets track 0 — the video track. Again no
|
|
132
|
+
// time-overlap, so only the kind gate keeps the overlay item off it.
|
|
133
|
+
const moved = moveItemAcrossTracks({ tracks, item: ovDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
|
|
134
|
+
expect(ids(moved)).toEqual([['vid'], ['stay', 'd']])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('still mints a new track past the top when every same-kind track is blocked', () => {
|
|
138
|
+
// Track 1 is overlay-kind (wrong kind, even though there's no
|
|
139
|
+
// time-overlap with `ov`); track 0 is video-kind but occupied by an
|
|
140
|
+
// overlapping clip (wrong reason, but still blocked). Neither works, so
|
|
141
|
+
// the search must mint a new (video) track above the stack. The result
|
|
142
|
+
// is then re-grouped into the canonical video-block/overlay-block stack
|
|
143
|
+
// (see `normalizeTrackOrder`), so the minted video track lands ahead of
|
|
144
|
+
// the overlay track in the final order, not merely "above" it positionally.
|
|
145
|
+
const videoDragged = clip('d', 0, 10)
|
|
146
|
+
const tracks: VisualTrack[] = [
|
|
147
|
+
{ id: 'trk-0', items: [videoDragged, clip('blocker', 2, 8)] },
|
|
148
|
+
{ id: 'trk-1', items: [overlay('ov', 50, 60)] },
|
|
149
|
+
]
|
|
150
|
+
const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
|
|
151
|
+
expect(ids(moved)).toEqual([['blocker'], ['d'], ['ov']])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ── Ripple-insert (`makeSpace`, magnet/ripple mode ON) ──────────────────
|
|
156
|
+
//
|
|
157
|
+
// Everything above exercises the DEFAULT (`makeSpace` omitted, i.e. magnet
|
|
158
|
+
// off) path and must keep passing byte-for-byte — that is the whole contract
|
|
159
|
+
// of the flag being optional. These tests are the ON path: a colliding
|
|
160
|
+
// target track stops being disqualifying and instead gets pushed open to fit
|
|
161
|
+
// the dropped item, CapCut-style.
|
|
162
|
+
|
|
163
|
+
describe('moveItemAcrossTracks — ripple-insert (makeSpace: true)', () => {
|
|
164
|
+
it('ripple-inserts on the target track instead of fanning out when it collides', () => {
|
|
165
|
+
// Same fixture as "rejects a target track where it would overlap by more
|
|
166
|
+
// than 30% of itself" above (which asserts the magnet-OFF fallback lands
|
|
167
|
+
// `d` back on its own track). With makeSpace on, the target (track 0, the
|
|
168
|
+
// base track) is no longer disqualified by the collision — `blocker`
|
|
169
|
+
// shifts right by `d`'s own duration (10s) instead.
|
|
170
|
+
const tracks = stack([clip('blocker', 5, 20)], [dragged])
|
|
171
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
|
|
172
|
+
expect(ids(moved)).toEqual([['blocker', 'd']])
|
|
173
|
+
expect(moved[0].items[0]).toMatchObject({ id: 'blocker', start: 15, end: 30 })
|
|
174
|
+
expect(moved[0].items[1]).toMatchObject({ id: 'd', start: 0, end: 10 })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('only pushes items starting at or after the drop point, leaving earlier items alone', () => {
|
|
178
|
+
const tracks = stack([clip('early', 0, 3), clip('blocker', 5, 20)], [dragged])
|
|
179
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 4, end: 14, sourceTrackIdx: 1, dy: 24, makeSpace: true })
|
|
180
|
+
expect(ids(moved)).toEqual([['early', 'blocker', 'd']])
|
|
181
|
+
expect(moved[0].items[0]).toMatchObject({ id: 'early', start: 0, end: 3 }) // untouched — starts before the drop point
|
|
182
|
+
expect(moved[0].items[1]).toMatchObject({ id: 'blocker', start: 15, end: 30 }) // pushed right by 10s
|
|
183
|
+
expect(moved[0].items[2]).toMatchObject({ id: 'd', start: 4, end: 14 })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('drops into a genuine gap unchanged — nothing to push', () => {
|
|
187
|
+
const tracks = stack([clip('far', 50, 60)], [dragged])
|
|
188
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
|
|
189
|
+
expect(ids(moved)).toEqual([['far', 'd']])
|
|
190
|
+
expect(moved[0].items[0]).toMatchObject({ id: 'far', start: 50, end: 60 })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not push the dragged item against itself when it collides on its own track', () => {
|
|
194
|
+
// A same-track re-drop (dy: 0) that overlaps a later neighbour on the
|
|
195
|
+
// SAME track it started on — the dragged item is excluded from its own
|
|
196
|
+
// track before the push, so it can never end up shifting itself.
|
|
197
|
+
const tracks = stack([dragged, clip('x', 12, 20)])
|
|
198
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 10, end: 20, sourceTrackIdx: 0, dy: 0, makeSpace: true })
|
|
199
|
+
expect(ids(moved)).toEqual([['x', 'd']])
|
|
200
|
+
expect(moved[0].items[0]).toMatchObject({ id: 'x', start: 22, end: 30 })
|
|
201
|
+
expect(moved[0].items[1]).toMatchObject({ id: 'd', start: 10, end: 20 })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('keeps the kind-lock even with makeSpace on — a video item never ripple-inserts onto an overlay track', () => {
|
|
205
|
+
const videoDragged = clip('d', 0, 10)
|
|
206
|
+
const tracks: VisualTrack[] = [
|
|
207
|
+
{ id: 'trk-0', items: [overlay('ov', 0, 20)] },
|
|
208
|
+
{ id: 'trk-1', items: [videoDragged] },
|
|
209
|
+
]
|
|
210
|
+
// One step down targets track 0 — overlay-kind, so the kind gate refuses
|
|
211
|
+
// it regardless of `makeSpace`; the item falls back to its own (now
|
|
212
|
+
// empty) track rather than ripple-inserting into the overlay. The result
|
|
213
|
+
// is then re-grouped into the canonical video-block/overlay-block stack
|
|
214
|
+
// (`normalizeTrackOrder`), which is why the video track leads even though
|
|
215
|
+
// it was positionally second.
|
|
216
|
+
const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
|
|
217
|
+
expect(ids(moved)).toEqual([['d'], ['ov']])
|
|
218
|
+
expect(moved[1].items[0]).toMatchObject({ id: 'ov', start: 0, end: 20 }) // untouched — no ripple happened
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('still mints a new track when every existing track is kind-blocked, even with makeSpace on', () => {
|
|
222
|
+
// Both existing tracks are overlay-kind from the dragged item's point of
|
|
223
|
+
// view (track 1 mixes the dragged video item with an overlay — an
|
|
224
|
+
// unusual shape, but one the kind gate has to cope with either way), so
|
|
225
|
+
// there is no kind-ok candidate anywhere in the array — only past its
|
|
226
|
+
// end, same as the magnet-off "mint" case.
|
|
227
|
+
const videoDragged = clip('d', 0, 10)
|
|
228
|
+
const tracks: VisualTrack[] = [
|
|
229
|
+
{ id: 'trk-0', items: [overlay('ov0', 0, 5)] },
|
|
230
|
+
{ id: 'trk-1', items: [videoDragged, overlay('ov1', 50, 60)] },
|
|
231
|
+
]
|
|
232
|
+
const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 0, makeSpace: true })
|
|
233
|
+
expect(ids(moved)).toEqual([['d'], ['ov0'], ['ov1']])
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// ── Track grouping (Part B): the result is ALWAYS canonical ─────────────
|
|
238
|
+
//
|
|
239
|
+
// `moveItemAcrossTracks` re-groups its own output into the video-block/
|
|
240
|
+
// overlay-block stack (`normalizeTrackOrder`) before returning, so every
|
|
241
|
+
// mid-drag frame is already canonical rather than relying on a later pass to
|
|
242
|
+
// fix it up.
|
|
243
|
+
|
|
244
|
+
const trackKind = (t: VisualTrack): 'video' | 'overlay' =>
|
|
245
|
+
t.items.some(i => i.type === 'video') ? 'video' : 'overlay'
|
|
246
|
+
|
|
247
|
+
describe('moveItemAcrossTracks — result is canonically grouped', () => {
|
|
248
|
+
it('a newly-minted video track (past the top of the stack, over an overlay track) lands in the video block, not stranded above the overlay', () => {
|
|
249
|
+
// trk-0 is fully occupied by an overlapping blocker (can't share), and
|
|
250
|
+
// trk-1 is overlay-kind (kind-lock rules it out too) — the search mints
|
|
251
|
+
// a new track two steps up. Before Part B that new track would simply
|
|
252
|
+
// sit "on top" (index 2, above the overlay track); the re-group now
|
|
253
|
+
// moves it back down into the video block, ahead of the overlay track.
|
|
254
|
+
const videoDragged = clip('d', 0, 10)
|
|
255
|
+
const tracks: VisualTrack[] = [
|
|
256
|
+
{ id: 'trk-0', items: [videoDragged, clip('blocker', 0, 10)] },
|
|
257
|
+
{ id: 'trk-1', items: [overlay('ov', 50, 60)] },
|
|
258
|
+
]
|
|
259
|
+
const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -48 })
|
|
260
|
+
expect(ids(moved)).toEqual([['blocker'], ['d'], ['ov']])
|
|
261
|
+
expect(moved.map(trackKind)).toEqual(['video', 'video', 'overlay'])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('the result is always canonical — matches its own re-grouping — for an ordinary same-kind move too', () => {
|
|
265
|
+
const tracks = stack([dragged], [clip('x', 50, 60)])
|
|
266
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -13 })
|
|
267
|
+
const videoGroup = moved.filter(t => trackKind(t) === 'video')
|
|
268
|
+
const overlayGroup = moved.filter(t => trackKind(t) === 'overlay')
|
|
269
|
+
expect(moved).toEqual([...videoGroup, ...overlayGroup])
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('moveItemAcrossTracks — bookkeeping', () => {
|
|
274
|
+
it('carries the item\'s other props through untouched', () => {
|
|
275
|
+
const rich = { ...dragged, inPoint: 3, outPoint: 13, muted: true } as VisualItem
|
|
276
|
+
const moved = moveItemAcrossTracks({ tracks: stack([rich]), item: rich, start: 5, end: 15, sourceTrackIdx: 0, dy: 0 })
|
|
277
|
+
expect(moved[0].items[0]).toMatchObject({ inPoint: 3, outPoint: 13, muted: true, start: 5, end: 15 })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('prunes tracks the move left empty', () => {
|
|
281
|
+
const tracks = stack([clip('a', 0, 10)], [dragged])
|
|
282
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 20, end: 30, sourceTrackIdx: 1, dy: 24 })
|
|
283
|
+
expect(ids(moved)).toEqual([['a', 'd']])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('does not mutate the tracks it was given', () => {
|
|
287
|
+
const tracks = stack([clip('a', 0, 10)], [dragged])
|
|
288
|
+
const before = JSON.stringify(tracks)
|
|
289
|
+
moveItemAcrossTracks({ tracks, item: dragged, start: 20, end: 30, sourceTrackIdx: 1, dy: 24 })
|
|
290
|
+
expect(JSON.stringify(tracks)).toBe(before)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('gives a newly minted top track an id that collides with nothing', () => {
|
|
294
|
+
// `trk-2` is deliberately squatting on the id the index rule would hand out
|
|
295
|
+
// for the new track, so the normalizer's dedupe suffix has to kick in.
|
|
296
|
+
const tracks: VisualTrack[] = [
|
|
297
|
+
{ id: 'trk-0', items: [dragged, clip('stay', 50, 60)] },
|
|
298
|
+
{ id: 'trk-2', items: [clip('b', 50, 60)] },
|
|
299
|
+
]
|
|
300
|
+
const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -240 })
|
|
301
|
+
expect(moved.map(t => t.id)).toEqual(['trk-0', 'trk-2', 'trk-2-2'])
|
|
302
|
+
expect(ids(moved)).toEqual([['stay'], ['b'], ['d']])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// ── The reason tracks became objects ─────────────────────────────────────────
|
|
307
|
+
//
|
|
308
|
+
// This is the bug the whole shape change exists to make impossible. Track
|
|
309
|
+
// settings used to have nowhere to live but a second array indexed alongside
|
|
310
|
+
// `tracks`. The prune below deletes a track from the MIDDLE of the stack, so
|
|
311
|
+
// every index above it shifts down by one — and a parallel array, which has no
|
|
312
|
+
// idea a prune happened, keeps handing out settings by the OLD index. The
|
|
313
|
+
// result is silent: no error, no crash, the timeline just quietly plays a
|
|
314
|
+
// different track at the wrong volume.
|
|
315
|
+
//
|
|
316
|
+
// Settings that live ON the track object travel with the track through the
|
|
317
|
+
// filter, so there is no index to get out of step. Both tests below would fail
|
|
318
|
+
// against a parallel-array implementation.
|
|
319
|
+
|
|
320
|
+
describe('moveItemAcrossTracks — settings survive the prune', () => {
|
|
321
|
+
/** Three tracks, each with settings only IT should ever carry. */
|
|
322
|
+
function settled(soloItem: VisualItem): VisualTrack[] {
|
|
323
|
+
return [
|
|
324
|
+
{ id: 'trk-a', items: [clip('a', 0, 10)], volume: 0.2 },
|
|
325
|
+
{ id: 'trk-b', items: [soloItem], volume: 0.5 },
|
|
326
|
+
{ id: 'trk-c', items: [clip('c', 0, 10)], muted: true },
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
it('does not shift a pruned middle track\'s settings onto the track above it', () => {
|
|
331
|
+
const solo = clip('solo', 40, 50)
|
|
332
|
+
// Downward travel lowers the index, so trk-b's ONLY item lands on trk-a and
|
|
333
|
+
// trk-b is pruned. trk-c moves from index 2 to index 1.
|
|
334
|
+
const moved = moveItemAcrossTracks({
|
|
335
|
+
tracks: settled(solo), item: solo, start: 40, end: 50, sourceTrackIdx: 1, dy: 24,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(ids(moved)).toEqual([['a', 'solo'], ['c']])
|
|
339
|
+
expect(moved.map(t => t.id)).toEqual(['trk-a', 'trk-c'])
|
|
340
|
+
// Each survivor still carries ITS OWN settings, not the ones its new index
|
|
341
|
+
// used to point at.
|
|
342
|
+
expect(moved[0]).toMatchObject({ id: 'trk-a', volume: 0.2 })
|
|
343
|
+
expect(moved[0].muted).toBeUndefined()
|
|
344
|
+
expect(moved[1]).toMatchObject({ id: 'trk-c', muted: true })
|
|
345
|
+
expect(moved[1].volume).toBeUndefined() // trk-b's 0.5 did not follow the index down
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('keeps settings intact when the prune also mints a new top track', () => {
|
|
349
|
+
const solo = clip('solo', 40, 50)
|
|
350
|
+
// Two steps up from track 1 is index 3, one past the end — a new track.
|
|
351
|
+
const moved = moveItemAcrossTracks({
|
|
352
|
+
tracks: settled(solo), item: solo, start: 40, end: 50, sourceTrackIdx: 1, dy: -48,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
expect(ids(moved)).toEqual([['a'], ['c'], ['solo']])
|
|
356
|
+
expect(moved.map(t => t.id)).toEqual(['trk-a', 'trk-c', 'trk-3'])
|
|
357
|
+
expect(moved.map(t => [t.volume, t.muted])).toEqual([
|
|
358
|
+
[0.2, undefined],
|
|
359
|
+
[undefined, true],
|
|
360
|
+
[undefined, undefined], // a fresh track carries no settings at all
|
|
361
|
+
])
|
|
362
|
+
})
|
|
363
|
+
})
|