Add project file save/load workflow, menu actions, and persistence tests

This commit is contained in:
Yusuf Mohsinally
2026-02-18 11:01:14 -08:00
parent 518fe4ca15
commit 491db0ab2e
9 changed files with 735 additions and 3 deletions
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.local
+4
View File
@@ -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<string>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
+89
View File
@@ -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) => {
+91
View File
@@ -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()
+16
View File
@@ -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')
},
+26 -1
View File
@@ -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({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
<Button
type="button"
variant="outline"
onClick={onLoadProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
</Button>
<Button
type="button"
variant="outline"
onClick={onSaveProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
</Button>
</div>
<Button
type="button"
size="lg"
+367 -2
View File
@@ -29,14 +29,41 @@ 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 { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ASPECT_RATIOS, 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<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -89,6 +116,172 @@ export default function VideoEditor() {
return fileUrl;
};
const 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:\/\//, '');
}
};
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;
};
const isFiniteNumber = (value: unknown): value is number => (
typeof value === 'number' && Number.isFinite(value)
);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const validateProjectData = (candidate: unknown): candidate is EditorProjectData => {
if (!candidate || typeof candidate !== 'object') return false;
const project = candidate as Partial<EditorProjectData>;
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']>): EditorProjectData['editor'] => {
const validAspectRatios = new Set<AspectRatio>(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() {
try {
@@ -96,6 +289,7 @@ export default function VideoEditor() {
if (result.success && result.path) {
const videoUrl = toFileUrl(result.path);
setVideoSourcePath(result.path);
setVideoPath(videoUrl);
} else {
setError('No video to load. Please record or select a video.');
@@ -109,6 +303,165 @@ export default function VideoEditor() {
loadVideo();
}, []);
const handleSaveProject = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error('Unable to determine source video path');
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 fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`;
const result = await window.electronAPI.saveProjectFile(projectData, fileNameBase, currentProjectPath ?? undefined);
if (result.cancelled) {
toast.info('Project save cancelled');
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to save project');
return;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
toast.success(`Project saved to ${result.path}`);
}, [
videoPath,
videoSourcePath,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
if (result.cancelled) {
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to load project');
return;
}
if (!validateProjectData(result.project)) {
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}`);
}, []);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
return () => {
removeLoadListener?.();
removeSaveListener?.();
};
}, [handleLoadProject, handleSaveProject]);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
@@ -722,7 +1075,16 @@ export default function VideoEditor() {
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-destructive">{error}</div>
<div className="flex flex-col items-center gap-3">
<div className="text-destructive">{error}</div>
<button
type="button"
onClick={handleLoadProject}
className="px-3 py-1.5 rounded-md bg-[#34B27B] text-white text-sm hover:bg-[#34B27B]/90"
>
Load Project File
</button>
</div>
</div>
);
}
@@ -748,6 +1110,7 @@ export default function VideoEditor() {
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: getAspectRatioValue(aspectRatio), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
<VideoPlayback
key={videoPath || 'no-video'}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ''}
@@ -878,6 +1241,8 @@ export default function VideoEditor() {
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
onSaveProject={handleSaveProject}
onLoadProject={handleLoadProject}
/>
</div>
+124
View File
@@ -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<any>
}
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' },
})
})
})
+17
View File
@@ -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
}
}