@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. 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 { currentTime, totalDuration, contentDuration, markers, setMarkers, snapBoundaries, onTimeUpdate, scrubberRef, selection } = useTimelineContext()
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
- onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
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) { onTimeUpdate(snappedTo); return }
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; onTimeUpdate(b); return }
106
+ if (Math.abs(rawT - b) < attract) { snappedTo = b; clock.set(b); return }
105
107
  }
106
- onTimeUpdate(rawT)
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
- currentTime: number
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, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
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
- const snapBoundaries = [...new Set([
70
- ...allTracks.flat().flatMap(c => [c.start, c.end]),
71
- ...audioTracks.flatMap(t => [t.start, t.end]),
72
- ])]
73
- const contentDuration = Math.max(
74
- allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
75
- audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
76
- )
77
- // Add 20% padding beyond content so the rightmost item can always be
78
- // dragged or resized further out. Minimum 5s headroom.
79
- const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
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, currentTime + dir * step))
153
- onTimeUpdate(next)
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, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
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, currentTime, onTimeUpdate, markers, setMarkers, selection,
198
+ overlayDraggedRef, clock, markers, setMarkers, selection,
170
199
  }), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
171
- overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection])
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 [currentTime, null]
191
- if (b === null) return [a, currentTime]
192
- return [currentTime, null]
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) { onTimeUpdate(b); return }
235
+ if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
206
236
  }
207
- onTimeUpdate(clickedTime)
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
- <TranscriptPanel
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
- <TranscriptModal
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
- currentTime: number
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?: (project: Project) => void
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, onProjectChange, onCaptionEdit, onClose }: TranscriptModalProps) {
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
- <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
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
- <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
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 { Volume2, VolumeX, Info, Scissors } from 'lucide-react'
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
- export default function VisualTrackRow({
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, currentTime, onTimeUpdate, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
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) { onTimeUpdate(b); return }
201
+ if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
197
202
  }
198
- onTimeUpdate(clickedTime)
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) onTimeUpdate(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
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
- {playheadLine}
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)