@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 +1 -1
- package/src/index.ts +8 -0
- package/src/schema.ts +19 -1
- package/src/types.ts +15 -0
- package/src/video/ImageToneMenu.tsx +133 -0
- package/src/video/VideoEditor.tsx +35 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/timeline/Timeline.tsx +2 -0
- package/src/video/timeline/TranscriptPanel.tsx +48 -7
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +79 -2
package/package.json
CHANGED
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: {
|
|
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=
|
|
141
|
-
ariaLabel=
|
|
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(
|
|
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
|