Refine video editor layout and export flow

This commit is contained in:
webadderall
2026-03-19 21:49:33 +11:00
parent 8e8173cc58
commit 055baa88f9
14 changed files with 1304 additions and 1060 deletions
+2 -2
View File
@@ -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
+78 -4
View File
@@ -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
+33 -76
View File
@@ -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>
);
}
+237 -56
View File
@@ -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);
+3 -3
View File
@@ -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