@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.
@@ -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?: (project: Project) => void
12
+ // `onProjectChange` deliberately NOT accepted here: `makeCaptionEdit` fires
13
+ // both callbacks it's given with the same updated project (see
14
+ // makeCaptionEdit.ts), and a text edit only fires once (on blur), so there is
15
+ // no live-preview gesture to route through a second channel — only
16
+ // `onCaptionEdit` (the commit path) is needed. Accepting-but-ignoring it would
17
+ // invite a future call site to re-introduce the double-commit bug this fixed.
13
18
  onCaptionEdit?: (project: Project) => void
14
19
  onClose: () => void
15
20
  }
16
21
 
17
- export default function TranscriptModal({ captionTrack, currentTime, project, onProjectChange, onCaptionEdit, onClose }: TranscriptModalProps) {
22
+ export default function TranscriptModal({ captionTrack, currentTime, project, onCaptionEdit, onClose }: TranscriptModalProps) {
18
23
  useEffect(() => {
19
24
  const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
20
25
  document.addEventListener('keydown', onKey)
@@ -50,7 +55,9 @@ export default function TranscriptModal({ captionTrack, currentTime, project, on
50
55
  >
51
56
  <span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
52
57
  <span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
53
- <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
58
+ {/* Commit-only — see the file-header note on why `onProjectChange`
59
+ isn't accepted/passed here. */}
60
+ <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
54
61
  </span>
55
62
  </div>
56
63
  )
@@ -213,7 +213,13 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
213
213
  {vi > 0 && ' '}
214
214
  <span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
215
215
  <span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
216
- <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, onProjectChange, onCaptionEdit)} />
216
+ {/* Commit-only: `onProjectChange` is this component's LIVE-preview
217
+ channel (see the fontsize/color controls above) — but a text
218
+ edit fires once, on blur, and is already a complete, discrete
219
+ change, not a continuous gesture. Passing both callbacks here
220
+ would double-fire `sync.mutate` for one edit (two undo entries,
221
+ two queued saves); `onCaptionEdit` is the single commit path. */}
222
+ <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
217
223
  </span>
218
224
  </span>
219
225
  )