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 { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { calculateEffectiveSourceDimensions, calculateMp4ExportSettings, 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 { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { getExportFolder, loadUserPreferences, parentDirectoryOf, saveUserPreferences, } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { nativeBridgeClient, useCursorRecordingData, useCursorTelemetry } from "@/native"; import type { NativePlatform } from "@/native/contracts"; 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, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_BLUR_DATA, DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLIP_TO_BOUNDS, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, type FigureData, type PlaybackSpeed, type Rotation3DPreset, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, type ZoomFocusMode, type ZoomRegion, } from "./types"; import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; function isClickInteractionType(interactionType: string | null | undefined) { return ( interactionType === "click" || interactionType === "double-click" || interactionType === "right-click" || interactionType === "middle-click" ); } interface ExportDiagnostics { formatLabel: "GIF" | "Video"; reason?: string; sourcePath?: string | null; width?: number; height?: number; frameRate?: number; codec?: string; bitrate?: number; } function getFileNameForDiagnostics(filePath?: string | null) { if (!filePath) return "unknown"; try { const url = new URL(filePath); if (url.protocol === "file:") { return decodeURIComponent(url.pathname).split(/[\\/]/).pop() || filePath; } } catch { // Treat non-URL values as filesystem paths. } return filePath.split(/[\\/]/).pop() || filePath; } function buildExportDiagnosticMessage(diagnostics: ExportDiagnostics) { const details = [ diagnostics.reason ? `Reason: ${diagnostics.reason}` : null, `Source: ${getFileNameForDiagnostics(diagnostics.sourcePath)}`, diagnostics.width && diagnostics.height ? `Output: ${diagnostics.width}x${diagnostics.height}${ diagnostics.frameRate ? ` @ ${diagnostics.frameRate} fps` : "" }` : null, diagnostics.codec ? `Codec: ${diagnostics.codec}` : null, diagnostics.bitrate ? `Bitrate: ${Math.round(diagnostics.bitrate / 1_000_000)} Mbps` : null, `VideoEncoder: ${"VideoEncoder" in window ? "available" : "unavailable"}`, ].filter(Boolean); return `${diagnostics.formatLabel} export failed\n${details.join("\n")}`; } function buildSaveDiagnosticMessage(formatLabel: "GIF" | "Video", reason?: string) { return `${formatLabel} export save failed${reason ? `\nReason: ${reason}` : ""}`; } 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, } = 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 [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 [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); const { samples: cursorTelemetry, error: cursorTelemetryError } = useCursorTelemetry(cursorTelemetrySourcePath); const { data: cursorRecordingData, error: cursorRecordingDataError } = useCursorRecordingData(cursorTelemetrySourcePath); const cursorClickTimestamps = useMemo(() => { const recordingClicks = cursorRecordingData?.samples .filter((sample) => isClickInteractionType(sample.interactionType)) .map((sample) => sample.timeMs) ?? []; if (recordingClicks.length > 0) { return recordingClicks; } return cursorTelemetry .filter((sample) => isClickInteractionType(sample.interactionType)) .map((sample) => sample.timeMs); }, [cursorRecordingData, cursorTelemetry]); // Cursor & motion blur visual settings (non-undoable preferences) const [showCursor, setShowCursor] = useState(true); const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE); const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); const [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS); const [nativePlatform, setNativePlatform] = useState(null); const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = useState(null); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); // Native Windows recordings include captured cursor assets. Native macOS // recordings hide the system cursor in ScreenCaptureKit and use telemetry // samples with OpenScreen's default arrow asset for the editable overlay. const hasEditableCursorRecording = recordingCursorCaptureMode === "editable-overlay" && (nativePlatform === "win32" || nativePlatform === "darwin") && hasNativeCursorRecordingData(cursorRecordingData); const effectiveShowCursor = showCursor && hasEditableCursorRecording; const showCursorSettings = hasEditableCursorRecording; 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 { screenVideoPath, ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), ...(recordingCursorCaptureMode ? { cursorCaptureMode: recordingCursorCaptureMode } : {}), }; }, [ videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath, recordingCursorCaptureMode, ]); const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { if (!validateProjectData(candidate)) { return false; } const project = candidate; const projectMedia = resolveProjectMedia(project); if (!projectMedia) { return false; } const sourcePath = projectMedia.screenVideoPath; const webcamSourcePath = projectMedia.webcamVideoPath ?? null; const projectCursorCaptureMode = projectMedia.cursorCaptureMode ?? null; const normalizedEditor = normalizeProjectEditor(project.editor); const inferredDurationMs = Math.max( 0, ...normalizedEditor.zoomRegions.map((region) => region.endMs), ...normalizedEditor.trimRegions.map((region) => region.endMs), ...normalizedEditor.speedRegions.map((region) => region.endMs), ...normalizedEditor.annotationRegions.map((region) => region.endMs), ); try { videoPlaybackRef.current?.pause(); } catch { // no-op } setIsPlaying(false); setCurrentTime(0); setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0); setError(null); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setRecordingCursorCaptureMode(projectCursorCaptureMode); 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( { screenVideoPath: sourcePath, ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), ...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}), }, 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 nativeBridgeClient.project.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); setRecordingCursorCaptureMode(session.cursorCaptureMode ?? null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot( { screenVideoPath: sourcePath, ...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}), ...(session.cursorCaptureMode ? { cursorCaptureMode: session.cursorCaptureMode } : {}), }, INITIAL_EDITOR_STATE, ), ); return; } const result = await nativeBridgeClient.project.getCurrentVideoPath(); if (result.success && result.path) { setVideoSourcePath(result.path); setVideoPath(toFileUrl(result.path)); setRecordingCursorCaptureMode(null); setCurrentProjectPath(null); setLastSavedSnapshot( createProjectSnapshot({ screenVideoPath: result.path }, 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, }; 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 nativeBridgeClient.project.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, ], ); useEffect(() => { window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges); }, [hasUnsavedChanges]); useEffect(() => { const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => { return saveProject(false); }); return () => cleanup(); }, [saveProject]); useEffect(() => { const cleanup = window.electronAPI.onRequestCloseConfirm(() => { setShowCloseConfirmDialog(true); }); return () => cleanup(); }, []); const handleCloseConfirmSave = useCallback(() => { setShowCloseConfirmDialog(false); window.electronAPI.sendCloseConfirmResponse("save"); }, []); const handleCloseConfirmDiscard = useCallback(() => { setShowCloseConfirmDialog(false); window.electronAPI.sendCloseConfirmResponse("discard"); }, []); const handleCloseConfirmCancel = useCallback(() => { setShowCloseConfirmDialog(false); window.electronAPI.sendCloseConfirmResponse("cancel"); }, []); 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 nativeBridgeClient.project.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 canceled = false; nativeBridgeClient.system .getPlatform() .then((platform) => { if (!canceled) { setNativePlatform(platform); } }) .catch((error) => { console.warn("Unable to resolve native platform for cursor settings:", error); if (!canceled) { setNativePlatform(null); } }); return () => { canceled = true; }; }, []); useEffect(() => { if (cursorTelemetryError) { console.warn("Unable to load cursor telemetry:", cursorTelemetryError); } }, [cursorTelemetryError]); useEffect(() => { if (cursorRecordingDataError) { console.warn("Unable to load cursor recording data:", cursorRecordingDataError); } }, [cursorRecordingDataError]); 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, customScale: ZOOM_DEPTH_SCALES[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, customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), }; // Bulk suggest must not steal selection — keeping a zoom selected hides // the export panel (SettingsPanel gates it on !hasTimelineSelection), // trapping users who just want to export after auto-zoom. pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); }, [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, customScale: ZOOM_DEPTH_SCALES[depth], focus: clampFocusToDepth(region.focus, depth), } : region, ), })); }, [selectedZoomId, pushState], ); const handleZoomCustomScaleChange = useCallback( (scale: number) => { if (!selectedZoomId) return; const rounded = Math.round(scale * 100) / 100; if (!Number.isFinite(rounded)) return; updateState((prev) => ({ zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId ? { ...region, customScale: rounded } : region, ), })); }, [selectedZoomId, updateState], ); const handleZoomCustomScaleCommit = useCallback(() => { commitState(); }, [commitState]); 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); const folder = parentDirectoryOf(filePath); if (folder) { saveUserPreferences({ exportFolder: folder }); } 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 pickResult = await window.electronAPI.pickExportSavePath( unsavedExport.fileName, getExportFolder(), ); if (pickResult.canceled || !pickResult.success || !pickResult.path) { toast.info("Export canceled"); return; } const saveResult = await window.electronAPI.writeExportToPath( unsavedExport.arrayBuffer, pickResult.path, ); if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { toast.error( buildSaveDiagnosticMessage( unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.message || "Failed to save export", ), ); } } catch (error) { console.error("Error saving unsaved export:", error); toast.error( buildSaveDiagnosticMessage( unsavedExport.format === "gif" ? "GIF" : "Video", error instanceof Error ? error.message : "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; } // Ask the user where to save BEFORE starting the export. This avoids the // post-export save dialog getting hidden behind other windows after a // long-running export. const isGifFormat = settings.format === "gif"; const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`; const pickResult = await window.electronAPI.pickExportSavePath( targetFileName, getExportFolder(), ); if (pickResult.canceled || !pickResult.success || !pickResult.path) { setShowExportDialog(false); return; } const targetPath = pickResult.path; 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 effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, cropRegion, ); 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, cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, annotationRegions, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, previewWidth, previewHeight, cursorTelemetry, cursorClickTimestamps, 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(); if (result.warnings) { for (const warning of result.warnings) { toast.warning(warning); } } const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" }); const message = buildSaveDiagnosticMessage( "GIF", saveResult.message || "Failed to save GIF", ); setExportError(message); toast.error(message); } } else { const message = buildExportDiagnosticMessage({ formatLabel: "GIF", reason: result.error || "GIF export failed", sourcePath: videoSourcePath ?? videoPath, width: settings.gifConfig.width, height: settings.gifConfig.height, frameRate: settings.gifConfig.frameRate, }); setExportError(message); toast.error(message); } } else { // MP4 Export const quality = settings.quality || exportQuality; const { width: exportWidth, height: exportHeight, bitrate, } = calculateMp4ExportSettings({ quality, sourceWidth: effectiveSourceDimensions.width, sourceHeight: effectiveSourceDimensions.height, aspectRatioValue, }); 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, cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, annotationRegions, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, previewWidth, previewHeight, cursorTelemetry, cursorClickTimestamps, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, }); exporterRef.current = exporter; const result = await exporter.export(); if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); if (result.warnings) { for (const warning of result.warnings) { toast.warning(warning); } } const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" }); const message = buildSaveDiagnosticMessage( "Video", saveResult.message || "Failed to save video", ); setExportError(message); toast.error(message); } } else { const message = buildExportDiagnosticMessage({ formatLabel: "Video", reason: result.error || "Export failed", sourcePath: videoSourcePath ?? videoPath, width: exportWidth, height: exportHeight, frameRate: 60, codec: "avc1.640033", bitrate, }); setExportError(message); toast.error(message); } } 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"; const message = buildExportDiagnosticMessage({ formatLabel: settings.format === "gif" ? "GIF" : "Video", reason: errorMessage, sourcePath: videoSourcePath ?? videoPath, }); setExportError(message); toast.error(t("errors.exportFailedWithError", { error: message })); } } 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, videoSourcePath, webcamVideoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurAmount, borderRadius, padding, cropRegion, cursorRecordingData, annotationRegions, isPlaying, aspectRatio, webcamLayoutPreset, webcamMaskShape, webcamSizePreset, webcamPosition, exportQuality, handleExportSaved, cursorTelemetry, cursorClickTimestamps, effectiveShowCursor, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, 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 effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, cropRegion, ); const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); const gifDimensions = calculateOutputDimensions( effectiveSourceDimensions.width, effectiveSourceDimensions.height, 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")}
{/* Top section: preview and contextual settings */}
{/* 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} cursorRecordingData={cursorRecordingData} 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} cursorClickTimestamps={cursorClickTimestamps} showCursor={effectiveShowCursor} cursorSize={cursorSize} cursorSmoothing={cursorSmoothing} cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} cursorClipToBounds={cursorClipToBounds} />
{/* Playback controls */}
pushState({ wallpaper: w })} selectedZoomDepth={ selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomCustomScale={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) : null } onZoomCustomScaleChange={handleZoomCustomScaleChange} onZoomCustomScaleCommit={handleZoomCustomScaleCommit} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") : null } onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode) } selectedZoomFocus={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) : null } onZoomFocusCoordinateChange={(focus) => selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) } onZoomFocusCoordinateCommit={commitState} 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( calculateEffectiveSourceDimensions( videoPlaybackRef.current?.video?.videoWidth || 1920, videoPlaybackRef.current?.video?.videoHeight || 1080, cropRegion, ).width, calculateEffectiveSourceDimensions( videoPlaybackRef.current?.video?.videoWidth || 1920, videoPlaybackRef.current?.video?.videoHeight || 1080, cropRegion, ).height, 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} showCursor={showCursor} onShowCursorChange={setShowCursor} cursorSize={cursorSize} onCursorSizeChange={setCursorSize} cursorSmoothing={cursorSmoothing} onCursorSmoothingChange={setCursorSmoothing} cursorMotionBlur={cursorMotionBlur} onCursorMotionBlurChange={setCursorMotionBlur} cursorClickBounce={cursorClickBounce} onCursorClickBounceChange={setCursorClickBounce} cursorClipToBounds={cursorClipToBounds} onCursorClipToBoundsChange={setCursorClipToBounds} hasCursorData={ cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) } showCursorSettings={showCursorSettings} />
{/* Full-width timeline */}
pushState({ aspectRatio: ar, webcamLayoutPreset: (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") ? "picture-in-picture" : webcamLayoutPreset, }) } />
setShowExportDialog(false)} progress={exportProgress} isExporting={isExporting} error={exportError} onCancel={handleCancelExport} exportFormat={exportFormat} exportedFilePath={exportedFilePath || undefined} onShowInFolder={ exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } />
); }