@bycrux/editor 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +331 -111
- package/src/video/__tests__/VideoEditor.test.tsx +279 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +27 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +94 -30
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { inferOverlayPropFields } from '../overlay-prop-fields'
|
|
3
|
+
|
|
4
|
+
test('infers kinds from values', () => {
|
|
5
|
+
const fields = inferOverlayPropFields({
|
|
6
|
+
homeName: 'Colombia',
|
|
7
|
+
homeScore: 2,
|
|
8
|
+
accent: '#FCD116',
|
|
9
|
+
muted: false,
|
|
10
|
+
players: [{ name: 'x' }], // non-primitive: skipped
|
|
11
|
+
onClick: () => {}, // non-primitive: skipped
|
|
12
|
+
homeSrc: '/path/to/crest.png', // image path → image kind
|
|
13
|
+
})
|
|
14
|
+
expect(fields).toEqual([
|
|
15
|
+
{ name: 'homeName', kind: 'text', value: 'Colombia' },
|
|
16
|
+
{ name: 'homeScore', kind: 'number', value: 2 },
|
|
17
|
+
{ name: 'accent', kind: 'color', value: '#FCD116' },
|
|
18
|
+
{ name: 'muted', kind: 'boolean', value: false },
|
|
19
|
+
{ name: 'homeSrc', kind: 'image', value: '/path/to/crest.png' },
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('detects image paths (extensions, query/hash, data URLs) vs plain text', () => {
|
|
24
|
+
const f = inferOverlayPropFields({
|
|
25
|
+
a: '/assets/logo.PNG',
|
|
26
|
+
b: 'https://cdn.x/y.jpg?v=2',
|
|
27
|
+
c: 'photo.webp#frag',
|
|
28
|
+
d: 'inline.svg',
|
|
29
|
+
e: 'data:image/png;base64,AAAA',
|
|
30
|
+
f: '/notes/readme.txt', // not an image
|
|
31
|
+
g: 'just some words', // not an image
|
|
32
|
+
})
|
|
33
|
+
expect(f.map(x => x.kind)).toEqual(['image', 'image', 'image', 'image', 'image', 'text', 'text'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('preserves insertion order and handles empty/absent props', () => {
|
|
37
|
+
expect(inferOverlayPropFields({})).toEqual([])
|
|
38
|
+
expect(inferOverlayPropFields(undefined)).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('color detection is strict hex only', () => {
|
|
42
|
+
const f = inferOverlayPropFields({ a: '#ff0', b: '#FFAA00CC', c: '#xyz', d: 'red' })
|
|
43
|
+
expect(f.map(x => x.kind)).toEqual(['color', 'color', 'text', 'text'])
|
|
44
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure drag maths for caption-segment positioning in the preview.
|
|
3
|
+
*
|
|
4
|
+
* Why this is NOT `useDragOverlay` generalised
|
|
5
|
+
* --------------------------------------------
|
|
6
|
+
* The two gestures look alike but their coordinate models are different, and
|
|
7
|
+
* the overlay hook is the highest-traffic drag path in the preview:
|
|
8
|
+
*
|
|
9
|
+
* - `useDragOverlay` drives an element that is `inset-0` (frame-sized) and then
|
|
10
|
+
* scaled from its centre, so `scale` is a *box* scale. Its edge-snap geometry
|
|
11
|
+
* (`edgeX = (0.5 - s/2) * 100`) is derived from exactly that. A caption's
|
|
12
|
+
* `scale` is a font-size multiplier applied to a small, content-sized anchor
|
|
13
|
+
* box — the same formula would compute snap positions that mean nothing.
|
|
14
|
+
* - Overlays rotate; caption segments do not (no `rotation` on CaptionSegment).
|
|
15
|
+
* - Overlays commit through `onOverlayChange(id, changes)` into `project.tracks`;
|
|
16
|
+
* caption segments commit through `makeCaptionEdit` into `project.captions`.
|
|
17
|
+
*
|
|
18
|
+
* The only genuinely shared logic is "screen delta ÷ preview scale → percent of
|
|
19
|
+
* frame", which is two divisions. Parameterising the overlay hook to switch off
|
|
20
|
+
* rotation and edge snapping, and to swap its commit contract, would churn that
|
|
21
|
+
* hook for no reuse worth having — so this duplicates the gesture maths
|
|
22
|
+
* deliberately, as pure functions (no React), and keeps the overlay path
|
|
23
|
+
* untouched.
|
|
24
|
+
*
|
|
25
|
+
* Coordinate convention
|
|
26
|
+
* ---------------------
|
|
27
|
+
* Offsets are **percent of frame** (`offsetX: 22` = 22% of frame width), the
|
|
28
|
+
* same unit overlay and video items use, and the unit the caption render
|
|
29
|
+
* templates consume via `captionOuterStyle`.
|
|
30
|
+
*
|
|
31
|
+
* Deltas **accumulate from the gesture start**: the segment's committed geometry
|
|
32
|
+
* and the pointer position are both captured in `CaptionDragState` at mousedown,
|
|
33
|
+
* and every subsequent move recomputes `initOffset + (pointer - initPointer)`.
|
|
34
|
+
* Nothing is derived from an absolute cursor position, and no intermediate
|
|
35
|
+
* result is fed back in — so a gesture is idempotent for a given pointer
|
|
36
|
+
* position and cannot accumulate rounding drift over a long drag.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { CaptionSegment } from '../../schema'
|
|
40
|
+
|
|
41
|
+
export type CaptionCorner = 'nw' | 'ne' | 'sw' | 'se'
|
|
42
|
+
export type CaptionDragType = 'move' | `resize-${CaptionCorner}`
|
|
43
|
+
|
|
44
|
+
/** Everything captured at mousedown. Immutable for the life of the gesture. */
|
|
45
|
+
export interface CaptionDragState {
|
|
46
|
+
/** `CaptionSegment.id` of the segment being dragged. */
|
|
47
|
+
id: string
|
|
48
|
+
type: CaptionDragType
|
|
49
|
+
/** Pointer position at gesture start, in client (screen) px. */
|
|
50
|
+
initX: number
|
|
51
|
+
initY: number
|
|
52
|
+
/** The segment's committed geometry at gesture start. */
|
|
53
|
+
initOffsetX: number
|
|
54
|
+
initOffsetY: number
|
|
55
|
+
initScale: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A caption segment's positioning geometry, with schema defaults filled in. */
|
|
59
|
+
export interface CaptionGeometry {
|
|
60
|
+
offsetX: number
|
|
61
|
+
offsetY: number
|
|
62
|
+
scale: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The preview's design-resolution mapping.
|
|
67
|
+
*
|
|
68
|
+
* `previewScale` is CaptionPreview's ResizeObserver value: on-screen frame width
|
|
69
|
+
* ÷ `renderW`. Callers MUST pass the same `renderW` that produced `previewScale`
|
|
70
|
+
* — the width terms then cancel exactly (`px / previewScale / renderW` ≡
|
|
71
|
+
* `px / onScreenWidth`), which is what makes the conversion independent of
|
|
72
|
+
* window size.
|
|
73
|
+
*/
|
|
74
|
+
export interface CaptionFrameMetrics {
|
|
75
|
+
previewScale: number
|
|
76
|
+
renderW: number
|
|
77
|
+
renderH: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bounds on the font-size multiplier. Below ~0.1 the caption is unreadable and
|
|
81
|
+
// its selection box becomes too small to grab back; above 5 it is larger than
|
|
82
|
+
// the frame in every template. Mirrors the overlay hook's `Math.max(0.1, …)`
|
|
83
|
+
// floor, with a ceiling added because a caption's scale grows text, not a box,
|
|
84
|
+
// so there is no self-limiting geometry.
|
|
85
|
+
export const CAPTION_MIN_SCALE = 0.1
|
|
86
|
+
export const CAPTION_MAX_SCALE = 5
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Click slop, in screen px. The same mousedown both selects a segment and arms
|
|
90
|
+
* a move, so a click that wobbles a couple of pixels must not nudge the caption
|
|
91
|
+
* (and must not push an undo step). Until the pointer travels this far the
|
|
92
|
+
* gesture is treated as a click, and returning inside the radius cancels it
|
|
93
|
+
* again — so a drag that comes back to where it started commits nothing.
|
|
94
|
+
*/
|
|
95
|
+
export const CAPTION_DRAG_SLOP_PX = 3
|
|
96
|
+
|
|
97
|
+
/** Has the pointer moved far enough from the mousedown to count as a drag? */
|
|
98
|
+
export function hasEscapedClickSlop(drag: CaptionDragState, clientX: number, clientY: number): boolean {
|
|
99
|
+
return Math.hypot(clientX - drag.initX, clientY - drag.initY) > CAPTION_DRAG_SLOP_PX
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read a segment's geometry, applying the schema defaults (0 / 0 / 1). */
|
|
103
|
+
export function readCaptionGeometry(seg: Pick<CaptionSegment, 'offsetX' | 'offsetY' | 'scale'> | null | undefined): CaptionGeometry {
|
|
104
|
+
return {
|
|
105
|
+
offsetX: seg?.offsetX ?? 0,
|
|
106
|
+
offsetY: seg?.offsetY ?? 0,
|
|
107
|
+
scale: seg?.scale ?? 1,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Convert a screen-pixel delta to percent of frame.
|
|
113
|
+
*
|
|
114
|
+
* screen px → design px: px / previewScale
|
|
115
|
+
* design px → percent: designPx / renderW * 100 (renderH for Y)
|
|
116
|
+
*
|
|
117
|
+
* Returns a zero delta for degenerate metrics (scale not measured yet, zero-size
|
|
118
|
+
* canvas) rather than NaN/Infinity, so a drag that starts before the first
|
|
119
|
+
* ResizeObserver callback is inert instead of catapulting the segment offscreen.
|
|
120
|
+
*/
|
|
121
|
+
export function screenDeltaToFramePercent(
|
|
122
|
+
dxPx: number,
|
|
123
|
+
dyPx: number,
|
|
124
|
+
{ previewScale, renderW, renderH }: CaptionFrameMetrics,
|
|
125
|
+
): { dx: number; dy: number } {
|
|
126
|
+
if (!(previewScale > 0) || !(renderW > 0) || !(renderH > 0)) return { dx: 0, dy: 0 }
|
|
127
|
+
return {
|
|
128
|
+
dx: (dxPx / previewScale) / renderW * 100,
|
|
129
|
+
dy: (dyPx / previewScale) / renderH * 100,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Geometry for the current pointer position, given the gesture captured at
|
|
135
|
+
* mousedown. `move` translates; `resize-<corner>` scales and leaves the offsets
|
|
136
|
+
* alone (the inner anchor box scales around its own centre, so a scaled caption
|
|
137
|
+
* stays put — see captionInnerStyle in overlay-runtime/position.js).
|
|
138
|
+
*/
|
|
139
|
+
export function captionDragGeometry(
|
|
140
|
+
drag: CaptionDragState,
|
|
141
|
+
clientX: number,
|
|
142
|
+
clientY: number,
|
|
143
|
+
metrics: CaptionFrameMetrics,
|
|
144
|
+
): CaptionGeometry {
|
|
145
|
+
const { dx, dy } = screenDeltaToFramePercent(clientX - drag.initX, clientY - drag.initY, metrics)
|
|
146
|
+
|
|
147
|
+
if (drag.type === 'move') {
|
|
148
|
+
return { offsetX: drag.initOffsetX + dx, offsetY: drag.initOffsetY + dy, scale: drag.initScale }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resize from a corner: project the pointer delta onto the corner's outward
|
|
152
|
+
// diagonal so dragging away from the box grows it and toward it shrinks it.
|
|
153
|
+
// Percent-of-frame ÷ 100 = fraction of frame, used as a proportional nudge on
|
|
154
|
+
// the starting scale — same shape as the overlay hook's corner resize.
|
|
155
|
+
const corner = drag.type.slice('resize-'.length) as CaptionCorner
|
|
156
|
+
const sx = corner.includes('e') ? 1 : -1
|
|
157
|
+
const sy = corner.includes('s') ? 1 : -1
|
|
158
|
+
const delta = (dx * sx + dy * sy) / 100
|
|
159
|
+
const scale = Math.min(CAPTION_MAX_SCALE, Math.max(CAPTION_MIN_SCALE, drag.initScale * (1 + delta)))
|
|
160
|
+
return { offsetX: drag.initOffsetX, offsetY: drag.initOffsetY, scale }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The patch to commit on mouseup — only the fields the gesture actually
|
|
165
|
+
* changed, so a move never rewrites `scale` (and vice versa) and the resulting
|
|
166
|
+
* undo step reads as what the operator did.
|
|
167
|
+
*/
|
|
168
|
+
export function captionDragPatch(
|
|
169
|
+
drag: CaptionDragState,
|
|
170
|
+
geom: CaptionGeometry,
|
|
171
|
+
): Pick<CaptionSegment, 'offsetX' | 'offsetY' | 'scale'> {
|
|
172
|
+
return drag.type === 'move'
|
|
173
|
+
? { offsetX: geom.offsetX, offsetY: geom.offsetY }
|
|
174
|
+
: { scale: geom.scale }
|
|
175
|
+
}
|
|
@@ -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,235 @@
|
|
|
1
|
+
// CaptionTrackRow — the caption track's own row in the timeline.
|
|
2
|
+
//
|
|
3
|
+
// Captions are NOT part of `tracks[]` (see schema.ts / the plan) — this row
|
|
4
|
+
// reads and writes `project.captions` directly, keeping that special-track
|
|
5
|
+
// data model intact. One block per segment, positioned on the shared timeline
|
|
6
|
+
// scale exactly like VisualTrackRow/AudioTrackRow (same `pct()` + context —
|
|
7
|
+
// never a locally-computed px-per-second).
|
|
8
|
+
//
|
|
9
|
+
// Selection is unified with the preview's caption selection box
|
|
10
|
+
// (`selectedCaptionId`/`onSelectCaption`, both owned by VideoEditor — see
|
|
11
|
+
// ReviewSurface) and is mutually exclusive with the normal item-selection
|
|
12
|
+
// model (`selectedIds`): the two never show handles at once. This row only
|
|
13
|
+
// owns the caption→item half of that rule at the CALL site (clicking a block
|
|
14
|
+
// below calls `onSelectCaption`, same as the preview's click); the actual
|
|
15
|
+
// "also clear selectedIds" side effect lives in VideoEditor's wrapped
|
|
16
|
+
// `onSelectCaption`, since a caption can be selected from the preview too,
|
|
17
|
+
// outside this row entirely. Timeline.handleSelectItem owns the other half
|
|
18
|
+
// (clearing `selectedCaptionId` when a normal item is selected).
|
|
19
|
+
//
|
|
20
|
+
// Editing a segment's text (double-click) and retiming it (drag an edge) both
|
|
21
|
+
// funnel through the single `onCaptionSegmentChange(id, patch)` callback
|
|
22
|
+
// (VideoEditor's `handleCaptionSegmentChange`, which wraps `makeCaptionEdit` +
|
|
23
|
+
// `sync.mutate`). That function pushes ONE undo entry and enqueues ONE save
|
|
24
|
+
// PER CALL, so — unlike VisualTrackRow, which calls its per-tick
|
|
25
|
+
// `onProjectChange` on every mousemove during a resize — a drag here stays
|
|
26
|
+
// entirely in local state (`live`) until mouseup, where `onCaptionSegmentChange`
|
|
27
|
+
// fires exactly once. This mirrors CaptionPreview's drag lifecycle (the
|
|
28
|
+
// sibling caption-editing surface), which does the same for the same reason.
|
|
29
|
+
import { useEffect, useRef, useState } from 'react'
|
|
30
|
+
import type { CaptionSegment, Captions } from '../../schema'
|
|
31
|
+
import { pct, trackRow } from './utils'
|
|
32
|
+
import { useTimelineContext } from './TimelineContext'
|
|
33
|
+
import PlayheadLine from './PlayheadLine'
|
|
34
|
+
import { useItemDragDrop } from './useItemDragDrop'
|
|
35
|
+
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
36
|
+
import { EditableSegment } from './EditableSegment'
|
|
37
|
+
import type { CaptionEditPatch } from './makeCaptionEdit'
|
|
38
|
+
|
|
39
|
+
interface CaptionTrackRowProps {
|
|
40
|
+
captionTrack: Captions | undefined
|
|
41
|
+
/** Project frame rate — needed only to make the click-seek land INSIDE the
|
|
42
|
+
* clicked segment once the preview quantizes the clock (see the click
|
|
43
|
+
* handler below). */
|
|
44
|
+
fps: number
|
|
45
|
+
/** Shared selection id — see the file header. Null when nothing is selected. */
|
|
46
|
+
selectedCaptionId: string | null
|
|
47
|
+
onSelectCaption?: (id: string | null) => void
|
|
48
|
+
/** The single commit channel for both text edits and retiming. */
|
|
49
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function CaptionTrackRow({ captionTrack, fps, selectedCaptionId, onSelectCaption, onCaptionSegmentChange }: CaptionTrackRowProps) {
|
|
53
|
+
const { totalDuration, snapBoundaries, scrollRef, zoomRef, overlayDraggedRef, clock } = useTimelineContext()
|
|
54
|
+
const { beginResize } = useItemDragDrop({
|
|
55
|
+
totalDuration,
|
|
56
|
+
snapBoundaries,
|
|
57
|
+
scrollRef,
|
|
58
|
+
zoomRef,
|
|
59
|
+
draggedFlagRef: overlayDraggedRef,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// In-flight edge-drag geometry for the dragged segment only, keyed by id so a
|
|
63
|
+
// stale `live` from a previous gesture can never leak onto a different
|
|
64
|
+
// segment. Never written to the project mid-drag — see file header.
|
|
65
|
+
const [live, setLive] = useState<{ id: string; start: number; end: number } | null>(null)
|
|
66
|
+
// Which segment's text is currently in the contentEditable state (double-
|
|
67
|
+
// click to enter, blur to exit). At most one at a time — no multi-edit.
|
|
68
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
69
|
+
|
|
70
|
+
const segments = captionTrack?.segments ?? []
|
|
71
|
+
|
|
72
|
+
// Empty state: no `project.captions`, or a track with zero segments. Still
|
|
73
|
+
// rendered (not `null`) so the operator can see captions exist as a concept
|
|
74
|
+
// even before any exist.
|
|
75
|
+
if (segments.length === 0) {
|
|
76
|
+
return (
|
|
77
|
+
<div className={trackRow}>
|
|
78
|
+
<PlayheadLine />
|
|
79
|
+
<div className="absolute inset-0 flex items-center px-2 pointer-events-none">
|
|
80
|
+
<span className="text-[10px] text-gray-500 italic select-none">Captions</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleEdgeDrag(e: React.MouseEvent, seg: CaptionSegment, edge: 'start' | 'end') {
|
|
87
|
+
if (!seg.id || !onCaptionSegmentChange) return
|
|
88
|
+
const segId = seg.id
|
|
89
|
+
const origStart = seg.start
|
|
90
|
+
const origEnd = seg.end
|
|
91
|
+
let committedValue = edge === 'start' ? origStart : origEnd
|
|
92
|
+
|
|
93
|
+
beginResize(e, seg as Draggable, edge, {
|
|
94
|
+
onLivePreview: ({ item: resized }: DragEventContext) => {
|
|
95
|
+
// `resized` is the hook's `{ ...seg, [edge]: <new value> }` — it spreads
|
|
96
|
+
// the full segment (text, words, offsets) because the hook is generic
|
|
97
|
+
// and doesn't know it's a caption. Read ONLY the one numeric field that
|
|
98
|
+
// changed; the rest of `resized` is discarded, never persisted.
|
|
99
|
+
committedValue = edge === 'start' ? resized.start : resized.end
|
|
100
|
+
setLive({
|
|
101
|
+
id: segId,
|
|
102
|
+
start: edge === 'start' ? committedValue : origStart,
|
|
103
|
+
end: edge === 'end' ? committedValue : origEnd,
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
onCommit: () => {
|
|
107
|
+
// `beginResize` commits unconditionally on mouseup — unlike `beginDrag`
|
|
108
|
+
// it has no travel threshold — so a bare click on the 6px edge handle
|
|
109
|
+
// lands here with the edge untouched. Committing that would push an
|
|
110
|
+
// undo entry and queue a save for an unchanged project, since
|
|
111
|
+
// onCaptionSegmentChange is a full `sync.mutate` (see file header).
|
|
112
|
+
if (committedValue === (edge === 'start' ? origStart : origEnd)) {
|
|
113
|
+
setLive(null)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
// Patch carries ONLY the dragged edge — never `text` — so
|
|
117
|
+
// makeCaptionEdit never respreads word timings on a pure retime.
|
|
118
|
+
onCaptionSegmentChange(segId, edge === 'start' ? { start: committedValue } : { end: committedValue })
|
|
119
|
+
setLive(null)
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={trackRow}>
|
|
126
|
+
<PlayheadLine />
|
|
127
|
+
{segments.map((seg) => {
|
|
128
|
+
// `live` must be checked for null FIRST: an id-less segment (seg.id
|
|
129
|
+
// === undefined, e.g. before backfillCaptionIds runs) would otherwise
|
|
130
|
+
// compare equal to a null `live` (undefined === undefined), making
|
|
131
|
+
// isLive true and dereferencing null on the next two lines.
|
|
132
|
+
const isLive = live !== null && live.id === seg.id
|
|
133
|
+
const start = isLive ? live.start : seg.start
|
|
134
|
+
const end = isLive ? live.end : seg.end
|
|
135
|
+
const isSelected = !!seg.id && selectedCaptionId === seg.id
|
|
136
|
+
const isEditing = !!seg.id && editingId === seg.id
|
|
137
|
+
// A segment briefly lacks an id in the window before VideoEditor's
|
|
138
|
+
// backfillCaptionIds effect mints one — `handleCaptionSegmentChange`
|
|
139
|
+
// only accepts a string id, so stay non-interactive until then (same
|
|
140
|
+
// guard CaptionPreview uses for its selection box).
|
|
141
|
+
const canInteract = !!seg.id
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
key={seg.id ?? `${seg.start}-${seg.end}`}
|
|
146
|
+
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden
|
|
147
|
+
${canInteract ? 'cursor-pointer' : ''}
|
|
148
|
+
${isSelected ? 'bg-purple-600/70 ring-1 ring-inset ring-purple-300/80' : 'bg-purple-700/40 hover:bg-purple-600/50 border border-purple-500/40'}`}
|
|
149
|
+
style={{ left: `${pct(start, totalDuration)}%`, width: `${pct(end - start, totalDuration)}%` }}
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.stopPropagation()
|
|
152
|
+
if (!canInteract || overlayDraggedRef.current) return
|
|
153
|
+
onSelectCaption?.(seg.id!)
|
|
154
|
+
// Seek to the segment on a fresh select (not on re-clicking an
|
|
155
|
+
// already-selected block). Load-bearing: the preview only shows
|
|
156
|
+
// drag handles for the segment active AT THE PLAYHEAD, so
|
|
157
|
+
// selecting from here without seeking would select a segment the
|
|
158
|
+
// preview can't yet act on.
|
|
159
|
+
//
|
|
160
|
+
// Half a frame IN, not to `start` itself. CaptionPreview snaps the
|
|
161
|
+
// clock to the frame grid before running the templates' own
|
|
162
|
+
// `t >= start && t < end` test (`t = Math.round(currentTime * fps)
|
|
163
|
+
// / fps`), and caption starts are arbitrary floats out of Whisper.
|
|
164
|
+
// Seeking to exactly `start` rounds DOWN into the PREVIOUS segment
|
|
165
|
+
// whenever `start * fps` has a fractional part below 0.5 — about
|
|
166
|
+
// half of all segments (e.g. start 3.44 at 30fps → frame 103 →
|
|
167
|
+
// t 3.4333 < 3.44). `start + 0.5 / fps` puts `t` in
|
|
168
|
+
// [start, start + 1/fps), always inside: `beginResize` enforces a
|
|
169
|
+
// 0.1s floor on segment duration, so no segment is under a frame.
|
|
170
|
+
if (!isSelected) clock.set(start + 0.5 / fps)
|
|
171
|
+
}}
|
|
172
|
+
onDoubleClick={(e) => {
|
|
173
|
+
e.stopPropagation()
|
|
174
|
+
if (!canInteract) return
|
|
175
|
+
setEditingId(seg.id!)
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{canInteract && (
|
|
179
|
+
<div
|
|
180
|
+
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-purple-300/40"
|
|
181
|
+
onMouseDown={(e) => handleEdgeDrag(e, seg, 'start')}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
<span className="text-[10px] text-purple-100 truncate flex-1 min-w-0 px-2">
|
|
185
|
+
{isEditing ? (
|
|
186
|
+
<EditableSegmentAutofocus
|
|
187
|
+
seg={seg}
|
|
188
|
+
onEdit={(text) => onCaptionSegmentChange?.(seg.id!, { text })}
|
|
189
|
+
onDone={() => setEditingId(null)}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
seg.text
|
|
193
|
+
)}
|
|
194
|
+
</span>
|
|
195
|
+
{canInteract && (
|
|
196
|
+
<div
|
|
197
|
+
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-purple-300/40"
|
|
198
|
+
onMouseDown={(e) => handleEdgeDrag(e, seg, 'end')}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Wraps EditableSegment (unmodified — the same component TranscriptPanel uses)
|
|
209
|
+
// with focus-on-mount. Double-click swaps a plain label for this contentEditable
|
|
210
|
+
// span on the NEXT render, by which point the browser's native dblclick text
|
|
211
|
+
// selection has already resolved against the old, non-editable element — so
|
|
212
|
+
// without this the operator would need a third click just to place a cursor.
|
|
213
|
+
function EditableSegmentAutofocus({ seg, onEdit, onDone }: { seg: CaptionSegment; onEdit: (text: string) => void; onDone: () => void }) {
|
|
214
|
+
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const el = wrapRef.current?.querySelector<HTMLElement>('[contenteditable]')
|
|
218
|
+
if (!el) return
|
|
219
|
+
el.focus()
|
|
220
|
+
// Place the caret at the end rather than leaving it at the browser default
|
|
221
|
+
// (start), so continuing to type appends instead of interrupting mid-word.
|
|
222
|
+
const range = document.createRange()
|
|
223
|
+
range.selectNodeContents(el)
|
|
224
|
+
range.collapse(false)
|
|
225
|
+
const sel = window.getSelection()
|
|
226
|
+
sel?.removeAllRanges()
|
|
227
|
+
sel?.addRange(range)
|
|
228
|
+
}, [])
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<span ref={wrapRef} onBlur={onDone} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
232
|
+
<EditableSegment seg={seg} onEdit={onEdit} />
|
|
233
|
+
</span>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -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
|
+
}
|