@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.
- package/package.json +1 -1
- package/src/carousel/CarouselEditor.tsx +34 -14
- package/src/carousel/SlidePropertyPanel.tsx +1 -35
- package/src/index.ts +4 -0
- package/src/schema.ts +11 -3
- package/src/ui/SwatchInput.tsx +57 -0
- package/src/ui/index.ts +2 -0
- package/src/video/VideoEditor.tsx +107 -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/TranscriptPanel.tsx +57 -0
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- 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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
@@ -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 {
|
|
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)
|