@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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `placeDroppedClip` — the one placement rule shared by every "new footage
|
|
3
|
+
* lands on the timeline" drop entry point (see placement.ts's own doc
|
|
4
|
+
* comment for who calls it). Tracks are built through `vtracks()`, the same
|
|
5
|
+
* `trk-<i>` id convention `normalizeTracks` hands out, so fixtures already
|
|
6
|
+
* sit in canonical order and reordering surprises stay out of the tests that
|
|
7
|
+
* aren't specifically about ordering.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import type { EditorProject as Project, VisualItem, VisualTrack } from '../../../schema'
|
|
11
|
+
import { placeDroppedClip, resolveDropTrackIndex } from '../placement'
|
|
12
|
+
|
|
13
|
+
function vtracks(...items: VisualItem[][]): VisualTrack[] {
|
|
14
|
+
return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeProject(over: Partial<Project> = {}): Project {
|
|
18
|
+
return {
|
|
19
|
+
id: 'p1',
|
|
20
|
+
status: 'draft',
|
|
21
|
+
settings: { resolution: [1080, 1920] },
|
|
22
|
+
tracks: vtracks([]),
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const videoItem = (id: string, start: number, end: number): VisualItem =>
|
|
28
|
+
({ id, type: 'video', src: `${id}.mp4`, start, end }) as VisualItem
|
|
29
|
+
|
|
30
|
+
const imageItem = (id: string, start: number, end: number): VisualItem =>
|
|
31
|
+
({ id, type: 'image', src: `${id}.png`, start, end }) as VisualItem
|
|
32
|
+
|
|
33
|
+
describe('placeDroppedClip — guard', () => {
|
|
34
|
+
it.each([0, NaN, -1])('places nothing for a sourceDuration of %s, returning the input project by reference', dur => {
|
|
35
|
+
const p = makeProject({ tracks: vtracks([videoItem('a', 0, 5)]) })
|
|
36
|
+
const result = placeDroppedClip(p, {
|
|
37
|
+
atTime: 0,
|
|
38
|
+
preferredTrackIndex: 0,
|
|
39
|
+
clip: { src: 'n.mp4', sourceDuration: dur },
|
|
40
|
+
})
|
|
41
|
+
expect(result).toEqual({ project: p, trackIndex: -1, start: 0, createdTrack: false })
|
|
42
|
+
expect(result.project).toBe(p)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('placeDroppedClip — free preferred track', () => {
|
|
47
|
+
it('lands on the preferred track at exactly atTime when its region is free', () => {
|
|
48
|
+
const p = makeProject({ tracks: vtracks([videoItem('a', 20, 25)]) })
|
|
49
|
+
const result = placeDroppedClip(p, {
|
|
50
|
+
atTime: 3,
|
|
51
|
+
preferredTrackIndex: 0,
|
|
52
|
+
clip: { src: 'n.mp4', sourceDuration: 4 },
|
|
53
|
+
})
|
|
54
|
+
expect(result.trackIndex).toBe(0)
|
|
55
|
+
expect(result.start).toBe(3)
|
|
56
|
+
expect(result.createdTrack).toBe(false)
|
|
57
|
+
expect(result.itemId).toBeDefined()
|
|
58
|
+
const placed = result.project.tracks![0].items.find(it => it.id === result.itemId)!
|
|
59
|
+
expect(placed).toMatchObject({ start: 3, end: 7, type: 'video' })
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('placeDroppedClip — occupied preferred track falls back', () => {
|
|
64
|
+
it('lands at the same time on the CLOSEST free video track, not just any free one', () => {
|
|
65
|
+
const p = makeProject({
|
|
66
|
+
tracks: vtracks(
|
|
67
|
+
[videoItem('a', 0, 5)], // trk-0: preferred, but OCCUPIED at the drop window
|
|
68
|
+
[videoItem('b', 20, 25)], // trk-1: video-kind (item elsewhere), free at the drop window — distance 1
|
|
69
|
+
[videoItem('c', 20, 25)], // trk-2: video-kind (item elsewhere), free at the drop window — distance 2
|
|
70
|
+
),
|
|
71
|
+
})
|
|
72
|
+
const result = placeDroppedClip(p, {
|
|
73
|
+
atTime: 0,
|
|
74
|
+
preferredTrackIndex: 0,
|
|
75
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
76
|
+
})
|
|
77
|
+
expect(result.trackIndex).toBe(1)
|
|
78
|
+
expect(result.start).toBe(0)
|
|
79
|
+
expect(result.createdTrack).toBe(false)
|
|
80
|
+
// trk-0's occupant and trk-2 are both untouched.
|
|
81
|
+
expect(result.project.tracks![0].items).toEqual([videoItem('a', 0, 5)])
|
|
82
|
+
expect(result.project.tracks![2].items).toEqual([videoItem('c', 20, 25)])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('breaks a distance tie in favor of the LOWER index', () => {
|
|
86
|
+
const p = makeProject({
|
|
87
|
+
tracks: vtracks(
|
|
88
|
+
[videoItem('a', 20, 25)], // trk-0: free at the drop window — distance 1 from ref
|
|
89
|
+
[videoItem('b', 0, 5)], // trk-1: preferred, OCCUPIED — this is `ref`
|
|
90
|
+
[videoItem('c', 20, 25)], // trk-2: free at the drop window — distance 1 from ref
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
const result = placeDroppedClip(p, {
|
|
94
|
+
atTime: 0,
|
|
95
|
+
preferredTrackIndex: 1,
|
|
96
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
97
|
+
})
|
|
98
|
+
expect(result.trackIndex).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('placeDroppedClip — new track creation', () => {
|
|
103
|
+
it('creates a new video track when every existing video track is occupied at the drop region, without moving anything', () => {
|
|
104
|
+
const p = makeProject({
|
|
105
|
+
tracks: vtracks(
|
|
106
|
+
[videoItem('a', 0, 10)],
|
|
107
|
+
[videoItem('b', 0, 10)],
|
|
108
|
+
[imageItem('ov', 0, 100)], // overlay/image track — sorts after the video block
|
|
109
|
+
),
|
|
110
|
+
})
|
|
111
|
+
const result = placeDroppedClip(p, {
|
|
112
|
+
atTime: 2,
|
|
113
|
+
preferredTrackIndex: 0,
|
|
114
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
115
|
+
})
|
|
116
|
+
expect(result.createdTrack).toBe(true)
|
|
117
|
+
expect(result.trackIndex).toBe(2) // top of the video block, below the overlay track
|
|
118
|
+
const tracks = result.project.tracks!
|
|
119
|
+
expect(tracks).toHaveLength(4)
|
|
120
|
+
expect(tracks[0].items).toEqual([videoItem('a', 0, 10)]) // untouched
|
|
121
|
+
expect(tracks[1].items).toEqual([videoItem('b', 0, 10)]) // untouched
|
|
122
|
+
expect(tracks[2].items).toHaveLength(1)
|
|
123
|
+
expect(tracks[2].items[0]).toMatchObject({ start: 2, end: 5, type: 'video' })
|
|
124
|
+
expect(tracks[3].items).toEqual([imageItem('ov', 0, 100)]) // the overlay track, still last
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('placeDroppedClip — kind lock', () => {
|
|
129
|
+
it('never chooses an overlay/image track, even when it is the preferred index and free', () => {
|
|
130
|
+
const p = makeProject({
|
|
131
|
+
tracks: vtracks(
|
|
132
|
+
[videoItem('a', 20, 25)], // trk-0: video-kind, free at the drop window
|
|
133
|
+
[imageItem('img', 0, 100)], // trk-1: image-kind, free everywhere, but not a candidate
|
|
134
|
+
),
|
|
135
|
+
})
|
|
136
|
+
const result = placeDroppedClip(p, {
|
|
137
|
+
atTime: 0,
|
|
138
|
+
preferredTrackIndex: 1, // points at the image track
|
|
139
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
140
|
+
})
|
|
141
|
+
expect(result.trackIndex).toBe(0)
|
|
142
|
+
expect(result.project.tracks![1].items).toEqual([imageItem('img', 0, 100)]) // untouched
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('resolveDropTrackIndex — where the ghost band goes', () => {
|
|
147
|
+
it('resolves a drop released over an overlay/image row to the VIDEO row (the ghost bug)', () => {
|
|
148
|
+
// The exact filesystem-drop ghost defect: the pointer released over the
|
|
149
|
+
// image row (index 1), so the ghost used to draw there — but the clip
|
|
150
|
+
// always lands on the video row. The ghost must resolve the same way.
|
|
151
|
+
const p = makeProject({
|
|
152
|
+
tracks: vtracks(
|
|
153
|
+
[videoItem('a', 20, 25)], // trk-0: video-kind, free at the drop window
|
|
154
|
+
[imageItem('img', 0, 100)], // trk-1: image-kind — never a ghost home
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
const idx = resolveDropTrackIndex(p, {
|
|
158
|
+
atTime: 0,
|
|
159
|
+
preferredTrackIndex: 1, // released over the image row
|
|
160
|
+
clip: { sourceDuration: 3 },
|
|
161
|
+
})
|
|
162
|
+
expect(idx).toBe(0)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('returns a past-the-end index (a fresh video row) when no video row is free', () => {
|
|
166
|
+
const p = makeProject({
|
|
167
|
+
tracks: vtracks(
|
|
168
|
+
[videoItem('a', 0, 100)], // the only video row, occupied across the drop
|
|
169
|
+
[imageItem('img', 0, 100)],
|
|
170
|
+
),
|
|
171
|
+
})
|
|
172
|
+
const idx = resolveDropTrackIndex(p, {
|
|
173
|
+
atTime: 0,
|
|
174
|
+
preferredTrackIndex: 0,
|
|
175
|
+
ripple: false,
|
|
176
|
+
clip: { sourceDuration: 5 }, // overlaps the occupied video row
|
|
177
|
+
})
|
|
178
|
+
expect(idx).toBe(2) // == tracks.length ⇒ placeDroppedClip would make a new track
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('placeDroppedClip — no preference', () => {
|
|
183
|
+
it('defaults ref to 0, not some other plausible default — proven by a fixture where they disagree', () => {
|
|
184
|
+
// A single-track fixture can't tell "ref correctly defaults to 0" apart
|
|
185
|
+
// from a broken implementation that skips the `>= 0` clamp entirely: with
|
|
186
|
+
// exactly one candidate, EVERY possible ref lands on it, so the assertion
|
|
187
|
+
// would pass for the wrong reason. This fixture forces a real choice:
|
|
188
|
+
// trk-0 — free at the drop window, distance 0 from a correct ref=0
|
|
189
|
+
// trk-1 — OCCUPIED at the drop window (never a candidate either way;
|
|
190
|
+
// exists only to sit between trk-0 and trk-2 on the timeline)
|
|
191
|
+
// trk-2 — free at the drop window, distance 2 from ref=0, but distance
|
|
192
|
+
// 0 from a plausible WRONG default such as "ref = last track
|
|
193
|
+
// index" (tracks.length - 1)
|
|
194
|
+
// A correct ref=0 must land on trk-0; a wrong "last track" default would
|
|
195
|
+
// land on trk-2 instead — so this test can actually fail if the `>= 0`
|
|
196
|
+
// fallback regresses.
|
|
197
|
+
const p = makeProject({
|
|
198
|
+
tracks: vtracks(
|
|
199
|
+
[videoItem('a', 20, 25)],
|
|
200
|
+
[videoItem('b', 0, 5)],
|
|
201
|
+
[videoItem('c', 20, 25)],
|
|
202
|
+
),
|
|
203
|
+
})
|
|
204
|
+
const result = placeDroppedClip(p, {
|
|
205
|
+
atTime: 0,
|
|
206
|
+
preferredTrackIndex: -1,
|
|
207
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
208
|
+
})
|
|
209
|
+
expect(result.trackIndex).toBe(0)
|
|
210
|
+
expect(result.start).toBe(0)
|
|
211
|
+
// trk-1's occupant and trk-2 are both untouched — trk-2 in particular is
|
|
212
|
+
// the wrong-default's pick, so leaving it alone confirms it was never
|
|
213
|
+
// even chosen, not just chosen-then-unaffected.
|
|
214
|
+
expect(result.project.tracks![1].items).toEqual([videoItem('b', 0, 5)])
|
|
215
|
+
expect(result.project.tracks![2].items).toEqual([videoItem('c', 20, 25)])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('placeDroppedClip — snapping', () => {
|
|
220
|
+
it('snaps atTime to a nearby snap target within tolerance, and leaves it alone outside tolerance', () => {
|
|
221
|
+
const p = makeProject({ tracks: vtracks([]) })
|
|
222
|
+
|
|
223
|
+
const snapped = placeDroppedClip(p, {
|
|
224
|
+
atTime: 5.3,
|
|
225
|
+
preferredTrackIndex: 0,
|
|
226
|
+
clip: { src: 'n.mp4', sourceDuration: 2 },
|
|
227
|
+
snapTimes: [5],
|
|
228
|
+
snapToleranceSec: 0.5,
|
|
229
|
+
})
|
|
230
|
+
expect(snapped.start).toBe(5)
|
|
231
|
+
|
|
232
|
+
const notSnapped = placeDroppedClip(p, {
|
|
233
|
+
atTime: 5.8,
|
|
234
|
+
preferredTrackIndex: 0,
|
|
235
|
+
clip: { src: 'n.mp4', sourceDuration: 2 },
|
|
236
|
+
snapTimes: [5],
|
|
237
|
+
snapToleranceSec: 0.5,
|
|
238
|
+
})
|
|
239
|
+
expect(notSnapped.start).toBe(5.8)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('placeDroppedClip — ripple', () => {
|
|
244
|
+
it('ignores collision on the preferred track (pushes the occupant right) instead of falling back', () => {
|
|
245
|
+
const p = makeProject({ tracks: vtracks([videoItem('a', 0, 20)]) })
|
|
246
|
+
const result = placeDroppedClip(p, {
|
|
247
|
+
atTime: 10, // dead center of 'a' — a straddle, and the exact 50% tie
|
|
248
|
+
preferredTrackIndex: 0,
|
|
249
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
250
|
+
ripple: true,
|
|
251
|
+
})
|
|
252
|
+
expect(result.createdTrack).toBe(false)
|
|
253
|
+
expect(result.trackIndex).toBe(0)
|
|
254
|
+
const tracks = result.project.tracks!
|
|
255
|
+
expect(tracks).toHaveLength(1) // no new track — the collision never disqualified trk-0
|
|
256
|
+
// Tie at exactly 50% resolves to the straddler's START (Sam's rule, cuts.ts).
|
|
257
|
+
expect(result.start).toBe(0)
|
|
258
|
+
const a = tracks[0].items.find(it => it.id === 'a')!
|
|
259
|
+
expect(a).toMatchObject({ start: 3, end: 23 }) // pushed right by the new clip's length
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('placeDroppedClip — purity', () => {
|
|
264
|
+
it('never mutates the input project or its item objects', () => {
|
|
265
|
+
const p = makeProject({ tracks: vtracks([videoItem('a', 0, 5)]) })
|
|
266
|
+
const itemRef = p.tracks![0].items[0]
|
|
267
|
+
const before = JSON.stringify(p)
|
|
268
|
+
|
|
269
|
+
placeDroppedClip(p, {
|
|
270
|
+
atTime: 10,
|
|
271
|
+
preferredTrackIndex: 0,
|
|
272
|
+
clip: { src: 'n.mp4', sourceDuration: 3 },
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(JSON.stringify(p)).toBe(before)
|
|
276
|
+
expect(p.tracks![0].items[0]).toBe(itemRef)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The span/window invariant, which is the contract every renderer downstream
|
|
4
|
+
* relies on:
|
|
5
|
+
*
|
|
6
|
+
* outPoint − inPoint === (end − start) · speed
|
|
7
|
+
*
|
|
8
|
+
* Break it and nothing errors — the clip just quietly starts lying. The
|
|
9
|
+
* filmstrip walks the source at its own rate and freezes on the last frame it
|
|
10
|
+
* can find; the waveform windows to inPoint..outPoint and stretches that
|
|
11
|
+
* across the clip; the preview and the export each pick one. One clip, four
|
|
12
|
+
* different stories, no warning anywhere.
|
|
13
|
+
*
|
|
14
|
+
* It broke because the timeline edge and the source window used to be clamped
|
|
15
|
+
* INDEPENDENTLY, so whichever limit bit first left the other free to keep
|
|
16
|
+
* going. These tests drive the edge past every limit there is and assert the
|
|
17
|
+
* invariant survives each one.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from 'vitest'
|
|
20
|
+
import { computeResizedItem, resizeWindowedItem, type Draggable } from '../useItemDragDrop'
|
|
21
|
+
|
|
22
|
+
function clip(over: Partial<Draggable> = {}): Draggable {
|
|
23
|
+
return { id: 'c', type: 'video', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20, ...over }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** outPoint − inPoint, which must equal (end − start) · speed. */
|
|
27
|
+
function windowOf(i: Draggable): number {
|
|
28
|
+
return (i.outPoint ?? 0) - (i.inPoint ?? 0)
|
|
29
|
+
}
|
|
30
|
+
function spanOf(i: Draggable): number {
|
|
31
|
+
return i.end - i.start
|
|
32
|
+
}
|
|
33
|
+
function expectCoherent(i: Draggable) {
|
|
34
|
+
expect(windowOf(i)).toBeCloseTo(spanOf(i) * (i.speed ?? 1), 6)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resizeWindowedItem — the span/window invariant', () => {
|
|
38
|
+
it('holds when the in edge is dragged past the head of the media', () => {
|
|
39
|
+
// THE BUG. inPoint is already 0, so there is no source left to reveal —
|
|
40
|
+
// but `start` used to slide left anyway, inventing 3s of timeline with no
|
|
41
|
+
// footage behind it.
|
|
42
|
+
const r = resizeWindowedItem(clip({ inPoint: 0, outPoint: 5 }), 'start', 2)
|
|
43
|
+
expectCoherent(r)
|
|
44
|
+
expect(r.start).toBe(5) // pinned at the media's head
|
|
45
|
+
expect(r.inPoint).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('holds when the out edge is dragged past the tail of the media', () => {
|
|
49
|
+
// The mirror image: outPoint already at sourceDuration.
|
|
50
|
+
const r = resizeWindowedItem(clip({ inPoint: 15, outPoint: 20, start: 0, end: 5 }), 'end', 9)
|
|
51
|
+
expectCoherent(r)
|
|
52
|
+
expect(r.end).toBe(5)
|
|
53
|
+
expect(r.outPoint).toBe(20)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('lets the edge travel exactly as far as there is source, and no further', () => {
|
|
57
|
+
// 2s of source ahead of inPoint, so the edge stops 2s earlier.
|
|
58
|
+
const r = resizeWindowedItem(clip(), 'start', -100)
|
|
59
|
+
expect(r.start).toBeCloseTo(3)
|
|
60
|
+
expect(r.inPoint).toBeCloseTo(0)
|
|
61
|
+
expectCoherent(r)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('holds for an ordinary trim well inside the media', () => {
|
|
65
|
+
const inward = resizeWindowedItem(clip(), 'start', 6)
|
|
66
|
+
expect(inward.start).toBe(6)
|
|
67
|
+
expect(inward.inPoint).toBeCloseTo(3)
|
|
68
|
+
expectCoherent(inward)
|
|
69
|
+
|
|
70
|
+
const outward = resizeWindowedItem(clip(), 'end', 12)
|
|
71
|
+
expect(outward.end).toBe(12)
|
|
72
|
+
expect(outward.outPoint).toBeCloseTo(9)
|
|
73
|
+
expectCoherent(outward)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('holds at the minimum-duration floor, from either edge', () => {
|
|
77
|
+
expectCoherent(resizeWindowedItem(clip(), 'start', 999))
|
|
78
|
+
expectCoherent(resizeWindowedItem(clip(), 'end', -999))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('leaves the tail unbounded when the media length is unknown', () => {
|
|
82
|
+
// No sourceDuration means no known end to run past, so the edge keeps the
|
|
83
|
+
// benefit of the doubt rather than being pinned at its current position.
|
|
84
|
+
const r = resizeWindowedItem(clip({ sourceDuration: undefined }), 'end', 40)
|
|
85
|
+
expect(r.end).toBe(40)
|
|
86
|
+
expectCoherent(r)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('writes BOTH window bounds, including the edge it did not move', () => {
|
|
90
|
+
// An implicit half of the window is what made this invisible: you cannot
|
|
91
|
+
// check the invariant against a field that isn't there.
|
|
92
|
+
const r = resizeWindowedItem({ id: 'a', start: 1, end: 6 } as Draggable, 'end', 7)
|
|
93
|
+
expect(r.inPoint).toBe(0)
|
|
94
|
+
expect(r.outPoint).toBeCloseTo(6)
|
|
95
|
+
expectCoherent(r)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ── Per-clip speed ──
|
|
99
|
+
|
|
100
|
+
it('consumes source at the clip\'s speed, not 1:1', () => {
|
|
101
|
+
const fast = resizeWindowedItem(clip({ speed: 2, outPoint: 12 }), 'end', 12)
|
|
102
|
+
// 2 extra timeline seconds at 2x is 4 extra source seconds.
|
|
103
|
+
expect(fast.outPoint).toBeCloseTo(16)
|
|
104
|
+
expectCoherent(fast)
|
|
105
|
+
|
|
106
|
+
const slow = resizeWindowedItem(clip({ speed: 0.5, outPoint: 4.5 }), 'end', 12)
|
|
107
|
+
expect(slow.outPoint).toBeCloseTo(5.5)
|
|
108
|
+
expectCoherent(slow)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('measures the media limit in timeline seconds, which speed scales', () => {
|
|
112
|
+
// 2s of source ahead of inPoint at 0.5x is FOUR seconds of timeline.
|
|
113
|
+
const r = resizeWindowedItem(clip({ speed: 0.5, outPoint: 4.5 }), 'start', -100)
|
|
114
|
+
expect(r.start).toBeCloseTo(1)
|
|
115
|
+
expect(r.inPoint).toBeCloseTo(0)
|
|
116
|
+
expectCoherent(r)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('shrugs off a project carrying a nonsense speed', () => {
|
|
120
|
+
for (const speed of [0, -1, NaN, Infinity]) {
|
|
121
|
+
const r = resizeWindowedItem(clip({ speed }), 'end', 12)
|
|
122
|
+
expect(Number.isFinite(r.end)).toBe(true)
|
|
123
|
+
expect(Number.isFinite(r.outPoint ?? 0)).toBe(true)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('computeResizedItem — the non-video branch', () => {
|
|
129
|
+
it('moves an overlay\'s edge without inventing a source window', () => {
|
|
130
|
+
const overlay = { id: 'o', type: 'overlay', start: 2, end: 4 } as Draggable
|
|
131
|
+
expect(computeResizedItem(overlay, 'end', 9)).toMatchObject({ start: 2, end: 9 })
|
|
132
|
+
expect(computeResizedItem(overlay, 'end', 9).outPoint).toBeUndefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('still enforces the minimum duration on both edges', () => {
|
|
136
|
+
const overlay = { id: 'o', type: 'overlay', start: 2, end: 4 } as Draggable
|
|
137
|
+
expect(computeResizedItem(overlay, 'start', 99).start).toBeCloseTo(3.9)
|
|
138
|
+
expect(computeResizedItem(overlay, 'end', -99).end).toBeCloseTo(2.1)
|
|
139
|
+
})
|
|
140
|
+
})
|