Files
openscreen/src/components/video-editor/VideoEditor.tsx
T
2026-05-10 15:11:03 +02:00

2226 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages, Save, Video } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
import { type Locale } from "@/i18n/config";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import {
calculateOutputDimensions,
type ExportFormat,
type ExportProgress,
type ExportQuality,
type ExportSettings,
GIF_SIZE_PRESETS,
GifExporter,
type GifFrameRate,
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
getExportFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import { nativeBridgeClient, useCursorRecordingData, useCursorTelemetry } from "@/native";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
isPortraitAspectRatio,
} from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
createProjectSnapshot,
deriveNextId,
fromFileUrl,
hasProjectUnsavedChanges,
normalizeProjectEditor,
resolveProjectMedia,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
type BlurData,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_DATA,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
type FigureData,
type PlaybackSpeed,
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
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,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
cursorHighlight,
} = editorState;
// ── Non-undoable state
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [webcamVideoPath, setWebcamVideoPath] = useState<string | null>(null);
const [webcamVideoSourcePath, setWebcamVideoSourcePath] = 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 currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const durationRef = useRef(duration);
durationRef.current = duration;
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 [selectedBlurId, setSelectedBlurId] = 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 [showNewRecordingDialog, setShowNewRecordingDialog] = 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 [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const [unsavedExport, setUnsavedExport] = useState<{
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
// Cursor & motion blur visual settings (non-undoable preferences)
const [showCursor, setShowCursor] = useState(true);
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
// renderers while keeping the persisted value intact for round-tripping.
const effectiveCursorHighlight = useMemo(
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
[cursorHighlight, isMac],
);
const { locale, setLocale, t: rawT } = useI18n();
const t = useScopedT("editor");
const ts = useScopedT("settings");
const availableLocales = getAvailableLocales();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const annotationOnlyRegions = useMemo(
() => annotationRegions.filter((region) => region.type !== "blur"),
[annotationRegions],
);
const blurRegions = useMemo(
() => annotationRegions.filter((region) => region.type === "blur"),
[annotationRegions],
);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
return null;
}
const webcamSourcePath =
webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null);
return webcamSourcePath
? { screenVideoPath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath };
}, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]);
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
return false;
}
const project = candidate;
const projectMedia = resolveProjectMedia(project);
if (!projectMedia) {
return false;
}
const sourcePath = projectMedia.screenVideoPath;
const webcamSourcePath = projectMedia.webcamVideoPath ?? null;
const normalizedEditor = normalizeProjectEditor(project.editor);
const inferredDurationMs = Math.max(
0,
...normalizedEditor.zoomRegions.map((region) => region.endMs),
...normalizedEditor.trimRegions.map((region) => region.endMs),
...normalizedEditor.speedRegions.map((region) => region.endMs),
...normalizedEditor.annotationRegions.map((region) => region.endMs),
);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
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,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(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(
createProjectSnapshot(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
normalizedEditor,
),
);
return true;
},
[pushState],
);
const currentProjectSnapshot = useMemo(() => {
if (!currentProjectMedia) {
return null;
}
return createProjectSnapshot(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
}, [
currentProjectMedia,
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
]);
const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
useEffect(() => {
async function loadInitialData() {
try {
const currentProjectResult = await nativeBridgeClient.project.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await applyLoadedProject(
currentProjectResult.project,
currentProjectResult.path ?? null,
);
if (restored) {
return;
}
}
const currentSessionResult = await window.electronAPI.getCurrentRecordingSession();
if (currentSessionResult.success && currentSessionResult.session) {
const session = currentSessionResult.session;
const sourcePath = fromFileUrl(session.screenVideoPath);
const webcamSourcePath = session.webcamVideoPath
? fromFileUrl(session.webcamVideoPath)
: null;
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(null);
setLastSavedSnapshot(
createProjectSnapshot(
webcamSourcePath
? {
screenVideoPath: sourcePath,
webcamVideoPath: webcamSourcePath,
}
: { screenVideoPath: sourcePath },
INITIAL_EDITOR_STATE,
),
);
return;
}
const result = await nativeBridgeClient.project.getCurrentVideoPath();
if (result.success && result.path) {
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setCurrentProjectPath(null);
setLastSavedSnapshot(
createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE),
);
} 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]);
// Track whether user preferences have been loaded to avoid
// overwriting saved prefs with defaults on the first render
const [prefsHydrated, setPrefsHydrated] = useState(false);
// Load persisted user preferences on mount (intentionally runs once)
useEffect(() => {
const prefs = loadUserPreferences();
updateState({
padding: prefs.padding,
aspectRatio: prefs.aspectRatio,
});
setExportQuality(prefs.exportQuality);
setExportFormat(prefs.exportFormat);
setPrefsHydrated(true);
}, [updateState]);
// Auto-save user preferences when settings change
useEffect(() => {
if (!prefsHydrated) return;
saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
}, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error(t("errors.noVideoLoaded"));
return false;
}
if (!currentProjectMedia) {
toast.error(t("errors.unableToDetermineSourcePath"));
return false;
}
const editorState = {
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
cursorHighlight,
};
const projectData = createProjectData(currentProjectMedia, editorState);
const fileNameBase =
currentProjectMedia.screenVideoPath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
// Match the normalization path used by `currentProjectSnapshot` so the
// post-save baseline compares equal and `hasUnsavedChanges` clears.
const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
const result = await nativeBridgeClient.project.saveProjectFile(
projectData,
fileNameBase,
forceSaveAs ? undefined : (currentProjectPath ?? undefined),
);
if (result.canceled) {
toast.info(t("project.saveCanceled"));
return false;
}
if (!result.success) {
toast.error(result.message || t("project.failedToSave"));
return false;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(projectSnapshot);
toast.success(t("project.savedTo", { path: result.path ?? "" }));
return true;
},
[
currentProjectMedia,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
videoPath,
t,
webcamSizePreset,
cursorHighlight,
],
);
useEffect(() => {
window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges]);
useEffect(() => {
const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => {
return saveProject(false);
});
return () => cleanup();
}, [saveProject]);
useEffect(() => {
const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
setShowCloseConfirmDialog(true);
});
return () => cleanup();
}, []);
const handleCloseConfirmSave = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("save");
}, []);
const handleCloseConfirmDiscard = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("discard");
}, []);
const handleCloseConfirmCancel = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("cancel");
}, []);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
await saveProject(true);
}, [saveProject]);
const handleNewRecordingConfirm = useCallback(async () => {
const result = await window.electronAPI.startNewRecording();
if (result.success) {
setShowNewRecordingDialog(false);
} else {
console.error("Failed to start new recording:", result.error);
setError("Failed to start new recording: " + (result.error || "Unknown error"));
}
}, []);
const handleLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || t("project.failedToLoad"));
return;
}
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error(t("project.invalidFormat"));
return;
}
toast.success(t("project.loadedFrom", { path: result.path ?? "" }));
}, [applyLoadedProject, t]);
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(() => {
if (cursorTelemetryError) {
console.warn("Unable to load cursor telemetry:", cursorTelemetryError);
}
}, [cursorTelemetryError]);
useEffect(() => {
if (cursorRecordingDataError) {
console.warn("Unable to load cursor recording data:", cursorRecordingDataError);
}
}, [cursorRecordingDataError]);
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));
}
}
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
useEffect(() => {
if (!isFullscreen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isFullscreen]);
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);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
setSelectedTrimId(id);
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
const handleSelectAnnotation = useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedBlurId(null);
}
}, []);
const handleSelectBlur = useCallback((id: string | null) => {
setSelectedBlurId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedSpeedId(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,
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: { cx: 0.5, cy: 0.5 },
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(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,
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(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);
setSelectedBlurId(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,
customScale: ZOOM_DEPTH_SCALES[depth],
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
}));
},
[selectedZoomId, pushState],
);
const handleZoomCustomScaleChange = useCallback(
(scale: number) => {
if (!selectedZoomId) return;
const rounded = Math.round(scale * 100) / 100;
if (!Number.isFinite(rounded)) return;
updateState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, customScale: rounded } : region,
),
}));
},
[selectedZoomId, updateState],
);
const handleZoomCustomScaleCommit = useCallback(() => {
commitState();
}, [commitState]);
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, focusMode } : 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 handleZoomRotationPresetChange = useCallback(
(preset: Rotation3DPreset | null) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) => {
if (region.id !== selectedZoomId) return region;
if (preset === null) {
const { rotationPreset: _p, ...rest } = region;
return rest;
}
return { ...region, rotationPreset: preset };
}),
}));
},
[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);
setSelectedBlurId(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);
setSelectedBlurId(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);
setSelectedBlurId(null);
},
[pushState],
);
const handleBlurAdded = 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: "blur",
content: "",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
blurData: { ...DEFAULT_BLUR_DATA },
};
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
setSelectedBlurId(id);
setSelectedAnnotationId(null);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(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 handleAnnotationDuplicate = useCallback(
(id: string) => {
const duplicateId = `annotation-${nextAnnotationIdRef.current++}`;
const duplicateZIndex = nextAnnotationZIndexRef.current++;
pushState((prev) => {
const source = prev.annotationRegions.find((region) => region.id === id);
if (!source) return {};
const duplicate: AnnotationRegion = {
...source,
id: duplicateId,
zIndex: duplicateZIndex,
position: { x: source.position.x + 4, y: source.position.y + 4 },
size: { ...source.size },
style: { ...source.style },
figureData: source.figureData ? { ...source.figureData } : undefined,
};
return { annotationRegions: [...prev.annotationRegions, duplicate] };
});
setSelectedAnnotationId(duplicateId);
setSelectedZoomId(null);
setSelectedTrimId(null);
},
[pushState],
);
const handleAnnotationDelete = useCallback(
(id: string) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.filter((r) => r.id !== id),
}));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
if (selectedBlurId === id) {
setSelectedBlurId(null);
}
},
[selectedAnnotationId, selectedBlurId, 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 };
}
} else if (type === "blur") {
updatedRegion.content = "";
if (!region.blurData) {
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
}
}
return updatedRegion;
}),
}));
if (type === "blur" && selectedAnnotationId === id) {
setSelectedAnnotationId(null);
setSelectedBlurId(id);
setSelectedSpeedId(null);
} else if (type !== "blur" && selectedBlurId === id) {
setSelectedBlurId(null);
setSelectedAnnotationId(id);
}
},
[pushState, selectedAnnotationId, selectedBlurId],
);
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 handleBlurDataPreviewChange = useCallback(
(id: string, blurData: BlurData) => {
updateState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? {
...region,
blurData,
// Freehand drawing area is the full video surface.
...(blurData.shape === "freehand"
? {
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
}
: {}),
}
: region,
),
}));
},
[updateState],
);
const handleBlurDataPanelChange = useCallback(
(id: string, blurData: BlurData) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? {
...region,
blurData,
...(blurData.shape === "freehand"
? {
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
}
: {}),
}
: 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;
}
// Frame-step navigation (arrow keys, no modifiers)
if (
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
!e.ctrlKey &&
!e.metaKey &&
!e.shiftKey &&
!e.altKey
) {
const target = e.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement &&
(target.isContentEditable ||
target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
) {
return;
}
e.preventDefault();
const video = videoPlaybackRef.current?.video;
if (!video) {
return;
}
const direction = e.key === "ArrowLeft" ? "backward" : "forward";
const newTime = computeFrameStepTime(
video.currentTime,
Number.isFinite(video.duration) ? video.duration : durationRef.current,
direction,
);
video.currentTime = newTime;
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 &&
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
setSelectedBlurId(null);
}
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
setSelectedSpeedId(null);
}
}, [selectedSpeedId, speedRegions]);
const handleShowExportedFile = useCallback(async (filePath: string) => {
try {
const result = await window.electronAPI.revealInFolder(filePath);
if (!result.success) {
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
console.error("Failed to reveal in folder:", errorMessage);
toast.error(errorMessage);
}
} catch (error) {
const errorMessage = String(error);
console.error("Error calling revealInFolder IPC:", errorMessage);
toast.error(`Error revealing in folder: ${errorMessage}`);
}
}, []);
const handleExportSaved = useCallback(
(formatLabel: "GIF" | "Video", filePath: string) => {
setExportedFilePath(filePath);
const folder = parentDirectoryOf(filePath);
if (folder) {
saveUserPreferences({ exportFolder: folder });
}
toast.success(
t("export.exportedSuccessfully", {
format: formatLabel,
}),
{
description: filePath,
action: {
label: rawT("common.actions.showInFolder"),
onClick: () => {
void handleShowExportedFile(filePath);
},
},
},
);
},
[handleShowExportedFile, t, rawT],
);
const handleSaveUnsavedExport = useCallback(async () => {
if (!unsavedExport) return;
try {
const pickResult = await window.electronAPI.pickExportSavePath(
unsavedExport.fileName,
getExportFolder(),
);
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
toast.info("Export canceled");
return;
}
const saveResult = await window.electronAPI.writeExportToPath(
unsavedExport.arrayBuffer,
pickResult.path,
);
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
toast.error(saveResult.message || "Failed to save export");
}
} catch (error) {
console.error("Error saving unsaved export:", error);
toast.error("Failed to save exported video");
}
}, [unsavedExport, handleExportSaved]);
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;
}
// Ask the user where to save BEFORE starting the export. This avoids the
// post-export save dialog getting hidden behind other windows after a
// long-running export.
const isGifFormat = settings.format === "gif";
const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`;
const pickResult = await window.electronAPI.pickExportSavePath(
targetFileName,
getExportFolder(),
);
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
setShowExportDialog(false);
return;
}
const targetPath = pickResult.path;
setIsExporting(true);
setExportProgress(null);
setExportError(null);
setExportedFilePath(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,
webcamVideoUrl: webcamVideoPath || undefined,
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,
cursorRecordingData,
cursorScale: showCursor ? cursorSize : 0,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
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();
if (result.warnings) {
for (const warning of result.warnings) {
toast.warning(warning);
}
}
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("GIF", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
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") {
exportWidth = sourceWidth;
exportHeight = sourceHeight;
// Use the source's longer dimension as the long axis of the export so
// a landscape recording can still fill a portrait target (and vice versa).
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
if (aspectRatioValue === 1) {
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
const baseWidth = Math.floor(sourceLongDim / 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 {
const baseHeight = Math.floor(sourceLongDim / 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;
}
}
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 {
// Quality presets target the SHORT side; the long side derives from the
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
const targetShortDim = quality === "medium" ? 720 : 1080;
if (aspectRatioValue >= 1) {
exportHeight = Math.floor(targetShortDim / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
} else {
exportWidth = Math.floor(targetShortDim / 2) * 2;
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
}
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,
webcamVideoUrl: webcamVideoPath || undefined,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: "avc1.640033",
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
cursorRecordingData,
cursorScale: showCursor ? cursorSize : 0,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
if (result.warnings) {
for (const warning of result.warnings) {
toast.warning(warning);
}
}
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("Video", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
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);
if (error instanceof BackgroundLoadError) {
const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl });
setExportError(message);
toast.error(message);
} else {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(t("errors.exportFailedWithError", { error: 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,
webcamVideoPath,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
cursorRecordingData,
annotationRegions,
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
cursorClickTimestamps,
effectiveCursorHighlight,
t,
],
);
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 aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
const gifDimensions = calculateOutputDimensions(
sourceWidth,
sourceHeight,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatioValue,
);
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);
setExportedFilePath(null);
// Start export immediately
handleExport(settings);
}, [
videoPath,
exportFormat,
exportQuality,
gifFrameRate,
gifLoop,
gifSizePreset,
aspectRatio,
cropRegion,
handleExport,
]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
toast.info("Export canceled");
setShowExportDialog(false);
setIsExporting(false);
setExportProgress(null);
setExportError(null);
setExportedFilePath(null);
}
}, []);
const handleSaveDiagnostic = useCallback(async () => {
const result = await window.electronAPI.saveDiagnostic({
error: exportError ?? "Manual diagnostic export",
projectState: editorState,
logs: [],
});
if (result.success) {
toast.success("Diagnostic file saved");
} else if (!result.canceled) {
toast.error("Failed to save diagnostic file");
}
}, [exportError, editorState]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-foreground">{t("loadingVideo")}</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"
>
{ts("project.load")}
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
<DialogContent
className="sm:max-w-[425px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<DialogHeader>
<DialogTitle>{t("newRecording.title")}</DialogTitle>
<DialogDescription>{t("newRecording.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={() => setShowNewRecordingDialog(false)}
className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
>
{t("newRecording.cancel")}
</button>
<button
type="button"
onClick={handleNewRecordingConfirm}
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
>
{t("newRecording.confirm")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
className="h-11 flex-shrink-0 bg-[#070809]/85 backdrop-blur-xl border-b border-white/[0.07] flex items-center justify-between px-5 z-50 shadow-[0_1px_0_rgba(255,255,255,0.03)]"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex-1 flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{availableLocales.map((loc) => (
<option key={loc} value={loc} className="bg-[#09090b] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => setShowNewRecordingDialog(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<Video size={14} />
{t("newRecording.title")}
</button>
<button
type="button"
onClick={handleLoadProject}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<FolderOpen size={14} />
{ts("project.load")}
</button>
<button
type="button"
onClick={handleSaveProject}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<Save size={14} />
{ts("project.save")}
</button>
</div>
</div>
<div className="editor-workspace flex-1 min-h-0 relative">
<PanelGroup direction="vertical" className="gap-3 min-h-0">
{/* Top section: preview and contextual settings */}
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
<div className="editor-main-deck h-full min-h-0">
<div className="editor-preview-zone min-w-0 h-full">
<div
ref={playerContainerRef}
className={
isFullscreen
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
}
>
{/* Video preview */}
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
<div
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
style={{
aspectRatio:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
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={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
blurRegions={blurRegions}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onBlurPositionChange={handleAnnotationPositionChange}
onBlurSizeChange={handleAnnotationSizeChange}
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
cursorHighlight={effectiveCursorHighlight}
cursorClickTimestamps={cursorClickTimestamps}
/>
</div>
</div>
{/* Playback controls */}
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
<div className="w-full max-w-[760px]">
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</div>
</div>
<div className="editor-settings-rail min-w-0 h-full">
<SettingsPanel
cursorHighlight={cursorHighlight}
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
cursorHighlightSupportsClicks={isMac}
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomCustomScale={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
: null
}
onZoomCustomScaleChange={handleZoomCustomScaleChange}
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) =>
selectedZoomId && handleZoomFocusModeChange(mode)
}
selectedZoomFocus={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
: null
}
onZoomFocusCoordinateChange={(focus) =>
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
}
onZoomFocusCoordinateCommit={commitState}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedZoomRotationPreset={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
: null
}
onZoomRotationPresetChange={handleZoomRotationPresetChange}
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}
hasWebcam={Boolean(webcamVideoPath)}
webcamLayoutPreset={webcamLayoutPreset}
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
})
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
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,
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDuplicate={handleAnnotationDuplicate}
onAnnotationDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
blurRegions={blurRegions}
onBlurDataChange={handleBlurDataPanelChange}
onBlurDataCommit={commitState}
onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
onSaveDiagnostic={handleSaveDiagnostic}
/>
</div>
</div>
</Panel>
<PanelResizeHandle className="editor-resize-handle group">
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
</PanelResizeHandle>
{/* Full-width timeline */}
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
<div className="editor-timeline-panel h-full 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={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
blurRegions={blurRegions}
onBlurAdded={handleBlurAdded}
onBlurSpanChange={handleAnnotationSpanChange}
onBlurDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
? "picture-in-picture"
: webcamLayoutPreset,
})
}
/>
</div>
</Panel>
</PanelGroup>
</div>
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
progress={exportProgress}
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
exportFormat={exportFormat}
exportedFilePath={exportedFilePath || undefined}
onShowInFolder={
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
}
/>
<UnsavedChangesDialog
isOpen={showCloseConfirmDialog}
onSaveAndClose={handleCloseConfirmSave}
onDiscardAndClose={handleCloseConfirmDiscard}
onCancel={handleCloseConfirmCancel}
/>
</div>
);
}