From e8a8eae2d294ee6d409af063fb4c42818b9daf71 Mon Sep 17 00:00:00 2001
From: webadderall <131426131+webadderall@users.noreply.github.com>
Date: Mon, 23 Mar 2026 19:22:39 +1100
Subject: [PATCH] feat(editor): add project browser and whisper selection flows
---
electron/electron-env.d.ts | 33 +
electron/preload.ts | 27 +-
.../video-editor/ProjectBrowserDialog.tsx | 187 ++++
src/components/video-editor/SettingsPanel.tsx | 738 ++++++++++---
src/components/video-editor/VideoEditor.tsx | 995 +++++++++++-------
.../video-editor/autoCaptionSource.ts | 28 +
6 files changed, 1525 insertions(+), 483 deletions(-)
create mode 100644 src/components/video-editor/ProjectBrowserDialog.tsx
create mode 100644 src/components/video-editor/autoCaptionSource.ts
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 2b723af7..20cc1469 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -197,6 +197,7 @@ interface Window {
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
+ thumbnailDataUrl?: string | null,
) => Promise<{
success: boolean;
path?: string;
@@ -220,6 +221,38 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
+ getProjectsDirectory: () => Promise<{
+ success: boolean;
+ path?: string;
+ error?: string;
+ }>;
+ listProjectFiles: () => Promise<{
+ success: boolean;
+ projectsDir?: string | null;
+ entries: Array<{
+ path: string;
+ name: string;
+ updatedAt: number;
+ thumbnailPath: string | null;
+ isCurrent: boolean;
+ isInProjectsDirectory: boolean;
+ }>;
+ error?: string;
+ }>;
+ openProjectFileAtPath: (filePath: string) => Promise<{
+ success: boolean;
+ path?: string;
+ project?: unknown;
+ message?: string;
+ canceled?: boolean;
+ error?: string;
+ }>;
+ openProjectsDirectory: () => Promise<{
+ success: boolean;
+ path?: string;
+ message?: string;
+ error?: string;
+ }>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
diff --git a/electron/preload.ts b/electron/preload.ts
index bdf00e46..43f141b7 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -209,8 +209,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
deleteRecordingFile: (filePath: string) => {
return ipcRenderer.invoke("delete-recording-file", filePath);
},
- saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
- return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
+ saveProjectFile: (
+ projectData: unknown,
+ suggestedName?: string,
+ existingProjectPath?: string,
+ thumbnailDataUrl?: string | null,
+ ) => {
+ return ipcRenderer.invoke(
+ "save-project-file",
+ projectData,
+ suggestedName,
+ existingProjectPath,
+ thumbnailDataUrl,
+ );
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
@@ -218,6 +229,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
},
+ getProjectsDirectory: () => {
+ return ipcRenderer.invoke("get-projects-directory");
+ },
+ listProjectFiles: () => {
+ return ipcRenderer.invoke("list-project-files");
+ },
+ openProjectFileAtPath: (filePath: string) => {
+ return ipcRenderer.invoke("open-project-file-at-path", filePath);
+ },
+ openProjectsDirectory: () => {
+ return ipcRenderer.invoke("open-projects-directory");
+ },
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-load-project", listener);
diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx
new file mode 100644
index 00000000..37a82d45
--- /dev/null
+++ b/src/components/video-editor/ProjectBrowserDialog.tsx
@@ -0,0 +1,187 @@
+import { FolderOpen, Save } from "lucide-react";
+import { useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { toFileUrl } from "./projectPersistence";
+
+export type ProjectLibraryEntry = {
+ path: string;
+ name: string;
+ updatedAt: number;
+ thumbnailPath: string | null;
+ isCurrent: boolean;
+ isInProjectsDirectory: boolean;
+};
+
+type ProjectBrowserDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ entries: ProjectLibraryEntry[];
+ projectsDirectoryPath: string | null;
+ onOpenProject: (projectPath: string) => void;
+ onBrowseProjectFiles: () => void;
+ onOpenProjectsFolder: () => void;
+ onSaveProjectAs: () => void;
+};
+
+function formatUpdatedAt(updatedAt: number) {
+ try {
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(updatedAt);
+ } catch {
+ return new Date(updatedAt).toLocaleString();
+ }
+}
+
+export default function ProjectBrowserDialog({
+ open,
+ onOpenChange,
+ entries,
+ projectsDirectoryPath,
+ onOpenProject,
+ onBrowseProjectFiles,
+ onOpenProjectsFolder,
+ onSaveProjectAs,
+}: ProjectBrowserDialogProps) {
+ const visibleEntries = useMemo(() => entries.slice(0, 16), [entries]);
+
+ return (
+
+
+
+ Projects
+
+ Open recent Recordly projects from one place. New projects are saved to the dedicated
+ projects folder by default.
+
+
+
+
+
+ Projects Folder
+
+
+ {projectsDirectoryPath ?? "Loading projects folder..."}
+
+
+
+
+
+ Open Folder
+
+
+
+ Browse Files
+
+
+
+ Save As
+
+
+
+
+ {visibleEntries.length > 0 ? (
+
+ {visibleEntries.map((entry) => {
+ const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null;
+ return (
+
onOpenProject(entry.path)}
+ className="group flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] text-left transition hover:border-[#2563EB]/60 hover:bg-white/[0.05]"
+ >
+
+ {thumbnailSrc ? (
+
+ ) : (
+
+ No preview yet
+
+ )}
+
+
+ {entry.isInProjectsDirectory ? "Library" : "Recent"}
+
+ {entry.isCurrent ? (
+
+ Current
+
+ ) : null}
+
+
+
+
+ {entry.name}
+
+
{entry.path}
+
+ Updated {formatUpdatedAt(entry.updatedAt)}
+
+
+
+ );
+ })}
+
+ ) : (
+
+
No saved projects yet
+
+ Save a project and Recordly will keep it in the projects folder with a preview
+ thumbnail so it is easy to reopen later.
+
+
+
+
+ Save Project
+
+
+
+ Browse Existing
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index be5806f8..aad84702 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,13 +1,5 @@
-import { LayoutGroup } from "motion/react";
-import minimalCursorUrl from "../../../Minimal Cursor.svg";
-import tahoeCursorUrl from "../../assets/cursors/Cursor=Default.svg";
-import {
- Palette,
- Trash2,
- Upload,
- X,
-} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
+import { Palette, Trash2, Upload, X } from "lucide-react";
+import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -22,30 +14,40 @@ import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { cn } from "@/lib/utils";
-import { BUILT_IN_WALLPAPERS, getAvailableWallpapers } from "@/lib/wallpapers";
import type { BuiltInWallpaper } from "@/lib/wallpapers";
+import { BUILT_IN_WALLPAPERS, getAvailableWallpapers } from "@/lib/wallpapers";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
+import minimalCursorUrl from "../../../Minimal Cursor.svg";
+import tahoeCursorUrl from "../../assets/cursors/Cursor=Default.svg";
import { useI18n, useScopedT } from "../../contexts/I18nContext";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { SliderControl } from "./SliderControl";
import type {
+ AnnotationRegion,
+ AnnotationType,
AutoCaptionAnimation,
AutoCaptionSettings,
CaptionCue,
- AnnotationRegion,
- AnnotationType,
CropRegion,
CursorStyle,
FigureData,
PlaybackSpeed,
- WebcamPositionPreset,
WebcamOverlaySettings,
+ WebcamPositionPreset,
ZoomDepth,
ZoomTransitionEasing,
} from "./types";
import {
DEFAULT_AUTO_CAPTION_SETTINGS,
+ DEFAULT_CROP_REGION,
+ DEFAULT_CURSOR_CLICK_BOUNCE,
+ DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
+ DEFAULT_CURSOR_MOTION_BLUR,
+ DEFAULT_CURSOR_SIZE,
+ DEFAULT_CURSOR_SMOOTHING,
+ DEFAULT_CURSOR_STYLE,
+ DEFAULT_CURSOR_SWAY,
DEFAULT_WEBCAM_CORNER_RADIUS,
DEFAULT_WEBCAM_MARGIN,
DEFAULT_WEBCAM_POSITION_PRESET,
@@ -54,20 +56,15 @@ import {
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_SHADOW,
DEFAULT_WEBCAM_SIZE,
- DEFAULT_CROP_REGION,
- DEFAULT_CURSOR_CLICK_BOUNCE,
- DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
- DEFAULT_CURSOR_MOTION_BLUR,
- DEFAULT_CURSOR_SIZE,
- DEFAULT_CURSOR_STYLE,
- DEFAULT_CURSOR_SMOOTHING,
- DEFAULT_CURSOR_SWAY,
DEFAULT_ZOOM_MOTION_BLUR,
SPEED_OPTIONS,
} from "./types";
-import { getWebcamPositionForPreset, resolveWebcamCorner } from "./webcamOverlay";
import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway";
-import { uploadedCursorAssets, UPLOADED_CURSOR_SAMPLE_SIZE } from "./videoPlayback/uploadedCursorAssets";
+import {
+ UPLOADED_CURSOR_SAMPLE_SIZE,
+ uploadedCursorAssets,
+} from "./videoPlayback/uploadedCursorAssets";
+import { getWebcamPositionForPreset, resolveWebcamCorner } from "./webcamOverlay";
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
@@ -104,7 +101,14 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str
];
type BackgroundTab = "image" | "color" | "gradient";
-export type EditorEffectSection = "scene" | "cursor" | "captions" | "webcam" | "zoom" | "frame" | "crop";
+export type EditorEffectSection =
+ | "scene"
+ | "cursor"
+ | "captions"
+ | "webcam"
+ | "zoom"
+ | "frame"
+ | "crop";
function isHexWallpaper(value: string): boolean {
return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value);
@@ -124,9 +128,7 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab {
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
+ {children}
);
}
@@ -211,6 +213,7 @@ interface SettingsPanelProps {
isGeneratingCaptions?: boolean;
onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void;
onPickWhisperExecutable?: () => void;
+ onPickWhisperModel?: () => void;
onGenerateAutoCaptions?: () => void;
onClearAutoCaptions?: () => void;
onDownloadWhisperSmallModel?: () => void;
@@ -276,10 +279,7 @@ function loadPreviewImage(url: string) {
});
}
-function trimCanvasToAlpha(
- canvas: HTMLCanvasElement,
- hotspot?: { x: number; y: number },
-) {
+function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) {
const ctx = canvas.getContext("2d");
if (!ctx) {
return {
@@ -327,7 +327,17 @@ function trimCanvasToAlpha(
croppedCanvas.width = croppedWidth;
croppedCanvas.height = croppedHeight;
const croppedCtx = croppedCanvas.getContext("2d")!;
- croppedCtx.drawImage(canvas, minX, minY, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight);
+ croppedCtx.drawImage(
+ canvas,
+ minX,
+ minY,
+ croppedWidth,
+ croppedHeight,
+ 0,
+ 0,
+ croppedWidth,
+ croppedHeight,
+ );
return {
dataUrl: croppedCanvas.toDataURL("image/png"),
@@ -335,9 +345,9 @@ function trimCanvasToAlpha(
height: croppedHeight,
hotspot: hotspot
? {
- x: hotspot.x - minX,
- y: hotspot.y - minY,
- }
+ x: hotspot.x - minX,
+ y: hotspot.y - minY,
+ }
: undefined,
};
}
@@ -427,7 +437,9 @@ function CursorStylePreview({
}
if (style === "dot") {
- return ;
+ return (
+
+ );
}
return (
@@ -496,11 +508,14 @@ export function SettingsPanel({
onAnnotationDelete,
autoCaptions = [],
autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS,
+ whisperExecutablePath,
whisperModelPath,
whisperModelDownloadStatus = "idle",
whisperModelDownloadProgress = 0,
isGeneratingCaptions = false,
onAutoCaptionSettingsChange,
+ onPickWhisperExecutable,
+ onPickWhisperModel,
onGenerateAutoCaptions,
onClearAutoCaptions,
onDownloadWhisperSmallModel,
@@ -514,9 +529,8 @@ export function SettingsPanel({
const { t } = useI18n();
const isBackgroundPanel = panelMode === "background";
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
- const [builtInWallpapers, setBuiltInWallpapers] = useState(
- BUILT_IN_WALLPAPERS,
- );
+ const [builtInWallpapers, setBuiltInWallpapers] =
+ useState(BUILT_IN_WALLPAPERS);
const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]);
const [customImages, setCustomImages] = useState(
initialEditorPreferences.customWallpapers,
@@ -531,6 +545,16 @@ export function SettingsPanel({
[builtInWallpapers],
);
const captionCueCount = autoCaptions.length;
+ const getPathDisplayName = (value?: string | null) => {
+ if (!value) {
+ return null;
+ }
+
+ const parts = value.split(/[\\/]/);
+ return parts[parts.length - 1] || value;
+ };
+ const whisperRuntimeLabel = getPathDisplayName(whisperExecutablePath);
+ const whisperModelLabel = getPathDisplayName(whisperModelPath);
const updateAutoCaptionSettings = (partial: Partial) => {
onAutoCaptionSettingsChange?.({
...autoCaptionSettings,
@@ -596,7 +620,9 @@ export function SettingsPanel({
const defaultWebcam = initialEditorPreferences.webcam;
const [internalActiveEffectSection] = useState("scene");
const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection;
- const [cursorPreviewUrls, setCursorPreviewUrls] = useState>>({});
+ const [cursorPreviewUrls, setCursorPreviewUrls] = useState<
+ Partial>
+ >({});
useEffect(() => {
let cancelled = false;
@@ -606,10 +632,10 @@ export function SettingsPanel({
const tahoeAsset = uploadedCursorAssets.arrow;
const tahoePreview = tahoeAsset
? await createTrimmedSvgPreview(
- tahoeAsset.url,
- UPLOADED_CURSOR_SAMPLE_SIZE,
- tahoeAsset.trim,
- )
+ tahoeAsset.url,
+ UPLOADED_CURSOR_SAMPLE_SIZE,
+ tahoeAsset.trim,
+ )
: tahoeCursorUrl;
const minimalPreview = await createTrimmedSvgPreview(minimalCursorUrl, 512);
const invertedPreview = await createInvertedPreview(tahoePreview);
@@ -935,11 +961,13 @@ export function SettingsPanel({
- {([
- { value: "image", label: tSettings("background.image") },
- { value: "color", label: tSettings("background.color") },
- { value: "gradient", label: tSettings("background.gradient") },
- ] as const).map((option) => {
+ {(
+ [
+ { value: "image", label: tSettings("background.image") },
+ { value: "color", label: tSettings("background.color") },
+ { value: "gradient", label: tSettings("background.gradient") },
+ ] as const
+ ).map((option) => {
const isActive = backgroundTab === option.value;
return (
) : null}
-
{option.label}
+
+ {option.label}
+
);
})}
@@ -1011,7 +1046,8 @@ export function SettingsPanel({
: builtInWallpaperPaths
).map((previewPath, index) => {
const wallpaper = builtInWallpapers[index] ?? BUILT_IN_WALLPAPERS[index];
- const wallpaperValue = wallpaper?.publicPath ?? builtInWallpaperPaths[index] ?? previewPath;
+ const wallpaperValue =
+ wallpaper?.publicPath ?? builtInWallpaperPaths[index] ?? previewPath;
const isSelected = getWallpaperTileState(wallpaperValue, previewPath);
return renderWallpaperImageTile(previewPath, isSelected, {
key: wallpaperValue,
@@ -1054,7 +1090,12 @@ export function SettingsPanel({
customColorInputRef.current?.click()}
- className={wallpaperTileClass(isHexWallpaper(selected) && !visibleColorPalette.some((color) => color.toLowerCase() === selected.toLowerCase()))}
+ className={wallpaperTileClass(
+ isHexWallpaper(selected) &&
+ !visibleColorPalette.some(
+ (color) => color.toLowerCase() === selected.toLowerCase(),
+ ),
+ )}
style={{
background: `linear-gradient(135deg, ${selectedColor} 0%, ${selectedColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`,
}}
@@ -1163,15 +1204,57 @@ export function SettingsPanel({
{tSettings("sections.frame", "Frame")}
- {t("common.actions.reset", "Reset")}
+
+ {t("common.actions.reset", "Reset")}
+
-
onShadowChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} />
- onBorderRadiusChange?.(v)} formatValue={(v) => `${v}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} />
- onPaddingChange?.(v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
+ onShadowChange?.(v)}
+ formatValue={(v) => `${Math.round(v * 100)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
+ />
+ onBorderRadiusChange?.(v)}
+ formatValue={(v) => `${v}px`}
+ parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
+ />
+ onPaddingChange?.(v)}
+ formatValue={(v) => `${v}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
- {tSettings("effects.removeBackground")}
-
+
+ {tSettings("effects.removeBackground")}
+
+
@@ -1181,13 +1264,61 @@ export function SettingsPanel({
{tSettings("sections.crop", "Crop")}
- {isCropped ? {t("common.actions.reset", "Reset")} : null}
+ {isCropped ? (
+
+ {t("common.actions.reset", "Reset")}
+
+ ) : null}
- setCropInset("top", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- setCropInset("bottom", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- setCropInset("left", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- setCropInset("right", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
+ setCropInset("top", v)}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ setCropInset("bottom", v)}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ setCropInset("left", v)}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ setCropInset("right", v)}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
);
@@ -1216,9 +1347,43 @@ export function SettingsPanel({
+
+
+ {tSettings("captions.selectRuntime", "Select Runtime")}
+
+
+ {tSettings("captions.selectModel", "Select Model")}
+
+
+
+
+ {tSettings("captions.runtimeStatus", "Runtime")}:{" "}
+ {whisperRuntimeLabel ??
+ tSettings("captions.runtimeAuto", "Bundled or system auto-detect")}
+
+
+ {tSettings("captions.modelStatus", "Model")}:{" "}
+ {whisperModelLabel ?? tSettings("captions.modelMissing", "No model selected")}
+
+
-
{tSettings("captions.language", "Language")}
-
updateAutoCaptionSettings({ language: value })}>
+
+ {tSettings("captions.language", "Language")}
+
+ updateAutoCaptionSettings({ language: value })}
+ >
@@ -1232,33 +1397,73 @@ export function SettingsPanel({
-
+
{whisperModelDownloadStatus === "downloading" ? (
-
- {tSettings("captions.downloading", "Downloading...")} {Math.round(whisperModelDownloadProgress)}%
+
+ {tSettings("captions.downloading", "Downloading...")}{" "}
+ {Math.round(whisperModelDownloadProgress)}%
) : whisperModelPath ? (
-
+
{tSettings("captions.clearModel", "Clear Model")}
) : (
-
+
{tSettings("captions.downloadModel", "Download Model")}
)}
-
+
+ {tSettings("captions.browseModel", "Browse Model")}
+
+
{tSettings("captions.clearFull", "Clear Captions")}
-
- {isGeneratingCaptions ? tSettings("captions.generating", "Generating...") : captionCueCount > 0 ? tSettings("captions.regenerateFull", "Regenerate Captions") : tSettings("captions.generateFull", "Generate Captions")}
+
+ {isGeneratingCaptions
+ ? tSettings("captions.generating", "Generating...")
+ : captionCueCount > 0
+ ? tSettings("captions.regenerateFull", "Regenerate Captions")
+ : tSettings("captions.generateFull", "Generate Captions")}
{isGeneratingCaptions ? (
- {tSettings("captions.generatingStatus", "Generating captions. This can take a moment.")}
+ {tSettings(
+ "captions.generatingStatus",
+ "Generating captions. This can take a moment.",
+ )}
@@ -1266,15 +1471,25 @@ export function SettingsPanel({
{whisperModelDownloadStatus === "downloading" ? (
) : null}
-
{tSettings("captions.animation", "Animation")}
-
updateAutoCaptionSettings({ animationStyle: value as AutoCaptionAnimation })}>
+
+ {tSettings("captions.animation", "Animation")}
+
+
+ updateAutoCaptionSettings({ animationStyle: value as AutoCaptionAnimation })
+ }
+ >
@@ -1288,7 +1503,9 @@ export function SettingsPanel({
- {tSettings("captions.textColor", "Text color")}
+
+ {tSettings("captions.textColor", "Text color")}
+
-
{tSettings("captions.fontSettings", "Font Settings")}
-
updateAutoCaptionSettings({ fontSize: value })} formatValue={(value) => `${Math.round(value)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} />
- updateAutoCaptionSettings({ maxRows: Math.round(value) })} formatValue={(value) => `${Math.round(value)}`} parseInput={(text) => parseFloat(text)} />
- updateAutoCaptionSettings({ bottomOffset: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- updateAutoCaptionSettings({ maxWidth: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- updateAutoCaptionSettings({ boxRadius: value })} formatValue={(value) => `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} />
- updateAutoCaptionSettings({ backgroundOpacity: value })} formatValue={(value) => `${Math.round(value * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} />
+
+ {tSettings("captions.fontSettings", "Font Settings")}
+
+ updateAutoCaptionSettings({ fontSize: value })}
+ formatValue={(value) => `${Math.round(value)}px`}
+ parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
+ />
+ updateAutoCaptionSettings({ maxRows: Math.round(value) })}
+ formatValue={(value) => `${Math.round(value)}`}
+ parseInput={(text) => parseFloat(text)}
+ />
+ updateAutoCaptionSettings({ bottomOffset: value })}
+ formatValue={(value) => `${Math.round(value)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ updateAutoCaptionSettings({ maxWidth: value })}
+ formatValue={(value) => `${Math.round(value)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ updateAutoCaptionSettings({ boxRadius: value })}
+ formatValue={(value) =>
+ `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}px`
+ }
+ parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
+ />
+ updateAutoCaptionSettings({ backgroundOpacity: value })}
+ formatValue={(value) => `${Math.round(value * 100)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
+ />
);
@@ -1326,8 +1607,8 @@ export function SettingsPanel({
return sceneSectionContent;
case "crop":
return sceneSectionContent;
- case "captions":
- return captionsSectionContent;
+ case "captions":
+ return captionsSectionContent;
case "cursor":
return (
@@ -1385,18 +1666,71 @@ export function SettingsPanel({
>
))}
- onCursorSizeChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} />
- onCursorSmoothingChange?.(v)} formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))} parseInput={(text) => parseFloat(text)} />
- onCursorMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} />
- onCursorClickBounceChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} />
- onCursorClickBounceDurationChange?.(v)} formatValue={(v) => `${Math.round(v)} ms`} parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} />
+ onCursorSizeChange?.(v)}
+ formatValue={(v) => `${v.toFixed(2)}×`}
+ parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
+ />
+ onCursorSmoothingChange?.(v)}
+ formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))}
+ parseInput={(text) => parseFloat(text)}
+ />
+ onCursorMotionBlurChange?.(v)}
+ formatValue={(v) => `${v.toFixed(2)}×`}
+ parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
+ />
+ onCursorClickBounceChange?.(v)}
+ formatValue={(v) => `${v.toFixed(2)}×`}
+ parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
+ />
+ onCursorClickBounceDurationChange?.(v)}
+ formatValue={(v) => `${Math.round(v)} ms`}
+ parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())}
+ />
{tSettings("sections.webcam", "Webcam")}
- {t("common.actions.reset", "Reset")}
+
+ {t("common.actions.reset", "Reset")}
+
-
{tSettings("effects.show", "Show")} updateWebcam({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
-
{tSettings("effects.webcamReactToZoom")} updateWebcam({ reactToZoom })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
-
updateWebcam({ size: v })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
+
+
+ {tSettings("effects.show", "Show")}
+
+ updateWebcam({ enabled })}
+ className="data-[state=checked]:bg-[#2563EB] scale-75"
+ />
+
+
+
+ {tSettings("effects.webcamReactToZoom")}
+
+ updateWebcam({ reactToZoom })}
+ className="data-[state=checked]:bg-[#2563EB] scale-75"
+ />
+
+ updateWebcam({ size: v })}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
-
{tSettings("effects.webcamPosition", "Position")}
+
+ {tSettings("effects.webcamPosition", "Position")}
+
{WEBCAM_POSITION_PRESETS.map((option) => {
const isActive = webcamPositionPreset === option.preset;
@@ -1449,28 +1819,110 @@ export function SettingsPanel({
})}
- {tSettings("effects.webcamCustomPosition", "Custom position")}
- applyWebcamPositionPreset(checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET)} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+
+ {tSettings("effects.webcamCustomPosition", "Custom position")}
+
+
+ applyWebcamPositionPreset(checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET)
+ }
+ className="data-[state=checked]:bg-[#2563EB] scale-75"
+ />
{webcamPositionPreset === "custom" ? (
<>
- updateWebcam({ positionPreset: "custom", positionX: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
- updateWebcam({ positionPreset: "custom", positionY: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} />
+ updateWebcam({ positionPreset: "custom", positionX: v / 100 })}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
+ updateWebcam({ positionPreset: "custom", positionY: v / 100 })}
+ formatValue={(v) => `${Math.round(v)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
+ />
>
) : null}
- updateWebcam({ margin: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} />
- updateWebcam({ cornerRadius: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} />
- updateWebcam({ shadow: v })} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} />
+ updateWebcam({ margin: v })}
+ formatValue={(v) => `${Math.round(v)}px`}
+ parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
+ />
+ updateWebcam({ cornerRadius: v })}
+ formatValue={(v) => `${Math.round(v)}px`}
+ parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
+ />
+ updateWebcam({ shadow: v })}
+ formatValue={(v) => `${Math.round(v * 100)}%`}
+ parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
+ />
-
{tSettings("effects.webcamFootage")}
-
{webcamFileName ?? tSettings("effects.webcamFootageDescription")}
+
+ {tSettings("effects.webcamFootage")}
+
+
+ {webcamFileName ?? tSettings("effects.webcamFootageDescription")}
+
- {webcam?.sourcePath ? tSettings("effects.replaceWebcamFootage") : tSettings("effects.uploadWebcamFootage")}
- {webcam?.sourcePath ? {tSettings("effects.removeWebcamFootage")} : null}
+
+
+ {webcam?.sourcePath
+ ? tSettings("effects.replaceWebcamFootage")
+ : tSettings("effects.uploadWebcamFootage")}
+
+ {webcam?.sourcePath ? (
+
+
+ {tSettings("effects.removeWebcamFootage")}
+
+ ) : null}
@@ -1483,17 +1935,17 @@ export function SettingsPanel({
return (
-
-
- {effectSectionContent}
-
-
+
+
+ {effectSectionContent}
+
+
@@ -1530,15 +1982,29 @@ export function SettingsPanel({
);
})}
- {!zoomEnabled &&
{tSettings("zoom.selectRegion")}
}
+ {!zoomEnabled && (
+
+ {tSettings("zoom.selectRegion")}
+
+ )}
{zoomEnabled && (
-
+
{tSettings("zoom.deleteZoom")}
)}
{trimEnabled && (
-
+
{tSettings("trim.deleteRegion")}
@@ -1547,10 +2013,13 @@ export function SettingsPanel({
- {tSettings("speed.playbackSpeed")}
+
+ {tSettings("speed.playbackSpeed")}
+
{selectedSpeedId && selectedSpeedValue && (
- {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
+ {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
+ `${selectedSpeedValue}×`}
)}
@@ -1565,7 +2034,9 @@ export function SettingsPanel({
onClick={() => onSpeedChange?.(option.speed)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all duration-200 ease-out",
- selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
+ selectedSpeedId
+ ? "opacity-100 cursor-pointer"
+ : "opacity-40 cursor-not-allowed",
isActive
? "border-[#d97706] bg-[#d97706] text-white"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
@@ -1576,9 +2047,18 @@ export function SettingsPanel({
);
})}
- {!selectedSpeedId && {tSettings("speed.selectRegion")}
}
+ {!selectedSpeedId && (
+
+ {tSettings("speed.selectRegion")}
+
+ )}
{selectedSpeedId && (
- selectedSpeedId && onSpeedDelete?.(selectedSpeedId)} variant="destructive" size="sm" className="mt-2 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20">
+ selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
+ variant="destructive"
+ size="sm"
+ className="mt-2 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
+ >
{tSettings("speed.deleteRegion")}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 9651173a..c0cbe77a 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,5 +1,17 @@
import type { Span } from "dnd-timeline";
-import { Camera, Captions, Download, FolderOpen, Languages, MousePointer2, Redo2, Save, Sparkles, Undo2, X } from "lucide-react";
+import {
+ Camera,
+ Captions,
+ Download,
+ FolderOpen,
+ Languages,
+ MousePointer2,
+ Redo2,
+ Save,
+ Sparkles,
+ Undo2,
+ X,
+} from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
@@ -32,10 +44,12 @@ import {
} from "@/lib/exporter";
import { matchesShortcut } from "@/lib/shortcuts";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
+import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
+import { CropControl } from "./CropControl";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import PlaybackControls from "./PlaybackControls";
-import { CropControl } from "./CropControl";
+import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
import {
createProjectData,
deriveNextId,
@@ -46,37 +60,41 @@ import {
validateProjectData,
} from "./projectPersistence";
import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel";
-import { APP_HEADER_ACTION_BUTTON_CLASS, FeedbackDialog, KeyboardShortcutsDialog } from "./TutorialHelp";
+import {
+ APP_HEADER_ACTION_BUTTON_CLASS,
+ FeedbackDialog,
+ KeyboardShortcutsDialog,
+} from "./TutorialHelp";
import TimelineEditor from "./timeline/TimelineEditor";
import {
detectInteractionCandidates,
normalizeCursorTelemetry,
} from "./timeline/zoomSuggestionUtils";
import {
- type AutoCaptionSettings,
- type CaptionCue,
type AnnotationRegion,
type AudioRegion,
+ type AutoCaptionSettings,
+ type CaptionCue,
type CropRegion,
type CursorStyle,
- DEFAULT_AUTO_CAPTION_SETTINGS,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_AUTO_CAPTION_SETTINGS,
DEFAULT_CONNECTED_ZOOM_DURATION_MS,
DEFAULT_CONNECTED_ZOOM_EASING,
DEFAULT_CONNECTED_ZOOM_GAP_MS,
- DEFAULT_FIGURE_DATA,
- DEFAULT_PLAYBACK_SPEED,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_STYLE,
+ DEFAULT_FIGURE_DATA,
+ DEFAULT_PLAYBACK_SPEED,
+ DEFAULT_WEBCAM_OVERLAY,
+ DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
- DEFAULT_WEBCAM_OVERLAY,
- DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
type FigureData,
@@ -286,6 +304,9 @@ export default function VideoEditor() {
const [videoPath, setVideoPath] = useState(null);
const [videoSourcePath, setVideoSourcePath] = useState(null);
const [currentProjectPath, setCurrentProjectPath] = useState(null);
+ const [projectLibraryEntries, setProjectLibraryEntries] = useState([]);
+ const [projectsDirectoryPath, setProjectsDirectoryPath] = useState(null);
+ const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -364,6 +385,7 @@ export default function VideoEditor() {
const [whisperModelPath, setWhisperModelPath] = useState(
initialEditorPreferences.whisperModelPath,
);
+ const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState(null);
const [whisperModelDownloadStatus, setWhisperModelDownloadStatus] = useState<
"idle" | "downloading" | "downloaded" | "error"
>(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle");
@@ -414,12 +436,13 @@ export default function VideoEditor() {
const cropSnapshotRef = useRef(null);
const mp4SupportRequestRef = useRef(0);
const [historyVersion, setHistoryVersion] = useState(0);
- const [supportedMp4SourceDimensions, setSupportedMp4SourceDimensions] = useState({
- width: 1920,
- height: 1080,
- capped: false,
- encoderPath: null,
- });
+ const [supportedMp4SourceDimensions, setSupportedMp4SourceDimensions] =
+ useState({
+ width: 1920,
+ height: 1080,
+ capped: false,
+ encoderPath: null,
+ });
const syncHistoryButtons = useCallback(() => {
setHistoryVersion((version) => version + 1);
@@ -430,6 +453,87 @@ export default function VideoEditor() {
setHasPendingExportSave(false);
}, []);
+ const refreshProjectLibrary = useCallback(async () => {
+ try {
+ const result = await window.electronAPI.listProjectFiles();
+ if (!result.success) {
+ throw new Error(result.error || "Failed to load project library");
+ }
+
+ setProjectsDirectoryPath(result.projectsDir ?? null);
+ setProjectLibraryEntries(result.entries);
+ } catch (projectLibraryError) {
+ console.warn("Unable to refresh project library:", projectLibraryError);
+ }
+ }, []);
+
+ const captureProjectThumbnail = useCallback(async () => {
+ const previewHandle = videoPlaybackRef.current;
+ const previewCanvas = previewHandle?.app?.canvas ?? null;
+ const previewVideo = previewHandle?.video ?? null;
+
+ if (previewHandle && previewVideo && previewVideo.paused) {
+ try {
+ await previewHandle.refreshFrame();
+ } catch (thumbnailRefreshError) {
+ console.warn(
+ "Unable to refresh preview frame before thumbnail capture:",
+ thumbnailRefreshError,
+ );
+ }
+ }
+
+ const canvas = document.createElement("canvas");
+ const targetWidth = 1280;
+ const targetHeight = 720;
+ canvas.width = targetWidth;
+ canvas.height = targetHeight;
+
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return null;
+ }
+
+ context.fillStyle = "#111113";
+ context.fillRect(0, 0, targetWidth, targetHeight);
+
+ const drawableSource =
+ previewCanvas && previewCanvas.width > 0 && previewCanvas.height > 0
+ ? previewCanvas
+ : previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0
+ ? previewVideo
+ : null;
+
+ if (!drawableSource) {
+ return null;
+ }
+
+ const sourceWidth =
+ drawableSource instanceof HTMLVideoElement ? drawableSource.videoWidth : drawableSource.width;
+ const sourceHeight =
+ drawableSource instanceof HTMLVideoElement
+ ? drawableSource.videoHeight
+ : drawableSource.height;
+
+ if (sourceWidth <= 0 || sourceHeight <= 0) {
+ return null;
+ }
+
+ const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
+ const drawWidth = Math.round(sourceWidth * scale);
+ const drawHeight = Math.round(sourceHeight * scale);
+ const offsetX = Math.round((targetWidth - drawWidth) / 2);
+ const offsetY = Math.round((targetHeight - drawHeight) / 2);
+
+ try {
+ context.drawImage(drawableSource, offsetX, offsetY, drawWidth, drawHeight);
+ return canvas.toDataURL("image/png");
+ } catch (thumbnailError) {
+ console.warn("Unable to capture project thumbnail:", thumbnailError);
+ return null;
+ }
+ }, []);
+
const markExportAsSaving = useCallback(() => {
setExportProgress((previous) => ({
currentFrame: previous?.totalFrames ?? previous?.currentFrame ?? 1,
@@ -448,6 +552,10 @@ export default function VideoEditor() {
};
}, []);
+ useEffect(() => {
+ void refreshProjectLibrary();
+ }, [refreshProjectLibrary]);
+
const canUndo = historyPastRef.current.length > 0;
const canRedo = historyFutureRef.current.length > 0;
@@ -465,7 +573,7 @@ export default function VideoEditor() {
gifSizePreset,
GIF_SIZE_PRESETS,
),
- [gifSizePreset, videoPath],
+ [gifSizePreset],
);
const desiredMp4SourceDimensions = useMemo(
@@ -475,7 +583,7 @@ export default function VideoEditor() {
videoPlaybackRef.current?.video?.videoHeight || 1080,
aspectRatio,
),
- [aspectRatio, duration, videoPath],
+ [aspectRatio],
);
const mp4OutputDimensions = useMemo(() => {
@@ -492,7 +600,13 @@ export default function VideoEditor() {
high: calculateMp4ExportDimensions(baseWidth, baseHeight, "high"),
source: calculateMp4ExportDimensions(baseWidth, baseHeight, "source"),
};
- }, [desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width, supportedMp4SourceDimensions.encoderPath, supportedMp4SourceDimensions.height, supportedMp4SourceDimensions.width]);
+ }, [
+ desiredMp4SourceDimensions.height,
+ desiredMp4SourceDimensions.width,
+ supportedMp4SourceDimensions.encoderPath,
+ supportedMp4SourceDimensions.height,
+ supportedMp4SourceDimensions.width,
+ ]);
const ensureSupportedMp4SourceDimensions = useCallback(async () => {
const result = await probeSupportedMp4Dimensions({
@@ -559,20 +673,26 @@ export default function VideoEditor() {
return () => {
cancelled = true;
};
- }, [desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width, ensureSupportedMp4SourceDimensions]);
-
- const projectDisplayName = useMemo(() => {
- const fileName = currentProjectPath?.split(/[\\/]/).pop() ?? "";
- const withoutExtension = fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, "");
- return withoutExtension || t("editor.project.untitled", "Untitled");
- }, [currentProjectPath, t]);
+ }, [
+ desiredMp4SourceDimensions.height,
+ desiredMp4SourceDimensions.width,
+ ensureSupportedMp4SourceDimensions,
+ ]);
const editorSectionButtons = useMemo(
() => [
{ id: "scene" as const, label: t("settings.sections.scene", "Scene"), icon: Sparkles },
- { id: "cursor" as const, label: t("settings.sections.cursor", "Cursor"), icon: MousePointer2 },
+ {
+ id: "cursor" as const,
+ label: t("settings.sections.cursor", "Cursor"),
+ icon: MousePointer2,
+ },
{ id: "webcam" as const, label: t("settings.sections.webcam", "Webcam"), icon: Camera },
- { id: "captions" as const, label: t("settings.sections.captions", "Captions"), icon: Captions },
+ {
+ id: "captions" as const,
+ label: t("settings.sections.captions", "Captions"),
+ icon: Captions,
+ },
],
[t],
);
@@ -642,6 +762,13 @@ export default function VideoEditor() {
[videoPath, videoSourcePath],
);
+ const projectDisplayName = useMemo(() => {
+ const fileName =
+ currentProjectPath?.split(/[\\/]/).pop() ?? currentSourcePath?.split(/[\\/]/).pop() ?? "";
+ const withoutExtension = fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, "");
+ return withoutExtension || t("editor.project.untitled", "Untitled");
+ }, [currentProjectPath, currentSourcePath, t]);
+
const currentPersistedEditorState = useMemo(
() =>
buildPersistedEditorState({
@@ -823,118 +950,124 @@ export default function VideoEditor() {
syncHistoryButtons();
}, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]);
- const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
- if (!validateProjectData(candidate)) {
- return false;
- }
+ const applyLoadedProject = useCallback(
+ async (candidate: unknown, path?: string | null) => {
+ if (!validateProjectData(candidate)) {
+ return false;
+ }
- const project = candidate;
- const sourcePath = fromFileUrl(project.videoPath);
- const normalizedEditor = normalizeProjectEditor(project.editor);
+ const project = candidate;
+ const sourcePath = fromFileUrl(project.videoPath);
+ const normalizedEditor = normalizeProjectEditor(project.editor);
- try {
- videoPlaybackRef.current?.pause();
- } catch {
- // no-op
- }
- setIsPlaying(false);
- setCurrentTime(0);
- setDuration(0);
+ try {
+ videoPlaybackRef.current?.pause();
+ } catch {
+ // no-op
+ }
+ setIsPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
- setError(null);
- setVideoSourcePath(sourcePath);
- setVideoPath(toFileUrl(sourcePath));
- setCurrentProjectPath(path ?? null);
- if (normalizedEditor.webcam.sourcePath) {
- await window.electronAPI.setCurrentRecordingSession?.({
- videoPath: sourcePath,
- webcamPath: normalizedEditor.webcam.sourcePath,
- });
- } else {
- await window.electronAPI.setCurrentVideoPath(sourcePath);
- }
+ setError(null);
+ setVideoSourcePath(sourcePath);
+ setVideoPath(toFileUrl(sourcePath));
+ setCurrentProjectPath(path ?? null);
+ if (normalizedEditor.webcam.sourcePath) {
+ await window.electronAPI.setCurrentRecordingSession?.({
+ videoPath: sourcePath,
+ webcamPath: normalizedEditor.webcam.sourcePath,
+ });
+ } else {
+ await window.electronAPI.setCurrentVideoPath(sourcePath);
+ }
- setWallpaper(normalizedEditor.wallpaper);
- setShadowIntensity(normalizedEditor.shadowIntensity);
- setBackgroundBlur(normalizedEditor.backgroundBlur);
- setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
- setConnectZooms(normalizedEditor.connectZooms);
- setZoomInDurationMs(normalizedEditor.zoomInDurationMs);
- setZoomInOverlapMs(normalizedEditor.zoomInOverlapMs);
- setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs);
- setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs);
- setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs);
- setZoomInEasing(normalizedEditor.zoomInEasing);
- setZoomOutEasing(normalizedEditor.zoomOutEasing);
- setConnectedZoomEasing(normalizedEditor.connectedZoomEasing);
- setShowCursor(normalizedEditor.showCursor);
- setLoopCursor(normalizedEditor.loopCursor);
- setCursorStyle(normalizedEditor.cursorStyle);
- setCursorSize(normalizedEditor.cursorSize);
- setCursorSmoothing(normalizedEditor.cursorSmoothing);
- setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
- setCursorClickBounce(normalizedEditor.cursorClickBounce);
- setCursorClickBounceDuration(normalizedEditor.cursorClickBounceDuration);
- setCursorSway(normalizedEditor.cursorSway);
- setBorderRadius(normalizedEditor.borderRadius);
- setPadding(normalizedEditor.padding);
- setCropRegion(DEFAULT_CROP_REGION);
- setWebcam(normalizedEditor.webcam);
- setZoomRegions(normalizedEditor.zoomRegions);
- setTrimRegions(normalizedEditor.trimRegions);
- setSpeedRegions(normalizedEditor.speedRegions);
- setAnnotationRegions(normalizedEditor.annotationRegions);
- setAudioRegions(normalizedEditor.audioRegions);
- setAutoCaptions(normalizedEditor.autoCaptions);
- setAutoCaptionSettings(normalizedEditor.autoCaptionSettings);
- setAspectRatio(normalizedEditor.aspectRatio);
- setExportQuality(normalizedEditor.exportQuality);
- setExportFormat(normalizedEditor.exportFormat);
- setGifFrameRate(normalizedEditor.gifFrameRate);
- setGifLoop(normalizedEditor.gifLoop);
- setGifSizePreset(normalizedEditor.gifSizePreset);
+ setWallpaper(normalizedEditor.wallpaper);
+ setShadowIntensity(normalizedEditor.shadowIntensity);
+ setBackgroundBlur(normalizedEditor.backgroundBlur);
+ setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
+ setConnectZooms(normalizedEditor.connectZooms);
+ setZoomInDurationMs(normalizedEditor.zoomInDurationMs);
+ setZoomInOverlapMs(normalizedEditor.zoomInOverlapMs);
+ setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs);
+ setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs);
+ setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs);
+ setZoomInEasing(normalizedEditor.zoomInEasing);
+ setZoomOutEasing(normalizedEditor.zoomOutEasing);
+ setConnectedZoomEasing(normalizedEditor.connectedZoomEasing);
+ setShowCursor(normalizedEditor.showCursor);
+ setLoopCursor(normalizedEditor.loopCursor);
+ setCursorStyle(normalizedEditor.cursorStyle);
+ setCursorSize(normalizedEditor.cursorSize);
+ setCursorSmoothing(normalizedEditor.cursorSmoothing);
+ setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
+ setCursorClickBounce(normalizedEditor.cursorClickBounce);
+ setCursorClickBounceDuration(normalizedEditor.cursorClickBounceDuration);
+ setCursorSway(normalizedEditor.cursorSway);
+ setBorderRadius(normalizedEditor.borderRadius);
+ setPadding(normalizedEditor.padding);
+ setCropRegion(DEFAULT_CROP_REGION);
+ setWebcam(normalizedEditor.webcam);
+ setZoomRegions(normalizedEditor.zoomRegions);
+ setTrimRegions(normalizedEditor.trimRegions);
+ setSpeedRegions(normalizedEditor.speedRegions);
+ setAnnotationRegions(normalizedEditor.annotationRegions);
+ setAudioRegions(normalizedEditor.audioRegions);
+ setAutoCaptions(normalizedEditor.autoCaptions);
+ setAutoCaptionSettings(normalizedEditor.autoCaptionSettings);
+ setAspectRatio(normalizedEditor.aspectRatio);
+ setExportQuality(normalizedEditor.exportQuality);
+ setExportFormat(normalizedEditor.exportFormat);
+ setGifFrameRate(normalizedEditor.gifFrameRate);
+ setGifLoop(normalizedEditor.gifLoop);
+ setGifSizePreset(normalizedEditor.gifSizePreset);
- setSelectedZoomId(null);
- setSelectedTrimId(null);
- setSelectedSpeedId(null);
- setSelectedAnnotationId(null);
- setSelectedAudioId(null);
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedSpeedId(null);
+ setSelectedAnnotationId(null);
+ setSelectedAudioId(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),
- );
- nextAudioIdRef.current = deriveNextId(
- "audio",
- normalizedEditor.audioRegions.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;
+ 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),
+ );
+ nextAudioIdRef.current = deriveNextId(
+ "audio",
+ normalizedEditor.audioRegions.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;
- historyPastRef.current = [];
- historyFutureRef.current = [];
- historyCurrentRef.current = null;
- applyingHistoryRef.current = false;
- syncHistoryButtons();
+ historyPastRef.current = [];
+ historyFutureRef.current = [];
+ historyCurrentRef.current = null;
+ applyingHistoryRef.current = false;
+ syncHistoryButtons();
- setLastSavedSnapshot(
- cloneStructured(createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor))),
- );
- return true;
- }, [buildPersistedEditorState, syncHistoryButtons]);
+ setLastSavedSnapshot(
+ cloneStructured(createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor))),
+ );
+ await refreshProjectLibrary();
+ return true;
+ },
+ [buildPersistedEditorState, refreshProjectLibrary, syncHistoryButtons],
+ );
const currentProjectSnapshot = useMemo(() => {
if (!currentSourcePath) {
@@ -1170,10 +1303,11 @@ export default function VideoEditor() {
setWhisperModelDownloadStatus(state.status);
setWhisperModelDownloadProgress(state.progress);
if (state.status === "downloaded") {
- setWhisperModelPath(state.path ?? null);
+ setDownloadedWhisperModelPath(state.path ?? null);
+ setWhisperModelPath((currentPath) => currentPath ?? state.path ?? null);
}
if (state.status === "idle") {
- setWhisperModelPath(null);
+ setDownloadedWhisperModelPath(null);
}
if (state.status === "error" && state.error) {
toast.error(state.error);
@@ -1187,13 +1321,14 @@ export default function VideoEditor() {
}
if (result.exists && result.path) {
- setWhisperModelPath(result.path);
+ setDownloadedWhisperModelPath(result.path);
+ setWhisperModelPath((currentPath) => currentPath ?? result.path ?? null);
setWhisperModelDownloadStatus("downloaded");
setWhisperModelDownloadProgress(100);
return;
}
- setWhisperModelPath(null);
+ setDownloadedWhisperModelPath(null);
setWhisperModelDownloadStatus("idle");
setWhisperModelDownloadProgress(0);
})();
@@ -1226,10 +1361,21 @@ export default function VideoEditor() {
}
if (result.path) {
+ setDownloadedWhisperModelPath(result.path);
setWhisperModelPath(result.path);
}
}, [whisperModelDownloadStatus]);
+ const handlePickWhisperModel = useCallback(async () => {
+ const result = await window.electronAPI.openWhisperModelPicker();
+ if (!result.success || !result.path) {
+ return;
+ }
+
+ setWhisperModelPath(result.path);
+ toast.success("Whisper model selected");
+ }, []);
+
const handleDeleteWhisperSmallModel = useCallback(async () => {
const result = await window.electronAPI.deleteWhisperSmallModel();
if (!result.success) {
@@ -1237,27 +1383,35 @@ export default function VideoEditor() {
return;
}
- setWhisperModelPath(null);
+ setWhisperModelPath((currentPath) =>
+ currentPath === downloadedWhisperModelPath ? null : currentPath,
+ );
+ setDownloadedWhisperModelPath(null);
setWhisperModelDownloadStatus("idle");
setWhisperModelDownloadProgress(0);
toast.success("Whisper small model deleted");
- }, []);
+ }, [downloadedWhisperModelPath]);
const handleGenerateAutoCaptions = useCallback(async () => {
if (isGeneratingCaptions) {
return;
}
- let sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
+ let sourcePath = resolveAutoCaptionSourcePath({
+ videoSourcePath,
+ videoPath,
+ });
- const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
- if (sessionResult?.success && sessionResult.session?.videoPath) {
- sourcePath = fromFileUrl(sessionResult.session.videoPath);
- } else {
+ if (!sourcePath) {
+ const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
const currentVideoResult = await window.electronAPI.getCurrentVideoPath();
- if (currentVideoResult.success && currentVideoResult.path) {
- sourcePath = fromFileUrl(currentVideoResult.path);
- }
+ sourcePath = resolveAutoCaptionSourcePath({
+ recordingSessionVideoPath:
+ sessionResult?.success && sessionResult.session?.videoPath
+ ? sessionResult.session.videoPath
+ : null,
+ currentVideoPath: currentVideoResult.success ? (currentVideoResult.path ?? null) : null,
+ });
}
if (!sourcePath) {
@@ -1273,7 +1427,7 @@ export default function VideoEditor() {
await syncActiveVideoSource(sourcePath, webcam.sourcePath ?? null);
if (!whisperModelPath) {
- toast.error("Download the Whisper small model first");
+ toast.error("Select a Whisper model or download the small model first");
return;
}
@@ -1287,7 +1441,9 @@ export default function VideoEditor() {
});
if (!result.success || !result.cues) {
- toast.error(result.message || getErrorMessage(result.error) || "Failed to generate captions");
+ toast.error(
+ result.message || getErrorMessage(result.error) || "Failed to generate captions",
+ );
return;
}
@@ -1342,10 +1498,13 @@ export default function VideoEditor() {
}
}
+ const thumbnailDataUrl = await captureProjectThumbnail();
+
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
targetProjectPath,
+ thumbnailDataUrl,
);
if (result.canceled) {
@@ -1362,15 +1521,18 @@ export default function VideoEditor() {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(cloneStructured(projectData));
+ await refreshProjectLibrary();
toast.success(`Project saved to ${result.path}`);
return true;
},
[
+ captureProjectThumbnail,
currentSourcePath,
currentProjectPath,
currentProjectSnapshot,
currentPersistedEditorState,
+ refreshProjectLibrary,
],
);
@@ -1391,7 +1553,10 @@ export default function VideoEditor() {
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
- await saveProject(true);
+ const saved = await saveProject(true);
+ if (saved) {
+ setProjectBrowserOpen(false);
+ }
}, [saveProject]);
const handleLoadProject = useCallback(async () => {
@@ -1412,8 +1577,49 @@ export default function VideoEditor() {
return;
}
+ setProjectBrowserOpen(false);
+ await refreshProjectLibrary();
+
toast.success(`Project loaded from ${result.path}`);
- }, [applyLoadedProject]);
+ }, [applyLoadedProject, refreshProjectLibrary]);
+
+ const handleOpenProjectFromLibrary = useCallback(
+ async (projectPath: string) => {
+ const result = await window.electronAPI.openProjectFileAtPath(projectPath);
+
+ if (result.canceled) {
+ return;
+ }
+
+ if (!result.success) {
+ toast.error(result.message || "Failed to load project");
+ return;
+ }
+
+ const restored = await applyLoadedProject(result.project, result.path ?? null);
+ if (!restored) {
+ toast.error("Invalid project file format");
+ return;
+ }
+
+ setProjectBrowserOpen(false);
+ await refreshProjectLibrary();
+ toast.success(`Project loaded from ${result.path}`);
+ },
+ [applyLoadedProject, refreshProjectLibrary],
+ );
+
+ const handleOpenProjectsFolder = useCallback(async () => {
+ const result = await window.electronAPI.openProjectsDirectory();
+ if (!result.success) {
+ toast.error(result.message || result.error || "Failed to open projects folder");
+ }
+ }, []);
+
+ const handleOpenProjectBrowser = useCallback(async () => {
+ await refreshProjectLibrary();
+ setProjectBrowserOpen(true);
+ }, [refreshProjectLibrary]);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
@@ -2419,11 +2625,13 @@ export default function VideoEditor() {
zoomOutEasing,
connectedZoomEasing,
showCursor,
+ cursorStyle,
effectiveCursorTelemetry,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
+ cursorClickBounceDuration,
cursorSway,
audioRegions,
borderRadius,
@@ -2431,10 +2639,12 @@ export default function VideoEditor() {
cropRegion,
webcam,
annotationRegions,
+ autoCaptions,
+ autoCaptionSettings,
isPlaying,
- aspectRatio,
exportQuality,
effectiveZoomRegions,
+ ensureSupportedMp4SourceDimensions,
markExportAsSaving,
showExportSuccessToast,
],
@@ -2454,10 +2664,7 @@ export default function VideoEditor() {
setShowExportDropdown(true);
setExportProgress(null);
setExportError(null);
- }, [
- videoPath,
- hasPendingExportSave,
- ]);
+ }, [videoPath, hasPendingExportSave]);
const handleStartExportFromDropdown = useCallback(() => {
const video = videoPlaybackRef.current?.video;
@@ -2606,8 +2813,12 @@ export default function VideoEditor() {
? isExportSaving
? t("editor.exportStatus.saving", "Opening save dialog...")
: isExportFinalizing && typeof exportProgress.renderProgress === "number"
- ? t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", { percent: Math.round(exportProgress.renderProgress) })
- : t("editor.exportStatus.completePercent", "{{percent}}% complete", { percent: Math.round(exportProgress.percentage) })
+ ? t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", {
+ percent: Math.round(exportProgress.renderProgress),
+ })
+ : t("editor.exportStatus.completePercent", "{{percent}}% complete", {
+ percent: Math.round(exportProgress.percentage),
+ })
: t("editor.exportStatus.preparing", "Preparing export...");
if (loading) {
@@ -2624,10 +2835,10 @@ export default function VideoEditor() {
{error}
- Load Project File
+ Open Projects
@@ -2641,7 +2852,9 @@ export default function VideoEditor() {
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
- {projectDisplayName}
+
+ {projectDisplayName}
+
.recordly
- {t("common.actions.load", "Load")}
+
+ {t("editor.project.projects", "Projects")}
+
-
-
+
+
{t("common.actions.save")}
-
+
- {t("common.actions.export", "Export")}
+
+ {t("common.actions.export", "Export")}
+
-
{t("editor.exportStatus.exporting", "Exporting")}
-
{t("editor.exportStatus.renderingFile", "Rendering your file.")}
+
+ {t("editor.exportStatus.exporting", "Exporting")}
+
+
+ {t("editor.exportStatus.renderingFile", "Rendering your file.")}
+
-
{t("common.actions.cancel")}
+
+ {t("common.actions.cancel")}
+
{isExportSaving ? (
@@ -2743,33 +2975,70 @@ export default function VideoEditor() {
) : (
)}
-
- {exportPercentLabel}
-
+ {exportPercentLabel}
) : exportError ? (
-
{t("editor.exportStatus.issue", "Export issue")}
+
+ {t("editor.exportStatus.issue", "Export issue")}
+
{exportError}
{hasPendingExportSave ? (
- {t("editor.actions.saveAgain", "Save Again")}
+
+ {t("editor.actions.saveAgain", "Save Again")}
+
) : null}
- {t("common.actions.close", "Close")}
+
+ {t("common.actions.close", "Close")}
+
) : exportedFilePath ? (
-
{t("editor.exportStatus.complete", "Export complete")}
-
{t("editor.exportStatus.savedSuccessfully", "Your file was saved successfully.")}
-
{exportedFilePath.split("/").pop()}
+
+ {t("editor.exportStatus.complete", "Export complete")}
+
+
+ {t(
+ "editor.exportStatus.savedSuccessfully",
+ "Your file was saved successfully.",
+ )}
+
+
+ {exportedFilePath.split("/").pop()}
+
- {t("editor.actions.showInFolder", "Show In Folder")}
- Done
+
+ {t("editor.actions.showInFolder", "Show In Folder")}
+
+
+ Done
+
) : (
@@ -2823,7 +3092,10 @@ export default function VideoEditor() {
animate={{ scale: isActive ? 1.06 : 1, opacity: isActive ? 1 : 0.82 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
>
-
+
@@ -2846,79 +3118,79 @@ export default function VideoEditor() {
{
- const previewVideo = videoPlaybackRef.current?.video;
- if (previewVideo && previewVideo.videoHeight > 0) {
- return previewVideo.videoWidth / previewVideo.videoHeight;
- }
- return 16 / 9;
- })(),
- ),
- maxWidth: "100%",
- margin: "0 auto",
- boxSizing: "border-box",
- }}
- >
- 0}
- shadowIntensity={shadowIntensity}
- backgroundBlur={backgroundBlur}
- zoomMotionBlur={zoomMotionBlur}
- connectZooms={connectZooms}
- zoomInDurationMs={zoomInDurationMs}
- zoomInOverlapMs={zoomInOverlapMs}
- zoomOutDurationMs={zoomOutDurationMs}
- connectedZoomGapMs={connectedZoomGapMs}
- connectedZoomDurationMs={connectedZoomDurationMs}
- zoomInEasing={zoomInEasing}
- zoomOutEasing={zoomOutEasing}
- connectedZoomEasing={connectedZoomEasing}
- borderRadius={borderRadius}
- padding={padding}
- cropRegion={cropRegion}
- webcam={webcam}
- webcamVideoPath={webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null}
- trimRegions={trimRegions}
- speedRegions={speedRegions}
- annotationRegions={annotationRegions}
- autoCaptions={autoCaptions}
- autoCaptionSettings={autoCaptionSettings}
- selectedAnnotationId={selectedAnnotationId}
- onSelectAnnotation={handleSelectAnnotation}
- onAnnotationPositionChange={handleAnnotationPositionChange}
- onAnnotationSizeChange={handleAnnotationSizeChange}
- cursorTelemetry={effectiveCursorTelemetry}
- showCursor={showCursor}
- cursorStyle={cursorStyle}
- cursorSize={cursorSize}
- cursorSmoothing={cursorSmoothing}
- cursorMotionBlur={cursorMotionBlur}
- cursorClickBounce={cursorClickBounce}
- cursorClickBounceDuration={cursorClickBounceDuration}
- cursorSway={cursorSway}
- volume={previewVolume}
- />
+ className="relative overflow-hidden rounded-[30px]"
+ style={{
+ width: "auto",
+ height: "100%",
+ aspectRatio: getAspectRatioValue(
+ aspectRatio,
+ (() => {
+ const previewVideo = videoPlaybackRef.current?.video;
+ if (previewVideo && previewVideo.videoHeight > 0) {
+ return previewVideo.videoWidth / previewVideo.videoHeight;
+ }
+ return 16 / 9;
+ })(),
+ ),
+ maxWidth: "100%",
+ margin: "0 auto",
+ boxSizing: "border-box",
+ }}
+ >
+ 0}
+ shadowIntensity={shadowIntensity}
+ backgroundBlur={backgroundBlur}
+ zoomMotionBlur={zoomMotionBlur}
+ connectZooms={connectZooms}
+ zoomInDurationMs={zoomInDurationMs}
+ zoomInOverlapMs={zoomInOverlapMs}
+ zoomOutDurationMs={zoomOutDurationMs}
+ connectedZoomGapMs={connectedZoomGapMs}
+ connectedZoomDurationMs={connectedZoomDurationMs}
+ zoomInEasing={zoomInEasing}
+ zoomOutEasing={zoomOutEasing}
+ connectedZoomEasing={connectedZoomEasing}
+ borderRadius={borderRadius}
+ padding={padding}
+ cropRegion={cropRegion}
+ webcam={webcam}
+ webcamVideoPath={webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null}
+ trimRegions={trimRegions}
+ speedRegions={speedRegions}
+ annotationRegions={annotationRegions}
+ autoCaptions={autoCaptions}
+ autoCaptionSettings={autoCaptionSettings}
+ selectedAnnotationId={selectedAnnotationId}
+ onSelectAnnotation={handleSelectAnnotation}
+ onAnnotationPositionChange={handleAnnotationPositionChange}
+ onAnnotationSizeChange={handleAnnotationSizeChange}
+ cursorTelemetry={effectiveCursorTelemetry}
+ showCursor={showCursor}
+ cursorStyle={cursorStyle}
+ cursorSize={cursorSize}
+ cursorSmoothing={cursorSmoothing}
+ cursorMotionBlur={cursorMotionBlur}
+ cursorClickBounce={cursorClickBounce}
+ cursorClickBounceDuration={cursorClickBounceDuration}
+ cursorSway={cursorSway}
+ volume={previewVolume}
+ />
@@ -2992,8 +3264,8 @@ export default function VideoEditor() {
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
- onOpenCropEditor={handleOpenCropEditor}
- isCropped={isCropped}
+ onOpenCropEditor={handleOpenCropEditor}
+ isCropped={isCropped}
/>
@@ -3003,100 +3275,101 @@ export default function VideoEditor() {
{/* Left section: settings panel */}
z.id === selectedZoomId)?.depth : null
- }
- onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
- selectedZoomId={selectedZoomId}
- onZoomDelete={handleZoomDelete}
- selectedTrimId={selectedTrimId}
- onTrimDelete={handleTrimDelete}
- shadowIntensity={shadowIntensity}
- onShadowChange={setShadowIntensity}
- backgroundBlur={backgroundBlur}
- onBackgroundBlurChange={setBackgroundBlur}
- zoomMotionBlur={zoomMotionBlur}
- onZoomMotionBlurChange={setZoomMotionBlur}
- connectZooms={connectZooms}
- onConnectZoomsChange={setConnectZooms}
- zoomInDurationMs={zoomInDurationMs}
- onZoomInDurationMsChange={setZoomInDurationMs}
- zoomInOverlapMs={zoomInOverlapMs}
- onZoomInOverlapMsChange={setZoomInOverlapMs}
- zoomOutDurationMs={zoomOutDurationMs}
- onZoomOutDurationMsChange={setZoomOutDurationMs}
- connectedZoomGapMs={connectedZoomGapMs}
- onConnectedZoomGapMsChange={setConnectedZoomGapMs}
- connectedZoomDurationMs={connectedZoomDurationMs}
- onConnectedZoomDurationMsChange={setConnectedZoomDurationMs}
- zoomInEasing={zoomInEasing}
- onZoomInEasingChange={setZoomInEasing}
- zoomOutEasing={zoomOutEasing}
- onZoomOutEasingChange={setZoomOutEasing}
- connectedZoomEasing={connectedZoomEasing}
- onConnectedZoomEasingChange={setConnectedZoomEasing}
- showCursor={showCursor}
- onShowCursorChange={setShowCursor}
- loopCursor={loopCursor}
- onLoopCursorChange={setLoopCursor}
- cursorStyle={cursorStyle}
- onCursorStyleChange={setCursorStyle}
- cursorSize={cursorSize}
- onCursorSizeChange={setCursorSize}
- cursorSmoothing={cursorSmoothing}
- onCursorSmoothingChange={setCursorSmoothing}
- cursorMotionBlur={cursorMotionBlur}
- onCursorMotionBlurChange={setCursorMotionBlur}
- cursorClickBounce={cursorClickBounce}
- onCursorClickBounceChange={setCursorClickBounce}
- cursorClickBounceDuration={cursorClickBounceDuration}
- onCursorClickBounceDurationChange={setCursorClickBounceDuration}
- cursorSway={cursorSway}
- onCursorSwayChange={setCursorSway}
- borderRadius={borderRadius}
- onBorderRadiusChange={setBorderRadius}
- webcam={webcam}
- onWebcamChange={setWebcam}
- onUploadWebcam={handleUploadWebcam}
- onClearWebcam={handleClearWebcam}
- padding={padding}
- onPaddingChange={setPadding}
- cropRegion={cropRegion}
- onCropChange={setCropRegion}
- aspectRatio={aspectRatio}
- onAspectRatioChange={setAspectRatio}
- selectedAnnotationId={selectedAnnotationId}
- annotationRegions={annotationRegions}
- autoCaptions={autoCaptions}
- autoCaptionSettings={autoCaptionSettings}
- whisperExecutablePath={whisperExecutablePath}
- whisperModelPath={whisperModelPath}
- whisperModelDownloadStatus={whisperModelDownloadStatus}
- whisperModelDownloadProgress={whisperModelDownloadProgress}
- isGeneratingCaptions={isGeneratingCaptions}
- onAutoCaptionSettingsChange={setAutoCaptionSettings}
- onPickWhisperExecutable={handlePickWhisperExecutable}
- onGenerateAutoCaptions={handleGenerateAutoCaptions}
- onClearAutoCaptions={handleClearAutoCaptions}
- onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel}
- onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel}
- onAnnotationContentChange={handleAnnotationContentChange}
- onAnnotationTypeChange={handleAnnotationTypeChange}
- onAnnotationStyleChange={handleAnnotationStyleChange}
- onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
- onAnnotationDelete={handleAnnotationDelete}
- selectedSpeedId={selectedSpeedId}
- selectedSpeedValue={
- selectedSpeedId
- ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
- : null
- }
- onSpeedChange={handleSpeedChange}
- onSpeedDelete={handleSpeedDelete}
+ panelMode="editor"
+ activeEffectSection={activeEffectSection}
+ selected={wallpaper}
+ onWallpaperChange={setWallpaper}
+ selectedZoomDepth={
+ selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
+ }
+ onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
+ selectedZoomId={selectedZoomId}
+ onZoomDelete={handleZoomDelete}
+ selectedTrimId={selectedTrimId}
+ onTrimDelete={handleTrimDelete}
+ shadowIntensity={shadowIntensity}
+ onShadowChange={setShadowIntensity}
+ backgroundBlur={backgroundBlur}
+ onBackgroundBlurChange={setBackgroundBlur}
+ zoomMotionBlur={zoomMotionBlur}
+ onZoomMotionBlurChange={setZoomMotionBlur}
+ connectZooms={connectZooms}
+ onConnectZoomsChange={setConnectZooms}
+ zoomInDurationMs={zoomInDurationMs}
+ onZoomInDurationMsChange={setZoomInDurationMs}
+ zoomInOverlapMs={zoomInOverlapMs}
+ onZoomInOverlapMsChange={setZoomInOverlapMs}
+ zoomOutDurationMs={zoomOutDurationMs}
+ onZoomOutDurationMsChange={setZoomOutDurationMs}
+ connectedZoomGapMs={connectedZoomGapMs}
+ onConnectedZoomGapMsChange={setConnectedZoomGapMs}
+ connectedZoomDurationMs={connectedZoomDurationMs}
+ onConnectedZoomDurationMsChange={setConnectedZoomDurationMs}
+ zoomInEasing={zoomInEasing}
+ onZoomInEasingChange={setZoomInEasing}
+ zoomOutEasing={zoomOutEasing}
+ onZoomOutEasingChange={setZoomOutEasing}
+ connectedZoomEasing={connectedZoomEasing}
+ onConnectedZoomEasingChange={setConnectedZoomEasing}
+ showCursor={showCursor}
+ onShowCursorChange={setShowCursor}
+ loopCursor={loopCursor}
+ onLoopCursorChange={setLoopCursor}
+ cursorStyle={cursorStyle}
+ onCursorStyleChange={setCursorStyle}
+ cursorSize={cursorSize}
+ onCursorSizeChange={setCursorSize}
+ cursorSmoothing={cursorSmoothing}
+ onCursorSmoothingChange={setCursorSmoothing}
+ cursorMotionBlur={cursorMotionBlur}
+ onCursorMotionBlurChange={setCursorMotionBlur}
+ cursorClickBounce={cursorClickBounce}
+ onCursorClickBounceChange={setCursorClickBounce}
+ cursorClickBounceDuration={cursorClickBounceDuration}
+ onCursorClickBounceDurationChange={setCursorClickBounceDuration}
+ cursorSway={cursorSway}
+ onCursorSwayChange={setCursorSway}
+ borderRadius={borderRadius}
+ onBorderRadiusChange={setBorderRadius}
+ webcam={webcam}
+ onWebcamChange={setWebcam}
+ onUploadWebcam={handleUploadWebcam}
+ onClearWebcam={handleClearWebcam}
+ padding={padding}
+ onPaddingChange={setPadding}
+ cropRegion={cropRegion}
+ onCropChange={setCropRegion}
+ aspectRatio={aspectRatio}
+ onAspectRatioChange={setAspectRatio}
+ selectedAnnotationId={selectedAnnotationId}
+ annotationRegions={annotationRegions}
+ autoCaptions={autoCaptions}
+ autoCaptionSettings={autoCaptionSettings}
+ whisperExecutablePath={whisperExecutablePath}
+ whisperModelPath={whisperModelPath}
+ whisperModelDownloadStatus={whisperModelDownloadStatus}
+ whisperModelDownloadProgress={whisperModelDownloadProgress}
+ isGeneratingCaptions={isGeneratingCaptions}
+ onAutoCaptionSettingsChange={setAutoCaptionSettings}
+ onPickWhisperExecutable={handlePickWhisperExecutable}
+ onPickWhisperModel={handlePickWhisperModel}
+ onGenerateAutoCaptions={handleGenerateAutoCaptions}
+ onClearAutoCaptions={handleClearAutoCaptions}
+ onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel}
+ onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel}
+ onAnnotationContentChange={handleAnnotationContentChange}
+ onAnnotationTypeChange={handleAnnotationTypeChange}
+ onAnnotationStyleChange={handleAnnotationStyleChange}
+ onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
+ onAnnotationDelete={handleAnnotationDelete}
+ selectedSpeedId={selectedSpeedId}
+ selectedSpeedValue={
+ selectedSpeedId
+ ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
+ : null
+ }
+ onSpeedChange={handleSpeedChange}
+ onSpeedDelete={handleSpeedDelete}
/>
@@ -3141,8 +3414,26 @@ export default function VideoEditor() {
>
) : null}
-
+ {
+ void handleOpenProjectFromLibrary(projectPath);
+ }}
+ onBrowseProjectFiles={() => {
+ void handleLoadProject();
+ }}
+ onOpenProjectsFolder={() => {
+ void handleOpenProjectsFolder();
+ }}
+ onSaveProjectAs={() => {
+ void handleSaveProjectAs();
+ }}
+ />
+
);
}
diff --git a/src/components/video-editor/autoCaptionSource.ts b/src/components/video-editor/autoCaptionSource.ts
new file mode 100644
index 00000000..c74b1b7c
--- /dev/null
+++ b/src/components/video-editor/autoCaptionSource.ts
@@ -0,0 +1,28 @@
+import { fromFileUrl } from "./projectPersistence";
+
+type AutoCaptionSourceOptions = {
+ videoSourcePath?: string | null;
+ videoPath?: string | null;
+ recordingSessionVideoPath?: string | null;
+ currentVideoPath?: string | null;
+};
+
+export function resolveAutoCaptionSourcePath(options: AutoCaptionSourceOptions): string | null {
+ if (options.videoSourcePath) {
+ return options.videoSourcePath;
+ }
+
+ if (options.videoPath) {
+ return fromFileUrl(options.videoPath);
+ }
+
+ if (options.recordingSessionVideoPath) {
+ return fromFileUrl(options.recordingSessionVideoPath);
+ }
+
+ if (options.currentVideoPath) {
+ return fromFileUrl(options.currentVideoPath);
+ }
+
+ return null;
+}