add: first part of video editor refactoring, there is still so long to go.. god

This commit is contained in:
Alan Trebugeais
2026-05-13 00:49:29 +02:00
parent 7bc06c9925
commit 0bffd1bd63
24 changed files with 5158 additions and 3380 deletions
+34 -4
View File
@@ -14,7 +14,8 @@
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
"ffprobe-static": "^3.1.0",
"uiohook-napi": "^1.5.4"
"uiohook-napi": "^1.5.4",
"zustand": "^5.0.13"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
@@ -4506,14 +4507,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -6296,7 +6297,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/dashdash": {
@@ -13649,6 +13650,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+2 -1
View File
@@ -49,7 +49,8 @@
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
"ffprobe-static": "^3.1.0",
"uiohook-napi": "^1.5.4"
"uiohook-napi": "^1.5.4",
"zustand": "^5.0.13"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
import { DownloadSimple as Download } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ExportSettingsMenu } from "@/components/video-editor/ExportSettingsMenu";
type EditorExportMenuLabels = {
export: string;
exporting: string;
renderingFile: string;
cancel: string;
processingAudioEdits: string;
exportIssue: string;
saveAgain: string;
close: string;
exportComplete: string;
savedSuccessfully: string;
showInFolder: string;
done: string;
};
type EditorExportMenuProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenClick: () => void;
isExporting: boolean;
exportError: string | null;
exportedFilePath: string | null | undefined;
hasPendingExportSave: boolean;
onCancelExport: () => void;
onRetrySaveExport: () => void;
onClose: () => void;
onRevealExportedFile: () => void;
isLightningExportInProgress: boolean;
isLegacyExportInProgress: boolean;
onOpenLightningIssues: () => Promise<void>;
isExportPreparing: boolean;
isExportSaving: boolean;
isExportFinalSaveIndeterminate: boolean;
isRenderingAudio: boolean;
exportProgressPercentage: number;
exportPercentLabel: string;
exportRenderSpeedLabel: string | null | undefined;
exportRuntimeLabel: string | null | undefined;
exportNativeSkipLabel: string | null | undefined;
exportSettingsMenuProps: React.ComponentProps<typeof ExportSettingsMenu>;
labels: EditorExportMenuLabels;
};
export function EditorExportMenu(props: EditorExportMenuProps) {
return (
<DropdownMenu open={props.open} onOpenChange={props.onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
onClick={props.onOpenClick}
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
>
<Download className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">{props.labels.export}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="w-[360px] border-none bg-transparent p-0 shadow-none">
{props.isExporting ? (
<div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">{props.labels.exporting}</p>
<p className="text-xs text-muted-foreground">{props.labels.renderingFile}</p>
{props.isLightningExportInProgress ? (
<p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground/70">
PLEASE
<button
type="button"
onClick={() => void props.onOpenLightningIssues()}
className="underline decoration-slate-500/70 underline-offset-2 transition-colors hover:text-foreground"
>
report bugs
</button>
with Lightning export
<span aria-hidden="true">{"\u{1F64F}"}</span>
</p>
) : null}
{props.isLegacyExportInProgress ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Export too slow? Cancel and try Lightning export!
</p>
) : null}
</div>
<Button type="button" variant="outline" onClick={props.onCancelExport} className="h-8 border-red-500/20 bg-red-500/10 px-3 text-xs text-red-400 hover:bg-red-500/20">
{props.labels.cancel}
</Button>
</div>
<div className="h-2 overflow-hidden rounded-full border border-foreground/5 bg-foreground/5">
{props.isExportPreparing || props.isExportSaving || props.isExportFinalSaveIndeterminate ? (
<div className="indeterminate-progress h-full rounded-full bg-transparent" />
) : (
<div className="h-full bg-[#2563EB] transition-all duration-300 ease-out" style={{ width: `${Math.min(props.exportProgressPercentage, 100)}%` }} />
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{props.exportPercentLabel}</p>
{props.isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">{props.labels.processingAudioEdits}</p>
) : props.exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">{props.exportRenderSpeedLabel}</p>
) : null}
{props.exportRuntimeLabel ? <p className="mt-1 text-[11px] text-muted-foreground/70">Path: {props.exportRuntimeLabel}</p> : null}
{props.exportNativeSkipLabel ? <p className="mt-1 text-[11px] text-amber-500/80">{props.exportNativeSkipLabel}</p> : null}
</div>
) : props.exportError ? (
<div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl">
<p className="text-sm font-semibold text-foreground">{props.labels.exportIssue}</p>
{props.exportRuntimeLabel ? <p className="mt-1 text-[11px] text-muted-foreground/70">Path: {props.exportRuntimeLabel}</p> : null}
<p className="mt-1 whitespace-pre-line text-xs leading-relaxed text-muted-foreground">{props.exportError}</p>
<div className="mt-4 flex gap-2">
{props.hasPendingExportSave ? (
<Button type="button" onClick={props.onRetrySaveExport} className="h-8 flex-1 rounded-[5px] bg-[#2563EB] text-xs font-semibold text-white hover:bg-[#2563EB]/92">
{props.labels.saveAgain}
</Button>
) : null}
<Button type="button" variant="outline" onClick={props.onClose} className="h-8 flex-1 border-foreground/10 bg-foreground/5 text-xs text-muted-foreground hover:bg-foreground/10">
{props.labels.close}
</Button>
</div>
</div>
) : props.exportedFilePath ? (
<div className="rounded-2xl border border-foreground/10 bg-editor-surface p-4 text-foreground shadow-2xl">
<p className="text-sm font-semibold text-foreground">{props.labels.exportComplete}</p>
<p className="mt-1 text-xs text-muted-foreground">{props.labels.savedSuccessfully}</p>
{props.exportRuntimeLabel ? <p className="mt-1 text-[11px] text-muted-foreground/70">Path: {props.exportRuntimeLabel}</p> : null}
<p className="mt-3 truncate text-xs text-muted-foreground/70">{props.exportedFilePath.split("/").pop()}</p>
<div className="mt-4 flex gap-2">
<Button type="button" onClick={props.onRevealExportedFile} className="h-8 flex-1 rounded-[5px] bg-[#2563EB] text-xs font-semibold text-white hover:bg-[#2563EB]/92">
{props.labels.showInFolder}
</Button>
<Button type="button" variant="outline" onClick={props.onClose} className="h-8 flex-1 border-foreground/10 bg-foreground/5 text-xs text-muted-foreground hover:bg-foreground/10">
{props.labels.done}
</Button>
</div>
</div>
) : (
<ExportSettingsMenu {...props.exportSettingsMenuProps} className="shadow-2xl" />
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,16 @@
import type React from "react";
type EditorHeaderProps = {
children: React.ReactNode;
};
export function EditorHeader({ children }: EditorHeaderProps) {
return (
<div
className="relative flex h-11 flex-shrink-0 items-center justify-between bg-editor-header/88 px-5 backdrop-blur-md border-b border-foreground/10 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
{children}
</div>
);
}
@@ -0,0 +1,91 @@
import { CaretDown as ChevronDown, Check, Crop } from "@phosphor-icons/react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { useEditorUiState } from "@/components/video-editor/editor/hooks/useVideoEditorStore";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ASPECT_RATIOS,
getAspectRatioLabel,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
type EditorPreviewAreaProps = {
onOpenCropEditor: () => void;
isCropped: boolean;
fallbackVideoAspectRatio: number;
children: React.ReactNode;
};
export function EditorPreviewArea({
onOpenCropEditor,
isCropped,
fallbackVideoAspectRatio,
children,
}: EditorPreviewAreaProps) {
const { aspectRatio, setAspectRatio } = useEditorUiState();
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-center gap-2 py-1.5 flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1"
>
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="bg-editor-surface-alt border-foreground/10">
{ASPECT_RATIOS.map((ratio) => (
<DropdownMenuItem
key={ratio}
onClick={() => setAspectRatio(ratio)}
className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3"
>
<span>{getAspectRatioLabel(ratio)}</span>
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#2563EB]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[1px] h-4 bg-foreground/20" />
<Button
variant="ghost"
size="sm"
onClick={onOpenCropEditor}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1.5"
>
<Crop className="w-3.5 h-3.5" />
<span className="font-medium">Crop</span>
{isCropped ? <span className="h-1.5 w-1.5 rounded-full bg-[#2563EB]" /> : null}
</Button>
</div>
<div className="flex w-full min-h-0 flex-1 items-stretch" style={{ flex: "1 1 auto", margin: "6px 0 0" }}>
<div className="flex min-w-0 flex-1 items-center justify-center px-1">
<div
className="relative overflow-hidden rounded-[30px]"
style={{
width: "auto",
height: "100%",
aspectRatio: getAspectRatioValue(aspectRatio, fallbackVideoAspectRatio),
maxWidth: "100%",
margin: "0 auto",
boxSizing: "border-box",
}}
>
{children}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { UserCircle as User } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useCallback } from "react";
import type React from "react";
import { toast } from "sonner";
import { useEditorSidebarState } from "@/components/video-editor/editor/hooks/useVideoEditorStore";
import { ExtensionIcon } from "@/components/video-editor/ExtensionIcon";
import type { EditorEffectSection } from "@/components/video-editor/types";
export type EditorSidebarSection = {
id: EditorEffectSection;
label: string;
icon: React.ComponentType<{ className?: string; weight?: "fill" | "regular" }> | string;
extensionPath?: string | null;
};
type EditorSidebarProps = {
editorSectionButtons: EditorSidebarSection[];
t: (key: string, fallback?: string) => string;
renderPanel: (helpers: {
onUploadWebcam: () => Promise<void>;
onClearWebcam: () => Promise<void>;
}) => React.ReactNode;
};
export function EditorSidebar({
editorSectionButtons,
t,
renderPanel,
}: EditorSidebarProps) {
const {
activeEffectSection,
setActiveEffectSection,
setWebcam,
syncRecordingSessionWebcam,
defaultWebcamTimeOffsetMs,
} = useEditorSidebarState();
const handleUploadWebcam = useCallback(async () => {
if (!setWebcam || !syncRecordingSessionWebcam) {
return;
}
const result = await window.electronAPI.openVideoFilePicker();
if (!result.success || !result.path) {
return;
}
setWebcam((prev) => ({
...prev,
enabled: true,
sourcePath: result.path ?? null,
timeOffsetMs: defaultWebcamTimeOffsetMs,
}));
await syncRecordingSessionWebcam(result.path, defaultWebcamTimeOffsetMs);
toast.success(t("settings.effects.webcamFootageAdded"));
}, [defaultWebcamTimeOffsetMs, setWebcam, syncRecordingSessionWebcam, t]);
const handleClearWebcam = useCallback(async () => {
if (!setWebcam || !syncRecordingSessionWebcam) {
return;
}
setWebcam((prev) => ({
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: defaultWebcamTimeOffsetMs,
}));
await syncRecordingSessionWebcam(null);
toast.success(t("settings.effects.webcamFootageRemoved"));
}, [defaultWebcamTimeOffsetMs, setWebcam, syncRecordingSessionWebcam, t]);
return (
<div className="flex flex-shrink-0 gap-1.5">
<div className="flex flex-shrink-0 flex-col items-center gap-0.5 px-2 py-2">
{editorSectionButtons.map((section) => {
const isActive = activeEffectSection === section.id;
return (
<div key={section.id} className="flex items-center">
<motion.button
type="button"
onClick={() => setActiveEffectSection(section.id)}
title={section.label}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg outline-none focus:outline-none focus-visible:outline-none"
animate={{ opacity: isActive ? 1 : 0.55 }}
transition={{ duration: 0.14 }}
>
{isActive && (
<motion.span
layoutId="rail-active-bg"
className="absolute inset-0 rounded-lg bg-foreground/[0.08]"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
/>
)}
<motion.span
className="relative z-10"
animate={{
color: isActive ? "#2563EB" : "hsl(var(--foreground))",
}}
transition={{ duration: 0.14 }}
>
{typeof section.icon === "string" ? (
<ExtensionIcon
icon={section.icon}
extensionPath={section.extensionPath}
className="h-[27px] w-[27px]"
/>
) : (
<section.icon
className="h-[27px] w-[27px]"
weight={isActive ? "fill" : "regular"}
/>
)}
</motion.span>
</motion.button>
<div className="ml-1.5 h-1.5 w-1.5 flex-shrink-0">
{isActive && (
<motion.span
layoutId="rail-active-dot"
className="block h-1.5 w-1.5 rounded-full bg-[#2563EB]"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
/>
)}
</div>
</div>
);
})}
<div className="mt-auto flex flex-col items-center gap-0.5 pt-3">
<motion.button
type="button"
onClick={() => toast.info("Account coming soon")}
title="Account"
className="group relative flex h-9 w-9 items-center justify-center rounded-lg text-foreground/55 outline-none transition hover:text-foreground focus:outline-none focus-visible:outline-none"
whileHover={{ opacity: 1 }}
initial={{ opacity: 0.55 }}
>
<motion.span className="absolute inset-0 rounded-lg bg-foreground/[0.04] opacity-0 transition group-hover:opacity-100" />
<User className="relative z-10 h-[22px] w-[22px]" />
</motion.button>
</div>
</div>
{renderPanel({
onUploadWebcam: handleUploadWebcam,
onClearWebcam: handleClearWebcam,
})}
</div>
);
}
@@ -0,0 +1,21 @@
import type React from "react";
import { useEditorUiState } from "@/components/video-editor/editor/hooks/useVideoEditorStore";
type EditorTimelineAreaProps = {
children: React.ReactNode;
};
export function EditorTimelineArea({ children }: EditorTimelineAreaProps) {
const { timelineCollapsed } = useEditorUiState();
return (
<div
className="flex-shrink-0 flex flex-col"
style={{
height: timelineCollapsed ? undefined : "15%",
minHeight: timelineCollapsed ? 0 : 160,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,109 @@
import {
CaretDown as ChevronDown,
CaretUp as ChevronUp,
Pause,
Play,
Plus,
Scissors,
SkipBack,
SkipForward,
SpeakerHigh as Volume2,
SpeakerLow as Volume1,
SpeakerX as VolumeX,
MagicWand as WandSparkles,
MagnifyingGlassPlus as ZoomIn,
} from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type EditorTimelineToolbarProps = {
onAddAnnotation: () => void;
onAddAudio: () => void;
onAddZoom: () => void;
onSuggestZooms: () => void;
onSplitClip: () => void;
timelinePlayheadTimeLabel: string;
timelineDurationLabel: string;
isPlaying: boolean;
onSkipBack: () => void;
onTogglePlayPause: () => void;
onSkipForward: () => void;
timelineCollapsed: boolean;
onToggleTimelineCollapsed: () => void;
previewVolume: number;
onToggleMute: () => void;
onPreviewVolumeChange: (volume: number) => void;
labels: {
addLayer: string;
annotation: string;
audio: string;
splitClip: string;
addZoom: string;
suggestZooms: string;
skipBack: string;
skipForward: string;
expandTimeline: string;
collapseTimeline: string;
muteUnmute: string;
play: string;
pause: string;
};
};
export function EditorTimelineToolbar(props: EditorTimelineToolbarProps) {
return (
<div className="relative flex flex-shrink-0 items-center px-1 py-1">
<div className="z-10 flex min-w-0 flex-1 items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 gap-1 rounded-full border border-foreground/[0.08] bg-foreground/[0.04] px-2.5 text-[11px] text-foreground/65 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.06)] transition-all hover:bg-foreground/[0.08] hover:text-foreground">
<Plus className="w-3.5 h-3.5" />
<span className="font-medium">{props.labels.addLayer}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-editor-surface-alt border-foreground/10">
<DropdownMenuItem onClick={props.onAddAnnotation} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer">{props.labels.annotation}</DropdownMenuItem>
<DropdownMenuItem onClick={props.onAddAudio} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer">{props.labels.audio}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[1px] h-4 bg-foreground/10 mx-1" />
<Button onClick={props.onAddZoom} variant="ghost" size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-[#2563EB]/10 hover:text-[#2563EB]" title={props.labels.addZoom}><ZoomIn className="w-4 h-4" /></Button>
<Button onClick={props.onSuggestZooms} variant="ghost" size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-[#2563EB]/10 hover:text-[#2563EB]" title={props.labels.suggestZooms}><WandSparkles className="w-4 h-4" /></Button>
<Button onClick={props.onSplitClip} variant="ghost" size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" title={props.labels.splitClip}><Scissors className="w-4 h-4" /></Button>
</div>
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-1.5 pointer-events-auto">
<span className="mr-1 text-[10px] font-medium tabular-nums text-muted-foreground">{props.timelinePlayheadTimeLabel}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" title={props.labels.skipBack} onClick={props.onSkipBack}><SkipBack className="w-3.5 h-3.5" weight="fill" /></Button>
<Button variant="ghost" size="icon" className={`h-7 w-7 rounded-full border border-foreground/10 transition-all shadow-[0_8px_18px_rgba(0,0,0,0.18)] ${props.isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"}`} onClick={props.onTogglePlayPause} title={props.isPlaying ? props.labels.pause : props.labels.play}>
{props.isPlaying ? <Pause className="w-3.5 h-3.5" weight="fill" /> : <Play className="w-3.5 h-3.5" weight="fill" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" title={props.labels.skipForward} onClick={props.onSkipForward}><SkipForward className="w-3.5 h-3.5" weight="fill" /></Button>
<span className="text-[10px] font-medium text-muted-foreground/70 tabular-nums ml-1">{props.timelineDurationLabel}</span>
</div>
</div>
<div className="z-10 ml-auto flex items-center gap-2">
<Button variant="ghost" size="icon" title={props.timelineCollapsed ? props.labels.expandTimeline : props.labels.collapseTimeline} className="h-7 w-7 rounded-full text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" onClick={props.onToggleTimelineCollapsed}>
{props.timelineCollapsed ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</Button>
<div className="flex items-center gap-1.5">
<button type="button" className="text-muted-foreground hover:text-foreground transition-colors" title={props.labels.muteUnmute} onClick={props.onToggleMute}>
{props.previewVolume <= 0.001 ? <VolumeX className="w-3.5 h-3.5" /> : props.previewVolume < 0.5 ? <Volume1 className="w-3.5 h-3.5" /> : <Volume2 className="w-3.5 h-3.5" />}
</button>
<div className="relative flex h-7 w-24 select-none items-center overflow-hidden rounded-full border border-foreground/[0.06] bg-editor-bg/80 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.06)]">
<div className="absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08]" style={{ width: props.previewVolume > 0 ? `max(calc(${props.previewVolume * 100}% - 6px), 1.2rem)` : 0 }} />
<div className="pointer-events-none absolute bottom-[18%] top-[18%] z-10 w-[2px] rounded-full bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]" style={{ left: `calc(${props.previewVolume * 100}% - 8px)` }} />
<span className="pointer-events-none relative z-10 pl-2 text-[10px] font-medium text-muted-foreground">{Math.round(props.previewVolume * 100)}%</span>
<input type="range" min="0" max="1" step="0.01" value={props.previewVolume} onChange={(e) => props.onPreviewVolumeChange(Number(e.target.value))} className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { BookmarkSimple, CaretDown as ChevronDown, Check, X } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useVideoEditorPresets } from "@/components/video-editor/editor/hooks";
import { useEditorPresetState } from "@/components/video-editor/editor/hooks/useVideoEditorStore";
import { cn } from "@/lib/utils";
type PresetManagerProps = {
t: (key: string, fallback: string, params?: Record<string, string>) => string;
};
export function PresetManager({ t }: PresetManagerProps) {
const { presetState, presetSetters } = useEditorPresetState();
if (!presetState || !presetSetters) {
return null;
}
const {
editorPresets,
presetPopoverOpen,
setPresetPopoverOpen,
presetNameDraft,
setPresetNameDraft,
currentEditorPreset,
handleApplyEditorPreset,
handleDeleteEditorPreset,
handleSavePresetSubmit,
} = useVideoEditorPresets({
t,
presetState,
presetSetters,
});
return (
<Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
title={t("editor.presets.open", "Open presets")}
aria-label={t("editor.presets.open", "Open presets")}
className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80"
>
<span className="flex items-center gap-1.5">
<BookmarkSimple weight="fill" className="h-4 w-4" />
<span>{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}</span>
</span>
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={10}
className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl"
>
<div className="space-y-3">
<form
onSubmit={(event) => {
event.preventDefault();
handleSavePresetSubmit();
}}
className="space-y-2"
>
<p className="text-[11px] font-medium text-foreground">
{t("editor.presets.saveCurrentAs", "Save current preset as")}
</p>
<div className="flex items-center gap-2">
<Input
value={presetNameDraft}
onChange={(event) => setPresetNameDraft(event.target.value)}
className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm"
placeholder={t("editor.presets.namePlaceholder", "Preset name")}
aria-label={t("editor.presets.namePlaceholder", "Preset name")}
/>
<Button
type="submit"
size="sm"
className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]"
>
{t("common.actions.save", "Save")}
</Button>
</div>
</form>
<div className="space-y-2">
<p className="text-[11px] font-medium text-foreground">
{t("editor.presets.savedList", "Saved presets")}
</p>
<div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar">
{editorPresets.length === 0 ? (
<div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("editor.presets.empty", "No presets yet.")}
</div>
) : (
editorPresets.map((preset) => {
const isActive = preset.id === currentEditorPreset?.id;
return (
<div
key={preset.id}
className={cn(
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors",
isActive
? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground"
: "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
<button
type="button"
onClick={() => handleApplyEditorPreset(preset.id)}
className="flex min-w-0 flex-1 items-center justify-between text-left"
>
<span className="truncate pr-3">{preset.name}</span>
{isActive ? (
<Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" />
) : null}
</button>
<button
type="button"
onClick={() => handleDeleteEditorPreset(preset.id)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
aria-label={t(
"editor.presets.deleteAriaLabel",
"Delete preset {{name}}",
{ name: preset.name },
)}
title={t(
"editor.presets.deleteAriaLabel",
"Delete preset {{name}}",
{ name: preset.name },
)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,12 @@
export * from "./useEditorSettings";
export * from "./useExport";
export * from "./usePlayback";
export * from "./usePresets";
export * from "./useProject";
export * from "./useTimelineState";
export * from "./useTimelineActions";
export * from "./useWhisperCaptions";
export * from "./useVideoEditorPresets";
export * from "./useVideoEditorThumbnail";
export * from "./useVideoEditorProject";
export * from "./useVideoEditorStore";
@@ -0,0 +1,94 @@
import { useCallback, useMemo, useState } from "react";
import {
DEFAULT_CONNECTED_ZOOM_DURATION_MS,
DEFAULT_CONNECTED_ZOOM_EASING,
DEFAULT_CONNECTED_ZOOM_GAP_MS,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_STYLE,
DEFAULT_PADDING,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
type CursorStyle,
type Padding,
type ZoomTransitionEasing,
} from "@/components/video-editor/types";
export interface EditorSettingsState {
wallpaper: string;
shadowIntensity: number;
backgroundBlur: number;
borderRadius: number;
padding: Padding;
cursorStyle: CursorStyle;
cursorSize: number;
cursorSmoothing: number;
cursorMotionBlur: number;
zoomInDurationMs: number;
zoomInOverlapMs: number;
zoomOutDurationMs: number;
connectedZoomGapMs: number;
connectedZoomDurationMs: number;
zoomInEasing: ZoomTransitionEasing;
zoomOutEasing: ZoomTransitionEasing;
connectedZoomEasing: ZoomTransitionEasing;
}
export type PartialEditorSettingsState = Partial<EditorSettingsState>;
export interface UseEditorSettingsResult {
settings: EditorSettingsState;
updateSettings: (partial: PartialEditorSettingsState) => void;
resetSettings: () => void;
}
const DEFAULT_SETTINGS: EditorSettingsState = {
wallpaper: "",
shadowIntensity: 0.67,
backgroundBlur: 0,
borderRadius: 22,
padding: DEFAULT_PADDING,
cursorStyle: DEFAULT_CURSOR_STYLE,
cursorSize: DEFAULT_CURSOR_SIZE,
cursorSmoothing: DEFAULT_CURSOR_SMOOTHING,
cursorMotionBlur: DEFAULT_CURSOR_MOTION_BLUR,
zoomInDurationMs: DEFAULT_ZOOM_IN_DURATION_MS,
zoomInOverlapMs: DEFAULT_ZOOM_IN_OVERLAP_MS,
zoomOutDurationMs: DEFAULT_ZOOM_OUT_DURATION_MS,
connectedZoomGapMs: DEFAULT_CONNECTED_ZOOM_GAP_MS,
connectedZoomDurationMs: DEFAULT_CONNECTED_ZOOM_DURATION_MS,
zoomInEasing: DEFAULT_ZOOM_IN_EASING,
zoomOutEasing: DEFAULT_ZOOM_OUT_EASING,
connectedZoomEasing: DEFAULT_CONNECTED_ZOOM_EASING,
};
export function useEditorSettings(
initialSettings: PartialEditorSettingsState = {},
): UseEditorSettingsResult {
const mergedInitial = useMemo(
() => ({ ...DEFAULT_SETTINGS, ...initialSettings }),
[initialSettings],
);
const [settings, setSettings] = useState<EditorSettingsState>(mergedInitial);
const updateSettings = useCallback((partial: PartialEditorSettingsState) => {
setSettings((prev) => ({ ...prev, ...partial }));
}, []);
const resetSettings = useCallback(() => {
setSettings(mergedInitial);
}, [mergedInitial]);
return useMemo(
() => ({
settings,
updateSettings,
resetSettings,
}),
[settings, updateSettings, resetSettings],
);
}
@@ -0,0 +1,680 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
calculateOutputDimensions,
type ExportBackendPreference,
type ExportEncodingMode,
type ExportFormat,
type ExportMp4FrameRate,
type ExportPipelineModel,
type ExportProgress,
type ExportQuality,
type ExportSettings,
GIF_SIZE_PRESETS,
type GifFrameRate,
type GifSizePreset,
} from "@/lib/exporter";
import {
canUseInMemoryExportSaveFallback,
describeBlockedInMemoryExportSave,
} from "@/lib/exporter/exportSavePolicy";
export interface UseExportOptions<TConfig, TProgress> {
initialConfig: TConfig;
initialProgress: TProgress;
initialError?: string | null;
initialIsExporting?: boolean;
}
export interface UseExportResult<TConfig, TProgress> {
config: TConfig;
setConfig: (next: TConfig) => void;
updateConfig: (patch: Partial<TConfig>) => void;
progress: TProgress;
setProgress: (next: TProgress | ((prev: TProgress) => TProgress)) => void;
error: string | null;
setError: (next: string | null) => void;
isExporting: boolean;
setIsExporting: (next: boolean) => void;
resetFeedback: () => void;
}
export function useExport<TConfig, TProgress>(
options: UseExportOptions<TConfig, TProgress>,
): UseExportResult<TConfig, TProgress> {
const [config, setConfigState] = useState<TConfig>(options.initialConfig);
const [progress, setProgressState] = useState<TProgress>(options.initialProgress);
const [error, setErrorState] = useState<string | null>(options.initialError ?? null);
const [isExporting, setIsExportingState] = useState<boolean>(options.initialIsExporting ?? false);
const setConfig = useCallback((next: TConfig) => {
setConfigState(next);
}, []);
const updateConfig = useCallback((patch: Partial<TConfig>) => {
setConfigState((prev) => ({ ...prev, ...patch }));
}, []);
const setProgress = useCallback((next: TProgress | ((prev: TProgress) => TProgress)) => {
setProgressState((prev) => (typeof next === "function" ? (next as (prev: TProgress) => TProgress)(prev) : next));
}, []);
const setError = useCallback((next: string | null) => {
setErrorState(next);
}, []);
const setIsExporting = useCallback((next: boolean) => {
setIsExportingState(next);
}, []);
const resetFeedback = useCallback(() => {
setProgressState(options.initialProgress);
setErrorState(null);
}, [options.initialProgress]);
return useMemo(
() => ({
config,
setConfig,
updateConfig,
progress,
setProgress,
error,
setError,
isExporting,
setIsExporting,
resetFeedback,
}),
[
config,
setConfig,
updateConfig,
progress,
setProgress,
error,
setError,
isExporting,
setIsExporting,
resetFeedback,
],
);
}
type UseExportUiControllerOptions = {
videoPath: string | null;
videoElement: HTMLVideoElement | null;
hasPendingExportSave: boolean;
exportFormat: ExportFormat;
exportEncodingMode: ExportEncodingMode;
mp4FrameRate: ExportMp4FrameRate;
exportBackendPreference: ExportBackendPreference;
exportPipelineModel: ExportPipelineModel;
exportQuality: ExportQuality;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
isExporting: boolean;
exportProgress: ExportProgress | null;
exportedFilePath: string | undefined;
onRunExport: (settings: ExportSettings) => void;
onClearPendingExportSave: () => void;
onCancelExporter: () => void;
onSetExportProgress: (progress: ExportProgress | null) => void;
onSetExportError: (error: string | null) => void;
onSetExportedFilePath: (path: string | undefined) => void;
onSetIsExporting: (value: boolean) => void;
onOpenLightningIssues: () => Promise<void>;
t: (key: string, fallback?: string, vars?: Record<string, string | number>) => string;
};
export function useExportUiController(options: UseExportUiControllerOptions) {
const [showExportDropdown, setShowExportDropdown] = useState(false);
const handleOpenExportDropdown = useCallback(() => {
if (!options.videoPath) {
toast.error("No video loaded");
return;
}
if (options.hasPendingExportSave) {
setShowExportDropdown(true);
options.onSetExportError(
"Save dialog canceled. Click Save Again to save without re-rendering.",
);
return;
}
setShowExportDropdown(true);
options.onSetExportProgress(null);
options.onSetExportError(null);
}, [options]);
const handleStartExportFromDropdown = useCallback(() => {
const video = options.videoElement;
if (!options.videoPath) {
toast.error("No video loaded");
return;
}
if (!video) {
toast.error("Video not ready");
return;
}
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const gifDimensions = calculateOutputDimensions(
sourceWidth,
sourceHeight,
options.gifSizePreset,
GIF_SIZE_PRESETS,
);
const settings: ExportSettings = {
format: options.exportFormat,
encodingMode: options.exportFormat === "mp4" ? options.exportEncodingMode : undefined,
mp4FrameRate: options.exportFormat === "mp4" ? options.mp4FrameRate : undefined,
backendPreference:
options.exportFormat === "mp4" ? options.exportBackendPreference : undefined,
pipelineModel:
options.exportFormat === "mp4" ? options.exportPipelineModel : undefined,
quality: options.exportFormat === "mp4" ? options.exportQuality : undefined,
gifConfig:
options.exportFormat === "gif"
? {
frameRate: options.gifFrameRate,
loop: options.gifLoop,
sizePreset: options.gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
}
: undefined,
};
options.onSetExportError(null);
options.onSetExportedFilePath(undefined);
setShowExportDropdown(true);
options.onRunExport(settings);
}, [options]);
const handleCancelExport = useCallback(() => {
options.onCancelExporter();
toast.info("Export canceled");
options.onClearPendingExportSave();
setShowExportDropdown(false);
options.onSetIsExporting(false);
options.onSetExportProgress(null);
options.onSetExportError(null);
options.onSetExportedFilePath(undefined);
}, [options]);
const handleExportDropdownClose = useCallback(() => {
options.onClearPendingExportSave();
setShowExportDropdown(false);
options.onSetExportProgress(null);
options.onSetExportError(null);
options.onSetExportedFilePath(undefined);
}, [options]);
const revealExportedFile = useCallback(async () => {
if (!options.exportedFilePath) return;
try {
const result = await window.electronAPI.revealInFolder(options.exportedFilePath);
if (!result.success) {
toast.error(result.error || result.message || "Failed to reveal item in folder.");
}
} catch (error) {
toast.error(`Failed to reveal item in folder: ${String(error)}`);
}
}, [options]);
const isExportSaving = options.exportProgress?.phase === "saving";
const isExportPreparing =
options.isExporting &&
(!options.exportProgress || options.exportProgress.phase === "preparing");
const isExportFinalizing = options.exportProgress?.phase === "finalizing";
const isRenderingAudio =
isExportFinalizing && typeof options.exportProgress?.audioProgress === "number";
const exportFinalizingProgress = isExportFinalizing
? Math.min(
typeof options.exportProgress?.renderProgress === "number"
? options.exportProgress.renderProgress
: (options.exportProgress?.percentage ?? 100),
100,
)
: null;
const exportFinalizingPercent = isExportFinalizing
? Math.round(exportFinalizingProgress ?? 100)
: null;
const isExportMuxingAndSaving =
isExportFinalizing &&
options.exportFormat === "mp4" &&
options.exportPipelineModel === "modern" &&
!isRenderingAudio;
const isExportFinalSaveIndeterminate =
isExportMuxingAndSaving && (exportFinalizingPercent ?? 0) >= 98;
const isLightningExportInProgress =
options.exportFormat === "mp4" &&
options.exportPipelineModel === "modern" &&
(options.isExporting || options.exportProgress !== null);
const shouldSuspendPreviewRendering =
options.isExporting &&
options.exportFormat === "mp4" &&
options.exportPipelineModel === "modern";
const isLegacyExportInProgress =
options.exportFormat === "mp4" &&
options.exportPipelineModel === "legacy" &&
(options.isExporting || options.exportProgress !== null);
const exportRenderSpeedLabel =
!isExportPreparing &&
!isExportFinalizing &&
!isExportSaving &&
typeof options.exportProgress?.renderFps === "number" &&
Number.isFinite(options.exportProgress.renderFps) &&
options.exportProgress.renderFps > 0
? options.t("editor.exportStatus.renderSpeed", "Render speed {{fps}} FPS", {
fps: options.exportProgress.renderFps.toFixed(1),
})
: null;
const exportRuntimeLabel = useMemo(() => {
const renderBackend = options.exportProgress?.renderBackend;
const encodeBackend = options.exportProgress?.encodeBackend;
const encoderName = options.exportProgress?.encoderName;
if (!renderBackend && !encodeBackend && !encoderName) return null;
const rendererLabel =
renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null;
const encoderLabel =
encodeBackend === "ffmpeg"
? "Breeze"
: encodeBackend === "webcodecs"
? "WebCodecs"
: null;
const pathLabel =
rendererLabel && encoderLabel
? `${rendererLabel} + ${encoderLabel}`
: (rendererLabel ?? encoderLabel);
if (!pathLabel) return encoderName ?? null;
return encoderName ? `${pathLabel} (${encoderName})` : pathLabel;
}, [options]);
const exportNativeSkipReasons =
options.exportProgress?.nativeStaticLayoutSkipReasons &&
options.exportProgress.nativeStaticLayoutSkipReasons.length > 0
? options.exportProgress.nativeStaticLayoutSkipReasons
: options.exportProgress?.nativeStaticLayoutSkipReason
? [options.exportProgress.nativeStaticLayoutSkipReason]
: [];
const exportNativeSkipLabel =
exportNativeSkipReasons.length > 0
? `Native skipped: ${exportNativeSkipReasons[0]}${
exportNativeSkipReasons.length > 1
? ` (+${exportNativeSkipReasons.length - 1} more)`
: ""
}`
: null;
const exportPercentLabel = options.exportProgress
? isExportPreparing
? options.t("editor.exportStatus.preparing", "Preparing export...")
: isExportSaving
? options.t("editor.exportStatus.saving", "Opening save dialog...")
: isRenderingAudio
? options.t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", {
percent: Math.round((options.exportProgress.audioProgress ?? 0) * 100),
})
: isExportFinalizing
? options.exportFormat === "mp4" && options.exportPipelineModel === "modern"
? isExportFinalSaveIndeterminate
? options.t(
"editor.exportStatus.muxingAndSaving",
"Muxing audio and saving file...",
)
: options.t(
"editor.exportStatus.muxingAndSavingPercent",
"Muxing and saving {{percent}}%",
{ percent: exportFinalizingPercent ?? 100 },
)
: options.t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", {
percent: exportFinalizingPercent ?? 100,
})
: options.t("editor.exportStatus.completePercent", "{{percent}}% complete", {
percent: Math.round(options.exportProgress.percentage),
})
: options.t("editor.exportStatus.preparing", "Preparing export...");
return {
showExportDropdown,
setShowExportDropdown,
handleOpenExportDropdown,
handleStartExportFromDropdown,
handleCancelExport,
handleExportDropdownClose,
revealExportedFile,
isExportSaving,
isExportPreparing,
isExportFinalizing,
isRenderingAudio,
exportFinalizingProgress,
exportFinalizingPercent,
isExportMuxingAndSaving,
isExportFinalSaveIndeterminate,
isLightningExportInProgress,
shouldSuspendPreviewRendering,
isLegacyExportInProgress,
exportRenderSpeedLabel,
exportRuntimeLabel,
exportNativeSkipLabel,
exportPercentLabel,
};
}
type UseSmokeExportControllerOptions = {
enabled: boolean;
projectPath: string | null;
outputPath: string | null;
error: string | null;
videoPath: string | null;
videoSourcePath: string | null;
cursorTelemetrySourcePath: string | null;
loading: boolean;
isPreviewReady: boolean;
duration: number;
encodingMode: ExportEncodingMode;
onWriteSmokeReport: (outputPath: string | null, payload: Record<string, unknown>) => Promise<void>;
onRunExport: (settings: ExportSettings) => void;
onCloseWindow: () => void;
};
const SMOKE_EXPORT_READY_TIMEOUT_MS = 45000;
export function useSmokeExportController(options: UseSmokeExportControllerOptions) {
const smokeExportStartedRef = useRef(false);
const smokeExportReadyStateRef = useRef<Record<string, unknown>>({});
useEffect(() => {
smokeExportReadyStateRef.current = {
cursorTelemetrySourcePath: options.cursorTelemetrySourcePath,
duration: options.duration,
hasVideoPath: Boolean(options.videoPath),
isPreviewReady: options.isPreviewReady,
loading: options.loading,
projectPath: options.projectPath ?? null,
videoSourcePath: options.videoSourcePath,
};
}, [
options.cursorTelemetrySourcePath,
options.duration,
options.isPreviewReady,
options.loading,
options.projectPath,
options.videoPath,
options.videoSourcePath,
]);
useEffect(() => {
if (!options.enabled) return;
const timeoutId = window.setTimeout(() => {
if (smokeExportStartedRef.current) return;
smokeExportStartedRef.current = true;
void options
.onWriteSmokeReport(options.outputPath, {
success: false,
phase: "ready",
error: `Smoke export did not become ready within ${SMOKE_EXPORT_READY_TIMEOUT_MS}ms.`,
readyState: smokeExportReadyStateRef.current,
})
.finally(() => options.onCloseWindow());
}, SMOKE_EXPORT_READY_TIMEOUT_MS);
return () => window.clearTimeout(timeoutId);
}, [options]);
useEffect(() => {
if (!options.enabled || smokeExportStartedRef.current) return;
if (options.error) {
smokeExportStartedRef.current = true;
void options
.onWriteSmokeReport(options.outputPath, {
success: false,
phase: "load",
error: options.error,
readyState: smokeExportReadyStateRef.current,
})
.finally(() => options.onCloseWindow());
return;
}
if (!options.videoPath || options.loading || !options.isPreviewReady || options.duration <= 0) {
return;
}
if (
options.projectPath &&
options.videoSourcePath &&
options.cursorTelemetrySourcePath !== options.videoSourcePath
) {
return;
}
smokeExportStartedRef.current = true;
options.onRunExport({
format: "mp4",
quality: "good",
encodingMode: options.encodingMode ?? "balanced",
});
}, [options]);
}
const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024;
async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise<string | null> {
if (
typeof window === "undefined" ||
!window.electronAPI?.openExportStream ||
!window.electronAPI?.writeExportStreamChunk ||
!window.electronAPI?.closeExportStream
) {
return null;
}
const openResult = await window.electronAPI.openExportStream({ extension });
if (!openResult.success || !openResult.streamId || !openResult.tempPath) {
throw new Error(openResult.error || "Failed to open export stream");
}
const { streamId } = openResult;
let position = 0;
try {
while (position < blob.size) {
const chunk = blob.slice(position, position + EXPORT_BLOB_STREAM_CHUNK_BYTES);
const chunkBuffer = await chunk.arrayBuffer();
const writeResult = await window.electronAPI.writeExportStreamChunk(
streamId,
position,
new Uint8Array(chunkBuffer),
);
if (!writeResult.success) {
throw new Error(writeResult.error || "Failed to write export stream chunk");
}
position += chunkBuffer.byteLength;
}
const closeResult = await window.electronAPI.closeExportStream(streamId);
if (!closeResult.success || !closeResult.tempPath) {
throw new Error(closeResult.error || "Failed to close export stream");
}
return closeResult.tempPath;
} catch (error) {
try {
await window.electronAPI.closeExportStream(streamId, { abort: true });
} catch {}
throw error;
}
}
export function useExportExecutionController(options: any) {
const exporterRef = useRef<any>(null);
const pendingExportSaveRef = useRef<any>(null);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const clearPendingExportSave = useCallback(() => {
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
setHasPendingExportSave(false);
if (pending?.tempFilePath && typeof window !== "undefined") {
void window.electronAPI.discardExportedTemp?.(pending.tempFilePath);
}
}, []);
const markExportAsSaving = useCallback(() => {
options.setExportProgress((previous: any) => ({
currentFrame: previous?.totalFrames ?? previous?.currentFrame ?? 1,
totalFrames: previous?.totalFrames ?? previous?.currentFrame ?? 1,
percentage: 100,
estimatedTimeRemaining: 0,
renderFps: previous?.renderFps,
renderBackend: previous?.renderBackend,
encodeBackend: previous?.encodeBackend,
encoderName: previous?.encoderName,
phase: "saving",
}));
}, [options]);
const saveBlobExport = useCallback(async (blob: Blob, fileName: string, outputPath: string | null = null) => {
const extension = fileName.split(".").pop()?.toLowerCase() || "bin";
const hasExportStreamApi =
typeof window !== "undefined" &&
typeof window.electronAPI?.openExportStream === "function" &&
typeof window.electronAPI?.writeExportStreamChunk === "function" &&
typeof window.electronAPI?.closeExportStream === "function";
let streamError: unknown = null;
try {
const tempFilePath = await streamExportBlobToTempFile(blob, extension);
if (tempFilePath) {
return {
saveResult: await window.electronAPI.finalizeExportedVideo({
tempPath: tempFilePath,
fileName,
outputPath,
}),
pendingSave: { fileName, tempFilePath },
};
}
} catch (error) {
streamError = error;
}
if (!canUseInMemoryExportSaveFallback({ blobSize: blob.size, extension, hasExportStreamApi })) {
const message = describeBlockedInMemoryExportSave({ blobSize: blob.size, extension });
console.error("[export] Refusing in-memory blob save fallback", { fileName, streamError });
throw new Error(message);
}
const arrayBuffer = await blob.arrayBuffer();
return {
saveResult: outputPath
? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath)
: await window.electronAPI.saveExportedVideo(arrayBuffer, fileName),
pendingSave: { fileName, arrayBuffer },
};
}, []);
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!options.videoPath) return toast.error("No video loaded");
const video = options.videoPlaybackRef.current?.video;
if (!video) return toast.error("Video not ready");
options.setIsExporting(true);
options.setExportProgress(null);
options.setExportError(null);
clearPendingExportSave();
options.onEmitExportEvent?.({ type: "export:start" });
let keepExportDialogOpen = false;
try {
const wasPlaying = options.isPlaying;
const restoreTime = video.currentTime;
if (wasPlaying) options.videoPlaybackRef.current?.pause();
const result = await options.runExportPipeline({
settings,
video,
exporterRef,
saveBlobExport,
markExportAsSaving,
setHasPendingExportSave,
pendingExportSaveRef,
setKeepExportDialogOpen: (v: boolean) => {
keepExportDialogOpen = v;
},
setExportProgress: options.setExportProgress,
setExportError: options.setExportError,
setExportedFilePath: options.setExportedFilePath,
showExportSuccessToast: options.showExportSuccessToast,
showExportErrorToast: options.showExportErrorToast,
writeSmokeExportReport: options.writeSmokeExportReport,
closeWindow: options.closeWindow,
});
if (result?.restorePlayback) {
if (wasPlaying) options.videoPlaybackRef.current?.play();
else video.currentTime = restoreTime;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
options.setExportError(errorMessage);
options.showExportErrorToast(`Export failed: ${errorMessage}`);
keepExportDialogOpen = true;
} finally {
options.onEmitExportEvent?.({ type: "export:complete" });
options.setIsExporting(false);
exporterRef.current = null;
options.setShowExportDropdown(keepExportDialogOpen);
options.remountPreview();
}
}, [options, clearPendingExportSave, markExportAsSaving, saveBlobExport]);
const handleRetrySaveExport = useCallback(async () => {
const pendingSave = pendingExportSaveRef.current;
if (!pendingSave) return;
let saveResult: any;
if (pendingSave.tempFilePath) {
saveResult = await window.electronAPI.finalizeExportedVideo({
tempPath: pendingSave.tempFilePath,
fileName: pendingSave.fileName,
outputPath: null,
});
} else if (pendingSave.arrayBuffer) {
saveResult = await window.electronAPI.saveExportedVideo(
pendingSave.arrayBuffer,
pendingSave.fileName,
);
} else {
saveResult = { success: false, message: "No pending export to save" };
}
if (saveResult.canceled) {
options.setExportError("Save dialog canceled. Click Save Again to save without re-rendering.");
toast.info("Save canceled. You can try again.");
return;
}
if (saveResult.success && saveResult.path) {
pendingExportSaveRef.current = null;
setHasPendingExportSave(false);
options.setExportError(null);
options.setExportedFilePath(saveResult.path);
options.showExportSuccessToast(saveResult.path);
options.setShowExportDropdown(true);
return;
}
const errorMessage = saveResult.message || "Failed to save video";
options.setExportError(errorMessage);
toast.error(errorMessage);
}, [options]);
const cancelExport = useCallback(() => {
exporterRef.current?.cancel?.();
}, []);
useEffect(() => {
return () => {
exporterRef.current?.cancel?.();
exporterRef.current = null;
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
if (pending?.tempFilePath && typeof window !== "undefined") {
void window.electronAPI.discardExportedTemp?.(pending.tempFilePath);
}
};
}, []);
return {
handleExport,
handleRetrySaveExport,
clearPendingExportSave,
hasPendingExportSave,
cancelExport,
setHasPendingExportSave,
setPendingExportSaveRef: (value: any) => {
pendingExportSaveRef.current = value;
},
};
}
@@ -0,0 +1,110 @@
import { useCallback, useMemo, useState } from "react";
export interface UsePlaybackOptions {
initialTime?: number;
initialDuration?: number;
initialIsPlaying?: boolean;
initialVolume?: number;
initialMuted?: boolean;
}
export interface UsePlaybackResult {
currentTime: number;
duration: number;
isPlaying: boolean;
volume: number;
isMuted: boolean;
setDuration: (nextDuration: number) => void;
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (nextTime: number) => void;
skip: (deltaMs: number) => void;
setVolume: (nextVolume: number) => void;
toggleMute: () => void;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function usePlayback(options: UsePlaybackOptions = {}): UsePlaybackResult {
const [currentTime, setCurrentTime] = useState(options.initialTime ?? 0);
const [duration, setDurationState] = useState(options.initialDuration ?? 0);
const [isPlaying, setIsPlaying] = useState(options.initialIsPlaying ?? false);
const [volume, setVolumeState] = useState(clamp(options.initialVolume ?? 1, 0, 1));
const [isMuted, setIsMuted] = useState(options.initialMuted ?? false);
const setDuration = useCallback((nextDuration: number) => {
const safeDuration = Math.max(0, nextDuration);
setDurationState(safeDuration);
setCurrentTime((prev) => clamp(prev, 0, safeDuration));
}, []);
const play = useCallback(() => {
setIsPlaying(true);
}, []);
const pause = useCallback(() => {
setIsPlaying(false);
}, []);
const togglePlayPause = useCallback(() => {
setIsPlaying((prev) => !prev);
}, []);
const seek = useCallback(
(nextTime: number) => {
setCurrentTime(clamp(nextTime, 0, duration));
},
[duration],
);
const skip = useCallback(
(deltaMs: number) => {
setCurrentTime((prev) => clamp(prev + deltaMs, 0, duration));
},
[duration],
);
const setVolume = useCallback((nextVolume: number) => {
setVolumeState(clamp(nextVolume, 0, 1));
}, []);
const toggleMute = useCallback(() => {
setIsMuted((prev) => !prev);
}, []);
return useMemo(
() => ({
currentTime,
duration,
isPlaying,
volume,
isMuted,
setDuration,
play,
pause,
togglePlayPause,
seek,
skip,
setVolume,
toggleMute,
}),
[
currentTime,
duration,
isPlaying,
volume,
isMuted,
setDuration,
play,
pause,
togglePlayPause,
seek,
skip,
setVolume,
toggleMute,
],
);
}
@@ -0,0 +1,75 @@
import { useCallback, useMemo, useState } from "react";
import {
loadEditorPresets,
saveEditorPresets,
type EditorPreset,
type EditorPresetSnapshot,
} from "@/components/video-editor/editorPreferences";
export interface UsePresetsResult {
presets: EditorPreset[];
savePreset: (name: string, snapshot: EditorPresetSnapshot) => EditorPreset | null;
deletePreset: (id: string) => boolean;
applyPreset: (id: string) => EditorPresetSnapshot | null;
}
export function usePresets(): UsePresetsResult {
const [presets, setPresets] = useState<EditorPreset[]>(() => loadEditorPresets());
const savePreset = useCallback((name: string, snapshot: EditorPresetSnapshot) => {
const normalizedName = name.trim().replace(/\s+/g, " ");
if (normalizedName.length === 0) {
return null;
}
const now = new Date().toISOString();
const created: EditorPreset = {
id: crypto.randomUUID(),
name: normalizedName,
createdAt: now,
updatedAt: now,
snapshot,
};
let saved = false;
setPresets((prev) => {
const next = [created, ...prev].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
saved = saveEditorPresets(next);
return saved ? next : prev;
});
return saved ? created : null;
}, []);
const deletePreset = useCallback((id: string) => {
let removed = false;
let persisted = false;
setPresets((prev) => {
const next = prev.filter((preset) => preset.id !== id);
removed = next.length !== prev.length;
if (!removed) {
return prev;
}
persisted = saveEditorPresets(next);
return persisted ? next : prev;
});
return removed && persisted;
}, []);
const applyPreset = useCallback(
(id: string) => presets.find((preset) => preset.id === id)?.snapshot ?? null,
[presets],
);
return useMemo(
() => ({
presets,
savePreset,
deletePreset,
applyPreset,
}),
[presets, savePreset, deletePreset, applyPreset],
);
}
@@ -0,0 +1,746 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
export interface EditorProjectFile {
projectId: string;
projectName: string;
videoPath: string;
projectPath: string | null;
path?: string;
name?: string;
updatedAt?: number;
thumbnailPath?: string | null;
isCurrent?: boolean;
isInProjectsDirectory?: boolean;
}
export interface UseProjectOptions<TSnapshot> {
initialProject?: Partial<EditorProjectFile>;
initialSnapshot: TSnapshot;
autosaveDelayMs?: number;
onSave?: (payload: { project: EditorProjectFile; snapshot: TSnapshot }) => Promise<void> | void;
onLoad?: (
projectPath: string,
) => Promise<{ project: EditorProjectFile; snapshot: TSnapshot } | null>;
onRefreshLibrary?: () => Promise<EditorProjectFile[]>;
}
export interface UseProjectResult<TSnapshot> {
project: EditorProjectFile;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
lastSavedAt: string | null;
projectLibrary: EditorProjectFile[];
updateProject: (patch: Partial<EditorProjectFile>, options?: { markDirty?: boolean }) => void;
registerSnapshotProvider: (provider: () => TSnapshot) => void;
saveProject: () => Promise<boolean>;
loadProject: (projectPath: string) => Promise<TSnapshot | null>;
addToLibrary: (entry: EditorProjectFile) => void;
setProjectLibrary: (entries: EditorProjectFile[]) => void;
removeFromLibrary: (projectId: string) => void;
refreshProjectLibrary: () => Promise<EditorProjectFile[]>;
markDirty: () => void;
clearDirty: () => void;
}
function getProjectErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error.replace(/^Error:\s*/i, "");
}
return "Something went wrong";
}
export interface UseProjectControllerOptions<TSnapshot, TProjectData> {
projectManager: UseProjectResult<TSnapshot>;
projectDisplayName: string;
currentSourcePath: string | null;
currentProjectPath: string | null;
currentProjectSnapshot: TProjectData | null;
currentPersistedEditorState: TProjectData extends { editor: infer TEditor } ? TEditor : never;
lastSavedProjectId: string | null;
captureProjectThumbnail: () => Promise<string | null | undefined>;
remountPreview: () => void;
setCurrentProjectPath: (path: string | null) => void;
setLastSavedSnapshot: (snapshot: TProjectData | null) => void;
createProjectData: (
sourcePath: string,
editorState: TProjectData extends { editor: infer TEditor } ? TEditor : never,
projectId: string | null,
) => TProjectData;
cloneStructured: <T>(value: T) => T;
applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>;
onMenuLoadProject: (handler: () => void) => (() => void) | undefined;
onMenuSaveProject: (handler: () => void) => (() => void) | undefined;
onMenuSaveProjectAs: (handler: () => void) => (() => void) | undefined;
onRequestSaveBeforeClose: (
handler: () => Promise<boolean>,
) => (() => void) | undefined;
}
type InitializeEditorProjectStateOptions = {
smokeExportEnabled: boolean;
smokeExportProjectPath: string | null;
smokeExportInputPath: string | null;
smokeExportWebcamInputPath: string | null;
smokeExportWebcamShadow: number | undefined;
smokeExportWebcamSize: number | undefined;
devOpenRecordingInputPath: string | null;
devOpenRecordingWebcamInputPath: string | null;
autoApplyFreshRecordingAutoZooms: boolean;
initialEditorPreferences: {
padding: unknown;
borderRadius: number;
aspectRatio: unknown;
exportFormat: unknown;
mp4FrameRate: unknown;
exportQuality: unknown;
exportEncodingMode: unknown;
exportBackendPreference: unknown;
exportPipelineModel: unknown;
gifFrameRate: unknown;
gifLoop: boolean;
gifSizePreset: unknown;
};
fromFileUrl: (value: string) => string;
resolveVideoUrl: (value: string) => Promise<string>;
applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>;
applySessionPresentation: (session: any) => void;
setVideoSourcePath: (value: string) => void;
setVideoPath: (value: string) => void;
setCurrentProjectPath: (value: string | null) => void;
setLastSavedSnapshot: (value: null) => void;
setPendingFreshRecordingAutoZoomPath: (value: string | null) => void;
setWebcam: (update: (prev: any) => any) => void;
setError: (value: string | null) => void;
setLoading: (value: boolean) => void;
setPadding: (value: any) => void;
setBorderRadius: (value: number) => void;
setAspectRatio: (value: any) => void;
setExportFormat: (value: any) => void;
setMp4FrameRate: (value: any) => void;
setExportQuality: (value: any) => void;
setExportEncodingMode: (value: any) => void;
setExportBackendPreference: (value: any) => void;
setExportPipelineModel: (value: any) => void;
setGifFrameRate: (value: any) => void;
setGifLoop: (value: boolean) => void;
setGifSizePreset: (value: any) => void;
defaultWebcamTimeOffsetMs: number;
};
export function useProjectBootstrapController() {
const initializeEditorProjectState = useCallback(
async (options: InitializeEditorProjectStateOptions) => {
try {
if (options.smokeExportEnabled && options.smokeExportProjectPath) {
const projectResult = await window.electronAPI.openProjectFileAtPath(
options.smokeExportProjectPath,
);
if (!projectResult.success || !projectResult.project) {
options.setError(
`Smoke export failed to load project ${options.smokeExportProjectPath}: ${
projectResult.error || projectResult.message || "unknown error"
}`,
);
return;
}
const restored = await options.applyLoadedProject(
projectResult.project,
projectResult.path ?? options.smokeExportProjectPath,
);
if (!restored) {
options.setError(
`Smoke export could not apply project ${options.smokeExportProjectPath}`,
);
return;
}
options.setError(null);
return;
}
if (!options.smokeExportEnabled && options.devOpenRecordingInputPath) {
const sourcePath = options.fromFileUrl(options.devOpenRecordingInputPath);
const sourceVideoUrl = await options.resolveVideoUrl(sourcePath);
const webcamSourcePath = options.devOpenRecordingWebcamInputPath
? options.fromFileUrl(options.devOpenRecordingWebcamInputPath)
: null;
options.setVideoSourcePath(sourcePath);
options.setVideoPath(sourceVideoUrl);
options.setCurrentProjectPath(null);
options.setLastSavedSnapshot(null);
options.setPendingFreshRecordingAutoZoomPath(
options.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null,
);
options.setWebcam((prev) => ({
...prev,
enabled: Boolean(webcamSourcePath),
sourcePath: webcamSourcePath,
timeOffsetMs: options.defaultWebcamTimeOffsetMs,
}));
options.setError(null);
return;
}
if (options.smokeExportEnabled) {
if (!options.smokeExportInputPath) {
options.setError("Smoke export input path is missing.");
return;
}
const sourcePath = options.fromFileUrl(options.smokeExportInputPath);
const sourceVideoUrl = await options.resolveVideoUrl(sourcePath);
const smokeWebcamSourcePath = options.smokeExportWebcamInputPath
? options.fromFileUrl(options.smokeExportWebcamInputPath)
: null;
options.setVideoSourcePath(sourcePath);
options.setVideoPath(sourceVideoUrl);
options.setCurrentProjectPath(null);
options.setLastSavedSnapshot(null);
options.setPendingFreshRecordingAutoZoomPath(null);
options.setWebcam((prev) => ({
...prev,
enabled: !!smokeWebcamSourcePath,
sourcePath: smokeWebcamSourcePath,
timeOffsetMs: options.defaultWebcamTimeOffsetMs,
shadow:
options.smokeExportWebcamShadow === undefined
? prev.shadow
: options.smokeExportWebcamShadow,
size:
options.smokeExportWebcamSize === undefined
? prev.size
: options.smokeExportWebcamSize,
}));
options.setError(null);
return;
}
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await options.applyLoadedProject(
currentProjectResult.project,
currentProjectResult.path ?? null,
);
if (restored) {
options.setPadding(options.initialEditorPreferences.padding);
options.setBorderRadius(options.initialEditorPreferences.borderRadius);
options.setAspectRatio(options.initialEditorPreferences.aspectRatio);
options.setExportFormat(options.initialEditorPreferences.exportFormat);
options.setMp4FrameRate(options.initialEditorPreferences.mp4FrameRate);
options.setExportQuality(options.initialEditorPreferences.exportQuality);
options.setExportEncodingMode(
options.initialEditorPreferences.exportEncodingMode,
);
options.setExportBackendPreference(
options.initialEditorPreferences.exportBackendPreference,
);
options.setExportPipelineModel(
options.initialEditorPreferences.exportPipelineModel,
);
options.setGifFrameRate(options.initialEditorPreferences.gifFrameRate);
options.setGifLoop(options.initialEditorPreferences.gifLoop);
options.setGifSizePreset(options.initialEditorPreferences.gifSizePreset);
return;
}
}
const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
if (sessionResult?.success && sessionResult.session?.videoPath) {
const sourcePath = options.fromFileUrl(sessionResult.session.videoPath);
const sourceVideoUrl = await options.resolveVideoUrl(sourcePath);
options.setVideoSourcePath(sourcePath);
options.setVideoPath(sourceVideoUrl);
options.setCurrentProjectPath(null);
options.setLastSavedSnapshot(null);
options.setPendingFreshRecordingAutoZoomPath(
options.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null,
);
options.applySessionPresentation(sessionResult.session);
options.setWebcam((prev) => ({
...prev,
enabled: Boolean(sessionResult.session?.webcamPath),
sourcePath: sessionResult.session?.webcamPath ?? null,
timeOffsetMs:
sessionResult.session?.timeOffsetMs ??
options.defaultWebcamTimeOffsetMs,
}));
return;
}
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
const sourcePath = options.fromFileUrl(result.path);
const sourceVideoUrl = await options.resolveVideoUrl(sourcePath);
options.setVideoSourcePath(sourcePath);
options.setVideoPath(sourceVideoUrl);
options.setCurrentProjectPath(null);
options.setLastSavedSnapshot(null);
options.setPendingFreshRecordingAutoZoomPath(null);
options.applySessionPresentation(null);
options.setWebcam((prev) => ({
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: options.defaultWebcamTimeOffsetMs,
}));
} else {
options.setError("No video to load. Please record or select a video.");
}
} catch (err) {
options.setError(`Error loading video: ${String(err)}`);
} finally {
options.setLoading(false);
}
},
[],
);
return { initializeEditorProjectState };
}
export function useProjectController<TSnapshot, TProjectData>(
options: UseProjectControllerOptions<TSnapshot, TProjectData>,
) {
const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
const [isEditingProjectName, setIsEditingProjectName] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
const [isSavingProjectName, setIsSavingProjectName] = useState(false);
const [projectSaveQueue, setProjectSaveQueue] = useState<Promise<void>>(Promise.resolve());
useEffect(() => {
if (!isEditingProjectName) {
setProjectNameDraft(options.projectDisplayName);
}
}, [isEditingProjectName, options.projectDisplayName]);
const queueProjectSave = useCallback((task: () => Promise<boolean>) => {
const run = projectSaveQueue.catch(() => undefined).then(task);
setProjectSaveQueue(run.then(() => undefined).catch(() => undefined));
return run;
}, [projectSaveQueue]);
const saveProject = useCallback(
async (forceSaveAs: boolean, saveOpts?: { silent?: boolean }) => {
return queueProjectSave(async () => {
if (!options.currentSourcePath) {
if (!saveOpts?.silent) {
toast.error("No video loaded");
}
return false;
}
try {
const projectData =
options.currentProjectSnapshot &&
(options.currentProjectSnapshot as { videoPath?: string }).videoPath ===
options.currentSourcePath
? options.currentProjectSnapshot
: options.createProjectData(
options.currentSourcePath,
options.currentPersistedEditorState,
options.lastSavedProjectId,
);
const fileNameBase =
options.currentSourcePath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
let targetProjectPath = forceSaveAs
? undefined
: (options.currentProjectPath ?? undefined);
if (!forceSaveAs && !targetProjectPath) {
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (activeProjectResult.success && activeProjectResult.path) {
targetProjectPath = activeProjectResult.path;
options.setCurrentProjectPath(activeProjectResult.path);
}
}
const thumbnailDataUrl = await options.captureProjectThumbnail();
const result = await window.electronAPI.saveProjectFile(
projectData as never,
fileNameBase,
targetProjectPath,
thumbnailDataUrl,
);
if (result.canceled) {
if (!saveOpts?.silent) {
toast.info("Project save canceled");
}
return false;
}
if (!result.success) {
if (!saveOpts?.silent) {
toast.error(result.message || "Failed to save project");
}
return false;
}
if (result.path) {
options.setCurrentProjectPath(result.path);
}
options.setLastSavedSnapshot(
options.cloneStructured(
options.createProjectData(
(options.currentSourcePath as string),
(options.currentPersistedEditorState as never),
result.projectId ??
((projectData as { projectId?: string | null }).projectId ?? null),
),
),
);
await options.projectManager.refreshProjectLibrary();
if (!saveOpts?.silent) {
toast.success(`Project saved to ${result.path}`);
}
return true;
} finally {
options.remountPreview();
}
});
},
[options, queueProjectSave],
);
const saveProjectWithName = useCallback(
async (projectName: string) => {
const trimmedProjectName = projectName.trim();
if (!trimmedProjectName) {
toast.error("Project name is required");
return false;
}
if (!options.currentSourcePath) {
toast.error("No video loaded");
return false;
}
try {
const projectData =
options.currentProjectSnapshot &&
(options.currentProjectSnapshot as { videoPath?: string }).videoPath ===
options.currentSourcePath
? options.currentProjectSnapshot
: options.createProjectData(
options.currentSourcePath,
options.currentPersistedEditorState,
options.lastSavedProjectId,
);
const thumbnailDataUrl = await options.captureProjectThumbnail();
const result = await window.electronAPI.saveProjectFileNamed(
projectData as never,
trimmedProjectName,
thumbnailDataUrl,
);
if (result.canceled) {
toast.info("Project save canceled");
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return false;
}
if (result.path) {
options.setCurrentProjectPath(result.path);
}
options.setLastSavedSnapshot(
options.cloneStructured(
options.createProjectData(
options.currentSourcePath,
options.currentPersistedEditorState,
result.projectId ??
((projectData as { projectId?: string | null }).projectId ?? null),
),
),
);
await options.projectManager.refreshProjectLibrary();
toast.success(result.path ? `Project saved to ${result.path}` : "Project saved");
return true;
} finally {
options.remountPreview();
}
},
[options],
);
const closeProjectNameEditor = useCallback(() => {
setProjectNameDraft(options.projectDisplayName);
setIsEditingProjectName(false);
}, [options.projectDisplayName]);
const handleProjectNameSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const trimmedProjectName = projectNameDraft.trim();
if (!trimmedProjectName) {
closeProjectNameEditor();
return;
}
setIsSavingProjectName(true);
let saved = false;
try {
saved = await saveProjectWithName(trimmedProjectName);
} catch (error) {
toast.error(getProjectErrorMessage(error));
} finally {
setIsSavingProjectName(false);
}
if (saved) {
setIsEditingProjectName(false);
}
},
[closeProjectNameEditor, projectNameDraft, saveProjectWithName],
);
const handleOpenProjectFromLibrary = useCallback(
async (projectPath: string) => {
const loadedProject = await options.projectManager.loadProject(projectPath);
if (!loadedProject) {
toast.error("Failed to load project");
return;
}
const restored = await options.applyLoadedProject(loadedProject, projectPath);
if (!restored) {
toast.error("Invalid project file format");
return;
}
setProjectBrowserOpen(false);
await options.projectManager.refreshProjectLibrary();
toast.success(`Project loaded from ${projectPath}`);
},
[options],
);
const handleOpenProjectBrowser = useCallback(async () => {
if (projectBrowserOpen) {
setProjectBrowserOpen(false);
return;
}
await options.projectManager.refreshProjectLibrary();
setProjectBrowserOpen(true);
}, [options.projectManager, projectBrowserOpen]);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
const handleSaveProjectAs = useCallback(async () => {
const saved = await saveProject(true);
if (saved) {
setProjectBrowserOpen(false);
}
}, [saveProject]);
useEffect(() => {
const removeLoadListener = options.onMenuLoadProject(() => {
void handleOpenProjectBrowser();
});
const removeSaveListener = options.onMenuSaveProject(() => {
void handleSaveProject();
});
const removeSaveAsListener = options.onMenuSaveProjectAs(() => {
void handleSaveProjectAs();
});
return () => {
removeLoadListener?.();
removeSaveListener?.();
removeSaveAsListener?.();
};
}, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs, options]);
useEffect(() => {
const cleanup = options.onRequestSaveBeforeClose(async () => saveProject(false));
return () => cleanup?.();
}, [options, saveProject]);
return {
projectBrowserOpen,
setProjectBrowserOpen,
isEditingProjectName,
setIsEditingProjectName,
projectNameDraft,
setProjectNameDraft,
isSavingProjectName,
closeProjectNameEditor,
handleProjectNameSubmit,
handleOpenProjectFromLibrary,
handleOpenProjectBrowser,
handleSaveProject,
handleSaveProjectAs,
saveProject,
saveProjectWithName,
};
}
function normalizeProjectFile(input?: Partial<EditorProjectFile>): EditorProjectFile {
return {
projectId: input?.projectId ?? crypto.randomUUID(),
projectName: input?.projectName ?? "Untitled Project",
videoPath: input?.videoPath ?? "",
projectPath: input?.projectPath ?? null,
};
}
export function useProject<TSnapshot>(
options: UseProjectOptions<TSnapshot>,
): UseProjectResult<TSnapshot> {
const [project, setProject] = useState(() => normalizeProjectFile(options.initialProject));
const [projectLibrary, setProjectLibrary] = useState<EditorProjectFile[]>([]);
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
const snapshotProviderRef = useRef<() => TSnapshot>(() => options.initialSnapshot);
const autosaveDelayMs = options.autosaveDelayMs ?? 1200;
const registerSnapshotProvider = useCallback((provider: () => TSnapshot) => {
snapshotProviderRef.current = provider;
}, []);
const saveProject = useCallback(async () => {
if (!options.onSave) {
setIsDirty(false);
return true;
}
setIsSaving(true);
setSaveError(null);
try {
await options.onSave({
project,
snapshot: snapshotProviderRef.current(),
});
setIsDirty(false);
setLastSavedAt(new Date().toISOString());
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Failed to save project");
return false;
} finally {
setIsSaving(false);
}
}, [options, project]);
const loadProject = useCallback(
async (projectPath: string) => {
if (!options.onLoad) {
return null;
}
const loaded = await options.onLoad(projectPath);
if (!loaded) {
return null;
}
setProject(loaded.project);
setIsDirty(false);
setSaveError(null);
return loaded.snapshot;
},
[options],
);
const updateProject = useCallback(
(patch: Partial<EditorProjectFile>, options?: { markDirty?: boolean }) => {
setProject((prev) => ({ ...prev, ...patch }));
if (options?.markDirty ?? true) {
setIsDirty(true);
}
},
[],
);
const addToLibrary = useCallback((entry: EditorProjectFile) => {
setProjectLibrary((prev) => {
const deduped = prev.filter((item) => item.projectId !== entry.projectId);
return [entry, ...deduped];
});
}, []);
const removeFromLibrary = useCallback((projectId: string) => {
setProjectLibrary((prev) => prev.filter((item) => item.projectId !== projectId));
}, []);
const setProjectLibraryEntries = useCallback((entries: EditorProjectFile[]) => {
setProjectLibrary(entries);
}, []);
const refreshProjectLibrary = useCallback(async () => {
if (!options.onRefreshLibrary) {
return [];
}
try {
const entries = await options.onRefreshLibrary();
setProjectLibrary(entries);
return entries;
} catch {
return [];
}
}, [options.onRefreshLibrary]);
const markDirty = useCallback(() => {
setIsDirty(true);
}, []);
const clearDirty = useCallback(() => {
setIsDirty(false);
}, []);
useEffect(() => {
if (!isDirty || !options.onSave) {
return;
}
const timeout = globalThis.setTimeout(() => {
void saveProject();
}, autosaveDelayMs);
return () => globalThis.clearTimeout(timeout);
}, [isDirty, options.onSave, saveProject, autosaveDelayMs]);
return useMemo(
() => ({
project,
isDirty,
isSaving,
saveError,
lastSavedAt,
projectLibrary,
updateProject,
registerSnapshotProvider,
saveProject,
loadProject,
addToLibrary,
setProjectLibrary: setProjectLibraryEntries,
removeFromLibrary,
refreshProjectLibrary,
markDirty,
clearDirty,
}),
[
project,
isDirty,
isSaving,
saveError,
lastSavedAt,
projectLibrary,
updateProject,
registerSnapshotProvider,
saveProject,
loadProject,
addToLibrary,
setProjectLibraryEntries,
removeFromLibrary,
refreshProjectLibrary,
markDirty,
clearDirty,
],
);
}
@@ -0,0 +1,369 @@
import { useCallback } from "react";
import { extensionHost } from "@/lib/extensions";
import {
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_AUTO_ZOOM_DEPTH,
DEFAULT_FIGURE_DATA,
type AnnotationRegion,
type AudioRegion,
type ClipRegion,
type EditorEffectSection,
type FigureData,
type SpeedRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomMode,
type ZoomRegion,
} from "@/components/video-editor/types";
import type { Span } from "dnd-timeline";
type UseTimelineActionsParams = {
videoPath: string | null;
pendingFreshRecordingAutoZoomPathRef: React.MutableRefObject<string | null>;
autoSuggestedVideoPathRef: React.MutableRefObject<string | null>;
nextZoomIdRef: React.MutableRefObject<number>;
nextClipIdRef: React.MutableRefObject<number>;
nextAudioIdRef: React.MutableRefObject<number>;
nextAnnotationIdRef: React.MutableRefObject<number>;
nextAnnotationZIndexRef: React.MutableRefObject<number>;
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
selectedZoomId: string | null;
selectedClipId: string | null;
selectedAnnotationId: string | null;
selectedAudioId: string | null;
onSetActiveEffectSection: React.Dispatch<React.SetStateAction<EditorEffectSection>>;
onSetZoomRegions: React.Dispatch<React.SetStateAction<ZoomRegion[]>>;
onSetClipRegions: React.Dispatch<React.SetStateAction<ClipRegion[]>>;
onSetSpeedRegions: React.Dispatch<React.SetStateAction<SpeedRegion[]>>;
onSetAnnotationRegions: React.Dispatch<React.SetStateAction<AnnotationRegion[]>>;
onSetAudioRegions: React.Dispatch<React.SetStateAction<AudioRegion[]>>;
onSetSelectedZoomId: (id: string | null) => void;
onSetSelectedClipId: (id: string | null) => void;
onSetSelectedAnnotationId: (id: string | null) => void;
onSetSelectedAudioId: (id: string | null) => void;
};
export function useTimelineActions(params: UseTimelineActionsParams) {
const handleSelectZoom = useCallback((id: string | null) => {
params.onSetSelectedZoomId(id);
if (id) {
params.onSetActiveEffectSection("zoom");
params.onSetSelectedAnnotationId(null);
params.onSetSelectedAudioId(null);
} else {
params.onSetActiveEffectSection((s) => (s === "zoom" ? "scene" : s));
}
}, [params]);
const handleSelectAnnotation = useCallback((id: string | null) => {
params.onSetSelectedAnnotationId(id);
if (id) {
params.onSetSelectedZoomId(null);
params.onSetSelectedAudioId(null);
}
}, [params]);
const handleZoomAdded = useCallback((span: Span) => {
const id = `zoom-${params.nextZoomIdRef.current++}`;
const defaultDepth: ZoomDepth = 2;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: defaultDepth,
focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, defaultDepth),
mode: "auto",
};
if (params.videoPath && params.pendingFreshRecordingAutoZoomPathRef.current === params.videoPath) {
params.autoSuggestedVideoPathRef.current = params.videoPath;
params.pendingFreshRecordingAutoZoomPathRef.current = null;
}
params.onSetZoomRegions((prev) => [...prev, newRegion]);
params.onSetSelectedZoomId(id);
params.onSetSelectedAnnotationId(null);
extensionHost.emitEvent({ type: "timeline:region-added", data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } });
}, [params]);
const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
const id = `zoom-${params.nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_AUTO_ZOOM_DEPTH,
focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH),
mode: "auto",
};
if (params.videoPath && params.pendingFreshRecordingAutoZoomPathRef.current === params.videoPath) {
params.autoSuggestedVideoPathRef.current = params.videoPath;
params.pendingFreshRecordingAutoZoomPathRef.current = null;
}
params.onSetZoomRegions((prev) => [...prev, newRegion]);
extensionHost.emitEvent({ type: "timeline:region-added", data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } });
}, [params]);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
params.onSetZoomRegions((prev) => prev.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } : region));
}, [params]);
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
params.onSetZoomRegions((prev) => prev.map((region) => region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region));
}, [params]);
const handleZoomDepthChange = useCallback((depth: ZoomDepth) => {
if (!params.selectedZoomId) return;
params.onSetZoomRegions((prev) => prev.map((region) => region.id === params.selectedZoomId ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } : region));
}, [params]);
const handleZoomModeChange = useCallback((mode: ZoomMode) => {
if (!params.selectedZoomId) return;
params.onSetZoomRegions((prev) => prev.map((region) => (region.id === params.selectedZoomId ? { ...region, mode } : region)));
}, [params]);
const handleZoomDelete = useCallback((id: string) => {
params.onSetZoomRegions((prev) => prev.filter((region) => region.id !== id));
if (params.selectedZoomId === id) {
params.onSetSelectedZoomId(null);
}
extensionHost.emitEvent({ type: "timeline:region-removed", data: { id } });
}, [params]);
const handleSelectClip = useCallback((id: string | null) => {
params.onSetSelectedClipId(id);
if (id) {
params.onSetActiveEffectSection("clip");
params.onSetSelectedZoomId(null);
params.onSetSelectedAnnotationId(null);
params.onSetSelectedAudioId(null);
} else {
params.onSetActiveEffectSection((s) => (s === "clip" ? "scene" : s));
}
}, [params]);
const handleClipSplit = useCallback((splitMs: number) => {
params.onSetClipRegions((prev) => {
const target = prev.find((c) => splitMs > c.startMs && splitMs < c.endMs);
if (!target) return prev;
const leftId = `clip-${params.nextClipIdRef.current++}`;
const rightId = `clip-${params.nextClipIdRef.current++}`;
const left: ClipRegion = { id: leftId, startMs: target.startMs, endMs: Math.round(splitMs), speed: target.speed, muted: target.muted };
const right: ClipRegion = { id: rightId, startMs: Math.round(splitMs), endMs: target.endMs, speed: target.speed, muted: target.muted };
if (params.selectedClipId === target.id) params.onSetSelectedClipId(leftId);
return prev.flatMap((c) => (c.id === target.id ? [left, right] : [c]));
});
}, [params]);
const handleClipSpanChange = useCallback((id: string, span: Span) => {
const oldClip = params.clipRegions.find((c) => c.id === id);
const newStart = Math.round(span.start);
const newEnd = Math.round(span.end);
const removedSegments = oldClip ? [...(newStart > oldClip.startMs ? [{ startMs: oldClip.startMs, endMs: newStart }] : []), ...(newEnd < oldClip.endMs ? [{ startMs: newEnd, endMs: oldClip.endMs }] : [])] : [];
if (oldClip) {
const startDelta = newStart - oldClip.startMs;
const endDelta = newEnd - oldClip.endMs;
const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0;
if (isMove) {
const delta = startDelta;
params.onSetZoomRegions((prev) => prev.map((zoom) => (zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs ? { ...zoom, startMs: zoom.startMs + delta, endMs: zoom.endMs + delta } : zoom)));
}
}
if (removedSegments.length > 0) {
const removeTrimmedRegions = <T extends { startMs: number; endMs: number }>(regions: T[]): T[] => regions.filter((region) => !removedSegments.some((segment) => region.startMs < segment.endMs && region.endMs > segment.startMs));
params.onSetZoomRegions((prev) => removeTrimmedRegions(prev));
params.onSetAnnotationRegions((prev) => removeTrimmedRegions(prev));
params.onSetSpeedRegions((prev) => removeTrimmedRegions(prev));
params.onSetAudioRegions((prev) => removeTrimmedRegions(prev));
}
params.onSetClipRegions((prev) => prev.map((clip) => (clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip)));
}, [params]);
const handleClipSpeedChange = useCallback((speed: number) => {
if (!params.selectedClipId || !Number.isFinite(speed) || speed <= 0) return;
const clip = params.clipRegions.find((c) => c.id === params.selectedClipId);
if (!clip) return;
const oldSpeed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
const sourceDurationMs = (clip.endMs - clip.startMs) * oldSpeed;
const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed);
const scaleFactor = oldSpeed / speed;
params.onSetClipRegions((prev) => prev.map((c) => (c.id === params.selectedClipId ? { ...c, speed, endMs: newEndMs } : c)));
params.onSetZoomRegions((prev) => prev.map((zoom) => {
if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) return zoom;
return { ...zoom, startMs: Math.round(clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor), endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor) };
}));
}, [params]);
const handleClipMutedChange = useCallback((muted: boolean) => {
if (!params.selectedClipId) return;
params.onSetClipRegions((prev) => prev.map((clip) => (clip.id === params.selectedClipId ? { ...clip, muted } : clip)));
}, [params]);
const handleClipShowSourceAudioChange = useCallback((showSourceAudio: boolean) => {
if (!params.selectedClipId) return;
params.onSetClipRegions((prev) => prev.map((clip) => (clip.id === params.selectedClipId ? { ...clip, showSourceAudio } : clip)));
}, [params]);
const handleClipDelete = useCallback((id: string) => {
const deletedClip = params.clipRegions.find((clip) => clip.id === id);
params.onSetClipRegions((prev) => prev.filter((clip) => clip.id !== id));
if (deletedClip) {
const { startMs, endMs } = deletedClip;
params.onSetZoomRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs));
params.onSetAnnotationRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs));
params.onSetSpeedRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs));
params.onSetAudioRegions((prev) => prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs));
}
if (params.selectedClipId === id) params.onSetSelectedClipId(null);
}, [params]);
const handleSelectAudio = useCallback((id: string | null) => {
params.onSetSelectedAudioId(id);
if (id) {
params.onSetSelectedZoomId(null);
params.onSetSelectedAnnotationId(null);
params.onSetActiveEffectSection("audio");
}
}, [params]);
const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => {
const id = `audio-${params.nextAudioIdRef.current++}`;
const newRegion: AudioRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), audioPath, volume: 1, normalize: false, trackIndex };
params.onSetAudioRegions((prev) => [...prev, newRegion]);
params.onSetSelectedAudioId(id);
params.onSetSelectedZoomId(null);
params.onSetSelectedAnnotationId(null);
params.onSetActiveEffectSection("audio");
}, [params]);
const handleAudioSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => {
const normalizedTrackIndex = typeof trackIndex === "number" && Number.isFinite(trackIndex) ? Math.max(0, Math.floor(trackIndex)) : undefined;
params.onSetAudioRegions((prev) => prev.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), ...(normalizedTrackIndex === undefined ? {} : { trackIndex: normalizedTrackIndex }) } : region));
}, [params]);
const handleAudioVolumeChange = useCallback((volume: number) => {
if (!params.selectedAudioId || !Number.isFinite(volume)) return;
const nextVolume = Math.max(0, Math.min(1, volume));
params.onSetAudioRegions((prev) => prev.map((region) => region.id === params.selectedAudioId ? { ...region, volume: nextVolume } : region));
}, [params]);
const handleAudioDelete = useCallback((id: string) => {
params.onSetAudioRegions((prev) => prev.filter((region) => region.id !== id));
if (params.selectedAudioId === id) params.onSetSelectedAudioId(null);
}, [params]);
const handleAudioNormalizeChange = useCallback((normalize: boolean) => {
if (!params.selectedAudioId) return;
params.onSetAudioRegions((prev) => prev.map((region) => region.id === params.selectedAudioId ? { ...region, normalize } : region));
}, [params]);
const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => {
const id = `annotation-${params.nextAnnotationIdRef.current++}`;
const zIndex = params.nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), type: "text", content: "Enter text...", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, trackIndex };
params.onSetAnnotationRegions((prev) => [...prev, newRegion]);
params.onSetSelectedAnnotationId(id);
params.onSetSelectedZoomId(null);
}, [params]);
const handleAnnotationSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => {
const normalizedTrackIndex = typeof trackIndex === "number" && Number.isFinite(trackIndex) ? Math.max(0, Math.floor(trackIndex)) : undefined;
params.onSetAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), ...(normalizedTrackIndex === undefined ? {} : { trackIndex: normalizedTrackIndex }) } : region));
}, [params]);
const handleAnnotationDelete = useCallback((id: string) => {
params.onSetAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
if (params.selectedAnnotationId === id) params.onSetSelectedAnnotationId(null);
}, [params]);
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => {
if (region.id !== id) return region;
if (region.type === "text") return { ...region, content, textContent: content };
if (region.type === "image") return { ...region, content, imageContent: content };
return { ...region, content };
}));
}, [params]);
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion["type"]) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
if (type === "text") updatedRegion.content = region.textContent || "Enter text...";
else if (type === "image") updatedRegion.content = region.imageContent || "";
else if (type === "figure") {
updatedRegion.content = "";
if (!region.figureData) updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
} else if (type === "blur") {
updatedRegion.content = "";
if (region.blurIntensity === undefined) updatedRegion.blurIntensity = 20;
}
return updatedRegion;
}));
}, [params]);
const handleAnnotationStyleChange = useCallback((id: string, style: Partial<AnnotationRegion["style"]>) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, style: { ...region.style, ...style } } : region));
}, [params]);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => (region.id === id ? { ...region, figureData } : region)));
}, [params]);
const handleAnnotationBlurIntensityChange = useCallback((id: string, blurIntensity: number) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => (region.id === id ? { ...region, blurIntensity } : region)));
}, [params]);
const handleAnnotationBlurColorChange = useCallback((id: string, blurColor: string) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => (region.id === id ? { ...region, blurColor } : region)));
}, [params]);
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => (region.id === id ? { ...region, position } : region)));
}, [params]);
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
params.onSetAnnotationRegions((prev) => prev.map((region) => (region.id === id ? { ...region, size } : region)));
}, [params]);
return {
handleSelectZoom,
handleSelectAnnotation,
handleZoomAdded,
handleZoomSuggested,
handleZoomSpanChange,
handleZoomFocusChange,
handleZoomDepthChange,
handleZoomModeChange,
handleZoomDelete,
handleSelectClip,
handleClipSplit,
handleClipSpanChange,
handleClipSpeedChange,
handleClipMutedChange,
handleClipShowSourceAudioChange,
handleClipDelete,
handleSelectAudio,
handleAudioAdded,
handleAudioSpanChange,
handleAudioVolumeChange,
handleAudioDelete,
handleAudioNormalizeChange,
handleAnnotationAdded,
handleAnnotationSpanChange,
handleAnnotationDelete,
handleAnnotationContentChange,
handleAnnotationTypeChange,
handleAnnotationStyleChange,
handleAnnotationFigureDataChange,
handleAnnotationBlurIntensityChange,
handleAnnotationBlurColorChange,
handleAnnotationPositionChange,
handleAnnotationSizeChange,
};
}
@@ -0,0 +1,164 @@
import { useCallback, useMemo, useState } from "react";
import type {
AnnotationRegion,
AudioRegion,
CaptionCue,
ClipRegion,
SpeedRegion,
ZoomRegion,
} from "@/components/video-editor/types";
export interface TimelineState {
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
audioRegions: AudioRegion[];
autoCaptions: CaptionCue[];
selectedZoomId: string | null;
selectedClipId: string | null;
selectedSpeedId: string | null;
selectedAnnotationId: string | null;
selectedAudioId: string | null;
}
export type TimelineStatePatch = Partial<TimelineState>;
export interface UseTimelineStateResult {
state: TimelineState;
updateState: (patch: TimelineStatePatch) => void;
replaceState: (next: TimelineStatePatch) => void;
resetState: () => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
}
interface HistoryState {
past: TimelineState[];
present: TimelineState;
future: TimelineState[];
}
const DEFAULT_TIMELINE_STATE: TimelineState = {
zoomRegions: [],
clipRegions: [],
speedRegions: [],
annotationRegions: [],
audioRegions: [],
autoCaptions: [],
selectedZoomId: null,
selectedClipId: null,
selectedSpeedId: null,
selectedAnnotationId: null,
selectedAudioId: null,
};
function cloneState(state: TimelineState): TimelineState {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(state);
}
return JSON.parse(JSON.stringify(state)) as TimelineState;
}
export function useTimelineState(
initialState: TimelineStatePatch = {},
historyLimit = 100,
): UseTimelineStateResult {
const mergedInitial = useMemo(
() => ({ ...DEFAULT_TIMELINE_STATE, ...initialState }),
[initialState],
);
const [history, setHistory] = useState<HistoryState>({
past: [],
present: mergedInitial,
future: [],
});
const updateState = useCallback(
(patch: TimelineStatePatch) => {
setHistory((prev) => {
const nextPresent = { ...prev.present, ...patch };
if (JSON.stringify(nextPresent) === JSON.stringify(prev.present)) {
return prev;
}
const nextPast = [...prev.past, cloneState(prev.present)].slice(-historyLimit);
return {
past: nextPast,
present: nextPresent,
future: [],
};
});
},
[historyLimit],
);
const replaceState = useCallback(
(next: TimelineStatePatch) => {
setHistory(() => ({
past: [],
present: {
...DEFAULT_TIMELINE_STATE,
...next,
},
future: [],
}));
},
[],
);
const resetState = useCallback(() => {
setHistory({
past: [],
present: mergedInitial,
future: [],
});
}, [mergedInitial]);
const undo = useCallback(() => {
setHistory((prev) => {
if (prev.past.length === 0) {
return prev;
}
const previous = prev.past[prev.past.length - 1];
const nextPast = prev.past.slice(0, -1);
return {
past: nextPast,
present: previous,
future: [cloneState(prev.present), ...prev.future],
};
});
}, []);
const redo = useCallback(() => {
setHistory((prev) => {
if (prev.future.length === 0) {
return prev;
}
const [next, ...rest] = prev.future;
return {
past: [...prev.past, cloneState(prev.present)].slice(-historyLimit),
present: next,
future: rest,
};
});
}, [historyLimit]);
return useMemo(
() => ({
state: history.present,
updateState,
replaceState,
resetState,
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
}),
[history, updateState, replaceState, resetState, undo, redo],
);
}
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
type EditorPreset,
type EditorPresetSnapshot,
serializeEditorPresetSnapshot,
} from "../../editorPreferences";
import { usePresets } from "./usePresets";
type TranslateFn = (key: string, fallback: string, params?: Record<string, string>) => string;
type PresetSetters = {
[K in keyof EditorPresetSnapshot]: (value: EditorPresetSnapshot[K]) => void;
};
type UseVideoEditorPresetsArgs = {
t: TranslateFn;
presetState: EditorPresetSnapshot;
presetSetters: PresetSetters;
};
type UseVideoEditorPresetsResult = {
editorPresets: EditorPreset[];
activeEditorPresetId: string | null;
presetPopoverOpen: boolean;
setPresetPopoverOpen: (open: boolean) => void;
presetNameDraft: string;
setPresetNameDraft: (next: string) => void;
currentEditorPreset: EditorPreset | null;
handleApplyEditorPreset: (presetId: string) => void;
handleSaveEditorPreset: (name: string) => boolean;
handleDeleteEditorPreset: (presetId: string) => void;
handleSavePresetSubmit: () => void;
};
function cloneSnapshot(snapshot: EditorPresetSnapshot): EditorPresetSnapshot {
return {
...snapshot,
zoomMotionBlurTuning: { ...snapshot.zoomMotionBlurTuning },
padding: { ...snapshot.padding },
webcam: { ...snapshot.webcam },
autoCaptionSettings: { ...snapshot.autoCaptionSettings },
};
}
export function useVideoEditorPresets(args: UseVideoEditorPresetsArgs): UseVideoEditorPresetsResult {
const { t, presetState, presetSetters } = args;
const { presets: editorPresets, savePreset, deletePreset, applyPreset } = usePresets();
const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(null);
const [presetPopoverOpen, setPresetPopoverOpen] = useState(false);
const [presetNameDraft, setPresetNameDraft] = useState("");
const captureEditorPresetSnapshot = useCallback(
(): EditorPresetSnapshot => cloneSnapshot(presetState),
[presetState],
);
const currentPresetSnapshot = useMemo(
() => captureEditorPresetSnapshot(),
[captureEditorPresetSnapshot],
);
const currentPresetSignature = useMemo(
() => serializeEditorPresetSnapshot(currentPresetSnapshot),
[currentPresetSnapshot],
);
const currentEditorPreset = useMemo(
() => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null,
[activeEditorPresetId, editorPresets],
);
useEffect(() => {
const activePreset = currentEditorPreset;
if (
activePreset &&
serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature
) {
return;
}
const matchingPreset =
editorPresets.find(
(preset) => serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature,
) ?? null;
const nextActivePresetId = matchingPreset?.id ?? null;
if (nextActivePresetId !== activeEditorPresetId) {
setActiveEditorPresetId(nextActivePresetId);
}
}, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]);
useEffect(() => {
if (!presetPopoverOpen) {
setPresetNameDraft("");
}
}, [presetPopoverOpen]);
const applyEditorPresetSnapshot = useCallback(
(snapshot: EditorPresetSnapshot) => {
const next = cloneSnapshot(snapshot);
presetSetters.wallpaper(next.wallpaper);
presetSetters.shadowIntensity(next.shadowIntensity);
presetSetters.backgroundBlur(next.backgroundBlur);
presetSetters.zoomMotionBlur(next.zoomMotionBlur);
presetSetters.zoomMotionBlurTuning(next.zoomMotionBlurTuning);
presetSetters.zoomTemporalMotionBlur(next.zoomTemporalMotionBlur);
presetSetters.zoomMotionBlurSampleCount(next.zoomMotionBlurSampleCount);
presetSetters.zoomMotionBlurShutterFraction(next.zoomMotionBlurShutterFraction);
presetSetters.connectZooms(next.connectZooms);
presetSetters.zoomInDurationMs(next.zoomInDurationMs);
presetSetters.zoomInOverlapMs(next.zoomInOverlapMs);
presetSetters.zoomOutDurationMs(next.zoomOutDurationMs);
presetSetters.connectedZoomGapMs(next.connectedZoomGapMs);
presetSetters.connectedZoomDurationMs(next.connectedZoomDurationMs);
presetSetters.zoomInEasing(next.zoomInEasing);
presetSetters.zoomOutEasing(next.zoomOutEasing);
presetSetters.connectedZoomEasing(next.connectedZoomEasing);
presetSetters.showCursor(next.showCursor);
presetSetters.loopCursor(next.loopCursor);
presetSetters.cursorStyle(next.cursorStyle);
presetSetters.cursorSize(next.cursorSize);
presetSetters.cursorSmoothing(next.cursorSmoothing);
presetSetters.cursorSpringStiffnessMultiplier(next.cursorSpringStiffnessMultiplier);
presetSetters.cursorSpringDampingMultiplier(next.cursorSpringDampingMultiplier);
presetSetters.cursorSpringMassMultiplier(next.cursorSpringMassMultiplier);
presetSetters.cameraSpringStiffnessMultiplier(next.cameraSpringStiffnessMultiplier);
presetSetters.cameraSpringDampingMultiplier(next.cameraSpringDampingMultiplier);
presetSetters.cameraSpringMassMultiplier(next.cameraSpringMassMultiplier);
presetSetters.cursorMotionBlur(next.cursorMotionBlur);
presetSetters.cursorClickBounce(next.cursorClickBounce);
presetSetters.cursorClickBounceDuration(next.cursorClickBounceDuration);
presetSetters.cursorSway(next.cursorSway);
presetSetters.borderRadius(next.borderRadius);
presetSetters.padding(next.padding);
presetSetters.frame(next.frame);
presetSetters.webcam(next.webcam);
presetSetters.aspectRatio(next.aspectRatio);
presetSetters.exportEncodingMode(next.exportEncodingMode);
presetSetters.exportBackendPreference(next.exportBackendPreference);
presetSetters.exportPipelineModel(next.exportPipelineModel);
presetSetters.exportQuality(next.exportQuality);
presetSetters.mp4FrameRate(next.mp4FrameRate);
presetSetters.exportFormat(next.exportFormat);
presetSetters.gifFrameRate(next.gifFrameRate);
presetSetters.gifLoop(next.gifLoop);
presetSetters.gifSizePreset(next.gifSizePreset);
presetSetters.autoCaptionSettings(next.autoCaptionSettings);
presetSetters.whisperExecutablePath(next.whisperExecutablePath);
presetSetters.whisperModelPath(next.whisperModelPath);
},
[presetSetters],
);
const handleApplyEditorPreset = useCallback(
(presetId: string) => {
const snapshot = applyPreset(presetId);
const preset = editorPresets.find((item) => item.id === presetId) ?? null;
if (!snapshot || !preset) {
return;
}
setActiveEditorPresetId(preset.id);
applyEditorPresetSnapshot(snapshot);
toast.success(
t("editor.presets.toasts.applied", 'Applied preset "{{name}}"', {
name: preset.name,
}),
);
},
[applyEditorPresetSnapshot, applyPreset, editorPresets, t],
);
const handleSaveEditorPreset = useCallback(
(name: string) => {
const normalizedName = name.trim().replace(/\s+/g, " ");
if (normalizedName.length === 0) {
toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name."));
return false;
}
const hasDuplicateName = editorPresets.some(
(preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(),
);
if (hasDuplicateName) {
toast.error(
t(
"editor.presets.errors.duplicateName",
"A preset with that name already exists.",
),
);
return false;
}
const snapshot = captureEditorPresetSnapshot();
const createdPreset = savePreset(normalizedName, snapshot);
if (!createdPreset) {
toast.error(
t(
"editor.presets.errors.saveFailed",
"Could not save that preset. Check your browser storage settings and try again.",
),
);
return false;
}
setActiveEditorPresetId(createdPreset.id);
toast.success(
t("editor.presets.toasts.saved", 'Saved preset "{{name}}"', {
name: normalizedName,
}),
);
return true;
},
[captureEditorPresetSnapshot, editorPresets, savePreset, t],
);
const handleDeleteEditorPreset = useCallback(
(presetId: string) => {
const preset = editorPresets.find((item) => item.id === presetId);
if (!preset) {
return;
}
if (!deletePreset(presetId)) {
toast.error(
t(
"editor.presets.errors.deleteFailed",
"Could not delete that preset. Check your browser storage settings and try again.",
),
);
return;
}
if (preset.id === activeEditorPresetId) {
setActiveEditorPresetId(null);
}
toast.success(
t("editor.presets.toasts.deleted", 'Deleted preset "{{name}}"', {
name: preset.name,
}),
);
},
[activeEditorPresetId, deletePreset, editorPresets, t],
);
const handleSavePresetSubmit = useCallback(() => {
const didSave = handleSaveEditorPreset(presetNameDraft);
if (didSave) {
setPresetNameDraft("");
}
}, [handleSaveEditorPreset, presetNameDraft]);
return {
editorPresets,
activeEditorPresetId,
presetPopoverOpen,
setPresetPopoverOpen,
presetNameDraft,
setPresetNameDraft,
currentEditorPreset,
handleApplyEditorPreset,
handleSaveEditorPreset,
handleDeleteEditorPreset,
handleSavePresetSubmit,
};
}
@@ -0,0 +1,398 @@
import { type MutableRefObject, type RefObject, useCallback, useMemo } from "react";
import {
createProjectData,
deriveNextId,
type EditorProjectData,
fromFileUrl,
normalizeProjectEditor,
stripPersistedDevMotionBlurSettings,
validateProjectData,
} from "../../projectPersistence";
import type { ClipRegion, WebcamOverlaySettings } from "../../types";
import type { VideoPlaybackRef } from "../../VideoPlayback";
import { useProjectController, type UseProjectResult } from "./useProject";
type TimelineStateLike = {
replaceState: (next: {
zoomRegions: any[];
clipRegions: any[];
speedRegions: any[];
annotationRegions: any[];
audioRegions: any[];
autoCaptions: any[];
selectedZoomId: string | null;
selectedClipId: string | null;
selectedAnnotationId: string | null;
selectedAudioId: string | null;
}) => void;
};
type EditorRefs = {
videoPlaybackRef: RefObject<VideoPlaybackRef | null>;
pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>;
clipInitializedRef: MutableRefObject<boolean>;
autoFullTrackClipIdRef: MutableRefObject<string | null>;
autoFullTrackClipEndMsRef: MutableRefObject<number | null>;
nextZoomIdRef: MutableRefObject<number>;
nextClipIdRef: MutableRefObject<number>;
nextAudioIdRef: MutableRefObject<number>;
nextAnnotationIdRef: MutableRefObject<number>;
nextAnnotationZIndexRef: MutableRefObject<number>;
};
type EditorRuntime = {
currentProjectPath: string | null;
currentSourcePath: string | null;
lastSavedProjectId: string | null;
currentPersistedEditorState: any;
webcamTimeOffsetMs: number;
resolveVideoUrl: (sourcePath: string) => Promise<string>;
applySessionPresentation: (
session:
| {
hideOverlayCursorByDefault?: boolean;
nativeCaptureUnavailable?: boolean;
}
| null
| undefined,
) => void;
buildPersistedEditorState: (editor: any) => any;
captureProjectThumbnail: () => Promise<string | null | undefined>;
remountPreview: () => void;
};
type EditorSetters = {
setIsPlaying: (next: boolean) => void;
setCurrentTime: (next: number) => void;
setDuration: (next: number) => void;
setError: (next: string | null) => void;
setVideoSourcePath: (next: string) => void;
setVideoPath: (next: string) => void;
setCurrentProjectPath: (next: string | null) => void;
setWallpaper: (next: any) => void;
setShadowIntensity: (next: any) => void;
setBackgroundBlur: (next: any) => void;
setZoomMotionBlur: (next: any) => void;
setZoomMotionBlurTuning: (next: any) => void;
setZoomTemporalMotionBlur: (next: any) => void;
setZoomMotionBlurSampleCount: (next: any) => void;
setZoomMotionBlurShutterFraction: (next: any) => void;
setConnectZooms: (next: any) => void;
setZoomInDurationMs: (next: any) => void;
setZoomInOverlapMs: (next: any) => void;
setZoomOutDurationMs: (next: any) => void;
setConnectedZoomGapMs: (next: any) => void;
setConnectedZoomDurationMs: (next: any) => void;
setZoomInEasing: (next: any) => void;
setZoomOutEasing: (next: any) => void;
setConnectedZoomEasing: (next: any) => void;
setShowCursor: (next: any) => void;
setLoopCursor: (next: any) => void;
setCursorStyle: (next: any) => void;
setCursorSize: (next: any) => void;
setCursorSmoothing: (next: any) => void;
setCursorSpringStiffnessMultiplier: (next: any) => void;
setCursorSpringDampingMultiplier: (next: any) => void;
setCursorSpringMassMultiplier: (next: any) => void;
setCameraSpringStiffnessMultiplier: (next: any) => void;
setCameraSpringDampingMultiplier: (next: any) => void;
setCameraSpringMassMultiplier: (next: any) => void;
setZoomSmoothness: (next: any) => void;
setZoomClassicMode: (next: any) => void;
setCursorMotionBlur: (next: any) => void;
setCursorClickBounce: (next: any) => void;
setCursorClickBounceDuration: (next: any) => void;
setCursorSway: (next: any) => void;
setBorderRadius: (next: any) => void;
setPadding: (next: any) => void;
setFrame: (next: any) => void;
setCropRegion: (next: any) => void;
setWebcam: (next: WebcamOverlaySettings) => void;
setTrimRegions: (next: any) => void;
setSourceAudioTrackSettingsByClip: (next: any) => void;
setDefaultSourceAudioTrackSettings: (next: any) => void;
setAutoCaptionSettings: (next: any) => void;
setAspectRatio: (next: any) => void;
setExportEncodingMode: (next: any) => void;
setExportBackendPreference: (next: any) => void;
setExportPipelineModel: (next: any) => void;
setExportQuality: (next: any) => void;
setMp4FrameRate: (next: any) => void;
setExportFormat: (next: any) => void;
setGifFrameRate: (next: any) => void;
setGifLoop: (next: any) => void;
setGifSizePreset: (next: any) => void;
setLastSavedSnapshot: (next: any) => void;
};
type UseVideoEditorProjectArgs = {
projectManager: UseProjectResult<EditorProjectData>;
timelineState: TimelineStateLike;
editorRefs: EditorRefs;
editorRuntime: EditorRuntime;
editorSetters: EditorSetters;
projectDisplayName: string;
cloneStructured: <T>(value: T) => T;
onMenuLoadProject: (handler: () => void) => (() => void) | undefined;
onMenuSaveProject: (handler: () => void) => (() => void) | undefined;
onMenuSaveProjectAs: (handler: () => void) => (() => void) | undefined;
onRequestSaveBeforeClose: (handler: () => Promise<boolean>) => (() => void) | undefined;
};
function applyLoadedProjectVisualSettings(editorSetters: EditorSetters, normalizedEditor: any) {
editorSetters.setWallpaper(normalizedEditor.wallpaper);
editorSetters.setShadowIntensity(normalizedEditor.shadowIntensity);
editorSetters.setBackgroundBlur(normalizedEditor.backgroundBlur);
editorSetters.setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
editorSetters.setZoomMotionBlurTuning({ ...normalizedEditor.zoomMotionBlurTuning });
editorSetters.setZoomTemporalMotionBlur(normalizedEditor.zoomTemporalMotionBlur);
editorSetters.setZoomMotionBlurSampleCount(normalizedEditor.zoomMotionBlurSampleCount);
editorSetters.setZoomMotionBlurShutterFraction(normalizedEditor.zoomMotionBlurShutterFraction);
editorSetters.setConnectZooms(normalizedEditor.connectZooms);
editorSetters.setZoomInDurationMs(normalizedEditor.zoomInDurationMs);
editorSetters.setZoomInOverlapMs(normalizedEditor.zoomInOverlapMs);
editorSetters.setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs);
editorSetters.setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs);
editorSetters.setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs);
editorSetters.setZoomInEasing(normalizedEditor.zoomInEasing);
editorSetters.setZoomOutEasing(normalizedEditor.zoomOutEasing);
editorSetters.setConnectedZoomEasing(normalizedEditor.connectedZoomEasing);
editorSetters.setShowCursor(normalizedEditor.showCursor);
editorSetters.setLoopCursor(normalizedEditor.loopCursor);
editorSetters.setCursorStyle(normalizedEditor.cursorStyle);
editorSetters.setCursorSize(normalizedEditor.cursorSize);
editorSetters.setCursorSmoothing(normalizedEditor.cursorSmoothing);
editorSetters.setCursorSpringStiffnessMultiplier(
normalizedEditor.cursorSpringStiffnessMultiplier,
);
editorSetters.setCursorSpringDampingMultiplier(normalizedEditor.cursorSpringDampingMultiplier);
editorSetters.setCursorSpringMassMultiplier(normalizedEditor.cursorSpringMassMultiplier);
editorSetters.setCameraSpringStiffnessMultiplier(
normalizedEditor.cameraSpringStiffnessMultiplier,
);
editorSetters.setCameraSpringDampingMultiplier(normalizedEditor.cameraSpringDampingMultiplier);
editorSetters.setCameraSpringMassMultiplier(normalizedEditor.cameraSpringMassMultiplier);
editorSetters.setZoomSmoothness(normalizedEditor.zoomSmoothness);
editorSetters.setZoomClassicMode(normalizedEditor.zoomClassicMode);
editorSetters.setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
editorSetters.setCursorClickBounce(normalizedEditor.cursorClickBounce);
editorSetters.setCursorClickBounceDuration(normalizedEditor.cursorClickBounceDuration);
editorSetters.setCursorSway(normalizedEditor.cursorSway);
editorSetters.setBorderRadius(normalizedEditor.borderRadius);
editorSetters.setPadding(normalizedEditor.padding);
editorSetters.setFrame(normalizedEditor.frame);
editorSetters.setCropRegion(normalizedEditor.cropRegion);
editorSetters.setWebcam(normalizedEditor.webcam);
editorSetters.setTrimRegions(normalizedEditor.trimRegions);
editorSetters.setSourceAudioTrackSettingsByClip(
normalizedEditor.sourceAudioTrackSettingsByClip ?? {},
);
editorSetters.setDefaultSourceAudioTrackSettings(
normalizedEditor.defaultSourceAudioTrackSettings ?? {},
);
editorSetters.setAutoCaptionSettings(normalizedEditor.autoCaptionSettings);
editorSetters.setAspectRatio(normalizedEditor.aspectRatio);
}
function applyLoadedProjectExportSettings(editorSetters: EditorSetters, normalizedEditor: any) {
editorSetters.setExportEncodingMode(normalizedEditor.exportEncodingMode);
editorSetters.setExportBackendPreference(normalizedEditor.exportBackendPreference);
editorSetters.setExportPipelineModel(normalizedEditor.exportPipelineModel);
editorSetters.setExportQuality(normalizedEditor.exportQuality);
editorSetters.setMp4FrameRate(normalizedEditor.mp4FrameRate);
editorSetters.setExportFormat(normalizedEditor.exportFormat);
editorSetters.setGifFrameRate(normalizedEditor.gifFrameRate);
editorSetters.setGifLoop(normalizedEditor.gifLoop);
editorSetters.setGifSizePreset(normalizedEditor.gifSizePreset);
}
function applyLoadedProjectTimeline(
timelineState: TimelineStateLike,
editorRefs: EditorRefs,
normalizedEditor: any,
) {
timelineState.replaceState({
zoomRegions: normalizedEditor.zoomRegions,
clipRegions: normalizedEditor.clipRegions,
speedRegions: normalizedEditor.speedRegions,
annotationRegions: normalizedEditor.annotationRegions,
audioRegions: normalizedEditor.audioRegions,
autoCaptions: normalizedEditor.autoCaptions,
selectedZoomId: null,
selectedClipId: null,
selectedAnnotationId: null,
selectedAudioId: null,
});
editorRefs.clipInitializedRef.current = normalizedEditor.clipRegions.length > 0;
editorRefs.autoFullTrackClipIdRef.current = null;
editorRefs.autoFullTrackClipEndMsRef.current = null;
editorRefs.nextZoomIdRef.current = deriveNextId(
"zoom",
normalizedEditor.zoomRegions.map((region: { id: string }) => region.id),
);
editorRefs.nextClipIdRef.current = deriveNextId(
"clip",
normalizedEditor.clipRegions.map((region: ClipRegion) => region.id),
);
editorRefs.nextAudioIdRef.current = deriveNextId(
"audio",
normalizedEditor.audioRegions.map((region: { id: string }) => region.id),
);
editorRefs.nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region: { id: string }) => region.id),
);
editorRefs.nextAnnotationZIndexRef.current =
normalizedEditor.annotationRegions.reduce(
(max: number, region: { zIndex: number }) => Math.max(max, region.zIndex),
0,
) + 1;
}
export function useVideoEditorProject({
projectManager,
timelineState,
editorRefs,
editorRuntime,
editorSetters,
projectDisplayName,
cloneStructured,
onMenuLoadProject,
onMenuSaveProject,
onMenuSaveProjectAs,
onRequestSaveBeforeClose,
}: UseVideoEditorProjectArgs) {
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(
stripPersistedDevMotionBlurSettings(project.editor ?? {}),
);
try {
editorRefs.videoPlaybackRef.current?.pause();
} catch {
// no-op
}
editorSetters.setIsPlaying(false);
editorSetters.setCurrentTime(0);
editorSetters.setDuration(0);
editorSetters.setError(null);
editorSetters.setVideoSourcePath(sourcePath);
editorSetters.setVideoPath(await editorRuntime.resolveVideoUrl(sourcePath));
editorSetters.setCurrentProjectPath(path ?? null);
editorRefs.pendingFreshRecordingAutoZoomPathRef.current = null;
if (normalizedEditor.webcam.sourcePath) {
await window.electronAPI.setCurrentRecordingSession?.(
{
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
},
{
preserveProjectPath: Boolean(path),
},
);
const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
editorRuntime.applySessionPresentation(
sessionResult?.success ? sessionResult.session : null,
);
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
editorRuntime.applySessionPresentation(null);
}
applyLoadedProjectVisualSettings(editorSetters, normalizedEditor);
applyLoadedProjectExportSettings(editorSetters, normalizedEditor);
applyLoadedProjectTimeline(timelineState, editorRefs, normalizedEditor);
editorSetters.setLastSavedSnapshot(
cloneStructured(
createProjectData(
sourcePath,
editorRuntime.buildPersistedEditorState(normalizedEditor),
project.projectId ?? null,
),
),
);
await projectManager.refreshProjectLibrary();
return true;
},
[cloneStructured, editorRefs, editorRuntime, editorSetters, projectManager, timelineState],
);
const syncActiveVideoSource = useCallback(
async (sourcePath: string, webcamPath?: string | null) => {
if (webcamPath) {
await window.electronAPI.setCurrentRecordingSession?.(
{
videoPath: sourcePath,
webcamPath,
timeOffsetMs: editorRuntime.webcamTimeOffsetMs,
},
{
preserveProjectPath: Boolean(editorRuntime.currentProjectPath),
},
);
return;
}
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(editorRuntime.currentProjectPath),
});
},
[editorRuntime.currentProjectPath, editorRuntime.webcamTimeOffsetMs],
);
const computedProjectSnapshot = useMemo(() => {
if (!editorRuntime.currentSourcePath) {
return null;
}
return createProjectData(
editorRuntime.currentSourcePath,
editorRuntime.currentPersistedEditorState,
editorRuntime.lastSavedProjectId,
);
}, [
editorRuntime.currentPersistedEditorState,
editorRuntime.currentSourcePath,
editorRuntime.lastSavedProjectId,
]);
const projectController = useProjectController({
projectManager,
projectDisplayName,
currentSourcePath: editorRuntime.currentSourcePath,
currentProjectPath: editorRuntime.currentProjectPath,
currentProjectSnapshot: computedProjectSnapshot,
currentPersistedEditorState: editorRuntime.currentPersistedEditorState,
lastSavedProjectId: editorRuntime.lastSavedProjectId,
captureProjectThumbnail: editorRuntime.captureProjectThumbnail,
remountPreview: editorRuntime.remountPreview,
setCurrentProjectPath: editorSetters.setCurrentProjectPath,
setLastSavedSnapshot: editorSetters.setLastSavedSnapshot,
createProjectData,
cloneStructured,
applyLoadedProject,
onMenuLoadProject,
onMenuSaveProject,
onMenuSaveProjectAs,
onRequestSaveBeforeClose,
});
return {
applyLoadedProject,
syncActiveVideoSource,
projectController,
currentProjectSnapshot: computedProjectSnapshot,
};
}
@@ -0,0 +1,96 @@
import { create } from "zustand";
import type React from "react";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
import type { EditorPresetSnapshot } from "@/components/video-editor/editorPreferences";
import type { EditorEffectSection, WebcamOverlaySettings } from "@/components/video-editor/types";
export type PresetSetters = {
[K in keyof EditorPresetSnapshot]: (value: EditorPresetSnapshot[K]) => void;
};
type WebcamSetter = React.Dispatch<React.SetStateAction<WebcamOverlaySettings>>;
type VideoEditorStoreState = {
activeEffectSection: EditorEffectSection;
timelineCollapsed: boolean;
aspectRatio: AspectRatio;
presetState: EditorPresetSnapshot | null;
presetSetters: PresetSetters | null;
defaultWebcamTimeOffsetMs: number;
setWebcam: WebcamSetter | null;
syncRecordingSessionWebcam: ((webcamPath: string | null, timeOffsetMs?: number) => Promise<void>) | null;
setActiveEffectSection: React.Dispatch<React.SetStateAction<EditorEffectSection>>;
setTimelineCollapsed: (next: boolean) => void;
toggleTimelineCollapsed: () => void;
setAspectRatio: (next: AspectRatio) => void;
syncUiState: (patch: Partial<Pick<VideoEditorStoreState, "activeEffectSection" | "timelineCollapsed" | "aspectRatio">>) => void;
syncPresetBindings: (presetState: EditorPresetSnapshot, presetSetters: PresetSetters) => void;
syncSidebarBindings: (args: {
defaultWebcamTimeOffsetMs: number;
setWebcam: WebcamSetter;
syncRecordingSessionWebcam: (webcamPath: string | null, timeOffsetMs?: number) => Promise<void>;
}) => void;
};
export const useVideoEditorStore = create<VideoEditorStoreState>((set) => ({
activeEffectSection: "scene",
timelineCollapsed: false,
aspectRatio: "native",
presetState: null,
presetSetters: null,
defaultWebcamTimeOffsetMs: 0,
setWebcam: null,
syncRecordingSessionWebcam: null,
setActiveEffectSection: (next) =>
set((state) => ({
activeEffectSection:
typeof next === "function"
? (next as (prev: EditorEffectSection) => EditorEffectSection)(state.activeEffectSection)
: next,
})),
setTimelineCollapsed: (next) => set({ timelineCollapsed: next }),
toggleTimelineCollapsed: () =>
set((state) => ({
timelineCollapsed: !state.timelineCollapsed,
})),
setAspectRatio: (next) => set({ aspectRatio: next }),
syncUiState: (patch) => set(patch),
syncPresetBindings: (presetState, presetSetters) => set({ presetState, presetSetters }),
syncSidebarBindings: (args) =>
set({
defaultWebcamTimeOffsetMs: args.defaultWebcamTimeOffsetMs,
setWebcam: args.setWebcam,
syncRecordingSessionWebcam: args.syncRecordingSessionWebcam,
}),
}));
export function useEditorUiState() {
return useVideoEditorStore((state) => ({
activeEffectSection: state.activeEffectSection,
timelineCollapsed: state.timelineCollapsed,
aspectRatio: state.aspectRatio,
setActiveEffectSection: state.setActiveEffectSection,
setTimelineCollapsed: state.setTimelineCollapsed,
toggleTimelineCollapsed: state.toggleTimelineCollapsed,
setAspectRatio: state.setAspectRatio,
}));
}
export function useEditorPresetState() {
return useVideoEditorStore((state) => ({
presetState: state.presetState,
presetSetters: state.presetSetters,
}));
}
export function useEditorSidebarState() {
return useVideoEditorStore((state) => ({
activeEffectSection: state.activeEffectSection,
setActiveEffectSection: state.setActiveEffectSection,
defaultWebcamTimeOffsetMs: state.defaultWebcamTimeOffsetMs,
setWebcam: state.setWebcam,
syncRecordingSessionWebcam: state.syncRecordingSessionWebcam,
}));
}
@@ -0,0 +1,271 @@
import { type RefObject, useCallback } from "react";
import { FrameRenderer } from "@/lib/exporter";
import { getClipSourceEndMs, type SpeedRegion } from "../../types";
import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
ClipRegion,
CropRegion,
CursorStyle,
CursorTelemetryPoint,
Padding,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
ZoomRegion,
ZoomTransitionEasing,
} from "../../types";
import { toFileUrl } from "../../projectPersistence";
import type { VideoPlaybackRef } from "../../VideoPlayback";
type ThumbnailRenderState = {
wallpaper: string;
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomMotionBlurTuning: ZoomMotionBlurTuning;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
connectZooms: boolean;
zoomInDurationMs: number;
zoomInOverlapMs: number;
zoomOutDurationMs: number;
connectedZoomGapMs: number;
connectedZoomDurationMs: number;
zoomInEasing: ZoomTransitionEasing;
zoomOutEasing: ZoomTransitionEasing;
connectedZoomEasing: ZoomTransitionEasing;
borderRadius: number;
padding: Padding;
cropRegion: CropRegion;
webcam: WebcamOverlaySettings;
resolvedWebcamVideoUrl: string | null;
zoomRegions: ZoomRegion[];
annotationRegions: AnnotationRegion[];
autoCaptions: CaptionCue[];
autoCaptionSettings: AutoCaptionSettings;
clipRegions: ClipRegion[];
speedRegions: SpeedRegion[];
cursorTelemetry: CursorTelemetryPoint[];
effectiveShowCursor: boolean;
cursorStyle: CursorStyle;
cursorSize: number;
cursorSmoothing: number;
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cameraSpringStiffnessMultiplier: number;
cameraSpringDampingMultiplier: number;
cameraSpringMassMultiplier: number;
zoomSmoothness: number;
zoomClassicMode: boolean;
cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
cursorSway: number;
};
type UseVideoEditorThumbnailArgs = {
videoPlaybackRef: RefObject<VideoPlaybackRef | null>;
currentTime: number;
renderState: ThumbnailRenderState;
};
type CaptureProjectThumbnailArgs = {
previewHandle: VideoPlaybackRef | null;
currentTime: number;
renderState: ThumbnailRenderState;
};
async function captureProjectThumbnail({
previewHandle,
currentTime,
renderState,
}: CaptureProjectThumbnailArgs): Promise<string | null> {
const previewVideo = previewHandle?.video ?? null;
const previewCanvas = previewHandle?.app?.canvas ?? null;
if (previewHandle && previewVideo && previewVideo.paused) {
try {
await previewHandle.refreshFrame();
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
} catch (thumbnailRefreshError) {
console.warn(
"Unable to refresh preview frame before thumbnail capture:",
thumbnailRefreshError,
);
}
}
const canvas = document.createElement("canvas");
const targetWidth = 320;
const targetHeight = 180;
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const editorBgHsl = getComputedStyle(document.documentElement)
.getPropertyValue("--editor-bg")
.trim();
context.fillStyle = editorBgHsl ? `hsl(${editorBgHsl})` : "#111113";
context.fillRect(0, 0, targetWidth, targetHeight);
const previewWidth = previewHandle?.containerRef.current?.clientWidth || 1920;
const previewHeight = previewHandle?.containerRef.current?.clientHeight || 1080;
const frameTimestampUs = Math.max(0, Math.round(currentTime * 1_000_000));
if (previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) {
let videoFrame: VideoFrame | null = null;
let frameRenderer: FrameRenderer | null = null;
try {
videoFrame = new VideoFrame(previewVideo, { timestamp: frameTimestampUs });
frameRenderer = new FrameRenderer({
width: targetWidth,
height: targetHeight,
wallpaper: renderState.wallpaper,
zoomRegions: renderState.zoomRegions,
showShadow: renderState.shadowIntensity > 0,
shadowIntensity: renderState.shadowIntensity,
backgroundBlur: renderState.backgroundBlur,
zoomMotionBlur: renderState.zoomMotionBlur,
zoomMotionBlurTuning: renderState.zoomMotionBlurTuning,
zoomTemporalMotionBlur: renderState.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: renderState.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: renderState.zoomMotionBlurShutterFraction,
connectZooms: renderState.connectZooms,
zoomInDurationMs: renderState.zoomInDurationMs,
zoomInOverlapMs: renderState.zoomInOverlapMs,
zoomOutDurationMs: renderState.zoomOutDurationMs,
connectedZoomGapMs: renderState.connectedZoomGapMs,
connectedZoomDurationMs: renderState.connectedZoomDurationMs,
zoomInEasing: renderState.zoomInEasing,
zoomOutEasing: renderState.zoomOutEasing,
connectedZoomEasing: renderState.connectedZoomEasing,
borderRadius: renderState.borderRadius,
padding: renderState.padding,
cropRegion: renderState.cropRegion,
webcam: renderState.webcam,
webcamUrl:
renderState.resolvedWebcamVideoUrl ??
(renderState.webcam.sourcePath ? toFileUrl(renderState.webcam.sourcePath) : null),
videoWidth: previewVideo.videoWidth,
videoHeight: previewVideo.videoHeight,
annotationRegions: renderState.annotationRegions,
autoCaptions: renderState.autoCaptions,
autoCaptionSettings: renderState.autoCaptionSettings,
speedRegions: (() => {
const clipDerived: SpeedRegion[] = renderState.clipRegions
.filter((clip) => clip.speed !== 1)
.map((clip) => ({
id: `clip-speed-${clip.id}`,
startMs: clip.startMs,
endMs: getClipSourceEndMs(clip),
speed: clip.speed as SpeedRegion["speed"],
}));
if (clipDerived.length === 0) return renderState.speedRegions;
const result = [...renderState.speedRegions];
for (const clipSpeed of clipDerived) {
const overlaps = renderState.speedRegions.some(
(speedRegion) =>
speedRegion.endMs > clipSpeed.startMs &&
speedRegion.startMs < clipSpeed.endMs,
);
if (!overlaps) {
result.push(clipSpeed);
}
}
return result;
})(),
previewWidth,
previewHeight,
cursorTelemetry: renderState.cursorTelemetry,
showCursor: renderState.effectiveShowCursor,
cursorStyle: renderState.cursorStyle,
cursorSize: renderState.cursorSize,
cursorSmoothing: renderState.cursorSmoothing,
cursorSpringStiffnessMultiplier: renderState.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: renderState.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: renderState.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: renderState.cameraSpringStiffnessMultiplier,
cameraSpringDampingMultiplier: renderState.cameraSpringDampingMultiplier,
cameraSpringMassMultiplier: renderState.cameraSpringMassMultiplier,
zoomSmoothness: renderState.zoomSmoothness,
zoomClassicMode: renderState.zoomClassicMode,
cursorMotionBlur: renderState.cursorMotionBlur,
cursorClickBounce: renderState.cursorClickBounce,
cursorClickBounceDuration: renderState.cursorClickBounceDuration,
cursorSway: renderState.cursorSway,
});
await frameRenderer.initialize();
await frameRenderer.renderFrame(videoFrame, frameTimestampUs);
return frameRenderer.getCanvas().toDataURL("image/png");
} catch (thumbnailRenderError) {
console.warn("Unable to render thumbnail from composed frame:", thumbnailRenderError);
} finally {
videoFrame?.close();
frameRenderer?.destroy();
}
}
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;
}
}
export function useVideoEditorThumbnail({
videoPlaybackRef,
currentTime,
renderState,
}: UseVideoEditorThumbnailArgs) {
return useCallback(
() =>
captureProjectThumbnail({
previewHandle: videoPlaybackRef.current,
currentTime,
renderState,
}),
[currentTime, renderState, videoPlaybackRef],
);
}
@@ -0,0 +1,215 @@
import { useCallback, useEffect } from "react";
import { toast } from "sonner";
import { resolveAutoCaptionSourcePath } from "@/components/video-editor/autoCaptionSource";
import { type AutoCaptionSettings, type CaptionCue } from "@/components/video-editor/types";
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error.replace(/^Error:\s*/i, "");
}
return "Something went wrong";
}
type UseWhisperCaptionsParams = {
videoPath: string | null;
videoSourcePath: string | null;
webcamSourcePath: string | null;
autoCaptionSettings: AutoCaptionSettings;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
downloadedWhisperModelPath: string | null;
whisperModelDownloadStatus: "idle" | "downloading" | "downloaded" | "error";
isGeneratingCaptions: boolean;
onSyncVideoSource: (sourcePath: string, webcamSourcePath: string | null) => Promise<void>;
onResolveVideoUrl: (sourcePath: string) => Promise<string>;
onSetVideoSourcePath: (path: string) => void;
onSetVideoPath: (path: string) => void;
onSetAutoCaptions: (cues: CaptionCue[]) => void;
onSetAutoCaptionSettings: React.Dispatch<React.SetStateAction<AutoCaptionSettings>>;
onSetWhisperExecutablePath: (path: string | null) => void;
onSetWhisperModelPath: React.Dispatch<React.SetStateAction<string | null>>;
onSetDownloadedWhisperModelPath: (path: string | null) => void;
onSetWhisperModelDownloadStatus: (status: "idle" | "downloading" | "downloaded" | "error") => void;
onSetWhisperModelDownloadProgress: (progress: number) => void;
onSetIsGeneratingCaptions: (value: boolean) => void;
};
export function useWhisperCaptions(params: UseWhisperCaptionsParams) {
useEffect(() => {
const unsubscribe = window.electronAPI.onWhisperSmallModelDownloadProgress((state) => {
params.onSetWhisperModelDownloadStatus(state.status);
params.onSetWhisperModelDownloadProgress(state.progress);
if (state.status === "downloaded") {
params.onSetDownloadedWhisperModelPath(state.path ?? null);
params.onSetWhisperModelPath((currentPath) => currentPath ?? state.path ?? null);
}
if (state.status === "idle") {
params.onSetDownloadedWhisperModelPath(null);
}
if (state.status === "error" && state.error) {
toast.error(state.error);
}
});
void (async () => {
const result = await window.electronAPI.getWhisperSmallModelStatus();
if (!result.success) {
return;
}
if (result.exists && result.path) {
params.onSetDownloadedWhisperModelPath(result.path);
params.onSetWhisperModelPath((currentPath) => currentPath ?? result.path ?? null);
params.onSetWhisperModelDownloadStatus("downloaded");
params.onSetWhisperModelDownloadProgress(100);
return;
}
params.onSetDownloadedWhisperModelPath(null);
params.onSetWhisperModelDownloadStatus("idle");
params.onSetWhisperModelDownloadProgress(0);
})();
return () => unsubscribe?.();
}, []);
const handlePickWhisperExecutable = useCallback(async () => {
const result = await window.electronAPI.openWhisperExecutablePicker();
if (!result.success || !result.path) {
return;
}
params.onSetWhisperExecutablePath(result.path);
toast.success("Whisper executable selected");
}, [params]);
const handleDownloadWhisperSmallModel = useCallback(async () => {
if (params.whisperModelDownloadStatus === "downloading") {
return;
}
params.onSetWhisperModelDownloadStatus("downloading");
params.onSetWhisperModelDownloadProgress(0);
const result = await window.electronAPI.downloadWhisperSmallModel();
if (!result.success) {
params.onSetWhisperModelDownloadStatus("error");
toast.error(result.error || "Failed to download Whisper small model");
return;
}
if (result.path) {
params.onSetDownloadedWhisperModelPath(result.path);
params.onSetWhisperModelPath(result.path);
}
}, [params]);
const handlePickWhisperModel = useCallback(async () => {
const result = await window.electronAPI.openWhisperModelPicker();
if (!result.success || !result.path) {
return;
}
params.onSetWhisperModelPath(result.path);
toast.success("Whisper model selected");
}, [params]);
const handleDeleteWhisperSmallModel = useCallback(async () => {
const result = await window.electronAPI.deleteWhisperSmallModel();
if (!result.success) {
toast.error(result.error || "Failed to delete Whisper small model");
params.onSetWhisperModelDownloadStatus("idle");
params.onSetWhisperModelDownloadProgress(0);
return;
}
params.onSetWhisperModelPath((currentPath) =>
currentPath === params.downloadedWhisperModelPath ? null : currentPath,
);
params.onSetDownloadedWhisperModelPath(null);
params.onSetWhisperModelDownloadStatus("idle");
params.onSetWhisperModelDownloadProgress(0);
toast.success("Whisper small model deleted");
}, [params]);
const handleGenerateAutoCaptions = useCallback(async () => {
if (params.isGeneratingCaptions) {
return;
}
let sourcePath = resolveAutoCaptionSourcePath({
videoSourcePath: params.videoSourcePath,
videoPath: params.videoPath,
});
if (!sourcePath) {
const sessionResult = await window.electronAPI.getCurrentRecordingSession?.();
const currentVideoResult = await window.electronAPI.getCurrentVideoPath();
sourcePath = resolveAutoCaptionSourcePath({
recordingSessionVideoPath:
sessionResult?.success && sessionResult.session?.videoPath
? sessionResult.session.videoPath
: null,
currentVideoPath: currentVideoResult.success
? (currentVideoResult.path ?? null)
: null,
});
}
if (!sourcePath) {
toast.error("No source video is loaded");
return;
}
if (sourcePath !== params.videoSourcePath) {
params.onSetVideoSourcePath(sourcePath);
params.onSetVideoPath(await params.onResolveVideoUrl(sourcePath));
}
await params.onSyncVideoSource(sourcePath, params.webcamSourcePath ?? null);
if (!params.whisperModelPath) {
toast.error("Select a Whisper model or download the small model first");
return;
}
params.onSetIsGeneratingCaptions(true);
try {
const result = await window.electronAPI.generateAutoCaptions({
videoPath: sourcePath,
whisperExecutablePath: params.whisperExecutablePath ?? undefined,
whisperModelPath: params.whisperModelPath,
language: params.autoCaptionSettings.language,
});
if (!result.success || !result.cues) {
toast.error(
result.message || getErrorMessage(result.error) || "Failed to generate captions",
);
return;
}
params.onSetAutoCaptions(result.cues);
params.onSetAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
toast.success(result.message || `Generated ${result.cues.length} captions`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
params.onSetIsGeneratingCaptions(false);
}
}, [params]);
const handleClearAutoCaptions = useCallback(() => {
params.onSetAutoCaptions([]);
params.onSetAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
}, [params]);
return {
handlePickWhisperExecutable,
handleDownloadWhisperSmallModel,
handlePickWhisperModel,
handleDeleteWhisperSmallModel,
handleGenerateAutoCaptions,
handleClearAutoCaptions,
};
}
+1
View File
@@ -7,3 +7,4 @@ export type {
} from "./timeline/TimelineEditor";
export { default as VideoEditor } from "./VideoEditor";
export { default as VideoPlayback } from "./VideoPlayback";
export * from "./editor/hooks";