@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,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
+ }