@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
// CaptionTrackRow — the caption track's own row in the timeline.
|
|
2
|
-
//
|
|
3
|
-
// Captions are NOT part of `tracks[]` (see schema.ts / the plan) — this row
|
|
4
|
-
// reads and writes `project.captions` directly, keeping that special-track
|
|
5
|
-
// data model intact. One block per segment, positioned on the shared timeline
|
|
6
|
-
// scale exactly like VisualTrackRow/AudioTrackRow (same `pct()` + context —
|
|
7
|
-
// never a locally-computed px-per-second).
|
|
8
|
-
//
|
|
9
|
-
// Selection is unified with the preview's caption selection box
|
|
10
|
-
// (`selectedCaptionId`/`onSelectCaption`, both owned by VideoEditor — see
|
|
11
|
-
// ReviewSurface) and is mutually exclusive with the normal item-selection
|
|
12
|
-
// model (`selectedIds`): the two never show handles at once. This row only
|
|
13
|
-
// owns the caption→item half of that rule at the CALL site (clicking a block
|
|
14
|
-
// below calls `onSelectCaption`, same as the preview's click); the actual
|
|
15
|
-
// "also clear selectedIds" side effect lives in VideoEditor's wrapped
|
|
16
|
-
// `onSelectCaption`, since a caption can be selected from the preview too,
|
|
17
|
-
// outside this row entirely. Timeline.handleSelectItem owns the other half
|
|
18
|
-
// (clearing `selectedCaptionId` when a normal item is selected).
|
|
19
|
-
//
|
|
20
|
-
// Editing a segment's text (double-click) and retiming it (drag an edge) both
|
|
21
|
-
// funnel through the single `onCaptionSegmentChange(id, patch)` callback
|
|
22
|
-
// (VideoEditor's `handleCaptionSegmentChange`, which wraps `makeCaptionEdit` +
|
|
23
|
-
// `sync.mutate`). That function pushes ONE undo entry and enqueues ONE save
|
|
24
|
-
// PER CALL, so — unlike VisualTrackRow, which calls its per-tick
|
|
25
|
-
// `onProjectChange` on every mousemove during a resize — a drag here stays
|
|
26
|
-
// entirely in local state (`live`) until mouseup, where `onCaptionSegmentChange`
|
|
27
|
-
// fires exactly once. This mirrors CaptionPreview's drag lifecycle (the
|
|
28
|
-
// sibling caption-editing surface), which does the same for the same reason.
|
|
29
|
-
import { useEffect, useRef, useState } from 'react'
|
|
30
|
-
import type { CaptionSegment, Captions } from '../../schema'
|
|
31
|
-
import { pct, trackRow } from './utils'
|
|
32
|
-
import { useTimelineContext } from './TimelineContext'
|
|
33
|
-
import PlayheadLine from './PlayheadLine'
|
|
34
|
-
import { useItemDragDrop } from './useItemDragDrop'
|
|
35
|
-
import type { Draggable, DragEventContext } from './useItemDragDrop'
|
|
36
|
-
import { EditableSegment } from './EditableSegment'
|
|
37
|
-
import type { CaptionEditPatch } from './makeCaptionEdit'
|
|
38
|
-
|
|
39
|
-
interface CaptionTrackRowProps {
|
|
40
|
-
captionTrack: Captions | undefined
|
|
41
|
-
/** Project frame rate — needed only to make the click-seek land INSIDE the
|
|
42
|
-
* clicked segment once the preview quantizes the clock (see the click
|
|
43
|
-
* handler below). */
|
|
44
|
-
fps: number
|
|
45
|
-
/** Shared selection id — see the file header. Null when nothing is selected. */
|
|
46
|
-
selectedCaptionId: string | null
|
|
47
|
-
onSelectCaption?: (id: string | null) => void
|
|
48
|
-
/** The single commit channel for both text edits and retiming. */
|
|
49
|
-
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export default function CaptionTrackRow({ captionTrack, fps, selectedCaptionId, onSelectCaption, onCaptionSegmentChange }: CaptionTrackRowProps) {
|
|
53
|
-
const { totalDuration, snapBoundaries, scrollRef, zoomRef, overlayDraggedRef, clock } = useTimelineContext()
|
|
54
|
-
const { beginResize } = useItemDragDrop({
|
|
55
|
-
totalDuration,
|
|
56
|
-
snapBoundaries,
|
|
57
|
-
scrollRef,
|
|
58
|
-
zoomRef,
|
|
59
|
-
draggedFlagRef: overlayDraggedRef,
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
// In-flight edge-drag geometry for the dragged segment only, keyed by id so a
|
|
63
|
-
// stale `live` from a previous gesture can never leak onto a different
|
|
64
|
-
// segment. Never written to the project mid-drag — see file header.
|
|
65
|
-
const [live, setLive] = useState<{ id: string; start: number; end: number } | null>(null)
|
|
66
|
-
// Which segment's text is currently in the contentEditable state (double-
|
|
67
|
-
// click to enter, blur to exit). At most one at a time — no multi-edit.
|
|
68
|
-
const [editingId, setEditingId] = useState<string | null>(null)
|
|
69
|
-
|
|
70
|
-
const segments = captionTrack?.segments ?? []
|
|
71
|
-
|
|
72
|
-
// Empty state: no `project.captions`, or a track with zero segments. Still
|
|
73
|
-
// rendered (not `null`) so the operator can see captions exist as a concept
|
|
74
|
-
// even before any exist.
|
|
75
|
-
if (segments.length === 0) {
|
|
76
|
-
return (
|
|
77
|
-
<div className={trackRow}>
|
|
78
|
-
<PlayheadLine />
|
|
79
|
-
<div className="absolute inset-0 flex items-center px-2 pointer-events-none">
|
|
80
|
-
<span className="text-[10px] text-gray-500 italic select-none">Captions</span>
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function handleEdgeDrag(e: React.MouseEvent, seg: CaptionSegment, edge: 'start' | 'end') {
|
|
87
|
-
if (!seg.id || !onCaptionSegmentChange) return
|
|
88
|
-
const segId = seg.id
|
|
89
|
-
const origStart = seg.start
|
|
90
|
-
const origEnd = seg.end
|
|
91
|
-
let committedValue = edge === 'start' ? origStart : origEnd
|
|
92
|
-
|
|
93
|
-
beginResize(e, seg as Draggable, edge, {
|
|
94
|
-
onLivePreview: ({ item: resized }: DragEventContext) => {
|
|
95
|
-
// `resized` is the hook's `{ ...seg, [edge]: <new value> }` — it spreads
|
|
96
|
-
// the full segment (text, words, offsets) because the hook is generic
|
|
97
|
-
// and doesn't know it's a caption. Read ONLY the one numeric field that
|
|
98
|
-
// changed; the rest of `resized` is discarded, never persisted.
|
|
99
|
-
committedValue = edge === 'start' ? resized.start : resized.end
|
|
100
|
-
setLive({
|
|
101
|
-
id: segId,
|
|
102
|
-
start: edge === 'start' ? committedValue : origStart,
|
|
103
|
-
end: edge === 'end' ? committedValue : origEnd,
|
|
104
|
-
})
|
|
105
|
-
},
|
|
106
|
-
onCommit: () => {
|
|
107
|
-
// `beginResize` commits unconditionally on mouseup — unlike `beginDrag`
|
|
108
|
-
// it has no travel threshold — so a bare click on the 6px edge handle
|
|
109
|
-
// lands here with the edge untouched. Committing that would push an
|
|
110
|
-
// undo entry and queue a save for an unchanged project, since
|
|
111
|
-
// onCaptionSegmentChange is a full `sync.mutate` (see file header).
|
|
112
|
-
if (committedValue === (edge === 'start' ? origStart : origEnd)) {
|
|
113
|
-
setLive(null)
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
// Patch carries ONLY the dragged edge — never `text` — so
|
|
117
|
-
// makeCaptionEdit never respreads word timings on a pure retime.
|
|
118
|
-
onCaptionSegmentChange(segId, edge === 'start' ? { start: committedValue } : { end: committedValue })
|
|
119
|
-
setLive(null)
|
|
120
|
-
},
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<div className={trackRow}>
|
|
126
|
-
<PlayheadLine />
|
|
127
|
-
{segments.map((seg) => {
|
|
128
|
-
// `live` must be checked for null FIRST: an id-less segment (seg.id
|
|
129
|
-
// === undefined, e.g. before backfillCaptionIds runs) would otherwise
|
|
130
|
-
// compare equal to a null `live` (undefined === undefined), making
|
|
131
|
-
// isLive true and dereferencing null on the next two lines.
|
|
132
|
-
const isLive = live !== null && live.id === seg.id
|
|
133
|
-
const start = isLive ? live.start : seg.start
|
|
134
|
-
const end = isLive ? live.end : seg.end
|
|
135
|
-
const isSelected = !!seg.id && selectedCaptionId === seg.id
|
|
136
|
-
const isEditing = !!seg.id && editingId === seg.id
|
|
137
|
-
// A segment briefly lacks an id in the window before VideoEditor's
|
|
138
|
-
// backfillCaptionIds effect mints one — `handleCaptionSegmentChange`
|
|
139
|
-
// only accepts a string id, so stay non-interactive until then (same
|
|
140
|
-
// guard CaptionPreview uses for its selection box).
|
|
141
|
-
const canInteract = !!seg.id
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<div
|
|
145
|
-
key={seg.id ?? `${seg.start}-${seg.end}`}
|
|
146
|
-
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden
|
|
147
|
-
${canInteract ? 'cursor-pointer' : ''}
|
|
148
|
-
${isSelected ? 'bg-purple-600/70 ring-1 ring-inset ring-purple-300/80' : 'bg-purple-700/40 hover:bg-purple-600/50 border border-purple-500/40'}`}
|
|
149
|
-
style={{ left: `${pct(start, totalDuration)}%`, width: `${pct(end - start, totalDuration)}%` }}
|
|
150
|
-
onClick={(e) => {
|
|
151
|
-
e.stopPropagation()
|
|
152
|
-
if (!canInteract || overlayDraggedRef.current) return
|
|
153
|
-
onSelectCaption?.(seg.id!)
|
|
154
|
-
// Seek to the segment on a fresh select (not on re-clicking an
|
|
155
|
-
// already-selected block). Load-bearing: the preview only shows
|
|
156
|
-
// drag handles for the segment active AT THE PLAYHEAD, so
|
|
157
|
-
// selecting from here without seeking would select a segment the
|
|
158
|
-
// preview can't yet act on.
|
|
159
|
-
//
|
|
160
|
-
// Half a frame IN, not to `start` itself. CaptionPreview snaps the
|
|
161
|
-
// clock to the frame grid before running the templates' own
|
|
162
|
-
// `t >= start && t < end` test (`t = Math.round(currentTime * fps)
|
|
163
|
-
// / fps`), and caption starts are arbitrary floats out of Whisper.
|
|
164
|
-
// Seeking to exactly `start` rounds DOWN into the PREVIOUS segment
|
|
165
|
-
// whenever `start * fps` has a fractional part below 0.5 — about
|
|
166
|
-
// half of all segments (e.g. start 3.44 at 30fps → frame 103 →
|
|
167
|
-
// t 3.4333 < 3.44). `start + 0.5 / fps` puts `t` in
|
|
168
|
-
// [start, start + 1/fps), always inside: `beginResize` enforces a
|
|
169
|
-
// 0.1s floor on segment duration, so no segment is under a frame.
|
|
170
|
-
if (!isSelected) clock.set(start + 0.5 / fps)
|
|
171
|
-
}}
|
|
172
|
-
onDoubleClick={(e) => {
|
|
173
|
-
e.stopPropagation()
|
|
174
|
-
if (!canInteract) return
|
|
175
|
-
setEditingId(seg.id!)
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
{canInteract && (
|
|
179
|
-
<div
|
|
180
|
-
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-purple-300/40"
|
|
181
|
-
onMouseDown={(e) => handleEdgeDrag(e, seg, 'start')}
|
|
182
|
-
/>
|
|
183
|
-
)}
|
|
184
|
-
<span className="text-[10px] text-purple-100 truncate flex-1 min-w-0 px-2">
|
|
185
|
-
{isEditing ? (
|
|
186
|
-
<EditableSegmentAutofocus
|
|
187
|
-
seg={seg}
|
|
188
|
-
onEdit={(text) => onCaptionSegmentChange?.(seg.id!, { text })}
|
|
189
|
-
onDone={() => setEditingId(null)}
|
|
190
|
-
/>
|
|
191
|
-
) : (
|
|
192
|
-
seg.text
|
|
193
|
-
)}
|
|
194
|
-
</span>
|
|
195
|
-
{canInteract && (
|
|
196
|
-
<div
|
|
197
|
-
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-purple-300/40"
|
|
198
|
-
onMouseDown={(e) => handleEdgeDrag(e, seg, 'end')}
|
|
199
|
-
/>
|
|
200
|
-
)}
|
|
201
|
-
</div>
|
|
202
|
-
)
|
|
203
|
-
})}
|
|
204
|
-
</div>
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Wraps EditableSegment (unmodified — the same component TranscriptPanel uses)
|
|
209
|
-
// with focus-on-mount. Double-click swaps a plain label for this contentEditable
|
|
210
|
-
// span on the NEXT render, by which point the browser's native dblclick text
|
|
211
|
-
// selection has already resolved against the old, non-editable element — so
|
|
212
|
-
// without this the operator would need a third click just to place a cursor.
|
|
213
|
-
function EditableSegmentAutofocus({ seg, onEdit, onDone }: { seg: CaptionSegment; onEdit: (text: string) => void; onDone: () => void }) {
|
|
214
|
-
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
215
|
-
|
|
216
|
-
useEffect(() => {
|
|
217
|
-
const el = wrapRef.current?.querySelector<HTMLElement>('[contenteditable]')
|
|
218
|
-
if (!el) return
|
|
219
|
-
el.focus()
|
|
220
|
-
// Place the caret at the end rather than leaving it at the browser default
|
|
221
|
-
// (start), so continuing to type appends instead of interrupting mid-word.
|
|
222
|
-
const range = document.createRange()
|
|
223
|
-
range.selectNodeContents(el)
|
|
224
|
-
range.collapse(false)
|
|
225
|
-
const sel = window.getSelection()
|
|
226
|
-
sel?.removeAllRanges()
|
|
227
|
-
sel?.addRange(range)
|
|
228
|
-
}, [])
|
|
229
|
-
|
|
230
|
-
return (
|
|
231
|
-
<span ref={wrapRef} onBlur={onDone} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
232
|
-
<EditableSegment seg={seg} onEdit={onEdit} />
|
|
233
|
-
</span>
|
|
234
|
-
)
|
|
235
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { useTimelineContext } from './TimelineContext'
|
|
2
|
-
import { usePlaybackTime } from '../playback-clock'
|
|
3
|
-
import { pct } from './utils'
|
|
4
|
-
|
|
5
|
-
/** Leaf playhead indicator: the ONLY per-tick subscriber inside track rows.
|
|
6
|
-
* Isolating the clock subscription here means VisualTrackRow/AudioTrackRow
|
|
7
|
-
* (and everything else in a row) no longer re-render on every playback tick. */
|
|
8
|
-
export default function PlayheadLine() {
|
|
9
|
-
const { clock, totalDuration } = useTimelineContext()
|
|
10
|
-
const currentTime = usePlaybackTime(clock)
|
|
11
|
-
if (totalDuration === 0) return null
|
|
12
|
-
return (
|
|
13
|
-
<div
|
|
14
|
-
className="absolute top-0 bottom-0 w-[2px] bg-red-500 pointer-events-none z-10"
|
|
15
|
-
style={{ left: `${pct(currentTime, totalDuration)}%` }}
|
|
16
|
-
/>
|
|
17
|
-
)
|
|
18
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
import type { Project } from '../../types'
|
|
4
|
-
import { formatTime } from './utils'
|
|
5
|
-
import { EditableSegment } from './EditableSegment'
|
|
6
|
-
import { makeCaptionEdit } from './makeCaptionEdit'
|
|
7
|
-
|
|
8
|
-
interface TranscriptModalProps {
|
|
9
|
-
captionTrack: Project['captions'] | undefined
|
|
10
|
-
currentTime: number
|
|
11
|
-
project: Project
|
|
12
|
-
// `onProjectChange` deliberately NOT accepted here: `makeCaptionEdit` fires
|
|
13
|
-
// both callbacks it's given with the same updated project (see
|
|
14
|
-
// makeCaptionEdit.ts), and a text edit only fires once (on blur), so there is
|
|
15
|
-
// no live-preview gesture to route through a second channel — only
|
|
16
|
-
// `onCaptionEdit` (the commit path) is needed. Accepting-but-ignoring it would
|
|
17
|
-
// invite a future call site to re-introduce the double-commit bug this fixed.
|
|
18
|
-
onCaptionEdit?: (project: Project) => void
|
|
19
|
-
onClose: () => void
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export default function TranscriptModal({ captionTrack, currentTime, project, onCaptionEdit, onClose }: TranscriptModalProps) {
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
25
|
-
document.addEventListener('keydown', onKey)
|
|
26
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
27
|
-
}, [onClose])
|
|
28
|
-
|
|
29
|
-
return createPortal(
|
|
30
|
-
<div
|
|
31
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
32
|
-
onClick={onClose}
|
|
33
|
-
>
|
|
34
|
-
<div
|
|
35
|
-
className="relative w-full max-w-xl max-h-[70vh] flex flex-col bg-gray-950 border border-gray-800 rounded-lg shadow-2xl mx-4"
|
|
36
|
-
onClick={(e) => e.stopPropagation()}
|
|
37
|
-
>
|
|
38
|
-
{/* Header */}
|
|
39
|
-
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800 shrink-0">
|
|
40
|
-
<span className="text-sm font-medium text-gray-200">Transcript</span>
|
|
41
|
-
<button
|
|
42
|
-
className="text-gray-500 hover:text-white transition-colors text-lg leading-none"
|
|
43
|
-
onClick={onClose}
|
|
44
|
-
>×</button>
|
|
45
|
-
</div>
|
|
46
|
-
|
|
47
|
-
{/* Segment list */}
|
|
48
|
-
<div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-1.5">
|
|
49
|
-
{(captionTrack?.segments ?? []).map((seg, i) => {
|
|
50
|
-
const isActive = currentTime >= seg.start && currentTime < seg.end
|
|
51
|
-
return (
|
|
52
|
-
<div
|
|
53
|
-
key={seg.id ?? i}
|
|
54
|
-
className={`flex gap-3 items-baseline px-2 py-1 rounded transition-colors ${isActive ? 'bg-white/5' : ''}`}
|
|
55
|
-
>
|
|
56
|
-
<span className="text-gray-600 text-[10px] font-mono shrink-0 w-12 pt-px">{formatTime(seg.start)}</span>
|
|
57
|
-
<span className={`text-sm leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>
|
|
58
|
-
{/* Commit-only — see the file-header note on why `onProjectChange`
|
|
59
|
-
isn't accepted/passed here. */}
|
|
60
|
-
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
61
|
-
</span>
|
|
62
|
-
</div>
|
|
63
|
-
)
|
|
64
|
-
})}
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
</div>,
|
|
68
|
-
document.body
|
|
69
|
-
)
|
|
70
|
-
}
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import type { Project } from '../../types'
|
|
3
|
-
import { formatTime } from './utils'
|
|
4
|
-
import { EditableSegment } from './EditableSegment'
|
|
5
|
-
import { makeCaptionEdit, type CaptionEditPatch } from './makeCaptionEdit'
|
|
6
|
-
import { SwatchInput } from '../../ui'
|
|
7
|
-
|
|
8
|
-
// Each caption style reads a different accent-color prop in its render template
|
|
9
|
-
// (see render/templates/captions/*.jsx). The color control writes whichever field
|
|
10
|
-
// the active style uses; `clean` and `word-by-word` have no accent (omitted here).
|
|
11
|
-
type CaptionStyle = NonNullable<Project['captions']>['style']
|
|
12
|
-
type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
|
|
13
|
-
const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
|
|
14
|
-
karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
|
|
15
|
-
pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
|
|
16
|
-
'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
17
|
-
outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
|
|
18
|
-
subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// The native <input type="color"> only accepts #rrggbb. Stored values are always
|
|
22
|
-
// hex once picked; fall back to the style default for unset / non-hex (e.g. an
|
|
23
|
-
// rgba() template default) so the swatch never gets an invalid value.
|
|
24
|
-
const HEX = /^#[0-9a-f]{6}$/i
|
|
25
|
-
const toHex = (v: unknown, fallback: string): string =>
|
|
26
|
-
typeof v === 'string' && HEX.test(v) ? v : fallback
|
|
27
|
-
|
|
28
|
-
interface TranscriptPanelProps {
|
|
29
|
-
project: Project
|
|
30
|
-
captionTrack: Project['captions'] | undefined
|
|
31
|
-
currentTime: number
|
|
32
|
-
onCaptionEdit?: (project: Project) => void
|
|
33
|
-
onProjectChange?: (project: Project) => void
|
|
34
|
-
onExpand: () => void
|
|
35
|
-
/** Opens the caption-regeneration modal. Provided only when the host adapter
|
|
36
|
-
* supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
|
|
37
|
-
onRegenerateCaptions?: () => void
|
|
38
|
-
/** Selected caption segment id (shared with the preview's selection box —
|
|
39
|
-
* see CaptionPreview/CaptionTrackRow). When set and it resolves to a real
|
|
40
|
-
* segment, the base color swatch below targets that segment's `color`
|
|
41
|
-
* instead of the track-level `color`. */
|
|
42
|
-
selectedCaptionId?: string | null
|
|
43
|
-
/** Commit a single-segment patch — the same channel CaptionTrackRow's
|
|
44
|
-
* edge-drag and text edits use (see makeCaptionEdit.ts). Used here as the
|
|
45
|
-
* per-segment color swatch's commit path. */
|
|
46
|
-
onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions, selectedCaptionId, onCaptionSegmentChange }: TranscriptPanelProps) {
|
|
50
|
-
const segs = captionTrack?.segments ?? []
|
|
51
|
-
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
52
|
-
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
53
|
-
// Live slider value during a drag — only committed (persisted) on
|
|
54
|
-
// pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
|
|
55
|
-
const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
|
|
56
|
-
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
setFontsize(captionTrack?.fontsize ?? 46)
|
|
59
|
-
}, [captionTrack?.fontsize])
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
return () => {
|
|
63
|
-
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
64
|
-
}
|
|
65
|
-
}, [])
|
|
66
|
-
// Find active segment index
|
|
67
|
-
const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
|
|
68
|
-
const nearIdx = activeIdx !== -1 ? activeIdx
|
|
69
|
-
: segs.reduce((best, s, i) => s.start <= currentTime ? i : best, -1)
|
|
70
|
-
// Vicinity: active ± 2 segments; track start offset for O(1) global index
|
|
71
|
-
const vicinityStart = nearIdx !== -1 ? Math.max(0, nearIdx - 2) : 0
|
|
72
|
-
const vicinitySegs = nearIdx !== -1
|
|
73
|
-
? segs.slice(vicinityStart, nearIdx + 3)
|
|
74
|
-
: segs.slice(0, 3)
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
|
|
78
|
-
<div className="flex items-center justify-between mb-2">
|
|
79
|
-
<span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
|
|
80
|
-
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
81
|
-
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
|
|
82
|
-
const active = captionTrack.style === style
|
|
83
|
-
return (
|
|
84
|
-
<button
|
|
85
|
-
key={style}
|
|
86
|
-
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
87
|
-
active
|
|
88
|
-
? 'bg-purple-600/30 border-purple-500/60 text-purple-300'
|
|
89
|
-
: 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
|
|
90
|
-
}`}
|
|
91
|
-
onClick={() => {
|
|
92
|
-
if (!project.captions) return
|
|
93
|
-
const updated = { ...project, captions: { ...project.captions, style } }
|
|
94
|
-
onCaptionEdit?.(updated)
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
{style}
|
|
98
|
-
</button>
|
|
99
|
-
)
|
|
100
|
-
})}
|
|
101
|
-
{captionTrack && (
|
|
102
|
-
<div className="flex items-center gap-1">
|
|
103
|
-
<input
|
|
104
|
-
type="range"
|
|
105
|
-
min={28}
|
|
106
|
-
max={120}
|
|
107
|
-
step={2}
|
|
108
|
-
value={fontsize}
|
|
109
|
-
className="w-20 sm:w-24 accent-purple-500"
|
|
110
|
-
onChange={e => {
|
|
111
|
-
if (!project.captions) return
|
|
112
|
-
const v = Number(e.target.value)
|
|
113
|
-
setFontsize(v)
|
|
114
|
-
// Live preview only — cheap local-state update, no save.
|
|
115
|
-
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
116
|
-
}}
|
|
117
|
-
onPointerUp={e => {
|
|
118
|
-
if (!project.captions) return
|
|
119
|
-
const v = Number((e.target as HTMLInputElement).value)
|
|
120
|
-
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
121
|
-
}}
|
|
122
|
-
onKeyUp={e => {
|
|
123
|
-
if (!project.captions) return
|
|
124
|
-
const v = Number((e.target as HTMLInputElement).value)
|
|
125
|
-
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
126
|
-
}}
|
|
127
|
-
/>
|
|
128
|
-
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
|
|
129
|
-
{fontsize}px
|
|
130
|
-
</span>
|
|
131
|
-
</div>
|
|
132
|
-
)}
|
|
133
|
-
{captionTrack && (() => {
|
|
134
|
-
const accent = ACCENT[captionTrack.style]
|
|
135
|
-
// onChange = live preview only (cheap, no PUT); onBlur commits one save.
|
|
136
|
-
const live = (patch: Record<string, string>) => {
|
|
137
|
-
if (!project.captions) return
|
|
138
|
-
onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
139
|
-
}
|
|
140
|
-
const commit = (patch: Record<string, string>) => {
|
|
141
|
-
if (!project.captions) return
|
|
142
|
-
onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Per-segment color: when a real segment is selected, the base
|
|
146
|
-
// swatch below targets ITS color instead of the track's. Live
|
|
147
|
-
// preview still flows through `onProjectChange` — a locally
|
|
148
|
-
// patched project, exactly the channel every other control on
|
|
149
|
-
// this panel already uses for live preview — but the commit
|
|
150
|
-
// goes through `onCaptionSegmentChange`, the single-segment
|
|
151
|
-
// patch channel (see makeCaptionEdit.ts), instead of rewriting
|
|
152
|
-
// the whole captions object through `onCaptionEdit`. Each is
|
|
153
|
-
// fired from exactly one of SwatchInput's onChange/onCommit, so
|
|
154
|
-
// (as with the track-level swatch above) one gesture produces
|
|
155
|
-
// exactly one commit — never both channels for the same value.
|
|
156
|
-
const selectedSeg = selectedCaptionId
|
|
157
|
-
? segs.find(s => s.id === selectedCaptionId)
|
|
158
|
-
: undefined
|
|
159
|
-
const liveSegColor = (v: string) => {
|
|
160
|
-
if (!project.captions || !selectedSeg) return
|
|
161
|
-
onProjectChange?.({
|
|
162
|
-
...project,
|
|
163
|
-
captions: {
|
|
164
|
-
...project.captions,
|
|
165
|
-
segments: project.captions.segments.map(s =>
|
|
166
|
-
s.id === selectedSeg.id ? { ...s, color: v } : s,
|
|
167
|
-
),
|
|
168
|
-
},
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
const commitSegColor = (v: string) => {
|
|
172
|
-
if (!selectedSeg) return
|
|
173
|
-
onCaptionSegmentChange?.(selectedSeg.id!, { color: v })
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
<div className="flex items-center gap-1.5">
|
|
178
|
-
<SwatchInput
|
|
179
|
-
size="sm"
|
|
180
|
-
showValue={false}
|
|
181
|
-
title={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
182
|
-
ariaLabel={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
|
|
183
|
-
value={toHex(selectedSeg ? (selectedSeg.color ?? captionTrack.color) : captionTrack.color, '#ffffff')}
|
|
184
|
-
onChange={v => selectedSeg ? liveSegColor(v) : live({ color: v })}
|
|
185
|
-
onCommit={v => selectedSeg ? commitSegColor(v) : commit({ color: v })}
|
|
186
|
-
/>
|
|
187
|
-
{accent && (
|
|
188
|
-
<SwatchInput
|
|
189
|
-
size="sm"
|
|
190
|
-
showValue={false}
|
|
191
|
-
title={`Caption ${accent.label.toLowerCase()} color`}
|
|
192
|
-
ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
|
|
193
|
-
value={toHex(captionTrack[accent.field], accent.def)}
|
|
194
|
-
onChange={v => live({ [accent.field]: v })}
|
|
195
|
-
onCommit={v => commit({ [accent.field]: v })}
|
|
196
|
-
/>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
)
|
|
200
|
-
})()}
|
|
201
|
-
{onRegenerateCaptions && (
|
|
202
|
-
<button
|
|
203
|
-
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|
|
204
|
-
onClick={() => onRegenerateCaptions?.()}
|
|
205
|
-
>
|
|
206
|
-
Regenerate
|
|
207
|
-
</button>
|
|
208
|
-
)}
|
|
209
|
-
{captionTrack && (
|
|
210
|
-
<button
|
|
211
|
-
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
212
|
-
confirmRemove
|
|
213
|
-
? 'bg-red-500/20 border-red-500/60 text-red-400'
|
|
214
|
-
: 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
|
|
215
|
-
}`}
|
|
216
|
-
onClick={() => {
|
|
217
|
-
if (!confirmRemove) {
|
|
218
|
-
setConfirmRemove(true)
|
|
219
|
-
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
220
|
-
removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
224
|
-
setConfirmRemove(false)
|
|
225
|
-
// Persistence needs an explicit `null` to clear the field server-side
|
|
226
|
-
// (a live PUT test confirmed `undefined` is dropped from the JSON body).
|
|
227
|
-
onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
|
|
228
|
-
}}
|
|
229
|
-
>
|
|
230
|
-
{confirmRemove ? 'Really remove?' : 'Remove'}
|
|
231
|
-
</button>
|
|
232
|
-
)}
|
|
233
|
-
{segs.length > 0 && (
|
|
234
|
-
<button
|
|
235
|
-
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|
|
236
|
-
onClick={onExpand}
|
|
237
|
-
>
|
|
238
|
-
Expand ↑
|
|
239
|
-
</button>
|
|
240
|
-
)}
|
|
241
|
-
</div>
|
|
242
|
-
</div>
|
|
243
|
-
|
|
244
|
-
<div className="h-10 overflow-y-auto">
|
|
245
|
-
{segs.length === 0 ? (
|
|
246
|
-
<p className="text-xs text-gray-600 italic">No transcript — captions are generated during the agent pass</p>
|
|
247
|
-
) : (
|
|
248
|
-
<p className="text-sm text-gray-700 dark:text-gray-200 leading-relaxed">
|
|
249
|
-
{vicinitySegs.map((seg, vi) => {
|
|
250
|
-
const i = vicinityStart + vi
|
|
251
|
-
const isActive = currentTime >= seg.start && currentTime < seg.end
|
|
252
|
-
return (
|
|
253
|
-
<span key={seg.id ?? i}>
|
|
254
|
-
{vi > 0 && ' '}
|
|
255
|
-
<span className="text-gray-500 text-[10px] font-mono mr-1">{formatTime(seg.start)}</span>
|
|
256
|
-
<span className={isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
|
|
257
|
-
{/* Commit-only: `onProjectChange` is this component's LIVE-preview
|
|
258
|
-
channel (see the fontsize/color controls above) — but a text
|
|
259
|
-
edit fires once, on blur, and is already a complete, discrete
|
|
260
|
-
change, not a continuous gesture. Passing both callbacks here
|
|
261
|
-
would double-fire `sync.mutate` for one edit (two undo entries,
|
|
262
|
-
two queued saves); `onCaptionEdit` is the single commit path. */}
|
|
263
|
-
<EditableSegment seg={seg} onEdit={makeCaptionEdit(i, project, undefined, onCaptionEdit)} />
|
|
264
|
-
</span>
|
|
265
|
-
</span>
|
|
266
|
-
)
|
|
267
|
-
})}
|
|
268
|
-
</p>
|
|
269
|
-
)}
|
|
270
|
-
</div>
|
|
271
|
-
</div>
|
|
272
|
-
)
|
|
273
|
-
}
|