@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
package/src/video/cuts.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Project } from '../types'
|
|
2
|
-
import type { VisualItem, AudioTrack, CaptionSegment, Word } from '../schema'
|
|
2
|
+
import type { VisualItem, AudioTrack, CaptionSegment, Word, VisualTrack, KeyframeTrack } from '../schema'
|
|
3
|
+
import { mapTrackItems, trackItems, normalizeTracks } from './timeline/timeline-model'
|
|
4
|
+
import { normalizeTrack } from '@bycrux/timeline-core'
|
|
5
|
+
import { canKeyframe } from './keyframeOps'
|
|
3
6
|
|
|
4
7
|
/** A time range to excise from the timeline. */
|
|
5
8
|
export interface Cut {
|
|
@@ -7,6 +10,14 @@ export interface Cut {
|
|
|
7
10
|
end: number
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
/** Shortest a clip may be left by a trim op. Mirrors the timeline drag clamp in
|
|
14
|
+
* `timeline/multiSelectOps.ts`, which owns the same constant module-privately. */
|
|
15
|
+
const MIN_DURATION = 0.1
|
|
16
|
+
|
|
17
|
+
/** Float slop for adjacency and no-op checks — matches the tolerance already
|
|
18
|
+
* used when deciding whether a collapse fragment is worth keeping. */
|
|
19
|
+
const EPSILON = 0.001
|
|
20
|
+
|
|
10
21
|
// ── ID generation ───────────────────────────────────────────────────────────
|
|
11
22
|
|
|
12
23
|
function uniqueId(base: string): string {
|
|
@@ -16,18 +27,22 @@ function uniqueId(base: string): string {
|
|
|
16
27
|
// ── Single base-clip helpers ────────────────────────────────────────────────
|
|
17
28
|
|
|
18
29
|
function trimClipEnd(item: VisualItem, at: number): VisualItem {
|
|
30
|
+
// A timeline delta maps to a SOURCE delta of `·S` (montaj/speed); `·1` is exact
|
|
31
|
+
// so a clip at S=1/absent is byte-identical to the pre-speed op.
|
|
32
|
+
const s = item.speed ?? 1
|
|
19
33
|
const trimmedDur = at - item.start
|
|
20
34
|
return {
|
|
21
35
|
...item,
|
|
22
36
|
end: at,
|
|
23
|
-
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur } : {}),
|
|
37
|
+
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur * s } : {}),
|
|
24
38
|
}
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
function trimClipStart(item: VisualItem, at: number): VisualItem {
|
|
28
|
-
// Lift-style: clip start advances to the cut end; inPoint advances by the same
|
|
42
|
+
// Lift-style: clip start advances to the cut end; inPoint advances by the same
|
|
43
|
+
// timeline distance converted to SOURCE seconds (`·S`, montaj/speed).
|
|
29
44
|
// The resulting gap before the clip's new start position is intentional.
|
|
30
|
-
const sourceOffset = at - item.start
|
|
45
|
+
const sourceOffset = (at - item.start) * (item.speed ?? 1)
|
|
31
46
|
return {
|
|
32
47
|
...item,
|
|
33
48
|
start: at,
|
|
@@ -35,14 +50,71 @@ function trimClipStart(item: VisualItem, at: number): VisualItem {
|
|
|
35
50
|
}
|
|
36
51
|
}
|
|
37
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Split `item.keyframes` (overlay-only; see schema.ts) into the two tracks
|
|
55
|
+
* each fragment of a cut should carry. Shared by BOTH `splitClip` (lift: the
|
|
56
|
+
* right fragment stays at `cut.end`, a gap opens where the cut was) and
|
|
57
|
+
* `cutSingleItem` (collapse: the right fragment moves to `cut.start`, no gap)
|
|
58
|
+
* — the two disagree about WHERE the right fragment ends up on the timeline,
|
|
59
|
+
* but that disagreement never reaches this function. `t` is item-relative
|
|
60
|
+
* TIMELINE seconds (docs/schemas/project.md), unrelated to `speed`'s source
|
|
61
|
+
* scaling — unlike `outPoint`/`inPoint` elsewhere in this file, keyframe
|
|
62
|
+
* times need no `·s` conversion, and this function never reads `item.speed`.
|
|
63
|
+
*
|
|
64
|
+
* Left keeps every point at or before the cut (its own new span is
|
|
65
|
+
* `[item.start, cut.start)`, so `t` is unchanged — the left fragment's start
|
|
66
|
+
* didn't move, in EITHER caller). Right keeps every point at or after the cut
|
|
67
|
+
* and RE-ANCHORS it by subtracting `cut.end − item.start`, so `t` stays
|
|
68
|
+
* relative to the right fragment's own new `start` — wherever that new
|
|
69
|
+
* `start` physically lands, the point is still "this far past the cut" in
|
|
70
|
+
* the ORIGINAL animation curve, which is the one thing both callers need.
|
|
71
|
+
* (For `splitClip` specifically, where the right fragment's new `start` IS
|
|
72
|
+
* `cut.end`, this re-anchoring also happens to preserve the original
|
|
73
|
+
* ABSOLUTE wall-clock instant — a stronger property that only holds for a
|
|
74
|
+
* lift, not a collapse, but follows for free from the same subtraction.) A
|
|
75
|
+
* point exactly at a zero-width split (`splitAtTime`, where `cut.start ===
|
|
76
|
+
* cut.end`) is legitimately kept by BOTH fragments — as the last point of
|
|
77
|
+
* left's curve and the first of right's — so the animation stays continuous
|
|
78
|
+
* across the cut rather than only one side inheriting the boundary value.
|
|
79
|
+
*
|
|
80
|
+
* Each returned track is a fresh array (`normalizeTrack` always allocates),
|
|
81
|
+
* so the two fragments never share a reference with each other or with
|
|
82
|
+
* `item.keyframes` — the bug this exists to fix. Returns `undefined` for a
|
|
83
|
+
* side with no points left, matching `keyframeOps.ts`'s `withTrack`
|
|
84
|
+
* invariant: no lingering empty-`points` track, no lingering empty array.
|
|
85
|
+
*/
|
|
86
|
+
function splitKeyframeTracks(item: VisualItem, cut: Cut): { left?: KeyframeTrack[]; right?: KeyframeTrack[] } {
|
|
87
|
+
const tracks = item.keyframes
|
|
88
|
+
if (!tracks || tracks.length === 0) return {}
|
|
89
|
+
|
|
90
|
+
const leftDur = cut.start - item.start
|
|
91
|
+
const rightOffset = cut.end - item.start
|
|
92
|
+
|
|
93
|
+
const left = tracks
|
|
94
|
+
.map(track => normalizeTrack({ prop: track.prop, points: track.points.filter(p => p.t <= leftDur) }))
|
|
95
|
+
.filter((track): track is KeyframeTrack => !!track && track.points.length > 0)
|
|
96
|
+
const right = tracks
|
|
97
|
+
.map(track => normalizeTrack({
|
|
98
|
+
prop: track.prop,
|
|
99
|
+
points: track.points.filter(p => p.t >= rightOffset).map(p => ({ ...p, t: p.t - rightOffset })),
|
|
100
|
+
}))
|
|
101
|
+
.filter((track): track is KeyframeTrack => !!track && track.points.length > 0)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
left: left.length > 0 ? left : undefined,
|
|
105
|
+
right: right.length > 0 ? right : undefined,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
|
|
110
|
+
const s = item.speed ?? 1
|
|
39
111
|
const leftDur = cut.start - item.start
|
|
40
|
-
const rightSourceOffset = cut.end - item.start
|
|
112
|
+
const rightSourceOffset = (cut.end - item.start) * s
|
|
41
113
|
|
|
42
114
|
const left: VisualItem = {
|
|
43
115
|
...item,
|
|
44
116
|
end: cut.start,
|
|
45
|
-
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur } : {}),
|
|
117
|
+
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur * s } : {}),
|
|
46
118
|
}
|
|
47
119
|
const right: VisualItem = {
|
|
48
120
|
...item,
|
|
@@ -50,6 +122,18 @@ function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
|
|
|
50
122
|
start: cut.end, // lift: right fragment stays at its original timeline position
|
|
51
123
|
...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + rightSourceOffset } : {}),
|
|
52
124
|
}
|
|
125
|
+
|
|
126
|
+
// Overlay-only (schema.ts). Every OTHER item type either never carries
|
|
127
|
+
// `keyframes` or has it ignored (docs/schemas/project.md), so this never
|
|
128
|
+
// runs for a video/image clip even if one somehow had a stray array.
|
|
129
|
+
if (canKeyframe(item) && item.keyframes && item.keyframes.length > 0) {
|
|
130
|
+
const { left: leftKf, right: rightKf } = splitKeyframeTracks(item, cut)
|
|
131
|
+
if (leftKf) left.keyframes = leftKf
|
|
132
|
+
else delete left.keyframes
|
|
133
|
+
if (rightKf) right.keyframes = rightKf
|
|
134
|
+
else delete right.keyframes
|
|
135
|
+
}
|
|
136
|
+
|
|
53
137
|
return [left, right]
|
|
54
138
|
}
|
|
55
139
|
|
|
@@ -119,29 +203,61 @@ function applyCutToCaptions(segments: CaptionSegment[], cut: Cut): CaptionSegmen
|
|
|
119
203
|
* Right fragment starts at cut.start (item shrinks; gap appears at item tail).
|
|
120
204
|
*/
|
|
121
205
|
function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
|
|
206
|
+
// Timeline↔source conversions carry the clip's speed S (montaj/speed): a
|
|
207
|
+
// timeline delta is `·S` source-seconds, and a source length is `/S` timeline
|
|
208
|
+
// seconds. `·1`/`/1` are exact, so S=1/absent is byte-identical.
|
|
209
|
+
const s = item.speed ?? 1
|
|
122
210
|
const inPoint = item.inPoint ?? 0
|
|
123
|
-
const outPoint = item.outPoint ?? (inPoint + (item.end - item.start))
|
|
211
|
+
const outPoint = item.outPoint ?? (inPoint + (item.end - item.start) * s)
|
|
124
212
|
|
|
125
|
-
const physStart = inPoint + (cut.start - item.start)
|
|
126
|
-
const physEnd = inPoint + (cut.end - item.start)
|
|
213
|
+
const physStart = inPoint + (cut.start - item.start) * s
|
|
214
|
+
const physEnd = inPoint + (cut.end - item.start) * s
|
|
127
215
|
|
|
128
216
|
const result: VisualItem[] = []
|
|
129
217
|
|
|
218
|
+
// Same overlay-keyframe split `splitClip` performs, for the same reason: a
|
|
219
|
+
// bare `...item` spread would hand BOTH fragments the same `keyframes` array
|
|
220
|
+
// object, and leave the right fragment's points anchored to the ORIGINAL
|
|
221
|
+
// item's start even though its own `start` has moved — so its animation would
|
|
222
|
+
// play at the wrong times, and every point inside the removed range would sit
|
|
223
|
+
// dead in the data.
|
|
224
|
+
//
|
|
225
|
+
// `splitKeyframeTracks` applies unchanged despite this function COLLAPSING
|
|
226
|
+
// the right fragment onto `cut.start` (where `splitClip`'s LIFT leaves it at
|
|
227
|
+
// `cut.end`, unmoved): a keyframe's `t` is relative to its own fragment's
|
|
228
|
+
// `start`, wherever that new `start` physically lands, so the collapse vs.
|
|
229
|
+
// lift distinction cancels out and the re-anchor is `t - (cut.end -
|
|
230
|
+
// item.start)` either way. It also holds at any speed, since `(outPoint -
|
|
231
|
+
// physEnd) / s` reduces to `item.end - cut.end`, exactly the timeline span
|
|
232
|
+
// the re-anchored points cover.
|
|
233
|
+
const keyed = canKeyframe(item) && !!item.keyframes && item.keyframes.length > 0
|
|
234
|
+
const { left: leftKf, right: rightKf } = keyed ? splitKeyframeTracks(item, cut) : {}
|
|
235
|
+
|
|
130
236
|
if (physStart > inPoint) {
|
|
131
|
-
|
|
237
|
+
const left: VisualItem = {
|
|
132
238
|
...item,
|
|
133
239
|
end: cut.start,
|
|
134
240
|
...(item.outPoint !== undefined ? { outPoint: physStart } : {}),
|
|
135
|
-
}
|
|
241
|
+
}
|
|
242
|
+
if (keyed) {
|
|
243
|
+
if (leftKf) left.keyframes = leftKf
|
|
244
|
+
else delete left.keyframes
|
|
245
|
+
}
|
|
246
|
+
result.push(left)
|
|
136
247
|
}
|
|
137
248
|
if (outPoint - physEnd > 0.001) {
|
|
138
|
-
|
|
249
|
+
const right: VisualItem = {
|
|
139
250
|
...item,
|
|
140
251
|
id: uniqueId(item.id),
|
|
141
252
|
start: cut.start,
|
|
142
|
-
end: cut.start + (outPoint - physEnd),
|
|
253
|
+
end: cut.start + (outPoint - physEnd) / s,
|
|
143
254
|
...(item.inPoint !== undefined ? { inPoint: physEnd } : {}),
|
|
144
|
-
}
|
|
255
|
+
}
|
|
256
|
+
if (keyed) {
|
|
257
|
+
if (rightKf) right.keyframes = rightKf
|
|
258
|
+
else delete right.keyframes
|
|
259
|
+
}
|
|
260
|
+
result.push(right)
|
|
145
261
|
}
|
|
146
262
|
|
|
147
263
|
return result
|
|
@@ -161,14 +277,22 @@ function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
|
|
|
161
277
|
export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
|
|
162
278
|
if (cut.end <= cut.start) return project
|
|
163
279
|
|
|
164
|
-
const
|
|
165
|
-
|
|
280
|
+
const newTracks = mapTrackItems(project, (items, i) =>
|
|
281
|
+
i === 0 ? items.flatMap(item => applyCutToBaseClip(item, cut)) : items,
|
|
282
|
+
)
|
|
166
283
|
|
|
167
284
|
const newCaptions = project.captions
|
|
168
285
|
? { ...project.captions, segments: applyCutToCaptions(project.captions.segments, cut) }
|
|
169
286
|
: project.captions
|
|
170
287
|
|
|
171
|
-
return { ...project, tracks:
|
|
288
|
+
return { ...project, tracks: newTracks, captions: newCaptions }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Options for `collapseGaps`. */
|
|
292
|
+
export interface CollapseGapsOptions {
|
|
293
|
+
/** Remap `project.captions` with the same shifts the clips get. Default true
|
|
294
|
+
* — see the "exactly one caption-mover" note on `collapseGaps`. */
|
|
295
|
+
remapCaptions?: boolean
|
|
172
296
|
}
|
|
173
297
|
|
|
174
298
|
/**
|
|
@@ -180,9 +304,25 @@ export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
|
|
|
180
304
|
* tracks[0] if no video track exists.
|
|
181
305
|
*
|
|
182
306
|
* Returns the same project reference if no gaps exist (safe to call always).
|
|
307
|
+
*
|
|
308
|
+
* EXACTLY ONE CAPTION-MOVER PER COMPOSITION. `applyCutToTracks` ripples captions
|
|
309
|
+
* itself (via `applyCutToCaptions`), and `collapseGaps` remaps them too — so
|
|
310
|
+
* composing those two shifts every caption TWICE. That is why the audio-polish
|
|
311
|
+
* path (`video/audioPolish.ts`) passes `remapCaptions: false`. By contrast
|
|
312
|
+
* `deleteSelection` is tracks-and-audio vocabulary only and never touches
|
|
313
|
+
* `project.captions`, which is why `deleteSelection` + `collapseGaps`
|
|
314
|
+
* (Timeline.tsx's delete keymap) is safe with the default. Naming both the
|
|
315
|
+
* unsafe and the safe composition is what makes this checkable.
|
|
316
|
+
*
|
|
317
|
+
* `remapCaptions` therefore defaults to TRUE — today's behaviour, byte-identical
|
|
318
|
+
* for every call site that passes no options — and is opted OUT of only by a
|
|
319
|
+
* caller that has already moved the captions itself.
|
|
183
320
|
*/
|
|
184
|
-
export function collapseGaps<P extends Project>(
|
|
185
|
-
|
|
321
|
+
export function collapseGaps<P extends Project>(
|
|
322
|
+
project: P,
|
|
323
|
+
{ remapCaptions = true }: CollapseGapsOptions = {},
|
|
324
|
+
): P {
|
|
325
|
+
const tracks = trackItems(project)
|
|
186
326
|
|
|
187
327
|
const primaryIdx = tracks.findIndex(t => t.some(c => c.type === 'video'))
|
|
188
328
|
const effectiveIdx = primaryIdx >= 0 ? primaryIdx : 0
|
|
@@ -214,9 +354,9 @@ export function collapseGaps<P extends Project>(project: P): P {
|
|
|
214
354
|
return entry?.delta ?? 0
|
|
215
355
|
}
|
|
216
356
|
|
|
217
|
-
const newTracks =
|
|
357
|
+
const newTracks = mapTrackItems(project, (items, i) => {
|
|
218
358
|
if (i === effectiveIdx) return compacted
|
|
219
|
-
return
|
|
359
|
+
return items.map(clip => {
|
|
220
360
|
const d = applyShift(clip.start, clip.end)
|
|
221
361
|
if (d === 0) return clip
|
|
222
362
|
return { ...clip, start: clip.start + d, end: clip.end + d }
|
|
@@ -224,7 +364,7 @@ export function collapseGaps<P extends Project>(project: P): P {
|
|
|
224
364
|
})
|
|
225
365
|
|
|
226
366
|
let newCaptions = project.captions
|
|
227
|
-
if (newCaptions) {
|
|
367
|
+
if (remapCaptions && newCaptions) {
|
|
228
368
|
const segments = newCaptions.segments.map(seg => {
|
|
229
369
|
const d = applyShift(seg.start, seg.end)
|
|
230
370
|
if (d === 0) return seg
|
|
@@ -255,7 +395,8 @@ export function collapseGaps<P extends Project>(project: P): P {
|
|
|
255
395
|
export function applyCutToItem<P extends Project>(project: P, itemId: string, cut: Cut): P {
|
|
256
396
|
if (cut.end <= cut.start) return project
|
|
257
397
|
|
|
258
|
-
const
|
|
398
|
+
const tracks = trackItems(project)
|
|
399
|
+
const primaryTrack = tracks[0] ?? []
|
|
259
400
|
|
|
260
401
|
// ── Primary track ──
|
|
261
402
|
const primaryIdx = primaryTrack.findIndex(item => item.id === itemId)
|
|
@@ -283,12 +424,19 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
|
|
|
283
424
|
newCaptions = { ...newCaptions, segments: merged }
|
|
284
425
|
}
|
|
285
426
|
|
|
286
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
...project,
|
|
429
|
+
tracks: mapTrackItems(project, (items, i) => (i === 0 ? newPrimary : items)),
|
|
430
|
+
captions: newCaptions,
|
|
431
|
+
}
|
|
287
432
|
}
|
|
288
433
|
|
|
289
434
|
// ── Overlay tracks ──
|
|
290
|
-
|
|
291
|
-
|
|
435
|
+
// `ti` is an ABSOLUTE track index (the loop starts at 1, just above the
|
|
436
|
+
// primary track), so the rebuild below addresses the same track the item was
|
|
437
|
+
// found on without an off-by-one.
|
|
438
|
+
for (let ti = 1; ti < tracks.length; ti++) {
|
|
439
|
+
const track = tracks[ti]
|
|
292
440
|
const itemIdx = track.findIndex(item => item.id === itemId)
|
|
293
441
|
if (itemIdx === -1) continue
|
|
294
442
|
|
|
@@ -303,8 +451,7 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
|
|
|
303
451
|
...cutSingleItem(item, clamped),
|
|
304
452
|
...track.slice(itemIdx + 1),
|
|
305
453
|
]
|
|
306
|
-
|
|
307
|
-
return { ...project, tracks: [primaryTrack, ...newOverlays] }
|
|
454
|
+
return { ...project, tracks: mapTrackItems(project, (items, i) => (i === ti ? newTrack : items)) }
|
|
308
455
|
}
|
|
309
456
|
|
|
310
457
|
return project // itemId not found
|
|
@@ -313,19 +460,39 @@ export function applyCutToItem<P extends Project>(project: P, itemId: string, cu
|
|
|
313
460
|
// ── Audio split helper ────────────────────────────────────────────────────
|
|
314
461
|
|
|
315
462
|
function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack] {
|
|
316
|
-
|
|
317
|
-
|
|
463
|
+
// Resolve the ORIGINAL track's effective source window the same way the canvas
|
|
464
|
+
// waveform painter (`audioTrackSourceWindow`) and the DOM `AudioWaveformLayer`
|
|
465
|
+
// default it — `inPoint ?? 0`, `outPoint ?? sourceDuration ?? span` — then hand
|
|
466
|
+
// BOTH halves a fully-specified [inPoint, outPoint] window. Audio always runs at
|
|
467
|
+
// speed 1, so the split point's source offset equals its timeline offset.
|
|
468
|
+
//
|
|
469
|
+
// The right half MUST carry an explicit `outPoint`. Previously it only inherited
|
|
470
|
+
// whatever the source had, so a track with no `outPoint`/`sourceDuration` left
|
|
471
|
+
// the fragment's source end unknown; `audioTrackSourceWindow` then fell back to
|
|
472
|
+
// the fragment's own `end - start` (its timeline span, not the source end),
|
|
473
|
+
// yielding a truncated window — and, for any split past the track's midpoint, a
|
|
474
|
+
// non-positive one that the painter skips entirely. That is why the new fragment
|
|
475
|
+
// rendered as a solid block with no waveform while the left half (which already
|
|
476
|
+
// got an explicit `outPoint`) kept its bars. Slicing by src+window is the
|
|
477
|
+
// codebase's design (peaks are fetched per source window and cached by
|
|
478
|
+
// src+window+bucket), so the fix is to give the fragment the correct window; no
|
|
479
|
+
// extra decode beyond the one cheap windowed peaks fetch each new half needs.
|
|
480
|
+
const inPoint = track.inPoint ?? 0
|
|
481
|
+
const outPoint = track.outPoint ?? track.sourceDuration ?? (track.end - track.start)
|
|
482
|
+
const splitPoint = inPoint + (at - track.start)
|
|
318
483
|
|
|
319
484
|
const left: AudioTrack = {
|
|
320
485
|
...track,
|
|
321
486
|
end: at,
|
|
322
|
-
|
|
487
|
+
inPoint,
|
|
488
|
+
outPoint: splitPoint,
|
|
323
489
|
}
|
|
324
490
|
const right: AudioTrack = {
|
|
325
491
|
...track,
|
|
326
492
|
id: uniqueId(track.id),
|
|
327
493
|
start: at,
|
|
328
|
-
inPoint:
|
|
494
|
+
inPoint: splitPoint,
|
|
495
|
+
outPoint,
|
|
329
496
|
}
|
|
330
497
|
return [left, right]
|
|
331
498
|
}
|
|
@@ -341,15 +508,14 @@ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack
|
|
|
341
508
|
export function splitAtTime<P extends Project>(project: P, at: number, itemId: string | null): P {
|
|
342
509
|
let changed = false
|
|
343
510
|
|
|
344
|
-
const newTracks = (project
|
|
345
|
-
|
|
511
|
+
const newTracks = mapTrackItems(project, items =>
|
|
512
|
+
items.flatMap(item => {
|
|
346
513
|
if (itemId !== null && item.id !== itemId) return [item]
|
|
347
514
|
if (at <= item.start || at >= item.end) return [item] // playhead not inside this clip
|
|
348
515
|
changed = true
|
|
349
516
|
return splitClip(item, { start: at, end: at })
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
})
|
|
517
|
+
}),
|
|
518
|
+
)
|
|
353
519
|
|
|
354
520
|
// Also split audio tracks
|
|
355
521
|
const audioTracks = project.audio?.tracks ?? []
|
|
@@ -367,3 +533,513 @@ export function splitAtTime<P extends Project>(project: P, at: number, itemId: s
|
|
|
367
533
|
audio: { ...project.audio, tracks: newAudioTracks },
|
|
368
534
|
}
|
|
369
535
|
}
|
|
536
|
+
|
|
537
|
+
// ── Trim ops (ripple / roll / slip / slide) ─────────────────────────────────
|
|
538
|
+
//
|
|
539
|
+
// Four pure editing ops over the same data the cut engine above works on. Rules
|
|
540
|
+
// shared by all four, matching the ops above:
|
|
541
|
+
//
|
|
542
|
+
// • in/outPoints are ORIGINAL SOURCE coordinates; `normalizedInPoint` is a
|
|
543
|
+
// cache origin and is never rewritten here (see schema.ts).
|
|
544
|
+
// • Source points are written only when the item already carries them, so an
|
|
545
|
+
// op never invents an inPoint/outPoint on an item that had none.
|
|
546
|
+
// • A clip's per-clip speed S (`item.speed ?? 1`, montaj/speed) is the timeline
|
|
547
|
+
// ↔source scale: `outPoint − inPoint === S·(end − start)`. A timeline delta
|
|
548
|
+
// converts to a SOURCE delta by `·S`, and a source length back to a timeline
|
|
549
|
+
// length by `/S`. `·1`/`/1` are exact, so every op is byte-identical at
|
|
550
|
+
// S=1/absent. MIN_DURATION clamps stay in TIMELINE terms (clip spans are
|
|
551
|
+
// timeline); only the source-media bounds (`inPoint ≥ 0`, `outPoint ≤
|
|
552
|
+
// sourceDuration`) pick up the `/S` factor, so a sped clip can't be trimmed
|
|
553
|
+
// to a negative or oversized source window.
|
|
554
|
+
// • `sourceDuration` absent ⇒ the source end is unknown ⇒ no upper clamp,
|
|
555
|
+
// the same `?? Infinity` convention `timeline/multiSelectOps.ts` uses.
|
|
556
|
+
// • Every op returns the SAME project reference when it would change nothing.
|
|
557
|
+
//
|
|
558
|
+
// AUDIO COUPLING: `project.audio.tracks` is never moved by these ops, matching
|
|
559
|
+
// `collapseGaps` — music beds and voiceover are timed independently of the
|
|
560
|
+
// visual track, so rippling video must not desync them. `splitAtTime` is the
|
|
561
|
+
// only op in this file that reaches into audio, and it splits rather than
|
|
562
|
+
// shifts. Ripple targets are visual items only; an audio-track id is a no-op.
|
|
563
|
+
|
|
564
|
+
/** Locate an item by id in a project's ITEMS — the `trackItems(project)` view,
|
|
565
|
+
* not `project.tracks` — so `{ti, ii}` indexes straight back into that view.
|
|
566
|
+
* `ti` is also the track's index in `project.tracks`: `trackItems` preserves
|
|
567
|
+
* track order, so a `mapTrackItems` rebuild addresses the same track. */
|
|
568
|
+
function findItem(tracks: VisualItem[][], itemId: string): { ti: number; ii: number } | null {
|
|
569
|
+
for (let ti = 0; ti < tracks.length; ti++) {
|
|
570
|
+
const ii = tracks[ti].findIndex(item => item.id === itemId)
|
|
571
|
+
if (ii !== -1) return { ti, ii }
|
|
572
|
+
}
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Source window of an item in original-source coordinates, defaulted the same
|
|
577
|
+
* way `cutSingleItem` defaults it — the synthesized length carries speed S
|
|
578
|
+
* (`(end − start)·S`) so `outPoint − inPoint === S·(end − start)` holds whether
|
|
579
|
+
* outPoint was stored or defaulted. */
|
|
580
|
+
function sourceWindow(item: VisualItem): { inPoint: number; outPoint: number } {
|
|
581
|
+
const inPoint = item.inPoint ?? 0
|
|
582
|
+
return { inPoint, outPoint: item.outPoint ?? (inPoint + (item.end - item.start) * (item.speed ?? 1)) }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Shift the segments whose midpoint falls inside `window` by `delta`, leaving
|
|
586
|
+
* every other segment at the same reference. Midpoint ownership is the rule
|
|
587
|
+
* `collapseGaps` already uses to decide which clip a caption belongs to. */
|
|
588
|
+
function shiftCaptionsInWindow(
|
|
589
|
+
segments: CaptionSegment[],
|
|
590
|
+
window: { start: number; end: number },
|
|
591
|
+
delta: number,
|
|
592
|
+
): CaptionSegment[] {
|
|
593
|
+
return segments.map(seg => {
|
|
594
|
+
const mid = (seg.start + seg.end) / 2
|
|
595
|
+
if (mid < window.start || mid >= window.end) return seg
|
|
596
|
+
return {
|
|
597
|
+
...seg,
|
|
598
|
+
start: seg.start + delta,
|
|
599
|
+
end: seg.end + delta,
|
|
600
|
+
words: seg.words?.map(w => ({ ...w, start: w.start + delta, end: w.end + delta })),
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Delete an item and close the gap it leaves by pulling later timeline content
|
|
607
|
+
* earlier by its duration.
|
|
608
|
+
*
|
|
609
|
+
* Contrast with `collapseGaps`, which normalizes EVERY gap in the primary track:
|
|
610
|
+
* ripple-delete shifts only content that starts at or after the deletion point,
|
|
611
|
+
* so gaps the editor placed deliberately earlier in the timeline survive.
|
|
612
|
+
*
|
|
613
|
+
* - Items in every track (primary and overlay) whose `start` is at/after the
|
|
614
|
+
* deleted item's `end` shift earlier; items overlapping the deleted window are
|
|
615
|
+
* not "subsequent" and stay put. Shifted items move on the timeline only —
|
|
616
|
+
* their source windows are untouched.
|
|
617
|
+
* - Captions are remapped with the same `applyCutToCaptions` rules the lift cut
|
|
618
|
+
* uses: segments inside the deleted window are dropped, partials are trimmed,
|
|
619
|
+
* and later segments (and their words) shift by the deleted duration.
|
|
620
|
+
* - Audio tracks are untouched (see AUDIO COUPLING above).
|
|
621
|
+
* - Empty tracks are kept, as everywhere else in this file.
|
|
622
|
+
* - Returns the same project reference if `itemId` names no visual item.
|
|
623
|
+
*/
|
|
624
|
+
export function rippleDelete<P extends Project>(project: P, itemId: string): P {
|
|
625
|
+
const tracks = trackItems(project)
|
|
626
|
+
const found = findItem(tracks, itemId)
|
|
627
|
+
if (!found) return project
|
|
628
|
+
|
|
629
|
+
const item = tracks[found.ti][found.ii]
|
|
630
|
+
const duration = item.end - item.start
|
|
631
|
+
|
|
632
|
+
const newTracks = mapTrackItems(project, items =>
|
|
633
|
+
items
|
|
634
|
+
.filter(other => other.id !== itemId)
|
|
635
|
+
.map(other =>
|
|
636
|
+
duration > EPSILON && other.start >= item.end - EPSILON
|
|
637
|
+
? { ...other, start: other.start - duration, end: other.end - duration }
|
|
638
|
+
: other,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
const newCaptions = project.captions && duration > EPSILON
|
|
643
|
+
? {
|
|
644
|
+
...project.captions,
|
|
645
|
+
segments: applyCutToCaptions(project.captions.segments, { start: item.start, end: item.end }),
|
|
646
|
+
}
|
|
647
|
+
: project.captions
|
|
648
|
+
|
|
649
|
+
return { ...project, tracks: newTracks, captions: newCaptions }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Move the boundary shared by two adjacent clips: the left clip's outPoint and
|
|
654
|
+
* the right clip's inPoint travel together, so the pair's combined duration and
|
|
655
|
+
* every other item on the timeline stay exactly where they are.
|
|
656
|
+
*
|
|
657
|
+
* `delta` is the boundary movement in seconds (positive = later) and is clamped,
|
|
658
|
+
* not rejected, so a drag past a limit parks the boundary at that limit:
|
|
659
|
+
* - neither clip may drop below MIN_DURATION;
|
|
660
|
+
* - the left clip may not run past the end of its source media;
|
|
661
|
+
* - the right clip may not start before the start of its source media.
|
|
662
|
+
* Source clamps apply to video items only; images/overlays roll geometrically.
|
|
663
|
+
*
|
|
664
|
+
* Captions and audio are untouched — a roll swaps which source frames play at
|
|
665
|
+
* the boundary without moving anything on the timeline.
|
|
666
|
+
*
|
|
667
|
+
* Returns the same project reference when either id is missing, the two clips
|
|
668
|
+
* are on different tracks, they are not adjacent (which is also what a reversed
|
|
669
|
+
* argument pair looks like), or the clamped movement is zero.
|
|
670
|
+
*/
|
|
671
|
+
export function rollEdit<P extends Project>(
|
|
672
|
+
project: P,
|
|
673
|
+
leftItemId: string,
|
|
674
|
+
rightItemId: string,
|
|
675
|
+
delta: number,
|
|
676
|
+
): P {
|
|
677
|
+
const tracks = trackItems(project)
|
|
678
|
+
const l = findItem(tracks, leftItemId)
|
|
679
|
+
const r = findItem(tracks, rightItemId)
|
|
680
|
+
if (!l || !r) return project
|
|
681
|
+
if (l.ti !== r.ti) return project // a boundary only exists within one track
|
|
682
|
+
|
|
683
|
+
const left = tracks[l.ti][l.ii]
|
|
684
|
+
const right = tracks[r.ti][r.ii]
|
|
685
|
+
if (Math.abs(left.end - right.start) > EPSILON) return project // not adjacent
|
|
686
|
+
|
|
687
|
+
const { outPoint: leftOut } = sourceWindow(left)
|
|
688
|
+
const { inPoint: rightIn } = sourceWindow(right)
|
|
689
|
+
// The two clips may run at different speeds: the boundary moves `d` on the
|
|
690
|
+
// TIMELINE, so each clip's source point moves by `d·S` in ITS OWN source, and
|
|
691
|
+
// each source-media clamp on `d` is that clip's source room `/S`.
|
|
692
|
+
const sLeft = left.speed ?? 1
|
|
693
|
+
const sRight = right.speed ?? 1
|
|
694
|
+
|
|
695
|
+
let minDelta = left.start + MIN_DURATION - left.end
|
|
696
|
+
let maxDelta = right.end - MIN_DURATION - right.start
|
|
697
|
+
if (left.type === 'video') maxDelta = Math.min(maxDelta, ((left.sourceDuration ?? Infinity) - leftOut) / sLeft)
|
|
698
|
+
if (right.type === 'video') minDelta = Math.max(minDelta, -rightIn / sRight)
|
|
699
|
+
if (minDelta > maxDelta) return project // already past both limits — nothing safe to do
|
|
700
|
+
|
|
701
|
+
const d = Math.max(minDelta, Math.min(delta, maxDelta))
|
|
702
|
+
if (Math.abs(d) <= EPSILON) return project
|
|
703
|
+
|
|
704
|
+
const newLeft: VisualItem = {
|
|
705
|
+
...left,
|
|
706
|
+
end: left.end + d,
|
|
707
|
+
...(left.outPoint !== undefined ? { outPoint: left.outPoint + d * sLeft } : {}),
|
|
708
|
+
}
|
|
709
|
+
const newRight: VisualItem = {
|
|
710
|
+
...right,
|
|
711
|
+
start: right.start + d,
|
|
712
|
+
...(right.inPoint !== undefined ? { inPoint: right.inPoint + d * sRight } : {}),
|
|
713
|
+
}
|
|
714
|
+
const newTrack = tracks[l.ti].map(item =>
|
|
715
|
+
item.id === left.id ? newLeft : item.id === right.id ? newRight : item,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
return { ...project, tracks: mapTrackItems(project, (items, i) => (i === l.ti ? newTrack : items)) }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Slide an item's source window through its media while the item keeps its exact
|
|
723
|
+
* timeline position: for a `delta`-second timeline drag `inPoint` and `outPoint`
|
|
724
|
+
* both move by `delta·S` in source (montaj/speed), `start` and `end` do not. The
|
|
725
|
+
* window length never changes, so MIN_DURATION cannot bind — only the
|
|
726
|
+
* source-media bounds do (`inPoint` >= 0, `outPoint` <= sourceDuration), and each
|
|
727
|
+
* bound on `delta` is that room `/S`.
|
|
728
|
+
*
|
|
729
|
+
* Nothing else on the timeline is affected, so captions and audio are untouched.
|
|
730
|
+
*
|
|
731
|
+
* Returns the same project reference when the item is missing, is not a video
|
|
732
|
+
* clip, carries no source window to slip, or the clamped movement is zero.
|
|
733
|
+
*/
|
|
734
|
+
export function slipItem<P extends Project>(project: P, itemId: string, delta: number): P {
|
|
735
|
+
const tracks = trackItems(project)
|
|
736
|
+
const found = findItem(tracks, itemId)
|
|
737
|
+
if (!found) return project
|
|
738
|
+
|
|
739
|
+
const item = tracks[found.ti][found.ii]
|
|
740
|
+
if (item.type !== 'video') return project // no source media
|
|
741
|
+
if (item.inPoint === undefined && item.outPoint === undefined) return project
|
|
742
|
+
|
|
743
|
+
const s = item.speed ?? 1
|
|
744
|
+
const { inPoint, outPoint } = sourceWindow(item)
|
|
745
|
+
const minDelta = -inPoint / s
|
|
746
|
+
const maxDelta = ((item.sourceDuration ?? Infinity) - outPoint) / s
|
|
747
|
+
if (minDelta > maxDelta) return project
|
|
748
|
+
|
|
749
|
+
const d = Math.max(minDelta, Math.min(delta, maxDelta))
|
|
750
|
+
if (Math.abs(d) <= EPSILON) return project
|
|
751
|
+
|
|
752
|
+
const newItem: VisualItem = {
|
|
753
|
+
...item,
|
|
754
|
+
...(item.inPoint !== undefined ? { inPoint: item.inPoint + d * s } : {}),
|
|
755
|
+
...(item.outPoint !== undefined ? { outPoint: item.outPoint + d * s } : {}),
|
|
756
|
+
}
|
|
757
|
+
const newTrack = tracks[found.ti].map(other => (other.id === item.id ? newItem : other))
|
|
758
|
+
|
|
759
|
+
return { ...project, tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)) }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Move an item along the timeline with its source window unchanged, letting its
|
|
764
|
+
* adjacent neighbors absorb the movement: the previous neighbor's outPoint
|
|
765
|
+
* extends or shrinks to meet the item's new start, and the next neighbor's
|
|
766
|
+
* inPoint does the same at its new end. The three-clip span therefore keeps its
|
|
767
|
+
* total duration and nothing outside it moves.
|
|
768
|
+
*
|
|
769
|
+
* `delta` is clamped rather than rejected:
|
|
770
|
+
* - neither neighbor may drop below MIN_DURATION;
|
|
771
|
+
* - the previous neighbor may not extend past the end of its source media;
|
|
772
|
+
* - the next neighbor may not extend before the start of its source media;
|
|
773
|
+
* - the item may not cross the timeline origin.
|
|
774
|
+
* A neighbor that is absent or separated by a gap absorbs nothing and imposes no
|
|
775
|
+
* limit — the item simply moves through the empty space.
|
|
776
|
+
*
|
|
777
|
+
* Captions whose midpoint sits over the item's OLD window travel with it, the
|
|
778
|
+
* same midpoint-ownership rule `collapseGaps` uses; captions over the neighbors
|
|
779
|
+
* do not move, because the neighbors' existing content does not move either.
|
|
780
|
+
* Audio is untouched.
|
|
781
|
+
*
|
|
782
|
+
* Returns the same project reference when the item is missing or the clamped
|
|
783
|
+
* movement is zero.
|
|
784
|
+
*/
|
|
785
|
+
export function slideItem<P extends Project>(project: P, itemId: string, delta: number): P {
|
|
786
|
+
const tracks = trackItems(project)
|
|
787
|
+
const found = findItem(tracks, itemId)
|
|
788
|
+
if (!found) return project
|
|
789
|
+
|
|
790
|
+
const track = tracks[found.ti]
|
|
791
|
+
const item = track[found.ii]
|
|
792
|
+
|
|
793
|
+
// Neighbors are the items either side of this one in TIMELINE order, which is
|
|
794
|
+
// not necessarily array order.
|
|
795
|
+
const sorted = [...track].sort((a, b) => a.start - b.start)
|
|
796
|
+
const pos = sorted.findIndex(other => other.id === itemId)
|
|
797
|
+
const before = pos > 0 ? sorted[pos - 1] : undefined
|
|
798
|
+
const after = pos < sorted.length - 1 ? sorted[pos + 1] : undefined
|
|
799
|
+
const prev = before && Math.abs(before.end - item.start) <= EPSILON ? before : undefined
|
|
800
|
+
const next = after && Math.abs(after.start - item.end) <= EPSILON ? after : undefined
|
|
801
|
+
|
|
802
|
+
// A neighbor absorbs the move by re-timing its source window: the previous
|
|
803
|
+
// neighbor's outPoint and the next neighbor's inPoint travel `d·S` in each
|
|
804
|
+
// neighbor's OWN source (montaj/speed), so each neighbor's source-media bound
|
|
805
|
+
// on `d` is its room `/S`. The timeline MIN_DURATION clamps are unaffected.
|
|
806
|
+
const sPrev = prev?.speed ?? 1
|
|
807
|
+
const sNext = next?.speed ?? 1
|
|
808
|
+
let minDelta = -item.start
|
|
809
|
+
let maxDelta = Infinity
|
|
810
|
+
if (prev) {
|
|
811
|
+
minDelta = Math.max(minDelta, MIN_DURATION - (prev.end - prev.start))
|
|
812
|
+
if (prev.type === 'video') {
|
|
813
|
+
maxDelta = Math.min(maxDelta, ((prev.sourceDuration ?? Infinity) - sourceWindow(prev).outPoint) / sPrev)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (next) {
|
|
817
|
+
maxDelta = Math.min(maxDelta, (next.end - next.start) - MIN_DURATION)
|
|
818
|
+
if (next.type === 'video') minDelta = Math.max(minDelta, -sourceWindow(next).inPoint / sNext)
|
|
819
|
+
}
|
|
820
|
+
if (minDelta > maxDelta) return project
|
|
821
|
+
|
|
822
|
+
const d = Math.max(minDelta, Math.min(delta, maxDelta))
|
|
823
|
+
if (Math.abs(d) <= EPSILON) return project
|
|
824
|
+
|
|
825
|
+
const moved = new Map<string, VisualItem>()
|
|
826
|
+
moved.set(item.id, { ...item, start: item.start + d, end: item.end + d })
|
|
827
|
+
if (prev) {
|
|
828
|
+
moved.set(prev.id, {
|
|
829
|
+
...prev,
|
|
830
|
+
end: prev.end + d,
|
|
831
|
+
...(prev.outPoint !== undefined ? { outPoint: prev.outPoint + d * sPrev } : {}),
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
if (next) {
|
|
835
|
+
moved.set(next.id, {
|
|
836
|
+
...next,
|
|
837
|
+
start: next.start + d,
|
|
838
|
+
...(next.inPoint !== undefined ? { inPoint: next.inPoint + d * sNext } : {}),
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
const newTrack = track.map(other => moved.get(other.id) ?? other)
|
|
842
|
+
|
|
843
|
+
const newCaptions = project.captions
|
|
844
|
+
? {
|
|
845
|
+
...project.captions,
|
|
846
|
+
segments: shiftCaptionsInWindow(
|
|
847
|
+
project.captions.segments,
|
|
848
|
+
{ start: item.start, end: item.end },
|
|
849
|
+
d,
|
|
850
|
+
),
|
|
851
|
+
}
|
|
852
|
+
: project.captions
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
...project,
|
|
856
|
+
tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)),
|
|
857
|
+
captions: newCaptions,
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── Speed ────────────────────────────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
/** Speed bounds (montaj/speed). Mirrors the schema note on `VisualItem.speed`
|
|
864
|
+
* and `engine/validate.py`'s range check. */
|
|
865
|
+
const MIN_SPEED = 0.25
|
|
866
|
+
const MAX_SPEED = 4
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Set a clip's per-clip playback speed and re-fit its timeline span to the same
|
|
870
|
+
* source range at the new rate.
|
|
871
|
+
*
|
|
872
|
+
* `speed` is clamped to [MIN_SPEED, MAX_SPEED]. `inPoint`/`outPoint` are
|
|
873
|
+
* speed-independent ORIGINAL-source coordinates and are left untouched; only the
|
|
874
|
+
* timeline `end` moves:
|
|
875
|
+
*
|
|
876
|
+
* end = start + (effectiveOutPoint − effectiveInPoint) / speed
|
|
877
|
+
*
|
|
878
|
+
* The effective window comes from the same `sourceWindow` helper the trim ops
|
|
879
|
+
* use, so the source length is read consistently whether `outPoint` was stored
|
|
880
|
+
* or synthesized — which makes a re-speed from ANY prior S correct, since the
|
|
881
|
+
* source length is speed-invariant. The op re-times the ONE clip only; it
|
|
882
|
+
* deliberately does not close the gap it opens (speeding up) or the overlap it
|
|
883
|
+
* creates (slowing down) — the caller decides based on the magnet toggle
|
|
884
|
+
* (`collapseGaps`).
|
|
885
|
+
*
|
|
886
|
+
* Returns a new Project. Same reference back when `clipId` names no item, or
|
|
887
|
+
* the item is not a video clip — speed is video-only per the schema, so an
|
|
888
|
+
* image/overlay must not pick up a `speed` field or a rescaled `end`.
|
|
889
|
+
*/
|
|
890
|
+
export function setClipSpeed<P extends Project>(project: P, clipId: string, speed: number): P {
|
|
891
|
+
const tracks = trackItems(project)
|
|
892
|
+
const found = findItem(tracks, clipId)
|
|
893
|
+
if (!found) return project
|
|
894
|
+
|
|
895
|
+
const item = tracks[found.ti][found.ii]
|
|
896
|
+
if (item.type !== 'video') return project
|
|
897
|
+
|
|
898
|
+
const clamped = Math.min(MAX_SPEED, Math.max(MIN_SPEED, speed))
|
|
899
|
+
const { inPoint, outPoint } = sourceWindow(item)
|
|
900
|
+
|
|
901
|
+
const newItem: VisualItem = {
|
|
902
|
+
...item,
|
|
903
|
+
speed: clamped,
|
|
904
|
+
end: item.start + (outPoint - inPoint) / clamped,
|
|
905
|
+
}
|
|
906
|
+
const newTrack = tracks[found.ti].map(other => (other.id === clipId ? newItem : other))
|
|
907
|
+
|
|
908
|
+
return { ...project, tracks: mapTrackItems(project, (items, i) => (i === found.ti ? newTrack : items)) }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ── Insert (place a new clip from the media bin) ──────────────────────────────
|
|
912
|
+
|
|
913
|
+
/** The footage a media-bin drop hands to `insertClipAt`: enough to place a
|
|
914
|
+
* whole-source video item, nothing more. `sourceDuration` is required — it is
|
|
915
|
+
* both the placed clip's timeline length and its source window's end. */
|
|
916
|
+
export interface NewClipInput {
|
|
917
|
+
src: string
|
|
918
|
+
proxySrc?: string
|
|
919
|
+
sourceDuration: number
|
|
920
|
+
sourceWidth?: number
|
|
921
|
+
sourceHeight?: number
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* A generic, collision-resistant timeline-item id — distinct in SHAPE from the
|
|
926
|
+
* `clip-<n>` ids init hands source clips, and from the `<base>_split_…` ids
|
|
927
|
+
* `uniqueId` mints for split fragments, so a bin placement can never be mistaken
|
|
928
|
+
* for either. Date.now()+random is fine in app runtime code (this is not a pure
|
|
929
|
+
* transform of the project).
|
|
930
|
+
*/
|
|
931
|
+
export function newClipId(): string {
|
|
932
|
+
// Two independent random draws (each ~52 bits, base36) plus the timestamp:
|
|
933
|
+
// a single 4-char draw is only ~1.7M-wide, so a tight burst of ids minted in
|
|
934
|
+
// the same millisecond (Date.now() constant) collides at a birthday rate that
|
|
935
|
+
// is small but real. Doubling the random tail makes a collision negligible.
|
|
936
|
+
const rand = Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 8)
|
|
937
|
+
return `clip_${Date.now().toString(36)}${rand}`
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** True when [s, e) overlaps any item's window by more than float slop. Touching
|
|
941
|
+
* edges (butt-adjacency) are NOT an overlap, matching the EPSILON tolerance the
|
|
942
|
+
* rest of this file uses for adjacency. Exported because `timeline/placement.ts`
|
|
943
|
+
* reuses it for its own free-window check — the butt-adjacency tolerance
|
|
944
|
+
* (`EPSILON`) is defined exactly once, here, rather than redeclared. */
|
|
945
|
+
export function overlapsAny(s: number, e: number, items: VisualItem[]): boolean {
|
|
946
|
+
return items.some(it => Math.min(e, it.end) - Math.max(s, it.start) > EPSILON)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Place a NEW whole-source video clip on the track identified by `trackId`,
|
|
951
|
+
* returning a new Project (inputs — the project, its tracks/items, and the
|
|
952
|
+
* `clip` descriptor — are never mutated).
|
|
953
|
+
*
|
|
954
|
+
* The built item is a whole-source window: `inPoint 0 → outPoint sourceDuration`,
|
|
955
|
+
* `start → end` spanning exactly `sourceDuration` on the timeline, with a fresh
|
|
956
|
+
* `newClipId()`. `atTime` is clamped to `>= 0`.
|
|
957
|
+
*
|
|
958
|
+
* Placement follows the magnet, exactly as the other timeline ops treat it:
|
|
959
|
+
*
|
|
960
|
+
* • `ripple: true` — shift-right insert. If the drop point lands inside an
|
|
961
|
+
* existing item's span (straddles it), the effective insertion point snaps
|
|
962
|
+
* to that item's NEAREST edge — start or end, ties going to START — so the
|
|
963
|
+
* new clip never lands inside another clip's window. Every existing item
|
|
964
|
+
* whose `start` is at/after the (possibly snapped) insertion point then
|
|
965
|
+
* moves right by the new clip's length, and the new clip takes the freed
|
|
966
|
+
* slot at the insertion point. This leaves NO overlaps and preserves order;
|
|
967
|
+
* splitting the straddling clip is intentionally NOT done here.
|
|
968
|
+
*
|
|
969
|
+
* The tie-break is Sam's product call (2026-08-25): "never split a clip,
|
|
970
|
+
* always keep them whole — if the drop is over 50% into the clip, make
|
|
971
|
+
* room to the RIGHT of it (push everything else right); at or before 50%,
|
|
972
|
+
* make room to the LEFT of it." A drop in the first half of a clip reads
|
|
973
|
+
* as "put the new clip before this one", so an exact-midpoint drop (the
|
|
974
|
+
* tie) resolves the same way — to the clip's start, not its end.
|
|
975
|
+
*
|
|
976
|
+
* • `ripple: false` — place at the drop point WITHOUT moving anything, but
|
|
977
|
+
* never overlapping: if the drop window collides, the new clip snaps to the
|
|
978
|
+
* nearest free gap at/after the drop point that fits it, and if no such gap
|
|
979
|
+
* fits it is appended after the last item on the track. No overlaps result.
|
|
980
|
+
*
|
|
981
|
+
* The returned track's items are sorted by `start`. An unknown `trackId` returns
|
|
982
|
+
* the project unchanged (matching how the sibling ops no-op on a missing id).
|
|
983
|
+
*/
|
|
984
|
+
export function insertClipAt<P extends Project>(
|
|
985
|
+
project: P,
|
|
986
|
+
trackId: string,
|
|
987
|
+
clip: NewClipInput,
|
|
988
|
+
atTime: number,
|
|
989
|
+
opts: { ripple: boolean },
|
|
990
|
+
): P {
|
|
991
|
+
const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
|
|
992
|
+
const trackIdx = tracks.findIndex(t => t.id === trackId)
|
|
993
|
+
if (trackIdx === -1) return project
|
|
994
|
+
|
|
995
|
+
const len = clip.sourceDuration
|
|
996
|
+
const dropAt = Math.max(0, atTime)
|
|
997
|
+
const existing = tracks[trackIdx].items
|
|
998
|
+
|
|
999
|
+
// Where the new clip's timeline window actually lands.
|
|
1000
|
+
let start: number
|
|
1001
|
+
let shifted = existing
|
|
1002
|
+
if (opts.ripple) {
|
|
1003
|
+
// If the drop point lands inside an existing item's span, snap the
|
|
1004
|
+
// effective insertion point to that item's nearest edge (ties → START —
|
|
1005
|
+
// Sam's call, see this function's doc comment) so the new clip never
|
|
1006
|
+
// lands inside another clip's window. At most one item can straddle a
|
|
1007
|
+
// point on a non-overlapping track.
|
|
1008
|
+
const straddler = existing.find(it => it.start < dropAt - EPSILON && it.end > dropAt + EPSILON)
|
|
1009
|
+
const insertAt = straddler
|
|
1010
|
+
? (dropAt - straddler.start <= straddler.end - dropAt ? straddler.start : straddler.end)
|
|
1011
|
+
: dropAt
|
|
1012
|
+
|
|
1013
|
+
start = insertAt
|
|
1014
|
+
shifted = existing.map(it =>
|
|
1015
|
+
it.start >= insertAt - EPSILON ? { ...it, start: it.start + len, end: it.end + len } : it,
|
|
1016
|
+
)
|
|
1017
|
+
} else {
|
|
1018
|
+
// Earliest start >= dropAt whose [start, start+len] window is free. The
|
|
1019
|
+
// candidate starts are the drop point itself and the end of every item
|
|
1020
|
+
// at/after it; the last item's end always fits (nothing follows it).
|
|
1021
|
+
const candidates = [
|
|
1022
|
+
dropAt,
|
|
1023
|
+
...existing.filter(it => it.end >= dropAt - EPSILON).map(it => it.end),
|
|
1024
|
+
].sort((a, b) => a - b)
|
|
1025
|
+
start = candidates.find(s => !overlapsAny(s, s + len, existing)) ?? dropAt
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const placed: VisualItem = {
|
|
1029
|
+
id: newClipId(),
|
|
1030
|
+
type: 'video',
|
|
1031
|
+
src: clip.src,
|
|
1032
|
+
start,
|
|
1033
|
+
end: start + len,
|
|
1034
|
+
inPoint: 0,
|
|
1035
|
+
outPoint: len,
|
|
1036
|
+
sourceDuration: len,
|
|
1037
|
+
...(clip.proxySrc !== undefined ? { proxySrc: clip.proxySrc } : {}),
|
|
1038
|
+
...(clip.sourceWidth !== undefined ? { sourceWidth: clip.sourceWidth } : {}),
|
|
1039
|
+
...(clip.sourceHeight !== undefined ? { sourceHeight: clip.sourceHeight } : {}),
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const newItems = [...shifted, placed].sort((a, b) => a.start - b.start)
|
|
1043
|
+
|
|
1044
|
+
return { ...project, tracks: mapTrackItems(project, (items, i) => (i === trackIdx ? newItems : items)) }
|
|
1045
|
+
}
|