Merge remote-tracking branch 'origin/main' into feature/undo-redo
# Conflicts: # src/components/video-editor/VideoEditor.tsx
This commit is contained in:
@@ -71,7 +71,7 @@ export function LaunchWindow() {
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.cancelled) {
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user