@bycrux/editor 0.10.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/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 +260 -124
- package/src/video/__tests__/VideoEditor.test.tsx +245 -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/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/Timeline.tsx +31 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -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,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,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
|
+
}
|
|
@@ -11,7 +11,9 @@ import Scrubber from './Scrubber'
|
|
|
11
11
|
import TranscriptPanel from './TranscriptPanel'
|
|
12
12
|
import TranscriptModal from './TranscriptModal'
|
|
13
13
|
import VisualTrackRow from './VisualTrackRow'
|
|
14
|
+
import CaptionTrackRow from './CaptionTrackRow'
|
|
14
15
|
import { deleteSelection, toggleSelection } from './multiSelectOps'
|
|
16
|
+
import type { CaptionEditPatch } from './makeCaptionEdit'
|
|
15
17
|
|
|
16
18
|
interface TimelineProps {
|
|
17
19
|
project: Project
|
|
@@ -25,6 +27,13 @@ interface TimelineProps {
|
|
|
25
27
|
/** Unified selection — covers both visual items and audio tracks. */
|
|
26
28
|
selectedIds?: string[]
|
|
27
29
|
onSelectIds?: (ids: string[]) => void
|
|
30
|
+
/** Selected caption segment id — shared with the preview's selection box.
|
|
31
|
+
* Mutually exclusive with `selectedIds` (see CaptionTrackRow). */
|
|
32
|
+
selectedCaptionId?: string | null
|
|
33
|
+
onSelectCaption?: (id: string | null) => void
|
|
34
|
+
/** Commit a caption segment patch — text edit or edge-drag retime. Threaded
|
|
35
|
+
* straight to CaptionTrackRow (see makeCaptionEdit.ts). */
|
|
36
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
28
37
|
onSplit?: (at: number) => void
|
|
29
38
|
onCut?: (cut: { start: number; end: number }) => void
|
|
30
39
|
onInspectClip?: (id: string) => void
|
|
@@ -57,12 +66,22 @@ interface TimelineProps {
|
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
|
|
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) {
|
|
69
|
+
export default function Timeline({ project, clock, onProjectChange, onCaptionEdit, onOverlayEdit, onEditOverlay, selectedIds = [], onSelectIds, selectedCaptionId = null, onSelectCaption, onCaptionSegmentChange, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
|
|
61
70
|
const primarySelectedId = selectedIds[0] ?? null
|
|
62
71
|
|
|
63
72
|
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
73
|
+
// Also enforces the item→caption half of the two selection models' mutual
|
|
74
|
+
// exclusivity: selecting a real item clears `selectedCaptionId`, and so does
|
|
75
|
+
// clicking empty track space (id === null) — otherwise a "deselect all" click
|
|
76
|
+
// would clear the item handles but leave the caption's preview handles up,
|
|
77
|
+
// and since nothing else clears caption selection there would be no way to
|
|
78
|
+
// put a caption down at all. The other half (caption select clears
|
|
79
|
+
// `selectedIds`) lives in VideoEditor's wrapped `onSelectCaption`, since a
|
|
80
|
+
// caption can be selected from the preview too — outside Timeline entirely —
|
|
81
|
+
// not just from CaptionTrackRow.
|
|
64
82
|
function handleSelectItem(id: string | null, additive: boolean) {
|
|
65
83
|
if (!onSelectIds) return
|
|
84
|
+
onSelectCaption?.(null)
|
|
66
85
|
if (id === null) { onSelectIds([]); return }
|
|
67
86
|
onSelectIds(toggleSelection(selectedIds, id, additive))
|
|
68
87
|
}
|
|
@@ -291,6 +310,17 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
|
|
|
291
310
|
<span>ffmpeg render — overlays are preview only, final text is burned by ffmpeg</span>
|
|
292
311
|
</div>
|
|
293
312
|
)}
|
|
313
|
+
{/* ── Caption track — its own row above the visual tracks. NOT part of
|
|
314
|
+
tracks[]; reads/writes project.captions directly (see
|
|
315
|
+
CaptionTrackRow's file header for the special-track rationale). ── */}
|
|
316
|
+
<CaptionTrackRow
|
|
317
|
+
captionTrack={captionTrack}
|
|
318
|
+
fps={project.settings?.fps ?? 30}
|
|
319
|
+
selectedCaptionId={selectedCaptionId}
|
|
320
|
+
onSelectCaption={onSelectCaption}
|
|
321
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
322
|
+
/>
|
|
323
|
+
|
|
294
324
|
{[...allTracks].reverse().map((trackItems, reversedIdx) => {
|
|
295
325
|
const trackIdx = allTracks.length - 1 - reversedIdx
|
|
296
326
|
return (
|
|
@@ -388,7 +418,6 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
|
|
|
388
418
|
clock={clock}
|
|
389
419
|
project={project}
|
|
390
420
|
captionTrack={captionTrack}
|
|
391
|
-
onProjectChange={onProjectChange}
|
|
392
421
|
onCaptionEdit={onCaptionEdit}
|
|
393
422
|
onClose={() => setTranscriptModalOpen(false)}
|
|
394
423
|
/>
|
|
@@ -9,12 +9,17 @@ interface TranscriptModalProps {
|
|
|
9
9
|
captionTrack: Project['captions'] | undefined
|
|
10
10
|
currentTime: number
|
|
11
11
|
project: Project
|
|
12
|
-
onProjectChange
|
|
12
|
+
// `onProjectChange` deliberately NOT accepted here: `makeCaptionEdit` fires
|
|
13
|
+
// both callbacks it's given with the same updated project (see
|
|
14
|
+
// makeCaptionEdit.ts), and a text edit only fires once (on blur), so there is
|
|
15
|
+
// no live-preview gesture to route through a second channel — only
|
|
16
|
+
// `onCaptionEdit` (the commit path) is needed. Accepting-but-ignoring it would
|
|
17
|
+
// invite a future call site to re-introduce the double-commit bug this fixed.
|
|
13
18
|
onCaptionEdit?: (project: Project) => void
|
|
14
19
|
onClose: () => void
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
export default function TranscriptModal({ captionTrack, currentTime, project,
|
|
22
|
+
export default function TranscriptModal({ captionTrack, currentTime, project, onCaptionEdit, onClose }: TranscriptModalProps) {
|
|
18
23
|
useEffect(() => {
|
|
19
24
|
const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
20
25
|
document.addEventListener('keydown', onKey)
|
|
@@ -50,7 +55,9 @@ export default function TranscriptModal({ captionTrack, currentTime, project, on
|
|
|
50
55
|
>
|
|
51
56
|
<span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
|
|
52
57
|
<span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
|
|
53
|
-
|
|
58
|
+
{/* Commit-only — see the file-header note on why `onProjectChange`
|
|
59
|
+
isn't accepted/passed here. */}
|
|
60
|
+
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
54
61
|
</span>
|
|
55
62
|
</div>
|
|
56
63
|
)
|
|
@@ -213,7 +213,13 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
213
213
|
{vi > 0 && ' '}
|
|
214
214
|
<span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
|
|
215
215
|
<span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
|
|
216
|
-
|
|
216
|
+
{/* Commit-only: `onProjectChange` is this component's LIVE-preview
|
|
217
|
+
channel (see the fontsize/color controls above) — but a text
|
|
218
|
+
edit fires once, on blur, and is already a complete, discrete
|
|
219
|
+
change, not a continuous gesture. Passing both callbacks here
|
|
220
|
+
would double-fire `sync.mutate` for one edit (two undo entries,
|
|
221
|
+
two queued saves); `onCaptionEdit` is the single commit path. */}
|
|
222
|
+
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
217
223
|
</span>
|
|
218
224
|
</span>
|
|
219
225
|
)
|