@bycrux/editor 0.12.0 → 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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
// Sidebar caption editor (SP5-captions Phase 5). Replaces the bottom
|
|
2
|
+
// TranscriptPanel + its "Expand" TranscriptModal: instead of a two-line
|
|
3
|
+
// vicinity strip at the bottom of the timeline pane, every caption segment
|
|
4
|
+
// lives in a searchable, numbered list, under three sub-tabs — "Format" (the
|
|
5
|
+
// track-level size/color/font controls, the default), "Styles" (the live
|
|
6
|
+
// style gallery, CaptionStyleGallery.tsx) and "Captions" (the list itself) —
|
|
7
|
+
// so the list stays the prominent thing in a ~300px-wide column. Mounted by
|
|
8
|
+
// BOTH VideoEditor layouts: the CapCut left panel's Captions tab and the
|
|
9
|
+
// classic right rail.
|
|
10
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
11
|
+
import { AlignCenter, AlignLeft, AlignRight, Loader2, RefreshCw, Search, Trash2 } from 'lucide-react'
|
|
12
|
+
import type { Project, OverlayFactory } from '../types'
|
|
13
|
+
import type { Captions } from '../schema'
|
|
14
|
+
import type { PlaybackClock } from './playback-clock'
|
|
15
|
+
import type { CaptionEditPatch } from './timeline/makeCaptionEdit'
|
|
16
|
+
import { EditableSegment } from './timeline/EditableSegment'
|
|
17
|
+
import { formatTime } from './timeline/utils'
|
|
18
|
+
import { NumberField, Slider, stepValue, SwatchInput } from '../ui'
|
|
19
|
+
import { usePersistentState } from '../ui/usePersistentState'
|
|
20
|
+
import { groupCaptionLanes, laneOf } from './captionLanes'
|
|
21
|
+
import { FontFamilyPicker, findFontOption } from '../text/FontPicker'
|
|
22
|
+
import CaptionStyleGallery from './CaptionStyleGallery'
|
|
23
|
+
import TabNav from './panels/TabNav'
|
|
24
|
+
import { CAPTION_STYLE_LETTER_SPACING, CAPTION_STYLE_LINE_HEIGHT, CAPTION_STYLE_TEXT_TRANSFORM } from './captionStyleDefaults'
|
|
25
|
+
|
|
26
|
+
/** Which of the panel's three sub-tabs is showing: the fine formatting
|
|
27
|
+
* controls, the style gallery, or the caption transcript. 'format' is the
|
|
28
|
+
* default — you land on the size/colors/font/case controls, the thing an
|
|
29
|
+
* operator reaches for most once a style is chosen, with the gallery of live
|
|
30
|
+
* style previews one tab over and the full transcript one further. Replaces
|
|
31
|
+
* the retired collapsible "Caption style" subsection; a DIFFERENT key than
|
|
32
|
+
* that collapse's 'montaj.editor.captionListStyleExpanded' (a boolean) so a
|
|
33
|
+
* stale expanded/collapsed value can never be misread as a tab name. */
|
|
34
|
+
const CAPTION_TAB_STORAGE_KEY = 'montaj.editor.captionPanelTab'
|
|
35
|
+
type CaptionPanelTab = 'styles' | 'format' | 'captions'
|
|
36
|
+
/** Anything unrecognised reads as "no stored preference" (→ the default), with
|
|
37
|
+
* ONE explicit migration: before the tab split a single 'style' tab held both
|
|
38
|
+
* the preset chips and the fine controls. A browser carrying that value lands
|
|
39
|
+
* on 'format' — the new default a returning user should see — rather than
|
|
40
|
+
* being silently reset, which is what a plain reject-to-default would do. */
|
|
41
|
+
export const reviveCaptionTab = (raw: unknown): CaptionPanelTab | null => {
|
|
42
|
+
if (raw === 'styles' || raw === 'format' || raw === 'captions') return raw
|
|
43
|
+
if (raw === 'style') return 'format' // migrate the pre-split value
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
const CAPTION_TABS: readonly { value: CaptionPanelTab; label: string }[] = [
|
|
47
|
+
{ value: 'format', label: 'Format' },
|
|
48
|
+
{ value: 'styles', label: 'Styles' },
|
|
49
|
+
{ value: 'captions', label: 'Captions' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
// ── Ported from TranscriptPanel.tsx (that file is being deleted) ──
|
|
53
|
+
// Each caption style reads a different accent-color prop in its render template
|
|
54
|
+
// (see render/templates/captions/*.jsx). The color control writes whichever field
|
|
55
|
+
// the active style uses; `clean` and `word-by-word` have no accent (omitted here).
|
|
56
|
+
type CaptionStyle = NonNullable<Project['captions']>['style']
|
|
57
|
+
type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
|
|
58
|
+
const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
|
|
59
|
+
karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
|
|
60
|
+
pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
|
|
61
|
+
'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
62
|
+
outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
63
|
+
subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The native <input type="color"> only accepts #rrggbb. Stored values are always
|
|
67
|
+
// hex once picked; fall back to the style default for unset / non-hex (e.g. an
|
|
68
|
+
// rgba() template default) so the swatch never gets an invalid value.
|
|
69
|
+
const HEX = /^#[0-9a-f]{6}$/i
|
|
70
|
+
const toHex = (v: unknown, fallback: string): string =>
|
|
71
|
+
typeof v === 'string' && HEX.test(v) ? v : fallback
|
|
72
|
+
|
|
73
|
+
// ── Text styling: case + alignment button groups ──
|
|
74
|
+
// Glyph labels only (the rail is ~300px), so each carries an aria-label — "TT"
|
|
75
|
+
// is not a word a screen reader can announce usefully.
|
|
76
|
+
const CASES = [
|
|
77
|
+
{ value: 'uppercase', glyph: 'TT', label: 'Uppercase' },
|
|
78
|
+
{ value: 'lowercase', glyph: 'tt', label: 'Lowercase' },
|
|
79
|
+
{ value: 'capitalize', glyph: 'Tt', label: 'Capitalize' },
|
|
80
|
+
] as const
|
|
81
|
+
|
|
82
|
+
const ALIGNMENTS = [
|
|
83
|
+
{ value: 'left', Icon: AlignLeft, label: 'Align left' },
|
|
84
|
+
{ value: 'center', Icon: AlignCenter, label: 'Align center' },
|
|
85
|
+
{ value: 'right', Icon: AlignRight, label: 'Align right' },
|
|
86
|
+
] as const
|
|
87
|
+
|
|
88
|
+
/** The panel's one chip look, worn by every toggle on the Format tab (Bold,
|
|
89
|
+
* case, alignment). The inactive foreground is
|
|
90
|
+
* `text-[var(--editor-text)] opacity-60` rather than
|
|
91
|
+
* `text-[var(--editor-text)]/60` — Tailwind cannot generate an opacity
|
|
92
|
+
* modifier on an arbitrary var() color, so that class is a silent no-op (see
|
|
93
|
+
* the longer note on the row index span at the bottom of this file). The
|
|
94
|
+
* row-filter chips on the Captions tab hand-roll the same look with the older
|
|
95
|
+
* `/N` classes; that predates the finding above and is left alone. */
|
|
96
|
+
const chipClass = (active: boolean): string =>
|
|
97
|
+
`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
98
|
+
active
|
|
99
|
+
? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
|
|
100
|
+
: 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)] opacity-60 hover:opacity-100'
|
|
101
|
+
}`
|
|
102
|
+
|
|
103
|
+
/** One labeled row on the Format tab — a label on the left, its control filling
|
|
104
|
+
* the rest. Worn by every row there so the whole tab reads as a two-column
|
|
105
|
+
* grid (label · control) rather than a stack of loose widgets. */
|
|
106
|
+
const rowClass = 'flex items-center gap-1.5'
|
|
107
|
+
/** The label cell of a `rowClass` row. The width is FIXED rather than intrinsic
|
|
108
|
+
* — that is the entire point: it is what makes the labels a column, so every
|
|
109
|
+
* control starts at the same x instead of "Font" and "Alignment" indenting
|
|
110
|
+
* their controls differently. `w-16` (64px) clears the longest label
|
|
111
|
+
* ("Alignment") at this size and still leaves the ~300px rail enough room for
|
|
112
|
+
* the widest control (the font picker + Bold).
|
|
113
|
+
*
|
|
114
|
+
* `opacity-60`, NOT `text-[var(--editor-text)]/60` — an opacity modifier on an
|
|
115
|
+
* arbitrary var() color is a silent no-op; see the note on `chipClass` above.
|
|
116
|
+
*
|
|
117
|
+
* These labels are PRESENTATIONAL: plain sibling spans, never a
|
|
118
|
+
* `<label htmlFor>`. A bound <label> REPLACES the control's accessible name,
|
|
119
|
+
* which would silently rewrite "Caption font size" to "Font size" — losing the
|
|
120
|
+
* disambiguation a screen reader needs in a rail that also carries overlay and
|
|
121
|
+
* clip controls. Every control here keeps its own `aria-label` unchanged, and
|
|
122
|
+
* a span that would then repeat those words as loose text beside it carries
|
|
123
|
+
* `aria-hidden` so they aren't announced twice. */
|
|
124
|
+
const rowLabelClass = 'w-16 shrink-0 text-xs text-[var(--editor-text)] opacity-60'
|
|
125
|
+
|
|
126
|
+
/** Display value for the letter-spacing box: the numeric part of a stored CSS
|
|
127
|
+
* length, read as em. Tolerates absent, '0.02em', '-0.02em', '.02em',
|
|
128
|
+
* ' 0.02 em ' and a bare number. A value stored in some OTHER unit ('2px')
|
|
129
|
+
* deliberately reads as EMPTY rather than as "2" — showing it would silently
|
|
130
|
+
* re-interpret it as 2em the moment the operator nudges the field. */
|
|
131
|
+
function parseEmValue(value: string | undefined): string {
|
|
132
|
+
if (!value) return ''
|
|
133
|
+
const m = /^\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*(?:em)?\s*$/i.exec(value)
|
|
134
|
+
return m ? m[1] : ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Display value for the line-height box. `Captions.lineHeight` is
|
|
138
|
+
* `number | string`; a string is accepted only when it is unitless, which is
|
|
139
|
+
* the form CSS reads as a multiple of the font size and the only form this
|
|
140
|
+
* field writes. Anything else ('120%', '1.3em') reads as empty, same
|
|
141
|
+
* reasoning as above. */
|
|
142
|
+
function parseUnitlessValue(value: number | string | undefined): string {
|
|
143
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''
|
|
144
|
+
if (!value) return ''
|
|
145
|
+
const m = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*$/.exec(value)
|
|
146
|
+
return m ? m[1] : ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Strip float-step noise (0.1 + 0.01 = 0.11000000000000001) before the value
|
|
150
|
+
* is written into the project — and, for letter spacing, before it is
|
|
151
|
+
* stringified into a CSS length that ends up in the saved JSON. */
|
|
152
|
+
const roundTo = (n: number, places: number): number => Number(n.toFixed(places))
|
|
153
|
+
|
|
154
|
+
/** Clamps a typed/stepped value to a field's own range. `NumberField` (see its
|
|
155
|
+
* doc comment) deliberately does NOT bound a typed value against its `min`/
|
|
156
|
+
* `max` — those are a native spinner hint only — so every numeric field in
|
|
157
|
+
* this panel that wants a hard ceiling/floor (font size, letter spacing, line
|
|
158
|
+
* height) applies it here, on the write path, exactly where the retired
|
|
159
|
+
* `StepperField.numeric()` used to. */
|
|
160
|
+
const clampToRange = (n: number, min: number, max: number): number => Math.min(max, Math.max(min, n))
|
|
161
|
+
|
|
162
|
+
/** Font-size floor/ceiling for the typeable box. The `type="range"` slider
|
|
163
|
+
* this replaces was physically stuck at 28-120; a typed value now isn't, on
|
|
164
|
+
* purpose (Sam's ask is "type 52, arrow it up/down" with no artificial
|
|
165
|
+
* slider ceiling) — but 0/negative would break every caption template's
|
|
166
|
+
* layout math, and an unbounded ceiling risks a canvas-filling render that
|
|
167
|
+
* is effectively broken output. `8` sits well BELOW the old 28 floor (so the
|
|
168
|
+
* box is strictly more permissive than the slider, never less) while keeping
|
|
169
|
+
* text nominally visible; `400` is generous enough for a full-frame "giant
|
|
170
|
+
* word" caption style on a 1080-wide vertical export without inviting a
|
|
171
|
+
* pathological value. Both are judgement calls, not derived from a template
|
|
172
|
+
* constraint — revisit if a real project needs more. */
|
|
173
|
+
const CAPTION_FONT_SIZE_MIN = 8
|
|
174
|
+
const CAPTION_FONT_SIZE_MAX = 400
|
|
175
|
+
|
|
176
|
+
/** A double-click-to-edit request from the canvas timeline (Phase 6). `nonce`
|
|
177
|
+
* changes on every request even if `id` repeats, so re-double-clicking the
|
|
178
|
+
* same segment re-triggers the scroll + focus instead of being a no-op. */
|
|
179
|
+
export interface CaptionEditFocusRequest {
|
|
180
|
+
id: string
|
|
181
|
+
nonce: number
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Builds the NEXT edit-focus request from whatever VideoEditor's `editFocusId`
|
|
185
|
+
* state currently holds. Pure and exported (rather than inlined at the one
|
|
186
|
+
* call site, VideoEditor.tsx's `handleEditCaption`) so the increment can be
|
|
187
|
+
* unit-tested directly instead of only through source-text matching, which
|
|
188
|
+
* pins formatting rather than behaviour and would miss a wrong-operator bug.
|
|
189
|
+
* The nonce climbs off the PREVIOUS request regardless of id — not a
|
|
190
|
+
* per-id counter — so double-clicking the SAME caption twice always produces
|
|
191
|
+
* two distinct requests (see the doc comment on the type above). */
|
|
192
|
+
export function nextEditFocus(prev: CaptionEditFocusRequest | null, id: string): CaptionEditFocusRequest {
|
|
193
|
+
return { id, nonce: (prev?.nonce ?? 0) + 1 }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface CaptionListPanelProps {
|
|
197
|
+
captionTrack: Project['captions'] | undefined
|
|
198
|
+
project: Project
|
|
199
|
+
/** Playhead time — drives the active-segment highlight. Supplied by a
|
|
200
|
+
* clock-subscribing wrapper at the mount site (see VideoEditor.tsx's
|
|
201
|
+
* `CaptionListPanelWithClock`, mirroring Timeline.tsx's
|
|
202
|
+
* `TranscriptPanelWithClock`) so only this leaf re-renders per tick. */
|
|
203
|
+
currentTime: number
|
|
204
|
+
/** Unified selection (D1: caption ids share `selectedIds` with clips and
|
|
205
|
+
* audio). Used only to derive which segment (if any) is selected. */
|
|
206
|
+
selectedIds: string[]
|
|
207
|
+
onSelectCaption: (id: string | null) => void
|
|
208
|
+
/** Commit a single-segment patch — text edits and the per-segment color
|
|
209
|
+
* swatch. */
|
|
210
|
+
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
211
|
+
/** Whole-project commit — style/fontsize/track-color changes and "Remove
|
|
212
|
+
* all". */
|
|
213
|
+
onCaptionEdit?: (project: Project) => void
|
|
214
|
+
/** Live preview only, no save, no undo entry — fontsize drag and
|
|
215
|
+
* track/segment color picks fire this on every intermediate value. */
|
|
216
|
+
onProjectChange?: (project: Project) => void
|
|
217
|
+
/** Delete one segment by id, via the whole-track channel. There is no add
|
|
218
|
+
* counterpart (R4): captions come from transcription, and Regenerate
|
|
219
|
+
* rebuilds the whole track. */
|
|
220
|
+
onCaptionSegmentDelete?: (segmentId: string) => void
|
|
221
|
+
/** Opens the caption-regeneration modal. Provided only when the host
|
|
222
|
+
* adapter supports `generateCaptions`; absent → the button is hidden. */
|
|
223
|
+
onRegenerateCaptions?: () => void
|
|
224
|
+
/** Whether a generate/regenerate job is currently streaming (the
|
|
225
|
+
* `CaptionRegenModal` is open and running). Disables the trigger so it
|
|
226
|
+
* can't be fired twice from this panel; the modal itself owns the
|
|
227
|
+
* progress UI, so this panel does nothing more than go inert. Optional
|
|
228
|
+
* and defaults to falsy — a host that never passes it (or that stops
|
|
229
|
+
* passing `true` once a job ends, including a failed one) leaves the
|
|
230
|
+
* trigger enabled exactly as it is today. */
|
|
231
|
+
captionsGenerating?: boolean
|
|
232
|
+
fps: number
|
|
233
|
+
/** Imperative seek target for a row click (see the half-frame comment
|
|
234
|
+
* below) — separate from `currentTime`, which only drives the highlight. */
|
|
235
|
+
clock: PlaybackClock
|
|
236
|
+
/** Phase 6 wires the canvas double-click into this; scrolls the target row
|
|
237
|
+
* into view and focuses its `EditableSegment` when `nonce` changes. */
|
|
238
|
+
editFocusId?: CaptionEditFocusRequest | null
|
|
239
|
+
/** Compile a JSX overlay template into an `OverlayFactory` (sourced from
|
|
240
|
+
* `adapter.compileOverlay`, same as `CaptionPreview`). Optional — absent
|
|
241
|
+
* ⇒ the style gallery falls back to a static specimen card instead of a
|
|
242
|
+
* live-rendered one, so hosts without a compiler (e.g. Hub/LP) degrade
|
|
243
|
+
* gracefully rather than breaking. */
|
|
244
|
+
compileOverlay?: (src: string) => Promise<OverlayFactory>
|
|
245
|
+
/** Resolve a caption style name to the template identifier `compileOverlay`
|
|
246
|
+
* expects (sourced from `adapter.resolveCaptionTemplate`). Optional, and
|
|
247
|
+
* only meaningful alongside `compileOverlay` — without either, the gallery
|
|
248
|
+
* falls back to the static specimen card. */
|
|
249
|
+
resolveCaptionTemplate?: (style: string) => string
|
|
250
|
+
/** Editor theme mode — light/dark. Only affects the delete/"remove all"
|
|
251
|
+
* destructive hues (red-400 is sub-AA on a light `--editor-surface`).
|
|
252
|
+
* Absent -> dark, matching every existing caller. */
|
|
253
|
+
mode?: 'light' | 'dark'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function CaptionListPanel({
|
|
257
|
+
captionTrack,
|
|
258
|
+
project,
|
|
259
|
+
currentTime,
|
|
260
|
+
selectedIds,
|
|
261
|
+
onSelectCaption,
|
|
262
|
+
onCaptionSegmentChange,
|
|
263
|
+
onCaptionEdit,
|
|
264
|
+
onProjectChange,
|
|
265
|
+
onCaptionSegmentDelete,
|
|
266
|
+
onRegenerateCaptions,
|
|
267
|
+
captionsGenerating,
|
|
268
|
+
fps,
|
|
269
|
+
clock,
|
|
270
|
+
editFocusId,
|
|
271
|
+
compileOverlay,
|
|
272
|
+
resolveCaptionTemplate,
|
|
273
|
+
mode = 'dark',
|
|
274
|
+
}: CaptionListPanelProps) {
|
|
275
|
+
const segs = captionTrack?.segments ?? []
|
|
276
|
+
|
|
277
|
+
// Nothing to show and nothing to offer — no captions yet, and the host
|
|
278
|
+
// doesn't support generating them from here either.
|
|
279
|
+
if (segs.length === 0 && !captionTrack && !onRegenerateCaptions) return null
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<CaptionListPanelBody
|
|
283
|
+
captionTrack={captionTrack}
|
|
284
|
+
project={project}
|
|
285
|
+
currentTime={currentTime}
|
|
286
|
+
selectedIds={selectedIds}
|
|
287
|
+
onSelectCaption={onSelectCaption}
|
|
288
|
+
onCaptionSegmentChange={onCaptionSegmentChange}
|
|
289
|
+
onCaptionEdit={onCaptionEdit}
|
|
290
|
+
onProjectChange={onProjectChange}
|
|
291
|
+
onCaptionSegmentDelete={onCaptionSegmentDelete}
|
|
292
|
+
onRegenerateCaptions={onRegenerateCaptions}
|
|
293
|
+
captionsGenerating={captionsGenerating}
|
|
294
|
+
fps={fps}
|
|
295
|
+
clock={clock}
|
|
296
|
+
editFocusId={editFocusId}
|
|
297
|
+
compileOverlay={compileOverlay}
|
|
298
|
+
resolveCaptionTemplate={resolveCaptionTemplate}
|
|
299
|
+
mode={mode}
|
|
300
|
+
/>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Split from the default export so the early `return null` above stays a
|
|
305
|
+
// clean guard clause — the hooks below can't run conditionally, and this
|
|
306
|
+
// component only mounts once that guard has already passed.
|
|
307
|
+
function CaptionListPanelBody({
|
|
308
|
+
captionTrack,
|
|
309
|
+
project,
|
|
310
|
+
currentTime,
|
|
311
|
+
selectedIds,
|
|
312
|
+
onSelectCaption,
|
|
313
|
+
onCaptionSegmentChange,
|
|
314
|
+
onCaptionEdit,
|
|
315
|
+
onProjectChange,
|
|
316
|
+
onCaptionSegmentDelete,
|
|
317
|
+
onRegenerateCaptions,
|
|
318
|
+
captionsGenerating,
|
|
319
|
+
fps,
|
|
320
|
+
clock,
|
|
321
|
+
editFocusId,
|
|
322
|
+
compileOverlay,
|
|
323
|
+
resolveCaptionTemplate,
|
|
324
|
+
mode = 'dark',
|
|
325
|
+
}: CaptionListPanelProps) {
|
|
326
|
+
const segs = captionTrack?.segments ?? []
|
|
327
|
+
const [search, setSearch] = useState('')
|
|
328
|
+
// `null` = the "All" chip (no row filter). Set to a lane index by clicking
|
|
329
|
+
// a "Row N" chip. Lives beside `search` rather than folded into it — they
|
|
330
|
+
// compose (both apply) instead of one being a mode that disables the other.
|
|
331
|
+
const [rowFilter, setRowFilter] = useState<number | null>(null)
|
|
332
|
+
const [tab, setTab] = usePersistentState<CaptionPanelTab>(CAPTION_TAB_STORAGE_KEY, 'format', reviveCaptionTab)
|
|
333
|
+
|
|
334
|
+
// ── Remove all (confirm-twice, ported from TranscriptPanel) ──
|
|
335
|
+
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
336
|
+
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
return () => {
|
|
339
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
340
|
+
}
|
|
341
|
+
}, [])
|
|
342
|
+
|
|
343
|
+
// Live value while dragging the font-size slider or typing into its box —
|
|
344
|
+
// previewed on every drag-frame/keystroke, committed (persisted) on
|
|
345
|
+
// release/blur/Enter so mid-gesture updates don't each trigger a server PUT.
|
|
346
|
+
// Both the slider and the box read and write THIS state, so they stay in
|
|
347
|
+
// lockstep and the preview tracks the edit as it happens.
|
|
348
|
+
const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
setFontsize(captionTrack?.fontsize ?? 46)
|
|
351
|
+
}, [captionTrack?.fontsize])
|
|
352
|
+
|
|
353
|
+
// The selected segment, if any — derived from the unified `selectedIds`
|
|
354
|
+
// the same way VideoEditor derives its own `selectedCaptionId` mirror: a
|
|
355
|
+
// marquee can put more than one caption id in there, but only the preview's
|
|
356
|
+
// single selection box (and this swatch) needs to pick one, so take the
|
|
357
|
+
// first that resolves to a real segment.
|
|
358
|
+
const selectedSeg = useMemo(() => {
|
|
359
|
+
if (!captionTrack) return undefined
|
|
360
|
+
const idSet = new Set(selectedIds)
|
|
361
|
+
return captionTrack.segments.find(s => s.id && idSet.has(s.id))
|
|
362
|
+
}, [captionTrack, selectedIds])
|
|
363
|
+
|
|
364
|
+
// One group per lane (0..maxLane, holes included) via the same module the
|
|
365
|
+
// canvas timeline reads lanes through — see captionLanes.ts. `multiLane`
|
|
366
|
+
// gates ONLY the header/chip UI below; the grouping, search and index math
|
|
367
|
+
// run unconditionally so a single-lane project (every project has exactly
|
|
368
|
+
// one lane until Phase 3/4's drag gesture is used) takes the exact same
|
|
369
|
+
// code path as a multi-lane one, rather than a separate `length === 1`
|
|
370
|
+
// branch — with one group, its segments ARE `segs` in original order, so
|
|
371
|
+
// the per-group index below already equals the pre-Phase-5 global index.
|
|
372
|
+
const captionGroups = useMemo(() => (segs.length > 0 ? groupCaptionLanes(segs) : []), [segs])
|
|
373
|
+
const multiLane = captionGroups.length > 1
|
|
374
|
+
|
|
375
|
+
// Chip filter and search compose: a group is dropped by the chip (lane
|
|
376
|
+
// mismatch) before its rows are tested against the search query, and a row
|
|
377
|
+
// survives only if it passes both. Per-group index restarts at 0 within
|
|
378
|
+
// each group (group.segments is that lane's slice, in original order), so
|
|
379
|
+
// in the single-lane case it's identical to the old global `segs` index.
|
|
380
|
+
// A group with zero surviving rows is dropped entirely rather than kept
|
|
381
|
+
// with an empty body — this way search never leaves a dangling "Row N"
|
|
382
|
+
// header with nothing under it, and neither does a would-be hole lane.
|
|
383
|
+
const visibleGroups = useMemo(() => {
|
|
384
|
+
const q = search.trim().toLowerCase()
|
|
385
|
+
return captionGroups
|
|
386
|
+
.filter(g => rowFilter === null || g.lane === rowFilter)
|
|
387
|
+
.map(g => ({
|
|
388
|
+
lane: g.lane,
|
|
389
|
+
rows: g.segments
|
|
390
|
+
.map((seg, index) => ({ seg, index }))
|
|
391
|
+
.filter(({ seg }) => !q || seg.text.toLowerCase().includes(q)),
|
|
392
|
+
}))
|
|
393
|
+
.filter(g => g.rows.length > 0)
|
|
394
|
+
}, [captionGroups, rowFilter, search])
|
|
395
|
+
const totalVisible = useMemo(() => visibleGroups.reduce((sum, g) => sum + g.rows.length, 0), [visibleGroups])
|
|
396
|
+
|
|
397
|
+
// ── editFocusId: scroll + focus the target row (Phase 6's canvas
|
|
398
|
+
// double-click wires into this). `lastHandledNonce` guards against
|
|
399
|
+
// re-focusing on every unrelated re-render once a given request has been
|
|
400
|
+
// served — only a NEW nonce (even for the same segment id) re-triggers it. ──
|
|
401
|
+
const rowRefs = useRef(new Map<string, HTMLDivElement>())
|
|
402
|
+
const lastHandledNonceRef = useRef<number | null>(null)
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!editFocusId || editFocusId.nonce === lastHandledNonceRef.current) return
|
|
405
|
+
const seg = segs.find(s => s.id === editFocusId.id)
|
|
406
|
+
if (!seg) return
|
|
407
|
+
// The transcript rows only render on the "Captions" tab; a canvas
|
|
408
|
+
// double-click that arrives while the "Styles" or "Format" tab is showing
|
|
409
|
+
// has to flip there first. Same one-clear-per-pass shape as the
|
|
410
|
+
// search/rowFilter guards below — this effect re-runs (tab is a dep) once
|
|
411
|
+
// the Captions tab has actually mounted the target row, and only then does
|
|
412
|
+
// the lookup below find it. Without this, a double-click on the canvas
|
|
413
|
+
// would silently do nothing whenever the panel was on another tab.
|
|
414
|
+
if (tab !== 'captions') {
|
|
415
|
+
setTab('captions')
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
// A live search filtering the target row out of the DOM would make the
|
|
419
|
+
// row-ref lookup below miss. Clear it and let this effect re-run once the
|
|
420
|
+
// list re-renders with the row present.
|
|
421
|
+
const q = search.trim().toLowerCase()
|
|
422
|
+
if (q && !seg.text.toLowerCase().includes(q)) {
|
|
423
|
+
setSearch('')
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
// Same guard, extended to the row chip: a canvas double-click can target
|
|
427
|
+
// a caption in a lane the active "Row N" chip has filtered out (e.g. a
|
|
428
|
+
// double-click on a row-2 caption while "Row 1" is selected), which would
|
|
429
|
+
// otherwise leave the row-ref lookup below silently missing forever. Each
|
|
430
|
+
// `setState` here only clears ONE filter and returns; if both are
|
|
431
|
+
// blocking the target, this effect just re-runs (via the `search` /
|
|
432
|
+
// `rowFilter` deps below) and clears the other on the next pass.
|
|
433
|
+
if (rowFilter !== null && laneOf(seg) !== rowFilter) {
|
|
434
|
+
setRowFilter(null)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const el = rowRefs.current.get(editFocusId.id)
|
|
438
|
+
if (!el) return
|
|
439
|
+
lastHandledNonceRef.current = editFocusId.nonce
|
|
440
|
+
el.scrollIntoView({ block: 'nearest' })
|
|
441
|
+
el.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
|
|
442
|
+
}, [editFocusId, search, rowFilter, segs, tab, setTab])
|
|
443
|
+
|
|
444
|
+
function handleRowClick(segId: string | undefined, start: number) {
|
|
445
|
+
if (!segId) return
|
|
446
|
+
onSelectCaption(segId)
|
|
447
|
+
// `CaptionPreview` snaps the clock to the frame grid (`round(t*fps)/fps`)
|
|
448
|
+
// BEFORE testing `t >= start && t < end` against it. Caption starts are
|
|
449
|
+
// arbitrary floats from transcription, not frame-aligned, so seeking to
|
|
450
|
+
// exactly `start` rounds DOWN into the previous segment about half the
|
|
451
|
+
// time once snapped. The half-frame nudge lands inside the segment after
|
|
452
|
+
// rounding, every time.
|
|
453
|
+
//
|
|
454
|
+
// `fps` comes from `project.settings?.fps ?? 30`, which passes an
|
|
455
|
+
// explicit `0` through untouched (falsy, but not nullish) — and
|
|
456
|
+
// `0.5 / 0` is `Infinity`, which parks the playhead at `totalDuration`
|
|
457
|
+
// instead of inside the segment. Guarded here too.
|
|
458
|
+
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30
|
|
459
|
+
clock.set(start + 0.5 / safeFps)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function handleDelete(segId: string | undefined) {
|
|
463
|
+
if (!segId) return
|
|
464
|
+
onCaptionSegmentDelete?.(segId)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function handleRemoveAllClick() {
|
|
468
|
+
if (!confirmRemove) {
|
|
469
|
+
setConfirmRemove(true)
|
|
470
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
471
|
+
removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
475
|
+
setConfirmRemove(false)
|
|
476
|
+
// Persistence needs an explicit `null` to clear the field server-side (a
|
|
477
|
+
// live PUT test confirmed `undefined` is dropped from the JSON body).
|
|
478
|
+
onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const accent = captionTrack ? ACCENT[captionTrack.style] : undefined
|
|
482
|
+
|
|
483
|
+
// Bold reads "on" whenever `fontWeight` is ABSENT — each of the seven JSX
|
|
484
|
+
// caption templates then renders at its OWN designed weight (outline's 900
|
|
485
|
+
// stencil stamp, subtitle's lighter 600), which is what every existing
|
|
486
|
+
// project renders today, so bold has to read as "on" by default. It also
|
|
487
|
+
// reads "on" for anything already stored at 600+ (a project that happens to
|
|
488
|
+
// carry an explicit weight there). Off is the single explicit value 400.
|
|
489
|
+
//
|
|
490
|
+
// `fontWeight` is `number | string` (schema.ts) and CSS accepts the string
|
|
491
|
+
// keywords 'bold'/'bolder' as legal values — `Number('bold')` is `NaN`,
|
|
492
|
+
// which fails the `>= 600` check, so a project carrying the keyword read as
|
|
493
|
+
// OFF even though it renders bold. Checked as its own case rather than
|
|
494
|
+
// folded into the numeric one.
|
|
495
|
+
const isBoldKeyword = typeof captionTrack?.fontWeight === 'string'
|
|
496
|
+
&& ['bold', 'bolder'].includes(captionTrack.fontWeight.trim().toLowerCase())
|
|
497
|
+
const boldOn = captionTrack
|
|
498
|
+
? (captionTrack.fontWeight == null || isBoldKeyword || Number(captionTrack.fontWeight) >= 600)
|
|
499
|
+
: false
|
|
500
|
+
|
|
501
|
+
// onChange = live preview only (cheap, no PUT); onCommit persists once.
|
|
502
|
+
// `Partial<Captions>`, not `Record<string, string>`: the text-styling
|
|
503
|
+
// controls below write a `string[]` (googleFonts) and a number
|
|
504
|
+
// (lineHeight), and stringifying those to fit a narrower patch type would
|
|
505
|
+
// put values in the project that the caption templates can't read.
|
|
506
|
+
const liveTrack = (patch: Partial<Captions>) => {
|
|
507
|
+
if (!project.captions) return
|
|
508
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
509
|
+
}
|
|
510
|
+
const commitTrack = (patch: Partial<Captions>) => {
|
|
511
|
+
if (!project.captions) return
|
|
512
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
513
|
+
}
|
|
514
|
+
// Deletes a key from the persisted captions object entirely, rather than
|
|
515
|
+
// overwriting it. `commitTrack` above can only ADD/OVERWRITE keys via its
|
|
516
|
+
// patch spread — writing `{ [key]: undefined }` through it would leave the
|
|
517
|
+
// key present (with an `undefined` value) in the live-preview project, but
|
|
518
|
+
// JSON.stringify DROPS `undefined` keys on the way to the server, so the
|
|
519
|
+
// preview and the persisted project would end up disagreeing about whether
|
|
520
|
+
// the field is set at all. Building the object explicitly and destructuring
|
|
521
|
+
// the key out of it is the only way both paths agree. Used by the Bold
|
|
522
|
+
// toggle: switching bold back ON has to DELETE `fontWeight`, not write 700
|
|
523
|
+
// (see the toggle's own comment for why a flat 700 is wrong).
|
|
524
|
+
const commitTrackOmitting = (key: keyof Captions) => {
|
|
525
|
+
if (!project.captions) return
|
|
526
|
+
const { [key]: _dropped, ...rest } = project.captions
|
|
527
|
+
onCaptionEdit?.({ ...project, captions: rest as Captions })
|
|
528
|
+
}
|
|
529
|
+
// Per-segment color: when a real segment is selected, the base swatch
|
|
530
|
+
// targets ITS color instead of the track's. Live preview still flows
|
|
531
|
+
// through `onProjectChange` — a locally patched project, exactly the
|
|
532
|
+
// channel every other control here uses for live preview — but the commit
|
|
533
|
+
// goes through `onCaptionSegmentChange`, the single-segment patch channel,
|
|
534
|
+
// instead of rewriting the whole captions object through `onCaptionEdit`.
|
|
535
|
+
// Each fires from exactly one of SwatchInput's onChange/onCommit, so one
|
|
536
|
+
// gesture produces exactly one commit — never both channels for one value.
|
|
537
|
+
const liveSegColor = (v: string) => {
|
|
538
|
+
if (!project.captions || !selectedSeg) return
|
|
539
|
+
onProjectChange?.({
|
|
540
|
+
...project,
|
|
541
|
+
captions: {
|
|
542
|
+
...project.captions,
|
|
543
|
+
segments: project.captions.segments.map(s => s.id === selectedSeg.id ? { ...s, color: v } : s),
|
|
544
|
+
},
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
const commitSegColor = (v: string) => {
|
|
548
|
+
if (!selectedSeg?.id) return
|
|
549
|
+
onCaptionSegmentChange?.(selectedSeg.id, { color: v })
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<div className="flex flex-col h-full min-h-0">
|
|
554
|
+
{/* ── Header: title + count + the Styles / Format / Captions tab switch.
|
|
555
|
+
The strip itself is the shared `TabNav` (panels/TabNav.tsx), which
|
|
556
|
+
every small in-panel tab switch in the editor now renders: uppercase
|
|
557
|
+
labels, an accent underline under the active tab sitting on this
|
|
558
|
+
header's bottom border, muted inactive — NOT a filled segmented
|
|
559
|
+
control — so the left rail's panels share one tab language. ── */}
|
|
560
|
+
<div className="shrink-0 border-b border-[var(--editor-border)] flex flex-col">
|
|
561
|
+
<div className="px-3 pt-2 pb-1 flex items-center justify-between gap-2">
|
|
562
|
+
<span className="text-xs font-medium text-[var(--editor-text)]/60 uppercase tracking-wide">
|
|
563
|
+
Captions
|
|
564
|
+
{segs.length > 0 && (
|
|
565
|
+
<span className="ml-1.5 text-[var(--editor-text)]/40 normal-case tracking-normal">{segs.length}</span>
|
|
566
|
+
)}
|
|
567
|
+
</span>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{/* Only mounts once there are captions to split between the gallery,
|
|
571
|
+
the format controls and the transcript; 'Format' is the default.
|
|
572
|
+
No `className` is passed: TabNav's own base is `flex items-center`,
|
|
573
|
+
which is exactly what this strip carried inline, and the header's
|
|
574
|
+
bottom border (which the active tab's underline sits on) belongs to
|
|
575
|
+
the wrapper ABOVE — not to the strip.
|
|
576
|
+
|
|
577
|
+
TabNav renders `aria-pressed` buttons under a `role="group"` rather
|
|
578
|
+
than a `role="tab"`/`tablist` pair; its own doc comment has the
|
|
579
|
+
general reasoning, which bites doubly here because the host's LEFT
|
|
580
|
+
rail (LeftPanelTabs) already owns a tab literally named "Captions",
|
|
581
|
+
so a second `role="tab"` of that name would put two "Captions tab"
|
|
582
|
+
nodes in the a11y tree. */}
|
|
583
|
+
{segs.length > 0 && captionTrack && (
|
|
584
|
+
<TabNav tabs={CAPTION_TABS} value={tab} onChange={setTab} ariaLabel="Caption panel view" />
|
|
585
|
+
)}
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
{segs.length === 0 ? (
|
|
589
|
+
/* ── Empty / loading state (no captions yet) — spans the whole panel,
|
|
590
|
+
no tabs. While a background job runs this is a loading placeholder
|
|
591
|
+
that agrees with the top-bar progress indicator; otherwise it is
|
|
592
|
+
the idle prompt and its one primary trigger. ── */
|
|
593
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
|
594
|
+
{captionsGenerating ? (
|
|
595
|
+
<div className="flex flex-col items-center gap-3 text-center mt-10 px-4">
|
|
596
|
+
<Loader2 size={22} className="animate-spin text-[var(--editor-accent)]" aria-hidden="true" />
|
|
597
|
+
<div className="flex flex-col gap-1" role="status" aria-live="polite">
|
|
598
|
+
<p className="text-xs font-medium text-[var(--editor-text)]">Generating captions…</p>
|
|
599
|
+
<p className="text-[11px] text-[var(--editor-text)] opacity-50 leading-relaxed">
|
|
600
|
+
Transcribing the timeline's audio. You can keep editing while this runs.
|
|
601
|
+
</p>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
) : (
|
|
605
|
+
<div className="flex flex-col items-center gap-2 text-center mt-4 px-2">
|
|
606
|
+
<p className="text-xs text-[var(--editor-text)]/50 leading-relaxed">
|
|
607
|
+
{onRegenerateCaptions
|
|
608
|
+
? "Captions are generated from the timeline's audio."
|
|
609
|
+
: 'No captions yet. Captions are generated during transcription.'}
|
|
610
|
+
</p>
|
|
611
|
+
{/* Primary action — the only thing to do on an otherwise empty
|
|
612
|
+
panel, so it gets the accent treatment rather than the small
|
|
613
|
+
ghost buttons in the footer. Absent entirely when the host has
|
|
614
|
+
no `generateCaptions` (Hub/LP): a button with no working
|
|
615
|
+
handler is worse than no button. */}
|
|
616
|
+
{onRegenerateCaptions && (
|
|
617
|
+
<button
|
|
618
|
+
type="button"
|
|
619
|
+
onClick={onRegenerateCaptions}
|
|
620
|
+
className="text-xs font-medium rounded-md px-3 py-1.5 bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-opacity"
|
|
621
|
+
>
|
|
622
|
+
Generate captions
|
|
623
|
+
</button>
|
|
624
|
+
)}
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
) : tab === 'styles' ? (
|
|
629
|
+
/* ── Styles tab — the gallery of style previews, replacing the preset
|
|
630
|
+
chip row that used to sit above the fine controls. The gallery
|
|
631
|
+
renders a plain grid and owns no scrolling of its own, so it sits
|
|
632
|
+
inside the same scroll wrapper the chip row did. ── */
|
|
633
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
|
|
634
|
+
{captionTrack && (
|
|
635
|
+
<CaptionStyleGallery
|
|
636
|
+
captions={captionTrack}
|
|
637
|
+
project={project}
|
|
638
|
+
onCaptionEdit={onCaptionEdit}
|
|
639
|
+
compileOverlay={compileOverlay}
|
|
640
|
+
resolveCaptionTemplate={resolveCaptionTemplate}
|
|
641
|
+
/>
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
644
|
+
) : tab === 'format' ? (
|
|
645
|
+
/* ── Format tab — the fine caption controls (size, colors, font, case,
|
|
646
|
+
alignment, spacing), always visible (no collapse), scrollable.
|
|
647
|
+
Every control sits on a LABELED ROW: a fixed-width label cell then
|
|
648
|
+
the control (`rowClass` / `rowLabelClass`), so the tab reads as one
|
|
649
|
+
two-column grid. ── */
|
|
650
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
|
|
651
|
+
{captionTrack && (
|
|
652
|
+
<div className="flex flex-col gap-1.5">
|
|
653
|
+
<div className={rowClass}>
|
|
654
|
+
<span className={rowLabelClass} aria-hidden="true">Font size</span>
|
|
655
|
+
{/* Both controls drive the same value: drag the slider for a
|
|
656
|
+
quick size, or type/arrow an exact one in the box. Slider
|
|
657
|
+
previews per drag-frame and commits on release; the box
|
|
658
|
+
previews per keystroke and commits on blur/Enter.
|
|
659
|
+
|
|
660
|
+
They carry DIFFERENT accessible names on purpose. Two
|
|
661
|
+
controls sharing one name is ambiguous to a screen reader
|
|
662
|
+
(and makes `getByLabelText` throw on the duplicate), so
|
|
663
|
+
the box keeps the canonical "Caption font size" that the
|
|
664
|
+
behavioural tests query and the slider is named for what
|
|
665
|
+
it is — the same split OverlayInspector uses for its
|
|
666
|
+
"Scale" box and "Scale slider". */}
|
|
667
|
+
<Slider
|
|
668
|
+
aria-label="Caption font size slider"
|
|
669
|
+
className="min-w-0 flex-1"
|
|
670
|
+
step={1}
|
|
671
|
+
min={CAPTION_FONT_SIZE_MIN}
|
|
672
|
+
max={CAPTION_FONT_SIZE_MAX}
|
|
673
|
+
value={fontsize}
|
|
674
|
+
onChange={v => {
|
|
675
|
+
if (!project.captions) return
|
|
676
|
+
const next = clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX)
|
|
677
|
+
setFontsize(next)
|
|
678
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: next } })
|
|
679
|
+
}}
|
|
680
|
+
onCommit={v => {
|
|
681
|
+
if (!project.captions) return
|
|
682
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX) } })
|
|
683
|
+
}}
|
|
684
|
+
/>
|
|
685
|
+
<NumberField
|
|
686
|
+
name="Caption font size"
|
|
687
|
+
className="w-20"
|
|
688
|
+
unit="px"
|
|
689
|
+
step={1}
|
|
690
|
+
min={CAPTION_FONT_SIZE_MIN}
|
|
691
|
+
max={CAPTION_FONT_SIZE_MAX}
|
|
692
|
+
value={fontsize}
|
|
693
|
+
onPreview={v => {
|
|
694
|
+
if (!project.captions) return
|
|
695
|
+
const next = clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX)
|
|
696
|
+
setFontsize(next)
|
|
697
|
+
// Live preview only — cheap local-state update, no save.
|
|
698
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: next } })
|
|
699
|
+
}}
|
|
700
|
+
onCommit={v => {
|
|
701
|
+
if (!project.captions) return
|
|
702
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX) } })
|
|
703
|
+
}}
|
|
704
|
+
onStep={d => {
|
|
705
|
+
if (!project.captions) return
|
|
706
|
+
const next = stepValue(fontsize, d, { step: 1, min: CAPTION_FONT_SIZE_MIN, max: CAPTION_FONT_SIZE_MAX })
|
|
707
|
+
setFontsize(next)
|
|
708
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: next } })
|
|
709
|
+
}}
|
|
710
|
+
/>
|
|
711
|
+
</div>
|
|
712
|
+
<div className={rowClass}>
|
|
713
|
+
{/* One label for both swatches: the base one (track, or the
|
|
714
|
+
selected segment) and the active style's accent, when it
|
|
715
|
+
has one. Each still carries its own `aria-label` naming
|
|
716
|
+
exactly which color it writes. */}
|
|
717
|
+
<span className={rowLabelClass} aria-hidden="true">Color</span>
|
|
718
|
+
<SwatchInput
|
|
719
|
+
size="sm"
|
|
720
|
+
showValue={false}
|
|
721
|
+
title={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
722
|
+
ariaLabel={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
723
|
+
value={toHex(selectedSeg ? (selectedSeg.color ?? captionTrack.color) : captionTrack.color, '#ffffff')}
|
|
724
|
+
onChange={v => selectedSeg ? liveSegColor(v) : liveTrack({ color: v })}
|
|
725
|
+
onCommit={v => selectedSeg ? commitSegColor(v) : commitTrack({ color: v })}
|
|
726
|
+
/>
|
|
727
|
+
{accent && (
|
|
728
|
+
<SwatchInput
|
|
729
|
+
size="sm"
|
|
730
|
+
showValue={false}
|
|
731
|
+
title={`Caption ${accent.label.toLowerCase()} color`}
|
|
732
|
+
ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
|
|
733
|
+
value={toHex(captionTrack[accent.field], accent.def)}
|
|
734
|
+
onChange={v => liveTrack({ [accent.field]: v })}
|
|
735
|
+
onCommit={v => commitTrack({ [accent.field]: v })}
|
|
736
|
+
/>
|
|
737
|
+
)}
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
{/* ── Text styling ──
|
|
741
|
+
Every control here writes a track-level `Captions` field
|
|
742
|
+
that all seven JSX caption templates already read as a
|
|
743
|
+
prop, so one write reaches the editor preview and the
|
|
744
|
+
export together — no separate render-side plumbing. */}
|
|
745
|
+
<div className="flex flex-col gap-1.5 border-t border-[var(--editor-border)] pt-2">
|
|
746
|
+
<div className={rowClass}>
|
|
747
|
+
<span className={rowLabelClass} aria-hidden="true">Font</span>
|
|
748
|
+
<FontFamilyPicker
|
|
749
|
+
value={captionTrack.fontFamily ?? ''}
|
|
750
|
+
onChange={value => {
|
|
751
|
+
// `fontFamily` and `googleFonts` MUST be written in ONE
|
|
752
|
+
// patch. A family whose font file is never fetched
|
|
753
|
+
// renders as the fallback face — in the preview AND in
|
|
754
|
+
// the export — which is the exact failure this control
|
|
755
|
+
// exists to prevent; and two patches would also stack
|
|
756
|
+
// two undo entries and could interleave with another
|
|
757
|
+
// edit between them. The System option carries no
|
|
758
|
+
// `spec`, and `[]` is the right write for it:
|
|
759
|
+
// explicitly empty, so a previously picked family's
|
|
760
|
+
// spec cannot linger and keep being fetched.
|
|
761
|
+
//
|
|
762
|
+
// The spec is resolved here rather than handed over by
|
|
763
|
+
// `FontFamilyPicker` (whose onChange gives only the CSS
|
|
764
|
+
// value) so its signature — and its other caller,
|
|
765
|
+
// text/TextFormattingToolbar.tsx — stays untouched.
|
|
766
|
+
// `findFontOption` is an exact round-trip on an
|
|
767
|
+
// option's own `value`, so this always finds the option
|
|
768
|
+
// the operator just clicked.
|
|
769
|
+
const opt = findFontOption(value)
|
|
770
|
+
commitTrack({ fontFamily: value, googleFonts: opt?.spec ? [opt.spec] : [] })
|
|
771
|
+
}}
|
|
772
|
+
className="flex-1 min-w-0"
|
|
773
|
+
buttonClassName="flex h-7 w-full items-center gap-1 rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] px-1.5 text-[11px] text-[var(--editor-text)] hover:border-[var(--editor-accent)] focus:outline-none focus:border-[var(--editor-accent)]"
|
|
774
|
+
/>
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
aria-pressed={boldOn}
|
|
778
|
+
aria-label="Bold"
|
|
779
|
+
// Surprising on first read, so said explicitly: ON means
|
|
780
|
+
// the key is ABSENT, not "set to some bold number". Each
|
|
781
|
+
// of the seven caption templates has its own designed
|
|
782
|
+
// weight (outline's 900 stencil, subtitle's 600, …), and
|
|
783
|
+
// that only comes through when `fontWeight` isn't in the
|
|
784
|
+
// project at all. Writing 700 here would flatten every
|
|
785
|
+
// style onto the same weight and quietly break each
|
|
786
|
+
// style's look — so turning bold back ON has to DELETE
|
|
787
|
+
// the key (via `commitTrackOmitting`), never re-write it.
|
|
788
|
+
onClick={() => boldOn ? commitTrack({ fontWeight: 400 }) : commitTrackOmitting('fontWeight')}
|
|
789
|
+
className={`${chipClass(boldOn)} font-bold shrink-0`}
|
|
790
|
+
>
|
|
791
|
+
B
|
|
792
|
+
</button>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
<div className={rowClass}>
|
|
796
|
+
<span className={rowLabelClass} aria-hidden="true">Case</span>
|
|
797
|
+
<div role="group" aria-label="Caption text case" className="flex items-center gap-1">
|
|
798
|
+
{/* Absent `textTransform` seeds from the active style's
|
|
799
|
+
own default (same reasoning as `boldOn` above) — a
|
|
800
|
+
fresh `outline` caption renders uppercase before any
|
|
801
|
+
case chip is ever clicked, so the chip has to read
|
|
802
|
+
pressed for that to be true rather than lying about
|
|
803
|
+
the caption's real on-screen case. An EXPLICIT
|
|
804
|
+
'none' (written when the operator turns a case back
|
|
805
|
+
off) is not "absent" and never falls back. */}
|
|
806
|
+
{CASES.map(({ value, glyph, label }) => {
|
|
807
|
+
const effectiveTransform = captionTrack.textTransform ?? CAPTION_STYLE_TEXT_TRANSFORM[captionTrack.style]
|
|
808
|
+
const active = effectiveTransform === value
|
|
809
|
+
return (
|
|
810
|
+
<button
|
|
811
|
+
key={value}
|
|
812
|
+
type="button"
|
|
813
|
+
aria-label={label}
|
|
814
|
+
aria-pressed={active}
|
|
815
|
+
// Re-clicking the active case clears it. Writes
|
|
816
|
+
// 'none' rather than dropping the key: an absent
|
|
817
|
+
// field can't overwrite the value already saved on
|
|
818
|
+
// the project, so "off" has to be said out loud.
|
|
819
|
+
onClick={() => commitTrack({ textTransform: active ? 'none' : value })}
|
|
820
|
+
className={`${chipClass(active)} font-mono`}
|
|
821
|
+
>
|
|
822
|
+
{glyph}
|
|
823
|
+
</button>
|
|
824
|
+
)
|
|
825
|
+
})}
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
|
|
829
|
+
{/* Alignment used to ride on the RIGHT of the case row. Its
|
|
830
|
+
own labeled row now, so it reads as a named control like
|
|
831
|
+
every other one here instead of an unlabeled trio of icons
|
|
832
|
+
pushed to the far edge. */}
|
|
833
|
+
<div className={rowClass}>
|
|
834
|
+
<span className={rowLabelClass} aria-hidden="true">Alignment</span>
|
|
835
|
+
<div role="group" aria-label="Caption text alignment" className="flex items-center gap-1">
|
|
836
|
+
{ALIGNMENTS.map(({ value, Icon, label }) => {
|
|
837
|
+
const active = captionTrack.textAlign === value
|
|
838
|
+
return (
|
|
839
|
+
<button
|
|
840
|
+
key={value}
|
|
841
|
+
type="button"
|
|
842
|
+
aria-label={label}
|
|
843
|
+
aria-pressed={active}
|
|
844
|
+
onClick={() => commitTrack({ textAlign: value })}
|
|
845
|
+
className={`${chipClass(active)} flex items-center justify-center px-1.5`}
|
|
846
|
+
>
|
|
847
|
+
<Icon size={11} />
|
|
848
|
+
</button>
|
|
849
|
+
)
|
|
850
|
+
})}
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
|
|
854
|
+
{/* One row each, same `rowClass`/`rowLabelClass` pair every
|
|
855
|
+
other row on this tab wears, now that these are plain
|
|
856
|
+
`NumberField`s rather than the retired `StepperField`
|
|
857
|
+
(which drew its own bound <label> — see the note on
|
|
858
|
+
`rowLabelClass` above for why a bound label was never
|
|
859
|
+
needed even there). */}
|
|
860
|
+
<div className={rowClass}>
|
|
861
|
+
<span className={rowLabelClass} aria-hidden="true">Spacing</span>
|
|
862
|
+
<NumberField
|
|
863
|
+
name="Caption letter spacing"
|
|
864
|
+
className="w-20"
|
|
865
|
+
unit="em"
|
|
866
|
+
min={-0.1}
|
|
867
|
+
max={0.5}
|
|
868
|
+
step={0.01}
|
|
869
|
+
value={parseEmValue(captionTrack.letterSpacing)}
|
|
870
|
+
placeholder={parseEmValue(CAPTION_STYLE_LETTER_SPACING[captionTrack.style])}
|
|
871
|
+
// NumberField doesn't clamp a typed value (native
|
|
872
|
+
// min/max are a spinner hint only) — reuse the same
|
|
873
|
+
// clamp+round the retired StepperField's `numeric()`
|
|
874
|
+
// applied, on both the live and the committed write.
|
|
875
|
+
onPreview={n => liveTrack({ letterSpacing: `${roundTo(clampToRange(n, -0.1, 0.5), 3)}em` })}
|
|
876
|
+
onCommit={n => commitTrack({ letterSpacing: `${roundTo(clampToRange(n, -0.1, 0.5), 3)}em` })}
|
|
877
|
+
onStep={d => {
|
|
878
|
+
// Nudge off whatever is actually IN FORCE — the
|
|
879
|
+
// stored value, or (nothing stored) the active
|
|
880
|
+
// style's own default shown as the placeholder —
|
|
881
|
+
// never off a bare 0, which would ignore what the
|
|
882
|
+
// caption is really rendering at right now.
|
|
883
|
+
const base = Number(
|
|
884
|
+
parseEmValue(captionTrack.letterSpacing) || parseEmValue(CAPTION_STYLE_LETTER_SPACING[captionTrack.style]) || 0,
|
|
885
|
+
)
|
|
886
|
+
commitTrack({ letterSpacing: `${stepValue(base, d, { step: 0.01, min: -0.1, max: 0.5 })}em` })
|
|
887
|
+
}}
|
|
888
|
+
/>
|
|
889
|
+
</div>
|
|
890
|
+
<div className={rowClass}>
|
|
891
|
+
<span className={rowLabelClass} aria-hidden="true">Line</span>
|
|
892
|
+
<NumberField
|
|
893
|
+
name="Caption line height"
|
|
894
|
+
className="w-20"
|
|
895
|
+
min={0.8}
|
|
896
|
+
max={2.5}
|
|
897
|
+
step={0.05}
|
|
898
|
+
value={parseUnitlessValue(captionTrack.lineHeight)}
|
|
899
|
+
placeholder={parseUnitlessValue(CAPTION_STYLE_LINE_HEIGHT[captionTrack.style])}
|
|
900
|
+
onPreview={n => liveTrack({ lineHeight: roundTo(clampToRange(n, 0.8, 2.5), 2) })}
|
|
901
|
+
onCommit={n => commitTrack({ lineHeight: roundTo(clampToRange(n, 0.8, 2.5), 2) })}
|
|
902
|
+
onStep={d => {
|
|
903
|
+
const base = Number(
|
|
904
|
+
parseUnitlessValue(captionTrack.lineHeight) || parseUnitlessValue(CAPTION_STYLE_LINE_HEIGHT[captionTrack.style]) || 0,
|
|
905
|
+
)
|
|
906
|
+
commitTrack({ lineHeight: stepValue(base, d, { step: 0.05, min: 0.8, max: 2.5 }) })
|
|
907
|
+
}}
|
|
908
|
+
/>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
)}
|
|
913
|
+
</div>
|
|
914
|
+
) : (
|
|
915
|
+
/* ── Captions tab — search, row filters, the numbered transcript, and
|
|
916
|
+
the generate / remove actions. Reachable only once captions exist
|
|
917
|
+
(segs.length > 0), so search and the footer are unconditional here
|
|
918
|
+
rather than re-gated on the count. ── */
|
|
919
|
+
<>
|
|
920
|
+
<div className="shrink-0 border-b border-[var(--editor-border)] px-3 py-2 flex flex-col gap-2">
|
|
921
|
+
{/* ── Search ── */}
|
|
922
|
+
<div className="relative">
|
|
923
|
+
<Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--editor-text)]/40 pointer-events-none" />
|
|
924
|
+
<input
|
|
925
|
+
type="text"
|
|
926
|
+
value={search}
|
|
927
|
+
onChange={e => setSearch(e.target.value)}
|
|
928
|
+
placeholder="Search captions…"
|
|
929
|
+
aria-label="Search captions"
|
|
930
|
+
className="w-full h-7 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] pl-6 pr-2 text-xs text-[var(--editor-text)] placeholder-[var(--editor-text)]/40 focus:outline-none focus:border-[var(--editor-accent)]"
|
|
931
|
+
/>
|
|
932
|
+
</div>
|
|
933
|
+
|
|
934
|
+
{/* ── Row filter chips (only when there's more than one lane to filter
|
|
935
|
+
between — a single-lane project never mounts this, keeping its
|
|
936
|
+
toolbar byte-for-byte what it was before lanes existed). Composes
|
|
937
|
+
with search rather than replacing it: both narrow the same
|
|
938
|
+
`visibleGroups` computation above. Same button look as the
|
|
939
|
+
Format tab's chips, for one visual chip language in this
|
|
940
|
+
panel. ── */}
|
|
941
|
+
{multiLane && (
|
|
942
|
+
<div role="group" aria-label="Filter captions by row" className="flex flex-wrap gap-1">
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
aria-pressed={rowFilter === null}
|
|
946
|
+
onClick={() => setRowFilter(null)}
|
|
947
|
+
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
948
|
+
rowFilter === null
|
|
949
|
+
? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
|
|
950
|
+
: 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 hover:border-[var(--editor-text)]/30'
|
|
951
|
+
}`}
|
|
952
|
+
>
|
|
953
|
+
All
|
|
954
|
+
</button>
|
|
955
|
+
{captionGroups.map(g => (
|
|
956
|
+
<button
|
|
957
|
+
key={g.lane}
|
|
958
|
+
type="button"
|
|
959
|
+
aria-pressed={rowFilter === g.lane}
|
|
960
|
+
onClick={() => setRowFilter(g.lane)}
|
|
961
|
+
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
962
|
+
rowFilter === g.lane
|
|
963
|
+
? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
|
|
964
|
+
: 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 hover:border-[var(--editor-text)]/30'
|
|
965
|
+
}`}
|
|
966
|
+
>
|
|
967
|
+
{`Row ${g.lane + 1}`}
|
|
968
|
+
</button>
|
|
969
|
+
))}
|
|
970
|
+
</div>
|
|
971
|
+
)}
|
|
972
|
+
</div>
|
|
973
|
+
|
|
974
|
+
{/* ── Scrollable numbered list ── */}
|
|
975
|
+
<div role="list" aria-label="Caption segments" className="flex-1 min-h-0 overflow-y-auto px-2 py-2 flex flex-col gap-1">
|
|
976
|
+
{totalVisible === 0 ? (
|
|
977
|
+
<p className="text-xs text-[var(--editor-text)]/50 italic text-center mt-4 px-2">No matching captions.</p>
|
|
978
|
+
) : (
|
|
979
|
+
visibleGroups.map(group => (
|
|
980
|
+
// A single-lane project has exactly one entry here, so this
|
|
981
|
+
// Fragment (which renders no DOM node of its own) is the only
|
|
982
|
+
// thing standing between this map and a bare `rows.map(...)` —
|
|
983
|
+
// the header below stays unmounted (`multiLane` false), leaving
|
|
984
|
+
// the row markup underneath byte-for-byte what it was pre-Phase-5.
|
|
985
|
+
<Fragment key={group.lane}>
|
|
986
|
+
{multiLane && (
|
|
987
|
+
// No "move to row" control here (v1, deliberate): row
|
|
988
|
+
// membership is a timeline DRAG gesture (Phase 3/4's
|
|
989
|
+
// resolveDropLane), and a sidebar dropdown doing the same
|
|
990
|
+
// move by a different mechanism invites the two to disagree
|
|
991
|
+
// about what a drag means. Follow-up, not YAGNI-scope here.
|
|
992
|
+
<div className="sticky top-0 z-10 -mx-2 px-2 py-1 bg-[var(--editor-surface)] border-b border-[var(--editor-border)] text-[10px] font-medium text-[var(--editor-text)]/50 uppercase tracking-wide">
|
|
993
|
+
{`Row ${group.lane + 1}`}
|
|
994
|
+
</div>
|
|
995
|
+
)}
|
|
996
|
+
{group.rows.map(({ seg, index }) => {
|
|
997
|
+
const isActive = currentTime >= seg.start && currentTime < seg.end
|
|
998
|
+
const isSelected = !!seg.id && selectedIds.includes(seg.id)
|
|
999
|
+
return (
|
|
1000
|
+
<div
|
|
1001
|
+
key={seg.id ?? index}
|
|
1002
|
+
role="listitem"
|
|
1003
|
+
// `aria-current`, not `aria-selected`: each row contains a
|
|
1004
|
+
// contenteditable and a delete button, and ARIA forbids
|
|
1005
|
+
// interactive descendants inside `option`/`listbox` — a
|
|
1006
|
+
// screen reader could not reliably reach either control. A
|
|
1007
|
+
// plain `list`/`listitem` has no such restriction.
|
|
1008
|
+
aria-current={isSelected ? 'true' : undefined}
|
|
1009
|
+
ref={el => {
|
|
1010
|
+
if (!seg.id) return
|
|
1011
|
+
if (el) rowRefs.current.set(seg.id, el)
|
|
1012
|
+
else rowRefs.current.delete(seg.id)
|
|
1013
|
+
}}
|
|
1014
|
+
onClick={() => handleRowClick(seg.id, seg.start)}
|
|
1015
|
+
// Every segment reads as its own card: a solid surface fill
|
|
1016
|
+
// plus a visible border always, so rows don't melt into the
|
|
1017
|
+
// panel. Accent (indigo-500 == --editor-accent #6366f1) is
|
|
1018
|
+
// used through real Tailwind classes, not `/N` on the arbitrary
|
|
1019
|
+
// var — the latter is a silent no-op on a hex-valued var.
|
|
1020
|
+
className={`group flex items-start gap-2.5 rounded-md px-2.5 py-2 cursor-pointer border transition-colors ${
|
|
1021
|
+
isSelected
|
|
1022
|
+
? 'bg-indigo-500/10 border-indigo-500/60'
|
|
1023
|
+
: isActive
|
|
1024
|
+
? 'bg-[var(--editor-surface)] border-indigo-500/30'
|
|
1025
|
+
: 'bg-[var(--editor-surface)] border-[var(--editor-border)] hover:border-indigo-400/40'
|
|
1026
|
+
}`}
|
|
1027
|
+
>
|
|
1028
|
+
{/* Index + timestamp, stacked — the two labels are complementary
|
|
1029
|
+
(R3: nothing from the retired TranscriptPanel/TranscriptModal
|
|
1030
|
+
dropped). The index gives ordinal position; the timestamp
|
|
1031
|
+
gives where in the video the line actually lands, which is
|
|
1032
|
+
what you navigate by. Both small/mono/muted so neither
|
|
1033
|
+
competes with the caption text beside them. */}
|
|
1034
|
+
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5 w-10">
|
|
1035
|
+
{/* NOT `text-[var(--editor-text)]/N` — Tailwind cannot generate a
|
|
1036
|
+
rule for an opacity modifier on an arbitrary var() color, so
|
|
1037
|
+
that class is a silent no-op and these spans inherit the row's
|
|
1038
|
+
own (near-identical) foreground color instead. `opacity-N` is a
|
|
1039
|
+
real utility and, on a plain text span with no background,
|
|
1040
|
+
renders identically to the alpha-blended color that was
|
|
1041
|
+
intended. See CaptionListPanel.test.tsx for the regression
|
|
1042
|
+
guard. */}
|
|
1043
|
+
<span className="text-[10px] font-mono text-[var(--editor-text)] opacity-60">{index + 1}</span>
|
|
1044
|
+
<span className="text-[9px] font-mono text-[var(--editor-text)] opacity-50">{formatTime(seg.start)}</span>
|
|
1045
|
+
</div>
|
|
1046
|
+
<span className="flex-1 min-w-0 text-xs text-[var(--editor-text)] leading-snug">
|
|
1047
|
+
<EditableSegment
|
|
1048
|
+
seg={seg}
|
|
1049
|
+
onEdit={text => { if (seg.id) onCaptionSegmentChange?.(seg.id, { text }) }}
|
|
1050
|
+
/>
|
|
1051
|
+
</span>
|
|
1052
|
+
<button
|
|
1053
|
+
className={`shrink-0 opacity-0 group-hover:opacity-100 text-[var(--editor-text)]/40 transition-opacity ${mode === 'light' ? 'hover:text-red-600' : 'hover:text-red-400'}`}
|
|
1054
|
+
onClick={e => { e.stopPropagation(); handleDelete(seg.id) }}
|
|
1055
|
+
title="Delete caption"
|
|
1056
|
+
aria-label="Delete caption"
|
|
1057
|
+
>
|
|
1058
|
+
<Trash2 size={12} />
|
|
1059
|
+
</button>
|
|
1060
|
+
</div>
|
|
1061
|
+
)
|
|
1062
|
+
})}
|
|
1063
|
+
</Fragment>
|
|
1064
|
+
))
|
|
1065
|
+
)}
|
|
1066
|
+
</div>
|
|
1067
|
+
|
|
1068
|
+
{/* ── Actions footer ──
|
|
1069
|
+
The generate / remove triggers live at the BOTTOM of the Captions
|
|
1070
|
+
tab, out of the way of the list. Reachable only when captions
|
|
1071
|
+
exist, so this is unconditional rather than re-gated on the count;
|
|
1072
|
+
the empty state above keeps its own primary Generate button. */}
|
|
1073
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] px-3 py-2.5 flex items-center gap-2">
|
|
1074
|
+
{onRegenerateCaptions && (
|
|
1075
|
+
<button
|
|
1076
|
+
type="button"
|
|
1077
|
+
onClick={onRegenerateCaptions}
|
|
1078
|
+
disabled={captionsGenerating}
|
|
1079
|
+
className="flex-1 flex items-center justify-center gap-1.5 h-8 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] text-xs font-medium text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:text-[var(--editor-accent)] disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
|
|
1080
|
+
>
|
|
1081
|
+
<RefreshCw size={13} className={captionsGenerating ? 'animate-spin' : undefined} />
|
|
1082
|
+
Regenerate captions
|
|
1083
|
+
</button>
|
|
1084
|
+
)}
|
|
1085
|
+
{captionTrack && (
|
|
1086
|
+
<button
|
|
1087
|
+
type="button"
|
|
1088
|
+
onClick={handleRemoveAllClick}
|
|
1089
|
+
className={`flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border text-xs font-medium transition-colors ${
|
|
1090
|
+
confirmRemove
|
|
1091
|
+
? (mode === 'light' ? 'bg-red-50 border-red-400 text-red-700' : 'bg-red-500/15 border-red-500/60 text-red-400')
|
|
1092
|
+
: (mode === 'light'
|
|
1093
|
+
? 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:border-red-400 hover:text-red-700'
|
|
1094
|
+
: 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:border-red-500/50 hover:text-red-400')
|
|
1095
|
+
}`}
|
|
1096
|
+
>
|
|
1097
|
+
<Trash2 size={13} />
|
|
1098
|
+
{confirmRemove ? 'Really remove?' : 'Remove all'}
|
|
1099
|
+
</button>
|
|
1100
|
+
)}
|
|
1101
|
+
</div>
|
|
1102
|
+
</>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1105
|
+
)
|
|
1106
|
+
}
|