Skip to content
This repository was archived by the owner on Jun 7, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface Window {
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
exportFolder?: string,
options?: { autoSaveToDownloads?: boolean; exportFolder?: string },
) => Promise<{
success: boolean;
path?: string;
Expand Down
85 changes: 47 additions & 38 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,57 +988,66 @@ export function registerIpcHandlers(

ipcMain.handle(
"save-exported-video",
async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
async (
_,
videoData: ArrayBuffer,
fileName: string,
options?: { autoSaveToDownloads?: boolean; 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"] }];

// 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;
let targetPath = path.join(app.getPath("downloads"), fileName);

if (!options?.autoSaveToDownloads) {
const exportFolder = options?.exportFolder;
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,
);
}
} 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,
);
}
}
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);
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",
};
}

if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
targetPath = result.filePath;
}

// --- FIX: Normalize the path for Windows compatibility ---
const normalizedPath = path.normalize(result.filePath);
const normalizedPath = path.normalize(targetPath);

// Ensure the parent directory exists (Windows may fail if the folder is missing)
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
Expand Down
8 changes: 6 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ 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);
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
options?: { autoSaveToDownloads?: boolean; exportFolder?: string },
) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName, options);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
Expand Down
36 changes: 29 additions & 7 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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 {
Expand Down Expand Up @@ -168,6 +169,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;
Expand Down Expand Up @@ -202,6 +205,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;
Expand Down Expand Up @@ -261,6 +266,8 @@ export function SettingsPanel({
cursorHighlightSupportsClicks = false,
selected,
onWallpaperChange,
customImages = [],
onCustomImagesChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
Expand Down Expand Up @@ -294,6 +301,8 @@ export function SettingsPanel({
onExportQualityChange,
exportFormat = "mp4",
onExportFormatChange,
autoSaveExportToDownloads = false,
onAutoSaveExportToDownloadsChange,
gifFrameRate = 15,
onGifFrameRateChange,
gifLoop = true,
Expand Down Expand Up @@ -336,7 +345,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<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const colorPalette = [
"#FF0000",
Expand Down Expand Up @@ -483,9 +491,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"),
});
Expand All @@ -498,7 +504,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"));
}
Expand All @@ -517,7 +523,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]);
Expand Down Expand Up @@ -1294,7 +1300,7 @@ export function SettingsPanel({
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,image/jpeg"
accept={BACKGROUND_IMAGE_ACCEPT}
className="hidden"
/>
<Button
Expand Down Expand Up @@ -1650,6 +1656,22 @@ export function SettingsPanel({
</div>
)}

<div className="mb-3 flex items-center justify-between gap-3 rounded-xl bg-white/[0.03] border border-white/5 p-3">
<div className="min-w-0">
<div className="text-xs font-medium text-slate-200">
{t("export.autoSaveToDownloads")}
</div>
<div className="text-[10px] text-slate-500 mt-0.5">
{t("export.autoSaveToDownloadsDescription")}
</div>
</div>
<Switch
checked={autoSaveExportToDownloads}
onCheckedChange={onAutoSaveExportToDownloadsChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

{unsavedExport && (
<Button
type="button"
Expand Down
Loading
Loading