mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Refine video editor layout and export flow
This commit is contained in:
@@ -15,10 +15,10 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Track className="relative h-2.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-[#2563EB]" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#2563EB] bg-[#2563EB] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB]/50 disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#2563EB] bg-[#2563EB] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB]/50 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
{showSettings ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">{t('export.exportingFormat', undefined, { format: formatLabel })}</span>
|
||||
<span className="text-sm text-slate-400">Choose format and quality before exporting.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ExportSettingsMenu
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={onExportFormatChange}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={onExportQualityChange}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={onGifFrameRateChange}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={onGifLoopChange}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={onGifSizePresetChange}
|
||||
gifOutputDimensions={gifOutputDimensions}
|
||||
onLoadProject={onLoadProject}
|
||||
onSaveProject={onSaveProject}
|
||||
onExport={onStartExport}
|
||||
className="border-white/8 bg-[#121216] p-0 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
@@ -226,13 +298,13 @@ export function ExportDialog({
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.3)] transition-all duration-300 ease-out"
|
||||
className="h-full bg-[#2563EB] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.3)]"
|
||||
className="absolute h-full w-1/3 bg-[#2563EB]"
|
||||
style={{
|
||||
animation: 'indeterminate 1.5s ease-in-out infinite',
|
||||
}}
|
||||
@@ -247,7 +319,7 @@ export function ExportDialog({
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.3)] transition-all duration-300 ease-out"
|
||||
className="h-full bg-[#2563EB] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
@@ -292,6 +364,8 @@ export function ExportDialog({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn("w-full rounded-2xl border border-white/10 bg-[#17171a] p-3 text-slate-200", className)}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Export
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<LayoutGroup id="header-export-format-toggle">
|
||||
{([
|
||||
{ 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 (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onExportFormatChange?.(option.value)}
|
||||
className={cn(
|
||||
"relative flex-1 overflow-hidden rounded-xl border py-2 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "border-[#2563EB]/50 text-white"
|
||||
: "border-white/10 bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="header-export-format-pill"
|
||||
className="absolute inset-0 rounded-xl bg-[#2563EB]/10"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
|
||||
{exportFormat === "mp4" ? (
|
||||
<LayoutGroup id="header-export-quality-toggle">
|
||||
<div className="mb-3 grid h-8 w-full grid-cols-4 rounded-xl border border-white/5 bg-white/5 p-0.5">
|
||||
{([
|
||||
{ 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 (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onExportQualityChange?.(option.value)}
|
||||
className="relative rounded-lg text-[11px] font-medium transition-colors"
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="header-export-quality-pill"
|
||||
className="absolute inset-0 rounded-lg bg-white"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn("relative z-10", isActive ? "text-black" : "text-slate-400 hover:text-slate-200")}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
) : (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGroup id="header-gif-frame-rate-toggle">
|
||||
<div className="grid h-8 flex-1 grid-cols-4 rounded-xl border border-white/5 bg-white/5 p-0.5">
|
||||
{GIF_FRAME_RATES.map((rate) => {
|
||||
const isActive = gifFrameRate === rate.value;
|
||||
return (
|
||||
<button
|
||||
key={rate.value}
|
||||
type="button"
|
||||
onClick={() => onGifFrameRateChange?.(rate.value)}
|
||||
className="relative rounded-lg text-[11px] font-medium transition-colors"
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span layoutId="header-gif-frame-rate-pill" className="absolute inset-0 rounded-lg bg-white" transition={{ type: "spring", stiffness: 420, damping: 34 }} />
|
||||
) : null}
|
||||
<span className={cn("relative z-10", isActive ? "text-black" : "text-slate-400 hover:text-slate-200")}>
|
||||
{rate.value}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
<LayoutGroup id="header-gif-size-toggle">
|
||||
<div className="grid h-8 flex-1 grid-cols-3 rounded-xl border border-white/5 bg-white/5 p-0.5">
|
||||
{Object.entries(GIF_SIZE_PRESETS).map(([key]) => {
|
||||
const isActive = gifSizePreset === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
|
||||
className="relative rounded-lg text-[11px] font-medium transition-colors"
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span layoutId="header-gif-size-pill" className="absolute inset-0 rounded-lg bg-white" transition={{ type: "spring", stiffness: 420, damping: 34 }} />
|
||||
) : null}
|
||||
<span className={cn("relative z-10", isActive ? "text-black" : "text-slate-400 hover:text-slate-200")}>
|
||||
{key === "original" ? "Orig" : key.charAt(0).toUpperCase() + key.slice(1, 3)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-400">{tSettings("export.loop")}</span>
|
||||
<Switch checked={gifLoop} onCheckedChange={onGifLoopChange} className="scale-75 data-[state=checked]:bg-[#2563EB]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="outline" onClick={onLoadProject} className="h-8 gap-1.5 border-white/10 bg-white/5 text-[10px] font-medium text-slate-300 hover:bg-white/10">
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{tSettings("export.loadProject")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onSaveProject} className="h-8 gap-1.5 border-white/10 bg-white/5 text-[10px] font-medium text-slate-300 hover:bg-white/10">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{tSettings("export.saveProject")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="lg" onClick={onExport} className="h-11 w-full gap-2 rounded-lg bg-[#2563EB] text-sm font-semibold text-white transition-colors duration-200 hover:bg-[#2563EB]/90">
|
||||
<Download className="h-4 w-4" />
|
||||
{tSettings("export.exportVideo", undefined, {
|
||||
format: exportFormat === "gif" ? "GIF" : "Video",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>) {
|
||||
onVolumeChange(Number(e.target.value));
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
|
||||
<div className="flex items-center gap-2 px-1.5 pr-3 py-0.5 rounded-full bg-black/75 backdrop-blur-md border border-white/10 transition-colors duration-300 hover:bg-black/80 hover:border-white/20">
|
||||
<Button
|
||||
onClick={onTogglePlayPause}
|
||||
size="icon"
|
||||
@@ -41,7 +49,7 @@ export default function PlaybackControls({
|
||||
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
|
||||
isPlaying
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||
: "bg-white text-black hover:bg-white/90",
|
||||
)}
|
||||
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
|
||||
>
|
||||
@@ -75,7 +83,7 @@ export default function PlaybackControls({
|
||||
|
||||
{/* Custom Thumb (visual only, follows progress) */}
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: "translateX(-50%)",
|
||||
@@ -86,6 +94,32 @@ export default function PlaybackControls({
|
||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 pl-1">
|
||||
{volume <= 0.001 ? (
|
||||
<VolumeX className="h-3.5 w-3.5 text-slate-400" />
|
||||
) : (
|
||||
<Volume2 className="h-3.5 w-3.5 text-slate-400" />
|
||||
)}
|
||||
<div className="group relative flex h-6 w-20 items-center">
|
||||
<div className="absolute left-0 right-0 h-0.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-white/70" style={{ width: `${volume * 100}%` }} />
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute h-2.5 w-2.5 rounded-full bg-white transition-transform duration-100 group-hover:scale-125"
|
||||
style={{ left: `${volume * 100}%`, transform: "translateX(-50%)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">{label}</div>
|
||||
{isModified && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(defaultValue)}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[10px] text-slate-500 font-mono cursor-text hover:text-slate-300 transition-colors"
|
||||
onClick={() => {
|
||||
setEditText(formatValue(value));
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<div className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-black/60 px-1.5">
|
||||
<div
|
||||
className="absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-white/[0.08] shadow-[0_4px_10px_0_rgba(0,0,0,0.18)] transition-none"
|
||||
style={{
|
||||
width: pct > 0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-[18%] top-[18%] z-10 w-[2px] rounded-full transition-none",
|
||||
dividerClass,
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
value={[value]}
|
||||
onValueChange={(values) => onChange(values[0])}
|
||||
style={{ left: `calc(${pct}% - 8px)` }}
|
||||
/>
|
||||
<span className="pointer-events-none relative z-10 flex-1 pl-3 text-[12px] font-medium text-slate-300">
|
||||
{label}
|
||||
</span>
|
||||
<span className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-slate-100">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
className={
|
||||
accentColor === "purple"
|
||||
? "w-full [&_[role=slider]]:bg-[#8b5cf6] [&_[role=slider]]:border-[#8b5cf6] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
: "w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [previewVolume, setPreviewVolume] = useState(1);
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(initialEditorPreferences.aspectRatio);
|
||||
const [activeEffectSection, setActiveEffectSection] = useState<EditorEffectSection>("scene");
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>(
|
||||
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<VideoExporter | null>(null);
|
||||
const autoSuggestedVideoPathRef = useRef<string | null>(null);
|
||||
const historyPastRef = useRef<EditorHistorySnapshot[]>([]);
|
||||
const historyFutureRef = useRef<EditorHistorySnapshot[]>([]);
|
||||
const historyCurrentRef = useRef<EditorHistorySnapshot | null>(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 (
|
||||
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#2563EB]/30">
|
||||
<div className="flex flex-col h-screen bg-[#111113] text-slate-200 overflow-hidden selection:bg-[#2563EB]/30">
|
||||
<div
|
||||
className="relative h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-center px-6 z-50"
|
||||
className="relative h-11 flex-shrink-0 bg-[#151518]/88 backdrop-blur-md border-b border-white/10 flex items-center justify-center px-8 z-50"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<span className="text-sm font-semibold tracking-tight text-white/90">Recordly</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm font-semibold tracking-tight text-white/90">{projectDisplayName}</span>
|
||||
<span className="text-xs font-medium tracking-tight text-slate-500">.recordly</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-4 flex items-center gap-1"
|
||||
className="absolute left-[88px] flex items-center gap-2"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-lg bg-white px-4 text-black transition-colors hover:bg-white/92"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">Load</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveProject}
|
||||
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-lg bg-white px-4 text-black transition-colors hover:bg-white/92"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-5 flex items-center gap-2 pr-3"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openRecordingsFolder()}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-white/90 transition hover:bg-white/8 hover:text-white cursor-pointer"
|
||||
className="inline-flex h-7 items-center justify-center rounded-md px-2 text-white/90 transition hover:bg-white/8 hover:text-white cursor-pointer"
|
||||
title={t("common.app.manageRecordings", "Open recordings folder")}
|
||||
aria-label={t("common.app.manageRecordings", "Open recordings folder")}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs font-normal">
|
||||
{t("common.app.manageRecordings", "Manage recordings")}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenExportDialog}
|
||||
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-lg bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
||||
<div className="relative flex min-h-0 flex-1 gap-3 p-4">
|
||||
{/* Left Column - Video & Timeline */}
|
||||
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
||||
<div className="order-2 flex h-full min-w-0 flex-[7] flex-col gap-3">
|
||||
<PanelGroup direction="vertical" className="gap-3">
|
||||
{/* Top section: video preview and controls */}
|
||||
<Panel defaultSize={70} minSize={40}>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
|
||||
<Panel defaultSize={67} minSize={40}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden">
|
||||
{/* Video preview */}
|
||||
<div
|
||||
className="w-full flex justify-center items-center"
|
||||
className="flex w-full min-h-0 flex-1 items-stretch"
|
||||
style={{ flex: "1 1 auto", margin: "6px 0 0" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
<div className="flex w-11 flex-shrink-0 items-center justify-center pl-1">
|
||||
<LayoutGroup id="preview-icon-rail">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{EDITOR_SECTION_BUTTONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isActive = activeEffectSection === section.id;
|
||||
return (
|
||||
<motion.button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<motion.span animate={{ color: isActive ? "#2563EB" : "rgba(255,255,255,0.75)" }} transition={{ duration: 0.16 }}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</motion.span>
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="preview-active-dot"
|
||||
className="absolute -left-1 h-1.5 w-1.5 rounded-full bg-[#2563EB]"
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.6 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 32 }}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center pl-2 pr-1">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-[30px]"
|
||||
style={{
|
||||
width: "auto",
|
||||
height: "100%",
|
||||
@@ -2055,7 +2234,9 @@ export default function VideoEditor() {
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorSway={cursorSway}
|
||||
volume={previewVolume}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback controls */}
|
||||
@@ -2075,19 +2256,21 @@ export default function VideoEditor() {
|
||||
duration={duration}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
volume={previewVolume}
|
||||
onVolumeChange={setPreviewVolume}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="h-3 bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full mx-4 flex items-center justify-center">
|
||||
<PanelResizeHandle className="h-3 bg-transparent transition-colors mx-4 flex items-center justify-center">
|
||||
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Timeline section */}
|
||||
<Panel defaultSize={30} minSize={20}>
|
||||
<div className="h-full min-h-0 bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-auto flex flex-col">
|
||||
<Panel defaultSize={33} minSize={20}>
|
||||
<div className="h-full min-h-0 bg-[#17171a] rounded-2xl border border-white/10 shadow-lg overflow-auto flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
@@ -2132,8 +2315,11 @@ export default function VideoEditor() {
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Right section: settings panel */}
|
||||
<SettingsPanel
|
||||
{/* Left section: settings panel */}
|
||||
<div className="order-1 flex">
|
||||
<SettingsPanel
|
||||
panelMode="editor"
|
||||
activeEffectSection={activeEffectSection}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={setWallpaper}
|
||||
selectedZoomDepth={
|
||||
@@ -2179,23 +2365,6 @@ export default function VideoEditor() {
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={setAspectRatio}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
@@ -2203,8 +2372,6 @@ export default function VideoEditor() {
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
onSaveProject={handleSaveProject}
|
||||
onLoadProject={handleLoadProject}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
@@ -2213,7 +2380,8 @@ export default function VideoEditor() {
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster theme="dark" className="pointer-events-auto" />
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -137,6 +137,7 @@ interface VideoPlaybackProps {
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorSway?: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -190,6 +191,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorSway = DEFAULT_CURSOR_SWAY,
|
||||
volume = 1,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -385,6 +387,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -94,16 +94,16 @@ export default function Item({
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onPointerDownCapture={() => onSelect?.()}
|
||||
className="group"
|
||||
className="group h-full"
|
||||
>
|
||||
<div style={{ ...itemContentStyle, minWidth: 24 }}>
|
||||
<div className="h-full" style={{ ...itemContentStyle, minWidth: 24, height: "100%" }}>
|
||||
<div
|
||||
className={cn(
|
||||
glassClass,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
isSelected && glassStyles.selected
|
||||
)}
|
||||
style={{ height: 40, color: '#fff', minWidth: 24 }}
|
||||
style={{ height: "100%", minHeight: 22, color: '#fff', minWidth: 24 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = '
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative flex-1 min-h-[26px]"
|
||||
style={{ ...rowWrapperStyle, marginBottom: 2 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
@@ -30,7 +30,7 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = '
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
<div ref={setNodeRef} className="relative h-full min-h-0" style={rowStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ interface SubrowProps {
|
||||
|
||||
export default function Subrow({ children }: SubrowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300")}>
|
||||
<div className={cn("flex items-center min-h-[24px] gap-1 px-1.5 py-0 bg-[#23232a] rounded-md text-slate-300")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -385,7 +385,7 @@ function TimelineAxis({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-[#09090b] border-b border-white/5 relative overflow-hidden select-none"
|
||||
className="h-8 bg-[#161619] border-b border-white/10 relative overflow-hidden select-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
@@ -513,7 +513,7 @@ function Timeline({
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||
className="select-none bg-[#17171a] h-full min-h-0 relative cursor-pointer group flex flex-col"
|
||||
onClick={handleTimelineClick}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
@@ -526,91 +526,93 @@ function Timeline({
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
<div className="relative z-10 flex flex-1 min-h-0 flex-col">
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedTrimId}
|
||||
onSelect={() => onSelectTrim?.(item.id)}
|
||||
variant="trim"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedTrimId}
|
||||
onSelect={() => onSelectTrim?.(item.id)}
|
||||
variant="trim"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
id={ANNOTATION_ROW_ID}
|
||||
isEmpty={annotationItems.length === 0}
|
||||
hint="Press A to add annotation"
|
||||
>
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedAnnotationId}
|
||||
onSelect={() => onSelectAnnotation?.(item.id)}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
<Row
|
||||
id={ANNOTATION_ROW_ID}
|
||||
isEmpty={annotationItems.length === 0}
|
||||
hint="Press A to add annotation"
|
||||
>
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedAnnotationId}
|
||||
onSelect={() => onSelectAnnotation?.(item.id)}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedSpeedId}
|
||||
onSelect={() => onSelectSpeed?.(item.id)}
|
||||
variant="speed"
|
||||
speedValue={item.speedValue}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedSpeedId}
|
||||
onSelect={() => onSelectSpeed?.(item.id)}
|
||||
variant="speed"
|
||||
speedValue={item.speedValue}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={AUDIO_ROW_ID} isEmpty={audioItems.length === 0} hint="Click music icon to add audio">
|
||||
{audioItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedAudioId}
|
||||
onSelect={() => onSelectAudio?.(item.id)}
|
||||
variant="audio"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
<Row id={AUDIO_ROW_ID} isEmpty={audioItems.length === 0} hint="Click music icon to add audio">
|
||||
{audioItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedAudioId}
|
||||
onSelect={() => onSelectAudio?.(item.id)}
|
||||
variant="audio"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -675,6 +677,10 @@ export default function TimelineEditor({
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
setRange(createInitialRange(totalMs));
|
||||
}, [totalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (aspectRatio === 'native') {
|
||||
return;
|
||||
@@ -1340,7 +1346,7 @@ export default function TimelineEditor({
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-[#09090b] gap-3">
|
||||
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-[#17171a] gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center">
|
||||
<Plus className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
@@ -1353,8 +1359,8 @@ export default function TimelineEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-auto">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-[#17171a] overflow-auto">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/10 bg-[#161619]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={handleAddZoom}
|
||||
@@ -1423,7 +1429,7 @@ export default function TimelineEditor({
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1c] border-white/10">
|
||||
{ASPECT_RATIOS.map((ratio) => (
|
||||
<DropdownMenuItem
|
||||
key={ratio}
|
||||
@@ -1489,7 +1495,7 @@ export default function TimelineEditor({
|
||||
</div>
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 min-h-0 overflow-auto bg-[#09090b] relative"
|
||||
className="flex-1 min-h-0 overflow-auto bg-[#17171a] relative"
|
||||
onClick={() => setSelectedKeyframeId(null)}
|
||||
onWheel={handleTimelineWheel}
|
||||
>
|
||||
|
||||
@@ -297,7 +297,7 @@ export default function TimelineWrapper({
|
||||
onDragEnd={onDragEndWithTooltip}
|
||||
autoScroll={{ enabled: false }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative h-full min-h-0">
|
||||
{children}
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user