From c1f6cf67b2b8e43df761932c88aebbe7b564e345 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 9 May 2026 11:59:52 -0700 Subject: [PATCH] loc first and then export processing --- electron/electron-env.d.ts | 12 +- electron/ipc/handlers.ts | 149 ++++++++++---------- electron/preload.ts | 7 +- src/components/video-editor/VideoEditor.tsx | 56 ++++---- 4 files changed, 123 insertions(+), 101 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index ab2b8cf..744c2c7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -79,8 +79,7 @@ interface Window { }>; onStopRecordingFromTray: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; - saveExportedVideo: ( - videoData: ArrayBuffer, + pickExportSavePath: ( fileName: string, exportFolder?: string, ) => Promise<{ @@ -90,6 +89,15 @@ interface Window { canceled?: boolean; error?: string; }>; + writeExportToPath: ( + videoData: ArrayBuffer, + filePath: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index bf0bc97..82acdf9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -986,81 +986,88 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle( - "save-exported-video", - async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: 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("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => { + try { + const isGif = fileName.toLowerCase().endsWith(".gif"); + const filters = isGif + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - // Prefer the user's last export folder if it still exists, otherwise fall - // back to ~/Downloads. Validation must happen here because the renderer - // can't stat the filesystem. - let defaultDir = app.getPath("downloads"); - if (exportFolder) { - try { - const stats = await fs.stat(exportFolder); - if (stats.isDirectory()) { - defaultDir = exportFolder; - } - } catch (err) { - // Stat can fail because the folder was moved/deleted (expected) or - // because of a permission error (worth surfacing). Either way we - // fall back to Downloads, but log so debugging isn't blind. - console.warn( - `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, - err, - ); + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; } + } catch (err) { + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); } - const dialogOptions = buildDialogOptions( - { - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(defaultDir, fileName), - 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); - - // Ensure the parent directory exists (Windows may fail if the folder is missing) - await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- - - 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), - }; } - }, - ); + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); + + if (result.canceled || !result.filePath) { + return { success: false, canceled: true, message: "Export canceled" }; + } + + return { success: true, path: path.normalize(result.filePath) }; + } catch (error) { + console.error("Failed to show save dialog:", error); + return { + success: false, + message: "Failed to show save dialog", + error: String(error), + }; + } + }); + + ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { + try { + // Sanity-check the path. The renderer is trusted (contextIsolation is on), + // but a stale state bug shouldn't be able to clobber arbitrary files. + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + return { success: false, message: "Invalid path" }; + } + const lower = filePath.toLowerCase(); + if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) { + return { success: false, message: "Invalid file type" }; + } + + const normalizedPath = path.normalize(filePath); + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + await fs.writeFile(normalizedPath, Buffer.from(videoData)); + + return { + success: true, + path: normalizedPath, + message: "Video exported successfully", + }; + } catch (error) { + console.error("Failed to write 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 6d729b9..5334a00 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -71,8 +71,11 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder); + pickExportSavePath: (fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder); + }, + writeExportToPath: (videoData: ArrayBuffer, filePath: string) => { + return ipcRenderer.invoke("write-export-to-path", videoData, filePath); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index d6b363d..2e04a83 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1395,14 +1395,19 @@ export default function VideoEditor() { const handleSaveUnsavedExport = useCallback(async () => { if (!unsavedExport) return; try { - const saveResult = await window.electronAPI.saveExportedVideo( - unsavedExport.arrayBuffer, + const pickResult = await window.electronAPI.pickExportSavePath( unsavedExport.fileName, getExportFolder(), ); - if (saveResult.canceled) { + if (pickResult.canceled || !pickResult.success || !pickResult.path) { toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + return; + } + const saveResult = await window.electronAPI.writeExportToPath( + unsavedExport.arrayBuffer, + pickResult.path, + ); + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { @@ -1427,6 +1432,21 @@ export default function VideoEditor() { return; } + // Ask the user where to save BEFORE starting the export. This avoids the + // post-export save dialog getting hidden behind other windows after a + // long-running export. + const isGifFormat = settings.format === "gif"; + const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`; + const pickResult = await window.electronAPI.pickExportSavePath( + targetFileName, + getExportFolder(), + ); + if (pickResult.canceled || !pickResult.success || !pickResult.path) { + setShowExportDialog(false); + return; + } + const targetPath = pickResult.path; + setIsExporting(true); setExportProgress(null); setExportError(null); @@ -1493,8 +1513,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.gif`; if (result.warnings) { for (const warning of result.warnings) { @@ -1502,19 +1520,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - getExportFolder(), - ); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" }); setExportError(saveResult.message || "Failed to save GIF"); toast.error(saveResult.message || "Failed to save GIF"); } @@ -1642,8 +1654,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; if (result.warnings) { for (const warning of result.warnings) { @@ -1651,19 +1661,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - getExportFolder(), - ); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" }); setExportError(saveResult.message || "Failed to save video"); toast.error(saveResult.message || "Failed to save video"); }