@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Clipboard pure ops: copy / paste / duplicate / paste-attributes over the
|
|
2
|
+
// SAME project shape `video/cuts.ts` transforms — visual items
|
|
3
|
+
// (`project.tracks[*].items`) and audio tracks (`project.audio.tracks`).
|
|
4
|
+
//
|
|
5
|
+
// SCOPE: visual items + audio tracks only. Caption segments are deliberately
|
|
6
|
+
// EXCLUDED from every op below — they carry word timings and have their own
|
|
7
|
+
// editing surface (the caption list / row UI), so duplicating or pasting one
|
|
8
|
+
// over the timeline is not a real workflow. A caption id passed in
|
|
9
|
+
// `selectedIds` is a no-op for that id: it never matches a visual item or an
|
|
10
|
+
// audio track, so it's skipped by construction rather than by an explicit
|
|
11
|
+
// check.
|
|
12
|
+
//
|
|
13
|
+
// CONVENTIONS (mirrors video/cuts.ts):
|
|
14
|
+
// • Every op returns the SAME project reference when it would change
|
|
15
|
+
// nothing — an empty/missing selection or clipboard, or a selection that
|
|
16
|
+
// names no real visual item/audio track (captions only, stale ids).
|
|
17
|
+
// • `mapTrackItems`/`normalizeTracks` (timeline/timeline-model.ts) are the
|
|
18
|
+
// only way this module reads or rebuilds `project.tracks`, so both the
|
|
19
|
+
// legacy array-of-arrays shape and the current `VisualTrack[]` object
|
|
20
|
+
// shape are handled the same way everywhere else in the codebase.
|
|
21
|
+
// • `EPSILON` float slop and the ripple:false "earliest gap that fits"
|
|
22
|
+
// placement below are the SAME tolerance and algorithm `insertClipAt`
|
|
23
|
+
// (cuts.ts) uses, reimplemented here over full copied/duplicated items
|
|
24
|
+
// rather than a single new-clip descriptor — see `pasteAt`'s doc comment
|
|
25
|
+
// for why this does not route through `insertClipAt` itself.
|
|
26
|
+
// • `newClipId()` (cuts.ts) mints every fresh id below; its double-random
|
|
27
|
+
// draw design makes minting several ids in one tick safe.
|
|
28
|
+
|
|
29
|
+
import type { Project } from '../types'
|
|
30
|
+
import type { VisualItem, AudioTrack, VisualTrack } from '../schema'
|
|
31
|
+
import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline/timeline-model'
|
|
32
|
+
import { newClipId } from './cuts'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether two audio tracks share a rendered LANE, per `groupAudioLanes`
|
|
36
|
+
* (timeline/timeline-model.ts) — audio tracks are PARALLEL rows, not one
|
|
37
|
+
* shared timeline, so only same-lane tracks can ever collide.
|
|
38
|
+
*
|
|
39
|
+
* `groupAudioLanes` assigns every lane-less track a lane strictly above the
|
|
40
|
+
* highest EXPLICIT lane in the whole array (a first pass finds that max, a
|
|
41
|
+
* second pass hands out `nextAutoLane++` to each lane-less track in order).
|
|
42
|
+
* Two consequences that make the direct comparison below exactly equivalent
|
|
43
|
+
* to running the real algorithm, without needing this module to carry a
|
|
44
|
+
* `contentDuration` to call it:
|
|
45
|
+
* - a lane-less track's auto-assigned lane is never shared with anything
|
|
46
|
+
* else, regardless of how many other lane-less tracks exist or their
|
|
47
|
+
* array order — so `lane == null` means "my own lane" unconditionally;
|
|
48
|
+
* - two EXPLICIT lanes collide iff their numbers are equal, which is just
|
|
49
|
+
* `===`.
|
|
50
|
+
* So "same lane" reduces to: both tracks declare a lane, and it's the same
|
|
51
|
+
* number.
|
|
52
|
+
*/
|
|
53
|
+
function sameAudioLane(a: AudioTrack, b: AudioTrack): boolean {
|
|
54
|
+
return a.lane != null && a.lane === b.lane
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Float slop for overlap/adjacency checks — mirrors the tolerance `cuts.ts`
|
|
58
|
+
* uses for the same purpose. */
|
|
59
|
+
const EPSILON = 0.001
|
|
60
|
+
|
|
61
|
+
// ── Clipboard payload ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** A deep-cloned VisualItem snapshot plus the id of the track it was copied
|
|
64
|
+
* FROM, so `pasteAt` can try to land it back on that same track. */
|
|
65
|
+
export interface ClipboardVisualEntry {
|
|
66
|
+
item: VisualItem
|
|
67
|
+
sourceTrackId: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Everything a copy captured. Both arrays may be non-empty at once (a mixed
|
|
71
|
+
* clip+audio selection); `copySelection` returns `null` instead of an
|
|
72
|
+
* all-empty payload so callers can no-op on a single falsy check. */
|
|
73
|
+
export interface ClipboardPayload {
|
|
74
|
+
items: ClipboardVisualEntry[]
|
|
75
|
+
audioTracks: AudioTrack[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Snapshot every selected visual item and audio track into a clipboard
|
|
80
|
+
* payload. Each snapshot is a DEEP clone (`structuredClone`) — mutating the
|
|
81
|
+
* project afterward can never reach back into a copied payload.
|
|
82
|
+
*
|
|
83
|
+
* Caption segment ids in `selectedIds` are ignored (see the module header).
|
|
84
|
+
*
|
|
85
|
+
* Returns `null` when nothing in `selectedIds` names a real visual item or
|
|
86
|
+
* audio track — including an empty `selectedIds` — so every caller can
|
|
87
|
+
* no-op on `!payload` rather than checking two empty arrays.
|
|
88
|
+
*/
|
|
89
|
+
export function copySelection(project: Project, selectedIds: readonly string[]): ClipboardPayload | null {
|
|
90
|
+
const targets = new Set(selectedIds)
|
|
91
|
+
if (targets.size === 0) return null
|
|
92
|
+
|
|
93
|
+
const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
|
|
94
|
+
const items: ClipboardVisualEntry[] = []
|
|
95
|
+
for (const track of tracks) {
|
|
96
|
+
for (const item of track.items) {
|
|
97
|
+
if (targets.has(item.id)) items.push({ item: structuredClone(item), sourceTrackId: track.id })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const audioTracks = (project.audio?.tracks ?? [])
|
|
102
|
+
.filter(t => targets.has(t.id))
|
|
103
|
+
.map(t => structuredClone(t))
|
|
104
|
+
|
|
105
|
+
if (items.length === 0 && audioTracks.length === 0) return null
|
|
106
|
+
return { items, audioTracks }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Local placement (verbatim clone + ripple:false gap resolution) ─────────
|
|
110
|
+
|
|
111
|
+
/** Earliest `start >= desiredStart` whose `[start, start+duration)` window
|
|
112
|
+
* doesn't overlap anything in `existing` — the same ripple:false placement
|
|
113
|
+
* `insertClipAt` (cuts.ts) uses, reimplemented here over full items instead
|
|
114
|
+
* of a single new-clip descriptor. Candidates are the desired point itself
|
|
115
|
+
* and the end of every existing item at/after it; the last item's end always
|
|
116
|
+
* fits, since nothing follows it. */
|
|
117
|
+
function earliestFittingGap(
|
|
118
|
+
desiredStart: number,
|
|
119
|
+
duration: number,
|
|
120
|
+
existing: ReadonlyArray<{ start: number; end: number }>,
|
|
121
|
+
): number {
|
|
122
|
+
const overlaps = (s: number, e: number) =>
|
|
123
|
+
existing.some(it => Math.min(e, it.end) - Math.max(s, it.start) > EPSILON)
|
|
124
|
+
const candidates = [
|
|
125
|
+
desiredStart,
|
|
126
|
+
...existing.filter(it => it.end >= desiredStart - EPSILON).map(it => it.end),
|
|
127
|
+
].sort((a, b) => a - b)
|
|
128
|
+
return candidates.find(s => !overlaps(s, s + duration)) ?? desiredStart
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Paste a clipboard payload onto the timeline at `atTime`.
|
|
133
|
+
*
|
|
134
|
+
* Every copied `VisualItem` is cloned VERBATIM — type, `inPoint`/`outPoint`,
|
|
135
|
+
* every look field, the `moveItemAcrossTracks` (timeline-model.ts) precedent
|
|
136
|
+
* of carrying a full item across tracks — and given a fresh `newClipId()`.
|
|
137
|
+
* This deliberately does NOT route through `insertClipAt` (cuts.ts:854-914):
|
|
138
|
+
* that function takes a `NewClipInput` and unconditionally mints a fresh
|
|
139
|
+
* whole-source `type:'video'` item, which would un-trim a trimmed clip and
|
|
140
|
+
* corrupt an image/overlay item's fields. Placement here is local to this
|
|
141
|
+
* module instead.
|
|
142
|
+
*
|
|
143
|
+
* The group's relative layout is preserved across BOTH arrays together: every
|
|
144
|
+
* copied item/track keeps its original offset from the earliest-starting
|
|
145
|
+
* member of the whole payload, and that earliest member lands exactly at
|
|
146
|
+
* `atTime` (clamped to `>= 0`) — a multi-item copy pastes back in the same
|
|
147
|
+
* shape it was copied in, just moved as one rigid group.
|
|
148
|
+
*
|
|
149
|
+
* Each visual item lands back on its source track when that track id still
|
|
150
|
+
* exists in `project`; otherwise it falls back to track 0. If the target span
|
|
151
|
+
* collides with what's already on that track (including items already placed
|
|
152
|
+
* earlier in this same paste), the item shifts right to the earliest gap that
|
|
153
|
+
* fits (`earliestFittingGap` above).
|
|
154
|
+
*
|
|
155
|
+
* Audio tracks clone the full `AudioTrack` object the same verbatim way, with
|
|
156
|
+
* `start`/`end` shifted by the same delta and appended to
|
|
157
|
+
* `project.audio.tracks`, gap-resolved the same way against the existing
|
|
158
|
+
* tracks plus anything already pasted IN THE SAME LANE (`sameAudioLane`) —
|
|
159
|
+
* audio lanes are parallel rows, so a track in a different lane (e.g. a
|
|
160
|
+
* voiceover pasted under a much longer music bed) never affects placement.
|
|
161
|
+
*
|
|
162
|
+
* Returns the same project reference when the payload is `null` or captured
|
|
163
|
+
* nothing.
|
|
164
|
+
*/
|
|
165
|
+
export function pasteAt<P extends Project>(project: P, payload: ClipboardPayload | null, atTime: number): P {
|
|
166
|
+
if (!payload || (payload.items.length === 0 && payload.audioTracks.length === 0)) return project
|
|
167
|
+
|
|
168
|
+
const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
|
|
169
|
+
|
|
170
|
+
const earliest = Math.min(
|
|
171
|
+
...payload.items.map(e => e.item.start),
|
|
172
|
+
...payload.audioTracks.map(t => t.start),
|
|
173
|
+
)
|
|
174
|
+
const delta = Math.max(0, atTime) - earliest
|
|
175
|
+
|
|
176
|
+
const byTrackId = new Map<string, VisualItem[]>(tracks.map(t => [t.id, t.items]))
|
|
177
|
+
const fallbackTrackId = tracks[0]?.id
|
|
178
|
+
|
|
179
|
+
for (const entry of payload.items) {
|
|
180
|
+
const targetId = byTrackId.has(entry.sourceTrackId) ? entry.sourceTrackId : fallbackTrackId
|
|
181
|
+
if (targetId === undefined) continue // no track anywhere in the project to land on
|
|
182
|
+
const existing = byTrackId.get(targetId)!
|
|
183
|
+
const duration = entry.item.end - entry.item.start
|
|
184
|
+
const start = earliestFittingGap(entry.item.start + delta, duration, existing)
|
|
185
|
+
const pasted: VisualItem = { ...entry.item, id: newClipId(), start, end: start + duration }
|
|
186
|
+
byTrackId.set(targetId, [...existing, pasted].sort((a, b) => a.start - b.start))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const newTracks = mapTrackItems(project, (items, i) => byTrackId.get(tracks[i].id) ?? items)
|
|
190
|
+
|
|
191
|
+
let audioTracks = project.audio?.tracks ?? []
|
|
192
|
+
for (const track of payload.audioTracks) {
|
|
193
|
+
const duration = track.end - track.start
|
|
194
|
+
// Gap-resolve against SAME-LANE tracks only — see `sameAudioLane` — so a
|
|
195
|
+
// longer clip in a different lane (e.g. a music bed under a voiceover)
|
|
196
|
+
// never shoves this paste past where it actually collides.
|
|
197
|
+
const laneMates = audioTracks.filter(t => sameAudioLane(t, track))
|
|
198
|
+
const start = earliestFittingGap(track.start + delta, duration, laneMates)
|
|
199
|
+
audioTracks = [...audioTracks, { ...track, id: newClipId(), start, end: start + duration }]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Only attach `audio` when there's a reason to: the project already had one
|
|
203
|
+
// (preserve it, even if this paste added nothing to it), or this paste
|
|
204
|
+
// actually added audio tracks. Otherwise leave `project.audio` exactly as
|
|
205
|
+
// it was (`undefined` on an audio-less project) — a visual-only paste must
|
|
206
|
+
// not materialize an empty `audio: { tracks: [] }` and its undo/save entry.
|
|
207
|
+
const audio = project.audio
|
|
208
|
+
? { ...project.audio, tracks: audioTracks }
|
|
209
|
+
: payload.audioTracks.length > 0
|
|
210
|
+
? { tracks: audioTracks }
|
|
211
|
+
: project.audio
|
|
212
|
+
|
|
213
|
+
return { ...project, tracks: newTracks, audio }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Duplicate every selected visual item and audio track in place: each
|
|
218
|
+
* verbatim copy (fresh `newClipId()`, every field otherwise untouched) lands
|
|
219
|
+
* immediately after its original — at the original's own `end` — resolving
|
|
220
|
+
* any collision with `earliestFittingGap`, the same placement `pasteAt` uses.
|
|
221
|
+
*
|
|
222
|
+
* Selected items are duplicated in (track, start) order, one at a time, so a
|
|
223
|
+
* run of adjacent selected clips stacks its duplicates deterministically
|
|
224
|
+
* against each other instead of depending on `selectedIds`' incoming order.
|
|
225
|
+
*
|
|
226
|
+
* Caption segment ids in `selectedIds` are ignored (see the module header).
|
|
227
|
+
*
|
|
228
|
+
* Returns the same project reference when nothing in `selectedIds` names a
|
|
229
|
+
* real visual item or audio track.
|
|
230
|
+
*/
|
|
231
|
+
export function duplicateSelection<P extends Project>(project: P, selectedIds: readonly string[]): P {
|
|
232
|
+
const targets = new Set(selectedIds)
|
|
233
|
+
if (targets.size === 0) return project
|
|
234
|
+
|
|
235
|
+
const tracks = (normalizeTracks(project).tracks ?? []) as VisualTrack[]
|
|
236
|
+
const byTrackId = new Map<string, VisualItem[]>(tracks.map(t => [t.id, t.items]))
|
|
237
|
+
let changed = false
|
|
238
|
+
|
|
239
|
+
for (const track of tracks) {
|
|
240
|
+
const selected = track.items.filter(it => targets.has(it.id)).sort((a, b) => a.start - b.start)
|
|
241
|
+
for (const item of selected) {
|
|
242
|
+
const existing = byTrackId.get(track.id)!
|
|
243
|
+
const duration = item.end - item.start
|
|
244
|
+
const start = earliestFittingGap(item.end, duration, existing)
|
|
245
|
+
const dup: VisualItem = { ...item, id: newClipId(), start, end: start + duration }
|
|
246
|
+
byTrackId.set(track.id, [...existing, dup].sort((a, b) => a.start - b.start))
|
|
247
|
+
changed = true
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const newTracks = mapTrackItems(project, (items, i) => byTrackId.get(tracks[i].id) ?? items)
|
|
252
|
+
|
|
253
|
+
const existingAudio = project.audio?.tracks ?? []
|
|
254
|
+
const selectedAudio = existingAudio.filter(t => targets.has(t.id)).sort((a, b) => a.start - b.start)
|
|
255
|
+
let audioTracks = existingAudio
|
|
256
|
+
for (const track of selectedAudio) {
|
|
257
|
+
const duration = track.end - track.start
|
|
258
|
+
// Same-lane scoping — see `sameAudioLane` and its use in `pasteAt` above.
|
|
259
|
+
const laneMates = audioTracks.filter(t => sameAudioLane(t, track))
|
|
260
|
+
const start = earliestFittingGap(track.end, duration, laneMates)
|
|
261
|
+
audioTracks = [...audioTracks, { ...track, id: newClipId(), start, end: start + duration }]
|
|
262
|
+
changed = true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!changed) return project
|
|
266
|
+
return {
|
|
267
|
+
...project,
|
|
268
|
+
tracks: newTracks,
|
|
269
|
+
audio: project.audio ? { ...project.audio, tracks: audioTracks } : project.audio,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Paste attributes (look fields only, type-gated) ─────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The transferable visual "look" fields, gated on the TARGET item's type —
|
|
277
|
+
* decision 3 of the clipboard-ops spec. `sourceCrop` is deliberately EXCLUDED:
|
|
278
|
+
* it's a source-geometry field (0–1 fractions against THAT source), not a
|
|
279
|
+
* look, so it never belongs in this patch regardless of target type.
|
|
280
|
+
*/
|
|
281
|
+
function visualAttributesPatch(source: VisualItem, targetType: VisualItem['type']): Partial<VisualItem> {
|
|
282
|
+
return {
|
|
283
|
+
...(source.offsetX !== undefined ? { offsetX: source.offsetX } : {}),
|
|
284
|
+
...(source.offsetY !== undefined ? { offsetY: source.offsetY } : {}),
|
|
285
|
+
...(source.scale !== undefined ? { scale: source.scale } : {}),
|
|
286
|
+
...(source.opacity !== undefined ? { opacity: source.opacity } : {}),
|
|
287
|
+
...(source.rotation !== undefined ? { rotation: source.rotation } : {}),
|
|
288
|
+
...(targetType === 'image' && source.fit !== undefined ? { fit: source.fit } : {}),
|
|
289
|
+
...(targetType === 'video' && source.volume !== undefined ? { volume: source.volume } : {}),
|
|
290
|
+
...(targetType === 'video' && source.muted !== undefined ? { muted: source.muted } : {}),
|
|
291
|
+
...(targetType === 'video' && source.speed !== undefined ? { speed: source.speed } : {}),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The transferable audio "look" fields — decision 3's audio→audio set. */
|
|
296
|
+
function audioAttributesPatch(source: AudioTrack): Partial<AudioTrack> {
|
|
297
|
+
return {
|
|
298
|
+
...(source.volume !== undefined ? { volume: source.volume } : {}),
|
|
299
|
+
...(source.muted !== undefined ? { muted: source.muted } : {}),
|
|
300
|
+
...(source.ducking !== undefined ? { ducking: source.ducking } : {}),
|
|
301
|
+
...(source.fadeIn !== undefined ? { fadeIn: source.fadeIn } : {}),
|
|
302
|
+
...(source.fadeOut !== undefined ? { fadeOut: source.fadeOut } : {}),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Copy "look" attributes from the clipboard's FIRST copied visual item (for
|
|
308
|
+
* selected visual items) and FIRST copied audio track (for selected audio
|
|
309
|
+
* tracks) onto every id in `selectedIds`, type-gated per `visualAttributesPatch`
|
|
310
|
+
* / `audioAttributesPatch` above. A selected item never receives fields from
|
|
311
|
+
* the wrong source kind (e.g. a selected audio track never reads
|
|
312
|
+
* `payload.items[0]`) and never receives a field its own type doesn't carry
|
|
313
|
+
* (e.g. `fit` never lands on a video item).
|
|
314
|
+
*
|
|
315
|
+
* Caption segment ids in `selectedIds` are ignored (see the module header).
|
|
316
|
+
*
|
|
317
|
+
* Returns the same project reference when `selectedIds` is empty, the
|
|
318
|
+
* payload is `null`, the payload has neither a visual item nor an audio track
|
|
319
|
+
* to source from, or applying the gated patch would change nothing (e.g. the
|
|
320
|
+
* only selected items are captions, or the source's fields don't survive type
|
|
321
|
+
* gating for any selected target).
|
|
322
|
+
*/
|
|
323
|
+
export function pasteAttributes<P extends Project>(
|
|
324
|
+
project: P,
|
|
325
|
+
payload: ClipboardPayload | null,
|
|
326
|
+
selectedIds: readonly string[],
|
|
327
|
+
): P {
|
|
328
|
+
const targets = new Set(selectedIds)
|
|
329
|
+
if (targets.size === 0 || !payload) return project
|
|
330
|
+
|
|
331
|
+
const sourceVisual = payload.items[0]?.item
|
|
332
|
+
const sourceAudio = payload.audioTracks[0]
|
|
333
|
+
if (!sourceVisual && !sourceAudio) return project
|
|
334
|
+
|
|
335
|
+
let changed = false
|
|
336
|
+
let next = project
|
|
337
|
+
|
|
338
|
+
if (sourceVisual) {
|
|
339
|
+
const patchCache = new Map<VisualItem['type'], Partial<VisualItem>>()
|
|
340
|
+
const patchFor = (type: VisualItem['type']) => {
|
|
341
|
+
let patch = patchCache.get(type)
|
|
342
|
+
if (!patch) {
|
|
343
|
+
patch = visualAttributesPatch(sourceVisual, type)
|
|
344
|
+
patchCache.set(type, patch)
|
|
345
|
+
}
|
|
346
|
+
return patch
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let visualChanged = false
|
|
350
|
+
const newTracks = mapTrackItems(next, items =>
|
|
351
|
+
items.map(item => {
|
|
352
|
+
if (!targets.has(item.id)) return item
|
|
353
|
+
const patch = patchFor(item.type)
|
|
354
|
+
if (Object.keys(patch).length === 0) return item
|
|
355
|
+
visualChanged = true
|
|
356
|
+
return { ...item, ...patch }
|
|
357
|
+
}),
|
|
358
|
+
)
|
|
359
|
+
if (visualChanged) {
|
|
360
|
+
next = { ...next, tracks: newTracks }
|
|
361
|
+
changed = true
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (sourceAudio) {
|
|
366
|
+
const patch = audioAttributesPatch(sourceAudio)
|
|
367
|
+
if (Object.keys(patch).length > 0) {
|
|
368
|
+
for (const track of next.audio?.tracks ?? []) {
|
|
369
|
+
if (!targets.has(track.id)) continue
|
|
370
|
+
next = updateAudioTrack(next, track.id, patch) as P
|
|
371
|
+
changed = true
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return changed ? next : project
|
|
377
|
+
}
|