From 0bffd1bd6309f02a8a89b5bebcfa956b75f258be Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Wed, 13 May 2026 00:49:29 +0200 Subject: [PATCH] add: first part of video editor refactoring, there is still so long to go.. god --- package-lock.json | 38 +- package.json | 3 +- src/components/video-editor/VideoEditor.tsx | 4319 ++++------------- .../editor/components/EditorExportMenu.tsx | 150 + .../editor/components/EditorHeader.tsx | 16 + .../editor/components/EditorPreviewArea.tsx | 91 + .../editor/components/EditorSidebar.tsx | 152 + .../editor/components/EditorTimelineArea.tsx | 21 + .../components/EditorTimelineToolbar.tsx | 109 + .../editor/components/PresetManager.tsx | 144 + .../video-editor/editor/hooks/index.ts | 12 + .../editor/hooks/useEditorSettings.ts | 94 + .../video-editor/editor/hooks/useExport.ts | 680 +++ .../video-editor/editor/hooks/usePlayback.ts | 110 + .../video-editor/editor/hooks/usePresets.ts | 75 + .../video-editor/editor/hooks/useProject.ts | 746 +++ .../editor/hooks/useTimelineActions.ts | 369 ++ .../editor/hooks/useTimelineState.ts | 164 + .../editor/hooks/useVideoEditorPresets.ts | 264 + .../editor/hooks/useVideoEditorProject.ts | 398 ++ .../editor/hooks/useVideoEditorStore.ts | 96 + .../editor/hooks/useVideoEditorThumbnail.ts | 271 ++ .../editor/hooks/useWhisperCaptions.ts | 215 + src/components/video-editor/index.ts | 1 + 24 files changed, 5158 insertions(+), 3380 deletions(-) create mode 100644 src/components/video-editor/editor/components/EditorExportMenu.tsx create mode 100644 src/components/video-editor/editor/components/EditorHeader.tsx create mode 100644 src/components/video-editor/editor/components/EditorPreviewArea.tsx create mode 100644 src/components/video-editor/editor/components/EditorSidebar.tsx create mode 100644 src/components/video-editor/editor/components/EditorTimelineArea.tsx create mode 100644 src/components/video-editor/editor/components/EditorTimelineToolbar.tsx create mode 100644 src/components/video-editor/editor/components/PresetManager.tsx create mode 100644 src/components/video-editor/editor/hooks/index.ts create mode 100644 src/components/video-editor/editor/hooks/useEditorSettings.ts create mode 100644 src/components/video-editor/editor/hooks/useExport.ts create mode 100644 src/components/video-editor/editor/hooks/usePlayback.ts create mode 100644 src/components/video-editor/editor/hooks/usePresets.ts create mode 100644 src/components/video-editor/editor/hooks/useProject.ts create mode 100644 src/components/video-editor/editor/hooks/useTimelineActions.ts create mode 100644 src/components/video-editor/editor/hooks/useTimelineState.ts create mode 100644 src/components/video-editor/editor/hooks/useVideoEditorPresets.ts create mode 100644 src/components/video-editor/editor/hooks/useVideoEditorProject.ts create mode 100644 src/components/video-editor/editor/hooks/useVideoEditorStore.ts create mode 100644 src/components/video-editor/editor/hooks/useVideoEditorThumbnail.ts create mode 100644 src/components/video-editor/editor/hooks/useWhisperCaptions.ts diff --git a/package-lock.json b/package-lock.json index d2212347..566f5f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", "ffprobe-static": "^3.1.0", - "uiohook-napi": "^1.5.4" + "uiohook-napi": "^1.5.4", + "zustand": "^5.0.13" }, "devDependencies": { "@biomejs/biome": "2.3.13", @@ -4506,14 +4507,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.26", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6296,7 +6297,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dashdash": { @@ -13649,6 +13650,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index c0e30642..f6c50d84 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", "ffprobe-static": "^3.1.0", - "uiohook-napi": "^1.5.4" + "uiohook-napi": "^1.5.4", + "zustand": "^5.0.13" }, "devDependencies": { "@biomejs/biome": "2.3.13", diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 589b79bf..f51f3c2d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,35 +1,15 @@ import { - BookmarkSimple, - Check, - CaretDown as ChevronDown, - CaretUp as ChevronUp, ClosedCaptioning, - Crop, Cursor, - DownloadSimple as Download, FolderOpen, Gear, - Pause, Camera as PhCameraRegular, - Play, - Plus, PuzzlePiece, ArrowClockwise as Redo2, - Scissors, - SkipBack, - SkipForward, Sparkle, ArrowCounterClockwise as Undo2, - UserCircle as User, - SpeakerLow as Volume1, - SpeakerHigh as Volume2, - SpeakerX as VolumeX, - MagicWand as WandSparkles, X, - MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; -import type { Span } from "dnd-timeline"; -import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -41,14 +21,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Toaster } from "@/components/ui/sonner"; import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; @@ -64,7 +36,6 @@ import { type ExportQuality, type ExportRenderBackend, type ExportSettings, - FrameRenderer, GIF_SIZE_PRESETS, GifExporter, type GifFrameRate, @@ -81,14 +52,7 @@ import { describeBlockedInMemoryExportSave, } from "@/lib/exporter/exportSavePolicy"; import { matchesShortcut } from "@/lib/shortcuts"; -import { cn } from "@/lib/utils"; -import { - ASPECT_RATIOS, - type AspectRatio, - getAspectRatioLabel, - getAspectRatioValue, -} from "@/utils/aspectRatioUtils"; -import { ExtensionIcon } from "./ExtensionIcon"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; import { calculateMp4ExportDimensions, calculateMp4SourceDimensions, @@ -114,18 +78,12 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) ); import { extensionHost } from "@/lib/extensions"; -import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; -import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; import { - type EditorPreset, - type EditorPresetSnapshot, loadEditorPreferences, - loadEditorPresets, saveEditorPreferences, - saveEditorPresets, - serializeEditorPresetSnapshot, + type EditorPresetSnapshot, } from "./editorPreferences"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { @@ -133,11 +91,9 @@ import { deriveNextId, type EditorProjectData, fromFileUrl, - normalizeProjectEditor, resolveVideoUrl, stripPersistedDevMotionBlurSettings, toFileUrl, - validateProjectData, } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; @@ -160,19 +116,13 @@ import { type CropRegion, type CursorStyle, type CursorTelemetryPoint, - clampFocusToDepth, clipsToTrims, - DEFAULT_ANNOTATION_POSITION, - DEFAULT_ANNOTATION_SIZE, - DEFAULT_ANNOTATION_STYLE, DEFAULT_AUTO_CAPTION_SETTINGS, - DEFAULT_AUTO_ZOOM_DEPTH, DEFAULT_CONNECTED_ZOOM_DURATION_MS, DEFAULT_CONNECTED_ZOOM_EASING, DEFAULT_CONNECTED_ZOOM_GAP_MS, DEFAULT_CROP_REGION, DEFAULT_CURSOR_STYLE, - DEFAULT_FIGURE_DATA, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_TIME_OFFSET_MS, DEFAULT_ZOOM_IN_DURATION_MS, @@ -183,7 +133,6 @@ import { DEFAULT_ZOOM_OUT_EASING, type EditorEffectSection, extendAutoFullTrackClip, - type FigureData, getClipSourceEndMs, getTimelineDurationMs, type Padding, @@ -193,9 +142,6 @@ import { type TrimRegion, trimsToClips, type WebcamOverlaySettings, - type ZoomDepth, - type ZoomFocus, - type ZoomMode, type ZoomMotionBlurTuning, type ZoomRegion, type ZoomTransitionEasing, @@ -205,19 +151,30 @@ import { buildLoopedCursorTelemetry, getDisplayedTimelineWindowMs, } from "./videoPlayback/cursorLoopTelemetry"; - -type EditorHistorySnapshot = { - zoomRegions: ZoomRegion[]; - clipRegions: ClipRegion[]; - speedRegions: SpeedRegion[]; - annotationRegions: AnnotationRegion[]; - audioRegions: AudioRegion[]; - autoCaptions: CaptionCue[]; - selectedZoomId: string | null; - selectedClipId: string | null; - selectedAnnotationId: string | null; - selectedAudioId: string | null; -}; +import { + useEditorSettings, + usePlayback, + type EditorProjectFile, + useProject, + useProjectBootstrapController, + useTimelineActions, + useTimelineState, + useExport, + useSmokeExportController, + useExportUiController, + useVideoEditorProject, + useVideoEditorThumbnail, + useWhisperCaptions, + useEditorUiState, + useVideoEditorStore, +} from "./editor/hooks"; +import { EditorHeader } from "./editor/components/EditorHeader"; +import { EditorExportMenu } from "./editor/components/EditorExportMenu"; +import { EditorPreviewArea } from "./editor/components/EditorPreviewArea"; +import { PresetManager } from "./editor/components/PresetManager"; +import { EditorSidebar } from "./editor/components/EditorSidebar"; +import { EditorTimelineArea } from "./editor/components/EditorTimelineArea"; +import { EditorTimelineToolbar } from "./editor/components/EditorTimelineToolbar"; type PendingExportSave = { fileName: string; @@ -305,13 +262,6 @@ async function streamExportBlobToTempFile(blob: Blob, extension: string): Promis } } -type SaveProjectOptions = { - silent?: boolean; - remountPreviewAfterSave?: boolean; - refreshLibraryAfterSave?: boolean; - captureThumbnail?: boolean; -}; - type DevOpenRecordingConfig = { inputPath: string | null; webcamInputPath: string | null; @@ -340,7 +290,6 @@ async function writeSmokeExportReport( } } -const SMOKE_EXPORT_READY_TIMEOUT_MS = 30_000; const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const PROJECT_AUTOSAVE_DELAY_MS = 1000; const EXPORT_ERROR_TOAST_DURATION_MS = 20000; @@ -539,22 +488,108 @@ export default function VideoEditor() { const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); - const [currentProjectPath, setCurrentProjectPath] = useState(null); - const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); - const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); - const [isEditingProjectName, setIsEditingProjectName] = useState(false); - const [projectNameDraft, setProjectNameDraft] = useState(""); - const [isSavingProjectName, setIsSavingProjectName] = useState(false); + const autosaveRunnerRef = useRef<(() => Promise) | null>(null); + const projectManager = useProject({ + initialSnapshot: createProjectData("", {}, null), + autosaveDelayMs: PROJECT_AUTOSAVE_DELAY_MS, + onRefreshLibrary: async () => { + const result = await window.electronAPI.listProjectFiles(); + if (!result.success) { + throw new Error(result.error || "Failed to load project library"); + } + return result.entries.map( + (entry) => + ({ + projectId: entry.path, + projectName: entry.name, + videoPath: "", + projectPath: entry.path, + ...entry, + }) satisfies EditorProjectFile, + ); + }, + onLoad: async (projectPath) => { + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled || !result.success || !result.project) { + return null; + } + const candidateProject = result.project as EditorProjectData; + return { + project: { + projectId: result.path ?? projectPath, + projectName: (result.path ?? projectPath).split(/[\\/]/).pop() ?? "Untitled", + videoPath: fromFileUrl(candidateProject.videoPath), + projectPath: result.path ?? projectPath, + path: result.path ?? projectPath, + name: (result.path ?? projectPath).split(/[\\/]/).pop() ?? "Untitled", + updatedAt: Date.now(), + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + }, + snapshot: candidateProject, + }; + }, + onSave: async () => { + if (!autosaveRunnerRef.current) { + return; + } + await autosaveRunnerRef.current(); + }, + }); + const currentProjectPath = projectManager.project.projectPath; + const setCurrentProjectPath = useCallback((next: string | null) => { + projectManager.updateProject({ projectPath: next }, { markDirty: false }); + }, [projectManager]); + const projectLibraryEntries = projectManager.projectLibrary as ProjectLibraryEntry[]; 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 [wallpaper, setWallpaper] = useState(initialEditorPreferences.wallpaper); - const [shadowIntensity, setShadowIntensity] = useState( - initialEditorPreferences.shadowIntensity, - ); - const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur); + const playback = usePlayback({ + initialIsPlaying: false, + initialTime: 0, + initialDuration: 0, + }); + const isPlaying = playback.isPlaying; + const currentTime = playback.currentTime; + const duration = playback.duration; + const setIsPlaying = useCallback((next: boolean) => { + if (next) { + playback.play(); + return; + } + playback.pause(); + }, [playback]); + const setCurrentTime = useCallback((next: number) => { + playback.seek(next); + }, [playback]); + const setDuration = useCallback((next: number) => { + playback.setDuration(next); + }, [playback]); + const { settings, updateSettings } = useEditorSettings({ + wallpaper: initialEditorPreferences.wallpaper, + shadowIntensity: initialEditorPreferences.shadowIntensity, + backgroundBlur: initialEditorPreferences.backgroundBlur, + borderRadius: initialEditorPreferences.borderRadius, + padding: initialEditorPreferences.padding, + cursorStyle: initialEditorPreferences.cursorStyle ?? DEFAULT_CURSOR_STYLE, + cursorSize: initialEditorPreferences.cursorSize, + cursorSmoothing: initialEditorPreferences.cursorSmoothing, + cursorMotionBlur: initialEditorPreferences.cursorMotionBlur, + zoomInDurationMs: initialEditorPreferences.zoomInDurationMs ?? DEFAULT_ZOOM_IN_DURATION_MS, + zoomInOverlapMs: initialEditorPreferences.zoomInOverlapMs ?? DEFAULT_ZOOM_IN_OVERLAP_MS, + zoomOutDurationMs: initialEditorPreferences.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_DURATION_MS, + connectedZoomGapMs: + initialEditorPreferences.connectedZoomGapMs ?? DEFAULT_CONNECTED_ZOOM_GAP_MS, + connectedZoomDurationMs: + initialEditorPreferences.connectedZoomDurationMs ?? DEFAULT_CONNECTED_ZOOM_DURATION_MS, + zoomInEasing: initialEditorPreferences.zoomInEasing ?? DEFAULT_ZOOM_IN_EASING, + zoomOutEasing: initialEditorPreferences.zoomOutEasing ?? DEFAULT_ZOOM_OUT_EASING, + connectedZoomEasing: + initialEditorPreferences.connectedZoomEasing ?? DEFAULT_CONNECTED_ZOOM_EASING, + }); + const wallpaper = settings.wallpaper; + const shadowIntensity = settings.shadowIntensity; + const backgroundBlur = settings.backgroundBlur; const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur); const [zoomMotionBlurTuning, setZoomMotionBlurTuning] = useState( initialEditorPreferences.zoomMotionBlurTuning ?? DEFAULT_ZOOM_MOTION_BLUR_TUNING, @@ -572,39 +607,19 @@ export default function VideoEditor() { initialEditorPreferences.autoApplyFreshRecordingAutoZooms, ); const [connectZooms, setConnectZooms] = useState(initialEditorPreferences.connectZooms); - const [zoomInDurationMs, setZoomInDurationMs] = useState( - initialEditorPreferences.zoomInDurationMs ?? DEFAULT_ZOOM_IN_DURATION_MS, - ); - const [zoomInOverlapMs, setZoomInOverlapMs] = useState( - initialEditorPreferences.zoomInOverlapMs ?? DEFAULT_ZOOM_IN_OVERLAP_MS, - ); - const [zoomOutDurationMs, setZoomOutDurationMs] = useState( - initialEditorPreferences.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_DURATION_MS, - ); - const [connectedZoomGapMs, setConnectedZoomGapMs] = useState( - initialEditorPreferences.connectedZoomGapMs ?? DEFAULT_CONNECTED_ZOOM_GAP_MS, - ); - const [connectedZoomDurationMs, setConnectedZoomDurationMs] = useState( - initialEditorPreferences.connectedZoomDurationMs ?? DEFAULT_CONNECTED_ZOOM_DURATION_MS, - ); - const [zoomInEasing, setZoomInEasing] = useState( - initialEditorPreferences.zoomInEasing ?? DEFAULT_ZOOM_IN_EASING, - ); - const [zoomOutEasing, setZoomOutEasing] = useState( - initialEditorPreferences.zoomOutEasing ?? DEFAULT_ZOOM_OUT_EASING, - ); - const [connectedZoomEasing, setConnectedZoomEasing] = useState( - initialEditorPreferences.connectedZoomEasing ?? DEFAULT_CONNECTED_ZOOM_EASING, - ); + const zoomInDurationMs = settings.zoomInDurationMs; + const zoomInOverlapMs = settings.zoomInOverlapMs; + const zoomOutDurationMs = settings.zoomOutDurationMs; + const connectedZoomGapMs = settings.connectedZoomGapMs; + const connectedZoomDurationMs = settings.connectedZoomDurationMs; + const zoomInEasing = settings.zoomInEasing; + const zoomOutEasing = settings.zoomOutEasing; + const connectedZoomEasing = settings.connectedZoomEasing; const [showCursor, setShowCursor] = useState(initialEditorPreferences.showCursor); const [loopCursor, setLoopCursor] = useState(initialEditorPreferences.loopCursor); - const [cursorStyle, setCursorStyle] = useState( - initialEditorPreferences.cursorStyle ?? DEFAULT_CURSOR_STYLE, - ); - const [cursorSize, setCursorSize] = useState(initialEditorPreferences.cursorSize); - const [cursorSmoothing, setCursorSmoothing] = useState( - initialEditorPreferences.cursorSmoothing, - ); + const cursorStyle = settings.cursorStyle; + const cursorSize = settings.cursorSize; + const cursorSmoothing = settings.cursorSmoothing; const [cursorSpringStiffnessMultiplier, setCursorSpringStiffnessMultiplier] = useState( initialEditorPreferences.cursorSpringStiffnessMultiplier, ); @@ -631,9 +646,7 @@ export default function VideoEditor() { useState(false); const [zoomSmoothness, setZoomSmoothness] = useState(0.5); const [zoomClassicMode, setZoomClassicMode] = useState(false); - const [cursorMotionBlur, setCursorMotionBlur] = useState( - initialEditorPreferences.cursorMotionBlur, - ); + const cursorMotionBlur = settings.cursorMotionBlur; const [cursorClickBounce, setCursorClickBounce] = useState( initialEditorPreferences.cursorClickBounce, ); @@ -641,15 +654,27 @@ export default function VideoEditor() { initialEditorPreferences.cursorClickBounceDuration, ); const [cursorSway, setCursorSway] = useState(initialEditorPreferences.cursorSway); - const [borderRadius, setBorderRadius] = useState(initialEditorPreferences.borderRadius); - const [padding, setPadding] = useState(initialEditorPreferences.padding); + const borderRadius = settings.borderRadius; + const padding = settings.padding; const [frame, setFrame] = useState(initialEditorPreferences.frame); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [webcam, setWebcam] = useState( initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, ); const [resolvedWebcamVideoUrl, setResolvedWebcamVideoUrl] = useState(null); - const [zoomRegions, setZoomRegions] = useState([]); + const timelineState = useTimelineState({ + zoomRegions: [], + clipRegions: [], + speedRegions: [], + annotationRegions: [], + audioRegions: [], + autoCaptions: [], + selectedZoomId: null, + selectedClipId: null, + selectedAnnotationId: null, + selectedAudioId: null, + }); + const zoomRegions = timelineState.state.zoomRegions; const [cursorTelemetry, setCursorTelemetry] = useState([]); // Tracks the videoSourcePath for which the cursor telemetry IPC has already // resolved. The smoke-export auto-trigger waits on this so long recordings @@ -657,15 +682,15 @@ export default function VideoEditor() { // auto-export fires as soon as the video loads and the telemetry arrives // after encoding has started. const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState(null); - const [selectedZoomId, setSelectedZoomId] = useState(null); + const selectedZoomId = timelineState.state.selectedZoomId; const [trimRegions, setTrimRegions] = useState([]); - const [clipRegions, setClipRegions] = useState([]); - const [selectedClipId, setSelectedClipId] = useState(null); - const [speedRegions, setSpeedRegions] = useState([]); - const [annotationRegions, setAnnotationRegions] = useState([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); - const [audioRegions, setAudioRegions] = useState([]); - const [selectedAudioId, setSelectedAudioId] = useState(null); + const clipRegions = timelineState.state.clipRegions; + const selectedClipId = timelineState.state.selectedClipId; + const speedRegions = timelineState.state.speedRegions; + const annotationRegions = timelineState.state.annotationRegions; + const selectedAnnotationId = timelineState.state.selectedAnnotationId; + const audioRegions = timelineState.state.audioRegions; + const selectedAudioId = timelineState.state.selectedAudioId; const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState< Record >({}); @@ -673,7 +698,118 @@ export default function VideoEditor() { SourceAudioTrackSettings >({}); const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false); - const [autoCaptions, setAutoCaptions] = useState([]); + const autoCaptions = timelineState.state.autoCaptions; + const setWallpaper = useCallback((next: string) => { + updateSettings({ wallpaper: next }); + }, [updateSettings]); + const setShadowIntensity = useCallback((next: number) => { + updateSettings({ shadowIntensity: next }); + }, [updateSettings]); + const setBackgroundBlur = useCallback((next: number) => { + updateSettings({ backgroundBlur: next }); + }, [updateSettings]); + const setZoomInDurationMs = useCallback((next: number) => { + updateSettings({ zoomInDurationMs: next }); + }, [updateSettings]); + const setZoomInOverlapMs = useCallback((next: number) => { + updateSettings({ zoomInOverlapMs: next }); + }, [updateSettings]); + const setZoomOutDurationMs = useCallback((next: number) => { + updateSettings({ zoomOutDurationMs: next }); + }, [updateSettings]); + const setConnectedZoomGapMs = useCallback((next: number) => { + updateSettings({ connectedZoomGapMs: next }); + }, [updateSettings]); + const setConnectedZoomDurationMs = useCallback((next: number) => { + updateSettings({ connectedZoomDurationMs: next }); + }, [updateSettings]); + const setZoomInEasing = useCallback((next: ZoomTransitionEasing) => { + updateSettings({ zoomInEasing: next }); + }, [updateSettings]); + const setZoomOutEasing = useCallback((next: ZoomTransitionEasing) => { + updateSettings({ zoomOutEasing: next }); + }, [updateSettings]); + const setConnectedZoomEasing = useCallback((next: ZoomTransitionEasing) => { + updateSettings({ connectedZoomEasing: next }); + }, [updateSettings]); + const setCursorStyle = useCallback((next: CursorStyle) => { + updateSettings({ cursorStyle: next }); + }, [updateSettings]); + const setCursorSize = useCallback((next: number) => { + updateSettings({ cursorSize: next }); + }, [updateSettings]); + const setCursorSmoothing = useCallback((next: number) => { + updateSettings({ cursorSmoothing: next }); + }, [updateSettings]); + const setCursorMotionBlur = useCallback((next: number) => { + updateSettings({ cursorMotionBlur: next }); + }, [updateSettings]); + const setBorderRadius = useCallback((next: number) => { + updateSettings({ borderRadius: next }); + }, [updateSettings]); + const setPadding = useCallback((next: Padding) => { + updateSettings({ padding: next }); + }, [updateSettings]); + const setZoomRegions = useCallback( + (next: ZoomRegion[] | ((prev: ZoomRegion[]) => ZoomRegion[])) => { + timelineState.updateState({ + zoomRegions: typeof next === "function" ? next(zoomRegions) : next, + }); + }, + [timelineState, zoomRegions], + ); + const setClipRegions = useCallback( + (next: ClipRegion[] | ((prev: ClipRegion[]) => ClipRegion[])) => { + timelineState.updateState({ + clipRegions: typeof next === "function" ? next(clipRegions) : next, + }); + }, + [timelineState, clipRegions], + ); + const setSpeedRegions = useCallback( + (next: SpeedRegion[] | ((prev: SpeedRegion[]) => SpeedRegion[])) => { + timelineState.updateState({ + speedRegions: typeof next === "function" ? next(speedRegions) : next, + }); + }, + [timelineState, speedRegions], + ); + const setAnnotationRegions = useCallback( + (next: AnnotationRegion[] | ((prev: AnnotationRegion[]) => AnnotationRegion[])) => { + timelineState.updateState({ + annotationRegions: typeof next === "function" ? next(annotationRegions) : next, + }); + }, + [timelineState, annotationRegions], + ); + const setAudioRegions = useCallback( + (next: AudioRegion[] | ((prev: AudioRegion[]) => AudioRegion[])) => { + timelineState.updateState({ + audioRegions: typeof next === "function" ? next(audioRegions) : next, + }); + }, + [timelineState, audioRegions], + ); + const setAutoCaptions = useCallback( + (next: CaptionCue[] | ((prev: CaptionCue[]) => CaptionCue[])) => { + timelineState.updateState({ + autoCaptions: typeof next === "function" ? next(autoCaptions) : next, + }); + }, + [timelineState, autoCaptions], + ); + const setSelectedZoomId = useCallback((next: string | null) => { + timelineState.updateState({ selectedZoomId: next }); + }, [timelineState]); + const setSelectedClipId = useCallback((next: string | null) => { + timelineState.updateState({ selectedClipId: next }); + }, [timelineState]); + const setSelectedAnnotationId = useCallback((next: string | null) => { + timelineState.updateState({ selectedAnnotationId: next }); + }, [timelineState]); + const setSelectedAudioId = useCallback((next: string | null) => { + timelineState.updateState({ selectedAudioId: next }); + }, [timelineState]); const [autoCaptionSettings, setAutoCaptionSettings] = useState( DEFAULT_AUTO_CAPTION_SETTINGS, ); @@ -691,10 +827,28 @@ export default function VideoEditor() { >(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle"); const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); - const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(null); - const [exportError, setExportError] = useState(null); - const [showExportDropdown, setShowExportDropdown] = useState(false); + const exportState = useExport({ + initialConfig: { + exportQuality: initialEditorPreferences.exportQuality, + exportEncodingMode: initialEditorPreferences.exportEncodingMode, + exportBackendPreference: initialEditorPreferences.exportBackendPreference, + exportPipelineModel: initialEditorPreferences.exportPipelineModel, + mp4FrameRate: initialEditorPreferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE, + exportFormat: initialEditorPreferences.exportFormat, + gifFrameRate: initialEditorPreferences.gifFrameRate, + gifLoop: initialEditorPreferences.gifLoop, + gifSizePreset: initialEditorPreferences.gifSizePreset, + }, + initialProgress: null as ExportProgress | null, + initialError: null, + initialIsExporting: false, + }); + const isExporting = exportState.isExporting; + const setIsExporting = exportState.setIsExporting; + const exportProgress = exportState.progress; + const setExportProgress = exportState.setProgress; + const exportError = exportState.error; + const setExportError = exportState.setError; const [previewVolume, setPreviewVolume] = useState(1); const applySessionPresentation = useCallback( ( @@ -713,42 +867,60 @@ export default function VideoEditor() { [], ); const effectiveShowCursor = sessionShowCursorOverride ?? showCursor; - const [aspectRatio, setAspectRatio] = useState( - initialEditorPreferences.aspectRatio, - ); - const [activeEffectSection, setActiveEffectSection] = useState("scene"); - const [exportQuality, setExportQuality] = useState( - initialEditorPreferences.exportQuality, - ); - const [exportEncodingMode, setExportEncodingMode] = useState( - initialEditorPreferences.exportEncodingMode, - ); - const [exportBackendPreference, setExportBackendPreference] = useState( - initialEditorPreferences.exportBackendPreference, - ); - const [exportPipelineModel, setExportPipelineModel] = useState( - initialEditorPreferences.exportPipelineModel, - ); - const [mp4FrameRate, setMp4FrameRate] = useState( - initialEditorPreferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE, - ); - const [exportFormat, setExportFormat] = useState( - initialEditorPreferences.exportFormat, - ); - const [gifFrameRate, setGifFrameRate] = useState( - initialEditorPreferences.gifFrameRate, - ); - const [gifLoop, setGifLoop] = useState(initialEditorPreferences.gifLoop); - const [gifSizePreset, setGifSizePreset] = useState( - initialEditorPreferences.gifSizePreset, + const { + aspectRatio, + activeEffectSection, + timelineCollapsed, + setAspectRatio, + setActiveEffectSection, + toggleTimelineCollapsed, + } = useEditorUiState(); + const { syncUiState, syncPresetBindings, syncSidebarBindings } = useVideoEditorStore( + (state) => ({ + syncUiState: state.syncUiState, + syncPresetBindings: state.syncPresetBindings, + syncSidebarBindings: state.syncSidebarBindings, + }), ); + const exportQuality = exportState.config.exportQuality; + const exportEncodingMode = exportState.config.exportEncodingMode; + const exportBackendPreference = exportState.config.exportBackendPreference; + const exportPipelineModel = exportState.config.exportPipelineModel; + const mp4FrameRate = exportState.config.mp4FrameRate; + const exportFormat = exportState.config.exportFormat; + const gifFrameRate = exportState.config.gifFrameRate; + const gifLoop = exportState.config.gifLoop; + const gifSizePreset = exportState.config.gifSizePreset; + const setExportQuality = useCallback((next: ExportQuality) => { + exportState.updateConfig({ exportQuality: next }); + }, [exportState]); + const setExportEncodingMode = useCallback((next: ExportEncodingMode) => { + exportState.updateConfig({ exportEncodingMode: next }); + }, [exportState]); + const setExportBackendPreference = useCallback((next: ExportBackendPreference) => { + exportState.updateConfig({ exportBackendPreference: next }); + }, [exportState]); + const setExportPipelineModel = useCallback((next: ExportPipelineModel) => { + exportState.updateConfig({ exportPipelineModel: next }); + }, [exportState]); + const setMp4FrameRate = useCallback((next: ExportMp4FrameRate) => { + exportState.updateConfig({ mp4FrameRate: next }); + }, [exportState]); + const setExportFormat = useCallback((next: ExportFormat) => { + exportState.updateConfig({ exportFormat: next }); + }, [exportState]); + const setGifFrameRate = useCallback((next: GifFrameRate) => { + exportState.updateConfig({ gifFrameRate: next }); + }, [exportState]); + const setGifLoop = useCallback((next: boolean) => { + exportState.updateConfig({ gifLoop: next }); + }, [exportState]); + const setGifSizePreset = useCallback((next: GifSizePreset) => { + exportState.updateConfig({ gifSizePreset: next }); + }, [exportState]); const [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); - const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets()); - const [activeEditorPresetId, setActiveEditorPresetId] = useState(null); - const [presetPopoverOpen, setPresetPopoverOpen] = useState(false); - const [presetNameDraft, setPresetNameDraft] = useState(""); const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); @@ -762,6 +934,9 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextClipIdRef = useRef(1); const nextAudioIdRef = useRef(1); + const clipInitializedRef = useRef(false); + const autoFullTrackClipIdRef = useRef(null); + const autoFullTrackClipEndMsRef = useRef(null); const { shortcuts, isMac } = useShortcuts(); const nextAnnotationIdRef = useRef(1); @@ -769,21 +944,12 @@ export default function VideoEditor() { const exporterRef = useRef(null); const autoSuggestedVideoPathRef = useRef(null); const pendingFreshRecordingAutoZoomPathRef = useRef(null); - const historyPastRef = useRef([]); - const historyFutureRef = useRef([]); - const historyCurrentRef = useRef(null); - const applyingHistoryRef = useRef(false); const pendingExportSaveRef = useRef(null); const pendingTelemetryRetryTimeoutRef = useRef(null); const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); - const smokeExportStartedRef = useRef(false); - const projectAutosaveTimeoutRef = useRef(null); - const projectSaveQueueRef = useRef>(Promise.resolve()); - const smokeExportReadyStateRef = useRef>({}); - const [historyVersion, setHistoryVersion] = useState(0); const timelineRef = useRef(null); function formatTime(seconds: number) { @@ -793,7 +959,13 @@ export default function VideoEditor() { return `${mins}:${secs.toString().padStart(2, "0")}`; } - const [timelineCollapsed, setTimelineCollapsed] = useState(false); + useEffect(() => { + syncUiState({ + activeEffectSection: "scene", + timelineCollapsed: false, + aspectRatio: initialEditorPreferences.aspectRatio, + }); + }, [initialEditorPreferences.aspectRatio, syncUiState]); useEffect(() => { void window.electronAPI?.getPlatform?.()?.then((platform) => { @@ -823,63 +995,21 @@ export default function VideoEditor() { encoderPath: null, }); - const syncHistoryButtons = useCallback(() => { - setHistoryVersion((version) => version + 1); + const clearPendingExportSave = useCallback(() => { + const pending = pendingExportSaveRef.current; + pendingExportSaveRef.current = null; + setHasPendingExportSave(false); + if (pending?.tempFilePath && typeof window !== "undefined") { + // Best-effort cleanup — main-process also reaps stale temp files on + // before-quit, so we ignore failures here. + void window.electronAPI.discardExportedTemp?.(pending.tempFilePath); + } }, []); - const captureEditorPresetSnapshot = useCallback( - (): EditorPresetSnapshot => ({ - wallpaper, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - zoomMotionBlurTuning: { ...zoomMotionBlurTuning }, - zoomTemporalMotionBlur, - zoomMotionBlurSampleCount, - zoomMotionBlurShutterFraction, - connectZooms, - zoomInDurationMs, - zoomInOverlapMs, - zoomOutDurationMs, - connectedZoomGapMs, - connectedZoomDurationMs, - zoomInEasing, - zoomOutEasing, - connectedZoomEasing, - showCursor, - loopCursor, - cursorStyle, - cursorSize, - cursorSmoothing, - cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier, - cameraSpringMassMultiplier, - cursorMotionBlur, - cursorClickBounce, - cursorClickBounceDuration, - cursorSway, - borderRadius, - padding: { ...padding }, - frame, - webcam: { ...webcam }, - aspectRatio, - exportEncodingMode, - exportBackendPreference, - exportPipelineModel, - exportQuality, - mp4FrameRate, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - autoCaptionSettings: { ...autoCaptionSettings }, - whisperExecutablePath, - whisperModelPath, - }), - [ + const captureProjectThumbnail = useVideoEditorThumbnail({ + videoPlaybackRef, + currentTime, + renderState: { wallpaper, shadowIntensity, backgroundBlur, @@ -897,8 +1027,19 @@ export default function VideoEditor() { zoomInEasing, zoomOutEasing, connectedZoomEasing, - showCursor, - loopCursor, + borderRadius, + padding, + cropRegion, + webcam, + resolvedWebcamVideoUrl, + zoomRegions, + annotationRegions, + autoCaptions, + autoCaptionSettings, + clipRegions, + speedRegions, + cursorTelemetry, + effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, @@ -908,474 +1049,14 @@ export default function VideoEditor() { cameraSpringStiffnessMultiplier, cameraSpringDampingMultiplier, cameraSpringMassMultiplier, + zoomSmoothness, + zoomClassicMode, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, cursorSway, - borderRadius, - padding, - frame, - webcam, - aspectRatio, - exportEncodingMode, - exportBackendPreference, - exportPipelineModel, - exportQuality, - mp4FrameRate, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - autoCaptionSettings, - whisperExecutablePath, - whisperModelPath, - ], - ); - - const currentPresetSnapshot = useMemo( - () => captureEditorPresetSnapshot(), - [captureEditorPresetSnapshot], - ); - const currentPresetSignature = useMemo( - () => serializeEditorPresetSnapshot(currentPresetSnapshot), - [currentPresetSnapshot], - ); - const currentEditorPreset = useMemo( - () => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null, - [activeEditorPresetId, editorPresets], - ); - - useEffect(() => { - const activePreset = currentEditorPreset; - if ( - activePreset && - serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature - ) { - return; - } - - const matchingPreset = - editorPresets.find( - (preset) => - serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature, - ) ?? null; - const nextActivePresetId = matchingPreset?.id ?? null; - if (nextActivePresetId !== activeEditorPresetId) { - setActiveEditorPresetId(nextActivePresetId); - } - }, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]); - - useEffect(() => { - if (!presetPopoverOpen) { - setPresetNameDraft(""); - } - }, [presetPopoverOpen]); - - const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => { - setWallpaper(snapshot.wallpaper); - setShadowIntensity(snapshot.shadowIntensity); - setBackgroundBlur(snapshot.backgroundBlur); - setZoomMotionBlur(snapshot.zoomMotionBlur); - setZoomMotionBlurTuning({ ...snapshot.zoomMotionBlurTuning }); - setZoomTemporalMotionBlur(snapshot.zoomTemporalMotionBlur); - setZoomMotionBlurSampleCount(snapshot.zoomMotionBlurSampleCount); - setZoomMotionBlurShutterFraction(snapshot.zoomMotionBlurShutterFraction); - setConnectZooms(snapshot.connectZooms); - setZoomInDurationMs(snapshot.zoomInDurationMs); - setZoomInOverlapMs(snapshot.zoomInOverlapMs); - setZoomOutDurationMs(snapshot.zoomOutDurationMs); - setConnectedZoomGapMs(snapshot.connectedZoomGapMs); - setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs); - setZoomInEasing(snapshot.zoomInEasing); - setZoomOutEasing(snapshot.zoomOutEasing); - setConnectedZoomEasing(snapshot.connectedZoomEasing); - setShowCursor(snapshot.showCursor); - setLoopCursor(snapshot.loopCursor); - setCursorStyle(snapshot.cursorStyle); - setCursorSize(snapshot.cursorSize); - setCursorSmoothing(snapshot.cursorSmoothing); - setCursorSpringStiffnessMultiplier(snapshot.cursorSpringStiffnessMultiplier); - setCursorSpringDampingMultiplier(snapshot.cursorSpringDampingMultiplier); - setCursorSpringMassMultiplier(snapshot.cursorSpringMassMultiplier); - setCameraSpringStiffnessMultiplier(snapshot.cameraSpringStiffnessMultiplier); - setCameraSpringDampingMultiplier(snapshot.cameraSpringDampingMultiplier); - setCameraSpringMassMultiplier(snapshot.cameraSpringMassMultiplier); - setCursorMotionBlur(snapshot.cursorMotionBlur); - setCursorClickBounce(snapshot.cursorClickBounce); - setCursorClickBounceDuration(snapshot.cursorClickBounceDuration); - setCursorSway(snapshot.cursorSway); - setBorderRadius(snapshot.borderRadius); - setPadding({ ...snapshot.padding }); - setFrame(snapshot.frame); - setWebcam({ ...snapshot.webcam }); - setAspectRatio(snapshot.aspectRatio); - setExportEncodingMode(snapshot.exportEncodingMode); - setExportBackendPreference(snapshot.exportBackendPreference); - setExportPipelineModel(snapshot.exportPipelineModel); - setExportQuality(snapshot.exportQuality); - setMp4FrameRate(snapshot.mp4FrameRate); - setExportFormat(snapshot.exportFormat); - setGifFrameRate(snapshot.gifFrameRate); - setGifLoop(snapshot.gifLoop); - setGifSizePreset(snapshot.gifSizePreset); - setAutoCaptionSettings({ ...snapshot.autoCaptionSettings }); - setWhisperExecutablePath(snapshot.whisperExecutablePath); - setWhisperModelPath(snapshot.whisperModelPath); - }, []); - - const handleApplyEditorPreset = useCallback( - (presetId: string) => { - const preset = editorPresets.find((item) => item.id === presetId); - if (!preset) { - return; - } - - setActiveEditorPresetId(preset.id); - applyEditorPresetSnapshot(preset.snapshot); - toast.success( - t("editor.presets.toasts.applied", 'Applied preset "{{name}}"', { - name: preset.name, - }), - ); }, - [applyEditorPresetSnapshot, editorPresets, t], - ); - - const handleSaveEditorPreset = useCallback( - (name: string) => { - const normalizedName = name.trim().replace(/\s+/g, " "); - if (normalizedName.length === 0) { - toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name.")); - return false; - } - - const hasDuplicateName = editorPresets.some( - (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), - ); - if (hasDuplicateName) { - toast.error( - t( - "editor.presets.errors.duplicateName", - "A preset with that name already exists.", - ), - ); - return false; - } - - const snapshot = captureEditorPresetSnapshot(); - const timestamp = new Date().toISOString(); - const nextPreset: EditorPreset = { - id: crypto.randomUUID(), - name: normalizedName, - createdAt: timestamp, - updatedAt: timestamp, - snapshot, - }; - const nextPresets: EditorPreset[] = [nextPreset, ...editorPresets]; - - if (!saveEditorPresets(nextPresets)) { - toast.error( - t( - "editor.presets.errors.saveFailed", - "Could not save that preset. Check your browser storage settings and try again.", - ), - ); - return false; - } - - setEditorPresets(nextPresets); - setActiveEditorPresetId(nextPreset.id); - toast.success( - t("editor.presets.toasts.saved", 'Saved preset "{{name}}"', { - name: normalizedName, - }), - ); - return true; - }, - [captureEditorPresetSnapshot, editorPresets, t], - ); - - const handleDeleteEditorPreset = useCallback( - (presetId: string) => { - const preset = editorPresets.find((item) => item.id === presetId); - if (!preset) { - return; - } - - const nextPresets = editorPresets.filter((item) => item.id !== presetId); - if (!saveEditorPresets(nextPresets)) { - toast.error( - t( - "editor.presets.errors.deleteFailed", - "Could not delete that preset. Check your browser storage settings and try again.", - ), - ); - return; - } - - setEditorPresets(nextPresets); - if (preset.id === activeEditorPresetId) { - setActiveEditorPresetId(null); - } - toast.success( - t("editor.presets.toasts.deleted", 'Deleted preset "{{name}}"', { - name: preset.name, - }), - ); - }, - [activeEditorPresetId, editorPresets, t], - ); - - const handleSavePresetSubmit = useCallback(() => { - const didSave = handleSaveEditorPreset(presetNameDraft); - if (didSave) { - setPresetNameDraft(""); - } - }, [handleSaveEditorPreset, presetNameDraft]); - - const clearPendingExportSave = useCallback(() => { - const pending = pendingExportSaveRef.current; - pendingExportSaveRef.current = null; - setHasPendingExportSave(false); - if (pending?.tempFilePath && typeof window !== "undefined") { - // Best-effort cleanup — main-process also reaps stale temp files on - // before-quit, so we ignore failures here. - void window.electronAPI.discardExportedTemp?.(pending.tempFilePath); - } - }, []); - - const refreshProjectLibrary = useCallback(async () => { - try { - const result = await window.electronAPI.listProjectFiles(); - if (!result.success) { - throw new Error(result.error || "Failed to load project library"); - } - - setProjectLibraryEntries(result.entries); - } catch (projectLibraryError) { - console.warn("Unable to refresh project library:", projectLibraryError); - } - }, []); - - const captureProjectThumbnail = useCallback(async () => { - const previewHandle = videoPlaybackRef.current; - const previewVideo = previewHandle?.video ?? null; - const previewCanvas = previewHandle?.app?.canvas ?? null; - - if (previewHandle && previewVideo && previewVideo.paused) { - try { - await previewHandle.refreshFrame(); - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - } catch (thumbnailRefreshError) { - console.warn( - "Unable to refresh preview frame before thumbnail capture:", - thumbnailRefreshError, - ); - } - } - - const canvas = document.createElement("canvas"); - const targetWidth = 320; - const targetHeight = 180; - canvas.width = targetWidth; - canvas.height = targetHeight; - - const context = canvas.getContext("2d"); - if (!context) { - return null; - } - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "high"; - const editorBgHsl = getComputedStyle(document.documentElement) - .getPropertyValue("--editor-bg") - .trim(); - context.fillStyle = editorBgHsl ? `hsl(${editorBgHsl})` : "#111113"; - context.fillRect(0, 0, targetWidth, targetHeight); - - const previewWidth = previewHandle?.containerRef.current?.clientWidth || 1920; - const previewHeight = previewHandle?.containerRef.current?.clientHeight || 1080; - const frameTimestampUs = Math.max(0, Math.round(currentTime * 1_000_000)); - - if (previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { - let videoFrame: VideoFrame | null = null; - let frameRenderer: FrameRenderer | null = null; - - try { - videoFrame = new VideoFrame(previewVideo, { timestamp: frameTimestampUs }); - frameRenderer = new FrameRenderer({ - width: targetWidth, - height: targetHeight, - wallpaper, - zoomRegions, - showShadow: shadowIntensity > 0, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - zoomMotionBlurTuning, - zoomTemporalMotionBlur, - zoomMotionBlurSampleCount, - zoomMotionBlurShutterFraction, - connectZooms, - zoomInDurationMs, - zoomInOverlapMs, - zoomOutDurationMs, - connectedZoomGapMs, - connectedZoomDurationMs, - zoomInEasing, - zoomOutEasing, - connectedZoomEasing, - borderRadius, - padding, - cropRegion, - webcam, - webcamUrl: - resolvedWebcamVideoUrl ?? - (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), - videoWidth: previewVideo.videoWidth, - videoHeight: previewVideo.videoHeight, - annotationRegions, - autoCaptions, - autoCaptionSettings, - speedRegions: (() => { - const clipDerived: SpeedRegion[] = clipRegions - .filter((clip) => clip.speed !== 1) - .map((clip) => ({ - id: `clip-speed-${clip.id}`, - startMs: clip.startMs, - endMs: getClipSourceEndMs(clip), - speed: clip.speed as SpeedRegion["speed"], - })); - if (clipDerived.length === 0) return speedRegions; - const result = [...speedRegions]; - for (const cs of clipDerived) { - const overlaps = speedRegions.some( - (sr) => sr.endMs > cs.startMs && sr.startMs < cs.endMs, - ); - if (!overlaps) { - result.push(cs); - } - } - return result; - })(), - previewWidth, - previewHeight, - cursorTelemetry, - showCursor: effectiveShowCursor, - cursorStyle, - cursorSize, - cursorSmoothing, - cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier, - cameraSpringMassMultiplier, - zoomSmoothness, - zoomClassicMode, - cursorMotionBlur, - cursorClickBounce, - cursorClickBounceDuration, - cursorSway, - }); - await frameRenderer.initialize(); - await frameRenderer.renderFrame(videoFrame, frameTimestampUs); - return frameRenderer.getCanvas().toDataURL("image/png"); - } catch (thumbnailRenderError) { - console.warn( - "Unable to render thumbnail from composed frame:", - thumbnailRenderError, - ); - } finally { - videoFrame?.close(); - frameRenderer?.destroy(); - } - } - - const drawableSource = - previewCanvas && previewCanvas.width > 0 && previewCanvas.height > 0 - ? previewCanvas - : previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0 - ? previewVideo - : null; - - if (!drawableSource) { - return null; - } - - const sourceWidth = - drawableSource instanceof HTMLVideoElement - ? drawableSource.videoWidth - : drawableSource.width; - const sourceHeight = - drawableSource instanceof HTMLVideoElement - ? drawableSource.videoHeight - : drawableSource.height; - - if (sourceWidth <= 0 || sourceHeight <= 0) { - return null; - } - - const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight); - const drawWidth = Math.round(sourceWidth * scale); - const drawHeight = Math.round(sourceHeight * scale); - const offsetX = Math.round((targetWidth - drawWidth) / 2); - const offsetY = Math.round((targetHeight - drawHeight) / 2); - - try { - context.drawImage(drawableSource, offsetX, offsetY, drawWidth, drawHeight); - return canvas.toDataURL("image/png"); - } catch (thumbnailError) { - console.warn("Unable to capture project thumbnail:", thumbnailError); - return null; - } - }, [ - annotationRegions, - autoCaptionSettings, - autoCaptions, - backgroundBlur, - borderRadius, - connectZooms, - connectedZoomDurationMs, - connectedZoomEasing, - connectedZoomGapMs, - cropRegion, - currentTime, - cursorClickBounce, - cursorClickBounceDuration, - cursorMotionBlur, - cursorSize, - cursorSmoothing, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - cursorSpringStiffnessMultiplier, - cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier, - cameraSpringMassMultiplier, - zoomSmoothness, - cursorStyle, - cursorSway, - cursorTelemetry, - clipRegions, - padding, - resolvedWebcamVideoUrl, - shadowIntensity, - effectiveShowCursor, - speedRegions, - wallpaper, - webcam, - zoomInDurationMs, - zoomInEasing, - zoomInOverlapMs, - zoomMotionBlur, - zoomMotionBlurTuning, - zoomTemporalMotionBlur, - zoomMotionBlurSampleCount, - zoomMotionBlurShutterFraction, - zoomOutDurationMs, - zoomOutEasing, - zoomRegions, - zoomClassicMode, - ]); + }); const markExportAsSaving = useCallback(() => { setExportProgress((previous) => ({ @@ -1401,18 +1082,6 @@ export default function VideoEditor() { setPreviewVersion((version) => version + 1); }, []); - const clearPendingProjectAutosave = useCallback(() => { - if (projectAutosaveTimeoutRef.current !== null) { - window.clearTimeout(projectAutosaveTimeoutRef.current); - projectAutosaveTimeoutRef.current = null; - } - }, []); - - const queueProjectSave = useCallback((task: () => Promise) => { - const run = projectSaveQueueRef.current.catch(() => undefined).then(task); - projectSaveQueueRef.current = run.catch(() => undefined); - return run; - }, []); const saveBlobExport = useCallback( async (blob: Blob, fileName: string, outputPath: string | null = null) => { @@ -1502,25 +1171,15 @@ export default function VideoEditor() { window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); pendingFreshRecordingAutoSuggestTimeoutRef.current = null; } - if (projectAutosaveTimeoutRef.current !== null) { - window.clearTimeout(projectAutosaveTimeoutRef.current); - projectAutosaveTimeoutRef.current = null; - } }; }, []); useEffect(() => { - void refreshProjectLibrary(); - }, [refreshProjectLibrary]); + void projectManager.refreshProjectLibrary(); + }, [projectManager.refreshProjectLibrary]); - const canUndo = historyPastRef.current.length > 0; - const canRedo = historyFutureRef.current.length > 0; - - void historyVersion; - - const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { - return cloneStructured(snapshot); - }, []); + const canUndo = timelineState.canUndo; + const canRedo = timelineState.canRedo; const gifOutputDimensions = useMemo( () => @@ -1793,27 +1452,6 @@ export default function VideoEditor() { return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); - useEffect(() => { - if (!isEditingProjectName) { - setProjectNameDraft(projectDisplayName); - } - }, [isEditingProjectName, projectDisplayName]); - - useEffect(() => { - if (!isEditingProjectName) { - return; - } - - const frameId = window.requestAnimationFrame(() => { - projectNameInputRef.current?.focus(); - projectNameInputRef.current?.select(); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [isEditingProjectName]); - const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -1941,270 +1579,110 @@ export default function VideoEditor() { ], ); - const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { - return { - zoomRegions, - clipRegions, - speedRegions, - annotationRegions, - audioRegions, - autoCaptions, - selectedZoomId, - selectedClipId, - selectedAnnotationId, - selectedAudioId, - }; - }, [ - zoomRegions, - clipRegions, - speedRegions, - annotationRegions, - audioRegions, - autoCaptions, - selectedZoomId, - selectedClipId, - selectedAnnotationId, - selectedAudioId, - ]); - - const applyHistorySnapshot = useCallback( - (snapshot: EditorHistorySnapshot) => { - applyingHistoryRef.current = true; - const cloned = cloneSnapshot(snapshot); - setZoomRegions(cloned.zoomRegions); - setClipRegions(cloned.clipRegions); - setSpeedRegions(cloned.speedRegions); - setAnnotationRegions(cloned.annotationRegions); - setAudioRegions(cloned.audioRegions); - setAutoCaptions(cloned.autoCaptions); - setSelectedZoomId(cloned.selectedZoomId); - setSelectedClipId(cloned.selectedClipId); - setSelectedAnnotationId(cloned.selectedAnnotationId); - setSelectedAudioId(cloned.selectedAudioId); - - nextZoomIdRef.current = deriveNextId( - "zoom", - cloned.zoomRegions.map((region) => region.id), - ); - nextClipIdRef.current = deriveNextId( - "clip", - cloned.clipRegions.map((region) => region.id), - ); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - cloned.annotationRegions.map((region) => region.id), - ); - nextAudioIdRef.current = deriveNextId( - "audio", - cloned.audioRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + - 1; + const handleUndo = timelineState.undo; + const handleRedo = timelineState.redo; + const { + applyLoadedProject, + syncActiveVideoSource, + projectController, + currentProjectSnapshot, + } = useVideoEditorProject({ + projectManager, + timelineState, + editorRefs: { + videoPlaybackRef, + pendingFreshRecordingAutoZoomPathRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + nextZoomIdRef, + nextClipIdRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, }, - [cloneSnapshot], - ); - - const handleUndo = useCallback(() => { - if (historyPastRef.current.length === 0) return; - - const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); - const previous = historyPastRef.current.pop(); - if (!previous) return; - - historyFutureRef.current.push(cloneSnapshot(current)); - historyCurrentRef.current = cloneSnapshot(previous); - applyHistorySnapshot(previous); - syncHistoryButtons(); - }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); - - const handleRedo = useCallback(() => { - if (historyFutureRef.current.length === 0) return; - - const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); - const next = historyFutureRef.current.pop(); - if (!next) return; - - historyPastRef.current.push(cloneSnapshot(current)); - historyCurrentRef.current = cloneSnapshot(next); - applyHistorySnapshot(next); - syncHistoryButtons(); - }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); - - const applyLoadedProject = useCallback( - async (candidate: unknown, path?: string | null) => { - if (!validateProjectData(candidate)) { - return false; - } - - const project = candidate; - const sourcePath = fromFileUrl(project.videoPath); - const normalizedEditor = normalizeProjectEditor( - stripPersistedDevMotionBlurSettings(project.editor ?? {}), - ); - - try { - videoPlaybackRef.current?.pause(); - } catch { - // no-op - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); - - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(await resolveVideoUrl(sourcePath)); - setCurrentProjectPath(path ?? null); - pendingFreshRecordingAutoZoomPathRef.current = null; - if (normalizedEditor.webcam.sourcePath) { - await window.electronAPI.setCurrentRecordingSession?.( - { - videoPath: sourcePath, - webcamPath: normalizedEditor.webcam.sourcePath, - timeOffsetMs: normalizedEditor.webcam.timeOffsetMs, - }, - { - preserveProjectPath: Boolean(path), - }, - ); - const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); - applySessionPresentation(sessionResult?.success ? sessionResult.session : null); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: Boolean(path), - }); - applySessionPresentation(null); - } - - setWallpaper(normalizedEditor.wallpaper); - setShadowIntensity(normalizedEditor.shadowIntensity); - setBackgroundBlur(normalizedEditor.backgroundBlur); - setZoomMotionBlur(normalizedEditor.zoomMotionBlur); - setZoomMotionBlurTuning({ ...normalizedEditor.zoomMotionBlurTuning }); - setZoomTemporalMotionBlur(normalizedEditor.zoomTemporalMotionBlur); - setZoomMotionBlurSampleCount(normalizedEditor.zoomMotionBlurSampleCount); - setZoomMotionBlurShutterFraction(normalizedEditor.zoomMotionBlurShutterFraction); - setConnectZooms(normalizedEditor.connectZooms); - setZoomInDurationMs(normalizedEditor.zoomInDurationMs); - setZoomInOverlapMs(normalizedEditor.zoomInOverlapMs); - setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs); - setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs); - setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs); - setZoomInEasing(normalizedEditor.zoomInEasing); - setZoomOutEasing(normalizedEditor.zoomOutEasing); - setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); - setShowCursor(normalizedEditor.showCursor); - setLoopCursor(normalizedEditor.loopCursor); - setCursorStyle(normalizedEditor.cursorStyle); - setCursorSize(normalizedEditor.cursorSize); - setCursorSmoothing(normalizedEditor.cursorSmoothing); - setCursorSpringStiffnessMultiplier(normalizedEditor.cursorSpringStiffnessMultiplier); - setCursorSpringDampingMultiplier(normalizedEditor.cursorSpringDampingMultiplier); - setCursorSpringMassMultiplier(normalizedEditor.cursorSpringMassMultiplier); - setCameraSpringStiffnessMultiplier(normalizedEditor.cameraSpringStiffnessMultiplier); - setCameraSpringDampingMultiplier(normalizedEditor.cameraSpringDampingMultiplier); - setCameraSpringMassMultiplier(normalizedEditor.cameraSpringMassMultiplier); - setZoomSmoothness(normalizedEditor.zoomSmoothness); - setZoomClassicMode(normalizedEditor.zoomClassicMode); - setCursorMotionBlur(normalizedEditor.cursorMotionBlur); - setCursorClickBounce(normalizedEditor.cursorClickBounce); - setCursorClickBounceDuration(normalizedEditor.cursorClickBounceDuration); - setCursorSway(normalizedEditor.cursorSway); - setBorderRadius(normalizedEditor.borderRadius); - setPadding(normalizedEditor.padding); - setFrame(normalizedEditor.frame); - setCropRegion(normalizedEditor.cropRegion); - setWebcam(normalizedEditor.webcam); - setZoomRegions(normalizedEditor.zoomRegions); - setTrimRegions(normalizedEditor.trimRegions); - setClipRegions(normalizedEditor.clipRegions); - clipInitializedRef.current = normalizedEditor.clipRegions.length > 0; - autoFullTrackClipIdRef.current = null; - autoFullTrackClipEndMsRef.current = null; - setSpeedRegions(normalizedEditor.speedRegions); - setAnnotationRegions(normalizedEditor.annotationRegions); - setAudioRegions(normalizedEditor.audioRegions); - setSourceAudioTrackSettingsByClip(normalizedEditor.sourceAudioTrackSettingsByClip ?? {}); - setDefaultSourceAudioTrackSettings( - normalizedEditor.defaultSourceAudioTrackSettings ?? {}, - ); - setAutoCaptions(normalizedEditor.autoCaptions); - setAutoCaptionSettings(normalizedEditor.autoCaptionSettings); - setAspectRatio(normalizedEditor.aspectRatio); - setExportEncodingMode(normalizedEditor.exportEncodingMode); - setExportBackendPreference(normalizedEditor.exportBackendPreference); - setExportPipelineModel(normalizedEditor.exportPipelineModel); - setExportQuality(normalizedEditor.exportQuality); - setMp4FrameRate(normalizedEditor.mp4FrameRate); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); - - setSelectedZoomId(null); - setSelectedClipId(null); - setSelectedAnnotationId(null); - setSelectedAudioId(null); - - nextZoomIdRef.current = deriveNextId( - "zoom", - normalizedEditor.zoomRegions.map((region) => region.id), - ); - nextClipIdRef.current = deriveNextId( - "clip", - normalizedEditor.clipRegions.map((region: ClipRegion) => region.id), - ); - nextAudioIdRef.current = deriveNextId( - "audio", - normalizedEditor.audioRegions.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; - - historyPastRef.current = []; - historyFutureRef.current = []; - historyCurrentRef.current = null; - applyingHistoryRef.current = false; - syncHistoryButtons(); - - setLastSavedSnapshot( - cloneStructured( - createProjectData( - sourcePath, - buildPersistedEditorState(normalizedEditor), - project.projectId ?? null, - ), - ), - ); - await refreshProjectLibrary(); - return true; - }, - [ + editorRuntime: { + currentProjectPath, + currentSourcePath, + lastSavedProjectId: lastSavedSnapshot?.projectId ?? null, + currentPersistedEditorState, + webcamTimeOffsetMs: webcam.timeOffsetMs, + resolveVideoUrl, applySessionPresentation, buildPersistedEditorState, - refreshProjectLibrary, - syncHistoryButtons, - ], - ); - - const currentProjectSnapshot = useMemo(() => { - if (!currentSourcePath) { - return null; - } - return createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, - ); - }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); + captureProjectThumbnail, + remountPreview, + }, + editorSetters: { + setIsPlaying, + setCurrentTime, + setDuration, + setError, + setVideoSourcePath, + setVideoPath, + setCurrentProjectPath, + setWallpaper, + setShadowIntensity, + setBackgroundBlur, + setZoomMotionBlur, + setZoomMotionBlurTuning, + setZoomTemporalMotionBlur, + setZoomMotionBlurSampleCount, + setZoomMotionBlurShutterFraction, + setConnectZooms, + setZoomInDurationMs, + setZoomInOverlapMs, + setZoomOutDurationMs, + setConnectedZoomGapMs, + setConnectedZoomDurationMs, + setZoomInEasing, + setZoomOutEasing, + setConnectedZoomEasing, + setShowCursor, + setLoopCursor, + setCursorStyle, + setCursorSize, + setCursorSmoothing, + setCursorSpringStiffnessMultiplier, + setCursorSpringDampingMultiplier, + setCursorSpringMassMultiplier, + setCameraSpringStiffnessMultiplier, + setCameraSpringDampingMultiplier, + setCameraSpringMassMultiplier, + setZoomSmoothness, + setZoomClassicMode, + setCursorMotionBlur, + setCursorClickBounce, + setCursorClickBounceDuration, + setCursorSway, + setBorderRadius, + setPadding, + setFrame, + setCropRegion, + setWebcam, + setTrimRegions, + setSourceAudioTrackSettingsByClip, + setDefaultSourceAudioTrackSettings, + setAutoCaptionSettings, + setAspectRatio, + setExportEncodingMode, + setExportBackendPreference, + setExportPipelineModel, + setExportQuality, + setMp4FrameRate, + setExportFormat, + setGifFrameRate, + setGifLoop, + setGifSizePreset, + setLastSavedSnapshot, + }, + projectDisplayName, + cloneStructured, + onMenuLoadProject: window.electronAPI.onMenuLoadProject, + onMenuSaveProject: window.electronAPI.onMenuSaveProject, + onMenuSaveProjectAs: window.electronAPI.onMenuSaveProjectAs, + onRequestSaveBeforeClose: window.electronAPI.onRequestSaveBeforeClose, + }); const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null, timeOffsetMs?: number) => { @@ -2231,86 +1709,112 @@ export default function VideoEditor() { [currentProjectPath, currentSourcePath, webcam.timeOffsetMs], ); - const syncActiveVideoSource = useCallback( - async (sourcePath: string, webcamPath?: string | null) => { - if (webcamPath) { - await window.electronAPI.setCurrentRecordingSession?.( - { - videoPath: sourcePath, - webcamPath, - timeOffsetMs: webcam.timeOffsetMs, - }, - { - preserveProjectPath: Boolean(currentProjectPath), - }, - ); - return; - } - - await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: Boolean(currentProjectPath), - }); - }, - [currentProjectPath, webcam.timeOffsetMs], - ); - - const handleUploadWebcam = useCallback(async () => { - const result = await window.electronAPI.openVideoFilePicker(); - if (!result.success || !result.path) { - return; - } - - setWebcam((prev) => ({ - ...prev, - enabled: true, - sourcePath: result.path ?? null, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - - await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS); - toast.success(t("settings.effects.webcamFootageAdded")); - }, [syncRecordingSessionWebcam, t]); - - const handleClearWebcam = useCallback(async () => { - setWebcam((prev) => ({ - ...prev, - enabled: false, - sourcePath: null, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - - await syncRecordingSessionWebcam(null); - toast.success(t("settings.effects.webcamFootageRemoved")); - }, [syncRecordingSessionWebcam, t]); + useEffect(() => { + syncSidebarBindings({ + defaultWebcamTimeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + setWebcam, + syncRecordingSessionWebcam, + }); + }, [setWebcam, syncRecordingSessionWebcam, syncSidebarBindings]); useEffect(() => { - const snapshot = buildHistorySnapshot(); + syncPresetBindings( + { + ...(currentPersistedEditorState as EditorPresetSnapshot), + autoCaptionSettings, + whisperExecutablePath, + whisperModelPath, + }, + { + wallpaper: setWallpaper, + shadowIntensity: setShadowIntensity, + backgroundBlur: setBackgroundBlur, + zoomMotionBlur: setZoomMotionBlur, + zoomMotionBlurTuning: setZoomMotionBlurTuning, + zoomTemporalMotionBlur: setZoomTemporalMotionBlur, + zoomMotionBlurSampleCount: setZoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: setZoomMotionBlurShutterFraction, + connectZooms: setConnectZooms, + zoomInDurationMs: setZoomInDurationMs, + zoomInOverlapMs: setZoomInOverlapMs, + zoomOutDurationMs: setZoomOutDurationMs, + connectedZoomGapMs: setConnectedZoomGapMs, + connectedZoomDurationMs: setConnectedZoomDurationMs, + zoomInEasing: setZoomInEasing, + zoomOutEasing: setZoomOutEasing, + connectedZoomEasing: setConnectedZoomEasing, + showCursor: setShowCursor, + loopCursor: setLoopCursor, + cursorStyle: setCursorStyle, + cursorSize: setCursorSize, + cursorSmoothing: setCursorSmoothing, + cursorSpringStiffnessMultiplier: setCursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: setCursorSpringDampingMultiplier, + cursorSpringMassMultiplier: setCursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: setCameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier: setCameraSpringDampingMultiplier, + cameraSpringMassMultiplier: setCameraSpringMassMultiplier, + cursorMotionBlur: setCursorMotionBlur, + cursorClickBounce: setCursorClickBounce, + cursorClickBounceDuration: setCursorClickBounceDuration, + cursorSway: setCursorSway, + borderRadius: setBorderRadius, + padding: setPadding, + frame: setFrame, + webcam: setWebcam, + aspectRatio: setAspectRatio, + exportEncodingMode: setExportEncodingMode, + exportBackendPreference: setExportBackendPreference, + exportPipelineModel: setExportPipelineModel, + exportQuality: setExportQuality, + mp4FrameRate: setMp4FrameRate, + exportFormat: setExportFormat, + gifFrameRate: setGifFrameRate, + gifLoop: setGifLoop, + gifSizePreset: setGifSizePreset, + autoCaptionSettings: setAutoCaptionSettings, + whisperExecutablePath: setWhisperExecutablePath, + whisperModelPath: setWhisperModelPath, + }, + ); + }, [ + autoCaptionSettings, + currentPersistedEditorState, + syncPresetBindings, + whisperExecutablePath, + whisperModelPath, + ]); - if (!historyCurrentRef.current) { - historyCurrentRef.current = cloneSnapshot(snapshot); - syncHistoryButtons(); - return; - } - - if (applyingHistoryRef.current) { - historyCurrentRef.current = cloneSnapshot(snapshot); - applyingHistoryRef.current = false; - syncHistoryButtons(); - return; - } - - if (areDeepEqual(historyCurrentRef.current, snapshot)) { - return; - } - - historyPastRef.current.push(cloneSnapshot(historyCurrentRef.current)); - if (historyPastRef.current.length > 100) { - historyPastRef.current.shift(); - } - historyCurrentRef.current = cloneSnapshot(snapshot); - historyFutureRef.current = []; - syncHistoryButtons(); - }, [buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); + const whisperCaptions = useWhisperCaptions({ + videoPath, + videoSourcePath, + webcamSourcePath: webcam.sourcePath ?? null, + autoCaptionSettings, + whisperExecutablePath, + whisperModelPath, + downloadedWhisperModelPath, + whisperModelDownloadStatus, + isGeneratingCaptions, + onSyncVideoSource: syncActiveVideoSource, + onResolveVideoUrl: resolveVideoUrl, + onSetVideoSourcePath: setVideoSourcePath, + onSetVideoPath: setVideoPath, + onSetAutoCaptions: setAutoCaptions, + onSetAutoCaptionSettings: setAutoCaptionSettings, + onSetWhisperExecutablePath: setWhisperExecutablePath, + onSetWhisperModelPath: setWhisperModelPath, + onSetDownloadedWhisperModelPath: setDownloadedWhisperModelPath, + onSetWhisperModelDownloadStatus: setWhisperModelDownloadStatus, + onSetWhisperModelDownloadProgress: setWhisperModelDownloadProgress, + onSetIsGeneratingCaptions: setIsGeneratingCaptions, + }); + const handlePickWhisperExecutable = whisperCaptions.handlePickWhisperExecutable; + const handleDownloadWhisperSmallModel = whisperCaptions.handleDownloadWhisperSmallModel; + const handlePickWhisperModel = whisperCaptions.handlePickWhisperModel; + const handleDeleteWhisperSmallModel = whisperCaptions.handleDeleteWhisperSmallModel; + const handleGenerateAutoCaptions = whisperCaptions.handleGenerateAutoCaptions; + const handleClearAutoCaptions = whisperCaptions.handleClearAutoCaptions; + const projectBootstrap = useProjectBootstrapController(); const hasUnsavedChanges = useMemo( () => @@ -2323,170 +1827,45 @@ export default function VideoEditor() { ); useEffect(() => { - async function loadInitialData() { - try { - if (smokeExportConfig.enabled && smokeExportConfig.projectPath) { - const projectResult = await window.electronAPI.openProjectFileAtPath( - smokeExportConfig.projectPath, - ); - if (!projectResult.success || !projectResult.project) { - setError( - `Smoke export failed to load project ${smokeExportConfig.projectPath}: ${ - projectResult.error || projectResult.message || "unknown error" - }`, - ); - return; - } - const restored = await applyLoadedProject( - projectResult.project, - projectResult.path ?? smokeExportConfig.projectPath, - ); - if (!restored) { - setError( - `Smoke export could not apply project ${smokeExportConfig.projectPath}`, - ); - return; - } - setError(null); - return; - } - - if (!smokeExportConfig.enabled && devOpenRecordingConfig.inputPath) { - const sourcePath = fromFileUrl(devOpenRecordingConfig.inputPath); - const sourceVideoUrl = await resolveVideoUrl(sourcePath); - const webcamSourcePath = devOpenRecordingConfig.webcamInputPath - ? fromFileUrl(devOpenRecordingConfig.webcamInputPath) - : null; - setVideoSourcePath(sourcePath); - setVideoPath(sourceVideoUrl); - setCurrentProjectPath(null); - setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms - ? sourceVideoUrl - : null; - setWebcam((prev) => ({ - ...prev, - enabled: Boolean(webcamSourcePath), - sourcePath: webcamSourcePath, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - setError(null); - return; - } - - if (smokeExportConfig.enabled) { - if (!smokeExportConfig.inputPath) { - setError("Smoke export input path is missing."); - return; - } - - const sourcePath = fromFileUrl(smokeExportConfig.inputPath); - const sourceVideoUrl = await resolveVideoUrl(sourcePath); - const smokeWebcamSourcePath = smokeExportConfig.webcamInputPath - ? fromFileUrl(smokeExportConfig.webcamInputPath) - : null; - setVideoSourcePath(sourcePath); - setVideoPath(sourceVideoUrl); - setCurrentProjectPath(null); - setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = null; - setWebcam((prev) => ({ - ...prev, - enabled: !!smokeWebcamSourcePath, - sourcePath: smokeWebcamSourcePath, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - shadow: - smokeExportConfig.webcamShadow === undefined - ? prev.shadow - : smokeExportConfig.webcamShadow, - size: - smokeExportConfig.webcamSize === undefined - ? prev.size - : smokeExportConfig.webcamSize, - })); - setError(null); - return; - } - - const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); - if (currentProjectResult.success && currentProjectResult.project) { - const restored = await applyLoadedProject( - currentProjectResult.project, - currentProjectResult.path ?? null, - ); - if (restored) { - // Re-apply user preferences so stale project data does not - // overwrite the last-used padding, aspect ratio, export - // settings, etc. that were saved to localStorage. - setPadding(initialEditorPreferences.padding); - setBorderRadius(initialEditorPreferences.borderRadius); - setAspectRatio(initialEditorPreferences.aspectRatio); - setExportFormat(initialEditorPreferences.exportFormat); - setMp4FrameRate( - initialEditorPreferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE, - ); - setExportQuality(initialEditorPreferences.exportQuality); - setExportEncodingMode(initialEditorPreferences.exportEncodingMode); - setExportBackendPreference( - initialEditorPreferences.exportBackendPreference, - ); - setExportPipelineModel(initialEditorPreferences.exportPipelineModel); - setGifFrameRate(initialEditorPreferences.gifFrameRate); - setGifLoop(initialEditorPreferences.gifLoop); - setGifSizePreset(initialEditorPreferences.gifSizePreset); - return; - } - } - - const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); - if (sessionResult?.success && sessionResult.session?.videoPath) { - const sourcePath = fromFileUrl(sessionResult.session.videoPath); - const sourceVideoUrl = await resolveVideoUrl(sourcePath); - setVideoSourcePath(sourcePath); - setVideoPath(sourceVideoUrl); - setCurrentProjectPath(null); - setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms - ? sourceVideoUrl - : null; - applySessionPresentation(sessionResult.session); - setWebcam((prev) => ({ - ...prev, - enabled: Boolean(sessionResult.session?.webcamPath), - sourcePath: sessionResult.session?.webcamPath ?? null, - timeOffsetMs: - sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - return; - } - - const result = await window.electronAPI.getCurrentVideoPath(); - if (result.success && result.path) { - const sourcePath = fromFileUrl(result.path); - const sourceVideoUrl = await resolveVideoUrl(sourcePath); - setVideoSourcePath(sourcePath); - setVideoPath(sourceVideoUrl); - setCurrentProjectPath(null); - setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = null; - applySessionPresentation(null); - setWebcam((prev) => ({ - ...prev, - enabled: false, - sourcePath: null, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - } else { - setError("No video to load. Please record or select a video."); - } - } catch (err) { - setError("Error loading video: " + String(err)); - } finally { - setLoading(false); - } - } - - loadInitialData(); + void projectBootstrap.initializeEditorProjectState({ + smokeExportEnabled: smokeExportConfig.enabled, + smokeExportProjectPath: smokeExportConfig.projectPath ?? null, + smokeExportInputPath: smokeExportConfig.inputPath ?? null, + smokeExportWebcamInputPath: smokeExportConfig.webcamInputPath ?? null, + smokeExportWebcamShadow: smokeExportConfig.webcamShadow, + smokeExportWebcamSize: smokeExportConfig.webcamSize, + devOpenRecordingInputPath: devOpenRecordingConfig.inputPath ?? null, + devOpenRecordingWebcamInputPath: devOpenRecordingConfig.webcamInputPath ?? null, + autoApplyFreshRecordingAutoZooms, + initialEditorPreferences, + fromFileUrl, + resolveVideoUrl, + applyLoadedProject, + applySessionPresentation, + setVideoSourcePath, + setVideoPath, + setCurrentProjectPath, + setLastSavedSnapshot: () => setLastSavedSnapshot(null), + setPendingFreshRecordingAutoZoomPath: (value) => { + pendingFreshRecordingAutoZoomPathRef.current = value; + }, + setWebcam: (update) => setWebcam((prev) => update(prev as never) as WebcamOverlaySettings), + setError, + setLoading, + setPadding, + setBorderRadius, + setAspectRatio, + setExportFormat, + setMp4FrameRate, + setExportQuality, + setExportEncodingMode, + setExportBackendPreference, + setExportPipelineModel, + setGifFrameRate, + setGifLoop, + setGifSizePreset, + defaultWebcamTimeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + }); }, [ applyLoadedProject, applySessionPresentation, @@ -2500,6 +1879,7 @@ export default function VideoEditor() { smokeExportConfig.webcamInputPath, smokeExportConfig.webcamShadow, smokeExportConfig.webcamSize, + projectBootstrap, ]); useEffect(() => { @@ -2654,501 +2034,52 @@ export default function VideoEditor() { whisperModelPath, ]); + const { + projectBrowserOpen, + setProjectBrowserOpen, + isEditingProjectName, + setIsEditingProjectName, + projectNameDraft, + setProjectNameDraft, + isSavingProjectName, + closeProjectNameEditor, + handleProjectNameSubmit, + handleOpenProjectFromLibrary, + handleOpenProjectBrowser, + saveProject, + } = projectController; + useEffect(() => { - const unsubscribe = window.electronAPI.onWhisperSmallModelDownloadProgress((state) => { - setWhisperModelDownloadStatus(state.status); - setWhisperModelDownloadProgress(state.progress); - if (state.status === "downloaded") { - setDownloadedWhisperModelPath(state.path ?? null); - setWhisperModelPath((currentPath) => currentPath ?? state.path ?? null); - } - if (state.status === "idle") { - setDownloadedWhisperModelPath(null); - } - if (state.status === "error" && state.error) { - toast.error(state.error); - } + if (!isEditingProjectName) { + return; + } + const frameId = window.requestAnimationFrame(() => { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); }); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [isEditingProjectName]); - void (async () => { - const result = await window.electronAPI.getWhisperSmallModelStatus(); - if (!result.success) { - return; - } - - if (result.exists && result.path) { - setDownloadedWhisperModelPath(result.path); - setWhisperModelPath((currentPath) => currentPath ?? result.path ?? null); - setWhisperModelDownloadStatus("downloaded"); - setWhisperModelDownloadProgress(100); - return; - } - - setDownloadedWhisperModelPath(null); - setWhisperModelDownloadStatus("idle"); - setWhisperModelDownloadProgress(0); - })(); - - return () => unsubscribe?.(); - }, []); - - const handlePickWhisperExecutable = useCallback(async () => { - const result = await window.electronAPI.openWhisperExecutablePicker(); - if (!result.success || !result.path) { - return; - } - - setWhisperExecutablePath(result.path); - toast.success("Whisper executable selected"); - }, []); - - const handleDownloadWhisperSmallModel = useCallback(async () => { - if (whisperModelDownloadStatus === "downloading") { - return; - } - - setWhisperModelDownloadStatus("downloading"); - setWhisperModelDownloadProgress(0); - const result = await window.electronAPI.downloadWhisperSmallModel(); - if (!result.success) { - setWhisperModelDownloadStatus("error"); - toast.error(result.error || "Failed to download Whisper small model"); - return; - } - - if (result.path) { - setDownloadedWhisperModelPath(result.path); - setWhisperModelPath(result.path); - } - }, [whisperModelDownloadStatus]); - - const handlePickWhisperModel = useCallback(async () => { - const result = await window.electronAPI.openWhisperModelPicker(); - if (!result.success || !result.path) { - return; - } - - setWhisperModelPath(result.path); - toast.success("Whisper model selected"); - }, []); - - const handleDeleteWhisperSmallModel = useCallback(async () => { - const result = await window.electronAPI.deleteWhisperSmallModel(); - if (!result.success) { - toast.error(result.error || "Failed to delete Whisper small model"); - // Reset download state so re-download is not blocked - setWhisperModelDownloadStatus("idle"); - setWhisperModelDownloadProgress(0); - return; - } - - setWhisperModelPath((currentPath) => - currentPath === downloadedWhisperModelPath ? null : currentPath, - ); - setDownloadedWhisperModelPath(null); - setWhisperModelDownloadStatus("idle"); - setWhisperModelDownloadProgress(0); - toast.success("Whisper small model deleted"); - }, [downloadedWhisperModelPath]); - - const handleGenerateAutoCaptions = useCallback(async () => { - if (isGeneratingCaptions) { - return; - } - - let sourcePath = resolveAutoCaptionSourcePath({ - videoSourcePath, - videoPath, - }); - - if (!sourcePath) { - const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); - const currentVideoResult = await window.electronAPI.getCurrentVideoPath(); - sourcePath = resolveAutoCaptionSourcePath({ - recordingSessionVideoPath: - sessionResult?.success && sessionResult.session?.videoPath - ? sessionResult.session.videoPath - : null, - currentVideoPath: currentVideoResult.success - ? (currentVideoResult.path ?? null) - : null, - }); - } - - if (!sourcePath) { - toast.error("No source video is loaded"); - return; - } - - if (sourcePath !== videoSourcePath) { - setVideoSourcePath(sourcePath); - setVideoPath(await resolveVideoUrl(sourcePath)); - } - - await syncActiveVideoSource(sourcePath, webcam.sourcePath ?? null); - - if (!whisperModelPath) { - toast.error("Select a Whisper model or download the small model first"); - return; - } - - setIsGeneratingCaptions(true); - try { - const result = await window.electronAPI.generateAutoCaptions({ - videoPath: sourcePath, - whisperExecutablePath: whisperExecutablePath ?? undefined, - whisperModelPath, - language: autoCaptionSettings.language, - }); - - if (!result.success || !result.cues) { - toast.error( - result.message || - getErrorMessage(result.error) || - "Failed to generate captions", - ); - return; - } - - setAutoCaptions(result.cues); - setAutoCaptionSettings((prev) => ({ ...prev, enabled: true })); - toast.success(result.message || `Generated ${result.cues.length} captions`); - } catch (error) { - toast.error(getErrorMessage(error)); - } finally { - setIsGeneratingCaptions(false); - } - }, [ - autoCaptionSettings.language, - isGeneratingCaptions, - webcam.sourcePath, - syncActiveVideoSource, - videoPath, - videoSourcePath, - whisperExecutablePath, - whisperModelPath, - ]); - - const handleClearAutoCaptions = useCallback(() => { - setAutoCaptions([]); - setAutoCaptionSettings((prev) => ({ ...prev, enabled: false })); - }, []); - - const saveProject = useCallback( - async (forceSaveAs: boolean, options?: SaveProjectOptions) => { - clearPendingProjectAutosave(); - return queueProjectSave(async () => { - if (!currentSourcePath) { - if (!options?.silent) { - toast.error("No video loaded"); - } - return false; - } - - const shouldCaptureThumbnail = options?.captureThumbnail ?? true; - const shouldRefreshLibrary = options?.refreshLibraryAfterSave ?? true; - const shouldRemountPreview = options?.remountPreviewAfterSave ?? true; - - try { - const projectData = - currentProjectSnapshot?.videoPath === currentSourcePath - ? currentProjectSnapshot - : createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, - ); - - const fileNameBase = - currentSourcePath - .split(/[\\/]/) - .pop() - ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - let targetProjectPath = forceSaveAs - ? undefined - : (currentProjectPath ?? undefined); - - if (!forceSaveAs && !targetProjectPath) { - const activeProjectResult = - await window.electronAPI.loadCurrentProjectFile(); - if (activeProjectResult.success && activeProjectResult.path) { - targetProjectPath = activeProjectResult.path; - setCurrentProjectPath(activeProjectResult.path); - } - } - - const thumbnailDataUrl = shouldCaptureThumbnail - ? await captureProjectThumbnail() - : undefined; - - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - targetProjectPath, - thumbnailDataUrl, - ); - - if (result.canceled) { - if (!options?.silent) { - toast.info("Project save canceled"); - } - return false; - } - - if (!result.success) { - if (!options?.silent) { - toast.error(result.message || "Failed to save project"); - } - return false; - } - - if (result.path) { - setCurrentProjectPath(result.path); - } - setLastSavedSnapshot( - cloneStructured( - createProjectData( - projectData.videoPath, - projectData.editor, - result.projectId ?? projectData.projectId ?? null, - ), - ), - ); - if (shouldRefreshLibrary) { - await refreshProjectLibrary(); - } - - if (!options?.silent) { - toast.success(`Project saved to ${result.path}`); - } - return true; - } finally { - if (shouldRemountPreview) { - remountPreview(); - } - } - }); - }, - [ - captureProjectThumbnail, - clearPendingProjectAutosave, - currentSourcePath, - currentProjectPath, - currentProjectSnapshot, - currentPersistedEditorState, - lastSavedSnapshot?.projectId, - queueProjectSave, - refreshProjectLibrary, - remountPreview, - ], - ); + useEffect(() => { + autosaveRunnerRef.current = () => saveProject(false, { silent: true }); + return () => { + autosaveRunnerRef.current = null; + }; + }, [saveProject]); 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 () => { - const saved = await saveProject(true); - if (saved) { - setProjectBrowserOpen(false); - } - }, [saveProject]); - - useEffect(() => { - if (!currentProjectPath || !hasUnsavedChanges) { - clearPendingProjectAutosave(); + if (currentProjectPath && hasUnsavedChanges) { + projectManager.markDirty(); return; } - - projectAutosaveTimeoutRef.current = window.setTimeout(() => { - projectAutosaveTimeoutRef.current = null; - void saveProject(false, { - silent: true, - remountPreviewAfterSave: false, - refreshLibraryAfterSave: false, - captureThumbnail: false, - }); - }, PROJECT_AUTOSAVE_DELAY_MS); - - return () => { - clearPendingProjectAutosave(); - }; - }, [clearPendingProjectAutosave, currentProjectPath, hasUnsavedChanges, saveProject]); - - /** - * Saves the current project directly into the projects library under a chosen name. - */ - const saveProjectWithName = useCallback( - async (projectName: string) => { - const trimmedProjectName = projectName.trim(); - if (!trimmedProjectName) { - toast.error("Project name is required"); - return false; - } - - if (!currentSourcePath) { - toast.error("No video loaded"); - return false; - } - - try { - const projectData = - currentProjectSnapshot?.videoPath === currentSourcePath - ? currentProjectSnapshot - : createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, - ); - const thumbnailDataUrl = await captureProjectThumbnail(); - const result = await window.electronAPI.saveProjectFileNamed( - projectData, - trimmedProjectName, - thumbnailDataUrl, - ); - - if (result.canceled) { - toast.info("Project save canceled"); - return false; - } - - if (!result.success) { - toast.error(result.message || "Failed to save project"); - return false; - } - - if (result.path) { - setCurrentProjectPath(result.path); - } - setLastSavedSnapshot( - cloneStructured( - createProjectData( - projectData.videoPath, - projectData.editor, - result.projectId ?? projectData.projectId ?? null, - ), - ), - ); - await refreshProjectLibrary(); - toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); - return true; - } finally { - remountPreview(); - } - }, - [ - captureProjectThumbnail, - currentPersistedEditorState, - currentProjectSnapshot, - currentSourcePath, - lastSavedSnapshot?.projectId, - refreshProjectLibrary, - remountPreview, - ], - ); - - /** - * Resets the inline project-name editor back to the current saved display name. - */ - const closeProjectNameEditor = useCallback(() => { - setProjectNameDraft(projectDisplayName); - setIsEditingProjectName(false); - }, [projectDisplayName]); - - /** - * Commits the inline project-name editor and persists the project under that name. - */ - const handleProjectNameSubmit = useCallback( - async (event?: React.FormEvent) => { - event?.preventDefault(); - const trimmedProjectName = projectNameDraft.trim(); - if (!trimmedProjectName) { - closeProjectNameEditor(); - return; - } - - setIsSavingProjectName(true); - let saved = false; - try { - saved = await saveProjectWithName(trimmedProjectName); - } catch (error) { - toast.error(getErrorMessage(error)); - } finally { - setIsSavingProjectName(false); - } - - if (saved) { - setIsEditingProjectName(false); - return; - } - - projectNameInputRef.current?.focus(); - projectNameInputRef.current?.select(); - }, - [closeProjectNameEditor, projectNameDraft, saveProjectWithName], - ); - - const handleOpenProjectFromLibrary = useCallback( - async (projectPath: string) => { - const result = await window.electronAPI.openProjectFileAtPath(projectPath); - - if (result.canceled) { - return; - } - - if (!result.success) { - toast.error(result.message || "Failed to load project"); - return; - } - - const restored = await applyLoadedProject(result.project, result.path ?? null); - if (!restored) { - toast.error("Invalid project file format"); - return; - } - - setProjectBrowserOpen(false); - await refreshProjectLibrary(); - toast.success(`Project loaded from ${result.path}`); - }, - [applyLoadedProject, refreshProjectLibrary], - ); - - const handleOpenProjectBrowser = useCallback(async () => { - if (projectBrowserOpen) { - setProjectBrowserOpen(false); - return; - } - - await refreshProjectLibrary(); - setProjectBrowserOpen(true); - }, [projectBrowserOpen, refreshProjectLibrary]); - - useEffect(() => { - const removeLoadListener = window.electronAPI.onMenuLoadProject(() => { - void handleOpenProjectBrowser(); - }); - const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); - const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); - - return () => { - removeLoadListener?.(); - removeSaveListener?.(); - removeSaveAsListener?.(); - }; - }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); + projectManager.clearDirty(); + }, [currentProjectPath, hasUnsavedChanges, projectManager]); useEffect(() => { let mounted = true; @@ -3260,9 +2191,6 @@ export default function VideoEditor() { }, [loopCursor, normalizedCursorTelemetry, displayedTimelineWindow]); // Initialize a full-track clip when duration is first known - const clipInitializedRef = useRef(false); - const autoFullTrackClipIdRef = useRef(null); - const autoFullTrackClipEndMsRef = useRef(null); useEffect(() => { const totalMs = Math.round(duration * 1000); if (totalMs <= 0) return; @@ -3422,77 +2350,67 @@ export default function VideoEditor() { const handleTimelineSeek = useCallback((time: number) => { handleSeek(time, { pause: true }); }, [handleSeek]); - - const handleSelectZoom = useCallback((id: string | null) => { - setSelectedZoomId(id); - if (id) { - setActiveEffectSection("zoom"); - setSelectedAnnotationId(null); - setSelectedAudioId(null); - } else { - setActiveEffectSection((s) => (s === "zoom" ? "scene" : s)); - } - }, []); - - const handleSelectAnnotation = useCallback((id: string | null) => { - setSelectedAnnotationId(id); - if (id) { - setSelectedZoomId(null); - setSelectedAudioId(null); - } - }, []); - - const handleZoomAdded = useCallback( - (span: Span) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const defaultDepth: ZoomDepth = 2; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - depth: defaultDepth, - focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, defaultDepth), - mode: "auto", - }; - if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { - autoSuggestedVideoPathRef.current = videoPath; - pendingFreshRecordingAutoZoomPathRef.current = null; - } - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - setSelectedAnnotationId(null); - extensionHost.emitEvent({ - type: "timeline:region-added", - data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs }, - }); - }, - [videoPath], - ); - - 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_AUTO_ZOOM_DEPTH, - focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH), - mode: "auto", - }; - if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { - autoSuggestedVideoPathRef.current = videoPath; - pendingFreshRecordingAutoZoomPathRef.current = null; - } - setZoomRegions((prev) => [...prev, newRegion]); - // Don't auto-select suggested zooms — they follow cursor and don't need user interaction - extensionHost.emitEvent({ - type: "timeline:region-added", - data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs }, - }); - }, - [videoPath], - ); + const timelineActions = useTimelineActions({ + videoPath, + pendingFreshRecordingAutoZoomPathRef, + autoSuggestedVideoPathRef, + nextZoomIdRef, + nextClipIdRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + zoomRegions, + clipRegions, + selectedZoomId, + selectedClipId, + selectedAnnotationId, + selectedAudioId, + onSetActiveEffectSection: setActiveEffectSection, + onSetZoomRegions: setZoomRegions, + onSetClipRegions: setClipRegions, + onSetSpeedRegions: setSpeedRegions, + onSetAnnotationRegions: setAnnotationRegions, + onSetAudioRegions: setAudioRegions, + onSetSelectedZoomId: setSelectedZoomId, + onSetSelectedClipId: setSelectedClipId, + onSetSelectedAnnotationId: setSelectedAnnotationId, + onSetSelectedAudioId: setSelectedAudioId, + }); + const { + handleSelectZoom, + handleSelectAnnotation, + handleZoomAdded, + handleZoomSuggested, + handleZoomSpanChange, + handleZoomFocusChange, + handleZoomDepthChange, + handleZoomModeChange, + handleZoomDelete, + handleSelectClip, + handleClipSplit, + handleClipSpanChange, + handleClipSpeedChange, + handleClipMutedChange, + handleClipShowSourceAudioChange, + handleClipDelete, + handleSelectAudio, + handleAudioAdded, + handleAudioSpanChange, + handleAudioVolumeChange, + handleAudioDelete, + handleAudioNormalizeChange, + handleAnnotationAdded, + handleAnnotationSpanChange, + handleAnnotationDelete, + handleAnnotationContentChange, + handleAnnotationTypeChange, + handleAnnotationStyleChange, + handleAnnotationFigureDataChange, + handleAnnotationBlurIntensityChange, + handleAnnotationBlurColorChange, + handleAnnotationPositionChange, + handleAnnotationSizeChange, + } = timelineActions; useEffect(() => { if ( @@ -3553,504 +2471,6 @@ export default function VideoEditor() { zoomRegions, ]); - const handleZoomSpanChange = useCallback((id: string, span: Span) => { - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); - - const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - focus: clampFocusToDepth(focus, region.depth), - } - : region, - ), - ); - }, []); - - const handleZoomDepthChange = useCallback( - (depth: ZoomDepth) => { - if (!selectedZoomId) return; - setZoomRegions((prev) => - prev.map((region) => - region.id === selectedZoomId - ? { - ...region, - depth, - focus: clampFocusToDepth(region.focus, depth), - } - : region, - ), - ); - }, - [selectedZoomId], - ); - - const handleZoomModeChange = useCallback( - (mode: ZoomMode) => { - if (!selectedZoomId) return; - setZoomRegions((prev) => - prev.map((region) => (region.id === selectedZoomId ? { ...region, mode } : region)), - ); - }, - [selectedZoomId], - ); - - const handleZoomDelete = useCallback( - (id: string) => { - setZoomRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedZoomId === id) { - setSelectedZoomId(null); - } - extensionHost.emitEvent({ type: "timeline:region-removed", data: { id } }); - }, - [selectedZoomId], - ); - - const handleSelectClip = useCallback((id: string | null) => { - setSelectedClipId(id); - if (id) { - setActiveEffectSection("clip"); - setSelectedZoomId(null); - setSelectedAnnotationId(null); - setSelectedAudioId(null); - } else { - setActiveEffectSection((s) => (s === "clip" ? "scene" : s)); - } - }, []); - - const handleClipSplit = useCallback( - (splitMs: number) => { - setClipRegions((prev) => { - const target = prev.find((c) => splitMs > c.startMs && splitMs < c.endMs); - if (!target) return prev; - const leftId = `clip-${nextClipIdRef.current++}`; - const rightId = `clip-${nextClipIdRef.current++}`; - const left: ClipRegion = { - id: leftId, - startMs: target.startMs, - endMs: Math.round(splitMs), - speed: target.speed, - muted: target.muted, - }; - const right: ClipRegion = { - id: rightId, - startMs: Math.round(splitMs), - endMs: target.endMs, - speed: target.speed, - muted: target.muted, - }; - if (selectedClipId === target.id) { - setSelectedClipId(leftId); - } - return prev.flatMap((c) => (c.id === target.id ? [left, right] : [c])); - }); - }, - [selectedClipId], - ); - - const handleClipSpanChange = useCallback( - (id: string, span: Span) => { - const oldClip = clipRegions.find((c) => c.id === id); - const newStart = Math.round(span.start); - const newEnd = Math.round(span.end); - const removedSegments = oldClip - ? [ - ...(newStart > oldClip.startMs - ? [{ startMs: oldClip.startMs, endMs: newStart }] - : []), - ...(newEnd < oldClip.endMs - ? [{ startMs: newEnd, endMs: oldClip.endMs }] - : []), - ] - : []; - - if (oldClip) { - const startDelta = newStart - oldClip.startMs; - const endDelta = newEnd - oldClip.endMs; - const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; - - if (isMove) { - const delta = startDelta; - setZoomRegions((prev) => - prev.map((zoom) => { - const overlaps = - zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; - if (overlaps) { - return { - ...zoom, - startMs: zoom.startMs + delta, - endMs: zoom.endMs + delta, - }; - } - return zoom; - }), - ); - } - } - - if (removedSegments.length > 0) { - const removeTrimmedRegions = ( - regions: T[], - ): T[] => - regions.filter( - (region) => - !removedSegments.some( - (segment) => - region.startMs < segment.endMs && - region.endMs > segment.startMs, - ), - ); - setZoomRegions((prev) => removeTrimmedRegions(prev)); - setAnnotationRegions((prev) => removeTrimmedRegions(prev)); - setSpeedRegions((prev) => removeTrimmedRegions(prev)); - setAudioRegions((prev) => removeTrimmedRegions(prev)); - } - - setClipRegions((prev) => - prev.map((clip) => - clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, - ), - ); - }, - [clipRegions], - ); - - const handleClipSpeedChange = useCallback( - (speed: number) => { - if (!selectedClipId) return; - if (!Number.isFinite(speed) || speed <= 0) { - return; - } - const clip = clipRegions.find((c) => c.id === selectedClipId); - if (!clip) return; - const oldSpeed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; - const sourceDurationMs = (clip.endMs - clip.startMs) * oldSpeed; - const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed); - const scaleFactor = oldSpeed / speed; - - setClipRegions((prev) => - prev.map((c) => (c.id === selectedClipId ? { ...c, speed, endMs: newEndMs } : c)), - ); - // Scale zoom regions that lie within this clip proportionally - setZoomRegions((prev) => - prev.map((zoom) => { - if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) return zoom; - return { - ...zoom, - startMs: Math.round( - clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor, - ), - endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor), - }; - }), - ); - }, - [selectedClipId, clipRegions], - ); - - const handleClipMutedChange = useCallback( - (muted: boolean) => { - if (!selectedClipId) return; - setClipRegions((prev) => - prev.map((clip) => (clip.id === selectedClipId ? { ...clip, muted } : clip)), - ); - }, - [selectedClipId], - ); - - const handleClipShowSourceAudioChange = useCallback( - (showSourceAudio: boolean) => { - if (!selectedClipId) return; - setClipRegions((prev) => - prev.map((clip) => - clip.id === selectedClipId ? { ...clip, showSourceAudio } : clip, - ), - ); - }, - [selectedClipId], - ); - - const handleClipDelete = useCallback( - (id: string) => { - const deletedClip = clipRegions.find((clip) => clip.id === id); - setClipRegions((prev) => prev.filter((clip) => clip.id !== id)); - if (deletedClip) { - const { startMs, endMs } = deletedClip; - setZoomRegions((prev) => - prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), - ); - setAnnotationRegions((prev) => - prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), - ); - setSpeedRegions((prev) => - prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), - ); - setAudioRegions((prev) => - prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs), - ); - } - if (selectedClipId === id) { - setSelectedClipId(null); - } - }, - [clipRegions, selectedClipId], - ); - - const handleSelectAudio = useCallback((id: string | null) => { - setSelectedAudioId(id); - if (id) { - setSelectedZoomId(null); - setSelectedAnnotationId(null); - setActiveEffectSection("audio"); - } - }, []); - - const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { - const id = `audio-${nextAudioIdRef.current++}`; - const newRegion: AudioRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - audioPath, - volume: 1, - normalize: false, - trackIndex, - }; - setAudioRegions((prev) => [...prev, newRegion]); - setSelectedAudioId(id); - setSelectedZoomId(null); - setSelectedAnnotationId(null); - setActiveEffectSection("audio"); - }, []); - - const handleAudioSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => { - const normalizedTrackIndex = - typeof trackIndex === "number" && Number.isFinite(trackIndex) - ? Math.max(0, Math.floor(trackIndex)) - : undefined; - - setAudioRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - ...(normalizedTrackIndex === undefined - ? {} - : { trackIndex: normalizedTrackIndex }), - } - : region, - ), - ); - }, []); - - const handleAudioVolumeChange = useCallback( - (volume: number) => { - if (!selectedAudioId) { - return; - } - - if (!Number.isFinite(volume)) { - return; - } - - const nextVolume = Math.max(0, Math.min(1, volume)); - setAudioRegions((prev) => - prev.map((region) => - region.id === selectedAudioId ? { ...region, volume: nextVolume } : region, - ), - ); - }, - [selectedAudioId], - ); - - const handleAudioDelete = useCallback( - (id: string) => { - setAudioRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedAudioId === id) { - setSelectedAudioId(null); - } - }, - [selectedAudioId], - ); - - const handleAudioNormalizeChange = useCallback( - (normalize: boolean) => { - if (!selectedAudioId) { - return; - } - setAudioRegions((prev) => - prev.map((region) => - region.id === selectedAudioId ? { ...region, normalize } : region, - ), - ); - }, - [selectedAudioId], - ); - - const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { - const id = `annotation-${nextAnnotationIdRef.current++}`; - const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order - 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, - trackIndex, - }; - setAnnotationRegions((prev) => [...prev, newRegion]); - setSelectedAnnotationId(id); - setSelectedZoomId(null); - }, []); - - const handleAnnotationSpanChange = useCallback( - (id: string, span: Span, trackIndex?: number) => { - const normalizedTrackIndex = - typeof trackIndex === "number" && Number.isFinite(trackIndex) - ? Math.max(0, Math.floor(trackIndex)) - : undefined; - - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - ...(normalizedTrackIndex === undefined - ? {} - : { trackIndex: normalizedTrackIndex }), - } - : region, - ), - ); - }, - [], - ); - - const handleAnnotationDelete = useCallback( - (id: string) => { - setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedAnnotationId === id) { - setSelectedAnnotationId(null); - } - }, - [selectedAnnotationId], - ); - - const handleAnnotationContentChange = useCallback((id: string, content: string) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { - if (region.id !== id) return region; - - // Store content in type-specific fields - if (region.type === "text") { - return { ...region, content, textContent: content }; - } else if (region.type === "image") { - return { ...region, content, imageContent: content }; - } else { - return { ...region, content }; - } - }); - return updated; - }); - }, []); - - const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion["type"]) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { - if (region.id !== id) return region; - - const updatedRegion = { ...region, type }; - - // Restore content from type-specific storage - 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.blurIntensity === undefined) { - updatedRegion.blurIntensity = 20; - } - } - - return updatedRegion; - }); - return updated; - }); - }, []); - - const handleAnnotationStyleChange = useCallback( - (id: string, style: Partial) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id ? { ...region, style: { ...region.style, ...style } } : region, - ), - ); - }, - [], - ); - - const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { - setAnnotationRegions((prev) => - prev.map((region) => (region.id === id ? { ...region, figureData } : region)), - ); - }, []); - - const handleAnnotationBlurIntensityChange = useCallback((id: string, blurIntensity: number) => { - setAnnotationRegions((prev) => - prev.map((region) => (region.id === id ? { ...region, blurIntensity } : region)), - ); - }, []); - - const handleAnnotationBlurColorChange = useCallback((id: string, blurColor: string) => { - setAnnotationRegions((prev) => - prev.map((region) => (region.id === id ? { ...region, blurColor } : region)), - ); - }, []); - - const handleAnnotationPositionChange = useCallback( - (id: string, position: { x: number; y: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => (region.id === id ? { ...region, position } : region)), - ); - }, - [], - ); - - const handleAnnotationSizeChange = useCallback( - (id: string, size: { width: number; height: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => (region.id === id ? { ...region, size } : region)), - ); - }, - [], - ); // Global Tab prevention useEffect(() => { @@ -4157,6 +2577,7 @@ export default function VideoEditor() { }); }, []); + const handleExport = useCallback( async (settings: ExportSettings) => { if (!videoPath) { @@ -4757,195 +3178,22 @@ export default function VideoEditor() { ], ); - useEffect(() => { - smokeExportReadyStateRef.current = { - cursorTelemetrySourcePath, - duration, - hasVideoPath: Boolean(videoPath), - isPreviewReady, - loading, - projectPath: smokeExportConfig.projectPath ?? null, - videoSourcePath, - }; - }, [ - cursorTelemetrySourcePath, - duration, - isPreviewReady, - loading, - smokeExportConfig.projectPath, - videoPath, - videoSourcePath, - ]); - - useEffect(() => { - if (!smokeExportConfig.enabled) { - return; - } - - const timeoutId = window.setTimeout(() => { - if (smokeExportStartedRef.current) { - return; - } - - smokeExportStartedRef.current = true; - void writeSmokeExportReport(smokeExportConfig.outputPath, { - success: false, - phase: "ready", - error: `Smoke export did not become ready within ${SMOKE_EXPORT_READY_TIMEOUT_MS}ms.`, - readyState: smokeExportReadyStateRef.current, - }).finally(() => window.close()); - }, SMOKE_EXPORT_READY_TIMEOUT_MS); - - return () => window.clearTimeout(timeoutId); - }, [smokeExportConfig.enabled, smokeExportConfig.outputPath]); - - useEffect(() => { - if (!smokeExportConfig.enabled || smokeExportStartedRef.current) { - return; - } - - if (error) { - smokeExportStartedRef.current = true; - console.error(`[smoke-export] ${error}`); - void writeSmokeExportReport(smokeExportConfig.outputPath, { - success: false, - phase: "load", - error, - readyState: smokeExportReadyStateRef.current, - }).finally(() => window.close()); - return; - } - - if (!videoPath || loading || !isPreviewReady || duration <= 0) { - return; - } - - // When smoke-export opens a .recordly project, the cursor telemetry - // sidecar is loaded asynchronously after the editor state applies. - // Without this gate the auto-export fires before telemetry arrives and - // produces a video with no cursor/zoom animations. - if ( - smokeExportConfig.projectPath && - videoSourcePath && - cursorTelemetrySourcePath !== videoSourcePath - ) { - return; - } - - smokeExportStartedRef.current = true; - void handleExport({ - format: "mp4", - quality: "good", - encodingMode: smokeExportConfig.encodingMode ?? "balanced", - }); - }, [ - cursorTelemetrySourcePath, + useSmokeExportController({ + enabled: smokeExportConfig.enabled, + projectPath: smokeExportConfig.projectPath ?? null, + outputPath: smokeExportConfig.outputPath ?? null, error, - handleExport, - isPreviewReady, - loading, - duration, - smokeExportConfig.enabled, - smokeExportConfig.encodingMode, - smokeExportConfig.outputPath, - smokeExportConfig.projectPath, videoPath, videoSourcePath, - ]); - - const handleOpenExportDropdown = useCallback(() => { - if (!videoPath) { - toast.error("No video loaded"); - return; - } - - if (hasPendingExportSave) { - setShowExportDropdown(true); - setExportError("Save dialog canceled. Click Save Again to save without re-rendering."); - return; - } - setShowExportDropdown(true); - setExportProgress(null); - setExportError(null); - }, [videoPath, hasPendingExportSave]); - - const handleStartExportFromDropdown = useCallback(() => { - const video = videoPlaybackRef.current?.video; - if (!videoPath) { - toast.error("No video loaded"); - return; - } - if (!video) { - toast.error("Video not ready"); - return; - } - - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; - const gifDimensions = calculateOutputDimensions( - sourceWidth, - sourceHeight, - gifSizePreset, - GIF_SIZE_PRESETS, - ); - - const settings: ExportSettings = { - format: exportFormat, - encodingMode: exportFormat === "mp4" ? exportEncodingMode : undefined, - mp4FrameRate: exportFormat === "mp4" ? mp4FrameRate : undefined, - backendPreference: exportFormat === "mp4" ? exportBackendPreference : undefined, - pipelineModel: exportFormat === "mp4" ? exportPipelineModel : undefined, - quality: exportFormat === "mp4" ? exportQuality : undefined, - gifConfig: - exportFormat === "gif" - ? { - frameRate: gifFrameRate, - loop: gifLoop, - sizePreset: gifSizePreset, - width: gifDimensions.width, - height: gifDimensions.height, - } - : undefined, - }; - - setExportError(null); - setExportedFilePath(undefined); - setShowExportDropdown(true); - handleExport(settings); - }, [ - videoPath, - exportFormat, - exportEncodingMode, - exportQuality, - mp4FrameRate, - gifFrameRate, - gifLoop, - gifSizePreset, - exportBackendPreference, - exportPipelineModel, - handleExport, - ]); - - const handleCancelExport = useCallback(() => { - if (exporterRef.current) { - exporterRef.current.cancel(); - toast.info("Export canceled"); - clearPendingExportSave(); - setShowExportDropdown(false); - setIsExporting(false); - setExportProgress(null); - setExportError(null); - setExportedFilePath(undefined); - } - }, [clearPendingExportSave]); - - const handleExportDropdownClose = useCallback(() => { - clearPendingExportSave(); - setShowExportDropdown(false); - setExportProgress(null); - setExportError(null); - setExportedFilePath(undefined); - }, [clearPendingExportSave]); + cursorTelemetrySourcePath, + loading, + isPreviewReady, + duration, + encodingMode: smokeExportConfig.encodingMode ?? "balanced", + onWriteSmokeReport: writeSmokeExportReport, + onRunExport: (settings) => void handleExport(settings), + onCloseWindow: () => window.close(), + }); const handleRetrySaveExport = useCallback(async () => { const pendingSave = pendingExportSaveRef.current; @@ -5023,19 +3271,6 @@ export default function VideoEditor() { return top > 0 || left > 0 || bottom > 0 || right > 0; }, [cropRegion]); - const revealExportedFile = useCallback(async () => { - if (!exportedFilePath) return; - - try { - const result = await window.electronAPI.revealInFolder(exportedFilePath); - if (!result.success) { - toast.error(result.error || result.message || "Failed to reveal item in folder."); - } - } catch (error) { - toast.error(`Failed to reveal item in folder: ${String(error)}`); - } - }, [exportedFilePath]); - const openLightningIssues = useCallback(async () => { await openExternalLink( RECORDLY_ISSUES_URL, @@ -5043,128 +3278,53 @@ export default function VideoEditor() { ); }, [t]); - const isExportSaving = exportProgress?.phase === "saving"; - const isExportPreparing = - isExporting && (!exportProgress || exportProgress.phase === "preparing"); - const isExportFinalizing = exportProgress?.phase === "finalizing"; - const isRenderingAudio = - isExportFinalizing && typeof exportProgress?.audioProgress === "number"; - const exportFinalizingProgress = isExportFinalizing - ? Math.min( - typeof exportProgress?.renderProgress === "number" - ? exportProgress.renderProgress - : (exportProgress?.percentage ?? 100), - 100, - ) - : null; - const exportFinalizingPercent = isExportFinalizing - ? Math.round(exportFinalizingProgress ?? 100) - : null; - const isExportMuxingAndSaving = - isExportFinalizing && - exportFormat === "mp4" && - exportPipelineModel === "modern" && - !isRenderingAudio; - const isExportFinalSaveIndeterminate = - isExportMuxingAndSaving && (exportFinalizingPercent ?? 0) >= 98; - const isLightningExportInProgress = - exportFormat === "mp4" && - exportPipelineModel === "modern" && - (isExporting || exportProgress !== null); - const shouldSuspendPreviewRendering = - isExporting && exportFormat === "mp4" && exportPipelineModel === "modern"; - const isLegacyExportInProgress = - exportFormat === "mp4" && - exportPipelineModel === "legacy" && - (isExporting || exportProgress !== null); - const exportRenderSpeedLabel = - !isExportPreparing && - !isExportFinalizing && - !isExportSaving && - typeof exportProgress?.renderFps === "number" && - Number.isFinite(exportProgress.renderFps) && - exportProgress.renderFps > 0 - ? t("editor.exportStatus.renderSpeed", "Render speed {{fps}} FPS", { - fps: exportProgress.renderFps.toFixed(1), - }) - : null; - const exportRuntimeLabel = useMemo(() => { - const renderBackend = exportProgress?.renderBackend; - const encodeBackend = exportProgress?.encodeBackend; - const encoderName = exportProgress?.encoderName; - - if (!renderBackend && !encodeBackend && !encoderName) { - return null; - } - - const rendererLabel = - renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null; - const encoderLabel = - encodeBackend === "ffmpeg" - ? "Breeze" - : encodeBackend === "webcodecs" - ? "WebCodecs" - : null; - const pathLabel = - rendererLabel && encoderLabel - ? `${rendererLabel} + ${encoderLabel}` - : (rendererLabel ?? encoderLabel); - - if (!pathLabel) { - return encoderName ?? null; - } - - return encoderName ? `${pathLabel} (${encoderName})` : pathLabel; - }, [exportProgress]); - const exportNativeSkipReasons = - exportProgress?.nativeStaticLayoutSkipReasons && - exportProgress.nativeStaticLayoutSkipReasons.length > 0 - ? exportProgress.nativeStaticLayoutSkipReasons - : exportProgress?.nativeStaticLayoutSkipReason - ? [exportProgress.nativeStaticLayoutSkipReason] - : []; - const exportNativeSkipLabel = - exportNativeSkipReasons.length > 0 - ? `Native skipped: ${exportNativeSkipReasons[0]}${ - exportNativeSkipReasons.length > 1 - ? ` (+${exportNativeSkipReasons.length - 1} more)` - : "" - }` - : null; - const exportPercentLabel = exportProgress - ? isExportPreparing - ? t("editor.exportStatus.preparing", "Preparing export...") - : isExportSaving - ? t("editor.exportStatus.saving", "Opening save dialog...") - : isRenderingAudio - ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { - percent: Math.round((exportProgress.audioProgress ?? 0) * 100), - }) - : isExportFinalizing - ? exportFormat === "mp4" && exportPipelineModel === "modern" - ? isExportFinalSaveIndeterminate - ? t( - "editor.exportStatus.muxingAndSaving", - "Muxing audio and saving file...", - ) - : t( - "editor.exportStatus.muxingAndSavingPercent", - "Muxing and saving {{percent}}%", - { - percent: exportFinalizingPercent ?? 100, - }, - ) - : t( - "editor.exportStatus.finalizingPercent", - "Finalizing {{percent}}%", - { - percent: exportFinalizingPercent ?? 100, - }, - ) - : t("editor.exportStatus.completePercent", "{{percent}}% complete", { - percent: Math.round(exportProgress.percentage), - }) - : t("editor.exportStatus.preparing", "Preparing export..."); + const exportUi = useExportUiController({ + videoPath, + videoElement: videoPlaybackRef.current?.video ?? null, + hasPendingExportSave, + exportFormat, + exportEncodingMode, + mp4FrameRate, + exportBackendPreference, + exportPipelineModel, + exportQuality, + gifFrameRate, + gifLoop, + gifSizePreset, + isExporting, + exportProgress, + exportedFilePath, + onRunExport: handleExport, + onClearPendingExportSave: clearPendingExportSave, + onCancelExporter: () => exporterRef.current?.cancel(), + onSetExportProgress: setExportProgress, + onSetExportError: setExportError, + onSetExportedFilePath: setExportedFilePath, + onSetIsExporting: setIsExporting, + onOpenLightningIssues: openLightningIssues, + t, + }); + const { + showExportDropdown, + setShowExportDropdown, + handleOpenExportDropdown, + handleStartExportFromDropdown, + handleCancelExport, + handleExportDropdownClose, + revealExportedFile, + isExportSaving, + isExportPreparing, + isRenderingAudio, + exportFinalizingProgress, + isExportFinalSaveIndeterminate, + isLightningExportInProgress, + shouldSuspendPreviewRendering, + isLegacyExportInProgress, + exportRenderSpeedLabel, + exportRuntimeLabel, + exportNativeSkipLabel, + exportPercentLabel, + } = exportUi; const projectBrowser = ( -
+
- - - - - -
-
{ - event.preventDefault(); - handleSavePresetSubmit(); - }} - className="space-y-2" - > -

- {t( - "editor.presets.saveCurrentAs", - "Save current preset as", - )} -

-
- - setPresetNameDraft(event.target.value) - } - className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm" - placeholder={t( - "editor.presets.namePlaceholder", - "Preset name", - )} - aria-label={t( - "editor.presets.namePlaceholder", - "Preset name", - )} - /> - -
-
- -
-

- {t("editor.presets.savedList", "Saved presets")} -

-
- {editorPresets.length === 0 ? ( -
- {t("editor.presets.empty", "No presets yet.")} -
- ) : ( - editorPresets.map((preset) => { - const isActive = - preset.id === currentEditorPreset?.id; - return ( -
- - -
- ); - }) - )} -
-
-
-
-
+