@bycrux/editor 0.9.0 → 0.10.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/video/VideoEditor.tsx +98 -14
- package/src/video/__tests__/VideoEditor.test.tsx +34 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +14 -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__/overlay-prop-fields.test.ts +44 -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/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +64 -29
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// cross-lane drag, edge trim, mute toggle, inline volume, delete,
|
|
6
6
|
// click-to-select, and inspect button.
|
|
7
7
|
|
|
8
|
-
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { memo, useEffect, useRef, useState } from 'react'
|
|
9
9
|
import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
|
|
10
10
|
import type { AudioTrack } from '../../schema'
|
|
11
11
|
import type { Project } from '../../types'
|
|
12
12
|
import { pct } from './utils'
|
|
13
13
|
import { useTimelineContext } from './TimelineContext'
|
|
14
|
+
import PlayheadLine from './PlayheadLine'
|
|
14
15
|
import { useItemDragDrop } from './useItemDragDrop'
|
|
15
16
|
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
16
17
|
import AudioWaveformLayer from './AudioWaveformLayer'
|
|
@@ -46,7 +47,7 @@ function updateAudioTrack(project: Project, trackId: string, changes: Partial<Au
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
function AudioTrackRow({
|
|
50
51
|
tracks,
|
|
51
52
|
laneIndex,
|
|
52
53
|
laneCount,
|
|
@@ -61,7 +62,6 @@ export default function AudioTrackRow({
|
|
|
61
62
|
}: AudioTrackRowProps) {
|
|
62
63
|
const {
|
|
63
64
|
totalDuration,
|
|
64
|
-
currentTime,
|
|
65
65
|
snapBoundaries,
|
|
66
66
|
scrollRef,
|
|
67
67
|
overlayDraggedRef,
|
|
@@ -78,11 +78,7 @@ export default function AudioTrackRow({
|
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
80
|
<div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
|
|
81
|
-
|
|
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
|
-
/>
|
|
81
|
+
<PlayheadLine />
|
|
86
82
|
{tracks.map(track => (
|
|
87
83
|
<AudioTrackItem
|
|
88
84
|
key={track.id}
|
|
@@ -139,6 +135,8 @@ export default function AudioTrackRow({
|
|
|
139
135
|
)
|
|
140
136
|
}
|
|
141
137
|
|
|
138
|
+
export default memo(AudioTrackRow)
|
|
139
|
+
|
|
142
140
|
// ── Single audio item within a lane ──────────────────────────────────────────
|
|
143
141
|
|
|
144
142
|
interface AudioTrackItemProps {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useTimelineContext } from './TimelineContext'
|
|
2
|
+
import { usePlaybackTime } from '../playback-clock'
|
|
3
|
+
import { pct } from './utils'
|
|
4
|
+
|
|
5
|
+
/** Leaf playhead indicator: the ONLY per-tick subscriber inside track rows.
|
|
6
|
+
* Isolating the clock subscription here means VisualTrackRow/AudioTrackRow
|
|
7
|
+
* (and everything else in a row) no longer re-render on every playback tick. */
|
|
8
|
+
export default function PlayheadLine() {
|
|
9
|
+
const { clock, totalDuration } = useTimelineContext()
|
|
10
|
+
const currentTime = usePlaybackTime(clock)
|
|
11
|
+
if (totalDuration === 0) return null
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
|
|
15
|
+
style={{ left: `${pct(currentTime, totalDuration)}%` }}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -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,6 +6,7 @@ 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'
|
|
@@ -14,11 +15,13 @@ import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
|
14
15
|
|
|
15
16
|
interface TimelineProps {
|
|
16
17
|
project: Project
|
|
17
|
-
|
|
18
|
-
onTimeUpdate: (t: number) => void
|
|
18
|
+
clock: PlaybackClock
|
|
19
19
|
onProjectChange?: (p: Project) => void
|
|
20
20
|
onCaptionEdit?: (p: Project) => void
|
|
21
21
|
onOverlayEdit?: (p: Project) => void
|
|
22
|
+
/** Open the overlay props dialog (owned by VideoEditor). Threaded to each
|
|
23
|
+
* VisualTrackRow so a selected overlay block can offer an edit button. */
|
|
24
|
+
onEditOverlay?: (id: string) => void
|
|
22
25
|
/** Unified selection — covers both visual items and audio tracks. */
|
|
23
26
|
selectedIds?: string[]
|
|
24
27
|
onSelectIds?: (ids: string[]) => void
|
|
@@ -54,7 +57,7 @@ interface TimelineProps {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
|
|
57
|
-
export default function Timeline({ project,
|
|
60
|
+
export default function Timeline({ project, clock, onProjectChange, onCaptionEdit, onOverlayEdit, onEditOverlay, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
|
|
58
61
|
const primarySelectedId = selectedIds[0] ?? null
|
|
59
62
|
|
|
60
63
|
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
@@ -66,17 +69,24 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
66
69
|
const allTracks = project.tracks ?? []
|
|
67
70
|
const captionTrack = project.captions
|
|
68
71
|
const audioTracks = project.audio?.tracks ?? []
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const contentDuration =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
|
|
73
|
+
// Memoized so playback ticks (which re-render Timeline via the ctx useMemo's
|
|
74
|
+
// clock dependency) don't recompute these on every frame — they only change
|
|
75
|
+
// when the underlying tracks/audio actually change.
|
|
76
|
+
const { snapBoundaries, contentDuration, totalDuration } = useMemo(() => {
|
|
77
|
+
const snapBoundaries = [...new Set([
|
|
78
|
+
...allTracks.flat().flatMap(c => [c.start, c.end]),
|
|
79
|
+
...audioTracks.flatMap(t => [t.start, t.end]),
|
|
80
|
+
])]
|
|
81
|
+
const contentDuration = Math.max(
|
|
82
|
+
allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
83
|
+
audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
|
|
84
|
+
)
|
|
85
|
+
// Add 20% padding beyond content so the rightmost item can always be
|
|
86
|
+
// dragged or resized further out. Minimum 5s headroom.
|
|
87
|
+
const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
|
|
88
|
+
return { snapBoundaries, contentDuration, totalDuration }
|
|
89
|
+
}, [project.tracks, project.audio])
|
|
80
90
|
|
|
81
91
|
// Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
|
|
82
92
|
// and fade-in on the later, each equal to the overlap duration.
|
|
@@ -149,15 +159,15 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
149
159
|
e.preventDefault()
|
|
150
160
|
const step = e.shiftKey ? 1 : frame
|
|
151
161
|
const dir = e.key === 'ArrowRight' ? 1 : -1
|
|
152
|
-
const next = Math.max(0, Math.min(totalDuration,
|
|
153
|
-
|
|
162
|
+
const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
|
|
163
|
+
clock.set(next)
|
|
154
164
|
setKeyNavTime(next)
|
|
155
165
|
if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
|
|
156
166
|
keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
|
|
157
167
|
}
|
|
158
168
|
document.addEventListener('keydown', onKey)
|
|
159
169
|
return () => document.removeEventListener('keydown', onKey)
|
|
160
|
-
}, [totalDuration,
|
|
170
|
+
}, [totalDuration, clock, project.settings?.fps, transcriptModalOpen])
|
|
161
171
|
|
|
162
172
|
// Derive selection from two placed markers
|
|
163
173
|
const selection = markers[0] !== null && markers[1] !== null
|
|
@@ -166,9 +176,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
166
176
|
|
|
167
177
|
const ctx = useMemo<TimelineContextValue>(() => ({
|
|
168
178
|
totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
169
|
-
overlayDraggedRef,
|
|
179
|
+
overlayDraggedRef, clock, markers, setMarkers, selection,
|
|
170
180
|
}), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
|
|
171
|
-
overlayDraggedRef,
|
|
181
|
+
overlayDraggedRef, clock, markers, setMarkers, selection])
|
|
172
182
|
|
|
173
183
|
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
174
184
|
if ((e.target as HTMLElement).isContentEditable) return
|
|
@@ -186,10 +196,11 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
186
196
|
|
|
187
197
|
if (e.key !== 'Enter' || totalDuration === 0) return
|
|
188
198
|
e.preventDefault()
|
|
199
|
+
const t = clock.get()
|
|
189
200
|
setMarkers(([a, b]) => {
|
|
190
|
-
if (a === null) return [
|
|
191
|
-
if (b === null) return [a,
|
|
192
|
-
return [
|
|
201
|
+
if (a === null) return [t, null]
|
|
202
|
+
if (b === null) return [a, t]
|
|
203
|
+
return [t, null]
|
|
193
204
|
})
|
|
194
205
|
}
|
|
195
206
|
|
|
@@ -202,9 +213,9 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
202
213
|
const snapThreshold = (8 / rect.width) * totalDuration
|
|
203
214
|
const boundaries = snapBoundaries
|
|
204
215
|
for (const b of boundaries) {
|
|
205
|
-
if (Math.abs(clickedTime - b) < snapThreshold) {
|
|
216
|
+
if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
|
|
206
217
|
}
|
|
207
|
-
|
|
218
|
+
clock.set(clickedTime)
|
|
208
219
|
}
|
|
209
220
|
|
|
210
221
|
const cutButtonLabel = primarySelectedId
|
|
@@ -292,6 +303,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
292
303
|
rippleMode={rippleMode}
|
|
293
304
|
onProjectChange={onProjectChange}
|
|
294
305
|
onOverlayEdit={onOverlayEdit}
|
|
306
|
+
onEditOverlay={onEditOverlay}
|
|
295
307
|
onSelectItem={handleSelectItem}
|
|
296
308
|
onInspectClip={onInspectClip}
|
|
297
309
|
subcutClipId={subcutClipId}
|
|
@@ -357,10 +369,13 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
357
369
|
})()}
|
|
358
370
|
|
|
359
371
|
{/* ── Transcript editor ── */}
|
|
360
|
-
|
|
372
|
+
{/* Wrapped in a clock-subscribing child so the active-segment highlight
|
|
373
|
+
tracks the playhead WITHOUT Timeline (and its track rows) re-rendering
|
|
374
|
+
every tick. */}
|
|
375
|
+
<TranscriptPanelWithClock
|
|
376
|
+
clock={clock}
|
|
361
377
|
project={project}
|
|
362
378
|
captionTrack={captionTrack}
|
|
363
|
-
currentTime={currentTime}
|
|
364
379
|
onCaptionEdit={onCaptionEdit}
|
|
365
380
|
onProjectChange={onProjectChange}
|
|
366
381
|
onExpand={() => setTranscriptModalOpen(true)}
|
|
@@ -369,10 +384,10 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
369
384
|
|
|
370
385
|
{/* ── Transcript modal ── */}
|
|
371
386
|
{transcriptModalOpen && (
|
|
372
|
-
<
|
|
387
|
+
<TranscriptModalWithClock
|
|
388
|
+
clock={clock}
|
|
373
389
|
project={project}
|
|
374
390
|
captionTrack={captionTrack}
|
|
375
|
-
currentTime={currentTime}
|
|
376
391
|
onProjectChange={onProjectChange}
|
|
377
392
|
onCaptionEdit={onCaptionEdit}
|
|
378
393
|
onClose={() => setTranscriptModalOpen(false)}
|
|
@@ -383,3 +398,23 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
383
398
|
</TimelineContext.Provider>
|
|
384
399
|
)
|
|
385
400
|
}
|
|
401
|
+
|
|
402
|
+
// The transcript views highlight the active caption segment, so they genuinely
|
|
403
|
+
// display time and must re-render every tick. Subscribing HERE (rather than in
|
|
404
|
+
// Timeline) keeps the per-tick re-render scoped to these leaves — Timeline and
|
|
405
|
+
// the track rows are unaffected.
|
|
406
|
+
function TranscriptPanelWithClock({
|
|
407
|
+
clock,
|
|
408
|
+
...rest
|
|
409
|
+
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptPanel>, 'currentTime'>) {
|
|
410
|
+
const currentTime = usePlaybackTime(clock)
|
|
411
|
+
return <TranscriptPanel currentTime={currentTime} {...rest} />
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function TranscriptModalWithClock({
|
|
415
|
+
clock,
|
|
416
|
+
...rest
|
|
417
|
+
}: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptModal>, 'currentTime'>) {
|
|
418
|
+
const currentTime = usePlaybackTime(clock)
|
|
419
|
+
return <TranscriptModal currentTime={currentTime} {...rest} />
|
|
420
|
+
}
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { render, act } from '@testing-library/react'
|
|
3
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
4
|
+
import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
|
|
5
|
+
import PlayheadLine from '../PlayheadLine'
|
|
6
|
+
|
|
7
|
+
function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
|
|
8
|
+
return {
|
|
9
|
+
totalDuration: 10,
|
|
10
|
+
contentDuration: 10,
|
|
11
|
+
snapBoundaries: [],
|
|
12
|
+
zoom: 1,
|
|
13
|
+
zoomRef: { current: 1 },
|
|
14
|
+
scrollRef: { current: null },
|
|
15
|
+
scrubberRef: { current: null },
|
|
16
|
+
overlayDraggedRef: { current: false },
|
|
17
|
+
clock: createPlaybackClock(),
|
|
18
|
+
markers: [null, null],
|
|
19
|
+
setMarkers: () => {},
|
|
20
|
+
selection: null,
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('PlayheadLine moves on clock.set without re-rendering a sibling row', () => {
|
|
26
|
+
const ctx = makeCtx()
|
|
27
|
+
let siblingRenders = 0
|
|
28
|
+
|
|
29
|
+
function Sibling() {
|
|
30
|
+
siblingRenders++
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
function Root() {
|
|
34
|
+
return (
|
|
35
|
+
<TimelineContext.Provider value={ctx}>
|
|
36
|
+
<PlayheadLine />
|
|
37
|
+
<Sibling />
|
|
38
|
+
</TimelineContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { container } = render(<Root />)
|
|
43
|
+
const before = siblingRenders
|
|
44
|
+
|
|
45
|
+
act(() => { ctx.clock.set(4) })
|
|
46
|
+
|
|
47
|
+
const line = container.querySelector('div') as HTMLDivElement
|
|
48
|
+
expect(line.style.left).toBe('40%')
|
|
49
|
+
expect(siblingRenders).toBe(before)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('renders nothing when totalDuration is 0', () => {
|
|
53
|
+
const ctx = makeCtx({ totalDuration: 0 })
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<TimelineContext.Provider value={ctx}>
|
|
56
|
+
<PlayheadLine />
|
|
57
|
+
</TimelineContext.Provider>,
|
|
58
|
+
)
|
|
59
|
+
expect(container.firstChild).toBeNull()
|
|
60
|
+
})
|