@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
@@ -8,19 +8,28 @@ interface CaptionRegenModalProps<P extends Project = Project> {
8
8
  /** Adapter driving the caption-regeneration stream. Must implement
9
9
  * `generateCaptions` — callers gate rendering on its presence. */
10
10
  adapter: EditorAdapter<P>
11
+ /** Caption rows the project has right now (`maxCaptionLane(segments) + 1`,
12
+ * so 1 for a lane-less or empty track). Regeneration replaces the whole
13
+ * track with a single fresh row, so this is the count the warning banner
14
+ * below reports as about to be discarded. */
15
+ existingRowCount: number
11
16
  /** Fired on terminal success with the freshly transcribed caption track. The
12
17
  * caller patches `project.captions` from this; the modal then closes. */
13
18
  onDone: (captions: Captions) => void
14
19
  /** Fired when the modal closes (cancel, error dismiss, or post-done). */
15
20
  onClose: () => void
21
+ /** Editor theme mode — light/dark. The panel and log box follow
22
+ * `--editor-surface`, so log/status hues need to darken in light mode.
23
+ * Absent -> dark, matching every existing caller. */
24
+ mode?: 'light' | 'dark'
16
25
  }
17
26
 
18
- function LogLine({ text }: { text: string }) {
27
+ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark' }) {
19
28
  let color = 'text-[var(--editor-text)]/60'
20
- if (/ready|complete|done|transcribed/i.test(text)) color = 'text-green-400'
21
- else if (/transcrib|detecting|loading|model/i.test(text)) color = 'text-sky-400'
22
- else if (/extract|building|composing/i.test(text)) color = 'text-amber-400'
23
- else if (/error|fail|warn/i.test(text)) color = 'text-red-400'
29
+ if (/ready|complete|done|transcribed/i.test(text)) color = mode === 'light' ? 'text-green-700' : 'text-green-400'
30
+ else if (/transcrib|detecting|loading|model/i.test(text)) color = mode === 'light' ? 'text-sky-700' : 'text-sky-400'
31
+ else if (/extract|building|composing|mixing/i.test(text)) color = mode === 'light' ? 'text-amber-700' : 'text-amber-400'
32
+ else if (/error|fail|warn/i.test(text)) color = mode === 'light' ? 'text-red-600' : 'text-red-400'
24
33
 
25
34
  return (
26
35
  <span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
@@ -29,7 +38,7 @@ function LogLine({ text }: { text: string }) {
29
38
  )
30
39
  }
31
40
 
32
- export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, onDone, onClose }: CaptionRegenModalProps<P>) {
41
+ export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, existingRowCount, onDone, onClose, mode = 'dark' }: CaptionRegenModalProps<P>) {
33
42
  const [logs, setLogs] = useState<string[]>([])
34
43
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
35
44
  const [errorMsg, setError] = useState<string | null>(null)
@@ -124,6 +133,15 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
124
133
  : status === 'done' ? 'Captions regenerated'
125
134
  : 'Caption regeneration failed'}
126
135
  </h2>
136
+ {/* What gets transcribed is not obvious from the button that
137
+ opened this modal, and mute is the one control the editor
138
+ has over it — worth one line here rather than a support
139
+ question about why a clip's audio showed up in the
140
+ captions. Read at generation time only: unmuting later just
141
+ means the next run sees it. */}
142
+ <p className="text-[11px] text-[var(--editor-text)]/50">
143
+ Transcribes everything audible on the timeline. Muted clips and tracks are skipped.
144
+ </p>
127
145
  </div>
128
146
  </div>
129
147
  {status !== 'running' && (
@@ -131,6 +149,16 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
131
149
  )}
132
150
  </div>
133
151
 
152
+ {/* Discard warning — only when there is more than one row to lose.
153
+ Regeneration always replaces project.captions wholesale with a
154
+ single fresh row (see onDone below), so a hand-built second or
155
+ third row (a title card, a call-out) is silently gone otherwise. */}
156
+ {existingRowCount > 1 && (
157
+ <div className={`px-5 py-2 text-[11px] leading-snug border-b border-[var(--editor-border)] ${mode === 'light' ? 'text-amber-700' : 'text-amber-400/90'}`}>
158
+ This will replace all {existingRowCount} caption rows with a single new row.
159
+ </div>
160
+ )}
161
+
134
162
  {/* Log output */}
135
163
  <div className="relative">
136
164
  <button
@@ -148,10 +176,10 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
148
176
  <span className="text-[var(--editor-text)]/40 italic">Starting transcription…</span>
149
177
  )}
150
178
  {logs.map((line, i) => (
151
- <LogLine key={i} text={line} />
179
+ <LogLine key={i} text={line} mode={mode} />
152
180
  ))}
153
181
  {status === 'error' && errorMsg && (
154
- <span className="text-red-400 mt-1">{errorMsg}</span>
182
+ <span className={`mt-1 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{errorMsg}</span>
155
183
  )}
156
184
  </div>
157
185
  </div>
@@ -161,7 +189,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
161
189
  {status === 'running' ? (
162
190
  <button
163
191
  onClick={handleCancel}
164
- className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
192
+ className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
165
193
  >
166
194
  Cancel
167
195
  </button>
@@ -0,0 +1,160 @@
1
+ /**
2
+ * CaptionSpecimen — the live font-and-size specimen strip for the caption
3
+ * styling panel. Shows the ACTIVE WORD from the user's own video (via
4
+ * `captionActiveWord`), rendered in the candidate typeface at a size
5
+ * proportional to the real caption render size, so a font choice is judged
6
+ * against real content and real scale rather than fixed-size placeholder
7
+ * text.
8
+ *
9
+ * This is deliberately NOT a duplicate of `FontFamilyPicker`'s dropdown
10
+ * (src/text/FontPicker.tsx), which already previews every option in its own
11
+ * face, at a small fixed UI size, inside the list itself. This strip is the
12
+ * larger, in-context confirmation of the CURRENT pick — real word, real
13
+ * relative size.
14
+ *
15
+ * Presentational only: takes caption/time/style data in, owns no state
16
+ * beyond the width measurement below, and never touches the project.
17
+ */
18
+ import { useEffect, useRef, useState } from 'react'
19
+ import type { Captions } from '../schema'
20
+ import { activeCaptionWord } from './captionActiveWord'
21
+ import { findFontOption } from '../text/FontPicker'
22
+ import { CAPTION_STYLE_FONT_FAMILY, CAPTION_STYLE_FONT_WEIGHT } from './captionStyleDefaults'
23
+
24
+ // Captions render against a fixed 1080-wide frame — a `fontsize` of 46 is
25
+ // 46px OF THAT 1080-wide frame, not 46 real screen px (see RENDER_W in
26
+ // preview/CaptionPreview.tsx, the source of truth for this constant).
27
+ // Duplicated rather than imported: CaptionPreview.tsx is being edited
28
+ // elsewhere in this change and must not gain a new export for this.
29
+ const RENDER_W = 1080
30
+
31
+ // Readable floor / blow-out ceiling for the specimen word's on-screen size.
32
+ // The floor covers two degenerate cases: before the first ResizeObserver
33
+ // callback fires the measured width is 0, and in a test environment where
34
+ // no callback ever fires (or jsdom, which never lays anything out) it STAYS
35
+ // 0 — both would otherwise compute a 0px, invisible specimen. The ceiling
36
+ // keeps an unexpectedly wide host row from blowing the specimen past a
37
+ // normal caption-panel row height.
38
+ const MIN_SPECIMEN_PX = 11
39
+ const MAX_SPECIMEN_PX = 64
40
+
41
+ export interface CaptionSpecimenProps {
42
+ captions: Captions | undefined
43
+ currentTime: number
44
+ selectedSegmentId?: string
45
+ /** The candidate family being previewed — `captions.fontFamily`, or the
46
+ * style's default when unset. */
47
+ fontFamily?: string
48
+ /** The caption's real render font size, in project pixels (`captions.fontsize`). */
49
+ fontSize: number
50
+ textTransform?: string
51
+ letterSpacing?: string
52
+ /** `captions.fontWeight` — absent means the active style's own designed
53
+ * weight (the browser/template default), so leaving this unset is what
54
+ * the specimen should show for "bold on". */
55
+ fontWeight?: number | string
56
+ }
57
+
58
+ export default function CaptionSpecimen({
59
+ captions,
60
+ currentTime,
61
+ selectedSegmentId,
62
+ fontFamily,
63
+ fontSize,
64
+ textTransform,
65
+ letterSpacing,
66
+ fontWeight,
67
+ }: CaptionSpecimenProps) {
68
+ const wrapRef = useRef<HTMLDivElement>(null)
69
+ const [measuredWidth, setMeasuredWidth] = useState(0)
70
+
71
+ // Measure the strip's own width so the specimen can be scaled the same way
72
+ // CaptionPreview scales the caption layer: `measuredWidth / RENDER_W`.
73
+ useEffect(() => {
74
+ const el = wrapRef.current
75
+ if (!el) return
76
+ const obs = new ResizeObserver(([entry]) => {
77
+ setMeasuredWidth(entry.contentRect.width)
78
+ })
79
+ obs.observe(el)
80
+ return () => obs.disconnect()
81
+ }, [])
82
+
83
+ const active = activeCaptionWord(captions, currentTime, selectedSegmentId)
84
+
85
+ // Mirrors captionActiveWord's own `currentTime >= seg.start && currentTime
86
+ // < seg.end` predicate (captionActiveWord.ts) rather than importing a
87
+ // helper for it — used only to tell whether the resolved word came from
88
+ // directly under the playhead (some segment is active) or from the
89
+ // selectedSegmentId fallback (activeCaptionWord only reaches for that
90
+ // fallback when no segment is active at all).
91
+ const anySegmentActive = (captions?.segments ?? []).some(
92
+ (seg) => currentTime >= seg.start && currentTime < seg.end,
93
+ )
94
+ const isFallback = !!active && !anySegmentActive
95
+
96
+ const scale = measuredWidth / RENDER_W
97
+ const specimenPx = Math.min(MAX_SPECIMEN_PX, Math.max(MIN_SPECIMEN_PX, fontSize * scale))
98
+
99
+ const fontOption = fontFamily ? findFontOption(fontFamily) : null
100
+ const familyLabel = fontFamily ? fontOption?.label ?? 'Custom' : 'Default'
101
+
102
+ // The specimen must show what will ACTUALLY render, not just what was
103
+ // explicitly picked — `fontWeight`/`fontFamily` are absent by default
104
+ // (each style's own designed weight/face), so falling back to the raw
105
+ // props here would always preview weight 400 in the editor's own UI font,
106
+ // never the style's real look. `captionStyleDefaults.ts` is the duplicated
107
+ // source of truth for what each style's template actually defaults to; a
108
+ // style string this table doesn't recognize (or no `captions` at all)
109
+ // resolves to `undefined` here, same as before this fallback existed.
110
+ const styleDefaultWeight = captions ? CAPTION_STYLE_FONT_WEIGHT[captions.style] : undefined
111
+ const styleDefaultFamily = captions ? CAPTION_STYLE_FONT_FAMILY[captions.style] : undefined
112
+ const resolvedFontWeight = fontWeight ?? styleDefaultWeight
113
+ const resolvedFontFamily = fontFamily ?? styleDefaultFamily
114
+
115
+ return (
116
+ <div
117
+ ref={wrapRef}
118
+ data-testid="caption-specimen"
119
+ className="flex flex-col gap-1 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 py-2"
120
+ >
121
+ <div className="flex items-center justify-between gap-2">
122
+ {active ? (
123
+ <span
124
+ data-testid="caption-specimen-word"
125
+ className="flex-1 min-w-0 truncate leading-tight text-[var(--editor-text)]"
126
+ style={{
127
+ fontFamily: resolvedFontFamily,
128
+ fontSize: `${specimenPx}px`,
129
+ fontWeight: resolvedFontWeight,
130
+ textTransform,
131
+ letterSpacing,
132
+ }}
133
+ >
134
+ {active.word}
135
+ </span>
136
+ ) : (
137
+ <span
138
+ data-testid="caption-specimen-word"
139
+ className="flex-1 min-w-0 truncate text-xs italic text-[var(--editor-text)] opacity-50"
140
+ >
141
+ Move the playhead over a caption to preview it
142
+ </span>
143
+ )}
144
+ {isFallback && (
145
+ <span className="shrink-0 rounded border border-[var(--editor-border)] px-1 py-px text-[9px] text-[var(--editor-text)] opacity-60">
146
+ Selected caption
147
+ </span>
148
+ )}
149
+ </div>
150
+
151
+ {/* Family + size stay visible in the empty state too, so the row does
152
+ not change height when the playhead moves on/off a caption. */}
153
+ <div className="flex items-center gap-1.5 text-[10px] text-[var(--editor-text)] opacity-50">
154
+ <span className="truncate">{familyLabel}</span>
155
+ <span aria-hidden="true">&middot;</span>
156
+ <span className="font-mono">{fontSize}px</span>
157
+ </div>
158
+ </div>
159
+ )
160
+ }