@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.
- package/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/CarouselEditor.tsx +28 -16
- package/src/carousel/SlideCanvas.tsx +12 -0
- package/src/carousel/SlidePropertyPanel.tsx +40 -2
- package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
- package/src/index.ts +23 -1
- package/src/types.ts +161 -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,406 @@
|
|
|
1
|
+
// AudioTrackRow — renders one audio lane on the timeline.
|
|
2
|
+
//
|
|
3
|
+
// A lane can contain one or more AudioTrack items (e.g. two voiceover segments
|
|
4
|
+
// at different times sharing the same row). Supports: drag-to-reposition,
|
|
5
|
+
// cross-lane drag, edge trim, mute toggle, inline volume, delete,
|
|
6
|
+
// click-to-select, and inspect button.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
|
+
import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
|
|
10
|
+
import type { AudioTrack } from '../../schema'
|
|
11
|
+
import type { Project } from '../../types'
|
|
12
|
+
import { pct } from './utils'
|
|
13
|
+
import { useTimelineContext } from './TimelineContext'
|
|
14
|
+
import { useItemDragDrop } from './useItemDragDrop'
|
|
15
|
+
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
16
|
+
import AudioWaveformLayer from './AudioWaveformLayer'
|
|
17
|
+
import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
|
|
18
|
+
import { applyMuteToSelection, applyResizeDeltaToSelection } from './multiSelectOps'
|
|
19
|
+
|
|
20
|
+
interface AudioTrackRowProps {
|
|
21
|
+
tracks: AudioTrack[]
|
|
22
|
+
laneIndex: number
|
|
23
|
+
laneCount: number
|
|
24
|
+
project: Project
|
|
25
|
+
onProjectChange?: (p: Project) => void
|
|
26
|
+
onOverlayEdit?: (p: Project) => void
|
|
27
|
+
/** Unified multi-selection — same array shared across visual + audio rows. */
|
|
28
|
+
selectedIds: string[]
|
|
29
|
+
onSelectItem: (id: string | null, additive: boolean) => void
|
|
30
|
+
onInspect?: (id: string) => void
|
|
31
|
+
getWaveformChunks?: GetWaveformChunks
|
|
32
|
+
resolveFilePath?: ResolveFilePath
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LANE_HEIGHT_PX = 40 // must match the h-10 (2.5rem = 40px) on the row container
|
|
36
|
+
|
|
37
|
+
function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
|
|
38
|
+
return {
|
|
39
|
+
...project,
|
|
40
|
+
audio: {
|
|
41
|
+
...project.audio,
|
|
42
|
+
tracks: (project.audio?.tracks ?? []).map(t =>
|
|
43
|
+
t.id === trackId ? { ...t, ...changes } : t,
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function AudioTrackRow({
|
|
50
|
+
tracks,
|
|
51
|
+
laneIndex,
|
|
52
|
+
laneCount,
|
|
53
|
+
project,
|
|
54
|
+
onProjectChange,
|
|
55
|
+
onOverlayEdit,
|
|
56
|
+
selectedIds,
|
|
57
|
+
onSelectItem,
|
|
58
|
+
onInspect,
|
|
59
|
+
getWaveformChunks,
|
|
60
|
+
resolveFilePath,
|
|
61
|
+
}: AudioTrackRowProps) {
|
|
62
|
+
const {
|
|
63
|
+
totalDuration,
|
|
64
|
+
currentTime,
|
|
65
|
+
snapBoundaries,
|
|
66
|
+
scrollRef,
|
|
67
|
+
overlayDraggedRef,
|
|
68
|
+
zoomRef,
|
|
69
|
+
} = useTimelineContext()
|
|
70
|
+
|
|
71
|
+
const { beginDrag, beginResize } = useItemDragDrop({
|
|
72
|
+
totalDuration,
|
|
73
|
+
snapBoundaries,
|
|
74
|
+
scrollRef,
|
|
75
|
+
zoomRef,
|
|
76
|
+
draggedFlagRef: overlayDraggedRef,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
|
|
81
|
+
{/* Playhead line */}
|
|
82
|
+
<div
|
|
83
|
+
className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
|
|
84
|
+
style={{ left: `${pct(currentTime, totalDuration)}%` }}
|
|
85
|
+
/>
|
|
86
|
+
{tracks.map(track => (
|
|
87
|
+
<AudioTrackItem
|
|
88
|
+
key={track.id}
|
|
89
|
+
track={track}
|
|
90
|
+
laneIndex={laneIndex}
|
|
91
|
+
laneCount={laneCount}
|
|
92
|
+
project={project}
|
|
93
|
+
totalDuration={totalDuration}
|
|
94
|
+
selected={selectedIds.includes(track.id)}
|
|
95
|
+
selectedIds={selectedIds}
|
|
96
|
+
onSelectItem={onSelectItem}
|
|
97
|
+
onInspect={onInspect}
|
|
98
|
+
onProjectChange={onProjectChange}
|
|
99
|
+
onOverlayEdit={onOverlayEdit}
|
|
100
|
+
beginDrag={beginDrag}
|
|
101
|
+
beginResize={beginResize}
|
|
102
|
+
overlayDraggedRef={overlayDraggedRef}
|
|
103
|
+
getWaveformChunks={getWaveformChunks}
|
|
104
|
+
resolveFilePath={resolveFilePath}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
{/* Crossfade indicators — shown in the overlap zone between two tracks */}
|
|
108
|
+
{(() => {
|
|
109
|
+
const sorted = [...tracks].filter(t => !t.muted).sort((a, b) => a.start - b.start)
|
|
110
|
+
const indicators = []
|
|
111
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
112
|
+
const a = sorted[i], b = sorted[i + 1]
|
|
113
|
+
if (a.end > b.start) {
|
|
114
|
+
const overlapStart = b.start
|
|
115
|
+
const overlapEnd = Math.min(a.end, b.end)
|
|
116
|
+
indicators.push(
|
|
117
|
+
<div
|
|
118
|
+
key={`xfade-${a.id}-${b.id}`}
|
|
119
|
+
className="absolute top-0 bottom-0 pointer-events-none z-[5] flex items-center justify-center"
|
|
120
|
+
style={{
|
|
121
|
+
left: `${pct(overlapStart, totalDuration)}%`,
|
|
122
|
+
width: `${pct(overlapEnd - overlapStart, totalDuration)}%`,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{/* Crossfade background highlight */}
|
|
126
|
+
<div className="absolute inset-0 bg-amber-400/15 border-x border-amber-400/30" />
|
|
127
|
+
{/* Crossfade icon — two crossing lines */}
|
|
128
|
+
<svg width="16" height="16" viewBox="0 0 16 16" className="relative text-amber-300/70 drop-shadow-sm">
|
|
129
|
+
<line x1="2" y1="12" x2="14" y2="4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
130
|
+
<line x1="2" y1="4" x2="14" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
131
|
+
</svg>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return indicators
|
|
137
|
+
})()}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Single audio item within a lane ──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
interface AudioTrackItemProps {
|
|
145
|
+
track: AudioTrack
|
|
146
|
+
laneIndex: number
|
|
147
|
+
laneCount: number
|
|
148
|
+
project: Project
|
|
149
|
+
totalDuration: number
|
|
150
|
+
selected: boolean
|
|
151
|
+
selectedIds: string[]
|
|
152
|
+
onSelectItem: (id: string | null, additive: boolean) => void
|
|
153
|
+
onInspect?: (id: string) => void
|
|
154
|
+
onProjectChange?: (p: Project) => void
|
|
155
|
+
onOverlayEdit?: (p: Project) => void
|
|
156
|
+
beginDrag: ReturnType<typeof useItemDragDrop>['beginDrag']
|
|
157
|
+
beginResize: ReturnType<typeof useItemDragDrop>['beginResize']
|
|
158
|
+
overlayDraggedRef: React.RefObject<boolean>
|
|
159
|
+
getWaveformChunks?: GetWaveformChunks
|
|
160
|
+
resolveFilePath?: ResolveFilePath
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function AudioTrackItem({
|
|
164
|
+
track,
|
|
165
|
+
laneIndex,
|
|
166
|
+
laneCount: _laneCount,
|
|
167
|
+
project,
|
|
168
|
+
totalDuration,
|
|
169
|
+
selected,
|
|
170
|
+
selectedIds,
|
|
171
|
+
onSelectItem,
|
|
172
|
+
onInspect,
|
|
173
|
+
onProjectChange,
|
|
174
|
+
onOverlayEdit,
|
|
175
|
+
beginDrag,
|
|
176
|
+
beginResize,
|
|
177
|
+
overlayDraggedRef,
|
|
178
|
+
getWaveformChunks,
|
|
179
|
+
resolveFilePath,
|
|
180
|
+
}: AudioTrackItemProps) {
|
|
181
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
182
|
+
const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
183
|
+
useEffect(() => () => { if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current) }, [])
|
|
184
|
+
|
|
185
|
+
const left = pct(track.start, totalDuration)
|
|
186
|
+
const width = pct(track.end - track.start, totalDuration)
|
|
187
|
+
const label = track.label ?? track.src.split('/').pop() ?? 'audio'
|
|
188
|
+
|
|
189
|
+
// ── Drag to reposition (horizontal + cross-lane) ──
|
|
190
|
+
function handleDragStart(e: React.MouseEvent) {
|
|
191
|
+
if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
|
|
192
|
+
if (!onProjectChange) return
|
|
193
|
+
let lastUpdated = project
|
|
194
|
+
|
|
195
|
+
beginDrag(e, track as Draggable, {
|
|
196
|
+
onLivePreview: ({ item: moved, dy }: DragEventContext) => {
|
|
197
|
+
// Cross-lane: compute target lane from vertical drag.
|
|
198
|
+
// Positive dy = dragged down = higher lane index.
|
|
199
|
+
const laneDelta = Math.round(dy / LANE_HEIGHT_PX)
|
|
200
|
+
const targetLane = Math.max(0, laneIndex + laneDelta)
|
|
201
|
+
|
|
202
|
+
const next = updateAudioTrack(project, track.id, {
|
|
203
|
+
start: moved.start,
|
|
204
|
+
end: moved.end,
|
|
205
|
+
lane: targetLane,
|
|
206
|
+
})
|
|
207
|
+
lastUpdated = next
|
|
208
|
+
onProjectChange!(next)
|
|
209
|
+
},
|
|
210
|
+
onCommit: () => {
|
|
211
|
+
onOverlayEdit?.(lastUpdated)
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Edge trim ──
|
|
217
|
+
function handleResizeStart(e: React.MouseEvent, edge: 'start' | 'end') {
|
|
218
|
+
if (!onProjectChange) return
|
|
219
|
+
let lastUpdated = project
|
|
220
|
+
|
|
221
|
+
const origStart = track.start
|
|
222
|
+
const origEnd = track.end
|
|
223
|
+
const origInPoint = track.inPoint ?? 0
|
|
224
|
+
const origOutPoint = track.outPoint ?? (origInPoint + (origEnd - origStart))
|
|
225
|
+
const srcDur = track.sourceDuration ?? Infinity
|
|
226
|
+
const multiTargets = selectedIds.length > 1 && selectedIds.includes(track.id)
|
|
227
|
+
|
|
228
|
+
beginResize(e, track as Draggable, edge, {
|
|
229
|
+
onLivePreview: ({ item: resized }: DragEventContext) => {
|
|
230
|
+
let newInPoint = origInPoint
|
|
231
|
+
let newOutPoint = origOutPoint
|
|
232
|
+
|
|
233
|
+
if (edge === 'start') {
|
|
234
|
+
const dt = resized.start - origStart
|
|
235
|
+
newInPoint = Math.max(0, Math.min(origInPoint + dt, origOutPoint - 0.1))
|
|
236
|
+
} else {
|
|
237
|
+
const dt = resized.end - origEnd
|
|
238
|
+
newOutPoint = Math.max(origInPoint + 0.1, Math.min(origOutPoint + dt, srcDur))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let next = updateAudioTrack(project, track.id, {
|
|
242
|
+
start: resized.start,
|
|
243
|
+
end: resized.end,
|
|
244
|
+
inPoint: newInPoint,
|
|
245
|
+
outPoint: newOutPoint,
|
|
246
|
+
})
|
|
247
|
+
if (multiTargets) {
|
|
248
|
+
const dStart = edge === 'start' ? resized.start - origStart : 0
|
|
249
|
+
const dEnd = edge === 'end' ? resized.end - origEnd : 0
|
|
250
|
+
next = applyResizeDeltaToSelection(next, track.id, selectedIds, edge, { dStart, dEnd })
|
|
251
|
+
}
|
|
252
|
+
lastUpdated = next
|
|
253
|
+
onProjectChange!(next)
|
|
254
|
+
},
|
|
255
|
+
onCommit: () => {
|
|
256
|
+
onOverlayEdit?.(lastUpdated)
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Mute toggle ──
|
|
262
|
+
function handleToggleMute(e: React.MouseEvent) {
|
|
263
|
+
e.stopPropagation()
|
|
264
|
+
if (!onProjectChange) return
|
|
265
|
+
const newMuted = !track.muted
|
|
266
|
+
// Multi-selection: set every selected item to the new state. Solo: just this.
|
|
267
|
+
const targetIds = selectedIds.length > 1 && selectedIds.includes(track.id) ? selectedIds : [track.id]
|
|
268
|
+
const updated = applyMuteToSelection(project, targetIds, newMuted)
|
|
269
|
+
onProjectChange(updated)
|
|
270
|
+
onOverlayEdit?.(updated)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Delete (two-step confirm) ──
|
|
274
|
+
function handleDeleteClick(e: React.MouseEvent) {
|
|
275
|
+
e.stopPropagation()
|
|
276
|
+
if (confirmingDelete) {
|
|
277
|
+
if (!onProjectChange) return
|
|
278
|
+
const updated: Project = {
|
|
279
|
+
...project,
|
|
280
|
+
audio: {
|
|
281
|
+
...project.audio,
|
|
282
|
+
tracks: (project.audio?.tracks ?? []).filter(t => t.id !== track.id),
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
onProjectChange(updated)
|
|
286
|
+
onOverlayEdit?.(updated)
|
|
287
|
+
onSelectItem(null, false)
|
|
288
|
+
setConfirmingDelete(false)
|
|
289
|
+
} else {
|
|
290
|
+
setConfirmingDelete(true)
|
|
291
|
+
if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current)
|
|
292
|
+
deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 3000)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Click to select ──
|
|
297
|
+
function handleBarClick(e: React.MouseEvent) {
|
|
298
|
+
e.stopPropagation()
|
|
299
|
+
if (overlayDraggedRef.current) return
|
|
300
|
+
const additive = e.shiftKey || e.metaKey || e.ctrlKey
|
|
301
|
+
onSelectItem(track.id, additive)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Double-click to open inspector ──
|
|
305
|
+
function handleBarDoubleClick(e: React.MouseEvent) {
|
|
306
|
+
e.stopPropagation()
|
|
307
|
+
if (overlayDraggedRef.current) return
|
|
308
|
+
onInspect?.(track.id)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div
|
|
313
|
+
className={`absolute top-1 bottom-1 rounded cursor-grab active:cursor-grabbing flex items-center overflow-hidden
|
|
314
|
+
${track.muted ? 'bg-white/10' : 'bg-emerald-500/40 border border-emerald-500/60'}
|
|
315
|
+
${selected ? 'ring-1 ring-inset ring-emerald-300/80' : ''}`}
|
|
316
|
+
style={{ left: `${left}%`, width: `${width}%` }}
|
|
317
|
+
title={label}
|
|
318
|
+
onClick={handleBarClick}
|
|
319
|
+
onDoubleClick={handleBarDoubleClick}
|
|
320
|
+
onMouseDown={handleDragStart}
|
|
321
|
+
>
|
|
322
|
+
{/* Waveform layer */}
|
|
323
|
+
<AudioWaveformLayer
|
|
324
|
+
track={track}
|
|
325
|
+
projectId={project.id}
|
|
326
|
+
getWaveformChunks={getWaveformChunks}
|
|
327
|
+
resolveFilePath={resolveFilePath}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
{/* Fade-in gradient */}
|
|
331
|
+
{(track.fadeIn ?? 0) > 0 && (
|
|
332
|
+
<div
|
|
333
|
+
className="absolute top-0 bottom-0 left-0 pointer-events-none z-[2]"
|
|
334
|
+
style={{
|
|
335
|
+
width: `${Math.min(100, ((track.fadeIn ?? 0) / (track.end - track.start)) * 100)}%`,
|
|
336
|
+
background: 'linear-gradient(to right, rgba(0,0,0,0.6), transparent)',
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
{/* Fade-out gradient */}
|
|
341
|
+
{(track.fadeOut ?? 0) > 0 && (
|
|
342
|
+
<div
|
|
343
|
+
className="absolute top-0 bottom-0 right-0 pointer-events-none z-[2]"
|
|
344
|
+
style={{
|
|
345
|
+
width: `${Math.min(100, ((track.fadeOut ?? 0) / (track.end - track.start)) * 100)}%`,
|
|
346
|
+
background: 'linear-gradient(to left, rgba(0,0,0,0.6), transparent)',
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* Left resize handle */}
|
|
352
|
+
<div
|
|
353
|
+
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
|
|
354
|
+
onMouseDown={(e) => handleResizeStart(e, 'start')}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
{/* Mute toggle */}
|
|
358
|
+
<button
|
|
359
|
+
className="shrink-0 ml-2 z-10 cursor-pointer"
|
|
360
|
+
onClick={handleToggleMute}
|
|
361
|
+
title={track.muted ? 'Unmute' : 'Mute'}
|
|
362
|
+
>
|
|
363
|
+
{track.muted
|
|
364
|
+
? <VolumeX className="w-3.5 h-3.5 text-white/30" />
|
|
365
|
+
: <Volume2 className="w-3.5 h-3.5 text-emerald-200" />}
|
|
366
|
+
</button>
|
|
367
|
+
|
|
368
|
+
{/* Track type label */}
|
|
369
|
+
<span className="text-[10px] text-emerald-200 truncate flex-1 min-w-0 ml-1.5 pointer-events-none z-[1] drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">
|
|
370
|
+
{track.type === 'voiceover' ? 'Voiceover' : track.type === 'music' ? 'Music' : label}
|
|
371
|
+
</span>
|
|
372
|
+
|
|
373
|
+
{/* Info button when selected */}
|
|
374
|
+
{selected && onInspect && (
|
|
375
|
+
<button
|
|
376
|
+
className="shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 text-emerald-200"
|
|
377
|
+
onClick={(e) => { e.stopPropagation(); onInspect(track.id) }}
|
|
378
|
+
title="Inspect audio track"
|
|
379
|
+
>
|
|
380
|
+
<Info size={12} />
|
|
381
|
+
</button>
|
|
382
|
+
)}
|
|
383
|
+
|
|
384
|
+
{/* Delete button when selected (two-step confirm) */}
|
|
385
|
+
{selected && (
|
|
386
|
+
<button
|
|
387
|
+
className={`shrink-0 ml-1 mr-2 z-10 cursor-pointer transition-colors ${
|
|
388
|
+
confirmingDelete
|
|
389
|
+
? 'opacity-100 text-red-400'
|
|
390
|
+
: 'opacity-60 hover:opacity-100 text-emerald-200'
|
|
391
|
+
}`}
|
|
392
|
+
onClick={handleDeleteClick}
|
|
393
|
+
title={confirmingDelete ? 'Click again to confirm delete' : 'Delete audio track'}
|
|
394
|
+
>
|
|
395
|
+
<Trash2 size={12} />
|
|
396
|
+
</button>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Right resize handle */}
|
|
400
|
+
<div
|
|
401
|
+
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
|
|
402
|
+
onMouseDown={(e) => handleResizeStart(e, 'end')}
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { WaveformChunk } from '../../types'
|
|
3
|
+
import type { AudioTrack } from '../../schema'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetches the waveform chunks for one audio track. Threaded from the timeline
|
|
7
|
+
* root (VideoEditor supplies `adapter.getWaveformChunks` in V4). The package is
|
|
8
|
+
* host-agnostic: it takes the fn as a prop rather than reaching for an adapter.
|
|
9
|
+
*/
|
|
10
|
+
export type GetWaveformChunks = (
|
|
11
|
+
projectId: string,
|
|
12
|
+
trackId: string,
|
|
13
|
+
trackSrc: string,
|
|
14
|
+
chunkDurationS?: number,
|
|
15
|
+
) => Promise<WaveformChunk[]>
|
|
16
|
+
|
|
17
|
+
/** Resolves a host-internal chunk path into a displayable URL (e.g. Montaj's
|
|
18
|
+
* `/api/files?path=…`). Defaults to identity when the host omits it. */
|
|
19
|
+
export type ResolveFilePath = (path: string) => string
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
track: AudioTrack
|
|
23
|
+
projectId: string
|
|
24
|
+
/** When absent/undefined, no waveform is rendered (graceful fallback). */
|
|
25
|
+
getWaveformChunks?: GetWaveformChunks
|
|
26
|
+
resolveFilePath?: ResolveFilePath
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function LoadingBar() {
|
|
30
|
+
return (
|
|
31
|
+
<div className="relative w-full h-full overflow-hidden rounded bg-emerald-500/20">
|
|
32
|
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function PlainFallback() {
|
|
38
|
+
return <div className="w-full h-full rounded bg-emerald-500/40 border border-emerald-500/60" />
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function AudioWaveformLayer({ track, projectId, getWaveformChunks, resolveFilePath }: Props) {
|
|
42
|
+
const [chunks, setChunks] = useState<WaveformChunk[] | null>(null)
|
|
43
|
+
const [error, setError] = useState(false)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!getWaveformChunks) return
|
|
47
|
+
let cancelled = false
|
|
48
|
+
setChunks(null)
|
|
49
|
+
setError(false)
|
|
50
|
+
|
|
51
|
+
getWaveformChunks(projectId, track.id, track.src)
|
|
52
|
+
.then((result) => {
|
|
53
|
+
if (!cancelled) setChunks(result)
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
if (!cancelled) setError(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true
|
|
61
|
+
}
|
|
62
|
+
}, [track.id, track.src, projectId, getWaveformChunks])
|
|
63
|
+
|
|
64
|
+
const isMuted = track.muted ?? false
|
|
65
|
+
|
|
66
|
+
// No fetcher supplied → render nothing (graceful: the bar shows its base color).
|
|
67
|
+
if (!getWaveformChunks) return null
|
|
68
|
+
|
|
69
|
+
const resolvePath = resolveFilePath ?? ((p: string) => p)
|
|
70
|
+
|
|
71
|
+
const content = (() => {
|
|
72
|
+
if (error) return <PlainFallback />
|
|
73
|
+
if (!chunks) return <LoadingBar />
|
|
74
|
+
|
|
75
|
+
const sourceDur = track.sourceDuration ?? (track.end - track.start)
|
|
76
|
+
const inPt = track.inPoint ?? 0
|
|
77
|
+
const outPt = track.outPoint ?? sourceDur
|
|
78
|
+
const visibleSpan = outPt - inPt
|
|
79
|
+
|
|
80
|
+
if (visibleSpan <= 0) return <PlainFallback />
|
|
81
|
+
|
|
82
|
+
// Filter to chunks that overlap [inPt, outPt]
|
|
83
|
+
const visible = chunks.filter((c) => c.end > inPt && c.start < outPt)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="relative w-full h-full overflow-hidden rounded">
|
|
87
|
+
{visible.map((chunk) => {
|
|
88
|
+
const leftPct = ((chunk.start - inPt) / visibleSpan) * 100
|
|
89
|
+
const widthPct = ((chunk.end - chunk.start) / visibleSpan) * 100
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<img
|
|
93
|
+
key={chunk.path}
|
|
94
|
+
src={resolvePath(chunk.path)}
|
|
95
|
+
alt=""
|
|
96
|
+
draggable={false}
|
|
97
|
+
className="absolute top-0 h-full object-fill"
|
|
98
|
+
style={{
|
|
99
|
+
left: `${leftPct}%`,
|
|
100
|
+
width: `${widthPct}%`,
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
})()
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className="absolute inset-0 pointer-events-none"
|
|
112
|
+
style={{ opacity: isMuted ? 0.3 : 1 }}
|
|
113
|
+
>
|
|
114
|
+
{content}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useRef, type KeyboardEvent } from 'react'
|
|
2
|
+
import type { CaptionSegment } from '../../schema'
|
|
3
|
+
|
|
4
|
+
export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit: (text: string) => void }) {
|
|
5
|
+
const spanRef = useRef<HTMLSpanElement>(null)
|
|
6
|
+
|
|
7
|
+
function handleBlur() {
|
|
8
|
+
const text = spanRef.current?.textContent?.trim() ?? ''
|
|
9
|
+
if (!text) { if (spanRef.current) spanRef.current.textContent = seg.text; return }
|
|
10
|
+
if (text !== seg.text) onEdit(text)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function handleKeyDown(e: KeyboardEvent<HTMLSpanElement>) {
|
|
14
|
+
if (e.key === 'Enter') { e.preventDefault(); spanRef.current?.blur() }
|
|
15
|
+
if (e.key === 'Escape') { if (spanRef.current) spanRef.current.textContent = seg.text; spanRef.current?.blur() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<span
|
|
20
|
+
ref={spanRef}
|
|
21
|
+
contentEditable
|
|
22
|
+
suppressContentEditableWarning
|
|
23
|
+
onBlur={handleBlur}
|
|
24
|
+
onKeyDown={handleKeyDown}
|
|
25
|
+
className="cursor-text rounded px-0.5 hover:bg-white/5 focus:bg-white/10 focus:outline-none focus:ring-1 focus:ring-purple-500/40"
|
|
26
|
+
>
|
|
27
|
+
{seg.text}
|
|
28
|
+
</span>
|
|
29
|
+
)
|
|
30
|
+
}
|