@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. 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: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void,
48
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void,
29
49
  ) {
30
50
  const [dragState, setDragState] = useState<DragState | null>(null)
31
51
 
@@ -291,15 +291,24 @@ export function useVideoPlayback(
291
291
  applyClipVolume(clip)
292
292
  }, [clips, activeSlot])
293
293
 
294
+ // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
295
+ // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
296
+ // down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
297
+ // to it). onTimeUpdate is the stable clock.set identity.
298
+ const canvasMaxEndRef = useRef(0)
294
299
  useEffect(() => {
295
- if (!isCanvasProject) return
296
300
  const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
297
- const maxEnd = Math.max(
301
+ canvasMaxEndRef.current = Math.max(
298
302
  overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
299
303
  captionEnd,
300
304
  )
305
+ }, [overlayTracks, project.captions])
306
+
307
+ useEffect(() => {
308
+ if (!isCanvasProject) return
301
309
 
302
310
  function tick(ms: number) {
311
+ const maxEnd = canvasMaxEndRef.current
303
312
  if (rafLastMs.current !== null) {
304
313
  const dt = (ms - rafLastMs.current) / 1000
305
314
  const next = Math.min(lastTimeRef.current + dt, maxEnd)
@@ -327,7 +336,7 @@ export function useVideoPlayback(
327
336
  return () => {
328
337
  if (rafRef.current) cancelAnimationFrame(rafRef.current)
329
338
  }
330
- }, [isPlaying, isCanvasProject, overlayTracks, project, onTimeUpdate])
339
+ }, [isPlaying, isCanvasProject, onTimeUpdate])
331
340
 
332
341
  // ── Multi-track audio management ───────────────────────────────────────────
333
342
  // Derive unmuted tracks. The full tracks array is a new reference on every
@@ -5,12 +5,13 @@
5
5
  // cross-lane drag, edge trim, mute toggle, inline volume, delete,
6
6
  // click-to-select, and inspect button.
7
7
 
8
- import { useEffect, useRef, useState } from 'react'
8
+ import { memo, useEffect, useRef, useState } from 'react'
9
9
  import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
10
10
  import type { AudioTrack } from '../../schema'
11
11
  import type { Project } from '../../types'
12
12
  import { pct } from './utils'
13
13
  import { useTimelineContext } from './TimelineContext'
14
+ import PlayheadLine from './PlayheadLine'
14
15
  import { useItemDragDrop } from './useItemDragDrop'
15
16
  import type { Draggable, DragEventContext } from './useItemDragDrop'
16
17
  import AudioWaveformLayer from './AudioWaveformLayer'
@@ -46,7 +47,7 @@ function updateAudioTrack(project: Project, trackId: string, changes: Partial<Au
46
47
  }
47
48
  }
48
49
 
49
- export default function AudioTrackRow({
50
+ function AudioTrackRow({
50
51
  tracks,
51
52
  laneIndex,
52
53
  laneCount,
@@ -61,7 +62,6 @@ export default function AudioTrackRow({
61
62
  }: AudioTrackRowProps) {
62
63
  const {
63
64
  totalDuration,
64
- currentTime,
65
65
  snapBoundaries,
66
66
  scrollRef,
67
67
  overlayDraggedRef,
@@ -78,11 +78,7 @@ export default function AudioTrackRow({
78
78
 
79
79
  return (
80
80
  <div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
81
- {/* Playhead line */}
82
- <div
83
- className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
84
- style={{ left: `${pct(currentTime, totalDuration)}%` }}
85
- />
81
+ <PlayheadLine />
86
82
  {tracks.map(track => (
87
83
  <AudioTrackItem
88
84
  key={track.id}
@@ -139,6 +135,8 @@ export default function AudioTrackRow({
139
135
  )
140
136
  }
141
137
 
138
+ export default memo(AudioTrackRow)
139
+
142
140
  // ── Single audio item within a lane ──────────────────────────────────────────
143
141
 
144
142
  interface AudioTrackItemProps {
@@ -0,0 +1,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
+ }