Merge remote-tracking branch 'origin/main' into feature/undo-redo

# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
This commit is contained in:
FabLrc
2026-03-04 12:41:49 +01:00
12 changed files with 1348 additions and 466 deletions
+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;
}
+26 -1
View File
@@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -86,6 +86,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[];
@@ -148,6 +150,8 @@ export function SettingsPanel({
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -748,6 +752,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"
+296 -40
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
@@ -10,6 +10,14 @@ import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import {
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import type { Span } from "dnd-timeline";
import {
@@ -32,14 +40,10 @@ import {
} 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 { getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
export default function VideoEditor() {
const { state: editorState, pushState, updateState, commitState, undo, redo } =
useEditorHistory(INITIAL_EDITOR_STATE);
@@ -52,6 +56,8 @@ export default function VideoEditor() {
// ── Non-undoable state
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);
@@ -71,6 +77,7 @@ export default function VideoEditor() {
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -82,43 +89,289 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const toFileUrl = (filePath: string): string => {
const normalized = filePath.replace(/\\/g, '/');
return normalized.match(/^[a-zA-Z]:/) ? `file:///${normalized}` : `file://${normalized}`;
};
const fromFileUrl = (fileUrl: string): string => {
if (!fileUrl.startsWith('file://')) {
return fileUrl;
const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
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);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(path ?? null);
pushState({
wallpaper: normalizedEditor.wallpaper,
shadowIntensity: normalizedEditor.shadowIntensity,
showBlur: normalizedEditor.showBlur,
motionBlurEnabled: normalizedEditor.motionBlurEnabled,
borderRadius: normalizedEditor.borderRadius,
padding: normalizedEditor.padding,
cropRegion: normalizedEditor.cropRegion,
zoomRegions: normalizedEditor.zoomRegions,
trimRegions: normalizedEditor.trimRegions,
speedRegions: normalizedEditor.speedRegions,
annotationRegions: normalizedEditor.annotationRegions,
aspectRatio: normalizedEditor.aspectRatio,
});
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id));
nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id));
nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.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;
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
return true;
}, [pushState]);
const currentProjectSnapshot = useMemo(() => {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
return null;
}
return JSON.stringify(
createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
}),
);
}, [
videoPath,
videoSourcePath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
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);
setVideoPath(videoUrl);
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
} 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) {
toast.error('No video loaded');
return;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error('Unable to determine source video path');
return;
}
const projectData = createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`;
const projectSnapshot = JSON.stringify(projectData);
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
forceSaveAs ? undefined : currentProjectPath ?? undefined,
);
if (result.canceled) {
toast.info("Project save canceled");
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to save project');
return;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
}, [
videoPath,
videoSourcePath,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
await saveProject(true);
}, [saveProject]);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || 'Failed to load project');
return;
}
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error('Invalid project file format');
return;
}
toast.success(`Project loaded from ${result.path}`);
}, [applyLoadedProject]);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
return () => {
removeLoadListener?.();
removeSaveListener?.();
removeSaveAsListener?.();
};
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
useEffect(() => {
let mounted = true;
@@ -151,15 +404,6 @@ export default function VideoEditor() {
};
}, [videoPath]);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
getAssetPath('wallpapers/wallpaper1.jpg')
.then((path) => { if (mounted) updateState({ wallpaper: path }); })
.catch((err) => console.warn('Failed to resolve default wallpaper path:', err));
return () => { mounted = false; };
}, [updateState]);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
@@ -581,8 +825,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 {
@@ -707,8 +951,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 {
@@ -778,7 +1022,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);
@@ -796,7 +1040,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>
);
}
@@ -822,6 +1075,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 || ''}
@@ -965,6 +1219,8 @@ export default function VideoEditor() {
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
onSaveProject={handleSaveProject}
onLoadProject={handleLoadProject}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null}
onSpeedChange={handleSpeedChange}
@@ -0,0 +1,280 @@
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_PLAYBACK_SPEED,
DEFAULT_FIGURE_DATA,
DEFAULT_ZOOM_DEPTH,
type AnnotationRegion,
type CropRegion,
type SpeedRegion,
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[];
speedRegions: SpeedRegion[];
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 normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
? editor.speedRegions
.filter((region): region is SpeedRegion => 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);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
: DEFAULT_PLAYBACK_SPEED;
return {
id: region.id,
startMs,
endMs,
speed,
};
})
: [];
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,
speedRegions: normalizedSpeedRegions,
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,
};
}