address PR #153 review feedback

This commit is contained in:
Yusuf Mohsinally
2026-02-28 00:28:01 -08:00
parent bd50b193a1
commit 236ca4da29
9 changed files with 489 additions and 475 deletions
+9 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
},
})
})
+1 -1
View File
@@ -71,7 +71,7 @@ export function LaunchWindow() {
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.cancelled) {
if (result.canceled) {
return;
}
+102 -294
View File
@@ -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,
};
}
-124
View File
@@ -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' },
})
})
})
+35 -27
View File
@@ -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
}
}
}