From 9bc2c78b4d2665548ded4ce45754c7729d931543 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Thu, 26 Feb 2026 15:41:32 +0100 Subject: [PATCH 1/8] 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 92d2a41296d2b86198871e4bff2e68ccfed6bde0 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Fri, 27 Feb 2026 00:24:27 +0100 Subject: [PATCH 2/8] fix: improve encoder queue management and adjust latency mode for better throughput --- src/lib/exporter/videoExporter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 937c4a3..c2f9417 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -125,8 +125,8 @@ export class VideoExporter { }); // Check encoder queue before encoding to keep it full - while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) { - await new Promise(resolve => setTimeout(resolve, 0)); + while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) { + await new Promise(resolve => setTimeout(resolve, 5)); } if (this.encoder && this.encoder.state === 'configured') { @@ -250,7 +250,7 @@ export class VideoExporter { height: this.config.height, bitrate: this.config.bitrate, framerate: this.config.frameRate, - latencyMode: 'realtime', + latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput bitrateMode: 'variable', hardwareAcceleration: 'prefer-hardware', }; From 83d3e7b6b89a9bb63be36b2e709118bde9ae97ec Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:08:19 -0600 Subject: [PATCH 3/8] refactor: replace magic numbers with named constants in useScreenRecorder --- src/hooks/useScreenRecorder.ts | 61 ++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index cfb2183..5b25c16 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,6 +1,36 @@ import { useState, useRef, useEffect } from "react"; import { fixWebmDuration } from "@fix-webm-duration/fix"; +// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up +const TARGET_FRAME_RATE = 60; +const MIN_FRAME_RATE = 30; +const TARGET_WIDTH = 3840; +const TARGET_HEIGHT = 2160; +const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT; +const QHD_WIDTH = 2560; +const QHD_HEIGHT = 1440; +const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT; + +// Bitrates (bits per second) per resolution tier +const BITRATE_4K = 45_000_000; +const BITRATE_QHD = 28_000_000; +const BITRATE_BASE = 18_000_000; +const HIGH_FRAME_RATE_THRESHOLD = 60; +const HIGH_FRAME_RATE_BOOST = 1.7; + +// Fallback track settings when the driver reports nothing +const DEFAULT_WIDTH = 1920; +const DEFAULT_HEIGHT = 1080; + +// Codec alignment: VP9/AV1 require dimensions divisible by 2 +const CODEC_ALIGNMENT = 2; + +const RECORDER_TIMESLICE_MS = 1000; +const BITS_PER_MEGABIT = 1_000_000; +const CHROME_MEDIA_SOURCE = "desktop"; +const RECORDING_FILE_PREFIX = "recording-"; +const VIDEO_FILE_EXTENSION = ".webm"; + type UseScreenRecorderReturn = { recording: boolean; toggleRecording: () => void; @@ -13,11 +43,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const chunks = useRef([]); const startTime = useRef(0); - // Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up - const TARGET_FRAME_RATE = 60; - const TARGET_WIDTH = 3840; - const TARGET_HEIGHT = 2160; - const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT; const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -32,17 +57,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const computeBitrate = (width: number, height: number) => { const pixels = width * height; - const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1; + const highFrameRateBoost = TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1; if (pixels >= FOUR_K_PIXELS) { - return Math.round(45_000_000 * highFrameRateBoost); + return Math.round(BITRATE_4K * highFrameRateBoost); } - if (pixels >= 2560 * 1440) { - return Math.round(28_000_000 * highFrameRateBoost); + if (pixels >= QHD_PIXELS) { + return Math.round(BITRATE_QHD * highFrameRateBoost); } - return Math.round(18_000_000 * highFrameRateBoost); + return Math.round(BITRATE_BASE * highFrameRateBoost); }; const stopRecording = useRef(() => { @@ -91,12 +116,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { audio: false, video: { mandatory: { - chromeMediaSource: "desktop", + chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: selectedSource.id, maxWidth: TARGET_WIDTH, maxHeight: TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE, - minFrameRate: 30, + minFrameRate: MIN_FRAME_RATE, }, }, }); @@ -115,18 +140,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error); } - let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings(); + let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings(); // Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility - width = Math.floor(width / 2) * 2; - height = Math.floor(height / 2) * 2; + width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; + height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; const videoBitsPerSecond = computeBitrate(width, height); const mimeType = selectMimeType(); console.log( `Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round( - videoBitsPerSecond / 1_000_000 + videoBitsPerSecond / BITS_PER_MEGABIT )} Mbps` ); @@ -148,7 +173,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // Clear chunks early to free memory immediately after blob creation chunks.current = []; const timestamp = Date.now(); - const videoFileName = `recording-${timestamp}.webm`; + const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`; try { const videoBlob = await fixWebmDuration(buggyBlob, duration); @@ -169,7 +194,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; recorder.onerror = () => setRecording(false); - recorder.start(1000); + recorder.start(RECORDER_TIMESLICE_MS); startTime.current = Date.now(); setRecording(true); window.electronAPI?.setRecordingState(true); From 4b3afcf535d3a27decc9cdea653ae1148ed8579e Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 27 Feb 2026 23:44:02 -0800 Subject: [PATCH 4/8] annotation bounding and canvas wrapping --- src/lib/exporter/annotationRenderer.ts | 29 ++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index af04e19..06c4121 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -138,7 +138,12 @@ function renderText( const style = annotation.style; ctx.save(); - + + // Clip text to annotation box bounds (matches editor's overflow: hidden) + ctx.beginPath(); + ctx.rect(x, y, width, height); + ctx.clip(); + const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal'; const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal'; const scaledFontSize = style.fontSize * scaleFactor; @@ -161,7 +166,27 @@ function renderText( ctx.textAlign = 'left'; } - const lines = annotation.content.split('\n'); + const availableWidth = width - containerPadding * 2; + const rawLines = annotation.content.split('\n'); + const lines: string[] = []; + for (const rawLine of rawLines) { + if (!rawLine) { + lines.push(''); + continue; + } + const words = rawLine.split(/(\s+)/); + let current = ''; + for (const word of words) { + const test = current + word; + if (current && ctx.measureText(test).width > availableWidth) { + lines.push(current); + current = word.trimStart(); + } else { + current = test; + } + } + if (current) lines.push(current); + } const lineHeight = scaledFontSize * 1.4; const startY = textY - ((lines.length - 1) * lineHeight) / 2; From a2b9eea90aa96595bf808933c37410fda43b3624 Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:29:50 -0800 Subject: [PATCH 5/8] feat: add cursor telemetry-driven zoom suggestions --- electron/electron-env.d.ts | 7 + electron/ipc/handlers.ts | 117 +++++++++++++- electron/preload.ts | 3 + src/components/video-editor/VideoEditor.tsx | 65 +++++++- .../video-editor/timeline/TimelineEditor.tsx | 151 +++++++++++++++++- src/components/video-editor/types.ts | 6 + src/vite-env.d.ts | 12 ++ 7 files changed, 355 insertions(+), 6 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..dda3d8d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -32,6 +32,7 @@ interface Window { storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> setRecordingState: (recording: boolean) => Promise + getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }> onStopRecordingFromTray: (callback: () => void) => () => void openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }> @@ -52,3 +53,9 @@ interface ProcessedDesktopSource { thumbnail: string | null appIcon: string | null } + +interface CursorTelemetryPoint { + timeMs: number + cx: number + cy: number +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 867b72b..24a63a1 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,10 +1,62 @@ -import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron' +import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron' import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' let selectedSource: any = null +let currentVideoPath: string | null = null + +const CURSOR_TELEMETRY_VERSION = 1 +const CURSOR_SAMPLE_INTERVAL_MS = 100 +const MAX_CURSOR_SAMPLES = 60 * 60 * 10 // 1 hour @ 10Hz + +interface CursorTelemetryPoint { + timeMs: number + cx: number + cy: number +} + +let cursorCaptureInterval: NodeJS.Timeout | null = null +let cursorCaptureStartTimeMs = 0 +let activeCursorSamples: CursorTelemetryPoint[] = [] +let pendingCursorSamples: CursorTelemetryPoint[] = [] + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function stopCursorCapture() { + if (cursorCaptureInterval) { + clearInterval(cursorCaptureInterval) + cursorCaptureInterval = null + } +} + +function sampleCursorPoint() { + const cursor = screen.getCursorScreenPoint() + const sourceDisplayId = Number(selectedSource?.display_id) + const sourceDisplay = Number.isFinite(sourceDisplayId) + ? screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null + : null + const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor) + const bounds = display.bounds + const width = Math.max(1, bounds.width) + const height = Math.max(1, bounds.height) + + const cx = clamp((cursor.x - bounds.x) / width, 0, 1) + const cy = clamp((cursor.y - bounds.y) / height, 0, 1) + + activeCursorSamples.push({ + timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs), + cx, + cy, + }) + + if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { + activeCursorSamples.shift() + } +} export function registerIpcHandlers( createEditorWindow: () => void, @@ -61,6 +113,17 @@ export function registerIpcHandlers( const videoPath = path.join(RECORDINGS_DIR, fileName) await fs.writeFile(videoPath, Buffer.from(videoData)) currentVideoPath = videoPath; + + const telemetryPath = `${videoPath}.cursor.json` + if (pendingCursorSamples.length > 0) { + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + 'utf-8' + ) + } + pendingCursorSamples = [] + return { success: true, path: videoPath, @@ -98,12 +161,62 @@ export function registerIpcHandlers( }) ipcMain.handle('set-recording-state', (_, recording: boolean) => { + if (recording) { + stopCursorCapture() + activeCursorSamples = [] + pendingCursorSamples = [] + cursorCaptureStartTimeMs = Date.now() + sampleCursorPoint() + cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS) + } else { + stopCursorCapture() + pendingCursorSamples = [...activeCursorSamples] + activeCursorSamples = [] + } + const source = selectedSource || { name: 'Screen' } if (onRecordingStateChange) { onRecordingStateChange(recording, source.name) } }) + ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => { + const targetVideoPath = videoPath ?? currentVideoPath + if (!targetVideoPath) { + return { success: true, samples: [] } + } + + const telemetryPath = `${targetVideoPath}.cursor.json` + try { + const content = await fs.readFile(telemetryPath, 'utf-8') + const parsed = JSON.parse(content) + const rawSamples = Array.isArray(parsed) + ? parsed + : (Array.isArray(parsed?.samples) ? parsed.samples : []) + + const samples: CursorTelemetryPoint[] = rawSamples + .filter((sample: unknown) => Boolean(sample && typeof sample === 'object')) + .map((sample: unknown) => { + const point = sample as Partial + return { + timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0, + cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5, + cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5, + } + }) + .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs) + + return { success: true, samples } + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code === 'ENOENT') { + return { success: true, samples: [] } + } + console.error('Failed to load cursor telemetry:', error) + return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] } + } + }) + ipcMain.handle('open-external-url', async (_, url: string) => { try { @@ -198,8 +311,6 @@ export function registerIpcHandlers( } }); - let currentVideoPath: string | null = null; - ipcMain.handle('set-current-video-path', (_, path: string) => { currentVideoPath = path; return { success: true }; diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..f58d8a8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -37,6 +37,9 @@ contextBridge.exposeInMainWorld('electronAPI', { setRecordingState: (recording: boolean) => { return ipcRenderer.invoke('set-recording-state', recording) }, + getCursorTelemetry: (videoPath?: string) => { + return ipcRenderer.invoke('get-cursor-telemetry', videoPath) + }, onStopRecordingFromTray: (callback: () => void) => { const listener = () => callback() ipcRenderer.on('stop-recording-from-tray', listener) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0a038e..d418950 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -23,6 +23,7 @@ import { type ZoomDepth, type ZoomFocus, type ZoomRegion, + type CursorTelemetryPoint, type TrimRegion, type AnnotationRegion, type CropRegion, @@ -50,6 +51,7 @@ export default function VideoEditor() { const [padding, setPadding] = useState(50); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); + const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(null); @@ -89,6 +91,19 @@ export default function VideoEditor() { return fileUrl; }; + const fromFileUrl = (fileUrl: string): string => { + if (!fileUrl.startsWith('file://')) { + return fileUrl; + } + + try { + const url = new URL(fileUrl); + return decodeURIComponent(url.pathname); + } catch { + return fileUrl.replace(/^file:\/\//, ''); + } + }; + useEffect(() => { async function loadVideo() { try { @@ -109,6 +124,37 @@ export default function VideoEditor() { loadVideo(); }, []); + useEffect(() => { + let mounted = true; + + async function loadCursorTelemetry() { + if (!videoPath) { + if (mounted) { + setCursorTelemetry([]); + } + return; + } + + try { + const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath)); + if (mounted) { + setCursorTelemetry(result.success ? result.samples : []); + } + } catch (telemetryError) { + console.warn('Unable to load cursor telemetry:', telemetryError); + if (mounted) { + setCursorTelemetry([]); + } + } + } + + loadCursorTelemetry(); + + return () => { + mounted = false; + }; + }, [videoPath]); + // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; @@ -180,6 +226,21 @@ export default function VideoEditor() { setSelectedAnnotationId(null); }, []); + const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_ZOOM_DEPTH, + focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), + }; + setZoomRegions((prev) => [...prev, newRegion]); + setSelectedZoomId(id); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + }, []); + const handleTrimAdded = useCallback((span: Span) => { const id = `trim-${nextTrimIdRef.current++}`; const newRegion: TrimRegion = { @@ -804,8 +865,10 @@ export default function VideoEditor() { videoDuration={duration} currentTime={currentTime} onSeek={handleSeek} + cursorTelemetry={cursorTelemetry} zoomRegions={zoomRegions} onZoomAdded={handleZoomAdded} + onZoomSuggested={handleZoomSuggested} onZoomSpanChange={handleZoomSpanChange} onZoomDelete={handleZoomDelete} selectedZoomId={selectedZoomId} @@ -894,4 +957,4 @@ export default function VideoEditor() { />
); -} \ No newline at end of file +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b091ef..5f965d0 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, 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 } from "../types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, CursorTelemetryPoint, ZoomFocus } from "../types"; import { v4 as uuidv4 } from 'uuid'; import { DropdownMenu, @@ -26,13 +26,19 @@ const TRIM_ROW_ID = "row-trim"; const ANNOTATION_ROW_ID = "row-annotation"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; +const MIN_DWELL_DURATION_MS = 450; +const MAX_DWELL_DURATION_MS = 2600; +const DWELL_MOVE_THRESHOLD = 0.02; +const SUGGESTION_SPACING_MS = 1800; interface TimelineEditorProps { videoDuration: number; currentTime: number; onSeek?: (time: number) => void; + cursorTelemetry?: CursorTelemetryPoint[]; zoomRegions: ZoomRegion[]; onZoomAdded: (span: Span) => void; + onZoomSuggested?: (span: Span, focus: ZoomFocus) => void; onZoomSpanChange: (id: string, span: Span) => void; onZoomDelete: (id: string) => void; selectedZoomId: string | null; @@ -520,8 +526,10 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek, + cursorTelemetry = [], zoomRegions, onZoomAdded, + onZoomSuggested, onZoomSpanChange, onZoomDelete, selectedZoomId, @@ -716,6 +724,136 @@ export default function TimelineEditor({ onZoomAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]); + const handleSuggestZooms = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + if (!onZoomSuggested) { + toast.error("Zoom suggestion handler unavailable"); + return; + } + + if (cursorTelemetry.length < 2) { + toast.info("No cursor telemetry available", { + description: "Record a screencast first to generate cursor-based suggestions.", + }); + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const reservedSpans = [...zoomRegions] + .map((region) => ({ start: region.startMs, end: region.endMs })) + .sort((a, b) => a.start - b.start); + + const normalizedSamples = [...cursorTelemetry] + .filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy)) + .sort((a, b) => a.timeMs - b.timeMs) + .map((sample) => ({ + timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)), + cx: Math.max(0, Math.min(sample.cx, 1)), + cy: Math.max(0, Math.min(sample.cy, 1)), + })); + + if (normalizedSamples.length < 2) { + toast.info("No usable cursor telemetry", { + description: "The recording does not include enough cursor movement data.", + }); + return; + } + + const dwellCandidates: Array<{ centerTimeMs: number; focus: ZoomFocus; strength: number }> = []; + let runStart = 0; + + const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => { + if (endIndexExclusive - startIndex < 2) { + return; + } + + const start = normalizedSamples[startIndex]; + const end = normalizedSamples[endIndexExclusive - 1]; + const runDuration = end.timeMs - start.timeMs; + if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) { + return; + } + + const runSamples = normalizedSamples.slice(startIndex, endIndexExclusive); + const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length; + const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length; + + dwellCandidates.push({ + centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2), + focus: { cx: avgCx, cy: avgCy }, + strength: runDuration, + }); + }; + + for (let index = 1; index < normalizedSamples.length; index += 1) { + const prev = normalizedSamples[index - 1]; + const curr = normalizedSamples[index]; + const dx = curr.cx - prev.cx; + const dy = curr.cy - prev.cy; + const distance = Math.hypot(dx, dy); + + if (distance > DWELL_MOVE_THRESHOLD) { + pushRunIfDwell(runStart, index); + runStart = index; + } + } + pushRunIfDwell(runStart, normalizedSamples.length); + + if (dwellCandidates.length === 0) { + toast.info("No clear cursor dwell moments found", { + description: "Try a recording with slower cursor pauses on important actions.", + }); + return; + } + + const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength); + const acceptedCenters: number[] = []; + + let addedCount = 0; + + sortedCandidates.forEach((candidate) => { + const tooCloseToAccepted = acceptedCenters.some( + (center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS, + ); + + if (tooCloseToAccepted) { + return; + } + + const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2); + const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration)); + const candidateEnd = candidateStart + defaultDuration; + const hasOverlap = reservedSpans.some( + (span) => candidateEnd > span.start && candidateStart < span.end, + ); + + if (hasOverlap) { + return; + } + + reservedSpans.push({ start: candidateStart, end: candidateEnd }); + acceptedCenters.push(candidate.centerTimeMs); + onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus); + addedCount += 1; + }); + + if (addedCount === 0) { + toast.info("No auto-zoom slots available", { + description: "Detected dwell points overlap existing zoom regions.", + }); + return; + } + + toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`); + }, [videoDuration, totalMs, defaultRegionDurationMs, zoomRegions, onZoomSuggested, cursorTelemetry]); + const handleAddTrim = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { return; @@ -920,6 +1058,15 @@ export default function TimelineEditor({ > + +
+
+ {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 4ab8f3d1f1af1bb4f2ff29dcc11c03f7e554e6a8 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 28 Feb 2026 12:36:50 -0800 Subject: [PATCH 8/8] export zoom focus clamping --- src/lib/exporter/frameRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 0e7cfe2..9caa4d2 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -385,7 +385,7 @@ export class FrameRenderer { private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } { if (!this.layoutCache) return focus; - return clampFocusToStageUtil(focus, depth as any, this.layoutCache); + return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize); } private updateAnimationState(timeMs: number): number {