From 9bc2c78b4d2665548ded4ce45754c7729d931543 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Thu, 26 Feb 2026 15:41:32 +0100 Subject: [PATCH 1/6] feat: implement keyboard shortcuts management and configuration --- dist-electron/main.js | 20 +- dist-electron/preload.mjs | 6 + electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 21 +++ electron/preload.ts | 6 + src/App.tsx | 9 +- .../video-editor/KeyboardShortcutsHelp.tsx | 91 +++++---- .../video-editor/ShortcutsConfigDialog.tsx | 172 ++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 8 +- .../video-editor/timeline/TimelineEditor.tsx | 23 ++- src/contexts/ShortcutsContext.tsx | 60 ++++++ src/lib/shortcuts.ts | 77 ++++++++ 12 files changed, 434 insertions(+), 61 deletions(-) create mode 100644 src/components/video-editor/ShortcutsConfigDialog.tsx create mode 100644 src/contexts/ShortcutsContext.tsx create mode 100644 src/lib/shortcuts.ts diff --git a/dist-electron/main.js b/dist-electron/main.js index 39a5ce5..c9416de 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,4 +1,4 @@ -import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; +import { ipcMain, screen, BrowserWindow, app, desktopCapturer, shell, dialog, nativeImage, Tray, Menu } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs/promises"; @@ -126,6 +126,7 @@ function createSourceSelectorWindow() { } return win; } +const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); let selectedSource = null; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { ipcMain.handle("get-sources", async (_, opts) => { @@ -298,6 +299,23 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("get-platform", () => { return process.platform; }); + ipcMain.handle("get-shortcuts", async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } + }); + ipcMain.handle("save-shortcuts", async (_, shortcuts) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index cb59604..6355b7b 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -59,5 +59,11 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, getPlatform: () => { return electron.ipcRenderer.invoke("get-platform"); + }, + getShortcuts: () => { + return electron.ipcRenderer.invoke("get-shortcuts"); + }, + saveShortcuts: (shortcuts) => { + return electron.ipcRenderer.invoke("save-shortcuts", shortcuts); } }); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..247b679 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -40,6 +40,8 @@ interface Window { getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> getPlatform: () => Promise + getShortcuts: () => Promise | null> + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }> hudOverlayHide: () => void; hudOverlayClose: () => void; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 867b72b..caa82ca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4,6 +4,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json') + let selectedSource: any = null export function registerIpcHandlers( @@ -217,4 +219,23 @@ export function registerIpcHandlers( ipcMain.handle('get-platform', () => { return process.platform; }); + + ipcMain.handle('get-shortcuts', async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } + }); + + ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8'); + return { success: true }; + } catch (error) { + console.error('Failed to save shortcuts:', error); + return { success: false, error: String(error) }; + } + }); } diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..34cf056 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -63,4 +63,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, + getShortcuts: () => { + return ipcRenderer.invoke('get-shortcuts') + }, + saveShortcuts: (shortcuts: unknown) => { + return ipcRenderer.invoke('save-shortcuts', shortcuts) + }, }) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ad94efb..cc6742c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import VideoEditor from "./components/video-editor/VideoEditor"; import { loadAllCustomFonts } from "./lib/customFonts"; +import { ShortcutsProvider } from "./contexts/ShortcutsContext"; +import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; export default function App() { const [windowType, setWindowType] = useState(''); @@ -29,7 +31,12 @@ export default function App() { case 'source-selector': return ; case 'editor': - return ; + return ( + + + + + ); default: return (
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 3edbd04..b48896f 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,65 +1,62 @@ -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' - }); + const { shortcuts, isMac, openConfig } = useShortcuts(); + + const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); useEffect(() => { Promise.all([ - formatShortcut(['mod', 'D']), formatShortcut(['shift', 'mod', 'Scroll']), - formatShortcut(['mod', 'Scroll']) - ]).then(([deleteKey, panKey, zoomKey]) => { - setShortcuts({ - delete: deleteKey, - pan: panKey, - zoom: zoomKey - }); - }); + formatShortcut(['mod', 'Scroll']), + ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); return (
+
-
Keyboard Shortcuts
+
+ Keyboard Shortcuts + +
+
-
- Add Zoom - Z -
-
- Add Annotation - A -
-
- Add Keyframe - F -
-
- Add Trim - T -
-
- Delete Selected - {shortcuts.delete} -
-
- Pan Timeline - {shortcuts.pan} -
-
- Zoom Timeline - {shortcuts.zoom} -
-
- Pause/Play - Space + {SHORTCUT_ACTIONS.map((action) => ( +
+ {SHORTCUT_LABELS[action]} + + {formatBinding(shortcuts[action], isMac)} + +
+ ))} + +
+
+ Pan Timeline + {scrollLabels.pan} +
+
+ Zoom Timeline + {scrollLabels.zoom} +
+
+ Cycle Annotations + Tab +
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx new file mode 100644 index 0000000..f7d6074 --- /dev/null +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -0,0 +1,172 @@ +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, + SHORTCUT_ACTIONS, + SHORTCUT_LABELS, + formatBinding, + type ShortcutAction, + type ShortcutBinding, + type ShortcutsConfig, +} from '@/lib/shortcuts'; +import { useShortcuts } from '@/contexts/ShortcutsContext'; + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +const FIXED_SHORTCUTS = [ + { label: 'Cycle Annotations Forward', display: 'Tab' }, + { label: 'Cycle Annotations Backward', display: 'Shift + Tab' }, + { label: 'Delete Selected (alt)', display: 'Del / ⌫' }, + { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll' }, + { label: 'Zoom Timeline', display: 'Ctrl + Scroll' }, +] as const; + +export function ShortcutsConfigDialog() { + const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = + useShortcuts(); + + const [draft, setDraft] = useState(shortcuts); + const [captureFor, setCaptureFor] = useState(null); + + useEffect(() => { + if (isConfigOpen) { + setDraft(shortcuts); + setCaptureFor(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 } : {}), + }; + + setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + setCaptureFor(null); + }; + + window.addEventListener('keydown', handleCapture, { capture: true }); + return () => window.removeEventListener('keydown', handleCapture, { capture: true }); + }, [captureFor]); + + 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); + closeConfig(); + }, [closeConfig]); + + return ( + { if (!open) handleClose(); }}> + + + + + Keyboard Shortcuts + + + +
+

Configurable

+ {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + return ( +
+ {SHORTCUT_LABELS[action]} + +
+ ); + })} +
+ +
+

Fixed

+ {FIXED_SHORTCUTS.map(({ label, display }) => ( +
+ {label} + + {display} + +
+ ))} +
+ +

+ Click a shortcut then press the new key combination. Press{' '} + Esc to cancel. +

+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0a038e..66313f6 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,6 +31,8 @@ import { import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; +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`); @@ -69,6 +71,8 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); + + const { shortcuts, isMac } = useShortcuts(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); @@ -398,7 +402,7 @@ export default function VideoEditor() { e.preventDefault(); } - if (e.key === ' ' || e.code === 'Space') { + if (matchesShortcut(e, shortcuts.playPause, isMac)) { // Allow space only in inputs/textareas if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; @@ -418,7 +422,7 @@ export default function VideoEditor() { window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, []); + }, [shortcuts, isMac]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b091ef..dc2b2bd 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -20,6 +20,8 @@ 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"; const ZOOM_ROW_ID = "row-zoom"; const TRIM_ROW_ID = "row-trim"; @@ -552,16 +554,17 @@ export default function TimelineEditor({ const [range, setRange] = useState(() => createInitialRange(totalMs)); const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); - const [shortcuts, setShortcuts] = useState({ + const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); const timelineContainerRef = useRef(null); + const { shortcuts: keyShortcuts, isMac } = useShortcuts(); useEffect(() => { formatShortcut(['shift', 'mod', 'Scroll']).then(pan => { formatShortcut(['mod', 'Scroll']).then(zoom => { - setShortcuts({ pan, zoom }); + setScrollLabels({ pan, zoom }); }); }); }, []); @@ -769,16 +772,16 @@ export default function TimelineEditor({ return; } - if (e.key === 'f' || e.key === 'F') { + if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { addKeyframe(); } - if (e.key === 'z' || e.key === 'Z') { + if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { handleAddZoom(); } - if (e.key === 't' || e.key === 'T') { + if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) { handleAddTrim(); } - if (e.key === 'a' || e.key === 'A') { + if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } @@ -805,7 +808,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) { @@ -819,7 +822,7 @@ export default function TimelineEditor({ }; 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, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -970,11 +973,11 @@ export default function TimelineEditor({
- {shortcuts.pan} + {scrollLabels.pan} Pan - {shortcuts.zoom} + {scrollLabels.zoom} Zoom
diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx new file mode 100644 index 0000000..bc6a57b --- /dev/null +++ b/src/contexts/ShortcutsContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts'; +import { isMac as getIsMac } from '@/utils/platformUtils'; + +interface ShortcutsContextValue { + shortcuts: ShortcutsConfig; + isMac: boolean; + setShortcuts: (config: ShortcutsConfig) => void; + persistShortcuts: (config?: ShortcutsConfig) => Promise; + isConfigOpen: boolean; + openConfig: () => void; + closeConfig: () => void; +} + +const ShortcutsContext = createContext(null); + +export function useShortcuts(): ShortcutsContextValue { + const ctx = useContext(ShortcutsContext); + if (!ctx) throw new Error('useShortcuts must be used within '); + return ctx; +} + +export function ShortcutsProvider({ children }: { children: ReactNode }) { + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [isMac, setIsMac] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + + useEffect(() => { + getIsMac().then(setIsMac).catch(() => {}); + + window.electronAPI.getShortcuts?.() + .then((saved) => { + if (saved) { + setShortcuts(mergeWithDefaults(saved as Partial)); + } + }) + .catch(() => {}); + }, []); + + const persistShortcuts = useCallback( + async (config?: ShortcutsConfig) => { + await window.electronAPI.saveShortcuts?.(config ?? shortcuts); + }, + [shortcuts], + ); + + const openConfig = useCallback(() => setIsConfigOpen(true), []); + const closeConfig = useCallback(() => setIsConfigOpen(false), []); + + const value = useMemo( + () => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }), + [shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig], + ); + + return ( + + {children} + + ); +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..4b81444 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,77 @@ +export const SHORTCUT_ACTIONS = [ + 'addZoom', + 'addTrim', + 'addAnnotation', + 'addKeyframe', + 'deleteSelected', + 'playPause', +] as const; + +export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; + +export interface ShortcutBinding { + key: string; + /** Maps to Cmd on macOS, Ctrl on Windows/Linux */ + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +} + +export type ShortcutsConfig = Record; + +export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + addZoom: { key: 'z' }, + addTrim: { key: 't' }, + addAnnotation: { key: 'a' }, + addKeyframe: { key: 'f' }, + deleteSelected: { key: 'd', ctrl: true }, + playPause: { key: ' ' }, +}; + +export const SHORTCUT_LABELS: Record = { + addZoom: 'Add Zoom', + addTrim: 'Add Trim', + addAnnotation: 'Add Annotation', + addKeyframe: 'Add Keyframe', + deleteSelected: 'Delete Selected', + playPause: 'Play / Pause', +}; + +export function matchesShortcut( + e: KeyboardEvent, + binding: ShortcutBinding, + isMacPlatform: boolean, +): boolean { + if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false; + + const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey; + if (primaryMod !== !!binding.ctrl) return false; + if (e.shiftKey !== !!binding.shift) return false; + if (e.altKey !== !!binding.alt) return false; + + return true; +} + +const KEY_LABELS: Record = { + ' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc', + 'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→', +}; + +export function formatBinding(binding: ShortcutBinding, isMac: boolean): string { + const parts: string[] = []; + if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl'); + if (binding.shift) parts.push(isMac ? '⇧' : 'Shift'); + if (binding.alt) parts.push(isMac ? '⌥' : 'Alt'); + parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase()); + return parts.join(' + '); +} + +export function mergeWithDefaults(partial: Partial): ShortcutsConfig { + const merged = { ...DEFAULT_SHORTCUTS }; + for (const action of SHORTCUT_ACTIONS) { + if (partial[action]) { + merged[action] = partial[action] as ShortcutBinding; + } + } + return merged; +} From 397a9434261d7d3d7a172b458034922546444325 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:20:04 -0600 Subject: [PATCH 2/6] feat: speed thing --- src/components/video-editor/SettingsPanel.tsx | 59 ++++++- src/components/video-editor/VideoEditor.tsx | 81 +++++++++- src/components/video-editor/VideoPlayback.tsx | 10 +- src/components/video-editor/timeline/Item.tsx | 30 +++- .../timeline/ItemGlass.module.css | 30 +++- .../video-editor/timeline/TimelineEditor.tsx | 149 ++++++++++++++++-- src/components/video-editor/types.ts | 21 +++ .../videoPlayback/videoEventHandlers.ts | 22 ++- src/lib/exporter/frameRenderer.ts | 3 +- src/lib/exporter/gifExporter.ts | 4 +- src/lib/exporter/videoExporter.ts | 4 +- 11 files changed, 378 insertions(+), 35 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index db5e9d8..b2d5c47 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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"; @@ -90,6 +91,10 @@ interface SettingsPanelProps { onAnnotationStyleChange?: (id: string, style: Partial) => 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; @@ -145,6 +150,10 @@ export function SettingsPanel({ onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDelete, + selectedSpeedId, + selectedSpeedValue, + onSpeedChange, + onSpeedDelete, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); @@ -321,6 +330,54 @@ export function SettingsPanel({
)} +
+
+ Playback Speed + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`} + + )} +
+
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + + ); + })} +
+ {!selectedSpeedId && ( +

Select a speed region to adjust

+ )} + {selectedSpeedId && ( + + )} +
+ diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0a038e..c5b8af2 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -20,6 +20,7 @@ import { DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_FIGURE_DATA, + DEFAULT_PLAYBACK_SPEED, type ZoomDepth, type ZoomFocus, type ZoomRegion, @@ -27,6 +28,8 @@ import { type AnnotationRegion, type CropRegion, 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 { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; @@ -53,6 +56,8 @@ export default function VideoEditor() { const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(null); + const [speedRegions, setSpeedRegions] = useState([]); + const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [annotationRegions, setAnnotationRegions] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isExporting, setIsExporting] = useState(false); @@ -69,6 +74,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); + const nextSpeedIdRef = useRef(1); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); @@ -263,6 +269,60 @@ export default function VideoEditor() { } }, [selectedTrimId]); + 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++; // Assign z-index based on creation order @@ -438,6 +498,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'); @@ -482,6 +548,7 @@ export default function VideoEditor() { wallpaper, zoomRegions, trimRegions, + speedRegions, showShadow: shadowIntensity > 0, shadowIntensity, showBlur, @@ -608,6 +675,7 @@ export default function VideoEditor() { wallpaper, zoomRegions, trimRegions, + speedRegions, showShadow: shadowIntensity > 0, shadowIntensity, showBlur, @@ -663,7 +731,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) { @@ -770,6 +838,7 @@ export default function VideoEditor() { padding={padding} cropRegion={cropRegion} trimRegions={trimRegions} + speedRegions={speedRegions} annotationRegions={annotationRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} @@ -816,6 +885,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} @@ -878,6 +953,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} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 4dc491e..2801f1a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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"; @@ -35,6 +35,7 @@ interface VideoPlaybackProps { padding?: number; cropRegion?: import('./types').CropRegion; trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; aspectRatio: AspectRatio; annotationRegions?: AnnotationRegion[]; selectedAnnotationId?: string | null; @@ -74,6 +75,7 @@ const VideoPlayback = forwardRef(({ padding = 50, cropRegion, trimRegions = [], + speedRegions = [], aspectRatio, annotationRegions = [], selectedAnnotationId, @@ -111,6 +113,7 @@ const VideoPlayback = forwardRef(({ const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); + const speedRegionsRef = useRef([]); const motionBlurEnabledRef = useRef(motionBlurEnabled); const videoReadyRafRef = useRef(null); @@ -319,6 +322,10 @@ const VideoPlayback = forwardRef(({ trimRegionsRef.current = trimRegions; }, [trimRegions]); + useEffect(() => { + speedRegionsRef.current = speedRegions; + }, [speedRegions]); + useEffect(() => { motionBlurEnabledRef.current = motionBlurEnabled; }, [motionBlurEnabled]); @@ -557,6 +564,7 @@ const VideoPlayback = forwardRef(({ onPlayStateChange, onTimeUpdate, trimRegionsRef, + speedRegionsRef, }); video.addEventListener('play', handlePlay); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index ed5fc8b..6f9a706 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -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 + ) : isSpeed ? ( + <> + + + {speedValue !== undefined ? `${speedValue}×` : 'Speed'} + + ) : ( <> diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index d89cc0e..92796be 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -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; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b091ef..7a0d478 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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 } from "lucide-react"; +import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge } 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 } from "../types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion } from "../types"; import { v4 as uuidv4 } from 'uuid'; import { DropdownMenu, @@ -24,6 +24,7 @@ import { TutorialHelp } from "../TutorialHelp"; 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; @@ -49,6 +50,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; } @@ -67,7 +74,8 @@ interface TimelineRenderItem { span: Span; label: string; zoomDepth?: number; - variant: 'zoom' | 'trim' | 'annotation'; + speedValue?: number; + variant: 'zoom' | 'trim' | 'annotation' | 'speed'; } const SCALE_CANDIDATES = [ @@ -396,9 +404,11 @@ function Timeline({ onSelectZoom, onSelectTrim, onSelectAnnotation, + onSelectSpeed, selectedZoomId, selectedTrimId, selectedAnnotationId, + selectedSpeedId, keyframes = [], }: { items: TimelineRenderItem[]; @@ -409,9 +419,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(); @@ -430,6 +442,7 @@ function Timeline({ onSelectZoom?.(null); onSelectTrim?.(null); onSelectAnnotation?.(null); + onSelectSpeed?.(null); const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -441,11 +454,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 (
))} + + + {speedItems.map((item) => ( + onSelectSpeed?.(item.id)} + variant="speed" + speedValue={item.speedValue} + > + {item.label} + + ))} +
); } @@ -538,6 +569,12 @@ export default function TimelineEditor({ onAnnotationDelete, selectedAnnotationId, onSelectAnnotation, + speedRegions = [], + onSpeedAdded, + onSpeedSpanChange, + onSpeedDelete, + selectedSpeedId, + onSelectSpeed, aspectRatio, onAspectRatioChange, }: TimelineEditorProps) { @@ -606,6 +643,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]); @@ -615,8 +658,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) { @@ -646,21 +691,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) @@ -676,8 +734,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. @@ -746,6 +808,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; @@ -781,6 +873,9 @@ export default function TimelineEditor({ if (e.key === 'a' || e.key === 'A') { handleAddAnnotation(); } + if (e.key === 's' || e.key === 'S') { + handleAddSpeed(); + } // Tab: Cycle through overlapping annotations at current time if (e.key === 'Tab' && annotationRegions.length > 0) { @@ -814,12 +909,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]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -872,26 +969,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 ( @@ -938,6 +1047,15 @@ export default function TimelineEditor({ > +
@@ -1012,11 +1130,12 @@ export default function TimelineEditor({ onSelectZoom={onSelectZoom} onSelectTrim={onSelectTrim} onSelectAnnotation={onSelectAnnotation} + onSelectSpeed={onSelectSpeed} selectedZoomId={selectedZoomId} selectedTrimId={selectedTrimId} selectedAnnotationId={selectedAnnotationId} + selectedSpeedId={selectedSpeedId} keyframes={keyframes} - />
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e138d75..f1e8c09 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -108,6 +108,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 = { 1: 1.25, 2: 1.5, diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 8a55545..c5d92ee 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -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; + speedRegionsRef: React.MutableRefObject; } 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); } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 0e7cfe2..8dbae17 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,5 +1,5 @@ import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js'; -import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types'; +import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types'; import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform'; @@ -22,6 +22,7 @@ interface FrameRenderConfig { videoWidth: number; videoHeight: number; annotationRegions?: AnnotationRegion[]; + speedRegions?: SpeedRegion[]; previewWidth?: number; previewHeight?: number; } diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index bf7a5f4..382a010 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -2,7 +2,7 @@ import GIF from 'gif.js'; import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types'; import { StreamingVideoDecoder } from './streamingDecoder'; import { FrameRenderer } from './frameRenderer'; -import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types'; +import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types'; const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString(); @@ -16,6 +16,7 @@ interface GifExporterConfig { wallpaper: string; zoomRegions: ZoomRegion[]; trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; showShadow: boolean; shadowIntensity: number; showBlur: boolean; @@ -100,6 +101,7 @@ export class GifExporter { videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions, + speedRegions: this.config.speedRegions, previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, }); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 937c4a3..8c513b2 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -2,13 +2,14 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types'; import { StreamingVideoDecoder } from './streamingDecoder'; import { FrameRenderer } from './frameRenderer'; import { VideoMuxer } from './muxer'; -import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types'; +import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types'; interface VideoExporterConfig extends ExportConfig { videoUrl: string; wallpaper: string; zoomRegions: ZoomRegion[]; trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; showShadow: boolean; shadowIntensity: number; showBlur: boolean; @@ -68,6 +69,7 @@ export class VideoExporter { videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions, + speedRegions: this.config.speedRegions, previewWidth: this.config.previewWidth, previewHeight: this.config.previewHeight, }); From 185969a9d1fc67e236f3c6d71c5b7894ded8c4a1 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:27:01 -0600 Subject: [PATCH 3/6] build: package-lock stuff --- package-lock.json | 78 +++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0183e3..7bf3c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -342,7 +341,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1317,6 +1315,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1338,6 +1337,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1354,6 +1354,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1368,6 +1369,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2051,7 +2053,6 @@ "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/core": "^0.16.13" @@ -2094,7 +2095,6 @@ "integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2109,7 +2109,6 @@ "integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2138,7 +2137,6 @@ "integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13", @@ -2188,7 +2186,6 @@ "integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2332,7 +2329,6 @@ "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2347,7 +2343,6 @@ "integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2365,7 +2360,6 @@ "integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2792,6 +2786,7 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2806,7 +2801,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2833,7 +2829,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2860,19 +2857,22 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2884,6 +2884,7 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2895,6 +2896,7 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2909,19 +2911,22 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/utils/node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@pixi/utils/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4402,7 +4407,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4414,7 +4418,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4723,7 +4726,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5528,7 +5530,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5841,6 +5842,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6283,7 +6285,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6579,7 +6582,6 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7006,6 +7008,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7026,6 +7029,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8666,7 +8670,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9825,6 +9828,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -10414,7 +10418,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10559,6 +10562,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10576,6 +10580,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10724,6 +10729,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -10782,7 +10788,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10795,7 +10800,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11579,6 +11583,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -11598,6 +11603,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -11614,6 +11620,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11632,6 +11639,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12269,7 +12277,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12342,6 +12349,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12405,6 +12413,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12419,6 +12428,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12432,7 +12442,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12585,7 +12594,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12819,6 +12827,7 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", + "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -12831,7 +12840,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -12945,7 +12955,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13020,8 +13029,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vitest": { "version": "4.0.16", @@ -13585,7 +13593,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13599,7 +13606,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From cf8d211eb2f376eca0134691147e71c854747710 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 02:16:03 -0600 Subject: [PATCH 4/6] feat: add the speed to exporter lol --- src/lib/exporter/gifExporter.ts | 3 +- src/lib/exporter/streamingDecoder.ts | 51 +++++++++++++++++++++++----- src/lib/exporter/videoExporter.ts | 3 +- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 382a010..db7f299 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -124,7 +124,7 @@ export class GifExporter { }); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); // Calculate frame delay in milliseconds (gif.js uses ms) @@ -144,6 +144,7 @@ export class GifExporter { await this.streamingDecoder.decodeAll( this.config.frameRate, this.config.trimRegions, + this.config.speedRegions, async (videoFrame, _exportTimestampUs, sourceTimestampMs) => { if (this.cancelled) { videoFrame.close(); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index d5610fc..d07e164 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,5 +1,5 @@ import { WebDemuxer } from 'web-demuxer'; -import type { TrimRegion } from '@/components/video-editor/types'; +import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types'; export interface DecodedVideoInfo { width: number; @@ -67,6 +67,7 @@ export class StreamingVideoDecoder { async decodeAll( targetFrameRate: number, trimRegions: TrimRegion[] | undefined, + speedRegions: SpeedRegion[] | undefined, onFrame: OnFrameCallback ): Promise { if (!this.demuxer || !this.metadata) { @@ -74,7 +75,10 @@ export class StreamingVideoDecoder { } const decoderConfig = await this.demuxer.getDecoderConfig('video'); - const segments = this.computeSegments(this.metadata.duration, trimRegions); + const segments = this.splitBySpeed( + this.computeSegments(this.metadata.duration, trimRegions), + speedRegions + ); const frameDurationUs = 1_000_000 / targetFrameRate; // Async frame queue — decoder pushes, consumer pulls @@ -218,7 +222,7 @@ export class StreamingVideoDecoder { */ private async deliverSegment( frames: VideoFrame[], - segment: { startSec: number; endSec: number }, + segment: { startSec: number; endSec: number; speed: number }, targetFrameRate: number, frameDurationUs: number, startExportFrameIndex: number, @@ -226,7 +230,9 @@ export class StreamingVideoDecoder { ): Promise { if (frames.length === 0) return startExportFrameIndex; - const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate); + const segmentFrameCount = Math.ceil( + (segment.endSec - segment.startSec) / segment.speed * targetFrameRate + ); let exportFrameIndex = startExportFrameIndex; for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) { @@ -271,12 +277,39 @@ export class StreamingVideoDecoder { return segments; } - getEffectiveDuration(trimRegions?: TrimRegion[]): number { + getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number { if (!this.metadata) throw new Error('Must call loadMetadata() first'); - const trimmed = (trimRegions || []).reduce( - (sum, r) => sum + (r.endMs - r.startMs) / 1000, 0 - ); - return this.metadata.duration - trimmed; + const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); + const speedSegments = this.splitBySpeed(trimSegments, speedRegions); + return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0); + } + + private splitBySpeed( + segments: Array<{ startSec: number; endSec: number }>, + speedRegions?: SpeedRegion[] + ): Array<{ startSec: number; endSec: number; speed: number }> { + if (!speedRegions || speedRegions.length === 0) + return segments.map(s => ({ ...s, speed: 1 })); + + const result: Array<{ startSec: number; endSec: number; speed: number }> = []; + for (const segment of segments) { + const overlapping = speedRegions + .filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec) + .sort((a, b) => a.startMs - b.startMs); + + if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; } + + let cursor = segment.startSec; + for (const sr of overlapping) { + const srStart = Math.max(sr.startMs / 1000, segment.startSec); + const srEnd = Math.min(sr.endMs / 1000, segment.endSec); + if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); + cursor = srEnd; + } + if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 }); + } + return result.filter(s => s.endSec - s.startSec > 0.0001); } cancel(): void { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 8c513b2..e41bc47 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -83,7 +83,7 @@ export class VideoExporter { await this.muxer.initialize(); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); console.log('[VideoExporter] Original duration:', videoInfo.duration, 's'); @@ -98,6 +98,7 @@ export class VideoExporter { await this.streamingDecoder.decodeAll( this.config.frameRate, this.config.trimRegions, + this.config.speedRegions, async (videoFrame, _exportTimestampUs, sourceTimestampMs) => { if (this.cancelled) { videoFrame.close(); From d76f38fb35557dd1198db5f470afd4b0fa1e17c3 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Sat, 28 Feb 2026 11:11:12 +0100 Subject: [PATCH 5/6] feat: enhance shortcuts configuration with conflict detection and fixed shortcuts --- .../video-editor/ShortcutsConfigDialog.tsx | 105 +++++++++++++----- src/lib/shortcuts.ts | 45 ++++++++ 2 files changed, 123 insertions(+), 27 deletions(-) diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index f7d6074..e524e38 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -6,36 +6,33 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from ' 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']); -const FIXED_SHORTCUTS = [ - { label: 'Cycle Annotations Forward', display: 'Tab' }, - { label: 'Cycle Annotations Backward', display: 'Shift + Tab' }, - { label: 'Delete Selected (alt)', display: 'Del / ⌫' }, - { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll' }, - { label: 'Zoom Timeline', display: 'Ctrl + Scroll' }, -] as const; - export function ShortcutsConfigDialog() { const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = useShortcuts(); const [draft, setDraft] = useState(shortcuts); const [captureFor, setCaptureFor] = useState(null); + const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null); useEffect(() => { if (isConfigOpen) { setDraft(shortcuts); setCaptureFor(null); + setConflict(null); } }, [isConfigOpen, shortcuts]); @@ -60,14 +57,39 @@ export function ShortcutsConfigDialog() { ...(e.altKey ? { alt: true } : {}), }; - setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + 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); @@ -82,6 +104,7 @@ export function ShortcutsConfigDialog() { const handleClose = useCallback(() => { setCaptureFor(null); + setConflict(null); closeConfig(); }, [closeConfig]); @@ -99,25 +122,53 @@ export function ShortcutsConfigDialog() {

Configurable

{SHORTCUT_ACTIONS.map((action) => { const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; return ( -
- {SHORTCUT_LABELS[action]} - +
+
+ {SHORTCUT_LABELS[action]} + +
+ {hasConflict && conflict?.conflictWith.type === 'configurable' && ( +
+ + ⚠ Already used by {SHORTCUT_LABELS[conflict.conflictWith.action]} + +
+ + +
+
+ )}
); })} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 4b81444..75ccce9 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -19,6 +19,51 @@ export interface ShortcutBinding { export type ShortcutsConfig = Record; +export interface FixedShortcut { + label: string; + display: string; + bindings: ShortcutBinding[]; +} + +export const FIXED_SHORTCUTS: FixedShortcut[] = [ + { label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] }, + { label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] }, + { label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] }, + { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] }, + { label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] }, +]; + +export type ShortcutConflict = + | { type: 'configurable'; action: ShortcutAction } + | { type: 'fixed'; label: string }; + +export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { + return ( + a.key.toLowerCase() === b.key.toLowerCase() && + !!a.ctrl === !!b.ctrl && + !!a.shift === !!b.shift && + !!a.alt === !!b.alt + ); +} + +export function findConflict( + binding: ShortcutBinding, + forAction: ShortcutAction, + config: ShortcutsConfig, +): ShortcutConflict | null { + for (const fixed of FIXED_SHORTCUTS) { + if (fixed.bindings.some((b) => bindingsEqual(b, binding))) { + return { type: 'fixed', label: fixed.label }; + } + } + for (const action of SHORTCUT_ACTIONS) { + if (action !== forAction && bindingsEqual(config[action], binding)) { + return { type: 'configurable', action }; + } + } + return null; +} + export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addZoom: { key: 'z' }, addTrim: { key: 't' }, From 0e082fff9c921d177235795361a2fa8d303ab4ce Mon Sep 17 00:00:00 2001 From: FabLrc Date: Sun, 1 Mar 2026 23:08:05 +0100 Subject: [PATCH 6/6] fix: Add configurable shortcut for speed adjustment in TimelineEditor --- src/components/video-editor/timeline/TimelineEditor.tsx | 2 +- src/lib/shortcuts.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 17a3767..2b03685 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -967,7 +967,7 @@ export default function TimelineEditor({ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); } - if (e.key === 's' || e.key === 'S') { + if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) { handleAddSpeed(); } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 75ccce9..847c753 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,6 +1,7 @@ export const SHORTCUT_ACTIONS = [ 'addZoom', 'addTrim', + 'addSpeed', 'addAnnotation', 'addKeyframe', 'deleteSelected', @@ -67,6 +68,7 @@ export function findConflict( export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addZoom: { key: 'z' }, addTrim: { key: 't' }, + addSpeed: { key: 's' }, addAnnotation: { key: 'a' }, addKeyframe: { key: 'f' }, deleteSelected: { key: 'd', ctrl: true }, @@ -76,6 +78,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { export const SHORTCUT_LABELS: Record = { addZoom: 'Add Zoom', addTrim: 'Add Trim', + addSpeed: 'Add Speed', addAnnotation: 'Add Annotation', addKeyframe: 'Add Keyframe', deleteSelected: 'Delete Selected',