@devbycrux/editor 0.1.0 → 0.3.0

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,86 @@
1
+ import type { Project } from '../../types'
2
+ import { formatTime } from './utils'
3
+ import { EditableSegment } from './EditableSegment'
4
+ import { makeCaptionEdit } from './makeCaptionEdit'
5
+
6
+ interface TranscriptPanelProps {
7
+ project: Project
8
+ captionTrack: Project['captions'] | undefined
9
+ currentTime: number
10
+ onCaptionEdit?: (project: Project) => void
11
+ onProjectChange?: (project: Project) => void
12
+ onExpand: () => void
13
+ }
14
+
15
+ export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand }: TranscriptPanelProps) {
16
+ const segs = captionTrack?.segments ?? []
17
+ // Find active segment index
18
+ const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
19
+ const nearIdx = activeIdx !== -1 ? activeIdx
20
+ : segs.reduce((best, s, i) => s.start <= currentTime ? i : best, -1)
21
+ // Vicinity: active ± 2 segments; track start offset for O(1) global index
22
+ const vicinityStart = nearIdx !== -1 ? Math.max(0, nearIdx - 2) : 0
23
+ const vicinitySegs = nearIdx !== -1
24
+ ? segs.slice(vicinityStart, nearIdx + 3)
25
+ : segs.slice(0, 3)
26
+
27
+ return (
28
+ <div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
29
+ <div className="flex items-center justify-between mb-2">
30
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
31
+ <div className="flex items-center gap-2">
32
+ {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle'] as const).map(style => {
33
+ const active = captionTrack.style === style
34
+ return (
35
+ <button
36
+ key={style}
37
+ className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
38
+ active
39
+ ? 'bg-purple-600/30 border-purple-500/60 text-purple-300'
40
+ : 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
41
+ }`}
42
+ onClick={() => {
43
+ if (!project.captions) return
44
+ const updated = { ...project, captions: { ...project.captions, style } }
45
+ onCaptionEdit?.(updated)
46
+ }}
47
+ >
48
+ {style}
49
+ </button>
50
+ )
51
+ })}
52
+ {segs.length > 0 && (
53
+ <button
54
+ className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
55
+ onClick={onExpand}
56
+ >
57
+ Expand ↑
58
+ </button>
59
+ )}
60
+ </div>
61
+ </div>
62
+
63
+ <div className="h-10 overflow-y-auto">
64
+ {segs.length === 0 ? (
65
+ <p className="text-xs text-gray-600 italic">No transcript — captions are generated during the agent pass</p>
66
+ ) : (
67
+ <p className="text-sm text-gray-700 dark:text-gray-200 leading-relaxed">
68
+ {vicinitySegs.map((seg, vi) => {
69
+ const i = vicinityStart + vi
70
+ const isActive = currentTime >= seg.start && currentTime < seg.end
71
+ return (
72
+ <span key={seg.id ?? i}>
73
+ {vi > 0 && ' '}
74
+ <span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
75
+ <span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
76
+ <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
77
+ </span>
78
+ </span>
79
+ )
80
+ })}
81
+ </p>
82
+ )}
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,293 @@
1
+ import { Volume2, VolumeX, Info, Scissors } from 'lucide-react'
2
+ import type { VisualItem } from '../../schema'
3
+ import type { Project } from '../../types'
4
+ import { collapseGaps } from '../cuts'
5
+ import { pct, ratioFromClientX, trackRow, trackRowTall } from './utils'
6
+ import { useTimelineContext } from './TimelineContext'
7
+ import { useItemDragDrop } from './useItemDragDrop'
8
+ import type { Draggable, DragEventContext } from './useItemDragDrop'
9
+ import { applyMuteToSelection, applyResizeDeltaToSelection, deleteSelection } from './multiSelectOps'
10
+
11
+ interface VisualTrackRowProps {
12
+ trackItems: VisualItem[]
13
+ trackIdx: number
14
+ project: Project
15
+ /** Unified multi-selection. First entry is the primary; isSel checks membership. */
16
+ selectedIds: string[]
17
+ rippleMode: boolean
18
+ onProjectChange?: (p: Project) => void
19
+ onOverlayEdit?: (p: Project) => void
20
+ /** Click handler — additive when shift/meta is held. */
21
+ onSelectItem: (id: string | null, additive: boolean) => void
22
+ onInspectClip?: (id: string) => void
23
+ subcutClipId: string | null
24
+ setSubcutClipId: (id: string | null) => void
25
+ /** Host-computed gate for the subcut-regenerate affordance (Montaj: project
26
+ * is an ai_video). When false/undefined the Scissors button is hidden — the
27
+ * package stays agnostic of Montaj's projectType. */
28
+ regenEnabled?: boolean
29
+ /** Host-computed predicate: is this clip already queued for regeneration?
30
+ * (Montaj: project.regenQueue has an entry for this clipId.) Drives the
31
+ * "queued" badge. */
32
+ isClipQueued?: (itemId: string) => boolean
33
+ }
34
+
35
+ const trackColors = [
36
+ { bg: 'bg-slate-600/80', bgHov: 'hover:bg-slate-500/80', bgSel: 'bg-slate-500/90', ring: 'ring-slate-300/80', border: 'border-slate-400/50', text: 'text-slate-200', resHov: 'hover:bg-slate-300/40' },
37
+ { bg: 'bg-sky-700/80', bgHov: 'hover:bg-sky-600/80', bgSel: 'bg-sky-600/90', ring: 'ring-sky-300/80', border: 'border-sky-400/50', text: 'text-sky-200', resHov: 'hover:bg-sky-300/40' },
38
+ { bg: 'bg-violet-700/80', bgHov: 'hover:bg-violet-600/80', bgSel: 'bg-violet-600/90', ring: 'ring-violet-300/80', border: 'border-violet-400/50', text: 'text-violet-200', resHov: 'hover:bg-violet-300/40' },
39
+ { bg: 'bg-emerald-700/80',bgHov: 'hover:bg-emerald-600/80',bgSel: 'bg-emerald-600/90',ring: 'ring-emerald-300/80',border: 'border-emerald-400/50',text: 'text-emerald-200',resHov: 'hover:bg-emerald-300/40'},
40
+ { bg: 'bg-rose-700/80', bgHov: 'hover:bg-rose-600/80', bgSel: 'bg-rose-600/90', ring: 'ring-rose-300/80', border: 'border-rose-400/50', text: 'text-rose-200', resHov: 'hover:bg-rose-300/40' },
41
+ { bg: 'bg-amber-700/60', bgHov: 'hover:bg-amber-700/80', bgSel: 'bg-amber-600/80', ring: 'ring-amber-400/80', border: 'border-amber-500/50', text: 'text-amber-200', resHov: 'hover:bg-amber-300/40' },
42
+ ]
43
+
44
+ export default function VisualTrackRow({
45
+ trackItems,
46
+ trackIdx,
47
+ project,
48
+ selectedIds,
49
+ rippleMode,
50
+ onProjectChange,
51
+ onOverlayEdit,
52
+ onSelectItem,
53
+ onInspectClip,
54
+ subcutClipId,
55
+ setSubcutClipId,
56
+ regenEnabled,
57
+ isClipQueued,
58
+ }: VisualTrackRowProps) {
59
+ const { totalDuration, snapBoundaries, scrollRef, scrubberRef, currentTime, onTimeUpdate, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
60
+ const tc = trackColors[trackIdx % trackColors.length]
61
+ const markerActive = markers[0] !== null || selection !== null
62
+ const primarySelectedId = selectedIds[0] ?? null
63
+ const dimmed = markerActive && primarySelectedId !== null && !trackItems.some(i => i.id === primarySelectedId)
64
+
65
+ const { beginDrag, beginResize } = useItemDragDrop({
66
+ totalDuration,
67
+ snapBoundaries,
68
+ scrollRef,
69
+ zoomRef,
70
+ draggedFlagRef: overlayDraggedRef,
71
+ })
72
+
73
+ function handleItemResizeStart(e: React.MouseEvent, item: VisualItem, edge: 'start' | 'end') {
74
+ if (!onProjectChange) return
75
+ let lastUpdated = project
76
+ const origStart = item.start
77
+ const origEnd = item.end
78
+ const multiTargets = selectedIds.length > 1 && selectedIds.includes(item.id)
79
+
80
+ beginResize(e, item as Draggable, edge, {
81
+ onLivePreview: ({ item: resized }: DragEventContext) => {
82
+ // First: apply the hook's resize (which has clamping, snap, in/outPoint
83
+ // math) to the originator clip.
84
+ let next: Project = {
85
+ ...project,
86
+ tracks: (project.tracks ?? []).map(track =>
87
+ track.map(ov => ov.id !== item.id ? ov : { ...ov, start: resized.start, end: resized.end, inPoint: resized.inPoint, outPoint: resized.outPoint })
88
+ ),
89
+ }
90
+ // Then: propagate the same delta to every other selected item across
91
+ // visual + audio tracks.
92
+ if (multiTargets) {
93
+ const dStart = edge === 'start' ? resized.start - origStart : 0
94
+ const dEnd = edge === 'end' ? resized.end - origEnd : 0
95
+ next = applyResizeDeltaToSelection(next, item.id, selectedIds, edge, { dStart, dEnd })
96
+ }
97
+ if (rippleMode) next = collapseGaps(next)
98
+ lastUpdated = next
99
+ onProjectChange!(next)
100
+ },
101
+ onCommit: () => {
102
+ onOverlayEdit?.(lastUpdated)
103
+ },
104
+ })
105
+ }
106
+
107
+ function handleDeleteOverlay(id: string) {
108
+ if (!onProjectChange) return
109
+ // If part of a multi-selection, delete the whole selection; otherwise just
110
+ // this item. Matches the Delete-key behavior in Timeline.handleKeyDown.
111
+ const ids = selectedIds.length > 1 && selectedIds.includes(id) ? selectedIds : [id]
112
+ let updated = deleteSelection(project, ids)
113
+ if (rippleMode) updated = collapseGaps(updated)
114
+ onProjectChange(updated)
115
+ onOverlayEdit?.(updated)
116
+ onSelectItem(null, false)
117
+ }
118
+
119
+ function handleToggleMute(item: VisualItem) {
120
+ if (!onProjectChange) return
121
+ const newMuted = !item.muted
122
+ // If clicked item is in a multi-selection, set every selected item's mute
123
+ // state to the new target (uniform end state, no surprise mixed state).
124
+ const targetIds = selectedIds.length > 1 && selectedIds.includes(item.id) ? selectedIds : [item.id]
125
+ const updated = applyMuteToSelection(project, targetIds, newMuted)
126
+ onProjectChange(updated)
127
+ onOverlayEdit?.(updated)
128
+ }
129
+
130
+ function handleOverlayDragStart(e: React.MouseEvent, item: VisualItem, sourceTrackIdx: number) {
131
+ if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
132
+ if (!onProjectChange) return
133
+ const projectChange = onProjectChange
134
+ const ROW_HEIGHT_PX = 24
135
+ let lastUpdated = project
136
+
137
+ beginDrag(e, item as Draggable, {
138
+ onLivePreview: ({ item: moved, dy }: DragEventContext) => {
139
+ const trackDelta = Math.round(dy / ROW_HEIGHT_PX)
140
+ const targetIdx = Math.max(0, sourceTrackIdx - trackDelta)
141
+ const duration = moved.end - moved.start
142
+ const overlapMin = duration * 0.3
143
+
144
+ function hasOverlap(track: VisualItem[]): boolean {
145
+ return track.some(ov => {
146
+ if (ov.id === item.id) return false
147
+ return Math.min(moved.end, ov.end) - Math.max(moved.start, ov.start) > overlapMin
148
+ })
149
+ }
150
+
151
+ const tracks = lastUpdated.tracks ?? []
152
+ let bestIdx = targetIdx
153
+ outer: for (let delta = 0; delta <= tracks.length; delta++) {
154
+ for (const i of delta === 0 ? [targetIdx] : [targetIdx - delta, targetIdx + delta]) {
155
+ if (i < 0) continue
156
+ const candidateTrack = i < tracks.length ? tracks[i] : []
157
+ if (!hasOverlap(candidateTrack)) { bestIdx = i; break outer }
158
+ }
159
+ }
160
+
161
+ const removed = tracks.map(t => t.filter(ov => ov.id !== item.id))
162
+ const movedItem = { ...item, start: moved.start, end: moved.end }
163
+ const final = bestIdx >= removed.length
164
+ ? [...removed, [movedItem]]
165
+ : removed.map((t, i) => i === bestIdx ? [...t, movedItem] : t)
166
+
167
+ const next = { ...lastUpdated, tracks: final.filter(t => t.length > 0) }
168
+ projectChange(next)
169
+ lastUpdated = next
170
+ },
171
+ onCommit: () => {
172
+ onOverlayEdit?.(lastUpdated)
173
+ },
174
+ })
175
+ }
176
+
177
+ function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
178
+ if (totalDuration === 0) return
179
+ e.preventDefault()
180
+ const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
181
+ setMarkers(([a, b]) => {
182
+ if (a === null) return [t, null] // place first marker
183
+ if (b === null) return [a, t] // place second → selection complete
184
+ return [t, null] // reset: start fresh with new first marker
185
+ })
186
+ }
187
+
188
+ function handleTrackClick(e: React.MouseEvent) {
189
+ e.stopPropagation()
190
+ if (totalDuration === 0) return
191
+ const clickedTime = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
192
+ const rect = scrubberRef.current?.getBoundingClientRect()
193
+ const snapThreshold = rect ? (8 / rect.width) * totalDuration : 0
194
+ const boundaries = snapBoundaries
195
+ for (const b of boundaries) {
196
+ if (Math.abs(clickedTime - b) < snapThreshold) { onTimeUpdate(b); return }
197
+ }
198
+ onTimeUpdate(clickedTime)
199
+ }
200
+
201
+ const playheadLine = (
202
+ <div
203
+ className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
204
+ style={{ left: `${pct(currentTime, totalDuration)}%` }}
205
+ />
206
+ )
207
+
208
+ return (
209
+ <div className={`${trackIdx === 0 ? trackRowTall : trackRow} transition-opacity ${dimmed ? 'opacity-30 pointer-events-none' : ''}`} onClick={handleTrackClick} onDoubleClick={handleScrubDoubleClick}>
210
+ {trackItems.map((item) => {
211
+ const isSel = selectedIds.includes(item.id)
212
+ return (
213
+ <div
214
+ key={item.id}
215
+ className={`absolute top-0 bottom-0 flex items-center overflow-hidden cursor-grab active:cursor-grabbing
216
+ ${isSel ? `${tc.bgSel} ring-1 ring-inset ${tc.ring}` : `${tc.bg} ${tc.bgHov}`}
217
+ border-r ${tc.border}`}
218
+ style={{ left: `${pct(item.start, totalDuration)}%`, width: `${pct(item.end - item.start, totalDuration)}%` }}
219
+ onClick={(e) => {
220
+ e.stopPropagation()
221
+ if (overlayDraggedRef.current) return
222
+ const additive = e.shiftKey || e.metaKey || e.ctrlKey
223
+ onSelectItem(item.id, additive)
224
+ // Only seek playhead on a plain single-select click (not on
225
+ // additive shift-clicks, which shouldn't disrupt scrubbing).
226
+ if (!additive && !isSel) onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
227
+ }}
228
+ onDoubleClick={(e) => {
229
+ e.stopPropagation()
230
+ if (onInspectClip) onInspectClip(item.id)
231
+ }}
232
+ onMouseDown={(e) => handleOverlayDragStart(e, item, trackIdx)}
233
+ >
234
+ <div
235
+ className={`absolute left-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
236
+ onMouseDown={(e) => handleItemResizeStart(e, item, 'start')}
237
+ />
238
+ <span className={`text-[10px] ${tc.text} truncate flex-1 min-w-0 pl-3`}>
239
+ ▪ {item.type}
240
+ {project.renderMode === 'ffmpeg-drawtext' && trackIdx > 0 && (
241
+ <span className="ml-1.5 text-amber-400/60">preview</span>
242
+ )}
243
+ {isClipQueued?.(item.id) && (
244
+ <span className="ml-1.5 text-amber-300/80 font-medium">queued</span>
245
+ )}
246
+ </span>
247
+ {item.type === 'video' && (
248
+ <button
249
+ className={`shrink-0 mr-3 z-10 cursor-pointer transition-opacity ${item.muted ? 'opacity-30 hover:opacity-60' : 'opacity-50 hover:opacity-90'} ${tc.text}`}
250
+ onClick={(e) => { e.stopPropagation(); handleToggleMute(item) }}
251
+ title={item.muted ? 'Unmute' : 'Mute'}
252
+ >
253
+ {item.muted ? <VolumeX size={10} /> : <Volume2 size={10} />}
254
+ </button>
255
+ )}
256
+ {isSel && onInspectClip && item.type === 'video' && (
257
+ <button
258
+ className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
259
+ onClick={(e) => { e.stopPropagation(); onInspectClip(item.id) }}
260
+ title="Inspect generation"
261
+ ><Info size={10} /></button>
262
+ )}
263
+ {isSel && regenEnabled && item.generation && (item.end - item.start) >= 3 && (
264
+ <button
265
+ className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
266
+ onClick={(e) => { e.stopPropagation(); setSubcutClipId(subcutClipId === item.id ? null : item.id) }}
267
+ title="Subcut regenerate"
268
+ ><Scissors size={10} /></button>
269
+ )}
270
+ {isSel && (
271
+ <button
272
+ className={`shrink-0 ml-1 mr-3 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text} text-[11px] leading-none`}
273
+ onClick={(e) => { e.stopPropagation(); handleDeleteOverlay(item.id) }}
274
+ title="Delete"
275
+ >×</button>
276
+ )}
277
+ <div
278
+ className={`absolute right-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
279
+ onMouseDown={(e) => handleItemResizeStart(e, item, 'end')}
280
+ />
281
+ </div>
282
+ )
283
+ })}
284
+ {playheadLine}
285
+ {selection && (
286
+ <div
287
+ className="absolute inset-y-0 bg-red-500/20 pointer-events-none"
288
+ style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
289
+ />
290
+ )}
291
+ </div>
292
+ )
293
+ }
@@ -0,0 +1,32 @@
1
+ import type { Project } from '../../types'
2
+
3
+ export function makeCaptionEdit(
4
+ globalIdx: number,
5
+ project: Project,
6
+ onProjectChange?: (p: Project) => void,
7
+ onCaptionEdit?: (p: Project) => void,
8
+ ) {
9
+ return (text: string) => {
10
+ if (!project.captions) return
11
+ const updated = {
12
+ ...project,
13
+ captions: {
14
+ ...project.captions,
15
+ segments: project.captions.segments.map((s, j) => {
16
+ if (j !== globalIdx) return s
17
+ const newWords = text.split(/\s+/).filter(Boolean)
18
+ const segDur = s.end - s.start
19
+ const wordDur = segDur / (newWords.length || 1)
20
+ const words = newWords.map((w, wi) => ({
21
+ word: w,
22
+ start: s.start + wi * wordDur,
23
+ end: s.start + (wi + 1) * wordDur,
24
+ }))
25
+ return { ...s, text, words }
26
+ }),
27
+ },
28
+ }
29
+ onProjectChange?.(updated)
30
+ onCaptionEdit?.(updated)
31
+ }
32
+ }
@@ -0,0 +1,157 @@
1
+ // Multi-select project mutations shared by VisualTrackRow and AudioTrackRow.
2
+ //
3
+ // Selection IDs are unified — a single `selectedIds: string[]` covers visual
4
+ // items (project.tracks[*][*]) and audio tracks (project.audio.tracks[*]).
5
+ // Cross-type ops (e.g. resize a video clip + an audio track in one drag) work
6
+ // because every id is globally unique within a Project.
7
+
8
+ import type { VisualItem, AudioTrack } from '../../schema'
9
+ import type { Project } from '../../types'
10
+
11
+ const MIN_DURATION = 0.1
12
+
13
+ export interface ResizeDeltas {
14
+ /** Delta in seconds applied to start-edge resizes. 0 if edge !== 'start'. */
15
+ dStart: number
16
+ /** Delta in seconds applied to end-edge resizes. 0 if edge !== 'end'. */
17
+ dEnd: number
18
+ }
19
+
20
+ /** Apply a start/end resize delta to every item in `selectedIds`, skipping the
21
+ * originator (already updated by the hook's onLivePreview). Returns a new
22
+ * Project. Clamps each item to its own min-duration and source-duration. */
23
+ export function applyResizeDeltaToSelection(
24
+ project: Project,
25
+ originatorId: string,
26
+ selectedIds: readonly string[],
27
+ edge: 'start' | 'end',
28
+ deltas: ResizeDeltas,
29
+ ): Project {
30
+ if (selectedIds.length <= 1) return project
31
+ const targets = new Set(selectedIds.filter(id => id !== originatorId))
32
+ if (targets.size === 0) return project
33
+
34
+ const nextTracks = (project.tracks ?? []).map(track =>
35
+ track.map(item => targets.has(item.id) ? resizeVisualItem(item, edge, deltas) : item)
36
+ )
37
+ const nextAudio = (project.audio?.tracks ?? []).map(t =>
38
+ targets.has(t.id) ? resizeAudioTrack(t, edge, deltas) : t
39
+ )
40
+
41
+ return {
42
+ ...project,
43
+ tracks: nextTracks,
44
+ audio: project.audio ? { ...project.audio, tracks: nextAudio } : project.audio,
45
+ }
46
+ }
47
+
48
+ function resizeVisualItem(item: VisualItem, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): VisualItem {
49
+ if (edge === 'start') {
50
+ const newStart = Math.max(0, Math.min(item.start + dStart, item.end - MIN_DURATION))
51
+ if (item.type !== 'video') return { ...item, start: newStart }
52
+ const inP = item.inPoint ?? 0
53
+ const outP = item.outPoint ?? (inP + (item.end - item.start))
54
+ const dActual = newStart - item.start
55
+ return {
56
+ ...item,
57
+ start: newStart,
58
+ inPoint: Math.max(0, Math.min(inP + dActual, outP - MIN_DURATION)),
59
+ }
60
+ } else {
61
+ const newEnd = Math.max(item.start + MIN_DURATION, item.end + dEnd)
62
+ if (item.type !== 'video') return { ...item, end: newEnd }
63
+ const inP = item.inPoint ?? 0
64
+ const outP = item.outPoint ?? (inP + (item.end - item.start))
65
+ const dActual = newEnd - item.end
66
+ return {
67
+ ...item,
68
+ end: newEnd,
69
+ outPoint: Math.max(inP + MIN_DURATION, Math.min(outP + dActual, item.sourceDuration ?? Infinity)),
70
+ }
71
+ }
72
+ }
73
+
74
+ function resizeAudioTrack(track: AudioTrack, edge: 'start' | 'end', { dStart, dEnd }: ResizeDeltas): AudioTrack {
75
+ const inP = track.inPoint ?? 0
76
+ const outP = track.outPoint ?? (inP + (track.end - track.start))
77
+ const srcDur = track.sourceDuration ?? Infinity
78
+
79
+ if (edge === 'start') {
80
+ const newStart = Math.max(0, Math.min(track.start + dStart, track.end - MIN_DURATION))
81
+ const dActual = newStart - track.start
82
+ return {
83
+ ...track,
84
+ start: newStart,
85
+ inPoint: Math.max(0, Math.min(inP + dActual, outP - MIN_DURATION)),
86
+ }
87
+ } else {
88
+ const newEnd = Math.max(track.start + MIN_DURATION, track.end + dEnd)
89
+ const dActual = newEnd - track.end
90
+ return {
91
+ ...track,
92
+ end: newEnd,
93
+ outPoint: Math.max(inP + MIN_DURATION, Math.min(outP + dActual, srcDur)),
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Set `muted` on every selected item to the given target value. Both visual
99
+ * items (`VisualItem.muted` is video-type-only but we just spread it; harmless
100
+ * on overlays/images that won't read it) and audio tracks are covered. */
101
+ export function applyMuteToSelection(
102
+ project: Project,
103
+ selectedIds: readonly string[],
104
+ muted: boolean,
105
+ ): Project {
106
+ const targets = new Set(selectedIds)
107
+ if (targets.size === 0) return project
108
+
109
+ return {
110
+ ...project,
111
+ tracks: (project.tracks ?? []).map(track =>
112
+ track.map(item => targets.has(item.id) ? { ...item, muted } : item)
113
+ ),
114
+ audio: project.audio
115
+ ? {
116
+ ...project.audio,
117
+ tracks: (project.audio.tracks ?? []).map(t => targets.has(t.id) ? { ...t, muted } : t),
118
+ }
119
+ : project.audio,
120
+ }
121
+ }
122
+
123
+ /** Remove every selected item from both visual tracks and audio tracks.
124
+ * Visual tracks that become empty are pruned (matches existing single-delete
125
+ * behavior in Timeline.handleKeyDown). */
126
+ export function deleteSelection(project: Project, selectedIds: readonly string[]): Project {
127
+ const targets = new Set(selectedIds)
128
+ if (targets.size === 0) return project
129
+
130
+ return {
131
+ ...project,
132
+ tracks: (project.tracks ?? [])
133
+ .map(track => track.filter(item => !targets.has(item.id)))
134
+ .filter(track => track.length > 0),
135
+ audio: project.audio
136
+ ? {
137
+ ...project.audio,
138
+ tracks: (project.audio.tracks ?? []).filter(t => !targets.has(t.id)),
139
+ }
140
+ : project.audio,
141
+ }
142
+ }
143
+
144
+ /** Selection helper: toggle additive (shift-click) vs replace (plain click).
145
+ * - additive + already selected → remove from selection
146
+ * - additive + not selected → add to selection
147
+ * - !additive + sole selection of id → clear (re-clicking the only selected
148
+ * item deselects it, matching the prior single-select behavior)
149
+ * - !additive otherwise → selection becomes [id] */
150
+ export function toggleSelection(current: readonly string[], id: string, additive: boolean): string[] {
151
+ const has = current.includes(id)
152
+ if (additive) {
153
+ return has ? current.filter(x => x !== id) : [...current, id]
154
+ }
155
+ if (has && current.length === 1) return []
156
+ return [id]
157
+ }