@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { Project } from '../../../types'
|
|
3
|
+
import type { AudioTrack, VisualItem, VisualTrack } from '../../../schema'
|
|
4
|
+
import {
|
|
5
|
+
AUDIO_FALLBACK_SPAN_SECONDS,
|
|
6
|
+
resolveAudioWindow,
|
|
7
|
+
computeAutoCrossfade,
|
|
8
|
+
computeDerivedTiming,
|
|
9
|
+
groupAudioLanes,
|
|
10
|
+
mapTrackItems,
|
|
11
|
+
normalizeTrackOrder,
|
|
12
|
+
normalizeTracks,
|
|
13
|
+
trackItems,
|
|
14
|
+
} from '../timeline-model'
|
|
15
|
+
|
|
16
|
+
function track(overrides: Partial<AudioTrack> = {}): AudioTrack {
|
|
17
|
+
return { id: 't0', src: 'a.mp3', start: 0, end: 2, ...overrides }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function project(overrides: Partial<Project> = {}): Project {
|
|
21
|
+
return { id: 'p1', ...overrides } as unknown as Project
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('computeDerivedTiming', () => {
|
|
25
|
+
it('derives snap boundaries, content duration, and padded total duration from tracks + audio', () => {
|
|
26
|
+
const p = project({
|
|
27
|
+
tracks: [[
|
|
28
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4 },
|
|
29
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 10 },
|
|
30
|
+
]],
|
|
31
|
+
audio: { tracks: [track({ start: 2, end: 6 })] },
|
|
32
|
+
} as unknown as Partial<Project>)
|
|
33
|
+
|
|
34
|
+
const { snapBoundaries, contentDuration, totalDuration } = computeDerivedTiming(p)
|
|
35
|
+
expect(snapBoundaries.sort((a, b) => a - b)).toEqual([0, 2, 4, 6, 10])
|
|
36
|
+
expect(contentDuration).toBe(10)
|
|
37
|
+
// max(5, 10 * 0.2) = 5s padding beyond content
|
|
38
|
+
expect(totalDuration).toBe(15)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses the 5s minimum headroom when 20% of content duration is smaller', () => {
|
|
42
|
+
const p = project({
|
|
43
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 2 }]],
|
|
44
|
+
} as unknown as Partial<Project>)
|
|
45
|
+
|
|
46
|
+
const { contentDuration, totalDuration } = computeDerivedTiming(p)
|
|
47
|
+
expect(contentDuration).toBe(2)
|
|
48
|
+
expect(totalDuration).toBe(7) // 2 + max(5, 0.4) = 7
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns zeroed timing for a project with no tracks or audio', () => {
|
|
52
|
+
const { snapBoundaries, contentDuration, totalDuration } = computeDerivedTiming(project())
|
|
53
|
+
expect(snapBoundaries).toEqual([])
|
|
54
|
+
expect(contentDuration).toBe(0)
|
|
55
|
+
expect(totalDuration).toBe(5)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('computeAutoCrossfade', () => {
|
|
60
|
+
it('returns null (no-change signal) when there are no audio tracks', () => {
|
|
61
|
+
expect(computeAutoCrossfade(project())).toBeNull()
|
|
62
|
+
expect(computeAutoCrossfade(project({ audio: { tracks: [] } }))).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns null when tracks do not overlap, leaving any stale fade values untouched', () => {
|
|
66
|
+
// The overlap check is a strict `a.end > b.start` — touching (not
|
|
67
|
+
// overlapping) tracks never enter the fade-assignment branch at all, so
|
|
68
|
+
// pre-existing fade values (e.g. left over from before the tracks were
|
|
69
|
+
// moved apart) are not cleared. This mirrors the original inline effect
|
|
70
|
+
// exactly: there is no "un-fade" branch, only "set fade while overlapping".
|
|
71
|
+
const p = project({
|
|
72
|
+
audio: {
|
|
73
|
+
tracks: [
|
|
74
|
+
track({ id: 'a', start: 0, end: 2, fadeOut: 1 }),
|
|
75
|
+
track({ id: 'b', start: 2, end: 4, fadeIn: 1 }),
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
expect(computeAutoCrossfade(p)).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('adds fade-out/fade-in on overlapping tracks, sized to the overlap', () => {
|
|
83
|
+
const p = project({
|
|
84
|
+
audio: { tracks: [track({ id: 'a', start: 0, end: 3 }), track({ id: 'b', start: 2, end: 5 })] },
|
|
85
|
+
})
|
|
86
|
+
const next = computeAutoCrossfade(p)
|
|
87
|
+
expect(next).not.toBeNull()
|
|
88
|
+
const tracks = next!.audio!.tracks
|
|
89
|
+
expect(tracks.find(t => t.id === 'a')!.fadeOut).toBe(1)
|
|
90
|
+
expect(tracks.find(t => t.id === 'b')!.fadeIn).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('shrinks an existing fade as two still-overlapping tracks are moved further apart', () => {
|
|
94
|
+
const p = project({
|
|
95
|
+
audio: {
|
|
96
|
+
tracks: [
|
|
97
|
+
track({ id: 'a', start: 0, end: 3, fadeOut: 2 }),
|
|
98
|
+
track({ id: 'b', start: 2.5, end: 5, fadeIn: 2 }),
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
const next = computeAutoCrossfade(p)
|
|
103
|
+
expect(next).not.toBeNull()
|
|
104
|
+
const tracks = next!.audio!.tracks
|
|
105
|
+
expect(tracks.find(t => t.id === 'a')!.fadeOut).toBe(0.5)
|
|
106
|
+
expect(tracks.find(t => t.id === 'b')!.fadeIn).toBe(0.5)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns null when existing fades already match the overlap (no-change signal, loop-safe)', () => {
|
|
110
|
+
const p = project({
|
|
111
|
+
audio: {
|
|
112
|
+
tracks: [
|
|
113
|
+
track({ id: 'a', start: 0, end: 3, fadeOut: 1 }),
|
|
114
|
+
track({ id: 'b', start: 2, end: 5, fadeIn: 1 }),
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
expect(computeAutoCrossfade(p)).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('is idempotent on a non-0.1-multiple overlap — a second pass over its own output is a no-op', () => {
|
|
122
|
+
// Overlap = 0.37s, which rounds to 0.4. The bug this guards: the change
|
|
123
|
+
// check compared the ROUNDED assignment (0.4) against the RAW overlap
|
|
124
|
+
// (0.37), which never match, so `changed` was permanently true for any
|
|
125
|
+
// overlap that wasn't already a multiple of 0.1s — merely opening an
|
|
126
|
+
// already-processed project reported a change (and, via Timeline.tsx's
|
|
127
|
+
// effect, wrote to disk and pushed a no-op undo entry) forever.
|
|
128
|
+
const p = project({
|
|
129
|
+
audio: { tracks: [track({ id: 'a', start: 0, end: 3.37 }), track({ id: 'b', start: 3, end: 6 })] },
|
|
130
|
+
})
|
|
131
|
+
const first = computeAutoCrossfade(p)
|
|
132
|
+
expect(first).not.toBeNull()
|
|
133
|
+
const tracks = first!.audio!.tracks
|
|
134
|
+
expect(tracks.find(t => t.id === 'a')!.fadeOut).toBe(0.4)
|
|
135
|
+
expect(tracks.find(t => t.id === 'b')!.fadeIn).toBe(0.4)
|
|
136
|
+
|
|
137
|
+
// Re-running on the already-processed project must be a no-op.
|
|
138
|
+
expect(computeAutoCrossfade(first!)).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('ignores overlap when either track is muted', () => {
|
|
142
|
+
const p = project({
|
|
143
|
+
audio: {
|
|
144
|
+
tracks: [
|
|
145
|
+
track({ id: 'a', start: 0, end: 3, muted: true }),
|
|
146
|
+
track({ id: 'b', start: 2, end: 5 }),
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
expect(computeAutoCrossfade(p)).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('preserves original track order and untouched tracks in the returned project', () => {
|
|
154
|
+
const p = project({
|
|
155
|
+
audio: {
|
|
156
|
+
tracks: [
|
|
157
|
+
track({ id: 'solo', start: 20, end: 22 }),
|
|
158
|
+
track({ id: 'a', start: 0, end: 3 }),
|
|
159
|
+
track({ id: 'b', start: 2, end: 5 }),
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
const next = computeAutoCrossfade(p)
|
|
164
|
+
expect(next).not.toBeNull()
|
|
165
|
+
expect(next!.audio!.tracks.map(t => t.id)).toEqual(['solo', 'a', 'b'])
|
|
166
|
+
expect(next!.audio!.tracks.find(t => t.id === 'solo')!.fadeOut).toBeUndefined()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('groupAudioLanes', () => {
|
|
171
|
+
it('keeps explicit lanes and sorts ascending', () => {
|
|
172
|
+
const lanes = groupAudioLanes([
|
|
173
|
+
track({ id: 'b', lane: 2 }),
|
|
174
|
+
track({ id: 'a', lane: 0 }),
|
|
175
|
+
track({ id: 'c', lane: 2 }),
|
|
176
|
+
])
|
|
177
|
+
expect(lanes.map(l => l.laneIndex)).toEqual([0, 2])
|
|
178
|
+
expect(lanes[1].tracks.map(t => t.id)).toEqual(['b', 'c'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('auto-assigns lane-less tracks above the highest explicit lane, in array order', () => {
|
|
182
|
+
const lanes = groupAudioLanes([
|
|
183
|
+
track({ id: 'auto1' }),
|
|
184
|
+
track({ id: 'pinned', lane: 3 }),
|
|
185
|
+
track({ id: 'auto2' }),
|
|
186
|
+
])
|
|
187
|
+
expect(lanes.map(l => l.laneIndex)).toEqual([3, 4, 5])
|
|
188
|
+
expect(lanes.map(l => l.tracks[0].id)).toEqual(['pinned', 'auto1', 'auto2'])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('returns nothing for no tracks', () => {
|
|
192
|
+
expect(groupAudioLanes([])).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('resolveAudioWindow', () => {
|
|
197
|
+
it('passes a well-formed window straight through', () => {
|
|
198
|
+
expect(resolveAudioWindow(track({ start: 2, end: 9 }), 30)).toEqual({ start: 2, end: 9 })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('defaults a missing start to 0', () => {
|
|
202
|
+
const t = { id: 'a', src: 'a.mp3', end: 9 } as unknown as AudioTrack
|
|
203
|
+
expect(resolveAudioWindow(t, 30).start).toBe(0)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('derives a missing end from outPoint minus inPoint', () => {
|
|
207
|
+
const t = { id: 'a', src: 'a.mp3', start: 3, inPoint: 2, outPoint: 12 } as unknown as AudioTrack
|
|
208
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 3, end: 13 })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('falls back to sourceDuration when there is no outPoint', () => {
|
|
212
|
+
const t = { id: 'a', src: 'a.mp3', start: 0, sourceDuration: 8 } as unknown as AudioTrack
|
|
213
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 0, end: 8 })
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('falls back to the project content duration when the source length is unknown', () => {
|
|
217
|
+
const t = { id: 'a', src: 'a.mp3' } as unknown as AudioTrack
|
|
218
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 0, end: 30 })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('never returns a zero-width window, even with no content to measure against', () => {
|
|
222
|
+
const t = { id: 'a', src: 'a.mp3' } as unknown as AudioTrack
|
|
223
|
+
expect(resolveAudioWindow(t, 0)).toEqual({ start: 0, end: AUDIO_FALLBACK_SPAN_SECONDS })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('survives a poisoned contentDuration', () => {
|
|
227
|
+
// One item with a non-numeric `end` anywhere in the project makes
|
|
228
|
+
// computeDerivedTiming's contentDuration NaN; the bar must still draw.
|
|
229
|
+
const t = { id: 'a', src: 'a.mp3' } as unknown as AudioTrack
|
|
230
|
+
// The exact span is the only value that proves the NaN horizon was
|
|
231
|
+
// REJECTED rather than propagated through Math.max.
|
|
232
|
+
expect(resolveAudioWindow(t, NaN)).toEqual({ start: 0, end: AUDIO_FALLBACK_SPAN_SECONDS })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('ignores a stale outPoint that is not past the in-point', () => {
|
|
236
|
+
const t = { id: 'a', src: 'a.mp3', start: 0, outPoint: 0, sourceDuration: 180 } as unknown as AudioTrack
|
|
237
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 0, end: 180 })
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('treats a zero-width declared window as undeclared', () => {
|
|
241
|
+
// `start: 0, end: 0` is what skills/lyrics-video shipped; it drew an
|
|
242
|
+
// invisible bar for the same reason a missing window did.
|
|
243
|
+
const t = { id: 'a', src: 'a.mp3', start: 0, end: 0 } as unknown as AudioTrack
|
|
244
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 0, end: 30 })
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('treats an inverted declared window as undeclared', () => {
|
|
248
|
+
const t = { id: 'a', src: 'a.mp3', start: 10, end: 4 } as unknown as AudioTrack
|
|
249
|
+
// `start` must survive: zeroing it would jump the bar to the timeline head.
|
|
250
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 10, end: 30 })
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('treats NaN the same as absent', () => {
|
|
254
|
+
const t = { id: 'a', src: 'a.mp3', start: NaN, end: NaN, sourceDuration: 4 } as unknown as AudioTrack
|
|
255
|
+
expect(resolveAudioWindow(t, 30)).toEqual({ start: 0, end: 4 })
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('groupAudioLanes — window resolution', () => {
|
|
260
|
+
it('returns the SAME object for a well-formed track (no-regression guarantee)', () => {
|
|
261
|
+
const t = track({ id: 'ok', start: 0, end: 5 })
|
|
262
|
+
const [lane] = groupAudioLanes([t], 30)
|
|
263
|
+
expect(lane.tracks[0]).toBe(t)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns a resolved copy for a track missing its end', () => {
|
|
267
|
+
const t = { id: 'bed', src: 'a.mp3', start: 0 } as unknown as AudioTrack
|
|
268
|
+
const [lane] = groupAudioLanes([t], 30)
|
|
269
|
+
expect(lane.tracks[0]).not.toBe(t)
|
|
270
|
+
expect(lane.tracks[0].end).toBe(30)
|
|
271
|
+
expect(lane.tracks[0].id).toBe('bed')
|
|
272
|
+
expect(t.end).toBeUndefined() // the caller's project is never mutated
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('still groups by lane exactly as before', () => {
|
|
276
|
+
const a = { id: 'a', src: 'a.mp3', lane: 2 } as unknown as AudioTrack
|
|
277
|
+
const b = track({ id: 'b', lane: 0 })
|
|
278
|
+
expect(groupAudioLanes([a, b], 30).map(l => l.laneIndex)).toEqual([0, 2])
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ── Track shape ────────────────────────────────────────────────────────────
|
|
283
|
+
//
|
|
284
|
+
// The load-bearing properties: normalization never mutates its input, is
|
|
285
|
+
// idempotent, and returns the SAME OBJECT when the project is already in
|
|
286
|
+
// object form (the lazy on-open migration reads that identity as "no write
|
|
287
|
+
// needed"). Mirrored case for case by tests/test_project_tracks.py and
|
|
288
|
+
// montaj_assets/render/test/project-tracks.test.mjs.
|
|
289
|
+
|
|
290
|
+
describe('normalizeTracks / trackItems', () => {
|
|
291
|
+
/** Deliberately loose, like the functions themselves: `tracks` is whatever is
|
|
292
|
+
* on disk, legacy or object shape. */
|
|
293
|
+
type Proj = { id: string; tracks?: unknown }
|
|
294
|
+
interface NormTrack { id: string; items: Array<{ id: string }>; [k: string]: unknown }
|
|
295
|
+
|
|
296
|
+
const item = (id: string) => ({ id, type: 'video', src: `${id}.mp4`, start: 0, end: 1 })
|
|
297
|
+
|
|
298
|
+
/** The normalized tracks, typed. `normalizeTracks` hands back the caller's own
|
|
299
|
+
* project type, so the object shape has to be named at the read site. */
|
|
300
|
+
const tracksOf = (p: Proj): NormTrack[] => normalizeTracks(p).tracks as NormTrack[]
|
|
301
|
+
|
|
302
|
+
const legacy = (): Proj => ({ id: 'p1', tracks: [[item('a'), item('b')], [item('c')]] })
|
|
303
|
+
const objectShape = (): Proj => ({
|
|
304
|
+
id: 'p1',
|
|
305
|
+
tracks: [
|
|
306
|
+
{ id: 'trk-0', items: [item('a'), item('b')] },
|
|
307
|
+
{ id: 'trk-1', items: [item('c')] },
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('turns the legacy VisualItem[][] shape into the object shape', () => {
|
|
312
|
+
expect(tracksOf(legacy())).toEqual([
|
|
313
|
+
{ id: 'trk-0', items: [item('a'), item('b')] },
|
|
314
|
+
{ id: 'trk-1', items: [item('c')] },
|
|
315
|
+
])
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('carries items over by reference and preserves item + track order', () => {
|
|
319
|
+
const a = item('a'), b = item('b'), c = item('c')
|
|
320
|
+
const out = tracksOf({ id: 'p1', tracks: [[a, b], [c]] })
|
|
321
|
+
expect(out[0].items[0]).toBe(a)
|
|
322
|
+
expect(out[0].items[1]).toBe(b)
|
|
323
|
+
expect(out[1].items[0]).toBe(c)
|
|
324
|
+
expect(out.map(t => t.items.map(i => i.id))).toEqual([['a', 'b'], ['c']])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('does not mutate its input, and builds new item arrays', () => {
|
|
328
|
+
const p = legacy()
|
|
329
|
+
const before = structuredClone(p)
|
|
330
|
+
const out = tracksOf(p)
|
|
331
|
+
expect(p).toEqual(before)
|
|
332
|
+
expect(Array.isArray((p.tracks as unknown[])[0])).toBe(true) // still legacy shape
|
|
333
|
+
out[0].items.push(item('z'))
|
|
334
|
+
expect((p.tracks as unknown[][])[0]).toHaveLength(2)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('returns the SAME OBJECT when tracks are already normalized', () => {
|
|
338
|
+
const p = objectShape()
|
|
339
|
+
expect(normalizeTracks(p)).toBe(p)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('is idempotent and converges, so a second pass needs no write', () => {
|
|
343
|
+
const once = normalizeTracks(legacy())
|
|
344
|
+
const twice = normalizeTracks(once)
|
|
345
|
+
expect(twice).toEqual(once)
|
|
346
|
+
expect(twice).toBe(once)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('leaves a project with no tracks alone and invents no tracks key', () => {
|
|
350
|
+
const missing: Proj = { id: 'p1' }
|
|
351
|
+
expect(normalizeTracks(missing)).toBe(missing)
|
|
352
|
+
expect('tracks' in normalizeTracks(missing)).toBe(false)
|
|
353
|
+
|
|
354
|
+
const nulled: Proj = { id: 'p1', tracks: null }
|
|
355
|
+
expect(normalizeTracks(nulled)).toBe(nulled)
|
|
356
|
+
const empty: Proj = { id: 'p1', tracks: [] }
|
|
357
|
+
expect(normalizeTracks(empty)).toBe(empty)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('leaves a tracks that is not an array alone rather than throwing', () => {
|
|
361
|
+
for (const bogus of ['nope', 7, { 'trk-0': [] }]) {
|
|
362
|
+
const p: Proj = { id: 'p1', tracks: bogus }
|
|
363
|
+
expect(normalizeTracks(p)).toBe(p)
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('turns a null/string/number track into an empty track', () => {
|
|
368
|
+
expect(tracksOf({ id: 'p1', tracks: [null, 'nope', 7] })).toEqual([
|
|
369
|
+
{ id: 'trk-0', items: [] },
|
|
370
|
+
{ id: 'trk-1', items: [] },
|
|
371
|
+
{ id: 'trk-2', items: [] },
|
|
372
|
+
])
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('coerces a missing, null or non-array items to an empty array', () => {
|
|
376
|
+
expect(tracksOf({
|
|
377
|
+
id: 'p1',
|
|
378
|
+
tracks: [{ id: 'a' }, { id: 'b', items: null }, { id: 'c', items: 'x' }],
|
|
379
|
+
})).toEqual([
|
|
380
|
+
{ id: 'a', items: [] },
|
|
381
|
+
{ id: 'b', items: [] },
|
|
382
|
+
{ id: 'c', items: [] },
|
|
383
|
+
])
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('fills in a missing, empty or non-string id', () => {
|
|
387
|
+
const out = tracksOf({
|
|
388
|
+
id: 'p1',
|
|
389
|
+
tracks: [{ items: [item('a')] }, { id: '', items: [] }, { id: 7, items: [] }],
|
|
390
|
+
})
|
|
391
|
+
expect(out.map(t => t.id)).toEqual(['trk-0', 'trk-1', 'trk-2'])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('lets the first holder of a duplicate id keep it', () => {
|
|
395
|
+
const out = tracksOf({
|
|
396
|
+
id: 'p1',
|
|
397
|
+
tracks: [{ id: 'dup', items: [] }, { id: 'dup', items: [] }],
|
|
398
|
+
})
|
|
399
|
+
expect(out.map(t => t.id)).toEqual(['dup', 'trk-1'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('steps a generated id aside when an explicit id already claims the name', () => {
|
|
403
|
+
const out = tracksOf({
|
|
404
|
+
id: 'p1',
|
|
405
|
+
tracks: [[item('a')], { id: 'trk-0', items: [item('b')] }],
|
|
406
|
+
})
|
|
407
|
+
expect(out.map(t => t.id)).toEqual(['trk-0-2', 'trk-0'])
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('keeps generated ids unique under repeated collision', () => {
|
|
411
|
+
const out = tracksOf({
|
|
412
|
+
id: 'p1',
|
|
413
|
+
tracks: [[], { id: 'trk-0', items: [] }, { id: 'trk-0-2', items: [] }],
|
|
414
|
+
})
|
|
415
|
+
expect(out.map(t => t.id)).toEqual(['trk-0-3', 'trk-0', 'trk-0-2'])
|
|
416
|
+
expect(new Set(out.map(t => t.id)).size).toBe(3)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('carries volume / muted / enabled and unknown keys through, and adds no defaults', () => {
|
|
420
|
+
const out = tracksOf({
|
|
421
|
+
id: 'p1',
|
|
422
|
+
tracks: [
|
|
423
|
+
{ id: 'trk-0', items: [], volume: 0.8, muted: false, enabled: true, somethingNew: { a: 1 } },
|
|
424
|
+
// Overlay-typed, not `item('a')`'s default video — both tracks stay
|
|
425
|
+
// in the SAME group (empty and overlay both group as overlay), so
|
|
426
|
+
// normalizeTracks' order-canonicalization doesn't move this one past
|
|
427
|
+
// index 0 and this test can stay about shape-normalization alone.
|
|
428
|
+
[{ id: 'a', type: 'overlay', start: 0, end: 1 }], // forces a rebuild so the object above is copied, not returned as-is
|
|
429
|
+
],
|
|
430
|
+
})
|
|
431
|
+
expect(out[0]).toEqual({
|
|
432
|
+
id: 'trk-0', items: [], volume: 0.8, muted: false, enabled: true, somethingNew: { a: 1 },
|
|
433
|
+
})
|
|
434
|
+
expect(Object.keys(out[1]).sort()).toEqual(['id', 'items'])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('reads items out of either shape', () => {
|
|
438
|
+
expect(trackItems(legacy())).toEqual([[item('a'), item('b')], [item('c')]])
|
|
439
|
+
const p = objectShape()
|
|
440
|
+
expect(trackItems(p)).toEqual([[item('a'), item('b')], [item('c')]])
|
|
441
|
+
expect(trackItems(p)[0]).toBe((p.tracks as NormTrack[])[0].items)
|
|
442
|
+
expect(trackItems(legacy()).flat().map(i => i.id)).toEqual(['a', 'b', 'c'])
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('reads no items out of a project with no usable tracks', () => {
|
|
446
|
+
const missing: Proj = { id: 'p1' }
|
|
447
|
+
const nulled: Proj = { id: 'p1', tracks: null }
|
|
448
|
+
const empty: Proj = { id: 'p1', tracks: [] }
|
|
449
|
+
const bogus: Proj = { id: 'p1', tracks: 'nope' }
|
|
450
|
+
expect(trackItems(missing)).toEqual([])
|
|
451
|
+
expect(trackItems(nulled)).toEqual([])
|
|
452
|
+
expect(trackItems(empty)).toEqual([])
|
|
453
|
+
expect(trackItems(bogus)).toEqual([])
|
|
454
|
+
expect(trackItems(null)).toEqual([])
|
|
455
|
+
expect(trackItems(undefined)).toEqual([])
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// ── Track group ordering ────────────────────────────────────────────────
|
|
460
|
+
//
|
|
461
|
+
// The canonical stack: every video track first (in existing relative
|
|
462
|
+
// order), then every overlay track (in existing relative order). Identity
|
|
463
|
+
// preservation on an already-canonical project is load-bearing — see the
|
|
464
|
+
// function's own doc comment — so it gets its own `toBe` assertion here,
|
|
465
|
+
// not folded into a looser `toEqual` check.
|
|
466
|
+
|
|
467
|
+
describe('normalizeTrackOrder', () => {
|
|
468
|
+
const videoItem = (id: string): VisualItem => ({ id, type: 'video', src: `${id}.mp4`, start: 0, end: 1 } as VisualItem)
|
|
469
|
+
const overlayItem = (id: string): VisualItem => ({ id, type: 'overlay', start: 0, end: 1 } as unknown as VisualItem)
|
|
470
|
+
const videoTrack = (id: string): VisualTrack => ({ id, items: [videoItem(`${id}-i`)] })
|
|
471
|
+
const overlayTrack = (id: string): VisualTrack => ({ id, items: [overlayItem(`${id}-i`)] })
|
|
472
|
+
const emptyTrack = (id: string): VisualTrack => ({ id, items: [] })
|
|
473
|
+
const tracksOf = (p: Project): VisualTrack[] => normalizeTrackOrder(p).tracks as VisualTrack[]
|
|
474
|
+
|
|
475
|
+
it('stably partitions [video, overlay, video] into [video, video, overlay]', () => {
|
|
476
|
+
const v0 = videoTrack('v0')
|
|
477
|
+
const o0 = overlayTrack('o0')
|
|
478
|
+
const v1 = videoTrack('v1')
|
|
479
|
+
const out = tracksOf(project({ tracks: [v0, o0, v1] } as unknown as Partial<Project>))
|
|
480
|
+
expect(out.map(t => t.id)).toEqual(['v0', 'v1', 'o0'])
|
|
481
|
+
// Stable: v0 still precedes v1 (their original relative order), and
|
|
482
|
+
// every track survives BY REFERENCE — only the array's order changes.
|
|
483
|
+
expect(out[0]).toBe(v0)
|
|
484
|
+
expect(out[1]).toBe(v1)
|
|
485
|
+
expect(out[2]).toBe(o0)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('returns the SAME project object when tracks are already canonical', () => {
|
|
489
|
+
const p = project({ tracks: [videoTrack('v0'), videoTrack('v1'), overlayTrack('o0')] } as unknown as Partial<Project>)
|
|
490
|
+
expect(normalizeTrackOrder(p)).toBe(p)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('base video (index 0) always stays first', () => {
|
|
494
|
+
const base = videoTrack('base')
|
|
495
|
+
const out = tracksOf(project({ tracks: [base, overlayTrack('o0'), videoTrack('v1')] } as unknown as Partial<Project>))
|
|
496
|
+
expect(out[0]).toBe(base)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('leaves an overlay-only project untouched (already canonical, no video group at all)', () => {
|
|
500
|
+
const p = project({ tracks: [overlayTrack('o0'), overlayTrack('o1')] } as unknown as Partial<Project>)
|
|
501
|
+
expect(normalizeTrackOrder(p)).toBe(p)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('groups an empty track as overlay', () => {
|
|
505
|
+
const v0 = videoTrack('v0')
|
|
506
|
+
const out = tracksOf(project({ tracks: [emptyTrack('e0'), v0] } as unknown as Partial<Project>))
|
|
507
|
+
expect(out.map(t => t.id)).toEqual(['v0', 'e0'])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('groups a track with even one video item as video, mixed with overlay items or not', () => {
|
|
511
|
+
const mixed: VisualTrack = { id: 'mixed', items: [overlayItem('a'), videoItem('b')] }
|
|
512
|
+
const out = tracksOf(project({ tracks: [overlayTrack('o0'), mixed] } as unknown as Partial<Project>))
|
|
513
|
+
expect(out.map(t => t.id)).toEqual(['mixed', 'o0'])
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('is a no-op for a project with no tracks, or an empty tracks array', () => {
|
|
517
|
+
const noTracks = project({})
|
|
518
|
+
expect(normalizeTrackOrder(noTracks)).toBe(noTracks)
|
|
519
|
+
const emptyTracks = project({ tracks: [] })
|
|
520
|
+
expect(normalizeTrackOrder(emptyTracks)).toBe(emptyTracks)
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
describe('mapTrackItems', () => {
|
|
525
|
+
type Proj = { id: string; tracks?: unknown }
|
|
526
|
+
const item = (id: string) => ({ id, type: 'video', src: `${id}.mp4`, start: 0, end: 1 }) as unknown as VisualItem
|
|
527
|
+
|
|
528
|
+
it('preserves every track\'s id and settings while rewriting its items', () => {
|
|
529
|
+
const p: Proj = {
|
|
530
|
+
id: 'p1',
|
|
531
|
+
tracks: [
|
|
532
|
+
{ id: 'trk-a', items: [item('a')], volume: 0.2 },
|
|
533
|
+
{ id: 'trk-b', items: [item('b')], muted: true, enabled: false, somethingNew: 1 },
|
|
534
|
+
],
|
|
535
|
+
}
|
|
536
|
+
expect(mapTrackItems(p, items => items.map(i => ({ ...i, end: 9 })))).toEqual([
|
|
537
|
+
{ id: 'trk-a', items: [{ ...item('a'), end: 9 }], volume: 0.2 },
|
|
538
|
+
{ id: 'trk-b', items: [{ ...item('b'), end: 9 }], muted: true, enabled: false, somethingNew: 1 },
|
|
539
|
+
])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('preserves track order and passes each track its own index', () => {
|
|
543
|
+
const p: Proj = { id: 'p1', tracks: [[item('a')], [item('b')], [item('c')]] }
|
|
544
|
+
const seen: number[] = []
|
|
545
|
+
const out = mapTrackItems(p, (items, i) => { seen.push(i); return i === 1 ? [] : items })
|
|
546
|
+
expect(seen).toEqual([0, 1, 2])
|
|
547
|
+
expect(out.map(t => t.id)).toEqual(['trk-0', 'trk-1', 'trk-2'])
|
|
548
|
+
expect(out.map(t => t.items.map(i => i.id))).toEqual([['a'], [], ['c']])
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('keeps a track the callback emptied — pruning is never its business', () => {
|
|
552
|
+
const p: Proj = { id: 'p1', tracks: [[item('a')], [item('b')]] }
|
|
553
|
+
expect(mapTrackItems(p, () => []).map(t => t.items)).toEqual([[], []])
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('tolerates the legacy shape on input and always returns the object shape', () => {
|
|
557
|
+
const p: Proj = { id: 'p1', tracks: [[item('a'), item('b')], [item('c')]] }
|
|
558
|
+
expect(mapTrackItems(p, items => items.slice(0, 1))).toEqual([
|
|
559
|
+
{ id: 'trk-0', items: [item('a')] },
|
|
560
|
+
{ id: 'trk-1', items: [item('c')] },
|
|
561
|
+
])
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('does not mutate its input', () => {
|
|
565
|
+
const p: Proj = { id: 'p1', tracks: [{ id: 'trk-a', items: [item('a')], volume: 0.2 }] }
|
|
566
|
+
const before = structuredClone(p)
|
|
567
|
+
mapTrackItems(p, () => [item('z')])
|
|
568
|
+
expect(p).toEqual(before)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('reads no tracks out of a project with no usable tracks', () => {
|
|
572
|
+
expect(mapTrackItems({ id: 'p1' } as Proj, items => items)).toEqual([])
|
|
573
|
+
expect(mapTrackItems({ id: 'p1', tracks: null } as Proj, items => items)).toEqual([])
|
|
574
|
+
expect(mapTrackItems({ id: 'p1', tracks: 'nope' } as Proj, items => items)).toEqual([])
|
|
575
|
+
})
|
|
576
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* What a timeline block calls itself. The old label was the bare `type`, which
|
|
4
|
+
* meant a project with twenty overlays showed the word "overlay" twenty times —
|
|
5
|
+
* the block you wanted could only be found by clicking through them.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { visualItemLabel } from '../timeline-model'
|
|
9
|
+
import type { VisualItem } from '../../../schema'
|
|
10
|
+
|
|
11
|
+
function overlay(over: Partial<VisualItem> = {}): VisualItem {
|
|
12
|
+
return { id: 'o', type: 'overlay', start: 0, end: 1, ...over }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('visualItemLabel', () => {
|
|
16
|
+
it('names an overlay by its component, so two kinds are told apart at a glance', () => {
|
|
17
|
+
expect(visualItemLabel(overlay({ src: '/p/overlays/photo_hero.jsx' }))).toBe('photo_hero')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('appends the text of the first line, which is what is actually on screen', () => {
|
|
21
|
+
const item = overlay({
|
|
22
|
+
src: '/p/overlays/text_line.jsx',
|
|
23
|
+
props: { lines: [{ text: 'we still need them', accent: 'need' }, { text: 'second' }] },
|
|
24
|
+
})
|
|
25
|
+
expect(visualItemLabel(item)).toBe('text_line · we still need them')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('falls back through the other copy-carrying props', () => {
|
|
29
|
+
const src = '/p/overlays/photo_hero.jsx'
|
|
30
|
+
expect(visualItemLabel(overlay({ src, props: { caption: 'ex-googler' } }))).toBe('photo_hero · ex-googler')
|
|
31
|
+
expect(visualItemLabel(overlay({ src, props: { title: 'A Title' } }))).toBe('photo_hero · A Title')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps the kind alone when the overlay has no copy of its own', () => {
|
|
35
|
+
const item = overlay({ src: '/p/overlays/cold_open.jsx', props: { img1: '/a.jpg', img2: '/b.png' } })
|
|
36
|
+
expect(visualItemLabel(item)).toBe('cold_open')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('truncates long copy rather than letting it run the width of the clip', () => {
|
|
40
|
+
const item = overlay({
|
|
41
|
+
src: '/p/overlays/text_line.jsx',
|
|
42
|
+
props: { text: 'a sentence far longer than any timeline block can show' },
|
|
43
|
+
})
|
|
44
|
+
const label = visualItemLabel(item)
|
|
45
|
+
expect(label.endsWith('…')).toBe(true)
|
|
46
|
+
expect(label.length).toBeLessThanOrEqual('text_line · '.length + 28)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('ignores blank and non-string copy instead of labelling an empty suffix', () => {
|
|
50
|
+
const src = '/p/overlays/text_line.jsx'
|
|
51
|
+
expect(visualItemLabel(overlay({ src, props: { caption: ' ' } }))).toBe('text_line')
|
|
52
|
+
expect(visualItemLabel(overlay({ src, props: { caption: 42 } }))).toBe('text_line')
|
|
53
|
+
expect(visualItemLabel(overlay({ src, props: { lines: [] } }))).toBe('text_line')
|
|
54
|
+
expect(visualItemLabel(overlay({ src, props: { lines: [{ accent: 'x' }] } }))).toBe('text_line')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('gives video clips no label — the track rail names the row, the filmstrip names the shot', () => {
|
|
58
|
+
expect(visualItemLabel({ id: 'c', type: 'video', src: '/p/IMG_9401.MOV', start: 0, end: 1 })).toBe('')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('names an image by its file', () => {
|
|
62
|
+
expect(visualItemLabel({ id: 'i', type: 'image', src: '/p/assets/robot_dog.png', start: 0, end: 1 })).toBe('robot_dog')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('degrades to something printable when there is no src at all', () => {
|
|
66
|
+
expect(visualItemLabel(overlay())).toBe('overlay')
|
|
67
|
+
expect(visualItemLabel({ id: 'i', type: 'image', start: 0, end: 1 })).toBe('image')
|
|
68
|
+
})
|
|
69
|
+
})
|