@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,235 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
1
- import { useTimelineContext } from './TimelineContext'
2
- import { usePlaybackTime } from '../playback-clock'
3
- import { pct } from './utils'
4
-
5
- /** Leaf playhead indicator: the ONLY per-tick subscriber inside track rows.
6
- * Isolating the clock subscription here means VisualTrackRow/AudioTrackRow
7
- * (and everything else in a row) no longer re-render on every playback tick. */
8
- export default function PlayheadLine() {
9
- const { clock, totalDuration } = useTimelineContext()
10
- const currentTime = usePlaybackTime(clock)
11
- if (totalDuration === 0) return null
12
- return (
13
- <div
14
- className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
15
- style={{ left: `${pct(currentTime, totalDuration)}%` }}
16
- />
17
- )
18
- }
@@ -1,70 +0,0 @@
1
- import { useEffect } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import type { Project } from '../../types'
4
- import { formatTime } from './utils'
5
- import { EditableSegment } from './EditableSegment'
6
- import { makeCaptionEdit } from './makeCaptionEdit'
7
-
8
- interface TranscriptModalProps {
9
- captionTrack: Project['captions'] | undefined
10
- currentTime: number
11
- project: Project
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.
18
- onCaptionEdit?: (project: Project) => void
19
- onClose: () => void
20
- }
21
-
22
- export default function TranscriptModal({ captionTrack, currentTime, project, onCaptionEdit, onClose }: TranscriptModalProps) {
23
- useEffect(() => {
24
- const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
25
- document.addEventListener('keydown', onKey)
26
- return () => document.removeEventListener('keydown', onKey)
27
- }, [onClose])
28
-
29
- return createPortal(
30
- <div
31
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
32
- onClick={onClose}
33
- >
34
- <div
35
- className="relative w-full max-w-xl max-h-[70vh] flex flex-col bg-gray-950 border border-gray-800 rounded-lg shadow-2xl mx-4"
36
- onClick={(e) => e.stopPropagation()}
37
- >
38
- {/* Header */}
39
- <div className="flex items-center justify-between px-4 py-3 border-b border-gray-800 shrink-0">
40
- <span className="text-sm font-medium text-gray-200">Transcript</span>
41
- <button
42
- className="text-gray-500 hover:text-white transition-colors text-lg leading-none"
43
- onClick={onClose}
44
- >×</button>
45
- </div>
46
-
47
- {/* Segment list */}
48
- <div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-1.5">
49
- {(captionTrack?.segments ?? []).map((seg, i) => {
50
- const isActive = currentTime >= seg.start && currentTime < seg.end
51
- return (
52
- <div
53
- key={seg.id ?? i}
54
- className={`flex gap-3 items-baseline px-2 py-1 rounded transition-colors ${isActive ? 'bg-white/5' : ''}`}
55
- >
56
- <span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
57
- <span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
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)} />
61
- </span>
62
- </div>
63
- )
64
- })}
65
- </div>
66
- </div>
67
- </div>,
68
- document.body
69
- )
70
- }
@@ -1,273 +0,0 @@
1
- import { useEffect, useRef, useState } from 'react'
2
- import type { Project } from '../../types'
3
- import { formatTime } from './utils'
4
- import { EditableSegment } from './EditableSegment'
5
- import { makeCaptionEdit, type CaptionEditPatch } from './makeCaptionEdit'
6
- import { SwatchInput } from '../../ui'
7
-
8
- // Each caption style reads a different accent-color prop in its render template
9
- // (see render/templates/captions/*.jsx). The color control writes whichever field
10
- // the active style uses; `clean` and `word-by-word` have no accent (omitted here).
11
- type CaptionStyle = NonNullable<Project['captions']>['style']
12
- type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
13
- const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
14
- karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
15
- pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
16
- 'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
17
- outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
18
- subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
19
- }
20
-
21
- // The native <input type="color"> only accepts #rrggbb. Stored values are always
22
- // hex once picked; fall back to the style default for unset / non-hex (e.g. an
23
- // rgba() template default) so the swatch never gets an invalid value.
24
- const HEX = /^#[0-9a-f]{6}$/i
25
- const toHex = (v: unknown, fallback: string): string =>
26
- typeof v === 'string' && HEX.test(v) ? v : fallback
27
-
28
- interface TranscriptPanelProps {
29
- project: Project
30
- captionTrack: Project['captions'] | undefined
31
- currentTime: number
32
- onCaptionEdit?: (project: Project) => void
33
- onProjectChange?: (project: Project) => void
34
- onExpand: () => void
35
- /** Opens the caption-regeneration modal. Provided only when the host adapter
36
- * supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
37
- onRegenerateCaptions?: () => void
38
- /** Selected caption segment id (shared with the preview's selection box —
39
- * see CaptionPreview/CaptionTrackRow). When set and it resolves to a real
40
- * segment, the base color swatch below targets that segment's `color`
41
- * instead of the track-level `color`. */
42
- selectedCaptionId?: string | null
43
- /** Commit a single-segment patch — the same channel CaptionTrackRow's
44
- * edge-drag and text edits use (see makeCaptionEdit.ts). Used here as the
45
- * per-segment color swatch's commit path. */
46
- onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
47
- }
48
-
49
- export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions, selectedCaptionId, onCaptionSegmentChange }: TranscriptPanelProps) {
50
- const segs = captionTrack?.segments ?? []
51
- const [confirmRemove, setConfirmRemove] = useState(false)
52
- const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
53
- // Live slider value during a drag — only committed (persisted) on
54
- // pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
55
- const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
56
-
57
- useEffect(() => {
58
- setFontsize(captionTrack?.fontsize ?? 46)
59
- }, [captionTrack?.fontsize])
60
-
61
- useEffect(() => {
62
- return () => {
63
- if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
64
- }
65
- }, [])
66
- // Find active segment index
67
- const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
68
- const nearIdx = activeIdx !== -1 ? activeIdx
69
- : segs.reduce((best, s, i) => s.start <= currentTime ? i : best, -1)
70
- // Vicinity: active ± 2 segments; track start offset for O(1) global index
71
- const vicinityStart = nearIdx !== -1 ? Math.max(0, nearIdx - 2) : 0
72
- const vicinitySegs = nearIdx !== -1
73
- ? segs.slice(vicinityStart, nearIdx + 3)
74
- : segs.slice(0, 3)
75
-
76
- return (
77
- <div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
78
- <div className="flex items-center justify-between mb-2">
79
- <span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
80
- <div className="flex items-center gap-2 flex-wrap justify-end">
81
- {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
82
- const active = captionTrack.style === style
83
- return (
84
- <button
85
- key={style}
86
- className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
87
- active
88
- ? 'bg-purple-600/30 border-purple-500/60 text-purple-300'
89
- : 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
90
- }`}
91
- onClick={() => {
92
- if (!project.captions) return
93
- const updated = { ...project, captions: { ...project.captions, style } }
94
- onCaptionEdit?.(updated)
95
- }}
96
- >
97
- {style}
98
- </button>
99
- )
100
- })}
101
- {captionTrack && (
102
- <div className="flex items-center gap-1">
103
- <input
104
- type="range"
105
- min={28}
106
- max={120}
107
- step={2}
108
- value={fontsize}
109
- className="w-20 sm:w-24 accent-purple-500"
110
- onChange={e => {
111
- if (!project.captions) return
112
- const v = Number(e.target.value)
113
- setFontsize(v)
114
- // Live preview only — cheap local-state update, no save.
115
- onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
116
- }}
117
- onPointerUp={e => {
118
- if (!project.captions) return
119
- const v = Number((e.target as HTMLInputElement).value)
120
- onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
121
- }}
122
- onKeyUp={e => {
123
- if (!project.captions) return
124
- const v = Number((e.target as HTMLInputElement).value)
125
- onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
126
- }}
127
- />
128
- <span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
129
- {fontsize}px
130
- </span>
131
- </div>
132
- )}
133
- {captionTrack && (() => {
134
- const accent = ACCENT[captionTrack.style]
135
- // onChange = live preview only (cheap, no PUT); onBlur commits one save.
136
- const live = (patch: Record<string, string>) => {
137
- if (!project.captions) return
138
- onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
139
- }
140
- const commit = (patch: Record<string, string>) => {
141
- if (!project.captions) return
142
- onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
143
- }
144
-
145
- // Per-segment color: when a real segment is selected, the base
146
- // swatch below targets ITS color instead of the track's. Live
147
- // preview still flows through `onProjectChange` — a locally
148
- // patched project, exactly the channel every other control on
149
- // this panel already uses for live preview — but the commit
150
- // goes through `onCaptionSegmentChange`, the single-segment
151
- // patch channel (see makeCaptionEdit.ts), instead of rewriting
152
- // the whole captions object through `onCaptionEdit`. Each is
153
- // fired from exactly one of SwatchInput's onChange/onCommit, so
154
- // (as with the track-level swatch above) one gesture produces
155
- // exactly one commit — never both channels for the same value.
156
- const selectedSeg = selectedCaptionId
157
- ? segs.find(s => s.id === selectedCaptionId)
158
- : undefined
159
- const liveSegColor = (v: string) => {
160
- if (!project.captions || !selectedSeg) return
161
- onProjectChange?.({
162
- ...project,
163
- captions: {
164
- ...project.captions,
165
- segments: project.captions.segments.map(s =>
166
- s.id === selectedSeg.id ? { ...s, color: v } : s,
167
- ),
168
- },
169
- })
170
- }
171
- const commitSegColor = (v: string) => {
172
- if (!selectedSeg) return
173
- onCaptionSegmentChange?.(selectedSeg.id!, { color: v })
174
- }
175
-
176
- return (
177
- <div className="flex items-center gap-1.5">
178
- <SwatchInput
179
- size="sm"
180
- showValue={false}
181
- title={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
182
- ariaLabel={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
183
- value={toHex(selectedSeg ? (selectedSeg.color ?? captionTrack.color) : captionTrack.color, '#ffffff')}
184
- onChange={v => selectedSeg ? liveSegColor(v) : live({ color: v })}
185
- onCommit={v => selectedSeg ? commitSegColor(v) : commit({ color: v })}
186
- />
187
- {accent && (
188
- <SwatchInput
189
- size="sm"
190
- showValue={false}
191
- title={`Caption ${accent.label.toLowerCase()} color`}
192
- ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
193
- value={toHex(captionTrack[accent.field], accent.def)}
194
- onChange={v => live({ [accent.field]: v })}
195
- onCommit={v => commit({ [accent.field]: v })}
196
- />
197
- )}
198
- </div>
199
- )
200
- })()}
201
- {onRegenerateCaptions && (
202
- <button
203
- className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
204
- onClick={() => onRegenerateCaptions?.()}
205
- >
206
- Regenerate
207
- </button>
208
- )}
209
- {captionTrack && (
210
- <button
211
- className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
212
- confirmRemove
213
- ? 'bg-red-500/20 border-red-500/60 text-red-400'
214
- : 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
215
- }`}
216
- onClick={() => {
217
- if (!confirmRemove) {
218
- setConfirmRemove(true)
219
- if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
220
- removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
221
- return
222
- }
223
- if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
224
- setConfirmRemove(false)
225
- // Persistence needs an explicit `null` to clear the field server-side
226
- // (a live PUT test confirmed `undefined` is dropped from the JSON body).
227
- onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
228
- }}
229
- >
230
- {confirmRemove ? 'Really remove?' : 'Remove'}
231
- </button>
232
- )}
233
- {segs.length > 0 && (
234
- <button
235
- className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
236
- onClick={onExpand}
237
- >
238
- Expand ↑
239
- </button>
240
- )}
241
- </div>
242
- </div>
243
-
244
- <div className="h-10 overflow-y-auto">
245
- {segs.length === 0 ? (
246
- <p className="text-xs text-gray-600 italic">No transcript — captions are generated during the agent pass</p>
247
- ) : (
248
- <p className="text-sm text-gray-700 dark:text-gray-200 leading-relaxed">
249
- {vicinitySegs.map((seg, vi) => {
250
- const i = vicinityStart + vi
251
- const isActive = currentTime >= seg.start && currentTime < seg.end
252
- return (
253
- <span key={seg.id ?? i}>
254
- {vi > 0 && ' '}
255
- <span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
256
- <span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
257
- {/* Commit-only: `onProjectChange` is this component's LIVE-preview
258
- channel (see the fontsize/color controls above) — but a text
259
- edit fires once, on blur, and is already a complete, discrete
260
- change, not a continuous gesture. Passing both callbacks here
261
- would double-fire `sync.mutate` for one edit (two undo entries,
262
- two queued saves); `onCaptionEdit` is the single commit path. */}
263
- <EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
264
- </span>
265
- </span>
266
- )
267
- })}
268
- </p>
269
- )}
270
- </div>
271
- </div>
272
- )
273
- }