Add project file save/load workflow, menu actions, and persistence tests
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user