@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Project } from '../../types'
|
|
2
2
|
import type { CaptionSegment } from '../../schema'
|
|
3
|
+
import { floorWordDurations } from '../captionWordFloor'
|
|
3
4
|
|
|
4
5
|
/** Fields `makeCaptionEdit` can patch onto a single caption segment. */
|
|
5
6
|
export type CaptionEditPatch = Partial<CaptionSegment>
|
|
@@ -40,11 +41,14 @@ export function makeCaptionEdit(
|
|
|
40
41
|
const newWords = next.text.split(/\s+/).filter(Boolean)
|
|
41
42
|
const segDur = next.end - next.start
|
|
42
43
|
const wordDur = segDur / (newWords.length || 1)
|
|
43
|
-
|
|
44
|
+
const spreadWords = newWords.map((w, wi) => ({
|
|
44
45
|
word: w,
|
|
45
46
|
start: next.start + wi * wordDur,
|
|
46
47
|
end: next.start + (wi + 1) * wordDur,
|
|
47
48
|
}))
|
|
49
|
+
// Uniform spread has no minimum of its own — floor it so a short
|
|
50
|
+
// word never falls below a frame at any fps. See captionWordFloor.ts.
|
|
51
|
+
next.words = floorWordDurations(spreadWords, next.end)
|
|
48
52
|
return next
|
|
49
53
|
}),
|
|
50
54
|
},
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
// Multi-select project mutations shared by
|
|
1
|
+
// Multi-select project mutations shared by the canvas pointer machine
|
|
2
|
+
// (canvas/pointer-machine.ts) and Timeline.tsx.
|
|
2
3
|
//
|
|
3
4
|
// Selection IDs are unified — a single `selectedIds: string[]` covers visual
|
|
4
|
-
// items (project.tracks[*][*])
|
|
5
|
+
// items (project.tracks[*][*]), audio tracks (project.audio.tracks[*]) and
|
|
6
|
+
// caption segments (project.captions.segments[*]).
|
|
5
7
|
// Cross-type ops (e.g. resize a video clip + an audio track in one drag) work
|
|
6
|
-
// because every id is globally unique within a Project.
|
|
8
|
+
// because every id is globally unique within a Project. Captions join only the
|
|
9
|
+
// MOVE op: mute, delete and resize are still clip/audio vocabulary.
|
|
7
10
|
|
|
8
|
-
import type { VisualItem, AudioTrack } from '../../schema'
|
|
11
|
+
import type { VisualItem, AudioTrack, CaptionSegment, Captions } from '../../schema'
|
|
9
12
|
import type { Project } from '../../types'
|
|
13
|
+
import { laneOf } from '../captionLanes'
|
|
14
|
+
import { mapTrackItems, normalizeTrackOrder, trackItems } from './timeline-model'
|
|
15
|
+
import { computeResizedItem, resizeWindowedItem, type Draggable } from './useItemDragDrop'
|
|
10
16
|
|
|
11
|
-
const MIN_DURATION = 0.1
|
|
12
17
|
|
|
13
18
|
export interface ResizeDeltas {
|
|
14
19
|
/** Delta in seconds applied to start-edge resizes. 0 if edge !== 'start'. */
|
|
@@ -31,8 +36,8 @@ export function applyResizeDeltaToSelection(
|
|
|
31
36
|
const targets = new Set(selectedIds.filter(id => id !== originatorId))
|
|
32
37
|
if (targets.size === 0) return project
|
|
33
38
|
|
|
34
|
-
const nextTracks = (project
|
|
35
|
-
|
|
39
|
+
const nextTracks = mapTrackItems(project, items =>
|
|
40
|
+
items.map(item => targets.has(item.id) ? resizeVisualItem(item, edge, deltas) : item)
|
|
36
41
|
)
|
|
37
42
|
const nextAudio = (project.audio?.tracks ?? []).map(t =>
|
|
38
43
|
targets.has(t.id) ? resizeAudioTrack(t, edge, deltas) : t
|
|
@@ -45,54 +50,200 @@ export function applyResizeDeltaToSelection(
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** Delegates to `computeResizedItem` so the span/window invariant is enforced
|
|
54
|
+
* in ONE place. This used to hand-roll the same arithmetic with the same
|
|
55
|
+
* independent clamps on the edge and the window, and therefore the same bug:
|
|
56
|
+
* propagating a trim to the rest of a multi-selection could stretch a clip's
|
|
57
|
+
* timeline span past the source it actually has. The only thing added here is
|
|
58
|
+
* the timeline floor at t=0, applied to the REQUESTED time so the shared
|
|
59
|
+
* function still does all the clamping that matters. */
|
|
48
60
|
function resizeVisualItem(item: VisualItem, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): VisualItem {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const outP = item.outPoint ?? (inP + (item.end - item.start))
|
|
54
|
-
const dActual = newStart - item.start
|
|
55
|
-
return {
|
|
56
|
-
...item,
|
|
57
|
-
start: newStart,
|
|
58
|
-
inPoint: Math.max(0, Math.min(inP + dActual, outP - MIN_DURATION)),
|
|
59
|
-
}
|
|
60
|
-
} else {
|
|
61
|
-
const newEnd = Math.max(item.start + MIN_DURATION, item.end + dEnd)
|
|
62
|
-
if (item.type !== 'video') return { ...item, end: newEnd }
|
|
63
|
-
const inP = item.inPoint ?? 0
|
|
64
|
-
const outP = item.outPoint ?? (inP + (item.end - item.start))
|
|
65
|
-
const dActual = newEnd - item.end
|
|
66
|
-
return {
|
|
67
|
-
...item,
|
|
68
|
-
end: newEnd,
|
|
69
|
-
outPoint: Math.max(inP + MIN_DURATION, Math.min(outP + dActual, item.sourceDuration ?? Infinity)),
|
|
70
|
-
}
|
|
71
|
-
}
|
|
61
|
+
const requested = edge === 'start'
|
|
62
|
+
? Math.max(0, item.start + dStart)
|
|
63
|
+
: item.end + dEnd
|
|
64
|
+
return computeResizedItem(item as Draggable, edge, requested) as VisualItem
|
|
72
65
|
}
|
|
73
66
|
|
|
67
|
+
/** As `resizeVisualItem`, but an audio track is always source-windowed — there
|
|
68
|
+
* is no "no window" case to fall through to, so it goes straight to
|
|
69
|
+
* `resizeWindowedItem` rather than through the video/non-video branch. */
|
|
74
70
|
function resizeAudioTrack(track: AudioTrack, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): AudioTrack {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
71
|
+
const requested = edge === 'start'
|
|
72
|
+
? Math.max(0, track.start + dStart)
|
|
73
|
+
: track.end + dEnd
|
|
74
|
+
return resizeWindowedItem(track as unknown as Draggable, edge, requested) as unknown as AudioTrack
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How far the SELECTED captions, as a group, may travel before any one of them
|
|
79
|
+
* would cross a NON-selected caption's edge IN ITS OWN LANE.
|
|
80
|
+
*
|
|
81
|
+
* Captions must never overlap WITHIN A LANE. `activeCaptionSegments`
|
|
82
|
+
* (timeline-core) hands the preview and every render template EVERY segment
|
|
83
|
+
* live at `t`, ordered by lane ascending — a lane is a z-order and carries no
|
|
84
|
+
* vertical offset of its own — so two segments overlapping in one lane paint
|
|
85
|
+
* at the same place, one straight over the other, in preview AND in export.
|
|
86
|
+
* With transcripts back-to-back by default that is not an edge case. Segments
|
|
87
|
+
* on DIFFERENT lanes coinciding is the feature caption rows exist for (each
|
|
88
|
+
* carries its own `offsetX`/`offsetY`), so a segment in lane 1 is not a bound
|
|
89
|
+
* on a segment in lane 0 and must not stop it moving.
|
|
90
|
+
*
|
|
91
|
+
* For each selected segment this finds its nearest NON-selected same-lane
|
|
92
|
+
* neighbour on either side (greatest `end <= seg.start` on the left, smallest
|
|
93
|
+
* `start >= seg.end` on the right) and how much room that neighbour leaves.
|
|
94
|
+
* The group bound is the TIGHTEST of those per-segment rooms — same shape as
|
|
95
|
+
* the extent scan below: one number covers the whole body. Selected ids are
|
|
96
|
+
* excluded from the neighbour search on BOTH sides (a selected segment is
|
|
97
|
+
* never treated as another selected segment's neighbour, itself included);
|
|
98
|
+
* that is what lets a run of adjacent selected captions move rigidly, stopping
|
|
99
|
+
* only at the first caption not coming along for the ride, rather than jamming
|
|
100
|
+
* against each other one gap in. Absent neighbour reads as unbounded
|
|
101
|
+
* (`Infinity`) on that side.
|
|
102
|
+
*
|
|
103
|
+
* Lanes are read through `laneOf`, never off `seg.lane` directly, so an absent
|
|
104
|
+
* or hand-edited value collapses the same way it does everywhere else.
|
|
105
|
+
*/
|
|
106
|
+
function captionNeighbourBounds(
|
|
107
|
+
captions: Captions | undefined,
|
|
108
|
+
targets: ReadonlySet<string>,
|
|
109
|
+
): { maxLeft: number; maxRight: number } {
|
|
110
|
+
const segments = captions?.segments ?? []
|
|
111
|
+
let maxLeft = Infinity
|
|
112
|
+
let maxRight = Infinity
|
|
113
|
+
for (const seg of segments) {
|
|
114
|
+
if (seg.id === undefined || !targets.has(seg.id)) continue
|
|
115
|
+
const lane = laneOf(seg)
|
|
116
|
+
let leftEnd = -Infinity
|
|
117
|
+
let rightStart = Infinity
|
|
118
|
+
for (const other of segments) {
|
|
119
|
+
// Skips every selected segment, this one included — see the WHY above.
|
|
120
|
+
if (other.id !== undefined && targets.has(other.id)) continue
|
|
121
|
+
// …and every segment on another row: overlap across lanes is legal.
|
|
122
|
+
if (laneOf(other) !== lane) continue
|
|
123
|
+
if (other.end <= seg.start && other.end > leftEnd) leftEnd = other.end
|
|
124
|
+
if (other.start >= seg.end && other.start < rightStart) rightStart = other.start
|
|
94
125
|
}
|
|
126
|
+
if (leftEnd > -Infinity) maxLeft = Math.min(maxLeft, seg.start - leftEnd)
|
|
127
|
+
if (rightStart < Infinity) maxRight = Math.min(maxRight, rightStart - seg.end)
|
|
95
128
|
}
|
|
129
|
+
return { maxLeft, maxRight }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shift every selected visual item, audio track and caption segment by the same
|
|
134
|
+
* timeline delta, preserving their relative positions — a rigid move of the
|
|
135
|
+
* whole selection.
|
|
136
|
+
*
|
|
137
|
+
* The delta is clamped ONCE, against the group as a body: the leftmost selected
|
|
138
|
+
* start cannot cross t=0 and the rightmost selected end cannot cross
|
|
139
|
+
* `totalDuration`, so the selection keeps its shape at the edges instead of
|
|
140
|
+
* bunching up when one member hits a wall. Cross-track (vertical) movement is
|
|
141
|
+
* deliberately not part of a group move — every item stays on its own
|
|
142
|
+
* track/lane, which is the predictable behaviour when several move at once.
|
|
143
|
+
*
|
|
144
|
+
* The SAME clamp also folds in `captionNeighbourBounds`: a selected caption may
|
|
145
|
+
* not cross a non-selected one SHARING ITS LANE, so a group that includes
|
|
146
|
+
* captions is clamped by whichever is tighter, the timeline edge or a
|
|
147
|
+
* same-lane caption neighbour. Vertical movement is not part of a group move
|
|
148
|
+
* for captions either — every segment keeps the lane it started in, so a
|
|
149
|
+
* multi-selection drag never changes rows however far the pointer travels up
|
|
150
|
+
* or down (`applyCaptionMove` in canvas/pointer-machine.ts owns the one gesture
|
|
151
|
+
* that does).
|
|
152
|
+
*
|
|
153
|
+
* The caller rebuilds from the press-time project on every move, so dragging
|
|
154
|
+
* back and forth cannot compound.
|
|
155
|
+
*/
|
|
156
|
+
export function applyMoveDeltaToSelection(
|
|
157
|
+
project: Project,
|
|
158
|
+
selectedIds: readonly string[],
|
|
159
|
+
requestedDelta: number,
|
|
160
|
+
totalDuration: number,
|
|
161
|
+
): Project {
|
|
162
|
+
const targets = new Set(selectedIds)
|
|
163
|
+
if (targets.size === 0) return project
|
|
164
|
+
|
|
165
|
+
// The group's extent, so one clamp keeps every member on the timeline.
|
|
166
|
+
let minStart = Infinity
|
|
167
|
+
let maxEnd = -Infinity
|
|
168
|
+
// `id` is optional only because `CaptionSegment.id` is (a segment briefly
|
|
169
|
+
// lacks one before `backfillCaptionIds` mints it). An id-less member can
|
|
170
|
+
// never be in `selectedIds`, so it simply never counts toward the extent.
|
|
171
|
+
const consider = (it: { id?: string; start: number; end: number }) => {
|
|
172
|
+
if (it.id === undefined || !targets.has(it.id)) return
|
|
173
|
+
if (it.start < minStart) minStart = it.start
|
|
174
|
+
if (it.end > maxEnd) maxEnd = it.end
|
|
175
|
+
}
|
|
176
|
+
for (const items of trackItems(project)) items.forEach(consider)
|
|
177
|
+
for (const t of project.audio?.tracks ?? []) consider(t)
|
|
178
|
+
// Captions join the SAME extent scan, so one clamp covers a mixed
|
|
179
|
+
// clip+caption selection rather than letting the captions slide past t=0
|
|
180
|
+
// while the clips hold.
|
|
181
|
+
for (const seg of project.captions?.segments ?? []) consider(seg)
|
|
182
|
+
// None of the selected ids are actually present (e.g. a stale selection).
|
|
183
|
+
if (!Number.isFinite(minStart)) return project
|
|
184
|
+
|
|
185
|
+
// The caption-neighbour bound folds into the SAME lo/hi the timeline edges
|
|
186
|
+
// use below — one range, not two clamps in sequence (a second clamp would
|
|
187
|
+
// undo the first, same reasoning `applyCaptionTrim` documents for its own
|
|
188
|
+
// lo/hi). `maxLeft`/`maxRight` are `Infinity` when nothing selected is a
|
|
189
|
+
// caption (or no neighbour exists), so `Math.max`/`Math.min` below are then
|
|
190
|
+
// no-ops and the plain timeline-edge bound is all that's left.
|
|
191
|
+
const { maxLeft, maxRight } = captionNeighbourBounds(project.captions, targets)
|
|
192
|
+
|
|
193
|
+
const lo = Math.max(-minStart, -maxLeft)
|
|
194
|
+
// Floored at 0: never push a member further out than it already is.
|
|
195
|
+
// `totalDuration` is derived from clips and audio only (timeline-model.ts),
|
|
196
|
+
// so a caption can legally outlast it (e.g. clips trimmed/deleted after
|
|
197
|
+
// captioning) — for clips/audio `maxEnd` never exceeds `totalDuration`, but
|
|
198
|
+
// an overhanging caption drives `maxEnd` past it, which without the floor
|
|
199
|
+
// sends `hi` negative and drags the whole group backwards on a rightward
|
|
200
|
+
// drag (or makes it un-movable when mixed with a clip at lo=0). Mirrors the
|
|
201
|
+
// `Math.max(ctx.totalDuration, seg.end)` ceiling `applyCaptionTrim` already
|
|
202
|
+
// uses for the same overhang case.
|
|
203
|
+
const hi = Math.min(Math.max(0, totalDuration - maxEnd), maxRight)
|
|
204
|
+
// A group already wider than the timeline (lo > hi) can't move without
|
|
205
|
+
// pushing a member off an edge, so it doesn't. Same short-circuit now also
|
|
206
|
+
// catches a group that can't move without crossing a caption neighbour.
|
|
207
|
+
const delta = lo > hi ? 0 : Math.max(lo, Math.min(hi, requestedDelta))
|
|
208
|
+
if (delta === 0) return project
|
|
209
|
+
|
|
210
|
+
const shift = <T extends { id: string; start: number; end: number }>(it: T): T =>
|
|
211
|
+
targets.has(it.id) ? { ...it, start: it.start + delta, end: it.end + delta } : it
|
|
212
|
+
|
|
213
|
+
const captions = shiftCaptions(project.captions, targets, delta)
|
|
214
|
+
return {
|
|
215
|
+
...project,
|
|
216
|
+
tracks: mapTrackItems(project, items => items.map(shift)),
|
|
217
|
+
audio: project.audio
|
|
218
|
+
? { ...project.audio, tracks: (project.audio.tracks ?? []).map(shift) }
|
|
219
|
+
: project.audio,
|
|
220
|
+
// Same reference back when no caption moved, so a clips-only group move
|
|
221
|
+
// leaves `project.captions` byte-for-byte what it was — and stays
|
|
222
|
+
// `undefined` on a project that has none.
|
|
223
|
+
captions,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The caption half of a group move. Word timings are ABSOLUTE seconds, so
|
|
228
|
+
* every word travels with its segment by the same delta; a move preserves
|
|
229
|
+
* duration, so nothing needs respreading. `text` is never touched, and
|
|
230
|
+
* neither is `lane` — it rides the spread untouched, which is what makes a
|
|
231
|
+
* group move horizontal-only for captions. */
|
|
232
|
+
function shiftCaptions(
|
|
233
|
+
captions: Captions | undefined,
|
|
234
|
+
targets: ReadonlySet<string>,
|
|
235
|
+
delta: number,
|
|
236
|
+
): Captions | undefined {
|
|
237
|
+
if (!captions) return captions
|
|
238
|
+
let changed = false
|
|
239
|
+
const segments = captions.segments.map(seg => {
|
|
240
|
+
if (seg.id === undefined || !targets.has(seg.id)) return seg
|
|
241
|
+
changed = true
|
|
242
|
+
const next: CaptionSegment = { ...seg, start: seg.start + delta, end: seg.end + delta }
|
|
243
|
+
if (seg.words) next.words = seg.words.map(w => ({ ...w, start: w.start + delta, end: w.end + delta }))
|
|
244
|
+
return next
|
|
245
|
+
})
|
|
246
|
+
return changed ? { ...captions, segments } : captions
|
|
96
247
|
}
|
|
97
248
|
|
|
98
249
|
/** Set `muted` on every selected item to the given target value. Both visual
|
|
@@ -108,8 +259,8 @@ export function applyMuteToSelection(
|
|
|
108
259
|
|
|
109
260
|
return {
|
|
110
261
|
...project,
|
|
111
|
-
tracks: (project
|
|
112
|
-
|
|
262
|
+
tracks: mapTrackItems(project, items =>
|
|
263
|
+
items.map(item => targets.has(item.id) ? { ...item, muted } : item)
|
|
113
264
|
),
|
|
114
265
|
audio: project.audio
|
|
115
266
|
? {
|
|
@@ -127,11 +278,13 @@ export function deleteSelection(project: Project, selectedIds: readonly string[]
|
|
|
127
278
|
const targets = new Set(selectedIds)
|
|
128
279
|
if (targets.size === 0) return project
|
|
129
280
|
|
|
130
|
-
|
|
281
|
+
const next: Project = {
|
|
131
282
|
...project,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
283
|
+
// Prune, not a plain map: an emptied track collapses. The surviving TRACK
|
|
284
|
+
// OBJECTS pass through the filter, so each keeps its own id and settings
|
|
285
|
+
// rather than inheriting the ones a shifted index used to point at.
|
|
286
|
+
tracks: mapTrackItems(project, items => items.filter(item => !targets.has(item.id)))
|
|
287
|
+
.filter(track => track.items.length > 0),
|
|
135
288
|
audio: project.audio
|
|
136
289
|
? {
|
|
137
290
|
...project.audio,
|
|
@@ -139,6 +292,12 @@ export function deleteSelection(project: Project, selectedIds: readonly string[]
|
|
|
139
292
|
}
|
|
140
293
|
: project.audio,
|
|
141
294
|
}
|
|
295
|
+
// Re-group (Part B): a prune can change a track's coarse kind — a mixed
|
|
296
|
+
// track that loses its only video item becomes overlay-kind — so the
|
|
297
|
+
// canonical video-block/overlay-block invariant (`normalizeTrackOrder`) is
|
|
298
|
+
// re-asserted here rather than assumed to survive every prune. A no-op
|
|
299
|
+
// (same reference) when it already does.
|
|
300
|
+
return normalizeTrackOrder(next)
|
|
142
301
|
}
|
|
143
302
|
|
|
144
303
|
/** Selection helper: toggle additive (shift-click) vs replace (plain click).
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The ONE placement rule shared by every entry point that drops brand-new
|
|
2
|
+
// footage onto the timeline — today the footage-bin drag in
|
|
3
|
+
// `canvas/TimelineCanvas.tsx`, and a filesystem drop landing in a later task.
|
|
4
|
+
// Both hand the pointer's drop time and (optionally) which row it released
|
|
5
|
+
// over to `placeDroppedClip` below and get back a project with the clip
|
|
6
|
+
// placed "where you dropped it, without stomping existing footage" — the
|
|
7
|
+
// exact rule lives in exactly one place instead of being reimplemented, and
|
|
8
|
+
// possibly drifting, at each drop site.
|
|
9
|
+
//
|
|
10
|
+
// Pure and DOM-free on purpose: no canvas, no DragEvent, no React — so the
|
|
11
|
+
// rule itself is unit-testable without simulating a real drag, and so a
|
|
12
|
+
// future THIRD drop entry point (there will be one) has nothing to
|
|
13
|
+
// reimplement either.
|
|
14
|
+
|
|
15
|
+
import type { Project } from '../../types'
|
|
16
|
+
import type { VisualTrack } from '../../schema'
|
|
17
|
+
import { insertClipAt, overlapsAny, type NewClipInput } from '../cuts'
|
|
18
|
+
import { normalizeTracks, nextVisualTrackId } from './timeline-model'
|
|
19
|
+
|
|
20
|
+
/** What a drop needs to tell `placeDroppedClip` about ITSELF — the pointer's
|
|
21
|
+
* drop time, which row (if any) it released over, and the magnet/snap state
|
|
22
|
+
* the caller already knows. Everything geometry-shaped (pixel→time, pixel→
|
|
23
|
+
* row) is the caller's job; this module only ever sees seconds and track
|
|
24
|
+
* indices. */
|
|
25
|
+
export interface DroppedClipPlacement {
|
|
26
|
+
/** Timeline time (seconds) the pointer released at. Clamped to >= 0. */
|
|
27
|
+
atTime: number
|
|
28
|
+
/** Index of the track the pointer released over, in the NORMALIZED track
|
|
29
|
+
* order (`normalizeTracks(project).tracks`). Pass -1 for "no preference"
|
|
30
|
+
* (e.g. released over the ruler, a caption band, or an audio lane). */
|
|
31
|
+
preferredTrackIndex: number
|
|
32
|
+
/** The footage to place. Its `sourceDuration` is the placed clip's length. */
|
|
33
|
+
clip: NewClipInput
|
|
34
|
+
/** Ripple/magnet mode — the editor's `rippleMode`. Default false. */
|
|
35
|
+
ripple?: boolean
|
|
36
|
+
/** Candidate snap targets in seconds (clip edges, the playhead), supplied by
|
|
37
|
+
* the caller because this module knows nothing about the viewport. */
|
|
38
|
+
snapTimes?: readonly number[]
|
|
39
|
+
/** Max distance (seconds) at which `atTime` is pulled to a `snapTimes`
|
|
40
|
+
* entry. Omitted / <= 0 disables snapping. */
|
|
41
|
+
snapToleranceSec?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What `placeDroppedClip` did. */
|
|
45
|
+
export interface PlacedClipResult<P extends Project> {
|
|
46
|
+
/** The updated project. The SAME reference as the input when nothing was
|
|
47
|
+
* placed (see `trackIndex`). */
|
|
48
|
+
project: P
|
|
49
|
+
/** Index of the track the clip landed on, in the RETURNED project's
|
|
50
|
+
* normalized order. `-1` means nothing was placed. */
|
|
51
|
+
trackIndex: number
|
|
52
|
+
/** The placed clip's start time on the timeline. `0` when nothing was placed. */
|
|
53
|
+
start: number
|
|
54
|
+
/** The placed item's id. Absent when nothing was placed. */
|
|
55
|
+
itemId?: string
|
|
56
|
+
/** True when a NEW video track had to be created to hold the clip. */
|
|
57
|
+
createdTrack: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The no-op result every early return below hands back — same project
|
|
61
|
+
* reference, nothing placed. */
|
|
62
|
+
function nothingPlaced<P extends Project>(project: P): PlacedClipResult<P> {
|
|
63
|
+
return { project, trackIndex: -1, start: 0, createdTrack: false }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Snap `atTime` to the nearest `snapTimes` entry within `snapToleranceSec`,
|
|
68
|
+
* ties going to the SMALLER time (deterministic — two equidistant snap
|
|
69
|
+
* targets must resolve the same way on every call, not by array-scan order).
|
|
70
|
+
* Falls through to `atTime` unchanged when snapping is disabled (no
|
|
71
|
+
* `snapTimes`, or a tolerance that is absent/<= 0) or nothing is in range.
|
|
72
|
+
*
|
|
73
|
+
* Exported (and typed against a `Pick` rather than the full
|
|
74
|
+
* `DroppedClipPlacement`) so a caller that doesn't yet know WHAT is being
|
|
75
|
+
* placed can still snap the drop point itself — `TimelineCanvas`'s OS-file
|
|
76
|
+
* drop branch has no `clip` at drop time (the file hasn't been probed or
|
|
77
|
+
* ingested yet), but still wants the ghost band and the eventual placement to
|
|
78
|
+
* land on the same magnetized second a bin drop would.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveDropPoint({
|
|
81
|
+
atTime,
|
|
82
|
+
snapTimes,
|
|
83
|
+
snapToleranceSec,
|
|
84
|
+
}: Pick<DroppedClipPlacement, 'atTime' | 'snapTimes' | 'snapToleranceSec'>): number {
|
|
85
|
+
if (!snapTimes || snapTimes.length === 0 || !(snapToleranceSec! > 0)) return atTime
|
|
86
|
+
|
|
87
|
+
let best: number | null = null
|
|
88
|
+
let bestDist = Infinity
|
|
89
|
+
for (const t of snapTimes) {
|
|
90
|
+
const dist = Math.abs(t - atTime)
|
|
91
|
+
if (dist > snapToleranceSec!) continue
|
|
92
|
+
if (dist < bestDist || (dist === bestDist && (best === null || t < best))) {
|
|
93
|
+
best = t
|
|
94
|
+
bestDist = dist
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return best ?? atTime
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A track at `index` is a video-placement CANDIDATE when either it already
|
|
102
|
+
* carries a video item (`trackGroupKind`'s own rule, timeline-model.ts), or
|
|
103
|
+
* it is the empty base row (`index === 0` with no items at all) —
|
|
104
|
+
* `computeTimelineLayout` (canvas/draw.ts) already draws that row tall,
|
|
105
|
+
* i.e. treats it as video-kind, regardless of content. Overlay/image tracks
|
|
106
|
+
* are never candidates. Captions and audio don't live in `project.tracks` at
|
|
107
|
+
* all, so they can never be selected here — there is nothing to exclude them
|
|
108
|
+
* with, they simply aren't in the array this function scans.
|
|
109
|
+
*/
|
|
110
|
+
function isVideoCandidate(track: VisualTrack, index: number): boolean {
|
|
111
|
+
return track.items.some(it => it.type === 'video') || (index === 0 && track.items.length === 0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pick the candidate index minimizing `(|i - ref|, i)` — closest to `ref`
|
|
116
|
+
* first, lower index breaking a tie. `candidates` must be non-empty.
|
|
117
|
+
*
|
|
118
|
+
* Index distance stands in for on-screen distance: rows differ in rendered
|
|
119
|
+
* height (120px for the base video row, 40px for the rest), but the video
|
|
120
|
+
* block is contiguous from index 0 after normalization (`normalizeTracks`),
|
|
121
|
+
* so ordering by index distance orders the candidates the same way the eye
|
|
122
|
+
* would order them by pixel distance.
|
|
123
|
+
*/
|
|
124
|
+
function closestByIndex(candidates: readonly number[], ref: number): number {
|
|
125
|
+
return candidates.reduce((best, i) =>
|
|
126
|
+
Math.abs(i - ref) < Math.abs(best - ref) || (Math.abs(i - ref) === Math.abs(best - ref) && i < best)
|
|
127
|
+
? i
|
|
128
|
+
: best,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Insert `clip` onto the track `trackId` already names via `insertClipAt`,
|
|
134
|
+
* then read back WHERE it actually landed — `insertClipAt` mints the placed
|
|
135
|
+
* item's id itself and doesn't hand it back, so the placed item is found by
|
|
136
|
+
* diffing the track's item ids before/after the call.
|
|
137
|
+
*
|
|
138
|
+
* Always re-normalizes the result before reading it back (`normalizeTracks`
|
|
139
|
+
* is a no-op, same-reference return when order is already canonical — see
|
|
140
|
+
* its own doc — so this costs nothing on the common path). That matters for
|
|
141
|
+
* exactly one case here: dropping onto the empty base row (index 0) flips it
|
|
142
|
+
* from overlay-kind to video-kind the moment it gains a video item, which can
|
|
143
|
+
* move its position in the canonical video-block/overlay-block stack
|
|
144
|
+
* (`orderedTrackArray`) — so the track's index has to be re-resolved by id,
|
|
145
|
+
* never assumed stable across the insert.
|
|
146
|
+
*/
|
|
147
|
+
function placeOnTrack<P extends Project>(
|
|
148
|
+
project: P,
|
|
149
|
+
trackId: string,
|
|
150
|
+
clip: NewClipInput,
|
|
151
|
+
dropAt: number,
|
|
152
|
+
ripple: boolean,
|
|
153
|
+
createdTrack: boolean,
|
|
154
|
+
): PlacedClipResult<P> {
|
|
155
|
+
const before = new Set(
|
|
156
|
+
(normalizeTracks(project).tracks ?? []).find(t => t.id === trackId)?.items.map(it => it.id) ?? [],
|
|
157
|
+
)
|
|
158
|
+
const normalized = normalizeTracks(insertClipAt(project, trackId, clip, dropAt, { ripple }))
|
|
159
|
+
const nextTracks = normalized.tracks ?? []
|
|
160
|
+
const trackIndex = nextTracks.findIndex(t => t.id === trackId)
|
|
161
|
+
const placedItem = trackIndex >= 0 ? nextTracks[trackIndex].items.find(it => !before.has(it.id)) : undefined
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
project: normalized,
|
|
165
|
+
trackIndex,
|
|
166
|
+
start: placedItem?.start ?? 0,
|
|
167
|
+
itemId: placedItem?.id,
|
|
168
|
+
createdTrack,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Step 5 of the algorithm: no existing video track has room, so mint a fresh
|
|
174
|
+
* one and place the clip there. The new track is appended empty (overlay-kind
|
|
175
|
+
* by `trackGroupKind`, since it holds no items), then `placeOnTrack`'s own
|
|
176
|
+
* re-normalize after the insert is what promotes it into the video block —
|
|
177
|
+
* at the END of that block, i.e. the TOP-most video row, because
|
|
178
|
+
* `orderedTrackArray` is a STABLE partition: the new track was the very last
|
|
179
|
+
* element before the re-sort, so among video-kind tracks (which it now is)
|
|
180
|
+
* it is still the last one encountered, and "last in the video group" is the
|
|
181
|
+
* highest index in a contiguous video-first stack.
|
|
182
|
+
*/
|
|
183
|
+
function placeOnNewTrack<P extends Project>(
|
|
184
|
+
project: P,
|
|
185
|
+
tracks: readonly VisualTrack[],
|
|
186
|
+
clip: NewClipInput,
|
|
187
|
+
dropAt: number,
|
|
188
|
+
ripple: boolean,
|
|
189
|
+
): PlacedClipResult<P> {
|
|
190
|
+
const newId = nextVisualTrackId(tracks)
|
|
191
|
+
const withNewTrack: P = { ...project, tracks: [...tracks, { id: newId, items: [] }] } as P
|
|
192
|
+
return placeOnTrack(withNewTrack, newId, clip, dropAt, ripple, true)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Place a single newly-dropped clip on the timeline, following the shared
|
|
197
|
+
* drop rule (see the module doc comment above for who calls this).
|
|
198
|
+
*
|
|
199
|
+
* Pure: never mutates `project`, its tracks/items, or `placement.clip`.
|
|
200
|
+
*
|
|
201
|
+
* ALGORITHM
|
|
202
|
+
* 0. Guard — `clip.sourceDuration` must be a finite number > 0, or nothing is
|
|
203
|
+
* placed (mirrors the guard the canvas drop handler and the host's
|
|
204
|
+
* `FootagePanel.hasPlaceableDuration` already apply before a drag even
|
|
205
|
+
* starts).
|
|
206
|
+
* 1. Resolve the drop point: snap `atTime` to the nearest in-tolerance
|
|
207
|
+
* `snapTimes` entry (see `resolveDropPoint`), then clamp to `>= 0`.
|
|
208
|
+
* 2. Gather video-placement candidate tracks (see `isVideoCandidate`).
|
|
209
|
+
* 3. `ref` — the preferred index if given, else 0 — is the point every
|
|
210
|
+
* "closest" measurement below is relative to.
|
|
211
|
+
* 4a. Ripple ON: the preferred track if it's a candidate, else the closest
|
|
212
|
+
* candidate (ties → lower index), else mint a new track. Collision never
|
|
213
|
+
* disqualifies a candidate here — ripple means "push what's in the way",
|
|
214
|
+
* so there is nothing to collide with.
|
|
215
|
+
* 4b. Ripple OFF: the closest candidate (ties → lower index) whose
|
|
216
|
+
* `[dropAt, dropAt + sourceDuration]` window is free of existing items
|
|
217
|
+
* (`overlapsAny`, cuts.ts — same EPSILON tolerance every other placement
|
|
218
|
+
* op in this codebase uses). No free candidate → mint a new track.
|
|
219
|
+
* 5. New track: see `placeOnNewTrack`.
|
|
220
|
+
*/
|
|
221
|
+
/**
|
|
222
|
+
* Where a dropped clip WOULD land, WITHOUT placing it — the resolved
|
|
223
|
+
* video-track index in normalized order, or `normalizeTracks(project).tracks.length`
|
|
224
|
+
* (a row that does not exist yet) when a NEW video track would be created.
|
|
225
|
+
*
|
|
226
|
+
* This is the EXACT selection `placeDroppedClip` makes (it calls this), so a
|
|
227
|
+
* caller can draw a pre-ingest ghost band on the row the real clip will land on
|
|
228
|
+
* instead of wherever the pointer happened to be — a filesystem drop that
|
|
229
|
+
* released over an overlay/image row must still ghost on a VIDEO row, because
|
|
230
|
+
* that is where the clip resolves to. `closestByIndex` here, and the ghost
|
|
231
|
+
* renderer's own "unknown index falls back to the base video row" (see
|
|
232
|
+
* `pendingDropBands`), together guarantee the ghost never sits on an overlay
|
|
233
|
+
* row. `clip.sourceDuration` matters only in the ripple-OFF free-gap test —
|
|
234
|
+
* pass the ghost's fast local probe.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveDropTrackIndex<P extends Project>(
|
|
237
|
+
project: P,
|
|
238
|
+
placement: Pick<DroppedClipPlacement, 'atTime' | 'preferredTrackIndex' | 'ripple' | 'snapTimes' | 'snapToleranceSec'>
|
|
239
|
+
& { clip: Pick<NewClipInput, 'sourceDuration'> },
|
|
240
|
+
): number {
|
|
241
|
+
const { clip, ripple = false, preferredTrackIndex } = placement
|
|
242
|
+
const dropAt = Math.max(0, resolveDropPoint(placement))
|
|
243
|
+
const tracks = normalizeTracks(project).tracks ?? []
|
|
244
|
+
const candidateIdxs: number[] = []
|
|
245
|
+
tracks.forEach((t, i) => { if (isVideoCandidate(t, i)) candidateIdxs.push(i) })
|
|
246
|
+
const ref = preferredTrackIndex >= 0 ? preferredTrackIndex : 0
|
|
247
|
+
|
|
248
|
+
if (ripple) {
|
|
249
|
+
// Ripple ON — collision never disqualifies a candidate.
|
|
250
|
+
if (candidateIdxs.includes(preferredTrackIndex)) return preferredTrackIndex
|
|
251
|
+
if (candidateIdxs.length > 0) return closestByIndex(candidateIdxs, ref)
|
|
252
|
+
return tracks.length // no video row exists ⇒ a fresh one would be created
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Ripple OFF — only a candidate whose drop window is actually free.
|
|
256
|
+
const freeIdxs = candidateIdxs.filter(
|
|
257
|
+
i => !overlapsAny(dropAt, dropAt + clip.sourceDuration, tracks[i].items),
|
|
258
|
+
)
|
|
259
|
+
if (freeIdxs.length === 0) return tracks.length
|
|
260
|
+
return closestByIndex(freeIdxs, ref)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function placeDroppedClip<P extends Project>(
|
|
264
|
+
project: P,
|
|
265
|
+
placement: DroppedClipPlacement,
|
|
266
|
+
): PlacedClipResult<P> {
|
|
267
|
+
const { clip, ripple = false } = placement
|
|
268
|
+
|
|
269
|
+
// 0. Guard — an unplaceable clip places nothing, by reference.
|
|
270
|
+
if (!(Number.isFinite(clip.sourceDuration) && clip.sourceDuration > 0)) return nothingPlaced(project)
|
|
271
|
+
|
|
272
|
+
// 1. Drop point + target row. The row is resolved by `resolveDropTrackIndex`
|
|
273
|
+
// so placement and the pre-ingest ghost band can never diverge.
|
|
274
|
+
const dropAt = Math.max(0, resolveDropPoint(placement))
|
|
275
|
+
const tracks = normalizeTracks(project).tracks ?? []
|
|
276
|
+
const targetIdx = resolveDropTrackIndex(project, placement)
|
|
277
|
+
|
|
278
|
+
// targetIdx past the end of the (same, normalized) array ⇒ no existing video
|
|
279
|
+
// row fits ⇒ a new track is created.
|
|
280
|
+
if (targetIdx >= tracks.length) return placeOnNewTrack(project, tracks, clip, dropAt, ripple)
|
|
281
|
+
return placeOnTrack(project, tracks[targetIdx].id, clip, dropAt, ripple, false)
|
|
282
|
+
}
|