@bycrux/editor 0.9.0 → 0.11.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/index.ts +4 -0
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +331 -111
- package/src/video/__tests__/VideoEditor.test.tsx +279 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +27 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +94 -30
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatTime, pct, ratioFromClientX } from './utils'
|
|
2
2
|
import { useTimelineContext } from './TimelineContext'
|
|
3
|
+
import { usePlaybackTime } from '../playback-clock'
|
|
3
4
|
|
|
4
5
|
interface ScrubberProps {
|
|
5
6
|
hoverPct: number | null
|
|
@@ -20,12 +21,13 @@ export default function Scrubber({
|
|
|
20
21
|
onCut,
|
|
21
22
|
cutButtonLabel,
|
|
22
23
|
}: ScrubberProps) {
|
|
23
|
-
const {
|
|
24
|
+
const { clock, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, scrubberRef, selection } = useTimelineContext()
|
|
25
|
+
const currentTime = usePlaybackTime(clock)
|
|
24
26
|
|
|
25
27
|
function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
26
28
|
e.stopPropagation()
|
|
27
29
|
if (totalDuration === 0) return
|
|
28
|
-
|
|
30
|
+
clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
@@ -96,14 +98,14 @@ export default function Scrubber({
|
|
|
96
98
|
const rawT = ratioFromClientX(me.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
|
|
97
99
|
// Already snapped — hold until cursor escapes release radius
|
|
98
100
|
if (snappedTo !== null) {
|
|
99
|
-
if (Math.abs(rawT - snappedTo) < release) {
|
|
101
|
+
if (Math.abs(rawT - snappedTo) < release) { clock.set(snappedTo); return }
|
|
100
102
|
snappedTo = null
|
|
101
103
|
}
|
|
102
104
|
// Scan for attraction
|
|
103
105
|
for (const b of boundaries) {
|
|
104
|
-
if (Math.abs(rawT - b) < attract) { snappedTo = b;
|
|
106
|
+
if (Math.abs(rawT - b) < attract) { snappedTo = b; clock.set(b); return }
|
|
105
107
|
}
|
|
106
|
-
|
|
108
|
+
clock.set(rawT)
|
|
107
109
|
}
|
|
108
110
|
function onUp() {
|
|
109
111
|
setDraggingPlayhead(false)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
|
2
2
|
import AudioTrackRow from './AudioTrackRow'
|
|
3
3
|
import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
|
|
4
4
|
import type { Project } from '../../types'
|
|
@@ -6,22 +6,34 @@ import { collapseGaps } from '../cuts'
|
|
|
6
6
|
import { ratioFromClientX } from './utils'
|
|
7
7
|
import { useTimelineZoom } from './useTimelineZoom'
|
|
8
8
|
import { TimelineContext, type TimelineContextValue } from './TimelineContext'
|
|
9
|
+
import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
|
|
9
10
|
import Scrubber from './Scrubber'
|
|
10
11
|
import TranscriptPanel from './TranscriptPanel'
|
|
11
12
|
import TranscriptModal from './TranscriptModal'
|
|
12
13
|
import VisualTrackRow from './VisualTrackRow'
|
|
14
|
+
import CaptionTrackRow from './CaptionTrackRow'
|
|
13
15
|
import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
16
|
+
import type { CaptionEditPatch } from './makeCaptionEdit'
|
|
14
17
|
|
|
15
18
|
interface TimelineProps {
|
|
16
19
|
project: Project
|
|
17
|
-
|
|
18
|
-
onTimeUpdate: (t: number) => void
|
|
20
|
+
clock: PlaybackClock
|
|
19
21
|
onProjectChange?: (p: Project) => void
|
|
20
22
|
onCaptionEdit?: (p: Project) => void
|
|
21
23
|
onOverlayEdit?: (p: Project) => void
|
|
24
|
+
/** Open the overlay props dialog (owned by VideoEditor). Threaded to each
|
|
25
|
+
* VisualTrackRow so a selected overlay block can offer an edit button. */
|
|
26
|
+
onEditOverlay?: (id: string) => void
|
|
22
27
|
/** Unified selection — covers both visual items and audio tracks. */
|
|
23
28
|
selectedIds?: string[]
|
|
24
29
|
onSelectIds?: (ids: string[]) => void
|
|
30
|
+
/** Selected caption segment id — shared with the preview's selection box.
|
|
31
|
+
* Mutually exclusive with `selectedIds` (see CaptionTrackRow). */
|
|
32
|
+
selectedCaptionId?: string | null
|
|
33
|
+
onSelectCaption?: (id: string | null) => void
|
|
34
|
+
/** Commit a caption segment patch — text edit or edge-drag retime. Threaded
|
|
35
|
+
* straight to CaptionTrackRow (see makeCaptionEdit.ts). */
|
|
36
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
25
37
|
onSplit?: (at: number) => void
|
|
26
38
|
onCut?: (cut: { start: number; end: number }) => void
|
|
27
39
|
onInspectClip?: (id: string) => void
|
|
@@ -54,29 +66,46 @@ interface TimelineProps {
|
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
|
|
57
|
-
export default function Timeline({ project,
|
|
69
|
+
export default function Timeline({ project, clock, onProjectChange, onCaptionEdit, onOverlayEdit, onEditOverlay, selectedIds = [], onSelectIds, selectedCaptionId = null, onSelectCaption, onCaptionSegmentChange, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
|
|
58
70
|
const primarySelectedId = selectedIds[0] ?? null
|
|
59
71
|
|
|
60
72
|
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
73
|
+
// Also enforces the item→caption half of the two selection models' mutual
|
|
74
|
+
// exclusivity: selecting a real item clears `selectedCaptionId`, and so does
|
|
75
|
+
// clicking empty track space (id === null) — otherwise a "deselect all" click
|
|
76
|
+
// would clear the item handles but leave the caption's preview handles up,
|
|
77
|
+
// and since nothing else clears caption selection there would be no way to
|
|
78
|
+
// put a caption down at all. The other half (caption select clears
|
|
79
|
+
// `selectedIds`) lives in VideoEditor's wrapped `onSelectCaption`, since a
|
|
80
|
+
// caption can be selected from the preview too — outside Timeline entirely —
|
|
81
|
+
// not just from CaptionTrackRow.
|
|
61
82
|
function handleSelectItem(id: string | null, additive: boolean) {
|
|
62
83
|
if (!onSelectIds) return
|
|
84
|
+
onSelectCaption?.(null)
|
|
63
85
|
if (id === null) { onSelectIds([]); return }
|
|
64
86
|
onSelectIds(toggleSelection(selectedIds, id, additive))
|
|
65
87
|
}
|
|
66
88
|
const allTracks = project.tracks ?? []
|
|
67
89
|
const captionTrack = project.captions
|
|
68
90
|
const audioTracks = project.audio?.tracks ?? []
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const contentDuration =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
|
|
92
|
+
// Memoized so playback ticks (which re-render Timeline via the ctx useMemo's
|
|
93
|
+
// clock dependency) don't recompute these on every frame — they only change
|
|
94
|
+
// when the underlying tracks/audio actually change.
|
|
95
|
+
const { snapBoundaries, contentDuration, totalDuration } = useMemo(() => {
|
|
96
|
+
const snapBoundaries = [...new Set([
|
|
97
|
+
...allTracks.flat().flatMap(c => [c.start, c.end]),
|
|
98
|
+
...audioTracks.flatMap(t => [t.start, t.end]),
|
|
99
|
+
])]
|
|
100
|
+
const contentDuration = Math.max(
|
|
101
|
+
allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
102
|
+
audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
|
|
103
|
+
)
|
|
104
|
+
// Add 20% padding beyond content so the rightmost item can always be
|
|
105
|
+
// dragged or resized further out. Minimum 5s headroom.
|
|
106
|
+
const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
|
|
107
|
+
return { snapBoundaries, contentDuration, totalDuration }
|
|
108
|
+
}, [project.tracks, project.audio])
|
|
80
109
|
|
|
81
110
|
// Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
|
|
82
111
|
// and fade-in on the later, each equal to the overlap duration.
|
|
@@ -149,15 +178,15 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
149
178
|
e.preventDefault()
|
|
150
179
|
const step = e.shiftKey ? 1 : frame
|
|
151
180
|
const dir = e.key === 'ArrowRight' ? 1 : -1
|
|
152
|
-
const next = Math.max(0, Math.min(totalDuration,
|
|
153
|
-
|
|
181
|
+
const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
|
|
182
|
+
clock.set(next)
|
|
154
183
|
setKeyNavTime(next)
|
|
155
184
|
if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
|
|
156
185
|
keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
|
|
157
186
|
}
|
|
158
187
|
document.addEventListener('keydown', onKey)
|
|
159
188
|
return () => document.removeEventListener('keydown', onKey)
|
|
160
|
-
}, [totalDuration,
|
|
189
|
+
}, [totalDuration, clock, project.settings?.fps, transcriptModalOpen])
|
|
161
190
|
|
|
162
191
|
// Derive selection from two placed markers
|
|
163
192
|
const selection = markers[0] !== null && markers[1] !== null
|
|
@@ -166,9 +195,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
166
195
|
|
|
167
196
|
const ctx = useMemo<TimelineContextValue>(() => ({
|
|
168
197
|
totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
169
|
-
overlayDraggedRef,
|
|
198
|
+
overlayDraggedRef, clock, markers, setMarkers, selection,
|
|
170
199
|
}), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
171
|
-
overlayDraggedRef,
|
|
200
|
+
overlayDraggedRef, clock, markers, setMarkers, selection])
|
|
172
201
|
|
|
173
202
|
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
174
203
|
if ((e.target as HTMLElement).isContentEditable) return
|
|
@@ -186,10 +215,11 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
186
215
|
|
|
187
216
|
if (e.key !== 'Enter' || totalDuration === 0) return
|
|
188
217
|
e.preventDefault()
|
|
218
|
+
const t = clock.get()
|
|
189
219
|
setMarkers(([a, b]) => {
|
|
190
|
-
if (a === null) return [
|
|
191
|
-
if (b === null) return [a,
|
|
192
|
-
return [
|
|
220
|
+
if (a === null) return [t, null]
|
|
221
|
+
if (b === null) return [a, t]
|
|
222
|
+
return [t, null]
|
|
193
223
|
})
|
|
194
224
|
}
|
|
195
225
|
|
|
@@ -202,9 +232,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
202
232
|
const snapThreshold = (8 / rect.width) * totalDuration
|
|
203
233
|
const boundaries = snapBoundaries
|
|
204
234
|
for (const b of boundaries) {
|
|
205
|
-
if (Math.abs(clickedTime - b) < snapThreshold) {
|
|
235
|
+
if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
|
|
206
236
|
}
|
|
207
|
-
|
|
237
|
+
clock.set(clickedTime)
|
|
208
238
|
}
|
|
209
239
|
|
|
210
240
|
const cutButtonLabel = primarySelectedId
|
|
@@ -280,6 +310,17 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
280
310
|
<span>ffmpeg render — overlays are preview only, final text is burned by ffmpeg</span>
|
|
281
311
|
</div>
|
|
282
312
|
)}
|
|
313
|
+
{/* ── Caption track — its own row above the visual tracks. NOT part of
|
|
314
|
+
tracks[]; reads/writes project.captions directly (see
|
|
315
|
+
CaptionTrackRow's file header for the special-track rationale). ── */}
|
|
316
|
+
<CaptionTrackRow
|
|
317
|
+
captionTrack={captionTrack}
|
|
318
|
+
fps={project.settings?.fps ?? 30}
|
|
319
|
+
selectedCaptionId={selectedCaptionId}
|
|
320
|
+
onSelectCaption={onSelectCaption}
|
|
321
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
322
|
+
/>
|
|
323
|
+
|
|
283
324
|
{[...allTracks].reverse().map((trackItems, reversedIdx) => {
|
|
284
325
|
const trackIdx = allTracks.length - 1 - reversedIdx
|
|
285
326
|
return (
|
|
@@ -292,6 +333,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
292
333
|
rippleMode={rippleMode}
|
|
293
334
|
onProjectChange={onProjectChange}
|
|
294
335
|
onOverlayEdit={onOverlayEdit}
|
|
336
|
+
onEditOverlay={onEditOverlay}
|
|
295
337
|
onSelectItem={handleSelectItem}
|
|
296
338
|
onInspectClip={onInspectClip}
|
|
297
339
|
subcutClipId={subcutClipId}
|
|
@@ -357,10 +399,13 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
357
399
|
})()}
|
|
358
400
|
|
|
359
401
|
{/* ── Transcript editor ── */}
|
|
360
|
-
|
|
402
|
+
{/* Wrapped in a clock-subscribing child so the active-segment highlight
|
|
403
|
+
tracks the playhead WITHOUT Timeline (and its track rows) re-rendering
|
|
404
|
+
every tick. */}
|
|
405
|
+
<TranscriptPanelWithClock
|
|
406
|
+
clock={clock}
|
|
361
407
|
project={project}
|
|
362
408
|
captionTrack={captionTrack}
|
|
363
|
-
currentTime={currentTime}
|
|
364
409
|
onCaptionEdit={onCaptionEdit}
|
|
365
410
|
onProjectChange={onProjectChange}
|
|
366
411
|
onExpand={() => setTranscriptModalOpen(true)}
|
|
@@ -369,11 +414,10 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
369
414
|
|
|
370
415
|
{/* ── Transcript modal ── */}
|
|
371
416
|
{transcriptModalOpen && (
|
|
372
|
-
<
|
|
417
|
+
<TranscriptModalWithClock
|
|
418
|
+
clock={clock}
|
|
373
419
|
project={project}
|
|
374
420
|
captionTrack={captionTrack}
|
|
375
|
-
currentTime={currentTime}
|
|
376
|
-
onProjectChange={onProjectChange}
|
|
377
421
|
onCaptionEdit={onCaptionEdit}
|
|
378
422
|
onClose={() => setTranscriptModalOpen(false)}
|
|
379
423
|
/>
|
|
@@ -383,3 +427,23 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
383
427
|
</TimelineContext.Provider>
|
|
384
428
|
)
|
|
385
429
|
}
|
|
430
|
+
|
|
431
|
+
// The transcript views highlight the active caption segment, so they genuinely
|
|
432
|
+
// display time and must re-render every tick. Subscribing HERE (rather than in
|
|
433
|
+
// Timeline) keeps the per-tick re-render scoped to these leaves — Timeline and
|
|
434
|
+
// the track rows are unaffected.
|
|
435
|
+
function TranscriptPanelWithClock({
|
|
436
|
+
clock,
|
|
437
|
+
...rest
|
|
438
|
+
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptPanel>, 'currentTime'>) {
|
|
439
|
+
const currentTime = usePlaybackTime(clock)
|
|
440
|
+
return <TranscriptPanel currentTime={currentTime} {...rest} />
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function TranscriptModalWithClock({
|
|
444
|
+
clock,
|
|
445
|
+
...rest
|
|
446
|
+
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptModal>, 'currentTime'>) {
|
|
447
|
+
const currentTime = usePlaybackTime(clock)
|
|
448
|
+
return <TranscriptModal currentTime={currentTime} {...rest} />
|
|
449
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { PlaybackClock } from '../playback-clock'
|
|
2
3
|
|
|
3
4
|
export interface TimelineContextValue {
|
|
4
5
|
totalDuration: number
|
|
@@ -9,8 +10,7 @@ export interface TimelineContextValue {
|
|
|
9
10
|
scrollRef: React.RefObject<HTMLDivElement | null>
|
|
10
11
|
scrubberRef: React.RefObject<HTMLDivElement | null>
|
|
11
12
|
overlayDraggedRef: React.MutableRefObject<boolean>
|
|
12
|
-
|
|
13
|
-
onTimeUpdate: (t: number) => void
|
|
13
|
+
clock: PlaybackClock
|
|
14
14
|
markers: [number | null, number | null]
|
|
15
15
|
setMarkers: (m: [number | null, number | null] | ((prev: [number | null, number | null]) => [number | null, number | null])) => void
|
|
16
16
|
selection: { start: number; end: number } | null
|
|
@@ -9,12 +9,17 @@ interface TranscriptModalProps {
|
|
|
9
9
|
captionTrack: Project['captions'] | undefined
|
|
10
10
|
currentTime: number
|
|
11
11
|
project: Project
|
|
12
|
-
onProjectChange
|
|
12
|
+
// `onProjectChange` deliberately NOT accepted here: `makeCaptionEdit` fires
|
|
13
|
+
// both callbacks it's given with the same updated project (see
|
|
14
|
+
// makeCaptionEdit.ts), and a text edit only fires once (on blur), so there is
|
|
15
|
+
// no live-preview gesture to route through a second channel — only
|
|
16
|
+
// `onCaptionEdit` (the commit path) is needed. Accepting-but-ignoring it would
|
|
17
|
+
// invite a future call site to re-introduce the double-commit bug this fixed.
|
|
13
18
|
onCaptionEdit?: (project: Project) => void
|
|
14
19
|
onClose: () => void
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
export default function TranscriptModal({ captionTrack, currentTime, project,
|
|
22
|
+
export default function TranscriptModal({ captionTrack, currentTime, project, onCaptionEdit, onClose }: TranscriptModalProps) {
|
|
18
23
|
useEffect(() => {
|
|
19
24
|
const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
20
25
|
document.addEventListener('keydown', onKey)
|
|
@@ -50,7 +55,9 @@ export default function TranscriptModal({ captionTrack, currentTime, project, on
|
|
|
50
55
|
>
|
|
51
56
|
<span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
|
|
52
57
|
<span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
|
|
53
|
-
|
|
58
|
+
{/* Commit-only — see the file-header note on why `onProjectChange`
|
|
59
|
+
isn't accepted/passed here. */}
|
|
60
|
+
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
54
61
|
</span>
|
|
55
62
|
</div>
|
|
56
63
|
)
|
|
@@ -213,7 +213,13 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
213
213
|
{vi > 0 && ' '}
|
|
214
214
|
<span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
|
|
215
215
|
<span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
|
|
216
|
-
|
|
216
|
+
{/* Commit-only: `onProjectChange` is this component's LIVE-preview
|
|
217
|
+
channel (see the fontsize/color controls above) — but a text
|
|
218
|
+
edit fires once, on blur, and is already a complete, discrete
|
|
219
|
+
change, not a continuous gesture. Passing both callbacks here
|
|
220
|
+
would double-fire `sync.mutate` for one edit (two undo entries,
|
|
221
|
+
two queued saves); `onCaptionEdit` is the single commit path. */}
|
|
222
|
+
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
217
223
|
</span>
|
|
218
224
|
</span>
|
|
219
225
|
)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import { Volume2, VolumeX, Info, Scissors, Pencil } from 'lucide-react'
|
|
2
3
|
import type { VisualItem } from '../../schema'
|
|
3
4
|
import type { Project } from '../../types'
|
|
4
5
|
import { collapseGaps } from '../cuts'
|
|
5
6
|
import { pct, ratioFromClientX, trackRow, trackRowTall } from './utils'
|
|
6
7
|
import { useTimelineContext } from './TimelineContext'
|
|
8
|
+
import PlayheadLine from './PlayheadLine'
|
|
7
9
|
import { useItemDragDrop } from './useItemDragDrop'
|
|
8
10
|
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
9
11
|
import { applyMuteToSelection, applyResizeDeltaToSelection, deleteSelection } from './multiSelectOps'
|
|
@@ -17,6 +19,8 @@ interface VisualTrackRowProps {
|
|
|
17
19
|
rippleMode: boolean
|
|
18
20
|
onProjectChange?: (p: Project) => void
|
|
19
21
|
onOverlayEdit?: (p: Project) => void
|
|
22
|
+
/** Open the overlay props dialog for an overlay item (VideoEditor owns it). */
|
|
23
|
+
onEditOverlay?: (id: string) => void
|
|
20
24
|
/** Click handler — additive when shift/meta is held. */
|
|
21
25
|
onSelectItem: (id: string | null, additive: boolean) => void
|
|
22
26
|
onInspectClip?: (id: string) => void
|
|
@@ -41,7 +45,7 @@ const trackColors = [
|
|
|
41
45
|
{ 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
46
|
]
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
function VisualTrackRow({
|
|
45
49
|
trackItems,
|
|
46
50
|
trackIdx,
|
|
47
51
|
project,
|
|
@@ -49,6 +53,7 @@ export default function VisualTrackRow({
|
|
|
49
53
|
rippleMode,
|
|
50
54
|
onProjectChange,
|
|
51
55
|
onOverlayEdit,
|
|
56
|
+
onEditOverlay,
|
|
52
57
|
onSelectItem,
|
|
53
58
|
onInspectClip,
|
|
54
59
|
subcutClipId,
|
|
@@ -56,7 +61,7 @@ export default function VisualTrackRow({
|
|
|
56
61
|
regenEnabled,
|
|
57
62
|
isClipQueued,
|
|
58
63
|
}: VisualTrackRowProps) {
|
|
59
|
-
const { totalDuration, snapBoundaries, scrollRef, scrubberRef,
|
|
64
|
+
const { totalDuration, snapBoundaries, scrollRef, scrubberRef, clock, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
|
|
60
65
|
const tc = trackColors[trackIdx % trackColors.length]
|
|
61
66
|
const markerActive = markers[0] !== null || selection !== null
|
|
62
67
|
const primarySelectedId = selectedIds[0] ?? null
|
|
@@ -193,18 +198,11 @@ export default function VisualTrackRow({
|
|
|
193
198
|
const snapThreshold = rect ? (8 / rect.width) * totalDuration : 0
|
|
194
199
|
const boundaries = snapBoundaries
|
|
195
200
|
for (const b of boundaries) {
|
|
196
|
-
if (Math.abs(clickedTime - b) < snapThreshold) {
|
|
201
|
+
if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
|
|
197
202
|
}
|
|
198
|
-
|
|
203
|
+
clock.set(clickedTime)
|
|
199
204
|
}
|
|
200
205
|
|
|
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
206
|
return (
|
|
209
207
|
<div className={`${trackIdx === 0 ? trackRowTall : trackRow} transition-opacity ${dimmed ? 'opacity-30 pointer-events-none' : ''}`} onClick={handleTrackClick} onDoubleClick={handleScrubDoubleClick}>
|
|
210
208
|
{trackItems.map((item) => {
|
|
@@ -223,7 +221,7 @@ export default function VisualTrackRow({
|
|
|
223
221
|
onSelectItem(item.id, additive)
|
|
224
222
|
// Only seek playhead on a plain single-select click (not on
|
|
225
223
|
// additive shift-clicks, which shouldn't disrupt scrubbing).
|
|
226
|
-
if (!additive && !isSel)
|
|
224
|
+
if (!additive && !isSel) clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
|
|
227
225
|
}}
|
|
228
226
|
onDoubleClick={(e) => {
|
|
229
227
|
e.stopPropagation()
|
|
@@ -267,6 +265,13 @@ export default function VisualTrackRow({
|
|
|
267
265
|
title="Subcut regenerate"
|
|
268
266
|
><Scissors size={10} /></button>
|
|
269
267
|
)}
|
|
268
|
+
{isSel && onEditOverlay && item.type === 'overlay' && !!item.src && (
|
|
269
|
+
<button
|
|
270
|
+
className={`shrink-0 ml-1 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text}`}
|
|
271
|
+
onClick={(e) => { e.stopPropagation(); onEditOverlay(item.id) }}
|
|
272
|
+
title="Edit overlay"
|
|
273
|
+
><Pencil size={10} /></button>
|
|
274
|
+
)}
|
|
270
275
|
{isSel && (
|
|
271
276
|
<button
|
|
272
277
|
className={`shrink-0 ml-1 mr-3 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text} text-[11px] leading-none`}
|
|
@@ -281,7 +286,7 @@ export default function VisualTrackRow({
|
|
|
281
286
|
</div>
|
|
282
287
|
)
|
|
283
288
|
})}
|
|
284
|
-
|
|
289
|
+
<PlayheadLine />
|
|
285
290
|
{selection && (
|
|
286
291
|
<div
|
|
287
292
|
className="absolute inset-y-0 bg-red-500/20 pointer-events-none"
|
|
@@ -291,3 +296,5 @@ export default function VisualTrackRow({
|
|
|
291
296
|
</div>
|
|
292
297
|
)
|
|
293
298
|
}
|
|
299
|
+
|
|
300
|
+
export default memo(VisualTrackRow)
|