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..."} +
+
+
+ + + +
+
+
+ {visibleEntries.length > 0 ? ( +
+ {visibleEntries.map((entry) => { + const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null; + return ( + + ); + })} +
+ ) : ( +
+
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. +
+
+ + +
+
+ )} +
+
+
+ ); +} 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 ( ); })} @@ -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({ +
- 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 ? : null} + {isCropped ? ( + + ) : 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.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 })} + > @@ -1232,33 +1397,73 @@ export function SettingsPanel({
-
+
{whisperModelDownloadStatus === "downloading" ? ( - ) : whisperModelPath ? ( - ) : ( - )} - +
- {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 }) + } + > @@ -1288,7 +1503,9 @@ export function SettingsPanel({
-
{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")} - +
-
{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 ? : null} + + {webcam?.sourcePath ? ( + + ) : 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 && ( - )} {trimEnabled && ( - @@ -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 && ( - 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}
@@ -2641,7 +2852,9 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "drag" } as React.CSSProperties} >
- {projectDisplayName} + + {projectDisplayName} + .recordly
- +
-

{t("editor.exportStatus.exporting", "Exporting")}

-

{t("editor.exportStatus.renderingFile", "Rendering your file.")}

+

+ {t("editor.exportStatus.exporting", "Exporting")} +

+

+ {t("editor.exportStatus.renderingFile", "Rendering your file.")} +

- +
{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 ? ( - + ) : null} - +
) : 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()} +

- - + +
) : ( @@ -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; +}