@devbycrux/editor 0.1.0 → 0.2.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.
- package/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/index.ts +23 -1
- package/src/types.ts +141 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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
|
+
}
|