From 491db0ab2eaf46a2f0b5e147f0b4d8751ef870ab Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:01:14 -0800 Subject: [PATCH 1/3] Add project file save/load workflow, menu actions, and persistence tests --- .gitignore | 1 + electron/electron-env.d.ts | 4 + electron/ipc/handlers.ts | 89 +++++ electron/main.ts | 91 +++++ electron/preload.ts | 16 + src/components/video-editor/SettingsPanel.tsx | 27 +- src/components/video-editor/VideoEditor.tsx | 369 +++++++++++++++++- src/projectPersistence.test.ts | 124 ++++++ src/vite-env.d.ts | 17 + 9 files changed, 735 insertions(+), 3 deletions(-) create mode 100644 src/projectPersistence.test.ts diff --git a/.gitignore b/.gitignore index 3913db1..680ad3b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr *.local diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..d7deb55 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -39,6 +39,10 @@ interface Window { setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean; error?: string }> + loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; cancelled?: boolean; error?: string }> + onMenuLoadProject: (callback: () => void) => () => void + onMenuSaveProject: (callback: () => void) => () => void getPlatform: () => Promise hudOverlayHide: () => void; hudOverlayClose: () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 867b72b..dfc4a1f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4,6 +4,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +const PROJECT_FILE_EXTENSION = 'openscreen' + let selectedSource: any = null export function registerIpcHandlers( @@ -198,6 +200,93 @@ export function registerIpcHandlers( } }); + ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + try { + if (existingProjectPath) { + await fs.writeFile(existingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + return { + success: true, + path: existingProjectPath, + message: 'Project saved successfully' + } + } + + const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') + const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) + ? safeName + : `${safeName}.${PROJECT_FILE_EXTENSION}` + + const result = await dialog.showSaveDialog({ + title: 'Save OpenScreen Project', + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] }, + { name: 'JSON', extensions: ['json'] } + ], + properties: ['createDirectory', 'showOverwriteConfirmation'] + }) + + if (result.canceled || !result.filePath) { + return { + success: false, + cancelled: true, + message: 'Save project cancelled' + } + } + + await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') + + return { + success: true, + path: result.filePath, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + + ipcMain.handle('load-project-file', async () => { + try { + const result = await dialog.showOpenDialog({ + title: 'Open OpenScreen Project', + defaultPath: RECORDINGS_DIR, + filters: [ + { name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] }, + { name: 'JSON', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile'] + }) + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, cancelled: true, message: 'Open project cancelled' } + } + + const filePath = result.filePaths[0] + const content = await fs.readFile(filePath, 'utf-8') + const project = JSON.parse(content) + + return { + success: true, + path: filePath, + project + } + } catch (error) { + console.error('Failed to load project file:', error) + return { + success: false, + message: 'Failed to load project file', + error: String(error) + } + } + }) + let currentVideoPath: string | null = null; ipcMain.handle('set-current-video-path', (_, path: string) => { diff --git a/electron/main.ts b/electron/main.ts index e40e08b..6d863d1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -53,6 +53,96 @@ function createWindow() { mainWindow = createHudOverlayWindow() } +function isEditorWindow(window: BrowserWindow) { + return window.webContents.getURL().includes('windowType=editor') +} + +function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project') { + let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow + + if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { + createEditorWindowWrapper() + targetWindow = mainWindow + if (!targetWindow || targetWindow.isDestroyed()) return + + targetWindow.webContents.once('did-finish-load', () => { + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send(channel) + }) + return + } + + targetWindow.webContents.send(channel) +} + +function setupApplicationMenu() { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { + label: 'Load Project…', + accelerator: 'CmdOrCtrl+O', + click: () => sendEditorMenuAction('menu-load-project'), + }, + { + label: 'Save Project…', + accelerator: 'CmdOrCtrl+S', + click: () => sendEditorMenuAction('menu-save-project'), + }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'close' }, + ], + }, + ] + + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +} + function createTray() { tray = new Tray(defaultTrayIcon); } @@ -145,6 +235,7 @@ app.whenReady().then(async () => { }); createTray() updateTrayMenu() + setupApplicationMenu() // Ensure recordings directory exists await ensureRecordingsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..425440f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -60,6 +60,22 @@ contextBridge.exposeInMainWorld('electronAPI', { clearCurrentVideoPath: () => { return ipcRenderer.invoke('clear-current-video-path') }, + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath) + }, + loadProjectFile: () => { + return ipcRenderer.invoke('load-project-file') + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('menu-load-project', listener) + return () => ipcRenderer.removeListener('menu-load-project', listener) + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('menu-save-project', listener) + return () => ipcRenderer.removeListener('menu-save-project', listener) + }, getPlatform: () => { return ipcRenderer.invoke('get-platform') }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index db5e9d8..eff37cf 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import Block from '@uiw/react-color-block'; -import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react"; +import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react"; import { toast } from "sonner"; import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types"; import { CropControl } from "./CropControl"; @@ -82,6 +82,8 @@ interface SettingsPanelProps { gifSizePreset?: GifSizePreset; onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; + onSaveProject?: () => void; + onLoadProject?: () => void; onExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; @@ -137,6 +139,8 @@ export function SettingsPanel({ gifSizePreset = 'medium', onGifSizePresetChange, gifOutputDimensions = { width: 1280, height: 720 }, + onSaveProject, + onLoadProject, onExport, selectedAnnotationId, annotationRegions = [], @@ -682,6 +686,27 @@ export function SettingsPanel({ )} +
+ + +
+ + ); } @@ -748,6 +1110,7 @@ export default function VideoEditor() {
diff --git a/src/projectPersistence.test.ts b/src/projectPersistence.test.ts new file mode 100644 index 0000000..10ecbaa --- /dev/null +++ b/src/projectPersistence.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' + +vi.mock('../electron/main', () => ({ + RECORDINGS_DIR: '/recordings', +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, + desktopCapturer: { + getSources: vi.fn().mockResolvedValue([]), + }, + shell: { + openExternal: vi.fn().mockResolvedValue(undefined), + }, + app: { + isPackaged: false, + getPath: vi.fn().mockReturnValue('/downloads'), + getAppPath: vi.fn().mockReturnValue('/app'), + }, + dialog: { + showSaveDialog: vi.fn(), + showOpenDialog: vi.fn(), + }, + BrowserWindow: class {}, +})) + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + }, +})) + +import { ipcMain, dialog } from 'electron' +import fs from 'node:fs/promises' +import { registerIpcHandlers } from '../electron/ipc/handlers' + +describe('project save/load handlers', () => { + const setupHandlers = () => { + registerIpcHandlers( + () => {}, + () => ({ close: vi.fn(), focus: vi.fn() }) as any, + () => null, + () => null, + ) + } + + const getRegisteredHandler = (channel: string) => { + const calls = (ipcMain.handle as unknown as Mock).mock.calls + const match = calls.find(([name]) => name === channel) + if (!match) { + throw new Error(`Handler not found for channel: ${channel}`) + } + return match[1] as (...args: any[]) => Promise + } + + beforeEach(() => { + vi.clearAllMocks() + setupHandlers() + }) + + it('overwrites existing project path without showing save dialog', async () => { + const saveHandler = getRegisteredHandler('save-project-file') + const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } + + ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) + + const result = await saveHandler({}, projectData, 'project-name', '/tmp/current.openscreen') + + expect(dialog.showSaveDialog).not.toHaveBeenCalled() + expect(fs.writeFile).toHaveBeenCalledWith( + '/tmp/current.openscreen', + JSON.stringify(projectData, null, 2), + 'utf-8', + ) + expect(result).toMatchObject({ success: true, path: '/tmp/current.openscreen' }) + }) + + it('uses save dialog when no existing project path is provided', async () => { + const saveHandler = getRegisteredHandler('save-project-file') + const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } + + ;(dialog.showSaveDialog as unknown as Mock).mockResolvedValue({ + canceled: false, + filePath: '/tmp/new.openscreen', + }) + ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) + + const result = await saveHandler({}, projectData, 'new-project') + + expect(dialog.showSaveDialog).toHaveBeenCalled() + expect(fs.writeFile).toHaveBeenCalledWith( + '/tmp/new.openscreen', + JSON.stringify(projectData, null, 2), + 'utf-8', + ) + expect(result).toMatchObject({ success: true, path: '/tmp/new.openscreen' }) + }) + + it('loads project JSON payload from selected file', async () => { + const loadHandler = getRegisteredHandler('load-project-file') + const serialized = JSON.stringify({ version: 1, videoPath: '/tmp/video.webm', editor: {} }) + + ;(dialog.showOpenDialog as unknown as Mock).mockResolvedValue({ + canceled: false, + filePaths: ['/tmp/example.openscreen'], + }) + ;(fs.readFile as unknown as Mock).mockResolvedValue(serialized) + + const result = await loadHandler({}) + + expect(dialog.showOpenDialog).toHaveBeenCalled() + expect(fs.readFile).toHaveBeenCalledWith('/tmp/example.openscreen', 'utf-8') + expect(result).toMatchObject({ + success: true, + path: '/tmp/example.openscreen', + project: { version: 1, videoPath: '/tmp/video.webm' }, + }) + }) +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0afbf58..cc0a9dc 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -42,5 +42,22 @@ interface Window { setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ + success: boolean + path?: string + message?: string + cancelled?: boolean + error?: string + }> + loadProjectFile: () => Promise<{ + success: boolean + path?: string + project?: unknown + message?: string + cancelled?: boolean + error?: string + }> + onMenuLoadProject: (callback: () => void) => () => void + onMenuSaveProject: (callback: () => void) => () => void } } \ No newline at end of file From bd50b193a15bf7dfe03859c330631d7a2a155af7 Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:08:01 -0800 Subject: [PATCH 2/3] Add Save Project As menu action and force prompt behavior --- electron/electron-env.d.ts | 1 + electron/main.ts | 7 ++++++- electron/preload.ts | 5 +++++ src/components/video-editor/VideoEditor.tsx | 20 +++++++++++++++++--- src/vite-env.d.ts | 1 + 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d7deb55..74240fc 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -43,6 +43,7 @@ interface Window { loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; cancelled?: boolean; error?: string }> onMenuLoadProject: (callback: () => void) => () => void onMenuSaveProject: (callback: () => void) => () => void + onMenuSaveProjectAs: (callback: () => void) => () => void getPlatform: () => Promise hudOverlayHide: () => void; hudOverlayClose: () => void; diff --git a/electron/main.ts b/electron/main.ts index 6d863d1..5bcfd67 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -57,7 +57,7 @@ function isEditorWindow(window: BrowserWindow) { return window.webContents.getURL().includes('windowType=editor') } -function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project') { +function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' | 'menu-save-project-as') { let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { @@ -102,6 +102,11 @@ function setupApplicationMenu() { accelerator: 'CmdOrCtrl+S', click: () => sendEditorMenuAction('menu-save-project'), }, + { + label: 'Save Project As…', + accelerator: 'CmdOrCtrl+Shift+S', + click: () => sendEditorMenuAction('menu-save-project-as'), + }, ], }, { diff --git a/electron/preload.ts b/electron/preload.ts index 425440f..ac96ce5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -76,6 +76,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('menu-save-project', listener) return () => ipcRenderer.removeListener('menu-save-project', listener) }, + onMenuSaveProjectAs: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('menu-save-project-as', listener) + return () => ipcRenderer.removeListener('menu-save-project-as', listener) + }, getPlatform: () => { return ipcRenderer.invoke('get-platform') }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cae6770..187988e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -303,7 +303,7 @@ export default function VideoEditor() { loadVideo(); }, []); - const handleSaveProject = useCallback(async () => { + const saveProject = useCallback(async (forceSaveAs: boolean) => { if (!videoPath) { toast.error('No video loaded'); return; @@ -339,7 +339,11 @@ export default function VideoEditor() { }; const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`; - const result = await window.electronAPI.saveProjectFile(projectData, fileNameBase, currentProjectPath ?? undefined); + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + forceSaveAs ? undefined : currentProjectPath ?? undefined, + ); if (result.cancelled) { toast.info('Project save cancelled'); @@ -378,6 +382,14 @@ export default function VideoEditor() { gifSizePreset, ]); + const handleSaveProject = useCallback(async () => { + await saveProject(false); + }, [saveProject]); + + const handleSaveProjectAs = useCallback(async () => { + await saveProject(true); + }, [saveProject]); + const handleLoadProject = useCallback(async () => { const result = await window.electronAPI.loadProjectFile(); @@ -455,12 +467,14 @@ export default function VideoEditor() { useEffect(() => { const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); return () => { removeLoadListener?.(); removeSaveListener?.(); + removeSaveAsListener?.(); }; - }, [handleLoadProject, handleSaveProject]); + }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); // Initialize default wallpaper with resolved asset path useEffect(() => { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index cc0a9dc..206000d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -59,5 +59,6 @@ interface Window { }> onMenuLoadProject: (callback: () => void) => () => void onMenuSaveProject: (callback: () => void) => () => void + onMenuSaveProjectAs: (callback: () => void) => () => void } } \ No newline at end of file From 236ca4da29ff7268dea921c775e7c9a4e632f545 Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:28:01 -0800 Subject: [PATCH 3/3] address PR #153 review feedback --- electron/electron-env.d.ts | 17 +- electron/ipc/handlers.ts | 79 +++- electron/main.ts | 32 +- electron/preload.ts | 5 +- src/components/launch/LaunchWindow.tsx | 2 +- src/components/video-editor/VideoEditor.tsx | 396 +++++------------- .../video-editor/projectPersistence.ts | 247 +++++++++++ src/projectPersistence.test.ts | 124 ------ src/vite-env.d.ts | 62 +-- 9 files changed, 489 insertions(+), 475 deletions(-) create mode 100644 src/components/video-editor/projectPersistence.ts delete mode 100644 src/projectPersistence.test.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 74240fc..8d2aaf6 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -34,14 +34,15 @@ interface Window { setRecordingState: (recording: boolean) => Promise onStopRecordingFromTray: (callback: () => void) => () => void openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean; error?: string }> - loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; cancelled?: boolean; error?: string }> - onMenuLoadProject: (callback: () => void) => () => void + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }> + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> + clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }> + loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> + loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> + onMenuLoadProject: (callback: () => void) => () => void onMenuSaveProject: (callback: () => void) => () => void onMenuSaveProjectAs: (callback: () => void) => () => void getPlatform: () => Promise diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index dfc4a1f..8780500 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -6,7 +6,25 @@ import { RECORDINGS_DIR } from '../main' const PROJECT_FILE_EXTENSION = 'openscreen' -let selectedSource: any = null +type SelectedSource = { + name: string + [key: string]: unknown +} + +let selectedSource: SelectedSource | null = null +let currentVideoPath: string | null = null +let currentProjectPath: string | null = null + +function normalizePath(filePath: string) { + return path.resolve(filePath) +} + +function isTrustedProjectPath(filePath?: string | null) { + if (!filePath || !currentProjectPath) { + return false + } + return normalizePath(filePath) === normalizePath(currentProjectPath) +} export function registerIpcHandlers( createEditorWindow: () => void, @@ -26,7 +44,7 @@ export function registerIpcHandlers( })) }) - ipcMain.handle('select-source', (_, source) => { + ipcMain.handle('select-source', (_, source: SelectedSource) => { selectedSource = source const sourceSelectorWin = getSourceSelectorWindow() if (sourceSelectorWin) { @@ -63,6 +81,7 @@ export function registerIpcHandlers( const videoPath = path.join(RECORDINGS_DIR, fileName) await fs.writeFile(videoPath, Buffer.from(videoData)) currentVideoPath = videoPath; + currentProjectPath = null return { success: true, path: videoPath, @@ -148,8 +167,8 @@ export function registerIpcHandlers( if (result.canceled || !result.filePath) { return { success: false, - cancelled: true, - message: 'Export cancelled' + canceled: true, + message: 'Export canceled' }; } @@ -183,9 +202,10 @@ export function registerIpcHandlers( }); if (result.canceled || result.filePaths.length === 0) { - return { success: false, cancelled: true }; + return { success: false, canceled: true }; } + currentProjectPath = null return { success: true, path: result.filePaths[0] @@ -202,11 +222,16 @@ export function registerIpcHandlers( ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { try { - if (existingProjectPath) { - await fs.writeFile(existingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) + ? existingProjectPath + : null + + if (trustedExistingProjectPath) { + await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + currentProjectPath = trustedExistingProjectPath return { success: true, - path: existingProjectPath, + path: trustedExistingProjectPath, message: 'Project saved successfully' } } @@ -229,12 +254,13 @@ export function registerIpcHandlers( if (result.canceled || !result.filePath) { return { success: false, - cancelled: true, - message: 'Save project cancelled' + canceled: true, + message: 'Save project canceled' } } await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') + currentProjectPath = result.filePath return { success: true, @@ -265,12 +291,16 @@ export function registerIpcHandlers( }) if (result.canceled || result.filePaths.length === 0) { - return { success: false, cancelled: true, message: 'Open project cancelled' } + return { success: false, canceled: true, message: 'Open project canceled' } } const filePath = result.filePaths[0] const content = await fs.readFile(filePath, 'utf-8') const project = JSON.parse(content) + currentProjectPath = filePath + if (project && typeof project === 'object' && typeof project.videoPath === 'string') { + currentVideoPath = project.videoPath + } return { success: true, @@ -287,10 +317,35 @@ export function registerIpcHandlers( } }) - let currentVideoPath: string | null = null; + ipcMain.handle('load-current-project-file', async () => { + try { + if (!currentProjectPath) { + return { success: false, message: 'No active project' } + } + + const content = await fs.readFile(currentProjectPath, 'utf-8') + const project = JSON.parse(content) + if (project && typeof project === 'object' && typeof project.videoPath === 'string') { + currentVideoPath = project.videoPath + } + return { + success: true, + path: currentProjectPath, + project, + } + } catch (error) { + console.error('Failed to load current project file:', error) + return { + success: false, + message: 'Failed to load current project file', + error: String(error), + } + } + }) ipcMain.handle('set-current-video-path', (_, path: string) => { currentVideoPath = path; + currentProjectPath = null return { success: true }; }); diff --git a/electron/main.ts b/electron/main.ts index 5bcfd67..9efbf73 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -76,19 +76,27 @@ function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' } function setupApplicationMenu() { - const template: Electron.MenuItemConstructorOptions[] = [ - { + const isMac = process.platform === 'darwin' + const template: Electron.MenuItemConstructorOptions[] = [] + + if (isMac) { + template.push({ label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], - }, + }) + } + + template.push( { label: 'File', submenu: [ @@ -107,6 +115,7 @@ function setupApplicationMenu() { accelerator: 'CmdOrCtrl+Shift+S', click: () => sendEditorMenuAction('menu-save-project-as'), }, + ...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]), ], }, { @@ -137,12 +146,19 @@ function setupApplicationMenu() { }, { label: 'Window', - submenu: [ - { role: 'minimize' }, - { role: 'close' }, - ], + submenu: isMac + ? [ + { role: 'minimize' }, + { role: 'zoom' }, + { type: 'separator' }, + { role: 'front' }, + ] + : [ + { role: 'minimize' }, + { role: 'close' }, + ], }, - ] + ) const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) diff --git a/electron/preload.ts b/electron/preload.ts index ac96ce5..cc096bb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -66,6 +66,9 @@ contextBridge.exposeInMainWorld('electronAPI', { loadProjectFile: () => { return ipcRenderer.invoke('load-project-file') }, + loadCurrentProjectFile: () => { + return ipcRenderer.invoke('load-current-project-file') + }, onMenuLoadProject: (callback: () => void) => { const listener = () => callback() ipcRenderer.on('menu-load-project', listener) @@ -84,4 +87,4 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, -}) \ No newline at end of file +}) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f82ae6c..0154ac7 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -71,7 +71,7 @@ export function LaunchWindow() { const openVideoFile = async () => { const result = await window.electronAPI.openVideoFilePicker(); - if (result.cancelled) { + if (result.canceled) { return; } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 187988e..fb57461 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -10,6 +10,15 @@ import PlaybackControls from "./PlaybackControls"; import TimelineEditor from "./timeline/TimelineEditor"; import { SettingsPanel } from "./SettingsPanel"; import { ExportDialog } from "./ExportDialog"; +import { + WALLPAPER_PATHS, + createProjectData, + deriveNextId, + fromFileUrl, + normalizeProjectEditor, + toFileUrl, + validateProjectData, +} from "./projectPersistence"; import type { Span } from "dnd-timeline"; import { @@ -29,37 +38,9 @@ import { type FigureData, } from "./types"; import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter"; -import { ASPECT_RATIOS, type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; -const WALLPAPER_COUNT = 18; -const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); - -const PROJECT_VERSION = 1; - -interface EditorProjectData { - version: number; - videoPath: string; - editor: { - wallpaper: string; - shadowIntensity: number; - showBlur: boolean; - motionBlurEnabled: boolean; - borderRadius: number; - padding: number; - cropRegion: CropRegion; - zoomRegions: ZoomRegion[]; - trimRegions: TrimRegion[]; - annotationRegions: AnnotationRegion[]; - aspectRatio: AspectRatio; - exportQuality: ExportQuality; - exportFormat: ExportFormat; - gifFrameRate: GifFrameRate; - gifLoop: boolean; - gifSizePreset: GifSizePreset; - }; -} - export default function VideoEditor() { const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); @@ -100,208 +81,92 @@ export default function VideoEditor() { const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); - // Helper to convert file path to proper file:// URL - const toFileUrl = (filePath: string): string => { - // Normalize path separators to forward slashes - const normalized = filePath.replace(/\\/g, '/'); - - // Check if it's a Windows absolute path (e.g., C:/Users/...) - if (normalized.match(/^[a-zA-Z]:/)) { - const fileUrl = `file:///${normalized}`; - return fileUrl; + const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { + if (!validateProjectData(candidate)) { + return false; } - - // Unix-style absolute path - const fileUrl = `file://${normalized}`; - return fileUrl; - }; - const fromFileUrl = (fileUrl: string): string => { - if (!fileUrl.startsWith('file://')) { - return fileUrl; - } + const project = candidate; + const sourcePath = project.videoPath; + const normalizedEditor = normalizeProjectEditor(project.editor); try { - const url = new URL(fileUrl); - return decodeURIComponent(url.pathname); + videoPlaybackRef.current?.pause(); } catch { - return fileUrl.replace(/^file:\/\//, ''); + // no-op } - }; + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); - const deriveNextId = (prefix: string, ids: string[]): number => { - const max = ids.reduce((acc, id) => { - const match = id.match(new RegExp(`^${prefix}-(\\d+)$`)); - if (!match) return acc; - const value = Number(match[1]); - return Number.isFinite(value) ? Math.max(acc, value) : acc; - }, 0); - return max + 1; - }; + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(path ?? null); - const isFiniteNumber = (value: unknown): value is number => ( - typeof value === 'number' && Number.isFinite(value) - ); + setWallpaper(normalizedEditor.wallpaper); + setShadowIntensity(normalizedEditor.shadowIntensity); + setShowBlur(normalizedEditor.showBlur); + setMotionBlurEnabled(normalizedEditor.motionBlurEnabled); + setBorderRadius(normalizedEditor.borderRadius); + setPadding(normalizedEditor.padding); + setCropRegion(normalizedEditor.cropRegion); + setZoomRegions(normalizedEditor.zoomRegions); + setTrimRegions(normalizedEditor.trimRegions); + setAnnotationRegions(normalizedEditor.annotationRegions); + setAspectRatio(normalizedEditor.aspectRatio); + setExportQuality(normalizedEditor.exportQuality); + setExportFormat(normalizedEditor.exportFormat); + setGifFrameRate(normalizedEditor.gifFrameRate); + setGifLoop(normalizedEditor.gifLoop); + setGifSizePreset(normalizedEditor.gifSizePreset); - const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + + nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id)); + nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id)); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + normalizedEditor.annotationRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; - const validateProjectData = (candidate: unknown): candidate is EditorProjectData => { - if (!candidate || typeof candidate !== 'object') return false; - const project = candidate as Partial; - if (typeof project.version !== 'number') return false; - if (typeof project.videoPath !== 'string' || !project.videoPath) return false; - if (!project.editor || typeof project.editor !== 'object') return false; return true; - }; - - const normalizeProjectEditor = (editor: Partial): EditorProjectData['editor'] => { - const validAspectRatios = new Set(ASPECT_RATIOS); - - const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) - ? editor.zoomRegions - .filter((region): region is ZoomRegion => Boolean(region && typeof region.id === 'string')) - .map((region) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; - const startMs = Math.max(0, Math.min(rawStart, rawEnd)); - const endMs = Math.max(startMs + 1, rawEnd); - - return { - id: region.id, - startMs, - endMs, - depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH, - focus: { - cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1), - cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1), - }, - }; - }) - : []; - - const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions) - ? editor.trimRegions - .filter((region): region is TrimRegion => Boolean(region && typeof region.id === 'string')) - .map((region) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; - const startMs = Math.max(0, Math.min(rawStart, rawEnd)); - const endMs = Math.max(startMs + 1, rawEnd); - return { - id: region.id, - startMs, - endMs, - }; - }) - : []; - - const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions) - ? editor.annotationRegions - .filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === 'string')) - .map((region, index) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; - const startMs = Math.max(0, Math.min(rawStart, rawEnd)); - const endMs = Math.max(startMs + 1, rawEnd); - - return { - id: region.id, - startMs, - endMs, - type: region.type === 'image' || region.type === 'figure' ? region.type : 'text', - content: typeof region.content === 'string' ? region.content : '', - textContent: typeof region.textContent === 'string' ? region.textContent : undefined, - imageContent: typeof region.imageContent === 'string' ? region.imageContent : undefined, - position: { - x: clamp(isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x, 0, 100), - y: clamp(isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y, 0, 100), - }, - size: { - width: clamp(isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width, 1, 200), - height: clamp(isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height, 1, 200), - }, - style: { - ...DEFAULT_ANNOTATION_STYLE, - ...(region.style && typeof region.style === 'object' ? region.style : {}), - }, - zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1, - figureData: region.figureData - ? { - ...DEFAULT_FIGURE_DATA, - ...region.figureData, - } - : undefined, - }; - }) - : []; - - const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x; - const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y; - const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width; - const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) ? editor.cropRegion.height : DEFAULT_CROP_REGION.height; - - const cropX = clamp(rawCropX, 0, 1); - const cropY = clamp(rawCropY, 0, 1); - const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX); - const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); - - return { - wallpaper: typeof editor.wallpaper === 'string' ? editor.wallpaper : WALLPAPER_PATHS[0], - shadowIntensity: typeof editor.shadowIntensity === 'number' ? editor.shadowIntensity : 0, - showBlur: typeof editor.showBlur === 'boolean' ? editor.showBlur : false, - motionBlurEnabled: typeof editor.motionBlurEnabled === 'boolean' ? editor.motionBlurEnabled : false, - borderRadius: typeof editor.borderRadius === 'number' ? editor.borderRadius : 0, - padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, - cropRegion: { - x: cropX, - y: cropY, - width: cropWidth, - height: cropHeight, - }, - zoomRegions: normalizedZoomRegions, - trimRegions: normalizedTrimRegions, - annotationRegions: normalizedAnnotationRegions, - aspectRatio: - editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) - ? editor.aspectRatio - : '16:9', - exportQuality: - editor.exportQuality === 'medium' || editor.exportQuality === 'source' - ? editor.exportQuality - : 'good', - exportFormat: editor.exportFormat === 'gif' ? 'gif' : 'mp4', - gifFrameRate: - editor.gifFrameRate === 15 || editor.gifFrameRate === 20 || editor.gifFrameRate === 25 || editor.gifFrameRate === 30 - ? editor.gifFrameRate - : 15, - gifLoop: typeof editor.gifLoop === 'boolean' ? editor.gifLoop : true, - gifSizePreset: - editor.gifSizePreset === 'medium' || editor.gifSizePreset === 'large' || editor.gifSizePreset === 'original' - ? editor.gifSizePreset - : 'medium', - }; - }; + }, []); useEffect(() => { - async function loadVideo() { + async function loadInitialData() { try { + const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); + if (currentProjectResult.success && currentProjectResult.project) { + const restored = await applyLoadedProject( + currentProjectResult.project, + currentProjectResult.path ?? null, + ); + if (restored) { + return; + } + } + const result = await window.electronAPI.getCurrentVideoPath(); - if (result.success && result.path) { - const videoUrl = toFileUrl(result.path); setVideoSourcePath(result.path); - setVideoPath(videoUrl); + setVideoPath(toFileUrl(result.path)); } else { - setError('No video to load. Please record or select a video.'); + setError("No video to load. Please record or select a video."); } } catch (err) { - setError('Error loading video: ' + String(err)); + setError("Error loading video: " + String(err)); } finally { setLoading(false); } } - loadVideo(); - }, []); + + loadInitialData(); + }, [applyLoadedProject]); const saveProject = useCallback(async (forceSaveAs: boolean) => { if (!videoPath) { @@ -315,28 +180,24 @@ export default function VideoEditor() { return; } - const projectData: EditorProjectData = { - version: PROJECT_VERSION, - videoPath: sourcePath, - editor: { - wallpaper, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - }, - }; + const projectData = createProjectData(sourcePath, { + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }); const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`; const result = await window.electronAPI.saveProjectFile( @@ -345,8 +206,8 @@ export default function VideoEditor() { forceSaveAs ? undefined : currentProjectPath ?? undefined, ); - if (result.cancelled) { - toast.info('Project save cancelled'); + if (result.canceled) { + toast.info("Project save canceled"); return; } @@ -393,7 +254,7 @@ export default function VideoEditor() { const handleLoadProject = useCallback(async () => { const result = await window.electronAPI.loadProjectFile(); - if (result.cancelled) { + if (result.canceled) { return; } @@ -402,67 +263,14 @@ export default function VideoEditor() { return; } - if (!validateProjectData(result.project)) { + const restored = await applyLoadedProject(result.project, result.path ?? null); + if (!restored) { toast.error('Invalid project file format'); return; } - const project = result.project; - const sourcePath = project.videoPath; - const normalizedEditor = normalizeProjectEditor(project.editor); - - try { - videoPlaybackRef.current?.pause(); - } catch { - // no-op - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); - - try { - await window.electronAPI.setCurrentVideoPath(sourcePath); - } catch (error) { - console.warn('Unable to update current video path:', error); - } - - const nextVideoPath = toFileUrl(sourcePath); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(nextVideoPath); - setCurrentProjectPath(result.path ?? null); - - setWallpaper(normalizedEditor.wallpaper); - setShadowIntensity(normalizedEditor.shadowIntensity); - setShowBlur(normalizedEditor.showBlur); - setMotionBlurEnabled(normalizedEditor.motionBlurEnabled); - setBorderRadius(normalizedEditor.borderRadius); - setPadding(normalizedEditor.padding); - setCropRegion(normalizedEditor.cropRegion); - setZoomRegions(normalizedEditor.zoomRegions); - setTrimRegions(normalizedEditor.trimRegions); - setAnnotationRegions(normalizedEditor.annotationRegions); - setAspectRatio(normalizedEditor.aspectRatio); - setExportQuality(normalizedEditor.exportQuality); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); - - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - - nextZoomIdRef.current = deriveNextId('zoom', normalizedEditor.zoomRegions.map((region) => region.id)); - nextTrimIdRef.current = deriveNextId('trim', normalizedEditor.trimRegions.map((region) => region.id)); - nextAnnotationIdRef.current = deriveNextId('annotation', normalizedEditor.annotationRegions.map((region) => region.id)); - nextAnnotationZIndexRef.current = normalizedEditor.annotationRegions.reduce( - (max, region) => Math.max(max, region.zIndex), - 0, - ) + 1; - toast.success(`Project loaded from ${result.path}`); - }, []); + }, [applyLoadedProject]); useEffect(() => { const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); @@ -875,8 +683,8 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - if (saveResult.cancelled) { - toast.info('Export cancelled'); + if (saveResult.canceled) { + toast.info('Export canceled'); } else if (saveResult.success) { toast.success(`GIF exported successfully to ${saveResult.path}`); } else { @@ -1000,8 +808,8 @@ export default function VideoEditor() { const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - if (saveResult.cancelled) { - toast.info('Export cancelled'); + if (saveResult.canceled) { + toast.info('Export canceled'); } else if (saveResult.success) { toast.success(`Video exported successfully to ${saveResult.path}`); } else { @@ -1071,7 +879,7 @@ export default function VideoEditor() { const handleCancelExport = useCallback(() => { if (exporterRef.current) { exporterRef.current.cancel(); - toast.info('Export cancelled'); + toast.info('Export canceled'); setShowExportDialog(false); setIsExporting(false); setExportProgress(null); @@ -1273,4 +1081,4 @@ export default function VideoEditor() { />
); -} \ No newline at end of file +} diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts new file mode 100644 index 0000000..9f8b737 --- /dev/null +++ b/src/components/video-editor/projectPersistence.ts @@ -0,0 +1,247 @@ +import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_CROP_REGION, + DEFAULT_FIGURE_DATA, + DEFAULT_ZOOM_DEPTH, + type AnnotationRegion, + type CropRegion, + type TrimRegion, + type ZoomRegion, +} from "./types"; + +const WALLPAPER_COUNT = 18; + +export const WALLPAPER_PATHS = Array.from( + { length: WALLPAPER_COUNT }, + (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`, +); + +export const PROJECT_VERSION = 1; + +export interface ProjectEditorState { + wallpaper: string; + shadowIntensity: number; + showBlur: boolean; + motionBlurEnabled: boolean; + borderRadius: number; + padding: number; + cropRegion: CropRegion; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + annotationRegions: AnnotationRegion[]; + aspectRatio: AspectRatio; + exportQuality: ExportQuality; + exportFormat: ExportFormat; + gifFrameRate: GifFrameRate; + gifLoop: boolean; + gifSizePreset: GifSizePreset; +} + +export interface EditorProjectData { + version: number; + videoPath: string; + editor: ProjectEditorState; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function toFileUrl(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + if (normalized.match(/^[a-zA-Z]:/)) { + return `file:///${normalized}`; + } + return `file://${normalized}`; +} + +export function fromFileUrl(fileUrl: string): string { + if (!fileUrl.startsWith("file://")) { + return fileUrl; + } + + try { + const url = new URL(fileUrl); + return decodeURIComponent(url.pathname); + } catch { + return fileUrl.replace(/^file:\/\//, ""); + } +} + +export function deriveNextId(prefix: string, ids: string[]): number { + const max = ids.reduce((acc, id) => { + const match = id.match(new RegExp(`^${prefix}-(\\d+)$`)); + if (!match) return acc; + const value = Number(match[1]); + return Number.isFinite(value) ? Math.max(acc, value) : acc; + }, 0); + return max + 1; +} + +export function validateProjectData(candidate: unknown): candidate is EditorProjectData { + if (!candidate || typeof candidate !== "object") return false; + const project = candidate as Partial; + if (typeof project.version !== "number") return false; + if (typeof project.videoPath !== "string" || !project.videoPath) return false; + if (!project.editor || typeof project.editor !== "object") return false; + return true; +} + +export function normalizeProjectEditor(editor: Partial): ProjectEditorState { + const validAspectRatios = new Set(ASPECT_RATIOS); + + const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) + ? editor.zoomRegions + .filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string")) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + return { + id: region.id, + startMs, + endMs, + depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH, + focus: { + cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1), + cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1), + }, + }; + }) + : []; + + const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions) + ? editor.trimRegions + .filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string")) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + return { + id: region.id, + startMs, + endMs, + }; + }) + : []; + + const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions) + ? editor.annotationRegions + .filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string")) + .map((region, index) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, rawEnd); + + return { + id: region.id, + startMs, + endMs, + type: region.type === "image" || region.type === "figure" ? region.type : "text", + content: typeof region.content === "string" ? region.content : "", + textContent: typeof region.textContent === "string" ? region.textContent : undefined, + imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined, + position: { + x: clamp( + isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x, + 0, + 100, + ), + y: clamp( + isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y, + 0, + 100, + ), + }, + size: { + width: clamp( + isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width, + 1, + 200, + ), + height: clamp( + isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height, + 1, + 200, + ), + }, + style: { + ...DEFAULT_ANNOTATION_STYLE, + ...(region.style && typeof region.style === "object" ? region.style : {}), + }, + zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1, + figureData: region.figureData + ? { + ...DEFAULT_FIGURE_DATA, + ...region.figureData, + } + : undefined, + }; + }) + : []; + + const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x; + const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y; + const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width; + const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) + ? editor.cropRegion.height + : DEFAULT_CROP_REGION.height; + + const cropX = clamp(rawCropX, 0, 1); + const cropY = clamp(rawCropY, 0, 1); + const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX); + const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); + + return { + wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], + shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, + showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, + motionBlurEnabled: typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false, + borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0, + padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, + cropRegion: { + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight, + }, + zoomRegions: normalizedZoomRegions, + trimRegions: normalizedTrimRegions, + annotationRegions: normalizedAnnotationRegions, + aspectRatio: editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", + exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good", + exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", + gifFrameRate: + editor.gifFrameRate === 15 || + editor.gifFrameRate === 20 || + editor.gifFrameRate === 25 || + editor.gifFrameRate === 30 + ? editor.gifFrameRate + : 15, + gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, + gifSizePreset: + editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original" + ? editor.gifSizePreset + : "medium", + }; +} + +export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData { + return { + version: PROJECT_VERSION, + videoPath, + editor, + }; +} diff --git a/src/projectPersistence.test.ts b/src/projectPersistence.test.ts deleted file mode 100644 index 10ecbaa..0000000 --- a/src/projectPersistence.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' - -vi.mock('../electron/main', () => ({ - RECORDINGS_DIR: '/recordings', -})) - -vi.mock('electron', () => ({ - ipcMain: { - handle: vi.fn(), - on: vi.fn(), - }, - desktopCapturer: { - getSources: vi.fn().mockResolvedValue([]), - }, - shell: { - openExternal: vi.fn().mockResolvedValue(undefined), - }, - app: { - isPackaged: false, - getPath: vi.fn().mockReturnValue('/downloads'), - getAppPath: vi.fn().mockReturnValue('/app'), - }, - dialog: { - showSaveDialog: vi.fn(), - showOpenDialog: vi.fn(), - }, - BrowserWindow: class {}, -})) - -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: vi.fn(), - readFile: vi.fn(), - readdir: vi.fn().mockResolvedValue([]), - }, -})) - -import { ipcMain, dialog } from 'electron' -import fs from 'node:fs/promises' -import { registerIpcHandlers } from '../electron/ipc/handlers' - -describe('project save/load handlers', () => { - const setupHandlers = () => { - registerIpcHandlers( - () => {}, - () => ({ close: vi.fn(), focus: vi.fn() }) as any, - () => null, - () => null, - ) - } - - const getRegisteredHandler = (channel: string) => { - const calls = (ipcMain.handle as unknown as Mock).mock.calls - const match = calls.find(([name]) => name === channel) - if (!match) { - throw new Error(`Handler not found for channel: ${channel}`) - } - return match[1] as (...args: any[]) => Promise - } - - beforeEach(() => { - vi.clearAllMocks() - setupHandlers() - }) - - it('overwrites existing project path without showing save dialog', async () => { - const saveHandler = getRegisteredHandler('save-project-file') - const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } - - ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) - - const result = await saveHandler({}, projectData, 'project-name', '/tmp/current.openscreen') - - expect(dialog.showSaveDialog).not.toHaveBeenCalled() - expect(fs.writeFile).toHaveBeenCalledWith( - '/tmp/current.openscreen', - JSON.stringify(projectData, null, 2), - 'utf-8', - ) - expect(result).toMatchObject({ success: true, path: '/tmp/current.openscreen' }) - }) - - it('uses save dialog when no existing project path is provided', async () => { - const saveHandler = getRegisteredHandler('save-project-file') - const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } - - ;(dialog.showSaveDialog as unknown as Mock).mockResolvedValue({ - canceled: false, - filePath: '/tmp/new.openscreen', - }) - ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) - - const result = await saveHandler({}, projectData, 'new-project') - - expect(dialog.showSaveDialog).toHaveBeenCalled() - expect(fs.writeFile).toHaveBeenCalledWith( - '/tmp/new.openscreen', - JSON.stringify(projectData, null, 2), - 'utf-8', - ) - expect(result).toMatchObject({ success: true, path: '/tmp/new.openscreen' }) - }) - - it('loads project JSON payload from selected file', async () => { - const loadHandler = getRegisteredHandler('load-project-file') - const serialized = JSON.stringify({ version: 1, videoPath: '/tmp/video.webm', editor: {} }) - - ;(dialog.showOpenDialog as unknown as Mock).mockResolvedValue({ - canceled: false, - filePaths: ['/tmp/example.openscreen'], - }) - ;(fs.readFile as unknown as Mock).mockResolvedValue(serialized) - - const result = await loadHandler({}) - - expect(dialog.showOpenDialog).toHaveBeenCalled() - expect(fs.readFile).toHaveBeenCalledWith('/tmp/example.openscreen', 'utf-8') - expect(result).toMatchObject({ - success: true, - path: '/tmp/example.openscreen', - project: { version: 1, videoPath: '/tmp/video.webm' }, - }) - }) -}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 206000d..3e87d7f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -32,33 +32,41 @@ interface Window { setRecordingState: (recording: boolean) => Promise onStopRecordingFromTray: (callback: () => void) => () => void openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message?: string - cancelled?: boolean - }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ - success: boolean - path?: string - message?: string - cancelled?: boolean - error?: string - }> - loadProjectFile: () => Promise<{ - success: boolean - path?: string - project?: unknown - message?: string - cancelled?: boolean - error?: string - }> - onMenuLoadProject: (callback: () => void) => () => void + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ + success: boolean + path?: string + message?: string + canceled?: boolean + }> + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> + clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ + success: boolean + path?: string + message?: string + canceled?: boolean + error?: string + }> + loadProjectFile: () => Promise<{ + success: boolean + path?: string + project?: unknown + message?: string + canceled?: boolean + error?: string + }> + loadCurrentProjectFile: () => Promise<{ + success: boolean + path?: string + project?: unknown + message?: string + canceled?: boolean + error?: string + }> + onMenuLoadProject: (callback: () => void) => () => void onMenuSaveProject: (callback: () => void) => () => void onMenuSaveProjectAs: (callback: () => void) => () => void } -} \ No newline at end of file +}