address PR #153 review feedback
This commit is contained in:
Vendored
+9
-8
@@ -34,14 +34,15 @@ interface Window {
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
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<string>
|
||||
|
||||
+67
-12
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
+24
-8
@@ -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)
|
||||
|
||||
+4
-1
@@ -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')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ export function LaunchWindow() {
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.cancelled) {
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
|
||||
@@ -100,208 +81,92 @@ export default function VideoEditor() {
|
||||
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
|
||||
const exporterRef = useRef<VideoExporter | null>(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<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() {
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
videoPath,
|
||||
editor,
|
||||
};
|
||||
}
|
||||
@@ -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<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
+35
-27
@@ -32,33 +32,41 @@ interface Window {
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user