From 43a9ec5701e54b830ea0950e2d76fb16d37bb9e1 Mon Sep 17 00:00:00 2001 From: KBCats Date: Sun, 15 Mar 2026 18:51:27 -0700 Subject: [PATCH] feat(editor): persist full editor preferences Keep the editor's background, effects, crop, cursor, and export controls between sessions so the workspace reopens with the user's last setup. --- src/components/video-editor/SettingsPanel.tsx | 72 +- src/components/video-editor/VideoEditor.tsx | 623 ++++++++---------- .../video-editor/editorPreferences.test.ts | 115 +++- .../video-editor/editorPreferences.ts | 168 ++++- 4 files changed, 594 insertions(+), 384 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 303fd9c4..d63ad2a9 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -14,7 +14,7 @@ import { Upload, X, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Accordion, @@ -34,12 +34,14 @@ import { type AspectRatio } from "@/utils/aspectRatioUtils"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CropControl } from "./CropControl"; +import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import { SliderControl } from "./SliderControl"; import type { AnnotationRegion, AnnotationType, CropRegion, + FigureData, PlaybackSpeed, ZoomDepth, } from "./types"; @@ -81,6 +83,24 @@ const GRADIENTS = [ "linear-gradient(to right, #0acffe 0%, #495aff 100%)", ]; +type BackgroundTab = "image" | "color" | "gradient"; + +function isHexWallpaper(value: string): boolean { + return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); +} + +function getBackgroundTabForWallpaper(value: string): BackgroundTab { + if (GRADIENTS.includes(value)) { + return "gradient"; + } + + if (isHexWallpaper(value)) { + return "color"; + } + + return "image"; +} + interface SettingsPanelProps { selected: string; onWallpaperChange: (path: string) => void; @@ -140,7 +160,7 @@ interface SettingsPanelProps { onAnnotationContentChange?: (id: string, content: string) => void; onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; onAnnotationStyleChange?: (id: string, style: Partial) => void; - onAnnotationFigureDataChange?: (id: string, figureData: any) => void; + onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; onAnnotationDelete?: (id: string) => void; selectedSpeedId?: string | null; selectedSpeedValue?: PlaybackSpeed | null; @@ -226,8 +246,11 @@ export function SettingsPanel({ }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { t } = useI18n(); + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); - const [customImages, setCustomImages] = useState([]); + const [customImages, setCustomImages] = useState( + initialEditorPreferences.customWallpapers, + ); const fileInputRef = useRef(null); useEffect(() => { @@ -240,7 +263,7 @@ export function SettingsPanel({ ), ); if (mounted) setWallpaperPreviewPaths(resolved); - } catch (err) { + } catch { if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); } })(); @@ -267,11 +290,38 @@ export function SettingsPanel({ "#795548", ]; - const [selectedColor, setSelectedColor] = useState("#ADADAD"); - const [gradient, setGradient] = useState(GRADIENTS[0]); + const [selectedColor, setSelectedColor] = useState( + isHexWallpaper(selected) ? selected : "#ADADAD", + ); + const [gradient, setGradient] = useState( + GRADIENTS.includes(selected) ? selected : GRADIENTS[0], + ); + const [backgroundTab, setBackgroundTab] = useState(() => + getBackgroundTabForWallpaper(selected), + ); const [showCropModal, setShowCropModal] = useState(false); const cropSnapshotRef = useRef(null); + useEffect(() => { + setBackgroundTab(getBackgroundTabForWallpaper(selected)); + + if (isHexWallpaper(selected)) { + setSelectedColor(selected); + } + + if (GRADIENTS.includes(selected)) { + setGradient(selected); + } + + if (selected.startsWith("data:image") && !customImages.includes(selected)) { + setCustomImages((prev) => [selected, ...prev]); + } + }, [customImages, selected]); + + useEffect(() => { + saveEditorPreferences({ customWallpapers: customImages }); + }, [customImages]); + const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); @@ -718,7 +768,11 @@ export function SettingsPanel({ - + setBackgroundTab(value as BackgroundTab)} + className="w-full" + > = { en: "EN", es: "ES", @@ -120,9 +103,7 @@ function LanguageSwitcher() { aria-label={t("common.app.language", "Language")} > - - {labels[locale] ?? locale.toUpperCase()} - + {labels[locale] ?? locale.toUpperCase()} ); } @@ -132,73 +113,61 @@ export default function VideoEditor() { const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); - const [currentProjectPath, setCurrentProjectPath] = useState( - null, - ); + const [currentProjectPath, setCurrentProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]); - const [shadowIntensity, setShadowIntensity] = useState(0.67); - const [backgroundBlur, setBackgroundBlur] = useState(0); - const [zoomMotionBlur, setZoomMotionBlur] = useState( - DEFAULT_ZOOM_MOTION_BLUR, - ); - const [connectZooms, setConnectZooms] = useState(true); - const [showCursor, setShowCursor] = useState(true); - const [loopCursor, setLoopCursor] = useState(false); - const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE); - const [cursorSmoothing, setCursorSmoothing] = useState( - DEFAULT_CURSOR_SMOOTHING, - ); + const [wallpaper, setWallpaper] = useState(initialEditorPreferences.wallpaper); + const [shadowIntensity, setShadowIntensity] = useState(initialEditorPreferences.shadowIntensity); + const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur); + const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur); + const [connectZooms, setConnectZooms] = useState(initialEditorPreferences.connectZooms); + const [showCursor, setShowCursor] = useState(initialEditorPreferences.showCursor); + const [loopCursor, setLoopCursor] = useState(initialEditorPreferences.loopCursor); + const [cursorSize, setCursorSize] = useState(initialEditorPreferences.cursorSize); + const [cursorSmoothing, setCursorSmoothing] = useState(initialEditorPreferences.cursorSmoothing); const [cursorMotionBlur, setCursorMotionBlur] = useState( - DEFAULT_CURSOR_MOTION_BLUR, + initialEditorPreferences.cursorMotionBlur, ); const [cursorClickBounce, setCursorClickBounce] = useState( - DEFAULT_CURSOR_CLICK_BOUNCE, + initialEditorPreferences.cursorClickBounce, ); - const [cursorSway, setCursorSway] = useState(DEFAULT_CURSOR_SWAY); - const [borderRadius, setBorderRadius] = useState(12.5); - const [padding, setPadding] = useState(50); - const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); + const [cursorSway, setCursorSway] = useState(initialEditorPreferences.cursorSway); + const [borderRadius, setBorderRadius] = useState(initialEditorPreferences.borderRadius); + const [padding, setPadding] = useState(initialEditorPreferences.padding); + const [cropRegion, setCropRegion] = useState(initialEditorPreferences.cropRegion); const [zoomRegions, setZoomRegions] = useState([]); - const [cursorTelemetry, setCursorTelemetry] = useState< - CursorTelemetryPoint[] - >([]); + const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(null); const [speedRegions, setSpeedRegions] = useState([]); const [selectedSpeedId, setSelectedSpeedId] = useState(null); - const [annotationRegions, setAnnotationRegions] = useState< - AnnotationRegion[] - >([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState< - string | null - >(null); + const [annotationRegions, setAnnotationRegions] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState( - null, - ); + const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); - const [aspectRatio, setAspectRatio] = useState( - initialEditorPreferences.aspectRatio, + const [aspectRatio, setAspectRatio] = useState(initialEditorPreferences.aspectRatio); + const [exportQuality, setExportQuality] = useState( + initialEditorPreferences.exportQuality, ); - const [exportQuality, setExportQuality] = useState("good"); - const [exportFormat, setExportFormat] = useState("mp4"); - const [gifFrameRate, setGifFrameRate] = useState(15); - const [gifLoop, setGifLoop] = useState(true); - const [gifSizePreset, setGifSizePreset] = useState("medium"); - const [exportedFilePath, setExportedFilePath] = useState( - undefined, + 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 [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); - const [lastSavedSnapshot, setLastSavedSnapshot] = useState( - null, - ); + const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); @@ -216,23 +185,18 @@ export default function VideoEditor() { const applyingHistoryRef = useRef(false); const pendingExportSaveRef = useRef(null); - const cloneSnapshot = useCallback( - (snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { - return { - zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)), - trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)), - speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)), - annotationRegions: JSON.parse( - JSON.stringify(snapshot.annotationRegions), - ), - selectedZoomId: snapshot.selectedZoomId, - selectedTrimId: snapshot.selectedTrimId, - selectedSpeedId: snapshot.selectedSpeedId, - selectedAnnotationId: snapshot.selectedAnnotationId, - }; - }, - [], - ); + const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { + return { + zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)), + trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)), + speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)), + annotationRegions: JSON.parse(JSON.stringify(snapshot.annotationRegions)), + selectedZoomId: snapshot.selectedZoomId, + selectedTrimId: snapshot.selectedTrimId, + selectedSpeedId: snapshot.selectedSpeedId, + selectedAnnotationId: snapshot.selectedAnnotationId, + }; + }, []); const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { return { @@ -286,10 +250,7 @@ export default function VideoEditor() { cloned.annotationRegions.map((region) => region.id), ); nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce( - (max, region) => Math.max(max, region.zIndex), - 0, - ) + 1; + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; }, [cloneSnapshot], ); @@ -297,8 +258,7 @@ export default function VideoEditor() { const handleUndo = useCallback(() => { if (historyPastRef.current.length === 0) return; - const current = - historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); const previous = historyPastRef.current.pop(); if (!previous) return; @@ -310,8 +270,7 @@ export default function VideoEditor() { const handleRedo = useCallback(() => { if (historyFutureRef.current.length === 0) return; - const current = - historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); const next = historyFutureRef.current.pop(); if (!next) return; @@ -320,94 +279,86 @@ export default function VideoEditor() { applyHistorySnapshot(next); }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot]); - const applyLoadedProject = useCallback( - async (candidate: unknown, path?: string | null) => { - if (!validateProjectData(candidate)) { - return false; - } + 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(project.editor); + const project = candidate; + const sourcePath = fromFileUrl(project.videoPath); + const normalizedEditor = normalizeProjectEditor(project.editor); - try { - videoPlaybackRef.current?.pause(); - } catch { - // no-op - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); - setCurrentProjectPath(path ?? null); + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(path ?? null); - setWallpaper(normalizedEditor.wallpaper); - setShadowIntensity(normalizedEditor.shadowIntensity); - setBackgroundBlur(normalizedEditor.backgroundBlur); - setZoomMotionBlur(normalizedEditor.zoomMotionBlur); - setConnectZooms(normalizedEditor.connectZooms); - setShowCursor(normalizedEditor.showCursor); - setLoopCursor(normalizedEditor.loopCursor); - setCursorSize(normalizedEditor.cursorSize); - setCursorSmoothing(normalizedEditor.cursorSmoothing); - setCursorMotionBlur(normalizedEditor.cursorMotionBlur); - setCursorClickBounce(normalizedEditor.cursorClickBounce); - setCursorSway(normalizedEditor.cursorSway); - setBorderRadius(normalizedEditor.borderRadius); - setPadding(normalizedEditor.padding); - setCropRegion(normalizedEditor.cropRegion); - setZoomRegions(normalizedEditor.zoomRegions); - setTrimRegions(normalizedEditor.trimRegions); - setSpeedRegions(normalizedEditor.speedRegions); - setAnnotationRegions(normalizedEditor.annotationRegions); - setAspectRatio(normalizedEditor.aspectRatio); - setExportQuality(normalizedEditor.exportQuality); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); + setWallpaper(normalizedEditor.wallpaper); + setShadowIntensity(normalizedEditor.shadowIntensity); + setBackgroundBlur(normalizedEditor.backgroundBlur); + setZoomMotionBlur(normalizedEditor.zoomMotionBlur); + setConnectZooms(normalizedEditor.connectZooms); + setShowCursor(normalizedEditor.showCursor); + setLoopCursor(normalizedEditor.loopCursor); + setCursorSize(normalizedEditor.cursorSize); + setCursorSmoothing(normalizedEditor.cursorSmoothing); + setCursorMotionBlur(normalizedEditor.cursorMotionBlur); + setCursorClickBounce(normalizedEditor.cursorClickBounce); + setCursorSway(normalizedEditor.cursorSway); + setBorderRadius(normalizedEditor.borderRadius); + setPadding(normalizedEditor.padding); + setCropRegion(normalizedEditor.cropRegion); + setZoomRegions(normalizedEditor.zoomRegions); + setTrimRegions(normalizedEditor.trimRegions); + setSpeedRegions(normalizedEditor.speedRegions); + setAnnotationRegions(normalizedEditor.annotationRegions); + setAspectRatio(normalizedEditor.aspectRatio); + setExportQuality(normalizedEditor.exportQuality); + setExportFormat(normalizedEditor.exportFormat); + setGifFrameRate(normalizedEditor.gifFrameRate); + setGifLoop(normalizedEditor.gifLoop); + setGifSizePreset(normalizedEditor.gifSizePreset); - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedSpeedId(null); - setSelectedAnnotationId(null); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); - nextZoomIdRef.current = deriveNextId( - "zoom", - normalizedEditor.zoomRegions.map((region) => region.id), - ); - nextTrimIdRef.current = deriveNextId( - "trim", - normalizedEditor.trimRegions.map((region) => region.id), - ); - nextSpeedIdRef.current = deriveNextId( - "speed", - normalizedEditor.speedRegions.map((region) => region.id), - ); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - normalizedEditor.annotationRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - normalizedEditor.annotationRegions.reduce( - (max, region) => Math.max(max, region.zIndex), - 0, - ) + 1; + nextZoomIdRef.current = deriveNextId( + "zoom", + normalizedEditor.zoomRegions.map((region) => region.id), + ); + nextTrimIdRef.current = deriveNextId( + "trim", + normalizedEditor.trimRegions.map((region) => region.id), + ); + nextSpeedIdRef.current = deriveNextId( + "speed", + normalizedEditor.speedRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + normalizedEditor.annotationRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + + 1; - setLastSavedSnapshot( - JSON.stringify(createProjectData(sourcePath, normalizedEditor)), - ); - return true; - }, - [], - ); + setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); + return true; + }, []); const currentProjectSnapshot = useMemo(() => { - const sourcePath = - videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!sourcePath) { return null; } @@ -500,16 +451,15 @@ export default function VideoEditor() { const hasUnsavedChanges = Boolean( currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - currentProjectSnapshot !== lastSavedSnapshot, + currentProjectSnapshot && + lastSavedSnapshot && + currentProjectSnapshot !== lastSavedSnapshot, ); useEffect(() => { async function loadInitialData() { try { - const currentProjectResult = - await window.electronAPI.loadCurrentProjectFile(); + const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); if (currentProjectResult.success && currentProjectResult.project) { const restored = await applyLoadedProject( currentProjectResult.project, @@ -541,8 +491,52 @@ export default function VideoEditor() { }, [applyLoadedProject]); useEffect(() => { - saveEditorPreferences({ aspectRatio }); - }, [aspectRatio]); + saveEditorPreferences({ + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + showCursor, + loopCursor, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + borderRadius, + padding, + cropRegion, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }); + }, [ + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + showCursor, + loopCursor, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + borderRadius, + padding, + cropRegion, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + ]); const saveProject = useCallback( async (forceSaveAs: boolean) => { @@ -692,10 +686,7 @@ export default function VideoEditor() { return; } - const restored = await applyLoadedProject( - result.project, - result.path ?? null, - ); + const restored = await applyLoadedProject(result.project, result.path ?? null); if (!restored) { toast.error("Invalid project file format"); return; @@ -705,12 +696,9 @@ export default function VideoEditor() { }, [applyLoadedProject]); useEffect(() => { - const removeLoadListener = - window.electronAPI.onMenuLoadProject(handleLoadProject); - const removeSaveListener = - window.electronAPI.onMenuSaveProject(handleSaveProject); - const removeSaveAsListener = - window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); + const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); + const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); return () => { removeLoadListener?.(); @@ -731,9 +719,7 @@ export default function VideoEditor() { } try { - const result = await window.electronAPI.getCursorTelemetry( - fromFileUrl(videoPath), - ); + const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath)); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); } @@ -797,11 +783,9 @@ export default function VideoEditor() { return zoomRegions; } - const dominantAtStart = findDominantRegion( - zoomRegions, - displayedTimelineWindow.startMs, - { connectZooms }, - ).region; + const dominantAtStart = findDominantRegion(zoomRegions, displayedTimelineWindow.startMs, { + connectZooms, + }).region; if (!dominantAtStart) { return zoomRegions; } @@ -821,10 +805,7 @@ export default function VideoEditor() { }, }; - return [ - ...zoomRegions.filter((region) => region.id !== loopEndRegion.id), - loopEndRegion, - ]; + return [...zoomRegions.filter((region) => region.id !== loopEndRegion.id), loopEndRegion]; }, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]); useEffect(() => { @@ -854,9 +835,7 @@ export default function VideoEditor() { const DEFAULT_DURATION_MS = 1100; const MIN_SPACING_MS = 1800; - const sortedCandidates = [...candidates].sort( - (a, b) => b.strength - a.strength, - ); + const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength); const acceptedCenters: number[] = []; setZoomRegions((prev) => { @@ -870,25 +849,17 @@ export default function VideoEditor() { sortedCandidates.forEach((candidate) => { const tooCloseToAccepted = acceptedCenters.some( - (center) => - Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS, + (center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS, ); if (tooCloseToAccepted) { return; } - const centeredStart = Math.round( - candidate.centerTimeMs - DEFAULT_DURATION_MS / 2, - ); - const startMs = Math.max( - 0, - Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS), - ); + const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2); + const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS)); const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS); - const hasOverlap = reservedSpans.some( - (span) => endMs > span.start && startMs < span.end, - ); + const hasOverlap = reservedSpans.some((span) => endMs > span.start && startMs < span.end); if (hasOverlap) { return; } @@ -920,9 +891,7 @@ export default function VideoEditor() { let mounted = true; (async () => { try { - const resolvedPath = await getAssetPath( - DEFAULT_WALLPAPER_RELATIVE_PATH, - ); + const resolvedPath = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); if (mounted) { setWallpaper(resolvedPath); } @@ -1149,9 +1118,7 @@ export default function VideoEditor() { (speed: PlaybackSpeed) => { if (!selectedSpeedId) return; setSpeedRegions((prev) => - prev.map((region) => - region.id === selectedSpeedId ? { ...region, speed } : region, - ), + prev.map((region) => (region.id === selectedSpeedId ? { ...region, speed } : region)), ); }, [selectedSpeedId], @@ -1201,85 +1168,70 @@ export default function VideoEditor() { [selectedAnnotationId], ); - const handleAnnotationContentChange = useCallback( - (id: string, content: string) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { - if (region.id !== id) return region; + 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; + // 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 handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion["type"]) => { + setAnnotationRegions((prev) => { + const updated = prev.map((region) => { + if (region.id !== id) return region; - const updatedRegion = { ...region, type }; + 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 }; - } + // 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 }; } + } - return updatedRegion; - }); - return updated; + 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, + 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 handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { + setAnnotationRegions((prev) => + prev.map((region) => (region.id === id ? { ...region, figureData } : region)), + ); + }, []); const handleAnnotationPositionChange = useCallback( (id: string, position: { x: number; y: number }) => { setAnnotationRegions((prev) => - prev.map((region) => - region.id === id ? { ...region, position } : region, - ), + prev.map((region) => (region.id === id ? { ...region, position } : region)), ); }, [], @@ -1353,24 +1305,17 @@ export default function VideoEditor() { }; window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => - window.removeEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [shortcuts, isMac, handleUndo, handleRedo]); useEffect(() => { - if ( - selectedZoomId && - !zoomRegions.some((region) => region.id === selectedZoomId) - ) { + if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { setSelectedZoomId(null); } }, [selectedZoomId, zoomRegions]); useEffect(() => { - if ( - selectedTrimId && - !trimRegions.some((region) => region.id === selectedTrimId) - ) { + if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { setSelectedTrimId(null); } }, [selectedTrimId, trimRegions]); @@ -1385,10 +1330,7 @@ export default function VideoEditor() { }, [selectedAnnotationId, annotationRegions]); useEffect(() => { - if ( - selectedSpeedId && - !speedRegions.some((region) => region.id === selectedSpeedId) - ) { + if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) { setSelectedSpeedId(null); } }, [selectedSpeedId, speedRegions]); @@ -1402,9 +1344,7 @@ export default function VideoEditor() { const result = await window.electronAPI.revealInFolder(filePath); if (!result.success) { const errorMessage = - result.error || - result.message || - "Failed to reveal item in folder."; + result.error || result.message || "Failed to reveal item in folder."; toast.error(errorMessage); } } catch (err) { @@ -1445,12 +1385,8 @@ export default function VideoEditor() { const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; - const sourceAspectRatio = - sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9; - const aspectRatioValue = getAspectRatioValue( - aspectRatio, - sourceAspectRatio, - ); + const sourceAspectRatio = sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9; + const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; @@ -1503,10 +1439,7 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; - const saveResult = await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - ); + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { pendingExportSaveRef.current = { arrayBuffer, fileName }; @@ -1514,9 +1447,7 @@ export default function VideoEditor() { setExportError( "Save dialog canceled. Click Save Again to save without re-rendering.", ); - toast.info( - "Save canceled. You can save again without re-exporting.", - ); + toast.info("Save canceled. You can save again without re-exporting."); keepExportDialogOpen = true; } else if (saveResult.success && saveResult.path) { showExportSuccessToast(saveResult.path); @@ -1546,8 +1477,7 @@ export default function VideoEditor() { exportHeight = Math.floor(sourceHeight / 2) * 2; } else if (aspectRatioValue === 1) { // Square (1:1): use smaller dimension to avoid codec limits - const baseDimension = - Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; + const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; exportWidth = baseDimension; exportHeight = baseDimension; } else if (aspectRatioValue > 1) { @@ -1556,10 +1486,7 @@ export default function VideoEditor() { let found = false; for (let w = baseWidth; w >= 100 && !found; w -= 2) { const h = Math.round(w / aspectRatioValue); - if ( - h % 2 === 0 && - Math.abs(w / h - aspectRatioValue) < 0.0001 - ) { + if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { exportWidth = w; exportHeight = h; found = true; @@ -1575,10 +1502,7 @@ export default function VideoEditor() { let found = false; for (let h = baseHeight; h >= 100 && !found; h -= 2) { const w = Math.round(h * aspectRatioValue); - if ( - w % 2 === 0 && - Math.abs(w / h - aspectRatioValue) < 0.0001 - ) { + if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { exportWidth = w; exportHeight = h; found = true; @@ -1586,8 +1510,7 @@ export default function VideoEditor() { } if (!found) { exportHeight = baseHeight; - exportWidth = - Math.floor((baseHeight * aspectRatioValue) / 2) * 2; + exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; } } @@ -1660,10 +1583,7 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; - const saveResult = await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - ); + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { pendingExportSaveRef.current = { arrayBuffer, fileName }; @@ -1671,9 +1591,7 @@ export default function VideoEditor() { setExportError( "Save dialog canceled. Click Save Again to save without re-rendering.", ); - toast.info( - "Save canceled. You can save again without re-exporting.", - ); + toast.info("Save canceled. You can save again without re-exporting."); keepExportDialogOpen = true; } else if (saveResult.success && saveResult.path) { showExportSuccessToast(saveResult.path); @@ -1696,8 +1614,7 @@ export default function VideoEditor() { } } catch (error) { console.error("Export error:", error); - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; + const errorMessage = error instanceof Error ? error.message : "Unknown error"; setExportError(errorMessage); toast.error(`Export failed: ${errorMessage}`); } finally { @@ -1746,9 +1663,7 @@ export default function VideoEditor() { if (hasPendingExportSave) { setShowExportDialog(true); - setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", - ); + setExportError("Save dialog canceled. Click Save Again to save without re-rendering."); return; } @@ -1828,9 +1743,7 @@ export default function VideoEditor() { ); if (saveResult.canceled) { - setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", - ); + setExportError("Save dialog canceled. Click Save Again to save without re-rendering."); toast.info("Save canceled. You can try again."); return; } @@ -1854,9 +1767,7 @@ export default function VideoEditor() { try { const result = await window.electronAPI.openRecordingsFolder(); if (!result.success) { - toast.error( - result.message || result.error || "Failed to open recordings folder.", - ); + toast.error(result.message || result.error || "Failed to open recordings folder."); } } catch (error) { toast.error(`Failed to open recordings folder: ${String(error)}`); @@ -1893,9 +1804,7 @@ export default function VideoEditor() { className="relative h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-center px-6 z-50" style={{ WebkitAppRegion: "drag" } as React.CSSProperties} > - - Recordly - + Recordly
void openRecordingsFolder()} className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-white/90 transition hover:bg-white/8 hover:text-white cursor-pointer" title={t("common.app.manageRecordings", "Open recordings folder")} - aria-label={t( - "common.app.manageRecordings", - "Open recordings folder", - )} + aria-label={t("common.app.manageRecordings", "Open recordings folder")} > @@ -1941,9 +1847,7 @@ export default function VideoEditor() { (() => { const previewVideo = videoPlaybackRef.current?.video; if (previewVideo && previewVideo.videoHeight > 0) { - return ( - previewVideo.videoWidth / previewVideo.videoHeight - ); + return previewVideo.videoWidth / previewVideo.videoHeight; } return 16 / 9; })(), @@ -1982,9 +1886,7 @@ export default function VideoEditor() { annotationRegions={annotationRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={ - handleAnnotationPositionChange - } + onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} cursorTelemetry={effectiveCursorTelemetry} showCursor={showCursor} @@ -2069,13 +1971,9 @@ export default function VideoEditor() { selected={wallpaper} onWallpaperChange={setWallpaper} selectedZoomDepth={ - selectedZoomId - ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth - : null - } - onZoomDepthChange={(depth) => - selectedZoomId && handleZoomDepthChange(depth) + selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} selectedTrimId={selectedTrimId} @@ -2139,8 +2037,7 @@ export default function VideoEditor() { selectedSpeedId={selectedSpeedId} selectedSpeedValue={ selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? - null) + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) : null } onSpeedChange={handleSpeedChange} diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 192c9249..4d185696 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -41,29 +41,62 @@ describe("editorPreferences", () => { it("normalizes invalid values back to safe defaults", () => { expect( normalizeEditorPreferences({ + wallpaper: 123, + showCursor: "yes", + cropRegion: { x: 2, width: -1 }, aspectRatio: "bad-value", customAspectWidth: "0", customAspectHeight: "", + customWallpapers: "not-an-array", }), ).toEqual(DEFAULT_EDITOR_PREFERENCES); }); - it("loads stored aspect ratio preferences", () => { + it("loads stored editor control preferences", () => { vi.stubGlobal( "localStorage", createStorageMock({ [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + wallpaper: "#123456", + backgroundBlur: 3.5, + showCursor: false, + cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, aspectRatio: "native", + exportFormat: "gif", + gifFrameRate: 30, + gifLoop: false, customAspectWidth: "21", customAspectHeight: "9", + customWallpapers: ["data:image/jpeg;base64,abc"], }), }), ); expect(loadEditorPreferences()).toEqual({ + wallpaper: "#123456", + shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity, + backgroundBlur: 3.5, + zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur, + connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms, + showCursor: false, + loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor, + cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing, + cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur, + cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce, + cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway, + borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius, + padding: DEFAULT_EDITOR_PREFERENCES.padding, + cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, aspectRatio: "native", + exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality, + exportFormat: "gif", + gifFrameRate: 30, + gifLoop: false, + gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset, customAspectWidth: "21", customAspectHeight: "9", + customWallpapers: ["data:image/jpeg;base64,abc"], }); }); @@ -81,8 +114,88 @@ describe("editorPreferences", () => { expect(loadEditorPreferences()).toEqual({ aspectRatio: "16:9", + wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper, + shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity, + backgroundBlur: DEFAULT_EDITOR_PREFERENCES.backgroundBlur, + zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur, + connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms, + showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor, + loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor, + cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing, + cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur, + cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce, + cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway, + borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius, + padding: DEFAULT_EDITOR_PREFERENCES.padding, + cropRegion: DEFAULT_EDITOR_PREFERENCES.cropRegion, + exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality, + exportFormat: DEFAULT_EDITOR_PREFERENCES.exportFormat, + gifFrameRate: DEFAULT_EDITOR_PREFERENCES.gifFrameRate, + gifLoop: DEFAULT_EDITOR_PREFERENCES.gifLoop, + gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset, customAspectWidth: "21", customAspectHeight: "9", + customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers, + }); + }); + + it("saves all editor controls with normalization", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + saveEditorPreferences({ + wallpaper: "linear-gradient(to right, #000000, #ffffff)", + shadowIntensity: 0.4, + backgroundBlur: 1.5, + zoomMotionBlur: 0.75, + connectZooms: false, + showCursor: false, + loopCursor: true, + cursorSize: 3, + cursorSmoothing: 1.25, + cursorMotionBlur: 0.5, + cursorClickBounce: 2.25, + cursorSway: 1.5, + borderRadius: 18, + padding: 30, + cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 }, + aspectRatio: "4:5", + exportQuality: "source", + exportFormat: "gif", + gifFrameRate: 20, + gifLoop: false, + gifSizePreset: "large", + customAspectWidth: "4", + customAspectHeight: "5", + customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"], + }); + + expect(loadEditorPreferences()).toEqual({ + wallpaper: "linear-gradient(to right, #000000, #ffffff)", + shadowIntensity: 0.4, + backgroundBlur: 1.5, + zoomMotionBlur: 0.75, + connectZooms: false, + showCursor: false, + loopCursor: true, + cursorSize: 3, + cursorSmoothing: 1.25, + cursorMotionBlur: 0.5, + cursorClickBounce: 2.25, + cursorSway: 1.5, + borderRadius: 18, + padding: 30, + cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 }, + aspectRatio: "4:5", + exportQuality: "source", + exportFormat: "gif", + gifFrameRate: 20, + gifLoop: false, + gifSizePreset: "large", + customAspectWidth: "4", + customAspectHeight: "5", + customWallpapers: ["data:image/jpeg;base64,abc"], }); }); }); diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 2a3bf126..3c153e3b 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -1,26 +1,69 @@ -import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; +import { normalizeProjectEditor, type ProjectEditorState } from "./projectPersistence"; -export interface EditorPreferences { - aspectRatio: AspectRatio; +type PersistedEditorControls = Pick< + ProjectEditorState, + | "wallpaper" + | "shadowIntensity" + | "backgroundBlur" + | "zoomMotionBlur" + | "connectZooms" + | "showCursor" + | "loopCursor" + | "cursorSize" + | "cursorSmoothing" + | "cursorMotionBlur" + | "cursorClickBounce" + | "cursorSway" + | "borderRadius" + | "padding" + | "cropRegion" + | "aspectRatio" + | "exportQuality" + | "exportFormat" + | "gifFrameRate" + | "gifLoop" + | "gifSizePreset" +>; + +type PartialEditorControls = Partial; + +export interface EditorPreferences extends PersistedEditorControls { customAspectWidth: string; customAspectHeight: string; + customWallpapers: string[]; } export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; +const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({}); + export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { - aspectRatio: "16:9", + wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity, + backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur, + zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur, + connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms, + showCursor: DEFAULT_EDITOR_CONTROLS.showCursor, + loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor, + cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize, + cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing, + cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur, + cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce, + cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway, + borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius, + padding: DEFAULT_EDITOR_CONTROLS.padding, + cropRegion: DEFAULT_EDITOR_CONTROLS.cropRegion, + aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio, + exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality, + exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat, + gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, + gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, + gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, customAspectWidth: "16", customAspectHeight: "9", + customWallpapers: [], }; -function isStoredAspectRatio(value: unknown): value is AspectRatio { - return ( - typeof value === "string" && - ((ASPECT_RATIOS as readonly string[]).includes(value) || isCustomAspectRatio(value)) - ); -} - function normalizePositiveIntegerString(value: unknown, fallback: string): string { if (typeof value !== "string" || value.trim().length === 0) { return fallback; @@ -34,6 +77,106 @@ function normalizePositiveIntegerString(value: unknown, fallback: string): strin return String(parsed); } +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function normalizeCropRegion( + value: unknown, + fallback: EditorPreferences["cropRegion"], +): EditorPreferences["cropRegion"] { + if (!value || typeof value !== "object") { + return fallback; + } + + const raw = value as Partial; + const x = isFiniteNumber(raw.x) && raw.x >= 0 && raw.x < 1 ? raw.x : fallback.x; + const y = isFiniteNumber(raw.y) && raw.y >= 0 && raw.y < 1 ? raw.y : fallback.y; + const maxWidth = 1 - x; + const maxHeight = 1 - y; + const fallbackWidth = clamp(fallback.width, 0.01, maxWidth); + const fallbackHeight = clamp(fallback.height, 0.01, maxHeight); + const width = + isFiniteNumber(raw.width) && raw.width >= 0.01 && raw.width <= maxWidth + ? raw.width + : fallbackWidth; + const height = + isFiniteNumber(raw.height) && raw.height >= 0.01 && raw.height <= maxHeight + ? raw.height + : fallbackHeight; + + return { x, y, width, height }; +} + +function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return fallback; + } + + return Array.from( + new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0)), + ); +} + +function normalizeEditorControls( + raw: Partial, + fallback: EditorPreferences, +): PersistedEditorControls { + const candidate: PartialEditorControls = { + wallpaper: raw.wallpaper ?? fallback.wallpaper, + shadowIntensity: raw.shadowIntensity ?? fallback.shadowIntensity, + backgroundBlur: raw.backgroundBlur ?? fallback.backgroundBlur, + zoomMotionBlur: raw.zoomMotionBlur ?? fallback.zoomMotionBlur, + connectZooms: raw.connectZooms ?? fallback.connectZooms, + showCursor: raw.showCursor ?? fallback.showCursor, + loopCursor: raw.loopCursor ?? fallback.loopCursor, + cursorSize: raw.cursorSize ?? fallback.cursorSize, + cursorSmoothing: raw.cursorSmoothing ?? fallback.cursorSmoothing, + cursorMotionBlur: raw.cursorMotionBlur ?? fallback.cursorMotionBlur, + cursorClickBounce: raw.cursorClickBounce ?? fallback.cursorClickBounce, + cursorSway: raw.cursorSway ?? fallback.cursorSway, + borderRadius: raw.borderRadius ?? fallback.borderRadius, + padding: raw.padding ?? fallback.padding, + cropRegion: normalizeCropRegion(raw.cropRegion, fallback.cropRegion), + aspectRatio: raw.aspectRatio ?? fallback.aspectRatio, + exportQuality: raw.exportQuality ?? fallback.exportQuality, + exportFormat: raw.exportFormat ?? fallback.exportFormat, + gifFrameRate: raw.gifFrameRate ?? fallback.gifFrameRate, + gifLoop: raw.gifLoop ?? fallback.gifLoop, + gifSizePreset: raw.gifSizePreset ?? fallback.gifSizePreset, + }; + + const normalized = normalizeProjectEditor(candidate); + + return { + wallpaper: normalized.wallpaper, + shadowIntensity: normalized.shadowIntensity, + backgroundBlur: normalized.backgroundBlur, + zoomMotionBlur: normalized.zoomMotionBlur, + connectZooms: normalized.connectZooms, + showCursor: normalized.showCursor, + loopCursor: normalized.loopCursor, + cursorSize: normalized.cursorSize, + cursorSmoothing: normalized.cursorSmoothing, + cursorMotionBlur: normalized.cursorMotionBlur, + cursorClickBounce: normalized.cursorClickBounce, + cursorSway: normalized.cursorSway, + borderRadius: normalized.borderRadius, + padding: normalized.padding, + cropRegion: normalized.cropRegion, + aspectRatio: normalized.aspectRatio, + exportQuality: normalized.exportQuality, + exportFormat: normalized.exportFormat, + gifFrameRate: normalized.gifFrameRate, + gifLoop: normalized.gifLoop, + gifSizePreset: normalized.gifSizePreset, + }; +} + export function normalizeEditorPreferences( candidate: unknown, fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES, @@ -42,7 +185,7 @@ export function normalizeEditorPreferences( candidate && typeof candidate === "object" ? (candidate as Partial) : {}; return { - aspectRatio: isStoredAspectRatio(raw.aspectRatio) ? raw.aspectRatio : fallback.aspectRatio, + ...normalizeEditorControls(raw, fallback), customAspectWidth: normalizePositiveIntegerString( raw.customAspectWidth, fallback.customAspectWidth, @@ -51,6 +194,7 @@ export function normalizeEditorPreferences( raw.customAspectHeight, fallback.customAspectHeight, ), + customWallpapers: normalizeCustomWallpapers(raw.customWallpapers, fallback.customWallpapers), }; }