feat: auto caption show on timeline editor

This commit is contained in:
Mahdy Arief
2026-03-27 17:52:31 +07:00
parent ced02c9a8f
commit 7e8a40f2fa
6 changed files with 208 additions and 90 deletions
+32 -33
View File
@@ -1,4 +1,4 @@
import { Palette, Trash2, Upload, X } from "lucide-react";
import { MessageSquare, Palette, Trash2, Upload, X } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -205,10 +205,11 @@ interface SettingsPanelProps {
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationBlurIntensityChange?: (id: string, blurIntensity: number) => void;
onAnnotationDelete?: (id: string) => void;
currentTime?: number;
onSeek?: (time: number) => void;
autoCaptions?: CaptionCue[];
onAutoCaptionsChange?: (captions: CaptionCue[]) => void;
selectedCaptionId?: string | null;
onSelectCaption?: (id: string | null) => void;
autoCaptionSettings?: AutoCaptionSettings;
whisperExecutablePath?: string | null;
whisperModelPath?: string | null;
@@ -528,7 +529,6 @@ export function SettingsPanel({
onAnnotationFigureDataChange,
onAnnotationBlurIntensityChange,
onAnnotationDelete,
currentTime = 0,
onSeek,
autoCaptions = [],
onAutoCaptionsChange,
@@ -544,6 +544,8 @@ export function SettingsPanel({
onClearAutoCaptions,
onDownloadWhisperModel,
onDeleteWhisperModel,
selectedCaptionId,
onSelectCaption,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -1459,7 +1461,6 @@ export function SettingsPanel({
)}
<Button
type="button"
variant="outline"
onClick={onClearAutoCaptions}
disabled={captionCueCount === 0}
className="h-10 w-full rounded-xl border-white/10 bg-white/5 px-4 text-sm text-slate-200 hover:bg-white/10 hover:text-white disabled:opacity-50"
@@ -1494,21 +1495,19 @@ export function SettingsPanel({
<div className="mt-4 flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
Edit Cues ({autoCaptions.length})
{selectedCaptionId ? "Edit Selected Cue" : "Select Cue on Timeline"}
</span>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-xl border border-white/5 bg-black/20 p-1 subtle-scrollbar">
<div className="flex flex-col gap-1">
{autoCaptions.map((cue, index) => {
const isActive = currentTime >= cue.startMs / 1000 && currentTime <= cue.endMs / 1000;
<div className="rounded-xl border border-white/5 bg-black/20 p-2">
{selectedCaptionId ? (
(() => {
const index = autoCaptions.findIndex((c) => c.id === selectedCaptionId);
const cue = autoCaptions[index];
if (!cue) return null;
return (
<div
key={cue.id || index}
className={cn(
"group flex flex-col gap-1 rounded-lg p-2 transition-colors",
isActive ? "bg-white/10" : "hover:bg-white/5"
)}
>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<button
type="button"
@@ -1523,10 +1522,11 @@ export function SettingsPanel({
const newCaptions = [...autoCaptions];
newCaptions.splice(index, 1);
onAutoCaptionsChange?.(newCaptions);
onSelectCaption?.(null);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-slate-500 hover:text-red-400"
className="text-slate-500 hover:text-red-400 p-1"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-4 w-4" />
</button>
</div>
<textarea
@@ -1536,24 +1536,23 @@ export function SettingsPanel({
newCaptions[index] = { ...cue, text: e.target.value };
onAutoCaptionsChange?.(newCaptions);
}}
rows={1}
className="w-full resize-none border-none bg-transparent p-0 text-sm text-slate-300 placeholder:text-slate-600 focus:outline-none focus:ring-0"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
ref={(el) => {
if (el) {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
}}
rows={2}
autoFocus
className="w-full resize-none rounded-lg border-none bg-white/5 p-2 text-sm text-slate-300 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-[#2563EB]"
/>
</div>
);
})}
</div>
})()
) : (
<div className="flex flex-col items-center justify-center py-6 px-4 text-center">
<div className="mb-2 rounded-full bg-white/5 p-2.5">
<MessageSquare className="h-4 w-4 text-slate-600" />
</div>
<p className="text-[11px] text-slate-500 leading-relaxed max-w-[160px]">
Select a caption block on the timeline to edit its text and timing
</p>
</div>
)}
</div>
</div>
)}
+41 -20
View File
@@ -12,6 +12,7 @@ import {
Undo2,
X,
} from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
@@ -127,6 +128,7 @@ type EditorHistorySnapshot = {
selectedSpeedId: string | null;
selectedAnnotationId: string | null;
selectedAudioId: string | null;
selectedCaptionId: string | null;
};
type PendingExportSave = {
@@ -377,6 +379,7 @@ export default function VideoEditor() {
const [audioRegions, setAudioRegions] = useState<AudioRegion[]>([]);
const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null);
const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]);
const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null);
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(() => ({
...DEFAULT_AUTO_CAPTION_SETTINGS,
selectedModel: (initialEditorPreferences.whisperSelectedModel as any) || "small",
@@ -967,6 +970,7 @@ export default function VideoEditor() {
selectedSpeedId,
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
};
}, [
zoomRegions,
@@ -980,46 +984,48 @@ export default function VideoEditor() {
selectedSpeedId,
selectedAnnotationId,
selectedAudioId,
selectedCaptionId,
]);
const applyHistorySnapshot = useCallback(
(snapshot: EditorHistorySnapshot) => {
applyingHistoryRef.current = true;
const cloned = cloneSnapshot(snapshot);
setZoomRegions(cloned.zoomRegions);
setTrimRegions(cloned.trimRegions);
setSpeedRegions(cloned.speedRegions);
setAnnotationRegions(cloned.annotationRegions);
setAudioRegions(cloned.audioRegions);
setAutoCaptions(cloned.autoCaptions);
setSelectedZoomId(cloned.selectedZoomId);
setSelectedTrimId(cloned.selectedTrimId);
setSelectedSpeedId(cloned.selectedSpeedId);
setSelectedAnnotationId(cloned.selectedAnnotationId);
setSelectedAudioId(cloned.selectedAudioId);
setZoomRegions(cloneStructured(snapshot.zoomRegions));
setTrimRegions(cloneStructured(snapshot.trimRegions));
setSpeedRegions(cloneStructured(snapshot.speedRegions));
setAnnotationRegions(cloneStructured(snapshot.annotationRegions));
setAudioRegions(cloneStructured(snapshot.audioRegions));
setAutoCaptions(cloneStructured(snapshot.autoCaptions));
setSelectedZoomId(snapshot.selectedZoomId);
setSelectedTrimId(snapshot.selectedTrimId);
setSelectedSpeedId(snapshot.selectedSpeedId);
setSelectedAnnotationId(snapshot.selectedAnnotationId);
setSelectedAudioId(snapshot.selectedAudioId);
setSelectedCaptionId(snapshot.selectedCaptionId);
nextZoomIdRef.current = deriveNextId(
"zoom",
cloned.zoomRegions.map((region) => region.id),
snapshot.zoomRegions.map((region: ZoomRegion) => region.id),
);
nextTrimIdRef.current = deriveNextId(
"trim",
cloned.trimRegions.map((region) => region.id),
snapshot.trimRegions.map((region: TrimRegion) => region.id),
);
nextSpeedIdRef.current = deriveNextId(
"speed",
cloned.speedRegions.map((region) => region.id),
snapshot.speedRegions.map((region: SpeedRegion) => region.id),
);
nextAnnotationIdRef.current = deriveNextId(
"annotation",
cloned.annotationRegions.map((region) => region.id),
snapshot.annotationRegions.map((region: AnnotationRegion) => region.id),
);
nextAudioIdRef.current = deriveNextId(
"audio",
cloned.audioRegions.map((region) => region.id),
snapshot.audioRegions.map((region: AudioRegion) => region.id),
);
nextAnnotationZIndexRef.current =
cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
snapshot.annotationRegions.reduce((max: number, region: AnnotationRegion) => Math.max(max, region.zIndex), 0) + 1;
applyingHistoryRef.current = false;
},
[cloneSnapshot],
);
@@ -1586,7 +1592,12 @@ export default function VideoEditor() {
return;
}
setAutoCaptions(result.cues);
const cuesWithIds = result.cues.map((cue: CaptionCue) => ({
...cue,
id: cue.id || uuidv4(),
}));
setAutoCaptions(cuesWithIds);
setAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
toast.success(result.message || `Generated ${result.cues.length} captions`);
} catch (error) {
@@ -2192,6 +2203,7 @@ export default function VideoEditor() {
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedCaptionId(null); // Added
}, []);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
@@ -3411,6 +3423,14 @@ export default function VideoEditor() {
onAspectRatioChange={setAspectRatio}
onOpenCropEditor={handleOpenCropEditor}
isCropped={isCropped}
autoCaptions={autoCaptions}
onCaptionSpanChange={(id, span) => {
setAutoCaptions((prev) =>
prev.map((r) => (r.id === id ? { ...r, startMs: span.start, endMs: span.end } : r)),
);
}}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
/>
</div>
</Panel>
@@ -3488,7 +3508,6 @@ export default function VideoEditor() {
onAspectRatioChange={setAspectRatio}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
currentTime={currentTime}
onSeek={(time) => videoPlaybackRef.current?.seek(time)}
autoCaptions={autoCaptions}
onAutoCaptionsChange={setAutoCaptions}
@@ -3520,6 +3539,8 @@ export default function VideoEditor() {
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
/>
</div>
</div>
@@ -14,7 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio';
variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption';
}
// Map zoom depth to multiplier labels
@@ -58,6 +58,7 @@ export default function Item({
const isTrim = variant === 'trim';
const isSpeed = variant === 'speed';
const isAudio = variant === 'audio';
const isCaption = variant === 'caption';
const glassClass = isZoom
? glassStyles.glassGreen
@@ -67,6 +68,8 @@ export default function Item({
? glassStyles.glassAmber
: isAudio
? glassStyles.glassPurple
: isCaption
? glassStyles.glassCyan
: glassStyles.glassYellow;
const endCapColor = isZoom
@@ -77,6 +80,8 @@ export default function Item({
? '#d97706'
: isAudio
? '#a855f7'
: isCaption
? '#0891b2'
: '#B4A046';
const timeLabel = useMemo(
@@ -128,6 +128,32 @@
z-index: 10;
}
.glassCyan {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(8, 145, 178, 0.15);
border: 1px solid rgba(8, 145, 178, 0.3);
box-shadow: 0 2px 12px 0 rgba(8, 145, 178, 0.1) inset;
margin: 1px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassCyan:hover {
background: rgba(8, 145, 178, 0.25);
border-color: rgba(8, 145, 178, 0.5);
box-shadow: 0 4px 20px 0 rgba(8, 145, 178, 0.2) inset;
}
.glassCyan.selected {
background: rgba(8, 145, 178, 0.35);
border-color: #0891b2;
box-shadow: 0 0 0 1px #0891b2, 0 4px 20px 0 rgba(8, 145, 178, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
@@ -148,7 +174,9 @@
.glassAmber:hover .zoomEndCap,
.glassAmber.selected .zoomEndCap,
.glassPurple:hover .zoomEndCap,
.glassPurple.selected .zoomEndCap {
.glassPurple.selected .zoomEndCap,
.glassCyan:hover .zoomEndCap,
.glassCyan.selected .zoomEndCap {
opacity: 1;
}
@@ -23,7 +23,7 @@ import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, CaptionCue } from "../types";
import { toFileUrl } from "../projectPersistence";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
@@ -32,6 +32,7 @@ const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const SPEED_ROW_ID = "row-speed";
const AUDIO_ROW_ID = "row-audio";
const CAPTION_ROW_ID = "row-caption";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
const SUGGESTION_SPACING_MS = 1800;
@@ -73,6 +74,10 @@ interface TimelineEditorProps {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
autoCaptions?: any[];
onCaptionSpanChange?: (id: string, span: Span) => void;
selectedCaptionId?: string | null;
onSelectCaption?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
onOpenCropEditor?: () => void;
@@ -92,7 +97,7 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
variant: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio';
variant: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption';
}
const SCALE_CANDIDATES = [
@@ -454,6 +459,8 @@ function Timeline({
selectedAnnotationId,
selectedSpeedId,
selectedAudioId,
selectedCaptionId,
onSelectCaption,
selectAllBlocksActive = false,
onClearBlockSelection,
keyframes = [],
@@ -472,6 +479,8 @@ function Timeline({
selectedAnnotationId?: string | null;
selectedSpeedId?: string | null;
selectedAudioId?: string | null;
selectedCaptionId?: string | null;
onSelectCaption?: (id: string | null) => void;
selectAllBlocksActive?: boolean;
onClearBlockSelection?: () => void;
keyframes?: { id: string; time: number }[];
@@ -498,6 +507,7 @@ function Timeline({
onSelectAnnotation?.(null);
onSelectSpeed?.(null);
onSelectAudio?.(null);
onSelectCaption?.(null);
onClearBlockSelection?.();
const rect = e.currentTarget.getBoundingClientRect();
@@ -510,13 +520,14 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onSelectCaption, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID);
const captionItems = items.filter(item => item.rowId === CAPTION_ROW_ID);
return (
<div
@@ -621,6 +632,22 @@ function Timeline({
</Item>
))}
</Row>
<Row id={CAPTION_ROW_ID} isEmpty={captionItems.length === 0} hint="Generated captions will appear here">
{captionItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedCaptionId}
onSelect={() => onSelectCaption?.(item.id)}
variant="caption"
>
{item.label}
</Item>
))}
</Row>
</div>
</div>
);
@@ -663,6 +690,10 @@ export default function TimelineEditor({
onAudioDelete,
selectedAudioId,
onSelectAudio,
autoCaptions = [],
onCaptionSpanChange,
selectedCaptionId,
onSelectCaption,
aspectRatio,
onAspectRatioChange,
onOpenCropEditor,
@@ -799,15 +830,17 @@ export default function TimelineEditor({
onSelectAnnotation?.(null);
onSelectSpeed?.(null);
onSelectAudio?.(null);
onSelectCaption?.(null);
setSelectAllBlocksActive(false);
}, [onSelectAnnotation, onSelectAudio, onSelectSpeed, onSelectTrim, onSelectZoom]);
}, [onSelectAnnotation, onSelectAudio, onSelectSpeed, onSelectTrim, onSelectZoom, onSelectCaption]);
const hasAnyTimelineBlocks =
zoomRegions.length > 0 ||
trimRegions.length > 0 ||
annotationRegions.length > 0 ||
speedRegions.length > 0 ||
audioRegions.length > 0;
audioRegions.length > 0 ||
autoCaptions.length > 0;
const deleteAllBlocks = useCallback(() => {
const zoomIds = zoomRegions.map((region) => region.id);
@@ -863,6 +896,11 @@ export default function TimelineEditor({
onSelectAudio?.(id);
}, [onSelectAudio]);
const handleSelectCaption = useCallback((id: string | null) => {
setSelectAllBlocksActive(false);
onSelectCaption?.(id);
}, [onSelectCaption]);
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -874,10 +912,12 @@ export default function TimelineEditor({
const trimRegionsRef = useRef(trimRegions);
const speedRegionsRef = useRef(speedRegions);
const audioRegionsRef = useRef(audioRegions);
const autoCaptionsRef = useRef(autoCaptions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
speedRegionsRef.current = speedRegions;
audioRegionsRef.current = audioRegions;
autoCaptionsRef.current = autoCaptions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
@@ -931,8 +971,20 @@ export default function TimelineEditor({
onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
autoCaptionsRef.current.forEach((caption: CaptionCue) => {
const clampedStart = Math.max(0, Math.min(caption.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, caption.endMs));
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
if (normalizedStart !== caption.startMs || normalizedEnd !== caption.endMs) {
onCaptionSpanChange?.(caption.id, { start: normalizedStart, end: normalizedEnd });
}
});
// Only re-run when the timeline scale changes, not on every region edit
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange]);
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange, onCaptionSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
@@ -941,8 +993,9 @@ export default function TimelineEditor({
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
const isAudioItem = audioRegions.some(r => r.id === excludeId);
const isCaptionItem = autoCaptions.some(r => r.id === excludeId);
if (isAnnotationItem) {
if (isAnnotationItem || isCaptionItem) {
return false;
}
@@ -972,7 +1025,7 @@ export default function TimelineEditor({
}
return false;
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions]);
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions]);
// Keep newly added timeline regions at the original short default instead of
// scaling them with the full recording length.
@@ -1373,19 +1426,25 @@ export default function TimelineEditor({
variant: 'speed',
}));
const audios: TimelineRenderItem[] = audioRegions.map((region) => {
const fileName = region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || 'Audio';
return {
id: region.id,
const audios: TimelineRenderItem[] = [
...audioRegions.map((audio): TimelineRenderItem => ({
id: audio.id,
rowId: AUDIO_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: fileName,
variant: 'audio',
};
});
span: { start: audio.startMs, end: audio.endMs },
label: audio.audioPath.split(/[\\/]/).pop() || 'Audio',
variant: 'audio'
})),
...autoCaptions.map((cue): TimelineRenderItem => ({
id: cue.id,
rowId: CAPTION_ROW_ID,
span: { start: cue.startMs, end: cue.endMs },
label: cue.text,
variant: 'caption'
}))
];
return [...zooms, ...trims, ...annotations, ...speeds, ...audios];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions]);
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions, autoCaptions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1393,23 +1452,25 @@ export default function TimelineEditor({
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const audios = audioRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims, ...speeds, ...audios];
}, [zoomRegions, trimRegions, speedRegions, audioRegions]);
const captions = autoCaptions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
return [...zooms, ...trims, ...speeds, ...audios, ...captions];
}, [zoomRegions, trimRegions, speedRegions, audioRegions, autoCaptions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom, trim, speed, or annotation item
if (zoomRegions.some(r => r.id === id)) {
const handleItemSpanChange = useCallback((id: string, span: Span, rowId: string) => {
if (rowId === ZOOM_ROW_ID) {
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
} else if (speedRegions.some(r => r.id === id)) {
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
} else if (audioRegions.some(r => r.id === id)) {
onAudioSpanChange?.(id, span);
} else if (rowId === TRIM_ROW_ID && onTrimSpanChange) {
onTrimSpanChange(id, span);
} else if (rowId === ANNOTATION_ROW_ID && onAnnotationSpanChange) {
onAnnotationSpanChange(id, span);
} else if (rowId === SPEED_ROW_ID && onSpeedSpanChange) {
onSpeedSpanChange(id, span);
} else if (rowId === AUDIO_ROW_ID && onAudioSpanChange) {
onAudioSpanChange(id, span);
} else if (rowId === CAPTION_ROW_ID && onCaptionSpanChange) {
onCaptionSpanChange(id, span);
}
}, [zoomRegions, trimRegions, speedRegions, annotationRegions, audioRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange, onAudioSpanChange]);
}, [onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange, onSpeedSpanChange, onAudioSpanChange, onCaptionSpanChange]);
const panTimelineRange = useCallback((deltaMs: number) => {
if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
@@ -1665,11 +1726,13 @@ export default function TimelineEditor({
onSelectAnnotation={handleSelectAnnotation}
onSelectSpeed={handleSelectSpeed}
onSelectAudio={handleSelectAudio}
onSelectCaption={handleSelectCaption}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedSpeedId={selectedSpeedId}
selectedAudioId={selectedAudioId}
selectedCaptionId={selectedCaptionId}
selectAllBlocksActive={selectAllBlocksActive}
onClearBlockSelection={clearSelectedBlocks}
keyframes={keyframes}
@@ -20,7 +20,7 @@ interface TimelineWrapperProps {
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
onItemSpanChange: (id: string, span: Span, rowId: string) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
@@ -133,6 +133,7 @@ export default function TimelineWrapper({
if (!updatedSpan) return;
const activeItemId = event.active.id as string;
const rowId = event.active.data.current.rowId as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
const effectiveMinDuration = totalMs > 0
@@ -151,7 +152,7 @@ export default function TimelineWrapper({
}
}
onItemSpanChange(activeItemId, clampedSpan);
onItemSpanChange(activeItemId, clampedSpan, rowId);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
);
@@ -163,6 +164,7 @@ export default function TimelineWrapper({
if (!updatedSpan || !activeRowId) return;
const activeItemId = event.active.id as string;
const rowId = event.active.data.current.rowId as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
// Clamp to neighbour boundaries instead of rejecting
@@ -173,7 +175,7 @@ export default function TimelineWrapper({
}
}
onItemSpanChange(activeItemId, clampedSpan);
onItemSpanChange(activeItemId, clampedSpan, rowId);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange]
);