From 8fdb812464d1c5cfa658e608108131002c9825da Mon Sep 17 00:00:00 2001 From: Sunwood-ai-labs Date: Sat, 9 May 2026 19:36:08 +0900 Subject: [PATCH 1/4] Add persistent editor defaults and export autosave --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 106 +++++---- electron/preload.ts | 8 +- src/components/video-editor/SettingsPanel.tsx | 36 ++- src/components/video-editor/VideoEditor.tsx | 146 ++++++++++-- .../backgroundImageUpload.test.ts | 20 ++ .../video-editor/backgroundImageUpload.ts | 20 ++ src/i18n/locales/ar/settings.json | 6 +- src/i18n/locales/en/settings.json | 6 +- src/i18n/locales/es/settings.json | 6 +- src/i18n/locales/fr/settings.json | 6 +- src/i18n/locales/ja-JP/settings.json | 6 +- src/i18n/locales/ko-KR/settings.json | 6 +- src/i18n/locales/tr/settings.json | 6 +- src/i18n/locales/zh-CN/settings.json | 6 +- src/i18n/locales/zh-TW/settings.json | 6 +- src/lib/userPreferences.browser.test.ts | 38 ++++ src/lib/userPreferences.test.ts | 73 ++++++ src/lib/userPreferences.ts | 208 +++++++++++++++++- 19 files changed, 612 insertions(+), 98 deletions(-) create mode 100644 src/components/video-editor/backgroundImageUpload.test.ts create mode 100644 src/components/video-editor/backgroundImageUpload.ts create mode 100644 src/lib/userPreferences.browser.test.ts create mode 100644 src/lib/userPreferences.test.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d9ebab272..26915a29e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -82,6 +82,7 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, + options?: { autoSaveToDownloads?: boolean }, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7361b26fc..fff6a0dca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -985,58 +985,72 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { - try { - // Determine file type from extension - const isGif = fileName.toLowerCase().endsWith(".gif"); - const filters = isGif - ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] - : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; + ipcMain.handle( + "save-exported-video", + async ( + _, + videoData: ArrayBuffer, + fileName: string, + options?: { autoSaveToDownloads?: boolean }, + ) => { + try { + // Determine file type from extension + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const dialogOptions = buildDialogOptions( - { - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }, - getMainWindow(), - ); - const result = await dialog.showSaveDialog(dialogOptions); + let targetPath = path.join(app.getPath("downloads"), fileName); - if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: "Export canceled", - }; - } + if (!options?.autoSaveToDownloads) { + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: targetPath, + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Export canceled", + }; + } - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); + targetPath = result.filePath; + } - // Ensure the parent directory exists (Windows may fail if the folder is missing) - await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(targetPath); - await fs.writeFile(normalizedPath, Buffer.from(videoData)); + // Ensure the parent directory exists (Windows may fail if the folder is missing) + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + // --- END FIX --- - return { - success: true, - path: normalizedPath, - message: "Video exported successfully", - }; - } catch (error) { - console.error("Failed to save exported video:", error); - return { - success: false, - message: "Failed to save exported video", - error: String(error), - }; - } - }); + await fs.writeFile(normalizedPath, Buffer.from(videoData)); + + return { + success: true, + path: normalizedPath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to save exported video:", error); + return { + success: false, + message: "Failed to save exported video", + error: String(error), + }; + } + }, + ); ipcMain.handle("open-video-file-picker", async () => { try { const dialogOptions = buildDialogOptions( diff --git a/electron/preload.ts b/electron/preload.ts index 6c705d7b8..1560f3ea7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -71,8 +71,12 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + options?: { autoSaveToDownloads?: boolean }, + ) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, options); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 76ff762dd..13052ba0b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -45,6 +45,7 @@ import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; +import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { @@ -167,6 +168,8 @@ interface SettingsPanelProps { cursorHighlightSupportsClicks?: boolean; selected: string; onWallpaperChange: (path: string) => void; + customImages?: string[]; + onCustomImagesChange?: (images: string[]) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; selectedZoomFocusMode?: ZoomFocusMode | null; @@ -201,6 +204,8 @@ interface SettingsPanelProps { // Export format settings exportFormat?: ExportFormat; onExportFormatChange?: (format: ExportFormat) => void; + autoSaveExportToDownloads?: boolean; + onAutoSaveExportToDownloadsChange?: (enabled: boolean) => void; gifFrameRate?: GifFrameRate; onGifFrameRateChange?: (rate: GifFrameRate) => void; gifLoop?: boolean; @@ -259,6 +264,8 @@ export function SettingsPanel({ cursorHighlightSupportsClicks = false, selected, onWallpaperChange, + customImages = [], + onCustomImagesChange, selectedZoomDepth, onZoomDepthChange, selectedZoomFocusMode, @@ -292,6 +299,8 @@ export function SettingsPanel({ onExportQualityChange, exportFormat = "mp4", onExportFormatChange, + autoSaveExportToDownloads = false, + onAutoSaveExportToDownloadsChange, gifFrameRate = 15, onGifFrameRateChange, gifLoop = true, @@ -333,7 +342,6 @@ export function SettingsPanel({ // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted // on click — never the machine-specific file:// URL. const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []); - const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(null); const colorPalette = [ "#FF0000", @@ -480,9 +488,7 @@ export function SettingsPanel({ const file = files[0]; - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { + if (!isSupportedBackgroundImageType(file.type, file.name)) { toast.error(t("imageUpload.invalidFileType"), { description: t("imageUpload.jpgOnly"), }); @@ -495,7 +501,7 @@ export function SettingsPanel({ reader.onload = (e) => { const dataUrl = e.target?.result as string; if (dataUrl) { - setCustomImages((prev) => [...prev, dataUrl]); + onCustomImagesChange?.([...customImages, dataUrl]); onWallpaperChange(dataUrl); toast.success(t("imageUpload.uploadSuccess")); } @@ -514,7 +520,7 @@ export function SettingsPanel({ const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { event.stopPropagation(); - setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + onCustomImagesChange?.(customImages.filter((img) => img !== imageUrl)); // If the removed image was selected, clear selection if (selected === imageUrl) { onWallpaperChange(WALLPAPER_PATHS[0]); @@ -1291,7 +1297,7 @@ export function SettingsPanel({ type="file" ref={fileInputRef} onChange={handleImageUpload} - accept=".jpg,.jpeg,image/jpeg" + accept={BACKGROUND_IMAGE_ACCEPT} className="hidden" />