diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..610fe594 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"; @@ -77,6 +80,7 @@ import { getAspectRatioLabel, getAspectRatioValue, } from "@/utils/aspectRatioUtils"; +import { cn } from "@/lib/utils"; import { ExtensionIcon } from "./ExtensionIcon"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -103,7 +107,15 @@ import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; -import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; +import { + loadEditorPreferences, + loadEditorPresets, + saveEditorPreferences, + saveEditorPresets, + serializeEditorPresetSnapshot, + type EditorPreset, + type EditorPresetSnapshot, +} from "./editorPreferences"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { createProjectData, @@ -670,6 +682,9 @@ 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 [presetPopoverOpen, setPresetPopoverOpen] = useState(false); + const [presetNameDraft, setPresetNameDraft] = useState(""); const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); @@ -749,6 +764,228 @@ export default function VideoEditor() { setHistoryVersion((version) => version + 1); }, []); + const captureEditorPresetSnapshot = useCallback( + (): EditorPresetSnapshot => ({ + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + zoomInDurationMs, + zoomInOverlapMs, + zoomOutDurationMs, + connectedZoomGapMs, + connectedZoomDurationMs, + zoomInEasing, + zoomOutEasing, + connectedZoomEasing, + showCursor, + loopCursor, + cursorStyle, + cursorSize, + cursorSmoothing, + 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, + connectZooms, + zoomInDurationMs, + zoomInOverlapMs, + zoomOutDurationMs, + connectedZoomGapMs, + connectedZoomDurationMs, + zoomInEasing, + zoomOutEasing, + connectedZoomEasing, + showCursor, + loopCursor, + cursorStyle, + cursorSize, + cursorSmoothing, + 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) => + serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature, + ) ?? null, + [editorPresets, currentPresetSignature], + ); + + 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); + 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; + } + + applyEditorPresetSnapshot(preset.snapshot); + toast.success(`Applied preset \"${preset.name}\"`); + }, + [applyEditorPresetSnapshot, editorPresets], + ); + + const handleSaveEditorPreset = useCallback( + (name: string) => { + const normalizedName = name.trim().replace(/\s+/g, " "); + if (normalizedName.length === 0) { + toast.error("Enter a preset name."); + return false; + } + + const hasDuplicateName = editorPresets.some( + (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), + ); + if (hasDuplicateName) { + toast.error("A preset with that name already exists."); + return false; + } + + const snapshot = captureEditorPresetSnapshot(); + const timestamp = new Date().toISOString(); + const nextPresets = [ + { + id: crypto.randomUUID(), + name: normalizedName, + createdAt: timestamp, + updatedAt: timestamp, + snapshot, + }, + ...editorPresets, + ]; + + setEditorPresets(nextPresets); + saveEditorPresets(nextPresets); + toast.success(`Saved preset \"${normalizedName}\"`); + return true; + }, + [captureEditorPresetSnapshot, editorPresets], + ); + + const handleDeleteEditorPreset = useCallback( + (presetId: string) => { + const preset = editorPresets.find((item) => item.id === presetId); + if (!preset) { + return; + } + + const nextPresets = editorPresets.filter((item) => item.id !== presetId); + setEditorPresets(nextPresets); + saveEditorPresets(nextPresets); + toast.success(`Deleted preset \"${preset.name}\"`); + }, + [editorPresets], + ); + + const handleSavePresetSubmit = useCallback(() => { + const didSave = handleSaveEditorPreset(presetNameDraft); + if (didSave) { + setPresetNameDraft(""); + } + }, [handleSaveEditorPreset, presetNameDraft]); + const clearPendingExportSave = useCallback(() => { const pending = pendingExportSaveRef.current; pendingExportSaveRef.current = null; @@ -4957,6 +5194,98 @@ export default function VideoEditor() { className="flex items-center gap-2 justify-self-end pr-3" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > + + + + + +
+
{ + event.preventDefault(); + handleSavePresetSubmit(); + }} + className="space-y-2" + > +

+ Save current preset as +

+
+ setPresetNameDraft(event.target.value)} + placeholder="Preset name" + className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm" + /> + +
+
+ +
+

Saved presets

+
+ {editorPresets.length === 0 ? ( +
+ No presets yet. +
+ ) : ( + editorPresets.map((preset) => { + const isActive = preset.id === currentEditorPreset?.id; + return ( +
+ + +
+ ); + }) + )} +
+
+
+
+
diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index c9be18ab..f60b1c03 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -48,6 +48,22 @@ type PersistedEditorControls = Pick< type PartialEditorControls = Partial; +type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"]; + +export interface EditorPresetSnapshot extends PersistedEditorControls { + autoCaptionSettings: PresetAutoCaptionSettings; + whisperExecutablePath: string | null; + whisperModelPath: string | null; +} + +export interface EditorPreset { + id: string; + name: string; + createdAt: string; + updatedAt: string; + snapshot: EditorPresetSnapshot; +} + export interface EditorPreferences extends PersistedEditorControls { customAspectWidth: string; customAspectHeight: string; @@ -58,6 +74,7 @@ export interface EditorPreferences extends PersistedEditorControls { } export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences"; +export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets"; const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({}); @@ -144,6 +161,77 @@ function normalizeNullablePath(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings { + return normalizeProjectEditor({ + autoCaptionSettings: + value && typeof value === "object" + ? (value as PresetAutoCaptionSettings) + : undefined, + }).autoCaptionSettings; +} + +function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot { + const normalizedPreferences = normalizeEditorPreferences(candidate); + const raw = + candidate && typeof candidate === "object" + ? (candidate as Partial) + : {}; + + return { + ...normalizeEditorControls(normalizedPreferences, normalizedPreferences), + autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), + whisperExecutablePath: + normalizeNullablePath(raw.whisperExecutablePath) ?? normalizedPreferences.whisperExecutablePath, + whisperModelPath: + normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath, + }; +} + +function normalizePresetName(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizePresetTimestamp(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback; +} + +function normalizeEditorPreset(candidate: unknown): EditorPreset | null { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const raw = candidate as Partial; + const name = normalizePresetName(raw.name); + if (!name) { + return null; + } + + const timestamp = new Date().toISOString(); + const id = typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID(); + + return { + id, + name, + createdAt: normalizePresetTimestamp(raw.createdAt, timestamp), + updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp), + snapshot: normalizeEditorPresetSnapshot(raw.snapshot), + }; +} + +export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string { + return JSON.stringify(normalizeEditorPresetSnapshot(snapshot)); +} + function normalizeEditorControls( raw: Partial, fallback: EditorPreferences, @@ -300,3 +388,40 @@ export function saveEditorPreferences(preferences: Partial): // Ignore storage failures so editor controls still work. } } + +export function loadEditorPresets(): EditorPreset[] { + if (typeof globalThis.localStorage === "undefined") { + return []; + } + + try { + const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY); + if (!stored) { + return []; + } + + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((item) => normalizeEditorPreset(item)) + .filter((preset): preset is EditorPreset => preset !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } catch { + return []; + } +} + +export function saveEditorPresets(presets: EditorPreset[]): void { + if (typeof globalThis.localStorage === "undefined") { + return; + } + + try { + globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(presets)); + } catch { + // Ignore storage failures so editor controls still work. + } +}