Files
openscreen/src/components/video-editor/VideoEditor.tsx
T
2026-03-16 12:51:54 +01:00

1436 lines
41 KiB
TypeScript

import type { Span } from "dnd-timeline";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
import {
calculateOutputDimensions,
type ExportFormat,
type ExportProgress,
type ExportQuality,
type ExportSettings,
GIF_SIZE_PRESETS,
GifExporter,
type GifFrameRate,
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import { matchesShortcut } from "@/lib/shortcuts";
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
type FigureData,
type PlaybackSpeed,
type SpeedRegion,
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
export default function VideoEditor() {
const {
state: editorState,
pushState,
updateState,
commitState,
undo,
redo,
} = useEditorHistory(INITIAL_EDITOR_STATE);
const {
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
cropRegion,
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
aspectRatio,
} = editorState;
// ── 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);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
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);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
const project = candidate;
const sourcePath = fromFileUrl(project.videoPath);
const normalizedEditor = normalizeProjectEditor(project.editor);
try {
videoPlaybackRef.current?.pause();
} catch {
// 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,
motionBlurAmount: normalizedEditor.motionBlurAmount,
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,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
}),
);
}, [
videoPath,
videoSourcePath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
useEffect(() => {
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 sourcePath = fromFileUrl(result.path);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
} else {
setError("No video to load. Please record or select a video.");
}
} catch (err) {
setError("Error loading video: " + String(err));
} finally {
setLoading(false);
}
}
loadInitialData();
}, [applyLoadedProject]);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error("No video loaded");
return false;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
toast.error("Unable to determine source video path");
return false;
}
const projectData = createProjectData(sourcePath, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
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 false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return false;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
return true;
},
[
videoPath,
videoSourcePath,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
],
);
useEffect(() => {
window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges]);
useEffect(() => {
const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => {
return saveProject(false);
});
return () => cleanup();
}, [saveProject]);
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;
async function loadCursorTelemetry() {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
if (mounted) {
setCursorTelemetry([]);
}
return;
}
try {
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
}
} catch (telemetryError) {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
}
}
}
loadCursorTelemetry();
return () => {
mounted = false;
};
}, [videoPath, videoSourcePath]);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
const video = playback?.video;
if (!playback || !video) return;
if (isPlaying) {
playback.pause();
} else {
playback.play().catch((err) => console.error("Video play failed:", err));
}
}
function handleSeek(time: number) {
const video = videoPlaybackRef.current?.video;
if (!video) return;
video.currentTime = time;
}
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
if (id) setSelectedTrimId(null);
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSelectAnnotation = useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
}
}, []);
const handleZoomAdded = useCallback(
(span: Span) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 },
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
},
[pushState],
);
const handleZoomSuggested = useCallback(
(span: Span, focus: ZoomFocus) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
},
[pushState],
);
const handleTrimAdded = useCallback(
(span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
const newRegion: TrimRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
};
pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] }));
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
},
[pushState],
);
const handleZoomSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
}));
},
[pushState],
);
const handleTrimSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
trimRegions: prev.trimRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
}));
},
[pushState],
);
// Focus drag: updateState for live preview, commitState on pointer-up
const handleZoomFocusChange = useCallback(
(id: string, focus: ZoomFocus) => {
updateState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region,
),
}));
},
[updateState],
);
const handleZoomDepthChange = useCallback(
(depth: ZoomDepth) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
: region,
),
}));
},
[selectedZoomId, pushState],
);
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
},
[selectedZoomId, pushState],
);
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
},
[selectedTrimId, pushState],
);
const handleSelectSpeed = useCallback((id: string | null) => {
setSelectedSpeedId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
}
}, []);
const handleSpeedAdded = useCallback(
(span: Span) => {
const id = `speed-${nextSpeedIdRef.current++}`;
const newRegion: SpeedRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
speed: DEFAULT_PLAYBACK_SPEED,
};
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
setSelectedSpeedId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
},
[pushState],
);
const handleSpeedSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
speedRegions: prev.speedRegions.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
},
[pushState],
);
const handleSpeedDelete = useCallback(
(id: string) => {
pushState((prev) => ({
speedRegions: prev.speedRegions.filter((region) => region.id !== id),
}));
if (selectedSpeedId === id) {
setSelectedSpeedId(null);
}
},
[selectedSpeedId, pushState],
);
const handleSpeedChange = useCallback(
(speed: PlaybackSpeed) => {
if (!selectedSpeedId) return;
pushState((prev) => ({
speedRegions: prev.speedRegions.map((region) =>
region.id === selectedSpeedId ? { ...region, speed } : region,
),
}));
},
[selectedSpeedId, pushState],
);
const handleAnnotationAdded = useCallback(
(span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: "text",
content: "Enter text...",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
},
[pushState],
);
const handleAnnotationSpanChange = useCallback(
(id: string, span: Span) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
}));
},
[pushState],
);
const handleAnnotationDelete = useCallback(
(id: string) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.filter((r) => r.id !== id),
}));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
},
[selectedAnnotationId, pushState],
);
const handleAnnotationContentChange = useCallback(
(id: string, content: string) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) => {
if (region.id !== id) return region;
if (region.type === "text") {
return { ...region, content, textContent: content };
} else if (region.type === "image") {
return { ...region, content, imageContent: content };
}
return { ...region, content };
}),
}));
},
[pushState],
);
const handleAnnotationTypeChange = useCallback(
(id: string, type: AnnotationRegion["type"]) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
if (type === "text") {
updatedRegion.content = region.textContent || "Enter text...";
} else if (type === "image") {
updatedRegion.content = region.imageContent || "";
} else if (type === "figure") {
updatedRegion.content = "";
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
return updatedRegion;
}),
}));
},
[pushState],
);
const handleAnnotationStyleChange = useCallback(
(id: string, style: Partial<AnnotationRegion["style"]>) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id ? { ...region, style: { ...region.style, ...style } } : region,
),
}));
},
[pushState],
);
const handleAnnotationFigureDataChange = useCallback(
(id: string, figureData: FigureData) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id ? { ...region, figureData } : region,
),
}));
},
[pushState],
);
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id ? { ...region, position } : region,
),
}));
},
[pushState],
);
const handleAnnotationSizeChange = useCallback(
(id: string, size: { width: number; height: number }) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id ? { ...region, size } : region,
),
}));
},
[pushState],
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
if (mod && key === "z" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
undo();
return;
}
if (mod && (key === "y" || (key === "z" && e.shiftKey))) {
e.preventDefault();
e.stopPropagation();
redo();
return;
}
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
if (e.key === "Tab" && !isInput) {
e.preventDefault();
}
if (matchesShortcut(e, shortcuts.playPause, isMac)) {
// Allow space only in inputs/textareas
if (isInput) {
return;
}
e.preventDefault();
const playback = videoPlaybackRef.current;
if (playback?.video) {
playback.video.paused ? playback.play().catch(console.error) : playback.pause();
}
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [undo, redo, shortcuts, isMac]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
}
}, [selectedZoomId, zoomRegions]);
useEffect(() => {
if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) {
setSelectedTrimId(null);
}
}, [selectedTrimId, trimRegions]);
useEffect(() => {
if (
selectedAnnotationId &&
!annotationRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId, annotationRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
setSelectedSpeedId(null);
}
}, [selectedSpeedId, speedRegions]);
const handleExport = useCallback(
async (settings: ExportSettings) => {
if (!videoPath) {
toast.error("No video loaded");
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error("Video not ready");
return;
}
setIsExporting(true);
setExportProgress(null);
setExportError(null);
try {
const wasPlaying = isPlaying;
if (wasPlaying) {
videoPlaybackRef.current?.pause();
}
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
// Get preview CONTAINER dimensions for scaling
const playbackRef = videoPlaybackRef.current;
const containerElement = playbackRef?.containerRef?.current;
const previewWidth = containerElement?.clientWidth || 1920;
const previewHeight = containerElement?.clientHeight || 1080;
if (settings.format === "gif" && settings.gifConfig) {
// GIF Export
const gifExporter = new GifExporter({
videoUrl: videoPath,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
loop: settings.gifConfig.loop,
sizePreset: settings.gifConfig.sizePreset,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
videoPadding: padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = gifExporter as unknown as VideoExporter;
const result = await gifExporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
toast.info("Export canceled");
} else if (saveResult.success) {
toast.success(`GIF exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || "Failed to save GIF");
toast.error(saveResult.message || "Failed to save GIF");
}
} else {
setExportError(result.error || "GIF export failed");
toast.error(result.error || "GIF export failed");
}
} else {
// MP4 Export
const quality = settings.quality || exportQuality;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (quality === "source") {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = quality === "medium" ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000;
} else {
bitrate = 30_000_000;
}
}
const exporter = new VideoExporter({
videoUrl: videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: "avc1.640033",
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
toast.info("Export canceled");
} else if (saveResult.success) {
toast.success(`Video exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || "Failed to save video");
toast.error(saveResult.message || "Failed to save video");
}
} else {
setExportError(result.error || "Export failed");
toast.error(result.error || "Export failed");
}
}
if (wasPlaying) {
videoPlaybackRef.current?.play();
}
} catch (error) {
console.error("Export error:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
setIsExporting(false);
exporterRef.current = null;
// Reset dialog state to ensure it can be opened again on next export
// This fixes the bug where second export doesn't show save dialog
setShowExportDialog(false);
setExportProgress(null);
}
},
[
videoPath,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
annotationRegions,
isPlaying,
aspectRatio,
exportQuality,
],
);
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
toast.error("No video loaded");
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error("Video not ready");
return;
}
// Build export settings from current state
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const gifDimensions = calculateOutputDimensions(
sourceWidth,
sourceHeight,
gifSizePreset,
GIF_SIZE_PRESETS,
);
const settings: ExportSettings = {
format: exportFormat,
quality: exportFormat === "mp4" ? exportQuality : undefined,
gifConfig:
exportFormat === "gif"
? {
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
}
: undefined,
};
setShowExportDialog(true);
setExportError(null);
// Start export immediately
handleExport(settings);
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
toast.info("Export canceled");
setShowExportDialog(false);
setIsExporting(false);
setExportProgress(null);
setExportError(null);
}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-foreground">Loading video...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<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>
);
}
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<div
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div className="flex-1" />
</div>
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
{/* Left Column - Video & Timeline */}
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} minSize={40}>
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<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:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
maxWidth: "100%",
margin: "0 auto",
boxSizing: "border-box",
}}
>
<VideoPlayback
key={videoPath || "no-video"}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
onZoomFocusDragEnd={commitState}
isPlaying={isPlaying}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurAmount={motionBlurAmount}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
/>
</div>
</div>
{/* Playback controls */}
<div
className="w-full flex justify-center items-center"
style={{
height: "48px",
flexShrink: 0,
padding: "6px 12px",
margin: "6px 0 6px 0",
}}
>
<div style={{ width: "100%", maxWidth: "700px" }}>
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</div>
</Panel>
<PanelResizeHandle className="h-3 bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full mx-4 flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Timeline section */}
<Panel defaultSize={30} minSize={20}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={cursorTelemetry}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
speedRegions={speedRegions}
onSpeedAdded={handleSpeedAdded}
onSpeedSpanChange={handleSpeedSpanChange}
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<SettingsPanel
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={(v) => updateState({ shadowIntensity: v })}
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
padding={padding}
onPaddingChange={(v) => updateState({ padding: v })}
onPaddingCommit={commitState}
cropRegion={cropRegion}
onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS,
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
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}
onSpeedDelete={handleSpeedDelete}
/>
</div>
<Toaster theme="dark" className="pointer-events-auto" />
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
progress={exportProgress}
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
exportFormat={exportFormat}
/>
</div>
);
}