2226 lines
67 KiB
TypeScript
2226 lines
67 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 {
|
||
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>
|
||
);
|
||
}
|