mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 08:15:42 +00:00
feat: auto caption show on timeline editor
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user