diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0ddbae82..e534d1fa 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,4 +1,5 @@ import { + BookmarkSimple, Check, CaretDown as ChevronDown, CaretUp as ChevronUp, @@ -38,6 +39,8 @@ import { 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"; @@ -106,7 +109,12 @@ import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; import { loadEditorPreferences, + loadEditorPresets, saveEditorPreferences, + saveEditorPresets, + serializeEditorPresetSnapshot, + type EditorPreset, + type EditorPresetSnapshot, } from "./editorPreferences"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { @@ -734,6 +742,10 @@ export default function VideoEditor() { 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); @@ -811,6 +823,297 @@ export default function VideoEditor() { setHistoryVersion((version) => version + 1); }, []); + const captureEditorPresetSnapshot = useCallback( + (): EditorPresetSnapshot => ({ + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + zoomTemporalMotionBlur: zoomMotionBlur, + zoomMotionBlurSampleCount: initialEditorPreferences.zoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: + initialEditorPreferences.zoomMotionBlurShutterFraction, + connectZooms, + zoomInDurationMs, + zoomInOverlapMs, + zoomOutDurationMs, + connectedZoomGapMs, + connectedZoomDurationMs, + zoomInEasing, + zoomOutEasing, + connectedZoomEasing, + showCursor, + loopCursor, + cursorStyle, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorMotionBlur, + cursorClickBounce, + cursorClickBounceDuration, + cursorSway, + borderRadius, + padding: { ...padding }, + frame, + webcam: { ...webcam }, + aspectRatio, + exportEncodingMode, + exportBackendPreference, + exportPipelineModel, + exportQuality, + mp4FrameRate, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + autoCaptionSettings: { ...autoCaptionSettings }, + whisperExecutablePath, + whisperModelPath, + }), + [ + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + initialEditorPreferences.zoomMotionBlurSampleCount, + initialEditorPreferences.zoomMotionBlurShutterFraction, + connectZooms, + zoomInDurationMs, + zoomInOverlapMs, + zoomOutDurationMs, + connectedZoomGapMs, + connectedZoomDurationMs, + zoomInEasing, + zoomOutEasing, + connectedZoomEasing, + showCursor, + loopCursor, + cursorStyle, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + 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); + 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); + 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; @@ -934,7 +1237,7 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, - effectiveShowCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, @@ -4032,7 +4335,7 @@ export default function VideoEditor() { autoCaptionSettings, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, - effectiveShowCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, @@ -4207,7 +4510,7 @@ export default function VideoEditor() { autoCaptionSettings, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, - effectiveShowCursor, + showCursor: effectiveShowCursor, cursorStyle, cursorSize, cursorSmoothing, @@ -4954,9 +5257,128 @@ export default function VideoEditor() { )}
+ + + + + +
+
{ + 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 ( +
+ + +
+ ); + }) + )} +
+
+
+
+
+