Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
13 changes: 12 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ interface Window {
message?: string;
error?: string;
}>;
storeBackgroundImage: (
imageData: ArrayBuffer,
fileName: string,
mimeType?: string,
) => Promise<{
success: boolean;
path?: string;
url?: string;
message?: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
Expand All @@ -82,7 +93,7 @@ interface Window {
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
exportFolder?: string,
options?: { autoSaveToDownloads?: boolean; exportFolder?: string },
) => Promise<{
success: boolean;
path?: string;
Expand Down
132 changes: 93 additions & 39 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

const nodeRequire = createRequire(import.meta.url);

Expand Down Expand Up @@ -32,8 +33,11 @@ import { RECORDINGS_DIR } from "../main";

const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const BACKGROUND_IMAGES_DIR = path.join(app.getPath("userData"), "background-images");
const RECORDING_SESSION_SUFFIX = ".session.json";
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
const ALLOWED_BACKGROUND_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
const ALLOWED_BACKGROUND_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);

/**
* Paths explicitly approved by the user via file picker dialogs or project loads.
Expand Down Expand Up @@ -80,6 +84,21 @@ function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}

function resolveBackgroundImageOutputPath(fileName: string, mimeType?: string): string {
const normalizedType = mimeType?.trim().toLowerCase() ?? "";
const extension = path.extname(fileName).toLowerCase();

if (normalizedType && !ALLOWED_BACKGROUND_IMAGE_TYPES.has(normalizedType)) {
throw new Error("Unsupported background image type");
}

if (!ALLOWED_BACKGROUND_IMAGE_EXTENSIONS.has(extension)) {
throw new Error("Unsupported background image extension");
}

return path.join(BACKGROUND_IMAGES_DIR, `${randomUUID()}${extension}`);
}

async function approveReadableVideoPath(
filePath?: string | null,
trustedDirs?: string[],
Expand Down Expand Up @@ -780,6 +799,30 @@ export function registerIpcHandlers(
}
});

ipcMain.handle(
"store-background-image",
async (_, imageData: ArrayBuffer, fileName: string, mimeType?: string) => {
try {
const targetPath = resolveBackgroundImageOutputPath(fileName, mimeType);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, Buffer.from(imageData));
return {
success: true,
path: targetPath,
url: pathToFileURL(targetPath).toString(),
message: "Background image stored successfully",
};
} catch (error) {
console.error("Failed to store background image:", error);
return {
success: false,
message: "Failed to store background image",
error: String(error),
};
}
},
);

ipcMain.handle("get-recorded-video-path", async () => {
try {
if (currentRecordingSession?.screenVideoPath) {
Expand Down Expand Up @@ -988,57 +1031,68 @@ 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: string;

if (options?.autoSaveToDownloads) {
targetPath = path.join(app.getPath("downloads"), fileName);
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
11 changes: 9 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload);
},
storeBackgroundImage: (imageData: ArrayBuffer, fileName: string, mimeType?: string) => {
return ipcRenderer.invoke("store-background-image", imageData, fileName, mimeType);
},

getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
Expand All @@ -71,8 +74,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
70 changes: 47 additions & 23 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 @@ -477,47 +485,45 @@ export function SettingsPanel({
}
};

const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;

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"),
});
event.target.value = "";
return;
}

const reader = new FileReader();

reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
setCustomImages((prev) => [...prev, dataUrl]);
onWallpaperChange(dataUrl);
toast.success(t("imageUpload.uploadSuccess"));
try {
const imageData = await file.arrayBuffer();
const result = await window.electronAPI.storeBackgroundImage(imageData, file.name, file.type);
if (!result.success || !result.url) {
toast.error(t("imageUpload.failedToUpload"), {
description: result.message || result.error || t("imageUpload.errorReading"),
});
return;
}
};

reader.onerror = () => {
onCustomImagesChange?.([...customImages, result.url]);
onWallpaperChange(result.url);
toast.success(t("imageUpload.uploadSuccess"));
} catch {
toast.error(t("imageUpload.failedToUpload"), {
description: t("imageUpload.errorReading"),
});
};

reader.readAsDataURL(file);
// Reset input so the same file can be selected again
event.target.value = "";
} finally {
// Reset input so the same file can be selected again
event.target.value = "";
}
};

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,24 @@ 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 id="autosave-downloads-label" className="text-xs font-medium text-slate-200">
{t("export.autoSaveToDownloads")}
</div>
<div id="autosave-downloads-desc" className="text-[10px] text-slate-500 mt-0.5">
{t("export.autoSaveToDownloadsDescription")}
</div>
</div>
<Switch
checked={autoSaveExportToDownloads}
onCheckedChange={onAutoSaveExportToDownloadsChange}
aria-labelledby="autosave-downloads-label"
aria-describedby="autosave-downloads-desc"
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>

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