From 055baa88f902e9cb8a7a58c18e95d034f858d3f0 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:51:54 +1100 Subject: [PATCH] Refine video editor layout and export flow --- src/components/ui/slider.tsx | 4 +- src/components/video-editor/ExportDialog.tsx | 82 +- .../video-editor/ExportSettingsMenu.tsx | 208 +++ .../video-editor/PlaybackControls.tsx | 42 +- src/components/video-editor/SettingsPanel.tsx | 1403 +++++++---------- src/components/video-editor/SliderControl.tsx | 109 +- src/components/video-editor/VideoEditor.tsx | 293 +++- src/components/video-editor/VideoPlayback.tsx | 11 + src/components/video-editor/timeline/Item.tsx | 10 +- .../timeline/ItemGlass.module.css | 10 +- src/components/video-editor/timeline/Row.tsx | 6 +- .../video-editor/timeline/Subrow.tsx | 2 +- .../video-editor/timeline/TimelineEditor.tsx | 182 +-- .../video-editor/timeline/TimelineWrapper.tsx | 2 +- 14 files changed, 1304 insertions(+), 1060 deletions(-) create mode 100644 src/components/video-editor/ExportSettingsMenu.tsx diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 1c7321c1..587f73f6 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -15,10 +15,10 @@ const Slider = React.forwardRef< )} {...props} > - + - + )) Slider.displayName = SliderPrimitive.Root.displayName diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx index 94a39b81..2b39c2f8 100644 --- a/src/components/video-editor/ExportDialog.tsx +++ b/src/components/video-editor/ExportDialog.tsx @@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'; import type { ExportProgress } from '@/lib/exporter'; import { toast } from 'sonner'; // Add this import import { useScopedT } from "../../contexts/I18nContext"; +import { ExportSettingsMenu } from './ExportSettingsMenu'; +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from '@/lib/exporter'; interface ExportDialogProps { @@ -17,6 +19,19 @@ interface ExportDialogProps { canRetrySave?: boolean; exportFormat?: 'mp4' | 'gif'; exportedFilePath?: string; + exportQuality?: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + 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 }; + onLoadProject?: () => void; + onSaveProject?: () => void; + onStartExport?: () => void; } export function ExportDialog({ @@ -30,6 +45,19 @@ export function ExportDialog({ canRetrySave = false, exportFormat = 'mp4', exportedFilePath, // Add this line + exportQuality = 'good', + onExportQualityChange, + onExportFormatChange, + gifFrameRate = '10', + onGifFrameRateChange, + gifLoop = true, + onGifLoopChange, + gifSizePreset = 'medium', + onGifSizePresetChange, + gifOutputDimensions = { width: 1280, height: 720 }, + onLoadProject, + onSaveProject, + onStartExport, }: ExportDialogProps) { const t = useScopedT('dialogs'); const [showSuccess, setShowSuccess] = useState(false); @@ -48,6 +76,12 @@ export function ExportDialog({ } }, [isOpen, isExporting, progress]); + useEffect(() => { + if (!isOpen) { + setShowSuccess(false); + } + }, [isOpen]); + useEffect(() => { if (!isExporting && progress && progress.percentage >= 100 && !error) { setShowSuccess(true); @@ -61,6 +95,8 @@ export function ExportDialog({ if (!isOpen) return null; + const showSettings = !isExporting && !progress && !error && !showSuccess; + const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video'; // Determine if we're in the compiling phase (frames done but still exporting) @@ -110,7 +146,43 @@ export function ExportDialog({ className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200" onClick={isExporting ? undefined : onClose} /> -
+
+ {showSettings ? ( +
+
+
+ {t('export.exportingFormat', undefined, { format: formatLabel })} + Choose format and quality before exporting. +
+ +
+ +
+ ) : ( +
{showSuccess ? ( @@ -226,13 +298,13 @@ export function ExportDialog({ // Show render progress if available, otherwise animated indeterminate bar renderProgress !== undefined && renderProgress > 0 ? (
) : (
)} @@ -292,6 +364,8 @@ export function ExportDialog({

)} +
+ )}
); diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx new file mode 100644 index 00000000..93cdfc56 --- /dev/null +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -0,0 +1,208 @@ +import { Download, Film, FolderOpen, Image, Save } from "lucide-react"; +import { LayoutGroup, motion } from "motion/react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { + ExportFormat, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; +import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; +import { cn } from "@/lib/utils"; + +interface ExportSettingsMenuProps { + exportFormat: ExportFormat; + onExportFormatChange?: (format: ExportFormat) => void; + exportQuality: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + gifFrameRate: GifFrameRate; + onGifFrameRateChange?: (rate: GifFrameRate) => void; + gifLoop: boolean; + onGifLoopChange?: (loop: boolean) => void; + gifSizePreset: GifSizePreset; + onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifOutputDimensions: { width: number; height: number }; + onLoadProject?: () => void; + onSaveProject?: () => void; + onExport?: () => void; + className?: string; +} + +export function ExportSettingsMenu({ + exportFormat, + onExportFormatChange, + exportQuality, + onExportQualityChange, + gifFrameRate, + onGifFrameRateChange, + gifLoop, + onGifLoopChange, + gifSizePreset, + onGifSizePresetChange, + gifOutputDimensions, + onLoadProject, + onSaveProject, + onExport, + className, +}: ExportSettingsMenuProps) { + const tSettings = useScopedT("settings"); + + return ( +
+
+ + Export + +
+ +
+ + {([ + { value: "mp4", label: tSettings("export.mp4"), icon: Film }, + { value: "gif", label: tSettings("export.gif"), icon: Image }, + ] as const).map((option) => { + const Icon = option.icon; + const isActive = exportFormat === option.value; + return ( + + ); + })} + +
+ + {exportFormat === "mp4" ? ( + +
+ {([ + { value: "medium", label: tSettings("export.quality.low") }, + { value: "good", label: tSettings("export.quality.medium") }, + { value: "high", label: tSettings("export.quality.high") }, + { value: "source", label: tSettings("export.quality.original") }, + ] as const).map((option) => { + const isActive = exportQuality === option.value; + return ( + + ); + })} +
+
+ ) : ( +
+
+ +
+ {GIF_FRAME_RATES.map((rate) => { + const isActive = gifFrameRate === rate.value; + return ( + + ); + })} +
+
+ +
+ {Object.entries(GIF_SIZE_PRESETS).map(([key]) => { + const isActive = gifSizePreset === key; + return ( + + ); + })} +
+
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {tSettings("export.loop")} + +
+
+
+ )} + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index d6afac39..820e13f0 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -1,4 +1,4 @@ -import { Pause, Play } from "lucide-react"; +import { Pause, Play, Volume2, VolumeX } from "lucide-react"; import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; @@ -9,6 +9,8 @@ interface PlaybackControlsProps { duration: number; onTogglePlayPause: () => void; onSeek: (time: number) => void; + volume: number; + onVolumeChange: (volume: number) => void; } export default function PlaybackControls({ @@ -17,6 +19,8 @@ export default function PlaybackControls({ duration, onTogglePlayPause, onSeek, + volume, + onVolumeChange, }: PlaybackControlsProps) { const t = useScopedT("editor"); function formatTime(seconds: number) { @@ -30,10 +34,14 @@ export default function PlaybackControls({ onSeek(parseFloat(e.target.value)); } + function handleVolumeChange(e: React.ChangeEvent) { + onVolumeChange(Number(e.target.value)); + } + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; return ( -
+
+
+ onBackgroundBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(1)}px`} + parseInput={(text) => parseFloat(text.replace(/px$/, ""))} + /> + + +
+ +
+ {([ + { value: "image", label: tSettings("background.image") }, + { 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 ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} + + {(wallpaperPreviewPaths.length > 0 + ? wallpaperPreviewPaths + : WALLPAPER_PATHS + ).map((previewPath, index) => { + const wallpaper = BUILT_IN_WALLPAPERS[index]; + const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath; + const isSelected = getWallpaperTileState(wallpaperValue, previewPath); + return ( +
onWallpaperChange(wallpaperValue)} + role="button" + /> + ); + })} +
+
+ ) : 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 if ( + !isBackgroundPanel && selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && @@ -472,613 +748,207 @@ export function SettingsPanel({ ); } - return ( -
-
-
-
- {tSettings("zoom.level")} -
- {zoomEnabled && selectedZoomDepth && ( - - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} - - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- {!zoomEnabled && ( -

- {tSettings("zoom.selectRegion")} -

- )} - {zoomEnabled && ( - - )} -
- - {trimEnabled && ( -
- -
- )} - -
-
+ if (isBackgroundPanel) { + return ( +
+
+
+ - {tSettings("speed.playbackSpeed")} + {tSettings("background.title")} - {selectedSpeedId && selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? - `${selectedSpeedValue}×`} - - )}
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
- {!selectedSpeedId && ( -

- {tSettings("speed.selectRegion")} -

- )} - {selectedSpeedId && ( - - )} + {backgroundSettingsContent}
+
+ ); + } - - - -
- - {tSettings("effects.title")} + const zoomSectionContent = ( +
+
+
+ Zoom + +
+
+ Connect + +
+
+ onZoomMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(text) => parseFloat(text.replace(/×$/, ""))} + /> +
+ ); + + const frameSectionContent = ( +
+
+ 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$/, ""))} /> + onPaddingChange?.(v)} formatValue={(v) => `${v}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> +
+ Remove Background + +
+
+
+ ); + + const cropSectionContent = ( +
+
+ 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 effectSectionContent = (() => { + switch (activeEffectSection) { + case "scene": + case "zoom": + case "frame": + case "crop": + return ( +
+ {backgroundSettingsContent} + {zoomSectionContent} + {frameSectionContent} + {cropSectionContent} +
+ ); + case "cursor": + return ( +
+
+
+ Cursor +
- - -
-
-
- {tSettings("effects.showCursor")} -
+
+
-
-
-
- {tSettings("effects.loopCursor")} -
-
+ +
-
- onBackgroundBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(1)}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
+
- -
-
- onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
- -
-
- {tSettings("effects.connectZooms")} +
+
+ onCursorSizeChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorSmoothingChange?.(v)} formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} parseInput={(text) => parseFloat(text)} /> + onCursorMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorClickBounceChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} parseInput={(text) => parseFloat(text.replace(/×$/, ""))} /> + onCursorSwayChange?.(fromCursorSwaySliderValue(v))} + formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} + parseInput={(text) => { + const normalized = text.trim().toLowerCase(); + if (normalized === "off") return 0; + return parseFloat(text.replace(/×$/, "")); + }} + /> +
+
+ ); + case "webcam": + return ( +
+
+ Webcam + +
+
+
Show updateWebcam({ enabled })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+
React To Zoom updateWebcam({ reactToZoom })} className="data-[state=checked]:bg-[#2563EB] scale-75" />
+ updateWebcam({ size: v })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> + 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} /> +
+
+
+
Footage
+
{webcamFileName ?? tSettings("effects.webcamFootageDescription")}
- -
-
- -
-
- onCursorSizeChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorSmoothingChange?.(v)} - formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} - parseInput={(t) => parseFloat(t)} - /> -
-
- -
-
- onCursorMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorClickBounceChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- -
-
- onCursorSwayChange?.(fromCursorSwaySliderValue(v))} - formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} - parseInput={(t) => { - const normalized = t.trim().toLowerCase(); - if (normalized === "off") { - return 0; - } - - return parseFloat(t.replace(/×$/, "")); - }} - /> -
-
- onShadowChange?.(v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} - /> -
-
- updateWebcam({ size: v })} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, ""))} - /> -
-
- updateWebcam({ cornerRadius: v })} - formatValue={(v) => `${Math.round(v)}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
- updateWebcam({ shadow: v })} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} - /> -
-
-
- {tSettings("effects.webcamReactToZoom")} -
- updateWebcam({ reactToZoom })} - className="data-[state=checked]:bg-[#2563EB] scale-90" - /> -
-
-
- {tSettings("effects.webcam")} -
- updateWebcam({ enabled })} - className="data-[state=checked]:bg-[#2563EB] scale-90" - /> -
-
-
-
-
- {tSettings("effects.webcamFootage")} -
-
- {webcamFileName ?? tSettings("effects.webcamFootageDescription")} -
-
-
- - {webcam?.sourcePath ? ( - - ) : null} -
+
+ + {webcam?.sourcePath ? : null}
-
- onBorderRadiusChange?.(v)} - formatValue={(v) => `${v}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
- onPaddingChange?.(v)} - formatValue={(v) => `${v}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, ""))} - /> -
-
-
{tSettings("effects.removeBackground")}
- -
+
+
+ ); + } + })(); - - - - - - -
- - {tSettings("background.title")} -
-
- - setBackgroundTab(value as BackgroundTab)} - className="w-full" - > - - - {tSettings("background.image")} - - - {tSettings("background.color")} - - - {tSettings("background.gradient")} - - - -
- - - - -
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > - -
- ); - })} - - {(wallpaperPreviewPaths.length > 0 - ? wallpaperPreviewPaths - : WALLPAPER_PATHS - ).map((previewPath, index) => { - const wallpaper = BUILT_IN_WALLPAPERS[index]; - const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath; - const isSelected = (() => { - if (!selected) return false; - if (selected === wallpaperValue || selected === previewPath) return true; - try { - const clean = (s: string) => - s.replace(/^file:\/\//, "").replace(/^\//, ""); - if (clean(selected).endsWith(clean(wallpaperValue))) return true; - if (clean(wallpaperValue).endsWith(clean(selected))) return true; - if (clean(selected).endsWith(clean(previewPath))) return true; - if (clean(previewPath).endsWith(clean(selected))) return true; - } catch { - return false; - } - return false; - })(); - return ( -
onWallpaperChange(wallpaperValue)} - role="button" - /> - ); - })} -
- - - -
- { - setSelectedColor(color.hex); - onWallpaperChange(color.hex); - }} - style={{ - width: "100%", - borderRadius: "8px", - }} - /> -
-
- - -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} -
- -
- - - - + return ( +
+
+ + + {effectSectionContent} + +
{showCropModal && cropRegion && onCropChange && ( @@ -1104,8 +974,8 @@ export function SettingsPanel({
@@ -1121,191 +991,94 @@ export function SettingsPanel({ )} -
-
- - -
- - {exportFormat === "mp4" && ( -
- - - - -
- )} - - {exportFormat === "gif" && ( -
+
+
+
+ {tSettings("zoom.level")}
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
-
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - -
- {tSettings("export.loop")} - -
+ {zoomEnabled && selectedZoomDepth && ( + + {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + + )} +
- )} - -
- - +
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+ {!zoomEnabled &&

{tSettings("zoom.selectRegion")}

} + {zoomEnabled && ( + + )} + {trimEnabled && ( + + )}
- - -
- ); - }} - className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors" - > - - {tSettings("export.reportBug")} - - + })} +
+ {!selectedSpeedId &&

{tSettings("speed.selectRegion")}

} + {selectedSpeedId && ( + + )}
diff --git a/src/components/video-editor/SliderControl.tsx b/src/components/video-editor/SliderControl.tsx index 7025bc65..358658a8 100644 --- a/src/components/video-editor/SliderControl.tsx +++ b/src/components/video-editor/SliderControl.tsx @@ -1,6 +1,4 @@ -import { useState, useRef, useEffect } from "react"; -import { Slider } from "@/components/ui/slider"; -import { RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; interface SliderControlProps { label: string; @@ -18,92 +16,51 @@ interface SliderControlProps { export function SliderControl({ label, value, - defaultValue, + defaultValue: _defaultValue, min, max, step, onChange, formatValue, - parseInput, + parseInput: _parseInput, accentColor = "blue", }: SliderControlProps) { - const [editing, setEditing] = useState(false); - const [editText, setEditText] = useState(""); - const inputRef = useRef(null); - const isModified = value !== defaultValue; - - useEffect(() => { - if (editing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editing]); - - const commitEdit = () => { - const parsed = parseInput(editText); - if (parsed != null && !isNaN(parsed)) { - onChange(Math.min(max, Math.max(min, parsed))); - } - setEditing(false); - }; - - const cancelEdit = () => { - setEditing(false); - }; + const pct = Math.min(100, Math.max(0, ((value - min) / (max - min || 1)) * 100)); + const dividerClass = + accentColor === "purple" + ? "bg-white/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]" + : "bg-white/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]"; return ( - <> -
-
-
{label}
- {isModified && ( - - )} -
- {editing ? ( - setEditText(e.target.value)} - onBlur={commitEdit} - onKeyDown={(e) => { - if (e.key === "Enter") commitEdit(); - if (e.key === "Escape") cancelEdit(); - }} - className="w-14 text-[10px] text-right font-mono bg-white/10 border border-white/20 rounded px-1 py-0 text-slate-200 outline-none focus:border-white/40" - /> - ) : ( - { - setEditText(formatValue(value)); - setEditing(true); - }} - > - {formatValue(value)} - +
+
0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0, + }} + /> +
- onChange(values[0])} + style={{ left: `calc(${pct}% - 8px)` }} + /> + + {label} + + + {formatValue(value)} + + onChange(Number(e.target.value))} + className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0" /> - +
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 214b78da..55095024 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,8 +1,10 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages } from "lucide-react"; +import { Camera, Download, FolderOpen, Languages, MousePointer2, Save, Sparkles } from "lucide-react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; @@ -35,9 +37,10 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; -import { SettingsPanel } from "./SettingsPanel"; +import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { + detectInteractionCandidates, normalizeCursorTelemetry, } from "./timeline/zoomSuggestionUtils"; import { @@ -71,6 +74,16 @@ import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; const LOOP_CURSOR_END_WINDOW_MS = 670; +const EDITOR_SECTION_BUTTONS: Array<{ + id: EditorEffectSection; + label: string; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { id: "scene", label: "Scene", icon: Sparkles }, + { id: "cursor", label: "Cursor", icon: MousePointer2 }, + { id: "webcam", label: "Webcam", icon: Camera }, +]; + type EditorHistorySnapshot = { zoomRegions: ZoomRegion[]; trimRegions: TrimRegion[]; @@ -160,7 +173,9 @@ export default function VideoEditor() { const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); + const [previewVolume, setPreviewVolume] = useState(1); const [aspectRatio, setAspectRatio] = useState(initialEditorPreferences.aspectRatio); + const [activeEffectSection, setActiveEffectSection] = useState("scene"); const [exportQuality, setExportQuality] = useState( initialEditorPreferences.exportQuality, ); @@ -188,6 +203,7 @@ export default function VideoEditor() { const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); + const autoSuggestedVideoPathRef = useRef(null); const historyPastRef = useRef([]); const historyFutureRef = useRef([]); const historyCurrentRef = useRef(null); @@ -209,6 +225,23 @@ export default function VideoEditor() { }; }, []); + const gifOutputDimensions = useMemo( + () => + calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + ), + [gifSizePreset, videoPath], + ); + + const projectDisplayName = useMemo(() => { + const fileName = currentProjectPath?.split(/[\\/]/).pop() ?? ""; + const withoutExtension = fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, ""); + return withoutExtension || "Untitled"; + }, [currentProjectPath]); + const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { return { zoomRegions, @@ -908,6 +941,84 @@ export default function VideoEditor() { return [...zoomRegions.filter((region) => region.id !== loopEndRegion.id), loopEndRegion]; }, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]); + useEffect(() => { + if ( + !videoPath || + duration <= 0 || + zoomRegions.length > 0 || + normalizedCursorTelemetry.length < 2 + ) { + return; + } + + if (autoSuggestedVideoPathRef.current === videoPath) { + return; + } + + const totalMs = Math.max(0, Math.round(duration * 1000)); + if (totalMs <= 0) { + return; + } + + const candidates = detectInteractionCandidates(normalizedCursorTelemetry); + if (candidates.length === 0) { + autoSuggestedVideoPathRef.current = videoPath; + return; + } + + const DEFAULT_DURATION_MS = 1100; + const MIN_SPACING_MS = 1800; + const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength); + const acceptedCenters: number[] = []; + + setZoomRegions((prev) => { + if (prev.length > 0) { + return prev; + } + + const reservedSpans: Array<{ start: number; end: number }> = []; + const additions: ZoomRegion[] = []; + let nextId = nextZoomIdRef.current; + + sortedCandidates.forEach((candidate) => { + const tooCloseToAccepted = acceptedCenters.some( + (center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS, + ); + if (tooCloseToAccepted) { + return; + } + + const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2); + const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS)); + const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS); + + const hasOverlap = reservedSpans.some((span) => endMs > span.start && startMs < span.end); + if (hasOverlap) { + return; + } + + additions.push({ + id: `zoom-${nextId++}`, + startMs, + endMs, + depth: DEFAULT_ZOOM_DEPTH, + focus: clampFocusToDepth(candidate.focus, DEFAULT_ZOOM_DEPTH), + }); + reservedSpans.push({ start: startMs, end: endMs }); + acceptedCenters.push(candidate.centerTimeMs); + }); + + if (additions.length === 0) { + return prev; + } + + nextZoomIdRef.current = nextId; + return [...prev, ...additions]; + }); + + autoSuggestedVideoPathRef.current = videoPath; + }, [videoPath, duration, normalizedCursorTelemetry, zoomRegions.length]); + // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; @@ -1824,14 +1935,25 @@ export default function VideoEditor() { setExportError("Save dialog canceled. Click Save Again to save without re-rendering."); return; } + setShowExportDialog(true); + setExportProgress(null); + setExportError(null); + }, [ + videoPath, + hasPendingExportSave, + ]); + const handleStartExportFromDialog = useCallback(() => { const video = videoPlaybackRef.current?.video; + if (!videoPath) { + toast.error("No video loaded"); + return; + } if (!video) { toast.error("Video not ready"); return; } - // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; const gifDimensions = calculateOutputDimensions( @@ -1856,21 +1978,9 @@ export default function VideoEditor() { : undefined, }; - setShowExportDialog(true); setExportError(null); - - // Start export immediately handleExport(settings); - }, [ - videoPath, - hasPendingExportSave, - exportFormat, - exportQuality, - gifFrameRate, - gifLoop, - gifSizePreset, - handleExport, - ]); + }, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { @@ -1886,6 +1996,8 @@ export default function VideoEditor() { const handleExportDialogClose = useCallback(() => { setShowExportDialog(false); + setExportProgress(null); + setExportError(null); setExportedFilePath(undefined); }, []); @@ -1957,46 +2069,113 @@ export default function VideoEditor() { } return ( -
+
- Recordly +
+ {projectDisplayName} + .recordly +
+ + +
+
+
-
+
{/* Left Column - Video & Timeline */} -
+
{/* Top section: video preview and controls */} - -
+ +
{/* Video preview */}
-
+ +
+ {EDITOR_SECTION_BUTTONS.map((section) => { + const Icon = section.icon; + const isActive = activeEffectSection === section.id; + return ( + setActiveEffectSection(section.id)} + title={section.label} + className="group relative flex h-8 w-8 items-center justify-center text-white/75 outline-none transition-colors hover:text-white focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" + animate={{ scale: isActive ? 1.06 : 1, opacity: isActive ? 1 : 0.82 }} + transition={{ type: "spring", stiffness: 420, damping: 28 }} + > + + + + + {isActive ? ( + + ) : null} + + + ); + })} +
+
+
+
+
+
{/* Playback controls */} @@ -2075,19 +2256,21 @@ export default function VideoEditor() { duration={duration} onTogglePlayPause={togglePlayPause} onSeek={handleSeek} + volume={previewVolume} + onVolumeChange={setPreviewVolume} />
- +
{/* Timeline section */} - -
+ +
- {/* Right section: settings panel */} - + + /> +
@@ -2229,6 +2397,19 @@ export default function VideoEditor() { canRetrySave={hasPendingExportSave} exportFormat={exportFormat} exportedFilePath={exportedFilePath} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={gifOutputDimensions} + onLoadProject={handleLoadProject} + onSaveProject={handleSaveProject} + onStartExport={handleStartExportFromDialog} />
); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index de3c58a0..5ac210a0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -137,6 +137,7 @@ interface VideoPlaybackProps { cursorMotionBlur?: number; cursorClickBounce?: number; cursorSway?: number; + volume?: number; } export interface VideoPlaybackRef { @@ -190,6 +191,7 @@ const VideoPlayback = forwardRef( cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, cursorSway = DEFAULT_CURSOR_SWAY, + volume = 1, }, ref, ) => { @@ -385,6 +387,15 @@ const VideoPlayback = forwardRef( } }, [updateOverlayForRegion, cropRegion, borderRadius, padding, applyWebcamBubbleLayout]); + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const nextVolume = Math.max(0, Math.min(1, volume)); + video.volume = nextVolume; + video.muted = nextVolume <= 0.001; + }, [volume, videoPath]); + useEffect(() => { layoutVideoContentRef.current = layoutVideoContent; }, [layoutVideoContent]); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 50670f4c..cc387ea9 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -84,8 +84,8 @@ export default function Item({ [span.start, span.end], ); - const MIN_ITEM_PX = 6; - const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX }; + const MIN_ITEM_PX = 6; + const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX, height: "100%" }; return (
onSelect?.()} - className="group" + className="group h-full" > -
+
{ event.stopPropagation(); onSelect?.(); diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 9c96ea22..95cfe5e3 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -5,7 +5,7 @@ background: rgba(37, 99, 235, 0.15); border: 1px solid rgba(37, 99, 235, 0.3); box-shadow: 0 2px 12px 0 rgba(37, 99, 235, 0.1) inset; - margin: 2px 0; + margin: 1px 0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); @@ -31,7 +31,7 @@ background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.3); box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset; - margin: 2px 0; + margin: 1px 0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); @@ -57,7 +57,7 @@ background: rgba(180, 160, 70, 0.15); border: 1px solid rgba(180, 160, 70, 0.3); box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset; - margin: 2px 0; + margin: 1px 0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); @@ -83,7 +83,7 @@ background: rgba(245, 158, 11, 0.15); border: 1px solid rgba(245, 158, 11, 0.3); box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset; - margin: 2px 0; + margin: 1px 0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); @@ -109,7 +109,7 @@ background: rgba(168, 85, 247, 0.15); border: 1px solid rgba(168, 85, 247, 0.3); box-shadow: 0 2px 12px 0 rgba(168, 85, 247, 0.1) inset; - margin: 2px 0; + margin: 1px 0; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 2e22621d..434aab29 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -14,8 +14,8 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = ' return (
{label && (
+
@@ -1353,8 +1359,8 @@ export default function TimelineEditor({ } return ( -
-
+
+
- + {ASPECT_RATIOS.map((ratio) => (
setSelectedKeyframeId(null)} onWheel={handleTimelineWheel} > diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index cb53a65b..a40f9a14 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -297,7 +297,7 @@ export default function TimelineWrapper({ onDragEnd={onDragEndWithTooltip} autoScroll={{ enabled: false }} > -
+
{children} {/* Floating tooltip shown during drag/resize */}