import type { Span } from "dnd-timeline"; import { FolderOpen, Languages, Save, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { calculateOutputDimensions, type ExportFormat, type ExportProgress, type ExportQuality, type ExportSettings, GIF_SIZE_PRESETS, GifExporter, type GifFrameRate, type GifSizePreset, VideoExporter, } from "@/lib/exporter"; import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, getNativeAspectRatioValue, isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, createProjectSnapshot, deriveNextId, fromFileUrl, hasProjectUnsavedChanges, normalizeProjectEditor, resolveProjectMedia, toFileUrl, validateProjectData, } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { type AnnotationRegion, type BlurData, type CursorTelemetryPoint, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_BLUR_DATA, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, type FigureData, type PlaybackSpeed, type Rotation3DPreset, type SpeedRegion, type TrimRegion, type ZoomDepth, type ZoomFocus, type ZoomFocusMode, type ZoomRegion, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { const { state: editorState, pushState, updateState, commitState, undo, redo, } = useEditorHistory(INITIAL_EDITOR_STATE); const { zoomRegions, trimRegions, speedRegions, annotationRegions, cropRegion, wallpaper, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, cursorHighlight, } = editorState; // ── Non-undoable state const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); const [webcamVideoPath, setWebcamVideoPath] = useState(null); const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const currentTimeRef = useRef(currentTime); currentTimeRef.current = currentTime; const durationRef = useRef(duration); durationRef.current = duration; const [cursorTelemetry, setCursorTelemetry] = useState([]); const [cursorClickTimestamps, setCursorClickTimestamps] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [selectedBlurId, setSelectedBlurId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); const [exportQuality, setExportQuality] = useState("good"); const [exportFormat, setExportFormat] = useState("mp4"); const [gifFrameRate, setGifFrameRate] = useState(15); const [gifLoop, setGifLoop] = useState(true); const [gifSizePreset, setGifSizePreset] = useState("medium"); const [exportedFilePath, setExportedFilePath] = useState(null); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const [unsavedExport, setUnsavedExport] = useState<{ arrayBuffer: ArrayBuffer; fileName: string; format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for // renderers while keeping the persisted value intact for round-tripping. const effectiveCursorHighlight = useMemo( () => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }), [cursorHighlight, isMac], ); const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); const availableLocales = getAvailableLocales(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); const annotationOnlyRegions = useMemo( () => annotationRegions.filter((region) => region.type !== "blur"), [annotationRegions], ); const blurRegions = useMemo( () => annotationRegions.filter((region) => region.type === "blur"), [annotationRegions], ); const currentProjectMedia = useMemo(() => { const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!screenVideoPath) { return null; } const webcamSourcePath = webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null); return webcamSourcePath ? { screenVideoPath, webcamVideoPath: webcamSourcePath } : { screenVideoPath }; }, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]); const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { if (!validateProjectData(candidate)) { return false; } const project = candidate; const media = resolveProjectMedia(project); if (!media) { return false; } const sourcePath = fromFileUrl(media.screenVideoPath); const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null; const normalizedEditor = normalizeProjectEditor(project.editor); try { videoPlaybackRef.current?.pause(); } catch { // no-op } setIsPlaying(false); setCurrentTime(0); setDuration(0); setError(null); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(path ?? null); pushState({ wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, motionBlurAmount: normalizedEditor.motionBlurAmount, borderRadius: normalizedEditor.borderRadius, padding: normalizedEditor.padding, cropRegion: normalizedEditor.cropRegion, zoomRegions: normalizedEditor.zoomRegions, trimRegions: normalizedEditor.trimRegions, speedRegions: normalizedEditor.speedRegions, annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, webcamMaskShape: normalizedEditor.webcamMaskShape, webcamSizePreset: normalizedEditor.webcamSizePreset, webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); setGifFrameRate(normalizedEditor.gifFrameRate); setGifLoop(normalizedEditor.gifLoop); setGifSizePreset(normalizedEditor.gifSizePreset); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); nextZoomIdRef.current = deriveNextId( "zoom", normalizedEditor.zoomRegions.map((region) => region.id), ); nextTrimIdRef.current = deriveNextId( "trim", normalizedEditor.trimRegions.map((region) => region.id), ); nextSpeedIdRef.current = deriveNextId( "speed", normalizedEditor.speedRegions.map((region) => region.id), ); nextAnnotationIdRef.current = deriveNextId( "annotation", normalizedEditor.annotationRegions.map((region) => region.id), ); nextAnnotationZIndexRef.current = normalizedEditor.annotationRegions.reduce( (max, region) => Math.max(max, region.zIndex), 0, ) + 1; setLastSavedSnapshot( createProjectSnapshot( webcamSourcePath ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } : { screenVideoPath: sourcePath }, normalizedEditor, ), ); return true; }, [pushState], ); const currentProjectSnapshot = useMemo(() => { if (!currentProjectMedia) { return null; } return createProjectSnapshot(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, zoomRegions, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, }); }, [ currentProjectMedia, wallpaper, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, zoomRegions, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, ]); const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot); useEffect(() => { async function loadInitialData() { try { const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); if (currentProjectResult.success && currentProjectResult.project) { const restored = await applyLoadedProject( currentProjectResult.project, currentProjectResult.path ?? null, ); if (restored) { return; } } const currentSessionResult = await window.electronAPI.getCurrentRecordingSession(); if (currentSessionResult.success && currentSessionResult.session) { const session = currentSessionResult.session; const sourcePath = fromFileUrl(session.screenVideoPath); const webcamSourcePath = session.webcamVideoPath ? fromFileUrl(session.webcamVideoPath) : null; setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot( webcamSourcePath ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath, } : { screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE, ), ); return; } const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const sourcePath = fromFileUrl(result.path); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(null); setWebcamVideoPath(null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE), ); } else { setError("No video to load. Please record or select a video."); } } catch (err) { setError("Error loading video: " + String(err)); } finally { setLoading(false); } } loadInitialData(); }, [applyLoadedProject]); // Track whether user preferences have been loaded to avoid // overwriting saved prefs with defaults on the first render const [prefsHydrated, setPrefsHydrated] = useState(false); // Load persisted user preferences on mount (intentionally runs once) useEffect(() => { const prefs = loadUserPreferences(); updateState({ padding: prefs.padding, aspectRatio: prefs.aspectRatio, }); setExportQuality(prefs.exportQuality); setExportFormat(prefs.exportFormat); setPrefsHydrated(true); }, [updateState]); // Auto-save user preferences when settings change useEffect(() => { if (!prefsHydrated) return; saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat }); }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]); const saveProject = useCallback( async (forceSaveAs: boolean) => { if (!videoPath) { toast.error(t("errors.noVideoLoaded")); return false; } if (!currentProjectMedia) { toast.error(t("errors.unableToDetermineSourcePath")); return false; } const editorState = { wallpaper, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, zoomRegions, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, cursorHighlight, }; const projectData = createProjectData(currentProjectMedia, editorState); const fileNameBase = currentProjectMedia.screenVideoPath .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; // Match the normalization path used by `currentProjectSnapshot` so the // post-save baseline compares equal and `hasUnsavedChanges` clears. const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState); const result = await window.electronAPI.saveProjectFile( projectData, fileNameBase, forceSaveAs ? undefined : (currentProjectPath ?? undefined), ); if (result.canceled) { toast.info(t("project.saveCanceled")); return false; } if (!result.success) { toast.error(result.message || t("project.failedToSave")); return false; } if (result.path) { setCurrentProjectPath(result.path); } setLastSavedSnapshot(projectSnapshot); toast.success(t("project.savedTo", { path: result.path ?? "" })); return true; }, [ currentProjectMedia, currentProjectPath, wallpaper, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, zoomRegions, trimRegions, speedRegions, annotationRegions, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, videoPath, t, webcamSizePreset, cursorHighlight, ], ); useEffect(() => { window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges); }, [hasUnsavedChanges]); useEffect(() => { const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => { return saveProject(false); }); return () => cleanup(); }, [saveProject]); const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); const handleSaveProjectAs = useCallback(async () => { await saveProject(true); }, [saveProject]); const handleNewRecordingConfirm = useCallback(async () => { const result = await window.electronAPI.startNewRecording(); if (result.success) { setShowNewRecordingDialog(false); } else { console.error("Failed to start new recording:", result.error); setError("Failed to start new recording: " + (result.error || "Unknown error")); } }, []); const handleLoadProject = useCallback(async () => { const result = await window.electronAPI.loadProjectFile(); if (result.canceled) { return; } if (!result.success) { toast.error(result.message || t("project.failedToLoad")); return; } const restored = await applyLoadedProject(result.project, result.path ?? null); if (!restored) { toast.error(t("project.invalidFormat")); return; } toast.success(t("project.loadedFrom", { path: result.path ?? "" })); }, [applyLoadedProject, t]); useEffect(() => { const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); return () => { removeLoadListener?.(); removeSaveListener?.(); removeSaveAsListener?.(); }; }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); useEffect(() => { let mounted = true; async function loadCursorTelemetry() { const sourcePath = currentProjectMedia?.screenVideoPath ?? null; if (!sourcePath) { if (mounted) { setCursorTelemetry([]); setCursorClickTimestamps([]); } return; } try { const result = await window.electronAPI.getCursorTelemetry(sourcePath); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []); } } catch (telemetryError) { console.warn("Unable to load cursor telemetry:", telemetryError); if (mounted) { setCursorTelemetry([]); setCursorClickTimestamps([]); } } } loadCursorTelemetry(); return () => { mounted = false; }; }, [currentProjectMedia]); function togglePlayPause() { const playback = videoPlaybackRef.current; const video = playback?.video; if (!playback || !video) return; if (isPlaying) { playback.pause(); } else { playback.play().catch((err) => console.error("Video play failed:", err)); } } const toggleFullscreen = useCallback(() => { setIsFullscreen((prev) => !prev); }, []); useEffect(() => { if (!isFullscreen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsFullscreen(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isFullscreen]); function handleSeek(time: number) { const video = videoPlaybackRef.current?.video; if (!video) return; video.currentTime = time; } const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } }, []); const handleSelectTrim = useCallback((id: string | null) => { setSelectedTrimId(id); if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } }, []); const handleSelectAnnotation = useCallback((id: string | null) => { setSelectedAnnotationId(id); if (id) { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedBlurId(null); } }, []); const handleSelectBlur = useCallback((id: string | null) => { setSelectedBlurId(id); if (id) { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedSpeedId(null); } }, []); const handleZoomAdded = useCallback( (span: Span) => { const id = `zoom-${nextZoomIdRef.current++}`; const newRegion: ZoomRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, focus: { cx: 0.5, cy: 0.5 }, }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, [pushState], ); const handleZoomSuggested = useCallback( (span: Span, focus: ZoomFocus) => { const id = `zoom-${nextZoomIdRef.current++}`; const newRegion: ZoomRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, [pushState], ); const handleTrimAdded = useCallback( (span: Span) => { const id = `trim-${nextTrimIdRef.current++}`; const newRegion: TrimRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), }; pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); setSelectedTrimId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, [pushState], ); const handleZoomSpanChange = useCallback( (id: string, span: Span) => { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), } : region, ), })); }, [pushState], ); const handleTrimSpanChange = useCallback( (id: string, span: Span) => { pushState((prev) => ({ trimRegions: prev.trimRegions.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), } : region, ), })); }, [pushState], ); // Focus drag: updateState for live preview, commitState on pointer-up const handleZoomFocusChange = useCallback( (id: string, focus: ZoomFocus) => { updateState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region, ), })); }, [updateState], ); const handleZoomDepthChange = useCallback( (depth: ZoomDepth) => { if (!selectedZoomId) return; pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth), } : region, ), })); }, [selectedZoomId, pushState], ); const handleZoomFocusModeChange = useCallback( (focusMode: ZoomFocusMode) => { if (!selectedZoomId) return; pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId ? { ...region, focusMode } : region, ), })); }, [selectedZoomId, pushState], ); const handleZoomDelete = useCallback( (id: string) => { pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id), })); if (selectedZoomId === id) { setSelectedZoomId(null); } }, [selectedZoomId, pushState], ); const handleZoomRotationPresetChange = useCallback( (preset: Rotation3DPreset | null) => { if (!selectedZoomId) return; pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => { if (region.id !== selectedZoomId) return region; if (preset === null) { const { rotationPreset: _p, ...rest } = region; return rest; } return { ...region, rotationPreset: preset }; }), })); }, [selectedZoomId, pushState], ); const handleTrimDelete = useCallback( (id: string) => { pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id), })); if (selectedTrimId === id) { setSelectedTrimId(null); } }, [selectedTrimId, pushState], ); const handleSelectSpeed = useCallback((id: string | null) => { setSelectedSpeedId(id); if (id) { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } }, []); const handleSpeedAdded = useCallback( (span: Span) => { const id = `speed-${nextSpeedIdRef.current++}`; const newRegion: SpeedRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), speed: DEFAULT_PLAYBACK_SPEED, }; pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion], })); setSelectedSpeedId(id); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, [pushState], ); const handleSpeedSpanChange = useCallback( (id: string, span: Span) => { pushState((prev) => ({ speedRegions: prev.speedRegions.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), } : region, ), })); }, [pushState], ); const handleSpeedDelete = useCallback( (id: string) => { pushState((prev) => ({ speedRegions: prev.speedRegions.filter((region) => region.id !== id), })); if (selectedSpeedId === id) { setSelectedSpeedId(null); } }, [selectedSpeedId, pushState], ); const handleSpeedChange = useCallback( (speed: PlaybackSpeed) => { if (!selectedSpeedId) return; pushState((prev) => ({ speedRegions: prev.speedRegions.map((region) => region.id === selectedSpeedId ? { ...region, speed } : region, ), })); }, [selectedSpeedId, pushState], ); const handleAnnotationAdded = useCallback( (span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; const zIndex = nextAnnotationZIndexRef.current++; const newRegion: AnnotationRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), type: "text", content: "Enter text...", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, }; pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion], })); setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedBlurId(null); }, [pushState], ); const handleBlurAdded = useCallback( (span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; const zIndex = nextAnnotationZIndexRef.current++; const newRegion: AnnotationRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), type: "blur", content: "", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, blurData: { ...DEFAULT_BLUR_DATA }, }; pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion], })); setSelectedBlurId(id); setSelectedAnnotationId(null); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); }, [pushState], ); const handleAnnotationSpanChange = useCallback( (id: string, span: Span) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), } : region, ), })); }, [pushState], ); const handleAnnotationDuplicate = useCallback( (id: string) => { const duplicateId = `annotation-${nextAnnotationIdRef.current++}`; const duplicateZIndex = nextAnnotationZIndexRef.current++; pushState((prev) => { const source = prev.annotationRegions.find((region) => region.id === id); if (!source) return {}; const duplicate: AnnotationRegion = { ...source, id: duplicateId, zIndex: duplicateZIndex, position: { x: source.position.x + 4, y: source.position.y + 4 }, size: { ...source.size }, style: { ...source.style }, figureData: source.figureData ? { ...source.figureData } : undefined, }; return { annotationRegions: [...prev.annotationRegions, duplicate] }; }); setSelectedAnnotationId(duplicateId); setSelectedZoomId(null); setSelectedTrimId(null); }, [pushState], ); const handleAnnotationDelete = useCallback( (id: string) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.filter((r) => r.id !== id), })); if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } if (selectedBlurId === id) { setSelectedBlurId(null); } }, [selectedAnnotationId, selectedBlurId, pushState], ); const handleAnnotationContentChange = useCallback( (id: string, content: string) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => { if (region.id !== id) return region; if (region.type === "text") { return { ...region, content, textContent: content }; } else if (region.type === "image") { return { ...region, content, imageContent: content }; } return { ...region, content }; }), })); }, [pushState], ); const handleAnnotationTypeChange = useCallback( (id: string, type: AnnotationRegion["type"]) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => { if (region.id !== id) return region; const updatedRegion = { ...region, type }; if (type === "text") { updatedRegion.content = region.textContent || "Enter text..."; } else if (type === "image") { updatedRegion.content = region.imageContent || ""; } else if (type === "figure") { updatedRegion.content = ""; if (!region.figureData) { updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; } } else if (type === "blur") { updatedRegion.content = ""; if (!region.blurData) { updatedRegion.blurData = { ...DEFAULT_BLUR_DATA }; } } return updatedRegion; }), })); if (type === "blur" && selectedAnnotationId === id) { setSelectedAnnotationId(null); setSelectedBlurId(id); setSelectedSpeedId(null); } else if (type !== "blur" && selectedBlurId === id) { setSelectedBlurId(null); setSelectedAnnotationId(id); } }, [pushState, selectedAnnotationId, selectedBlurId], ); const handleAnnotationStyleChange = useCallback( (id: string, style: Partial) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, style: { ...region.style, ...style } } : region, ), })); }, [pushState], ); const handleAnnotationFigureDataChange = useCallback( (id: string, figureData: FigureData) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, figureData } : region, ), })); }, [pushState], ); const handleBlurDataPreviewChange = useCallback( (id: string, blurData: BlurData) => { updateState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, blurData, // Freehand drawing area is the full video surface. ...(blurData.shape === "freehand" ? { position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, } : {}), } : region, ), })); }, [updateState], ); const handleBlurDataPanelChange = useCallback( (id: string, blurData: BlurData) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, blurData, ...(blurData.shape === "freehand" ? { position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, } : {}), } : region, ), })); }, [pushState], ); const handleAnnotationPositionChange = useCallback( (id: string, position: { x: number; y: number }) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, position } : region, ), })); }, [pushState], ); const handleAnnotationSizeChange = useCallback( (id: string, size: { width: number; height: number }) => { pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, size } : region, ), })); }, [pushState], ); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; const key = e.key.toLowerCase(); if (mod && key === "z" && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); undo(); return; } if (mod && (key === "y" || (key === "z" && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); redo(); return; } // Frame-step navigation (arrow keys, no modifiers) if ( (e.key === "ArrowLeft" || e.key === "ArrowRight") && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey ) { const target = e.target; if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || (target instanceof HTMLElement && (target.isContentEditable || target.closest('[role="separator"], [role="slider"], [role="spinbutton"]'))) ) { return; } e.preventDefault(); const video = videoPlaybackRef.current?.video; if (!video) { return; } const direction = e.key === "ArrowLeft" ? "backward" : "forward"; const newTime = computeFrameStepTime( video.currentTime, Number.isFinite(video.duration) ? video.duration : durationRef.current, direction, ); video.currentTime = newTime; return; } const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; if (e.key === "Tab" && !isInput) { e.preventDefault(); } if (matchesShortcut(e, shortcuts.playPause, isMac)) { // Allow space only in inputs/textareas if (isInput) { return; } e.preventDefault(); const playback = videoPlaybackRef.current; if (playback?.video) { playback.video.paused ? playback.play().catch(console.error) : playback.pause(); } } }; window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [undo, redo, shortcuts, isMac]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { setSelectedZoomId(null); } }, [selectedZoomId, zoomRegions]); useEffect(() => { if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { setSelectedTrimId(null); } }, [selectedTrimId, trimRegions]); useEffect(() => { if ( selectedAnnotationId && !annotationOnlyRegions.some((region) => region.id === selectedAnnotationId) ) { setSelectedAnnotationId(null); } if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) { setSelectedBlurId(null); } }, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]); useEffect(() => { if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) { setSelectedSpeedId(null); } }, [selectedSpeedId, speedRegions]); const handleShowExportedFile = useCallback(async (filePath: string) => { try { const result = await window.electronAPI.revealInFolder(filePath); if (!result.success) { const errorMessage = result.error || result.message || "Failed to reveal item in folder."; console.error("Failed to reveal in folder:", errorMessage); toast.error(errorMessage); } } catch (error) { const errorMessage = String(error); console.error("Error calling revealInFolder IPC:", errorMessage); toast.error(`Error revealing in folder: ${errorMessage}`); } }, []); const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); toast.success( t("export.exportedSuccessfully", { format: formatLabel, }), { description: filePath, action: { label: rawT("common.actions.showInFolder"), onClick: () => { void handleShowExportedFile(filePath); }, }, }, ); }, [handleShowExportedFile, t, rawT], ); const handleSaveUnsavedExport = useCallback(async () => { if (!unsavedExport) return; try { const saveResult = await window.electronAPI.saveExportedVideo( unsavedExport.arrayBuffer, unsavedExport.fileName, ); if (saveResult.canceled) { toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { toast.error(saveResult.message || "Failed to save export"); } } catch (error) { console.error("Error saving unsaved export:", error); toast.error("Failed to save exported video"); } }, [unsavedExport, handleExportSaved]); const handleExport = useCallback( async (settings: ExportSettings) => { if (!videoPath) { toast.error("No video loaded"); return; } const video = videoPlaybackRef.current?.video; if (!video) { toast.error("Video not ready"); return; } setIsExporting(true); setExportProgress(null); setExportError(null); setExportedFilePath(null); try { const wasPlaying = isPlaying; if (wasPlaying) { videoPlaybackRef.current?.pause(); } const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; const containerElement = playbackRef?.containerRef?.current; const previewWidth = containerElement?.clientWidth || 1920; const previewHeight = containerElement?.clientHeight || 1080; if (settings.format === "gif" && settings.gifConfig) { // GIF Export const gifExporter = new GifExporter({ videoUrl: videoPath, webcamVideoUrl: webcamVideoPath || undefined, width: settings.gifConfig.width, height: settings.gifConfig.height, frameRate: settings.gifConfig.frameRate, loop: settings.gifConfig.loop, sizePreset: settings.gifConfig.sizePreset, wallpaper, zoomRegions, trimRegions, speedRegions, showShadow: shadowIntensity > 0, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, videoPadding: padding, cropRegion, annotationRegions, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, previewWidth, previewHeight, cursorTelemetry, cursorClickTimestamps, cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, }); exporterRef.current = gifExporter as unknown as VideoExporter; const result = await gifExporter.export(); if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; if (result.warnings) { for (const warning of result.warnings) { toast.warning(warning); } } const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { setExportError(saveResult.message || "Failed to save GIF"); toast.error(saveResult.message || "Failed to save GIF"); } } else { setExportError(result.error || "GIF export failed"); toast.error(result.error || "GIF export failed"); } } else { // MP4 Export const quality = settings.quality || exportQuality; let exportWidth: number; let exportHeight: number; let bitrate: number; if (quality === "source") { // Use source resolution exportWidth = sourceWidth; exportHeight = sourceHeight; if (aspectRatioValue === 1) { // Square (1:1): use smaller dimension to avoid codec limits const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; exportWidth = baseDimension; exportHeight = baseDimension; } else if (aspectRatioValue > 1) { // Landscape: find largest even dimensions that exactly match aspect ratio const baseWidth = Math.floor(sourceWidth / 2) * 2; let found = false; for (let w = baseWidth; w >= 100 && !found; w -= 2) { const h = Math.round(w / aspectRatioValue); if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { exportWidth = w; exportHeight = h; found = true; } } if (!found) { exportWidth = baseWidth; exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; } } else { // Portrait: find largest even dimensions that exactly match aspect ratio const baseHeight = Math.floor(sourceHeight / 2) * 2; let found = false; for (let h = baseHeight; h >= 100 && !found; h -= 2) { const w = Math.round(h * aspectRatioValue); if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { exportWidth = w; exportHeight = h; found = true; } } if (!found) { exportHeight = baseHeight; exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; } } // Calculate visually lossless bitrate matching screen recording optimization const totalPixels = exportWidth * exportHeight; bitrate = 30_000_000; if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { bitrate = 50_000_000; } else if (totalPixels > 2560 * 1440) { bitrate = 80_000_000; } } else { // Use quality-based target resolution const targetHeight = quality === "medium" ? 720 : 1080; // Calculate dimensions maintaining aspect ratio exportHeight = Math.floor(targetHeight / 2) * 2; exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Adjust bitrate for lower resolutions const totalPixels = exportWidth * exportHeight; if (totalPixels <= 1280 * 720) { bitrate = 10_000_000; } else if (totalPixels <= 1920 * 1080) { bitrate = 20_000_000; } else { bitrate = 30_000_000; } } const exporter = new VideoExporter({ videoUrl: videoPath, webcamVideoUrl: webcamVideoPath || undefined, width: exportWidth, height: exportHeight, frameRate: 60, bitrate, codec: "avc1.640033", wallpaper, zoomRegions, trimRegions, speedRegions, showShadow: shadowIntensity > 0, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, annotationRegions, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, previewWidth, previewHeight, cursorTelemetry, cursorClickTimestamps, cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, }); exporterRef.current = exporter; const result = await exporter.export(); if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; if (result.warnings) { for (const warning of result.warnings) { toast.warning(warning); } } const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); toast.info("Export canceled"); } else if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { setExportError(saveResult.message || "Failed to save video"); toast.error(saveResult.message || "Failed to save video"); } } else { setExportError(result.error || "Export failed"); toast.error(result.error || "Export failed"); } } if (wasPlaying) { videoPlaybackRef.current?.play(); } } catch (error) { console.error("Export error:", error); if (error instanceof BackgroundLoadError) { const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl }); setExportError(message); toast.error(message); } else { const errorMessage = error instanceof Error ? error.message : "Unknown error"; setExportError(errorMessage); toast.error(t("errors.exportFailedWithError", { error: errorMessage })); } } finally { setIsExporting(false); exporterRef.current = null; // Reset dialog state to ensure it can be opened again on next export // This fixes the bug where second export doesn't show save dialog setShowExportDialog(false); setExportProgress(null); } }, [ videoPath, webcamVideoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, exportQuality, handleExportSaved, cursorTelemetry, cursorClickTimestamps, effectiveCursorHighlight, t, ], ); const handleOpenExportDialog = useCallback(() => { if (!videoPath) { toast.error("No video loaded"); return; } const video = videoPlaybackRef.current?.video; if (!video) { toast.error("Video not ready"); return; } // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); const gifDimensions = calculateOutputDimensions( sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS, aspectRatioValue, ); const settings: ExportSettings = { format: exportFormat, quality: exportFormat === "mp4" ? exportQuality : undefined, gifConfig: exportFormat === "gif" ? { frameRate: gifFrameRate, loop: gifLoop, sizePreset: gifSizePreset, width: gifDimensions.width, height: gifDimensions.height, } : undefined, }; setShowExportDialog(true); setExportError(null); setExportedFilePath(null); // Start export immediately handleExport(settings); }, [ videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, aspectRatio, cropRegion, handleExport, ]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { exporterRef.current.cancel(); toast.info("Export canceled"); setShowExportDialog(false); setIsExporting(false); setExportProgress(null); setExportError(null); setExportedFilePath(null); } }, []); const handleSaveDiagnostic = useCallback(async () => { const result = await window.electronAPI.saveDiagnostic({ error: exportError ?? "Manual diagnostic export", projectState: editorState, logs: [], }); if (result.success) { toast.success("Diagnostic file saved"); } else if (!result.canceled) { toast.error("Failed to save diagnostic file"); } }, [exportError, editorState]); if (loading) { return (
{t("loadingVideo")}
); } if (error) { return (
{error}
); } return (
{t("newRecording.title")} {t("newRecording.description")}
{/* Left Column - Video & Timeline */}
{/* Top section: video preview and controls */}
{/* Video preview */}
updateState({ webcamPosition: pos })} onWebcamPositionDragEnd={commitState} onDurationChange={setDuration} onTimeUpdate={setCurrentTime} currentTime={currentTime} onPlayStateChange={setIsPlaying} onError={setError} wallpaper={wallpaper} zoomRegions={zoomRegions} selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} onZoomFocusChange={handleZoomFocusChange} onZoomFocusDragEnd={commitState} isPlaying={isPlaying} showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} showBlur={showBlur} motionBlurAmount={motionBlurAmount} borderRadius={borderRadius} padding={padding} cropRegion={cropRegion} trimRegions={trimRegions} speedRegions={speedRegions} annotationRegions={annotationOnlyRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} blurRegions={blurRegions} selectedBlurId={selectedBlurId} onSelectBlur={handleSelectBlur} onBlurPositionChange={handleAnnotationPositionChange} onBlurSizeChange={handleAnnotationSizeChange} onBlurDataChange={handleBlurDataPreviewChange} onBlurDataCommit={commitState} cursorTelemetry={cursorTelemetry} cursorHighlight={effectiveCursorHighlight} cursorClickTimestamps={cursorClickTimestamps} />
{/* Playback controls */}
{/* Timeline section */}
pushState({ aspectRatio: ar, webcamLayoutPreset: (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") ? "picture-in-picture" : webcamLayoutPreset, }) } />
{/* Right section: settings panel */}
pushState({ cursorHighlight: next })} cursorHighlightSupportsClicks={isMac} selected={wallpaper} onWallpaperChange={(w) => pushState({ wallpaper: w })} selectedZoomDepth={ selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") : null } onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)} hasCursorTelemetry={cursorTelemetry.length > 0} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} selectedZoomRotationPreset={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null) : null } onZoomRotationPresetChange={handleZoomRotationPresetChange} selectedTrimId={selectedTrimId} onTrimDelete={handleTrimDelete} shadowIntensity={shadowIntensity} onShadowChange={(v) => updateState({ shadowIntensity: v })} onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} motionBlurAmount={motionBlurAmount} onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} onMotionBlurCommit={commitState} borderRadius={borderRadius} onBorderRadiusChange={(v) => updateState({ borderRadius: v })} onBorderRadiusCommit={commitState} padding={padding} onPaddingChange={(v) => updateState({ padding: v })} onPaddingCommit={commitState} cropRegion={cropRegion} onCropChange={(r) => pushState({ cropRegion: r })} aspectRatio={aspectRatio} hasWebcam={Boolean(webcamVideoPath)} webcamLayoutPreset={webcamLayoutPreset} onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset, webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, }) } webcamMaskShape={webcamMaskShape} onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} webcamSizePreset={webcamSizePreset} onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} onWebcamSizePresetCommit={commitState} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} exportFormat={exportFormat} onExportFormatChange={setExportFormat} gifFrameRate={gifFrameRate} onGifFrameRateChange={setGifFrameRate} gifLoop={gifLoop} onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( videoPlaybackRef.current?.video?.videoWidth || 1920, videoPlaybackRef.current?.video?.videoHeight || 1080, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" ? getNativeAspectRatioValue( videoPlaybackRef.current?.video?.videoWidth || 1920, videoPlaybackRef.current?.video?.videoHeight || 1080, cropRegion, ) : getAspectRatioValue(aspectRatio), )} onExport={handleOpenExportDialog} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationOnlyRegions} onAnnotationContentChange={handleAnnotationContentChange} onAnnotationTypeChange={handleAnnotationTypeChange} onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDuplicate={handleAnnotationDuplicate} onAnnotationDelete={handleAnnotationDelete} selectedBlurId={selectedBlurId} blurRegions={blurRegions} onBlurDataChange={handleBlurDataPanelChange} onBlurDataCommit={commitState} onBlurDelete={handleAnnotationDelete} selectedSpeedId={selectedSpeedId} selectedSpeedValue={ selectedSpeedId ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) : null } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} onSaveDiagnostic={handleSaveDiagnostic} />
setShowExportDialog(false)} progress={exportProgress} isExporting={isExporting} error={exportError} onCancel={handleCancelExport} exportFormat={exportFormat} exportedFilePath={exportedFilePath || undefined} onShowInFolder={ exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } />
); }