diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 42a57040..9b024082 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -9,7 +9,7 @@ import { MagicWand as WandSparkles, MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; -import type { Range, Span } from "dnd-timeline"; +import type { Span } from "dnd-timeline"; import { useTimelineContext } from "dnd-timeline"; import { forwardRef, @@ -20,10 +20,8 @@ import { useMemo, useRef, useState, - type WheelEvent, } from "react"; import { toast } from "sonner"; -import { v4 as uuidv4 } from "uuid"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -33,8 +31,6 @@ import { } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; -import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, @@ -67,12 +63,18 @@ import { TIMELINE_AXIS_HEIGHT_PX, } from "./timelineLayout"; import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks"; -import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils"; import { CLIP_ROW_ID, ZOOM_ROW_ID } from "./core/constants"; import { getAnnotationTrackIndex, getAnnotationTrackRowId, getAudioTrackIndex, getAudioTrackRowId, isAnnotationTrackRowId, isAudioTrackRowId } from "./core/rows"; -import { normalizeRegionSpan, spansOverlap } from "./core/spans"; -import { calculateAxisScale, calculateTimelineScale, createInitialRange, formatPlayheadTime, formatTimeLabel, normalizeWheelDeltaToPixels } from "./core/time"; +import { spansOverlap } from "./core/spans"; +import { calculateAxisScale, calculateTimelineScale, formatPlayheadTime, formatTimeLabel } from "./core/time"; import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "./model/timelineModel"; +import { useTimelineAnnotationsActions } from "./hooks/useTimelineAnnotationsActions"; +import { useTimelineAudioActions } from "./hooks/useTimelineAudioActions"; +import { useTimelineKeyboardShortcuts } from "./hooks/useTimelineKeyboardShortcuts"; +import { useTimelineNormalization } from "./hooks/useTimelineNormalization"; +import { useTimelineRange } from "./hooks/useTimelineRange"; +import { useTimelineSelection } from "./hooks/useTimelineSelection"; +import { useTimelineZoomActions } from "./hooks/useTimelineZoomActions"; export interface TimelineEditorProps { videoDuration: number; @@ -939,10 +941,12 @@ const TimelineEditor = forwardRef( [timelineScale.minItemDurationMs, totalMs], ); - const [range, setRange] = useState(() => createInitialRange(totalMs)); - const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); - const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); - const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); + const timelineContainerRef = useRef(null); + const isTimelineFocusedRef = useRef(false); + const { setRange, clampedRange, handleTimelineWheel } = useTimelineRange({ + totalMs, + timelineContainerRef, + }); const [customAspectWidth, setCustomAspectWidth] = useState( initialEditorPreferences.customAspectWidth, ); @@ -953,15 +957,9 @@ const TimelineEditor = forwardRef( pan: "Shift + Ctrl + Scroll", zoom: "Ctrl + Scroll", }); - const isTimelineFocusedRef = useRef(false); - const timelineContainerRef = useRef(null); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); const audioPeaks = useAudioPeaks(videoPath); - useEffect(() => { - setRange(createInitialRange(totalMs)); - }, [totalMs]); - useEffect(() => { if (aspectRatio === "native") { return; @@ -1009,213 +1007,60 @@ const TimelineEditor = forwardRef( }); }); }, []); - - // Add keyframe at current playhead position - const addKeyframe = useCallback(() => { - if (totalMs === 0) return; - const time = Math.max(0, Math.min(currentTimeMs, totalMs)); - if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return; - setKeyframes((prev) => [...prev, { id: uuidv4(), time }]); - }, [currentTimeMs, totalMs, keyframes]); - - // Delete selected keyframe - const deleteSelectedKeyframe = useCallback(() => { - if (!selectedKeyframeId) return; - setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId)); - setSelectedKeyframeId(null); - }, [selectedKeyframeId]); - - // Move keyframe to new time position - const handleKeyframeMove = useCallback( - (id: string, newTime: number) => { - setKeyframes((prev) => - prev.map((kf) => - kf.id === id - ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } - : kf, - ), - ); - }, - [totalMs], - ); - - // Delete selected zoom item - const deleteSelectedZoom = useCallback(() => { - if (!selectedZoomId) return; - onZoomDelete(selectedZoomId); - onSelectZoom(null); - }, [selectedZoomId, onZoomDelete, onSelectZoom]); - - const deleteSelectedClip = useCallback(() => { - if (!selectedClipId || !onClipDelete || !onSelectClip) return; - onClipDelete(selectedClipId); - onSelectClip(null); - }, [selectedClipId, onClipDelete, onSelectClip]); - - const deleteSelectedAnnotation = useCallback(() => { - if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return; - onAnnotationDelete(selectedAnnotationId); - onSelectAnnotation(null); - }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); - - const deleteSelectedAudio = useCallback(() => { - if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; - onAudioDelete(selectedAudioId); - onSelectAudio(null); - }, [selectedAudioId, onAudioDelete, onSelectAudio]); - - const clearSelectedBlocks = useCallback(() => { - onSelectZoom(null); - onSelectClip?.(null); - onSelectAnnotation?.(null); - onSelectAudio?.(null); - setSelectAllBlocksActive(false); - }, [onSelectAnnotation, onSelectAudio, onSelectClip, onSelectZoom]); - - const hasAnyTimelineBlocks = - zoomRegions.length > 0 || - clipRegions.length > 0 || - annotationRegions.length > 0 || - audioRegions.length > 0; - - const deleteAllBlocks = useCallback(() => { - const zoomIds = zoomRegions.map((region) => region.id); - const clipIds = clipRegions.map((region) => region.id); - const annotationIds = annotationRegions.map((region) => region.id); - const audioIds = audioRegions.map((region) => region.id); - - zoomIds.forEach((id) => onZoomDelete(id)); - clipIds.forEach((id) => onClipDelete?.(id)); - annotationIds.forEach((id) => onAnnotationDelete?.(id)); - audioIds.forEach((id) => onAudioDelete?.(id)); - - clearSelectedBlocks(); - setSelectedKeyframeId(null); - }, [ + const { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + hasAnyTimelineBlocks, + addKeyframe, + deleteSelectedKeyframe, + handleKeyframeMove, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + clearSelectedBlocks, + deleteAllBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + cycleAnnotationsAtCurrentTime, + } = useTimelineSelection({ + totalMs, + currentTimeMs, + zoomRegions, + clipRegions, annotationRegions, audioRegions, - clearSelectedBlocks, - clipRegions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + onZoomDelete, + onClipDelete, onAnnotationDelete, onAudioDelete, - onClipDelete, - onZoomDelete, - zoomRegions, - ]); + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + }); - const handleSelectZoom = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectZoom(id); - }, - [onSelectZoom], - ); - - const handleSelectClip = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectClip?.(id); - }, - [onSelectClip], - ); - - const handleSelectAnnotation = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectAnnotation?.(id); - }, - [onSelectAnnotation], - ); - - const handleSelectAudio = useCallback( - (id: string | null) => { - setSelectAllBlocksActive(false); - onSelectAudio?.(id); - }, - [onSelectAudio], - ); - - useEffect(() => { - setRange(createInitialRange(totalMs)); - }, [totalMs]); - - // Normalize regions only when timeline bounds change (not on every region edit). - // Using refs to read current regions avoids a dependency-loop that re-fires - // this effect on every drag/resize and races with dnd-timeline's internal state. - const zoomRegionsRef = useRef(zoomRegions); - const trimRegionsRef = useRef(trimRegions); - const speedRegionsRef = useRef(speedRegions); - const audioRegionsRef = useRef(audioRegions); - zoomRegionsRef.current = zoomRegions; - trimRegionsRef.current = trimRegions; - speedRegionsRef.current = speedRegions; - audioRegionsRef.current = audioRegions; - - useEffect(() => { - if (totalMs === 0 || safeMinDurationMs <= 0) { - return; - } - - zoomRegionsRef.current.forEach((region) => { - const normalized = normalizeRegionSpan({ - startMs: region.startMs, - endMs: region.endMs, - totalMs, - minDurationMs: safeMinDurationMs, - }); - - if (normalized.start !== region.startMs || normalized.end !== region.endMs) { - onZoomSpanChange(region.id, normalized); - } - }); - - trimRegionsRef.current.forEach((region) => { - const normalized = normalizeRegionSpan({ - startMs: region.startMs, - endMs: region.endMs, - totalMs, - minDurationMs: safeMinDurationMs, - }); - - if (normalized.start !== region.startMs || normalized.end !== region.endMs) { - onTrimSpanChange?.(region.id, normalized); - } - }); - - speedRegionsRef.current.forEach((region) => { - const normalized = normalizeRegionSpan({ - startMs: region.startMs, - endMs: region.endMs, - totalMs, - minDurationMs: safeMinDurationMs, - }); - - if (normalized.start !== region.startMs || normalized.end !== region.endMs) { - onSpeedSpanChange?.(region.id, normalized); - } - }); - - audioRegionsRef.current.forEach((region) => { - const normalized = normalizeRegionSpan({ - startMs: region.startMs, - endMs: region.endMs, - totalMs, - minDurationMs: safeMinDurationMs, - }); - - if (normalized.start !== region.startMs || normalized.end !== region.endMs) { - onAudioSpanChange?.(region.id, normalized); - } - }); - // Only re-run when the timeline scale changes, not on every region edit - }, [ + useTimelineNormalization({ totalMs, safeMinDurationMs, + zoomRegions, + trimRegions, + speedRegions, + audioRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange, - ]); + }); const hasOverlap = useCallback( (newSpan: Span, excludeId?: string, rowId?: string): boolean => { @@ -1283,163 +1128,25 @@ const TimelineEditor = forwardRef( // Keep newly added timeline regions at the original short default instead of // scaling them with the full recording length. - const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); - - const canPlaceZoomAtMs = useCallback( - (startMs: number) => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return false; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return false; - } - - const startPos = Math.max(0, Math.min(startMs, totalMs)); - const activeClip = - clipRegions.length === 0 - ? { startMs: 0, endMs: totalMs } - : clipRegions.find( - (clip) => startPos >= clip.startMs && startPos < clip.endMs, - ); - if (!activeClip) { - return false; - } - - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNextClipEdge = activeClip.endMs - startPos; - const gapToNextRegion = nextRegion - ? nextRegion.startMs - startPos - : gapToNextClipEdge; - const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion); - - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - - return !isOverlapping && availableDuration >= defaultDuration; - }, - [videoDuration, totalMs, zoomRegions, defaultRegionDurationMs, clipRegions], - ); - - const addZoomAtMs = useCallback( - (startMs: number) => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - const startPos = Math.max(0, Math.min(startMs, totalMs)); - if (!canPlaceZoomAtMs(startPos)) { - toast.error("Cannot place zoom here", { - description: - "Zoom already exists here or there is not enough room before the next zoom or clip end.", - }); - return; - } - - onZoomAdded({ start: startPos, end: startPos + defaultDuration }); - }, - [videoDuration, totalMs, onZoomAdded, defaultRegionDurationMs, canPlaceZoomAtMs], - ); - - const handleAddZoom = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - addZoomAtMs(currentTimeMs); - }, [addZoomAtMs, currentTimeMs, totalMs, videoDuration]); - - const handleSuggestZooms = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - if (disableSuggestedZooms) { - toast.info("Suggested zooms are unavailable while cursor looping is enabled."); - return; - } - - if (!onZoomSuggested) { - toast.error("Zoom suggestion handler unavailable"); - return; - } - - if (cursorTelemetry.length < 2) { - toast.info("No cursor telemetry available", { - description: "Record a screencast first to generate cursor-based suggestions.", - }); - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - const result = buildInteractionZoomSuggestions({ - cursorTelemetry, - totalMs, - defaultDurationMs: defaultDuration, - reservedSpans: zoomRegions - .map((region) => ({ start: region.startMs, end: region.endMs })) - .sort((a, b) => a.start - b.start), - }); - - if (result.status === "no-telemetry") { - toast.info("No usable cursor telemetry", { - description: "The recording does not include enough cursor movement data.", - }); - return; - } - - if (result.status === "no-interactions") { - toast.info("No clear interaction moments found", { - description: "Try a recording with pauses or clicks around important actions.", - }); - return; - } - - if (result.status === "no-slots" || result.suggestions.length === 0) { - toast.info("No auto-zoom slots available", { - description: "Detected dwell points overlap existing zoom regions.", - }); - return; - } - - for (const region of result.suggestions) { - onZoomSuggested({ start: region.start, end: region.end }, region.focus); - } - - toast.success( - `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`, - ); - }, [ + const { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + } = useTimelineZoomActions({ videoDuration, totalMs, - defaultRegionDurationMs, + currentTimeMs, zoomRegions, - disableSuggestedZooms, - onZoomSuggested, + clipRegions, cursorTelemetry, - ]); - - useEffect(() => { - if (autoSuggestZoomsTrigger <= 0) { - return; - } - - onAutoSuggestZoomsConsumed?.(); - - handleSuggestZooms(); - }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); + disableSuggestedZooms, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, + }); const handleSplitClip = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { @@ -1448,280 +1155,48 @@ const TimelineEditor = forwardRef( onClipSplit(currentTimeMs); }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); - const handleAddAudio = useCallback( - async (preferredTrackIndex?: number) => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { - return; - } + const { handleAddAudio } = useTimelineAudioActions({ + videoDuration, + totalMs, + currentTimeMs, + audioRegions, + onAudioAdded, + }); - const result = await window.electronAPI.openAudioFilePicker(); - if (!result?.success || !result.path) { - return; - } + const { handleAddAnnotation } = useTimelineAnnotationsActions({ + videoDuration, + totalMs, + currentTimeMs, + defaultRegionDurationMs, + onAnnotationAdded, + }); - const audioPath = result.path; - - // Load the audio file through the local-media resolver so local paths work reliably. - const audioDurationMs = await new Promise((resolve) => { - void (async () => { - const resolved = await resolveMediaElementSource(audioPath); - const audio = new Audio(); - const cleanup = () => { - audio.removeAttribute("src"); - audio.load(); - resolved.revoke(); - }; - - audio.addEventListener( - "loadedmetadata", - () => { - resolve(Math.round(audio.duration * 1000)); - cleanup(); - }, - { once: true }, - ); - audio.addEventListener( - "error", - () => { - resolve(0); - cleanup(); - }, - { once: true }, - ); - audio.src = resolved.src; - })(); - }); - - if (audioDurationMs <= 0) { - toast.error("Could not read audio file", { - description: - "The selected file may be corrupted or in an unsupported format.", - }); - return; - } - - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const maxRemainingDuration = totalMs - startPos; - if (maxRemainingDuration <= 0) { - toast.error("Cannot place audio here", { - description: - "There is no remaining space at the current playhead position.", - }); - return; - } - - const desiredDuration = Math.min(audioDurationMs, maxRemainingDuration); - const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex) - ? Math.max(0, Math.floor(preferredTrackIndex ?? 0)) - : null; - const maxTrackIndex = audioRegions.reduce( - (max, region) => Math.max(max, region.trackIndex ?? 0), - -1, - ); - const candidateTrackIndexes = - normalizedPreferredTrackIndex === null - ? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index) - : [normalizedPreferredTrackIndex]; - - const getGapForTrack = (trackIndex: number) => { - const trackRegions = audioRegions - .filter((region) => (region.trackIndex ?? 0) === trackIndex) - .sort((left, right) => left.startMs - right.startMs); - const desiredSpan = { - start: startPos, - end: startPos + desiredDuration, - }; - - const overlappingRegion = trackRegions.find((region) => - spansOverlap(desiredSpan, { start: region.startMs, end: region.endMs }), - ); - if (overlappingRegion) { - return 0; - } - - const nextRegion = trackRegions.find((region) => region.startMs > startPos); - return nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - }; - - let selectedTrackIndex: number | null = null; - let availableGap = 0; - - for (const trackIndex of candidateTrackIndexes) { - const gap = getGapForTrack(trackIndex); - if (gap >= desiredDuration) { - selectedTrackIndex = trackIndex; - availableGap = gap; - break; - } - } - - if (selectedTrackIndex === null && normalizedPreferredTrackIndex === null) { - for (const trackIndex of candidateTrackIndexes) { - const gap = getGapForTrack(trackIndex); - if (gap > 0) { - selectedTrackIndex = trackIndex; - availableGap = gap; - break; - } - } - } - - if (selectedTrackIndex === null || availableGap <= 0) { - toast.error("Cannot place audio here", { - description: - "Audio region already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(audioDurationMs, availableGap, totalMs - startPos); - onAudioAdded( - { start: startPos, end: startPos + actualDuration }, - result.path, - selectedTrackIndex, - ); - }, - [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded], - ); - - const handleAddAnnotation = useCallback( - (trackIndex = 0) => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Multiple annotations can exist at the same timestamp - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const endPos = Math.min(startPos + defaultDuration, totalMs); - - onAnnotationAdded({ start: startPos, end: endPos }, trackIndex); - }, - [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs], - ); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) { - if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) { - return; - } - - e.preventDefault(); - setSelectedKeyframeId(null); - setSelectAllBlocksActive(true); - return; - } - - if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { - addKeyframe(); - } - if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { - handleAddZoom(); - } - if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) { - handleSplitClip(); - } - if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { - handleAddAnnotation(); - } - - // Tab: Cycle through overlapping annotations at current time - if (e.key === "Tab" && annotationRegions.length > 0) { - const overlapping = annotationRegions - .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) - .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index - - if (overlapping.length > 0) { - e.preventDefault(); - - if ( - !selectedAnnotationId || - !overlapping.some((a) => a.id === selectedAnnotationId) - ) { - onSelectAnnotation?.(overlapping[0].id); - } else { - // Cycle to next annotation - const currentIndex = overlapping.findIndex( - (a) => a.id === selectedAnnotationId, - ); - const nextIndex = e.shiftKey - ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward - : (currentIndex + 1) % overlapping.length; // Tab = forward - onSelectAnnotation?.(overlapping[nextIndex].id); - } - } - } - // Delete key or Ctrl+D / Cmd+D - if ( - e.key === "Delete" || - e.key === "Backspace" || - matchesShortcut(e, keyShortcuts.deleteSelected, isMac) - ) { - if (selectAllBlocksActive) { - e.preventDefault(); - deleteAllBlocks(); - } else if (selectedKeyframeId) { - deleteSelectedKeyframe(); - } else if (selectedZoomId) { - deleteSelectedZoom(); - } else if (selectedClipId) { - deleteSelectedClip(); - } else if (selectedAnnotationId) { - deleteSelectedAnnotation(); - } else if (selectedAudioId) { - deleteSelectedAudio(); - } - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ + useTimelineKeyboardShortcuts({ + isMac, + keyShortcuts, + isTimelineFocusedRef, + hasAnyTimelineBlocks, + annotationCount: annotationRegions.length, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectAllBlocksActive, + setSelectAllBlocksActive, + setSelectedKeyframeId, addKeyframe, handleAddZoom, handleSplitClip, - handleAddAnnotation, + handleAddAnnotation: () => handleAddAnnotation(), deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, - selectedKeyframeId, - selectedZoomId, - selectedClipId, - selectedAnnotationId, - selectedAudioId, - annotationRegions, - currentTimeMs, - hasAnyTimelineBlocks, - onSelectAnnotation, - keyShortcuts, - isMac, - selectAllBlocksActive, - ]); - - const clampedRange = useMemo(() => { - if (totalMs === 0) { - return range; - } - - return { - start: Math.max(0, Math.min(range.start, totalMs)), - end: Math.min(range.end, totalMs), - }; - }, [range, totalMs]); + cycleAnnotationsAtCurrentTime, + }); useImperativeHandle( ref, @@ -1813,62 +1288,6 @@ const TimelineEditor = forwardRef( ], ); - const panTimelineRange = useCallback( - (deltaMs: number) => { - if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) { - return; - } - - setRange((previous) => { - const visibleSpan = Math.max(1, previous.end - previous.start); - const maxStart = Math.max(0, totalMs - visibleSpan); - const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart)); - - return { - start: nextStart, - end: nextStart + visibleSpan, - }; - }); - }, - [totalMs], - ); - - const handleTimelineWheel = useCallback( - (event: WheelEvent) => { - if (event.ctrlKey || event.metaKey || totalMs <= 0) { - return; - } - - const rawHorizontalDelta = - Math.abs(event.deltaX) > 0 - ? event.deltaX - : event.shiftKey && Math.abs(event.deltaY) > 0 - ? event.deltaY - : 0; - - if (rawHorizontalDelta === 0) { - return; - } - - const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; - const visibleRangeMs = clampedRange.end - clampedRange.start; - - if (containerWidth <= 0 || visibleRangeMs <= 0) { - return; - } - - event.preventDefault(); - - const horizontalDeltaPx = normalizeWheelDeltaToPixels( - rawHorizontalDelta, - event.deltaMode, - ); - const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; - - panTimelineRange(deltaMs); - }, - [clampedRange.end, clampedRange.start, panTimelineRange, totalMs], - ); if (!videoDuration || videoDuration === 0) { return ( diff --git a/src/components/video-editor/timeline/hooks/useTimelineAnnotationsActions.ts b/src/components/video-editor/timeline/hooks/useTimelineAnnotationsActions.ts new file mode 100644 index 00000000..c69f82bb --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineAnnotationsActions.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; + +interface UseTimelineAnnotationsActionsParams { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + defaultRegionDurationMs: number; + onAnnotationAdded?: (span: { start: number; end: number }, trackIndex?: number) => void; +} + +export function useTimelineAnnotationsActions({ + videoDuration, + totalMs, + currentTimeMs, + defaultRegionDurationMs, + onAnnotationAdded, +}: UseTimelineAnnotationsActionsParams) { + const handleAddAnnotation = useCallback( + (trackIndex = 0) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const endPos = Math.min(startPos + defaultDuration, totalMs); + onAnnotationAdded({ start: startPos, end: endPos }, trackIndex); + }, + [videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded], + ); + + return { handleAddAnnotation }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineAudioActions.ts b/src/components/video-editor/timeline/hooks/useTimelineAudioActions.ts new file mode 100644 index 00000000..78814096 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineAudioActions.ts @@ -0,0 +1,157 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; +import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; +import { spansOverlap } from "../core/spans"; + +interface AudioRegionLite { + id: string; + startMs: number; + endMs: number; + trackIndex?: number; +} + +interface UseTimelineAudioActionsParams { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + audioRegions: AudioRegionLite[]; + onAudioAdded?: (span: { start: number; end: number }, audioPath: string, trackIndex?: number) => void; +} + +export function useTimelineAudioActions({ + videoDuration, + totalMs, + currentTimeMs, + audioRegions, + onAudioAdded, +}: UseTimelineAudioActionsParams) { + const handleAddAudio = useCallback( + async (preferredTrackIndex?: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { + return; + } + + const result = await window.electronAPI.openAudioFilePicker(); + if (!result?.success || !result.path) { + return; + } + + const audioPath = result.path; + const audioDurationMs = await new Promise((resolve) => { + void (async () => { + const resolved = await resolveMediaElementSource(audioPath); + const audio = new Audio(); + const cleanup = () => { + audio.removeAttribute("src"); + audio.load(); + resolved.revoke(); + }; + + audio.addEventListener( + "loadedmetadata", + () => { + resolve(Math.round(audio.duration * 1000)); + cleanup(); + }, + { once: true }, + ); + audio.addEventListener( + "error", + () => { + resolve(0); + cleanup(); + }, + { once: true }, + ); + audio.src = resolved.src; + })(); + }); + + if (audioDurationMs <= 0) { + toast.error("Could not read audio file", { + description: "The selected file may be corrupted or in an unsupported format.", + }); + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const maxRemainingDuration = totalMs - startPos; + if (maxRemainingDuration <= 0) { + toast.error("Cannot place audio here", { + description: "There is no remaining space at the current playhead position.", + }); + return; + } + + const desiredDuration = Math.min(audioDurationMs, maxRemainingDuration); + const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex) + ? Math.max(0, Math.floor(preferredTrackIndex ?? 0)) + : null; + const maxTrackIndex = audioRegions.reduce( + (max, region) => Math.max(max, region.trackIndex ?? 0), + -1, + ); + const candidateTrackIndexes = + normalizedPreferredTrackIndex === null + ? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index) + : [normalizedPreferredTrackIndex]; + + const getGapForTrack = (trackIndex: number) => { + const trackRegions = audioRegions + .filter((region) => (region.trackIndex ?? 0) === trackIndex) + .sort((left, right) => left.startMs - right.startMs); + const desiredSpan = { + start: startPos, + end: startPos + desiredDuration, + }; + + const overlappingRegion = trackRegions.find((region) => + spansOverlap(desiredSpan, { start: region.startMs, end: region.endMs }), + ); + if (overlappingRegion) { + return 0; + } + + const nextRegion = trackRegions.find((region) => region.startMs > startPos); + return nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + }; + + let selectedTrackIndex: number | null = null; + let availableGap = 0; + + for (const trackIndex of candidateTrackIndexes) { + const gap = getGapForTrack(trackIndex); + if (gap >= desiredDuration) { + selectedTrackIndex = trackIndex; + availableGap = gap; + break; + } + } + + if (selectedTrackIndex === null && normalizedPreferredTrackIndex === null) { + for (const trackIndex of candidateTrackIndexes) { + const gap = getGapForTrack(trackIndex); + if (gap > 0) { + selectedTrackIndex = trackIndex; + availableGap = gap; + break; + } + } + } + + if (selectedTrackIndex === null || availableGap <= 0) { + toast.error("Cannot place audio here", { + description: + "Audio region already exists at this location or not enough space available.", + }); + return; + } + + const actualDuration = Math.min(audioDurationMs, availableGap, totalMs - startPos); + onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path, selectedTrackIndex); + }, + [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded], + ); + + return { handleAddAudio }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts new file mode 100644 index 00000000..3cb1fdbc --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -0,0 +1,142 @@ +import { useEffect, type RefObject } from "react"; +import { matchesShortcut } from "@/lib/shortcuts"; + +interface UseTimelineKeyboardShortcutsParams { + isMac: boolean; + keyShortcuts: { + addKeyframe: unknown; + addZoom: unknown; + splitClip: unknown; + addAnnotation: unknown; + deleteSelected: unknown; + }; + isTimelineFocusedRef: RefObject; + hasAnyTimelineBlocks: boolean; + annotationCount: number; + selectedKeyframeId: string | null; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + selectAllBlocksActive: boolean; + setSelectAllBlocksActive: (active: boolean) => void; + setSelectedKeyframeId: (id: string | null) => void; + addKeyframe: () => void; + handleAddZoom: () => void; + handleSplitClip: () => void; + handleAddAnnotation: () => void; + deleteAllBlocks: () => void; + deleteSelectedKeyframe: () => void; + deleteSelectedZoom: () => void; + deleteSelectedClip: () => void; + deleteSelectedAnnotation: () => void; + deleteSelectedAudio: () => void; + cycleAnnotationsAtCurrentTime: (backward?: boolean) => boolean; +} + +export function useTimelineKeyboardShortcuts({ + isMac, + keyShortcuts, + isTimelineFocusedRef, + hasAnyTimelineBlocks, + annotationCount, + selectedKeyframeId, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + selectAllBlocksActive, + setSelectAllBlocksActive, + setSelectedKeyframeId, + addKeyframe, + handleAddZoom, + handleSplitClip, + handleAddAnnotation, + deleteAllBlocks, + deleteSelectedKeyframe, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + cycleAnnotationsAtCurrentTime, +}: UseTimelineKeyboardShortcutsParams) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) { + if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) { + return; + } + e.preventDefault(); + setSelectedKeyframeId(null); + setSelectAllBlocksActive(true); + return; + } + + if (matchesShortcut(e, keyShortcuts.addKeyframe as never, isMac)) addKeyframe(); + if (matchesShortcut(e, keyShortcuts.addZoom as never, isMac)) handleAddZoom(); + if (matchesShortcut(e, keyShortcuts.splitClip as never, isMac)) handleSplitClip(); + if (matchesShortcut(e, keyShortcuts.addAnnotation as never, isMac)) { + handleAddAnnotation(); + } + + if (e.key === "Tab" && annotationCount > 0) { + if (cycleAnnotationsAtCurrentTime(e.shiftKey)) { + e.preventDefault(); + } + } + + if ( + e.key === "Delete" || + e.key === "Backspace" || + matchesShortcut(e, keyShortcuts.deleteSelected as never, isMac) + ) { + if (selectAllBlocksActive) { + e.preventDefault(); + deleteAllBlocks(); + } else if (selectedKeyframeId) { + deleteSelectedKeyframe(); + } else if (selectedZoomId) { + deleteSelectedZoom(); + } else if (selectedClipId) { + deleteSelectedClip(); + } else if (selectedAnnotationId) { + deleteSelectedAnnotation(); + } else if (selectedAudioId) { + deleteSelectedAudio(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + addKeyframe, + annotationCount, + cycleAnnotationsAtCurrentTime, + deleteAllBlocks, + deleteSelectedAnnotation, + deleteSelectedAudio, + deleteSelectedClip, + deleteSelectedKeyframe, + deleteSelectedZoom, + handleAddAnnotation, + handleAddZoom, + handleSplitClip, + hasAnyTimelineBlocks, + isMac, + isTimelineFocusedRef, + keyShortcuts, + selectAllBlocksActive, + selectedAnnotationId, + selectedAudioId, + selectedClipId, + selectedKeyframeId, + selectedZoomId, + setSelectAllBlocksActive, + setSelectedKeyframeId, + ]); +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts new file mode 100644 index 00000000..f5d7d48e --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef } from "react"; +import { normalizeRegionSpan } from "../core/spans"; +import type { AudioRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../../types"; + +interface UseTimelineNormalizationParams { + totalMs: number; + safeMinDurationMs: number; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + audioRegions: AudioRegion[]; + onZoomSpanChange: (id: string, span: { start: number; end: number }) => void; + onTrimSpanChange?: (id: string, span: { start: number; end: number }) => void; + onSpeedSpanChange?: (id: string, span: { start: number; end: number }) => void; + onAudioSpanChange?: (id: string, span: { start: number; end: number }) => void; +} + +export function useTimelineNormalization({ + totalMs, + safeMinDurationMs, + zoomRegions, + trimRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, +}: UseTimelineNormalizationParams) { + const zoomRegionsRef = useRef(zoomRegions); + const trimRegionsRef = useRef(trimRegions); + const speedRegionsRef = useRef(speedRegions); + const audioRegionsRef = useRef(audioRegions); + zoomRegionsRef.current = zoomRegions; + trimRegionsRef.current = trimRegions; + speedRegionsRef.current = speedRegions; + audioRegionsRef.current = audioRegions; + + useEffect(() => { + if (totalMs === 0 || safeMinDurationMs <= 0) { + return; + } + + zoomRegionsRef.current.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onZoomSpanChange(region.id, normalized); + } + }); + + trimRegionsRef.current.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onTrimSpanChange?.(region.id, normalized); + } + }); + + speedRegionsRef.current.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onSpeedSpanChange?.(region.id, normalized); + } + }); + + audioRegionsRef.current.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onAudioSpanChange?.(region.id, normalized); + } + }); + }, [ + totalMs, + safeMinDurationMs, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + ]); +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.ts new file mode 100644 index 00000000..e3d5ff59 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.ts @@ -0,0 +1,80 @@ +import type { Range } from "dnd-timeline"; +import { useCallback, useEffect, useMemo, useState, type RefObject, type WheelEvent } from "react"; +import { createInitialRange, normalizeWheelDeltaToPixels } from "../core/time"; + +interface UseTimelineRangeParams { + totalMs: number; + timelineContainerRef: RefObject; +} + +export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) { + const [range, setRange] = useState(() => createInitialRange(totalMs)); + + useEffect(() => { + setRange(createInitialRange(totalMs)); + }, [totalMs]); + + const clampedRange = useMemo(() => { + if (totalMs === 0) { + return range; + } + return { + start: Math.max(0, Math.min(range.start, totalMs)), + end: Math.min(range.end, totalMs), + }; + }, [range, totalMs]); + + const panTimelineRange = useCallback( + (deltaMs: number) => { + if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) { + return; + } + + setRange((previous) => { + const visibleSpan = Math.max(1, previous.end - previous.start); + const maxStart = Math.max(0, totalMs - visibleSpan); + const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart)); + return { start: nextStart, end: nextStart + visibleSpan }; + }); + }, + [totalMs], + ); + + const handleTimelineWheel = useCallback( + (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey || totalMs <= 0) { + return; + } + + const rawHorizontalDelta = + Math.abs(event.deltaX) > 0 + ? event.deltaX + : event.shiftKey && Math.abs(event.deltaY) > 0 + ? event.deltaY + : 0; + + if (rawHorizontalDelta === 0) { + return; + } + + const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; + const visibleRangeMs = clampedRange.end - clampedRange.start; + if (containerWidth <= 0 || visibleRangeMs <= 0) { + return; + } + + event.preventDefault(); + const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode); + const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; + panTimelineRange(deltaMs); + }, + [clampedRange.end, clampedRange.start, panTimelineRange, timelineContainerRef, totalMs], + ); + + return { + range, + setRange, + clampedRange, + handleTimelineWheel, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts new file mode 100644 index 00000000..2e682c72 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -0,0 +1,217 @@ +import { useCallback, useMemo, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; + +interface RegionId { + id: string; + startMs: number; + endMs: number; +} + +interface UseTimelineSelectionParams { + totalMs: number; + currentTimeMs: number; + zoomRegions: RegionId[]; + clipRegions: RegionId[]; + annotationRegions: (RegionId & { zIndex: number })[]; + audioRegions: RegionId[]; + selectedZoomId: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedAudioId?: string | null; + onZoomDelete: (id: string) => void; + onClipDelete?: (id: string) => void; + onAnnotationDelete?: (id: string) => void; + onAudioDelete?: (id: string) => void; + onSelectZoom: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; +} + +export function useTimelineSelection({ + totalMs, + currentTimeMs, + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + onZoomDelete, + onClipDelete, + onAnnotationDelete, + onAudioDelete, + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, +}: UseTimelineSelectionParams) { + const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); + const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); + const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); + + const addKeyframe = useCallback(() => { + if (totalMs === 0) return; + const time = Math.max(0, Math.min(currentTimeMs, totalMs)); + if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return; + setKeyframes((prev) => [...prev, { id: uuidv4(), time }]); + }, [currentTimeMs, totalMs, keyframes]); + + const deleteSelectedKeyframe = useCallback(() => { + if (!selectedKeyframeId) return; + setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId)); + setSelectedKeyframeId(null); + }, [selectedKeyframeId]); + + const handleKeyframeMove = useCallback( + (id: string, newTime: number) => { + setKeyframes((prev) => + prev.map((kf) => + kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf, + ), + ); + }, + [totalMs], + ); + + const deleteSelectedZoom = useCallback(() => { + if (!selectedZoomId) return; + onZoomDelete(selectedZoomId); + onSelectZoom(null); + }, [selectedZoomId, onZoomDelete, onSelectZoom]); + + const deleteSelectedClip = useCallback(() => { + if (!selectedClipId || !onClipDelete || !onSelectClip) return; + onClipDelete(selectedClipId); + onSelectClip(null); + }, [selectedClipId, onClipDelete, onSelectClip]); + + const deleteSelectedAnnotation = useCallback(() => { + if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return; + onAnnotationDelete(selectedAnnotationId); + onSelectAnnotation(null); + }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); + + const deleteSelectedAudio = useCallback(() => { + if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; + onAudioDelete(selectedAudioId); + onSelectAudio(null); + }, [selectedAudioId, onAudioDelete, onSelectAudio]); + + const clearSelectedBlocks = useCallback(() => { + onSelectZoom(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectAudio?.(null); + setSelectAllBlocksActive(false); + }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]); + + const hasAnyTimelineBlocks = useMemo( + () => + zoomRegions.length > 0 || + clipRegions.length > 0 || + annotationRegions.length > 0 || + audioRegions.length > 0, + [zoomRegions.length, clipRegions.length, annotationRegions.length, audioRegions.length], + ); + + const deleteAllBlocks = useCallback(() => { + zoomRegions.map((r) => r.id).forEach((id) => onZoomDelete(id)); + clipRegions.map((r) => r.id).forEach((id) => onClipDelete?.(id)); + annotationRegions.map((r) => r.id).forEach((id) => onAnnotationDelete?.(id)); + audioRegions.map((r) => r.id).forEach((id) => onAudioDelete?.(id)); + clearSelectedBlocks(); + setSelectedKeyframeId(null); + }, [ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + onZoomDelete, + onClipDelete, + onAnnotationDelete, + onAudioDelete, + clearSelectedBlocks, + ]); + + const handleSelectZoom = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectZoom(id); + }, + [onSelectZoom], + ); + + const handleSelectClip = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectClip?.(id); + }, + [onSelectClip], + ); + + const handleSelectAnnotation = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAnnotation?.(id); + }, + [onSelectAnnotation], + ); + + const handleSelectAudio = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAudio?.(id); + }, + [onSelectAudio], + ); + + const cycleAnnotationsAtCurrentTime = useCallback( + (backward = false) => { + const overlapping = annotationRegions + .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) + .sort((a, b) => a.zIndex - b.zIndex); + if (overlapping.length === 0) { + return false; + } + + if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) { + onSelectAnnotation?.(overlapping[0].id); + return true; + } + + const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId); + const nextIndex = backward + ? (currentIndex - 1 + overlapping.length) % overlapping.length + : (currentIndex + 1) % overlapping.length; + onSelectAnnotation?.(overlapping[nextIndex].id); + return true; + }, + [annotationRegions, currentTimeMs, selectedAnnotationId, onSelectAnnotation], + ); + + return { + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + selectAllBlocksActive, + setSelectAllBlocksActive, + hasAnyTimelineBlocks, + addKeyframe, + deleteSelectedKeyframe, + handleKeyframeMove, + deleteSelectedZoom, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedAudio, + clearSelectedBlocks, + deleteAllBlocks, + handleSelectZoom, + handleSelectClip, + handleSelectAnnotation, + handleSelectAudio, + cycleAnnotationsAtCurrentTime, + }; +} diff --git a/src/components/video-editor/timeline/hooks/useTimelineZoomActions.ts b/src/components/video-editor/timeline/hooks/useTimelineZoomActions.ts new file mode 100644 index 00000000..4e07bf68 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineZoomActions.ts @@ -0,0 +1,194 @@ +import type { Span } from "dnd-timeline"; +import { useCallback, useEffect, useMemo } from "react"; +import { toast } from "sonner"; +import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../../types"; +import { buildInteractionZoomSuggestions } from "../zoomSuggestionUtils"; + +interface UseTimelineZoomActionsParams { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + zoomRegions: ZoomRegion[]; + clipRegions: { startMs: number; endMs: number }[]; + cursorTelemetry: CursorTelemetryPoint[]; + disableSuggestedZooms: boolean; + autoSuggestZoomsTrigger: number; + onAutoSuggestZoomsConsumed?: () => void; + onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; +} + +export function useTimelineZoomActions({ + videoDuration, + totalMs, + currentTimeMs, + zoomRegions, + clipRegions, + cursorTelemetry, + disableSuggestedZooms, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, +}: UseTimelineZoomActionsParams) { + const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); + + const canPlaceZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return false; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return false; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + const activeClip = + clipRegions.length === 0 + ? { startMs: 0, endMs: totalMs } + : clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs); + if (!activeClip) { + return false; + } + + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNextClipEdge = activeClip.endMs - startPos; + const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge; + const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion); + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + + return !isOverlapping && availableDuration >= defaultDuration; + }, + [videoDuration, totalMs, defaultRegionDurationMs, clipRegions, zoomRegions], + ); + + const addZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceZoomAtMs(startPos)) { + toast.error("Cannot place zoom here", { + description: + "Zoom already exists here or there is not enough room before the next zoom or clip end.", + }); + return; + } + + onZoomAdded({ start: startPos, end: startPos + defaultDuration }); + }, + [videoDuration, totalMs, defaultRegionDurationMs, canPlaceZoomAtMs, onZoomAdded], + ); + + const handleAddZoom = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + addZoomAtMs(currentTimeMs); + }, [videoDuration, totalMs, currentTimeMs, addZoomAtMs]); + + const handleSuggestZooms = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + if (disableSuggestedZooms) { + toast.info("Suggested zooms are unavailable while cursor looping is enabled."); + return; + } + + if (!onZoomSuggested) { + toast.error("Zoom suggestion handler unavailable"); + return; + } + + if (cursorTelemetry.length < 2) { + toast.info("No cursor telemetry available", { + description: "Record a screencast first to generate cursor-based suggestions.", + }); + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry, + totalMs, + defaultDurationMs: defaultDuration, + reservedSpans: zoomRegions + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start), + }); + + if (result.status === "no-telemetry") { + toast.info("No usable cursor telemetry", { + description: "The recording does not include enough cursor movement data.", + }); + return; + } + + if (result.status === "no-interactions") { + toast.info("No clear interaction moments found", { + description: "Try a recording with pauses or clicks around important actions.", + }); + return; + } + + if (result.status === "no-slots" || result.suggestions.length === 0) { + toast.info("No auto-zoom slots available", { + description: "Detected dwell points overlap existing zoom regions.", + }); + return; + } + + for (const region of result.suggestions) { + onZoomSuggested({ start: region.start, end: region.end }, region.focus); + } + + toast.success( + `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`, + ); + }, [ + videoDuration, + totalMs, + disableSuggestedZooms, + onZoomSuggested, + cursorTelemetry, + defaultRegionDurationMs, + zoomRegions, + ]); + + useEffect(() => { + if (autoSuggestZoomsTrigger <= 0) { + return; + } + + onAutoSuggestZoomsConsumed?.(); + handleSuggestZooms(); + }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); + + return { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + }; +} diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index c2eeb5bc..ceca3eea 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -3,8 +3,6 @@ import type { AnnotationRegion, AudioRegion, ClipRegion, - SpeedRegion, - TrimRegion, ZoomMode, ZoomRegion, } from "../../types";