@bycrux/editor 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -56,6 +56,14 @@ export {
56
56
  export type { Cut } from './video/cuts'
57
57
  export { getOverlayDesignCanvas } from './video/design-canvas'
58
58
 
59
+ // ── Image tone (HDR image color mapping) ─────────────────────────────────────
60
+ // The picker component is exported so hosts using `onProvideImageTone` can
61
+ // render the same control (variant="header") in their own chrome.
62
+ export { default as ImageToneMenu } from './video/ImageToneMenu'
63
+ export type { ImageToneMenuProps } from './video/ImageToneMenu'
64
+ export { IMAGE_TONES, DEFAULT_IMAGE_TONE } from './video/imageTone'
65
+ export type { ImageTone, ImageToneInfo } from './video/imageTone'
66
+
59
67
  // ── Theme ─────────────────────────────────────────────────────────────────────
60
68
  export { defaultMontajTheme, applyTheme } from './theme'
61
69
 
package/src/schema.ts CHANGED
@@ -46,6 +46,13 @@ export interface CaptionSegment {
46
46
  // transform, not a font-size change, so it scales the background box and
47
47
  // text stroke too and does NOT re-wrap the text. Default 1.
48
48
  scale?: number
49
+ // Per-segment override of the track-level `Captions.color` (base text color
50
+ // only — not the per-style accent fields below). CSS color (hex or named).
51
+ // Absent → inherits the track-level color → the template's own default.
52
+ // Honored only by the JSX browser preview / Puppeteer render path; the
53
+ // ffmpeg `drawtext` render branch has no per-segment concept and keeps
54
+ // reading only the track-level `color`.
55
+ color?: string
49
56
  }
50
57
 
51
58
  export interface Captions {
@@ -208,7 +215,18 @@ export interface Slide {
208
215
  export interface EditorProject {
209
216
  id: string
210
217
  status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
211
- settings: { resolution: [number, number]; fps?: number; brandKit?: string; normalize?: 'eager' | 'lazy' }
218
+ settings: {
219
+ resolution: [number, number]
220
+ fps?: number
221
+ brandKit?: string
222
+ normalize?: 'eager' | 'lazy'
223
+ /** Project working color space (e.g. 'sdr_bt709', 'hdr_hlg'). Written by
224
+ * the render pipeline's smart-detect; read here to gate HDR-only UI. */
225
+ colorSpace?: string
226
+ /** Color mapping for overlay images in HDR renders — see video/imageTone.ts.
227
+ * Absent → the render default ('vivid'). No effect on SDR projects. */
228
+ imageTone?: 'vivid' | 'broadcast' | 'punchy' | 'raw'
229
+ }
212
230
  name?: string | null
213
231
  editingPrompt?: string
214
232
  slides?: Slide[]
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  OverlayElement,
24
24
  Captions,
25
25
  } from './schema'
26
+ import type { ImageTone } from './video/imageTone'
26
27
 
27
28
  // ── Overlay compiler ─────────────────────────────────────────────────────────
28
29
 
@@ -573,6 +574,20 @@ export interface VideoEditorProps<P extends Project = Project> {
573
574
  */
574
575
  onProvideRenderTrigger?: (openRender: () => void) => void
575
576
 
577
+ /**
578
+ * Opt a host OUT of the package's built-in toolbar image-tone button so it
579
+ * can surface the setting in its own chrome (e.g. the top-of-page header).
580
+ * Mirrors `onProvideRenderTrigger`: when provided, the package (a) hides its
581
+ * toolbar control and (b) calls this callback with the current state
582
+ * whenever it changes. The host renders its own control (the package exports
583
+ * `ImageToneMenu` with `variant="header"` for exactly this) and calls
584
+ * `set(tone)` to persist a choice; the editor owns the save path.
585
+ *
586
+ * Called with `null` when the control should not be shown (SDR project: the
587
+ * tone only affects HDR renders). Hosts must render nothing in that case.
588
+ */
589
+ onProvideImageTone?: (api: { value: ImageTone; set: (tone: ImageTone) => void } | null) => void
590
+
576
591
  // ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
577
592
  // The clip/audio inspector and the subcut-regeneration tool read host-only
578
593
  // fields (regenQueue, storyboard, the host's full Project) the package types
@@ -0,0 +1,133 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { ChevronDown, Palette } from 'lucide-react'
3
+ import { IMAGE_TONES, DEFAULT_IMAGE_TONE, type ImageTone } from './imageTone'
4
+ import { TONE_EXAMPLES } from './imageToneExamples'
5
+
6
+ export interface ImageToneMenuProps {
7
+ /** Currently selected tone (undefined resolves to the default). */
8
+ value: ImageTone | undefined
9
+ /** Persist a new tone into project settings. */
10
+ onChange: (tone: ImageTone) => void
11
+ /**
12
+ * Presentation:
13
+ * 'icon' - compact palette icon button, menu opens upward. Fits the
14
+ * editor's bottom toolbar (the package-internal fallback).
15
+ * 'header' - labeled pill showing the current tone, menu opens downward.
16
+ * For host chrome at the top of the page.
17
+ */
18
+ variant?: 'icon' | 'header'
19
+ }
20
+
21
+ /**
22
+ * Popover for choosing the image color mapping of HDR renders.
23
+ *
24
+ * Rendered only for HDR projects (the tone has no effect on SDR renders; the
25
+ * caller gates on settings.colorSpace). Each option shows an example: the same
26
+ * generic photo converted through the actual render pipeline under that mode.
27
+ */
28
+ export default function ImageToneMenu({ value, onChange, variant = 'icon' }: ImageToneMenuProps) {
29
+ const [open, setOpen] = useState(false)
30
+ const rootRef = useRef<HTMLDivElement>(null)
31
+
32
+ const current = value ?? DEFAULT_IMAGE_TONE
33
+ const currentInfo = IMAGE_TONES.find(t => t.id === current) ?? IMAGE_TONES[0]
34
+
35
+ // Close on outside click / Escape.
36
+ useEffect(() => {
37
+ if (!open) return
38
+ const onDown = (e: MouseEvent) => {
39
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
40
+ }
41
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
42
+ document.addEventListener('mousedown', onDown)
43
+ document.addEventListener('keydown', onKey)
44
+ return () => {
45
+ document.removeEventListener('mousedown', onDown)
46
+ document.removeEventListener('keydown', onKey)
47
+ }
48
+ }, [open])
49
+
50
+ const menuPosition = variant === 'header'
51
+ ? 'absolute right-0 top-9'
52
+ : 'absolute right-0 bottom-7'
53
+
54
+ return (
55
+ <div ref={rootRef} className="relative">
56
+ {variant === 'header' ? (
57
+ <button
58
+ onClick={() => setOpen(o => !o)}
59
+ title="How photos and logos are converted for the HDR render"
60
+ aria-haspopup="menu"
61
+ aria-expanded={open}
62
+ className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/80 hover:text-[var(--editor-text)] transition-colors"
63
+ >
64
+ <Palette size={12} className="opacity-70" />
65
+ <span className="opacity-60">Image color:</span>
66
+ <span className="font-semibold">{currentInfo.label}</span>
67
+ <ChevronDown size={12} className="opacity-60" />
68
+ </button>
69
+ ) : (
70
+ <button
71
+ onClick={() => setOpen(o => !o)}
72
+ title="Image color mapping: how photos and logos are converted for the HDR render"
73
+ aria-pressed={open}
74
+ aria-haspopup="menu"
75
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
76
+ open
77
+ ? 'text-violet-400 bg-violet-400/15 hover:bg-violet-400/25'
78
+ : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
79
+ }`}
80
+ >
81
+ <Palette size={12} />
82
+ </button>
83
+ )}
84
+
85
+ {open && (
86
+ <div
87
+ role="menu"
88
+ className={`${menuPosition} z-40 w-[300px] rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1`}
89
+ >
90
+ <p className="px-2 pt-1 pb-1.5 text-[11px] font-semibold text-[var(--editor-text)]/80">
91
+ Image color mapping
92
+ <span className="block font-normal text-[10px] text-[var(--editor-text)]/50">
93
+ How photos and logos are converted for the HDR render.
94
+ </span>
95
+ </p>
96
+ {IMAGE_TONES.map(tone => {
97
+ const active = tone.id === current
98
+ return (
99
+ <button
100
+ key={tone.id}
101
+ role="menuitemradio"
102
+ aria-checked={active}
103
+ onClick={() => { onChange(tone.id); setOpen(false) }}
104
+ className={`flex items-start gap-2.5 rounded-lg p-2 text-left transition-colors border ${
105
+ active
106
+ ? 'border-violet-400/60 bg-violet-400/10'
107
+ : 'border-transparent hover:bg-[var(--editor-text)]/5'
108
+ }`}
109
+ >
110
+ <img
111
+ src={TONE_EXAMPLES[tone.id]}
112
+ alt={`${tone.label} example`}
113
+ className="w-[88px] h-[53px] rounded-md object-cover shrink-0 border border-[var(--editor-border)]"
114
+ />
115
+ <span className="flex flex-col gap-0.5 min-w-0">
116
+ <span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
117
+ {tone.label}
118
+ {tone.id === DEFAULT_IMAGE_TONE && (
119
+ <span className="text-[9px] font-normal px-1 py-px rounded bg-[var(--editor-text)]/10 text-[var(--editor-text)]/55">default</span>
120
+ )}
121
+ </span>
122
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/60">
123
+ {tone.summary}
124
+ </span>
125
+ </span>
126
+ </button>
127
+ )
128
+ })}
129
+ </div>
130
+ )}
131
+ </div>
132
+ )
133
+ }
@@ -15,6 +15,8 @@ import { createPlaybackClock, type PlaybackClock } from './playback-clock'
15
15
  import type { OverlayChanges } from './preview/useDragOverlay'
16
16
  import VersionPanel from './VersionPanel'
17
17
  import RenderModal from './RenderModal'
18
+ import ImageToneMenu from './ImageToneMenu'
19
+ import type { ImageTone } from './imageTone'
18
20
  import CaptionRegenModal from './CaptionRegenModal'
19
21
  import OverlayPropsModal from './preview/OverlayPropsModal'
20
22
 
@@ -96,6 +98,7 @@ export default function VideoEditor<P extends Project = Project>({
96
98
  regenEnabled,
97
99
  isClipQueued,
98
100
  onProvideRenderTrigger,
101
+ onProvideImageTone,
99
102
  }: Props<P>) {
100
103
  const emit = onProjectChange ?? (() => {})
101
104
 
@@ -192,6 +195,7 @@ export default function VideoEditor<P extends Project = Project>({
192
195
  regenEnabled={regenEnabled}
193
196
  isClipQueued={isClipQueued}
194
197
  onProvideRenderTrigger={onProvideRenderTrigger}
198
+ onProvideImageTone={onProvideImageTone}
195
199
  />
196
200
  </div>
197
201
  )
@@ -221,6 +225,7 @@ interface SurfaceProps<P extends Project> {
221
225
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
222
226
  resolveFilePath: (path: string) => string
223
227
  onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
228
+ onProvideImageTone?: VideoEditorProps<P>['onProvideImageTone']
224
229
  }
225
230
 
226
231
  function PendingSurface<P extends Project>({
@@ -368,6 +373,7 @@ function ReviewSurface<P extends Project>({
368
373
  regenEnabled,
369
374
  isClipQueued,
370
375
  onProvideRenderTrigger,
376
+ onProvideImageTone,
371
377
  }: SurfaceProps<P> & {
372
378
  emit: (p: P) => void
373
379
  renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
@@ -411,6 +417,26 @@ function ReviewSurface<P extends Project>({
411
417
  // canonical and persists it.
412
418
  const { mutate: syncMutate, projectRef: syncProjectRef } = sync
413
419
  const emitRef = useRef(emit); emitRef.current = emit
420
+
421
+ // Persist the HDR image color mapping into project settings. A real user
422
+ // edit: goes through sync.mutate so it saves and participates in undo.
423
+ const handleImageToneChange = useCallback((tone: ImageTone) => {
424
+ void syncMutate(() => {
425
+ const cur = syncProjectRef.current
426
+ return { ...cur, settings: { ...cur.settings, imageTone: tone } } as P
427
+ })
428
+ }, [syncMutate, syncProjectRef])
429
+
430
+ // Host-chrome placement of the image-tone setting (mirrors
431
+ // onProvideRenderTrigger): push the current state up whenever it changes,
432
+ // and null for SDR projects so the host hides the control.
433
+ const isHdrProject = !!project.settings?.colorSpace?.startsWith('hdr')
434
+ const currentImageTone = project.settings?.imageTone
435
+ useEffect(() => {
436
+ if (!onProvideImageTone) return
437
+ onProvideImageTone(isHdrProject ? { value: currentImageTone ?? 'vivid', set: handleImageToneChange } : null)
438
+ }, [onProvideImageTone, isHdrProject, currentImageTone, handleImageToneChange])
439
+
414
440
  const openRender = useCallback(() => {
415
441
  const final = { ...syncProjectRef.current, status: 'final' } as P
416
442
  emitRef.current(final)
@@ -726,6 +752,15 @@ function ReviewSurface<P extends Project>({
726
752
  <Pencil size={12} />
727
753
  </button>
728
754
  )}
755
+ {/* Image color mapping. HDR projects only (the tone has no effect on
756
+ SDR renders). Hidden when the host surfaces the setting in its own
757
+ chrome via onProvideImageTone, mirroring the Render button. */}
758
+ {!onProvideImageTone && isHdrProject && (
759
+ <ImageToneMenu
760
+ value={currentImageTone}
761
+ onChange={handleImageToneChange}
762
+ />
763
+ )}
729
764
  {/* Default placement. A host that sets onProvideRenderTrigger renders
730
765
  Render in its own chrome instead, so the toolbar button is hidden. */}
731
766
  {!onProvideRenderTrigger && (
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Image tone modes: how overlay images are color-mapped into HDR renders.
3
+ *
4
+ * Background: HDR (HLG/PQ) projects reinterpret the overlay raster as HDR
5
+ * signal at composite time, which lands CSS graphics (cards, captions) at full
6
+ * signal brightness. Web images are sRGB and must be pre-converted; the tone
7
+ * mode decides how the converted image relates to the graphics around it.
8
+ *
9
+ * Keep ids in sync with lib/normalize_image.py::TONE_MODES and
10
+ * montaj_assets/render/render.js::IMAGE_TONE_MODES.
11
+ */
12
+ export type ImageTone = 'vivid' | 'broadcast' | 'punchy' | 'raw'
13
+
14
+ export const DEFAULT_IMAGE_TONE: ImageTone = 'vivid'
15
+
16
+ export interface ImageToneInfo {
17
+ id: ImageTone
18
+ label: string
19
+ /** One-line summary shown under the label. Plain English, no jargon. */
20
+ summary: string
21
+ /** Secondary detail for tooltips or expanded views. */
22
+ detail: string
23
+ }
24
+
25
+ export const IMAGE_TONES: ImageToneInfo[] = [
26
+ {
27
+ id: 'vivid',
28
+ label: 'Vivid',
29
+ summary: 'True colors at full brightness, matching your cards and captions.',
30
+ detail:
31
+ 'Faithful color conversion with image whites raised to the same level as '
32
+ + 'overlay graphics. The default, and usually what you want.',
33
+ },
34
+ {
35
+ id: 'punchy',
36
+ label: 'Punchy',
37
+ summary: 'The legacy contrast, with the color errors corrected.',
38
+ detail:
39
+ 'Keeps the darker, contrasty image tone older renders had, but fixes the '
40
+ + 'oversaturated hues. Closest to pre-3.9.3 videos without their color bug.',
41
+ },
42
+ {
43
+ id: 'broadcast',
44
+ label: 'Broadcast',
45
+ summary: 'TV-standard brightness (BT.2408). Accurate but dimmer than your graphics.',
46
+ detail:
47
+ 'Maps image white to the 203-nit broadcast graphics level. Technically '
48
+ + 'correct for TV delivery; images read muted next to full-brightness cards.',
49
+ },
50
+ {
51
+ id: 'raw',
52
+ label: 'Raw',
53
+ summary: 'No conversion. The old oversaturated look.',
54
+ detail:
55
+ 'Images are dropped into the HDR frame unconverted, shifting hues and '
56
+ + 'boosting saturation (yellows go orange). The pre-3.9.3 behavior.',
57
+ },
58
+ ]
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED example thumbnails for the image tone picker.
2
+ // A generic synthetic photo (no brands, no people) converted through the
3
+ // real lib/normalize_image.py per tone mode, composited on a raster-white
4
+ // card, reinterpreted as HLG, and tonemapped to SDR for display.
5
+ export const TONE_EXAMPLES: Record<string, string> = {
6
+ vivid: 'data:image/jpeg;base64,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',
7
+ broadcast: 'data:image/jpeg;base64,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',
8
+ punchy: 'data:image/jpeg;base64,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',
9
+ raw: 'data:image/jpeg;base64,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',
10
+ }
@@ -410,6 +410,8 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
410
410
  onProjectChange={onProjectChange}
411
411
  onExpand={() => setTranscriptModalOpen(true)}
412
412
  onRegenerateCaptions={onRegenerateCaptions}
413
+ selectedCaptionId={selectedCaptionId}
414
+ onCaptionSegmentChange={onCaptionSegmentChange}
413
415
  />
414
416
 
415
417
  {/* ── Transcript modal ── */}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import type { Project } from '../../types'
3
3
  import { formatTime } from './utils'
4
4
  import { EditableSegment } from './EditableSegment'
5
- import { makeCaptionEdit } from './makeCaptionEdit'
5
+ import { makeCaptionEdit, type CaptionEditPatch } from './makeCaptionEdit'
6
6
  import { SwatchInput } from '../../ui'
7
7
 
8
8
  // Each caption style reads a different accent-color prop in its render template
@@ -35,9 +35,18 @@ interface TranscriptPanelProps {
35
35
  /** Opens the caption-regeneration modal. Provided only when the host adapter
36
36
  * supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
37
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
38
47
  }
39
48
 
40
- export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
49
+ export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions, selectedCaptionId, onCaptionSegmentChange }: TranscriptPanelProps) {
41
50
  const segs = captionTrack?.segments ?? []
42
51
  const [confirmRemove, setConfirmRemove] = useState(false)
43
52
  const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -132,16 +141,48 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
132
141
  if (!project.captions) return
133
142
  onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
134
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
+
135
176
  return (
136
177
  <div className="flex items-center gap-1.5">
137
178
  <SwatchInput
138
179
  size="sm"
139
180
  showValue={false}
140
- title="Caption text color"
141
- ariaLabel="Caption text color"
142
- value={toHex(captionTrack.color, '#ffffff')}
143
- onChange={v => live({ color: v })}
144
- onCommit={v => commit({ color: v })}
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 })}
145
186
  />
146
187
  {accent && (
147
188
  <SwatchInput
@@ -10,9 +10,14 @@ function makeProject(style: Captions['style'], extra: Partial<Captions> = {}): P
10
10
  return { id: 'p1', captions: { style, segments: [], ...extra } } as unknown as Project
11
11
  }
12
12
 
13
- function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
13
+ function renderPanel(
14
+ style: Captions['style'],
15
+ extra: Partial<Captions> = {},
16
+ opts: { selectedCaptionId?: string | null } = {},
17
+ ) {
14
18
  const onCaptionEdit = vi.fn()
15
19
  const onProjectChange = vi.fn()
20
+ const onCaptionSegmentChange = vi.fn()
16
21
  const project = makeProject(style, extra)
17
22
  render(
18
23
  <TranscriptPanel
@@ -21,10 +26,12 @@ function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
21
26
  currentTime={0}
22
27
  onCaptionEdit={onCaptionEdit}
23
28
  onProjectChange={onProjectChange}
29
+ onCaptionSegmentChange={onCaptionSegmentChange}
30
+ selectedCaptionId={opts.selectedCaptionId ?? null}
24
31
  onExpand={() => {}}
25
32
  />,
26
33
  )
27
- return { onCaptionEdit, onProjectChange }
34
+ return { onCaptionEdit, onProjectChange, onCaptionSegmentChange }
28
35
  }
29
36
 
30
37
  describe('TranscriptPanel caption color controls', () => {
@@ -84,6 +91,76 @@ describe('TranscriptPanel caption color controls', () => {
84
91
  })
85
92
  })
86
93
 
94
+ describe('TranscriptPanel per-segment caption color', () => {
95
+ it('no selection: base swatch is unchanged — still previews/commits the track-level color', () => {
96
+ const { onCaptionEdit, onProjectChange } = renderPanel('karaoke', {
97
+ segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
98
+ })
99
+ const input = screen.getByLabelText('Caption text color') as HTMLInputElement
100
+
101
+ fireEvent.change(input, { target: { value: '#76b900' } })
102
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
103
+ expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
104
+
105
+ fireEvent.blur(input, { target: { value: '#76b900' } })
106
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
107
+ expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
108
+ })
109
+
110
+ it('a selected segment: swatch previews live via onProjectChange (only that segment patched) and commits via onCaptionSegmentChange — never onCaptionEdit', () => {
111
+ const { onCaptionEdit, onProjectChange, onCaptionSegmentChange } = renderPanel('karaoke', {
112
+ color: '#ffffff',
113
+ segments: [
114
+ { id: 'cap-0', text: 'hi', start: 0, end: 2 },
115
+ { id: 'cap-1', text: 'there', start: 2, end: 4 },
116
+ ],
117
+ }, { selectedCaptionId: 'cap-1' })
118
+
119
+ const input = screen.getByLabelText('Selected segment text color') as HTMLInputElement
120
+
121
+ // Live preview — onChange fires on every pick.
122
+ fireEvent.change(input, { target: { value: '#123456' } })
123
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
124
+ const previewed = onProjectChange.mock.calls[0][0]
125
+ expect(previewed.captions.segments[1].color).toBe('#123456')
126
+ expect(previewed.captions.segments[0].color).toBeUndefined() // only the selected segment changes
127
+ expect(previewed.captions.color).toBe('#ffffff') // track-level color untouched
128
+ expect(onCaptionSegmentChange).not.toHaveBeenCalled()
129
+ expect(onCaptionEdit).not.toHaveBeenCalled()
130
+
131
+ // Commit — onBlur fires once, through the segment-patch channel, not
132
+ // the whole-track onCaptionEdit channel. One commit per gesture.
133
+ fireEvent.blur(input, { target: { value: '#123456' } })
134
+ expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
135
+ expect(onCaptionSegmentChange).toHaveBeenCalledWith('cap-1', { color: '#123456' })
136
+ expect(onCaptionEdit).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it("the swatch reflects the selected segment's own color when it has one", () => {
140
+ renderPanel('karaoke', {
141
+ color: '#abcdef',
142
+ segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2, color: '#00ff00' }],
143
+ }, { selectedCaptionId: 'cap-0' })
144
+ expect((screen.getByLabelText('Selected segment text color') as HTMLInputElement).value).toBe('#00ff00')
145
+ })
146
+
147
+ it('the swatch falls back to the track color when the selected segment has none of its own', () => {
148
+ renderPanel('karaoke', {
149
+ color: '#abcdef',
150
+ segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
151
+ }, { selectedCaptionId: 'cap-0' })
152
+ expect((screen.getByLabelText('Selected segment text color') as HTMLInputElement).value).toBe('#abcdef')
153
+ })
154
+
155
+ it('a selectedCaptionId that matches no segment falls back to track-level behavior', () => {
156
+ renderPanel('karaoke', {
157
+ segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2 }],
158
+ }, { selectedCaptionId: 'cap-does-not-exist' })
159
+ expect(screen.getByLabelText('Caption text color')).toBeTruthy()
160
+ expect(screen.queryByLabelText('Selected segment text color')).toBeNull()
161
+ })
162
+ })
163
+
87
164
  describe('TranscriptPanel caption text edits', () => {
88
165
  // Regression: makeCaptionEdit fires every callback it's given with the same
89
166
  // updated project. TranscriptPanel used to pass BOTH onProjectChange and