Merge branch 'main' into feature/undo-redo
# Conflicts: # src/components/video-editor/KeyboardShortcutsHelp.tsx # src/components/video-editor/VideoEditor.tsx # src/components/video-editor/timeline/TimelineEditor.tsx
This commit is contained in:
@@ -1,85 +1,80 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
delete: 'Ctrl + D',
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll',
|
||||
undo: 'Ctrl + Z',
|
||||
redo: 'Ctrl + Shift + Z',
|
||||
redoAlt: 'Ctrl + Y',
|
||||
});
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
|
||||
const [undoRedoLabels, setUndoRedoLabels] = useState({ undo: 'Ctrl + Z', redo: 'Ctrl + Shift + Z', redoAlt: 'Ctrl + Y' });
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(['mod', 'D']),
|
||||
formatShortcut(['shift', 'mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Scroll']),
|
||||
formatShortcut(['mod', 'Z']),
|
||||
formatShortcut(['shift', 'mod', 'Z']),
|
||||
formatShortcut(['mod', 'Y']),
|
||||
]).then(([deleteKey, panKey, zoomKey, undoKey, redoKey, redoAltKey]) => {
|
||||
setShortcuts({
|
||||
delete: deleteKey,
|
||||
pan: panKey,
|
||||
zoom: zoomKey,
|
||||
undo: undoKey,
|
||||
redo: redoKey,
|
||||
redoAlt: redoAltKey,
|
||||
});
|
||||
]).then(([pan, zoom, undo, redo, redoAlt]) => {
|
||||
setScrollLabels({ pan, zoom });
|
||||
setUndoRedoLabels({ undo, redo, redoAlt });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="text-xs font-semibold text-slate-200 mb-2">Keyboard Shortcuts</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
title="Customize shortcuts"
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Zoom</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Annotation</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Keyframe</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Add Trim</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Delete Selected</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pause/Play</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.pan}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.zoom}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Cycle Annotations</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Tab</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Undo</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.undo}</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{undoRedoLabels.undo}</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Redo</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redo}</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{undoRedoLabels.redo}</kbd>
|
||||
<span className="text-slate-600 text-[9px]">or</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redoAlt}</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{undoRedoLabels.redoAlt}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useState } from "react";
|
||||
import Block from '@uiw/react-color-block';
|
||||
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
@@ -93,6 +94,10 @@ interface SettingsPanelProps {
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -151,6 +156,10 @@ export function SettingsPanel({
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
}: SettingsPanelProps) {
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
@@ -327,6 +336,54 @@ export function SettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
|
||||
{selectedSpeedId && selectedSpeedValue && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
||||
{SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const isActive = selectedSpeedValue === option.speed;
|
||||
return (
|
||||
<Button
|
||||
key={option.speed}
|
||||
type="button"
|
||||
disabled={!selectedSpeedId}
|
||||
onClick={() => onSpeedChange?.(option.speed)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out",
|
||||
selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
|
||||
isActive
|
||||
? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-semibold">{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a speed region to adjust</p>
|
||||
)}
|
||||
{selectedSpeedId && (
|
||||
<Button
|
||||
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Speed Region
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Keyboard, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
FIXED_SHORTCUTS,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_LABELS,
|
||||
findConflict,
|
||||
formatBinding,
|
||||
type ShortcutAction,
|
||||
type ShortcutBinding,
|
||||
type ShortcutConflict,
|
||||
type ShortcutsConfig,
|
||||
} from '@/lib/shortcuts';
|
||||
import { useShortcuts } from '@/contexts/ShortcutsContext';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
|
||||
|
||||
export function ShortcutsConfigDialog() {
|
||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||
useShortcuts();
|
||||
|
||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||
const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigOpen) {
|
||||
setDraft(shortcuts);
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
}
|
||||
}, [isConfigOpen, shortcuts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captureFor) return;
|
||||
|
||||
const handleCapture = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setCaptureFor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(e.key)) return;
|
||||
|
||||
const binding: ShortcutBinding = {
|
||||
key: e.key.toLowerCase(),
|
||||
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
|
||||
...(e.shiftKey ? { shift: true } : {}),
|
||||
...(e.altKey ? { alt: true } : {}),
|
||||
};
|
||||
|
||||
const found = findConflict(binding, captureFor, draft);
|
||||
setCaptureFor(null);
|
||||
|
||||
if (found?.type === 'fixed') {
|
||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (found?.type === 'configurable') {
|
||||
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleCapture, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
|
||||
}, [captureFor]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
|
||||
const { forAction, pending, conflictWith } = conflict;
|
||||
setDraft((prev: ShortcutsConfig) => ({
|
||||
...prev,
|
||||
[forAction]: pending,
|
||||
[conflictWith.action]: prev[forAction],
|
||||
}));
|
||||
setConflict(null);
|
||||
}, [conflict]);
|
||||
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success('Keyboard shortcuts saved');
|
||||
closeConfig();
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||
toast.info('Reset to default shortcuts — click Save to apply');
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
closeConfig();
|
||||
}, [closeConfig]);
|
||||
|
||||
return (
|
||||
<Dialog open={isConfigOpen} onOpenChange={(open: boolean) => { if (!open) handleClose(); }}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
Keyboard Shortcuts
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
|
||||
className={[
|
||||
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
|
||||
isCapturing
|
||||
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
|
||||
: hasConflict
|
||||
? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
|
||||
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
|
||||
].join(' ')}
|
||||
>
|
||||
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠ Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
Swap
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Fixed</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Click a shortcut then press the new key combination. Press{' '}
|
||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
@@ -26,11 +27,18 @@ import {
|
||||
type TrimRegion,
|
||||
type AnnotationRegion,
|
||||
type FigureData,
|
||||
type SpeedRegion,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
|
||||
|
||||
export default function VideoEditor() {
|
||||
const { state: editorState, pushState, updateState, commitState, undo, redo } =
|
||||
@@ -52,6 +60,8 @@ export default function VideoEditor() {
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
@@ -66,6 +76,9 @@ export default function VideoEditor() {
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
@@ -286,6 +299,60 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedTrimId, pushState]);
|
||||
|
||||
const handleSelectSpeed = useCallback((id: string | null) => {
|
||||
setSelectedSpeedId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSpeedAdded = useCallback((span: Span) => {
|
||||
const id = `speed-${nextSpeedIdRef.current++}`;
|
||||
const newRegion: SpeedRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
setSpeedRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleSpeedSpanChange = useCallback((id: string, span: Span) => {
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSpeedDelete = useCallback((id: string) => {
|
||||
setSpeedRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedSpeedId === id) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
|
||||
if (!selectedSpeedId) return;
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === selectedSpeedId ? { ...region, speed } : region,
|
||||
),
|
||||
);
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleAnnotationAdded = useCallback((span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++;
|
||||
@@ -403,7 +470,9 @@ export default function VideoEditor() {
|
||||
|
||||
if (e.key === 'Tab' && !isInput) { e.preventDefault(); }
|
||||
|
||||
if ((e.key === ' ' || e.code === 'Space') && !isInput) {
|
||||
if (matchesShortcut(e, shortcuts.playPause, isMac)) {
|
||||
// Allow space only in inputs/textareas
|
||||
if (isInput) { return; }
|
||||
e.preventDefault();
|
||||
const playback = videoPlaybackRef.current;
|
||||
if (playback?.video) {
|
||||
@@ -416,7 +485,7 @@ export default function VideoEditor() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, [undo, redo]);
|
||||
}, [undo, redo, shortcuts, isMac]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
|
||||
@@ -436,6 +505,12 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId, speedRegions]);
|
||||
|
||||
const handleExport = useCallback(async (settings: ExportSettings) => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
@@ -480,6 +555,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -606,6 +682,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -661,7 +738,7 @@ export default function VideoEditor() {
|
||||
setShowExportDialog(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
@@ -769,6 +846,7 @@ export default function VideoEditor() {
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
@@ -817,6 +895,12 @@ export default function VideoEditor() {
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
@@ -882,6 +966,10 @@ export default function VideoEditor() {
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type SpeedRegion, type AnnotationRegion } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
@@ -36,6 +36,7 @@ interface VideoPlaybackProps {
|
||||
padding?: number;
|
||||
cropRegion?: import('./types').CropRegion;
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
@@ -76,6 +77,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
padding = 50,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
aspectRatio,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
@@ -113,6 +115,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const motionBlurEnabledRef = useRef(motionBlurEnabled);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
|
||||
@@ -322,6 +325,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
speedRegionsRef.current = speedRegions;
|
||||
}, [speedRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
motionBlurEnabledRef.current = motionBlurEnabled;
|
||||
}, [motionBlurEnabled]);
|
||||
@@ -560,6 +567,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
});
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
|
||||
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
@@ -13,7 +13,8 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -36,13 +37,14 @@ function formatMs(ms: number): string {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
speedValue,
|
||||
variant = 'zoom',
|
||||
children
|
||||
}: ItemProps) {
|
||||
@@ -54,17 +56,22 @@ export default function Item({
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const isTrim = variant === 'trim';
|
||||
const isSpeed = variant === 'speed';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: isSpeed
|
||||
? glassStyles.glassAmber
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: isSpeed
|
||||
? '#d97706'
|
||||
: '#B4A046';
|
||||
|
||||
const timeLabel = useMemo(
|
||||
@@ -121,6 +128,13 @@ export default function Item({
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
@@ -76,6 +76,32 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
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;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassAmber:hover {
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassAmber.selected {
|
||||
background: rgba(245, 158, 11, 0.35);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -92,7 +118,9 @@
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassRed.selected .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
.glassYellow.selected .zoomEndCap {
|
||||
.glassYellow.selected .zoomEndCap,
|
||||
.glassAmber:hover .zoomEndCap,
|
||||
.glassAmber.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, WandSparkles } from "lucide-react";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
@@ -9,7 +9,7 @@ import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
|
||||
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
const SUGGESTION_SPACING_MS = 1800;
|
||||
@@ -53,6 +56,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
}
|
||||
@@ -71,7 +80,8 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -400,9 +410,11 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -413,9 +425,11 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
@@ -434,6 +448,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
@@ -445,11 +460,12 @@ function Timeline({
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
|
||||
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -516,6 +532,23 @@ function Timeline({
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -544,6 +577,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
onSpeedDelete,
|
||||
selectedSpeedId,
|
||||
onSelectSpeed,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
@@ -558,16 +597,17 @@ export default function TimelineEditor({
|
||||
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [shortcuts, setShortcuts] = useState({
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
});
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
|
||||
formatShortcut(['mod', 'Scroll']).then(zoom => {
|
||||
setShortcuts({ pan, zoom });
|
||||
setScrollLabels({ pan, zoom });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
@@ -612,6 +652,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
onSelectSpeed(null);
|
||||
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
setRange(createInitialRange(totalMs));
|
||||
}, [totalMs]);
|
||||
@@ -621,8 +667,10 @@ export default function TimelineEditor({
|
||||
// this effect on every drag/resize and races with dnd-timeline's internal state.
|
||||
const zoomRegionsRef = useRef(zoomRegions);
|
||||
const trimRegionsRef = useRef(trimRegions);
|
||||
const speedRegionsRef = useRef(speedRegions);
|
||||
zoomRegionsRef.current = zoomRegions;
|
||||
trimRegionsRef.current = trimRegions;
|
||||
speedRegionsRef.current = speedRegions;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalMs === 0 || safeMinDurationMs <= 0) {
|
||||
@@ -652,21 +700,34 @@ export default function TimelineEditor({
|
||||
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
|
||||
speedRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
// Only re-run when the timeline scale changes, not on every region edit
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
|
||||
|
||||
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||
// Determine which row the item belongs to
|
||||
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some(r => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to check overlap against a specific set of regions
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
|
||||
return regions.some((region) => {
|
||||
if (region.id === excludeId) return false;
|
||||
// True overlap: regions actually intersect (not just adjacent)
|
||||
@@ -682,8 +743,12 @@ export default function TimelineEditor({
|
||||
return checkOverlap(trimRegions);
|
||||
}
|
||||
|
||||
if (isSpeedItem) {
|
||||
return checkOverlap(speedRegions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
// is always wide enough to grab and resize comfortably.
|
||||
@@ -837,6 +902,36 @@ export default function TimelineEditor({
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddSpeed = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always place speed region at playhead
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
// Find the next speed region after the playhead
|
||||
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
const nextRegion = sorted.find(region => region.startMs > startPos);
|
||||
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
||||
|
||||
// Check if playhead is inside any speed region
|
||||
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place speed here", {
|
||||
description: "Speed region already exists at this location or not enough space available.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
@@ -860,20 +955,20 @@ export default function TimelineEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-letter shortcuts only when no modifier key is held
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
addKeyframe();
|
||||
}
|
||||
if (e.key === 'z' || e.key === 'Z') {
|
||||
handleAddZoom();
|
||||
}
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
handleAddTrim();
|
||||
}
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
|
||||
addKeyframe();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
|
||||
handleAddZoom();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
|
||||
handleAddTrim();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
@@ -899,7 +994,7 @@ export default function TimelineEditor({
|
||||
}
|
||||
}
|
||||
// Delete key or Ctrl+D / Cmd+D
|
||||
if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) {
|
||||
if (selectedKeyframeId) {
|
||||
deleteSelectedKeyframe();
|
||||
} else if (selectedZoomId) {
|
||||
@@ -908,12 +1003,14 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -966,26 +1063,38 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
return [...zooms, ...trims, ...annotations];
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Speed ${index + 1}`,
|
||||
speedValue: region.speed,
|
||||
variant: 'speed',
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims];
|
||||
}, [zoomRegions, trimRegions]);
|
||||
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims, ...speeds];
|
||||
}, [zoomRegions, trimRegions, speedRegions]);
|
||||
|
||||
const handleItemSpanChange = useCallback((id: string, span: Span) => {
|
||||
// Check if it's a zoom or trim item
|
||||
// Check if it's a zoom, trim, speed, or annotation item
|
||||
if (zoomRegions.some(r => r.id === id)) {
|
||||
onZoomSpanChange(id, span);
|
||||
} else if (trimRegions.some(r => r.id === id)) {
|
||||
onTrimSpanChange?.(id, span);
|
||||
} else if (speedRegions.some(r => r.id === id)) {
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some(r => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
}
|
||||
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
|
||||
}, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
@@ -1041,6 +1150,15 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title="Add Speed (S)"
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
@@ -1073,11 +1191,11 @@ export default function TimelineEditor({
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.pan}</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.zoom}</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1115,11 +1233,12 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,27 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
speed: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 0.25, label: "0.25×" },
|
||||
{ speed: 0.5, label: "0.5×" },
|
||||
{ speed: 0.75, label: "0.75×" },
|
||||
{ speed: 1.25, label: "1.25×" },
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import type { TrimRegion } from '../types';
|
||||
import type { TrimRegion, SpeedRegion } from '../types';
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
@@ -11,6 +11,7 @@ interface VideoEventHandlersParams {
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -24,6 +25,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
} = params;
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
@@ -39,16 +41,23 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
) || null;
|
||||
};
|
||||
|
||||
// Helper function to find the active speed region at the current time
|
||||
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
|
||||
return speedRegionsRef.current.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
|
||||
) || null;
|
||||
};
|
||||
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
|
||||
// If the skip would take us past the video duration, pause instead
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
@@ -57,9 +66,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
// Apply playback speed from active speed region
|
||||
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
|
||||
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
|
||||
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user