@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,184 @@
|
|
|
1
|
+
import { formatTime, pct, ratioFromClientX } from './utils'
|
|
2
|
+
import { useTimelineContext } from './TimelineContext'
|
|
3
|
+
|
|
4
|
+
interface ScrubberProps {
|
|
5
|
+
hoverPct: number | null
|
|
6
|
+
draggingPlayhead: boolean
|
|
7
|
+
setDraggingPlayhead: (v: boolean) => void
|
|
8
|
+
keyNavTime: number | null
|
|
9
|
+
onSplit?: (at: number) => void
|
|
10
|
+
onCut?: (cut: { start: number; end: number }) => void
|
|
11
|
+
cutButtonLabel: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Scrubber({
|
|
15
|
+
hoverPct,
|
|
16
|
+
draggingPlayhead,
|
|
17
|
+
setDraggingPlayhead,
|
|
18
|
+
keyNavTime,
|
|
19
|
+
onSplit,
|
|
20
|
+
onCut,
|
|
21
|
+
cutButtonLabel,
|
|
22
|
+
}: ScrubberProps) {
|
|
23
|
+
const { currentTime, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, onTimeUpdate, scrubberRef, selection } = useTimelineContext()
|
|
24
|
+
|
|
25
|
+
function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
26
|
+
e.stopPropagation()
|
|
27
|
+
if (totalDuration === 0) return
|
|
28
|
+
onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
32
|
+
if (totalDuration === 0) return
|
|
33
|
+
e.preventDefault()
|
|
34
|
+
const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
35
|
+
setMarkers(([a, b]) => {
|
|
36
|
+
if (a === null) return [t, null] // place first marker
|
|
37
|
+
if (b === null) return [a, t] // place second → selection complete
|
|
38
|
+
return [t, null] // reset: start fresh with new first marker
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{/* ── Scrubber ── */}
|
|
45
|
+
<div
|
|
46
|
+
ref={scrubberRef}
|
|
47
|
+
className={`relative h-4 rounded-full bg-gray-200 dark:bg-gray-800 group ${markers[0] !== null && markers[1] === null ? 'cursor-cell' : 'cursor-crosshair'}`}
|
|
48
|
+
onClick={handleScrubClick}
|
|
49
|
+
onDoubleClick={handleScrubDoubleClick}
|
|
50
|
+
>
|
|
51
|
+
{/* Elapsed fill */}
|
|
52
|
+
<div
|
|
53
|
+
className="absolute inset-y-0 left-0 rounded-full bg-gray-400 dark:bg-gray-600 pointer-events-none"
|
|
54
|
+
style={{ width: `${pct(currentTime, totalDuration)}%` }}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
{/* Selection range fill (both markers placed) */}
|
|
58
|
+
{selection && (
|
|
59
|
+
<div
|
|
60
|
+
className="absolute inset-y-0 bg-amber-500/25 pointer-events-none"
|
|
61
|
+
style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{/* Marker A */}
|
|
66
|
+
{markers[0] !== null && (
|
|
67
|
+
<div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[0], totalDuration)}%` }}>
|
|
68
|
+
<div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{/* Marker B */}
|
|
73
|
+
{markers[1] !== null && (
|
|
74
|
+
<div className="absolute top-0 bottom-0 w-px bg-amber-400 pointer-events-none" style={{ left: `${pct(markers[1], totalDuration)}%` }}>
|
|
75
|
+
<div className="absolute -top-0.5 -translate-x-1/2 w-2 h-2 bg-amber-400 rotate-45" />
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{/* Playhead handle */}
|
|
80
|
+
<div
|
|
81
|
+
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-red-500 ring-2 ring-red-500/30 transition-transform group-hover:scale-125 ${draggingPlayhead ? 'cursor-grabbing scale-125' : 'cursor-grab'}`}
|
|
82
|
+
style={{ left: `${pct(currentTime, totalDuration)}%` }}
|
|
83
|
+
onMouseDown={(e) => {
|
|
84
|
+
e.stopPropagation()
|
|
85
|
+
if (totalDuration === 0) return
|
|
86
|
+
setDraggingPlayhead(true)
|
|
87
|
+
const boundaries = snapBoundaries
|
|
88
|
+
let snappedTo: number | null = null // which boundary we're currently locked to
|
|
89
|
+
function onMove(me: MouseEvent) {
|
|
90
|
+
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
91
|
+
if (!rect) return
|
|
92
|
+
const attractPx = 18 // cursor enters this range → snaps in
|
|
93
|
+
const releasePx = 28 // cursor must leave this range → breaks free
|
|
94
|
+
const attract = (attractPx / rect.width) * totalDuration
|
|
95
|
+
const release = (releasePx / rect.width) * totalDuration
|
|
96
|
+
const rawT = ratioFromClientX(me.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
97
|
+
// Already snapped — hold until cursor escapes release radius
|
|
98
|
+
if (snappedTo !== null) {
|
|
99
|
+
if (Math.abs(rawT - snappedTo) < release) { onTimeUpdate(snappedTo); return }
|
|
100
|
+
snappedTo = null
|
|
101
|
+
}
|
|
102
|
+
// Scan for attraction
|
|
103
|
+
for (const b of boundaries) {
|
|
104
|
+
if (Math.abs(rawT - b) < attract) { snappedTo = b; onTimeUpdate(b); return }
|
|
105
|
+
}
|
|
106
|
+
onTimeUpdate(rawT)
|
|
107
|
+
}
|
|
108
|
+
function onUp() {
|
|
109
|
+
setDraggingPlayhead(false)
|
|
110
|
+
document.removeEventListener('mousemove', onMove)
|
|
111
|
+
document.removeEventListener('mouseup', onUp)
|
|
112
|
+
}
|
|
113
|
+
document.addEventListener('mousemove', onMove)
|
|
114
|
+
document.addEventListener('mouseup', onUp)
|
|
115
|
+
}}
|
|
116
|
+
onDoubleClick={(e) => {
|
|
117
|
+
e.stopPropagation()
|
|
118
|
+
handleScrubDoubleClick(e as React.MouseEvent<HTMLDivElement>)
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{keyNavTime !== null && (
|
|
122
|
+
<div className="absolute -top-7 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20">
|
|
123
|
+
{formatTime(keyNavTime)}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Time tooltip */}
|
|
129
|
+
{hoverPct !== null && totalDuration > 0 && (
|
|
130
|
+
<div
|
|
131
|
+
className="absolute -top-7 -translate-x-1/2 bg-gray-800 border border-gray-700 text-white text-[10px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-20"
|
|
132
|
+
style={{ left: `${hoverPct}%` }}
|
|
133
|
+
>
|
|
134
|
+
{formatTime((hoverPct / 100) * totalDuration)}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Time readout + marker / selection range */}
|
|
140
|
+
<div className="flex items-center justify-between text-[10px] font-mono text-gray-600 -mt-1">
|
|
141
|
+
<span>{formatTime(currentTime)}</span>
|
|
142
|
+
{markers[0] !== null && markers[1] === null && (
|
|
143
|
+
<span className="flex items-center gap-2 text-amber-400/80">
|
|
144
|
+
{onSplit && (
|
|
145
|
+
<button
|
|
146
|
+
className="px-2 py-0.5 rounded bg-amber-500/80 hover:bg-amber-400 text-black text-[10px] font-medium transition-colors"
|
|
147
|
+
onClick={(e) => { e.stopPropagation(); onSplit(markers[0]!); setMarkers([null, null]) }}
|
|
148
|
+
>
|
|
149
|
+
Split
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
{formatTime(markers[0])} — double-click to set end
|
|
153
|
+
<button
|
|
154
|
+
className="text-gray-600 hover:text-gray-400"
|
|
155
|
+
onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
|
|
156
|
+
>✕</button>
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
{selection && (
|
|
160
|
+
<span className="flex items-center gap-2 text-amber-400">
|
|
161
|
+
<button
|
|
162
|
+
className="text-gray-600 hover:text-gray-400"
|
|
163
|
+
onClick={(e) => { e.stopPropagation(); setMarkers([null, null]) }}
|
|
164
|
+
>✕</button>
|
|
165
|
+
{formatTime(selection.start)} – {formatTime(selection.end)}
|
|
166
|
+
{onCut && (
|
|
167
|
+
<button
|
|
168
|
+
className="px-2 py-0.5 rounded bg-red-600/80 hover:bg-red-500 text-white text-[10px] font-medium transition-colors"
|
|
169
|
+
onClick={(e) => {
|
|
170
|
+
e.stopPropagation()
|
|
171
|
+
onCut(selection)
|
|
172
|
+
setMarkers([null, null])
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{cutButtonLabel}
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
<span>{formatTime(contentDuration)}</span>
|
|
181
|
+
</div>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import AudioTrackRow from './AudioTrackRow'
|
|
3
|
+
import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
|
|
4
|
+
import type { Project } from '../../types'
|
|
5
|
+
import { collapseGaps } from '../cuts'
|
|
6
|
+
import { ratioFromClientX } from './utils'
|
|
7
|
+
import { useTimelineZoom } from './useTimelineZoom'
|
|
8
|
+
import { TimelineContext, type TimelineContextValue } from './TimelineContext'
|
|
9
|
+
import Scrubber from './Scrubber'
|
|
10
|
+
import TranscriptPanel from './TranscriptPanel'
|
|
11
|
+
import TranscriptModal from './TranscriptModal'
|
|
12
|
+
import VisualTrackRow from './VisualTrackRow'
|
|
13
|
+
import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
14
|
+
|
|
15
|
+
interface TimelineProps {
|
|
16
|
+
project: Project
|
|
17
|
+
currentTime: number
|
|
18
|
+
onTimeUpdate: (t: number) => void
|
|
19
|
+
onProjectChange?: (p: Project) => void
|
|
20
|
+
onCaptionEdit?: (p: Project) => void
|
|
21
|
+
onOverlayEdit?: (p: Project) => void
|
|
22
|
+
/** Unified selection — covers both visual items and audio tracks. */
|
|
23
|
+
selectedIds?: string[]
|
|
24
|
+
onSelectIds?: (ids: string[]) => void
|
|
25
|
+
onSplit?: (at: number) => void
|
|
26
|
+
onCut?: (cut: { start: number; end: number }) => void
|
|
27
|
+
onInspectClip?: (id: string) => void
|
|
28
|
+
onInspectAudio?: (id: string) => void
|
|
29
|
+
onSaveProject?: (p: Project) => Promise<unknown>
|
|
30
|
+
rippleMode?: boolean
|
|
31
|
+
/** Audio-waveform fetcher, threaded to every AudioWaveformLayer. In V4 the
|
|
32
|
+
* VideoEditor wires this from `adapter.getWaveformChunks`. Absent → no
|
|
33
|
+
* waveforms render (graceful). */
|
|
34
|
+
getWaveformChunks?: GetWaveformChunks
|
|
35
|
+
/** Resolves a waveform chunk's host path into a displayable URL. */
|
|
36
|
+
resolveFilePath?: ResolveFilePath
|
|
37
|
+
/** Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
|
|
38
|
+
* ai_video projects). The package never reads `projectType`. */
|
|
39
|
+
regenEnabled?: boolean
|
|
40
|
+
/** Host-computed predicate driving the per-clip "queued" badge (Montaj:
|
|
41
|
+
* project.regenQueue membership). The package never reads `regenQueue`. */
|
|
42
|
+
isClipQueued?: (itemId: string) => boolean
|
|
43
|
+
/** Render-prop seam for the Montaj-specific subcut-regeneration tool. The
|
|
44
|
+
* timeline owns the open/close trigger (the per-clip Scissors button toggles
|
|
45
|
+
* `subcutClipId`); when a clip is active it calls this with the clip id and a
|
|
46
|
+
* close callback. The host closure supplies the full Montaj project,
|
|
47
|
+
* regenQueue, storyboard, and onSave — none of which the package types know.
|
|
48
|
+
* Absent → the subcut tool is simply not rendered. */
|
|
49
|
+
renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen }: TimelineProps) {
|
|
54
|
+
const primarySelectedId = selectedIds[0] ?? null
|
|
55
|
+
|
|
56
|
+
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
57
|
+
function handleSelectItem(id: string | null, additive: boolean) {
|
|
58
|
+
if (!onSelectIds) return
|
|
59
|
+
if (id === null) { onSelectIds([]); return }
|
|
60
|
+
onSelectIds(toggleSelection(selectedIds, id, additive))
|
|
61
|
+
}
|
|
62
|
+
const allTracks = project.tracks ?? []
|
|
63
|
+
const captionTrack = project.captions
|
|
64
|
+
const audioTracks = project.audio?.tracks ?? []
|
|
65
|
+
const snapBoundaries = [...new Set([
|
|
66
|
+
...allTracks.flat().flatMap(c => [c.start, c.end]),
|
|
67
|
+
...audioTracks.flatMap(t => [t.start, t.end]),
|
|
68
|
+
])]
|
|
69
|
+
const contentDuration = Math.max(
|
|
70
|
+
allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
71
|
+
audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
|
|
72
|
+
)
|
|
73
|
+
// Add 20% padding beyond content so the rightmost item can always be
|
|
74
|
+
// dragged or resized further out. Minimum 5s headroom.
|
|
75
|
+
const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
|
|
76
|
+
|
|
77
|
+
// Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
|
|
78
|
+
// and fade-in on the later, each equal to the overlap duration.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!audioTracks.length || !onProjectChange) return
|
|
81
|
+
const sorted = [...audioTracks].sort((a, b) => a.start - b.start)
|
|
82
|
+
let changed = false
|
|
83
|
+
const updated = sorted.map(t => ({ ...t }))
|
|
84
|
+
|
|
85
|
+
// We only auto-set fades where overlap exists
|
|
86
|
+
for (let i = 0; i < updated.length - 1; i++) {
|
|
87
|
+
const a = updated[i]
|
|
88
|
+
const b = updated[i + 1]
|
|
89
|
+
if (a.end > b.start && !a.muted && !b.muted) {
|
|
90
|
+
// Overlap detected
|
|
91
|
+
const overlap = Math.min(a.end - b.start, a.end - a.start, b.end - b.start)
|
|
92
|
+
if ((a.fadeOut ?? 0) !== overlap) {
|
|
93
|
+
a.fadeOut = Math.round(overlap * 10) / 10 // round to 0.1s
|
|
94
|
+
changed = true
|
|
95
|
+
}
|
|
96
|
+
if ((b.fadeIn ?? 0) !== overlap) {
|
|
97
|
+
b.fadeIn = Math.round(overlap * 10) / 10
|
|
98
|
+
changed = true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (changed) {
|
|
104
|
+
const trackMap = new Map(updated.map(t => [t.id, t]))
|
|
105
|
+
const nextProject: typeof project = {
|
|
106
|
+
...project,
|
|
107
|
+
audio: {
|
|
108
|
+
...project.audio,
|
|
109
|
+
tracks: (project.audio?.tracks ?? []).map(t => trackMap.get(t.id) ?? t),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
onProjectChange(nextProject)
|
|
113
|
+
}
|
|
114
|
+
// Intentionally keyed on a stable digest of audio-track timing/mute rather
|
|
115
|
+
// than the array identity, so the crossfade pass only re-runs on real edits.
|
|
116
|
+
}, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}`).join('|')])
|
|
117
|
+
|
|
118
|
+
const [hoverPct, setHoverPct] = useState<number | null>(null)
|
|
119
|
+
const [draggingPlayhead, setDraggingPlayhead] = useState(false)
|
|
120
|
+
const [markers, setMarkers] = useState<[number | null, number | null]>([null, null])
|
|
121
|
+
const [transcriptModalOpen, setTranscriptModalOpen] = useState(false)
|
|
122
|
+
|
|
123
|
+
const scrubberRef = useRef<HTMLDivElement>(null)
|
|
124
|
+
const overlayDraggedRef = useRef(false)
|
|
125
|
+
const [keyNavTime, setKeyNavTime] = useState<number | null>(null)
|
|
126
|
+
const keyNavTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
127
|
+
|
|
128
|
+
const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
|
|
129
|
+
|
|
130
|
+
const { zoom, zoomRef, scrollRef, zoomTo, handleTimelineWheel } = useTimelineZoom(totalDuration)
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (totalDuration === 0) return
|
|
134
|
+
const fps = project.settings?.fps ?? 30
|
|
135
|
+
const frame = 1 / fps
|
|
136
|
+
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
137
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
138
|
+
if (e.key === 'Escape') { setMarkers([null, null]); return }
|
|
139
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
const step = e.shiftKey ? 1 : frame
|
|
142
|
+
const dir = e.key === 'ArrowRight' ? 1 : -1
|
|
143
|
+
const next = Math.max(0, Math.min(totalDuration, currentTime + dir * step))
|
|
144
|
+
onTimeUpdate(next)
|
|
145
|
+
setKeyNavTime(next)
|
|
146
|
+
if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
|
|
147
|
+
keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
|
|
148
|
+
}
|
|
149
|
+
document.addEventListener('keydown', onKey)
|
|
150
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
151
|
+
}, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps])
|
|
152
|
+
|
|
153
|
+
// Derive selection from two placed markers
|
|
154
|
+
const selection = markers[0] !== null && markers[1] !== null
|
|
155
|
+
? { start: Math.min(markers[0], markers[1]), end: Math.max(markers[0], markers[1]) }
|
|
156
|
+
: null
|
|
157
|
+
|
|
158
|
+
const ctx = useMemo<TimelineContextValue>(() => ({
|
|
159
|
+
totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
160
|
+
overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection,
|
|
161
|
+
}), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
162
|
+
overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection])
|
|
163
|
+
|
|
164
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
165
|
+
if ((e.target as HTMLElement).isContentEditable) return
|
|
166
|
+
|
|
167
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
|
168
|
+
e.preventDefault()
|
|
169
|
+
if (!onProjectChange) return
|
|
170
|
+
let updated = deleteSelection(project, selectedIds)
|
|
171
|
+
if (rippleMode) updated = collapseGaps(updated)
|
|
172
|
+
onProjectChange(updated)
|
|
173
|
+
onOverlayEdit?.(updated)
|
|
174
|
+
onSelectIds?.([])
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (e.key !== 'Enter' || totalDuration === 0) return
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
setMarkers(([a, b]) => {
|
|
181
|
+
if (a === null) return [currentTime, null]
|
|
182
|
+
if (b === null) return [a, currentTime]
|
|
183
|
+
return [currentTime, null]
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function handleContainerClick(e: React.MouseEvent) {
|
|
188
|
+
if ((e.target as HTMLElement).closest('button, input, [contenteditable]')) return
|
|
189
|
+
if (totalDuration === 0) return
|
|
190
|
+
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
191
|
+
if (!rect) return
|
|
192
|
+
const clickedTime = ratioFromClientX(e.clientX, rect) * totalDuration
|
|
193
|
+
const snapThreshold = (8 / rect.width) * totalDuration
|
|
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 cutButtonLabel = primarySelectedId
|
|
202
|
+
? `Cut ${allTracks.flat().find(i => i.id === primarySelectedId)?.type ?? 'item'}`
|
|
203
|
+
: 'Cut primary'
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<TimelineContext.Provider value={ctx}>
|
|
207
|
+
<div
|
|
208
|
+
className="flex flex-col gap-2 px-3 py-3 select-none outline-none"
|
|
209
|
+
tabIndex={0}
|
|
210
|
+
onKeyDown={handleKeyDown}
|
|
211
|
+
onMouseMove={(e) => {
|
|
212
|
+
const rect = scrubberRef.current?.getBoundingClientRect()
|
|
213
|
+
if (rect) setHoverPct(ratioFromClientX(e.clientX, rect) * 100)
|
|
214
|
+
}}
|
|
215
|
+
onMouseLeave={() => setHoverPct(null)}
|
|
216
|
+
onClick={handleContainerClick}
|
|
217
|
+
>
|
|
218
|
+
|
|
219
|
+
{/* Zoom controls */}
|
|
220
|
+
{totalDuration > 0 && (
|
|
221
|
+
<div className="flex items-center justify-end gap-0.5 -mb-1">
|
|
222
|
+
<button
|
|
223
|
+
className="text-[11px] leading-none text-gray-500 hover:text-gray-300 w-5 h-5 flex items-center justify-center rounded hover:bg-gray-800 transition-colors"
|
|
224
|
+
title="Zoom out"
|
|
225
|
+
onClick={(e) => { e.stopPropagation(); zoomTo(zoomRef.current - 1) }}
|
|
226
|
+
>−</button>
|
|
227
|
+
<span className="text-[10px] font-mono text-gray-500 w-7 text-center tabular-nums select-none">{zoom}×</span>
|
|
228
|
+
<button
|
|
229
|
+
className="text-[11px] leading-none text-gray-500 hover:text-gray-300 w-5 h-5 flex items-center justify-center rounded hover:bg-gray-800 transition-colors"
|
|
230
|
+
title="Zoom in"
|
|
231
|
+
onClick={(e) => { e.stopPropagation(); zoomTo(zoomRef.current + 1) }}
|
|
232
|
+
>+</button>
|
|
233
|
+
{zoom > 1 && (
|
|
234
|
+
<button
|
|
235
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1.5 h-5 rounded hover:bg-gray-800 transition-colors ml-0.5"
|
|
236
|
+
title="Fit to view"
|
|
237
|
+
onClick={(e) => { e.stopPropagation(); zoomTo(1) }}
|
|
238
|
+
>fit</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Scroll container for zoomed tracks */}
|
|
244
|
+
<div ref={scrollRef} className="overflow-x-auto" onWheel={handleTimelineWheel}>
|
|
245
|
+
<div style={{ width: zoom > 1 ? `${zoom * 100}%` : '100%' }} className="min-w-full">
|
|
246
|
+
|
|
247
|
+
{/* Scrubber + tracks wrapped in a relative container so the hover indicator spans the full height */}
|
|
248
|
+
<div className="relative flex flex-col gap-2">
|
|
249
|
+
{hoverPct !== null && totalDuration > 0 && (
|
|
250
|
+
<div
|
|
251
|
+
className="absolute inset-y-0 w-px bg-yellow-400/80 pointer-events-none z-20"
|
|
252
|
+
style={{ left: `${hoverPct}%` }}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
<Scrubber
|
|
257
|
+
hoverPct={hoverPct}
|
|
258
|
+
draggingPlayhead={draggingPlayhead}
|
|
259
|
+
setDraggingPlayhead={setDraggingPlayhead}
|
|
260
|
+
keyNavTime={keyNavTime}
|
|
261
|
+
onSplit={onSplit}
|
|
262
|
+
onCut={onCut}
|
|
263
|
+
cutButtonLabel={cutButtonLabel}
|
|
264
|
+
/>
|
|
265
|
+
|
|
266
|
+
{/* ── Tracks ── */}
|
|
267
|
+
<div className="flex flex-col gap-1">
|
|
268
|
+
{project.renderMode === 'ffmpeg-drawtext' && (
|
|
269
|
+
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-[10px] text-amber-400/70 select-none">
|
|
270
|
+
<span>⚡</span>
|
|
271
|
+
<span>ffmpeg render — overlays are preview only, final text is burned by ffmpeg</span>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
{[...allTracks].reverse().map((trackItems, reversedIdx) => {
|
|
275
|
+
const trackIdx = allTracks.length - 1 - reversedIdx
|
|
276
|
+
return (
|
|
277
|
+
<VisualTrackRow
|
|
278
|
+
key={trackIdx}
|
|
279
|
+
trackItems={trackItems}
|
|
280
|
+
trackIdx={trackIdx}
|
|
281
|
+
project={project}
|
|
282
|
+
selectedIds={selectedIds}
|
|
283
|
+
rippleMode={rippleMode}
|
|
284
|
+
onProjectChange={onProjectChange}
|
|
285
|
+
onOverlayEdit={onOverlayEdit}
|
|
286
|
+
onSelectItem={handleSelectItem}
|
|
287
|
+
onInspectClip={onInspectClip}
|
|
288
|
+
subcutClipId={subcutClipId}
|
|
289
|
+
setSubcutClipId={setSubcutClipId}
|
|
290
|
+
regenEnabled={regenEnabled}
|
|
291
|
+
isClipQueued={isClipQueued}
|
|
292
|
+
/>
|
|
293
|
+
)
|
|
294
|
+
})}
|
|
295
|
+
|
|
296
|
+
{/* Audio tracks — grouped by lane */}
|
|
297
|
+
{(() => {
|
|
298
|
+
// Group audio tracks by lane. Tracks without a lane get auto-assigned.
|
|
299
|
+
const laneMap = new Map<number, typeof audioTracks>()
|
|
300
|
+
let nextAutoLane = 0
|
|
301
|
+
for (const t of audioTracks) {
|
|
302
|
+
if (t.lane != null && t.lane >= nextAutoLane) nextAutoLane = t.lane + 1
|
|
303
|
+
}
|
|
304
|
+
for (const t of audioTracks) {
|
|
305
|
+
const lane = t.lane ?? nextAutoLane++
|
|
306
|
+
if (!laneMap.has(lane)) laneMap.set(lane, [])
|
|
307
|
+
laneMap.get(lane)!.push(t)
|
|
308
|
+
}
|
|
309
|
+
const lanes = [...laneMap.entries()].sort((a, b) => a[0] - b[0])
|
|
310
|
+
|
|
311
|
+
return lanes.map(([laneIdx, laneTracks]) => (
|
|
312
|
+
<AudioTrackRow
|
|
313
|
+
key={`audio-lane-${laneIdx}`}
|
|
314
|
+
tracks={laneTracks}
|
|
315
|
+
laneIndex={laneIdx}
|
|
316
|
+
laneCount={lanes.length}
|
|
317
|
+
project={project}
|
|
318
|
+
onProjectChange={onProjectChange}
|
|
319
|
+
onOverlayEdit={onOverlayEdit}
|
|
320
|
+
selectedIds={selectedIds}
|
|
321
|
+
onSelectItem={handleSelectItem}
|
|
322
|
+
onInspect={onInspectAudio}
|
|
323
|
+
getWaveformChunks={getWaveformChunks}
|
|
324
|
+
resolveFilePath={resolveFilePath}
|
|
325
|
+
/>
|
|
326
|
+
))
|
|
327
|
+
})()}
|
|
328
|
+
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
</div>{/* end scrubber+tracks wrapper */}
|
|
332
|
+
</div>{/* end inner zoom div */}
|
|
333
|
+
</div>{/* end scroll container */}
|
|
334
|
+
|
|
335
|
+
{/* ── Subcut regen tool (host-rendered via render-prop seam) ──
|
|
336
|
+
The Montaj-specific SubcutRegenTool lives in the host (it reads
|
|
337
|
+
regenQueue/storyboard). The timeline owns only the open trigger:
|
|
338
|
+
the per-clip Scissors button sets subcutClipId. We surface a clip
|
|
339
|
+
that still has frozen generation provenance (in-package field) and
|
|
340
|
+
let the host decide what to render. */}
|
|
341
|
+
{subcutClipId && renderSubcutRegen && (() => {
|
|
342
|
+
const subcutClip = allTracks[0]?.find(c => c.id === subcutClipId)
|
|
343
|
+
if (!subcutClip || !subcutClip.generation) return null
|
|
344
|
+
return renderSubcutRegen({
|
|
345
|
+
clipId: subcutClipId,
|
|
346
|
+
onClose: () => setSubcutClipId(null),
|
|
347
|
+
})
|
|
348
|
+
})()}
|
|
349
|
+
|
|
350
|
+
{/* ── Transcript editor ── */}
|
|
351
|
+
<TranscriptPanel
|
|
352
|
+
project={project}
|
|
353
|
+
captionTrack={captionTrack}
|
|
354
|
+
currentTime={currentTime}
|
|
355
|
+
onCaptionEdit={onCaptionEdit}
|
|
356
|
+
onProjectChange={onProjectChange}
|
|
357
|
+
onExpand={() => setTranscriptModalOpen(true)}
|
|
358
|
+
/>
|
|
359
|
+
|
|
360
|
+
{/* ── Transcript modal ── */}
|
|
361
|
+
{transcriptModalOpen && (
|
|
362
|
+
<TranscriptModal
|
|
363
|
+
project={project}
|
|
364
|
+
captionTrack={captionTrack}
|
|
365
|
+
currentTime={currentTime}
|
|
366
|
+
onProjectChange={onProjectChange}
|
|
367
|
+
onCaptionEdit={onCaptionEdit}
|
|
368
|
+
onClose={() => setTranscriptModalOpen(false)}
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
|
|
372
|
+
</div>
|
|
373
|
+
</TimelineContext.Provider>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface TimelineContextValue {
|
|
4
|
+
totalDuration: number
|
|
5
|
+
contentDuration: number
|
|
6
|
+
snapBoundaries: number[]
|
|
7
|
+
zoom: number
|
|
8
|
+
zoomRef: React.RefObject<number>
|
|
9
|
+
scrollRef: React.RefObject<HTMLDivElement | null>
|
|
10
|
+
scrubberRef: React.RefObject<HTMLDivElement | null>
|
|
11
|
+
overlayDraggedRef: React.MutableRefObject<boolean>
|
|
12
|
+
currentTime: number
|
|
13
|
+
onTimeUpdate: (t: number) => void
|
|
14
|
+
markers: [number | null, number | null]
|
|
15
|
+
setMarkers: (m: [number | null, number | null] | ((prev: [number | null, number | null]) => [number | null, number | null])) => void
|
|
16
|
+
selection: { start: number; end: number } | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TimelineContext = createContext<TimelineContextValue | null>(null)
|
|
20
|
+
|
|
21
|
+
export function useTimelineContext(): TimelineContextValue {
|
|
22
|
+
const ctx = useContext(TimelineContext)
|
|
23
|
+
if (!ctx) throw new Error('useTimelineContext must be used within a TimelineContext.Provider')
|
|
24
|
+
return ctx
|
|
25
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { Project } from '../../types'
|
|
4
|
+
import { formatTime } from './utils'
|
|
5
|
+
import { EditableSegment } from './EditableSegment'
|
|
6
|
+
import { makeCaptionEdit } from './makeCaptionEdit'
|
|
7
|
+
|
|
8
|
+
interface TranscriptModalProps {
|
|
9
|
+
captionTrack: Project['captions'] | undefined
|
|
10
|
+
currentTime: number
|
|
11
|
+
project: Project
|
|
12
|
+
onProjectChange?: (project: Project) => void
|
|
13
|
+
onCaptionEdit?: (project: Project) => void
|
|
14
|
+
onClose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function TranscriptModal({ captionTrack, currentTime, project, onProjectChange, onCaptionEdit, onClose }: TranscriptModalProps) {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
20
|
+
document.addEventListener('keydown', onKey)
|
|
21
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
22
|
+
}, [onClose])
|
|
23
|
+
|
|
24
|
+
return createPortal(
|
|
25
|
+
<div
|
|
26
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
27
|
+
onClick={onClose}
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
className="relative w-full max-w-xl max-h-[70vh] flex flex-col bg-gray-950 border border-gray-800 rounded-lg shadow-2xl mx-4"
|
|
31
|
+
onClick={(e) => e.stopPropagation()}
|
|
32
|
+
>
|
|
33
|
+
{/* Header */}
|
|
34
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800 shrink-0">
|
|
35
|
+
<span className="text-sm font-medium text-gray-200">Transcript</span>
|
|
36
|
+
<button
|
|
37
|
+
className="text-gray-500 hover:text-white transition-colors text-lg leading-none"
|
|
38
|
+
onClick={onClose}
|
|
39
|
+
>×</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{/* Segment list */}
|
|
43
|
+
<div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-1.5">
|
|
44
|
+
{(captionTrack?.segments ?? []).map((seg, i) => {
|
|
45
|
+
const isActive = currentTime >= seg.start && currentTime < seg.end
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
key={seg.id ?? i}
|
|
49
|
+
className={`flex gap-3 items-baseline px-2 py-1 rounded transition-colors ${isActive ? 'bg-white/5' : ''}`}
|
|
50
|
+
>
|
|
51
|
+
<span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
|
|
52
|
+
<span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
|
|
53
|
+
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>,
|
|
61
|
+
document.body
|
|
62
|
+
)
|
|
63
|
+
}
|