From ac3cb70aae3ed25240024d8083b84efa90822773 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 13:28:51 +0200 Subject: [PATCH 1/9] add: constants in the i18n. add: settings panel hooks --- .../launch/popovers/MorePopover.tsx | 16 +- src/components/video-editor/SettingsPanel.tsx | 549 ++---------------- .../video-editor/settings/constants.ts | 85 +++ .../settings/hooks/useSettingsPanel.ts | 421 ++++++++++++++ src/i18n/config.ts | 12 + 5 files changed, 574 insertions(+), 509 deletions(-) create mode 100644 src/components/video-editor/settings/constants.ts create mode 100644 src/components/video-editor/settings/hooks/useSettingsPanel.ts diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a52905..a7675841c 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -14,25 +14,13 @@ import { useI18n } from "@/contexts/I18nContext"; import { useScopedT } from "@/contexts/I18nContext"; import { useTheme } from "@/contexts/ThemeContext"; import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { APP_LANGUAGE_LABELS, SUPPORTED_LOCALES } from "@/i18n/config"; import styles from "../LaunchWindow.module.css"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { DropdownItem, HudPopover } from "./PopoverScaffold"; const POPOVER_ID = "more"; -const LOCALE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - it: "Italiano", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; - export function MorePopover({ trigger, supportsHudCaptureProtection, @@ -170,7 +158,7 @@ export function MorePopover({ requestClose(POPOVER_ID); }} > - {LOCALE_LABELS[code] ?? code} + {APP_LANGUAGE_LABELS[code] ?? code} ))} {appVersion && ( diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index cff898a53..066db70ea 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -8,7 +8,6 @@ import { } from "@phosphor-icons/react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Select, @@ -20,30 +19,20 @@ import { import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useTheme } from "@/contexts/ThemeContext"; -import { - getAssetPath, - getRenderableAssetUrl, - getRenderableVideoUrl, - getWallpaperThumbnailUrl, -} from "@/lib/assetPath"; +import { getRenderableVideoUrl } from "@/lib/assetPath"; import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, } from "@/lib/exporter/temporalMotionBlur"; import type { ExtensionSettingField } from "@/lib/extensions"; -import { extensionHost, type FrameInstance } from "@/lib/extensions"; +import { extensionHost } 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, isVideoWallpaperSource } from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import type { AppLocale } from "../../i18n/config"; -import { SUPPORTED_LOCALES } from "../../i18n/config"; +import { APP_LANGUAGE_LABELS, SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CURSOR_MOTION_PRESETS, @@ -105,10 +94,19 @@ import { normalizeWebcamCropRegion, resolveWebcamCorner, } from "./webcamOverlay"; +import { + BUILTIN_CURSOR_PREVIEW_FRAME_SIZE, + BUILTIN_CURSOR_PREVIEW_SIZE, + BUILTIN_CURSOR_STYLE_OPTIONS, + CAPTION_ANIMATION_OPTIONS, + CAPTION_LANGUAGE_OPTIONS, + GRADIENTS, + WEBCAM_POSITION_PRESETS, + ZOOM_DEPTH_OPTIONS, +} from "./settings/constants"; +import { useSettingsPanel } from "./settings/hooks/useSettingsPanel"; const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; -const BUILTIN_CURSOR_PREVIEW_SIZE = 28; -const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48; function getStepPrecision(step: number): number { if (!Number.isFinite(step) || step <= 0) return 0; @@ -119,61 +117,11 @@ function getStepPrecision(step: number): number { return Math.min(12, precision); } -const GRADIENTS = [ - "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", - "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", - "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", - "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", - "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", - "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", - "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", - "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", - "linear-gradient(135deg, #FBC8B4, #2447B1)", - "linear-gradient(109.6deg, #F635A6, #36D860)", - "linear-gradient(90deg, #FF0101, #4DFF01)", - "linear-gradient(315deg, #EC0101, #5044A9)", - "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", - "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", - "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", - "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", - "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", - "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", - "linear-gradient(to right, #fa709a 0%, #fee140 100%)", - "linear-gradient(to top, #30cfd0 0%, #330867 100%)", - "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", - "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", - "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", - "linear-gradient(to right, #0acffe 0%, #495aff 100%)", -]; - -const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: string }> = [ - { value: "none", label: "Off" }, - { value: "fade", label: "Fade" }, - { value: "rise", label: "Rise" }, - { value: "pop", label: "Pop" }, -]; - -type BackgroundTab = "image" | "video" | "color" | "gradient"; + function isHexWallpaper(value: string): boolean { return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); } -function getBackgroundTabForWallpaper(value: string): BackgroundTab { - if (GRADIENTS.includes(value)) { - return "gradient"; - } - - if (isHexWallpaper(value)) { - return "color"; - } - - if (isVideoWallpaperSource(value)) { - return "video"; - } - - return "image"; -} - function SectionLabel({ children }: { children: React.ReactNode }) { return (

@@ -584,71 +532,6 @@ interface SettingsPanelProps { onOpenNativeCaptureUnavailableModal?: () => void; } -const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ - { depth: 1, label: "1.25×" }, - { depth: 2, label: "1.5×" }, - { depth: 3, label: "1.8×" }, - { depth: 4, label: "2.2×" }, - { depth: 5, label: "3.5×" }, - { depth: 6, label: "5×" }, -]; - -const WEBCAM_POSITION_PRESETS: Array<{ - preset: Exclude; - label: string; -}> = [ - { preset: "top-left", label: "↖" }, - { preset: "top-center", label: "↑" }, - { preset: "top-right", label: "↗" }, - { preset: "center-left", label: "←" }, - { preset: "center", label: "•" }, - { preset: "center-right", label: "→" }, - { preset: "bottom-left", label: "↙" }, - { preset: "bottom-center", label: "↓" }, - { preset: "bottom-right", label: "↘" }, -]; - -type CursorStyleOption = { value: CursorStyle; label: string }; - -type WallpaperTile = { - key: string; - label: string; - value: string; - previewUrl: string; -}; - -const BUILTIN_CURSOR_STYLE_OPTIONS: CursorStyleOption[] = [ - { value: "macos", label: "macOS" }, - { value: "tahoe", label: "Tahoe" }, - { value: "tahoe-inverted", label: "Tahoe Inverted" }, - { value: "dot", label: "Dot" }, - { value: "figma", label: "Minimal" }, -]; - -const CAPTION_LANGUAGE_OPTIONS = [ - { value: "auto", label: "Auto Detect" }, - { value: "en", label: "English" }, - { value: "es", label: "Spanish" }, - { value: "fr", label: "French" }, - { value: "de", label: "German" }, - { value: "it", label: "Italian" }, - { value: "pt", label: "Portuguese" }, - { value: "zh", label: "Chinese (Simplified)" }, - { value: "ja", label: "Japanese" }, - { value: "ko", label: "Korean" }, -] as const; - -const APP_LANGUAGE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - it: "Italiano", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; function loadPreviewImage(url: string) { return new Promise((resolve, reject) => { @@ -970,32 +853,46 @@ export function SettingsPanel({ const { locale, setLocale, t } = useI18n(); const { preference: themePreference, setPreference: setThemePreference } = useTheme(); const isBackgroundPanel = panelMode === "background"; - const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); - const [builtInWallpapers, setBuiltInWallpapers] = - useState(BUILT_IN_WALLPAPERS); - const [extensionWallpapers, setExtensionWallpapers] = useState< - ReturnType - >([]); - const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); - const [extensionWallpaperPreviewUrls, setExtensionWallpaperPreviewUrls] = useState< - Record - >({}); - const [customImages, setCustomImages] = useState( - initialEditorPreferences.customWallpapers, - ); const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; padding: Padding; } | null>(null); - const fileInputRef = useRef(null); - const builtInWallpaperPaths = useMemo( - () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), - [builtInWallpapers], - ); - const extensionWallpaperPaths = useMemo( - () => extensionWallpapers.map((wallpaper) => wallpaper.resolvedUrl), - [extensionWallpapers], - ); + const { + initialEditorPreferences, + customImages, + fileInputRef, + customColorInputRef, + builtInWallpaperPaths, + extensionWallpaperPaths, + backgroundTab, + setBackgroundTab, + selectedColor, + setSelectedColor, + gradient, + setGradient, + availableFrames, + extensionPanels, + cursorPreviewUrls, + cursorStyleOptions, + imageWallpaperTiles, + videoWallpaperTiles, + handleImageUpload, + handleVideoUpload, + handleRemoveCustomImage, + } = useSettingsPanel({ + selected, + onWallpaperChange, + loadEditorPreferences, + saveEditorPreferences, + tSettings, + t, + gradients: GRADIENTS, + builtInCursorStyleOptions: BUILTIN_CURSOR_STYLE_OPTIONS, + createTrimmedSvgPreview, + createInvertedPreview, + minimalCursorUrl, + tahoeCursorUrl, + }); const captionCueCount = autoCaptions.length; const updateAutoCaptionSettings = (partial: Partial) => { onAutoCaptionSettingsChange?.({ @@ -1003,91 +900,6 @@ export function SettingsPanel({ ...partial, }); }; - - useEffect(() => { - let mounted = true; - (async () => { - try { - const availableWallpapers = await getAvailableWallpapers(); - const resolved = await Promise.all( - availableWallpapers.map(async (wallpaper) => { - const assetUrl = await getAssetPath(wallpaper.relativePath); - // Use tiny thumbnails for the grid; full-res loads on selection - if (isVideoWallpaperSource(wallpaper.publicPath)) { - return getRenderableVideoUrl(assetUrl); - } - return getWallpaperThumbnailUrl(assetUrl); - }), - ); - if (mounted) { - setBuiltInWallpapers(availableWallpapers); - setWallpaperPreviewPaths(resolved); - } - } catch { - if (mounted) { - setBuiltInWallpapers(BUILT_IN_WALLPAPERS); - setWallpaperPreviewPaths( - BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), - ); - } - } - })(); - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - let cancelled = false; - - const updateExtensionAssets = async () => { - const wallpapers = extensionHost.getContributedWallpapers(); - 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, - ), - ), - Promise.all( - cursorStyles.map( - async (cursorStyle) => - [ - cursorStyle.id, - await getRenderableAssetUrl(cursorStyle.resolvedDefaultUrl), - ] as const, - ), - ), - ]); - - if (cancelled) { - return; - } - - setExtensionWallpapers(wallpapers); - setExtensionWallpaperPreviewUrls(Object.fromEntries(wallpaperPreviewEntries)); - setExtensionCursorStyles(cursorStyles); - setExtensionCursorPreviewUrls(Object.fromEntries(cursorPreviewEntries)); - }; - - void extensionHost.autoActivateBuiltins().then(updateExtensionAssets); - const unsubscribe = extensionHost.onChange(() => { - void updateExtensionAssets(); - }); - - return () => { - cancelled = true; - unsubscribe(); - }; - }, []); const colorPalette = [ "#FF0000", "#FFD700", @@ -1107,32 +919,8 @@ export function SettingsPanel({ "#795548", ]; - const [selectedColor, setSelectedColor] = useState( - isHexWallpaper(selected) ? selected : "#ADADAD", - ); - const [gradient, setGradient] = useState( - GRADIENTS.includes(selected) ? selected : GRADIENTS[0], - ); const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding); - // Device frames from extension system - const [availableFrames, setAvailableFrames] = useState([]); - useEffect(() => { - const update = () => setAvailableFrames(extensionHost.getFrames()); - update(); - return extensionHost.onChange(update); - }, []); - - // Extension-contributed settings panels - const [extensionPanels, setExtensionPanels] = useState< - ReturnType - >([]); - useEffect(() => { - const update = () => setExtensionPanels(extensionHost.getSettingsPanels()); - update(); - return extensionHost.onChange(update); - }, []); - const renderExtensionPanelsForSections = (...sections: string[]) => extensionPanels .filter((panel) => { @@ -1148,169 +936,10 @@ export function SettingsPanel({ /> )); - const [backgroundTab, setBackgroundTab] = useState(() => - getBackgroundTabForWallpaper(selected), - ); - const customColorInputRef = useRef(null); const defaultWebcam = initialEditorPreferences.webcam; const [internalActiveEffectSection] = useState("scene"); const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; - const [extensionCursorStyles, setExtensionCursorStyles] = useState< - ReturnType - >([]); - const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState< - Partial> - >({}); - const [extensionCursorPreviewUrls, setExtensionCursorPreviewUrls] = useState< - Partial> - >({}); - const cursorPreviewUrls = useMemo( - () => ({ ...builtInCursorPreviewUrls, ...extensionCursorPreviewUrls }), - [builtInCursorPreviewUrls, extensionCursorPreviewUrls], - ); const showDevMotionControls = import.meta.env.DEV; - const cursorStyleOptions = useMemo( - () => [ - ...BUILTIN_CURSOR_STYLE_OPTIONS, - ...extensionCursorStyles.map((cursorStyle) => ({ - value: cursorStyle.id as CursorStyle, - label: cursorStyle.cursorStyle.label, - })), - ], - [extensionCursorStyles], - ); - - useEffect(() => { - let cancelled = false; - - void (async () => { - try { - const macosPreview = cursorSetAssets.macos.arrow.url; - const tahoePreview = cursorSetAssets.tahoe.arrow.url; - const minimalPreview = await createTrimmedSvgPreview(minimalCursorUrl, 512); - const invertedPreview = await createInvertedPreview(tahoePreview); - - if (!cancelled) { - setBuiltInCursorPreviewUrls({ - macos: macosPreview, - tahoe: tahoePreview, - figma: minimalPreview, - "tahoe-inverted": invertedPreview, - }); - } - } catch { - if (!cancelled) { - setBuiltInCursorPreviewUrls({ - macos: tahoeCursorUrl, - tahoe: tahoeCursorUrl, - figma: minimalCursorUrl, - "tahoe-inverted": tahoeCursorUrl, - }); - } - } - })(); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - setBackgroundTab(getBackgroundTabForWallpaper(selected)); - - if (isHexWallpaper(selected)) { - setSelectedColor(selected); - } - - if (GRADIENTS.includes(selected)) { - setGradient(selected); - } - }, [selected]); - - useEffect(() => { - if (selected.startsWith("data:image")) { - setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); - return; - } - - const isKnownWallpaper = - builtInWallpaperPaths.includes(selected) || - wallpaperPreviewPaths.includes(selected) || - extensionWallpaperPaths.includes(selected); - - if (!isKnownWallpaper && isVideoWallpaperSource(selected)) { - setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); - } - }, [ - builtInWallpaperPaths, - extensionWallpaperPaths, - selected, - wallpaperPreviewPaths, - ]); - - const imageWallpaperTiles = useMemo(() => { - const imageWallpapers = builtInWallpapers.filter( - (wallpaper) => !isVideoWallpaperSource(wallpaper.publicPath), - ); - const builtInTiles = ( - wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths : builtInWallpaperPaths - ) - .filter((path) => !isVideoWallpaperSource(path)) - .map((previewPath, index) => { - const wallpaper = imageWallpapers[index]; - return { - key: wallpaper ? `builtin/${wallpaper.id}` : previewPath, - label: wallpaper?.label ?? `Wallpaper ${index + 1}`, - value: wallpaper?.publicPath ?? previewPath, - previewUrl: previewPath, - }; - }); - - const extensionTiles = extensionWallpapers - .filter((wallpaper) => !isVideoWallpaperSource(wallpaper.resolvedUrl)) - .map((wallpaper) => ({ - key: wallpaper.id, - label: wallpaper.wallpaper.label, - value: wallpaper.resolvedUrl, - previewUrl: - extensionWallpaperPreviewUrls[wallpaper.id] ?? wallpaper.resolvedThumbnailUrl, - })); - - return [...builtInTiles, ...extensionTiles]; - }, [ - builtInWallpaperPaths, - builtInWallpapers, - extensionWallpaperPreviewUrls, - extensionWallpapers, - wallpaperPreviewPaths, - ]); - - const videoWallpaperTiles = useMemo(() => { - const builtInTiles = builtInWallpapers - .filter((wallpaper) => isVideoWallpaperSource(wallpaper.publicPath)) - .map((wallpaper) => ({ - key: `builtin/${wallpaper.id}`, - label: wallpaper.label, - value: wallpaper.publicPath, - previewUrl: wallpaper.publicPath, - })); - - const extensionTiles = extensionWallpapers - .filter((wallpaper) => isVideoWallpaperSource(wallpaper.resolvedUrl)) - .map((wallpaper) => ({ - key: wallpaper.id, - label: wallpaper.wallpaper.label, - value: wallpaper.resolvedUrl, - previewUrl: - extensionWallpaperPreviewUrls[wallpaper.id] ?? wallpaper.resolvedThumbnailUrl, - })); - - return [...builtInTiles, ...extensionTiles]; - }, [builtInWallpapers, extensionWallpaperPreviewUrls, extensionWallpapers]); - - useEffect(() => { - saveEditorPreferences({ customWallpapers: customImages }); - }, [customImages]); const handleRemoveBackgroundToggle = (checked: boolean) => { if (checked) { @@ -1495,7 +1124,8 @@ export function SettingsPanel({ (preferredWallpaper && extensionWallpaperPaths.includes(preferredWallpaper)) || (preferredWallpaper && customImages.includes(preferredWallpaper)) || (preferredWallpaper && isHexWallpaper(preferredWallpaper)) || - (preferredWallpaper && GRADIENTS.includes(preferredWallpaper)); + (preferredWallpaper && + GRADIENTS.some((gradientValue) => gradientValue === preferredWallpaper)); onWallpaperChange( (hasPreferredWallpaper ? preferredWallpaper : "") || @@ -1627,77 +1257,6 @@ export function SettingsPanel({ }); }; - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - const file = files[0]; - - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { - toast.error(tSettings("background.uploadError"), { - description: tSettings("background.uploadErrorDescription"), - }); - event.target.value = ""; - return; - } - - const reader = new FileReader(); - - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - setCustomImages((prev) => [...prev, dataUrl]); - onWallpaperChange(dataUrl); - toast.success(tSettings("background.uploadSuccess")); - } - }; - - reader.onerror = () => { - toast.error(t("common.errors.failedToUploadImage"), { - description: t("common.errors.fileReadError"), - }); - }; - - reader.readAsDataURL(file); - // Reset input so the same file can be selected again - event.target.value = ""; - }; - - const handleVideoUpload = async () => { - try { - const result = await window.electronAPI.openVideoFilePicker(); - if (!result?.success || !result.path) return; - const filePath = result.path; - if (!isVideoWallpaperSource(filePath)) { - toast.error("Unsupported format", { - description: "Please select a video file (mp4, webm, mov, etc.)", - }); - return; - } - setCustomImages((prev) => [filePath, ...prev]); - onWallpaperChange(filePath); - toast.success("Video background added"); - } catch { - toast.error("Failed to import video background"); - } - }; - - const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { - event.stopPropagation(); - setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); - // If the removed image was selected, clear selection - if (selected === imageUrl) { - onWallpaperChange( - builtInWallpaperPaths[0] ?? - extensionWallpaperPaths[0] ?? - BUILT_IN_WALLPAPERS[0]?.publicPath ?? - "", - ); - } - }; - // Find selected annotation const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) diff --git a/src/components/video-editor/settings/constants.ts b/src/components/video-editor/settings/constants.ts new file mode 100644 index 000000000..18fede17f --- /dev/null +++ b/src/components/video-editor/settings/constants.ts @@ -0,0 +1,85 @@ +import type { AutoCaptionAnimation, CursorStyle, WebcamPositionPreset, ZoomDepth } from "../types"; + +export type CursorStyleOption = { value: CursorStyle; label: string }; + +export const BUILTIN_CURSOR_PREVIEW_SIZE = 28; +export const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48; + +export const GRADIENTS = [ + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(109.6deg, #F635A6, #36D860)", + "linear-gradient(90deg, #FF0101, #4DFF01)", + "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + "linear-gradient(to right, #fa709a 0%, #fee140 100%)", + "linear-gradient(to top, #30cfd0 0%, #330867 100%)", + "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", + "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", + "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", + "linear-gradient(to right, #0acffe 0%, #495aff 100%)", +] as const; + +export const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: string }> = [ + { value: "none", label: "Off" }, + { value: "fade", label: "Fade" }, + { value: "rise", label: "Rise" }, + { value: "pop", label: "Pop" }, +]; + +export const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ + { depth: 1, label: "1.25×" }, + { depth: 2, label: "1.5×" }, + { depth: 3, label: "1.8×" }, + { depth: 4, label: "2.2×" }, + { depth: 5, label: "3.5×" }, + { depth: 6, label: "5×" }, +]; + +export const WEBCAM_POSITION_PRESETS: Array<{ + preset: Exclude; + label: string; +}> = [ + { preset: "top-left", label: "↖" }, + { preset: "top-center", label: "↑" }, + { preset: "top-right", label: "↗" }, + { preset: "center-left", label: "←" }, + { preset: "center", label: "•" }, + { preset: "center-right", label: "→" }, + { preset: "bottom-left", label: "↙" }, + { preset: "bottom-center", label: "↓" }, + { preset: "bottom-right", label: "↘" }, +]; + +export const BUILTIN_CURSOR_STYLE_OPTIONS: CursorStyleOption[] = [ + { value: "macos", label: "macOS" }, + { value: "tahoe", label: "Tahoe" }, + { value: "tahoe-inverted", label: "Tahoe Inverted" }, + { value: "dot", label: "Dot" }, + { value: "figma", label: "Minimal" }, +]; + +export const CAPTION_LANGUAGE_OPTIONS = [ + { value: "auto", label: "Auto Detect" }, + { value: "en", label: "English" }, + { value: "es", label: "Spanish" }, + { value: "fr", label: "French" }, + { value: "de", label: "German" }, + { value: "it", label: "Italian" }, + { value: "pt", label: "Portuguese" }, + { value: "zh", label: "Chinese (Simplified)" }, + { value: "ja", label: "Japanese" }, + { value: "ko", label: "Korean" }, +] as const; diff --git a/src/components/video-editor/settings/hooks/useSettingsPanel.ts b/src/components/video-editor/settings/hooks/useSettingsPanel.ts new file mode 100644 index 000000000..051524c33 --- /dev/null +++ b/src/components/video-editor/settings/hooks/useSettingsPanel.ts @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + getAssetPath, + getRenderableAssetUrl, + getRenderableVideoUrl, + getWallpaperThumbnailUrl, +} from "@/lib/assetPath"; +import { extensionHost, type FrameInstance } from "@/lib/extensions"; +import type { BuiltInWallpaper } from "@/lib/wallpapers"; +import { + BUILT_IN_WALLPAPERS, + getAvailableWallpapers, + isVideoWallpaperSource, +} from "@/lib/wallpapers"; +import { loadEditorPreferences as loadEditorPreferencesFn, saveEditorPreferences as saveEditorPreferencesFn } from "../../editorPreferences"; +import type { CursorStyle } from "../../types"; + +export type BackgroundTab = "image" | "video" | "color" | "gradient"; +export type CursorStyleOption = { value: CursorStyle; label: string }; +export type WallpaperTile = { key: string; label: string; value: string; previewUrl: string }; + +function isHexWallpaper(value: string): boolean { + return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); +} + +function getBackgroundTabForWallpaper(value: string, gradients: readonly string[]): BackgroundTab { + if (gradients.some((gradient) => gradient === value)) return "gradient"; + if (isHexWallpaper(value)) return "color"; + if (isVideoWallpaperSource(value)) return "video"; + return "image"; +} + +type UseSettingsPanelArgs = { + selected: string; + onWallpaperChange: (path: string) => void; + loadEditorPreferences: typeof loadEditorPreferencesFn; + saveEditorPreferences: typeof saveEditorPreferencesFn; + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + gradients: readonly string[]; + builtInCursorStyleOptions: CursorStyleOption[]; + createTrimmedSvgPreview: (url: string, sampleSize: number) => Promise; + createInvertedPreview: (url: string) => Promise; + minimalCursorUrl: string; + tahoeCursorUrl: string; +}; + +export function useSettingsPanel({ + selected, + onWallpaperChange, + loadEditorPreferences, + saveEditorPreferences, + tSettings, + t, + gradients, + builtInCursorStyleOptions, + createTrimmedSvgPreview, + createInvertedPreview, + minimalCursorUrl, + tahoeCursorUrl, +}: UseSettingsPanelArgs) { + const initialEditorPreferences = useMemo(() => loadEditorPreferences(), [loadEditorPreferences]); + const [builtInWallpapers, setBuiltInWallpapers] = useState(BUILT_IN_WALLPAPERS); + const [extensionWallpapers, setExtensionWallpapers] = useState>([]); + const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); + const [extensionWallpaperPreviewUrls, setExtensionWallpaperPreviewUrls] = useState>({}); + const [customImages, setCustomImages] = useState(initialEditorPreferences.customWallpapers); + const fileInputRef = useRef(null); + const customColorInputRef = useRef(null); + const [backgroundTab, setBackgroundTab] = useState(() => getBackgroundTabForWallpaper(selected, gradients)); + const [selectedColor, setSelectedColor] = useState(isHexWallpaper(selected) ? selected : "#ADADAD"); + const [gradient, setGradient] = useState( + gradients.some((gradientValue) => gradientValue === selected) ? selected : gradients[0], + ); + const [availableFrames, setAvailableFrames] = useState([]); + const [extensionPanels, setExtensionPanels] = useState>([]); + const [extensionCursorStyles, setExtensionCursorStyles] = useState>([]); + const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState>>({}); + const [extensionCursorPreviewUrls, setExtensionCursorPreviewUrls] = useState>>({}); + + const builtInWallpaperPaths = useMemo( + () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), + [builtInWallpapers], + ); + const extensionWallpaperPaths = useMemo( + () => extensionWallpapers.map((wallpaper) => wallpaper.resolvedUrl), + [extensionWallpapers], + ); + const cursorPreviewUrls = useMemo( + () => ({ ...builtInCursorPreviewUrls, ...extensionCursorPreviewUrls }), + [builtInCursorPreviewUrls, extensionCursorPreviewUrls], + ); + const cursorStyleOptions = useMemo( + () => [ + ...builtInCursorStyleOptions, + ...extensionCursorStyles.map((cursorStyle) => ({ + value: cursorStyle.id as CursorStyle, + label: cursorStyle.cursorStyle.label, + })), + ], + [builtInCursorStyleOptions, extensionCursorStyles], + ); + + const imageWallpaperTiles = useMemo(() => { + const imageWallpapers = builtInWallpapers.filter( + (wallpaper) => !isVideoWallpaperSource(wallpaper.publicPath), + ); + const builtInTiles = ( + wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths : builtInWallpaperPaths + ) + .filter((path) => !isVideoWallpaperSource(path)) + .map((previewPath, index) => { + const wallpaper = imageWallpapers[index]; + return { + key: wallpaper ? `builtin/${wallpaper.id}` : previewPath, + label: wallpaper?.label ?? `Wallpaper ${index + 1}`, + value: wallpaper?.publicPath ?? previewPath, + previewUrl: previewPath, + }; + }); + + const extensionTiles = extensionWallpapers + .filter((wallpaper) => !isVideoWallpaperSource(wallpaper.resolvedUrl)) + .map((wallpaper) => ({ + key: wallpaper.id, + label: wallpaper.wallpaper.label, + value: wallpaper.resolvedUrl, + previewUrl: + extensionWallpaperPreviewUrls[wallpaper.id] ?? wallpaper.resolvedThumbnailUrl, + })); + + return [...builtInTiles, ...extensionTiles]; + }, [ + builtInWallpaperPaths, + builtInWallpapers, + extensionWallpaperPreviewUrls, + extensionWallpapers, + wallpaperPreviewPaths, + ]); + + const videoWallpaperTiles = useMemo(() => { + const builtInTiles = builtInWallpapers + .filter((wallpaper) => isVideoWallpaperSource(wallpaper.publicPath)) + .map((wallpaper) => ({ + key: `builtin/${wallpaper.id}`, + label: wallpaper.label, + value: wallpaper.publicPath, + previewUrl: wallpaper.publicPath, + })); + + const extensionTiles = extensionWallpapers + .filter((wallpaper) => isVideoWallpaperSource(wallpaper.resolvedUrl)) + .map((wallpaper) => ({ + key: wallpaper.id, + label: wallpaper.wallpaper.label, + value: wallpaper.resolvedUrl, + previewUrl: + extensionWallpaperPreviewUrls[wallpaper.id] ?? wallpaper.resolvedThumbnailUrl, + })); + + return [...builtInTiles, ...extensionTiles]; + }, [builtInWallpapers, extensionWallpaperPreviewUrls, extensionWallpapers]); + + useEffect(() => { + let mounted = true; + (async () => { + try { + const availableWallpapers = await getAvailableWallpapers(); + const resolved = await Promise.all( + availableWallpapers.map(async (wallpaper) => { + const assetUrl = await getAssetPath(wallpaper.relativePath); + if (isVideoWallpaperSource(wallpaper.publicPath)) { + return getRenderableVideoUrl(assetUrl); + } + return getWallpaperThumbnailUrl(assetUrl); + }), + ); + if (mounted) { + setBuiltInWallpapers(availableWallpapers); + setWallpaperPreviewPaths(resolved); + } + } catch { + if (mounted) { + setBuiltInWallpapers(BUILT_IN_WALLPAPERS); + setWallpaperPreviewPaths( + BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), + ); + } + } + })(); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + const updateExtensionAssets = async () => { + const wallpapers = extensionHost.getContributedWallpapers(); + 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, + ), + ), + Promise.all( + cursorStyles.map( + async (cursorStyle) => + [ + cursorStyle.id, + await getRenderableAssetUrl(cursorStyle.resolvedDefaultUrl), + ] as const, + ), + ), + ]); + + if (cancelled) { + return; + } + + setExtensionWallpapers(wallpapers); + setExtensionWallpaperPreviewUrls(Object.fromEntries(wallpaperPreviewEntries)); + setExtensionCursorStyles(cursorStyles); + setExtensionCursorPreviewUrls(Object.fromEntries(cursorPreviewEntries)); + }; + + void extensionHost.autoActivateBuiltins().then(updateExtensionAssets); + const unsubscribe = extensionHost.onChange(() => { + void updateExtensionAssets(); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + const update = () => setAvailableFrames(extensionHost.getFrames()); + update(); + return extensionHost.onChange(update); + }, []); + + useEffect(() => { + const update = () => setExtensionPanels(extensionHost.getSettingsPanels()); + update(); + return extensionHost.onChange(update); + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const previewAssets = await import("../../videoPlayback/uploadedCursorAssets"); + const macosPreview = previewAssets.cursorSetAssets.macos.arrow.url; + const tahoePreview = previewAssets.cursorSetAssets.tahoe.arrow.url; + const minimalPreview = await createTrimmedSvgPreview(minimalCursorUrl, 512); + const invertedPreview = await createInvertedPreview(tahoePreview); + + if (!cancelled) { + setBuiltInCursorPreviewUrls({ + macos: macosPreview, + tahoe: tahoePreview, + figma: minimalPreview, + "tahoe-inverted": invertedPreview, + }); + } + } catch { + if (!cancelled) { + setBuiltInCursorPreviewUrls({ + macos: tahoeCursorUrl, + tahoe: tahoeCursorUrl, + figma: minimalCursorUrl, + "tahoe-inverted": tahoeCursorUrl, + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [createInvertedPreview, createTrimmedSvgPreview, minimalCursorUrl, tahoeCursorUrl]); + + useEffect(() => { + setBackgroundTab(getBackgroundTabForWallpaper(selected, gradients)); + + if (isHexWallpaper(selected)) { + setSelectedColor(selected); + } + + if (gradients.some((gradientValue) => gradientValue === selected)) { + setGradient(selected); + } + }, [selected, gradients]); + + useEffect(() => { + if (selected.startsWith("data:image")) { + setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); + return; + } + + const isKnownWallpaper = + builtInWallpaperPaths.includes(selected) || + wallpaperPreviewPaths.includes(selected) || + extensionWallpaperPaths.includes(selected); + + if (!isKnownWallpaper && isVideoWallpaperSource(selected)) { + setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); + } + }, [ + builtInWallpaperPaths, + extensionWallpaperPaths, + selected, + wallpaperPreviewPaths, + ]); + + useEffect(() => { + saveEditorPreferences({ customWallpapers: customImages }); + }, [customImages, saveEditorPreferences]); + + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + const validTypes = ["image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error(tSettings("background.uploadError"), { + description: tSettings("background.uploadErrorDescription"), + }); + event.target.value = ""; + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + setCustomImages((prev) => [...prev, dataUrl]); + onWallpaperChange(dataUrl); + toast.success(tSettings("background.uploadSuccess")); + } + }; + + reader.onerror = () => { + toast.error(t("common.errors.failedToUploadImage"), { + description: t("common.errors.fileReadError"), + }); + }; + + reader.readAsDataURL(file); + event.target.value = ""; + }; + + const handleVideoUpload = async () => { + try { + const result = await window.electronAPI.openVideoFilePicker(); + if (!result?.success || !result.path) return; + const filePath = result.path; + if (!isVideoWallpaperSource(filePath)) { + toast.error("Unsupported format", { + description: "Please select a video file (mp4, webm, mov, etc.)", + }); + return; + } + setCustomImages((prev) => [filePath, ...prev]); + onWallpaperChange(filePath); + toast.success("Video background added"); + } catch { + toast.error("Failed to import video background"); + } + }; + + const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + event.stopPropagation(); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + if (selected === imageUrl) { + onWallpaperChange( + builtInWallpaperPaths[0] ?? + extensionWallpaperPaths[0] ?? + BUILT_IN_WALLPAPERS[0]?.publicPath ?? + "", + ); + } + }; + + return { + initialEditorPreferences, + customImages, + fileInputRef, + customColorInputRef, + builtInWallpaperPaths, + extensionWallpaperPaths, + backgroundTab, + setBackgroundTab, + selectedColor, + setSelectedColor, + gradient, + setGradient, + availableFrames, + extensionPanels, + cursorPreviewUrls, + cursorStyleOptions, + imageWallpaperTiles, + videoWallpaperTiles, + handleImageUpload, + handleVideoUpload, + handleRemoveCustomImage, + }; +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 2ecee76f8..233137a7c 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -15,3 +15,15 @@ export const I18N_NAMESPACES = [ export type AppLocale = (typeof SUPPORTED_LOCALES)[number]; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; + +export const APP_LANGUAGE_LABELS: Record = { + en: "English", + es: "Español", + fr: "Français", + it: "Italiano", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +}; From d97087a000daa4a56f7f06e39eb464471de50d68 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 14:28:14 +0200 Subject: [PATCH 2/9] add settings panel seperated --- src/components/video-editor/SettingsPanel.tsx | 2090 ++--------------- .../settings/sections/AudioSection.tsx | 32 + .../settings/sections/BackgroundSection.tsx | 288 +++ .../settings/sections/CaptionsSection.tsx | 70 + .../settings/sections/ClipSection.tsx | 58 + .../settings/sections/CropSection.tsx | 48 + .../settings/sections/CursorSection.tsx | 35 + .../settings/sections/FrameSection.tsx | 78 + .../sections/GeneralSettingsSection.tsx | 434 ++++ .../settings/sections/WebcamSection.tsx | 29 + .../settings/sections/ZoomSection.tsx | 56 + 11 files changed, 1338 insertions(+), 1880 deletions(-) create mode 100644 src/components/video-editor/settings/sections/AudioSection.tsx create mode 100644 src/components/video-editor/settings/sections/BackgroundSection.tsx create mode 100644 src/components/video-editor/settings/sections/CaptionsSection.tsx create mode 100644 src/components/video-editor/settings/sections/ClipSection.tsx create mode 100644 src/components/video-editor/settings/sections/CropSection.tsx create mode 100644 src/components/video-editor/settings/sections/CursorSection.tsx create mode 100644 src/components/video-editor/settings/sections/FrameSection.tsx create mode 100644 src/components/video-editor/settings/sections/GeneralSettingsSection.tsx create mode 100644 src/components/video-editor/settings/sections/WebcamSection.tsx create mode 100644 src/components/video-editor/settings/sections/ZoomSection.tsx diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 066db70ea..e5c72c11c 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3,27 +3,13 @@ import { Palette, PresentationChart, Trash as Trash2, - UploadSimple as Upload, - X, } from "@phosphor-icons/react"; -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useTheme } from "@/contexts/ThemeContext"; import { getRenderableVideoUrl } from "@/lib/assetPath"; -import { - TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, - TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, -} from "@/lib/exporter/temporalMotionBlur"; import type { ExtensionSettingField } from "@/lib/extensions"; import { extensionHost } from "@/lib/extensions"; import { cn } from "@/lib/utils"; @@ -31,8 +17,6 @@ import { BUILT_IN_WALLPAPERS, isVideoWallpaperSource } from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; -import type { AppLocale } from "../../i18n/config"; -import { APP_LANGUAGE_LABELS, SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CURSOR_MOTION_PRESETS, @@ -41,11 +25,9 @@ import { } from "./cursorMotionPresets"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { SliderControl } from "./SliderControl"; -import { KeyboardShortcutsDialog } from "./TutorialHelp"; import type { AnnotationRegion, AnnotationType, - AutoCaptionAnimation, AutoCaptionSettings, CaptionCue, CropRegion, @@ -63,26 +45,18 @@ import type { import { DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION, - DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, DEFAULT_PADDING, - DEFAULT_WEBCAM_CORNER_RADIUS, - DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_POSITION_PRESET, DEFAULT_WEBCAM_POSITION_X, DEFAULT_WEBCAM_POSITION_Y, - DEFAULT_WEBCAM_REACT_TO_ZOOM, - DEFAULT_WEBCAM_SHADOW, - DEFAULT_WEBCAM_SIZE, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_MOTION_BLUR_TUNING, DEFAULT_ZOOM_OUT_DURATION_MS, } from "./types"; -import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; import { isZeroPadding } from "./videoPlayback/layoutUtils"; import { cursorSetAssets, @@ -98,13 +72,20 @@ import { BUILTIN_CURSOR_PREVIEW_FRAME_SIZE, BUILTIN_CURSOR_PREVIEW_SIZE, BUILTIN_CURSOR_STYLE_OPTIONS, - CAPTION_ANIMATION_OPTIONS, - CAPTION_LANGUAGE_OPTIONS, GRADIENTS, - WEBCAM_POSITION_PRESETS, - ZOOM_DEPTH_OPTIONS, } from "./settings/constants"; import { useSettingsPanel } from "./settings/hooks/useSettingsPanel"; +import { AudioSection } from "./settings/sections/AudioSection"; +import { BackgroundSection } from "./settings/sections/BackgroundSection"; +import { CaptionsSection } from "./settings/sections/CaptionsSection"; +import { ClipSection } from "./settings/sections/ClipSection"; +import { CropSection } from "./settings/sections/CropSection"; +import { CursorSection } from "./settings/sections/CursorSection"; +import { FrameSection } from "./settings/sections/FrameSection"; +import { GeneralSettingsSection } from "./settings/sections/GeneralSettingsSection"; +import { WebcamSection } from "./settings/sections/WebcamSection"; +import { ZoomSection } from "./settings/sections/ZoomSection"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; @@ -894,12 +875,6 @@ export function SettingsPanel({ tahoeCursorUrl, }); const captionCueCount = autoCaptions.length; - const updateAutoCaptionSettings = (partial: Partial) => { - onAutoCaptionSettingsChange?.({ - ...autoCaptionSettings, - ...partial, - }); - }; const colorPalette = [ "#FF0000", "#FFD700", @@ -1125,7 +1100,7 @@ export function SettingsPanel({ (preferredWallpaper && customImages.includes(preferredWallpaper)) || (preferredWallpaper && isHexWallpaper(preferredWallpaper)) || (preferredWallpaper && - GRADIENTS.some((gradientValue) => gradientValue === preferredWallpaper)); + GRADIENTS.some((gradientValue: string) => gradientValue === preferredWallpaper)); onWallpaperChange( (hasPreferredWallpaper ? preferredWallpaper : "") || @@ -1263,291 +1238,35 @@ export function SettingsPanel({ : null; const backgroundSettingsContent = ( -

-
-
- {tSettings("background.title")} - -
- onBackgroundBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(1)}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> -
- -
- -
- {( - [ - { value: "image", label: tSettings("background.image") }, - { value: "video", label: tSettings("background.video", "Video") }, - { value: "color", label: tSettings("background.color") }, - { value: "gradient", label: tSettings("background.gradient") }, - ] as const - ).map((option) => { - const isActive = backgroundTab === option.value; - return ( - - ); - })} -
-
- -
- - - {backgroundTab === "image" ? ( -
- - - -
- {customImages.map((imageUrl, idx) => { - const isSelected = getWallpaperTileState(imageUrl); - return renderWallpaperImageTile(imageUrl, isSelected, { - key: `custom-${idx}`, - ariaLabel: isVideoWallpaperSource(imageUrl) - ? (imageUrl.split(/[\\/]/).pop() ?? - tSettings( - "background.video", - "Video background", - )) - : undefined, - title: isVideoWallpaperSource(imageUrl) - ? imageUrl.split(/[\\/]/).pop() - : undefined, - onClick: () => onWallpaperChange(imageUrl), - children: ( - - ), - }); - })} - - {imageWallpaperTiles.map((tile) => { - const isSelected = getWallpaperTileState( - tile.value, - tile.previewUrl, - ); - return renderWallpaperImageTile( - tile.previewUrl, - isSelected, - { - key: tile.key, - ariaLabel: tile.label, - title: tile.label, - onClick: () => onWallpaperChange(tile.value), - }, - ); - })} -
-
- ) : backgroundTab === "video" ? ( -
- - -
- {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), - }, - ); - })} -
-
- ) : backgroundTab === "color" ? ( -
- { - setSelectedColor(event.target.value); - onWallpaperChange(event.target.value); - }} - className="sr-only" - /> -
- {visibleColorPalette.map((color) => { - const isSelected = - selected.toLowerCase() === color.toLowerCase(); - return ( - -
-
- ) : ( -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - > -
-
- ))} -
- )} - - -
-
-
+ ); // If an annotation is selected, show annotation settings instead @@ -1609,873 +1328,97 @@ export function SettingsPanel({ } const frameSectionContent = ( -
-
- {tSettings("sections.frame", "Frame")} - -
-
- onShadowChange?.(v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} - /> - onBorderRadiusChange?.(v)} - formatValue={(v) => `${v}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> -
-
- {tSettings("effects.padding")} - -
- - {padding.linked !== false ? ( - handlePaddingSideChange("top", v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - ) : ( -
- handlePaddingSideChange("top", v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - handlePaddingSideChange("bottom", v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - handlePaddingSideChange("left", v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - handlePaddingSideChange("right", v)} - formatValue={(v) => `${v}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> -
- )} -
-
- - {tSettings("effects.removeBackground")} - - -
- {/* Frame Picker */} - {availableFrames.length > 0 && ( -
-
- Frame - {frame && ( - - )} -
-
- {availableFrames.map((f) => { - const isSelected = frame === f.id; - return ( - - ); - })} -
-
- )} -
-
+ ); const cropSectionContent = ( -
-
- {tSettings("sections.crop", "Crop")} - {isCropped ? ( - - ) : null} -
-
- setCropInset("top", v)} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - setCropInset("bottom", v)} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - setCropInset("left", v)} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - setCropInset("right", v)} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> -
-
+ ); const captionsSectionContent = ( -
-
-
- {tSettings("sections.captions", "Captions")} - -
-
- {tSettings("captions.enabled", "Show")} - updateAutoCaptionSettings({ enabled })} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
- -
-
- -
-
-
- {tSettings("captions.language", "Language")} -
- -
-
-
- {whisperModelDownloadStatus === "downloading" ? ( - - ) : whisperModelPath ? ( - - ) : ( - - )} - -
-
-
- - {isGeneratingCaptions ? ( -
-
- {tSettings( - "captions.generatingStatus", - "Generating captions. This can take a moment.", - )} -
-
-
- ) : null} -
- {whisperModelDownloadStatus === "downloading" ? ( -
-
-
- ) : null} -
- -
-
-
- {tSettings("captions.animation", "Animation")} -
- -
- -
- {tSettings("captions.fontSettings", "Font Settings")} -
- updateAutoCaptionSettings({ fontSize: value })} - formatValue={(value) => `${Math.round(value)}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> - updateAutoCaptionSettings({ maxRows: Math.round(value) })} - formatValue={(value) => `${Math.round(value)}`} - parseInput={(text) => parseFloat(text)} - /> - updateAutoCaptionSettings({ bottomOffset: value })} - formatValue={(value) => `${Math.round(value)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - updateAutoCaptionSettings({ maxWidth: value })} - formatValue={(value) => `${Math.round(value)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - updateAutoCaptionSettings({ boxRadius: value })} - formatValue={(value) => - `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}px` - } - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> - updateAutoCaptionSettings({ backgroundOpacity: value })} - formatValue={(value) => `${Math.round(value * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} - /> - {renderExtensionPanelsForSections("captions")} -
-
+ renderExtensionPanelsForSections("captions")} + /> ); const effectSectionContent = (() => { const settingsSectionContent = ( -
-
- {t("editor.theme.appearance", "Appearance")} -
- {( - [ - { value: "light", label: t("editor.theme.light", "Light") }, - { value: "dark", label: t("editor.theme.dark", "Dark") }, - { value: "system", label: t("editor.theme.system", "System") }, - ] as const - ).map((option) => ( - - ))} -
-
- -
- {t("common.app.language", "Language")} - -
- -
-
-
-
- {tSettings( - "effects.autoApplyFreshRecordingZooms", - "Auto-apply fresh recording zooms", - )} -
-
- {tSettings( - "effects.autoApplyFreshRecordingZoomsDescription", - "Suggest cursor-follow zooms automatically when you open a new recording.", - )} -
-
- -
-
-
-
- {tSettings("effects.connectZooms", "Connect neighboring zooms")} -
-
- {tSettings( - "effects.connectZoomsDescription", - "Smooth consecutive zoom regions into a continuous camera move.", - )} -
-
- -
-
- -
- -
- -
- {t("editor.keyboardShortcuts.title")} - -
- - {showDevMotionControls ? ( -
-
-
- - {tSettings("effects.devSection", "Dev")} - -
- {tSettings( - "effects.devSectionHint", - "Temporary testing controls for native capture and motion tuning.", - )} -
-
- - DEV - -
- -
-
-
-
- {tSettings( - "effects.nativeCaptureWarningTester", - "Native capture warning", - )} -
-
- {nativeCaptureUnavailableSession - ? tSettings( - "effects.nativeCaptureWarningTesterUnavailable", - "This project is currently marked as native capture unavailable.", - ) - : tSettings( - "effects.nativeCaptureWarningTesterAvailable", - "This project is not marked as unsupported, but you can still open the modal for UI testing.", - )} -
-
- -
-
- -
-
-
- {tSettings("effects.motionBlurDebug", "Motion Blur Debug")} -
-
- {tSettings( - "effects.motionBlurDebugHint", - "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.", - )} -
-
- - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - panVelocityThreshold: value, - }) - } - formatValue={(value) => `${Math.round(value)} px/s`} - parseInput={(text) => - parseFloat(text.replace(/px\/s$/i, "").trim()) - } - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxDirectionalBlurPx: value, - }) - } - formatValue={(value) => `${value.toFixed(1)} px`} - parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())} - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - zoomVelocityThreshold: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxRadialBlurStrength: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> -
- -
-
-
- {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")} -
-
- {tSettings( - "effects.cameraDebugTuningHint", - "Development-only spring tuning controls for camera motion.", - )} -
-
- - onCameraSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCameraSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCameraSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> -
- -
-
-
- {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")} -
-
- {tSettings( - "effects.cursorDebugTuningHint", - "Development-only spring tuning controls.", - )} -
-
- - onCursorSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> -
-
- ) : null} -
+ ); const sceneSectionContent = ( @@ -2488,331 +1431,53 @@ export function SettingsPanel({ ); const zoomItemSectionContent = ( -
- {selectedZoomId && ( - <> -
- {tSettings("sections.zoom", "Zoom")} - {selectedZoomDepth && ( - - { - ZOOM_DEPTH_OPTIONS.find( - (o) => o.depth === selectedZoomDepth, - )?.label - } - - )} -
-
-
- - -
-

- {selectedZoomMode === "manual" - ? tSettings( - "zoom.modeManualDescription", - "Set a fixed focus point for this zoom", - ) - : tSettings( - "zoom.modeAutoDescription", - "Camera recenters when the cursor nears the edge of the zoomed view", - )} -

-
-
- {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 && ( -
- {tSettings( - "effects.motionPresetsZoomHint", - "Zoom motion presets are available in Settings.", - )} -
- )} -
-
- {showDevMotionControls - ? tSettings( - "effects.exportBlurMovedToDev", - "Export blur tuning is available in Settings > Dev.", - ) - : tSettings( - "effects.exportBlurLocked", - "Export blur is fixed for this build.", - )} -
-
- {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`} -
-
- {selectedZoomId && ( - - )} - {renderExtensionPanelsForSections("zoom", "appearance", "frame", "crop")} -
+ + renderExtensionPanelsForSections("zoom", "appearance", "frame", "crop") + } + /> ); const audioSectionContent = ( -
-
- {tSettings("audio.volumeTitle", "Audio")} - -
- onAudioVolumeChange?.(v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} - /> -
- - {tSettings("audio.normalize", "Normalize")} - - onAudioNormalizeChange?.(v)} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
+ ); const clipSectionContent = ( -
-
- {tSettings("clip.title", "Clip")} - {selectedClipSpeed != null && selectedClipSpeed !== 1 && ( - - {selectedClipSpeed}× - - )} -
- -
- {tSettings("speed.label", "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 ( - - ); - })} -
- -
- {tSettings("audio.title", "Audio")} - -
-
- - {tSettings("clip.mute", "Mute")} - -

- {selectedClipMuted - ? tSettings("clip.mutedState", "Audio is muted") - : tSettings("clip.unmutedState", "Audio is playing")} -

-
- onClipMutedChange?.(v)} - className="data-[state=checked]:bg-[#06b6d4] scale-75" - /> -
- {hasClipSourceAudio && ( -
- - {tSettings("clip.separateClipFromAudio", "Separate clip from audio")} - - onClipShowSourceAudioChange?.(v)} - className="data-[state=checked]:bg-[#06b6d4] scale-75" - /> -
- )} -
- - {selectedClipId && - hasClipSourceAudio && - sourceAudioTrackMeta.length > 0 && ( -
- {sourceAudioTrackMeta.map((track) => { - const settings = sourceAudioTrackSettings[track.id] ?? { - volume: 1, - normalize: false, - }; - return ( -
-
- - {track.label} - - -
-
- - {tSettings("audio.normalize", "Normalize")} - - - onSourceAudioTrackNormalizeChange?.(track.id, v) - } - className="data-[state=checked]:bg-[#06b6d4] scale-75" - /> -
- onSourceAudioTrackVolumeChange?.(track.id, v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => - parseFloat(text.replace(/%$/, "")) / 100 - } - /> -
- ); - })} -
- )} -
+ ); switch (activeEffectSection) { @@ -2834,390 +1499,55 @@ export function SettingsPanel({ return captionsSectionContent; case "cursor": return ( -
-
-
- - {tSettings("sections.cursor", "Cursor")} - - -
-
- - -
-
-
-
- { - if (value) { - onCursorStyleChange?.(value as CursorStyle); - } - }} - className="grid grid-cols-4 gap-2" - aria-label={tSettings("effects.cursorStyle", "Cursor Style")} - > - {cursorStyleOptions.map((option) => ( - -
-
- -
-
-
- ))} -
-
- onCursorSizeChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorClickBounceChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorClickBounceDurationChange?.(v)} - formatValue={(v) => `${Math.round(v)} ms`} - parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} - /> - onCursorSwayChange?.(fromCursorSwaySliderValue(v))} - formatValue={(v) => - v <= 0 ? tSettings("effects.off") : `${v.toFixed(2)}×` - } - parseInput={(text) => { - const normalized = text.trim().toLowerCase(); - if (normalized === "off") return 0; - return parseFloat(text.replace(/×$/, "")); - }} - /> - {showDevMotionControls ? ( -
-
- {tSettings( - "effects.cursorDebugMovedToDev", - "Cursor spring tuning is available in Settings > Dev.", - )} -
-
- ) : null} -
- {renderExtensionPanelsForSections("cursor")} -
+ renderExtensionPanelsForSections("cursor")} + /> ); case "webcam": return ( -
-
- {tSettings("sections.webcam", "Webcam")} - -
-
-
- - {tSettings("effects.show", "Show")} - - updateWebcam({ enabled })} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
- - {tSettings("effects.webcamReactToZoom")} - - updateWebcam({ reactToZoom })} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
- - {tSettings("effects.webcamMirror", "Mirror webcam")} - - updateWebcam({ mirror })} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
- updateWebcam({ size: v })} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> -
-
-
- {tSettings("effects.webcamCrop", "Crop")} -
- -
- updateWebcam({ cropRegion })} - /> -
-
-
- {tSettings("effects.webcamPosition", "Position")} -
-
- {WEBCAM_POSITION_PRESETS.map((option) => { - const isActive = webcamPositionPreset === option.preset; - return ( - - ); - })} -
-
- - {tSettings( - "effects.webcamCustomPosition", - "Custom position", - )} - - - applyWebcamPositionPreset( - checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET, - ) - } - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
- {webcamPositionPreset === "custom" ? ( - <> - - updateWebcam({ - positionPreset: "custom", - positionX: v / 100, - }) - } - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - - updateWebcam({ - positionPreset: "custom", - positionY: v / 100, - }) - } - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> - - ) : null} - updateWebcam({ margin: v })} - formatValue={(v) => `${Math.round(v)}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> - updateWebcam({ cornerRadius: v })} - formatValue={(v) => `${Math.round(v)}px`} - parseInput={(text) => parseFloat(text.replace(/px$/, ""))} - /> - updateWebcam({ shadow: v })} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} - /> -
-
-
-
- {tSettings("effects.webcamFootage")} -
-
- {webcamFileName ?? - tSettings("effects.webcamFootageDescription")} -
-
-
- - {webcam?.sourcePath ? ( - - ) : null} -
-
-
- {renderExtensionPanelsForSections("webcam")} -
-
+ renderExtensionPanelsForSections("webcam")} + /> ); default: { // Handle extension-contributed standalone section pages (ext:extensionId/panelId) diff --git a/src/components/video-editor/settings/sections/AudioSection.tsx b/src/components/video-editor/settings/sections/AudioSection.tsx new file mode 100644 index 000000000..de3c15813 --- /dev/null +++ b/src/components/video-editor/settings/sections/AudioSection.tsx @@ -0,0 +1,32 @@ +import { Switch } from "@/components/ui/switch"; +import { SliderControl } from "../../SliderControl"; + +export function AudioSection({ + tSettings, + t, + selectedAudioVolume, + selectedAudioNormalize, + onAudioVolumeChange, + onAudioNormalizeChange, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + selectedAudioVolume?: number | null; + selectedAudioNormalize?: boolean | null; + onAudioVolumeChange?: (v: number) => void; + onAudioNormalizeChange?: (v: boolean) => void; +}) { + return ( +
+
+

{tSettings("audio.volumeTitle", "Audio")}

+ +
+ onAudioVolumeChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} /> +
+ {tSettings("audio.normalize", "Normalize")} + onAudioNormalizeChange?.(v)} className="data-[state=checked]:bg-[#2563EB] scale-75" /> +
+
+ ); +} diff --git a/src/components/video-editor/settings/sections/BackgroundSection.tsx b/src/components/video-editor/settings/sections/BackgroundSection.tsx new file mode 100644 index 000000000..a6fa35152 --- /dev/null +++ b/src/components/video-editor/settings/sections/BackgroundSection.tsx @@ -0,0 +1,288 @@ +import { UploadSimple as Upload, X } from "@phosphor-icons/react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { isVideoWallpaperSource } from "@/lib/wallpapers"; +import { SliderControl } from "../../SliderControl"; +import { GRADIENTS } from "../constants"; + +export function BackgroundSection(props: any) { + const { + tSettings, + t, + resetBackgroundSection, + backgroundBlur, + defaultBackgroundBlur, + onBackgroundBlurChange, + backgroundTab, + setBackgroundTab, + fileInputRef, + handleImageUpload, + customImages, + getWallpaperTileState, + renderWallpaperImageTile, + onWallpaperChange, + handleRemoveCustomImage, + imageWallpaperTiles, + videoWallpaperTiles, + handleVideoUpload, + customColorInputRef, + selectedColor, + setSelectedColor, + selected, + visibleColorPalette, + wallpaperTileClass, + isHexWallpaper, + gradient, + setGradient, + } = props; + + return ( +
+
+
+

+ {tSettings("background.title")} +

+ +
+ onBackgroundBlurChange?.(value)} + formatValue={(value) => `${value.toFixed(1)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> +
+ +
+ +
+ {[ + { value: "image", label: tSettings("background.image") }, + { value: "video", label: tSettings("background.video", "Video") }, + { value: "color", label: tSettings("background.color") }, + { value: "gradient", label: tSettings("background.gradient") }, + ].map((option) => { + const isActive = backgroundTab === option.value; + return ( + + ); + })} +
+
+ +
+ + + {backgroundTab === "image" ? ( +
+ + +
+ {customImages.map((imageUrl: string, idx: number) => { + const isSelected = getWallpaperTileState(imageUrl); + return renderWallpaperImageTile(imageUrl, isSelected, { + key: `custom-${idx}`, + ariaLabel: isVideoWallpaperSource(imageUrl) + ? (imageUrl.split(/[\\/]/).pop() ?? + tSettings("background.video", "Video background")) + : undefined, + title: isVideoWallpaperSource(imageUrl) + ? imageUrl.split(/[\\/]/).pop() + : undefined, + onClick: () => onWallpaperChange(imageUrl), + children: ( + + ), + }); + })} + {imageWallpaperTiles.map((tile: any) => + renderWallpaperImageTile( + tile.previewUrl, + getWallpaperTileState(tile.value, tile.previewUrl), + { + key: tile.key, + ariaLabel: tile.label, + title: tile.label, + onClick: () => onWallpaperChange(tile.value), + }, + ), + )} +
+
+ ) : backgroundTab === "video" ? ( +
+ +
+ {customImages.filter(isVideoWallpaperSource).map((videoUrl: string, idx: number) => + renderWallpaperImageTile(videoUrl, getWallpaperTileState(videoUrl), { + key: `custom-video-${idx}`, + ariaLabel: videoUrl.split(/[\\/]/).pop() ?? "Video background", + title: videoUrl.split(/[\\/]/).pop(), + onClick: () => onWallpaperChange(videoUrl), + children: ( + + ), + }), + )} + {videoWallpaperTiles.map((tile: any) => + renderWallpaperImageTile( + tile.previewUrl, + getWallpaperTileState(tile.value, tile.previewUrl), + { + key: tile.key, + ariaLabel: tile.label, + title: tile.label, + onClick: () => onWallpaperChange(tile.value), + }, + ), + )} +
+
+ ) : backgroundTab === "color" ? ( +
+ { + setSelectedColor(event.target.value); + onWallpaperChange(event.target.value); + }} + className="sr-only" + /> +
+ {visibleColorPalette.map((color: string) => ( + +
+
+ ) : ( +
+ {GRADIENTS.map((candidate, idx) => ( +
{ + setGradient(candidate); + onWallpaperChange(candidate); + }} + role="button" + > +
+
+ ))} +
+ )} + + +
+
+
+ ); +} diff --git a/src/components/video-editor/settings/sections/CaptionsSection.tsx b/src/components/video-editor/settings/sections/CaptionsSection.tsx new file mode 100644 index 000000000..3208d93ae --- /dev/null +++ b/src/components/video-editor/settings/sections/CaptionsSection.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { CAPTION_ANIMATION_OPTIONS, CAPTION_LANGUAGE_OPTIONS } from "../constants"; +import { SliderControl } from "../../SliderControl"; +import type { AutoCaptionAnimation, AutoCaptionSettings } from "../../types"; + +export function CaptionsSection({ + tSettings, + t, + autoCaptionSettings, + defaultAutoCaptionSettings, + onAutoCaptionSettingsChange, + onPickWhisperModel, + onGenerateAutoCaptions, + onClearAutoCaptions, + onDownloadWhisperSmallModel, + onDeleteWhisperSmallModel, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + captionCueCount, + renderExtensionPanels, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + autoCaptionSettings: AutoCaptionSettings; + defaultAutoCaptionSettings: AutoCaptionSettings; + onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void; + onPickWhisperModel?: () => void; + onGenerateAutoCaptions?: () => void; + onClearAutoCaptions?: () => void; + onDownloadWhisperSmallModel?: () => void; + onDeleteWhisperSmallModel?: () => void; + whisperModelPath?: string | null; + whisperModelDownloadStatus: "idle" | "downloading" | "downloaded" | "error"; + whisperModelDownloadProgress: number; + isGeneratingCaptions: boolean; + captionCueCount: number; + renderExtensionPanels: () => React.ReactNode; +}) { + const update = (partial: Partial) => onAutoCaptionSettingsChange?.({ ...autoCaptionSettings, ...partial }); + return ( +
+
+
+

{tSettings("sections.captions", "Captions")}

+ +
+
{tSettings("captions.enabled", "Show")} update({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+
+
+
+
{tSettings("captions.language", "Language")}
+
{whisperModelDownloadStatus === "downloading" ? : whisperModelPath ? : }
+ +
+
+
{tSettings("captions.animation", "Animation")}
+ + update({ fontSize: value })} formatValue={(value) => `${Math.round(value)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> + update({ maxRows: Math.round(value) })} formatValue={(value) => `${Math.round(value)}`} parseInput={(text) => parseFloat(text)} /> + update({ bottomOffset: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + update({ maxWidth: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
+ {renderExtensionPanels()} +
+ ); +} diff --git a/src/components/video-editor/settings/sections/ClipSection.tsx b/src/components/video-editor/settings/sections/ClipSection.tsx new file mode 100644 index 000000000..35fd9ed5b --- /dev/null +++ b/src/components/video-editor/settings/sections/ClipSection.tsx @@ -0,0 +1,58 @@ +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { SliderControl } from "../../SliderControl"; + +export function ClipSection({ + tSettings, + t, + selectedClipId, + selectedClipSpeed, + selectedClipMuted, + selectedClipShowSourceAudio, + hasClipSourceAudio, + onClipSpeedChange, + onClipMutedChange, + onClipShowSourceAudioChange, + sourceAudioTrackMeta, + sourceAudioTrackSettings, + onSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + selectedClipId?: string | null; + selectedClipSpeed?: number | null; + selectedClipMuted?: boolean | null; + selectedClipShowSourceAudio?: boolean | null; + hasClipSourceAudio?: boolean; + onClipSpeedChange?: (speed: number) => void; + onClipMutedChange?: (muted: boolean) => void; + onClipShowSourceAudioChange?: (show: boolean) => void; + sourceAudioTrackMeta: Array<{ id: string; label: string }>; + sourceAudioTrackSettings: Record; + onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void; + onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void; +}) { + return ( +
+
+

{tSettings("clip.title", "Clip")}

+ {selectedClipSpeed != null && selectedClipSpeed !== 1 && {selectedClipSpeed}×} +
+

{tSettings("speed.label", "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 ; + })} +
+
+

{tSettings("audio.title", "Audio")}

+
{tSettings("clip.mute", "Mute")}

{selectedClipMuted ? tSettings("clip.mutedState", "Audio is muted") : tSettings("clip.unmutedState", "Audio is playing")}

onClipMutedChange?.(v)} className="data-[state=checked]:bg-[#06b6d4] scale-75" />
+ {hasClipSourceAudio &&
{tSettings("clip.separateClipFromAudio", "Separate clip from audio")} onClipShowSourceAudioChange?.(v)} className="data-[state=checked]:bg-[#06b6d4] scale-75" />
} +
+ {selectedClipId && hasClipSourceAudio && sourceAudioTrackMeta.length > 0 &&
{sourceAudioTrackMeta.map((track) => { const settings = sourceAudioTrackSettings[track.id] ?? { volume: 1, normalize: false }; return
{track.label}
{tSettings("audio.normalize", "Normalize")} onSourceAudioTrackNormalizeChange?.(track.id, v)} className="data-[state=checked]:bg-[#06b6d4] scale-75" />
onSourceAudioTrackVolumeChange?.(track.id, v)} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} />
; })}
} +
+ ); +} diff --git a/src/components/video-editor/settings/sections/CropSection.tsx b/src/components/video-editor/settings/sections/CropSection.tsx new file mode 100644 index 000000000..6e3ec0757 --- /dev/null +++ b/src/components/video-editor/settings/sections/CropSection.tsx @@ -0,0 +1,48 @@ +import { SliderControl } from "../../SliderControl"; + +export function CropSection({ + tSettings, + t, + isCropped, + resetCropSection, + cropTop, + cropBottom, + cropLeft, + cropRight, + setCropInset, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + isCropped: boolean; + resetCropSection: () => void; + cropTop: number; + cropBottom: number; + cropLeft: number; + cropRight: number; + setCropInset: (side: "top" | "bottom" | "left" | "right", pct: number) => void; +}) { + return ( +
+
+

+ {tSettings("sections.crop", "Crop")} +

+ {isCropped ? ( + + ) : null} +
+
+ setCropInset("top", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + setCropInset("bottom", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + setCropInset("left", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + setCropInset("right", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
+
+ ); +} diff --git a/src/components/video-editor/settings/sections/CursorSection.tsx b/src/components/video-editor/settings/sections/CursorSection.tsx new file mode 100644 index 000000000..eef1d5922 --- /dev/null +++ b/src/components/video-editor/settings/sections/CursorSection.tsx @@ -0,0 +1,35 @@ +import { Switch } from "@/components/ui/switch"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import type { CursorStyle } from "../../types"; +import { DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SWAY } from "../../types"; +import { SliderControl } from "../../SliderControl"; +import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "../../videoPlayback/cursorSway"; + +export function CursorSection(props: any) { + const { tSettings, t, resetCursorSection, showCursor, onShowCursorChange, loopCursor, onLoopCursorChange, cursorStyle, onCursorStyleChange, cursorStyleOptions, cursorPreviewUrls, CursorStylePreview, cursorSize, onCursorSizeChange, cursorMotionBlur, onCursorMotionBlurChange, cursorClickBounce, onCursorClickBounceChange, cursorClickBounceDuration, onCursorClickBounceDurationChange, cursorSway, onCursorSwayChange, showDevMotionControls, renderExtensionPanels } = props; + return ( +
+
+
+

{tSettings("sections.cursor", "Cursor")}

+ +
+
+ + +
+
+
+ value && onCursorStyleChange?.(value as CursorStyle)} className="grid grid-cols-4 gap-2" aria-label={tSettings("effects.cursorStyle", "Cursor Style")}>{cursorStyleOptions.map((option: any) =>
)}
+ onCursorSizeChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorClickBounceChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorClickBounceDurationChange?.(v)} formatValue={(v) => `${Math.round(v)} ms`} parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} /> + onCursorSwayChange?.(fromCursorSwaySliderValue(v))} formatValue={(v) => v <= 0 ? tSettings("effects.off") : `${v.toFixed(2)}×`} parseInput={(text) => { const normalized = text.trim().toLowerCase(); if (normalized === "off") return 0; return parseFloat(text.replace(/×$/, "")); }} /> + {showDevMotionControls ?
{tSettings("effects.cursorDebugMovedToDev", "Cursor spring tuning is available in Settings > Dev.")}
: null} +
+ {renderExtensionPanels?.()} +
+ ); +} diff --git a/src/components/video-editor/settings/sections/FrameSection.tsx b/src/components/video-editor/settings/sections/FrameSection.tsx new file mode 100644 index 000000000..098d2166d --- /dev/null +++ b/src/components/video-editor/settings/sections/FrameSection.tsx @@ -0,0 +1,78 @@ +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { SliderControl } from "../../SliderControl"; +import type { Padding } from "../../types"; + +export function FrameSection({ + tSettings, + t, + resetFrameSection, + shadowIntensity, + borderRadius, + initialShadowIntensity, + initialBorderRadius, + onShadowChange, + onBorderRadiusChange, + padding, + togglePaddingLink, + handlePaddingSideChange, + removeBackgroundEnabled, + handleRemoveBackgroundToggle, + availableFrames, + frame, + onFrameChange, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + resetFrameSection: () => void; + shadowIntensity: number; + borderRadius: number; + initialShadowIntensity: number; + initialBorderRadius: number; + onShadowChange?: (v: number) => void; + onBorderRadiusChange?: (v: number) => void; + padding: Padding; + togglePaddingLink: () => void; + handlePaddingSideChange: (side: keyof Padding, value: number) => void; + removeBackgroundEnabled: boolean; + handleRemoveBackgroundToggle: (checked: boolean) => void; + availableFrames: Array<{ id: string; label: string; thumbnailPath: string }>; + frame?: string | null; + onFrameChange?: (frameId: string | null) => void; +}) { + return ( +
+
+

{tSettings("sections.frame", "Frame")}

+ +
+
+ onShadowChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} /> + onBorderRadiusChange?.(v)} formatValue={(v) => `${v}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> +
+
+

{tSettings("effects.padding")}

+ +
+ {padding.linked !== false ? ( + handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + ) : ( +
+ handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + handlePaddingSideChange("bottom", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + handlePaddingSideChange("left", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + handlePaddingSideChange("right", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
+ )} +
+
{tSettings("effects.removeBackground")}
+ {availableFrames.length > 0 && ( +
+
Frame{frame && }
+
{availableFrames.map((f) => { const isSelected = frame === f.id; return ; })}
+
+ )} +
+
+ ); +} diff --git a/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx new file mode 100644 index 000000000..45c5d1559 --- /dev/null +++ b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx @@ -0,0 +1,434 @@ +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import type { AppLocale } from "@/i18n/config"; +import { APP_LANGUAGE_LABELS, SUPPORTED_LOCALES } from "@/i18n/config"; +import { SliderControl } from "../../SliderControl"; +import { KeyboardShortcutsDialog } from "../../TutorialHelp"; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function GeneralSettingsSection(props: any) { + const { + t, + tSettings, + themePreference, + setThemePreference, + locale, + setLocale, + autoApplyFreshRecordingAutoZooms, + onAutoApplyFreshRecordingAutoZoomsChange, + connectZooms, + onConnectZoomsChange, + MotionPresetCards, + activeMotionPresetId, + applyMotionPreset, + showDevMotionControls, + nativeCaptureUnavailableSession, + onOpenNativeCaptureUnavailableModal, + zoomMotionBlurTuning, + initialEditorPreferences, + onZoomMotionBlurTuningChange, + cameraSpringStiffnessMultiplier, + onCameraSpringStiffnessMultiplierChange, + cameraSpringDampingMultiplier, + onCameraSpringDampingMultiplierChange, + cameraSpringMassMultiplier, + onCameraSpringMassMultiplierChange, + cursorSpringStiffnessMultiplier, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier, + onCursorSpringMassMultiplierChange, + } = props; + + return ( +
+
+ {t("editor.theme.appearance", "Appearance")} +
+ {( + [ + { value: "light", label: t("editor.theme.light", "Light") }, + { value: "dark", label: t("editor.theme.dark", "Dark") }, + { value: "system", label: t("editor.theme.system", "System") }, + ] as const + ).map((option) => ( + + ))} +
+
+ +
+ {t("common.app.language", "Language")} + +
+ +
+
+
+
+ {tSettings( + "effects.autoApplyFreshRecordingZooms", + "Auto-apply fresh recording zooms", + )} +
+
+ {tSettings( + "effects.autoApplyFreshRecordingZoomsDescription", + "Suggest cursor-follow zooms automatically when you open a new recording.", + )} +
+
+ +
+
+
+
+ {tSettings("effects.connectZooms", "Connect neighboring zooms")} +
+
+ {tSettings( + "effects.connectZoomsDescription", + "Smooth consecutive zoom regions into a continuous camera move.", + )} +
+
+ +
+
+ +
+ +
+ +
+ {t("editor.keyboardShortcuts.title")} + +
+ + {showDevMotionControls ? ( +
+
+
+ + {tSettings("effects.devSection", "Dev")} + +
+ {tSettings( + "effects.devSectionHint", + "Temporary testing controls for native capture and motion tuning.", + )} +
+
+ + DEV + +
+ +
+
+
+
+ {tSettings( + "effects.nativeCaptureWarningTester", + "Native capture warning", + )} +
+
+ {nativeCaptureUnavailableSession + ? tSettings( + "effects.nativeCaptureWarningTesterUnavailable", + "This project is currently marked as native capture unavailable.", + ) + : tSettings( + "effects.nativeCaptureWarningTesterAvailable", + "This project is not marked as unsupported, but you can still open the modal for UI testing.", + )} +
+
+ +
+
+ +
+
+
+ {tSettings("effects.motionBlurDebug", "Motion Blur Debug")} +
+
+ {tSettings( + "effects.motionBlurDebugHint", + "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.", + )} +
+
+ + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + panVelocityThreshold: value, + }) + } + formatValue={(value) => `${Math.round(value)} px/s`} + parseInput={(text) => + parseFloat(text.replace(/px\/s$/i, "").trim()) + } + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + maxDirectionalBlurPx: value, + }) + } + formatValue={(value) => `${value.toFixed(1)} px`} + parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())} + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + zoomVelocityThreshold: value, + }) + } + formatValue={(value) => value.toFixed(3)} + parseInput={(text) => parseFloat(text)} + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + maxRadialBlurStrength: value, + }) + } + formatValue={(value) => value.toFixed(3)} + parseInput={(text) => parseFloat(text)} + /> +
+ +
+
+
+ {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")} +
+
+ {tSettings( + "effects.cameraDebugTuningHint", + "Development-only spring tuning controls for camera motion.", + )} +
+
+ + onCameraSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCameraSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCameraSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> +
+ +
+
+
+ {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")} +
+
+ {tSettings( + "effects.cursorDebugTuningHint", + "Development-only spring tuning controls.", + )} +
+
+ + onCursorSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> +
+
+ ) : null} +
+ ); +} diff --git a/src/components/video-editor/settings/sections/WebcamSection.tsx b/src/components/video-editor/settings/sections/WebcamSection.tsx new file mode 100644 index 000000000..158936a77 --- /dev/null +++ b/src/components/video-editor/settings/sections/WebcamSection.tsx @@ -0,0 +1,29 @@ +import { UploadSimple as Upload, Trash as Trash2 } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_CORNER_RADIUS, DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_POSITION_PRESET, DEFAULT_WEBCAM_POSITION_X, DEFAULT_WEBCAM_POSITION_Y, DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE } from "../../types"; +import { SliderControl } from "../../SliderControl"; +import { WEBCAM_POSITION_PRESETS } from "../constants"; + +export function WebcamSection(props: any) { + const { tSettings, t, resetWebcamSection, webcam, updateWebcam, webcamCrop, webcamPreviewSrc, webcamPreviewCurrentTime, webcamPreviewPlaying, WebcamCropControl, webcamPositionPreset, applyWebcamPositionPreset, webcamPositionX, webcamPositionY, webcamFileName, onUploadWebcam, onClearWebcam, renderExtensionPanels } = props; + return ( +
+

{tSettings("sections.webcam", "Webcam")}

+
+
{tSettings("effects.show", "Show")} updateWebcam({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+
{tSettings("effects.webcamReactToZoom")} updateWebcam({ reactToZoom })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+ updateWebcam({ size: v })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
{tSettings("effects.webcamCrop", "Crop")}
updateWebcam({ cropRegion })} />
+
{tSettings("effects.webcamPosition", "Position")}
{WEBCAM_POSITION_PRESETS.map((option) => { const isActive = webcamPositionPreset === option.preset; return ; })}
{tSettings("effects.webcamCustomPosition", "Custom position")} applyWebcamPositionPreset(checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET)} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+ {webcamPositionPreset === "custom" ? <> updateWebcam({ positionPreset: "custom", positionX: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> updateWebcam({ positionPreset: "custom", positionY: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> : null} + updateWebcam({ margin: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> + updateWebcam({ cornerRadius: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> + updateWebcam({ shadow: v })} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} /> +
{tSettings("effects.webcamFootage")}
{webcamFileName ?? tSettings("effects.webcamFootageDescription")}
{webcam?.sourcePath ? : null}
+ {renderExtensionPanels?.()} +
+
+ ); +} diff --git a/src/components/video-editor/settings/sections/ZoomSection.tsx b/src/components/video-editor/settings/sections/ZoomSection.tsx new file mode 100644 index 000000000..d22132f99 --- /dev/null +++ b/src/components/video-editor/settings/sections/ZoomSection.tsx @@ -0,0 +1,56 @@ +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION } from "@/lib/exporter/temporalMotionBlur"; +import { ZOOM_DEPTH_OPTIONS } from "../constants"; + +export function ZoomSection(props: any) { + const { + tSettings, + t, + selectedZoomId, + selectedZoomDepth, + selectedZoomMode, + onZoomModeChange, + onZoomDepthChange, + resetZoomSection, + zoomClassicMode, + onZoomClassicModeChange, + showDevMotionControls, + onZoomDelete, + renderExtensionPanels, + } = props; + return ( +
+ {selectedZoomId && ( + <> +
+

{tSettings("sections.zoom", "Zoom")}

+ {selectedZoomDepth && {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}} +
+
+
+ + +
+
+
+ {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" />
+
{showDevMotionControls ? tSettings("effects.exportBlurMovedToDev", "Export blur tuning is available in Settings > Dev.") : tSettings("effects.exportBlurLocked", "Export blur is fixed for this build.")}
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
+ {selectedZoomId && } + {renderExtensionPanels?.()} +
+ ); +} From c04bcee394e547b80dabe6145af3b82696ae78c1 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 17:16:05 +0200 Subject: [PATCH 3/9] add: setting panel is looking more like it ! 800 lines --- src/components/video-editor/SettingsPanel.tsx | 1030 ++--------------- .../settings/sections/BackgroundSection.tsx | 404 +++++-- .../settings/sections/CaptionsSection.tsx | 208 +++- .../settings/sections/CropSection.tsx | 105 +- .../settings/sections/CursorSection.tsx | 298 ++++- .../sections/ExtensionSettingsSection.tsx | 205 ++++ .../settings/sections/FrameSection.tsx | 282 ++++- .../sections/GeneralSettingsSection.tsx | 893 ++++++++------ .../settings/sections/WebcamSection.tsx | 305 ++++- .../settings/sections/ZoomSection.tsx | 190 ++- .../settings/utils/cursorPreview.ts | 126 ++ 11 files changed, 2492 insertions(+), 1554 deletions(-) create mode 100644 src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx create mode 100644 src/components/video-editor/settings/utils/cursorPreview.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index e5c72c11c..42cdc973a 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,30 +1,34 @@ -import { - CursorClick, - Palette, - PresentationChart, - Trash as Trash2, -} from "@phosphor-icons/react"; +import { Palette, Trash as Trash2 } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useState } from "react"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { useTheme } from "@/contexts/ThemeContext"; -import { getRenderableVideoUrl } from "@/lib/assetPath"; -import type { ExtensionSettingField } from "@/lib/extensions"; -import { extensionHost } from "@/lib/extensions"; import { cn } from "@/lib/utils"; -import { BUILT_IN_WALLPAPERS, isVideoWallpaperSource } from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; -import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { - CURSOR_MOTION_PRESETS, - type CursorMotionPresetId, - getMatchingCursorMotionPresetId, -} from "./cursorMotionPresets"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; -import { SliderControl } from "./SliderControl"; +import { BUILTIN_CURSOR_STYLE_OPTIONS, GRADIENTS } from "./settings/constants"; +import { useSettingsPanel } from "./settings/hooks/useSettingsPanel"; +import { + createInvertedPreview, + createTrimmedSvgPreview, +} from "./settings/utils/cursorPreview"; +import { AudioSection } from "./settings/sections/AudioSection"; +import { BackgroundSection } from "./settings/sections/BackgroundSection"; +import { CaptionsSection } from "./settings/sections/CaptionsSection"; +import { ClipSection } from "./settings/sections/ClipSection"; +import { CropSection } from "./settings/sections/CropSection"; +import { CursorSection } from "./settings/sections/CursorSection"; +import { + ExtensionSettingsSection, + SettingsExtensionPanels, +} from "./settings/sections/ExtensionSettingsSection"; +import { FrameSection } from "./settings/sections/FrameSection"; +import { GeneralSettingsSection } from "./settings/sections/GeneralSettingsSection"; +import { WebcamSection } from "./settings/sections/WebcamSection"; +import { ZoomSection } from "./settings/sections/ZoomSection"; import type { AnnotationRegion, AnnotationType, @@ -36,7 +40,6 @@ import type { FigureData, Padding, WebcamOverlaySettings, - WebcamPositionPreset, ZoomDepth, ZoomMode, ZoomMotionBlurTuning, @@ -44,65 +47,19 @@ import type { } from "./types"; import { DEFAULT_AUTO_CAPTION_SETTINGS, - DEFAULT_CROP_REGION, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, DEFAULT_PADDING, - DEFAULT_WEBCAM_POSITION_PRESET, - DEFAULT_WEBCAM_POSITION_X, - DEFAULT_WEBCAM_POSITION_Y, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_MOTION_BLUR_TUNING, DEFAULT_ZOOM_OUT_DURATION_MS, } from "./types"; -import { isZeroPadding } from "./videoPlayback/layoutUtils"; -import { - cursorSetAssets, - getCursorStyleSizeMultiplier, -} from "./videoPlayback/uploadedCursorAssets"; -import { WebcamCropControl } from "./WebcamCropControl"; -import { - getWebcamPositionForPreset, - normalizeWebcamCropRegion, - resolveWebcamCorner, -} from "./webcamOverlay"; -import { - BUILTIN_CURSOR_PREVIEW_FRAME_SIZE, - BUILTIN_CURSOR_PREVIEW_SIZE, - BUILTIN_CURSOR_STYLE_OPTIONS, - GRADIENTS, -} from "./settings/constants"; -import { useSettingsPanel } from "./settings/hooks/useSettingsPanel"; -import { AudioSection } from "./settings/sections/AudioSection"; -import { BackgroundSection } from "./settings/sections/BackgroundSection"; -import { CaptionsSection } from "./settings/sections/CaptionsSection"; -import { ClipSection } from "./settings/sections/ClipSection"; -import { CropSection } from "./settings/sections/CropSection"; -import { CursorSection } from "./settings/sections/CursorSection"; -import { FrameSection } from "./settings/sections/FrameSection"; -import { GeneralSettingsSection } from "./settings/sections/GeneralSettingsSection"; -import { WebcamSection } from "./settings/sections/WebcamSection"; -import { ZoomSection } from "./settings/sections/ZoomSection"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { cursorSetAssets } from "./videoPlayback/uploadedCursorAssets"; const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; -function getStepPrecision(step: number): number { - if (!Number.isFinite(step) || step <= 0) return 0; - const [mantissa = "0", exponentPart = "0"] = step.toExponential().split("e"); - const exponent = Number.parseInt(exponentPart, 10); - const mantissaDecimals = (mantissa.split(".")[1] ?? "").replace(/0+$/, "").length; - const precision = exponent < 0 ? Math.max(0, -exponent + mantissaDecimals) : mantissaDecimals; - return Math.min(12, precision); -} - - -function isHexWallpaper(value: string): boolean { - return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); -} - function SectionLabel({ children }: { children: React.ReactNode }) { return (

@@ -111,270 +68,6 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -function WallpaperVideoPreview({ src }: { src: string }) { - const [resolvedSrc, setResolvedSrc] = useState(src); - - useEffect(() => { - let cancelled = false; - setResolvedSrc(src); - - void (async () => { - try { - const nextSrc = await getRenderableVideoUrl(src); - if (!cancelled) { - setResolvedSrc(nextSrc); - } - } catch { - if (!cancelled) { - setResolvedSrc(src); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [src]); - - return ( -

-

- {label} -

- {fields.map((field) => { - const value = - extensionHost.getExtensionSetting(extensionId, field.id) ?? field.defaultValue; - - if (field.type === "toggle") { - return ( -
- {field.label} - { - extensionHost.setExtensionSetting( - extensionId, - field.id, - checked, - ); - forceUpdate((n) => n + 1); - }} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
- ); - } - - if (field.type === "slider") { - const step = field.step ?? 0.01; - const precision = getStepPrecision(step); - return ( -
- { - extensionHost.setExtensionSetting(extensionId, field.id, v); - forceUpdate((n) => n + 1); - }} - formatValue={(v) => v.toFixed(precision)} - parseInput={(text) => parseFloat(text)} - /> -
- ); - } - - if (field.type === "select" && field.options) { - return ( -
- - {field.label} - - -
- ); - } - - if (field.type === "color") { - return ( -
- - {field.label} - - { - extensionHost.setExtensionSetting( - extensionId, - field.id, - e.target.value, - ); - forceUpdate((n) => n + 1); - }} - className="w-7 h-5 rounded border border-foreground/10 cursor-pointer bg-transparent" - /> -
- ); - } - - if (field.type === "text") { - return ( -
- - {field.label} - - { - extensionHost.setExtensionSetting( - extensionId, - field.id, - e.target.value, - ); - forceUpdate((n) => n + 1); - }} - className="w-24 h-6 rounded bg-foreground/[0.06] border border-foreground/10 px-1.5 text-[10px] text-foreground" - /> -
- ); - } - - return null; - })} -
- ); -} - -const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"]; - -function MotionPresetCards({ - title, - activePresetId, - onApply, - tSettings, -}: { - title: string; - activePresetId: CursorMotionPresetId | null; - onApply: (presetId: CursorMotionPresetId) => void; - tSettings: (key: string, fallback?: string) => string; -}) { - return ( -
-
{title}
-
- {MOTION_PRESET_ORDER.map((presetId) => { - const Icon = presetId === "focused" ? CursorClick : PresentationChart; - const isActive = activePresetId === presetId; - - return ( - - ); - })} -
-
- ); -} - interface SettingsPanelProps { panelMode?: "editor" | "background"; activeEffectSection?: EditorEffectSection; @@ -513,206 +206,6 @@ interface SettingsPanelProps { onOpenNativeCaptureUnavailableModal?: () => void; } - -function loadPreviewImage(url: string) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error(`Failed to load preview asset: ${url}`)); - image.src = url; - }); -} - -function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) { - const ctx = canvas.getContext("2d"); - if (!ctx) { - return { - dataUrl: canvas.toDataURL("image/png"), - width: canvas.width, - height: canvas.height, - hotspot, - }; - } - - const { width, height } = canvas; - const imageData = ctx.getImageData(0, 0, width, height); - const { data } = imageData; - let minX = width; - let minY = height; - let maxX = -1; - let maxY = -1; - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const alpha = data[(y * width + x) * 4 + 3]; - if (alpha === 0) { - continue; - } - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - } - - if (maxX < minX || maxY < minY) { - return { - dataUrl: canvas.toDataURL("image/png"), - width, - height, - hotspot, - }; - } - - const croppedWidth = maxX - minX + 1; - const croppedHeight = maxY - minY + 1; - const croppedCanvas = document.createElement("canvas"); - croppedCanvas.width = croppedWidth; - croppedCanvas.height = croppedHeight; - const croppedCtx = croppedCanvas.getContext("2d")!; - croppedCtx.drawImage( - canvas, - minX, - minY, - croppedWidth, - croppedHeight, - 0, - 0, - croppedWidth, - croppedHeight, - ); - - return { - dataUrl: croppedCanvas.toDataURL("image/png"), - width: croppedWidth, - height: croppedHeight, - hotspot: hotspot - ? { - x: hotspot.x - minX, - y: hotspot.y - minY, - } - : undefined, - }; -} - -async function createTrimmedSvgPreview( - url: string, - sampleSize: number, - trim?: { x: number; y: number; width: number; height: number }, -) { - const image = await loadPreviewImage(url); - const sourceCanvas = document.createElement("canvas"); - sourceCanvas.width = sampleSize; - sourceCanvas.height = sampleSize; - const sourceCtx = sourceCanvas.getContext("2d")!; - sourceCtx.drawImage(image, 0, 0, sampleSize, sampleSize); - - if (trim) { - const croppedCanvas = document.createElement("canvas"); - croppedCanvas.width = trim.width; - croppedCanvas.height = trim.height; - const croppedCtx = croppedCanvas.getContext("2d")!; - croppedCtx.drawImage( - sourceCanvas, - trim.x, - trim.y, - trim.width, - trim.height, - 0, - 0, - trim.width, - trim.height, - ); - return croppedCanvas.toDataURL("image/png"); - } - - return trimCanvasToAlpha(sourceCanvas).dataUrl; -} - -async function createInvertedPreview(url: string) { - const image = await loadPreviewImage(url); - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const { data } = imageData; - for (let index = 0; index < data.length; index += 4) { - if (data[index + 3] === 0) { - continue; - } - data[index] = 255 - data[index]; - data[index + 1] = 255 - data[index + 1]; - data[index + 2] = 255 - data[index + 2]; - } - ctx.putImageData(imageData, 0, 0); - return canvas.toDataURL("image/png"); -} - -function CursorStylePreview({ - style, - previewUrls, -}: { - style: CursorStyle; - previewUrls: Partial>; -}) { - const previewSrc = - style === "macos" - ? (previewUrls.macos ?? tahoeCursorUrl) - : style === "tahoe" - ? (previewUrls.tahoe ?? tahoeCursorUrl) - : style === "figma" - ? (previewUrls.figma ?? minimalCursorUrl) - : style === "tahoe-inverted" - ? (previewUrls["tahoe-inverted"] ?? tahoeCursorUrl) - : previewUrls[style]; - - if (style === "macos" || style === "tahoe" || style === "tahoe-inverted") { - const previewSize = BUILTIN_CURSOR_PREVIEW_SIZE * getCursorStyleSizeMultiplier(style); - return ( -
- -
- ); - } - - if (style === "figma") { - return ; - } - - if (style === "dot") { - return ( - - ); - } - - return ( - - ); -} - export function SettingsPanel({ panelMode = "editor", activeEffectSection: activeEffectSectionProp, @@ -834,10 +327,6 @@ export function SettingsPanel({ const { locale, setLocale, t } = useI18n(); const { preference: themePreference, setPreference: setThemePreference } = useTheme(); const isBackgroundPanel = panelMode === "background"; - const removeBackgroundStateRef = useRef<{ - aspectRatio: AspectRatio; - padding: Padding; - } | null>(null); const { initialEditorPreferences, customImages, @@ -875,363 +364,10 @@ export function SettingsPanel({ tahoeCursorUrl, }); const captionCueCount = autoCaptions.length; - const colorPalette = [ - "#FF0000", - "#FFD700", - "#00FF00", - "#FFFFFF", - "#0000FF", - "#FF6B00", - "#9B59B6", - "#E91E63", - "#00BCD4", - "#FF5722", - "#8BC34A", - "#FFC107", - "#2563EB", - "#000000", - "#607D8B", - "#795548", - ]; - - const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding); - - const renderExtensionPanelsForSections = (...sections: string[]) => - extensionPanels - .filter((panel) => { - const parentSection = panel.panel.parentSection; - return parentSection ? sections.includes(parentSection) : false; - }) - .map((panel) => ( - - )); - - const defaultWebcam = initialEditorPreferences.webcam; const [internalActiveEffectSection] = useState("scene"); const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; const showDevMotionControls = import.meta.env.DEV; - const handleRemoveBackgroundToggle = (checked: boolean) => { - if (checked) { - removeBackgroundStateRef.current = { - aspectRatio, - padding, - }; - onAspectRatioChange?.("native"); - onPaddingChange?.({ top: 0, bottom: 0, left: 0, right: 0, linked: padding.linked }); - return; - } - - const previousState = removeBackgroundStateRef.current; - if (previousState) { - onAspectRatioChange?.(previousState.aspectRatio); - onPaddingChange?.(previousState.padding); - removeBackgroundStateRef.current = null; - return; - } - - // Fallback if the project loaded in a "background removed" state already - onAspectRatioChange?.(initialEditorPreferences.aspectRatio); - onPaddingChange?.({ ...DEFAULT_PADDING }); - }; - - const togglePaddingLink = () => { - const isLinked = padding.linked !== false; - const nextLinked = !isLinked; - if (nextLinked) { - // Compute average for relinking to avoid sudden shifts - const avg = Math.round( - (padding.top + padding.bottom + padding.left + padding.right) / 4, - ); - onPaddingChange?.({ - top: avg, - bottom: avg, - left: avg, - right: avg, - linked: true, - }); - } else { - onPaddingChange?.({ - ...padding, - linked: false, - }); - } - }; - - const handlePaddingSideChange = (side: keyof Padding, value: number) => { - if (padding.linked !== false) { - onPaddingChange?.({ - top: value, - bottom: value, - left: value, - right: value, - linked: true, - }); - } else { - onPaddingChange?.({ - ...padding, - [side]: value, - }); - } - }; - - const webcamFileName = webcam?.sourcePath?.split(/[\\/]/).pop() ?? null; - const visibleColorPalette = colorPalette.slice(0, 15); - const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; - const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X; - const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y; - const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion); - - const getWallpaperTileState = (candidateValue: string, previewPath?: string) => { - if (!selected) return false; - if (selected === candidateValue || (previewPath && selected === previewPath)) return true; - try { - const clean = (s: string) => s.replace(/^file:\/\//, "").replace(/^\//, ""); - if (clean(selected).endsWith(clean(candidateValue))) return true; - if (clean(candidateValue).endsWith(clean(selected))) return true; - if (previewPath && clean(selected).endsWith(clean(previewPath))) return true; - if (previewPath && clean(previewPath).endsWith(clean(selected))) return true; - } catch { - return false; - } - return false; - }; - - const wallpaperTileClass = (isSelected: boolean) => - cn( - "group relative aspect-square w-full overflow-hidden rounded-[10px] border bg-editor-bg transition-colors duration-150", - isSelected - ? "border-[#2563EB] bg-foreground/[0.08]" - : "border-foreground/10 bg-foreground/[0.045] hover:border-foreground/20 hover:bg-foreground/[0.07]", - ); - - const renderWallpaperImageTile = ( - wallpaperUrl: string, - isSelected: boolean, - props?: { - key?: string; - ariaLabel?: string; - title?: string; - onClick?: () => void; - children?: React.ReactNode; - }, - ) => ( -
-
- {isVideoWallpaperSource(wallpaperUrl) ? ( - - ) : ( - { - )} -
- {props?.children} -
- ); - - const crop = cropRegion ?? { - x: 0, - y: 0, - width: 1, - height: 1, - }; - const cropTop = Math.round(crop.y * 100); - const cropLeft = Math.round(crop.x * 100); - const cropBottom = Math.round((1 - crop.y - crop.height) * 100); - const cropRight = Math.round((1 - crop.x - crop.width) * 100); - const isCropped = cropTop > 0 || cropLeft > 0 || cropBottom > 0 || cropRight > 0; - - const setCropInset = (side: "top" | "bottom" | "left" | "right", pct: number) => { - if (!onCropChange) return; - - const v = pct / 100; - let { x, y, width, height } = crop; - - if (side === "top") { - const nextY = Math.min(v, 1 - y - height + v); - y = nextY; - height = Math.max(0.05, height - (nextY - crop.y)); - } - - if (side === "left") { - const nextX = Math.min(v, 1 - x - width + v); - x = nextX; - width = Math.max(0.05, width - (nextX - crop.x)); - } - - if (side === "bottom") { - height = Math.max(0.05, 1 - crop.y - v); - } - - if (side === "right") { - width = Math.max(0.05, 1 - crop.x - v); - } - - onCropChange({ x, y, width, height }); - }; - - const resetBackgroundSection = () => { - onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur); - - const preferredWallpaper = initialEditorPreferences.wallpaper; - const hasPreferredWallpaper = - (preferredWallpaper && builtInWallpaperPaths.includes(preferredWallpaper)) || - (preferredWallpaper && extensionWallpaperPaths.includes(preferredWallpaper)) || - (preferredWallpaper && customImages.includes(preferredWallpaper)) || - (preferredWallpaper && isHexWallpaper(preferredWallpaper)) || - (preferredWallpaper && - GRADIENTS.some((gradientValue: string) => gradientValue === preferredWallpaper)); - - onWallpaperChange( - (hasPreferredWallpaper ? preferredWallpaper : "") || - builtInWallpaperPaths[0] || - extensionWallpaperPaths[0] || - BUILT_IN_WALLPAPERS[0]?.publicPath || - "", - ); - }; - - const resetZoomSection = () => { - onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning); - onCameraSpringStiffnessMultiplierChange?.( - initialEditorPreferences.cameraSpringStiffnessMultiplier, - ); - onCameraSpringDampingMultiplierChange?.( - initialEditorPreferences.cameraSpringDampingMultiplier, - ); - onCameraSpringMassMultiplierChange?.(initialEditorPreferences.cameraSpringMassMultiplier); - onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs); - onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs); - onZoomClassicModeChange?.(false); - }; - - const resetCursorSection = () => { - onShowCursorChange?.(initialEditorPreferences.showCursor); - onLoopCursorChange?.(initialEditorPreferences.loopCursor); - onCursorStyleChange?.(initialEditorPreferences.cursorStyle); - onCursorSizeChange?.(initialEditorPreferences.cursorSize); - onCursorSmoothingChange?.(initialEditorPreferences.cursorSmoothing); - onCursorSpringStiffnessMultiplierChange?.( - initialEditorPreferences.cursorSpringStiffnessMultiplier, - ); - onCursorSpringDampingMultiplierChange?.( - initialEditorPreferences.cursorSpringDampingMultiplier, - ); - onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); - onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur); - onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce); - onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION); - onCursorSwayChange?.(initialEditorPreferences.cursorSway); - }; - - const activeMotionPresetId = useMemo(() => { - return ( - getMatchingCursorMotionPresetId({ - zoomInDurationMs, - zoomOutDurationMs, - cursorSize, - cursorSmoothing, - cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - cursorMotionBlur, - cursorClickBounce, - cursorClickBounceDuration, - }) ?? "focused" - ); - }, [ - cursorClickBounce, - cursorClickBounceDuration, - cursorMotionBlur, - cursorSize, - cursorSmoothing, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - cursorSpringStiffnessMultiplier, - zoomInDurationMs, - zoomOutDurationMs, - ]); - - const applyMotionPreset = (presetId: CursorMotionPresetId) => { - const preset = CURSOR_MOTION_PRESETS[presetId]; - onZoomInDurationMsChange?.(preset.zoomInDurationMs); - onZoomOutDurationMsChange?.(preset.zoomOutDurationMs); - onCursorSizeChange?.(preset.cursorSize); - onCursorSmoothingChange?.(preset.cursorSmoothing); - onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier); - onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier); - onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier); - onCursorMotionBlurChange?.(preset.cursorMotionBlur); - onCursorClickBounceChange?.(preset.cursorClickBounce); - onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); - }; - - const resetFrameSection = () => { - const preferredFrame = initialEditorPreferences.frame; - const resolvedFrame = preferredFrame - ? availableFrames.some((candidate) => candidate.id === preferredFrame) - ? preferredFrame - : null - : null; - onShadowChange?.(initialEditorPreferences.shadowIntensity); - onBorderRadiusChange?.(initialEditorPreferences.borderRadius); - onAspectRatioChange?.(initialEditorPreferences.aspectRatio); - onPaddingChange?.({ ...initialEditorPreferences.padding }); - onFrameChange?.(resolvedFrame); - removeBackgroundStateRef.current = null; - }; - - const resetWebcamSection = () => { - if (!onWebcamChange) return; - onWebcamChange({ ...defaultWebcam }); - }; - - const resetCropSection = () => { - onCropChange?.(DEFAULT_CROP_REGION); - }; - - const updateWebcam = (patch: Partial) => { - if (!webcam || !onWebcamChange) return; - onWebcamChange({ ...webcam, ...patch }); - }; - - const applyWebcamPositionPreset = (preset: WebcamPositionPreset) => { - if (!webcam) return; - - if (preset === "custom") { - updateWebcam({ positionPreset: "custom" }); - return; - } - - const position = getWebcamPositionForPreset(preset); - updateWebcam({ - positionPreset: preset, - positionX: position.x, - positionY: position.y, - corner: resolveWebcamCorner(preset, webcam.corner), - }); - }; - // Find selected annotation const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) @@ -1241,31 +377,27 @@ export function SettingsPanel({ ); @@ -1331,21 +463,18 @@ export function SettingsPanel({ ); @@ -1353,13 +482,8 @@ export function SettingsPanel({ ); @@ -1380,7 +504,7 @@ export function SettingsPanel({ whisperModelDownloadProgress={whisperModelDownloadProgress} isGeneratingCaptions={isGeneratingCaptions} captionCueCount={captionCueCount} - renderExtensionPanels={() => renderExtensionPanelsForSections("captions")} + extensionPanels={extensionPanels} /> ); @@ -1397,12 +521,23 @@ export function SettingsPanel({ onAutoApplyFreshRecordingAutoZoomsChange={onAutoApplyFreshRecordingAutoZoomsChange} connectZooms={connectZooms} onConnectZoomsChange={onConnectZoomsChange} - MotionPresetCards={MotionPresetCards} - activeMotionPresetId={activeMotionPresetId} - applyMotionPreset={applyMotionPreset} showDevMotionControls={showDevMotionControls} nativeCaptureUnavailableSession={nativeCaptureUnavailableSession} onOpenNativeCaptureUnavailableModal={onOpenNativeCaptureUnavailableModal} + zoomInDurationMs={zoomInDurationMs} + onZoomInDurationMsChange={onZoomInDurationMsChange} + zoomOutDurationMs={zoomOutDurationMs} + onZoomOutDurationMsChange={onZoomOutDurationMsChange} + cursorSize={cursorSize} + onCursorSizeChange={onCursorSizeChange} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={onCursorSmoothingChange} + cursorMotionBlur={cursorMotionBlur} + onCursorMotionBlurChange={onCursorMotionBlurChange} + cursorClickBounce={cursorClickBounce} + onCursorClickBounceChange={onCursorClickBounceChange} + cursorClickBounceDuration={cursorClickBounceDuration} + onCursorClickBounceDurationChange={onCursorClickBounceDurationChange} zoomMotionBlurTuning={zoomMotionBlurTuning} initialEditorPreferences={initialEditorPreferences} onZoomMotionBlurTuningChange={onZoomMotionBlurTuningChange} @@ -1426,7 +561,10 @@ export function SettingsPanel({ {backgroundSettingsContent} {frameSectionContent} {cropSectionContent} - {renderExtensionPanelsForSections("scene", "appearance", "frame", "crop")} +
); @@ -1439,27 +577,31 @@ export function SettingsPanel({ selectedZoomMode={selectedZoomMode} onZoomModeChange={onZoomModeChange} onZoomDepthChange={onZoomDepthChange} - resetZoomSection={resetZoomSection} zoomClassicMode={zoomClassicMode} onZoomClassicModeChange={onZoomClassicModeChange} showDevMotionControls={showDevMotionControls} onZoomDelete={onZoomDelete} - renderExtensionPanels={() => - renderExtensionPanelsForSections("zoom", "appearance", "frame", "crop") - } + initialEditorPreferences={initialEditorPreferences} + onZoomMotionBlurTuningChange={onZoomMotionBlurTuningChange} + onCameraSpringStiffnessMultiplierChange={onCameraSpringStiffnessMultiplierChange} + onCameraSpringDampingMultiplierChange={onCameraSpringDampingMultiplierChange} + onCameraSpringMassMultiplierChange={onCameraSpringMassMultiplierChange} + onZoomInDurationMsChange={onZoomInDurationMsChange} + onZoomOutDurationMsChange={onZoomOutDurationMsChange} + extensionPanels={extensionPanels} /> ); - const audioSectionContent = ( - - ); + const audioSectionContent = ( + + ); const clipSectionContent = ( renderExtensionPanelsForSections("cursor")} + initialEditorPreferences={initialEditorPreferences} + extensionPanels={extensionPanels} /> ); case "webcam": @@ -1531,22 +680,15 @@ export function SettingsPanel({ renderExtensionPanelsForSections("webcam")} + initialEditorPreferences={initialEditorPreferences} + extensionPanels={extensionPanels} /> ); default: { diff --git a/src/components/video-editor/settings/sections/BackgroundSection.tsx b/src/components/video-editor/settings/sections/BackgroundSection.tsx index a6fa35152..3a90d9e79 100644 --- a/src/components/video-editor/settings/sections/BackgroundSection.tsx +++ b/src/components/video-editor/settings/sections/BackgroundSection.tsx @@ -1,41 +1,217 @@ import { UploadSimple as Upload, X } from "@phosphor-icons/react"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { getRenderableVideoUrl } from "@/lib/assetPath"; import { cn } from "@/lib/utils"; -import { isVideoWallpaperSource } from "@/lib/wallpapers"; +import { BUILT_IN_WALLPAPERS, isVideoWallpaperSource } from "@/lib/wallpapers"; +import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; import { GRADIENTS } from "../constants"; +import type { BackgroundTab, WallpaperTile as WallpaperTileData } from "../hooks/useSettingsPanel"; -export function BackgroundSection(props: any) { - const { - tSettings, - t, - resetBackgroundSection, - backgroundBlur, - defaultBackgroundBlur, - onBackgroundBlurChange, - backgroundTab, - setBackgroundTab, - fileInputRef, - handleImageUpload, - customImages, - getWallpaperTileState, - renderWallpaperImageTile, - onWallpaperChange, - handleRemoveCustomImage, - imageWallpaperTiles, - videoWallpaperTiles, - handleVideoUpload, - customColorInputRef, - selectedColor, - setSelectedColor, - selected, - visibleColorPalette, - wallpaperTileClass, - isHexWallpaper, - gradient, - setGradient, - } = props; +const COLOR_PALETTE = [ + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#2563EB", + "#000000", + "#607D8B", +]; + +function isHexWallpaper(value: string): boolean { + return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); +} + +function WallpaperVideoPreview({ src }: { src: string }) { + const [resolvedSrc, setResolvedSrc] = useState(src); + + useEffect(() => { + let cancelled = false; + setResolvedSrc(src); + + void (async () => { + try { + const nextSrc = await getRenderableVideoUrl(src); + if (!cancelled) setResolvedSrc(nextSrc); + } catch { + if (!cancelled) setResolvedSrc(src); + } + })(); + + return () => { + cancelled = true; + }; + }, [src]); + + return ( +
) : backgroundTab === "color" ? (
{ + customColorInputRef.current = node; + }} type="color" value={selectedColor} onChange={(event) => { @@ -222,7 +429,7 @@ export function BackgroundSection(props: any) { className="sr-only" />
- {visibleColorPalette.map((color: string) => ( + {visibleColorPalette.map((color) => (
) : (
- {GRADIENTS.map((candidate, idx) => ( + {GRADIENTS.map((candidate, index) => (
{ setGradient(candidate); onWallpaperChange(candidate); diff --git a/src/components/video-editor/settings/sections/CaptionsSection.tsx b/src/components/video-editor/settings/sections/CaptionsSection.tsx index 3208d93ae..ff5ea5ad8 100644 --- a/src/components/video-editor/settings/sections/CaptionsSection.tsx +++ b/src/components/video-editor/settings/sections/CaptionsSection.tsx @@ -1,9 +1,16 @@ import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { CAPTION_ANIMATION_OPTIONS, CAPTION_LANGUAGE_OPTIONS } from "../constants"; import { SliderControl } from "../../SliderControl"; import type { AutoCaptionAnimation, AutoCaptionSettings } from "../../types"; +import { CAPTION_ANIMATION_OPTIONS, CAPTION_LANGUAGE_OPTIONS } from "../constants"; +import { SettingsExtensionPanels, type SettingsPanelExtension } from "./ExtensionSettingsSection"; export function CaptionsSection({ tSettings, @@ -21,7 +28,7 @@ export function CaptionsSection({ whisperModelDownloadProgress, isGeneratingCaptions, captionCueCount, - renderExtensionPanels, + extensionPanels, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -38,33 +45,196 @@ export function CaptionsSection({ whisperModelDownloadProgress: number; isGeneratingCaptions: boolean; captionCueCount: number; - renderExtensionPanels: () => React.ReactNode; + extensionPanels: SettingsPanelExtension[]; }) { - const update = (partial: Partial) => onAutoCaptionSettingsChange?.({ ...autoCaptionSettings, ...partial }); + const update = (partial: Partial) => + onAutoCaptionSettingsChange?.({ ...autoCaptionSettings, ...partial }); return (
-

{tSettings("sections.captions", "Captions")}

- +

+ {tSettings("sections.captions", "Captions")} +

+ +
+
+ {tSettings("captions.enabled", "Show")} + update({ enabled })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + />
-
{tSettings("captions.enabled", "Show")} update({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
-
-
{tSettings("captions.language", "Language")}
-
{whisperModelDownloadStatus === "downloading" ? : whisperModelPath ? : }
- +
+ +
+
+
+ {tSettings("captions.language", "Language")} +
+ +
+
+ {whisperModelDownloadStatus === "downloading" ? ( + + ) : whisperModelPath ? ( + + ) : ( + + )} + +
+
-
{tSettings("captions.animation", "Animation")}
- - update({ fontSize: value })} formatValue={(value) => `${Math.round(value)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> - update({ maxRows: Math.round(value) })} formatValue={(value) => `${Math.round(value)}`} parseInput={(text) => parseFloat(text)} /> - update({ bottomOffset: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - update({ maxWidth: value })} formatValue={(value) => `${Math.round(value)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
+
+ {tSettings("captions.animation", "Animation")} +
+ +
+ + update({ fontSize: value })} + formatValue={(value) => `${Math.round(value)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + update({ maxRows: Math.round(value) })} + formatValue={(value) => `${Math.round(value)}`} + parseInput={(text) => parseFloat(text)} + /> + update({ bottomOffset: value })} + formatValue={(value) => `${Math.round(value)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + update({ maxWidth: value })} + formatValue={(value) => `${Math.round(value)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + />
- {renderExtensionPanels()} +
); } diff --git a/src/components/video-editor/settings/sections/CropSection.tsx b/src/components/video-editor/settings/sections/CropSection.tsx index 6e3ec0757..2c794830c 100644 --- a/src/components/video-editor/settings/sections/CropSection.tsx +++ b/src/components/video-editor/settings/sections/CropSection.tsx @@ -1,26 +1,53 @@ import { SliderControl } from "../../SliderControl"; +import { type CropRegion, DEFAULT_CROP_REGION } from "../../types"; export function CropSection({ tSettings, t, - isCropped, - resetCropSection, - cropTop, - cropBottom, - cropLeft, - cropRight, - setCropInset, + cropRegion, + onCropChange, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; - isCropped: boolean; - resetCropSection: () => void; - cropTop: number; - cropBottom: number; - cropLeft: number; - cropRight: number; - setCropInset: (side: "top" | "bottom" | "left" | "right", pct: number) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; }) { + const crop = cropRegion ?? DEFAULT_CROP_REGION; + const cropTop = Math.round(crop.y * 100); + const cropLeft = Math.round(crop.x * 100); + const cropBottom = Math.round((1 - crop.y - crop.height) * 100); + const cropRight = Math.round((1 - crop.x - crop.width) * 100); + const isCropped = cropTop > 0 || cropLeft > 0 || cropBottom > 0 || cropRight > 0; + + const setCropInset = (side: "top" | "bottom" | "left" | "right", pct: number) => { + if (!onCropChange) return; + + const v = pct / 100; + let { x, y, width, height } = crop; + + if (side === "top") { + const nextY = Math.min(v, 1 - y - height + v); + y = nextY; + height = Math.max(0.05, height - (nextY - crop.y)); + } + + if (side === "left") { + const nextX = Math.min(v, 1 - x - width + v); + x = nextX; + width = Math.max(0.05, width - (nextX - crop.x)); + } + + if (side === "bottom") { + height = Math.max(0.05, 1 - crop.y - v); + } + + if (side === "right") { + width = Math.max(0.05, 1 - crop.x - v); + } + + onCropChange({ x, y, width, height }); + }; + return (
@@ -30,7 +57,7 @@ export function CropSection({ {isCropped ? (
- setCropInset("top", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - setCropInset("bottom", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - setCropInset("left", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - setCropInset("right", v)} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + setCropInset("top", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + setCropInset("bottom", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + setCropInset("left", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + setCropInset("right", v)} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + />
); diff --git a/src/components/video-editor/settings/sections/CursorSection.tsx b/src/components/video-editor/settings/sections/CursorSection.tsx index eef1d5922..96d24cbfb 100644 --- a/src/components/video-editor/settings/sections/CursorSection.tsx +++ b/src/components/video-editor/settings/sections/CursorSection.tsx @@ -1,35 +1,301 @@ +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; -import type { CursorStyle } from "../../types"; -import { DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SWAY } from "../../types"; +import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; +import type { CursorStyle } from "../../types"; +import { + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SWAY, +} from "../../types"; import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "../../videoPlayback/cursorSway"; +import { + cursorSetAssets, + getCursorStyleSizeMultiplier, +} from "../../videoPlayback/uploadedCursorAssets"; +import { + BUILTIN_CURSOR_PREVIEW_FRAME_SIZE, + BUILTIN_CURSOR_PREVIEW_SIZE, + type CursorStyleOption, +} from "../constants"; +import { SettingsExtensionPanels, type SettingsPanelExtension } from "./ExtensionSettingsSection"; + +const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; + +function CursorStylePreview({ + style, + previewUrls, +}: { + style: CursorStyle; + previewUrls: Partial>; +}) { + const previewSrc = + style === "macos" + ? (previewUrls.macos ?? tahoeCursorUrl) + : style === "tahoe" + ? (previewUrls.tahoe ?? tahoeCursorUrl) + : style === "figma" + ? (previewUrls.figma ?? minimalCursorUrl) + : style === "tahoe-inverted" + ? (previewUrls["tahoe-inverted"] ?? tahoeCursorUrl) + : previewUrls[style]; + + if (style === "macos" || style === "tahoe" || style === "tahoe-inverted") { + const previewSize = BUILTIN_CURSOR_PREVIEW_SIZE * getCursorStyleSizeMultiplier(style); + return ( +
+ +
+ ); + } + + if (style === "figma") { + return ; + } + + if (style === "dot") { + return ( + + ); + } + + return ( + + ); +} + +export function CursorSection({ + tSettings, + t, + showCursor, + onShowCursorChange, + loopCursor, + onLoopCursorChange, + cursorStyle, + onCursorStyleChange, + cursorStyleOptions, + cursorPreviewUrls, + cursorSize, + onCursorSizeChange, + onCursorSmoothingChange, + onCursorSpringStiffnessMultiplierChange, + onCursorSpringDampingMultiplierChange, + onCursorSpringMassMultiplierChange, + cursorMotionBlur, + onCursorMotionBlurChange, + cursorClickBounce, + onCursorClickBounceChange, + cursorClickBounceDuration, + onCursorClickBounceDurationChange, + cursorSway, + onCursorSwayChange, + showDevMotionControls, + initialEditorPreferences, + extensionPanels, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + showCursor: boolean; + onShowCursorChange?: (enabled: boolean) => void; + loopCursor: boolean; + onLoopCursorChange?: (enabled: boolean) => void; + cursorStyle: CursorStyle; + onCursorStyleChange?: (style: CursorStyle) => void; + cursorStyleOptions: CursorStyleOption[]; + cursorPreviewUrls: Partial>; + cursorSize: number; + onCursorSizeChange?: (size: number) => void; + onCursorSmoothingChange?: (smoothing: number) => void; + onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; + onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; + onCursorSpringMassMultiplierChange?: (multiplier: number) => void; + cursorMotionBlur: number; + onCursorMotionBlurChange?: (amount: number) => void; + cursorClickBounce: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorClickBounceDuration: number; + onCursorClickBounceDurationChange?: (duration: number) => void; + cursorSway: number; + onCursorSwayChange?: (amount: number) => void; + showDevMotionControls: boolean; + initialEditorPreferences: EditorPreferences; + extensionPanels: SettingsPanelExtension[]; +}) { + const resetCursorSection = () => { + onShowCursorChange?.(initialEditorPreferences.showCursor); + onLoopCursorChange?.(initialEditorPreferences.loopCursor); + onCursorStyleChange?.(initialEditorPreferences.cursorStyle); + onCursorSizeChange?.(initialEditorPreferences.cursorSize); + onCursorSmoothingChange?.(initialEditorPreferences.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.( + initialEditorPreferences.cursorSpringStiffnessMultiplier, + ); + onCursorSpringDampingMultiplierChange?.( + initialEditorPreferences.cursorSpringDampingMultiplier, + ); + onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); + onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur); + onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce); + onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION); + onCursorSwayChange?.(initialEditorPreferences.cursorSway); + }; -export function CursorSection(props: any) { - const { tSettings, t, resetCursorSection, showCursor, onShowCursorChange, loopCursor, onLoopCursorChange, cursorStyle, onCursorStyleChange, cursorStyleOptions, cursorPreviewUrls, CursorStylePreview, cursorSize, onCursorSizeChange, cursorMotionBlur, onCursorMotionBlurChange, cursorClickBounce, onCursorClickBounceChange, cursorClickBounceDuration, onCursorClickBounceDurationChange, cursorSway, onCursorSwayChange, showDevMotionControls, renderExtensionPanels } = props; return (
-

{tSettings("sections.cursor", "Cursor")}

- +

+ {tSettings("sections.cursor", "Cursor")} +

+
- - + +
- value && onCursorStyleChange?.(value as CursorStyle)} className="grid grid-cols-4 gap-2" aria-label={tSettings("effects.cursorStyle", "Cursor Style")}>{cursorStyleOptions.map((option: any) =>
)}
- onCursorSizeChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> - onCursorMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> - onCursorClickBounceChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> - onCursorClickBounceDurationChange?.(v)} formatValue={(v) => `${Math.round(v)} ms`} parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} /> - onCursorSwayChange?.(fromCursorSwaySliderValue(v))} formatValue={(v) => v <= 0 ? tSettings("effects.off") : `${v.toFixed(2)}×`} parseInput={(text) => { const normalized = text.trim().toLowerCase(); if (normalized === "off") return 0; return parseFloat(text.replace(/×$/, "")); }} /> - {showDevMotionControls ?
{tSettings("effects.cursorDebugMovedToDev", "Cursor spring tuning is available in Settings > Dev.")}
: null} + value && onCursorStyleChange?.(value as CursorStyle)} + className="grid grid-cols-4 gap-2" + aria-label={tSettings("effects.cursorStyle", "Cursor Style")} + > + {cursorStyleOptions.map((option) => ( + +
+
+ +
+
+
+ ))} +
+ onCursorSizeChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorClickBounceChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorClickBounceDurationChange?.(v)} + formatValue={(v) => `${Math.round(v)} ms`} + parseInput={(text) => parseFloat(text.replace(/ms$/i, "").trim())} + /> + onCursorSwayChange?.(fromCursorSwaySliderValue(v))} + formatValue={(v) => (v <= 0 ? tSettings("effects.off") : `${v.toFixed(2)}×`)} + parseInput={(text) => { + const normalized = text.trim().toLowerCase(); + if (normalized === "off") return 0; + return parseFloat(text.replace(/×$/, "")); + }} + /> + {showDevMotionControls ? ( +
+
+ {tSettings( + "effects.cursorDebugMovedToDev", + "Cursor spring tuning is available in Settings > Dev.", + )} +
+
+ ) : null}
- {renderExtensionPanels?.()} +
); } diff --git a/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx new file mode 100644 index 000000000..5489d9747 --- /dev/null +++ b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { Switch } from "@/components/ui/switch"; +import { type ExtensionSettingField, extensionHost } from "@/lib/extensions"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/select"; +import { SliderControl } from "../../SliderControl"; + +function getStepPrecision(step: number): number { + if (!Number.isFinite(step) || step <= 0) return 0; + const [mantissa = "0", exponentPart = "0"] = step.toExponential().split("e"); + const exponent = Number.parseInt(exponentPart, 10); + const mantissaDecimals = (mantissa.split(".")[1] ?? "").replace(/0+$/, "").length; + const precision = exponent < 0 ? Math.max(0, -exponent + mantissaDecimals) : mantissaDecimals; + return Math.min(12, precision); +} + +export type SettingsPanelExtension = ReturnType[number]; + +export function ExtensionSettingsSection({ + extensionId, + label, + fields, +}: { + extensionId: string; + label: string; + fields: ExtensionSettingField[]; +}) { + const [, forceUpdate] = useState(0); + + return ( +
+

+ {label} +

+ {fields.map((field) => { + const value = + extensionHost.getExtensionSetting(extensionId, field.id) ?? field.defaultValue; + + if (field.type === "toggle") { + return ( +
+ {field.label} + { + extensionHost.setExtensionSetting( + extensionId, + field.id, + checked, + ); + forceUpdate((n) => n + 1); + }} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+ ); + } + + if (field.type === "slider") { + const step = field.step ?? 0.01; + const precision = getStepPrecision(step); + return ( +
+ { + extensionHost.setExtensionSetting(extensionId, field.id, v); + forceUpdate((n) => n + 1); + }} + formatValue={(v) => v.toFixed(precision)} + parseInput={(text) => parseFloat(text)} + /> +
+ ); + } + + if (field.type === "select" && field.options) { + return ( +
+ + {field.label} + + +
+ ); + } + + if (field.type === "color") { + return ( +
+ + {field.label} + + { + extensionHost.setExtensionSetting( + extensionId, + field.id, + e.target.value, + ); + forceUpdate((n) => n + 1); + }} + className="w-7 h-5 rounded border border-foreground/10 cursor-pointer bg-transparent" + /> +
+ ); + } + + if (field.type === "text") { + return ( +
+ + {field.label} + + { + extensionHost.setExtensionSetting( + extensionId, + field.id, + e.target.value, + ); + forceUpdate((n) => n + 1); + }} + className="w-24 h-6 rounded bg-foreground/[0.06] border border-foreground/10 px-1.5 text-[10px] text-foreground" + /> +
+ ); + } + + return null; + })} +
+ ); +} + +export function SettingsExtensionPanels({ + panels, + sections, +}: { + panels: SettingsPanelExtension[]; + sections: string[]; +}) { + return ( + <> + {panels + .filter((panel) => { + const parentSection = panel.panel.parentSection; + return parentSection ? sections.includes(parentSection) : false; + }) + .map((panel) => ( + + ))} + + ); +} diff --git a/src/components/video-editor/settings/sections/FrameSection.tsx b/src/components/video-editor/settings/sections/FrameSection.tsx index 098d2166d..02c1bbb35 100644 --- a/src/components/video-editor/settings/sections/FrameSection.tsx +++ b/src/components/video-editor/settings/sections/FrameSection.tsx @@ -1,75 +1,297 @@ +import { useRef } from "react"; import { Switch } from "@/components/ui/switch"; +import type { FrameInstance } from "@/lib/extensions"; import { cn } from "@/lib/utils"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; -import type { Padding } from "../../types"; +import { DEFAULT_PADDING, type Padding } from "../../types"; +import { isZeroPadding } from "../../videoPlayback/layoutUtils"; export function FrameSection({ tSettings, t, - resetFrameSection, shadowIntensity, borderRadius, - initialShadowIntensity, - initialBorderRadius, onShadowChange, onBorderRadiusChange, padding, - togglePaddingLink, - handlePaddingSideChange, - removeBackgroundEnabled, - handleRemoveBackgroundToggle, + onPaddingChange, + aspectRatio, + onAspectRatioChange, availableFrames, frame, onFrameChange, + initialEditorPreferences, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; - resetFrameSection: () => void; shadowIntensity: number; borderRadius: number; - initialShadowIntensity: number; - initialBorderRadius: number; onShadowChange?: (v: number) => void; onBorderRadiusChange?: (v: number) => void; padding: Padding; - togglePaddingLink: () => void; - handlePaddingSideChange: (side: keyof Padding, value: number) => void; - removeBackgroundEnabled: boolean; - handleRemoveBackgroundToggle: (checked: boolean) => void; - availableFrames: Array<{ id: string; label: string; thumbnailPath: string }>; + onPaddingChange?: (padding: Padding) => void; + aspectRatio: AspectRatio; + onAspectRatioChange?: (ratio: AspectRatio) => void; + availableFrames: FrameInstance[]; frame?: string | null; onFrameChange?: (frameId: string | null) => void; + initialEditorPreferences: EditorPreferences; }) { + const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; padding: Padding } | null>( + null, + ); + const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding); + + const resetFrameSection = () => { + const preferredFrame = initialEditorPreferences.frame; + const resolvedFrame = preferredFrame + ? availableFrames.some((candidate) => candidate.id === preferredFrame) + ? preferredFrame + : null + : null; + onShadowChange?.(initialEditorPreferences.shadowIntensity); + onBorderRadiusChange?.(initialEditorPreferences.borderRadius); + onAspectRatioChange?.(initialEditorPreferences.aspectRatio); + onPaddingChange?.({ ...initialEditorPreferences.padding }); + onFrameChange?.(resolvedFrame); + removeBackgroundStateRef.current = null; + }; + + const togglePaddingLink = () => { + const isLinked = padding.linked !== false; + if (!isLinked) { + const avg = Math.round( + (padding.top + padding.bottom + padding.left + padding.right) / 4, + ); + onPaddingChange?.({ + top: avg, + bottom: avg, + left: avg, + right: avg, + linked: true, + }); + return; + } + + onPaddingChange?.({ ...padding, linked: false }); + }; + + const handlePaddingSideChange = (side: keyof Padding, value: number) => { + if (padding.linked !== false) { + onPaddingChange?.({ + top: value, + bottom: value, + left: value, + right: value, + linked: true, + }); + return; + } + + onPaddingChange?.({ ...padding, [side]: value }); + }; + + const handleRemoveBackgroundToggle = (checked: boolean) => { + if (checked) { + removeBackgroundStateRef.current = { aspectRatio, padding }; + onAspectRatioChange?.("native"); + onPaddingChange?.({ top: 0, bottom: 0, left: 0, right: 0, linked: padding.linked }); + return; + } + + const previousState = removeBackgroundStateRef.current; + if (previousState) { + onAspectRatioChange?.(previousState.aspectRatio); + onPaddingChange?.(previousState.padding); + removeBackgroundStateRef.current = null; + return; + } + + onAspectRatioChange?.(initialEditorPreferences.aspectRatio); + onPaddingChange?.({ ...DEFAULT_PADDING }); + }; + return (
-

{tSettings("sections.frame", "Frame")}

- +

+ {tSettings("sections.frame", "Frame")} +

+
- onShadowChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} /> - onBorderRadiusChange?.(v)} formatValue={(v) => `${v}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> + onShadowChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> + onBorderRadiusChange?.(v)} + formatValue={(v) => `${v}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + />
-

{tSettings("effects.padding")}

- +

+ {tSettings("effects.padding")} +

+
{padding.linked !== false ? ( - handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> ) : (
- handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - handlePaddingSideChange("bottom", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - handlePaddingSideChange("left", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> - handlePaddingSideChange("right", v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + handlePaddingSideChange("top", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("bottom", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("left", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + handlePaddingSideChange("right", v)} + formatValue={(v) => `${v}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + />
)}
-
{tSettings("effects.removeBackground")}
+
+ + {tSettings("effects.removeBackground")} + + +
{availableFrames.length > 0 && (
-
Frame{frame && }
-
{availableFrames.map((f) => { const isSelected = frame === f.id; return ; })}
+
+ Frame + {frame && ( + + )} +
+
+ {availableFrames.map((candidateFrame) => { + const isSelected = frame === candidateFrame.id; + return ( + + ); + })} +
)}
diff --git a/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx index 45c5d1559..3b41dca5f 100644 --- a/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx +++ b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx @@ -1,11 +1,25 @@ +import { CursorClick, PresentationChart } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { cn } from "@/lib/utils"; import type { AppLocale } from "@/i18n/config"; import { APP_LANGUAGE_LABELS, SUPPORTED_LOCALES } from "@/i18n/config"; +import { cn } from "@/lib/utils"; +import { + CURSOR_MOTION_PRESETS, + type CursorMotionPresetId, + getMatchingCursorMotionPresetId, +} from "../../cursorMotionPresets"; +import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; import { KeyboardShortcutsDialog } from "../../TutorialHelp"; +import type { ZoomMotionBlurTuning } from "../../types"; function SectionLabel({ children }: { children: React.ReactNode }) { return ( @@ -15,420 +29,531 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -export function GeneralSettingsSection(props: any) { - const { - t, - tSettings, - themePreference, - setThemePreference, - locale, - setLocale, - autoApplyFreshRecordingAutoZooms, - onAutoApplyFreshRecordingAutoZoomsChange, - connectZooms, - onConnectZoomsChange, - MotionPresetCards, - activeMotionPresetId, - applyMotionPreset, - showDevMotionControls, - nativeCaptureUnavailableSession, - onOpenNativeCaptureUnavailableModal, - zoomMotionBlurTuning, - initialEditorPreferences, - onZoomMotionBlurTuningChange, - cameraSpringStiffnessMultiplier, - onCameraSpringStiffnessMultiplierChange, - cameraSpringDampingMultiplier, - onCameraSpringDampingMultiplierChange, - cameraSpringMassMultiplier, - onCameraSpringMassMultiplierChange, - cursorSpringStiffnessMultiplier, - onCursorSpringStiffnessMultiplierChange, - cursorSpringDampingMultiplier, - onCursorSpringDampingMultiplierChange, - cursorSpringMassMultiplier, - onCursorSpringMassMultiplierChange, - } = props; +const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"]; +function MotionPresetCards({ + title, + activePresetId, + onApply, + tSettings, +}: { + title: string; + activePresetId: CursorMotionPresetId | null; + onApply: (presetId: CursorMotionPresetId) => void; + tSettings: (key: string, fallback?: string) => string; +}) { return ( -
-
- {t("editor.theme.appearance", "Appearance")} -
- {( - [ - { value: "light", label: t("editor.theme.light", "Light") }, - { value: "dark", label: t("editor.theme.dark", "Dark") }, - { value: "system", label: t("editor.theme.system", "System") }, - ] as const - ).map((option) => ( - - ))} -
-
+
+
{title}
+
+ {MOTION_PRESET_ORDER.map((presetId) => { + const Icon = presetId === "focused" ? CursorClick : PresentationChart; + const isActive = activePresetId === presetId; -
- {t("common.app.language", "Language")} - -
- -
-
-
-
- {tSettings( - "effects.autoApplyFreshRecordingZooms", - "Auto-apply fresh recording zooms", - )} + return ( + + ); + })} +
+
+ ); +} + +export function GeneralSettingsSection({ + t, + tSettings, + themePreference, + setThemePreference, + locale, + setLocale, + autoApplyFreshRecordingAutoZooms, + onAutoApplyFreshRecordingAutoZoomsChange, + connectZooms, + onConnectZoomsChange, + showDevMotionControls, + nativeCaptureUnavailableSession, + onOpenNativeCaptureUnavailableModal, + zoomInDurationMs, + onZoomInDurationMsChange, + zoomOutDurationMs, + onZoomOutDurationMsChange, + cursorSize, + onCursorSizeChange, + cursorSmoothing, + onCursorSmoothingChange, + cursorMotionBlur, + onCursorMotionBlurChange, + cursorClickBounce, + onCursorClickBounceChange, + cursorClickBounceDuration, + onCursorClickBounceDurationChange, + zoomMotionBlurTuning, + initialEditorPreferences, + onZoomMotionBlurTuningChange, + cameraSpringStiffnessMultiplier, + onCameraSpringStiffnessMultiplierChange, + cameraSpringDampingMultiplier, + onCameraSpringDampingMultiplierChange, + cameraSpringMassMultiplier, + onCameraSpringMassMultiplierChange, + cursorSpringStiffnessMultiplier, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier, + onCursorSpringMassMultiplierChange, +}: { + t: (key: string, fallback?: string) => string; + tSettings: (key: string, fallback?: string) => string; + themePreference: "light" | "dark" | "system"; + setThemePreference: (preference: "light" | "dark" | "system") => void; + locale: AppLocale; + setLocale: (locale: AppLocale) => void; + autoApplyFreshRecordingAutoZooms: boolean; + onAutoApplyFreshRecordingAutoZoomsChange?: (enabled: boolean) => void; + connectZooms: boolean; + onConnectZoomsChange?: (enabled: boolean) => void; + showDevMotionControls: boolean; + nativeCaptureUnavailableSession: boolean; + onOpenNativeCaptureUnavailableModal?: () => void; + zoomInDurationMs: number; + onZoomInDurationMsChange?: (duration: number) => void; + zoomOutDurationMs: number; + onZoomOutDurationMsChange?: (duration: number) => void; + cursorSize: number; + onCursorSizeChange?: (size: number) => void; + cursorSmoothing: number; + onCursorSmoothingChange?: (smoothing: number) => void; + cursorMotionBlur: number; + onCursorMotionBlurChange?: (amount: number) => void; + cursorClickBounce: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorClickBounceDuration: number; + onCursorClickBounceDurationChange?: (duration: number) => void; + zoomMotionBlurTuning: ZoomMotionBlurTuning; + initialEditorPreferences: EditorPreferences; + onZoomMotionBlurTuningChange?: (tuning: ZoomMotionBlurTuning) => void; + cameraSpringStiffnessMultiplier: number; + onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cameraSpringDampingMultiplier: number; + onCameraSpringDampingMultiplierChange?: (multiplier: number) => void; + cameraSpringMassMultiplier: number; + onCameraSpringMassMultiplierChange?: (multiplier: number) => void; + cursorSpringStiffnessMultiplier: number; + onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cursorSpringDampingMultiplier: number; + onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; + cursorSpringMassMultiplier: number; + onCursorSpringMassMultiplierChange?: (multiplier: number) => void; +}) { + const activeMotionPresetId = + getMatchingCursorMotionPresetId({ + zoomInDurationMs, + zoomOutDurationMs, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorMotionBlur, + cursorClickBounce, + cursorClickBounceDuration, + }) ?? "focused"; + + const applyMotionPreset = (presetId: CursorMotionPresetId) => { + const preset = CURSOR_MOTION_PRESETS[presetId]; + onZoomInDurationMsChange?.(preset.zoomInDurationMs); + onZoomOutDurationMsChange?.(preset.zoomOutDurationMs); + onCursorSizeChange?.(preset.cursorSize); + onCursorSmoothingChange?.(preset.cursorSmoothing); + onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier); + onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier); + onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier); + onCursorMotionBlurChange?.(preset.cursorMotionBlur); + onCursorClickBounceChange?.(preset.cursorClickBounce); + onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); + }; + + return ( +
+
+ {t("editor.theme.appearance", "Appearance")} +
+ {( + [ + { value: "light", label: t("editor.theme.light", "Light") }, + { value: "dark", label: t("editor.theme.dark", "Dark") }, + { value: "system", label: t("editor.theme.system", "System") }, + ] as const + ).map((option) => ( + + ))} +
+
+ +
+ {t("common.app.language", "Language")} + +
+ +
+
+
+
+ {tSettings( + "effects.autoApplyFreshRecordingZooms", + "Auto-apply fresh recording zooms", + )} +
+
+ {tSettings( + "effects.autoApplyFreshRecordingZoomsDescription", + "Suggest cursor-follow zooms automatically when you open a new recording.", + )} +
+
+ +
+
+
+
+ {tSettings("effects.connectZooms", "Connect neighboring zooms")} +
+
+ {tSettings( + "effects.connectZoomsDescription", + "Smooth consecutive zoom regions into a continuous camera move.", + )}
-
-
+ +
+
+ +
+ +
+ +
+ {t("editor.keyboardShortcuts.title")} + +
+ + {showDevMotionControls ? ( +
+
-
- {tSettings("effects.connectZooms", "Connect neighboring zooms")} -
-
+ {tSettings("effects.devSection", "Dev")} +
{tSettings( - "effects.connectZoomsDescription", - "Smooth consecutive zoom regions into a continuous camera move.", + "effects.devSectionHint", + "Temporary testing controls for native capture and motion tuning.", )}
- + + DEV +
-
- -
- -
- -
- {t("editor.keyboardShortcuts.title")} - -
- {showDevMotionControls ? ( -
-
+
+
- - {tSettings("effects.devSection", "Dev")} - -
+
{tSettings( - "effects.devSectionHint", - "Temporary testing controls for native capture and motion tuning.", + "effects.nativeCaptureWarningTester", + "Native capture warning", )}
-
- - DEV - -
- -
-
-
-
- {tSettings( - "effects.nativeCaptureWarningTester", - "Native capture warning", - )} -
-
- {nativeCaptureUnavailableSession - ? tSettings( - "effects.nativeCaptureWarningTesterUnavailable", - "This project is currently marked as native capture unavailable.", - ) - : tSettings( - "effects.nativeCaptureWarningTesterAvailable", - "This project is not marked as unsupported, but you can still open the modal for UI testing.", - )} -
+
+ {nativeCaptureUnavailableSession + ? tSettings( + "effects.nativeCaptureWarningTesterUnavailable", + "This project is currently marked as native capture unavailable.", + ) + : tSettings( + "effects.nativeCaptureWarningTesterAvailable", + "This project is not marked as unsupported, but you can still open the modal for UI testing.", + )}
-
+
+
-
-
-
- {tSettings("effects.motionBlurDebug", "Motion Blur Debug")} -
-
- {tSettings( - "effects.motionBlurDebugHint", - "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.", - )} -
+
+
+
+ {tSettings("effects.motionBlurDebug", "Motion Blur Debug")}
- - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - panVelocityThreshold: value, - }) - } - formatValue={(value) => `${Math.round(value)} px/s`} - parseInput={(text) => - parseFloat(text.replace(/px\/s$/i, "").trim()) - } - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxDirectionalBlurPx: value, - }) - } - formatValue={(value) => `${value.toFixed(1)} px`} - parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())} - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - zoomVelocityThreshold: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> - + {tSettings( + "effects.motionBlurDebugHint", + "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.", )} - value={zoomMotionBlurTuning.maxRadialBlurStrength} - defaultValue={ - initialEditorPreferences.zoomMotionBlurTuning - .maxRadialBlurStrength - } - min={0} - max={1.5} - step={0.005} - onChange={(value) => - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxRadialBlurStrength: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> +
+ + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + panVelocityThreshold: value, + }) + } + formatValue={(value) => `${Math.round(value)} px/s`} + parseInput={(text) => parseFloat(text.replace(/px\/s$/i, "").trim())} + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + maxDirectionalBlurPx: value, + }) + } + formatValue={(value) => `${value.toFixed(1)} px`} + parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())} + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + zoomVelocityThreshold: value, + }) + } + formatValue={(value) => value.toFixed(3)} + parseInput={(text) => parseFloat(text)} + /> + + onZoomMotionBlurTuningChange?.({ + ...zoomMotionBlurTuning, + maxRadialBlurStrength: value, + }) + } + formatValue={(value) => value.toFixed(3)} + parseInput={(text) => parseFloat(text)} + /> +
-
-
-
- {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")} -
-
- {tSettings( - "effects.cameraDebugTuningHint", - "Development-only spring tuning controls for camera motion.", - )} -
+
+
+
+ {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")}
- - onCameraSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCameraSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - + {tSettings( + "effects.cameraDebugTuningHint", + "Development-only spring tuning controls for camera motion.", )} - value={cameraSpringMassMultiplier} - defaultValue={initialEditorPreferences.cameraSpringMassMultiplier} - min={0.25} - max={3} - step={0.01} - onChange={(value) => onCameraSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> +
+ onCameraSpringStiffnessMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCameraSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCameraSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> +
-
-
-
- {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")} -
-
- {tSettings( - "effects.cursorDebugTuningHint", - "Development-only spring tuning controls.", - )} -
+
+
+
+ {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")}
- - onCursorSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - onCursorSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> - + {tSettings( + "effects.cursorDebugTuningHint", + "Development-only spring tuning controls.", )} - value={cursorSpringMassMultiplier} - defaultValue={initialEditorPreferences.cursorSpringMassMultiplier} - min={0.25} - max={3} - step={0.01} - onChange={(value) => onCursorSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - parseInput={(text) => parseFloat(text.replace(/×$/, ""))} - /> +
-
- ) : null} -
+ onCursorSpringStiffnessMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorSpringDampingMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> + onCursorSpringMassMultiplierChange?.(value)} + formatValue={(value) => `${value.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> +
+
+ ) : null} +
); } diff --git a/src/components/video-editor/settings/sections/WebcamSection.tsx b/src/components/video-editor/settings/sections/WebcamSection.tsx index 158936a77..da498ab26 100644 --- a/src/components/video-editor/settings/sections/WebcamSection.tsx +++ b/src/components/video-editor/settings/sections/WebcamSection.tsx @@ -1,28 +1,301 @@ -import { UploadSimple as Upload, Trash as Trash2 } from "@phosphor-icons/react"; +import { Trash as Trash2, UploadSimple as Upload } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; -import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_CORNER_RADIUS, DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_POSITION_PRESET, DEFAULT_WEBCAM_POSITION_X, DEFAULT_WEBCAM_POSITION_Y, DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE } from "../../types"; +import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; +import { + type CropRegion, + DEFAULT_CROP_REGION, + DEFAULT_WEBCAM_CORNER_RADIUS, + DEFAULT_WEBCAM_MARGIN, + DEFAULT_WEBCAM_POSITION_PRESET, + DEFAULT_WEBCAM_POSITION_X, + DEFAULT_WEBCAM_POSITION_Y, + DEFAULT_WEBCAM_REACT_TO_ZOOM, + DEFAULT_WEBCAM_SHADOW, + DEFAULT_WEBCAM_SIZE, + type WebcamOverlaySettings, + type WebcamPositionPreset, +} from "../../types"; +import { WebcamCropControl } from "../../WebcamCropControl"; +import { + getWebcamPositionForPreset, + normalizeWebcamCropRegion, + resolveWebcamCorner, +} from "../../webcamOverlay"; import { WEBCAM_POSITION_PRESETS } from "../constants"; +import { SettingsExtensionPanels, type SettingsPanelExtension } from "./ExtensionSettingsSection"; + +export function WebcamSection({ + tSettings, + t, + webcam, + webcamPreviewSrc, + webcamPreviewCurrentTime, + webcamPreviewPlaying, + onWebcamChange, + onUploadWebcam, + onClearWebcam, + initialEditorPreferences, + extensionPanels, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + webcam?: WebcamOverlaySettings; + webcamPreviewSrc?: string | null; + webcamPreviewCurrentTime?: number; + webcamPreviewPlaying?: boolean; + onWebcamChange?: (webcam: WebcamOverlaySettings) => void; + onUploadWebcam?: () => void; + onClearWebcam?: () => void; + initialEditorPreferences: EditorPreferences; + extensionPanels: SettingsPanelExtension[]; +}) { + const webcamFileName = webcam?.sourcePath?.split(/[\\/]/).pop() ?? null; + const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; + const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X; + const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y; + const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion); + + const resetWebcamSection = () => { + onWebcamChange?.({ ...initialEditorPreferences.webcam }); + }; + + const updateWebcam = (patch: Partial) => { + if (!webcam || !onWebcamChange) return; + onWebcamChange({ ...webcam, ...patch }); + }; + + const applyWebcamPositionPreset = (preset: WebcamPositionPreset) => { + if (!webcam) return; + + if (preset === "custom") { + updateWebcam({ positionPreset: "custom" }); + return; + } + + const position = getWebcamPositionForPreset(preset); + updateWebcam({ + positionPreset: preset, + positionX: position.x, + positionY: position.y, + corner: resolveWebcamCorner(preset, webcam.corner), + }); + }; -export function WebcamSection(props: any) { - const { tSettings, t, resetWebcamSection, webcam, updateWebcam, webcamCrop, webcamPreviewSrc, webcamPreviewCurrentTime, webcamPreviewPlaying, WebcamCropControl, webcamPositionPreset, applyWebcamPositionPreset, webcamPositionX, webcamPositionY, webcamFileName, onUploadWebcam, onClearWebcam, renderExtensionPanels } = props; return (
-

{tSettings("sections.webcam", "Webcam")}

+
+

+ {tSettings("sections.webcam", "Webcam")} +

+ +
-
{tSettings("effects.show", "Show")} updateWebcam({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
-
{tSettings("effects.webcamReactToZoom")} updateWebcam({ reactToZoom })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
- updateWebcam({ size: v })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> -
{tSettings("effects.webcamCrop", "Crop")}
updateWebcam({ cropRegion })} />
-
{tSettings("effects.webcamPosition", "Position")}
{WEBCAM_POSITION_PRESETS.map((option) => { const isActive = webcamPositionPreset === option.preset; return ; })}
{tSettings("effects.webcamCustomPosition", "Custom position")} applyWebcamPositionPreset(checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET)} className="data-[state=checked]:bg-[#2563EB] scale-75" />
- {webcamPositionPreset === "custom" ? <> updateWebcam({ positionPreset: "custom", positionX: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> updateWebcam({ positionPreset: "custom", positionY: v / 100 })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> : null} - updateWebcam({ margin: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> - updateWebcam({ cornerRadius: v })} formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> - updateWebcam({ shadow: v })} formatValue={(v) => `${Math.round(v * 100)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} /> -
{tSettings("effects.webcamFootage")}
{webcamFileName ?? tSettings("effects.webcamFootageDescription")}
{webcam?.sourcePath ? : null}
- {renderExtensionPanels?.()} +
+ + {tSettings("effects.show", "Show")} + + updateWebcam({ enabled })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+
+ + {tSettings("effects.webcamReactToZoom")} + + updateWebcam({ reactToZoom })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+ updateWebcam({ size: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> +
+
+
+ {tSettings("effects.webcamCrop", "Crop")} +
+ +
+ updateWebcam({ cropRegion })} + /> +
+
+
+ {tSettings("effects.webcamPosition", "Position")} +
+
+ {WEBCAM_POSITION_PRESETS.map((option) => { + const isActive = webcamPositionPreset === option.preset; + return ( + + ); + })} +
+
+ + {tSettings("effects.webcamCustomPosition", "Custom position")} + + + applyWebcamPositionPreset( + checked ? "custom" : DEFAULT_WEBCAM_POSITION_PRESET, + ) + } + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+
+ {webcamPositionPreset === "custom" ? ( + <> + + updateWebcam({ positionPreset: "custom", positionX: v / 100 }) + } + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + + updateWebcam({ positionPreset: "custom", positionY: v / 100 }) + } + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + + ) : null} + updateWebcam({ margin: v })} + formatValue={(v) => `${Math.round(v)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + updateWebcam({ cornerRadius: v })} + formatValue={(v) => `${Math.round(v)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + updateWebcam({ shadow: v })} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> +
+
+
+
+ {tSettings("effects.webcamFootage")} +
+
+ {webcamFileName ?? tSettings("effects.webcamFootageDescription")} +
+
+
+ + {webcam?.sourcePath ? ( + + ) : null} +
+
+
+
); diff --git a/src/components/video-editor/settings/sections/ZoomSection.tsx b/src/components/video-editor/settings/sections/ZoomSection.tsx index d22132f99..59ede0fcd 100644 --- a/src/components/video-editor/settings/sections/ZoomSection.tsx +++ b/src/components/video-editor/settings/sections/ZoomSection.tsx @@ -1,56 +1,190 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { + TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, + TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, +} from "@/lib/exporter/temporalMotionBlur"; import { cn } from "@/lib/utils"; -import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION } from "@/lib/exporter/temporalMotionBlur"; +import type { EditorPreferences } from "../../editorPreferences"; +import type { ZoomDepth, ZoomMode } from "../../types"; import { ZOOM_DEPTH_OPTIONS } from "../constants"; +import { SettingsExtensionPanels, type SettingsPanelExtension } from "./ExtensionSettingsSection"; + +export function ZoomSection({ + tSettings, + t, + selectedZoomId, + selectedZoomDepth, + selectedZoomMode, + onZoomModeChange, + onZoomDepthChange, + zoomClassicMode, + onZoomClassicModeChange, + showDevMotionControls, + onZoomDelete, + initialEditorPreferences, + onZoomMotionBlurTuningChange, + onCameraSpringStiffnessMultiplierChange, + onCameraSpringDampingMultiplierChange, + onCameraSpringMassMultiplierChange, + onZoomInDurationMsChange, + onZoomOutDurationMsChange, + extensionPanels, +}: { + tSettings: (key: string, fallback?: string) => string; + t: (key: string, fallback?: string) => string; + selectedZoomId?: string | null; + selectedZoomDepth?: ZoomDepth | null; + selectedZoomMode?: ZoomMode | null; + onZoomModeChange?: (mode: ZoomMode) => void; + onZoomDepthChange?: (depth: ZoomDepth) => void; + zoomClassicMode: boolean; + onZoomClassicModeChange?: (enabled: boolean) => void; + showDevMotionControls: boolean; + onZoomDelete?: (id: string) => void; + initialEditorPreferences: EditorPreferences; + onZoomMotionBlurTuningChange?: (tuning: EditorPreferences["zoomMotionBlurTuning"]) => void; + onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void; + onCameraSpringDampingMultiplierChange?: (multiplier: number) => void; + onCameraSpringMassMultiplierChange?: (multiplier: number) => void; + onZoomInDurationMsChange?: (duration: number) => void; + onZoomOutDurationMsChange?: (duration: number) => void; + extensionPanels: SettingsPanelExtension[]; +}) { + const resetZoomSection = () => { + onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning); + onCameraSpringStiffnessMultiplierChange?.( + initialEditorPreferences.cameraSpringStiffnessMultiplier, + ); + onCameraSpringDampingMultiplierChange?.( + initialEditorPreferences.cameraSpringDampingMultiplier, + ); + onCameraSpringMassMultiplierChange?.(initialEditorPreferences.cameraSpringMassMultiplier); + onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs); + onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs); + onZoomClassicModeChange?.(false); + }; -export function ZoomSection(props: any) { - const { - tSettings, - t, - selectedZoomId, - selectedZoomDepth, - selectedZoomMode, - onZoomModeChange, - onZoomDepthChange, - resetZoomSection, - zoomClassicMode, - onZoomClassicModeChange, - showDevMotionControls, - onZoomDelete, - renderExtensionPanels, - } = props; return (
{selectedZoomId && ( <>
-

{tSettings("sections.zoom", "Zoom")}

- {selectedZoomDepth && {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}} +

+ {tSettings("sections.zoom", "Zoom")} +

+ {selectedZoomDepth && ( + + { + ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth) + ?.label + } + + )}
- - + +
{ZOOM_DEPTH_OPTIONS.map((option) => { const isActive = selectedZoomDepth === option.depth; - return ; + return ( + + ); })}
)}
-

{tSettings("zoom.globalSettings", "Animation")}

- +

+ {tSettings("zoom.globalSettings", "Animation")} +

+ +
+
+ + {tSettings("effects.classicZoom", "Classic Animation")} + + onZoomClassicModeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+
+
+ {showDevMotionControls + ? tSettings( + "effects.exportBlurMovedToDev", + "Export blur tuning is available in Settings > Dev.", + ) + : tSettings( + "effects.exportBlurLocked", + "Export blur is fixed for this build.", + )} +
+
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
-
{tSettings("effects.classicZoom", "Classic Animation")} onZoomClassicModeChange?.(v)} className="data-[state=checked]:bg-[#2563EB] scale-75" />
-
{showDevMotionControls ? tSettings("effects.exportBlurMovedToDev", "Export blur tuning is available in Settings > Dev.") : tSettings("effects.exportBlurLocked", "Export blur is fixed for this build.")}
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
- {selectedZoomId && } - {renderExtensionPanels?.()} + {selectedZoomId && ( + + )} +
); } diff --git a/src/components/video-editor/settings/utils/cursorPreview.ts b/src/components/video-editor/settings/utils/cursorPreview.ts new file mode 100644 index 000000000..21d2afd2b --- /dev/null +++ b/src/components/video-editor/settings/utils/cursorPreview.ts @@ -0,0 +1,126 @@ +function loadPreviewImage(url: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load preview asset: ${url}`)); + image.src = url; + }); +} + +function trimCanvasToAlpha(canvas: HTMLCanvasElement, hotspot?: { x: number; y: number }) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return { + dataUrl: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + hotspot, + }; + } + + const { width, height } = canvas; + const imageData = ctx.getImageData(0, 0, width, height); + const { data } = imageData; + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const alpha = data[(y * width + x) * 4 + 3]; + if (alpha === 0) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) { + return { dataUrl: canvas.toDataURL("image/png"), width, height, hotspot }; + } + + const croppedWidth = maxX - minX + 1; + const croppedHeight = maxY - minY + 1; + const croppedCanvas = document.createElement("canvas"); + croppedCanvas.width = croppedWidth; + croppedCanvas.height = croppedHeight; + const croppedCtx = croppedCanvas.getContext("2d")!; + croppedCtx.drawImage( + canvas, + minX, + minY, + croppedWidth, + croppedHeight, + 0, + 0, + croppedWidth, + croppedHeight, + ); + + return { + dataUrl: croppedCanvas.toDataURL("image/png"), + width: croppedWidth, + height: croppedHeight, + hotspot: hotspot + ? { + x: hotspot.x - minX, + y: hotspot.y - minY, + } + : undefined, + }; +} + +export async function createTrimmedSvgPreview( + url: string, + sampleSize: number, + trim?: { x: number; y: number; width: number; height: number }, +) { + const image = await loadPreviewImage(url); + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = sampleSize; + sourceCanvas.height = sampleSize; + const sourceCtx = sourceCanvas.getContext("2d")!; + sourceCtx.drawImage(image, 0, 0, sampleSize, sampleSize); + + if (trim) { + const croppedCanvas = document.createElement("canvas"); + croppedCanvas.width = trim.width; + croppedCanvas.height = trim.height; + const croppedCtx = croppedCanvas.getContext("2d")!; + croppedCtx.drawImage( + sourceCanvas, + trim.x, + trim.y, + trim.width, + trim.height, + 0, + 0, + trim.width, + trim.height, + ); + return croppedCanvas.toDataURL("image/png"); + } + + return trimCanvasToAlpha(sourceCanvas).dataUrl; +} + +export async function createInvertedPreview(url: string) { + const image = await loadPreviewImage(url); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const { data } = imageData; + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] === 0) continue; + data[index] = 255 - data[index]; + data[index + 1] = 255 - data[index + 1]; + data[index + 2] = 255 - data[index + 2]; + } + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} From 7d72a4854dd21f46ba41dff8fba7e1a32d57dd01 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 18:52:07 +0200 Subject: [PATCH 4/9] Settings panel is fixed and much more easy to workwith --- src/components/video-editor/SettingsPanel.tsx | 808 ------------------ src/components/video-editor/VideoEditor.tsx | 2 +- src/components/video-editor/index.ts | 2 +- .../video-editor/settings/SettingsPanel.tsx | 516 +++++++++++ .../components/SettingsPanelFooterActions.tsx | 103 +++ .../components/SettingsPanelShell.tsx | 36 + .../components/SettingsSectionRouter.tsx | 111 +++ .../settings/hooks/useSettingsSectionProps.ts | 28 + .../settings/types/SettingsPanelProps.ts | 155 ++++ 9 files changed, 951 insertions(+), 810 deletions(-) delete mode 100644 src/components/video-editor/SettingsPanel.tsx create mode 100644 src/components/video-editor/settings/SettingsPanel.tsx create mode 100644 src/components/video-editor/settings/components/SettingsPanelFooterActions.tsx create mode 100644 src/components/video-editor/settings/components/SettingsPanelShell.tsx create mode 100644 src/components/video-editor/settings/components/SettingsSectionRouter.tsx create mode 100644 src/components/video-editor/settings/hooks/useSettingsSectionProps.ts create mode 100644 src/components/video-editor/settings/types/SettingsPanelProps.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx deleted file mode 100644 index 42cdc973a..000000000 --- a/src/components/video-editor/SettingsPanel.tsx +++ /dev/null @@ -1,808 +0,0 @@ -import { Palette, Trash as Trash2 } from "@phosphor-icons/react"; -import { AnimatePresence, motion } from "motion/react"; -import { useState } from "react"; -import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; -import { Button } from "@/components/ui/button"; -import { useTheme } from "@/contexts/ThemeContext"; -import { cn } from "@/lib/utils"; -import { type AspectRatio } from "@/utils/aspectRatioUtils"; -import { useI18n, useScopedT } from "../../contexts/I18nContext"; -import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; -import { BUILTIN_CURSOR_STYLE_OPTIONS, GRADIENTS } from "./settings/constants"; -import { useSettingsPanel } from "./settings/hooks/useSettingsPanel"; -import { - createInvertedPreview, - createTrimmedSvgPreview, -} from "./settings/utils/cursorPreview"; -import { AudioSection } from "./settings/sections/AudioSection"; -import { BackgroundSection } from "./settings/sections/BackgroundSection"; -import { CaptionsSection } from "./settings/sections/CaptionsSection"; -import { ClipSection } from "./settings/sections/ClipSection"; -import { CropSection } from "./settings/sections/CropSection"; -import { CursorSection } from "./settings/sections/CursorSection"; -import { - ExtensionSettingsSection, - SettingsExtensionPanels, -} from "./settings/sections/ExtensionSettingsSection"; -import { FrameSection } from "./settings/sections/FrameSection"; -import { GeneralSettingsSection } from "./settings/sections/GeneralSettingsSection"; -import { WebcamSection } from "./settings/sections/WebcamSection"; -import { ZoomSection } from "./settings/sections/ZoomSection"; -import type { - AnnotationRegion, - AnnotationType, - AutoCaptionSettings, - CaptionCue, - CropRegion, - CursorStyle, - EditorEffectSection, - FigureData, - Padding, - WebcamOverlaySettings, - ZoomDepth, - ZoomMode, - ZoomMotionBlurTuning, - ZoomTransitionEasing, -} from "./types"; -import { - DEFAULT_AUTO_CAPTION_SETTINGS, - DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_STYLE, - DEFAULT_CURSOR_SWAY, - DEFAULT_PADDING, - DEFAULT_ZOOM_IN_DURATION_MS, - DEFAULT_ZOOM_MOTION_BLUR_TUNING, - DEFAULT_ZOOM_OUT_DURATION_MS, -} from "./types"; -import { cursorSetAssets } from "./videoPlayback/uploadedCursorAssets"; - -const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ); -} - -interface SettingsPanelProps { - panelMode?: "editor" | "background"; - activeEffectSection?: EditorEffectSection; - selected: string; - onWallpaperChange: (path: string) => void; - selectedZoomDepth?: ZoomDepth | null; - onZoomDepthChange?: (depth: ZoomDepth) => void; - selectedZoomId?: string | null; - selectedZoomMode?: ZoomMode | null; - onZoomModeChange?: (mode: ZoomMode) => void; - onZoomDelete?: (id: string) => void; - selectedClipId?: string | null; - selectedClipSpeed?: number | null; - selectedClipMuted?: boolean | null; - selectedClipShowSourceAudio?: boolean | null; - hasClipSourceAudio?: boolean; - onClipSpeedChange?: (speed: number) => void; - onClipMutedChange?: (muted: boolean) => void; - onClipShowSourceAudioChange?: (show: boolean) => void; - sourceAudioTrackMeta?: Array<{ id: string; label: string }>; - sourceAudioTrackSettings?: Record; - onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void; - onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void; - onClipDelete?: (id: string) => void; - selectedAudioId?: string | null; - selectedAudioVolume?: number | null; - selectedAudioNormalize?: boolean | null; - onAudioVolumeChange?: (volume: number) => void; - onAudioNormalizeChange?: (normalize: boolean) => void; - onAudioDelete?: (id: string) => void; - shadowIntensity?: number; - onShadowChange?: (intensity: number) => void; - backgroundBlur?: number; - onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlurTuning?: ZoomMotionBlurTuning; - onZoomMotionBlurTuningChange?: (tuning: ZoomMotionBlurTuning) => void; - zoomTemporalMotionBlur?: number; - onZoomTemporalMotionBlurChange?: (amount: number) => void; - zoomMotionBlurSampleCount?: number | null; - onZoomMotionBlurSampleCountChange?: (count: number | null) => void; - zoomMotionBlurShutterFraction?: number | null; - onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void; - connectZooms?: boolean; - onConnectZoomsChange?: (enabled: boolean) => void; - autoApplyFreshRecordingAutoZooms?: boolean; - onAutoApplyFreshRecordingAutoZoomsChange?: (enabled: boolean) => void; - zoomInDurationMs?: number; - onZoomInDurationMsChange?: (duration: number) => void; - zoomInOverlapMs?: number; - onZoomInOverlapMsChange?: (duration: number) => void; - zoomOutDurationMs?: number; - onZoomOutDurationMsChange?: (duration: number) => void; - connectedZoomGapMs?: number; - onConnectedZoomGapMsChange?: (duration: number) => void; - connectedZoomDurationMs?: number; - onConnectedZoomDurationMsChange?: (duration: number) => void; - zoomInEasing?: ZoomTransitionEasing; - onZoomInEasingChange?: (easing: ZoomTransitionEasing) => void; - zoomOutEasing?: ZoomTransitionEasing; - onZoomOutEasingChange?: (easing: ZoomTransitionEasing) => void; - connectedZoomEasing?: ZoomTransitionEasing; - onConnectedZoomEasingChange?: (easing: ZoomTransitionEasing) => void; - showCursor?: boolean; - onShowCursorChange?: (enabled: boolean) => void; - loopCursor?: boolean; - onLoopCursorChange?: (enabled: boolean) => void; - cursorStyle?: CursorStyle; - onCursorStyleChange?: (style: CursorStyle) => void; - cursorSize?: number; - onCursorSizeChange?: (size: number) => void; - cursorSmoothing?: number; - onCursorSmoothingChange?: (smoothing: number) => void; - cursorSpringStiffnessMultiplier?: number; - onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; - cursorSpringDampingMultiplier?: number; - onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; - cursorSpringMassMultiplier?: number; - onCursorSpringMassMultiplierChange?: (multiplier: number) => void; - cameraSpringStiffnessMultiplier?: number; - onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void; - cameraSpringDampingMultiplier?: number; - onCameraSpringDampingMultiplierChange?: (multiplier: number) => void; - cameraSpringMassMultiplier?: number; - onCameraSpringMassMultiplierChange?: (multiplier: number) => void; - zoomClassicMode?: boolean; - onZoomClassicModeChange?: (enabled: boolean) => void; - cursorMotionBlur?: number; - onCursorMotionBlurChange?: (amount: number) => void; - cursorClickBounce?: number; - onCursorClickBounceChange?: (amount: number) => void; - cursorClickBounceDuration?: number; - onCursorClickBounceDurationChange?: (duration: number) => void; - cursorSway?: number; - onCursorSwayChange?: (amount: number) => void; - borderRadius?: number; - onBorderRadiusChange?: (radius: number) => void; - webcam?: WebcamOverlaySettings; - webcamPreviewSrc?: string | null; - webcamPreviewCurrentTime?: number; - webcamPreviewPlaying?: boolean; - onWebcamChange?: (webcam: WebcamOverlaySettings) => void; - onUploadWebcam?: () => void; - onClearWebcam?: () => void; - padding?: Padding; - onPaddingChange?: (padding: Padding) => void; - frame?: string | null; - onFrameChange?: (frameId: string | null) => void; - cropRegion?: CropRegion; - onCropChange?: (region: CropRegion) => void; - aspectRatio: AspectRatio; - onAspectRatioChange?: (ratio: AspectRatio) => void; - selectedAnnotationId?: string | null; - annotationRegions?: AnnotationRegion[]; - onAnnotationContentChange?: (id: string, content: string) => void; - onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - onAnnotationStyleChange?: (id: string, style: Partial) => void; - onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; - onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void; - onAnnotationBlurColorChange?: (id: string, color: string) => void; - onAnnotationDelete?: (id: string) => void; - autoCaptions?: CaptionCue[]; - autoCaptionSettings?: AutoCaptionSettings; - whisperExecutablePath?: string | null; - whisperModelPath?: string | null; - whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; - whisperModelDownloadProgress?: number; - isGeneratingCaptions?: boolean; - onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void; - onPickWhisperExecutable?: () => void; - onPickWhisperModel?: () => void; - onGenerateAutoCaptions?: () => void; - onClearAutoCaptions?: () => void; - onDownloadWhisperSmallModel?: () => void; - onDeleteWhisperSmallModel?: () => void; - nativeCaptureUnavailableSession?: boolean; - onOpenNativeCaptureUnavailableModal?: () => void; -} - -export function SettingsPanel({ - panelMode = "editor", - activeEffectSection: activeEffectSectionProp, - selected, - onWallpaperChange, - selectedZoomDepth, - onZoomDepthChange, - selectedZoomId, - selectedZoomMode, - onZoomModeChange, - onZoomDelete, - selectedClipId, - selectedClipSpeed, - selectedClipMuted, - selectedClipShowSourceAudio = false, - hasClipSourceAudio = false, - onClipSpeedChange, - onClipMutedChange, - onClipShowSourceAudioChange, - sourceAudioTrackMeta = [], - sourceAudioTrackSettings = {}, - onSourceAudioTrackVolumeChange, - onSourceAudioTrackNormalizeChange, - onClipDelete, - selectedAudioId, - selectedAudioVolume, - selectedAudioNormalize, - onAudioVolumeChange, - onAudioNormalizeChange, - onAudioDelete, - shadowIntensity = 0.67, - onShadowChange, - backgroundBlur = 0, - onBackgroundBlurChange, - zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING, - onZoomMotionBlurTuningChange, - connectZooms = true, - onConnectZoomsChange, - autoApplyFreshRecordingAutoZooms = true, - onAutoApplyFreshRecordingAutoZoomsChange, - zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, - onZoomInDurationMsChange, - zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS, - onZoomOutDurationMsChange, - showCursor = false, - onShowCursorChange, - loopCursor = false, - onLoopCursorChange, - cursorStyle = DEFAULT_CURSOR_STYLE, - onCursorStyleChange, - cursorSize = 5, - onCursorSizeChange, - cursorSmoothing = 2, - onCursorSmoothingChange, - cursorSpringStiffnessMultiplier = 1, - onCursorSpringStiffnessMultiplierChange, - cursorSpringDampingMultiplier = 1, - onCursorSpringDampingMultiplierChange, - cursorSpringMassMultiplier = 1, - onCursorSpringMassMultiplierChange, - cameraSpringStiffnessMultiplier = 1, - onCameraSpringStiffnessMultiplierChange, - cameraSpringDampingMultiplier = 1.13, - onCameraSpringDampingMultiplierChange, - cameraSpringMassMultiplier = 1.12, - onCameraSpringMassMultiplierChange, - zoomClassicMode = false, - onZoomClassicModeChange, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - onCursorMotionBlurChange, - cursorClickBounce = 1, - onCursorClickBounceChange, - cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, - onCursorClickBounceDurationChange, - cursorSway = DEFAULT_CURSOR_SWAY, - onCursorSwayChange, - borderRadius = 12.5, - onBorderRadiusChange, - webcam, - webcamPreviewSrc = null, - webcamPreviewCurrentTime = 0, - webcamPreviewPlaying = false, - onWebcamChange, - onUploadWebcam, - onClearWebcam, - padding = DEFAULT_PADDING, - onPaddingChange, - frame = null, - onFrameChange, - cropRegion, - onCropChange, - aspectRatio, - onAspectRatioChange, - selectedAnnotationId, - annotationRegions = [], - onAnnotationContentChange, - onAnnotationTypeChange, - onAnnotationStyleChange, - onAnnotationFigureDataChange, - onAnnotationBlurIntensityChange, - onAnnotationBlurColorChange, - onAnnotationDelete, - autoCaptions = [], - autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, - whisperModelPath, - whisperModelDownloadStatus = "idle", - whisperModelDownloadProgress = 0, - isGeneratingCaptions = false, - onAutoCaptionSettingsChange, - onPickWhisperModel, - onGenerateAutoCaptions, - onClearAutoCaptions, - onDownloadWhisperSmallModel, - onDeleteWhisperSmallModel, - nativeCaptureUnavailableSession = false, - onOpenNativeCaptureUnavailableModal, -}: SettingsPanelProps) { - const tSettings = useScopedT("settings"); - const { locale, setLocale, t } = useI18n(); - const { preference: themePreference, setPreference: setThemePreference } = useTheme(); - const isBackgroundPanel = panelMode === "background"; - const { - initialEditorPreferences, - customImages, - fileInputRef, - customColorInputRef, - builtInWallpaperPaths, - extensionWallpaperPaths, - backgroundTab, - setBackgroundTab, - selectedColor, - setSelectedColor, - gradient, - setGradient, - availableFrames, - extensionPanels, - cursorPreviewUrls, - cursorStyleOptions, - imageWallpaperTiles, - videoWallpaperTiles, - handleImageUpload, - handleVideoUpload, - handleRemoveCustomImage, - } = useSettingsPanel({ - selected, - onWallpaperChange, - loadEditorPreferences, - saveEditorPreferences, - tSettings, - t, - gradients: GRADIENTS, - builtInCursorStyleOptions: BUILTIN_CURSOR_STYLE_OPTIONS, - createTrimmedSvgPreview, - createInvertedPreview, - minimalCursorUrl, - tahoeCursorUrl, - }); - const captionCueCount = autoCaptions.length; - const [internalActiveEffectSection] = useState("scene"); - const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; - const showDevMotionControls = import.meta.env.DEV; - - // Find selected annotation - const selectedAnnotation = selectedAnnotationId - ? annotationRegions.find((a) => a.id === selectedAnnotationId) - : null; - - const backgroundSettingsContent = ( - - ); - - // If an annotation is selected, show annotation settings instead - if ( - !isBackgroundPanel && - selectedAnnotation && - onAnnotationContentChange && - onAnnotationTypeChange && - onAnnotationStyleChange && - onAnnotationDelete - ) { - return ( - - onAnnotationContentChange(selectedAnnotation.id, content) - } - onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} - onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} - onFigureDataChange={ - onAnnotationFigureDataChange - ? (figureData) => - onAnnotationFigureDataChange(selectedAnnotation.id, figureData) - : undefined - } - onBlurIntensityChange={ - onAnnotationBlurIntensityChange - ? (intensity) => - onAnnotationBlurIntensityChange(selectedAnnotation.id, intensity) - : undefined - } - onBlurColorChange={ - onAnnotationBlurColorChange - ? (color) => onAnnotationBlurColorChange(selectedAnnotation.id, color) - : undefined - } - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> - ); - } - - if (isBackgroundPanel) { - return ( -
-
-
- - - {tSettings("background.title")} - -
- {backgroundSettingsContent} -
-
- ); - } - - const frameSectionContent = ( - - ); - - const cropSectionContent = ( - - ); - - const captionsSectionContent = ( - - ); - - const effectSectionContent = (() => { - const settingsSectionContent = ( - - ); - - const sceneSectionContent = ( -
- {backgroundSettingsContent} - {frameSectionContent} - {cropSectionContent} - -
- ); - - const zoomItemSectionContent = ( - - ); - - const audioSectionContent = ( - - ); - - const clipSectionContent = ( - - ); - - switch (activeEffectSection) { - case "settings": - return settingsSectionContent; - case "scene": - return sceneSectionContent; - case "zoom": - return zoomItemSectionContent; - case "clip": - return clipSectionContent; - case "audio": - return audioSectionContent; - case "frame": - return sceneSectionContent; - case "crop": - return sceneSectionContent; - case "captions": - return captionsSectionContent; - case "cursor": - return ( - - ); - case "webcam": - return ( - - ); - default: { - // Handle extension-contributed standalone section pages (ext:extensionId/panelId) - if (activeEffectSection?.startsWith("ext:")) { - const panels = extensionPanels.filter( - (p) => - !p.panel.parentSection && - `ext:${p.extensionId}/${p.panel.id}` === activeEffectSection, - ); - if (panels.length > 0) { - const p = panels[0]; - return ( -
- {p.panel.label} - -
- ); - } - } - return sceneSectionContent; - } - } - })(); - - return ( -
-
- - - {effectSectionContent} - - -
- -
{ - if (activeEffectSection === "clip" && selectedClipId) return false; - if (activeEffectSection === "zoom" && selectedZoomId) return false; - if (activeEffectSection === "audio" && selectedAudioId) return false; - if (selectedAnnotationId) return false; // Annotation editor handles its own but let's see - return true; - })() && "hidden", - )} - > - {activeEffectSection === "clip" && selectedClipId && ( - - )} - {activeEffectSection === "zoom" && selectedZoomId && ( - - )} - {activeEffectSection === "audio" && selectedAudioId && ( - - )} - {selectedAnnotationId && ( - - )} -
-
- ); -} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 589b79bfe..20f89d952 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -139,7 +139,7 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; -import { SettingsPanel } from "./SettingsPanel"; +import { SettingsPanel } from "./settings/SettingsPanel"; import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { APP_HEADER_ICON_BUTTON_CLASS, diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts index ed621d59d..b80046109 100644 --- a/src/components/video-editor/index.ts +++ b/src/components/video-editor/index.ts @@ -1,5 +1,5 @@ export { default as PlaybackControls } from "./PlaybackControls"; -export { SettingsPanel } from "./SettingsPanel"; +export { SettingsPanel } from "./settings/SettingsPanel"; export { default as TimelineEditor } from "./timeline/TimelineEditor"; export type { TimelineEditorHandle, diff --git a/src/components/video-editor/settings/SettingsPanel.tsx b/src/components/video-editor/settings/SettingsPanel.tsx new file mode 100644 index 000000000..b45956459 --- /dev/null +++ b/src/components/video-editor/settings/SettingsPanel.tsx @@ -0,0 +1,516 @@ +import { Palette } from "@phosphor-icons/react"; +import { useState } from "react"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; +import { useTheme } from "@/contexts/ThemeContext"; +import { useI18n, useScopedT } from "../../../contexts/I18nContext"; +import { AnnotationSettingsPanel } from "../AnnotationSettingsPanel"; +import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences"; +import { SettingsPanelFooterActions } from "./components/SettingsPanelFooterActions"; +import { SettingsPanelShell } from "./components/SettingsPanelShell"; +import { SettingsSectionRouter } from "./components/SettingsSectionRouter"; +import { BUILTIN_CURSOR_STYLE_OPTIONS, GRADIENTS } from "./constants"; +import { useSettingsPanel } from "./hooks/useSettingsPanel"; +import { useSettingsSectionProps } from "./hooks/useSettingsSectionProps"; +import { + createInvertedPreview, + createTrimmedSvgPreview, +} from "./utils/cursorPreview"; +import { BackgroundSection } from "./sections/BackgroundSection"; +import type { SettingsPanelProps } from "./types/SettingsPanelProps"; +import type { + EditorEffectSection, +} from "../types"; +import { + DEFAULT_AUTO_CAPTION_SETTINGS, + DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_STYLE, + DEFAULT_CURSOR_SWAY, + DEFAULT_PADDING, + DEFAULT_ZOOM_IN_DURATION_MS, + DEFAULT_ZOOM_MOTION_BLUR_TUNING, + DEFAULT_ZOOM_OUT_DURATION_MS, +} from "../types"; +import { cursorSetAssets } from "../videoPlayback/uploadedCursorAssets"; + +const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; + +export function SettingsPanel({ + panelMode = "editor", + activeEffectSection: activeEffectSectionProp, + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + selectedZoomMode, + onZoomModeChange, + onZoomDelete, + selectedClipId, + selectedClipSpeed, + selectedClipMuted, + selectedClipShowSourceAudio = false, + hasClipSourceAudio = false, + onClipSpeedChange, + onClipMutedChange, + onClipShowSourceAudioChange, + sourceAudioTrackMeta = [], + sourceAudioTrackSettings = {}, + onSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange, + onClipDelete, + selectedAudioId, + selectedAudioVolume, + selectedAudioNormalize, + onAudioVolumeChange, + onAudioNormalizeChange, + onAudioDelete, + shadowIntensity = 0.67, + onShadowChange, + backgroundBlur = 0, + onBackgroundBlurChange, + zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING, + onZoomMotionBlurTuningChange, + connectZooms = true, + onConnectZoomsChange, + autoApplyFreshRecordingAutoZooms = true, + onAutoApplyFreshRecordingAutoZoomsChange, + zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS, + onZoomInDurationMsChange, + zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS, + onZoomOutDurationMsChange, + showCursor = false, + onShowCursorChange, + loopCursor = false, + onLoopCursorChange, + cursorStyle = DEFAULT_CURSOR_STYLE, + onCursorStyleChange, + cursorSize = 5, + onCursorSizeChange, + cursorSmoothing = 2, + onCursorSmoothingChange, + cursorSpringStiffnessMultiplier = 1, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier = 1, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier = 1, + onCursorSpringMassMultiplierChange, + cameraSpringStiffnessMultiplier = 1, + onCameraSpringStiffnessMultiplierChange, + cameraSpringDampingMultiplier = 1.13, + onCameraSpringDampingMultiplierChange, + cameraSpringMassMultiplier = 1.12, + onCameraSpringMassMultiplierChange, + zoomClassicMode = false, + onZoomClassicModeChange, + cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, + onCursorMotionBlurChange, + cursorClickBounce = 1, + onCursorClickBounceChange, + cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + onCursorClickBounceDurationChange, + cursorSway = DEFAULT_CURSOR_SWAY, + onCursorSwayChange, + borderRadius = 12.5, + onBorderRadiusChange, + webcam, + webcamPreviewSrc = null, + webcamPreviewCurrentTime = 0, + webcamPreviewPlaying = false, + onWebcamChange, + onUploadWebcam, + onClearWebcam, + padding = DEFAULT_PADDING, + onPaddingChange, + frame = null, + onFrameChange, + cropRegion, + onCropChange, + aspectRatio, + onAspectRatioChange, + selectedAnnotationId, + annotationRegions = [], + onAnnotationContentChange, + onAnnotationTypeChange, + onAnnotationStyleChange, + onAnnotationFigureDataChange, + onAnnotationBlurIntensityChange, + onAnnotationBlurColorChange, + onAnnotationDelete, + autoCaptions = [], + autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS, + whisperModelPath, + whisperModelDownloadStatus = "idle", + whisperModelDownloadProgress = 0, + isGeneratingCaptions = false, + onAutoCaptionSettingsChange, + onPickWhisperModel, + onGenerateAutoCaptions, + onClearAutoCaptions, + onDownloadWhisperSmallModel, + onDeleteWhisperSmallModel, + nativeCaptureUnavailableSession = false, + onOpenNativeCaptureUnavailableModal, +}: SettingsPanelProps) { + const tSettings = useScopedT("settings"); + const { locale, setLocale, t } = useI18n(); + const { preference: themePreference, setPreference: setThemePreference } = useTheme(); + const isBackgroundPanel = panelMode === "background"; + const { + initialEditorPreferences, + customImages, + fileInputRef, + customColorInputRef, + builtInWallpaperPaths, + extensionWallpaperPaths, + backgroundTab, + setBackgroundTab, + selectedColor, + setSelectedColor, + gradient, + setGradient, + availableFrames, + extensionPanels, + cursorPreviewUrls, + cursorStyleOptions, + imageWallpaperTiles, + videoWallpaperTiles, + handleImageUpload, + handleVideoUpload, + handleRemoveCustomImage, + } = useSettingsPanel({ + selected, + onWallpaperChange, + loadEditorPreferences, + saveEditorPreferences, + tSettings, + t, + gradients: GRADIENTS, + builtInCursorStyleOptions: BUILTIN_CURSOR_STYLE_OPTIONS, + createTrimmedSvgPreview, + createInvertedPreview, + minimalCursorUrl, + tahoeCursorUrl, + }); + const captionCueCount = autoCaptions.length; + const [internalActiveEffectSection] = useState("scene"); + const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; + const showDevMotionControls = import.meta.env.DEV; + + // Find selected annotation + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) + : null; + + const backgroundSettingsContent = ( + + ); + + // If an annotation is selected, show annotation settings instead + if ( + !isBackgroundPanel && + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + onAnnotationDelete + ) { + return ( + + onAnnotationContentChange(selectedAnnotation.id, content) + } + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => + onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onBlurIntensityChange={ + onAnnotationBlurIntensityChange + ? (intensity) => + onAnnotationBlurIntensityChange(selectedAnnotation.id, intensity) + : undefined + } + onBlurColorChange={ + onAnnotationBlurColorChange + ? (color) => onAnnotationBlurColorChange(selectedAnnotation.id, color) + : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> + ); + } + + if (isBackgroundPanel) { + return ( +
+
+
+ + + {tSettings("background.title")} + +
+ {backgroundSettingsContent} +
+
+ ); + } + + const sectionProps = useSettingsSectionProps({ + backgroundProps: { + tSettings, + t, + selected, + onWallpaperChange, + backgroundBlur, + onBackgroundBlurChange, + backgroundTab, + setBackgroundTab, + fileInputRef, + handleImageUpload, + customImages, + imageWallpaperTiles, + videoWallpaperTiles, + handleVideoUpload, + handleRemoveCustomImage, + customColorInputRef, + selectedColor, + setSelectedColor, + gradient, + setGradient, + initialEditorPreferences, + builtInWallpaperPaths, + extensionWallpaperPaths, + }, + frameProps: { + tSettings, + t, + shadowIntensity, + borderRadius, + onShadowChange, + onBorderRadiusChange, + padding, + onPaddingChange, + aspectRatio, + onAspectRatioChange, + availableFrames, + frame, + onFrameChange, + initialEditorPreferences, + }, + cropProps: { + tSettings, + t, + cropRegion, + onCropChange, + }, + captionsProps: { + tSettings, + t, + autoCaptionSettings, + defaultAutoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + onAutoCaptionSettingsChange, + onPickWhisperModel, + onGenerateAutoCaptions, + onClearAutoCaptions, + onDownloadWhisperSmallModel, + onDeleteWhisperSmallModel, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + captionCueCount, + extensionPanels, + }, + zoomProps: { + tSettings, + t, + selectedZoomId, + selectedZoomDepth, + selectedZoomMode, + onZoomModeChange, + onZoomDepthChange, + zoomClassicMode, + onZoomClassicModeChange, + showDevMotionControls, + onZoomDelete, + initialEditorPreferences, + onZoomMotionBlurTuningChange, + onCameraSpringStiffnessMultiplierChange, + onCameraSpringDampingMultiplierChange, + onCameraSpringMassMultiplierChange, + onZoomInDurationMsChange, + onZoomOutDurationMsChange, + extensionPanels, + }, + audioProps: { + tSettings, + t, + selectedAudioVolume, + selectedAudioNormalize, + onAudioVolumeChange, + onAudioNormalizeChange, + }, + clipProps: { + tSettings, + t, + selectedClipId, + selectedClipSpeed, + selectedClipMuted, + selectedClipShowSourceAudio, + hasClipSourceAudio, + onClipSpeedChange, + onClipMutedChange, + onClipShowSourceAudioChange, + sourceAudioTrackMeta, + sourceAudioTrackSettings, + onSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange, + }, + cursorProps: { + tSettings, + t, + showCursor, + onShowCursorChange, + loopCursor, + onLoopCursorChange, + cursorStyle, + onCursorStyleChange, + cursorStyleOptions, + cursorPreviewUrls, + cursorSize, + onCursorSizeChange, + onCursorSmoothingChange, + onCursorSpringStiffnessMultiplierChange, + onCursorSpringDampingMultiplierChange, + onCursorSpringMassMultiplierChange, + cursorMotionBlur, + onCursorMotionBlurChange, + cursorClickBounce, + onCursorClickBounceChange, + cursorClickBounceDuration, + onCursorClickBounceDurationChange, + cursorSway, + onCursorSwayChange, + showDevMotionControls, + initialEditorPreferences, + extensionPanels, + }, + webcamProps: { + tSettings, + t, + webcam, + webcamPreviewSrc, + webcamPreviewCurrentTime, + webcamPreviewPlaying, + onWebcamChange, + onUploadWebcam, + onClearWebcam, + initialEditorPreferences, + extensionPanels, + }, + generalSettingsProps: { + t, + tSettings, + themePreference, + setThemePreference, + locale, + setLocale, + autoApplyFreshRecordingAutoZooms, + onAutoApplyFreshRecordingAutoZoomsChange, + connectZooms, + onConnectZoomsChange, + showDevMotionControls, + nativeCaptureUnavailableSession, + onOpenNativeCaptureUnavailableModal, + zoomInDurationMs, + onZoomInDurationMsChange, + zoomOutDurationMs, + onZoomOutDurationMsChange, + cursorSize, + onCursorSizeChange, + cursorSmoothing, + onCursorSmoothingChange, + cursorMotionBlur, + onCursorMotionBlurChange, + cursorClickBounce, + onCursorClickBounceChange, + cursorClickBounceDuration, + onCursorClickBounceDurationChange, + zoomMotionBlurTuning, + initialEditorPreferences, + onZoomMotionBlurTuningChange, + cameraSpringStiffnessMultiplier, + onCameraSpringStiffnessMultiplierChange, + cameraSpringDampingMultiplier, + onCameraSpringDampingMultiplierChange, + cameraSpringMassMultiplier, + onCameraSpringMassMultiplierChange, + cursorSpringStiffnessMultiplier, + onCursorSpringStiffnessMultiplierChange, + cursorSpringDampingMultiplier, + onCursorSpringDampingMultiplierChange, + cursorSpringMassMultiplier, + onCursorSpringMassMultiplierChange, + }, + }); + + return ( + + } + footer={ + + } + /> + ); +} diff --git a/src/components/video-editor/settings/components/SettingsPanelFooterActions.tsx b/src/components/video-editor/settings/components/SettingsPanelFooterActions.tsx new file mode 100644 index 000000000..a53a05a6f --- /dev/null +++ b/src/components/video-editor/settings/components/SettingsPanelFooterActions.tsx @@ -0,0 +1,103 @@ +import { Trash as Trash2 } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { EditorEffectSection } from "../../types"; + +interface SettingsPanelFooterActionsProps { + activeEffectSection: EditorEffectSection; + selectedClipId?: string | null; + selectedZoomId?: string | null; + selectedAudioId?: string | null; + selectedAnnotationId?: string | null; + onClipDelete?: (id: string) => void; + onZoomDelete?: (id: string) => void; + onAudioDelete?: (id: string) => void; + onAnnotationDelete?: (id: string) => void; + tSettings: (key: string, fallback?: string) => string; +} + +export function SettingsPanelFooterActions({ + activeEffectSection, + selectedClipId, + selectedZoomId, + selectedAudioId, + selectedAnnotationId, + onClipDelete, + onZoomDelete, + onAudioDelete, + onAnnotationDelete, + tSettings, +}: SettingsPanelFooterActionsProps) { + const shouldHide = + (activeEffectSection === "clip" && !selectedClipId) || + (activeEffectSection === "zoom" && !selectedZoomId) || + (activeEffectSection === "audio" && !selectedAudioId) || + (activeEffectSection !== "clip" && + activeEffectSection !== "zoom" && + activeEffectSection !== "audio" && + !selectedAnnotationId); + + return ( +
+ {activeEffectSection === "clip" && selectedClipId && ( + + )} + {activeEffectSection === "zoom" && selectedZoomId && ( + + )} + {activeEffectSection === "audio" && selectedAudioId && ( + + )} + {selectedAnnotationId && ( + + )} +
+ ); +} diff --git a/src/components/video-editor/settings/components/SettingsPanelShell.tsx b/src/components/video-editor/settings/components/SettingsPanelShell.tsx new file mode 100644 index 000000000..dcc5a9325 --- /dev/null +++ b/src/components/video-editor/settings/components/SettingsPanelShell.tsx @@ -0,0 +1,36 @@ +import { AnimatePresence, motion } from "motion/react"; +import type { ReactNode } from "react"; + +interface SettingsPanelShellProps { + activeEffectSection: string; + content: ReactNode; + footer?: ReactNode; +} + +export function SettingsPanelShell({ + activeEffectSection, + content, + footer, +}: SettingsPanelShellProps) { + return ( +
+
+ + + {content} + + +
+ {footer} +
+ ); +} diff --git a/src/components/video-editor/settings/components/SettingsSectionRouter.tsx b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx new file mode 100644 index 000000000..2f330e890 --- /dev/null +++ b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx @@ -0,0 +1,111 @@ +import type { ComponentProps, ReactNode } from "react"; +import { AudioSection } from "../sections/AudioSection"; +import { BackgroundSection } from "../sections/BackgroundSection"; +import { CaptionsSection } from "../sections/CaptionsSection"; +import { ClipSection } from "../sections/ClipSection"; +import { CropSection } from "../sections/CropSection"; +import { CursorSection } from "../sections/CursorSection"; +import { + ExtensionSettingsSection, + SettingsExtensionPanels, + type SettingsPanelExtension, +} from "../sections/ExtensionSettingsSection"; +import { FrameSection } from "../sections/FrameSection"; +import { GeneralSettingsSection } from "../sections/GeneralSettingsSection"; +import { WebcamSection } from "../sections/WebcamSection"; +import { ZoomSection } from "../sections/ZoomSection"; +import type { EditorEffectSection } from "../../types"; + +function SectionLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +interface SettingsSectionRouterProps { + activeEffectSection: EditorEffectSection; + extensionPanels: SettingsPanelExtension[]; + backgroundProps: ComponentProps; + frameProps: ComponentProps; + cropProps: ComponentProps; + captionsProps: ComponentProps; + zoomProps: ComponentProps; + audioProps: ComponentProps; + clipProps: ComponentProps; + cursorProps: ComponentProps; + webcamProps: ComponentProps; + generalSettingsProps: ComponentProps; +} + +export function SettingsSectionRouter({ + activeEffectSection, + extensionPanels, + backgroundProps, + frameProps, + cropProps, + captionsProps, + zoomProps, + audioProps, + clipProps, + cursorProps, + webcamProps, + generalSettingsProps, +}: SettingsSectionRouterProps) { + const sceneSectionContent = ( +
+ + + + +
+ ); + + switch (activeEffectSection) { + case "settings": + return ; + case "scene": + case "frame": + case "crop": + return sceneSectionContent; + case "zoom": + return ; + case "clip": + return ; + case "audio": + return ; + case "captions": + return ; + case "cursor": + return ; + case "webcam": + return ; + default: { + if (activeEffectSection?.startsWith("ext:")) { + const panels = extensionPanels.filter( + (p) => + !p.panel.parentSection && + `ext:${p.extensionId}/${p.panel.id}` === activeEffectSection, + ); + if (panels.length > 0) { + const p = panels[0]; + return ( +
+ {p.panel.label} + +
+ ); + } + } + return sceneSectionContent; + } + } +} diff --git a/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts b/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts new file mode 100644 index 000000000..5685be062 --- /dev/null +++ b/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts @@ -0,0 +1,28 @@ +import type { ComponentProps } from "react"; +import { AudioSection } from "../sections/AudioSection"; +import { BackgroundSection } from "../sections/BackgroundSection"; +import { CaptionsSection } from "../sections/CaptionsSection"; +import { ClipSection } from "../sections/ClipSection"; +import { CropSection } from "../sections/CropSection"; +import { CursorSection } from "../sections/CursorSection"; +import { FrameSection } from "../sections/FrameSection"; +import { GeneralSettingsSection } from "../sections/GeneralSettingsSection"; +import { WebcamSection } from "../sections/WebcamSection"; +import { ZoomSection } from "../sections/ZoomSection"; + +interface UseSettingsSectionPropsArgs { + backgroundProps: ComponentProps; + frameProps: ComponentProps; + cropProps: ComponentProps; + captionsProps: ComponentProps; + zoomProps: ComponentProps; + audioProps: ComponentProps; + clipProps: ComponentProps; + cursorProps: ComponentProps; + webcamProps: ComponentProps; + generalSettingsProps: ComponentProps; +} + +export function useSettingsSectionProps(args: UseSettingsSectionPropsArgs) { + return args; +} diff --git a/src/components/video-editor/settings/types/SettingsPanelProps.ts b/src/components/video-editor/settings/types/SettingsPanelProps.ts new file mode 100644 index 000000000..48ae2f212 --- /dev/null +++ b/src/components/video-editor/settings/types/SettingsPanelProps.ts @@ -0,0 +1,155 @@ +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import type { + AnnotationRegion, + AnnotationType, + AutoCaptionSettings, + CaptionCue, + CropRegion, + CursorStyle, + EditorEffectSection, + FigureData, + Padding, + WebcamOverlaySettings, + ZoomDepth, + ZoomMode, + ZoomMotionBlurTuning, + ZoomTransitionEasing, +} from "../../types"; + +export interface SettingsPanelProps { + panelMode?: "editor" | "background"; + activeEffectSection?: EditorEffectSection; + selected: string; + onWallpaperChange: (path: string) => void; + selectedZoomDepth?: ZoomDepth | null; + onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomId?: string | null; + selectedZoomMode?: ZoomMode | null; + onZoomModeChange?: (mode: ZoomMode) => void; + onZoomDelete?: (id: string) => void; + selectedClipId?: string | null; + selectedClipSpeed?: number | null; + selectedClipMuted?: boolean | null; + selectedClipShowSourceAudio?: boolean | null; + hasClipSourceAudio?: boolean; + onClipSpeedChange?: (speed: number) => void; + onClipMutedChange?: (muted: boolean) => void; + onClipShowSourceAudioChange?: (show: boolean) => void; + sourceAudioTrackMeta?: Array<{ id: string; label: string }>; + sourceAudioTrackSettings?: Record; + onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void; + onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void; + onClipDelete?: (id: string) => void; + selectedAudioId?: string | null; + selectedAudioVolume?: number | null; + selectedAudioNormalize?: boolean | null; + onAudioVolumeChange?: (volume: number) => void; + onAudioNormalizeChange?: (normalize: boolean) => void; + onAudioDelete?: (id: string) => void; + shadowIntensity?: number; + onShadowChange?: (intensity: number) => void; + backgroundBlur?: number; + onBackgroundBlurChange?: (amount: number) => void; + zoomMotionBlurTuning?: ZoomMotionBlurTuning; + onZoomMotionBlurTuningChange?: (tuning: ZoomMotionBlurTuning) => void; + zoomTemporalMotionBlur?: number; + onZoomTemporalMotionBlurChange?: (amount: number) => void; + zoomMotionBlurSampleCount?: number | null; + onZoomMotionBlurSampleCountChange?: (count: number | null) => void; + zoomMotionBlurShutterFraction?: number | null; + onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void; + connectZooms?: boolean; + onConnectZoomsChange?: (enabled: boolean) => void; + autoApplyFreshRecordingAutoZooms?: boolean; + onAutoApplyFreshRecordingAutoZoomsChange?: (enabled: boolean) => void; + zoomInDurationMs?: number; + onZoomInDurationMsChange?: (duration: number) => void; + zoomInOverlapMs?: number; + onZoomInOverlapMsChange?: (duration: number) => void; + zoomOutDurationMs?: number; + onZoomOutDurationMsChange?: (duration: number) => void; + connectedZoomGapMs?: number; + onConnectedZoomGapMsChange?: (duration: number) => void; + connectedZoomDurationMs?: number; + onConnectedZoomDurationMsChange?: (duration: number) => void; + zoomInEasing?: ZoomTransitionEasing; + onZoomInEasingChange?: (easing: ZoomTransitionEasing) => void; + zoomOutEasing?: ZoomTransitionEasing; + onZoomOutEasingChange?: (easing: ZoomTransitionEasing) => void; + connectedZoomEasing?: ZoomTransitionEasing; + onConnectedZoomEasingChange?: (easing: ZoomTransitionEasing) => void; + showCursor?: boolean; + onShowCursorChange?: (enabled: boolean) => void; + loopCursor?: boolean; + onLoopCursorChange?: (enabled: boolean) => void; + cursorStyle?: CursorStyle; + onCursorStyleChange?: (style: CursorStyle) => void; + cursorSize?: number; + onCursorSizeChange?: (size: number) => void; + cursorSmoothing?: number; + onCursorSmoothingChange?: (smoothing: number) => void; + cursorSpringStiffnessMultiplier?: number; + onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cursorSpringDampingMultiplier?: number; + onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; + cursorSpringMassMultiplier?: number; + onCursorSpringMassMultiplierChange?: (multiplier: number) => void; + cameraSpringStiffnessMultiplier?: number; + onCameraSpringStiffnessMultiplierChange?: (multiplier: number) => void; + cameraSpringDampingMultiplier?: number; + onCameraSpringDampingMultiplierChange?: (multiplier: number) => void; + cameraSpringMassMultiplier?: number; + onCameraSpringMassMultiplierChange?: (multiplier: number) => void; + zoomClassicMode?: boolean; + onZoomClassicModeChange?: (enabled: boolean) => void; + cursorMotionBlur?: number; + onCursorMotionBlurChange?: (amount: number) => void; + cursorClickBounce?: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorClickBounceDuration?: number; + onCursorClickBounceDurationChange?: (duration: number) => void; + cursorSway?: number; + onCursorSwayChange?: (amount: number) => void; + borderRadius?: number; + onBorderRadiusChange?: (radius: number) => void; + webcam?: WebcamOverlaySettings; + webcamPreviewSrc?: string | null; + webcamPreviewCurrentTime?: number; + webcamPreviewPlaying?: boolean; + onWebcamChange?: (webcam: WebcamOverlaySettings) => void; + onUploadWebcam?: () => void; + onClearWebcam?: () => void; + padding?: Padding; + onPaddingChange?: (padding: Padding) => void; + frame?: string | null; + onFrameChange?: (frameId: string | null) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + onAspectRatioChange?: (ratio: AspectRatio) => void; + selectedAnnotationId?: string | null; + annotationRegions?: AnnotationRegion[]; + onAnnotationContentChange?: (id: string, content: string) => void; + onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; + onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; + onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void; + onAnnotationBlurColorChange?: (id: string, color: string) => void; + onAnnotationDelete?: (id: string) => void; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + whisperExecutablePath?: string | null; + whisperModelPath?: string | null; + whisperModelDownloadStatus?: "idle" | "downloading" | "downloaded" | "error"; + whisperModelDownloadProgress?: number; + isGeneratingCaptions?: boolean; + onAutoCaptionSettingsChange?: (settings: AutoCaptionSettings) => void; + onPickWhisperExecutable?: () => void; + onPickWhisperModel?: () => void; + onGenerateAutoCaptions?: () => void; + onClearAutoCaptions?: () => void; + onDownloadWhisperSmallModel?: () => void; + onDeleteWhisperSmallModel?: () => void; + nativeCaptureUnavailableSession?: boolean; + onOpenNativeCaptureUnavailableModal?: () => void; +} From 6a61aa3f1e0fe06d9e77b40be591e2b634a49b8d Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 19:37:56 +0200 Subject: [PATCH 5/9] fix background section performance, you can now have a lot of backgrounds and it works --- .../video-editor/settings/SettingsPanel.tsx | 33 +- .../components/SettingsSectionRouter.tsx | 10 +- .../settings/hooks/useSettingsPanel.ts | 134 ++-- .../settings/sections/BackgroundSection.tsx | 651 +++++++++++------- 4 files changed, 541 insertions(+), 287 deletions(-) diff --git a/src/components/video-editor/settings/SettingsPanel.tsx b/src/components/video-editor/settings/SettingsPanel.tsx index b45956459..3fb85a61d 100644 --- a/src/components/video-editor/settings/SettingsPanel.tsx +++ b/src/components/video-editor/settings/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { Palette } from "@phosphor-icons/react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useTheme } from "@/contexts/ThemeContext"; import { useI18n, useScopedT } from "../../../contexts/I18nContext"; @@ -178,6 +178,7 @@ export function SettingsPanel({ handleImageUpload, handleVideoUpload, handleRemoveCustomImage, + isInitialLoading, } = useSettingsPanel({ selected, onWallpaperChange, @@ -202,7 +203,7 @@ export function SettingsPanel({ ? annotationRegions.find((a) => a.id === selectedAnnotationId) : null; - const backgroundSettingsContent = ( + const backgroundSettingsContent = useMemo(() => ( - ); + ), [ + tSettings, + t, + selected, + onWallpaperChange, + backgroundBlur, + onBackgroundBlurChange, + backgroundTab, + setBackgroundTab, + fileInputRef, + handleImageUpload, + customImages, + imageWallpaperTiles, + videoWallpaperTiles, + handleVideoUpload, + handleRemoveCustomImage, + customColorInputRef, + selectedColor, + setSelectedColor, + gradient, + setGradient, + initialEditorPreferences, + builtInWallpaperPaths, + extensionWallpaperPaths, + isInitialLoading, + ]); // If an annotation is selected, show annotation settings instead if ( diff --git a/src/components/video-editor/settings/components/SettingsSectionRouter.tsx b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx index 2f330e890..cbff9cb85 100644 --- a/src/components/video-editor/settings/components/SettingsSectionRouter.tsx +++ b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ReactNode } from "react"; +import { memo, type ComponentProps, type ReactNode } from "react"; import { AudioSection } from "../sections/AudioSection"; import { BackgroundSection } from "../sections/BackgroundSection"; import { CaptionsSection } from "../sections/CaptionsSection"; @@ -39,7 +39,7 @@ interface SettingsSectionRouterProps { generalSettingsProps: ComponentProps; } -export function SettingsSectionRouter({ +export const SettingsSectionRouter = memo(({ activeEffectSection, extensionPanels, backgroundProps, @@ -52,7 +52,7 @@ export function SettingsSectionRouter({ cursorProps, webcamProps, generalSettingsProps, -}: SettingsSectionRouterProps) { +}: SettingsSectionRouterProps) => { const sceneSectionContent = (
@@ -108,4 +108,6 @@ export function SettingsSectionRouter({ return sceneSectionContent; } } -} +}); + +SettingsSectionRouter.displayName = "SettingsSectionRouter"; diff --git a/src/components/video-editor/settings/hooks/useSettingsPanel.ts b/src/components/video-editor/settings/hooks/useSettingsPanel.ts index 051524c33..60f42d6a6 100644 --- a/src/components/video-editor/settings/hooks/useSettingsPanel.ts +++ b/src/components/video-editor/settings/hooks/useSettingsPanel.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { getAssetPath, @@ -61,10 +61,43 @@ export function useSettingsPanel({ tahoeCursorUrl, }: UseSettingsPanelArgs) { const initialEditorPreferences = useMemo(() => loadEditorPreferences(), [loadEditorPreferences]); - const [builtInWallpapers, setBuiltInWallpapers] = useState(BUILT_IN_WALLPAPERS); - const [extensionWallpapers, setExtensionWallpapers] = useState>([]); - const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); - const [extensionWallpaperPreviewUrls, setExtensionWallpaperPreviewUrls] = useState>({}); + const [assets, setAssets] = useState<{ + builtInWallpapers: BuiltInWallpaper[]; + extensionWallpapers: ReturnType; + wallpaperPreviewPaths: string[]; + extensionWallpaperPreviewUrls: Record; + availableFrames: FrameInstance[]; + extensionPanels: ReturnType; + extensionCursorStyles: ReturnType; + builtInCursorPreviewUrls: Partial>; + extensionCursorPreviewUrls: Partial>; + isInitialLoading: boolean; + }>({ + builtInWallpapers: BUILT_IN_WALLPAPERS, + extensionWallpapers: [], + wallpaperPreviewPaths: [], + extensionWallpaperPreviewUrls: {}, + availableFrames: [], + extensionPanels: [], + extensionCursorStyles: [], + builtInCursorPreviewUrls: {}, + extensionCursorPreviewUrls: {}, + isInitialLoading: true, + }); + + const { + builtInWallpapers, + extensionWallpapers, + wallpaperPreviewPaths, + extensionWallpaperPreviewUrls, + availableFrames, + extensionPanels, + extensionCursorStyles, + builtInCursorPreviewUrls, + extensionCursorPreviewUrls, + isInitialLoading, + } = assets; + const [customImages, setCustomImages] = useState(initialEditorPreferences.customWallpapers); const fileInputRef = useRef(null); const customColorInputRef = useRef(null); @@ -73,11 +106,6 @@ export function useSettingsPanel({ const [gradient, setGradient] = useState( gradients.some((gradientValue) => gradientValue === selected) ? selected : gradients[0], ); - const [availableFrames, setAvailableFrames] = useState([]); - const [extensionPanels, setExtensionPanels] = useState>([]); - const [extensionCursorStyles, setExtensionCursorStyles] = useState>([]); - const [builtInCursorPreviewUrls, setBuiltInCursorPreviewUrls] = useState>>({}); - const [extensionCursorPreviewUrls, setExtensionCursorPreviewUrls] = useState>>({}); const builtInWallpaperPaths = useMemo( () => builtInWallpapers.map((wallpaper) => wallpaper.publicPath), @@ -177,15 +205,21 @@ export function useSettingsPanel({ }), ); if (mounted) { - setBuiltInWallpapers(availableWallpapers); - setWallpaperPreviewPaths(resolved); + setAssets(prev => ({ + ...prev, + builtInWallpapers: availableWallpapers, + wallpaperPreviewPaths: resolved, + isInitialLoading: false, + })); } } catch { if (mounted) { - setBuiltInWallpapers(BUILT_IN_WALLPAPERS); - setWallpaperPreviewPaths( - BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), - ); + setAssets(prev => ({ + ...prev, + builtInWallpapers: BUILT_IN_WALLPAPERS, + wallpaperPreviewPaths: BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath), + isInitialLoading: false, + })); } } })(); @@ -227,10 +261,15 @@ export function useSettingsPanel({ return; } - setExtensionWallpapers(wallpapers); - setExtensionWallpaperPreviewUrls(Object.fromEntries(wallpaperPreviewEntries)); - setExtensionCursorStyles(cursorStyles); - setExtensionCursorPreviewUrls(Object.fromEntries(cursorPreviewEntries)); + setAssets(prev => ({ + ...prev, + extensionWallpapers: wallpapers, + extensionWallpaperPreviewUrls: Object.fromEntries(wallpaperPreviewEntries), + extensionCursorStyles: cursorStyles, + extensionCursorPreviewUrls: Object.fromEntries(cursorPreviewEntries), + availableFrames: extensionHost.getFrames(), + extensionPanels: extensionHost.getSettingsPanels(), + })); }; void extensionHost.autoActivateBuiltins().then(updateExtensionAssets); @@ -244,18 +283,6 @@ export function useSettingsPanel({ }; }, []); - useEffect(() => { - const update = () => setAvailableFrames(extensionHost.getFrames()); - update(); - return extensionHost.onChange(update); - }, []); - - useEffect(() => { - const update = () => setExtensionPanels(extensionHost.getSettingsPanels()); - update(); - return extensionHost.onChange(update); - }, []); - useEffect(() => { let cancelled = false; @@ -268,21 +295,27 @@ export function useSettingsPanel({ const invertedPreview = await createInvertedPreview(tahoePreview); if (!cancelled) { - setBuiltInCursorPreviewUrls({ - macos: macosPreview, - tahoe: tahoePreview, - figma: minimalPreview, - "tahoe-inverted": invertedPreview, - }); + setAssets(prev => ({ + ...prev, + builtInCursorPreviewUrls: { + macos: macosPreview, + tahoe: tahoePreview, + figma: minimalPreview, + "tahoe-inverted": invertedPreview, + } + })); } } catch { if (!cancelled) { - setBuiltInCursorPreviewUrls({ - macos: tahoeCursorUrl, - tahoe: tahoeCursorUrl, - figma: minimalCursorUrl, - "tahoe-inverted": tahoeCursorUrl, - }); + setAssets(prev => ({ + ...prev, + builtInCursorPreviewUrls: { + macos: tahoeCursorUrl, + tahoe: tahoeCursorUrl, + figma: minimalCursorUrl, + "tahoe-inverted": tahoeCursorUrl, + } + })); } } })(); @@ -329,7 +362,7 @@ export function useSettingsPanel({ saveEditorPreferences({ customWallpapers: customImages }); }, [customImages, saveEditorPreferences]); - const handleImageUpload = (event: React.ChangeEvent) => { + const handleImageUpload = useCallback((event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; @@ -361,9 +394,9 @@ export function useSettingsPanel({ reader.readAsDataURL(file); event.target.value = ""; - }; + }, [onWallpaperChange, t, tSettings]); - const handleVideoUpload = async () => { + const handleVideoUpload = useCallback(async () => { try { const result = await window.electronAPI.openVideoFilePicker(); if (!result?.success || !result.path) return; @@ -380,9 +413,9 @@ export function useSettingsPanel({ } catch { toast.error("Failed to import video background"); } - }; + }, [onWallpaperChange]); - const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + const handleRemoveCustomImage = useCallback((imageUrl: string, event: React.MouseEvent) => { event.stopPropagation(); setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); if (selected === imageUrl) { @@ -393,9 +426,10 @@ export function useSettingsPanel({ "", ); } - }; + }, [builtInWallpaperPaths, extensionWallpaperPaths, onWallpaperChange, selected]); return { + isInitialLoading, initialEditorPreferences, customImages, fileInputRef, diff --git a/src/components/video-editor/settings/sections/BackgroundSection.tsx b/src/components/video-editor/settings/sections/BackgroundSection.tsx index 3a90d9e79..44de10cac 100644 --- a/src/components/video-editor/settings/sections/BackgroundSection.tsx +++ b/src/components/video-editor/settings/sections/BackgroundSection.tsx @@ -1,7 +1,8 @@ import { UploadSimple as Upload, X } from "@phosphor-icons/react"; -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { useEffect, useState } from "react"; +import { LayoutGroup, motion } from "motion/react"; +import { memo, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { getRenderableVideoUrl } from "@/lib/assetPath"; import { cn } from "@/lib/utils"; import { BUILT_IN_WALLPAPERS, isVideoWallpaperSource } from "@/lib/wallpapers"; @@ -10,6 +11,8 @@ import { SliderControl } from "../../SliderControl"; import { GRADIENTS } from "../constants"; import type { BackgroundTab, WallpaperTile as WallpaperTileData } from "../hooks/useSettingsPanel"; +const ITEMS_PER_PAGE = 24; + const COLOR_PALETTE = [ "#FF0000", "#FFD700", @@ -32,19 +35,26 @@ function isHexWallpaper(value: string): boolean { return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value); } -function WallpaperVideoPreview({ src }: { src: string }) { - const [resolvedSrc, setResolvedSrc] = useState(src); +const WallpaperVideoPreview = memo(({ src }: { src: string }) => { + const [resolvedSrc, setResolvedSrc] = useState(null); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { let cancelled = false; - setResolvedSrc(src); + setIsLoading(true); void (async () => { try { const nextSrc = await getRenderableVideoUrl(src); - if (!cancelled) setResolvedSrc(nextSrc); + if (!cancelled) { + setResolvedSrc(nextSrc); + setIsLoading(false); + } } catch { - if (!cancelled) setResolvedSrc(src); + if (!cancelled) { + setResolvedSrc(src); + setIsLoading(false); + } } })(); @@ -53,6 +63,10 @@ function WallpaperVideoPreview({ src }: { src: string }) { }; }, [src]); + if (isLoading || !resolvedSrc) { + return ; + } + return (
); -} +}); + +BackgroundSection.displayName = "BackgroundSection"; From 40d6b35dc4eb14aef176b346610f170c267b075c Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 19:39:07 +0200 Subject: [PATCH 6/9] fix add one shared interaction browser for everything --- .../settings/sections/BackgroundSection.tsx | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/src/components/video-editor/settings/sections/BackgroundSection.tsx b/src/components/video-editor/settings/sections/BackgroundSection.tsx index 44de10cac..b0f8ba135 100644 --- a/src/components/video-editor/settings/sections/BackgroundSection.tsx +++ b/src/components/video-editor/settings/sections/BackgroundSection.tsx @@ -105,6 +105,42 @@ function wallpaperTileClass(isSelected: boolean) { ); } +// Singleton Observer Manager to share one IntersectionObserver instance across all tiles +const observerManager = { + observer: null as IntersectionObserver | null, + callbacks: new WeakMap void>(), + + getObserver() { + if (typeof window === "undefined") return null; + if (!this.observer) { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const callback = this.callbacks.get(entry.target); + if (callback) callback(entry.isIntersecting); + }); + }, + { rootMargin: "100px" }, + ); + } + return this.observer; + }, + + observe(element: Element, callback: (isInView: boolean) => void) { + const obs = this.getObserver(); + if (!obs) return; + this.callbacks.set(element, callback); + obs.observe(element); + }, + + unobserve(element: Element) { + const obs = this.getObserver(); + if (!obs) return; + this.callbacks.delete(element); + obs.unobserve(element); + }, +}; + const WallpaperTile = memo(({ wallpaperUrl, isSelected, @@ -120,21 +156,20 @@ const WallpaperTile = memo(({ const isVideo = isVideoWallpaperSource(wallpaperUrl); useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setIsInView(true); - observer.disconnect(); - } - }, - { rootMargin: "100px" } // Load slightly before it enters the viewport - ); - - if (containerRef.current) { - observer.observe(containerRef.current); - } + const el = containerRef.current; + if (!el) return; + + observerManager.observe(el, (inView) => { + if (inView) { + setIsInView(true); + // Once in view, we stop observing this specific element + observerManager.unobserve(el); + } + }); - return () => observer.disconnect(); + return () => { + if (el) observerManager.unobserve(el); + }; }, []); return ( From 62c701a3634263a30b5e15110c0af38f13914f1d Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 20:06:08 +0200 Subject: [PATCH 7/9] fix initial loading and just in general the loading times --- .../video-editor/settings/SettingsPanel.tsx | 154 ++++++++++++++- .../components/SettingsSectionRouter.tsx | 10 +- .../settings/sections/AudioSection.tsx | 18 +- .../settings/sections/CaptionsSection.tsx | 30 ++- .../settings/sections/ClipSection.tsx | 25 ++- .../settings/sections/CropSection.tsx | 20 +- .../settings/sections/CursorSection.tsx | 179 ++++++++++++------ .../sections/ExtensionSettingsSection.tsx | 38 +++- .../settings/sections/FrameSection.tsx | 30 ++- .../sections/GeneralSettingsSection.tsx | 27 ++- .../settings/sections/WebcamSection.tsx | 38 +++- .../settings/sections/ZoomSection.tsx | 30 ++- 12 files changed, 522 insertions(+), 77 deletions(-) diff --git a/src/components/video-editor/settings/SettingsPanel.tsx b/src/components/video-editor/settings/SettingsPanel.tsx index 3fb85a61d..25fdf4669 100644 --- a/src/components/video-editor/settings/SettingsPanel.tsx +++ b/src/components/video-editor/settings/SettingsPanel.tsx @@ -1,5 +1,4 @@ -import { Palette } from "@phosphor-icons/react"; -import { useMemo, useState } from "react"; +import { useDeferredValue, useMemo, useState } from "react"; import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useTheme } from "@/contexts/ThemeContext"; import { useI18n, useScopedT } from "../../../contexts/I18nContext"; @@ -32,6 +31,7 @@ import { DEFAULT_ZOOM_OUT_DURATION_MS, } from "../types"; import { cursorSetAssets } from "../videoPlayback/uploadedCursorAssets"; +import { Palette } from "@phosphor-icons/react"; const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; @@ -198,6 +198,10 @@ export function SettingsPanel({ const activeEffectSection = activeEffectSectionProp ?? internalActiveEffectSection; const showDevMotionControls = import.meta.env.DEV; + // Optimization: Defer the section switch to keep the UI snappy + const deferredActiveSection = useDeferredValue(activeEffectSection); + const isSwitchingSection = activeEffectSection !== deferredActiveSection; + // Find selected annotation const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) @@ -315,7 +319,7 @@ export function SettingsPanel({ ); } - const sectionProps = useSettingsSectionProps({ + const sectionProps = useMemo(() => useSettingsSectionProps({ backgroundProps: { tSettings, t, @@ -340,6 +344,7 @@ export function SettingsPanel({ initialEditorPreferences, builtInWallpaperPaths, extensionWallpaperPaths, + isInitialLoading: isInitialLoading || isSwitchingSection, }, frameProps: { tSettings, @@ -356,12 +361,14 @@ export function SettingsPanel({ frame, onFrameChange, initialEditorPreferences, + isInitialLoading: isSwitchingSection, }, cropProps: { tSettings, t, cropRegion, onCropChange, + isInitialLoading: isSwitchingSection, }, captionsProps: { tSettings, @@ -380,6 +387,7 @@ export function SettingsPanel({ isGeneratingCaptions, captionCueCount, extensionPanels, + isInitialLoading: isSwitchingSection, }, zoomProps: { tSettings, @@ -401,6 +409,7 @@ export function SettingsPanel({ onZoomInDurationMsChange, onZoomOutDurationMsChange, extensionPanels, + isInitialLoading: isSwitchingSection, }, audioProps: { tSettings, @@ -409,6 +418,7 @@ export function SettingsPanel({ selectedAudioNormalize, onAudioVolumeChange, onAudioNormalizeChange, + isInitialLoading: isSwitchingSection, }, clipProps: { tSettings, @@ -425,6 +435,7 @@ export function SettingsPanel({ sourceAudioTrackSettings, onSourceAudioTrackVolumeChange, onSourceAudioTrackNormalizeChange, + isInitialLoading: isSwitchingSection, }, cursorProps: { tSettings, @@ -454,6 +465,7 @@ export function SettingsPanel({ showDevMotionControls, initialEditorPreferences, extensionPanels, + isInitialLoading: isSwitchingSection, }, webcamProps: { tSettings, @@ -467,6 +479,7 @@ export function SettingsPanel({ onClearWebcam, initialEditorPreferences, extensionPanels, + isInitialLoading: isSwitchingSection, }, generalSettingsProps: { t, @@ -511,16 +524,147 @@ export function SettingsPanel({ onCursorSpringDampingMultiplierChange, cursorSpringMassMultiplier, onCursorSpringMassMultiplierChange, + isInitialLoading: isSwitchingSection, }, - }); + }), [ + tSettings, + t, + selected, + onWallpaperChange, + backgroundBlur, + onBackgroundBlurChange, + backgroundTab, + setBackgroundTab, + fileInputRef, + handleImageUpload, + customImages, + imageWallpaperTiles, + videoWallpaperTiles, + handleVideoUpload, + handleRemoveCustomImage, + customColorInputRef, + selectedColor, + setSelectedColor, + gradient, + setGradient, + initialEditorPreferences, + builtInWallpaperPaths, + extensionWallpaperPaths, + isInitialLoading, + isSwitchingSection, + shadowIntensity, + borderRadius, + onShadowChange, + onBorderRadiusChange, + padding, + onPaddingChange, + aspectRatio, + onAspectRatioChange, + availableFrames, + frame, + onFrameChange, + cropRegion, + onCropChange, + autoCaptionSettings, + onAutoCaptionSettingsChange, + onPickWhisperModel, + onGenerateAutoCaptions, + onClearAutoCaptions, + onDownloadWhisperSmallModel, + onDeleteWhisperSmallModel, + whisperModelPath, + whisperModelDownloadStatus, + whisperModelDownloadProgress, + isGeneratingCaptions, + captionCueCount, + extensionPanels, + selectedZoomId, + selectedZoomDepth, + selectedZoomMode, + onZoomModeChange, + onZoomDepthChange, + zoomClassicMode, + onZoomClassicModeChange, + showDevMotionControls, + onZoomDelete, + onZoomMotionBlurTuningChange, + onCameraSpringStiffnessMultiplierChange, + onCameraSpringDampingMultiplierChange, + onCameraSpringMassMultiplierChange, + onZoomInDurationMsChange, + onZoomOutDurationMsChange, + selectedAudioVolume, + selectedAudioNormalize, + onAudioVolumeChange, + onAudioNormalizeChange, + selectedClipId, + selectedClipSpeed, + selectedClipMuted, + selectedClipShowSourceAudio, + hasClipSourceAudio, + onClipSpeedChange, + onClipMutedChange, + onClipShowSourceAudioChange, + sourceAudioTrackMeta, + sourceAudioTrackSettings, + onSourceAudioTrackVolumeChange, + onSourceAudioTrackNormalizeChange, + showCursor, + onShowCursorChange, + loopCursor, + onLoopCursorChange, + cursorStyle, + onCursorStyleChange, + cursorStyleOptions, + cursorPreviewUrls, + cursorSize, + onCursorSizeChange, + onCursorSmoothingChange, + onCursorSpringStiffnessMultiplierChange, + onCursorSpringDampingMultiplierChange, + onCursorSpringMassMultiplierChange, + cursorMotionBlur, + onCursorMotionBlurChange, + cursorClickBounce, + onCursorClickBounceChange, + cursorClickBounceDuration, + onCursorClickBounceDurationChange, + cursorSway, + onCursorSwayChange, + webcam, + webcamPreviewSrc, + webcamPreviewCurrentTime, + webcamPreviewPlaying, + onWebcamChange, + onUploadWebcam, + onClearWebcam, + themePreference, + setThemePreference, + locale, + setLocale, + autoApplyFreshRecordingAutoZooms, + onAutoApplyFreshRecordingAutoZoomsChange, + connectZooms, + onConnectZoomsChange, + nativeCaptureUnavailableSession, + onOpenNativeCaptureUnavailableModal, + zoomMotionBlurTuning, + cameraSpringStiffnessMultiplier, + cameraSpringDampingMultiplier, + cameraSpringMassMultiplier, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + ]); return ( } diff --git a/src/components/video-editor/settings/components/SettingsSectionRouter.tsx b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx index cbff9cb85..2e6afc53c 100644 --- a/src/components/video-editor/settings/components/SettingsSectionRouter.tsx +++ b/src/components/video-editor/settings/components/SettingsSectionRouter.tsx @@ -37,6 +37,7 @@ interface SettingsSectionRouterProps { cursorProps: ComponentProps; webcamProps: ComponentProps; generalSettingsProps: ComponentProps; + isInitialLoading?: boolean; } export const SettingsSectionRouter = memo(({ @@ -52,8 +53,9 @@ export const SettingsSectionRouter = memo(({ cursorProps, webcamProps, generalSettingsProps, + isInitialLoading = false, }: SettingsSectionRouterProps) => { - const sceneSectionContent = ( + const renderSceneSection = () => (
@@ -61,6 +63,7 @@ export const SettingsSectionRouter = memo(({
); @@ -71,7 +74,7 @@ export const SettingsSectionRouter = memo(({ case "scene": case "frame": case "crop": - return sceneSectionContent; + return renderSceneSection(); case "zoom": return ; case "clip": @@ -100,12 +103,13 @@ export const SettingsSectionRouter = memo(({ extensionId={p.extensionId} label={p.panel.label} fields={p.panel.fields} + isInitialLoading={isInitialLoading} />
); } } - return sceneSectionContent; + return renderSceneSection(); } } }); diff --git a/src/components/video-editor/settings/sections/AudioSection.tsx b/src/components/video-editor/settings/sections/AudioSection.tsx index de3c15813..00146a9f2 100644 --- a/src/components/video-editor/settings/sections/AudioSection.tsx +++ b/src/components/video-editor/settings/sections/AudioSection.tsx @@ -1,4 +1,5 @@ import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import { SliderControl } from "../../SliderControl"; export function AudioSection({ @@ -8,6 +9,7 @@ export function AudioSection({ selectedAudioNormalize, onAudioVolumeChange, onAudioNormalizeChange, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -15,9 +17,23 @@ export function AudioSection({ selectedAudioNormalize?: boolean | null; onAudioVolumeChange?: (v: number) => void; onAudioNormalizeChange?: (v: boolean) => void; + isInitialLoading?: boolean; }) { + if (isInitialLoading) { + return ( +
+
+ + +
+ + +
+ ); + } + return ( -
+

{tSettings("audio.volumeTitle", "Audio")}

diff --git a/src/components/video-editor/settings/sections/CaptionsSection.tsx b/src/components/video-editor/settings/sections/CaptionsSection.tsx index ff5ea5ad8..855115879 100644 --- a/src/components/video-editor/settings/sections/CaptionsSection.tsx +++ b/src/components/video-editor/settings/sections/CaptionsSection.tsx @@ -7,6 +7,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import { SliderControl } from "../../SliderControl"; import type { AutoCaptionAnimation, AutoCaptionSettings } from "../../types"; import { CAPTION_ANIMATION_OPTIONS, CAPTION_LANGUAGE_OPTIONS } from "../constants"; @@ -29,6 +30,7 @@ export function CaptionsSection({ isGeneratingCaptions, captionCueCount, extensionPanels, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -46,11 +48,37 @@ export function CaptionsSection({ isGeneratingCaptions: boolean; captionCueCount: number; extensionPanels: SettingsPanelExtension[]; + isInitialLoading?: boolean; }) { const update = (partial: Partial) => onAutoCaptionSettingsChange?.({ ...autoCaptionSettings, ...partial }); + + if (isInitialLoading) { + return ( +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ ); + } + return ( -
+

diff --git a/src/components/video-editor/settings/sections/ClipSection.tsx b/src/components/video-editor/settings/sections/ClipSection.tsx index 35fd9ed5b..44a7dca0f 100644 --- a/src/components/video-editor/settings/sections/ClipSection.tsx +++ b/src/components/video-editor/settings/sections/ClipSection.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { SliderControl } from "../../SliderControl"; @@ -18,6 +19,7 @@ export function ClipSection({ sourceAudioTrackSettings, onSourceAudioTrackVolumeChange, onSourceAudioTrackNormalizeChange, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -33,9 +35,30 @@ export function ClipSection({ sourceAudioTrackSettings: Record; onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void; onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void; + isInitialLoading?: boolean; }) { + if (isInitialLoading) { + return ( +

+
+ +
+ +
+ {[...Array(8)].map((_, i) => ( + + ))} +
+
+ + +
+
+ ); + } + return ( -
+

{tSettings("clip.title", "Clip")}

{selectedClipSpeed != null && selectedClipSpeed !== 1 && {selectedClipSpeed}×} diff --git a/src/components/video-editor/settings/sections/CropSection.tsx b/src/components/video-editor/settings/sections/CropSection.tsx index 2c794830c..ffc0d4f97 100644 --- a/src/components/video-editor/settings/sections/CropSection.tsx +++ b/src/components/video-editor/settings/sections/CropSection.tsx @@ -1,4 +1,5 @@ import { SliderControl } from "../../SliderControl"; +import { Skeleton } from "@/components/ui/skeleton"; import { type CropRegion, DEFAULT_CROP_REGION } from "../../types"; export function CropSection({ @@ -6,11 +7,13 @@ export function CropSection({ t, cropRegion, onCropChange, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; cropRegion?: CropRegion; onCropChange?: (region: CropRegion) => void; + isInitialLoading?: boolean; }) { const crop = cropRegion ?? DEFAULT_CROP_REGION; const cropTop = Math.round(crop.y * 100); @@ -48,8 +51,23 @@ export function CropSection({ onCropChange({ x, y, width, height }); }; + if (isInitialLoading) { + return ( +
+
+ +
+
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+ ); + } + return ( -
+

{tSettings("sections.crop", "Crop")} diff --git a/src/components/video-editor/settings/sections/CursorSection.tsx b/src/components/video-editor/settings/sections/CursorSection.tsx index 96d24cbfb..0b9110418 100644 --- a/src/components/video-editor/settings/sections/CursorSection.tsx +++ b/src/components/video-editor/settings/sections/CursorSection.tsx @@ -1,6 +1,8 @@ +import { memo, useEffect, useState, useTransition } from "react"; import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; @@ -26,13 +28,15 @@ import { SettingsExtensionPanels, type SettingsPanelExtension } from "./Extensio const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url; -function CursorStylePreview({ +const CursorStylePreview = memo(({ style, previewUrls, }: { style: CursorStyle; previewUrls: Partial>; -}) { +}) => { + const [isLoaded, setIsLoaded] = useState(false); + const previewSrc = style === "macos" ? (previewUrls.macos ?? tahoeCursorUrl) @@ -48,16 +52,21 @@ function CursorStylePreview({ const previewSize = BUILTIN_CURSOR_PREVIEW_SIZE * getCursorStyleSizeMultiplier(style); return (

+ {!isLoaded && } setIsLoaded(true)} alt="" - className="max-w-none object-contain drop-shadow-[0_8px_12px_rgba(15,23,42,0.18)]" + className={cn( + "max-w-none object-contain drop-shadow-[0_8px_12px_rgba(15,23,42,0.18)] transition-opacity duration-200", + isLoaded ? "opacity-100" : "opacity-0" + )} draggable={false} style={{ width: `${previewSize}px`, height: `${previewSize}px` }} /> @@ -65,27 +74,35 @@ function CursorStylePreview({ ); } - if (style === "figma") { - return ; - } - - if (style === "dot") { - return ( - - ); - } - return ( - +
+ {!isLoaded && style !== "dot" && } + {style === "figma" ? ( + setIsLoaded(true)} + alt="" + className={cn("h-7 w-7 object-contain transition-opacity duration-200", isLoaded ? "opacity-100" : "opacity-0")} + draggable={false} + /> + ) : style === "dot" ? ( + + ) : ( + setIsLoaded(true)} + alt="" + className={cn("h-7 w-7 object-contain transition-opacity duration-200", isLoaded ? "opacity-100" : "opacity-0")} + draggable={false} + /> + )} +
); -} +}); + +CursorStylePreview.displayName = "CursorStylePreview"; -export function CursorSection({ +export const CursorSection = memo(({ tSettings, t, showCursor, @@ -113,6 +130,7 @@ export function CursorSection({ showDevMotionControls, initialEditorPreferences, extensionPanels, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -141,7 +159,26 @@ export function CursorSection({ showDevMotionControls: boolean; initialEditorPreferences: EditorPreferences; extensionPanels: SettingsPanelExtension[]; -}) { + isInitialLoading?: boolean; +}) => { + const [visibleCount, setVisibleCount] = useState(4); + const [isPending] = useTransition(); + + useEffect(() => { + let frameId: number; + const step = () => { + setVisibleCount(prev => { + if (prev < cursorStyleOptions.length) { + frameId = requestAnimationFrame(step); + return Math.min(prev + 4, cursorStyleOptions.length); + } + return prev; + }); + }; + frameId = requestAnimationFrame(step); + return () => cancelAnimationFrame(frameId); + }, [cursorStyleOptions.length]); + const resetCursorSection = () => { onShowCursorChange?.(initialEditorPreferences.showCursor); onLoopCursorChange?.(initialEditorPreferences.loopCursor); @@ -161,8 +198,34 @@ export function CursorSection({ onCursorSwayChange?.(initialEditorPreferences.cursorSway); }; + if (isInitialLoading || isPending) { + return ( +
+
+ + +
+
+ + +
+ {[...Array(8)].map((_, i) => ( + + ))} +
+ +
+ +
+ ); + } + return ( -
+

@@ -196,35 +259,43 @@ export function CursorSection({

- value && onCursorStyleChange?.(value as CursorStyle)} - className="grid grid-cols-4 gap-2" - aria-label={tSettings("effects.cursorStyle", "Cursor Style")} - > - {cursorStyleOptions.map((option) => ( - -
-
- + {isInitialLoading || isPending ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( + value && onCursorStyleChange?.(value as CursorStyle)} + className="grid grid-cols-4 gap-2" + aria-label={tSettings("effects.cursorStyle", "Cursor Style")} + > + {cursorStyleOptions.slice(0, visibleCount).map((option) => ( + +
+
+ +
-
- - ))} - + + ))} + + )}
); -} +}); + +CursorSection.displayName = "CursorSection"; diff --git a/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx index 5489d9747..9f6065ff1 100644 --- a/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx +++ b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { memo, useMemo, useState } from "react"; import { Switch } from "@/components/ui/switch"; import { type ExtensionSettingField, extensionHost } from "@/lib/extensions"; +import { Skeleton } from "@/components/ui/skeleton"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/select"; import { SliderControl } from "../../SliderControl"; @@ -15,17 +16,29 @@ function getStepPrecision(step: number): number { export type SettingsPanelExtension = ReturnType[number]; -export function ExtensionSettingsSection({ +export const ExtensionSettingsSection = memo(({ extensionId, label, fields, + isInitialLoading = false, }: { extensionId: string; label: string; fields: ExtensionSettingField[]; -}) { + isInitialLoading?: boolean; +}) => { const [, forceUpdate] = useState(0); + if (isInitialLoading) { + return ( +
+ + + +
+ ); + } + return (

@@ -60,7 +73,9 @@ export function ExtensionSettingsSection({ if (field.type === "slider") { const step = field.step ?? 0.01; - const precision = getStepPrecision(step); + // Basic memoization for precision calculation + const precision = useMemo(() => getStepPrecision(step), [step]); + return (

); -} +}); + +ExtensionSettingsSection.displayName = "ExtensionSettingsSection"; -export function SettingsExtensionPanels({ +export const SettingsExtensionPanels = memo(({ panels, sections, + isInitialLoading = false, }: { panels: SettingsPanelExtension[]; sections: string[]; -}) { + isInitialLoading?: boolean; +}) => { return ( <> {panels @@ -198,8 +217,11 @@ export function SettingsExtensionPanels({ extensionId={panel.extensionId} label={panel.panel.label} fields={panel.panel.fields} + isInitialLoading={isInitialLoading} /> ))} ); -} +}); + +SettingsExtensionPanels.displayName = "SettingsExtensionPanels"; diff --git a/src/components/video-editor/settings/sections/FrameSection.tsx b/src/components/video-editor/settings/sections/FrameSection.tsx index 02c1bbb35..4bbc1a26e 100644 --- a/src/components/video-editor/settings/sections/FrameSection.tsx +++ b/src/components/video-editor/settings/sections/FrameSection.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import type { FrameInstance } from "@/lib/extensions"; import { cn } from "@/lib/utils"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; @@ -23,6 +24,7 @@ export function FrameSection({ frame, onFrameChange, initialEditorPreferences, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -38,6 +40,7 @@ export function FrameSection({ frame?: string | null; onFrameChange?: (frameId: string | null) => void; initialEditorPreferences: EditorPreferences; + isInitialLoading?: boolean; }) { const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; padding: Padding } | null>( null, @@ -113,8 +116,33 @@ export function FrameSection({ onPaddingChange?.({ ...DEFAULT_PADDING }); }; + if (isInitialLoading) { + return ( +
+
+ + +
+
+ + +
+ + +
+ +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+ ); + } + return ( -
+

{tSettings("sections.frame", "Frame")} diff --git a/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx index 3b41dca5f..768c529d8 100644 --- a/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx +++ b/src/components/video-editor/settings/sections/GeneralSettingsSection.tsx @@ -20,6 +20,7 @@ import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; import { KeyboardShortcutsDialog } from "../../TutorialHelp"; import type { ZoomMotionBlurTuning } from "../../types"; +import { Skeleton } from "@/components/ui/skeleton"; function SectionLabel({ children }: { children: React.ReactNode }) { return ( @@ -132,6 +133,7 @@ export function GeneralSettingsSection({ onCursorSpringDampingMultiplierChange, cursorSpringMassMultiplier, onCursorSpringMassMultiplierChange, + isInitialLoading = false, }: { t: (key: string, fallback?: string) => string; tSettings: (key: string, fallback?: string) => string; @@ -175,6 +177,7 @@ export function GeneralSettingsSection({ onCursorSpringDampingMultiplierChange?: (multiplier: number) => void; cursorSpringMassMultiplier: number; onCursorSpringMassMultiplierChange?: (multiplier: number) => void; + isInitialLoading?: boolean; }) { const activeMotionPresetId = getMatchingCursorMotionPresetId({ @@ -204,8 +207,30 @@ export function GeneralSettingsSection({ onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); }; + if (isInitialLoading) { + return ( +

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ); + } + return ( -
+
{t("editor.theme.appearance", "Appearance")}
diff --git a/src/components/video-editor/settings/sections/WebcamSection.tsx b/src/components/video-editor/settings/sections/WebcamSection.tsx index da498ab26..37ccbfd15 100644 --- a/src/components/video-editor/settings/sections/WebcamSection.tsx +++ b/src/components/video-editor/settings/sections/WebcamSection.tsx @@ -1,6 +1,7 @@ import { Trash as Trash2, UploadSimple as Upload } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import type { EditorPreferences } from "../../editorPreferences"; import { SliderControl } from "../../SliderControl"; @@ -39,6 +40,7 @@ export function WebcamSection({ onClearWebcam, initialEditorPreferences, extensionPanels, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -51,6 +53,7 @@ export function WebcamSection({ onClearWebcam?: () => void; initialEditorPreferences: EditorPreferences; extensionPanels: SettingsPanelExtension[]; + isInitialLoading?: boolean; }) { const webcamFileName = webcam?.sourcePath?.split(/[\\/]/).pop() ?? null; const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; @@ -84,8 +87,41 @@ export function WebcamSection({ }); }; + if (isInitialLoading) { + return ( +
+
+ + +
+
+ + + +
+ + +
+
+ +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+ +
+ ); + } + return ( -
+

{tSettings("sections.webcam", "Webcam")} diff --git a/src/components/video-editor/settings/sections/ZoomSection.tsx b/src/components/video-editor/settings/sections/ZoomSection.tsx index 59ede0fcd..c22f34a39 100644 --- a/src/components/video-editor/settings/sections/ZoomSection.tsx +++ b/src/components/video-editor/settings/sections/ZoomSection.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, @@ -30,6 +31,7 @@ export function ZoomSection({ onZoomInDurationMsChange, onZoomOutDurationMsChange, extensionPanels, + isInitialLoading = false, }: { tSettings: (key: string, fallback?: string) => string; t: (key: string, fallback?: string) => string; @@ -50,6 +52,7 @@ export function ZoomSection({ onZoomInDurationMsChange?: (duration: number) => void; onZoomOutDurationMsChange?: (duration: number) => void; extensionPanels: SettingsPanelExtension[]; + isInitialLoading?: boolean; }) { const resetZoomSection = () => { onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning); @@ -65,8 +68,33 @@ export function ZoomSection({ onZoomClassicModeChange?.(false); }; + if (isInitialLoading) { + return ( +

+
+ + +
+ +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+
+ + + +
+ ); + } + return ( -
+
{selectedZoomId && ( <>
From d956234b65542b55402149a67abcfc433c128075 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Tue, 12 May 2026 20:37:19 +0200 Subject: [PATCH 8/9] fix code rabbits comments --- .../video-editor/settings/SettingsPanel.tsx | 4 +-- .../settings/hooks/useSettingsSectionProps.ts | 2 +- .../settings/sections/BackgroundSection.tsx | 31 ++++++++++++++++--- .../settings/sections/CropSection.tsx | 17 ++++++---- .../settings/sections/CursorSection.tsx | 4 ++- .../sections/ExtensionSettingsSection.tsx | 5 ++- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/settings/SettingsPanel.tsx b/src/components/video-editor/settings/SettingsPanel.tsx index 25fdf4669..5889d22fe 100644 --- a/src/components/video-editor/settings/SettingsPanel.tsx +++ b/src/components/video-editor/settings/SettingsPanel.tsx @@ -9,7 +9,7 @@ import { SettingsPanelShell } from "./components/SettingsPanelShell"; import { SettingsSectionRouter } from "./components/SettingsSectionRouter"; import { BUILTIN_CURSOR_STYLE_OPTIONS, GRADIENTS } from "./constants"; import { useSettingsPanel } from "./hooks/useSettingsPanel"; -import { useSettingsSectionProps } from "./hooks/useSettingsSectionProps"; +import { createSettingsSectionProps } from "./hooks/useSettingsSectionProps"; import { createInvertedPreview, createTrimmedSvgPreview, @@ -319,7 +319,7 @@ export function SettingsPanel({ ); } - const sectionProps = useMemo(() => useSettingsSectionProps({ + const sectionProps = useMemo(() => createSettingsSectionProps({ backgroundProps: { tSettings, t, diff --git a/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts b/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts index 5685be062..9157e61c5 100644 --- a/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts +++ b/src/components/video-editor/settings/hooks/useSettingsSectionProps.ts @@ -23,6 +23,6 @@ interface UseSettingsSectionPropsArgs { generalSettingsProps: ComponentProps; } -export function useSettingsSectionProps(args: UseSettingsSectionPropsArgs) { +export function createSettingsSectionProps(args: UseSettingsSectionPropsArgs) { return args; } diff --git a/src/components/video-editor/settings/sections/BackgroundSection.tsx b/src/components/video-editor/settings/sections/BackgroundSection.tsx index b0f8ba135..b4850c93a 100644 --- a/src/components/video-editor/settings/sections/BackgroundSection.tsx +++ b/src/components/video-editor/settings/sections/BackgroundSection.tsx @@ -141,6 +141,10 @@ const observerManager = { }, }; +function isKeyboardActivationKey(key: string): boolean { + return key === "Enter" || key === " "; +} + const WallpaperTile = memo(({ wallpaperUrl, isSelected, @@ -180,6 +184,13 @@ const WallpaperTile = memo(({ title={title} onClick={onClick} role="button" + tabIndex={0} + onKeyDown={(event) => { + if (isKeyboardActivationKey(event.key)) { + event.preventDefault(); + onClick?.(); + } + }} >
{!isInView ? ( @@ -366,11 +377,13 @@ export const BackgroundSection = memo(({ }; const imageTiles = useMemo(() => [ - ...customImages.map((imageUrl, index) => ({ - type: "custom" as const, - url: imageUrl, - id: `custom-${index}` - })), + ...customImages + .filter((imageUrl) => !isVideoWallpaperSource(imageUrl)) + .map((imageUrl, index) => ({ + type: "custom" as const, + url: imageUrl, + id: `custom-${index}` + })), ...imageWallpaperTiles.map(tile => ({ type: "builtin" as const, tile, @@ -703,6 +716,14 @@ export const BackgroundSection = memo(({ onWallpaperChange(candidate); }} role="button" + tabIndex={0} + onKeyDown={(event) => { + if (isKeyboardActivationKey(event.key)) { + event.preventDefault(); + setGradient(candidate); + onWallpaperChange(candidate); + } + }} >
{ if (!onCropChange) return; + const MIN_DIMENSION = 0.05; const v = pct / 100; let { x, y, width, height } = crop; if (side === "top") { - const nextY = Math.min(v, 1 - y - height + v); + const bottomEdge = crop.y + crop.height; + const maxY = bottomEdge - MIN_DIMENSION; + const nextY = Math.min(v, maxY); y = nextY; - height = Math.max(0.05, height - (nextY - crop.y)); + height = bottomEdge - nextY; } if (side === "left") { - const nextX = Math.min(v, 1 - x - width + v); + const rightEdge = crop.x + crop.width; + const maxX = rightEdge - MIN_DIMENSION; + const nextX = Math.min(v, maxX); x = nextX; - width = Math.max(0.05, width - (nextX - crop.x)); + width = rightEdge - nextX; } if (side === "bottom") { - height = Math.max(0.05, 1 - crop.y - v); + height = Math.max(MIN_DIMENSION, 1 - crop.y - v); } if (side === "right") { - width = Math.max(0.05, 1 - crop.x - v); + width = Math.max(MIN_DIMENSION, 1 - crop.x - v); } onCropChange({ x, y, width, height }); diff --git a/src/components/video-editor/settings/sections/CursorSection.tsx b/src/components/video-editor/settings/sections/CursorSection.tsx index 0b9110418..0186d1ac0 100644 --- a/src/components/video-editor/settings/sections/CursorSection.tsx +++ b/src/components/video-editor/settings/sections/CursorSection.tsx @@ -194,7 +194,9 @@ export const CursorSection = memo(({ onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur); onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce); - onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION); + onCursorClickBounceDurationChange?.( + initialEditorPreferences.cursorClickBounceDuration ?? DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, + ); onCursorSwayChange?.(initialEditorPreferences.cursorSway); }; diff --git a/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx index 9f6065ff1..fd506d701 100644 --- a/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx +++ b/src/components/video-editor/settings/sections/ExtensionSettingsSection.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useState } from "react"; +import { memo, useState } from "react"; import { Switch } from "@/components/ui/switch"; import { type ExtensionSettingField, extensionHost } from "@/lib/extensions"; import { Skeleton } from "@/components/ui/skeleton"; @@ -73,8 +73,7 @@ export const ExtensionSettingsSection = memo(({ if (field.type === "slider") { const step = field.step ?? 0.01; - // Basic memoization for precision calculation - const precision = useMemo(() => getStepPrecision(step), [step]); + const precision = getStepPrecision(step); return (
From 9f9e0e802da8c49fc4e320a25f8ff8c97129ba35 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Wed, 13 May 2026 00:56:22 +0200 Subject: [PATCH 9/9] fix code rabbit --- .../video-editor/settings/SettingsPanel.tsx | 133 +----------------- 1 file changed, 2 insertions(+), 131 deletions(-) diff --git a/src/components/video-editor/settings/SettingsPanel.tsx b/src/components/video-editor/settings/SettingsPanel.tsx index 5889d22fe..c8bddb896 100644 --- a/src/components/video-editor/settings/SettingsPanel.tsx +++ b/src/components/video-editor/settings/SettingsPanel.tsx @@ -319,7 +319,7 @@ export function SettingsPanel({ ); } - const sectionProps = useMemo(() => createSettingsSectionProps({ + const sectionProps = createSettingsSectionProps({ backgroundProps: { tSettings, t, @@ -526,136 +526,7 @@ export function SettingsPanel({ onCursorSpringMassMultiplierChange, isInitialLoading: isSwitchingSection, }, - }), [ - tSettings, - t, - selected, - onWallpaperChange, - backgroundBlur, - onBackgroundBlurChange, - backgroundTab, - setBackgroundTab, - fileInputRef, - handleImageUpload, - customImages, - imageWallpaperTiles, - videoWallpaperTiles, - handleVideoUpload, - handleRemoveCustomImage, - customColorInputRef, - selectedColor, - setSelectedColor, - gradient, - setGradient, - initialEditorPreferences, - builtInWallpaperPaths, - extensionWallpaperPaths, - isInitialLoading, - isSwitchingSection, - shadowIntensity, - borderRadius, - onShadowChange, - onBorderRadiusChange, - padding, - onPaddingChange, - aspectRatio, - onAspectRatioChange, - availableFrames, - frame, - onFrameChange, - cropRegion, - onCropChange, - autoCaptionSettings, - onAutoCaptionSettingsChange, - onPickWhisperModel, - onGenerateAutoCaptions, - onClearAutoCaptions, - onDownloadWhisperSmallModel, - onDeleteWhisperSmallModel, - whisperModelPath, - whisperModelDownloadStatus, - whisperModelDownloadProgress, - isGeneratingCaptions, - captionCueCount, - extensionPanels, - selectedZoomId, - selectedZoomDepth, - selectedZoomMode, - onZoomModeChange, - onZoomDepthChange, - zoomClassicMode, - onZoomClassicModeChange, - showDevMotionControls, - onZoomDelete, - onZoomMotionBlurTuningChange, - onCameraSpringStiffnessMultiplierChange, - onCameraSpringDampingMultiplierChange, - onCameraSpringMassMultiplierChange, - onZoomInDurationMsChange, - onZoomOutDurationMsChange, - selectedAudioVolume, - selectedAudioNormalize, - onAudioVolumeChange, - onAudioNormalizeChange, - selectedClipId, - selectedClipSpeed, - selectedClipMuted, - selectedClipShowSourceAudio, - hasClipSourceAudio, - onClipSpeedChange, - onClipMutedChange, - onClipShowSourceAudioChange, - sourceAudioTrackMeta, - sourceAudioTrackSettings, - onSourceAudioTrackVolumeChange, - onSourceAudioTrackNormalizeChange, - showCursor, - onShowCursorChange, - loopCursor, - onLoopCursorChange, - cursorStyle, - onCursorStyleChange, - cursorStyleOptions, - cursorPreviewUrls, - cursorSize, - onCursorSizeChange, - onCursorSmoothingChange, - onCursorSpringStiffnessMultiplierChange, - onCursorSpringDampingMultiplierChange, - onCursorSpringMassMultiplierChange, - cursorMotionBlur, - onCursorMotionBlurChange, - cursorClickBounce, - onCursorClickBounceChange, - cursorClickBounceDuration, - onCursorClickBounceDurationChange, - cursorSway, - onCursorSwayChange, - webcam, - webcamPreviewSrc, - webcamPreviewCurrentTime, - webcamPreviewPlaying, - onWebcamChange, - onUploadWebcam, - onClearWebcam, - themePreference, - setThemePreference, - locale, - setLocale, - autoApplyFreshRecordingAutoZooms, - onAutoApplyFreshRecordingAutoZoomsChange, - connectZooms, - onConnectZoomsChange, - nativeCaptureUnavailableSession, - onOpenNativeCaptureUnavailableModal, - zoomMotionBlurTuning, - cameraSpringStiffnessMultiplier, - cameraSpringDampingMultiplier, - cameraSpringMassMultiplier, - cursorSpringStiffnessMultiplier, - cursorSpringDampingMultiplier, - cursorSpringMassMultiplier, - ]); + }); return (