From 48cdb5c2034c1e57ce9b4453e7dfd7a95b952bd2 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:52:53 +1000 Subject: [PATCH] feat: settings tab with language picker, extension fixes, and i18n expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsPanel: new 'settings' section — in-app language picker (all 5 locales), auto-apply fresh recording zooms toggle, connect-neighboring- zooms toggle, keybinds shortcut into KeyboardShortcutsDialog - VideoEditor: wire autoApplyFreshRecordingAutoZooms preference through to SettingsPanel; connect zoom settings into new section - editorPreferences: add autoApplyFreshRecordingAutoZooms field; update tests - extensionMarketplace: filter __MACOSX sidecar dirs when unzipping extensions uploaded from macOS Finder (fixes manifest-not-found on Finder zips) - useExtensions: guard window.electronAPI for SSR safety; reformat to tabs - I18nContext: expose setLocale; persist locale preference - ShortcutsContext: shortcut registration improvements - lib/extensions: renderHooks, extensionHost, fileUrls, cursorCoordinates updated for new extension capability surface - i18n: expand extension locale strings across all 5 supported languages --- src/components/video-editor/SettingsPanel.tsx | 929 ++++++---- src/components/video-editor/VideoEditor.tsx | 1496 +++++++++++------ .../video-editor/editorPreferences.test.ts | 10 + .../video-editor/editorPreferences.ts | 28 +- .../video-editor/projectPersistence.ts | 175 +- src/contexts/I18nContext.tsx | 415 ++--- src/contexts/ShortcutsContext.tsx | 102 +- src/hooks/useExtensions.ts | 447 ++--- src/i18n/config.ts | 26 +- src/i18n/locales/en/extensions.json | 117 +- src/i18n/locales/es/extensions.json | 115 +- src/i18n/locales/ko/extensions.json | 115 +- src/i18n/locales/nl/extensions.json | 115 +- src/i18n/locales/zh-CN/extensions.json | 115 +- src/lib/extensions/cursorCoordinates.ts | 7 +- src/lib/extensions/extensionHost.ts | 56 +- src/lib/extensions/fileUrls.ts | 2 +- src/lib/extensions/index.ts | 56 +- src/lib/extensions/renderHooks.ts | 404 ++--- src/lib/extensions/types.ts | 4 +- 20 files changed, 2859 insertions(+), 1875 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5fe6d9c0..33a990d4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { Palette, Trash2, Upload, X } from "lucide-react"; +import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -13,18 +13,25 @@ import { import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { getAssetPath, getRenderableAssetUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath"; -import { cn } from "@/lib/utils"; -import { extensionHost, type FrameInstance } from "@/lib/extensions"; import type { ExtensionSettingField } from "@/lib/extensions"; +import { extensionHost, type FrameInstance } from "@/lib/extensions"; +import { cn } from "@/lib/utils"; import type { BuiltInWallpaper } from "@/lib/wallpapers"; -import { BUILT_IN_WALLPAPERS, getAvailableWallpapers, isVideoWallpaperSource } from "@/lib/wallpapers"; +import { + BUILT_IN_WALLPAPERS, + getAvailableWallpapers, + isVideoWallpaperSource, +} from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; import minimalCursorUrl from "../../../Minimal Cursor.svg"; import tahoeCursorUrl from "../../assets/cursors/Cursor=Default.svg"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; +import type { AppLocale } from "../../i18n/config"; +import { SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { SliderControl } from "./SliderControl"; +import { KeyboardShortcutsDialog } from "./TutorialHelp"; import type { AnnotationRegion, AnnotationType, @@ -109,10 +116,12 @@ export type EditorEffectSection = | "cursor" | "captions" | "webcam" + | "settings" | "zoom" | "frame" | "crop" | "extensions" + | "clip" | `ext:${string}`; function isHexWallpaper(value: string): boolean { @@ -137,14 +146,20 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab { function SectionLabel({ children }: { children: React.ReactNode }) { return ( -

{children}

+

+ {children} +

); } /** * Renders extension-contributed settings fields (toggle, slider, select, color, text). */ -function ExtensionSettingsSection({ extensionId, label, fields }: { +function ExtensionSettingsSection({ + extensionId, + label, + fields, +}: { extensionId: string; label: string; fields: ExtensionSettingField[]; @@ -153,19 +168,29 @@ function ExtensionSettingsSection({ extensionId, label, fields }: { return (
-

{label}

+

+ {label} +

{fields.map((field) => { - const value = extensionHost.getExtensionSetting(extensionId, field.id) ?? field.defaultValue; + const value = + extensionHost.getExtensionSetting(extensionId, field.id) ?? field.defaultValue; - if (field.type === 'toggle') { + if (field.type === "toggle") { return ( -
+
{field.label} { - extensionHost.setExtensionSetting(extensionId, field.id, checked); - forceUpdate(n => n + 1); + extensionHost.setExtensionSetting( + extensionId, + field.id, + checked, + ); + forceUpdate((n) => n + 1); }} className="data-[state=checked]:bg-[#2563EB] scale-75" /> @@ -173,48 +198,70 @@ function ExtensionSettingsSection({ extensionId, label, fields }: { ); } - if (field.type === 'slider') { + if (field.type === "slider") { return ( -
- {field.label} +
+ + {field.label} +
{ - extensionHost.setExtensionSetting(extensionId, field.id, parseFloat(e.target.value)); - forceUpdate(n => n + 1); + extensionHost.setExtensionSetting( + extensionId, + field.id, + parseFloat(e.target.value), + ); + forceUpdate((n) => n + 1); }} className="w-20 h-1 accent-[#2563EB]" /> - {(typeof value === 'number' ? value : 0).toFixed(1)} + {(typeof value === "number" ? value : 0).toFixed(1)}
); } - if (field.type === 'select' && field.options) { + if (field.type === "select" && field.options) { return ( -
- {field.label} +
+ + {field.label} + { - extensionHost.setExtensionSetting(extensionId, field.id, e.target.value); - forceUpdate(n => n + 1); + extensionHost.setExtensionSetting( + extensionId, + field.id, + e.target.value, + ); + forceUpdate((n) => n + 1); }} className="w-7 h-5 rounded border border-white/10 cursor-pointer bg-transparent" /> @@ -241,16 +297,25 @@ function ExtensionSettingsSection({ extensionId, label, fields }: { ); } - if (field.type === 'text') { + if (field.type === "text") { return ( -
- {field.label} +
+ + {field.label} + { - extensionHost.setExtensionSetting(extensionId, field.id, e.target.value); - forceUpdate(n => n + 1); + extensionHost.setExtensionSetting( + extensionId, + field.id, + e.target.value, + ); + forceUpdate((n) => n + 1); }} className="w-24 h-6 rounded bg-white/[0.06] border border-white/10 px-1.5 text-[10px] text-slate-200" /> @@ -279,7 +344,9 @@ interface SettingsPanelProps { onTrimDelete?: (id: string) => void; selectedClipId?: string | null; selectedClipSpeed?: number | null; + selectedClipMuted?: boolean | null; onClipSpeedChange?: (speed: number) => void; + onClipMutedChange?: (muted: boolean) => void; onClipDelete?: (id: string) => void; shadowIntensity?: number; onShadowChange?: (intensity: number) => void; @@ -289,6 +356,8 @@ interface SettingsPanelProps { onZoomMotionBlurChange?: (amount: number) => void; connectZooms?: boolean; onConnectZoomsChange?: (enabled: boolean) => void; + autoApplyFreshRecordingAutoZooms?: boolean; + onAutoApplyFreshRecordingAutoZoomsChange?: (enabled: boolean) => void; zoomInDurationMs?: number; onZoomInDurationMsChange?: (duration: number) => void; zoomInOverlapMs?: number; @@ -425,6 +494,14 @@ const CAPTION_LANGUAGE_OPTIONS = [ { value: "ko", label: "Korean" }, ] as const; +const APP_LANGUAGE_LABELS: Record = { + en: "English", + es: "Español", + nl: "Nederlands", + ko: "한국어", + "zh-CN": "中文", +}; + function loadPreviewImage(url: string) { return new Promise((resolve, reject) => { const image = new Image(); @@ -571,11 +648,11 @@ function CursorStylePreview({ }) { const previewSrc = style === "tahoe" - ? previewUrls.tahoe ?? tahoeCursorUrl + ? (previewUrls.tahoe ?? tahoeCursorUrl) : style === "figma" - ? previewUrls.figma ?? minimalCursorUrl + ? (previewUrls.figma ?? minimalCursorUrl) : style === "mono" - ? previewUrls.mono ?? tahoeCursorUrl + ? (previewUrls.mono ?? tahoeCursorUrl) : previewUrls[style]; if (style === "tahoe") { @@ -590,14 +667,7 @@ function CursorStylePreview({ } if (style === "figma") { - return ( - - ); + return ; } if (style === "dot") { @@ -631,7 +701,9 @@ export function SettingsPanel({ onTrimDelete, selectedClipId, selectedClipSpeed, + selectedClipMuted, onClipSpeedChange, + onClipMutedChange, onClipDelete, shadowIntensity = 0.67, onShadowChange, @@ -639,6 +711,10 @@ export function SettingsPanel({ onBackgroundBlurChange, zoomMotionBlur = 0, onZoomMotionBlurChange, + connectZooms = true, + onConnectZoomsChange, + autoApplyFreshRecordingAutoZooms = true, + onAutoApplyFreshRecordingAutoZoomsChange, showCursor = false, onShowCursorChange, loopCursor = false, @@ -702,13 +778,14 @@ export function SettingsPanel({ onSpeedDelete, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); - const { t } = useI18n(); + const { locale, setLocale, t } = useI18n(); const isBackgroundPanel = panelMode === "background"; const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [builtInWallpapers, setBuiltInWallpapers] = useState(BUILT_IN_WALLPAPERS); - const [extensionWallpapers, setExtensionWallpapers] = - useState>([]); + const [extensionWallpapers, setExtensionWallpapers] = useState< + ReturnType + >([]); const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); const [extensionWallpaperPreviewUrls, setExtensionWallpaperPreviewUrls] = useState< Record @@ -759,7 +836,9 @@ export function SettingsPanel({ } catch { if (mounted) { setBuiltInWallpapers(BUILT_IN_WALLPAPERS); - setWallpaperPreviewPaths(BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath)); + setWallpaperPreviewPaths( + BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), + ); } } })(); @@ -776,18 +855,26 @@ export function SettingsPanel({ const cursorStyles = extensionHost.getContributedCursorStyles(); const [wallpaperPreviewEntries, cursorPreviewEntries] = await Promise.all([ Promise.all( - wallpapers.map(async (wallpaper) => [ - wallpaper.id, - isVideoWallpaperSource(wallpaper.resolvedThumbnailUrl) - ? wallpaper.resolvedThumbnailUrl - : await getWallpaperThumbnailUrl(wallpaper.resolvedThumbnailUrl), - ] as const), + wallpapers.map( + async (wallpaper) => + [ + wallpaper.id, + isVideoWallpaperSource(wallpaper.resolvedThumbnailUrl) + ? wallpaper.resolvedThumbnailUrl + : await getWallpaperThumbnailUrl( + wallpaper.resolvedThumbnailUrl, + ), + ] as const, + ), ), Promise.all( - cursorStyles.map(async (cursorStyle) => [ - cursorStyle.id, - await getRenderableAssetUrl(cursorStyle.resolvedDefaultUrl), - ] as const), + cursorStyles.map( + async (cursorStyle) => + [ + cursorStyle.id, + await getRenderableAssetUrl(cursorStyle.resolvedDefaultUrl), + ] as const, + ), ), ]); @@ -847,7 +934,9 @@ export function SettingsPanel({ }, []); // Extension-contributed settings panels - const [extensionPanels, setExtensionPanels] = useState>([]); + const [extensionPanels, setExtensionPanels] = useState< + ReturnType + >([]); useEffect(() => { const update = () => setExtensionPanels(extensionHost.getSettingsPanels()); update(); @@ -876,8 +965,9 @@ export function SettingsPanel({ const defaultWebcam = initialEditorPreferences.webcam; const [internalActiveEffectSection] = useState("scene"); const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; - const [extensionCursorStyles, setExtensionCursorStyles] = - useState>([]); + const [extensionCursorStyles, setExtensionCursorStyles] = useState< + ReturnType + >([]); const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState< Partial> >({}); @@ -977,9 +1067,8 @@ export function SettingsPanel({ const imageWallpapers = builtInWallpapers.filter( (wallpaper) => !isVideoWallpaperSource(wallpaper.publicPath), ); - const builtInTiles = (wallpaperPreviewPaths.length > 0 - ? wallpaperPreviewPaths - : builtInWallpaperPaths + const builtInTiles = ( + wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths : builtInWallpaperPaths ) .filter((path) => !isVideoWallpaperSource(path)) .map((previewPath, index) => { @@ -1113,8 +1202,11 @@ export function SettingsPanel({ preload="metadata" className="h-full w-full select-none object-cover [transform:translateZ(0)]" draggable={false} - onMouseEnter={(e) => e.currentTarget.play().catch(() => {})} - onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }} + onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)} + onMouseLeave={(e) => { + e.currentTarget.pause(); + e.currentTarget.currentTime = 0; + }} /> ) : ( ); - - const handleDeleteClick = () => { - if (selectedZoomId && onZoomDelete) { - onZoomDelete(selectedZoomId); - } - }; - const handleTrimDeleteClick = () => { if (selectedTrimId && onTrimDelete) { onTrimDelete(selectedTrimId); } }; - const handleClipDeleteClick = () => { - if (selectedClipId && onClipDelete) { - onClipDelete(selectedClipId); - } - }; - const crop = cropRegion ?? { x: 0, y: 0, @@ -1295,11 +1374,13 @@ export function SettingsPanel({ const handleVideoUpload = async () => { try { - const result = await (window as any).electronAPI.openVideoFilePicker(); + const result = await window.electronAPI.openVideoFilePicker(); if (!result?.success || !result.path) return; - const filePath = result.path as string; + const filePath = result.path; if (!isVideoWallpaperSource(filePath)) { - toast.error("Unsupported format", { description: "Please select a video file (mp4, webm, mov, etc.)" }); + toast.error("Unsupported format", { + description: "Please select a video file (mp4, webm, mov, etc.)", + }); return; } setCustomImages((prev) => [filePath, ...prev]); @@ -1317,9 +1398,9 @@ export function SettingsPanel({ if (selected === imageUrl) { onWallpaperChange( builtInWallpaperPaths[0] ?? - extensionWallpaperPaths[0] ?? - BUILT_IN_WALLPAPERS[0]?.publicPath ?? - "", + extensionWallpaperPaths[0] ?? + BUILT_IN_WALLPAPERS[0]?.publicPath ?? + "", ); } }; @@ -1378,13 +1459,19 @@ export function SettingsPanel({ ) : null} {option.label} @@ -1428,7 +1515,11 @@ export function SettingsPanel({ return renderWallpaperImageTile(imageUrl, isSelected, { key: `custom-${idx}`, ariaLabel: isVideoWallpaperSource(imageUrl) - ? imageUrl.split(/[\\/]/).pop() ?? tSettings("background.video", "Video background") + ? (imageUrl.split(/[\\/]/).pop() ?? + tSettings( + "background.video", + "Video background", + )) : undefined, title: isVideoWallpaperSource(imageUrl) ? imageUrl.split(/[\\/]/).pop() @@ -1436,7 +1527,9 @@ export function SettingsPanel({ onClick: () => onWallpaperChange(imageUrl), children: (
@@ -1468,35 +1568,53 @@ export function SettingsPanel({
- {customImages.filter(isVideoWallpaperSource).map((videoUrl, idx) => { - const isSelected = getWallpaperTileState(videoUrl); - return renderWallpaperImageTile(videoUrl, isSelected, { - key: `custom-video-${idx}`, - ariaLabel: videoUrl.split(/[\\/]/).pop() ?? "Video background", - title: videoUrl.split(/[\\/]/).pop(), - onClick: () => onWallpaperChange(videoUrl), - children: ( - - ), - }); - })} + {customImages + .filter(isVideoWallpaperSource) + .map((videoUrl, idx) => { + const isSelected = getWallpaperTileState(videoUrl); + return renderWallpaperImageTile( + videoUrl, + isSelected, + { + key: `custom-video-${idx}`, + ariaLabel: + videoUrl.split(/[\\/]/).pop() ?? + "Video background", + title: videoUrl.split(/[\\/]/).pop(), + onClick: () => onWallpaperChange(videoUrl), + children: ( + + ), + }, + ); + })} {videoWallpaperTiles.map((wallpaper) => { const isSelected = getWallpaperTileState( wallpaper.value, wallpaper.previewUrl, ); - return renderWallpaperImageTile(wallpaper.previewUrl, isSelected, { - key: wallpaper.key, - ariaLabel: wallpaper.label, - title: wallpaper.label, - onClick: () => onWallpaperChange(wallpaper.value), - }); + return renderWallpaperImageTile( + wallpaper.previewUrl, + isSelected, + { + key: wallpaper.key, + ariaLabel: wallpaper.label, + title: wallpaper.label, + onClick: () => + onWallpaperChange(wallpaper.value), + }, + ); })}
@@ -1514,7 +1632,8 @@ export function SettingsPanel({ />
{visibleColorPalette.map((color) => { - const isSelected = selected.toLowerCase() === color.toLowerCase(); + const isSelected = + selected.toLowerCase() === color.toLowerCase(); return ( -
-
-
- - {tSettings("effects.classicZoom", "Classic Animation")} - - onZoomClassicModeChange?.(v)} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
- {!zoomClassicMode && ( - onZoomSmoothnessChange?.(v)} - formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))} - parseInput={(text) => parseFloat(text)} - /> - )} - onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - - ); - const frameSectionContent = (
@@ -1982,7 +2059,9 @@ export function SettingsPanel({ updateAutoCaptionSettings({ textColor: event.target.value })} + onChange={(event) => + updateAutoCaptionSettings({ textColor: event.target.value }) + } className="h-7 w-10 rounded border border-white/10 bg-transparent" /> @@ -2085,21 +2166,296 @@ export function SettingsPanel({ ); const effectSectionContent = (() => { + const settingsSectionContent = ( +
+
+ {t("common.app.language", "Language")} + +
+ +
+
+
+
+ Auto-apply fresh recording zooms +
+
+ Suggest cursor-follow zooms automatically when you open a new recording. +
+
+ +
+
+
+
+ Connect neighboring zooms +
+
+ Smooth consecutive zoom regions into a continuous camera move. +
+
+ +
+
+ +
+ {tSettings("keyboardShortcuts.title", "Keybinds")} + +
+
+ ); + const sceneSectionContent = (
{backgroundSettingsContent} - {zoomSectionContent} {frameSectionContent} {cropSectionContent} {renderExtensionPanelsForSections("scene", "appearance", "zoom", "frame", "crop")}
); + const zoomItemSectionContent = ( +
+ {selectedZoomId && ( + <> +
+ {tSettings("sections.zoom", "Zoom")} + {selectedZoomDepth && ( + + { + ZOOM_DEPTH_OPTIONS.find( + (o) => o.depth === selectedZoomDepth, + )?.label + } + + )} +
+
+
+ + +
+

+ {selectedZoomMode === "manual" + ? "Set a fixed focus point for this zoom" + : "Camera follows cursor automatically"} +

+
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+
+ + )} +
+ {tSettings("zoom.globalSettings", "Animation")} + +
+
+ + {tSettings("effects.classicZoom", "Classic Animation")} + + onZoomClassicModeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+ {!zoomClassicMode && ( + onZoomSmoothnessChange?.(v)} + formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))} + parseInput={(text) => parseFloat(text)} + /> + )} + onZoomMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + {selectedZoomId && ( + + )} +
+ ); + + const clipSectionContent = ( +
+
+ Clip + {selectedClipSpeed != null && selectedClipSpeed !== 1 && ( + + {selectedClipSpeed}× + + )} +
+
+ Mute Audio + onClipMutedChange?.(v)} + className="data-[state=checked]:bg-[#06b6d4] scale-75" + /> +
+
+ Speed +
+
+ {[ + { speed: 0.25, label: "0.25×" }, + { speed: 0.5, label: "0.5×" }, + { speed: 0.75, label: "0.75×" }, + { speed: 1, label: "1×" }, + { speed: 1.25, label: "1.25×" }, + { speed: 1.5, label: "1.5×" }, + { speed: 2, label: "2×" }, + { speed: 2.5, label: "2.5×" }, + { speed: 3, label: "3×" }, + { speed: 4, label: "4×" }, + { speed: 5, label: "5×" }, + { speed: 8, label: "8×" }, + { speed: 10, label: "10×" }, + { speed: 15, label: "15×" }, + { speed: 20, label: "20×" }, + { speed: 30, label: "30×" }, + ].map((option) => { + const isActive = selectedClipSpeed === option.speed; + return ( + + ); + })} +
+ {selectedClipId && ( + + )} +
+ ); + switch (activeEffectSection) { + case "settings": + return settingsSectionContent; case "scene": return sceneSectionContent; case "zoom": - return sceneSectionContent; + return zoomItemSectionContent; + case "clip": + return clipSectionContent; case "frame": return sceneSectionContent; case "crop": @@ -2111,7 +2467,9 @@ export function SettingsPanel({
- {tSettings("sections.cursor", "Cursor")} + + {tSettings("sections.cursor", "Cursor")} +
- {tSettings("effects.webcamCustomPosition", "Custom position")} + {tSettings( + "effects.webcamCustomPosition", + "Custom position", + )} - applyWebcamPositionPreset(checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET) + applyWebcamPositionPreset( + checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET, + ) } className="data-[state=checked]:bg-[#2563EB] scale-75" /> @@ -2340,7 +2712,12 @@ export function SettingsPanel({ min={0} max={100} step={1} - onChange={(v) => updateWebcam({ positionPreset: "custom", positionX: v / 100 })} + onChange={(v) => + updateWebcam({ + positionPreset: "custom", + positionX: v / 100, + }) + } formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> @@ -2351,7 +2728,12 @@ export function SettingsPanel({ min={0} max={100} step={1} - onChange={(v) => updateWebcam({ positionPreset: "custom", positionY: v / 100 })} + onChange={(v) => + updateWebcam({ + positionPreset: "custom", + positionY: v / 100, + }) + } formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> @@ -2397,7 +2779,8 @@ export function SettingsPanel({ {tSettings("effects.webcamFootage")}
- {webcamFileName ?? tSettings("effects.webcamFootageDescription")} + {webcamFileName ?? + tSettings("effects.webcamFootageDescription")}
@@ -2432,9 +2815,11 @@ export function SettingsPanel({ ); default: { // Handle extension-contributed standalone section pages (ext:extensionId/panelId) - if (activeEffectSection?.startsWith('ext:')) { + if (activeEffectSection?.startsWith("ext:")) { const panels = extensionPanels.filter( - p => !p.panel.parentSection && `ext:${p.extensionId}/${p.panel.id}` === activeEffectSection, + (p) => + !p.panel.parentSection && + `ext:${p.extensionId}/${p.panel.id}` === activeEffectSection, ); if (panels.length > 0) { const p = panels[0]; @@ -2456,8 +2841,11 @@ export function SettingsPanel({ })(); return ( -
-
+
+
-
- {selectedZoomId && ( -
-
- {tSettings("zoom.level")} -
- {selectedZoomDepth && ( - - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} - - )} -
-
-
-
- - -
-

- {selectedZoomMode === 'manual' - ? "Set a fixed focus point for this zoom" - : "Camera follows cursor automatically"} -

-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- -
+
+ {selectedTrimId && (
@@ -2594,7 +2907,9 @@ export function SettingsPanel({ : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200", )} > - {option.label} + + {option.label} + ); })} @@ -2610,59 +2925,7 @@ export function SettingsPanel({
)} - - {selectedClipId && ( -
-
- Clip Speed - {selectedClipSpeed != null && selectedClipSpeed !== 1 && ( - - {selectedClipSpeed}× - - )} -
-
- {[ - { speed: 0.25, label: "0.25×" }, - { speed: 0.5, label: "0.5×" }, - { speed: 0.75, label: "0.75×" }, - { speed: 1, label: "1×" }, - { speed: 1.25, label: "1.25×" }, - { speed: 1.5, label: "1.5×" }, - { speed: 1.75, label: "1.75×" }, - { speed: 2, label: "2×" }, - ].map((option) => { - const isActive = selectedClipSpeed === option.speed; - return ( - - ); - })} -
- -
- )}
- ); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 18d36f98..d9759756 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,32 +1,47 @@ -import type { Span } from "dnd-timeline"; import { - Camera, - Captions, - Download, + ArrowClockwise as Redo2, + ArrowCounterClockwise as Undo2, + CaretDown as ChevronDown, + CaretUp as ChevronUp, + Check, + ClosedCaptioning, + Crop, + Cursor, + DownloadSimple as Download, + FloppyDisk as Save, FolderOpen, - MousePointer2, - Puzzle, - Redo2, - Save, - Sparkles, - Undo2, + Gear, + MagicWand as WandSparkles, + MagnifyingGlassPlus as ZoomIn, + Pause, + Play, + Plus, + Camera as PhCameraRegular, + PuzzlePiece, + Scissors, + SkipBack, + SkipForward, + SpeakerHigh as Volume2, + SpeakerLow as Volume1, + SpeakerX as VolumeX, + Sparkle, + UserCircle as User, X, -} from "lucide-react"; -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +} from "@phosphor-icons/react"; +import type { Span } from "dnd-timeline"; +import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { ExtensionIcon } from "./ExtensionIcon"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Toaster } from "@/components/ui/sonner"; import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; import { calculateOutputDimensions, DEFAULT_MP4_CODEC, @@ -55,12 +70,39 @@ import { getMediaSyncPlaybackRate, } from "@/lib/mediaTiming"; import { matchesShortcut } from "@/lib/shortcuts"; -import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { + ASPECT_RATIOS, + type AspectRatio, + getAspectRatioLabel, + getAspectRatioValue, +} from "@/utils/aspectRatioUtils"; +import { ExtensionIcon } from "./ExtensionIcon"; + +const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhCamera = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhCaptions = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhPuzzle = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhSparkle = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); +const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) => ( + +); + +import { extensionHost } from "@/lib/extensions"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; +import ExtensionManager from "./ExtensionManager"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; -import PlaybackControls from "./PlaybackControls"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { createProjectData, @@ -72,31 +114,31 @@ import { validateProjectData, } from "./projectPersistence"; import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel"; -import ExtensionManager from "./ExtensionManager"; -import { extensionHost } from "@/lib/extensions"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, FeedbackDialog, - KeyboardShortcutsDialog, openExternalLink, RECORDLY_ISSUES_URL, } from "./TutorialHelp"; -import TimelineEditor from "./timeline/TimelineEditor"; +import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; import { type AnnotationRegion, type AudioRegion, type AutoCaptionSettings, type CaptionCue, + type ClipRegion, type CropRegion, type CursorStyle, type CursorTelemetryPoint, clampFocusToDepth, + clipsToTrims, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_AUTO_CAPTION_SETTINGS, + DEFAULT_AUTO_ZOOM_DEPTH, DEFAULT_CONNECTED_ZOOM_DURATION_MS, DEFAULT_CONNECTED_ZOOM_EASING, DEFAULT_CONNECTED_ZOOM_GAP_MS, @@ -106,22 +148,20 @@ import { DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_OVERLAY, DEFAULT_ZOOM_DEPTH, - DEFAULT_AUTO_ZOOM_DEPTH, - type ZoomMode, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_IN_EASING, DEFAULT_ZOOM_IN_OVERLAP_MS, DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, type FigureData, + getClipSourceEndMs, type PlaybackSpeed, type SpeedRegion, type TrimRegion, - type ClipRegion, - clipsToTrims, type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, + type ZoomMode, type ZoomRegion, type ZoomTransitionEasing, } from "./types"; @@ -186,7 +226,10 @@ async function writeSmokeExportReport( reportBytes.byteOffset, reportBytes.byteOffset + reportBytes.byteLength, ) as ArrayBuffer; - await window.electronAPI.writeExportedVideoToPath(reportBuffer, `${outputPath}.report.json`); + await window.electronAPI.writeExportedVideoToPath( + reportBuffer, + `${outputPath}.report.json`, + ); } catch (error) { console.error("[smoke-export] Failed to write report", error); } @@ -254,16 +297,16 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { : enabled && params.get("smokeEncodingMode") === "quality" ? "quality" : undefined, - shadowIntensity: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeShadowIntensity")) - : undefined, - webcamInputPath: enabled ? params.get("smokeWebcamInput") : null, - webcamShadow: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamShadow")) - : undefined, - webcamSize: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamSize")) - : undefined, + shadowIntensity: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeShadowIntensity")) + : undefined, + webcamInputPath: enabled ? params.get("smokeWebcamInput") : null, + webcamShadow: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamShadow")) + : undefined, + webcamSize: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamSize")) + : undefined, pipelineModel: enabled && params.get("smokePipelineModel") === "modern" ? "modern" @@ -278,9 +321,15 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { : enabled && params.get("smokeBackendPreference") === "breeze" ? "breeze" : undefined, - maxEncodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) : undefined, - maxDecodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxDecodeQueue")) : undefined, - maxPendingFrames: enabled ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) : undefined, + maxEncodeQueue: enabled + ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) + : undefined, + maxDecodeQueue: enabled + ? parseSmokeExportNumber(params.get("smokeMaxDecodeQueue")) + : undefined, + maxPendingFrames: enabled + ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) + : undefined, }; } @@ -419,31 +468,6 @@ function getErrorMessage(error: unknown): string { return "Something went wrong"; } -function LanguageSwitcher() { - const { locale, setLocale, t } = useI18n(); - const idx = SUPPORTED_LOCALES.indexOf(locale as (typeof SUPPORTED_LOCALES)[number]); - const next = SUPPORTED_LOCALES[(idx + 1) % SUPPORTED_LOCALES.length] as AppLocale; - const labels: Record = { - en: "EN", - es: "ES", - "zh-CN": "中文", - ko: "한국어", - }; - return ( - - ); -} - export default function VideoEditor() { const { t } = useI18n(); const smokeExportConfig = useMemo( @@ -465,9 +489,14 @@ export default function VideoEditor() { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [wallpaper, setWallpaper] = useState(initialEditorPreferences.wallpaper); - const [shadowIntensity, setShadowIntensity] = useState(initialEditorPreferences.shadowIntensity); + const [shadowIntensity, setShadowIntensity] = useState( + initialEditorPreferences.shadowIntensity, + ); const [backgroundBlur, setBackgroundBlur] = useState(initialEditorPreferences.backgroundBlur); const [zoomMotionBlur, setZoomMotionBlur] = useState(initialEditorPreferences.zoomMotionBlur); + const [autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms] = useState( + initialEditorPreferences.autoApplyFreshRecordingAutoZooms, + ); const [connectZooms, setConnectZooms] = useState(initialEditorPreferences.connectZooms); const [zoomInDurationMs, setZoomInDurationMs] = useState( initialEditorPreferences.zoomInDurationMs ?? DEFAULT_ZOOM_IN_DURATION_MS, @@ -499,7 +528,9 @@ export default function VideoEditor() { initialEditorPreferences.cursorStyle ?? DEFAULT_CURSOR_STYLE, ); const [cursorSize, setCursorSize] = useState(initialEditorPreferences.cursorSize); - const [cursorSmoothing, setCursorSmoothing] = useState(initialEditorPreferences.cursorSmoothing); + const [cursorSmoothing, setCursorSmoothing] = useState( + initialEditorPreferences.cursorSmoothing, + ); const [zoomSmoothness, setZoomSmoothness] = useState(0.5); const [zoomClassicMode, setZoomClassicMode] = useState(false); const [cursorMotionBlur, setCursorMotionBlur] = useState( @@ -542,7 +573,9 @@ export default function VideoEditor() { const [whisperModelPath, setWhisperModelPath] = useState( initialEditorPreferences.whisperModelPath, ); - const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState(null); + const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState( + null, + ); const [whisperModelDownloadStatus, setWhisperModelDownloadStatus] = useState< "idle" | "downloading" | "downloaded" | "error" >(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle"); @@ -554,7 +587,9 @@ export default function VideoEditor() { const [showExportDropdown, setShowExportDropdown] = useState(false); const [previewVolume, setPreviewVolume] = useState(1); const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState([]); - const [aspectRatio, setAspectRatio] = useState(initialEditorPreferences.aspectRatio); + const [aspectRatio, setAspectRatio] = useState( + initialEditorPreferences.aspectRatio, + ); const [activeEffectSection, setActiveEffectSection] = useState("scene"); const [exportQuality, setExportQuality] = useState( initialEditorPreferences.exportQuality, @@ -562,8 +597,9 @@ export default function VideoEditor() { const [exportEncodingMode, setExportEncodingMode] = useState( initialEditorPreferences.exportEncodingMode, ); - const [exportBackendPreference, setExportBackendPreference] = - useState(initialEditorPreferences.exportBackendPreference); + const [exportBackendPreference, setExportBackendPreference] = useState( + initialEditorPreferences.exportBackendPreference, + ); const [exportPipelineModel, setExportPipelineModel] = useState( initialEditorPreferences.exportPipelineModel, ); @@ -616,9 +652,19 @@ export default function VideoEditor() { const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); const [historyVersion, setHistoryVersion] = useState(0); + const timelineRef = useRef(null); + + function formatTime(seconds: number) { + if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + + const [timelineCollapsed, setTimelineCollapsed] = useState(false); useEffect(() => { - void window.electronAPI.getPlatform().then((platform) => { + void window.electronAPI?.getPlatform?.()?.then((platform) => { setAppPlatform(platform); }); }, []); @@ -756,7 +802,10 @@ export default function VideoEditor() { await frameRenderer.renderFrame(videoFrame, frameTimestampUs); return frameRenderer.getCanvas().toDataURL("image/png"); } catch (thumbnailRenderError) { - console.warn("Unable to render thumbnail from composed frame:", thumbnailRenderError); + console.warn( + "Unable to render thumbnail from composed frame:", + thumbnailRenderError, + ); } finally { videoFrame?.close(); frameRenderer?.destroy(); @@ -775,7 +824,9 @@ export default function VideoEditor() { } const sourceWidth = - drawableSource instanceof HTMLVideoElement ? drawableSource.videoWidth : drawableSource.width; + drawableSource instanceof HTMLVideoElement + ? drawableSource.videoWidth + : drawableSource.width; const sourceHeight = drawableSource instanceof HTMLVideoElement ? drawableSource.videoHeight @@ -926,37 +977,41 @@ export default function VideoEditor() { supportedMp4SourceDimensions.width, ]); - const ensureSupportedMp4SourceDimensions = useCallback(async (frameRate: ExportMp4FrameRate) => { - const result = await probeSupportedMp4Dimensions({ - width: desiredMp4SourceDimensions.width, - height: desiredMp4SourceDimensions.height, - frameRate, - codec: DEFAULT_MP4_CODEC, - getBitrate: getSourceQualityBitrate, - }); + const ensureSupportedMp4SourceDimensions = useCallback( + async (frameRate: ExportMp4FrameRate) => { + const result = await probeSupportedMp4Dimensions({ + width: desiredMp4SourceDimensions.width, + height: desiredMp4SourceDimensions.height, + frameRate, + codec: DEFAULT_MP4_CODEC, + getBitrate: getSourceQualityBitrate, + }); - if (!result.encoderPath) { - throw new Error( - `Video encoding not supported on this system. Tried codec ${DEFAULT_MP4_CODEC} at ${frameRate} FPS up to ${desiredMp4SourceDimensions.width}x${desiredMp4SourceDimensions.height}.`, - ); - } - - setSupportedMp4SourceDimensions((current) => { - if ( - current.width === result.width && - current.height === result.height && - current.capped === result.capped && - current.encoderPath?.codec === result.encoderPath?.codec && - current.encoderPath?.hardwareAcceleration === result.encoderPath?.hardwareAcceleration - ) { - return current; + if (!result.encoderPath) { + throw new Error( + `Video encoding not supported on this system. Tried codec ${DEFAULT_MP4_CODEC} at ${frameRate} FPS up to ${desiredMp4SourceDimensions.width}x${desiredMp4SourceDimensions.height}.`, + ); } - return result; - }); + setSupportedMp4SourceDimensions((current) => { + if ( + current.width === result.width && + current.height === result.height && + current.capped === result.capped && + current.encoderPath?.codec === result.encoderPath?.codec && + current.encoderPath?.hardwareAcceleration === + result.encoderPath?.hardwareAcceleration + ) { + return current; + } - return result; - }, [desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width]); + return result; + }); + + return result; + }, + [desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width], + ); useEffect(() => { let cancelled = false; @@ -1000,17 +1055,17 @@ export default function VideoEditor() { // Extension-contributed standalone section pages (no parentSection) const [extensionSectionButtons, setExtensionSectionButtons] = useState< - { id: EditorEffectSection; label: string; icon: typeof Puzzle | string }[] + { id: EditorEffectSection; label: string; icon: typeof PhPuzzle | string }[] >([]); useEffect(() => { const update = () => { const panels = extensionHost.getSettingsPanels(); const standalone = panels - .filter(p => !p.panel.parentSection) - .map(p => ({ + .filter((p) => !p.panel.parentSection) + .map((p) => ({ id: `ext:${p.extensionId}/${p.panel.id}` as EditorEffectSection, label: p.panel.label, - icon: p.panel.icon || (Puzzle as typeof Puzzle | string), + icon: p.panel.icon || (PhPuzzle as typeof PhPuzzle | string), })); setExtensionSectionButtons(standalone); }; @@ -1020,34 +1075,39 @@ export default function VideoEditor() { const editorSectionButtons = useMemo( () => [ - { id: "scene" as const, label: t("settings.sections.scene", "Scene"), icon: Sparkles }, + { id: "scene" as const, label: t("settings.sections.scene", "Scene"), icon: PhSparkle }, { id: "cursor" as const, label: t("settings.sections.cursor", "Cursor"), - icon: MousePointer2, + icon: PhCursorFill, + }, + { + id: "webcam" as const, + label: t("settings.sections.webcam", "Webcam"), + icon: PhCamera, }, - { id: "webcam" as const, label: t("settings.sections.webcam", "Webcam"), icon: Camera }, { id: "captions" as const, label: t("settings.sections.captions", "Captions"), - icon: Captions, + icon: PhCaptions, + }, + { + id: "settings" as const, + label: t("settings.sections.settings", "Settings"), + icon: PhSettings, }, ...extensionSectionButtons, { id: "extensions" as const, label: t("settings.sections.extensions", "Extensions"), - icon: Puzzle, + icon: PhPuzzle, }, ], [t, extensionSectionButtons], ); useEffect(() => { - if ( - activeEffectSection === "zoom" || - activeEffectSection === "frame" || - activeEffectSection === "crop" - ) { + if (activeEffectSection === "frame" || activeEffectSection === "crop") { setActiveEffectSection("scene"); } }, [activeEffectSection]); @@ -1128,7 +1188,8 @@ export default function VideoEditor() { void (async () => { try { - const result = await window.electronAPI.getVideoAudioFallbackPaths(currentSourcePath); + const result = + await window.electronAPI.getVideoAudioFallbackPaths(currentSourcePath); if (cancelled) { return; } @@ -1147,7 +1208,9 @@ export default function VideoEditor() { const projectDisplayName = useMemo(() => { const fileName = - currentProjectPath?.split(/[\\/]/).pop() ?? currentSourcePath?.split(/[\\/]/).pop() ?? ""; + currentProjectPath?.split(/[\\/]/).pop() ?? + currentSourcePath?.split(/[\\/]/).pop() ?? + ""; const withoutExtension = fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, ""); return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); @@ -1208,6 +1271,7 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + autoApplyFreshRecordingAutoZooms, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1320,7 +1384,8 @@ export default function VideoEditor() { cloned.audioRegions.map((region) => region.id), ); nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + + 1; }, [cloneSnapshot], ); @@ -1415,8 +1480,8 @@ export default function VideoEditor() { setWebcam(normalizedEditor.webcam); setZoomRegions(normalizedEditor.zoomRegions); setTrimRegions(normalizedEditor.trimRegions); - setClipRegions((normalizedEditor as any).clipRegions ?? []); - clipInitializedRef.current = ((normalizedEditor as any).clipRegions ?? []).length > 0; + setClipRegions(normalizedEditor.clipRegions); + clipInitializedRef.current = normalizedEditor.clipRegions.length > 0; setSpeedRegions(normalizedEditor.speedRegions); setAnnotationRegions(normalizedEditor.annotationRegions); setAudioRegions(normalizedEditor.audioRegions); @@ -1450,7 +1515,7 @@ export default function VideoEditor() { ); nextClipIdRef.current = deriveNextId( "clip", - ((normalizedEditor as any).clipRegions ?? []).map((region: ClipRegion) => region.id), + normalizedEditor.clipRegions.map((region: ClipRegion) => region.id), ); nextSpeedIdRef.current = deriveNextId( "speed", @@ -1477,7 +1542,9 @@ export default function VideoEditor() { syncHistoryButtons(); setLastSavedSnapshot( - cloneStructured(createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor))), + cloneStructured( + createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor)), + ), ); await refreshProjectLibrary(); return true; @@ -1638,10 +1705,14 @@ export default function VideoEditor() { setBorderRadius(initialEditorPreferences.borderRadius); setAspectRatio(initialEditorPreferences.aspectRatio); setExportFormat(initialEditorPreferences.exportFormat); - setMp4FrameRate(initialEditorPreferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE); + setMp4FrameRate( + initialEditorPreferences.mp4FrameRate ?? DEFAULT_MP4_EXPORT_FRAME_RATE, + ); setExportQuality(initialEditorPreferences.exportQuality); setExportEncodingMode(initialEditorPreferences.exportEncodingMode); - setExportBackendPreference(initialEditorPreferences.exportBackendPreference); + setExportBackendPreference( + initialEditorPreferences.exportBackendPreference, + ); setExportPipelineModel(initialEditorPreferences.exportPipelineModel); setGifFrameRate(initialEditorPreferences.gifFrameRate); setGifLoop(initialEditorPreferences.gifLoop); @@ -1658,7 +1729,8 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = sourceVideoUrl; + pendingFreshRecordingAutoZoomPathRef.current = + autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; setWebcam((prev) => ({ ...prev, enabled: Boolean(sessionResult.session?.webcamPath), @@ -1675,7 +1747,8 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); - pendingFreshRecordingAutoZoomPathRef.current = sourceVideoUrl; + pendingFreshRecordingAutoZoomPathRef.current = + autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; setWebcam((prev) => ({ ...prev, enabled: false, @@ -1692,8 +1765,12 @@ export default function VideoEditor() { } loadInitialData(); - - }, [applyLoadedProject, smokeExportConfig.enabled, smokeExportConfig.inputPath]); + }, [ + applyLoadedProject, + autoApplyFreshRecordingAutoZooms, + smokeExportConfig.enabled, + smokeExportConfig.inputPath, + ]); useEffect(() => { saveEditorPreferences({ @@ -1741,6 +1818,7 @@ export default function VideoEditor() { shadowIntensity, backgroundBlur, zoomMotionBlur, + autoApplyFreshRecordingAutoZooms, connectZooms, zoomInDurationMs, zoomInOverlapMs, @@ -1894,7 +1972,9 @@ export default function VideoEditor() { sessionResult?.success && sessionResult.session?.videoPath ? sessionResult.session.videoPath : null, - currentVideoPath: currentVideoResult.success ? (currentVideoResult.path ?? null) : null, + currentVideoPath: currentVideoResult.success + ? (currentVideoResult.path ?? null) + : null, }); } @@ -1926,7 +2006,9 @@ export default function VideoEditor() { if (!result.success || !result.cues) { toast.error( - result.message || getErrorMessage(result.error) || "Failed to generate captions", + result.message || + getErrorMessage(result.error) || + "Failed to generate captions", ); return; } @@ -2223,7 +2305,45 @@ export default function VideoEditor() { setTrimRegions(clipsToTrims(clipRegions, totalMs)); }, [clipRegions, duration]); - const effectiveZoomRegions = zoomRegions; + const mapTimelineTimeToSourceTime = useCallback( + (timeMs: number) => { + for (const clip of clipRegions) { + if (timeMs < clip.startMs || timeMs > clip.endMs) continue; + const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; + return Math.round(clip.startMs + (timeMs - clip.startMs) * speed); + } + return Math.round(timeMs); + }, + [clipRegions], + ); + + const mapSourceTimeToTimelineTime = useCallback( + (timeMs: number) => { + for (const clip of clipRegions) { + const sourceEndMs = getClipSourceEndMs(clip); + if (timeMs < clip.startMs || timeMs > sourceEndMs) continue; + const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; + return Math.round(clip.startMs + (timeMs - clip.startMs) / speed); + } + return Math.round(timeMs); + }, + [clipRegions], + ); + + const effectiveZoomRegions = useMemo( + () => + zoomRegions.map((region) => ({ + ...region, + startMs: mapTimelineTimeToSourceTime(region.startMs), + endMs: mapTimelineTimeToSourceTime(region.endMs), + })), + [zoomRegions, mapTimelineTimeToSourceTime], + ); + + const timelinePlayheadTime = useMemo( + () => mapSourceTimeToTimelineTime(currentTime * 1000) / 1000, + [currentTime, mapSourceTimeToTimelineTime], + ); // Merge clip speeds into speed regions so playback + export respect per-clip speed const effectiveSpeedRegions = useMemo(() => { @@ -2232,7 +2352,7 @@ export default function VideoEditor() { .map((clip) => ({ id: `clip-speed-${clip.id}`, startMs: clip.startMs, - endMs: clip.endMs, + endMs: getClipSourceEndMs(clip), speed: clip.speed as SpeedRegion["speed"], })); if (clipDerived.length === 0) return speedRegions; @@ -2268,14 +2388,17 @@ export default function VideoEditor() { function handleSeek(time: number) { const video = videoPlaybackRef.current?.video; if (!video) return; - video.currentTime = time; + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; } const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { + setActiveEffectSection("zoom"); setSelectedTrimId(null); setSelectedAudioId(null); + } else { + setActiveEffectSection((s) => (s === "zoom" ? "scene" : s)); } }, []); @@ -2297,47 +2420,57 @@ export default function VideoEditor() { } }, []); - const handleZoomAdded = useCallback((span: Span) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, - mode: "manual", - }; - if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { - autoSuggestedVideoPathRef.current = videoPath; - pendingFreshRecordingAutoZoomPathRef.current = null; - } - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - extensionHost.emitEvent({ type: 'timeline:region-added', data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } }); - }, [videoPath]); + const handleZoomAdded = useCallback( + (span: Span) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_ZOOM_DEPTH, + focus: { cx: 0.5, cy: 0.5 }, + mode: "manual", + }; + if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + } + setZoomRegions((prev) => [...prev, newRegion]); + setSelectedZoomId(id); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + extensionHost.emitEvent({ + type: "timeline:region-added", + data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs }, + }); + }, + [videoPath], + ); - const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - depth: DEFAULT_AUTO_ZOOM_DEPTH, - focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH), - mode: "auto", - }; - if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { - autoSuggestedVideoPathRef.current = videoPath; - pendingFreshRecordingAutoZoomPathRef.current = null; - } - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - extensionHost.emitEvent({ type: 'timeline:region-added', data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs } }); - }, [videoPath]); + const handleZoomSuggested = useCallback( + (span: Span, focus: ZoomFocus) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_AUTO_ZOOM_DEPTH, + focus: clampFocusToDepth(focus, DEFAULT_AUTO_ZOOM_DEPTH), + mode: "auto", + }; + if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + } + setZoomRegions((prev) => [...prev, newRegion]); + // Don't auto-select suggested zooms — they follow cursor and don't need user interaction + extensionHost.emitEvent({ + type: "timeline:region-added", + data: { id, startMs: newRegion.startMs, endMs: newRegion.endMs }, + }); + }, + [videoPath], + ); useEffect(() => { if ( @@ -2474,11 +2607,7 @@ export default function VideoEditor() { (mode: ZoomMode) => { if (!selectedZoomId) return; setZoomRegions((prev) => - prev.map((region) => - region.id === selectedZoomId - ? { ...region, mode } - : region, - ), + prev.map((region) => (region.id === selectedZoomId ? { ...region, mode } : region)), ); }, [selectedZoomId], @@ -2490,7 +2619,7 @@ export default function VideoEditor() { if (selectedZoomId === id) { setSelectedZoomId(null); } - extensionHost.emitEvent({ type: 'timeline:region-removed', data: { id } }); + extensionHost.emitEvent({ type: "timeline:region-removed", data: { id } }); }, [selectedZoomId], ); @@ -2508,73 +2637,111 @@ export default function VideoEditor() { const handleSelectClip = useCallback((id: string | null) => { setSelectedClipId(id); if (id) { + setActiveEffectSection("clip"); setSelectedZoomId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); + } else { + setActiveEffectSection((s) => (s === "clip" ? "scene" : s)); } }, []); - const handleClipSplit = useCallback( - (splitMs: number) => { - setClipRegions((prev) => { - const target = prev.find((c) => splitMs > c.startMs && splitMs < c.endMs); - if (!target) return prev; - const leftId = `clip-${nextClipIdRef.current++}`; - const rightId = `clip-${nextClipIdRef.current++}`; - const left: ClipRegion = { id: leftId, startMs: target.startMs, endMs: Math.round(splitMs), speed: target.speed }; - const right: ClipRegion = { id: rightId, startMs: Math.round(splitMs), endMs: target.endMs, speed: target.speed }; - return prev.flatMap((c) => (c.id === target.id ? [left, right] : [c])); - }); - }, - [], - ); + const handleClipSplit = useCallback((splitMs: number) => { + setClipRegions((prev) => { + const target = prev.find((c) => splitMs > c.startMs && splitMs < c.endMs); + if (!target) return prev; + const leftId = `clip-${nextClipIdRef.current++}`; + const rightId = `clip-${nextClipIdRef.current++}`; + const left: ClipRegion = { + id: leftId, + startMs: target.startMs, + endMs: Math.round(splitMs), + speed: target.speed, + }; + const right: ClipRegion = { + id: rightId, + startMs: Math.round(splitMs), + endMs: target.endMs, + speed: target.speed, + }; + return prev.flatMap((c) => (c.id === target.id ? [left, right] : [c])); + }); + }, []); - const handleClipSpanChange = useCallback((id: string, span: Span) => { - const oldClip = clipRegions.find((c) => c.id === id); - const newStart = Math.round(span.start); - const newEnd = Math.round(span.end); + const handleClipSpanChange = useCallback( + (id: string, span: Span) => { + const oldClip = clipRegions.find((c) => c.id === id); + const newStart = Math.round(span.start); + const newEnd = Math.round(span.end); - if (oldClip) { - const startDelta = newStart - oldClip.startMs; - const endDelta = newEnd - oldClip.endMs; - const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; + if (oldClip) { + const startDelta = newStart - oldClip.startMs; + const endDelta = newEnd - oldClip.endMs; + const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; - if (isMove) { - const delta = startDelta; - setZoomRegions((prev) => - prev.map((zoom) => { - const overlaps = zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; - if (overlaps) { - return { - ...zoom, - startMs: zoom.startMs + delta, - endMs: zoom.endMs + delta, - }; - } - return zoom; - }), - ); + if (isMove) { + const delta = startDelta; + setZoomRegions((prev) => + prev.map((zoom) => { + const overlaps = + zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; + if (overlaps) { + return { + ...zoom, + startMs: zoom.startMs + delta, + endMs: zoom.endMs + delta, + }; + } + return zoom; + }), + ); + } } - } - setClipRegions((prev) => - prev.map((clip) => - clip.id === id - ? { ...clip, startMs: newStart, endMs: newEnd } - : clip, - ), - ); - }, [clipRegions]); + setClipRegions((prev) => + prev.map((clip) => + clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, + ), + ); + }, + [clipRegions], + ); const handleClipSpeedChange = useCallback( (speed: number) => { if (!selectedClipId) return; + const clip = clipRegions.find((c) => c.id === selectedClipId); + if (!clip) return; + const oldSpeed = clip.speed ?? 1; + const sourceDurationMs = (clip.endMs - clip.startMs) * oldSpeed; + const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed); + const scaleFactor = oldSpeed / speed; + setClipRegions((prev) => - prev.map((clip) => - clip.id === selectedClipId - ? { ...clip, speed } - : clip, - ), + prev.map((c) => (c.id === selectedClipId ? { ...c, speed, endMs: newEndMs } : c)), + ); + // Scale zoom regions that lie within this clip proportionally + setZoomRegions((prev) => + prev.map((zoom) => { + if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) return zoom; + return { + ...zoom, + startMs: Math.round( + clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor, + ), + endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor), + }; + }), + ); + }, + [selectedClipId, clipRegions], + ); + + const handleClipMutedChange = useCallback( + (muted: boolean) => { + if (!selectedClipId) return; + setClipRegions((prev) => + prev.map((clip) => (clip.id === selectedClipId ? { ...clip, muted } : clip)), ); }, [selectedClipId], @@ -2649,7 +2816,7 @@ export default function VideoEditor() { } }, []); - const handleAudioAdded = useCallback((span: Span, audioPath: string) => { + const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { const id = `audio-${nextAudioIdRef.current++}`; const newRegion: AudioRegion = { id, @@ -2657,6 +2824,7 @@ export default function VideoEditor() { endMs: Math.round(span.end), audioPath, volume: 1, + trackIndex, }; setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); @@ -2694,13 +2862,15 @@ export default function VideoEditor() { (speed: PlaybackSpeed) => { if (!selectedSpeedId) return; setSpeedRegions((prev) => - prev.map((region) => (region.id === selectedSpeedId ? { ...region, speed } : region)), + prev.map((region) => + region.id === selectedSpeedId ? { ...region, speed } : region, + ), ); }, [selectedSpeedId], ); - const handleAnnotationAdded = useCallback((span: Span) => { + const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order const newRegion: AnnotationRegion = { @@ -2713,6 +2883,7 @@ export default function VideoEditor() { size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, + trackIndex, }; setAnnotationRegions((prev) => [...prev, newRegion]); setSelectedAnnotationId(id); @@ -3192,7 +3363,9 @@ export default function VideoEditor() { const result = await window.electronAPI.revealInFolder(filePath); if (!result.success) { const errorMessage = - result.error || result.message || "Failed to reveal item in folder."; + result.error || + result.message || + "Failed to reveal item in folder."; toast.error(errorMessage); } } catch (err) { @@ -3220,7 +3393,7 @@ export default function VideoEditor() { setExportProgress(null); setExportError(null); clearPendingExportSave(); - extensionHost.emitEvent({ type: 'export:start' }); + extensionHost.emitEvent({ type: "export:start" }); const smokeExportStartedAt = smokeExportConfig.enabled ? performance.now() : null; let keepExportDialogOpen = false; @@ -3348,7 +3521,7 @@ export default function VideoEditor() { ? await window.electronAPI.writeExportedVideoToPath( arrayBuffer, smokeExportConfig.outputPath, - ) + ) : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { @@ -3391,28 +3564,30 @@ export default function VideoEditor() { // MP4 Export const quality = settings.quality ?? exportQuality; const encodingMode = smokeExportConfig.enabled - ? smokeExportConfig.encodingMode ?? settings.encodingMode ?? exportEncodingMode - : settings.encodingMode ?? exportEncodingMode; + ? (smokeExportConfig.encodingMode ?? + settings.encodingMode ?? + exportEncodingMode) + : (settings.encodingMode ?? exportEncodingMode); const selectedMp4FrameRate = settings.mp4FrameRate ?? mp4FrameRate; const pipelineModel = smokeExportConfig.enabled - ? smokeExportConfig.pipelineModel ?? - (smokeExportConfig.useNativeExport ? "modern" : "legacy") - : settings.pipelineModel ?? exportPipelineModel; + ? (smokeExportConfig.pipelineModel ?? + (smokeExportConfig.useNativeExport ? "modern" : "legacy")) + : (settings.pipelineModel ?? exportPipelineModel); const backendPreference = pipelineModel === "legacy" ? "webcodecs" : smokeExportConfig.enabled - ? smokeExportConfig.backendPreference ?? - (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs") + ? (smokeExportConfig.backendPreference ?? + (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) : "auto"; - const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions( - selectedMp4FrameRate, - ); - const { width: exportWidth, height: exportHeight } = calculateMp4ExportDimensions( - supportedSourceDimensions.width, - supportedSourceDimensions.height, - quality, - ); + const supportedSourceDimensions = + await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); + const { width: exportWidth, height: exportHeight } = + calculateMp4ExportDimensions( + supportedSourceDimensions.width, + supportedSourceDimensions.height, + quality, + ); let bitrate: number; if (quality === "source") { @@ -3504,9 +3679,9 @@ export default function VideoEditor() { const exporter = pipelineModel === "modern" ? new ModernVideoExporter({ - ...exporterConfig, - backendPreference, - }) + ...exporterConfig, + backendPreference, + }) : new VideoExporter(exporterConfig); exporterRef.current = exporter; @@ -3527,7 +3702,7 @@ export default function VideoEditor() { ? await window.electronAPI.writeExportedVideoToPath( arrayBuffer, smokeExportConfig.outputPath, - ) + ) : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { @@ -3654,7 +3829,7 @@ export default function VideoEditor() { window.close(); } } finally { - extensionHost.emitEvent({ type: 'export:complete' }); + extensionHost.emitEvent({ type: "export:complete" }); setIsExporting(false); exporterRef.current = null; setShowExportDropdown(keepExportDialogOpen); @@ -3932,19 +4107,24 @@ export default function VideoEditor() { const isExportSaving = exportProgress?.phase === "saving"; const isExportFinalizing = exportProgress?.phase === "finalizing"; - const isRenderingAudio = isExportFinalizing && typeof exportProgress?.audioProgress === "number"; + const isRenderingAudio = + isExportFinalizing && typeof exportProgress?.audioProgress === "number"; const exportFinalizingProgress = isExportFinalizing ? Math.min( typeof exportProgress?.renderProgress === "number" ? exportProgress.renderProgress : (exportProgress?.percentage ?? 99), 99, - ) + ) : null; const isLightningExportInProgress = - exportFormat === "mp4" && exportPipelineModel === "modern" && (isExporting || exportProgress !== null); + exportFormat === "mp4" && + exportPipelineModel === "modern" && + (isExporting || exportProgress !== null); const isLegacyExportInProgress = - exportFormat === "mp4" && exportPipelineModel === "legacy" && (isExporting || exportProgress !== null); + exportFormat === "mp4" && + exportPipelineModel === "legacy" && + (isExporting || exportProgress !== null); const exportRenderSpeedLabel = typeof exportProgress?.renderFps === "number" && Number.isFinite(exportProgress.renderFps) && @@ -3963,11 +4143,7 @@ export default function VideoEditor() { } const rendererLabel = - renderBackend === "webgpu" - ? "WebGPU" - : renderBackend === "webgl" - ? "WebGL" - : null; + renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null; const encoderLabel = encodeBackend === "ffmpeg" ? "Breeze" @@ -3977,7 +4153,7 @@ export default function VideoEditor() { const pathLabel = rendererLabel && encoderLabel ? `${rendererLabel} + ${encoderLabel}` - : rendererLabel ?? encoderLabel; + : (rendererLabel ?? encoderLabel); if (!pathLabel) { return encoderName ?? null; @@ -4052,7 +4228,6 @@ export default function VideoEditor() { className={`flex items-center gap-1.5 justify-self-start ${headerLeftControlsPaddingClass}`} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > - -
- + - - {t("common.actions.save")} + + + {t("common.actions.save")} +

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

{isLightningExportInProgress ? (

@@ -4176,7 +4359,8 @@ export default function VideoEditor() { ) : null} {isLegacyExportInProgress ? (

- Export too slow? Cancel and try Lightning export! + Export too slow? Cancel and try Lightning + export!

) : null}
@@ -4196,19 +4380,28 @@ export default function VideoEditor() {
)}
-

{exportPercentLabel}

+

+ {exportPercentLabel} +

{isRenderingAudio ? ( -

Audio requires real-time playback for speed/overlay edits

+

+ Audio requires real-time playback for speed/overlay + edits +

) : exportRenderSpeedLabel ? ( -

{exportRenderSpeedLabel}

+

+ {exportRenderSpeedLabel} +

) : null} {exportRuntimeLabel ? ( -

Path: {exportRuntimeLabel}

+

+ Path: {exportRuntimeLabel} +

) : null}
) : exportError ? ( @@ -4217,9 +4410,13 @@ export default function VideoEditor() { {t("editor.exportStatus.issue", "Export issue")}

{exportRuntimeLabel ? ( -

Path: {exportRuntimeLabel}

+

+ Path: {exportRuntimeLabel} +

) : null} -

{exportError}

+

+ {exportError} +

{hasPendingExportSave ? (
- {/* Top section: Video Preview + Settings */}
- {/* Left Column - Video Preview */} -
-
+ {/* Settings sidebar */} +
+ {/* Icon rail */} +
+ {editorSectionButtons.map((section) => { + const isActive = activeEffectSection === section.id; + return ( +
+ setActiveEffectSection(section.id)} + title={section.label} + className="group relative flex h-9 w-9 items-center justify-center rounded-lg outline-none focus:outline-none focus-visible:outline-none" + animate={{ opacity: isActive ? 1 : 0.55 }} + transition={{ duration: 0.14 }} + > + {isActive && ( + + )} + + {typeof section.icon === "string" ? ( + + ) : ( + + )} + + +
+ {isActive && ( + + )} +
+
+ ); + })} +
+ toast.info("Account coming soon")} + title="Account" + className="group relative flex h-9 w-9 items-center justify-center rounded-lg text-white/55 outline-none transition hover:text-white focus:outline-none focus-visible:outline-none" + whileHover={{ opacity: 1 }} + initial={{ opacity: 0.55 }} + > + + + +
+
+ {/* Panel */} + {activeEffectSection === "extensions" ? ( + + ) : ( + z.id === selectedZoomId)?.depth + : null + } + onZoomDepthChange={(depth) => + selectedZoomId && handleZoomDepthChange(depth) + } + selectedZoomId={selectedZoomId} + selectedZoomMode={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.mode ?? + "auto") + : null + } + onZoomModeChange={(mode) => + selectedZoomId && handleZoomModeChange(mode) + } + onZoomDelete={handleZoomDelete} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + selectedClipId={selectedClipId} + selectedClipSpeed={ + selectedClipId + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.speed ?? 1) + : null + } + selectedClipMuted={ + selectedClipId + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.muted ?? false) + : null + } + onClipSpeedChange={(speed) => + selectedClipId && handleClipSpeedChange(speed) + } + onClipMutedChange={(muted) => + selectedClipId && handleClipMutedChange(muted) + } + onClipDelete={handleClipDelete} + shadowIntensity={shadowIntensity} + onShadowChange={setShadowIntensity} + backgroundBlur={backgroundBlur} + onBackgroundBlurChange={setBackgroundBlur} + zoomMotionBlur={zoomMotionBlur} + onZoomMotionBlurChange={setZoomMotionBlur} + autoApplyFreshRecordingAutoZooms={autoApplyFreshRecordingAutoZooms} + onAutoApplyFreshRecordingAutoZoomsChange={ + setAutoApplyFreshRecordingAutoZooms + } + connectZooms={connectZooms} + onConnectZoomsChange={setConnectZooms} + zoomInDurationMs={zoomInDurationMs} + onZoomInDurationMsChange={setZoomInDurationMs} + zoomInOverlapMs={zoomInOverlapMs} + onZoomInOverlapMsChange={setZoomInOverlapMs} + zoomOutDurationMs={zoomOutDurationMs} + onZoomOutDurationMsChange={setZoomOutDurationMs} + connectedZoomGapMs={connectedZoomGapMs} + onConnectedZoomGapMsChange={setConnectedZoomGapMs} + connectedZoomDurationMs={connectedZoomDurationMs} + onConnectedZoomDurationMsChange={setConnectedZoomDurationMs} + zoomInEasing={zoomInEasing} + onZoomInEasingChange={setZoomInEasing} + zoomOutEasing={zoomOutEasing} + onZoomOutEasingChange={setZoomOutEasing} + connectedZoomEasing={connectedZoomEasing} + onConnectedZoomEasingChange={setConnectedZoomEasing} + showCursor={showCursor} + onShowCursorChange={setShowCursor} + loopCursor={loopCursor} + onLoopCursorChange={setLoopCursor} + cursorStyle={cursorStyle} + onCursorStyleChange={setCursorStyle} + cursorSize={cursorSize} + onCursorSizeChange={setCursorSize} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={setCursorSmoothing} + zoomSmoothness={zoomSmoothness} + onZoomSmoothnessChange={setZoomSmoothness} + zoomClassicMode={zoomClassicMode} + onZoomClassicModeChange={setZoomClassicMode} + cursorMotionBlur={cursorMotionBlur} + onCursorMotionBlurChange={setCursorMotionBlur} + cursorClickBounce={cursorClickBounce} + onCursorClickBounceChange={setCursorClickBounce} + cursorClickBounceDuration={cursorClickBounceDuration} + onCursorClickBounceDurationChange={setCursorClickBounceDuration} + cursorSway={cursorSway} + onCursorSwayChange={setCursorSway} + borderRadius={borderRadius} + onBorderRadiusChange={setBorderRadius} + webcam={webcam} + onWebcamChange={setWebcam} + onUploadWebcam={handleUploadWebcam} + onClearWebcam={handleClearWebcam} + padding={padding} + onPaddingChange={setPadding} + frame={frame} + onFrameChange={setFrame} + cropRegion={cropRegion} + onCropChange={setCropRegion} + aspectRatio={aspectRatio} + onAspectRatioChange={setAspectRatio} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationRegions} + autoCaptions={autoCaptions} + autoCaptionSettings={autoCaptionSettings} + whisperExecutablePath={whisperExecutablePath} + whisperModelPath={whisperModelPath} + whisperModelDownloadStatus={whisperModelDownloadStatus} + whisperModelDownloadProgress={whisperModelDownloadProgress} + isGeneratingCaptions={isGeneratingCaptions} + onAutoCaptionSettingsChange={setAutoCaptionSettings} + onPickWhisperExecutable={handlePickWhisperExecutable} + onPickWhisperModel={handlePickWhisperModel} + onGenerateAutoCaptions={handleGenerateAutoCaptions} + onClearAutoCaptions={handleClearAutoCaptions} + onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel} + onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationBlurIntensityChange={ + handleAnnotationBlurIntensityChange + } + onAnnotationBlurColorChange={handleAnnotationBlurColorChange} + onAnnotationDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId) + ?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + /> + )} +
+ {/* Right column: preview + timeline */} +
+ {/* Preview */} +
+
+ {/* Aspect ratio + crop controls above preview */} +
+ + + + + + {ASPECT_RATIOS.map((ratio) => ( + setAspectRatio(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 && ( + + )} + + ))} + + +
+ +
{/* Video preview */}
-
- -
- {editorSectionButtons.map((section) => { - const isActive = activeEffectSection === section.id; - return ( - setActiveEffectSection(section.id)} - title={section.label} - className="group relative flex h-8 w-8 items-center justify-center text-white/75 outline-none transition-colors hover:text-white focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" - animate={{ scale: isActive ? 1.06 : 1, opacity: isActive ? 1 : 0.82 }} - transition={{ type: "spring", stiffness: 420, damping: 28 }} - > - - {typeof section.icon === "string" ? ( - - ) : ( - - )} - - - {isActive ? ( - - ) : null} - - - ); - })} -
-
-
-
+
{ - const previewVideo = videoPlaybackRef.current?.video; - if (previewVideo && previewVideo.videoHeight > 0) { - return previewVideo.videoWidth / previewVideo.videoHeight; + const previewVideo = + videoPlaybackRef.current?.video; + if ( + previewVideo && + previewVideo.videoHeight > 0 + ) { + return ( + previewVideo.videoWidth / + previewVideo.videoHeight + ); } return 16 / 9; })(), @@ -4414,7 +4862,11 @@ export default function VideoEditor() { frame={frame} cropRegion={cropRegion} webcam={webcam} - webcamVideoPath={webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null} + webcamVideoPath={ + webcam.sourcePath + ? toFileUrl(webcam.sourcePath) + : null + } trimRegions={trimRegions} speedRegions={effectiveSpeedRegions} annotationRegions={annotationRegions} @@ -4422,7 +4874,9 @@ export default function VideoEditor() { autoCaptionSettings={autoCaptionSettings} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationPositionChange={ + handleAnnotationPositionChange + } onAnnotationSizeChange={handleAnnotationSizeChange} cursorTelemetry={effectiveCursorTelemetry} showCursor={showCursor} @@ -4433,167 +4887,236 @@ export default function VideoEditor() { zoomClassicMode={zoomClassicMode} cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} - cursorClickBounceDuration={cursorClickBounceDuration} + cursorClickBounceDuration={ + cursorClickBounceDuration + } cursorSway={cursorSway} volume={hasSourceAudioFallback ? 0 : previewVolume} />
- {/* Playback controls */} -
+
+ {/* Toolbar - sits at bottom of right column, only spans preview width */} +
+ {/* Left tools */} +
+ + + + + + { + const nextTrackIndex = + annotationRegions.length > 0 + ? Math.max( + ...annotationRegions.map( + (r) => r.trackIndex ?? 0, + ), + ) + 1 + : 0; + timelineRef.current?.addAnnotation(nextTrackIndex); + }} + className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer" + > + Annotation + + timelineRef.current?.addAudio()} + className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer" + > + Audio + + + +
+ + + +
+ {/* Playback controls - centered */} +
+
+ + {formatTime(timelinePlayheadTime)} + + + + + + {formatTime(duration)} + +
+
+ {/* Right: collapse + volume */} +
+ +
+ +
+
0 + ? `max(calc(${previewVolume * 100}% - 6px), 1.2rem)` + : 0, + }} + /> +
+ + {Math.round(previewVolume * 100)}% + + + setPreviewVolume(Number(e.target.value)) + } + className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0" />
+
+
- - {/* Left section: settings panel */} -
- {activeEffectSection === "extensions" ? ( - - ) : ( - z.id === selectedZoomId)?.depth : null - } - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomId={selectedZoomId} - selectedZoomMode={ - selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.mode ?? 'auto') : null - } - onZoomModeChange={(mode) => selectedZoomId && handleZoomModeChange(mode)} - onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} - selectedClipId={selectedClipId} - selectedClipSpeed={ - selectedClipId ? (clipRegions.find((c) => c.id === selectedClipId)?.speed ?? 1) : null - } - onClipSpeedChange={(speed) => selectedClipId && handleClipSpeedChange(speed)} - onClipDelete={handleClipDelete} - shadowIntensity={shadowIntensity} - onShadowChange={setShadowIntensity} - backgroundBlur={backgroundBlur} - onBackgroundBlurChange={setBackgroundBlur} - zoomMotionBlur={zoomMotionBlur} - onZoomMotionBlurChange={setZoomMotionBlur} - connectZooms={connectZooms} - onConnectZoomsChange={setConnectZooms} - zoomInDurationMs={zoomInDurationMs} - onZoomInDurationMsChange={setZoomInDurationMs} - zoomInOverlapMs={zoomInOverlapMs} - onZoomInOverlapMsChange={setZoomInOverlapMs} - zoomOutDurationMs={zoomOutDurationMs} - onZoomOutDurationMsChange={setZoomOutDurationMs} - connectedZoomGapMs={connectedZoomGapMs} - onConnectedZoomGapMsChange={setConnectedZoomGapMs} - connectedZoomDurationMs={connectedZoomDurationMs} - onConnectedZoomDurationMsChange={setConnectedZoomDurationMs} - zoomInEasing={zoomInEasing} - onZoomInEasingChange={setZoomInEasing} - zoomOutEasing={zoomOutEasing} - onZoomOutEasingChange={setZoomOutEasing} - connectedZoomEasing={connectedZoomEasing} - onConnectedZoomEasingChange={setConnectedZoomEasing} - showCursor={showCursor} - onShowCursorChange={setShowCursor} - loopCursor={loopCursor} - onLoopCursorChange={setLoopCursor} - cursorStyle={cursorStyle} - onCursorStyleChange={setCursorStyle} - cursorSize={cursorSize} - onCursorSizeChange={setCursorSize} - cursorSmoothing={cursorSmoothing} - onCursorSmoothingChange={setCursorSmoothing} - zoomSmoothness={zoomSmoothness} - onZoomSmoothnessChange={setZoomSmoothness} - zoomClassicMode={zoomClassicMode} - onZoomClassicModeChange={setZoomClassicMode} - cursorMotionBlur={cursorMotionBlur} - onCursorMotionBlurChange={setCursorMotionBlur} - cursorClickBounce={cursorClickBounce} - onCursorClickBounceChange={setCursorClickBounce} - cursorClickBounceDuration={cursorClickBounceDuration} - onCursorClickBounceDurationChange={setCursorClickBounceDuration} - cursorSway={cursorSway} - onCursorSwayChange={setCursorSway} - borderRadius={borderRadius} - onBorderRadiusChange={setBorderRadius} - webcam={webcam} - onWebcamChange={setWebcam} - onUploadWebcam={handleUploadWebcam} - onClearWebcam={handleClearWebcam} - padding={padding} - onPaddingChange={setPadding} - frame={frame} - onFrameChange={setFrame} - cropRegion={cropRegion} - onCropChange={setCropRegion} - aspectRatio={aspectRatio} - onAspectRatioChange={setAspectRatio} - selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} - autoCaptions={autoCaptions} - autoCaptionSettings={autoCaptionSettings} - whisperExecutablePath={whisperExecutablePath} - whisperModelPath={whisperModelPath} - whisperModelDownloadStatus={whisperModelDownloadStatus} - whisperModelDownloadProgress={whisperModelDownloadProgress} - isGeneratingCaptions={isGeneratingCaptions} - onAutoCaptionSettingsChange={setAutoCaptionSettings} - onPickWhisperExecutable={handlePickWhisperExecutable} - onPickWhisperModel={handlePickWhisperModel} - onGenerateAutoCaptions={handleGenerateAutoCaptions} - onClearAutoCaptions={handleClearAutoCaptions} - onDownloadWhisperSmallModel={handleDownloadWhisperSmallModel} - onDeleteWhisperSmallModel={handleDeleteWhisperSmallModel} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationBlurIntensityChange={handleAnnotationBlurIntensityChange} - onAnnotationBlurColorChange={handleAnnotationBlurColorChange} - onAnnotationDelete={handleAnnotationDelete} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - /> - )} -
-
- - {/* Timeline section - full width */} -
+
@@ -4652,8 +5176,12 @@ export default function VideoEditor() {
- {t("settings.crop.title")} -

{t("settings.crop.instruction")}

+ + {t("settings.crop.title")} + +

+ {t("settings.crop.instruction")} +