@bycrux/editor 0.8.9 → 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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/carousel/CarouselEditor.tsx +34 -14
  3. package/src/carousel/SlidePropertyPanel.tsx +1 -35
  4. package/src/index.ts +4 -0
  5. package/src/schema.ts +11 -3
  6. package/src/ui/SwatchInput.tsx +57 -0
  7. package/src/ui/index.ts +2 -0
  8. package/src/video/VideoEditor.tsx +107 -14
  9. package/src/video/__tests__/VideoEditor.test.tsx +34 -0
  10. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  11. package/src/video/playback-clock.ts +31 -0
  12. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  13. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  14. package/src/video/preview/PreviewPlayer.tsx +14 -6
  15. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  16. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  17. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  18. package/src/video/preview/overlay-prop-fields.ts +39 -0
  19. package/src/video/preview/useDragOverlay.ts +21 -1
  20. package/src/video/preview/useVideoPlayback.ts +12 -3
  21. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  22. package/src/video/timeline/PlayheadLine.tsx +18 -0
  23. package/src/video/timeline/Scrubber.tsx +7 -5
  24. package/src/video/timeline/Timeline.tsx +64 -29
  25. package/src/video/timeline/TimelineContext.ts +2 -2
  26. package/src/video/timeline/TranscriptPanel.tsx +57 -0
  27. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  28. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  29. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +85 -0
@@ -0,0 +1,39 @@
1
+ export type PropFieldKind = 'text' | 'number' | 'boolean' | 'color' | 'image'
2
+
3
+ export interface PropField {
4
+ name: string
5
+ kind: PropFieldKind
6
+ value: string | number | boolean
7
+ }
8
+
9
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
10
+ // A string prop that points at an image: a workspace/URL path ending in a known
11
+ // image extension (optionally with a query/hash), or a data: image URL. These
12
+ // render as a thumbnail + file picker instead of a raw path text field.
13
+ const IMAGE_PATH = /\.(?:png|jpe?g|webp|gif|svg|avif|bmp)(?:[?#].*)?$/i
14
+
15
+ function stringKind(value: string): PropFieldKind {
16
+ if (HEX_COLOR.test(value)) return 'color'
17
+ if (IMAGE_PATH.test(value) || value.startsWith('data:image/')) return 'image'
18
+ return 'text'
19
+ }
20
+
21
+ /**
22
+ * Editable fields for an overlay item, inferred from its stored props.
23
+ * Primitive values only — objects, arrays, functions, null are skipped
24
+ * (they stay untouched in the props object on save). No schema needed,
25
+ * so AI-written profile overlays work the same as shipped templates.
26
+ *
27
+ * String values are sub-typed by shape: `#hex` → color picker, an image path
28
+ * → thumbnail + file picker, everything else → text.
29
+ */
30
+ export function inferOverlayPropFields(props: Record<string, unknown> | undefined): PropField[] {
31
+ if (!props) return []
32
+ const fields: PropField[] = []
33
+ for (const [name, value] of Object.entries(props)) {
34
+ if (typeof value === 'boolean') fields.push({ name, kind: 'boolean', value })
35
+ else if (typeof value === 'number' && Number.isFinite(value)) fields.push({ name, kind: 'number', value })
36
+ else if (typeof value === 'string') fields.push({ name, kind: stringKind(value), value })
37
+ }
38
+ return fields
39
+ }
@@ -1,8 +1,28 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import type { VisualItem } from '../../schema'
2
3
 
3
4
  export type Corner = 'nw' | 'ne' | 'sw' | 'se'
4
5
  export type DragType = 'move' | `resize-${Corner}` | 'rotate'
5
6
 
7
+ // Shared shape for `onOverlayChange` across the preview layer: drag/resize/rotate
8
+ // gestures (useDragOverlay) only ever populate the geometric subset; content-editing
9
+ // callers (crop modal, future props/text editors) populate the rest. Callers pass a
10
+ // partial — VideoEditor.handleOverlayChange merges whatever arrives into the item.
11
+ export interface OverlayChanges {
12
+ offsetX?: number
13
+ offsetY?: number
14
+ scale?: number
15
+ rotation?: number
16
+ fit?: 'cover' | 'contain' | 'fill'
17
+ sourceCrop?: VisualItem['sourceCrop']
18
+ sourceWidth?: number
19
+ sourceHeight?: number
20
+ /** Full replacement for item.props (content editing). */
21
+ props?: Record<string, unknown>
22
+ /** Legacy text overlay items only. */
23
+ text?: string
24
+ }
25
+
6
26
  const SNAP_THRESHOLD = 2.5 // % of container
7
27
  const ROT_SNAP_ANGLES = [0, 90, 180, 270]
8
28
  const ROT_ATTRACT_DEG = 5 // snap in within ±5°
@@ -25,7 +45,7 @@ interface DragState {
25
45
 
26
46
  export function useDragOverlay(
27
47
  containerRef: React.RefObject<HTMLDivElement | null>,
28
- onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void,
48
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void,
29
49
  ) {
30
50
  const [dragState, setDragState] = useState<DragState | null>(null)
31
51
 
@@ -291,15 +291,24 @@ export function useVideoPlayback(
291
291
  applyClipVolume(clip)
292
292
  }, [clips, activeSlot])
293
293
 
294
+ // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
295
+ // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
296
+ // down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
297
+ // to it). onTimeUpdate is the stable clock.set identity.
298
+ const canvasMaxEndRef = useRef(0)
294
299
  useEffect(() => {
295
- if (!isCanvasProject) return
296
300
  const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
297
- const maxEnd = Math.max(
301
+ canvasMaxEndRef.current = Math.max(
298
302
  overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
299
303
  captionEnd,
300
304
  )
305
+ }, [overlayTracks, project.captions])
306
+
307
+ useEffect(() => {
308
+ if (!isCanvasProject) return
301
309
 
302
310
  function tick(ms: number) {
311
+ const maxEnd = canvasMaxEndRef.current
303
312
  if (rafLastMs.current !== null) {
304
313
  const dt = (ms - rafLastMs.current) / 1000
305
314
  const next = Math.min(lastTimeRef.current + dt, maxEnd)
@@ -327,7 +336,7 @@ export function useVideoPlayback(
327
336
  return () => {
328
337
  if (rafRef.current) cancelAnimationFrame(rafRef.current)
329
338
  }
330
- }, [isPlaying, isCanvasProject, overlayTracks, project, onTimeUpdate])
339
+ }, [isPlaying, isCanvasProject, onTimeUpdate])
331
340
 
332
341
  // ── Multi-track audio management ───────────────────────────────────────────
333
342
  // Derive unmuted tracks. The full tracks array is a new reference on every
@@ -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
- export default function AudioTrackRow({
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
- {/* Playhead line */}
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 { 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,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
- currentTime: number
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, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
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
- 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)
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, currentTime + dir * step))
153
- onTimeUpdate(next)
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, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
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, currentTime, onTimeUpdate, markers, setMarkers, selection,
179
+ overlayDraggedRef, clock, markers, setMarkers, selection,
170
180
  }), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
171
- overlayDraggedRef, currentTime, onTimeUpdate, markers, setMarkers, selection])
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 [currentTime, null]
191
- if (b === null) return [a, currentTime]
192
- return [currentTime, null]
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) { onTimeUpdate(b); return }
216
+ if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
206
217
  }
207
- onTimeUpdate(clickedTime)
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
- <TranscriptPanel
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
- <TranscriptModal
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
- 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
@@ -3,6 +3,27 @@ import type { Project } from '../../types'
3
3
  import { formatTime } from './utils'
4
4
  import { EditableSegment } from './EditableSegment'
5
5
  import { makeCaptionEdit } from './makeCaptionEdit'
6
+ import { SwatchInput } from '../../ui'
7
+
8
+ // Each caption style reads a different accent-color prop in its render template
9
+ // (see render/templates/captions/*.jsx). The color control writes whichever field
10
+ // the active style uses; `clean` and `word-by-word` have no accent (omitted here).
11
+ type CaptionStyle = NonNullable<Project['captions']>['style']
12
+ type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
13
+ const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
14
+ karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
15
+ pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
16
+ 'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
17
+ outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
18
+ subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
19
+ }
20
+
21
+ // The native <input type="color"> only accepts #rrggbb. Stored values are always
22
+ // hex once picked; fall back to the style default for unset / non-hex (e.g. an
23
+ // rgba() template default) so the swatch never gets an invalid value.
24
+ const HEX = /^#[0-9a-f]{6}$/i
25
+ const toHex = (v: unknown, fallback: string): string =>
26
+ typeof v === 'string' && HEX.test(v) ? v : fallback
6
27
 
7
28
  interface TranscriptPanelProps {
8
29
  project: Project
@@ -100,6 +121,42 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
100
121
  </span>
101
122
  </div>
102
123
  )}
124
+ {captionTrack && (() => {
125
+ const accent = ACCENT[captionTrack.style]
126
+ // onChange = live preview only (cheap, no PUT); onBlur commits one save.
127
+ const live = (patch: Record<string, string>) => {
128
+ if (!project.captions) return
129
+ onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
130
+ }
131
+ const commit = (patch: Record<string, string>) => {
132
+ if (!project.captions) return
133
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
134
+ }
135
+ return (
136
+ <div className="flex items-center gap-1.5">
137
+ <SwatchInput
138
+ size="sm"
139
+ showValue={false}
140
+ title="Caption text color"
141
+ ariaLabel="Caption text color"
142
+ value={toHex(captionTrack.color, '#ffffff')}
143
+ onChange={v => live({ color: v })}
144
+ onCommit={v => commit({ color: v })}
145
+ />
146
+ {accent && (
147
+ <SwatchInput
148
+ size="sm"
149
+ showValue={false}
150
+ title={`Caption ${accent.label.toLowerCase()} color`}
151
+ ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
152
+ value={toHex(captionTrack[accent.field], accent.def)}
153
+ onChange={v => live({ [accent.field]: v })}
154
+ onCommit={v => commit({ [accent.field]: v })}
155
+ />
156
+ )}
157
+ </div>
158
+ )
159
+ })()}
103
160
  {onRegenerateCaptions && (
104
161
  <button
105
162
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
@@ -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)