import * as SliderPrimitive from "@radix-ui/react-slider"; import { Bug, Crop, Download, FileDown, Film, Image, LayoutPanelTop, Lock, MousePointerClick, Palette, SlidersHorizontal, Sparkles, Star, Trash2, Unlock, Upload, X, } from "lucide-react"; import { type ComponentType, useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useScopedT } from "@/contexts/I18nContext"; import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { calculateEffectiveSourceDimensions, GIF_FRAME_RATES, GIF_SIZE_PRESETS, } from "@/lib/exporter"; import { cn } from "@/lib/utils"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; import { CropControl } from "./CropControl"; import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { DEFAULT_CURSOR_SETTINGS, DEFAULT_EDITOR_LAYOUT_SETTINGS, DEFAULT_EXPORT_SETTINGS, DEFAULT_GIF_SETTINGS, DEFAULT_SOURCE_DIMENSIONS, DEFAULT_WEBCAM_SETTINGS, } from "./editorDefaults"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, AnnotationType, BlurData, CropRegion, FigureData, PlaybackSpeed, Rotation3DPreset, WebcamLayoutPreset, WebcamMaskShape, WebcamSizePreset, ZoomDepth, ZoomFocus, ZoomFocusMode, } from "./types"; import { MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, SPEED_OPTIONS, ZOOM_DEPTH_SCALES, } from "./types"; import { getFocusBoundsForScale } from "./videoPlayback/focusUtils"; function CustomSpeedInput({ value, onChange, onError, }: { value: number; onChange: (val: number) => void; onError: () => void; }) { const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); const [draft, setDraft] = useState(isPreset ? "" : String(value)); const [isFocused, setIsFocused] = useState(false); const prevValue = useRef(value); if (!isFocused && prevValue.current !== value) { prevValue.current = value; setDraft(isPreset ? "" : String(value)); } const handleChange = useCallback( (e: React.ChangeEvent) => { const result = parseCustomPlaybackSpeedInput(e.target.value); if (result.status === "too-fast") { onError(); return; } setDraft(result.draft); if (result.status === "valid") { onChange(result.speed); } }, [onChange, onError], ); const handleBlur = useCallback(() => { setIsFocused(false); const result = parseCustomPlaybackSpeedInput(draft); if (result.status === "valid") { setDraft(String(result.speed)); } else { setDraft(isPreset ? "" : String(value)); } }, [draft, isPreset, value]); return (
setIsFocused(true)} onChange={handleChange} onBlur={handleBlur} onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" /> ×
); } function ZoomFocusCoordInput({ percent, onChange, onCommit, disabled, ariaLabel, }: { percent: number; onChange: (nextPercent: number) => void; onCommit?: () => void; disabled?: boolean; ariaLabel: string; }) { // While the input is focused (user is editing), show their draft text // so partial entries like "5" or "" don't get overwritten by re-renders. // When not focused, mirror the live prop value so external changes // (dragging the overlay on the preview) update the displayed number in real time. const [draft, setDraft] = useState(null); const display = percent.toFixed(1); return ( setDraft(display)} onChange={(e) => { const next = e.target.value; setDraft(next); const parsed = Number(next); if (next !== "" && Number.isFinite(parsed)) { const clamped = Math.min(100, Math.max(0, parsed)); onChange(clamped); } }} onBlur={() => { setDraft(null); onCommit?.(); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} className="h-7 w-full rounded-md border border-white/10 bg-white/5 px-2 text-[11px] text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" /> ); } 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%)", ]; interface SettingsPanelProps { selected: string; onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; selectedZoomCustomScale?: number | null; onZoomCustomScaleChange?: (scale: number) => void; onZoomCustomScaleCommit?: () => void; selectedZoomFocusMode?: ZoomFocusMode | null; onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; selectedZoomFocus?: ZoomFocus | null; onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void; onZoomFocusCoordinateCommit?: () => void; hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; selectedZoomRotationPreset?: Rotation3DPreset | null; onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void; selectedTrimId?: string | null; onTrimDelete?: (id: string) => void; shadowIntensity?: number; onShadowChange?: (intensity: number) => void; onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; motionBlurAmount?: number; onMotionBlurChange?: (amount: number) => void; onMotionBlurCommit?: () => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; onBorderRadiusCommit?: () => void; padding?: number; onPaddingChange?: (padding: number) => void; onPaddingCommit?: () => void; cropRegion?: CropRegion; onCropChange?: (region: CropRegion) => void; aspectRatio: AspectRatio; videoElement?: HTMLVideoElement | null; exportQuality?: ExportQuality; onExportQualityChange?: (quality: ExportQuality) => void; // Export format settings exportFormat?: ExportFormat; onExportFormatChange?: (format: ExportFormat) => void; gifFrameRate?: GifFrameRate; onGifFrameRateChange?: (rate: GifFrameRate) => void; gifLoop?: boolean; onGifLoopChange?: (loop: boolean) => void; gifSizePreset?: GifSizePreset; onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; onExport?: () => void; unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string; } | null; onSaveUnsavedExport?: () => 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; onAnnotationDuplicate?: (id: string) => void; onAnnotationDelete?: (id: string) => void; selectedBlurId?: string | null; blurRegions?: AnnotationRegion[]; onBlurDataChange?: (id: string, blurData: BlurData) => void; onBlurDataCommit?: () => void; onBlurDelete?: (id: string) => void; selectedSpeedId?: string | null; selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; hasWebcam?: boolean; webcamLayoutPreset?: WebcamLayoutPreset; onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; webcamSizePreset?: WebcamSizePreset; onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; onSaveDiagnostic?: () => Promise; showCursor?: boolean; onShowCursorChange?: (show: boolean) => void; cursorSize?: number; onCursorSizeChange?: (size: number) => void; cursorSmoothing?: number; onCursorSmoothingChange?: (smoothing: number) => void; cursorMotionBlur?: number; onCursorMotionBlurChange?: (blur: number) => void; cursorClickBounce?: number; onCursorClickBounceChange?: (bounce: number) => void; cursorClipToBounds?: boolean; onCursorClipToBoundsChange?: (clip: boolean) => void; hasCursorData?: boolean; showCursorSettings?: boolean; } export default SettingsPanel; 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×" }, ]; type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; const MP4_EXPORT_SHORT_SIDES = { medium: 720, good: 1080, } as const; function formatSourceDimensions(videoElement?: HTMLVideoElement | null, cropRegion?: CropRegion) { const width = videoElement?.videoWidth ?? 0; const height = videoElement?.videoHeight ?? 0; if (width <= 0 || height <= 0) { return null; } const dimensions = calculateEffectiveSourceDimensions(width, height, cropRegion); return { ...dimensions, shortSide: Math.min(dimensions.width, dimensions.height) }; } export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomCustomScale, onZoomCustomScaleChange, onZoomCustomScaleCommit, selectedZoomFocusMode, onZoomFocusModeChange, selectedZoomFocus, onZoomFocusCoordinateChange, onZoomFocusCoordinateCommit, hasCursorTelemetry = false, selectedZoomId, onZoomDelete, selectedZoomRotationPreset, onZoomRotationPresetChange, selectedTrimId, onTrimDelete, shadowIntensity = 0, onShadowChange, onShadowCommit, showBlur, onBlurChange, motionBlurAmount = 0, onMotionBlurChange, onMotionBlurCommit, borderRadius = 0, onBorderRadiusChange, onBorderRadiusCommit, padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, onPaddingChange, onPaddingCommit, cropRegion, onCropChange, aspectRatio, videoElement, exportQuality = DEFAULT_EXPORT_SETTINGS.quality, onExportQualityChange, exportFormat = DEFAULT_EXPORT_SETTINGS.format, onExportFormatChange, gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate, onGifFrameRateChange, gifLoop = DEFAULT_GIF_SETTINGS.loop, onGifLoopChange, gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset, onGifSizePresetChange, gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions, onExport, unsavedExport, onSaveUnsavedExport, selectedAnnotationId, annotationRegions = [], onAnnotationContentChange, onAnnotationTypeChange, onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDuplicate, onAnnotationDelete, selectedBlurId, blurRegions = [], onBlurDataChange, onBlurDataCommit, onBlurDelete, selectedSpeedId, selectedSpeedValue, onSpeedChange, onSpeedDelete, hasWebcam = false, webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset, onWebcamLayoutPresetChange, webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape, onWebcamMaskShapeChange, webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset, onWebcamSizePresetChange, onWebcamSizePresetCommit, onSaveDiagnostic, showCursor = DEFAULT_CURSOR_SETTINGS.show, onShowCursorChange, cursorSize = DEFAULT_CURSOR_SETTINGS.size, onCursorSizeChange, cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, onCursorSmoothingChange, cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, onCursorMotionBlurChange, cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, onCursorClickBounceChange, cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, onCursorClipToBoundsChange, hasCursorData = false, showCursorSettings = true, }: SettingsPanelProps) { const t = useScopedT("settings"); const [activePanelMode, setActivePanelMode] = useState("background"); const sourceDimensions = formatSourceDimensions(videoElement, cropRegion); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical // `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted // on click — never the machine-specific file:// URL. const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []); const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(null); const colorPalette = [ "#FF0000", "#FFD700", "#00FF00", "#FFFFFF", "#0000FF", "#FF6B00", "#9B59B6", "#E91E63", "#00BCD4", "#FF5722", "#8BC34A", "#FFC107", "#34B27B", "#000000", "#607D8B", "#795548", ]; const [selectedColor, setSelectedColor] = useState("#ADADAD"); const [gradient, setGradient] = useState(GRADIENTS[0]); const [cropAspectLocked, setCropAspectLocked] = useState(false); const [cropAspectRatio, setCropAspectRatio] = useState(""); const isPortraitCanvas = isPortraitAspectRatio(aspectRatio); const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const handleCropNumericChange = useCallback( (field: "x" | "y" | "width" | "height", pixelValue: number) => { if (!cropRegion || !onCropChange) return; const next = { ...cropRegion }; switch (field) { case "x": next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width)); break; case "y": next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height)); break; case "width": { const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x)); if (cropAspectLocked && next.width > 0 && next.height > 0) { const ratio = next.width / next.height; const newHeight = newWidth / ratio; if (next.y + newHeight <= 1) { next.width = newWidth; next.height = newHeight; } } else { next.width = newWidth; } break; } case "height": { const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y)); if (cropAspectLocked && next.width > 0 && next.height > 0) { const ratio = next.width / next.height; const newWidth = newHeight * ratio; if (next.x + newWidth <= 1) { next.height = newHeight; next.width = newWidth; } } else { next.height = newHeight; } break; } } onCropChange(next); }, [cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked], ); const applyCropAspectPreset = useCallback( (preset: string) => { if (!cropRegion || !onCropChange) return; setCropAspectRatio(preset); if (preset === "") { setCropAspectLocked(false); return; } const [wStr, hStr] = preset.split(":"); const targetRatio = Number(wStr) / Number(hStr); const next = { ...cropRegion }; const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight); if (next.y + nextHeight <= 1 && nextHeight >= 0.05) { next.height = nextHeight; } else { const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth; if (next.x + nextWidth <= 1 && nextWidth >= 0.05) { next.width = nextWidth; } } onCropChange(next); setCropAspectLocked(true); }, [cropRegion, onCropChange, videoWidth, videoHeight], ); const getCropPixelValue = useCallback( (field: "x" | "y" | "width" | "height"): number => { if (!cropRegion) return 0; switch (field) { case "x": return Math.round(cropRegion.x * videoWidth); case "y": return Math.round(cropRegion.y * videoHeight); case "width": return Math.round(cropRegion.width * videoWidth); case "height": return Math.round(cropRegion.height * videoHeight); } }, [cropRegion, videoWidth, videoHeight], ); const [showCropDropdown, setShowCropDropdown] = useState(false); const handleCropToggle = () => setShowCropDropdown((open) => !open); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId); const hasCursorPanel = showCursorSettings && hasCursorData; const panelModes: Array<{ id: SettingsPanelMode; label: string; icon: ComponentType<{ className?: string }>; disabled?: boolean; }> = [ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, ...(hasCursorPanel ? [ { id: "cursor" as const, label: t("effects.title"), icon: MousePointerClick, }, ] : []), ]; const exportPanelMode = { id: "export" as const, label: exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton"), icon: Download, }; const activeModeLabel = hasTimelineSelection ? selectedZoomId ? t("zoom.level") : selectedSpeedId ? t("speed.playbackSpeed") : t("trim.deleteRegion") : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { onZoomDelete(selectedZoomId); } }; const handleTrimDeleteClick = () => { if (selectedTrimId && onTrimDelete) { onTrimDelete(selectedTrimId); } }; const handleImageUpload = (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; const file = files[0]; if (!isSupportedBackgroundImageType(file.type, file.name)) { toast.error(t("imageUpload.invalidFileType"), { description: t("imageUpload.jpgOnly"), }); 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(t("imageUpload.uploadSuccess")); } }; reader.onerror = () => { toast.error(t("imageUpload.failedToUpload"), { description: t("imageUpload.errorReading"), }); }; reader.readAsDataURL(file); // Reset input so the same file can be selected again event.target.value = ""; }; 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(WALLPAPER_PATHS[0]); } }; // Find selected annotation const selectedAnnotation = selectedAnnotationId ? annotationRegions.find((a) => a.id === selectedAnnotationId) : null; const selectedBlur = selectedBlurId ? blurRegions.find((region) => region.id === selectedBlurId) : null; const commonFooterLinks = (
{onSaveDiagnostic && ( )}
); // If an annotation is selected, show annotation settings instead if ( 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 } onDuplicate={ onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined } onDelete={() => onAnnotationDelete(selectedAnnotation.id)} />
{commonFooterLinks}
); } if (selectedBlur && onBlurDataChange && onBlurDelete) { return (
onBlurDataChange(selectedBlur.id, blurData)} onBlurDataCommit={onBlurDataCommit} onDelete={() => onBlurDelete(selectedBlur.id)} />
{commonFooterLinks}
); } return (
{panelModes.map((mode) => { const Icon = mode.icon; const isActive = activePanelMode === mode.id && !hasTimelineSelection; return ( ); })}
{activeModeLabel}
{zoomEnabled && (
{t("zoom.level")} {( selectedZoomCustomScale ?? (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE) ).toFixed(2)} ×
{ZOOM_DEPTH_OPTIONS.map((option) => { const effectiveScale = selectedZoomCustomScale ?? (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; return ( ); })}
{zoomEnabled && (
onZoomCustomScaleChange?.(values[0])} onValueCommit={() => onZoomCustomScaleCommit?.()} disabled={!zoomEnabled} className="relative flex w-full touch-none select-none items-center py-1" >
{MIN_ZOOM_SCALE.toFixed(1)}× {MAX_ZOOM_SCALE.toFixed(1)}×
)} {zoomEnabled && hasCursorTelemetry && (
{t("zoom.focusMode.title")}
{(["manual", "auto"] as const).map((mode) => { const isActive = selectedZoomFocusMode === mode; return ( ); })}
)} {zoomEnabled && selectedZoomFocusMode !== "auto" && selectedZoomFocus && onZoomFocusCoordinateChange && (() => { const effectiveZoomScale = selectedZoomCustomScale ?? (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE); const bounds = getFocusBoundsForScale(effectiveZoomScale); const xRange = bounds.maxX - bounds.minX; const yRange = bounds.maxY - bounds.minY; const focusToPercentX = (cx: number) => xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); const focusToPercentY = (cy: number) => yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); const percentToFocusX = (p: number) => xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; const percentToFocusY = (p: number) => yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; return (
{t("zoom.position.title")}
onZoomFocusCoordinateChange({ cx: percentToFocusX(p), cy: selectedZoomFocus.cy, }) } onCommit={onZoomFocusCoordinateCommit} />
onZoomFocusCoordinateChange({ cx: selectedZoomFocus.cx, cy: percentToFocusY(p), }) } onCommit={onZoomFocusCoordinateCommit} />
); })()} {zoomEnabled && (
{t("zoom.threeD.title")}
{ROTATION_3D_PRESET_ORDER.map((preset) => { const isActive = selectedZoomRotationPreset === preset; return ( ); })}
)} {zoomEnabled && ( )}
)} {trimEnabled && (
)} {selectedSpeedId && (
{t("speed.playbackSpeed")} {selectedSpeedId && selectedSpeedValue && ( {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`} )}
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( ); })}
{t("speed.customPlaybackSpeed")} {selectedSpeedId ? ( onSpeedChange?.(val)} onError={() => toast.error(t("speed.maxSpeedError"))} /> ) : (
--
×
)}
{selectedSpeedId && ( )}
)} {!hasTimelineSelection && ( {hasWebcam && activePanelMode === "layout" && (
{t("layout.title")}
{t("layout.preset")}
{webcamLayoutPreset === "picture-in-picture" && (
{t("layout.webcamShape")}
{( [ { value: "rectangle", label: "Rect" }, { value: "circle", label: "Circle" }, { value: "square", label: "Square" }, { value: "rounded", label: "Rounded" }, ] as Array<{ value: WebcamMaskShape; label: string }> ).map((shape) => ( ))}
)} {webcamLayoutPreset === "picture-in-picture" && (
{t("layout.webcamSize")}
{webcamSizePreset}%
onWebcamSizePresetChange?.(values[0])} onValueCommit={() => onWebcamSizePresetCommit?.()} min={10} max={50} step={1} className="w-full" />
)}
)} {(activePanelMode === "effects" || activePanelMode === "cursor") && (
{activePanelMode === "cursor" ? ( ) : ( )} {t("effects.title")}
{activePanelMode === "effects" && ( <>
{t("effects.blurBg")}
{t("effects.motionBlur")}
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
onMotionBlurChange?.(values[0])} onValueCommit={() => onMotionBlurCommit?.()} min={0} max={1} step={0.01} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("effects.shadow")}
{Math.round(shadowIntensity * 100)}%
onShadowChange?.(values[0])} onValueCommit={() => onShadowCommit?.()} min={0} max={1} step={0.01} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("effects.roundness")}
{borderRadius}px
onBorderRadiusChange?.(values[0])} onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} step={0.5} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("effects.padding")}
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} max={100} step={1} disabled={webcamLayoutPreset === "vertical-stack"} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
)} {activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
{t("cursor.show")}
{showCursor && ( <>
{t("cursor.clipToBounds")}
{t("cursor.size")}
{cursorSize.toFixed(1)}
onCursorSizeChange?.(values[0])} min={0.5} max={10} step={0.1} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("cursor.smoothing")}
{Math.round(cursorSmoothing * 100)}%
onCursorSmoothingChange?.(values[0])} min={0} max={1} step={0.01} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("cursor.motionBlur")}
{Math.round(cursorMotionBlur * 100)}%
onCursorMotionBlurChange?.(values[0])} min={0} max={1} step={0.01} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
{t("cursor.clickBounce")}
{cursorClickBounce.toFixed(1)}
onCursorClickBounceChange?.(values[0])} min={0} max={5} step={0.1} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" />
)}
)}
)} {activePanelMode === "background" && (
{t("background.title")}
{t("background.image")} {t("background.color")} {t("background.gradient")}
{customImages.map((imageUrl, idx) => { const isSelected = selected === imageUrl; return (
onWallpaperChange(imageUrl)} role="button" >
); })} {WALLPAPER_PATHS.map((canonicalPath, i) => { const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath; const isSelected = selected === canonicalPath; return (
onWallpaperChange(canonicalPath)} role="button" /> ); })}
{ setSelectedColor(color); onWallpaperChange(color); }} />
{GRADIENTS.map((g, idx) => (
{ setGradient(g); onWallpaperChange(g); }} role="button" /> ))}
)} )}
{showCropDropdown && cropRegion && onCropChange && ( <>
setShowCropDropdown(false)} />
{t("crop.cropVideo")}

{t("crop.dragInstruction")}

{[ { label: "X", field: "x" as const, max: videoWidth }, { label: "Y", field: "y" as const, max: videoHeight }, { label: "W", field: "width" as const, max: videoWidth }, { label: "H", field: "height" as const, max: videoHeight }, ].map(({ label, field, max }) => (
handleCropNumericChange(field, Number(e.target.value))} className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
))}

{videoWidth} × {videoHeight}px

)}
{activePanelMode === "export" && !hasTimelineSelection && ( <>
{exportFormat === "mp4" && (
{sourceDimensions && (
{t("exportQuality.title")} Source {sourceDimensions.width}x{sourceDimensions.height}
)}
)} {exportFormat === "gif" && (
{GIF_FRAME_RATES.map((rate) => ( ))}
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( ))}
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
{t("gifSettings.loop")}
)} {unsavedExport && ( )} )} {commonFooterLinks}
); }