diff --git a/package-lock.json b/package-lock.json index 46d2bfe7..8367a4c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.23", "hasInstallScript": true, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", @@ -2843,6 +2844,19 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", @@ -9046,7 +9060,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -9361,7 +9374,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -11227,7 +11239,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11240,7 +11251,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -11948,7 +11958,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" diff --git a/package.json b/package.json index 68af12b7..d9e99cb9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test:watch": "vitest" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", diff --git a/src/components/video-editor/ExtensionManager.tsx b/src/components/video-editor/ExtensionManager.tsx index 5b08c833..033d5c7a 100644 --- a/src/components/video-editor/ExtensionManager.tsx +++ b/src/components/video-editor/ExtensionManager.tsx @@ -6,43 +6,40 @@ * - Installed: Local extensions with toggle switches */ -import { useCallback, useEffect, useState } from "react"; -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; -import { - Puzzle, - FolderOpen, - Plus, - Trash2, - RefreshCw, - Download, - Search, - ShieldAlert, - Loader2, - Check, - BookOpen, - Tag, - ExternalLink, - ChevronLeft, - ChevronRight, + BookOpen, + Check, + ChevronLeft, + ChevronRight, + Download, + ExternalLink, + FolderOpen, + Loader2, + Plus, + Puzzle, + RefreshCw, + Search, + ShieldAlert, + Tag, + Trash2, } from "lucide-react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import { useExtensions } from "@/hooks/useExtensions"; -import { ExtensionIcon } from "./ExtensionIcon"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; import { useScopedT } from "@/contexts/I18nContext"; +import { useExtensions } from "@/hooks/useExtensions"; import type { ExtensionInfo, MarketplaceExtension } from "@/lib/extensions"; +import { cn } from "@/lib/utils"; +import { ExtensionIcon } from "./ExtensionIcon"; type ExtensionTab = "installed" | "browse"; const TAB_OPTIONS: { value: ExtensionTab; labelKey: string }[] = [ - { value: "browse", labelKey: "tabs.browse" }, - { value: "installed", labelKey: "tabs.installed" }, + { value: "browse", labelKey: "tabs.browse" }, + { value: "installed", labelKey: "tabs.installed" }, ]; const EXTENSIONS_DOCS_URL = "https://marketplace.recordly.dev/extensions"; @@ -53,103 +50,113 @@ const EXTENSIONS_SUBMIT_URL = "https://marketplace.recordly.dev/extensions/submi // --------------------------------------------------------------------------- function InstalledExtensionCard({ - extension, - isActive, - onToggle, - onUninstall, - onClick, + extension, + isActive, + onToggle, + onUninstall, + onClick, }: { - extension: ExtensionInfo; - isActive: boolean; - onToggle: () => void; - onUninstall?: () => void; - onClick?: () => void; + extension: ExtensionInfo; + isActive: boolean; + onToggle: () => void; + onUninstall?: () => void; + onClick?: () => void; }) { - const t = useScopedT("extensions"); - const isError = extension.status === "error"; - const isBuiltin = extension.builtin; + const t = useScopedT("extensions"); + const isError = extension.status === "error"; + const isBuiltin = extension.builtin; - return ( -
-
- -
+ return ( +
+
+ +
-
-
- - {extension.manifest.name} - -
+
+
+ + {extension.manifest.name} + +
- {extension.manifest.author && ( -

- {extension.manifest.homepage ? ( - e.stopPropagation()} - > - {t("detail.by", undefined, { author: extension.manifest.author })} - - ) : ( - <>{t("detail.by", undefined, { author: extension.manifest.author })} - )} -

- )} + {extension.manifest.author && ( +

+ {extension.manifest.homepage ? ( + e.stopPropagation()} + > + {t("detail.by", undefined, { author: extension.manifest.author })} + + ) : ( + <>{t("detail.by", undefined, { author: extension.manifest.author })} + )} +

+ )} -

- {extension.manifest.description || t("detail.noDescription")} -

+

+ {extension.manifest.description || t("detail.noDescription")} +

- {isError && extension.error && ( -

{t("detail.error", undefined, { message: extension.error })}

- )} + {isError && extension.error && ( +

+ {t("detail.error", undefined, { message: extension.error })} +

+ )} - {extension.manifest.permissions.length > 0 && ( -
- {extension.manifest.permissions.map((perm) => ( - - {perm} - - ))} -
- )} -
+ {extension.manifest.permissions.length > 0 && ( +
+ {extension.manifest.permissions.map((perm) => ( + + {perm} + + ))} +
+ )} +
-
- {!isBuiltin && onUninstall && ( - - )} -
e.stopPropagation()}> - -
-
-
- ); +
+ {!isBuiltin && onUninstall && ( + + )} +
e.stopPropagation()}> + +
+
+
+ ); } // --------------------------------------------------------------------------- @@ -157,100 +164,110 @@ function InstalledExtensionCard({ // --------------------------------------------------------------------------- function MarketplaceCard({ - extension, - isInstalling, - onInstall, - onClick, + extension, + isInstalling, + onInstall, + onClick, }: { - extension: MarketplaceExtension; - isInstalling: boolean; - onInstall: () => void; - onClick?: () => void; + extension: MarketplaceExtension; + isInstalling: boolean; + onInstall: () => void; + onClick?: () => void; }) { - const t = useScopedT("extensions"); - return ( -
-
- {extension.iconUrl ? ( - - ) : ( - - )} -
+ const t = useScopedT("extensions"); + return ( +
+
+ {extension.iconUrl ? ( + + ) : ( + + )} +
-
-
- - {extension.name} - -
+
+
+ + {extension.name} + +
-

- {extension.homepage ? ( - e.stopPropagation()} - > - {t("detail.by", undefined, { author: extension.author })} - - ) : ( - <>{t("detail.by", undefined, { author: extension.author })} - )} -

+

+ {extension.homepage ? ( + e.stopPropagation()} + > + {t("detail.by", undefined, { author: extension.author })} + + ) : ( + <>{t("detail.by", undefined, { author: extension.author })} + )} +

-

- {extension.description} -

+

+ {extension.description} +

-
- - - {extension.downloads.toLocaleString()} - -
+
+ + + {extension.downloads.toLocaleString()} + +
- {extension.tags.length > 0 && ( -
- {extension.tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
- )} -
+ {extension.tags.length > 0 && ( +
+ {extension.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
-
- {extension.installed ? ( - - - {t("status.installed")} - - ) : ( - - )} -
-
- ); +
+ {extension.installed ? ( + + + {t("status.installed")} + + ) : ( + + )} +
+
+ ); } // --------------------------------------------------------------------------- @@ -258,56 +275,56 @@ function MarketplaceCard({ // --------------------------------------------------------------------------- function ScreenshotGallery({ screenshots }: { screenshots: string[] }) { - const t = useScopedT("extensions"); - const [index, setIndex] = useState(0); - const count = screenshots.length; - if (count === 0) return null; + const t = useScopedT("extensions"); + const [index, setIndex] = useState(0); + const count = screenshots.length; + if (count === 0) return null; - return ( -
-

- {t("detail.preview")} -

-
- {t("detail.screenshotAlt", - {count > 1 && ( - <> - - -
- {screenshots.map((_, i) => ( -
- - )} -
-
- ); + return ( +
+

+ {t("detail.preview")} +

+
+ {t("detail.screenshotAlt", + {count > 1 && ( + <> + + +
+ {screenshots.map((_, i) => ( +
+ + )} +
+
+ ); } // --------------------------------------------------------------------------- @@ -315,208 +332,223 @@ function ScreenshotGallery({ screenshots }: { screenshots: string[] }) { // --------------------------------------------------------------------------- type ExtensionDetailData = - | { source: 'installed'; ext: ExtensionInfo; isActive: boolean } - | { source: 'marketplace'; ext: MarketplaceExtension }; + | { source: "installed"; ext: ExtensionInfo; isActive: boolean } + | { source: "marketplace"; ext: MarketplaceExtension }; function ExtensionDetailModal({ - detail, - onClose, - onToggle, - onInstall, - isInstalling, + detail, + onClose, + onToggle, + onInstall, + isInstalling, }: { - detail: ExtensionDetailData; - onClose: () => void; - onToggle?: () => void; - onInstall?: () => void; - isInstalling?: boolean; + detail: ExtensionDetailData; + onClose: () => void; + onToggle?: () => void; + onInstall?: () => void; + isInstalling?: boolean; }) { - const t = useScopedT("extensions"); - const isInstalled = detail.source === 'installed'; - const name = isInstalled ? detail.ext.manifest.name : detail.ext.name; - const description = isInstalled - ? detail.ext.manifest.description || t("detail.noDescription") - : detail.ext.description || t("detail.noDescription"); - const author = isInstalled ? detail.ext.manifest.author : detail.ext.author; - const permissions = isInstalled ? detail.ext.manifest.permissions : detail.ext.permissions; - const homepage = isInstalled ? detail.ext.manifest.homepage : detail.ext.homepage; - const screenshots = detail.source === 'marketplace' ? detail.ext.screenshots ?? [] : []; - const isError = isInstalled ? detail.ext.status === 'error' : false; + const t = useScopedT("extensions"); + const isInstalled = detail.source === "installed"; + const name = isInstalled ? detail.ext.manifest.name : detail.ext.name; + const description = isInstalled + ? detail.ext.manifest.description || t("detail.noDescription") + : detail.ext.description || t("detail.noDescription"); + const author = isInstalled ? detail.ext.manifest.author : detail.ext.author; + const permissions = isInstalled ? detail.ext.manifest.permissions : detail.ext.permissions; + const homepage = isInstalled ? detail.ext.manifest.homepage : detail.ext.homepage; + const screenshots = detail.source === "marketplace" ? (detail.ext.screenshots ?? []) : []; + const isError = isInstalled ? detail.ext.status === "error" : false; - return ( - { if (!open) onClose(); }}> - - {/* Header */} -
-
-
- {detail.source === 'marketplace' && detail.ext.iconUrl ? ( - - ) : ( - - )} -
-
-
-

{name}

-
-

- {author ? ( - homepage ? ( - - {t("detail.by", undefined, { author })} - - - ) : ( - <>{t("detail.by", undefined, { author })} - ) - ) : t("detail.unknownAuthor")} -

-
-
+ return ( + { + if (!open) onClose(); + }} + > + + {/* Header */} +
+
+
+ {detail.source === "marketplace" && detail.ext.iconUrl ? ( + + ) : ( + + )} +
+
+
+

+ {name} +

+
+

+ {author ? ( + homepage ? ( + + {t("detail.by", undefined, { author })} + + + ) : ( + <>{t("detail.by", undefined, { author })} + ) + ) : ( + t("detail.unknownAuthor") + )} +

+
+
- {/* Stats for marketplace extensions */} - {detail.source === 'marketplace' && ( -
- - - {t("detail.downloads", undefined, { count: detail.ext.downloads.toLocaleString() })} - -
- )} -
+ {/* Stats for marketplace extensions */} + {detail.source === "marketplace" && ( +
+ + + {t("detail.downloads", undefined, { + count: detail.ext.downloads.toLocaleString(), + })} + +
+ )} +
- {/* Body */} -
- {/* Screenshot gallery */} - {screenshots.length > 0 && } + {/* Body */} +
+ {/* Screenshot gallery */} + {screenshots.length > 0 && } - {/* Description */} -
-

- {t("detail.description")} -

-

- {description} -

-
+ {/* Description */} +
+

+ {t("detail.description")} +

+

+ {description} +

+
- {/* Tags */} - {detail.source === 'marketplace' && detail.ext.tags.length > 0 && ( -
-

- {t("detail.tags")} -

-
- {detail.ext.tags.map((tag) => ( - - - {tag} - - ))} -
-
- )} + {/* Tags */} + {detail.source === "marketplace" && detail.ext.tags.length > 0 && ( +
+

+ {t("detail.tags")} +

+
+ {detail.ext.tags.map((tag) => ( + + + {tag} + + ))} +
+
+ )} - {/* Permissions */} - {permissions.length > 0 && ( -
-

- {t("detail.permissions")} -

-
- {permissions.map((perm) => ( - - {perm} - - ))} -
-
- )} + {/* Permissions */} + {permissions.length > 0 && ( +
+

+ {t("detail.permissions")} +

+
+ {permissions.map((perm) => ( + + {perm} + + ))} +
+
+ )} - {/* Path (installed extensions) */} - {isInstalled && ( -
-

- {t("detail.location")} -

-

- {detail.ext.path} -

-
- )} + {/* Path (installed extensions) */} + {isInstalled && ( +
+

+ {t("detail.location")} +

+

+ {detail.ext.path} +

+
+ )} - {/* Error */} - {isError && isInstalled && detail.ext.error && ( -
-

{detail.ext.error}

-
- )} -
+ {/* Error */} + {isError && isInstalled && detail.ext.error && ( +
+

{detail.ext.error}

+
+ )} +
- {/* Footer actions */} -
- {isInstalled && onToggle && ( -
- - - {detail.isActive ? t("status.enabled") : t("status.disabled")} - -
- )} - {detail.source === 'marketplace' && !detail.ext.installed && onInstall && ( - - )} - {detail.source === 'marketplace' && detail.ext.installed && ( - - - {t("status.installed")} - - )} -
- -
- -
- ); + {/* Footer actions */} +
+ {isInstalled && onToggle && ( +
+ + + {detail.isActive ? t("status.enabled") : t("status.disabled")} + +
+ )} + {detail.source === "marketplace" && !detail.ext.installed && onInstall && ( + + )} + {detail.source === "marketplace" && detail.ext.installed && ( + + + {t("status.installed")} + + )} +
+ +
+ + + ); } // --------------------------------------------------------------------------- @@ -524,59 +556,61 @@ function ExtensionDetailModal({ // --------------------------------------------------------------------------- function TabSwitcher({ - activeTab, - onTabChange, - extensionCount, + activeTab, + onTabChange, + extensionCount, }: { - activeTab: ExtensionTab; - onTabChange: (tab: ExtensionTab) => void; - extensionCount: number; + activeTab: ExtensionTab; + onTabChange: (tab: ExtensionTab) => void; + extensionCount: number; }) { - const t = useScopedT("extensions"); - return ( - -
- {TAB_OPTIONS.map((option) => { - const isActive = activeTab === option.value; - const count = option.value === "installed" ? extensionCount : undefined; - return ( - - ); - })} -
-
- ); + const t = useScopedT("extensions"); + return ( + +
+ {TAB_OPTIONS.map((option) => { + const isActive = activeTab === option.value; + const count = option.value === "installed" ? extensionCount : undefined; + return ( + + ); + })} +
+
+ ); } // --------------------------------------------------------------------------- @@ -584,262 +618,277 @@ function TabSwitcher({ // --------------------------------------------------------------------------- export default function ExtensionManager() { - const t = useScopedT("extensions"); - const { - extensions, - activeIds, - ready, - refresh, - toggleExtension, - installFromFolder, - uninstall, - openDirectory, - marketplaceSearch, - marketplaceInstall, - } = useExtensions(); + const t = useScopedT("extensions"); + const { + extensions, + activeIds, + ready, + refresh, + toggleExtension, + installFromFolder, + uninstall, + openDirectory, + marketplaceSearch, + marketplaceInstall, + } = useExtensions(); - const [activeTab, setActiveTab] = useState("browse"); - const [isRefreshing, setIsRefreshing] = useState(false); + const [activeTab, setActiveTab] = useState("browse"); + const [isRefreshing, setIsRefreshing] = useState(false); - // Marketplace state - const [searchQuery, setSearchQuery] = useState(""); - const [marketplaceResults, setMarketplaceResults] = useState([]); - const [marketplaceLoading, setMarketplaceLoading] = useState(false); - const [marketplaceError, setMarketplaceError] = useState(null); - const [installingIds, setInstallingIds] = useState>(new Set()); + // Marketplace state + const [searchQuery, setSearchQuery] = useState(""); + const [marketplaceResults, setMarketplaceResults] = useState([]); + const [marketplaceLoading, setMarketplaceLoading] = useState(false); + const [marketplaceError, setMarketplaceError] = useState(null); + const [installingIds, setInstallingIds] = useState>(new Set()); - // Extension detail modal state - const [detailData, setDetailData] = useState(null); + // Extension detail modal state + const [detailData, setDetailData] = useState(null); - const handleInstallFromFolder = useCallback(async () => { - const success = await installFromFolder(); - if (success) { - toast.success(t("toast.installedAndEnabled")); - } - }, [installFromFolder]); + const handleInstallFromFolder = useCallback(async () => { + const success = await installFromFolder(); + if (success) { + toast.success(t("toast.installedAndEnabled")); + } + }, [installFromFolder, t]); - const handleUninstall = useCallback( - async (id: string, name: string) => { - const success = await uninstall(id); - if (success) { - toast.success(t("toast.uninstalled", undefined, { name })); - // Clear installed flag in cached marketplace results - setMarketplaceResults((prev) => - prev.map((e) => (e.id === id ? { ...e, installed: false } : e)), - ); - } else { - toast.error(t("toast.uninstallFailed", undefined, { name })); - } - }, - [uninstall], - ); + const handleUninstall = useCallback( + async (id: string, name: string) => { + const success = await uninstall(id); + if (success) { + toast.success(t("toast.uninstalled", undefined, { name })); + // Clear installed flag in cached marketplace results + setMarketplaceResults((prev) => + prev.map((e) => (e.id === id ? { ...e, installed: false } : e)), + ); + } else { + toast.error(t("toast.uninstallFailed", undefined, { name })); + } + }, + [uninstall, t], + ); - // Marketplace search - const handleSearch = useCallback(async () => { - setMarketplaceLoading(true); - setMarketplaceError(null); - try { - const result = await marketplaceSearch({ - query: searchQuery || undefined, - sort: "popular", - pageSize: 50, - }); - setMarketplaceResults(result.extensions); - } catch (err: any) { - setMarketplaceError(err.message ?? t("toast.searchFailed")); - setMarketplaceResults([]); - } finally { - setMarketplaceLoading(false); - } - }, [searchQuery, marketplaceSearch]); + // Marketplace search + const handleSearch = useCallback(async () => { + setMarketplaceLoading(true); + setMarketplaceError(null); + try { + const result = await marketplaceSearch({ + query: searchQuery || undefined, + sort: "popular", + pageSize: 50, + }); + setMarketplaceResults(result.extensions); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t("toast.searchFailed"); + setMarketplaceError(message); + setMarketplaceResults([]); + } finally { + setMarketplaceLoading(false); + } + }, [searchQuery, marketplaceSearch, t]); - const handleRefresh = useCallback(async () => { - setIsRefreshing(true); - try { - await refresh(); + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + try { + await refresh(); - if (activeTab === "browse") { - await handleSearch(); - } + if (activeTab === "browse") { + await handleSearch(); + } - toast.success(t("toast.refreshed")); - } catch { - toast.error(t("toast.refreshFailed")); - } finally { - setIsRefreshing(false); - } - }, [activeTab, handleSearch, refresh]); + toast.success(t("toast.refreshed")); + } catch { + toast.error(t("toast.refreshFailed")); + } finally { + setIsRefreshing(false); + } + }, [activeTab, handleSearch, refresh, t]); - // Auto-search when switching to browse tab - useEffect(() => { - if (activeTab === "browse" && marketplaceResults.length === 0 && !marketplaceLoading) { - handleSearch(); - } - }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps + // Auto-search when switching to browse tab + useEffect(() => { + if (activeTab === "browse" && marketplaceResults.length === 0 && !marketplaceLoading) { + handleSearch(); + } + }, [activeTab, handleSearch, marketplaceLoading, marketplaceResults.length]); - // Marketplace install - const handleMarketplaceInstall = useCallback( - async (ext: MarketplaceExtension) => { - setInstallingIds((prev) => new Set(prev).add(ext.id)); - try { - const result = await marketplaceInstall(ext.id, ext.downloadUrl); - if (result.success) { - toast.success(t("toast.marketplaceInstalled", undefined, { name: ext.name })); - // Update the marketplace results to show installed state - setMarketplaceResults((prev) => - prev.map((e) => (e.id === ext.id ? { ...e, installed: true } : e)), - ); - } else { - toast.error(t("toast.marketplaceInstallFailed", undefined, { name: ext.name }), { - description: result.error, - }); - } - } finally { - setInstallingIds((prev) => { - const next = new Set(prev); - next.delete(ext.id); - return next; - }); - } - }, - [marketplaceInstall], - ); + // Marketplace install + const handleMarketplaceInstall = useCallback( + async (ext: MarketplaceExtension) => { + setInstallingIds((prev) => new Set(prev).add(ext.id)); + try { + const result = await marketplaceInstall(ext.id, ext.downloadUrl); + if (result.success) { + toast.success(t("toast.marketplaceInstalled", undefined, { name: ext.name })); + // Update the marketplace results to show installed state + setMarketplaceResults((prev) => + prev.map((e) => (e.id === ext.id ? { ...e, installed: true } : e)), + ); + } else { + toast.error( + t("toast.marketplaceInstallFailed", undefined, { name: ext.name }), + { + description: result.error, + }, + ); + } + } finally { + setInstallingIds((prev) => { + const next = new Set(prev); + next.delete(ext.id); + return next; + }); + } + }, + [marketplaceInstall, t], + ); - return ( -
- {/* Header */} -
-
-
- -

{t("title")}

-
-
- - - - -
-
+ return ( +
+ {/* Header */} +
+
+
+ +

{t("title")}

+
+
+ + + + +
+
- -
+ +
- {/* Content */} -
- {!ready ? ( -
- -
- ) : ( - - - {activeTab === "installed" && ( - - setDetailData({ source: 'installed', ext, isActive: activeIds.has(ext.manifest.id) }) - } - /> - )} + {/* Content */} +
+ {!ready ? ( +
+ +
+ ) : ( + + + {activeTab === "installed" && ( + + setDetailData({ + source: "installed", + ext, + isActive: activeIds.has(ext.manifest.id), + }) + } + /> + )} - {activeTab === "browse" && ( - setDetailData({ source: 'marketplace', ext })} - /> - )} - - - )} -
+ {activeTab === "browse" && ( + + setDetailData({ source: "marketplace", ext }) + } + /> + )} +
+
+ )} +
- {/* Extension Detail Modal */} - {detailData && ( - setDetailData(null)} - onToggle={ - detailData.source === 'installed' - ? () => { - toggleExtension(detailData.ext.manifest.id); - setDetailData((prev) => - prev?.source === 'installed' - ? { ...prev, isActive: !prev.isActive } - : prev, - ); - } - : undefined - } - onInstall={ - detailData.source === 'marketplace' && !detailData.ext.installed - ? () => handleMarketplaceInstall(detailData.ext as MarketplaceExtension) - : undefined - } - isInstalling={ - detailData.source === 'marketplace' - ? installingIds.has(detailData.ext.id) - : false - } - /> - )} -
- ); + {/* Extension Detail Modal */} + {detailData && ( + setDetailData(null)} + onToggle={ + detailData.source === "installed" + ? () => { + toggleExtension(detailData.ext.manifest.id); + setDetailData((prev) => + prev?.source === "installed" + ? { ...prev, isActive: !prev.isActive } + : prev, + ); + } + : undefined + } + onInstall={ + detailData.source === "marketplace" && !detailData.ext.installed + ? () => handleMarketplaceInstall(detailData.ext as MarketplaceExtension) + : undefined + } + isInstalling={ + detailData.source === "marketplace" + ? installingIds.has(detailData.ext.id) + : false + } + /> + )} +
+ ); } // --------------------------------------------------------------------------- @@ -847,91 +896,91 @@ export default function ExtensionManager() { // --------------------------------------------------------------------------- function InstalledTab({ - extensions, - activeIds, - onToggle, - onUninstall, - onInstallFromFolder, - onOpenDirectory, - onViewDetail, + extensions, + activeIds, + onToggle, + onUninstall, + onInstallFromFolder, + onOpenDirectory, + onViewDetail, }: { - extensions: ExtensionInfo[]; - activeIds: Set; - onToggle: (id: string) => Promise; - onUninstall: (id: string, name: string) => void; - onInstallFromFolder: () => void; - onOpenDirectory: () => void; - onViewDetail: (ext: ExtensionInfo) => void; + extensions: ExtensionInfo[]; + activeIds: Set; + onToggle: (id: string) => Promise; + onUninstall: (id: string, name: string) => void; + onInstallFromFolder: () => void; + onOpenDirectory: () => void; + onViewDetail: (ext: ExtensionInfo) => void; }) { - const t = useScopedT("extensions"); - if (extensions.length === 0) { - return ( -
-
- -
-
-

{t("empty.title")}

-

- {t("empty.description")} -

-
-
- - -
-
- ); - } + const t = useScopedT("extensions"); + if (extensions.length === 0) { + return ( +
+
+ +
+
+

{t("empty.title")}

+

+ {t("empty.description")} +

+
+
+ + +
+
+ ); + } - return ( -
-
-

- {t("tabs.installed")} -

- -
- {extensions.map((ext) => ( - onToggle(ext.manifest.id)} - onUninstall={ - ext.builtin - ? undefined - : () => onUninstall(ext.manifest.id, ext.manifest.name) - } - onClick={() => onViewDetail(ext)} - /> - ))} -
- ); + return ( +
+
+

+ {t("tabs.installed")} +

+ +
+ {extensions.map((ext) => ( + onToggle(ext.manifest.id)} + onUninstall={ + ext.builtin + ? undefined + : () => onUninstall(ext.manifest.id, ext.manifest.name) + } + onClick={() => onViewDetail(ext)} + /> + ))} +
+ ); } // --------------------------------------------------------------------------- @@ -939,94 +988,94 @@ function InstalledTab({ // --------------------------------------------------------------------------- function BrowseTab({ - searchQuery, - onSearchQueryChange, - onSearch, - results, - loading, - error, - installingIds, - onInstall, - onViewDetail, + searchQuery, + onSearchQueryChange, + onSearch, + results, + loading, + error, + installingIds, + onInstall, + onViewDetail, }: { - searchQuery: string; - onSearchQueryChange: (q: string) => void; - onSearch: () => void; - results: MarketplaceExtension[]; - loading: boolean; - error: string | null; - installingIds: Set; - onInstall: (ext: MarketplaceExtension) => void; - onViewDetail: (ext: MarketplaceExtension) => void; + searchQuery: string; + onSearchQueryChange: (q: string) => void; + onSearch: () => void; + results: MarketplaceExtension[]; + loading: boolean; + error: string | null; + installingIds: Set; + onInstall: (ext: MarketplaceExtension) => void; + onViewDetail: (ext: MarketplaceExtension) => void; }) { - const t = useScopedT("extensions"); - return ( -
- {/* Search */} -
- - onSearchQueryChange(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") onSearch(); - }} - className="w-full h-8 pl-8 pr-3 rounded-lg bg-white/[0.04] border border-white/[0.08] text-[12px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-[#2563EB]/50 focus:border-[#2563EB]/30 transition-colors" - /> -
+ const t = useScopedT("extensions"); + return ( +
+ {/* Search */} +
+ + onSearchQueryChange(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") onSearch(); + }} + className="w-full h-8 pl-8 pr-3 rounded-lg bg-white/[0.04] border border-white/[0.08] text-[12px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-[#2563EB]/50 focus:border-[#2563EB]/30 transition-colors" + /> +
- {/* Results */} - {loading && ( -
- -
- )} + {/* Results */} + {loading && ( +
+ +
+ )} - {error && ( -
- -

{error}

- -
- )} + {error && ( +
+ +

{error}

+ +
+ )} - {!loading && !error && results.length === 0 && ( -
- -

- {searchQuery ? t("search.noResults") : t("search.noMarketplace")} -

-
- )} + {!loading && !error && results.length === 0 && ( +
+ +

+ {searchQuery ? t("search.noResults") : t("search.noMarketplace")} +

+
+ )} - {!loading && !error && results.length > 0 && ( -
-

- {results.length !== 1 - ? t("search.countPlural", undefined, { count: results.length }) - : t("search.count", undefined, { count: results.length })} -

- {results.map((ext) => ( - onInstall(ext)} - onClick={() => onViewDetail(ext)} - /> - ))} -
- )} -
- ); + {!loading && !error && results.length > 0 && ( +
+

+ {results.length !== 1 + ? t("search.countPlural", undefined, { count: results.length }) + : t("search.count", undefined, { count: results.length })} +

+ {results.map((ext) => ( + onInstall(ext)} + onClick={() => onViewDetail(ext)} + /> + ))} +
+ )} +
+ ); } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 6d5e46f2..60aa8e95 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -40,6 +40,7 @@ import type { CaptionCue, CropRegion, CursorStyle, + EditorEffectSection, FigureData, PlaybackSpeed, WebcamOverlaySettings, @@ -111,19 +112,6 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str ]; type BackgroundTab = "image" | "video" | "color" | "gradient"; -export type EditorEffectSection = - | "scene" - | "cursor" - | "captions" - | "webcam" - | "settings" - | "zoom" - | "frame" - | "crop" - | "extensions" - | "clip" - | `ext:${string}`; - function isHexWallpaper(value: string): boolean { return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); } @@ -439,8 +427,6 @@ interface SettingsPanelProps { onSpeedDelete?: (id: string) => void; } -export default SettingsPanel; - const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 1, label: "1.25×" }, { depth: 2, label: "1.5×" }, @@ -2291,13 +2277,13 @@ export function SettingsPanel({

{selectedZoomMode === "manual" ? tSettings( - "zoom.modeManualDescription", - "Set a fixed focus point for this zoom", - ) + "zoom.modeManualDescription", + "Set a fixed focus point for this zoom", + ) : tSettings( - "zoom.modeAutoDescription", - "Camera follows cursor automatically", - )} + "zoom.modeAutoDescription", + "Camera follows cursor automatically", + )}

diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9a1d610d..d4071c98 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,32 +1,32 @@ import { - ArrowClockwise as Redo2, - ArrowCounterClockwise as Undo2, + Check, CaretDown as ChevronDown, CaretUp as ChevronUp, - Check, ClosedCaptioning, Crop, Cursor, DownloadSimple as Download, - FloppyDisk as Save, FolderOpen, Gear, - MagicWand as WandSparkles, - MagnifyingGlassPlus as ZoomIn, Pause, + Camera as PhCameraRegular, Play, Plus, - Camera as PhCameraRegular, PuzzlePiece, + ArrowClockwise as Redo2, + FloppyDisk as Save, Scissors, SkipBack, SkipForward, - SpeakerHigh as Volume2, - SpeakerLow as Volume1, - SpeakerX as VolumeX, Sparkle, + ArrowCounterClockwise as Undo2, UserCircle as User, + SpeakerLow as Volume1, + SpeakerHigh as Volume2, + SpeakerX as VolumeX, + MagicWand as WandSparkles, X, + MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; import { motion } from "motion/react"; @@ -113,7 +113,7 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; -import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel"; +import { SettingsPanel } from "./SettingsPanel"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -153,6 +153,7 @@ import { DEFAULT_ZOOM_IN_OVERLAP_MS, DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, + type EditorEffectSection, type FigureData, getClipSourceEndMs, type PlaybackSpeed, @@ -676,7 +677,7 @@ export default function VideoEditor() { window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); pendingFreshRecordingAutoSuggestTimeoutRef.current = null; } - }, [videoPath]); + }, []); // Auto-activate builtin extensions at editor startup (idempotent) useEffect(() => { @@ -783,7 +784,27 @@ export default function VideoEditor() { annotationRegions, autoCaptions, autoCaptionSettings, - speedRegions: effectiveSpeedRegions, + speedRegions: (() => { + const clipDerived: SpeedRegion[] = 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 speedRegions; + const result = [...speedRegions]; + for (const cs of clipDerived) { + const overlaps = speedRegions.some( + (sr) => sr.endMs > cs.startMs && sr.startMs < cs.endMs, + ); + if (!overlaps) { + result.push(cs); + } + } + return result; + })(), previewWidth, previewHeight, cursorTelemetry, @@ -870,11 +891,11 @@ export default function VideoEditor() { cursorStyle, cursorSway, cursorTelemetry, + clipRegions, padding, shadowIntensity, showCursor, speedRegions, - clipRegions, wallpaper, webcam, zoomInDurationMs, @@ -884,6 +905,7 @@ export default function VideoEditor() { zoomOutDurationMs, zoomOutEasing, zoomRegions, + zoomClassicMode, ]); const markExportAsSaving = useCallback(() => { @@ -1271,7 +1293,6 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, - autoApplyFreshRecordingAutoZooms, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1313,6 +1334,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + frame, ], ); @@ -1729,8 +1751,9 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = - autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms + ? sourceVideoUrl + : null; setWebcam((prev) => ({ ...prev, enabled: Boolean(sessionResult.session?.webcamPath), @@ -1747,8 +1770,9 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = - autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms + ? sourceVideoUrl + : null; setWebcam((prev) => ({ ...prev, enabled: false, @@ -1767,8 +1791,13 @@ export default function VideoEditor() { loadInitialData(); }, [ applyLoadedProject, + autoApplyFreshRecordingAutoZooms, + initialEditorPreferences, smokeExportConfig.enabled, smokeExportConfig.inputPath, + smokeExportConfig.webcamInputPath, + smokeExportConfig.webcamShadow, + smokeExportConfig.webcamSize, ]); useEffect(() => { @@ -1839,8 +1868,6 @@ export default function VideoEditor() { cursorStyle, cursorSize, cursorSmoothing, - zoomSmoothness, - zoomClassicMode, cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, @@ -2362,7 +2389,6 @@ export default function VideoEditor() { speed: clip.speed as SpeedRegion["speed"], })); if (clipDerived.length === 0) return speedRegions; - // Timeline speed regions take precedence; only fill in clip speed where no overlap exists const result = [...speedRegions]; for (const cs of clipDerived) { const overlaps = speedRegions.some( @@ -3852,8 +3878,6 @@ export default function VideoEditor() { videoPath, wallpaper, trimRegions, - speedRegions, - clipRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, @@ -3894,14 +3918,21 @@ export default function VideoEditor() { effectiveZoomRegions, ensureSupportedMp4SourceDimensions, markExportAsSaving, + mp4FrameRate, remountPreview, showExportSuccessToast, + smokeExportConfig.backendPreference, smokeExportConfig.enabled, smokeExportConfig.useNativeExport, smokeExportConfig.maxDecodeQueue, smokeExportConfig.maxEncodeQueue, smokeExportConfig.maxPendingFrames, smokeExportConfig.outputPath, + smokeExportConfig.pipelineModel, + smokeExportConfig.shadowIntensity, + effectiveSpeedRegions, + frame, + smokeExportConfig.encodingMode, ], ); @@ -4593,9 +4624,7 @@ export default function VideoEditor() { whileHover={{ opacity: 1 }} initial={{ opacity: 0.55 }} > - +
@@ -4921,7 +4950,9 @@ export default function VideoEditor() { className="h-7 gap-1 rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 text-[11px] text-white/65 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] transition-all hover:bg-white/[0.08] hover:text-white" > - {t("editor.toolbar.addLayer")} + + {t("editor.toolbar.addLayer")} + diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts index 8f1578b0..4fcca759 100644 --- a/src/components/video-editor/index.ts +++ b/src/components/video-editor/index.ts @@ -1,6 +1,5 @@ -export { default as VideoEditor } from './VideoEditor'; -export { default as VideoPlayback } from './VideoPlayback'; -export { default as PlaybackControls } from './PlaybackControls'; -export { default as TimelineEditor } from './timeline/TimelineEditor'; -export { default as SettingsPanel } from './SettingsPanel'; - +export { default as PlaybackControls } from "./PlaybackControls"; +export { SettingsPanel } from "./SettingsPanel"; +export { default as TimelineEditor } from "./timeline/TimelineEditor"; +export { default as VideoEditor } from "./VideoEditor"; +export { default as VideoPlayback } from "./VideoPlayback"; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index ef6ca31f..17af7928 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,5 +1,29 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type WheelEvent } from "react"; +import type { Range, Span } from "dnd-timeline"; import { useTimelineContext } from "dnd-timeline"; +import { + Check, + ChevronDown, + Crop, + MessageSquare, + Music, + Plus, + Scissors, + WandSparkles, + ZoomIn, +} from "lucide-react"; +import { + forwardRef, + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type WheelEvent, +} from "react"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from "uuid"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -7,26 +31,36 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, WandSparkles, Music, Crop } from "lucide-react"; -import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import { v4 as uuidv4 } from 'uuid'; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { matchesShortcut } from "@/lib/shortcuts"; -import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; +import { cn } from "@/lib/utils"; +import { + ASPECT_RATIOS, + type AspectRatio, + getAspectRatioLabel, + isCustomAspectRatio, +} from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences"; -import TimelineWrapper from "./TimelineWrapper"; -import Row from "./Row"; +import { toFileUrl } from "../projectPersistence"; +import type { + AnnotationRegion, + AudioRegion, + ClipRegion, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + ZoomFocus, + ZoomRegion, +} from "../types"; +import AudioWaveform from "./AudioWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; -import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion, TrimRegion, ClipRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types"; -import { toFileUrl } from "../projectPersistence"; +import Row from "./Row"; +import TimelineWrapper from "./TimelineWrapper"; +import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks"; import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils"; -import { useAudioPeaks, type AudioPeaksData } from "./useAudioPeaks"; -import AudioWaveform from "./AudioWaveform"; const ZOOM_ROW_ID = "row-zoom"; const CLIP_ROW_ID = "row-clip"; @@ -35,70 +69,70 @@ const AUDIO_ROW_ID = "row-audio"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; interface TimelineEditorProps { - videoDuration: number; - currentTime: number; - playheadTime?: number; - onSeek?: (time: number) => void; - cursorTelemetry?: CursorTelemetryPoint[]; - autoSuggestZoomsTrigger?: number; - onAutoSuggestZoomsConsumed?: () => void; - disableSuggestedZooms?: boolean; - zoomRegions: ZoomRegion[]; - onZoomAdded: (span: Span) => void; - onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; - onZoomSpanChange: (id: string, span: Span) => void; - onZoomDelete: (id: string) => void; - selectedZoomId: string | null; - onSelectZoom: (id: string | null) => void; - trimRegions?: TrimRegion[]; - onTrimAdded?: (span: Span) => void; - onTrimSpanChange?: (id: string, span: Span) => void; - onTrimDelete?: (id: string) => void; - selectedTrimId?: string | null; - onSelectTrim?: (id: string | null) => void; - clipRegions?: ClipRegion[]; - onClipSplit?: (splitMs: number) => void; - onClipSpanChange?: (id: string, span: Span) => void; - onClipDelete?: (id: string) => void; - selectedClipId?: string | null; - onSelectClip?: (id: string | null) => void; - annotationRegions?: AnnotationRegion[]; - onAnnotationAdded?: (span: Span, trackIndex?: number) => void; - onAnnotationSpanChange?: (id: string, span: Span) => void; - onAnnotationDelete?: (id: string) => void; - selectedAnnotationId?: string | null; - onSelectAnnotation?: (id: string | null) => void; - speedRegions?: SpeedRegion[]; - onSpeedAdded?: (span: Span) => void; - onSpeedSpanChange?: (id: string, span: Span) => void; - onSpeedDelete?: (id: string) => void; - selectedSpeedId?: string | null; - onSelectSpeed?: (id: string | null) => void; - audioRegions?: AudioRegion[]; - onAudioAdded?: (span: Span, audioPath: string) => void; - onAudioSpanChange?: (id: string, span: Span) => void; - onAudioDelete?: (id: string) => void; - selectedAudioId?: string | null; - onSelectAudio?: (id: string | null) => void; - aspectRatio?: AspectRatio; - onAspectRatioChange?: (aspectRatio: AspectRatio) => void; - onOpenCropEditor?: () => void; - isCropped?: boolean; - videoPath?: string | null; - isPlaying?: boolean; - onTogglePlayPause?: () => void; - volume?: number; - onVolumeChange?: (volume: number) => void; - hideToolbar?: boolean; + videoDuration: number; + currentTime: number; + playheadTime?: number; + onSeek?: (time: number) => void; + cursorTelemetry?: CursorTelemetryPoint[]; + autoSuggestZoomsTrigger?: number; + onAutoSuggestZoomsConsumed?: () => void; + disableSuggestedZooms?: boolean; + zoomRegions: ZoomRegion[]; + onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; + onZoomSpanChange: (id: string, span: Span) => void; + onZoomDelete: (id: string) => void; + selectedZoomId: string | null; + onSelectZoom: (id: string | null) => void; + trimRegions?: TrimRegion[]; + onTrimAdded?: (span: Span) => void; + onTrimSpanChange?: (id: string, span: Span) => void; + onTrimDelete?: (id: string) => void; + selectedTrimId?: string | null; + onSelectTrim?: (id: string | null) => void; + clipRegions?: ClipRegion[]; + onClipSplit?: (splitMs: number) => void; + onClipSpanChange?: (id: string, span: Span) => void; + onClipDelete?: (id: string) => void; + selectedClipId?: string | null; + onSelectClip?: (id: string | null) => void; + annotationRegions?: AnnotationRegion[]; + onAnnotationAdded?: (span: Span, trackIndex?: number) => void; + onAnnotationSpanChange?: (id: string, span: Span) => void; + onAnnotationDelete?: (id: string) => void; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + speedRegions?: SpeedRegion[]; + onSpeedAdded?: (span: Span) => void; + onSpeedSpanChange?: (id: string, span: Span) => void; + onSpeedDelete?: (id: string) => void; + selectedSpeedId?: string | null; + onSelectSpeed?: (id: string | null) => void; + audioRegions?: AudioRegion[]; + onAudioAdded?: (span: Span, audioPath: string) => void; + onAudioSpanChange?: (id: string, span: Span) => void; + onAudioDelete?: (id: string) => void; + selectedAudioId?: string | null; + onSelectAudio?: (id: string | null) => void; + aspectRatio?: AspectRatio; + onAspectRatioChange?: (aspectRatio: AspectRatio) => void; + onOpenCropEditor?: () => void; + isCropped?: boolean; + videoPath?: string | null; + isPlaying?: boolean; + onTogglePlayPause?: () => void; + volume?: number; + onVolumeChange?: (volume: number) => void; + hideToolbar?: boolean; } export interface TimelineEditorHandle { - addZoom: () => void; - suggestZooms: () => void; - splitClip: () => void; - addAnnotation: (trackIndex?: number) => void; - addAudio: () => Promise; - keyframes: { id: string; time: number }[]; + addZoom: () => void; + suggestZooms: () => void; + splitClip: () => void; + addAnnotation: (trackIndex?: number) => void; + addAudio: () => Promise; + keyframes: { id: string; time: number }[]; } interface TimelineScaleConfig { @@ -108,13 +142,13 @@ interface TimelineScaleConfig { } interface TimelineRenderItem { - id: string; - rowId: string; - span: Span; - label: string; - zoomDepth?: number; - speedValue?: number; - variant: 'zoom' | 'trim' | 'clip' | 'annotation' | 'speed' | 'audio'; + id: string; + rowId: string; + span: Span; + label: string; + zoomDepth?: number; + speedValue?: number; + variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; } const SCALE_CANDIDATES = [ @@ -411,7 +445,7 @@ function TimelineAxis({ return (
{marker.label} @@ -462,47 +498,47 @@ function TimelineAxis({ } function Timeline({ - items, - videoDurationMs, - currentTimeMs, - onSeek, - onSelectZoom, - onSelectTrim, - onSelectClip, - onSelectAnnotation, - onSelectSpeed, - onSelectAudio, - selectedZoomId, - selectedTrimId: _selectedTrimId, - selectedClipId, - selectedAnnotationId, - selectedSpeedId: _selectedSpeedId, - selectedAudioId, - selectAllBlocksActive = false, - onClearBlockSelection, - keyframes = [], - audioPeaks, + items, + videoDurationMs, + currentTimeMs, + onSeek, + onSelectZoom, + onSelectTrim, + onSelectClip, + onSelectAnnotation, + onSelectSpeed, + onSelectAudio, + selectedZoomId, + selectedTrimId: _selectedTrimId, + selectedClipId, + selectedAnnotationId, + selectedSpeedId: _selectedSpeedId, + selectedAudioId, + selectAllBlocksActive = false, + onClearBlockSelection, + keyframes = [], + audioPeaks, }: { - items: TimelineRenderItem[]; - videoDurationMs: number; - currentTimeMs: number; - onSeek?: (time: number) => void; - onSelectZoom?: (id: string | null) => void; - onSelectTrim?: (id: string | null) => void; - onSelectClip?: (id: string | null) => void; - onSelectAnnotation?: (id: string | null) => void; - onSelectSpeed?: (id: string | null) => void; - onSelectAudio?: (id: string | null) => void; - selectedZoomId: string | null; - selectedTrimId?: string | null; - selectedClipId?: string | null; - selectedAnnotationId?: string | null; - selectedSpeedId?: string | null; - selectedAudioId?: string | null; - selectAllBlocksActive?: boolean; - onClearBlockSelection?: () => void; - keyframes?: { id: string; time: number }[]; - audioPeaks?: AudioPeaksData | null; + items: TimelineRenderItem[]; + videoDurationMs: number; + currentTimeMs: number; + onSeek?: (time: number) => void; + onSelectZoom?: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectSpeed?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; + selectedZoomId: string | null; + selectedTrimId?: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedSpeedId?: string | null; + selectedAudioId?: string | null; + selectAllBlocksActive?: boolean; + onClearBlockSelection?: () => void; + keyframes?: { id: string; time: number }[]; + audioPeaks?: AudioPeaksData | null; }) { const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const localTimelineRef = useRef(null); @@ -519,15 +555,15 @@ function Timeline({ (e: React.MouseEvent) => { if (!onSeek || videoDurationMs <= 0) return; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectClip?.(null); - onSelectAnnotation?.(null); - onSelectSpeed?.(null); - onSelectAudio?.(null); - onClearBlockSelection?.(); + // Only clear selection if clicking on empty space (not on items) + // This is handled by event propagation - items stop propagation + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onClearBlockSelection?.(); const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -538,20 +574,35 @@ function Timeline({ const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); const timeInSeconds = absoluteMs / 1000; - onSeek(timeInSeconds); - }, [onSeek, onSelectZoom, onSelectTrim, onSelectClip, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); + onSeek(timeInSeconds); + }, + [ + onSeek, + onSelectZoom, + onSelectTrim, + onSelectClip, + onSelectAnnotation, + onSelectSpeed, + onSelectAudio, + onClearBlockSelection, + videoDurationMs, + sidebarWidth, + range.start, + pixelsToValue, + ], + ); - const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID); + const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); - const clipItems = items.filter(item => item.rowId === CLIP_ROW_ID); - const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID); - const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID); + const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID); + const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); + const audioItems = items.filter((item) => item.rowId === AUDIO_ROW_ID); return (
@@ -564,1154 +615,1391 @@ function Timeline({ keyframes={keyframes} /> -
- - {audioPeaks && } - {clipItems.map((item) => ( - onSelectClip?.(item.id)} - variant="clip" - > - {item.label} - - ))} - +
+ + {audioPeaks && } + {clipItems.map((item) => ( + onSelectClip?.(item.id)} + variant="clip" + > + {item.label} + + ))} + - - {zoomItems.map((item) => ( - onSelectZoom?.(item.id)} - zoomDepth={item.zoomDepth} - variant="zoom" - > - {item.label} - - ))} - + + {zoomItems.map((item) => ( + onSelectZoom?.(item.id)} + zoomDepth={item.zoomDepth} + variant="zoom" + > + {item.label} + + ))} + + + {annotationItems.map((item) => ( + onSelectAnnotation?.(item.id)} + variant="annotation" + > + {item.label} + + ))} + - - {annotationItems.map((item) => ( - onSelectAnnotation?.(item.id)} - variant="annotation" - > - {item.label} - - ))} - - - - {audioItems.map((item) => ( - onSelectAudio?.(item.id)} - variant="audio" - > - {item.label} - - ))} - -
-
- ); + + {audioItems.map((item) => ( + onSelectAudio?.(item.id)} + variant="audio" + > + {item.label} + + ))} + +
+
+ ); } -const TimelineEditor = forwardRef(function TimelineEditor({ - videoDuration, - currentTime, - playheadTime, - onSeek, - cursorTelemetry = [], - autoSuggestZoomsTrigger = 0, - onAutoSuggestZoomsConsumed, - disableSuggestedZooms = false, - zoomRegions, - onZoomAdded, - onZoomSuggested, - onZoomSpanChange, - onZoomDelete, - selectedZoomId, - onSelectZoom, - trimRegions = [], - onTrimAdded, - onTrimSpanChange, - onTrimDelete, - selectedTrimId, - onSelectTrim, - clipRegions = [], - onClipSplit, - onClipSpanChange, - onClipDelete, - selectedClipId, - onSelectClip, - annotationRegions = [], - onAnnotationAdded, - onAnnotationSpanChange, - onAnnotationDelete, - selectedAnnotationId, - onSelectAnnotation, - speedRegions = [], - onSpeedAdded, - onSpeedSpanChange, - onSpeedDelete, - selectedSpeedId, - onSelectSpeed, - audioRegions = [], - onAudioAdded, - onAudioSpanChange, - onAudioDelete, - selectedAudioId, - onSelectAudio, - aspectRatio = 'native', - onAspectRatioChange, - onOpenCropEditor, - isCropped = false, - videoPath, -}, ref) { - const t = useScopedT("settings"); - const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); - const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); - const currentTimeMs = useMemo(() => Math.round((playheadTime ?? currentTime) * 1000), [currentTime, playheadTime]); - const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]); - const safeMinDurationMs = useMemo( - () => (totalMs > 0 ? Math.min(timelineScale.minItemDurationMs, totalMs) : timelineScale.minItemDurationMs), - [timelineScale.minItemDurationMs, totalMs], - ); - - const [range, setRange] = useState(() => createInitialRange(totalMs)); - const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); - const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); - const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); - const [customAspectWidth, setCustomAspectWidth] = useState(initialEditorPreferences.customAspectWidth); - const [customAspectHeight, setCustomAspectHeight] = useState(initialEditorPreferences.customAspectHeight); - const [scrollLabels, setScrollLabels] = useState({ - pan: 'Shift + Ctrl + Scroll', - zoom: 'Ctrl + Scroll' - }); - const isTimelineFocusedRef = useRef(false); - const timelineContainerRef = useRef(null); - const { shortcuts: keyShortcuts, isMac } = useShortcuts(); - const audioPeaks = useAudioPeaks(videoPath); - - useEffect(() => { - setRange(createInitialRange(totalMs)); - }, [totalMs]); - - useEffect(() => { - if (aspectRatio === 'native') { - return; - } - const [width, height] = aspectRatio.split(':'); - if (width && height) { - setCustomAspectWidth(width); - setCustomAspectHeight(height); - } - }, [aspectRatio]); - - useEffect(() => { - saveEditorPreferences({ - customAspectWidth, - customAspectHeight, - }); - }, [customAspectHeight, customAspectWidth]); - - const applyCustomAspectRatio = useCallback(() => { - const width = Number.parseInt(customAspectWidth, 10); - const height = Number.parseInt(customAspectHeight, 10); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - toast.error('Custom aspect ratio must be positive numbers.'); - return; - } - onAspectRatioChange?.(`${width}:${height}` as AspectRatio); - }, [customAspectHeight, customAspectWidth, onAspectRatioChange]); - - const handleCustomAspectRatioKeyDown = useCallback((event: ReactKeyboardEvent) => { - // Prevent Radix DropdownMenu typeahead from selecting preset items while typing. - event.stopPropagation(); - if (event.key === 'Enter') { - event.preventDefault(); - applyCustomAspectRatio(); - } - }, [applyCustomAspectRatio]); - - useEffect(() => { - formatShortcut(['shift', 'mod', 'Scroll']).then(pan => { - formatShortcut(['mod', 'Scroll']).then(zoom => { - setScrollLabels({ pan, zoom }); - }); - }); - }, []); - - // Add keyframe at current playhead position - const addKeyframe = useCallback(() => { - if (totalMs === 0) return; - const time = Math.max(0, Math.min(currentTimeMs, totalMs)); - if (keyframes.some(kf => Math.abs(kf.time - time) < 1)) return; - setKeyframes(prev => [...prev, { id: uuidv4(), time }]); - }, [currentTimeMs, totalMs, keyframes]); - - // Delete selected keyframe - const deleteSelectedKeyframe = useCallback(() => { - if (!selectedKeyframeId) return; - setKeyframes(prev => prev.filter(kf => kf.id !== selectedKeyframeId)); - setSelectedKeyframeId(null); - }, [selectedKeyframeId]); - - // Move keyframe to new time position - const handleKeyframeMove = useCallback((id: string, newTime: number) => { - setKeyframes(prev => prev.map(kf => kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf)); - }, [totalMs]); - - // Delete selected zoom item - const deleteSelectedZoom = useCallback(() => { - if (!selectedZoomId) return; - onZoomDelete(selectedZoomId); - onSelectZoom(null); - }, [selectedZoomId, onZoomDelete, onSelectZoom]); - - // Delete selected trim item - const deleteSelectedTrim = useCallback(() => { - if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; - onTrimDelete(selectedTrimId); - onSelectTrim(null); - }, [selectedTrimId, onTrimDelete, onSelectTrim]); - - const deleteSelectedClip = useCallback(() => { - if (!selectedClipId || !onClipDelete || !onSelectClip) return; - onClipDelete(selectedClipId); - onSelectClip(null); - }, [selectedClipId, onClipDelete, onSelectClip]); - - const deleteSelectedAnnotation = useCallback(() => { - if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return; - onAnnotationDelete(selectedAnnotationId); - onSelectAnnotation(null); - }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); - - const deleteSelectedSpeed = useCallback(() => { - if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return; - onSpeedDelete(selectedSpeedId); - onSelectSpeed(null); - }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]); - - const deleteSelectedAudio = useCallback(() => { - if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; - onAudioDelete(selectedAudioId); - onSelectAudio(null); - }, [selectedAudioId, onAudioDelete, onSelectAudio]); - - const clearSelectedBlocks = useCallback(() => { - onSelectZoom(null); - onSelectTrim?.(null); - onSelectClip?.(null); - onSelectAnnotation?.(null); - onSelectSpeed?.(null); - onSelectAudio?.(null); - setSelectAllBlocksActive(false); - }, [onSelectAnnotation, onSelectAudio, onSelectClip, onSelectSpeed, onSelectTrim, onSelectZoom]); - - const hasAnyTimelineBlocks = - zoomRegions.length > 0 || - trimRegions.length > 0 || - clipRegions.length > 0 || - annotationRegions.length > 0 || - speedRegions.length > 0 || - audioRegions.length > 0; - - const deleteAllBlocks = useCallback(() => { - const zoomIds = zoomRegions.map((region) => region.id); - const trimIds = trimRegions.map((region) => region.id); - const clipIds = clipRegions.map((region) => region.id); - const annotationIds = annotationRegions.map((region) => region.id); - const speedIds = speedRegions.map((region) => region.id); - const audioIds = audioRegions.map((region) => region.id); - - zoomIds.forEach((id) => onZoomDelete(id)); - trimIds.forEach((id) => onTrimDelete?.(id)); - clipIds.forEach((id) => onClipDelete?.(id)); - annotationIds.forEach((id) => onAnnotationDelete?.(id)); - speedIds.forEach((id) => onSpeedDelete?.(id)); - audioIds.forEach((id) => onAudioDelete?.(id)); - - clearSelectedBlocks(); - setSelectedKeyframeId(null); - }, [ - annotationRegions, - audioRegions, - clearSelectedBlocks, - clipRegions, - onAnnotationDelete, - onAudioDelete, - onClipDelete, - onSpeedDelete, - onTrimDelete, - onZoomDelete, - speedRegions, - trimRegions, - zoomRegions, - ]); - - const handleSelectZoom = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectZoom(id); - }, [onSelectZoom]); - - const handleSelectTrim = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectTrim?.(id); - }, [onSelectTrim]); - - const handleSelectClip = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectClip?.(id); - }, [onSelectClip]); - - const handleSelectAnnotation = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectAnnotation?.(id); - }, [onSelectAnnotation]); - - const handleSelectSpeed = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectSpeed?.(id); - }, [onSelectSpeed]); - - const handleSelectAudio = useCallback((id: string | null) => { - setSelectAllBlocksActive(false); - onSelectAudio?.(id); - }, [onSelectAudio]); - - useEffect(() => { - setRange(createInitialRange(totalMs)); - }, [totalMs]); - - // Normalize regions only when timeline bounds change (not on every region edit). - // Using refs to read current regions avoids a dependency-loop that re-fires - // this effect on every drag/resize and races with dnd-timeline's internal state. - const zoomRegionsRef = useRef(zoomRegions); - const trimRegionsRef = useRef(trimRegions); - const speedRegionsRef = useRef(speedRegions); - const audioRegionsRef = useRef(audioRegions); - zoomRegionsRef.current = zoomRegions; - trimRegionsRef.current = trimRegions; - speedRegionsRef.current = speedRegions; - audioRegionsRef.current = audioRegions; - - useEffect(() => { - if (totalMs === 0 || safeMinDurationMs <= 0) { - return; - } - - zoomRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); - - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd }); - } - }); - - trimRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); - - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); - } - }); - - speedRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); - - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); - } - }); - - audioRegionsRef.current.forEach((region) => { - const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); - const minEnd = clampedStart + safeMinDurationMs; - const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); - const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); - const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); - - if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { - onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); - } - }); - // Only re-run when the timeline scale changes, not on every region edit - }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange]); - - const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => { - // Determine which row the item belongs to - const isZoomItem = zoomRegions.some(r => r.id === excludeId); - const isTrimItem = trimRegions.some(r => r.id === excludeId); - const isClipItem = clipRegions.some(r => r.id === excludeId); - const isAnnotationItem = annotationRegions.some(r => r.id === excludeId); - const isSpeedItem = speedRegions.some(r => r.id === excludeId); - const isAudioItem = audioRegions.some(r => r.id === excludeId); - - if (isAnnotationItem) { - return false; - } - - // Helper to check overlap against a specific set of regions - const checkOverlap = (regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[]) => { - return regions.some((region) => { - if (region.id === excludeId) return false; - // True overlap: regions actually intersect (not just adjacent) - return newSpan.end > region.startMs && newSpan.start < region.endMs; - }); - }; - - if (isZoomItem) { - return checkOverlap(zoomRegions); - } - - if (isTrimItem) { - return checkOverlap(trimRegions); - } - - if (isClipItem) { - return checkOverlap(clipRegions); - } - - if (isSpeedItem) { - return checkOverlap(speedRegions); - } - - if (isAudioItem) { - return checkOverlap(audioRegions); - } - - return false; - }, [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions]); - - // Keep newly added timeline regions at the original short default instead of - // scaling them with the full recording length. - const defaultRegionDurationMs = useMemo( - () => Math.min(1000, totalMs), - [totalMs], - ); - - const handleAddZoom = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place zoom at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find(region => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any zoom region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place zoom here", { - description: "Zoom already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]); - - const handleSuggestZooms = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0) { - return; - } - - if (disableSuggestedZooms) { - toast.info("Suggested zooms are unavailable while cursor looping is enabled."); - return; - } - - if (!onZoomSuggested) { - toast.error("Zoom suggestion handler unavailable"); - return; - } - - if (cursorTelemetry.length < 2) { - toast.info("No cursor telemetry available", { - description: "Record a screencast first to generate cursor-based suggestions.", - }); - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - const result = buildInteractionZoomSuggestions({ - cursorTelemetry, - totalMs, - defaultDurationMs: defaultDuration, - reservedSpans: zoomRegions - .map((region) => ({ start: region.startMs, end: region.endMs })) - .sort((a, b) => a.start - b.start), - }); - - if (result.status === 'no-telemetry') { - toast.info("No usable cursor telemetry", { - description: "The recording does not include enough cursor movement data.", - }); - return; - } - - if (result.status === 'no-interactions') { - toast.info("No clear interaction moments found", { - description: "Try a recording with pauses or clicks around important actions.", - }); - return; - } - - if (result.status === 'no-slots' || result.suggestions.length === 0) { - toast.info("No auto-zoom slots available", { - description: "Detected dwell points overlap existing zoom regions.", - }); - return; - } - - for (const region of result.suggestions) { - onZoomSuggested({ start: region.start, end: region.end }, region.focus); - } - - toast.success(`Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`); - }, [ - videoDuration, - totalMs, - defaultRegionDurationMs, - zoomRegions, - disableSuggestedZooms, - onZoomSuggested, - cursorTelemetry, - ]); - - useEffect(() => { - if (autoSuggestZoomsTrigger <= 0) { - return; - } - - onAutoSuggestZoomsConsumed?.(); - - handleSuggestZooms(); - }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); - - const handleAddTrim = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place trim at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next trim region after the playhead - const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find(region => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any trim region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place trim here", { - description: "Trim already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onTrimAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]); - - const handleSplitClip = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { - return; - } - onClipSplit(currentTimeMs); - }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); - - const handleAddSpeed = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place speed region at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next speed region after the playhead - const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find(region => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any speed region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place speed here", { - description: "Speed region already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onSpeedAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]); - - const handleAddAudio = useCallback(async () => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { - return; - } - - const result = await (window as any).electronAPI.openAudioFilePicker(); - if (!result?.success || !result.path) { - return; - } - - const audioPath = result.path; - - // Load the audio file to get its full duration - const audioDurationMs = await new Promise((resolve) => { - const audio = new Audio(toFileUrl(audioPath)); - audio.addEventListener('loadedmetadata', () => { - resolve(Math.round(audio.duration * 1000)); - }); - audio.addEventListener('error', () => { - resolve(0); - }); - }); - - if (audioDurationMs <= 0) { - toast.error("Could not read audio file", { - description: "The selected file may be corrupted or in an unsupported format.", - }); - return; - } - - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find(region => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place audio here", { - description: "Audio region already exists at this location or not enough space available.", - }); - return; - } - - // Use full audio duration, but clamp to available gap and video length - const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos); - onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path); - }, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]); - - const handleAddAnnotation = useCallback((trackIndex = 0) => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { - return; - } - - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Multiple annotations can exist at the same timestamp - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const endPos = Math.min(startPos + defaultDuration, totalMs); - - onAnnotationAdded({ start: startPos, end: endPos }, trackIndex); - }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - if (matchesShortcut(e, { key: 'a', ctrl: true }, isMac)) { - if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) { - return; - } - - e.preventDefault(); - setSelectedKeyframeId(null); - setSelectAllBlocksActive(true); - return; - } - - if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { - addKeyframe(); - } - if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { - handleAddZoom(); - } - if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) { - handleAddTrim(); - } - if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) { - handleSplitClip(); - } - if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { - handleAddAnnotation(); - } - if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { - handleAddSpeed(); - } - - // Tab: Cycle through overlapping annotations at current time - if (e.key === 'Tab' && annotationRegions.length > 0) { - const currentTimeMs = Math.round(currentTime * 1000); - const overlapping = annotationRegions - .filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) - .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index - - if (overlapping.length > 0) { - e.preventDefault(); - - if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) { - onSelectAnnotation?.(overlapping[0].id); - } else { - // Cycle to next annotation - const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId); - const nextIndex = e.shiftKey - ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward - : (currentIndex + 1) % overlapping.length; // Tab = forward - onSelectAnnotation?.(overlapping[nextIndex].id); - } - } - } - // Delete key or Ctrl+D / Cmd+D - if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) { - if (selectAllBlocksActive) { - e.preventDefault(); - deleteAllBlocks(); - } else if (selectedKeyframeId) { - deleteSelectedKeyframe(); - } else if (selectedZoomId) { - deleteSelectedZoom(); - } else if (selectedTrimId) { - deleteSelectedTrim(); - } else if (selectedClipId) { - deleteSelectedClip(); - } else if (selectedAnnotationId) { - deleteSelectedAnnotation(); - } else if (selectedSpeedId) { - deleteSelectedSpeed(); - } else if (selectedAudioId) { - deleteSelectedAudio(); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [addKeyframe, handleAddZoom, handleAddTrim, handleSplitClip, handleAddAnnotation, handleAddSpeed, deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedSpeed, deleteSelectedAudio, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedClipId, selectedAnnotationId, selectedSpeedId, selectedAudioId, annotationRegions, currentTime, hasAnyTimelineBlocks, onSelectAnnotation, keyShortcuts, isMac, selectAllBlocksActive]); - - const clampedRange = useMemo(() => { - if (totalMs === 0) { - return range; - } - - return { - start: Math.max(0, Math.min(range.start, totalMs)), - end: Math.min(range.end, totalMs), - }; - }, [range, totalMs]); - - useImperativeHandle(ref, () => ({ - addZoom: handleAddZoom, - suggestZooms: handleSuggestZooms, - splitClip: handleSplitClip, - addAnnotation: handleAddAnnotation, - addAudio: handleAddAudio, - keyframes, - }), [handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes]); - - const timelineItems = useMemo(() => { - const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ - id: region.id, - rowId: ZOOM_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Zoom ${index + 1}`, - zoomDepth: region.depth, - variant: 'zoom', - })); - - const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({ - id: region.id, - rowId: CLIP_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Clip ${index + 1}`, - variant: 'clip', - })); - - const annotations: TimelineRenderItem[] = annotationRegions.map((region) => { - let label: string; - - if (region.type === 'text') { - // Show text preview - const preview = region.content.trim() || 'Empty text'; - label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; - } else if (region.type === 'image') { - label = 'Image'; - } else { - label = 'Annotation'; - } - - return { - id: region.id, - rowId: ANNOTATION_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label, - variant: 'annotation', - }; - }); - - const audios: TimelineRenderItem[] = audioRegions.map((region) => { - const fileName = region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || 'Audio'; - return { - id: region.id, - rowId: AUDIO_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: fileName, - variant: 'audio', - }; - }); - - return [...zooms, ...clips, ...annotations, ...audios]; - }, [zoomRegions, clipRegions, annotationRegions, audioRegions]); - - // Flat list of draggable row spans for neighbour-clamping during drag/resize. - const allRegionSpans = useMemo(() => { - const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: ZOOM_ROW_ID })); - const clips = clipRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: CLIP_ROW_ID })); - const audios = audioRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: AUDIO_ROW_ID })); - return [...zooms, ...clips, ...audios]; - }, [zoomRegions, clipRegions, audioRegions]); - - const handleItemSpanChange = useCallback((id: string, span: Span) => { - // Check if it's a zoom, trim, clip, speed, or annotation item - if (zoomRegions.some(r => r.id === id)) { - onZoomSpanChange(id, span); - } else if (clipRegions.some(r => r.id === id)) { - onClipSpanChange?.(id, span); - } else if (annotationRegions.some(r => r.id === id)) { - onAnnotationSpanChange?.(id, span); - } else if (audioRegions.some(r => r.id === id)) { - onAudioSpanChange?.(id, span); - } - }, [zoomRegions, clipRegions, annotationRegions, audioRegions, onZoomSpanChange, onClipSpanChange, onAnnotationSpanChange, onAudioSpanChange]); - - const panTimelineRange = useCallback((deltaMs: number) => { - if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) { - return; - } - - setRange((previous) => { - const visibleSpan = Math.max(1, previous.end - previous.start); - const maxStart = Math.max(0, totalMs - visibleSpan); - const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart)); - - return { - start: nextStart, - end: nextStart + visibleSpan, - }; - }); - }, [totalMs]); - - const handleTimelineWheel = useCallback((event: WheelEvent) => { - if (event.ctrlKey || event.metaKey || totalMs <= 0) { - return; - } - - const rawHorizontalDelta = Math.abs(event.deltaX) > 0 - ? event.deltaX - : event.shiftKey && Math.abs(event.deltaY) > 0 - ? event.deltaY - : 0; - - if (rawHorizontalDelta === 0) { - return; - } - - const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; - const visibleRangeMs = clampedRange.end - clampedRange.start; - - if (containerWidth <= 0 || visibleRangeMs <= 0) { - return; - } - - event.preventDefault(); - - const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode); - const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; - - panTimelineRange(deltaMs); - }, [clampedRange.end, clampedRange.start, panTimelineRange, totalMs]); - - if (!videoDuration || videoDuration === 0) { - return ( -
-
- -
-
-

No Video Loaded

-

Drag and drop a video to start editing

-
-
- ); - } - - return ( -
-
-
- - - - - -
-
- - - - - - {ASPECT_RATIOS.map((ratio) => ( - onAspectRatioChange?.(ratio)} - className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3" - > - {getAspectRatioLabel(ratio)} - {aspectRatio === ratio && } - - ))} -
-
- Custom - setCustomAspectWidth(event.target.value.replace(/\D/g, ''))} - onKeyDown={handleCustomAspectRatioKeyDown} - className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" - aria-label="Custom aspect width" - /> - : - setCustomAspectHeight(event.target.value.replace(/\D/g, ''))} - onKeyDown={handleCustomAspectRatioKeyDown} - className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" - aria-label="Custom aspect height" - /> - - {isCustomAspectRatio(aspectRatio) && } -
- - -
- -
-
-
- - Side Scroll - Pan - - - {scrollLabels.pan} - Pan - - - {scrollLabels.zoom} - Zoom - -
-
-
{ - isTimelineFocusedRef.current = true; - }} - onBlur={() => { - isTimelineFocusedRef.current = false; - }} - onMouseDown={() => { - timelineContainerRef.current?.focus(); - isTimelineFocusedRef.current = true; - }} - onClick={() => { - setSelectedKeyframeId(null); - setSelectAllBlocksActive(false); - }} - onWheel={handleTimelineWheel} - > - - - - -
-
- ); -}); +const TimelineEditor = forwardRef( + function TimelineEditor( + { + videoDuration, + currentTime, + playheadTime, + onSeek, + cursorTelemetry = [], + autoSuggestZoomsTrigger = 0, + onAutoSuggestZoomsConsumed, + disableSuggestedZooms = false, + zoomRegions, + onZoomAdded, + onZoomSuggested, + onZoomSpanChange, + onZoomDelete, + selectedZoomId, + onSelectZoom, + trimRegions = [], + onTrimAdded, + onTrimSpanChange, + onTrimDelete, + selectedTrimId, + onSelectTrim, + clipRegions = [], + onClipSplit, + onClipSpanChange, + onClipDelete, + selectedClipId, + onSelectClip, + annotationRegions = [], + onAnnotationAdded, + onAnnotationSpanChange, + onAnnotationDelete, + selectedAnnotationId, + onSelectAnnotation, + speedRegions = [], + onSpeedAdded, + onSpeedSpanChange, + onSpeedDelete, + selectedSpeedId, + onSelectSpeed, + audioRegions = [], + onAudioAdded, + onAudioSpanChange, + onAudioDelete, + selectedAudioId, + onSelectAudio, + aspectRatio = "native", + onAspectRatioChange, + onOpenCropEditor, + isCropped = false, + videoPath, + }, + ref, + ) { + const t = useScopedT("settings"); + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); + const totalMs = useMemo( + () => Math.max(0, Math.round(videoDuration * 1000)), + [videoDuration], + ); + const currentTimeMs = useMemo( + () => Math.round((playheadTime ?? currentTime) * 1000), + [currentTime, playheadTime], + ); + const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]); + const safeMinDurationMs = useMemo( + () => + totalMs > 0 + ? Math.min(timelineScale.minItemDurationMs, totalMs) + : timelineScale.minItemDurationMs, + [timelineScale.minItemDurationMs, totalMs], + ); + + const [range, setRange] = useState(() => createInitialRange(totalMs)); + const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); + const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); + const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false); + const [customAspectWidth, setCustomAspectWidth] = useState( + initialEditorPreferences.customAspectWidth, + ); + const [customAspectHeight, setCustomAspectHeight] = useState( + initialEditorPreferences.customAspectHeight, + ); + const [scrollLabels, setScrollLabels] = useState({ + pan: "Shift + Ctrl + Scroll", + zoom: "Ctrl + Scroll", + }); + const isTimelineFocusedRef = useRef(false); + const timelineContainerRef = useRef(null); + const { shortcuts: keyShortcuts, isMac } = useShortcuts(); + const audioPeaks = useAudioPeaks(videoPath); + + useEffect(() => { + setRange(createInitialRange(totalMs)); + }, [totalMs]); + + useEffect(() => { + if (aspectRatio === "native") { + return; + } + const [width, height] = aspectRatio.split(":"); + if (width && height) { + setCustomAspectWidth(width); + setCustomAspectHeight(height); + } + }, [aspectRatio]); + + useEffect(() => { + saveEditorPreferences({ + customAspectWidth, + customAspectHeight, + }); + }, [customAspectHeight, customAspectWidth]); + + const applyCustomAspectRatio = useCallback(() => { + const width = Number.parseInt(customAspectWidth, 10); + const height = Number.parseInt(customAspectHeight, 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + toast.error("Custom aspect ratio must be positive numbers."); + return; + } + onAspectRatioChange?.(`${width}:${height}` as AspectRatio); + }, [customAspectHeight, customAspectWidth, onAspectRatioChange]); + + const handleCustomAspectRatioKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // Prevent Radix DropdownMenu typeahead from selecting preset items while typing. + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + applyCustomAspectRatio(); + } + }, + [applyCustomAspectRatio], + ); + + useEffect(() => { + formatShortcut(["shift", "mod", "Scroll"]).then((pan) => { + formatShortcut(["mod", "Scroll"]).then((zoom) => { + setScrollLabels({ pan, zoom }); + }); + }); + }, []); + + // Add keyframe at current playhead position + const addKeyframe = useCallback(() => { + if (totalMs === 0) return; + const time = Math.max(0, Math.min(currentTimeMs, totalMs)); + if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return; + setKeyframes((prev) => [...prev, { id: uuidv4(), time }]); + }, [currentTimeMs, totalMs, keyframes]); + + // Delete selected keyframe + const deleteSelectedKeyframe = useCallback(() => { + if (!selectedKeyframeId) return; + setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId)); + setSelectedKeyframeId(null); + }, [selectedKeyframeId]); + + // Move keyframe to new time position + const handleKeyframeMove = useCallback( + (id: string, newTime: number) => { + setKeyframes((prev) => + prev.map((kf) => + kf.id === id + ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } + : kf, + ), + ); + }, + [totalMs], + ); + + // Delete selected zoom item + const deleteSelectedZoom = useCallback(() => { + if (!selectedZoomId) return; + onZoomDelete(selectedZoomId); + onSelectZoom(null); + }, [selectedZoomId, onZoomDelete, onSelectZoom]); + + // Delete selected trim item + const deleteSelectedTrim = useCallback(() => { + if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; + onTrimDelete(selectedTrimId); + onSelectTrim(null); + }, [selectedTrimId, onTrimDelete, onSelectTrim]); + + const deleteSelectedClip = useCallback(() => { + if (!selectedClipId || !onClipDelete || !onSelectClip) return; + onClipDelete(selectedClipId); + onSelectClip(null); + }, [selectedClipId, onClipDelete, onSelectClip]); + + const deleteSelectedAnnotation = useCallback(() => { + if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return; + onAnnotationDelete(selectedAnnotationId); + onSelectAnnotation(null); + }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]); + + const deleteSelectedSpeed = useCallback(() => { + if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return; + onSpeedDelete(selectedSpeedId); + onSelectSpeed(null); + }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]); + + const deleteSelectedAudio = useCallback(() => { + if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return; + onAudioDelete(selectedAudioId); + onSelectAudio(null); + }, [selectedAudioId, onAudioDelete, onSelectAudio]); + + const clearSelectedBlocks = useCallback(() => { + onSelectZoom(null); + onSelectTrim?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + setSelectAllBlocksActive(false); + }, [ + onSelectAnnotation, + onSelectAudio, + onSelectClip, + onSelectSpeed, + onSelectTrim, + onSelectZoom, + ]); + + const hasAnyTimelineBlocks = + zoomRegions.length > 0 || + trimRegions.length > 0 || + clipRegions.length > 0 || + annotationRegions.length > 0 || + speedRegions.length > 0 || + audioRegions.length > 0; + + const deleteAllBlocks = useCallback(() => { + const zoomIds = zoomRegions.map((region) => region.id); + const trimIds = trimRegions.map((region) => region.id); + const clipIds = clipRegions.map((region) => region.id); + const annotationIds = annotationRegions.map((region) => region.id); + const speedIds = speedRegions.map((region) => region.id); + const audioIds = audioRegions.map((region) => region.id); + + zoomIds.forEach((id) => onZoomDelete(id)); + trimIds.forEach((id) => onTrimDelete?.(id)); + clipIds.forEach((id) => onClipDelete?.(id)); + annotationIds.forEach((id) => onAnnotationDelete?.(id)); + speedIds.forEach((id) => onSpeedDelete?.(id)); + audioIds.forEach((id) => onAudioDelete?.(id)); + + clearSelectedBlocks(); + setSelectedKeyframeId(null); + }, [ + annotationRegions, + audioRegions, + clearSelectedBlocks, + clipRegions, + onAnnotationDelete, + onAudioDelete, + onClipDelete, + onSpeedDelete, + onTrimDelete, + onZoomDelete, + speedRegions, + trimRegions, + zoomRegions, + ]); + + const handleSelectZoom = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectZoom(id); + }, + [onSelectZoom], + ); + + const handleSelectTrim = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectTrim?.(id); + }, + [onSelectTrim], + ); + + const handleSelectClip = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectClip?.(id); + }, + [onSelectClip], + ); + + const handleSelectAnnotation = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAnnotation?.(id); + }, + [onSelectAnnotation], + ); + + const handleSelectSpeed = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectSpeed?.(id); + }, + [onSelectSpeed], + ); + + const handleSelectAudio = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectAudio?.(id); + }, + [onSelectAudio], + ); + + useEffect(() => { + setRange(createInitialRange(totalMs)); + }, [totalMs]); + + // Normalize regions only when timeline bounds change (not on every region edit). + // Using refs to read current regions avoids a dependency-loop that re-fires + // this effect on every drag/resize and races with dnd-timeline's internal state. + const zoomRegionsRef = useRef(zoomRegions); + const trimRegionsRef = useRef(trimRegions); + const speedRegionsRef = useRef(speedRegions); + const audioRegionsRef = useRef(audioRegions); + zoomRegionsRef.current = zoomRegions; + trimRegionsRef.current = trimRegions; + speedRegionsRef.current = speedRegions; + audioRegionsRef.current = audioRegions; + + useEffect(() => { + if (totalMs === 0 || safeMinDurationMs <= 0) { + return; + } + + zoomRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max( + 0, + Math.min(clampedStart, totalMs - safeMinDurationMs), + ); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + + trimRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max( + 0, + Math.min(clampedStart, totalMs - safeMinDurationMs), + ); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + + speedRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max( + 0, + Math.min(clampedStart, totalMs - safeMinDurationMs), + ); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + + audioRegionsRef.current.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max( + 0, + Math.min(clampedStart, totalMs - safeMinDurationMs), + ); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + // Only re-run when the timeline scale changes, not on every region edit + }, [ + totalMs, + safeMinDurationMs, + onZoomSpanChange, + onTrimSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + ]); + + const hasOverlap = useCallback( + (newSpan: Span, excludeId?: string): boolean => { + // Determine which row the item belongs to + const isZoomItem = zoomRegions.some((r) => r.id === excludeId); + const isTrimItem = trimRegions.some((r) => r.id === excludeId); + const isClipItem = clipRegions.some((r) => r.id === excludeId); + const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); + const isSpeedItem = speedRegions.some((r) => r.id === excludeId); + const isAudioItem = audioRegions.some((r) => r.id === excludeId); + + if (isAnnotationItem) { + return false; + } + + // Helper to check overlap against a specific set of regions + const checkOverlap = ( + regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[], + ) => { + return regions.some((region) => { + if (region.id === excludeId) return false; + // True overlap: regions actually intersect (not just adjacent) + return newSpan.end > region.startMs && newSpan.start < region.endMs; + }); + }; + + if (isZoomItem) { + return checkOverlap(zoomRegions); + } + + if (isTrimItem) { + return checkOverlap(trimRegions); + } + + if (isClipItem) { + return checkOverlap(clipRegions); + } + + if (isSpeedItem) { + return checkOverlap(speedRegions); + } + + if (isAudioItem) { + return checkOverlap(audioRegions); + } + + return false; + }, + [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions], + ); + + // Keep newly added timeline regions at the original short default instead of + // scaling them with the full recording length. + const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); + + const handleAddZoom = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + // Always place zoom at playhead + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + // Find the next zoom region after the playhead + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + // Check if playhead is inside any zoom region + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + if (isOverlapping || gapToNext <= 0) { + toast.error("Cannot place zoom here", { + description: + "Zoom already exists at this location or not enough space available.", + }); + return; + } + + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + onZoomAdded({ start: startPos, end: startPos + actualDuration }); + }, [ + videoDuration, + totalMs, + currentTimeMs, + zoomRegions, + onZoomAdded, + defaultRegionDurationMs, + ]); + + const handleSuggestZooms = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + if (disableSuggestedZooms) { + toast.info("Suggested zooms are unavailable while cursor looping is enabled."); + return; + } + + if (!onZoomSuggested) { + toast.error("Zoom suggestion handler unavailable"); + return; + } + + if (cursorTelemetry.length < 2) { + toast.info("No cursor telemetry available", { + description: "Record a screencast first to generate cursor-based suggestions.", + }); + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry, + totalMs, + defaultDurationMs: defaultDuration, + reservedSpans: zoomRegions + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start), + }); + + if (result.status === "no-telemetry") { + toast.info("No usable cursor telemetry", { + description: "The recording does not include enough cursor movement data.", + }); + return; + } + + if (result.status === "no-interactions") { + toast.info("No clear interaction moments found", { + description: "Try a recording with pauses or clicks around important actions.", + }); + return; + } + + if (result.status === "no-slots" || result.suggestions.length === 0) { + toast.info("No auto-zoom slots available", { + description: "Detected dwell points overlap existing zoom regions.", + }); + return; + } + + for (const region of result.suggestions) { + onZoomSuggested({ start: region.start, end: region.end }, region.focus); + } + + toast.success( + `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`, + ); + }, [ + videoDuration, + totalMs, + defaultRegionDurationMs, + zoomRegions, + disableSuggestedZooms, + onZoomSuggested, + cursorTelemetry, + ]); + + useEffect(() => { + if (autoSuggestZoomsTrigger <= 0) { + return; + } + + onAutoSuggestZoomsConsumed?.(); + + handleSuggestZooms(); + }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]); + + const handleAddTrim = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + // Always place trim at playhead + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + // Find the next trim region after the playhead + const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + // Check if playhead is inside any trim region + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + if (isOverlapping || gapToNext <= 0) { + toast.error("Cannot place trim here", { + description: + "Trim already exists at this location or not enough space available.", + }); + return; + } + + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + onTrimAdded({ start: startPos, end: startPos + actualDuration }); + }, [ + videoDuration, + totalMs, + currentTimeMs, + trimRegions, + onTrimAdded, + defaultRegionDurationMs, + ]); + + const handleSplitClip = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { + return; + } + onClipSplit(currentTimeMs); + }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); + + const handleAddSpeed = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + // Always place speed region at playhead + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + // Find the next speed region after the playhead + const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + // Check if playhead is inside any speed region + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + if (isOverlapping || gapToNext <= 0) { + toast.error("Cannot place speed here", { + description: + "Speed region already exists at this location or not enough space available.", + }); + return; + } + + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + onSpeedAdded({ start: startPos, end: startPos + actualDuration }); + }, [ + videoDuration, + totalMs, + currentTimeMs, + speedRegions, + onSpeedAdded, + defaultRegionDurationMs, + ]); + + const handleAddAudio = useCallback(async () => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { + return; + } + + const result = await window.electronAPI.openAudioFilePicker(); + if (!result?.success || !result.path) { + return; + } + + const audioPath = result.path; + + // Load the audio file to get its full duration + const audioDurationMs = await new Promise((resolve) => { + const audio = new Audio(toFileUrl(audioPath)); + audio.addEventListener("loadedmetadata", () => { + resolve(Math.round(audio.duration * 1000)); + }); + audio.addEventListener("error", () => { + resolve(0); + }); + }); + + if (audioDurationMs <= 0) { + toast.error("Could not read audio file", { + description: "The selected file may be corrupted or in an unsupported format.", + }); + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + if (isOverlapping || gapToNext <= 0) { + toast.error("Cannot place audio here", { + description: + "Audio region already exists at this location or not enough space available.", + }); + return; + } + + // Use full audio duration, but clamp to available gap and video length + const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos); + onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path); + }, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]); + + const handleAddAnnotation = useCallback( + (trackIndex = 0) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + // Multiple annotations can exist at the same timestamp + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + const endPos = Math.min(startPos + defaultDuration, totalMs); + + onAnnotationAdded({ start: startPos, end: endPos }, trackIndex); + }, + [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) { + if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) { + return; + } + + e.preventDefault(); + setSelectedKeyframeId(null); + setSelectAllBlocksActive(true); + return; + } + + if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { + addKeyframe(); + } + if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { + handleAddZoom(); + } + if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) { + handleAddTrim(); + } + if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) { + handleSplitClip(); + } + if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { + handleAddAnnotation(); + } + if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { + handleAddSpeed(); + } + + // Tab: Cycle through overlapping annotations at current time + if (e.key === "Tab" && annotationRegions.length > 0) { + const currentTimeMs = Math.round(currentTime * 1000); + const overlapping = annotationRegions + .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) + .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index + + if (overlapping.length > 0) { + e.preventDefault(); + + if ( + !selectedAnnotationId || + !overlapping.some((a) => a.id === selectedAnnotationId) + ) { + onSelectAnnotation?.(overlapping[0].id); + } else { + // Cycle to next annotation + const currentIndex = overlapping.findIndex( + (a) => a.id === selectedAnnotationId, + ); + const nextIndex = e.shiftKey + ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward + : (currentIndex + 1) % overlapping.length; // Tab = forward + onSelectAnnotation?.(overlapping[nextIndex].id); + } + } + } + // Delete key or Ctrl+D / Cmd+D + if ( + e.key === "Delete" || + e.key === "Backspace" || + matchesShortcut(e, keyShortcuts.deleteSelected, isMac) + ) { + if (selectAllBlocksActive) { + e.preventDefault(); + deleteAllBlocks(); + } else if (selectedKeyframeId) { + deleteSelectedKeyframe(); + } else if (selectedZoomId) { + deleteSelectedZoom(); + } else if (selectedTrimId) { + deleteSelectedTrim(); + } else if (selectedClipId) { + deleteSelectedClip(); + } else if (selectedAnnotationId) { + deleteSelectedAnnotation(); + } else if (selectedSpeedId) { + deleteSelectedSpeed(); + } else if (selectedAudioId) { + deleteSelectedAudio(); + } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + addKeyframe, + handleAddZoom, + handleAddTrim, + handleSplitClip, + handleAddAnnotation, + handleAddSpeed, + deleteAllBlocks, + deleteSelectedKeyframe, + deleteSelectedZoom, + deleteSelectedTrim, + deleteSelectedClip, + deleteSelectedAnnotation, + deleteSelectedSpeed, + deleteSelectedAudio, + selectedKeyframeId, + selectedZoomId, + selectedTrimId, + selectedClipId, + selectedAnnotationId, + selectedSpeedId, + selectedAudioId, + annotationRegions, + currentTime, + hasAnyTimelineBlocks, + onSelectAnnotation, + keyShortcuts, + isMac, + selectAllBlocksActive, + ]); + + const clampedRange = useMemo(() => { + if (totalMs === 0) { + return range; + } + + return { + start: Math.max(0, Math.min(range.start, totalMs)), + end: Math.min(range.end, totalMs), + }; + }, [range, totalMs]); + + useImperativeHandle( + ref, + () => ({ + addZoom: handleAddZoom, + suggestZooms: handleSuggestZooms, + splitClip: handleSplitClip, + addAnnotation: handleAddAnnotation, + addAudio: handleAddAudio, + keyframes, + }), + [ + handleAddAnnotation, + handleAddAudio, + handleAddZoom, + handleSuggestZooms, + handleSplitClip, + keyframes, + ], + ); + + const timelineItems = useMemo(() => { + const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ + id: region.id, + rowId: ZOOM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Zoom ${index + 1}`, + zoomDepth: region.depth, + variant: "zoom", + })); + + const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({ + id: region.id, + rowId: CLIP_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Clip ${index + 1}`, + variant: "clip", + })); + + const annotations: TimelineRenderItem[] = annotationRegions.map((region) => { + let label: string; + + if (region.type === "text") { + // Show text preview + const preview = region.content.trim() || "Empty text"; + label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; + } else if (region.type === "image") { + label = "Image"; + } else { + label = "Annotation"; + } + + return { + id: region.id, + rowId: ANNOTATION_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label, + variant: "annotation", + }; + }); + + const audios: TimelineRenderItem[] = audioRegions.map((region) => { + const fileName = + region.audioPath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || "Audio"; + return { + id: region.id, + rowId: AUDIO_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: fileName, + variant: "audio", + }; + }); + + return [...zooms, ...clips, ...annotations, ...audios]; + }, [zoomRegions, clipRegions, annotationRegions, audioRegions]); + + // Flat list of draggable row spans for neighbour-clamping during drag/resize. + const allRegionSpans = useMemo(() => { + const zooms = zoomRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: ZOOM_ROW_ID, + })); + const clips = clipRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: CLIP_ROW_ID, + })); + const audios = audioRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: AUDIO_ROW_ID, + })); + return [...zooms, ...clips, ...audios]; + }, [zoomRegions, clipRegions, audioRegions]); + + const handleItemSpanChange = useCallback( + (id: string, span: Span) => { + // Check if it's a zoom, trim, clip, speed, or annotation item + if (zoomRegions.some((r) => r.id === id)) { + onZoomSpanChange(id, span); + } else if (clipRegions.some((r) => r.id === id)) { + onClipSpanChange?.(id, span); + } else if (annotationRegions.some((r) => r.id === id)) { + onAnnotationSpanChange?.(id, span); + } else if (audioRegions.some((r) => r.id === id)) { + onAudioSpanChange?.(id, span); + } + }, + [ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + onZoomSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onAudioSpanChange, + ], + ); + + const panTimelineRange = useCallback( + (deltaMs: number) => { + if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) { + return; + } + + setRange((previous) => { + const visibleSpan = Math.max(1, previous.end - previous.start); + const maxStart = Math.max(0, totalMs - visibleSpan); + const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart)); + + return { + start: nextStart, + end: nextStart + visibleSpan, + }; + }); + }, + [totalMs], + ); + + const handleTimelineWheel = useCallback( + (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey || totalMs <= 0) { + return; + } + + const rawHorizontalDelta = + Math.abs(event.deltaX) > 0 + ? event.deltaX + : event.shiftKey && Math.abs(event.deltaY) > 0 + ? event.deltaY + : 0; + + if (rawHorizontalDelta === 0) { + return; + } + + const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; + const visibleRangeMs = clampedRange.end - clampedRange.start; + + if (containerWidth <= 0 || visibleRangeMs <= 0) { + return; + } + + event.preventDefault(); + + const horizontalDeltaPx = normalizeWheelDeltaToPixels( + rawHorizontalDelta, + event.deltaMode, + ); + const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; + + panTimelineRange(deltaMs); + }, + [clampedRange.end, clampedRange.start, panTimelineRange, totalMs], + ); + + if (!videoDuration || videoDuration === 0) { + return ( +
+
+ +
+
+

No Video Loaded

+

+ Drag and drop a video to start editing +

+
+
+ ); + } + + return ( +
+
+
+ + + + + +
+
+ + + + + + {ASPECT_RATIOS.map((ratio) => ( + onAspectRatioChange?.(ratio)} + className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3" + > + {getAspectRatioLabel(ratio)} + {aspectRatio === ratio && ( + + )} + + ))} +
+
+ Custom + + setCustomAspectWidth( + event.target.value.replace(/\D/g, ""), + ) + } + onKeyDown={handleCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect width" + /> + : + + setCustomAspectHeight( + event.target.value.replace(/\D/g, ""), + ) + } + onKeyDown={handleCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect height" + /> + + {isCustomAspectRatio(aspectRatio) && ( + + )} +
+ + +
+ +
+
+
+ + + Side Scroll + + Pan + + + + {scrollLabels.pan} + + Pan + + + + {scrollLabels.zoom} + + Zoom + +
+
+
{ + isTimelineFocusedRef.current = true; + }} + onBlur={() => { + isTimelineFocusedRef.current = false; + }} + onMouseDown={() => { + timelineContainerRef.current?.focus(); + isTimelineFocusedRef.current = true; + }} + onClick={() => { + setSelectedKeyframeId(null); + setSelectAllBlocksActive(false); + }} + onWheel={handleTimelineWheel} + > + + + + +
+
+ ); + }, +); TimelineEditor.displayName = "TimelineEditor"; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 0d71fbb7..858ab65f 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -20,7 +20,13 @@ export interface CursorTelemetryPoint { timeMs: number; cx: number; cy: number; - interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup"; + interactionType?: + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "mouseup"; cursorType?: | "arrow" | "text" @@ -43,14 +49,22 @@ export interface CursorVisualSettings { style: CursorStyle; } -export type CursorStyle = - | "tahoe" - | "dot" - | "figma" - | "mono" - | (string & {}); // extension-contributed cursor styles +export type CursorStyle = "tahoe" | "dot" | "figma" | "mono" | (string & {}); // extension-contributed cursor styles export const DEFAULT_CURSOR_STYLE: CursorStyle = "tahoe"; +export type EditorEffectSection = + | "scene" + | "cursor" + | "captions" + | "webcam" + | "settings" + | "zoom" + | "frame" + | "crop" + | "extensions" + | "clip" + | `ext:${string}`; + export type ZoomTransitionEasing = "recordly" | "glide" | "smooth" | "snappy" | "linear"; export type WebcamCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; @@ -183,7 +197,6 @@ export const BLUR_ANNOTATION_STRENGTH = 20; export const BASE_PREVIEW_WIDTH = 1920; export const BASE_PREVIEW_HEIGHT = 1080; - export type ArrowDirection = | "up" | "down" diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index bfe0a403..674c7646 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -27,6 +27,8 @@ "noMicrophonesFound": "마이크를 찾을 수 없습니다", "webcam": "웹캠", "turnOffWebcam": "웹캠 끄기", + "hideFloatingWebcamPreview": "플로팅 미리보기 숨기기", + "showFloatingWebcamPreview": "플로팅 미리보기 표시", "selectWebcamToEnable": "사용할 웹캠을 선택하세요", "noWebcamsFound": "웹캠을 찾을 수 없습니다", "recordingsFolder": "녹화 폴더", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 750be65f..76646b52 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -159,6 +159,7 @@ "high": "높음", "original": "원본" }, + "fpsTitle": "FPS", "loop": "반복", "outputDimensions": "출력: {{dimensions}}px", "sizePresetOriginalShort": "원본", diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index 785c6098..33729275 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -27,6 +27,8 @@ "noMicrophonesFound": "Geen microfoons gevonden", "webcam": "Webcam", "turnOffWebcam": "Webcam uitschakelen", + "hideFloatingWebcamPreview": "Zwevende voorbeeldweergave verbergen", + "showFloatingWebcamPreview": "Zwevende voorbeeldweergave tonen", "selectWebcamToEnable": "Selecteer een webcam om in te schakelen", "noWebcamsFound": "Geen webcams gevonden", "recordingsFolder": "Opnamepad",