mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
add: first part of video editor refactoring, there is still so long to go.. god
This commit is contained in:
Generated
+34
-4
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user