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..a489699 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 }> @@ -40,6 +41,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; } @@ -52,3 +55,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..cec3ed9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,10 +1,64 @@ -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' +const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json') + 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 +115,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 +163,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 +313,6 @@ export function registerIpcHandlers( } }); - let currentVideoPath: string | null = null; - ipcMain.handle('set-current-video-path', (_, path: string) => { currentVideoPath = path; return { success: true }; @@ -217,4 +330,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..a609e7b 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) @@ -63,4 +66,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..e524e38 --- /dev/null +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Keyboard, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + DEFAULT_SHORTCUTS, + FIXED_SHORTCUTS, + SHORTCUT_ACTIONS, + SHORTCUT_LABELS, + findConflict, + formatBinding, + type ShortcutAction, + type ShortcutBinding, + type ShortcutConflict, + type ShortcutsConfig, +} from '@/lib/shortcuts'; +import { useShortcuts } from '@/contexts/ShortcutsContext'; + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +export function ShortcutsConfigDialog() { + const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = + useShortcuts(); + + const [draft, setDraft] = useState(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]); + + useEffect(() => { + if (!captureFor) return; + + const handleCapture = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + setCaptureFor(null); + return; + } + + if (MODIFIER_KEYS.has(e.key)) return; + + const binding: ShortcutBinding = { + key: e.key.toLowerCase(), + ...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}), + ...(e.shiftKey ? { shift: true } : {}), + ...(e.altKey ? { alt: true } : {}), + }; + + const found = findConflict(binding, captureFor, draft); + setCaptureFor(null); + + if (found?.type === 'fixed') { + toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`); + return; + } + + if (found?.type === 'configurable') { + setConflict({ forAction: captureFor, pending: binding, conflictWith: found }); + return; + } + + setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + }; + + window.addEventListener('keydown', handleCapture, { capture: true }); + return () => window.removeEventListener('keydown', handleCapture, { capture: true }); + }, [captureFor]); + + const handleSwap = useCallback(() => { + if (!conflict || conflict.conflictWith.type !== 'configurable') return; + const { forAction, pending, conflictWith } = conflict; + setDraft((prev: ShortcutsConfig) => ({ + ...prev, + [forAction]: pending, + [conflictWith.action]: prev[forAction], + })); + setConflict(null); + }, [conflict]); + + const handleCancelConflict = useCallback(() => setConflict(null), []); + + const handleSave = useCallback(async () => { + setShortcuts(draft); + await persistShortcuts(draft); + toast.success('Keyboard shortcuts saved'); + closeConfig(); + }, [draft, setShortcuts, persistShortcuts, closeConfig]); + + const handleReset = useCallback(() => { + setDraft({ ...DEFAULT_SHORTCUTS }); + toast.info('Reset to default shortcuts — click Save to apply'); + }, []); + + const handleClose = useCallback(() => { + setCaptureFor(null); + setConflict(null); + closeConfig(); + }, [closeConfig]); + + return ( + { if (!open) handleClose(); }}> + + + + + Keyboard Shortcuts + + + +
+

Configurable

+ {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( +
+
+ {SHORTCUT_LABELS[action]} + +
+ {hasConflict && conflict?.conflictWith.type === 'configurable' && ( +
+ + ⚠ Already used by {SHORTCUT_LABELS[conflict.conflictWith.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 c5b8af2..4e0365a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -24,6 +24,7 @@ import { type ZoomDepth, type ZoomFocus, type ZoomRegion, + type CursorTelemetryPoint, type TrimRegion, type AnnotationRegion, type CropRegion, @@ -34,6 +35,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`); @@ -53,6 +56,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); @@ -75,6 +79,8 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); + + const { shortcuts, isMac } = useShortcuts(); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); @@ -95,6 +101,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 { @@ -115,6 +134,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; @@ -186,6 +236,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 = { @@ -458,7 +523,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; @@ -478,7 +543,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)) { @@ -873,8 +938,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} @@ -973,4 +1040,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 7a0d478..17a3767 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, Gauge } from "lucide-react"; +import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import TimelineWrapper from "./TimelineWrapper"; @@ -9,7 +9,7 @@ import Row from "./Row"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion } from "../types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint, ZoomFocus } from "../types"; import { v4 as uuidv4 } from 'uuid'; import { DropdownMenu, @@ -20,6 +20,9 @@ import { import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { TutorialHelp } from "../TutorialHelp"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { matchesShortcut } from "@/lib/shortcuts"; +import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; const TRIM_ROW_ID = "row-trim"; @@ -27,13 +30,16 @@ const ANNOTATION_ROW_ID = "row-annotation"; const SPEED_ROW_ID = "row-speed"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; +const SUGGESTION_SPACING_MS = 1800; 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; @@ -551,8 +557,10 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek, + cursorTelemetry = [], zoomRegions, onZoomAdded, + onZoomSuggested, onZoomSpanChange, onZoomDelete, selectedZoomId, @@ -589,16 +597,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 }); }); }); }, []); @@ -778,6 +787,91 @@ 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 = normalizeCursorTelemetry(cursorTelemetry, totalMs); + + if (normalizedSamples.length < 2) { + toast.info("No usable cursor telemetry", { + description: "The recording does not include enough cursor movement data.", + }); + return; + } + + const dwellCandidates = detectZoomDwellCandidates(normalizedSamples); + + 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; @@ -861,16 +955,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(); } if (e.key === 's' || e.key === 'S') { @@ -900,7 +994,7 @@ export default function TimelineEditor({ } } // Delete key or Ctrl+D / Cmd+D - if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) { + if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) { if (selectedKeyframeId) { deleteSelectedKeyframe(); } else if (selectedZoomId) { @@ -916,7 +1010,7 @@ export default function TimelineEditor({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation]); + }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -1029,6 +1123,15 @@ export default function TimelineEditor({ > +