Files
openscreen/src/components/video-editor/VideoEditor.tsx
T

2372 lines
72 KiB
TypeScript

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 { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor";
import {
calculateEffectiveSourceDimensions,
calculateMp4ExportSettings,
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 { CursorCaptureMode, 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 type { NativePlatform } from "@/native/contracts";
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_CLIP_TO_BOUNDS,
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";
function isClickInteractionType(interactionType: string | null | undefined) {
return (
interactionType === "click" ||
interactionType === "double-click" ||
interactionType === "right-click" ||
interactionType === "middle-click"
);
}
interface ExportDiagnostics {
formatLabel: "GIF" | "Video";
reason?: string;
sourcePath?: string | null;
width?: number;
height?: number;
frameRate?: number;
codec?: string;
bitrate?: number;
}
function getFileNameForDiagnostics(filePath?: string | null) {
if (!filePath) return "unknown";
try {
const url = new URL(filePath);
if (url.protocol === "file:") {
return decodeURIComponent(url.pathname).split(/[\\/]/).pop() || filePath;
}
} catch {
// Treat non-URL values as filesystem paths.
}
return filePath.split(/[\\/]/).pop() || filePath;
}
function buildExportDiagnosticMessage(diagnostics: ExportDiagnostics) {
const details = [
diagnostics.reason ? `Reason: ${diagnostics.reason}` : null,
`Source: ${getFileNameForDiagnostics(diagnostics.sourcePath)}`,
diagnostics.width && diagnostics.height
? `Output: ${diagnostics.width}x${diagnostics.height}${
diagnostics.frameRate ? ` @ ${diagnostics.frameRate} fps` : ""
}`
: null,
diagnostics.codec ? `Codec: ${diagnostics.codec}` : null,
diagnostics.bitrate ? `Bitrate: ${Math.round(diagnostics.bitrate / 1_000_000)} Mbps` : null,
`VideoEncoder: ${"VideoEncoder" in window ? "available" : "unavailable"}`,
].filter(Boolean);
return `${diagnostics.formatLabel} export failed\n${details.join("\n")}`;
}
function buildSaveDiagnosticMessage(formatLabel: "GIF" | "Video", reason?: string) {
return `${formatLabel} export save failed${reason ? `\nReason: ${reason}` : ""}`;
}
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,
} = 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 [isPreviewingZoom, setIsPreviewingZoom] = useState(false);
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);
const playerContainerRef = useRef<HTMLDivElement | null>(null);
const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
const { samples: cursorTelemetry, error: cursorTelemetryError } =
useCursorTelemetry(cursorTelemetrySourcePath);
const { data: cursorRecordingData, error: cursorRecordingDataError } =
useCursorRecordingData(cursorTelemetrySourcePath);
const cursorClickTimestamps = useMemo<number[]>(() => {
const recordingClicks =
cursorRecordingData?.samples
.filter((sample) => isClickInteractionType(sample.interactionType))
.map((sample) => sample.timeMs) ?? [];
if (recordingClicks.length > 0) {
return recordingClicks;
}
return cursorTelemetry
.filter((sample) => isClickInteractionType(sample.interactionType))
.map((sample) => sample.timeMs);
}, [cursorRecordingData, cursorTelemetry]);
// 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 [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS);
const [nativePlatform, setNativePlatform] = useState<NativePlatform | null>(null);
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
useState<CursorCaptureMode | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
// Native Windows recordings include captured cursor assets. Native macOS
// recordings hide the system cursor in ScreenCaptureKit and use telemetry
// samples with OpenScreen's default arrow asset for the editable overlay.
const hasEditableCursorRecording =
recordingCursorCaptureMode === "editable-overlay" &&
(nativePlatform === "win32" || nativePlatform === "darwin") &&
hasNativeCursorRecordingData(cursorRecordingData);
const effectiveShowCursor = showCursor && hasEditableCursorRecording;
const showCursorSettings = hasEditableCursorRecording;
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 {
screenVideoPath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(recordingCursorCaptureMode ? { cursorCaptureMode: recordingCursorCaptureMode } : {}),
};
}, [
videoPath,
videoSourcePath,
webcamVideoPath,
webcamVideoSourcePath,
recordingCursorCaptureMode,
]);
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 projectCursorCaptureMode = projectMedia.cursorCaptureMode ?? 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);
setRecordingCursorCaptureMode(projectCursorCaptureMode);
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(
{
screenVideoPath: sourcePath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}),
},
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);
setRecordingCursorCaptureMode(session.cursorCaptureMode ?? null);
setCurrentProjectPath(null);
setLastSavedSnapshot(
createProjectSnapshot(
{
screenVideoPath: sourcePath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(session.cursorCaptureMode
? { cursorCaptureMode: session.cursorCaptureMode }
: {}),
},
INITIAL_EDITOR_STATE,
),
);
return;
}
const result = await nativeBridgeClient.project.getCurrentVideoPath();
if (result.success && result.path) {
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setRecordingCursorCaptureMode(null);
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,
};
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,
],
);
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(() => {
let canceled = false;
nativeBridgeClient.system
.getPlatform()
.then((platform) => {
if (!canceled) {
setNativePlatform(platform);
}
})
.catch((error) => {
console.warn("Unable to resolve native platform for cursor settings:", error);
if (!canceled) {
setNativePlatform(null);
}
});
return () => {
canceled = true;
};
}, []);
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),
};
// Bulk suggest must not steal selection — keeping a zoom selected hides
// the export panel (SettingsPanel gates it on !hasTimelineSelection),
// trapping users who just want to export after auto-zoom.
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
},
[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(
buildSaveDiagnosticMessage(
unsavedExport.format === "gif" ? "GIF" : "Video",
saveResult.message || "Failed to save export",
),
);
}
} catch (error) {
console.error("Error saving unsaved export:", error);
toast.error(
buildSaveDiagnosticMessage(
unsavedExport.format === "gif" ? "GIF" : "Video",
error instanceof Error ? error.message : "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 effectiveSourceDimensions = calculateEffectiveSourceDimensions(
sourceWidth,
sourceHeight,
cropRegion,
);
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: effectiveShowCursor ? cursorSize : 0,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
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" });
const message = buildSaveDiagnosticMessage(
"GIF",
saveResult.message || "Failed to save GIF",
);
setExportError(message);
toast.error(message);
}
} else {
const message = buildExportDiagnosticMessage({
formatLabel: "GIF",
reason: result.error || "GIF export failed",
sourcePath: videoSourcePath ?? videoPath,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
});
setExportError(message);
toast.error(message);
}
} else {
// MP4 Export
const quality = settings.quality || exportQuality;
const {
width: exportWidth,
height: exportHeight,
bitrate,
} = calculateMp4ExportSettings({
quality,
sourceWidth: effectiveSourceDimensions.width,
sourceHeight: effectiveSourceDimensions.height,
aspectRatioValue,
});
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: effectiveShowCursor ? cursorSize : 0,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
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" });
const message = buildSaveDiagnosticMessage(
"Video",
saveResult.message || "Failed to save video",
);
setExportError(message);
toast.error(message);
}
} else {
const message = buildExportDiagnosticMessage({
formatLabel: "Video",
reason: result.error || "Export failed",
sourcePath: videoSourcePath ?? videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
codec: "avc1.640033",
bitrate,
});
setExportError(message);
toast.error(message);
}
}
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";
const message = buildExportDiagnosticMessage({
formatLabel: settings.format === "gif" ? "GIF" : "Video",
reason: errorMessage,
sourcePath: videoSourcePath ?? videoPath,
});
setExportError(message);
toast.error(t("errors.exportFailedWithError", { error: message }));
}
} 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,
videoSourcePath,
webcamVideoPath,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
cursorRecordingData,
annotationRegions,
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
cursorClickTimestamps,
effectiveShowCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
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 effectiveSourceDimensions = calculateEffectiveSourceDimensions(
sourceWidth,
sourceHeight,
cropRegion,
);
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
const gifDimensions = calculateOutputDimensions(
effectiveSourceDimensions.width,
effectiveSourceDimensions.height,
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}
cursorRecordingData={cursorRecordingData}
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}
cursorClickTimestamps={cursorClickTimestamps}
showCursor={effectiveShowCursor}
cursorSize={cursorSize}
cursorSmoothing={cursorSmoothing}
cursorMotionBlur={cursorMotionBlur}
cursorClickBounce={cursorClickBounce}
cursorClipToBounds={cursorClipToBounds}
isPreviewingZoom={isPreviewingZoom}
/>
</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
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}
onZoomPreviewStart={() => setIsPreviewingZoom(true)}
onZoomPreviewEnd={() => setIsPreviewingZoom(false)}
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(
calculateEffectiveSourceDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
).width,
calculateEffectiveSourceDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
).height,
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}
showCursor={showCursor}
onShowCursorChange={setShowCursor}
cursorSize={cursorSize}
onCursorSizeChange={setCursorSize}
cursorSmoothing={cursorSmoothing}
onCursorSmoothingChange={setCursorSmoothing}
cursorMotionBlur={cursorMotionBlur}
onCursorMotionBlurChange={setCursorMotionBlur}
cursorClickBounce={cursorClickBounce}
onCursorClickBounceChange={setCursorClickBounce}
cursorClipToBounds={cursorClipToBounds}
onCursorClipToBoundsChange={setCursorClipToBounds}
hasCursorData={
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
}
showCursorSettings={showCursorSettings}
/>
</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>
);
}