From 0e85679b141aec0362ff9518ad97c352f628c448 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Fri, 27 Feb 2026 15:43:56 +0100 Subject: [PATCH 1/3] feat: implement undo/redo functionality in video editor --- .../video-editor/KeyboardShortcutsHelp.tsx | 29 +- src/components/video-editor/SettingsPanel.tsx | 9 + src/components/video-editor/VideoEditor.tsx | 291 +++++++----------- src/components/video-editor/VideoPlayback.tsx | 3 + .../video-editor/timeline/TimelineEditor.tsx | 25 +- src/hooks/useEditorHistory.ts | 112 +++++++ 6 files changed, 279 insertions(+), 190 deletions(-) create mode 100644 src/hooks/useEditorHistory.ts diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 3edbd04..308fe65 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -6,19 +6,28 @@ export function KeyboardShortcutsHelp() { const [shortcuts, setShortcuts] = useState({ delete: 'Ctrl + D', pan: 'Shift + Ctrl + Scroll', - zoom: 'Ctrl + Scroll' + zoom: 'Ctrl + Scroll', + undo: 'Ctrl + Z', + redo: 'Ctrl + Shift + Z', + redoAlt: 'Ctrl + Y', }); useEffect(() => { Promise.all([ formatShortcut(['mod', 'D']), formatShortcut(['shift', 'mod', 'Scroll']), - formatShortcut(['mod', 'Scroll']) - ]).then(([deleteKey, panKey, zoomKey]) => { + formatShortcut(['mod', 'Scroll']), + formatShortcut(['mod', 'Z']), + formatShortcut(['shift', 'mod', 'Z']), + formatShortcut(['mod', 'Y']), + ]).then(([deleteKey, panKey, zoomKey, undoKey, redoKey, redoAltKey]) => { setShortcuts({ delete: deleteKey, pan: panKey, - zoom: zoomKey + zoom: zoomKey, + undo: undoKey, + redo: redoKey, + redoAlt: redoAltKey, }); }); }, []); @@ -61,6 +70,18 @@ export function KeyboardShortcutsHelp() { Pause/Play Space +
+ Undo + {shortcuts.undo} +
+
+ Redo +
+ {shortcuts.redo} + or + {shortcuts.redoAlt} +
+
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index db5e9d8..93fcd12 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -58,14 +58,17 @@ interface SettingsPanelProps { onTrimDelete?: (id: string) => void; shadowIntensity?: number; onShadowChange?: (intensity: number) => void; + onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; motionBlurEnabled?: boolean; onMotionBlurChange?: (enabled: boolean) => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; + onBorderRadiusCommit?: () => void; padding?: number; onPaddingChange?: (padding: number) => void; + onPaddingCommit?: () => void; cropRegion?: CropRegion; onCropChange?: (region: CropRegion) => void; aspectRatio: AspectRatio; @@ -114,14 +117,17 @@ export function SettingsPanel({ onTrimDelete, shadowIntensity = 0, onShadowChange, + onShadowCommit, showBlur, onBlurChange, motionBlurEnabled = false, onMotionBlurChange, borderRadius = 0, onBorderRadiusChange, + onBorderRadiusCommit, padding = 50, onPaddingChange, + onPaddingCommit, cropRegion, onCropChange, aspectRatio, @@ -358,6 +364,7 @@ export function SettingsPanel({ onShadowChange?.(values[0])} + onValueCommit={() => onShadowCommit?.()} min={0} max={1} step={0.01} @@ -372,6 +379,7 @@ export function SettingsPanel({ onBorderRadiusChange?.(values[0])} + onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} step={0.5} @@ -386,6 +394,7 @@ export function SettingsPanel({ onPaddingChange?.(values[0])} + onValueCommit={() => onPaddingCommit?.()} min={0} max={100} step={1} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index d418950..7e93591 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -15,7 +15,6 @@ import type { Span } from "dnd-timeline"; import { DEFAULT_ZOOM_DEPTH, clampFocusToDepth, - DEFAULT_CROP_REGION, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, @@ -26,42 +25,38 @@ import { type CursorTelemetryPoint, type TrimRegion, type AnnotationRegion, - type CropRegion, type FigureData, } 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"; +import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; - -const WALLPAPER_COUNT = 18; -const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); +import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory"; export default function VideoEditor() { + const { state: editorState, pushState, updateState, commitState, undo, redo } = + useEditorHistory(INITIAL_EDITOR_STATE); + + const { + zoomRegions, trimRegions, annotationRegions, + cropRegion, wallpaper, shadowIntensity, showBlur, + motionBlurEnabled, borderRadius, padding, aspectRatio, + } = editorState; + + // ── Non-undoable state const [videoPath, setVideoPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]); - const [shadowIntensity, setShadowIntensity] = useState(0); - const [showBlur, setShowBlur] = useState(false); - const [motionBlurEnabled, setMotionBlurEnabled] = useState(false); - const [borderRadius, setBorderRadius] = useState(0); - 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); - const [annotationRegions, setAnnotationRegions] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); - const [aspectRatio, setAspectRatio] = useState('16:9'); const [exportQuality, setExportQuality] = useState('good'); const [exportFormat, setExportFormat] = useState('mp4'); const [gifFrameRate, setGifFrameRate] = useState(15); @@ -72,23 +67,12 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextAnnotationIdRef = useRef(1); - const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order + const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); - // Helper to convert file path to proper file:// URL const toFileUrl = (filePath: string): string => { - // Normalize path separators to forward slashes const normalized = filePath.replace(/\\/g, '/'); - - // Check if it's a Windows absolute path (e.g., C:/Users/...) - if (normalized.match(/^[a-zA-Z]:/)) { - const fileUrl = `file:///${normalized}`; - return fileUrl; - } - - // Unix-style absolute path - const fileUrl = `file://${normalized}`; - return fileUrl; + return normalized.match(/^[a-zA-Z]:/) ? `file:///${normalized}` : `file://${normalized}`; }; const fromFileUrl = (fileUrl: string): string => { @@ -158,19 +142,11 @@ export default function VideoEditor() { // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; - (async () => { - try { - const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg'); - if (mounted) { - setWallpaper(resolvedPath); - } - } catch (err) { - // If resolution fails, keep the fallback - console.warn('Failed to resolve default wallpaper path:', err); - } - })(); - return () => { mounted = false }; - }, []); + getAssetPath('wallpapers/wallpaper1.jpg') + .then((path) => { if (mounted) updateState({ wallpaper: path }); }) + .catch((err) => console.warn('Failed to resolve default wallpaper path:', err)); + return () => { mounted = false; }; + }, [updateState]); function togglePlayPause() { const playback = videoPlaybackRef.current; @@ -220,11 +196,11 @@ export default function VideoEditor() { depth: DEFAULT_ZOOM_DEPTH, focus: { cx: 0.5, cy: 0.5 }, }; - setZoomRegions((prev) => [...prev, newRegion]); + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); - }, []); + }, [pushState]); const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => { const id = `zoom-${nextZoomIdRef.current++}`; @@ -235,11 +211,11 @@ export default function VideoEditor() { depth: DEFAULT_ZOOM_DEPTH, focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), }; - setZoomRegions((prev) => [...prev, newRegion]); + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); setSelectedAnnotationId(null); - }, []); + }, [pushState]); const handleTrimAdded = useCallback((span: Span) => { const id = `trim-${nextTrimIdRef.current++}`; @@ -248,85 +224,71 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), }; - setTrimRegions((prev) => [...prev, newRegion]); + pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); setSelectedTrimId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); - }, []); + }, [pushState]); const handleZoomSpanChange = useCallback((id: string, span: Span) => { - setZoomRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } : region, ), - ); - }, []); + })); + }, [pushState]); const handleTrimSpanChange = useCallback((id: string, span: Span) => { - setTrimRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + trimRegions: prev.trimRegions.map((region) => region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } : region, ), - ); - }, []); + })); + }, [pushState]); + // Focus drag: updateState for live preview, commitState on pointer-up const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { - setZoomRegions((prev) => - prev.map((region) => + updateState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => region.id === id - ? { - ...region, - focus: clampFocusToDepth(focus, region.depth), - } + ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region, ), - ); - }, []); + })); + }, [updateState]); const handleZoomDepthChange = useCallback((depth: ZoomDepth) => { if (!selectedZoomId) return; - setZoomRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => region.id === selectedZoomId - ? { - ...region, - depth, - focus: clampFocusToDepth(region.focus, depth), - } + ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } : region, ), - ); - }, [selectedZoomId]); + })); + }, [selectedZoomId, pushState]); const handleZoomDelete = useCallback((id: string) => { - setZoomRegions((prev) => prev.filter((region) => region.id !== id)); + pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); if (selectedZoomId === id) { setSelectedZoomId(null); } - }, [selectedZoomId]); + }, [selectedZoomId, pushState]); const handleTrimDelete = useCallback((id: string) => { - setTrimRegions((prev) => prev.filter((region) => region.id !== id)); + pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) })); if (selectedTrimId === id) { setSelectedTrimId(null); } - }, [selectedTrimId]); + }, [selectedTrimId, pushState]); const handleAnnotationAdded = useCallback((span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; - const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order + const zIndex = nextAnnotationZIndexRef.current++; const newRegion: AnnotationRegion = { id, startMs: Math.round(span.start), @@ -338,59 +300,48 @@ export default function VideoEditor() { style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, }; - setAnnotationRegions((prev) => [...prev, newRegion]); + pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] })); setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); - }, []); + }, [pushState]); const handleAnnotationSpanChange = useCallback((id: string, span: Span) => { - setAnnotationRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } : region, ), - ); - }, []); + })); + }, [pushState]); const handleAnnotationDelete = useCallback((id: string) => { - setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); + pushState((prev) => ({ annotationRegions: prev.annotationRegions.filter((r) => r.id !== id) })); if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } - }, [selectedAnnotationId]); + }, [selectedAnnotationId, pushState]); const handleAnnotationContentChange = useCallback((id: string, content: string) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => { if (region.id !== id) return region; - - // Store content in type-specific fields if (region.type === 'text') { return { ...region, content, textContent: content }; } else if (region.type === 'image') { return { ...region, content, imageContent: content }; - } else { - return { ...region, content }; } - }); - return updated; - }); - }, []); + return { ...region, content }; + }), + })); + }, [pushState]); const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => { if (region.id !== id) return region; - const updatedRegion = { ...region, type }; - - // Restore content from type-specific storage if (type === 'text') { updatedRegion.content = region.textContent || 'Enter text...'; } else if (type === 'image') { @@ -401,85 +352,71 @@ export default function VideoEditor() { updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; } } - return updatedRegion; - }); - return updated; - }); - }, []); + }), + })); + }, [pushState]); const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => { - setAnnotationRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => region.id === id ? { ...region, style: { ...region.style, ...style } } : region, ), - ); - }, []); + })); + }, [pushState]); const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, figureData } - : region, + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, figureData } : region, ), - ); - }, []); + })); + }, [pushState]); const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, position } - : region, + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, position } : region, ), - ); - }, []); + })); + }, [pushState]); const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, size } - : region, + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, size } : region, ), - ); - }, []); + })); + }, [pushState]); - // Global Tab prevention useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Tab') { - // Allow tab only in inputs/textareas - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - e.preventDefault(); - } + const mod = e.ctrlKey || e.metaKey; + const key = e.key.toLowerCase(); - if (e.key === ' ' || e.code === 'Space') { - // Allow space only in inputs/textareas - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } + if (mod && key === 'z' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); undo(); return; } + if (mod && (key === 'y' || (key === 'z' && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); redo(); return; } + + const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; + + if (e.key === 'Tab' && !isInput) { e.preventDefault(); } + + if ((e.key === ' ' || e.code === 'Space') && !isInput) { e.preventDefault(); - const playback = videoPlaybackRef.current; if (playback?.video) { - if (playback.video.paused) { - playback.play().catch(console.error); - } else { - playback.pause(); - } + playback.video.paused + ? playback.play().catch(console.error) + : playback.pause(); } } }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, []); + }, [undo, redo]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { @@ -822,6 +759,7 @@ export default function VideoEditor() { selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} onZoomFocusChange={handleZoomFocusChange} + onZoomFocusDragEnd={commitState} isPlaying={isPlaying} showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} @@ -886,7 +824,7 @@ export default function VideoEditor() { selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} aspectRatio={aspectRatio} - onAspectRatioChange={setAspectRatio} + onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })} /> @@ -896,7 +834,7 @@ export default function VideoEditor() { {/* Right section: settings panel */} pushState({ wallpaper: w })} selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null} onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomId={selectedZoomId} @@ -904,17 +842,20 @@ export default function VideoEditor() { selectedTrimId={selectedTrimId} onTrimDelete={handleTrimDelete} shadowIntensity={shadowIntensity} - onShadowChange={setShadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} showBlur={showBlur} - onBlurChange={setShowBlur} + onBlurChange={(v) => pushState({ showBlur: v })} motionBlurEnabled={motionBlurEnabled} - onMotionBlurChange={setMotionBlurEnabled} + onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })} borderRadius={borderRadius} - onBorderRadiusChange={setBorderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} padding={padding} - onPaddingChange={setPadding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} cropRegion={cropRegion} - onCropChange={setCropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} aspectRatio={aspectRatio} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 4dc491e..a95a301 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -26,6 +26,7 @@ interface VideoPlaybackProps { selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; onZoomFocusChange: (id: string, focus: ZoomFocus) => void; + onZoomFocusDragEnd?: () => void; isPlaying: boolean; showShadow?: boolean; shadowIntensity?: number; @@ -65,6 +66,7 @@ const VideoPlayback = forwardRef(({ selectedZoomId, onSelectZoom, onZoomFocusChange, + onZoomFocusDragEnd, isPlaying, showShadow, shadowIntensity = 0, @@ -293,6 +295,7 @@ const VideoPlayback = forwardRef(({ } catch { } + onZoomFocusDragEnd?.(); }; const handleOverlayPointerUp = (event: React.PointerEvent) => { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b15333..2a5f061 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -860,17 +860,20 @@ export default function TimelineEditor({ return; } - if (e.key === 'f' || e.key === 'F') { - addKeyframe(); - } - if (e.key === 'z' || e.key === 'Z') { - handleAddZoom(); - } - if (e.key === 't' || e.key === 'T') { - handleAddTrim(); - } - if (e.key === 'a' || e.key === 'A') { - handleAddAnnotation(); + // Single-letter shortcuts only when no modifier key is held + if (!e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.key === 'f' || e.key === 'F') { + addKeyframe(); + } + if (e.key === 'z' || e.key === 'Z') { + handleAddZoom(); + } + if (e.key === 't' || e.key === 'T') { + handleAddTrim(); + } + if (e.key === 'a' || e.key === 'A') { + handleAddAnnotation(); + } } // Tab: Cycle through overlapping annotations at current time diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts new file mode 100644 index 0000000..d3f686d --- /dev/null +++ b/src/hooks/useEditorHistory.ts @@ -0,0 +1,112 @@ +import { useCallback, useRef, useState } from "react"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, CropRegion } from "@/components/video-editor/types"; +import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; + +// Undoable state — selection IDs are intentionally excluded (undoing a +// selection change would feel surprising to the user). +export interface EditorState { + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + annotationRegions: AnnotationRegion[]; + cropRegion: CropRegion; + wallpaper: string; + shadowIntensity: number; + showBlur: boolean; + motionBlurEnabled: boolean; + borderRadius: number; + padding: number; + aspectRatio: AspectRatio; +} + +export const INITIAL_EDITOR_STATE: EditorState = { + zoomRegions: [], + trimRegions: [], + annotationRegions: [], + cropRegion: DEFAULT_CROP_REGION, + wallpaper: "/wallpapers/wallpaper1.jpg", + shadowIntensity: 0, + showBlur: false, + motionBlurEnabled: false, + borderRadius: 0, + padding: 50, + aspectRatio: "16:9", +}; + +type StateUpdate = Partial | ((prev: EditorState) => Partial); + +interface History { + past: EditorState[]; + present: EditorState; + future: EditorState[]; +} + +const MAX_HISTORY = 80; + +function resolve(present: EditorState, update: StateUpdate): EditorState { + const partial = typeof update === "function" ? update(present) : update; + return { ...present, ...partial }; +} + +function withCheckpoint(history: History, newPresent: EditorState): History { + return { + past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present], + present: newPresent, + future: [], + }; +} + +export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { + const [history, setHistory] = useState({ past: [], present: initial, future: [] }); + + // Tracks whether a live-update series (e.g. slider drag) is in progress. + // The first updateState call saves the pre-interaction state as a checkpoint. + const dirtyRef = useRef(false); + + const pushState = useCallback((update: StateUpdate) => { + setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update))); + dirtyRef.current = false; + }, []); + + const updateState = useCallback((update: StateUpdate) => { + const isFirst = !dirtyRef.current; + dirtyRef.current = true; + setHistory((prev) => { + const next = resolve(prev.present, update); + return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next }; + }); + }, []); + + const commitState = useCallback(() => { + dirtyRef.current = false; + }, []); + + const undo = useCallback(() => { + setHistory((prev) => { + if (!prev.past.length) return prev; + const previous = prev.past[prev.past.length - 1]; + return { past: prev.past.slice(0, -1), present: previous, future: [prev.present, ...prev.future] }; + }); + dirtyRef.current = false; + }, []); + + const redo = useCallback(() => { + setHistory((prev) => { + if (!prev.future.length) return prev; + const [next, ...remainingFuture] = prev.future; + return { past: [...prev.past, prev.present], present: next, future: remainingFuture }; + }); + dirtyRef.current = false; + }, []); + + return { + state: history.present, + pushState, + updateState, + commitState, + undo, + redo, + canUndo: history.past.length > 0, + canRedo: history.future.length > 0, + }; +} From 6d44dafd96eafd70e939131aa73cf0bd9b1acea6 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Mon, 2 Mar 2026 16:26:42 +0100 Subject: [PATCH 2/3] fix: Fixing speed undoable and add undo/redo to the list of shortucts configuration --- .../video-editor/KeyboardShortcutsHelp.tsx | 54 ++++--------------- src/components/video-editor/VideoEditor.tsx | 27 +++++----- src/hooks/useEditorHistory.ts | 4 +- src/lib/shortcuts.ts | 12 +++-- 4 files changed, 33 insertions(+), 64 deletions(-) diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index b3798e1..2911b21 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,28 +1,10 @@ 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"; +import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS, FIXED_SHORTCUTS } from "@/lib/shortcuts"; export function KeyboardShortcutsHelp() { const { shortcuts, isMac, openConfig } = useShortcuts(); - const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); - const [undoRedoLabels, setUndoRedoLabels] = useState({ undo: 'Ctrl + Z', redo: 'Ctrl + Shift + Z', redoAlt: 'Ctrl + Y' }); - - useEffect(() => { - Promise.all([ - formatShortcut(['shift', 'mod', 'Scroll']), - formatShortcut(['mod', 'Scroll']), - formatShortcut(['mod', 'Z']), - formatShortcut(['shift', 'mod', 'Z']), - formatShortcut(['mod', 'Y']), - ]).then(([pan, zoom, undo, redo, redoAlt]) => { - setScrollLabels({ pan, zoom }); - setUndoRedoLabels({ undo, redo, redoAlt }); - }); - }, []); - return (
@@ -51,31 +33,15 @@ export function KeyboardShortcutsHelp() {
))} -
-
- Pan Timeline - {scrollLabels.pan} -
-
- Zoom Timeline - {scrollLabels.zoom} -
-
- Cycle Annotations - Tab -
-
-
- Undo - {undoRedoLabels.undo} -
-
- Redo -
- {undoRedoLabels.redo} - or - {undoRedoLabels.redoAlt} -
+
+ {FIXED_SHORTCUTS.map((fixed) => ( +
+ {fixed.label} + + {isMac ? fixed.display.replace(/Ctrl/g, '⌘').replace(/Shift/g, '⇧').replace(/Alt/g, '⌥') : fixed.display} + +
+ ))}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 791650a..49ac398 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -45,7 +45,7 @@ export default function VideoEditor() { useEditorHistory(INITIAL_EDITOR_STATE); const { - zoomRegions, trimRegions, annotationRegions, + zoomRegions, trimRegions, speedRegions, annotationRegions, cropRegion, wallpaper, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, aspectRatio, } = editorState; @@ -60,7 +60,6 @@ export default function VideoEditor() { const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); - const [speedRegions, setSpeedRegions] = useState([]); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isExporting, setIsExporting] = useState(false); @@ -316,16 +315,16 @@ export default function VideoEditor() { endMs: Math.round(span.end), speed: DEFAULT_PLAYBACK_SPEED, }; - setSpeedRegions((prev) => [...prev, newRegion]); + pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] })); setSelectedSpeedId(id); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); - }, []); + }, [pushState]); const handleSpeedSpanChange = useCallback((id: string, span: Span) => { - setSpeedRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((region) => region.id === id ? { ...region, @@ -334,24 +333,24 @@ export default function VideoEditor() { } : region, ), - ); - }, []); + })); + }, [pushState]); const handleSpeedDelete = useCallback((id: string) => { - setSpeedRegions((prev) => prev.filter((region) => region.id !== id)); + pushState((prev) => ({ speedRegions: prev.speedRegions.filter((region) => region.id !== id) })); if (selectedSpeedId === id) { setSelectedSpeedId(null); } - }, [selectedSpeedId]); + }, [selectedSpeedId, pushState]); const handleSpeedChange = useCallback((speed: PlaybackSpeed) => { if (!selectedSpeedId) return; - setSpeedRegions((prev) => - prev.map((region) => + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((region) => region.id === selectedSpeedId ? { ...region, speed } : region, ), - ); - }, [selectedSpeedId]); + })); + }, [selectedSpeedId, pushState]); const handleAnnotationAdded = useCallback((span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index d3f686d..b7db51c 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from "react"; -import type { ZoomRegion, TrimRegion, AnnotationRegion, CropRegion } from "@/components/video-editor/types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CropRegion } from "@/components/video-editor/types"; import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -8,6 +8,7 @@ import type { AspectRatio } from "@/utils/aspectRatioUtils"; export interface EditorState { zoomRegions: ZoomRegion[]; trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; annotationRegions: AnnotationRegion[]; cropRegion: CropRegion; wallpaper: string; @@ -22,6 +23,7 @@ export interface EditorState { export const INITIAL_EDITOR_STATE: EditorState = { zoomRegions: [], trimRegions: [], + speedRegions: [], annotationRegions: [], cropRegion: DEFAULT_CROP_REGION, wallpaper: "/wallpapers/wallpaper1.jpg", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 847c753..66a1b7e 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -27,11 +27,13 @@ export interface FixedShortcut { } 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: [] }, + { label: 'Undo', display: 'Ctrl + Z', bindings: [{ key: 'z', ctrl: true }] }, + { label: 'Redo', display: 'Ctrl + Shift + Z / Ctrl + Y', bindings: [{ key: 'z', ctrl: true, shift: true }, { key: 'y', ctrl: true }] }, + { 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 = From 4b79909116f692a38b5eb0b36ac649c20435055e Mon Sep 17 00:00:00 2001 From: FabLrc Date: Fri, 13 Mar 2026 11:24:54 +0100 Subject: [PATCH 3/3] fix: stabilize lint/typecheck and shortcut typing --- electron/electron-env.d.ts | 5 +- electron/preload.ts | 2 +- src/components/video-editor/CropControl.tsx | 4 +- .../video-editor/KeyboardShortcutsHelp.tsx | 87 +- src/components/video-editor/SettingsPanel.tsx | 1643 +++++----- .../video-editor/ShortcutsConfigDialog.tsx | 2 +- src/components/video-editor/VideoEditor.tsx | 2655 +++++++++-------- src/components/video-editor/VideoPlayback.tsx | 1846 ++++++------ .../video-editor/timeline/TimelineWrapper.tsx | 6 +- src/contexts/ShortcutsContext.tsx | 8 +- src/hooks/useAudioLevelMeter.ts | 36 +- src/hooks/useEditorHistory.ts | 170 +- src/hooks/useMicrophoneDevices.ts | 2 +- src/hooks/useScreenRecorder.ts | 24 +- src/lib/assetPath.ts | 7 +- src/lib/exporter/frameRenderer.ts | 44 +- src/lib/exporter/videoExporter.ts | 6 +- src/lib/shortcuts.ts | 185 +- src/vite-env.d.ts | 4 +- 19 files changed, 3585 insertions(+), 3151 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8818fc2..08fcf39 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -27,8 +27,9 @@ interface Window { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; - selectSource: (source: any) => Promise; - getSelectedSource: () => Promise; + selectSource: (source: ProcessedDesktopSource) => Promise; + getSelectedSource: () => Promise; + getAssetBasePath: () => Promise; storeRecordedVideo: ( videoData: ArrayBuffer, fileName: string, diff --git a/electron/preload.ts b/electron/preload.ts index f74b63a..62bc9e0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -20,7 +20,7 @@ contextBridge.exposeInMainWorld("electronAPI", { openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, - selectSource: (source: any) => { + selectSource: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("select-source", source); }, getSelectedSource: () => { diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 77c32ac..078b6e5 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -108,7 +108,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont if (isDragging) { try { e.currentTarget.releasePointerCapture(e.pointerId); - } catch {} + } catch { + // Pointer may already be released; ignore. + } } setIsDragging(null); }; diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 2911b21..a812b9a 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,50 +1,55 @@ import { HelpCircle, Settings2 } from "lucide-react"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS, FIXED_SHORTCUTS } from "@/lib/shortcuts"; +import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; export function KeyboardShortcutsHelp() { - const { shortcuts, isMac, openConfig } = useShortcuts(); + const { shortcuts, isMac, openConfig } = useShortcuts(); - return ( -
- + return ( +
+ -
-
- Keyboard Shortcuts - -
+
+
+ Keyboard Shortcuts + +
-
- {SHORTCUT_ACTIONS.map((action) => ( -
- {SHORTCUT_LABELS[action]} - - {formatBinding(shortcuts[action], isMac)} - -
- ))} +
+ {SHORTCUT_ACTIONS.map((action) => ( +
+ {SHORTCUT_LABELS[action]} + + {formatBinding(shortcuts[action], isMac)} + +
+ ))} -
- {FIXED_SHORTCUTS.map((fixed) => ( -
- {fixed.label} - - {isMac ? fixed.display.replace(/Ctrl/g, '⌘').replace(/Shift/g, '⇧').replace(/Alt/g, '⌥') : fixed.display} - -
- ))} -
-
-
-
- ); +
+ {FIXED_SHORTCUTS.map((fixed) => ( +
+ {fixed.label} + + {isMac + ? fixed.display + .replace(/Ctrl/g, "⌘") + .replace(/Shift/g, "⇧") + .replace(/Alt/g, "⌥") + : fixed.display} + +
+ ))} +
+
+
+
+ ); } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7b63c18..15be76e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,811 +1,924 @@ -import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; -import { getAssetPath } from "@/lib/assetPath"; +import Block from "@uiw/react-color-block"; +import { + Bug, + Crop, + Download, + Film, + FolderOpen, + Image, + Palette, + Save, + Sparkles, + Star, + Trash2, + Upload, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { useState } from "react"; -import Block from '@uiw/react-color-block'; -import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react"; -import { toast } from "sonner"; -import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { getAssetPath } from "@/lib/assetPath"; +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; +import { cn } from "@/lib/utils"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; -import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { type AspectRatio } from "@/utils/aspectRatioUtils"; -import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter"; -import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import type { + AnnotationRegion, + AnnotationType, + CropRegion, + FigureData, + PlaybackSpeed, + ZoomDepth, +} from "./types"; +import { SPEED_OPTIONS } from "./types"; const WALLPAPER_COUNT = 18; -const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`); +const WALLPAPER_RELATIVE = Array.from( + { length: WALLPAPER_COUNT }, + (_, i) => `wallpapers/wallpaper${i + 1}.jpg`, +); const GRADIENTS = [ - "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", - "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", - "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", - "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", - "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", - "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", - "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", - "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", - "linear-gradient(135deg, #FBC8B4, #2447B1)", - "linear-gradient(109.6deg, #F635A6, #36D860)", - "linear-gradient(90deg, #FF0101, #4DFF01)", - "linear-gradient(315deg, #EC0101, #5044A9)", - "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", - "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", - "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", - "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", - "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", - "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", - "linear-gradient(to right, #fa709a 0%, #fee140 100%)", - "linear-gradient(to top, #30cfd0 0%, #330867 100%)", - "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", - "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", - "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", - "linear-gradient(to right, #0acffe 0%, #495aff 100%)", + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(109.6deg, #F635A6, #36D860)", + "linear-gradient(90deg, #FF0101, #4DFF01)", + "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + "linear-gradient(to right, #fa709a 0%, #fee140 100%)", + "linear-gradient(to top, #30cfd0 0%, #330867 100%)", + "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", + "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", + "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", + "linear-gradient(to right, #0acffe 0%, #495aff 100%)", ]; interface SettingsPanelProps { - selected: string; - onWallpaperChange: (path: string) => void; - selectedZoomDepth?: ZoomDepth | null; - onZoomDepthChange?: (depth: ZoomDepth) => void; - selectedZoomId?: string | null; - onZoomDelete?: (id: string) => void; - selectedTrimId?: string | null; - onTrimDelete?: (id: string) => void; - shadowIntensity?: number; - onShadowChange?: (intensity: number) => void; - onShadowCommit?: () => void; - showBlur?: boolean; - onBlurChange?: (showBlur: boolean) => void; - motionBlurEnabled?: boolean; - onMotionBlurChange?: (enabled: boolean) => void; - borderRadius?: number; - onBorderRadiusChange?: (radius: number) => void; - onBorderRadiusCommit?: () => void; - padding?: number; - onPaddingChange?: (padding: number) => void; - onPaddingCommit?: () => void; - cropRegion?: CropRegion; - onCropChange?: (region: CropRegion) => void; - aspectRatio: AspectRatio; - videoElement?: HTMLVideoElement | null; - exportQuality?: ExportQuality; - onExportQualityChange?: (quality: ExportQuality) => void; - // Export format settings - exportFormat?: ExportFormat; - onExportFormatChange?: (format: ExportFormat) => void; - gifFrameRate?: GifFrameRate; - onGifFrameRateChange?: (rate: GifFrameRate) => void; - gifLoop?: boolean; - onGifLoopChange?: (loop: boolean) => void; - gifSizePreset?: GifSizePreset; - onGifSizePresetChange?: (preset: GifSizePreset) => void; - gifOutputDimensions?: { width: number; height: number }; - onSaveProject?: () => void; - onLoadProject?: () => void; - onExport?: () => void; - selectedAnnotationId?: string | null; - annotationRegions?: AnnotationRegion[]; - onAnnotationContentChange?: (id: string, content: string) => void; - onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - 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; + selected: string; + onWallpaperChange: (path: string) => void; + selectedZoomDepth?: ZoomDepth | null; + onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomId?: string | null; + onZoomDelete?: (id: string) => void; + selectedTrimId?: string | null; + onTrimDelete?: (id: string) => void; + shadowIntensity?: number; + onShadowChange?: (intensity: number) => void; + onShadowCommit?: () => void; + showBlur?: boolean; + onBlurChange?: (showBlur: boolean) => void; + motionBlurEnabled?: boolean; + onMotionBlurChange?: (enabled: boolean) => void; + borderRadius?: number; + onBorderRadiusChange?: (radius: number) => void; + onBorderRadiusCommit?: () => void; + padding?: number; + onPaddingChange?: (padding: number) => void; + onPaddingCommit?: () => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + videoElement?: HTMLVideoElement | null; + exportQuality?: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + // Export format settings + exportFormat?: ExportFormat; + onExportFormatChange?: (format: ExportFormat) => void; + gifFrameRate?: GifFrameRate; + onGifFrameRateChange?: (rate: GifFrameRate) => void; + gifLoop?: boolean; + onGifLoopChange?: (loop: boolean) => void; + gifSizePreset?: GifSizePreset; + onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifOutputDimensions?: { width: number; height: number }; + onSaveProject?: () => void; + onLoadProject?: () => void; + onExport?: () => void; + selectedAnnotationId?: string | null; + annotationRegions?: AnnotationRegion[]; + onAnnotationContentChange?: (id: string, content: string) => void; + onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; + onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; + onAnnotationDelete?: (id: string) => void; + selectedSpeedId?: string | null; + selectedSpeedValue?: PlaybackSpeed | null; + onSpeedChange?: (speed: PlaybackSpeed) => void; + onSpeedDelete?: (id: string) => void; } export default SettingsPanel; const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ - { depth: 1, label: "1.25×" }, - { depth: 2, label: "1.5×" }, - { depth: 3, label: "1.8×" }, - { depth: 4, label: "2.2×" }, - { depth: 5, label: "3.5×" }, - { depth: 6, label: "5×" }, + { depth: 1, label: "1.25×" }, + { depth: 2, label: "1.5×" }, + { depth: 3, label: "1.8×" }, + { depth: 4, label: "2.2×" }, + { depth: 5, label: "3.5×" }, + { depth: 6, label: "5×" }, ]; -export function SettingsPanel({ - selected, - onWallpaperChange, - selectedZoomDepth, - onZoomDepthChange, - selectedZoomId, - onZoomDelete, - selectedTrimId, - onTrimDelete, - shadowIntensity = 0, - onShadowChange, - onShadowCommit, - showBlur, - onBlurChange, - motionBlurEnabled = false, - onMotionBlurChange, - borderRadius = 0, - onBorderRadiusChange, - onBorderRadiusCommit, - padding = 50, - onPaddingChange, - onPaddingCommit, - cropRegion, - onCropChange, - aspectRatio, - videoElement, - exportQuality = 'good', - onExportQualityChange, - exportFormat = 'mp4', - onExportFormatChange, - gifFrameRate = 15, - onGifFrameRateChange, - gifLoop = true, - onGifLoopChange, - gifSizePreset = 'medium', - onGifSizePresetChange, - gifOutputDimensions = { width: 1280, height: 720 }, - onSaveProject, - onLoadProject, - onExport, - selectedAnnotationId, - annotationRegions = [], - onAnnotationContentChange, - onAnnotationTypeChange, - onAnnotationStyleChange, - onAnnotationFigureDataChange, - onAnnotationDelete, - selectedSpeedId, - selectedSpeedValue, - onSpeedChange, - onSpeedDelete, +export function SettingsPanel({ + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + onZoomDelete, + selectedTrimId, + onTrimDelete, + shadowIntensity = 0, + onShadowChange, + onShadowCommit, + showBlur, + onBlurChange, + motionBlurEnabled = false, + onMotionBlurChange, + borderRadius = 0, + onBorderRadiusChange, + onBorderRadiusCommit, + padding = 50, + onPaddingChange, + onPaddingCommit, + cropRegion, + onCropChange, + aspectRatio, + videoElement, + exportQuality = "good", + onExportQualityChange, + exportFormat = "mp4", + onExportFormatChange, + gifFrameRate = 15, + onGifFrameRateChange, + gifLoop = true, + onGifLoopChange, + gifSizePreset = "medium", + onGifSizePresetChange, + gifOutputDimensions = { width: 1280, height: 720 }, + onSaveProject, + onLoadProject, + onExport, + selectedAnnotationId, + annotationRegions = [], + onAnnotationContentChange, + onAnnotationTypeChange, + onAnnotationStyleChange, + onAnnotationFigureDataChange, + onAnnotationDelete, + selectedSpeedId, + selectedSpeedValue, + onSpeedChange, + onSpeedDelete, }: SettingsPanelProps) { - const [wallpaperPaths, setWallpaperPaths] = useState([]); - const [customImages, setCustomImages] = useState([]); - const fileInputRef = useRef(null); + const [wallpaperPaths, setWallpaperPaths] = useState([]); + const [customImages, setCustomImages] = useState([]); + const fileInputRef = useRef(null); - useEffect(() => { - let mounted = true - ;(async () => { - try { - const resolved = await Promise.all(WALLPAPER_RELATIVE.map(p => getAssetPath(p))) - if (mounted) setWallpaperPaths(resolved) - } catch (err) { - if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map(p => `/${p}`)) - } - })() - return () => { mounted = false } - }, []) - const colorPalette = [ - '#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00', - '#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107', - '#34B27B', '#000000', '#607D8B', '#795548', - ]; - - const [selectedColor, setSelectedColor] = useState('#ADADAD'); - const [gradient, setGradient] = useState(GRADIENTS[0]); - const [showCropDropdown, setShowCropDropdown] = useState(false); + useEffect(() => { + let mounted = true; + (async () => { + try { + const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p))); + if (mounted) setWallpaperPaths(resolved); + } catch (_err) { + if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`)); + } + })(); + return () => { + mounted = false; + }; + }, []); + const colorPalette = [ + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#34B27B", + "#000000", + "#607D8B", + "#795548", + ]; - const zoomEnabled = Boolean(selectedZoomDepth); - const trimEnabled = Boolean(selectedTrimId); - - const handleDeleteClick = () => { - if (selectedZoomId && onZoomDelete) { - onZoomDelete(selectedZoomId); - } - }; + const [selectedColor, setSelectedColor] = useState("#ADADAD"); + const [gradient, setGradient] = useState(GRADIENTS[0]); + const [showCropDropdown, setShowCropDropdown] = useState(false); - const handleTrimDeleteClick = () => { - if (selectedTrimId && onTrimDelete) { - onTrimDelete(selectedTrimId); - } - }; + const zoomEnabled = Boolean(selectedZoomDepth); + const trimEnabled = Boolean(selectedTrimId); - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; + const handleDeleteClick = () => { + if (selectedZoomId && onZoomDelete) { + onZoomDelete(selectedZoomId); + } + }; - const file = files[0]; - - // Validate file type - only allow JPG/JPEG - const validTypes = ['image/jpeg', 'image/jpg']; - if (!validTypes.includes(file.type)) { - toast.error('Invalid file type', { - description: 'Please upload a JPG or JPEG image file.', - }); - event.target.value = ''; - return; - } + const handleTrimDeleteClick = () => { + if (selectedTrimId && onTrimDelete) { + onTrimDelete(selectedTrimId); + } + }; - const reader = new FileReader(); + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - setCustomImages(prev => [...prev, dataUrl]); - onWallpaperChange(dataUrl); - toast.success('Custom image uploaded successfully!'); - } - }; + const file = files[0]; - reader.onerror = () => { - toast.error('Failed to upload image', { - description: 'There was an error reading the file.', - }); - }; + // Validate file type - only allow JPG/JPEG + const validTypes = ["image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error("Invalid file type", { + description: "Please upload a JPG or JPEG image file.", + }); + event.target.value = ""; + return; + } - reader.readAsDataURL(file); - // Reset input so the same file can be selected again - event.target.value = ''; - }; + const reader = new FileReader(); - const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { - event.stopPropagation(); - setCustomImages(prev => prev.filter(img => img !== imageUrl)); - // If the removed image was selected, clear selection - if (selected === imageUrl) { - onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]); - } - }; + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + setCustomImages((prev) => [...prev, dataUrl]); + onWallpaperChange(dataUrl); + toast.success("Custom image uploaded successfully!"); + } + }; - // Find selected annotation - const selectedAnnotation = selectedAnnotationId - ? annotationRegions.find(a => a.id === selectedAnnotationId) - : null; + reader.onerror = () => { + toast.error("Failed to upload image", { + description: "There was an error reading the file.", + }); + }; - // If an annotation is selected, show annotation settings instead - if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) { - return ( - onAnnotationContentChange(selectedAnnotation.id, content)} - onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} - onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} - onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined} - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> - ); - } + reader.readAsDataURL(file); + // Reset input so the same file can be selected again + event.target.value = ""; + }; - return ( -
-
-
-
- Zoom Level -
- {zoomEnabled && selectedZoomDepth && ( - - {ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} - - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- {!zoomEnabled && ( -

Select a zoom region to adjust

- )} - {zoomEnabled && ( - - )} -
+ const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + event.stopPropagation(); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + // If the removed image was selected, clear selection + if (selected === imageUrl) { + onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]); + } + }; - {trimEnabled && ( -
- -
- )} + // Find selected annotation + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) + : null; -
-
- 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 && ( - - )} -
+ // If an annotation is selected, show annotation settings instead + if ( + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + onAnnotationDelete + ) { + return ( + onAnnotationContentChange(selectedAnnotation.id, content)} + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> + ); + } - - - -
- - Video Effects -
-
- -
-
-
Motion Blur
- -
-
-
Blur BG
- -
-
- -
-
-
-
Shadow
- {Math.round(shadowIntensity * 100)}% -
- onShadowChange?.(values[0])} - onValueCommit={() => onShadowCommit?.()} - min={0} - max={1} - step={0.01} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
Roundness
- {borderRadius}px -
- onBorderRadiusChange?.(values[0])} - onValueCommit={() => onBorderRadiusCommit?.()} - min={0} - max={16} - step={0.5} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
-
-
Padding
- {padding}% -
- onPaddingChange?.(values[0])} - onValueCommit={() => onPaddingCommit?.()} - min={0} - max={100} - step={1} - className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" - /> -
-
+ return ( +
+
+
+
+ Zoom Level +
+ {zoomEnabled && selectedZoomDepth && ( + + {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + + )} + +
+
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+ {!zoomEnabled && ( +

+ Select a zoom region to adjust +

+ )} + {zoomEnabled && ( + + )} +
- - - + {trimEnabled && ( +
+ +
+ )} - - -
- - Background -
-
- - - - Image - Color - Gradient - - -
- - - +
+
+ 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 && ( + + )} +
-
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > - -
- ); - })} + + + +
+ + Video Effects +
+
+ +
+
+
Motion Blur
+ +
+
+
Blur BG
+ +
+
- {(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path) => { - const isSelected = (() => { - if (!selected) return false; - if (selected === path) return true; - try { - const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '') - if (clean(selected).endsWith(clean(path))) return true; - if (clean(path).endsWith(clean(selected))) return true; - } catch {} - return false; - })(); - return ( -
onWallpaperChange(path)} - role="button" - /> - ) - })} -
- - - -
- { - setSelectedColor(color.hex); - onWallpaperChange(color.hex); - }} - style={{ - width: '100%', - borderRadius: '8px', - }} - /> -
-
- - -
- {GRADIENTS.map((g, idx) => ( -
{ setGradient(g); onWallpaperChange(g); }} - role="button" - /> - ))} -
- -
- -
-
-
-
+
+
+
+
Shadow
+ + {Math.round(shadowIntensity * 100)}% + +
+ onShadowChange?.(values[0])} + onValueCommit={() => onShadowCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
Roundness
+ {borderRadius}px +
+ onBorderRadiusChange?.(values[0])} + onValueCommit={() => onBorderRadiusCommit?.()} + min={0} + max={16} + step={0.5} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
Padding
+ {padding}% +
+ onPaddingChange?.(values[0])} + onValueCommit={() => onPaddingCommit?.()} + min={0} + max={100} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
- {showCropDropdown && cropRegion && onCropChange && ( - <> -
setShowCropDropdown(false)} - /> -
-
-
- Crop Video -

Drag on each side to adjust the crop area

-
- -
- -
- -
-
- - )} + + + -
-
- - -
+ + +
+ + Background +
+
+ + + + + Image + + + Color + + + Gradient + + - {exportFormat === 'mp4' && ( -
- - - -
- )} +
+ + + - {exportFormat === 'gif' && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
-
-
- {gifOutputDimensions.width} × {gifOutputDimensions.height}px -
- Loop - -
-
-
- )} - -
- - -
+
+ {customImages.map((imageUrl, idx) => { + const isSelected = selected === imageUrl; + return ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} - + {(wallpaperPaths.length > 0 + ? wallpaperPaths + : WALLPAPER_RELATIVE.map((p) => `/${p}`) + ).map((path) => { + const isSelected = (() => { + if (!selected) return false; + if (selected === path) return true; + try { + const clean = (s: string) => + s.replace(/^file:\/\//, "").replace(/^\//, ""); + if (clean(selected).endsWith(clean(path))) return true; + if (clean(path).endsWith(clean(selected))) return true; + } catch { + // Best-effort comparison; fallback to strict match. + } + return false; + })(); + return ( +
onWallpaperChange(path)} + role="button" + /> + ); + })} +
+ -
- - -
-
-
- ); + +
+ { + setSelectedColor(color.hex); + onWallpaperChange(color.hex); + }} + style={{ + width: "100%", + borderRadius: "8px", + }} + /> +
+
+ + +
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+ +
+
+
+
+ +
+ + {showCropDropdown && cropRegion && onCropChange && ( + <> +
setShowCropDropdown(false)} + /> +
+
+
+ Crop Video +

+ Drag on each side to adjust the crop area +

+
+ +
+ +
+ +
+
+ + )} + +
+
+ + +
+ + {exportFormat === "mp4" && ( +
+ + + +
+ )} + + {exportFormat === "gif" && ( +
+
+
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ Loop + +
+
+
+ )} + +
+ + +
+ + + +
+ + +
+
+
+ ); } diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 9d86028..af710af 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -84,7 +84,7 @@ export function ShortcutsConfigDialog() { window.addEventListener("keydown", handleCapture, { capture: true }); return () => window.removeEventListener("keydown", handleCapture, { capture: true }); - }, [captureFor]); + }, [captureFor, draft]); const handleSwap = useCallback(() => { if (!conflict || conflict.conflictWith.type !== "configurable") return; diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index dee59d3..fe8b521 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,1244 +1,1423 @@ - - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Toaster } from "@/components/ui/sonner"; -import { toast } from "sonner"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; - -import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; -import PlaybackControls from "./PlaybackControls"; -import TimelineEditor from "./timeline/TimelineEditor"; -import { SettingsPanel } from "./SettingsPanel"; -import { ExportDialog } from "./ExportDialog"; -import { - createProjectData, - deriveNextId, - fromFileUrl, - normalizeProjectEditor, - toFileUrl, - validateProjectData, -} from "./projectPersistence"; - import type { Span } from "dnd-timeline"; -import { - DEFAULT_ZOOM_DEPTH, - clampFocusToDepth, - DEFAULT_ANNOTATION_POSITION, - DEFAULT_ANNOTATION_SIZE, - DEFAULT_ANNOTATION_STYLE, - DEFAULT_FIGURE_DATA, - DEFAULT_PLAYBACK_SPEED, - type ZoomDepth, - type ZoomFocus, - type ZoomRegion, - type CursorTelemetryPoint, - type TrimRegion, - type AnnotationRegion, - type FigureData, - type SpeedRegion, - type PlaybackSpeed, -} from "./types"; -import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter"; -import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; -import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { toast } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; +import { + calculateOutputDimensions, + type ExportFormat, + type ExportProgress, + type ExportQuality, + type ExportSettings, + GIF_SIZE_PRESETS, + GifExporter, + type GifFrameRate, + type GifSizePreset, + VideoExporter, +} from "@/lib/exporter"; import { matchesShortcut } from "@/lib/shortcuts"; +import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { ExportDialog } from "./ExportDialog"; +import PlaybackControls from "./PlaybackControls"; +import { + createProjectData, + deriveNextId, + fromFileUrl, + normalizeProjectEditor, + toFileUrl, + validateProjectData, +} from "./projectPersistence"; +import { SettingsPanel } from "./SettingsPanel"; +import TimelineEditor from "./timeline/TimelineEditor"; +import { + type AnnotationRegion, + type CursorTelemetryPoint, + clampFocusToDepth, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_FIGURE_DATA, + DEFAULT_PLAYBACK_SPEED, + DEFAULT_ZOOM_DEPTH, + type FigureData, + type PlaybackSpeed, + type SpeedRegion, + type TrimRegion, + type ZoomDepth, + type ZoomFocus, + type ZoomRegion, +} from "./types"; +import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { - const { state: editorState, pushState, updateState, commitState, undo, redo } = - useEditorHistory(INITIAL_EDITOR_STATE); - - const { - zoomRegions, trimRegions, speedRegions, annotationRegions, - cropRegion, wallpaper, shadowIntensity, showBlur, - motionBlurEnabled, borderRadius, padding, aspectRatio, - } = editorState; - - // ── Non-undoable state - const [videoPath, setVideoPath] = useState(null); - const [videoSourcePath, setVideoSourcePath] = useState(null); - const [currentProjectPath, setCurrentProjectPath] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [cursorTelemetry, setCursorTelemetry] = useState([]); - const [selectedZoomId, setSelectedZoomId] = useState(null); - const [selectedTrimId, setSelectedTrimId] = useState(null); - const [selectedSpeedId, setSelectedSpeedId] = useState(null); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); - const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(null); - const [exportError, setExportError] = useState(null); - const [showExportDialog, setShowExportDialog] = useState(false); - const [exportQuality, setExportQuality] = useState('good'); - const [exportFormat, setExportFormat] = useState('mp4'); - const [gifFrameRate, setGifFrameRate] = useState(15); - const [gifLoop, setGifLoop] = useState(true); - const [gifSizePreset, setGifSizePreset] = useState('medium'); - const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); - - const videoPlaybackRef = useRef(null); - const nextZoomIdRef = useRef(1); - const nextTrimIdRef = useRef(1); - const nextSpeedIdRef = useRef(1); - - const { shortcuts, isMac } = useShortcuts(); - const nextAnnotationIdRef = useRef(1); - const nextAnnotationZIndexRef = useRef(1); - const exporterRef = useRef(null); - - const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { - if (!validateProjectData(candidate)) { - return false; - } - - const project = candidate; - const sourcePath = project.videoPath; - const normalizedEditor = normalizeProjectEditor(project.editor); - - try { - videoPlaybackRef.current?.pause(); - } catch { - // no-op - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); - - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); - setCurrentProjectPath(path ?? null); - - pushState({ - wallpaper: normalizedEditor.wallpaper, - shadowIntensity: normalizedEditor.shadowIntensity, - showBlur: normalizedEditor.showBlur, - motionBlurEnabled: normalizedEditor.motionBlurEnabled, - borderRadius: normalizedEditor.borderRadius, - padding: normalizedEditor.padding, - cropRegion: normalizedEditor.cropRegion, - zoomRegions: normalizedEditor.zoomRegions, - trimRegions: normalizedEditor.trimRegions, - speedRegions: normalizedEditor.speedRegions, - annotationRegions: normalizedEditor.annotationRegions, - aspectRatio: normalizedEditor.aspectRatio, - }); - setExportQuality(normalizedEditor.exportQuality); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); - - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedSpeedId(null); - setSelectedAnnotationId(null); - - nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id)); - nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id)); - nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id)); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - normalizedEditor.annotationRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; - - setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); - return true; - }, [pushState]); - - const currentProjectSnapshot = useMemo(() => { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); - if (!sourcePath) { - return null; - } - return JSON.stringify( - createProjectData(sourcePath, { - wallpaper, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - speedRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - }), - ); - }, [ - videoPath, - videoSourcePath, - wallpaper, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - speedRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - ]); - - const hasUnsavedChanges = Boolean( - currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - currentProjectSnapshot !== lastSavedSnapshot, - ); - - useEffect(() => { - async function loadInitialData() { - try { - const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); - if (currentProjectResult.success && currentProjectResult.project) { - const restored = await applyLoadedProject( - currentProjectResult.project, - currentProjectResult.path ?? null, - ); - if (restored) { - return; - } - } - - const result = await window.electronAPI.getCurrentVideoPath(); - if (result.success && result.path) { - setVideoSourcePath(result.path); - setVideoPath(toFileUrl(result.path)); - setCurrentProjectPath(null); - setLastSavedSnapshot(null); - } else { - setError("No video to load. Please record or select a video."); - } - } catch (err) { - setError("Error loading video: " + String(err)); - } finally { - setLoading(false); - } - } - - loadInitialData(); - }, [applyLoadedProject]); - - const saveProject = useCallback(async (forceSaveAs: boolean) => { - if (!videoPath) { - toast.error('No video loaded'); - return; - } - - const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); - if (!sourcePath) { - toast.error('Unable to determine source video path'); - return; - } - - const projectData = createProjectData(sourcePath, { - wallpaper, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - speedRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - }); - - const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`; - const projectSnapshot = JSON.stringify(projectData); - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - forceSaveAs ? undefined : currentProjectPath ?? undefined, - ); - - if (result.canceled) { - toast.info("Project save canceled"); - return; - } - - if (!result.success) { - toast.error(result.message || 'Failed to save project'); - return; - } - - if (result.path) { - setCurrentProjectPath(result.path); - } - setLastSavedSnapshot(projectSnapshot); - - toast.success(`Project saved to ${result.path}`); - }, [ - videoPath, - videoSourcePath, - currentProjectPath, - wallpaper, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - speedRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - ]); - - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (!hasUnsavedChanges) { - return; - } - - event.preventDefault(); - event.returnValue = ''; - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [hasUnsavedChanges]); - - const handleSaveProject = useCallback(async () => { - await saveProject(false); - }, [saveProject]); - - const handleSaveProjectAs = useCallback(async () => { - await saveProject(true); - }, [saveProject]); - - const handleLoadProject = useCallback(async () => { - const result = await window.electronAPI.loadProjectFile(); - - if (result.canceled) { - return; - } - - if (!result.success) { - toast.error(result.message || 'Failed to load project'); - return; - } - - const restored = await applyLoadedProject(result.project, result.path ?? null); - if (!restored) { - toast.error('Invalid project file format'); - return; - } - - toast.success(`Project loaded from ${result.path}`); - }, [applyLoadedProject]); - - useEffect(() => { - const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); - const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); - const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); - - return () => { - removeLoadListener?.(); - removeSaveListener?.(); - removeSaveAsListener?.(); - }; - }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); - - 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]); - - function togglePlayPause() { - const playback = videoPlaybackRef.current; - const video = playback?.video; - if (!playback || !video) return; - - if (isPlaying) { - playback.pause(); - } else { - playback.play().catch(err => console.error('Video play failed:', err)); - } - } - - function handleSeek(time: number) { - const video = videoPlaybackRef.current?.video; - if (!video) return; - video.currentTime = time; - } - - const handleSelectZoom = useCallback((id: string | null) => { - setSelectedZoomId(id); - if (id) setSelectedTrimId(null); - }, []); - - const handleSelectTrim = useCallback((id: string | null) => { - setSelectedTrimId(id); - if (id) { - setSelectedZoomId(null); - setSelectedAnnotationId(null); - } - }, []); - - const handleSelectAnnotation = useCallback((id: string | null) => { - setSelectedAnnotationId(id); - if (id) { - setSelectedZoomId(null); - setSelectedTrimId(null); - } - }, []); - - const handleZoomAdded = useCallback((span: Span) => { - const id = `zoom-${nextZoomIdRef.current++}`; - const newRegion: ZoomRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, - }; - pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, [pushState]); - - 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), - }; - pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, [pushState]); - - const handleTrimAdded = useCallback((span: Span) => { - const id = `trim-${nextTrimIdRef.current++}`; - const newRegion: TrimRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - }; - pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); - setSelectedTrimId(id); - setSelectedZoomId(null); - setSelectedAnnotationId(null); - }, [pushState]); - - const handleZoomSpanChange = useCallback((id: string, span: Span) => { - pushState((prev) => ({ - zoomRegions: prev.zoomRegions.map((region) => - region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } - : region, - ), - })); - }, [pushState]); - - const handleTrimSpanChange = useCallback((id: string, span: Span) => { - pushState((prev) => ({ - trimRegions: prev.trimRegions.map((region) => - region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } - : region, - ), - })); - }, [pushState]); - - // Focus drag: updateState for live preview, commitState on pointer-up - const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { - updateState((prev) => ({ - zoomRegions: prev.zoomRegions.map((region) => - region.id === id - ? { ...region, focus: clampFocusToDepth(focus, region.depth) } - : region, - ), - })); - }, [updateState]); - - const handleZoomDepthChange = useCallback((depth: ZoomDepth) => { - if (!selectedZoomId) return; - pushState((prev) => ({ - zoomRegions: prev.zoomRegions.map((region) => - region.id === selectedZoomId - ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } - : region, - ), - })); - }, [selectedZoomId, pushState]); - - const handleZoomDelete = useCallback((id: string) => { - pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); - if (selectedZoomId === id) { - setSelectedZoomId(null); - } - }, [selectedZoomId, pushState]); - - const handleTrimDelete = useCallback((id: string) => { - pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) })); - if (selectedTrimId === id) { - setSelectedTrimId(null); - } - }, [selectedTrimId, pushState]); - - const handleSelectSpeed = useCallback((id: string | null) => { - setSelectedSpeedId(id); - if (id) { - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - } - }, []); - - const handleSpeedAdded = useCallback((span: Span) => { - const id = `speed-${nextSpeedIdRef.current++}`; - const newRegion: SpeedRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - speed: DEFAULT_PLAYBACK_SPEED, - }; - pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] })); - setSelectedSpeedId(id); - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, [pushState]); - - const handleSpeedSpanChange = useCallback((id: string, span: Span) => { - pushState((prev) => ({ - speedRegions: prev.speedRegions.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - })); - }, [pushState]); - - const handleSpeedDelete = useCallback((id: string) => { - pushState((prev) => ({ speedRegions: prev.speedRegions.filter((region) => region.id !== id) })); - if (selectedSpeedId === id) { - setSelectedSpeedId(null); - } - }, [selectedSpeedId, pushState]); - - const handleSpeedChange = useCallback((speed: PlaybackSpeed) => { - if (!selectedSpeedId) return; - pushState((prev) => ({ - speedRegions: prev.speedRegions.map((region) => - region.id === selectedSpeedId ? { ...region, speed } : region, - ), - })); - }, [selectedSpeedId, pushState]); - - const handleAnnotationAdded = useCallback((span: Span) => { - const id = `annotation-${nextAnnotationIdRef.current++}`; - const zIndex = nextAnnotationZIndexRef.current++; - const newRegion: AnnotationRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - type: 'text', - content: 'Enter text...', - position: { ...DEFAULT_ANNOTATION_POSITION }, - size: { ...DEFAULT_ANNOTATION_SIZE }, - style: { ...DEFAULT_ANNOTATION_STYLE }, - zIndex, - }; - pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] })); - setSelectedAnnotationId(id); - setSelectedZoomId(null); - setSelectedTrimId(null); - }, [pushState]); - - const handleAnnotationSpanChange = useCallback((id: string, span: Span) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id - ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } - : region, - ), - })); - }, [pushState]); - - const handleAnnotationDelete = useCallback((id: string) => { - pushState((prev) => ({ annotationRegions: prev.annotationRegions.filter((r) => r.id !== id) })); - if (selectedAnnotationId === id) { - setSelectedAnnotationId(null); - } - }, [selectedAnnotationId, pushState]); - - const handleAnnotationContentChange = useCallback((id: string, content: string) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => { - if (region.id !== id) return region; - if (region.type === 'text') { - return { ...region, content, textContent: content }; - } else if (region.type === 'image') { - return { ...region, content, imageContent: content }; - } - return { ...region, content }; - }), - })); - }, [pushState]); - - const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => { - if (region.id !== id) return region; - const updatedRegion = { ...region, type }; - if (type === 'text') { - updatedRegion.content = region.textContent || 'Enter text...'; - } else if (type === 'image') { - updatedRegion.content = region.imageContent || ''; - } else if (type === 'figure') { - updatedRegion.content = ''; - if (!region.figureData) { - updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; - } - } - return updatedRegion; - }), - })); - }, [pushState]); - - const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id - ? { ...region, style: { ...region.style, ...style } } - : region, - ), - })); - }, [pushState]); - - const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, figureData } : region, - ), - })); - }, [pushState]); - - const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, position } : region, - ), - })); - }, [pushState]); - - const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => { - pushState((prev) => ({ - annotationRegions: prev.annotationRegions.map((region) => - region.id === id ? { ...region, size } : region, - ), - })); - }, [pushState]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const mod = e.ctrlKey || e.metaKey; - const key = e.key.toLowerCase(); - - if (mod && key === 'z' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); undo(); return; } - if (mod && (key === 'y' || (key === 'z' && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); redo(); return; } - - const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; - - if (e.key === 'Tab' && !isInput) { e.preventDefault(); } - - if (matchesShortcut(e, shortcuts.playPause, isMac)) { - // Allow space only in inputs/textareas - if (isInput) { return; } - e.preventDefault(); - const playback = videoPlaybackRef.current; - if (playback?.video) { - playback.video.paused - ? playback.play().catch(console.error) - : playback.pause(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown, { capture: true }); - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, [undo, redo, shortcuts, isMac]); - - useEffect(() => { - if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { - setSelectedZoomId(null); - } - }, [selectedZoomId, zoomRegions]); - - useEffect(() => { - if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { - setSelectedTrimId(null); - } - }, [selectedTrimId, trimRegions]); - - useEffect(() => { - if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) { - setSelectedAnnotationId(null); - } - }, [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'); - return; - } - - const video = videoPlaybackRef.current?.video; - if (!video) { - toast.error('Video not ready'); - return; - } - - setIsExporting(true); - setExportProgress(null); - setExportError(null); - - try { - const wasPlaying = isPlaying; - if (wasPlaying) { - videoPlaybackRef.current?.pause(); - } - - const aspectRatioValue = getAspectRatioValue(aspectRatio); - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; - - // Get preview CONTAINER dimensions for scaling - const playbackRef = videoPlaybackRef.current; - const containerElement = playbackRef?.containerRef?.current; - const previewWidth = containerElement?.clientWidth || 1920; - const previewHeight = containerElement?.clientHeight || 1080; - - if (settings.format === 'gif' && settings.gifConfig) { - // GIF Export - const gifExporter = new GifExporter({ - videoUrl: videoPath, - width: settings.gifConfig.width, - height: settings.gifConfig.height, - frameRate: settings.gifConfig.frameRate, - loop: settings.gifConfig.loop, - sizePreset: settings.gifConfig.sizePreset, - wallpaper, - zoomRegions, - trimRegions, - speedRegions, - showShadow: shadowIntensity > 0, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - videoPadding: padding, - cropRegion, - annotationRegions, - previewWidth, - previewHeight, - onProgress: (progress: ExportProgress) => { - setExportProgress(progress); - }, - }); - - exporterRef.current = gifExporter as unknown as VideoExporter; - const result = await gifExporter.export(); - - if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.gif`; - - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - - if (saveResult.canceled) { - toast.info('Export canceled'); - } else if (saveResult.success) { - toast.success(`GIF exported successfully to ${saveResult.path}`); - } else { - setExportError(saveResult.message || 'Failed to save GIF'); - toast.error(saveResult.message || 'Failed to save GIF'); - } - } else { - setExportError(result.error || 'GIF export failed'); - toast.error(result.error || 'GIF export failed'); - } - } else { - // MP4 Export - const quality = settings.quality || exportQuality; - let exportWidth: number; - let exportHeight: number; - let bitrate: number; - - if (quality === 'source') { - // Use source resolution - exportWidth = sourceWidth; - exportHeight = sourceHeight; - - if (aspectRatioValue === 1) { - // Square (1:1): use smaller dimension to avoid codec limits - const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; - exportWidth = baseDimension; - exportHeight = baseDimension; - } else if (aspectRatioValue > 1) { - // Landscape: find largest even dimensions that exactly match aspect ratio - const baseWidth = Math.floor(sourceWidth / 2) * 2; - let found = false; - for (let w = baseWidth; w >= 100 && !found; w -= 2) { - const h = Math.round(w / aspectRatioValue); - if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportWidth = baseWidth; - exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2; - } - } else { - // Portrait: find largest even dimensions that exactly match aspect ratio - const baseHeight = Math.floor(sourceHeight / 2) * 2; - let found = false; - for (let h = baseHeight; h >= 100 && !found; h -= 2) { - const w = Math.round(h * aspectRatioValue); - if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportHeight = baseHeight; - exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; - } - } - - // Calculate visually lossless bitrate matching screen recording optimization - const totalPixels = exportWidth * exportHeight; - bitrate = 30_000_000; - if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { - bitrate = 50_000_000; - } else if (totalPixels > 2560 * 1440) { - bitrate = 80_000_000; - } - } else { - // Use quality-based target resolution - const targetHeight = quality === 'medium' ? 720 : 1080; - - // Calculate dimensions maintaining aspect ratio - exportHeight = Math.floor(targetHeight / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; - - // Adjust bitrate for lower resolutions - const totalPixels = exportWidth * exportHeight; - if (totalPixels <= 1280 * 720) { - bitrate = 10_000_000; - } else if (totalPixels <= 1920 * 1080) { - bitrate = 20_000_000; - } else { - bitrate = 30_000_000; - } - } - - const exporter = new VideoExporter({ - videoUrl: videoPath, - width: exportWidth, - height: exportHeight, - frameRate: 60, - bitrate, - codec: 'avc1.640033', - wallpaper, - zoomRegions, - trimRegions, - speedRegions, - showShadow: shadowIntensity > 0, - shadowIntensity, - showBlur, - motionBlurEnabled, - borderRadius, - padding, - cropRegion, - annotationRegions, - previewWidth, - previewHeight, - onProgress: (progress: ExportProgress) => { - setExportProgress(progress); - }, - }); - - exporterRef.current = exporter; - const result = await exporter.export(); - - if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; - - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - - if (saveResult.canceled) { - toast.info('Export canceled'); - } else if (saveResult.success) { - toast.success(`Video exported successfully to ${saveResult.path}`); - } else { - setExportError(saveResult.message || 'Failed to save video'); - toast.error(saveResult.message || 'Failed to save video'); - } - } else { - setExportError(result.error || 'Export failed'); - toast.error(result.error || 'Export failed'); - } - } - - if (wasPlaying) { - videoPlaybackRef.current?.play(); - } - } catch (error) { - console.error('Export error:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - setExportError(errorMessage); - toast.error(`Export failed: ${errorMessage}`); - } finally { - setIsExporting(false); - exporterRef.current = null; - // Reset dialog state to ensure it can be opened again on next export - // This fixes the bug where second export doesn't show save dialog - setShowExportDialog(false); - setExportProgress(null); - } - }, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]); - - const handleOpenExportDialog = useCallback(() => { - if (!videoPath) { - toast.error('No video loaded'); - return; - } - - const video = videoPlaybackRef.current?.video; - if (!video) { - toast.error('Video not ready'); - return; - } - - // Build export settings from current state - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; - const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS); - - const settings: ExportSettings = { - format: exportFormat, - quality: exportFormat === 'mp4' ? exportQuality : undefined, - gifConfig: exportFormat === 'gif' ? { - frameRate: gifFrameRate, - loop: gifLoop, - sizePreset: gifSizePreset, - width: gifDimensions.width, - height: gifDimensions.height, - } : undefined, - }; - - setShowExportDialog(true); - setExportError(null); - - // Start export immediately - handleExport(settings); - }, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); - - const handleCancelExport = useCallback(() => { - if (exporterRef.current) { - exporterRef.current.cancel(); - toast.info('Export canceled'); - setShowExportDialog(false); - setIsExporting(false); - setExportProgress(null); - setExportError(null); - } - }, []); - - if (loading) { - return ( -
-
Loading video...
-
- ); - } - if (error) { - return ( -
-
-
{error}
- -
-
- ); - } - - - return ( -
-
-
-
- -
- {/* Left Column - Video & Timeline */} -
- - {/* Top section: video preview and controls */} - -
- {/* Video preview */} -
-
- 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - motionBlurEnabled={motionBlurEnabled} - borderRadius={borderRadius} - padding={padding} - cropRegion={cropRegion} - trimRegions={trimRegions} - speedRegions={speedRegions} - annotationRegions={annotationRegions} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} - onAnnotationSizeChange={handleAnnotationSizeChange} - /> -
-
- {/* Playback controls */} -
-
- -
-
-
-
- - -
-
- - {/* Timeline section */} - -
- pushState({ aspectRatio: ar })} - /> -
-
-
-
- - {/* Right section: settings panel */} - pushState({ wallpaper: w })} - selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null} - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomId={selectedZoomId} - onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} - shadowIntensity={shadowIntensity} - onShadowChange={(v) => updateState({ shadowIntensity: v })} - onShadowCommit={commitState} - showBlur={showBlur} - onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurEnabled={motionBlurEnabled} - onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })} - borderRadius={borderRadius} - onBorderRadiusChange={(v) => updateState({ borderRadius: v })} - onBorderRadiusCommit={commitState} - padding={padding} - onPaddingChange={(v) => updateState({ padding: v })} - onPaddingCommit={commitState} - cropRegion={cropRegion} - onCropChange={(r) => pushState({ cropRegion: r })} - aspectRatio={aspectRatio} - videoElement={videoPlaybackRef.current?.video || null} - exportQuality={exportQuality} - onExportQualityChange={setExportQuality} - exportFormat={exportFormat} - onExportFormatChange={setExportFormat} - gifFrameRate={gifFrameRate} - onGifFrameRateChange={setGifFrameRate} - gifLoop={gifLoop} - onGifLoopChange={setGifLoop} - gifSizePreset={gifSizePreset} - onGifSizePresetChange={setGifSizePreset} - gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS - )} - onExport={handleOpenExportDialog} - selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationDelete={handleAnnotationDelete} - onSaveProject={handleSaveProject} - onLoadProject={handleLoadProject} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null} - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - /> -
- - - - setShowExportDialog(false)} - progress={exportProgress} - isExporting={isExporting} - error={exportError} - onCancel={handleCancelExport} - exportFormat={exportFormat} - /> -
- ); + const { + state: editorState, + pushState, + updateState, + commitState, + undo, + redo, + } = useEditorHistory(INITIAL_EDITOR_STATE); + + const { + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + cropRegion, + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + aspectRatio, + } = editorState; + + // ── Non-undoable state + const [videoPath, setVideoPath] = useState(null); + const [videoSourcePath, setVideoSourcePath] = useState(null); + const [currentProjectPath, setCurrentProjectPath] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [cursorTelemetry, setCursorTelemetry] = useState([]); + const [selectedZoomId, setSelectedZoomId] = useState(null); + const [selectedTrimId, setSelectedTrimId] = useState(null); + const [selectedSpeedId, setSelectedSpeedId] = useState(null); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(null); + const [exportError, setExportError] = useState(null); + const [showExportDialog, setShowExportDialog] = useState(false); + const [exportQuality, setExportQuality] = useState("good"); + const [exportFormat, setExportFormat] = useState("mp4"); + const [gifFrameRate, setGifFrameRate] = useState(15); + const [gifLoop, setGifLoop] = useState(true); + const [gifSizePreset, setGifSizePreset] = useState("medium"); + const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); + + const videoPlaybackRef = useRef(null); + const nextZoomIdRef = useRef(1); + const nextTrimIdRef = useRef(1); + const nextSpeedIdRef = useRef(1); + + const { shortcuts, isMac } = useShortcuts(); + const nextAnnotationIdRef = useRef(1); + const nextAnnotationZIndexRef = useRef(1); + const exporterRef = useRef(null); + + const applyLoadedProject = useCallback( + async (candidate: unknown, path?: string | null) => { + if (!validateProjectData(candidate)) { + return false; + } + + const project = candidate; + const sourcePath = project.videoPath; + const normalizedEditor = normalizeProjectEditor(project.editor); + + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(path ?? null); + + pushState({ + wallpaper: normalizedEditor.wallpaper, + shadowIntensity: normalizedEditor.shadowIntensity, + showBlur: normalizedEditor.showBlur, + motionBlurEnabled: normalizedEditor.motionBlurEnabled, + borderRadius: normalizedEditor.borderRadius, + padding: normalizedEditor.padding, + cropRegion: normalizedEditor.cropRegion, + zoomRegions: normalizedEditor.zoomRegions, + trimRegions: normalizedEditor.trimRegions, + speedRegions: normalizedEditor.speedRegions, + annotationRegions: normalizedEditor.annotationRegions, + aspectRatio: normalizedEditor.aspectRatio, + }); + setExportQuality(normalizedEditor.exportQuality); + setExportFormat(normalizedEditor.exportFormat); + setGifFrameRate(normalizedEditor.gifFrameRate); + setGifLoop(normalizedEditor.gifLoop); + setGifSizePreset(normalizedEditor.gifSizePreset); + + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); + + nextZoomIdRef.current = deriveNextId( + "zoom", + normalizedEditor.zoomRegions.map((region) => region.id), + ); + nextTrimIdRef.current = deriveNextId( + "trim", + normalizedEditor.trimRegions.map((region) => region.id), + ); + nextSpeedIdRef.current = deriveNextId( + "speed", + normalizedEditor.speedRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + normalizedEditor.annotationRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + normalizedEditor.annotationRegions.reduce( + (max, region) => Math.max(max, region.zIndex), + 0, + ) + 1; + + setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); + return true; + }, + [pushState], + ); + + const currentProjectSnapshot = useMemo(() => { + const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + if (!sourcePath) { + return null; + } + return JSON.stringify( + createProjectData(sourcePath, { + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }), + ); + }, [ + videoPath, + videoSourcePath, + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + ]); + + const hasUnsavedChanges = Boolean( + currentProjectPath && + currentProjectSnapshot && + lastSavedSnapshot && + currentProjectSnapshot !== lastSavedSnapshot, + ); + + useEffect(() => { + async function loadInitialData() { + try { + const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); + if (currentProjectResult.success && currentProjectResult.project) { + const restored = await applyLoadedProject( + currentProjectResult.project, + currentProjectResult.path ?? null, + ); + if (restored) { + return; + } + } + + const result = await window.electronAPI.getCurrentVideoPath(); + if (result.success && result.path) { + setVideoSourcePath(result.path); + setVideoPath(toFileUrl(result.path)); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + } else { + setError("No video to load. Please record or select a video."); + } + } catch (err) { + setError("Error loading video: " + String(err)); + } finally { + setLoading(false); + } + } + + loadInitialData(); + }, [applyLoadedProject]); + + const saveProject = useCallback( + async (forceSaveAs: boolean) => { + if (!videoPath) { + toast.error("No video loaded"); + return; + } + + const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); + if (!sourcePath) { + toast.error("Unable to determine source video path"); + return; + } + + const projectData = createProjectData(sourcePath, { + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }); + + const fileNameBase = + sourcePath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; + const projectSnapshot = JSON.stringify(projectData); + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + forceSaveAs ? undefined : (currentProjectPath ?? undefined), + ); + + if (result.canceled) { + toast.info("Project save canceled"); + return; + } + + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return; + } + + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot(projectSnapshot); + + toast.success(`Project saved to ${result.path}`); + }, + [ + videoPath, + videoSourcePath, + currentProjectPath, + wallpaper, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + ], + ); + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasUnsavedChanges) { + return; + } + + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasUnsavedChanges]); + + const handleSaveProject = useCallback(async () => { + await saveProject(false); + }, [saveProject]); + + const handleSaveProjectAs = useCallback(async () => { + await saveProject(true); + }, [saveProject]); + + const handleLoadProject = useCallback(async () => { + const result = await window.electronAPI.loadProjectFile(); + + if (result.canceled) { + return; + } + + if (!result.success) { + toast.error(result.message || "Failed to load project"); + return; + } + + const restored = await applyLoadedProject(result.project, result.path ?? null); + if (!restored) { + toast.error("Invalid project file format"); + return; + } + + toast.success(`Project loaded from ${result.path}`); + }, [applyLoadedProject]); + + useEffect(() => { + const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); + const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); + + return () => { + removeLoadListener?.(); + removeSaveListener?.(); + removeSaveAsListener?.(); + }; + }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); + + 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]); + + function togglePlayPause() { + const playback = videoPlaybackRef.current; + const video = playback?.video; + if (!playback || !video) return; + + if (isPlaying) { + playback.pause(); + } else { + playback.play().catch((err) => console.error("Video play failed:", err)); + } + } + + function handleSeek(time: number) { + const video = videoPlaybackRef.current?.video; + if (!video) return; + video.currentTime = time; + } + + const handleSelectZoom = useCallback((id: string | null) => { + setSelectedZoomId(id); + if (id) setSelectedTrimId(null); + }, []); + + const handleSelectTrim = useCallback((id: string | null) => { + setSelectedTrimId(id); + if (id) { + setSelectedZoomId(null); + setSelectedAnnotationId(null); + } + }, []); + + const handleSelectAnnotation = useCallback((id: string | null) => { + setSelectedAnnotationId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + } + }, []); + + const handleZoomAdded = useCallback( + (span: Span) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_ZOOM_DEPTH, + focus: { cx: 0.5, cy: 0.5 }, + }; + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); + setSelectedZoomId(id); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + }, + [pushState], + ); + + 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), + }; + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); + setSelectedZoomId(id); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + }, + [pushState], + ); + + const handleTrimAdded = useCallback( + (span: Span) => { + const id = `trim-${nextTrimIdRef.current++}`; + const newRegion: TrimRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + }; + pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); + setSelectedTrimId(id); + setSelectedZoomId(null); + setSelectedAnnotationId(null); + }, + [pushState], + ); + + const handleZoomSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === id + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + : region, + ), + })); + }, + [pushState], + ); + + const handleTrimSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + trimRegions: prev.trimRegions.map((region) => + region.id === id + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + : region, + ), + })); + }, + [pushState], + ); + + // Focus drag: updateState for live preview, commitState on pointer-up + const handleZoomFocusChange = useCallback( + (id: string, focus: ZoomFocus) => { + updateState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth) } : region, + ), + })); + }, + [updateState], + ); + + const handleZoomDepthChange = useCallback( + (depth: ZoomDepth) => { + if (!selectedZoomId) return; + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === selectedZoomId + ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) } + : region, + ), + })); + }, + [selectedZoomId, pushState], + ); + + const handleZoomDelete = useCallback( + (id: string) => { + pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); + if (selectedZoomId === id) { + setSelectedZoomId(null); + } + }, + [selectedZoomId, pushState], + ); + + const handleTrimDelete = useCallback( + (id: string) => { + pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) })); + if (selectedTrimId === id) { + setSelectedTrimId(null); + } + }, + [selectedTrimId, pushState], + ); + + const handleSelectSpeed = useCallback((id: string | null) => { + setSelectedSpeedId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + } + }, []); + + const handleSpeedAdded = useCallback( + (span: Span) => { + const id = `speed-${nextSpeedIdRef.current++}`; + const newRegion: SpeedRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + speed: DEFAULT_PLAYBACK_SPEED, + }; + pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] })); + setSelectedSpeedId(id); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + }, + [pushState], + ); + + const handleSpeedSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + })); + }, + [pushState], + ); + + const handleSpeedDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + speedRegions: prev.speedRegions.filter((region) => region.id !== id), + })); + if (selectedSpeedId === id) { + setSelectedSpeedId(null); + } + }, + [selectedSpeedId, pushState], + ); + + const handleSpeedChange = useCallback( + (speed: PlaybackSpeed) => { + if (!selectedSpeedId) return; + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((region) => + region.id === selectedSpeedId ? { ...region, speed } : region, + ), + })); + }, + [selectedSpeedId, pushState], + ); + + const handleAnnotationAdded = useCallback( + (span: Span) => { + const id = `annotation-${nextAnnotationIdRef.current++}`; + const zIndex = nextAnnotationZIndexRef.current++; + const newRegion: AnnotationRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + type: "text", + content: "Enter text...", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex, + }; + pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] })); + setSelectedAnnotationId(id); + setSelectedZoomId(null); + setSelectedTrimId(null); + }, + [pushState], + ); + + const handleAnnotationSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id + ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) } + : region, + ), + })); + }, + [pushState], + ); + + const handleAnnotationDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.filter((r) => r.id !== id), + })); + if (selectedAnnotationId === id) { + setSelectedAnnotationId(null); + } + }, + [selectedAnnotationId, pushState], + ); + + const handleAnnotationContentChange = useCallback( + (id: string, content: string) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => { + if (region.id !== id) return region; + if (region.type === "text") { + return { ...region, content, textContent: content }; + } else if (region.type === "image") { + return { ...region, content, imageContent: content }; + } + return { ...region, content }; + }), + })); + }, + [pushState], + ); + + const handleAnnotationTypeChange = useCallback( + (id: string, type: AnnotationRegion["type"]) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => { + if (region.id !== id) return region; + const updatedRegion = { ...region, type }; + if (type === "text") { + updatedRegion.content = region.textContent || "Enter text..."; + } else if (type === "image") { + updatedRegion.content = region.imageContent || ""; + } else if (type === "figure") { + updatedRegion.content = ""; + if (!region.figureData) { + updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; + } + } + return updatedRegion; + }), + })); + }, + [pushState], + ); + + const handleAnnotationStyleChange = useCallback( + (id: string, style: Partial) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, style: { ...region.style, ...style } } : region, + ), + })); + }, + [pushState], + ); + + const handleAnnotationFigureDataChange = useCallback( + (id: string, figureData: FigureData) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, figureData } : region, + ), + })); + }, + [pushState], + ); + + const handleAnnotationPositionChange = useCallback( + (id: string, position: { x: number; y: number }) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, position } : region, + ), + })); + }, + [pushState], + ); + + const handleAnnotationSizeChange = useCallback( + (id: string, size: { width: number; height: number }) => { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((region) => + region.id === id ? { ...region, size } : region, + ), + })); + }, + [pushState], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const mod = e.ctrlKey || e.metaKey; + const key = e.key.toLowerCase(); + + if (mod && key === "z" && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + undo(); + return; + } + if (mod && (key === "y" || (key === "z" && e.shiftKey))) { + e.preventDefault(); + e.stopPropagation(); + redo(); + return; + } + + const isInput = + e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; + + if (e.key === "Tab" && !isInput) { + e.preventDefault(); + } + + if (matchesShortcut(e, shortcuts.playPause, isMac)) { + // Allow space only in inputs/textareas + if (isInput) { + return; + } + e.preventDefault(); + const playback = videoPlaybackRef.current; + if (playback?.video) { + playback.video.paused ? playback.play().catch(console.error) : playback.pause(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [undo, redo, shortcuts, isMac]); + + useEffect(() => { + if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { + setSelectedZoomId(null); + } + }, [selectedZoomId, zoomRegions]); + + useEffect(() => { + if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { + setSelectedTrimId(null); + } + }, [selectedTrimId, trimRegions]); + + useEffect(() => { + if ( + selectedAnnotationId && + !annotationRegions.some((region) => region.id === selectedAnnotationId) + ) { + setSelectedAnnotationId(null); + } + }, [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"); + return; + } + + const video = videoPlaybackRef.current?.video; + if (!video) { + toast.error("Video not ready"); + return; + } + + setIsExporting(true); + setExportProgress(null); + setExportError(null); + + try { + const wasPlaying = isPlaying; + if (wasPlaying) { + videoPlaybackRef.current?.pause(); + } + + const aspectRatioValue = getAspectRatioValue(aspectRatio); + const sourceWidth = video.videoWidth || 1920; + const sourceHeight = video.videoHeight || 1080; + + // Get preview CONTAINER dimensions for scaling + const playbackRef = videoPlaybackRef.current; + const containerElement = playbackRef?.containerRef?.current; + const previewWidth = containerElement?.clientWidth || 1920; + const previewHeight = containerElement?.clientHeight || 1080; + + if (settings.format === "gif" && settings.gifConfig) { + // GIF Export + const gifExporter = new GifExporter({ + videoUrl: videoPath, + width: settings.gifConfig.width, + height: settings.gifConfig.height, + frameRate: settings.gifConfig.frameRate, + loop: settings.gifConfig.loop, + sizePreset: settings.gifConfig.sizePreset, + wallpaper, + zoomRegions, + trimRegions, + speedRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + videoPadding: padding, + cropRegion, + annotationRegions, + previewWidth, + previewHeight, + onProgress: (progress: ExportProgress) => { + setExportProgress(progress); + }, + }); + + exporterRef.current = gifExporter as unknown as VideoExporter; + const result = await gifExporter.export(); + + if (result.success && result.blob) { + const arrayBuffer = await result.blob.arrayBuffer(); + const timestamp = Date.now(); + const fileName = `export-${timestamp}.gif`; + + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + + if (saveResult.canceled) { + toast.info("Export canceled"); + } else if (saveResult.success) { + toast.success(`GIF exported successfully to ${saveResult.path}`); + } else { + setExportError(saveResult.message || "Failed to save GIF"); + toast.error(saveResult.message || "Failed to save GIF"); + } + } else { + setExportError(result.error || "GIF export failed"); + toast.error(result.error || "GIF export failed"); + } + } else { + // MP4 Export + const quality = settings.quality || exportQuality; + let exportWidth: number; + let exportHeight: number; + let bitrate: number; + + if (quality === "source") { + // Use source resolution + exportWidth = sourceWidth; + exportHeight = sourceHeight; + + if (aspectRatioValue === 1) { + // Square (1:1): use smaller dimension to avoid codec limits + const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; + exportWidth = baseDimension; + exportHeight = baseDimension; + } else if (aspectRatioValue > 1) { + // Landscape: find largest even dimensions that exactly match aspect ratio + const baseWidth = Math.floor(sourceWidth / 2) * 2; + let found = false; + for (let w = baseWidth; w >= 100 && !found; w -= 2) { + const h = Math.round(w / aspectRatioValue); + if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportWidth = baseWidth; + exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; + } + } else { + // Portrait: find largest even dimensions that exactly match aspect ratio + const baseHeight = Math.floor(sourceHeight / 2) * 2; + let found = false; + for (let h = baseHeight; h >= 100 && !found; h -= 2) { + const w = Math.round(h * aspectRatioValue); + if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportHeight = baseHeight; + exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; + } + } + + // Calculate visually lossless bitrate matching screen recording optimization + const totalPixels = exportWidth * exportHeight; + bitrate = 30_000_000; + if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { + bitrate = 50_000_000; + } else if (totalPixels > 2560 * 1440) { + bitrate = 80_000_000; + } + } else { + // Use quality-based target resolution + const targetHeight = quality === "medium" ? 720 : 1080; + + // Calculate dimensions maintaining aspect ratio + exportHeight = Math.floor(targetHeight / 2) * 2; + exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + + // Adjust bitrate for lower resolutions + const totalPixels = exportWidth * exportHeight; + if (totalPixels <= 1280 * 720) { + bitrate = 10_000_000; + } else if (totalPixels <= 1920 * 1080) { + bitrate = 20_000_000; + } else { + bitrate = 30_000_000; + } + } + + const exporter = new VideoExporter({ + videoUrl: videoPath, + width: exportWidth, + height: exportHeight, + frameRate: 60, + bitrate, + codec: "avc1.640033", + wallpaper, + zoomRegions, + trimRegions, + speedRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + annotationRegions, + previewWidth, + previewHeight, + onProgress: (progress: ExportProgress) => { + setExportProgress(progress); + }, + }); + + exporterRef.current = exporter; + const result = await exporter.export(); + + if (result.success && result.blob) { + const arrayBuffer = await result.blob.arrayBuffer(); + const timestamp = Date.now(); + const fileName = `export-${timestamp}.mp4`; + + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + + if (saveResult.canceled) { + toast.info("Export canceled"); + } else if (saveResult.success) { + toast.success(`Video exported successfully to ${saveResult.path}`); + } else { + setExportError(saveResult.message || "Failed to save video"); + toast.error(saveResult.message || "Failed to save video"); + } + } else { + setExportError(result.error || "Export failed"); + toast.error(result.error || "Export failed"); + } + } + + if (wasPlaying) { + videoPlaybackRef.current?.play(); + } + } catch (error) { + console.error("Export error:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + setExportError(errorMessage); + toast.error(`Export failed: ${errorMessage}`); + } finally { + setIsExporting(false); + exporterRef.current = null; + // Reset dialog state to ensure it can be opened again on next export + // This fixes the bug where second export doesn't show save dialog + setShowExportDialog(false); + setExportProgress(null); + } + }, + [ + videoPath, + wallpaper, + zoomRegions, + trimRegions, + speedRegions, + shadowIntensity, + showBlur, + motionBlurEnabled, + borderRadius, + padding, + cropRegion, + annotationRegions, + isPlaying, + aspectRatio, + exportQuality, + ], + ); + + const handleOpenExportDialog = useCallback(() => { + if (!videoPath) { + toast.error("No video loaded"); + return; + } + + const video = videoPlaybackRef.current?.video; + if (!video) { + toast.error("Video not ready"); + return; + } + + // Build export settings from current state + const sourceWidth = video.videoWidth || 1920; + const sourceHeight = video.videoHeight || 1080; + const gifDimensions = calculateOutputDimensions( + sourceWidth, + sourceHeight, + gifSizePreset, + GIF_SIZE_PRESETS, + ); + + const settings: ExportSettings = { + format: exportFormat, + quality: exportFormat === "mp4" ? exportQuality : undefined, + gifConfig: + exportFormat === "gif" + ? { + frameRate: gifFrameRate, + loop: gifLoop, + sizePreset: gifSizePreset, + width: gifDimensions.width, + height: gifDimensions.height, + } + : undefined, + }; + + setShowExportDialog(true); + setExportError(null); + + // Start export immediately + handleExport(settings); + }, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); + + const handleCancelExport = useCallback(() => { + if (exporterRef.current) { + exporterRef.current.cancel(); + toast.info("Export canceled"); + setShowExportDialog(false); + setIsExporting(false); + setExportProgress(null); + setExportError(null); + } + }, []); + + if (loading) { + return ( +
+
Loading video...
+
+ ); + } + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + return ( +
+
+
+
+ +
+ {/* Left Column - Video & Timeline */} +
+ + {/* Top section: video preview and controls */} + +
+ {/* Video preview */} +
+
+ 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + motionBlurEnabled={motionBlurEnabled} + borderRadius={borderRadius} + padding={padding} + cropRegion={cropRegion} + trimRegions={trimRegions} + speedRegions={speedRegions} + annotationRegions={annotationRegions} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + /> +
+
+ {/* Playback controls */} +
+
+ +
+
+
+
+ + +
+
+ + {/* Timeline section */} + +
+ pushState({ aspectRatio: ar })} + /> +
+
+
+
+ + {/* Right section: settings panel */} + pushState({ wallpaper: w })} + selectedZoomDepth={ + selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null + } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomId={selectedZoomId} + onZoomDelete={handleZoomDelete} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + shadowIntensity={shadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} + showBlur={showBlur} + onBlurChange={(v) => pushState({ showBlur: v })} + motionBlurEnabled={motionBlurEnabled} + onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })} + borderRadius={borderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} + padding={padding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} + cropRegion={cropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} + aspectRatio={aspectRatio} + videoElement={videoPlaybackRef.current?.video || null} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + )} + onExport={handleOpenExportDialog} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationRegions} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationDelete={handleAnnotationDelete} + onSaveProject={handleSaveProject} + onLoadProject={handleLoadProject} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + /> +
+ + + + setShowExportDialog(false)} + progress={exportProgress} + isExporting={isExporting} + error={exportError} + onCancel={handleCancelExport} + exportFormat={exportFormat} + /> +
+ ); } diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index fb7ce24..c4598ac 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,901 +1,971 @@ +import { + Application, + BlurFilter, + Container, + Graphics, + Sprite, + Texture, + VideoSource, +} from "pixi.js"; import type React from "react"; -import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} 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 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"; -import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; -import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; -import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; -import { applyZoomTransform } from "./videoPlayback/zoomTransform"; -import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { + type AnnotationRegion, + type SpeedRegion, + type TrimRegion, + ZOOM_DEPTH_SCALES, + type ZoomDepth, + type ZoomFocus, + type ZoomRegion, +} from "./types"; +import { DEFAULT_FOCUS, MIN_DELTA, SMOOTHING_FACTOR } from "./videoPlayback/constants"; +import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; +import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; +import { clamp01 } from "./videoPlayback/mathUtils"; +import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; +import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; +import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { applyZoomTransform } from "./videoPlayback/zoomTransform"; interface VideoPlaybackProps { - videoPath: string; - onDurationChange: (duration: number) => void; - onTimeUpdate: (time: number) => void; - currentTime: number; - onPlayStateChange: (playing: boolean) => void; - onError: (error: string) => void; - wallpaper?: string; - zoomRegions: ZoomRegion[]; - selectedZoomId: string | null; - onSelectZoom: (id: string | null) => void; - onZoomFocusChange: (id: string, focus: ZoomFocus) => void; - onZoomFocusDragEnd?: () => void; - isPlaying: boolean; - showShadow?: boolean; - shadowIntensity?: number; - showBlur?: boolean; - motionBlurEnabled?: boolean; - borderRadius?: number; - padding?: number; - cropRegion?: import('./types').CropRegion; - trimRegions?: TrimRegion[]; - speedRegions?: SpeedRegion[]; - aspectRatio: AspectRatio; - annotationRegions?: AnnotationRegion[]; - selectedAnnotationId?: string | null; - onSelectAnnotation?: (id: string | null) => void; - onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; - onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + videoPath: string; + onDurationChange: (duration: number) => void; + onTimeUpdate: (time: number) => void; + currentTime: number; + onPlayStateChange: (playing: boolean) => void; + onError: (error: string) => void; + wallpaper?: string; + zoomRegions: ZoomRegion[]; + selectedZoomId: string | null; + onSelectZoom: (id: string | null) => void; + onZoomFocusChange: (id: string, focus: ZoomFocus) => void; + onZoomFocusDragEnd?: () => void; + isPlaying: boolean; + showShadow?: boolean; + shadowIntensity?: number; + showBlur?: boolean; + motionBlurEnabled?: boolean; + borderRadius?: number; + padding?: number; + cropRegion?: import("./types").CropRegion; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + aspectRatio: AspectRatio; + annotationRegions?: AnnotationRegion[]; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; + onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; } export interface VideoPlaybackRef { - video: HTMLVideoElement | null; - app: Application | null; - videoSprite: Sprite | null; - videoContainer: Container | null; - containerRef: React.RefObject; - play: () => Promise; - pause: () => void; + video: HTMLVideoElement | null; + app: Application | null; + videoSprite: Sprite | null; + videoContainer: Container | null; + containerRef: React.RefObject; + play: () => Promise; + pause: () => void; } -const VideoPlayback = forwardRef(({ - videoPath, - onDurationChange, - onTimeUpdate, - currentTime, - onPlayStateChange, - onError, - wallpaper, - zoomRegions, - selectedZoomId, - onSelectZoom, - onZoomFocusChange, - onZoomFocusDragEnd, - isPlaying, - showShadow, - shadowIntensity = 0, - showBlur, - motionBlurEnabled = false, - borderRadius = 0, - padding = 50, - cropRegion, - trimRegions = [], - speedRegions = [], - aspectRatio, - annotationRegions = [], - selectedAnnotationId, - onSelectAnnotation, - onAnnotationPositionChange, - onAnnotationSizeChange, -}, ref) => { - const videoRef = useRef(null); - const containerRef = useRef(null); - const appRef = useRef(null); - const videoSpriteRef = useRef(null); - const videoContainerRef = useRef(null); - const cameraContainerRef = useRef(null); - const timeUpdateAnimationRef = useRef(null); - const [pixiReady, setPixiReady] = useState(false); - const [videoReady, setVideoReady] = useState(false); - const overlayRef = useRef(null); - const focusIndicatorRef = useRef(null); - const currentTimeRef = useRef(0); - const zoomRegionsRef = useRef([]); - const selectedZoomIdRef = useRef(null); - const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy }); - const blurFilterRef = useRef(null); - const isDraggingFocusRef = useRef(false); - const stageSizeRef = useRef({ width: 0, height: 0 }); - const videoSizeRef = useRef({ width: 0, height: 0 }); - const baseScaleRef = useRef(1); - const baseOffsetRef = useRef({ x: 0, y: 0 }); - const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); - const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); - const maskGraphicsRef = useRef(null); - const isPlayingRef = useRef(isPlaying); - const isSeekingRef = useRef(false); - const allowPlaybackRef = useRef(false); - 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); - - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); - }, []); - - const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => { - const overlayEl = overlayRef.current; - const indicatorEl = focusIndicatorRef.current; - - if (!overlayEl || !indicatorEl) { - return; - } - - // Update stage size from overlay dimensions - const stageWidth = overlayEl.clientWidth; - const stageHeight = overlayEl.clientHeight; - if (stageWidth && stageHeight) { - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - } - - updateOverlayIndicator({ - overlayEl, - indicatorEl, - region, - focusOverride, - videoSize: videoSizeRef.current, - baseScale: baseScaleRef.current, - isPlaying: isPlayingRef.current, - }); - }, []); - - const layoutVideoContent = useCallback(() => { - const container = containerRef.current; - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const maskGraphics = maskGraphicsRef.current; - const videoElement = videoRef.current; - const cameraContainer = cameraContainerRef.current; - - if (!container || !app || !videoSprite || !maskGraphics || !videoElement || !cameraContainer) { - return; - } - - // Lock video dimensions on first layout to prevent resize issues - if (!lockedVideoDimensionsRef.current && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { - lockedVideoDimensionsRef.current = { - width: videoElement.videoWidth, - height: videoElement.videoHeight, - }; - } - - const result = layoutVideoContentUtil({ - container, - app, - videoSprite, - maskGraphics, - videoElement, - cropRegion, - lockedVideoDimensions: lockedVideoDimensionsRef.current, - borderRadius, - padding, - }); - - if (result) { - stageSizeRef.current = result.stageSize; - videoSizeRef.current = result.videoSize; - baseScaleRef.current = result.baseScale; - baseOffsetRef.current = result.baseOffset; - baseMaskRef.current = result.maskRect; - cropBoundsRef.current = result.cropBounds; - - // Reset camera container to identity - cameraContainer.scale.set(1); - cameraContainer.position.set(0, 0); - - const selectedId = selectedZoomIdRef.current; - const activeRegion = selectedId - ? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null - : null; - - updateOverlayForRegion(activeRegion); - } - }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); - - useEffect(() => { - layoutVideoContentRef.current = layoutVideoContent; - }, [layoutVideoContent]); - - const selectedZoom = useMemo(() => { - if (!selectedZoomId) return null; - return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; - }, [zoomRegions, selectedZoomId]); - - useImperativeHandle(ref, () => ({ - video: videoRef.current, - app: appRef.current, - videoSprite: videoSpriteRef.current, - videoContainer: videoContainerRef.current, - containerRef, - play: async () => { - const vid = videoRef.current; - if (!vid) return; - try { - allowPlaybackRef.current = true; - await vid.play(); - } catch (error) { - allowPlaybackRef.current = false; - throw error; - } - }, - pause: () => { - const video = videoRef.current; - allowPlaybackRef.current = false; - if (!video) { - return; - } - video.pause(); - }, - })); - - const updateFocusFromClientPoint = (clientX: number, clientY: number) => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; - - const regionId = selectedZoomIdRef.current; - if (!regionId) return; - - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; - - const rect = overlayEl.getBoundingClientRect(); - const stageWidth = rect.width; - const stageHeight = rect.height; - - if (!stageWidth || !stageHeight) { - return; - } - - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - - const localX = clientX - rect.left; - const localY = clientY - rect.top; - - const unclampedFocus: ZoomFocus = { - cx: clamp01(localX / stageWidth), - cy: clamp01(localY / stageHeight), - }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); - - onZoomFocusChange(region.id, clampedFocus); - updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); - }; - - const handleOverlayPointerDown = (event: React.PointerEvent) => { - if (isPlayingRef.current) return; - const regionId = selectedZoomIdRef.current; - if (!regionId) return; - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; - onSelectZoom(region.id); - event.preventDefault(); - isDraggingFocusRef.current = true; - event.currentTarget.setPointerCapture(event.pointerId); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const handleOverlayPointerMove = (event: React.PointerEvent) => { - if (!isDraggingFocusRef.current) return; - event.preventDefault(); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const endFocusDrag = (event: React.PointerEvent) => { - if (!isDraggingFocusRef.current) return; - isDraggingFocusRef.current = false; - try { - event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - - } - onZoomFocusDragEnd?.(); - }; - - const handleOverlayPointerUp = (event: React.PointerEvent) => { - endFocusDrag(event); - }; - - const handleOverlayPointerLeave = (event: React.PointerEvent) => { - endFocusDrag(event); - }; - - useEffect(() => { - zoomRegionsRef.current = zoomRegions; - }, [zoomRegions]); - - useEffect(() => { - selectedZoomIdRef.current = selectedZoomId; - }, [selectedZoomId]); - - useEffect(() => { - isPlayingRef.current = isPlaying; - }, [isPlaying]); - - useEffect(() => { - trimRegionsRef.current = trimRegions; - }, [trimRegions]); - - useEffect(() => { - speedRegionsRef.current = speedRegions; - }, [speedRegions]); - - useEffect(() => { - motionBlurEnabledRef.current = motionBlurEnabled; - }, [motionBlurEnabled]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const cameraContainer = cameraContainerRef.current; - const video = videoRef.current; - - if (!app || !cameraContainer || !video) return; - - const tickerWasStarted = app.ticker?.started || false; - if (tickerWasStarted && app.ticker) { - app.ticker.stop(); - } - - const wasPlaying = !video.paused; - if (wasPlaying) { - video.pause(); - } - - animationStateRef.current = { - scale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - }; - - if (blurFilterRef.current) { - blurFilterRef.current.blur = 0; - } - - requestAnimationFrame(() => { - const container = cameraContainerRef.current; - const videoStage = videoContainerRef.current; - const sprite = videoSpriteRef.current; - const currentApp = appRef.current; - if (!container || !videoStage || !sprite || !currentApp) { - return; - } - - container.scale.set(1); - container.position.set(0, 0); - videoStage.scale.set(1); - videoStage.position.set(0, 0); - sprite.scale.set(1); - sprite.position.set(0, 0); - - layoutVideoContent(); - - applyZoomTransform({ - cameraContainer: container, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - motionIntensity: 0, - isPlaying: false, - motionBlurEnabled: motionBlurEnabledRef.current, - }); - - requestAnimationFrame(() => { - const finalApp = appRef.current; - if (wasPlaying && video) { - video.play().catch(() => { - }); - } - if (tickerWasStarted && finalApp?.ticker) { - finalApp.ticker.start(); - } - }); - }); - }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - const container = containerRef.current; - if (!container) return; - - if (typeof ResizeObserver === 'undefined') { - return; - } - - const observer = new ResizeObserver(() => { - layoutVideoContent(); - }); - - observer.observe(container); - return () => { - observer.disconnect(); - }; - }, [pixiReady, videoReady, layoutVideoContent]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - updateOverlayForRegion(selectedZoom); - }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); - - useEffect(() => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; - if (!selectedZoom) { - overlayEl.style.cursor = 'default'; - overlayEl.style.pointerEvents = 'none'; - return; - } - overlayEl.style.cursor = isPlaying ? 'not-allowed' : 'grab'; - overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; - }, [selectedZoom, isPlaying]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let mounted = true; - let app: Application | null = null; - - (async () => { - app = new Application(); - - await app.init({ - width: container.clientWidth, - height: container.clientHeight, - backgroundAlpha: 0, - antialias: true, - resolution: window.devicePixelRatio || 1, - autoDensity: true, - }); - - app.ticker.maxFPS = 60; - - if (!mounted) { - app.destroy(true, { children: true, texture: true, textureSource: true }); - return; - } - - appRef.current = app; - container.appendChild(app.canvas); - - // Camera container - this will be scaled/positioned for zoom - const cameraContainer = new Container(); - cameraContainerRef.current = cameraContainer; - app.stage.addChild(cameraContainer); - - // Video container - holds the masked video sprite - const videoContainer = new Container(); - videoContainerRef.current = videoContainer; - cameraContainer.addChild(videoContainer); - - setPixiReady(true); - })(); - - return () => { - mounted = false; - setPixiReady(false); - if (app && app.renderer) { - app.destroy(true, { children: true, texture: true, textureSource: true }); - } - appRef.current = null; - cameraContainerRef.current = null; - videoContainerRef.current = null; - videoSpriteRef.current = null; - }; - }, []); - - useEffect(() => { - const video = videoRef.current; - if (!video) return; - video.pause(); - video.currentTime = 0; - allowPlaybackRef.current = false; - lockedVideoDimensionsRef.current = null; - setVideoReady(false); - if (videoReadyRafRef.current) { - cancelAnimationFrame(videoReadyRafRef.current); - videoReadyRafRef.current = null; - } - }, [videoPath]); - - - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const video = videoRef.current; - const app = appRef.current; - const videoContainer = videoContainerRef.current; - - if (!video || !app || !videoContainer) return; - if (video.videoWidth === 0 || video.videoHeight === 0) return; - - const source = VideoSource.from(video); - if ('autoPlay' in source) { - (source as { autoPlay?: boolean }).autoPlay = false; - } - if ('autoUpdate' in source) { - (source as { autoUpdate?: boolean }).autoUpdate = true; - } - const videoTexture = Texture.from(source); - - const videoSprite = new Sprite(videoTexture); - videoSpriteRef.current = videoSprite; - - const maskGraphics = new Graphics(); - videoContainer.addChild(videoSprite); - videoContainer.addChild(maskGraphics); - videoContainer.mask = maskGraphics; - maskGraphicsRef.current = maskGraphics; - - animationStateRef.current = { - scale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - }; - - const blurFilter = new BlurFilter(); - blurFilter.quality = 3; - blurFilter.resolution = app.renderer.resolution; - blurFilter.blur = 0; - videoContainer.filters = [blurFilter]; - blurFilterRef.current = blurFilter; - - layoutVideoContent(); - video.pause(); - - const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({ - video, - isSeekingRef, - isPlayingRef, - allowPlaybackRef, - currentTimeRef, - timeUpdateAnimationRef, - onPlayStateChange, - onTimeUpdate, - trimRegionsRef, - speedRegionsRef, - }); - - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('ended', handlePause); - video.addEventListener('seeked', handleSeeked); - video.addEventListener('seeking', handleSeeking); - - return () => { - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('ended', handlePause); - video.removeEventListener('seeked', handleSeeked); - video.removeEventListener('seeking', handleSeeking); - - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - } - - if (videoSprite) { - videoContainer.removeChild(videoSprite); - videoSprite.destroy(); - } - if (maskGraphics) { - videoContainer.removeChild(maskGraphics); - maskGraphics.destroy(); - } - videoContainer.mask = null; - maskGraphicsRef.current = null; - if (blurFilterRef.current) { - videoContainer.filters = []; - blurFilterRef.current.destroy(); - blurFilterRef.current = null; - } - videoTexture.destroy(true); - - videoSpriteRef.current = null; - }; - }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const videoContainer = videoContainerRef.current; - if (!app || !videoSprite || !videoContainer) return; - - const applyTransform = (motionIntensity: number) => { - const cameraContainer = cameraContainerRef.current; - if (!cameraContainer) return; - - const state = animationStateRef.current; - - applyZoomTransform({ - cameraContainer, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: state.scale, - focusX: state.focusX, - focusY: state.focusY, - motionIntensity, - isPlaying: isPlayingRef.current, - motionBlurEnabled: motionBlurEnabledRef.current, - }); - }; - - const ticker = () => { - const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current); - - const defaultFocus = DEFAULT_FOCUS; - let targetScaleFactor = 1; - let targetFocus = defaultFocus; - - // If a zoom is selected but video is not playing, show default unzoomed view - // (the overlay will show where the zoom will be) - const selectedId = selectedZoomIdRef.current; - const hasSelectedZoom = selectedId !== null; - const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; - - if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = clampFocusToStage(region.focus, region.depth); - - // Interpolate scale and focus based on region strength - targetScaleFactor = 1 + (zoomScale - 1) * strength; - targetFocus = { - cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, - cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, - }; - } - - const state = animationStateRef.current; - - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; - - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; - } - - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } - - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } - - state.scale = nextScale; - state.focusX = nextFocusX; - state.focusY = nextFocusY; - - const motionIntensity = Math.max( - Math.abs(nextScale - prevScale), - Math.abs(nextFocusX - prevFocusX), - Math.abs(nextFocusY - prevFocusY) - ); - - applyTransform(motionIntensity); - }; - - app.ticker.add(ticker); - return () => { - if (app && app.ticker) { - app.ticker.remove(ticker); - } - }; - }, [pixiReady, videoReady, clampFocusToStage]); - - const handleLoadedMetadata = (e: React.SyntheticEvent) => { - const video = e.currentTarget; - onDurationChange(video.duration); - video.currentTime = 0; - video.pause(); - allowPlaybackRef.current = false; - currentTimeRef.current = 0; - - if (videoReadyRafRef.current) { - cancelAnimationFrame(videoReadyRafRef.current); - videoReadyRafRef.current = null; - } - - const waitForRenderableFrame = () => { - const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0; - const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; - if (hasDimensions && hasData) { - videoReadyRafRef.current = null; - setVideoReady(true); - return; - } - videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); - }; - - videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); - }; - - const [resolvedWallpaper, setResolvedWallpaper] = useState(null); - - useEffect(() => { - let mounted = true - ;(async () => { - try { - if (!wallpaper) { - const def = await getAssetPath('wallpapers/wallpaper1.jpg') - if (mounted) setResolvedWallpaper(def) - return - } - - if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { - if (mounted) setResolvedWallpaper(wallpaper) - return - } - - // If it's a data URL (custom uploaded image), use as-is - if (wallpaper.startsWith('data:')) { - if (mounted) setResolvedWallpaper(wallpaper) - return - } - - // If it's an absolute web/http or file path, use as-is - if (wallpaper.startsWith('http') || wallpaper.startsWith('file://') || wallpaper.startsWith('/')) { - // If it's an absolute server path (starts with '/'), resolve via getAssetPath as well - if (wallpaper.startsWith('/')) { - const rel = wallpaper.replace(/^\//, '') - const p = await getAssetPath(rel) - if (mounted) setResolvedWallpaper(p) - return - } - if (mounted) setResolvedWallpaper(wallpaper) - return - } - const p = await getAssetPath(wallpaper.replace(/^\//, '')) - if (mounted) setResolvedWallpaper(p) - } catch (err) { - if (mounted) setResolvedWallpaper(wallpaper || '/wallpapers/wallpaper1.jpg') - } - })() - return () => { mounted = false } - }, [wallpaper]) - - useEffect(() => { - return () => { - if (videoReadyRafRef.current) { - cancelAnimationFrame(videoReadyRafRef.current); - videoReadyRafRef.current = null; - } - }; - }, []) - - const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:'))) - const backgroundStyle = isImageUrl - ? { backgroundImage: `url(${resolvedWallpaper || ''})` } - : { background: resolvedWallpaper || '' }; - - return ( -
- {/* Background layer - always render as DOM element with blur */} -
-
0) - ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` - : 'none', - }} - /> - {/* Only render overlay after PIXI and video are fully initialized */} - {pixiReady && videoReady && ( -
-
- {(() => { - const filtered = (annotationRegions || []).filter((annotation) => { - if (typeof annotation.startMs !== 'number' || typeof annotation.endMs !== 'number') return false; - - if (annotation.id === selectedAnnotationId) return true; - - const timeMs = Math.round(currentTime * 1000); - return timeMs >= annotation.startMs && timeMs <= annotation.endMs; - }); - - // Sort by z-index (lowest to highest) so higher z-index renders on top - const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); - - // Handle click-through cycling: when clicking same annotation, cycle to next - const handleAnnotationClick = (clickedId: string) => { - if (!onSelectAnnotation) return; - - // If clicking on already selected annotation and there are multiple overlapping - if (clickedId === selectedAnnotationId && sorted.length > 1) { - // Find current index and cycle to next - const currentIndex = sorted.findIndex(a => a.id === clickedId); - const nextIndex = (currentIndex + 1) % sorted.length; - onSelectAnnotation(sorted[nextIndex].id); - } else { - // First click or clicking different annotation - onSelectAnnotation(clickedId); - } - }; - - return sorted.map((annotation) => ( - onAnnotationPositionChange?.(id, position)} - onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} - onClick={handleAnnotationClick} - zIndex={annotation.zIndex} - isSelectedBoost={annotation.id === selectedAnnotationId} - /> - )); - })()} -
- )} -
- ); -}); - -VideoPlayback.displayName = 'VideoPlayback'; +const VideoPlayback = forwardRef( + ( + { + videoPath, + onDurationChange, + onTimeUpdate, + currentTime, + onPlayStateChange, + onError, + wallpaper, + zoomRegions, + selectedZoomId, + onSelectZoom, + onZoomFocusChange, + onZoomFocusDragEnd, + isPlaying, + showShadow, + shadowIntensity = 0, + showBlur, + motionBlurEnabled = false, + borderRadius = 0, + padding = 50, + cropRegion, + trimRegions = [], + speedRegions = [], + aspectRatio, + annotationRegions = [], + selectedAnnotationId, + onSelectAnnotation, + onAnnotationPositionChange, + onAnnotationSizeChange, + }, + ref, + ) => { + const videoRef = useRef(null); + const containerRef = useRef(null); + const appRef = useRef(null); + const videoSpriteRef = useRef(null); + const videoContainerRef = useRef(null); + const cameraContainerRef = useRef(null); + const timeUpdateAnimationRef = useRef(null); + const [pixiReady, setPixiReady] = useState(false); + const [videoReady, setVideoReady] = useState(false); + const overlayRef = useRef(null); + const focusIndicatorRef = useRef(null); + const currentTimeRef = useRef(0); + const zoomRegionsRef = useRef([]); + const selectedZoomIdRef = useRef(null); + const animationStateRef = useRef({ + scale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + }); + const blurFilterRef = useRef(null); + const isDraggingFocusRef = useRef(false); + const stageSizeRef = useRef({ width: 0, height: 0 }); + const videoSizeRef = useRef({ width: 0, height: 0 }); + const baseScaleRef = useRef(1); + const baseOffsetRef = useRef({ x: 0, y: 0 }); + const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); + const maskGraphicsRef = useRef(null); + const isPlayingRef = useRef(isPlaying); + const isSeekingRef = useRef(false); + const allowPlaybackRef = useRef(false); + 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); + + const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { + return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + }, []); + + const updateOverlayForRegion = useCallback( + (region: ZoomRegion | null, focusOverride?: ZoomFocus) => { + const overlayEl = overlayRef.current; + const indicatorEl = focusIndicatorRef.current; + + if (!overlayEl || !indicatorEl) { + return; + } + + // Update stage size from overlay dimensions + const stageWidth = overlayEl.clientWidth; + const stageHeight = overlayEl.clientHeight; + if (stageWidth && stageHeight) { + stageSizeRef.current = { width: stageWidth, height: stageHeight }; + } + + updateOverlayIndicator({ + overlayEl, + indicatorEl, + region, + focusOverride, + videoSize: videoSizeRef.current, + baseScale: baseScaleRef.current, + isPlaying: isPlayingRef.current, + }); + }, + [], + ); + + const layoutVideoContent = useCallback(() => { + const container = containerRef.current; + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const maskGraphics = maskGraphicsRef.current; + const videoElement = videoRef.current; + const cameraContainer = cameraContainerRef.current; + + if ( + !container || + !app || + !videoSprite || + !maskGraphics || + !videoElement || + !cameraContainer + ) { + return; + } + + // Lock video dimensions on first layout to prevent resize issues + if ( + !lockedVideoDimensionsRef.current && + videoElement.videoWidth > 0 && + videoElement.videoHeight > 0 + ) { + lockedVideoDimensionsRef.current = { + width: videoElement.videoWidth, + height: videoElement.videoHeight, + }; + } + + const result = layoutVideoContentUtil({ + container, + app, + videoSprite, + maskGraphics, + videoElement, + cropRegion, + lockedVideoDimensions: lockedVideoDimensionsRef.current, + borderRadius, + padding, + }); + + if (result) { + stageSizeRef.current = result.stageSize; + videoSizeRef.current = result.videoSize; + baseScaleRef.current = result.baseScale; + baseOffsetRef.current = result.baseOffset; + baseMaskRef.current = result.maskRect; + cropBoundsRef.current = result.cropBounds; + + // Reset camera container to identity + cameraContainer.scale.set(1); + cameraContainer.position.set(0, 0); + + const selectedId = selectedZoomIdRef.current; + const activeRegion = selectedId + ? (zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null) + : null; + + updateOverlayForRegion(activeRegion); + } + }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); + + useEffect(() => { + layoutVideoContentRef.current = layoutVideoContent; + }, [layoutVideoContent]); + + const selectedZoom = useMemo(() => { + if (!selectedZoomId) return null; + return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; + }, [zoomRegions, selectedZoomId]); + + useImperativeHandle(ref, () => ({ + video: videoRef.current, + app: appRef.current, + videoSprite: videoSpriteRef.current, + videoContainer: videoContainerRef.current, + containerRef, + play: async () => { + const vid = videoRef.current; + if (!vid) return; + try { + allowPlaybackRef.current = true; + await vid.play(); + } catch (error) { + allowPlaybackRef.current = false; + throw error; + } + }, + pause: () => { + const video = videoRef.current; + allowPlaybackRef.current = false; + if (!video) { + return; + } + video.pause(); + }, + })); + + const updateFocusFromClientPoint = (clientX: number, clientY: number) => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; + + const regionId = selectedZoomIdRef.current; + if (!regionId) return; + + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region) return; + + const rect = overlayEl.getBoundingClientRect(); + const stageWidth = rect.width; + const stageHeight = rect.height; + + if (!stageWidth || !stageHeight) { + return; + } + + stageSizeRef.current = { width: stageWidth, height: stageHeight }; + + const localX = clientX - rect.left; + const localY = clientY - rect.top; + + const unclampedFocus: ZoomFocus = { + cx: clamp01(localX / stageWidth), + cy: clamp01(localY / stageHeight), + }; + const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + + onZoomFocusChange(region.id, clampedFocus); + updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); + }; + + const handleOverlayPointerDown = (event: React.PointerEvent) => { + if (isPlayingRef.current) return; + const regionId = selectedZoomIdRef.current; + if (!regionId) return; + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region) return; + onSelectZoom(region.id); + event.preventDefault(); + isDraggingFocusRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const handleOverlayPointerMove = (event: React.PointerEvent) => { + if (!isDraggingFocusRef.current) return; + event.preventDefault(); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const endFocusDrag = (event: React.PointerEvent) => { + if (!isDraggingFocusRef.current) return; + isDraggingFocusRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer may already be released. + } + onZoomFocusDragEnd?.(); + }; + + const handleOverlayPointerUp = (event: React.PointerEvent) => { + endFocusDrag(event); + }; + + const handleOverlayPointerLeave = (event: React.PointerEvent) => { + endFocusDrag(event); + }; + + useEffect(() => { + zoomRegionsRef.current = zoomRegions; + }, [zoomRegions]); + + useEffect(() => { + selectedZoomIdRef.current = selectedZoomId; + }, [selectedZoomId]); + + useEffect(() => { + isPlayingRef.current = isPlaying; + }, [isPlaying]); + + useEffect(() => { + trimRegionsRef.current = trimRegions; + }, [trimRegions]); + + useEffect(() => { + speedRegionsRef.current = speedRegions; + }, [speedRegions]); + + useEffect(() => { + motionBlurEnabledRef.current = motionBlurEnabled; + }, [motionBlurEnabled]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const cameraContainer = cameraContainerRef.current; + const video = videoRef.current; + + if (!app || !cameraContainer || !video) return; + + const tickerWasStarted = app.ticker?.started || false; + if (tickerWasStarted && app.ticker) { + app.ticker.stop(); + } + + const wasPlaying = !video.paused; + if (wasPlaying) { + video.pause(); + } + + animationStateRef.current = { + scale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + }; + + if (blurFilterRef.current) { + blurFilterRef.current.blur = 0; + } + + requestAnimationFrame(() => { + const container = cameraContainerRef.current; + const videoStage = videoContainerRef.current; + const sprite = videoSpriteRef.current; + const currentApp = appRef.current; + if (!container || !videoStage || !sprite || !currentApp) { + return; + } + + container.scale.set(1); + container.position.set(0, 0); + videoStage.scale.set(1); + videoStage.position.set(0, 0); + sprite.scale.set(1); + sprite.position.set(0, 0); + + layoutVideoContent(); + + applyZoomTransform({ + cameraContainer: container, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + motionIntensity: 0, + isPlaying: false, + motionBlurEnabled: motionBlurEnabledRef.current, + }); + + requestAnimationFrame(() => { + const finalApp = appRef.current; + if (wasPlaying && video) { + video.play().catch(() => { + // Ignore autoplay restoration failures. + }); + } + if (tickerWasStarted && finalApp?.ticker) { + finalApp.ticker.start(); + } + }); + }); + }, [pixiReady, videoReady, layoutVideoContent]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + const container = containerRef.current; + if (!container) return; + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + layoutVideoContent(); + }); + + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [pixiReady, videoReady, layoutVideoContent]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + updateOverlayForRegion(selectedZoom); + }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); + + useEffect(() => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; + if (!selectedZoom) { + overlayEl.style.cursor = "default"; + overlayEl.style.pointerEvents = "none"; + return; + } + overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab"; + overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; + }, [selectedZoom, isPlaying]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let mounted = true; + let app: Application | null = null; + + (async () => { + app = new Application(); + + await app.init({ + width: container.clientWidth, + height: container.clientHeight, + backgroundAlpha: 0, + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + }); + + app.ticker.maxFPS = 60; + + if (!mounted) { + app.destroy(true, { children: true, texture: true, textureSource: true }); + return; + } + + appRef.current = app; + container.appendChild(app.canvas); + + // Camera container - this will be scaled/positioned for zoom + const cameraContainer = new Container(); + cameraContainerRef.current = cameraContainer; + app.stage.addChild(cameraContainer); + + // Video container - holds the masked video sprite + const videoContainer = new Container(); + videoContainerRef.current = videoContainer; + cameraContainer.addChild(videoContainer); + + setPixiReady(true); + })(); + + return () => { + mounted = false; + setPixiReady(false); + if (app && app.renderer) { + app.destroy(true, { children: true, texture: true, textureSource: true }); + } + appRef.current = null; + cameraContainerRef.current = null; + videoContainerRef.current = null; + videoSpriteRef.current = null; + }; + }, []); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.pause(); + video.currentTime = 0; + allowPlaybackRef.current = false; + lockedVideoDimensionsRef.current = null; + setVideoReady(false); + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + }, []); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const video = videoRef.current; + const app = appRef.current; + const videoContainer = videoContainerRef.current; + + if (!video || !app || !videoContainer) return; + if (video.videoWidth === 0 || video.videoHeight === 0) return; + + const source = VideoSource.from(video); + if ("autoPlay" in source) { + (source as { autoPlay?: boolean }).autoPlay = false; + } + if ("autoUpdate" in source) { + (source as { autoUpdate?: boolean }).autoUpdate = true; + } + const videoTexture = Texture.from(source); + + const videoSprite = new Sprite(videoTexture); + videoSpriteRef.current = videoSprite; + + const maskGraphics = new Graphics(); + videoContainer.addChild(videoSprite); + videoContainer.addChild(maskGraphics); + videoContainer.mask = maskGraphics; + maskGraphicsRef.current = maskGraphics; + + animationStateRef.current = { + scale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + }; + + const blurFilter = new BlurFilter(); + blurFilter.quality = 3; + blurFilter.resolution = app.renderer.resolution; + blurFilter.blur = 0; + videoContainer.filters = [blurFilter]; + blurFilterRef.current = blurFilter; + + layoutVideoContent(); + video.pause(); + + const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({ + video, + isSeekingRef, + isPlayingRef, + allowPlaybackRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + trimRegionsRef, + speedRegionsRef, + }); + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handlePause); + video.addEventListener("seeked", handleSeeked); + video.addEventListener("seeking", handleSeeking); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handlePause); + video.removeEventListener("seeked", handleSeeked); + video.removeEventListener("seeking", handleSeeking); + + if (timeUpdateAnimationRef.current) { + cancelAnimationFrame(timeUpdateAnimationRef.current); + } + + if (videoSprite) { + videoContainer.removeChild(videoSprite); + videoSprite.destroy(); + } + if (maskGraphics) { + videoContainer.removeChild(maskGraphics); + maskGraphics.destroy(); + } + videoContainer.mask = null; + maskGraphicsRef.current = null; + if (blurFilterRef.current) { + videoContainer.filters = []; + blurFilterRef.current.destroy(); + blurFilterRef.current = null; + } + videoTexture.destroy(true); + + videoSpriteRef.current = null; + }; + }, [pixiReady, videoReady, onTimeUpdate, onPlayStateChange, layoutVideoContent]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const videoContainer = videoContainerRef.current; + if (!app || !videoSprite || !videoContainer) return; + + const applyTransform = (motionIntensity: number) => { + const cameraContainer = cameraContainerRef.current; + if (!cameraContainer) return; + + const state = animationStateRef.current; + + applyZoomTransform({ + cameraContainer, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + focusX: state.focusX, + focusY: state.focusY, + motionIntensity, + isPlaying: isPlayingRef.current, + motionBlurEnabled: motionBlurEnabledRef.current, + }); + }; + + const ticker = () => { + const { region, strength } = findDominantRegion( + zoomRegionsRef.current, + currentTimeRef.current, + ); + + const defaultFocus = DEFAULT_FOCUS; + let targetScaleFactor = 1; + let targetFocus = defaultFocus; + + // If a zoom is selected but video is not playing, show default unzoomed view + // (the overlay will show where the zoom will be) + const selectedId = selectedZoomIdRef.current; + const hasSelectedZoom = selectedId !== null; + const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; + + if (region && strength > 0 && !shouldShowUnzoomedView) { + const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; + const regionFocus = clampFocusToStage(region.focus, region.depth); + + // Interpolate scale and focus based on region strength + targetScaleFactor = 1 + (zoomScale - 1) * strength; + targetFocus = { + cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, + cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, + }; + } + + const state = animationStateRef.current; + + const prevScale = state.scale; + const prevFocusX = state.focusX; + const prevFocusY = state.focusY; + + const scaleDelta = targetScaleFactor - state.scale; + const focusXDelta = targetFocus.cx - state.focusX; + const focusYDelta = targetFocus.cy - state.focusY; + + let nextScale = prevScale; + let nextFocusX = prevFocusX; + let nextFocusY = prevFocusY; + + if (Math.abs(scaleDelta) > MIN_DELTA) { + nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; + } else { + nextScale = targetScaleFactor; + } + + if (Math.abs(focusXDelta) > MIN_DELTA) { + nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; + } else { + nextFocusX = targetFocus.cx; + } + + if (Math.abs(focusYDelta) > MIN_DELTA) { + nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; + } else { + nextFocusY = targetFocus.cy; + } + + state.scale = nextScale; + state.focusX = nextFocusX; + state.focusY = nextFocusY; + + const motionIntensity = Math.max( + Math.abs(nextScale - prevScale), + Math.abs(nextFocusX - prevFocusX), + Math.abs(nextFocusY - prevFocusY), + ); + + applyTransform(motionIntensity); + }; + + app.ticker.add(ticker); + return () => { + if (app && app.ticker) { + app.ticker.remove(ticker); + } + }; + }, [pixiReady, videoReady, clampFocusToStage]); + + const handleLoadedMetadata = (e: React.SyntheticEvent) => { + const video = e.currentTarget; + onDurationChange(video.duration); + video.currentTime = 0; + video.pause(); + allowPlaybackRef.current = false; + currentTimeRef.current = 0; + + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + + const waitForRenderableFrame = () => { + const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0; + const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + if (hasDimensions && hasData) { + videoReadyRafRef.current = null; + setVideoReady(true); + return; + } + videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); + }; + + videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); + }; + + const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + + useEffect(() => { + let mounted = true; + (async () => { + try { + if (!wallpaper) { + const def = await getAssetPath("wallpapers/wallpaper1.jpg"); + if (mounted) setResolvedWallpaper(def); + return; + } + + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + if (mounted) setResolvedWallpaper(wallpaper); + return; + } + + // If it's a data URL (custom uploaded image), use as-is + if (wallpaper.startsWith("data:")) { + if (mounted) setResolvedWallpaper(wallpaper); + return; + } + + // If it's an absolute web/http or file path, use as-is + if ( + wallpaper.startsWith("http") || + wallpaper.startsWith("file://") || + wallpaper.startsWith("/") + ) { + // If it's an absolute server path (starts with '/'), resolve via getAssetPath as well + if (wallpaper.startsWith("/")) { + const rel = wallpaper.replace(/^\//, ""); + const p = await getAssetPath(rel); + if (mounted) setResolvedWallpaper(p); + return; + } + if (mounted) setResolvedWallpaper(wallpaper); + return; + } + const p = await getAssetPath(wallpaper.replace(/^\//, "")); + if (mounted) setResolvedWallpaper(p); + } catch (_err) { + if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg"); + } + })(); + return () => { + mounted = false; + }; + }, [wallpaper]); + + useEffect(() => { + return () => { + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + }; + }, []); + + const isImageUrl = Boolean( + resolvedWallpaper && + (resolvedWallpaper.startsWith("file://") || + resolvedWallpaper.startsWith("http") || + resolvedWallpaper.startsWith("/") || + resolvedWallpaper.startsWith("data:")), + ); + const backgroundStyle = isImageUrl + ? { backgroundImage: `url(${resolvedWallpaper || ""})` } + : { background: resolvedWallpaper || "" }; + + return ( +
+ {/* Background layer - always render as DOM element with blur */} +
+
0 + ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` + : "none", + }} + /> + {/* Only render overlay after PIXI and video are fully initialized */} + {pixiReady && videoReady && ( +
+
+ {(() => { + const filtered = (annotationRegions || []).filter((annotation) => { + if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number") + return false; + + if (annotation.id === selectedAnnotationId) return true; + + const timeMs = Math.round(currentTime * 1000); + return timeMs >= annotation.startMs && timeMs <= annotation.endMs; + }); + + // Sort by z-index (lowest to highest) so higher z-index renders on top + const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + + // Handle click-through cycling: when clicking same annotation, cycle to next + const handleAnnotationClick = (clickedId: string) => { + if (!onSelectAnnotation) return; + + // If clicking on already selected annotation and there are multiple overlapping + if (clickedId === selectedAnnotationId && sorted.length > 1) { + // Find current index and cycle to next + const currentIndex = sorted.findIndex((a) => a.id === clickedId); + const nextIndex = (currentIndex + 1) % sorted.length; + onSelectAnnotation(sorted[nextIndex].id); + } else { + // First click or clicking different annotation + onSelectAnnotation(clickedId); + } + }; + + return sorted.map((annotation) => ( + onAnnotationPositionChange?.(id, position)} + onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} + onClick={handleAnnotationClick} + zIndex={annotation.zIndex} + isSelectedBoost={annotation.id === selectedAnnotationId} + /> + )); + })()} +
+ )} +
+ ); + }, +); + +VideoPlayback.displayName = "VideoPlayback"; export default VideoPlayback; diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index eb5873a..3616f18 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -187,12 +187,12 @@ export default function TimelineWrapper({ // Drag/resize tooltip (direct DOM updates, no re-renders) const tooltipRef = useRef(null); - const formatTooltipMs = (ms: number) => { + const formatTooltipMs = useCallback((ms: number) => { const s = ms / 1000; const min = Math.floor(s / 60); const sec = s % 60; return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`; - }; + }, []); const showTooltip = useCallback( (span: { start: number; end: number } | null, screenX?: number) => { @@ -213,7 +213,7 @@ export default function TimelineWrapper({ } } }, - [], + [formatTooltipMs], ); const onDragStart = useCallback( diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index cdeae4a..acbe25a 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { useEffect(() => { getIsMac() .then(setIsMac) - .catch(() => {}); + .catch(() => { + // Keep default non-mac fallback if detection fails. + }); window.electronAPI .getShortcuts?.() @@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { setShortcuts(mergeWithDefaults(saved as Partial)); } }) - .catch(() => {}); + .catch(() => { + // Keep default shortcuts if persisted settings can't be loaded. + }); }, []); const persistShortcuts = useCallback( diff --git a/src/hooks/useAudioLevelMeter.ts b/src/hooks/useAudioLevelMeter.ts index 12ff54b..6fef297 100644 --- a/src/hooks/useAudioLevelMeter.ts +++ b/src/hooks/useAudioLevelMeter.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; export interface AudioLevelMeterOptions { enabled: boolean; @@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) { const streamRef = useRef(null); const animationFrameRef = useRef(null); + const cleanup = useCallback(() => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + if (audioContextRef.current) { + audioContextRef.current.close(); + audioContextRef.current = null; + } + analyserRef.current = null; + }, []); + useEffect(() => { if (!options.enabled) { cleanup(); @@ -85,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) { mounted = false; cleanup(); }; - }, [options.enabled, options.deviceId, options.smoothingFactor]); - - const cleanup = () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); - streamRef.current = null; - } - if (audioContextRef.current) { - audioContextRef.current.close(); - audioContextRef.current = null; - } - analyserRef.current = null; - }; + }, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]); return { level }; } diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index b7db51c..38a1d4a 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -1,114 +1,124 @@ import { useCallback, useRef, useState } from "react"; -import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CropRegion } from "@/components/video-editor/types"; +import type { + AnnotationRegion, + CropRegion, + SpeedRegion, + TrimRegion, + ZoomRegion, +} from "@/components/video-editor/types"; import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; // Undoable state — selection IDs are intentionally excluded (undoing a // selection change would feel surprising to the user). export interface EditorState { - zoomRegions: ZoomRegion[]; - trimRegions: TrimRegion[]; - speedRegions: SpeedRegion[]; - annotationRegions: AnnotationRegion[]; - cropRegion: CropRegion; - wallpaper: string; - shadowIntensity: number; - showBlur: boolean; - motionBlurEnabled: boolean; - borderRadius: number; - padding: number; - aspectRatio: AspectRatio; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + cropRegion: CropRegion; + wallpaper: string; + shadowIntensity: number; + showBlur: boolean; + motionBlurEnabled: boolean; + borderRadius: number; + padding: number; + aspectRatio: AspectRatio; } export const INITIAL_EDITOR_STATE: EditorState = { - zoomRegions: [], - trimRegions: [], - speedRegions: [], - annotationRegions: [], - cropRegion: DEFAULT_CROP_REGION, - wallpaper: "/wallpapers/wallpaper1.jpg", - shadowIntensity: 0, - showBlur: false, - motionBlurEnabled: false, - borderRadius: 0, - padding: 50, - aspectRatio: "16:9", + zoomRegions: [], + trimRegions: [], + speedRegions: [], + annotationRegions: [], + cropRegion: DEFAULT_CROP_REGION, + wallpaper: "/wallpapers/wallpaper1.jpg", + shadowIntensity: 0, + showBlur: false, + motionBlurEnabled: false, + borderRadius: 0, + padding: 50, + aspectRatio: "16:9", }; type StateUpdate = Partial | ((prev: EditorState) => Partial); interface History { - past: EditorState[]; - present: EditorState; - future: EditorState[]; + past: EditorState[]; + present: EditorState; + future: EditorState[]; } const MAX_HISTORY = 80; function resolve(present: EditorState, update: StateUpdate): EditorState { - const partial = typeof update === "function" ? update(present) : update; - return { ...present, ...partial }; + const partial = typeof update === "function" ? update(present) : update; + return { ...present, ...partial }; } function withCheckpoint(history: History, newPresent: EditorState): History { - return { - past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present], - present: newPresent, - future: [], - }; + return { + past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present], + present: newPresent, + future: [], + }; } export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { - const [history, setHistory] = useState({ past: [], present: initial, future: [] }); + const [history, setHistory] = useState({ past: [], present: initial, future: [] }); - // Tracks whether a live-update series (e.g. slider drag) is in progress. - // The first updateState call saves the pre-interaction state as a checkpoint. - const dirtyRef = useRef(false); + // Tracks whether a live-update series (e.g. slider drag) is in progress. + // The first updateState call saves the pre-interaction state as a checkpoint. + const dirtyRef = useRef(false); - const pushState = useCallback((update: StateUpdate) => { - setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update))); - dirtyRef.current = false; - }, []); + const pushState = useCallback((update: StateUpdate) => { + setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update))); + dirtyRef.current = false; + }, []); - const updateState = useCallback((update: StateUpdate) => { - const isFirst = !dirtyRef.current; - dirtyRef.current = true; - setHistory((prev) => { - const next = resolve(prev.present, update); - return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next }; - }); - }, []); + const updateState = useCallback((update: StateUpdate) => { + const isFirst = !dirtyRef.current; + dirtyRef.current = true; + setHistory((prev) => { + const next = resolve(prev.present, update); + return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next }; + }); + }, []); - const commitState = useCallback(() => { - dirtyRef.current = false; - }, []); + const commitState = useCallback(() => { + dirtyRef.current = false; + }, []); - const undo = useCallback(() => { - setHistory((prev) => { - if (!prev.past.length) return prev; - const previous = prev.past[prev.past.length - 1]; - return { past: prev.past.slice(0, -1), present: previous, future: [prev.present, ...prev.future] }; - }); - dirtyRef.current = false; - }, []); + const undo = useCallback(() => { + setHistory((prev) => { + if (!prev.past.length) return prev; + const previous = prev.past[prev.past.length - 1]; + return { + past: prev.past.slice(0, -1), + present: previous, + future: [prev.present, ...prev.future], + }; + }); + dirtyRef.current = false; + }, []); - const redo = useCallback(() => { - setHistory((prev) => { - if (!prev.future.length) return prev; - const [next, ...remainingFuture] = prev.future; - return { past: [...prev.past, prev.present], present: next, future: remainingFuture }; - }); - dirtyRef.current = false; - }, []); + const redo = useCallback(() => { + setHistory((prev) => { + if (!prev.future.length) return prev; + const [next, ...remainingFuture] = prev.future; + return { past: [...prev.past, prev.present], present: next, future: remainingFuture }; + }); + dirtyRef.current = false; + }, []); - return { - state: history.present, - pushState, - updateState, - commitState, - undo, - redo, - canUndo: history.past.length > 0, - canRedo: history.future.length > 0, - }; + return { + state: history.present, + pushState, + updateState, + commitState, + undo, + redo, + canUndo: history.past.length > 0, + canRedo: history.future.length > 0, + }; } diff --git a/src/hooks/useMicrophoneDevices.ts b/src/hooks/useMicrophoneDevices.ts index 20ce4a5..4f40fac 100644 --- a/src/hooks/useMicrophoneDevices.ts +++ b/src/hooks/useMicrophoneDevices.ts @@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) { mounted = false; navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); }; - }, [enabled]); + }, [enabled, selectedDeviceId]); return { devices, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index e777410..2ac2e3b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -104,7 +104,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current = null; } if (mixingContext.current) { - mixingContext.current.close().catch(() => {}); + mixingContext.current.close().catch(() => { + // Ignore close errors during recorder teardown. + }); mixingContext.current = null; } mediaRecorder.current.stop(); @@ -142,7 +144,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current = null; } if (mixingContext.current) { - mixingContext.current.close().catch(() => {}); + mixingContext.current.close().catch(() => { + // Ignore close errors during cleanup. + }); mixingContext.current = null; } }; @@ -171,7 +175,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (systemAudioEnabled) { try { - screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({ + screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, @@ -179,20 +183,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, }, video: videoConstraints, - }); + } as unknown as MediaStreamConstraints); } catch (audioErr) { console.warn("System audio capture failed, falling back to video-only:", audioErr); toast.error("System audio not available. Recording without system audio."); - screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({ + screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, - }); + } as unknown as MediaStreamConstraints); } } else { - screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({ + screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, - }); + } as unknown as MediaStreamConstraints); } screenStream.current = screenMediaStream; @@ -354,7 +358,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current = null; } if (mixingContext.current) { - mixingContext.current.close().catch(() => {}); + mixingContext.current.close().catch(() => { + // Ignore close errors during error recovery. + }); mixingContext.current = null; } } diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index a696ff1..121983c 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -10,11 +10,8 @@ export async function getAssetPath(relativePath: string): Promise { return `/${relativePath.replace(/^\//, "")}`; } - if ( - (window as any).electronAPI && - typeof (window as any).electronAPI.getAssetBasePath === "function" - ) { - const base = await (window as any).electronAPI.getAssetBasePath(); + if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") { + const base = await window.electronAPI.getAssetBasePath(); if (base) { const normalized = base.replace(/\\/g, "/"); return `file://${normalized}/${relativePath}`; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index fe581e1..dbbb1f3 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,8 +1,17 @@ -import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; +import { + Application, + BlurFilter, + Container, + Graphics, + Sprite, + Texture, + type TextureSourceLike, +} from "pixi.js"; import type { AnnotationRegion, CropRegion, SpeedRegion, + ZoomDepth, ZoomRegion, } from "@/components/video-editor/types"; import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; @@ -42,6 +51,14 @@ interface AnimationState { focusY: number; } +interface LayoutCache { + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; + maskRect: { x: number; y: number; width: number; height: number }; +} + // Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export. export class FrameRenderer { @@ -49,7 +66,7 @@ export class FrameRenderer { private cameraContainer: Container | null = null; private videoContainer: Container | null = null; private videoSprite: Sprite | null = null; - private backgroundSprite: Sprite | null = null; + private backgroundSprite: HTMLCanvasElement | null = null; private maskGraphics: Graphics | null = null; private blurFilter: BlurFilter | null = null; private shadowCanvas: HTMLCanvasElement | null = null; @@ -58,7 +75,7 @@ export class FrameRenderer { private compositeCtx: CanvasRenderingContext2D | null = null; private config: FrameRenderConfig; private animationState: AnimationState; - private layoutCache: any = null; + private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; constructor(config: FrameRenderConfig) { @@ -263,7 +280,7 @@ export class FrameRenderer { } // Store the background canvas for compositing - this.backgroundSprite = bgCanvas as any; + this.backgroundSprite = bgCanvas; } async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { @@ -275,13 +292,13 @@ export class FrameRenderer { // Create or update video sprite from VideoFrame if (!this.videoSprite) { - const texture = Texture.from(videoFrame as any); + const texture = Texture.from(videoFrame as unknown as TextureSourceLike); this.videoSprite = new Sprite(texture); this.videoContainer.addChild(this.videoSprite); } else { // Destroy old texture to avoid memory leaks, then create new one const oldTexture = this.videoSprite.texture; - const newTexture = Texture.from(videoFrame as any); + const newTexture = Texture.from(videoFrame as unknown as TextureSourceLike); this.videoSprite.texture = newTexture; oldTexture.destroy(true); } @@ -298,12 +315,17 @@ export class FrameRenderer { maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity); } + const layoutCache = this.layoutCache; + if (!layoutCache) { + throw new Error("Layout cache not initialized"); + } + // Apply transform once with maximum motion intensity from all ticks applyZoomTransform({ cameraContainer: this.cameraContainer, blurFilter: this.blurFilter, - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, + stageSize: layoutCache.stageSize, + baseMask: layoutCache.maskRect, zoomScale: this.animationState.scale, focusX: this.animationState.focusX, focusY: this.animationState.focusY, @@ -411,10 +433,10 @@ export class FrameRenderer { private clampFocusToStage( focus: { cx: number; cy: number }, - depth: number, + depth: ZoomDepth, ): { cx: number; cy: number } { if (!this.layoutCache) return focus; - return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize); + return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize); } private updateAnimationState(timeMs: number): number { @@ -493,7 +515,7 @@ export class FrameRenderer { // Step 1: Draw background layer (with optional blur, not affected by zoom) if (this.backgroundSprite) { - const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement; + const bgCanvas = this.backgroundSprite; if (this.config.showBlur) { ctx.save(); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 806ebe8..583b133 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -219,7 +219,11 @@ export class VideoExporter { // Capture decoder config metadata from encoder output if (meta?.decoderConfig?.description && !videoDescription) { const desc = meta.decoderConfig.description; - videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any)); + if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) { + videoDescription = new Uint8Array(desc); + } else if (ArrayBuffer.isView(desc)) { + videoDescription = new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength); + } this.videoDescription = videoDescription; } // Capture colorSpace from encoder metadata if provided diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 66a1b7e..69af499 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,127 +1,148 @@ export const SHORTCUT_ACTIONS = [ - 'addZoom', - 'addTrim', - 'addSpeed', - 'addAnnotation', - 'addKeyframe', - 'deleteSelected', - 'playPause', + "addZoom", + "addTrim", + "addSpeed", + "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; + key: string; + /** Maps to Cmd on macOS, Ctrl on Windows/Linux */ + ctrl?: boolean; + shift?: boolean; + alt?: boolean; } export type ShortcutsConfig = Record; export interface FixedShortcut { - label: string; - display: string; - bindings: ShortcutBinding[]; + label: string; + display: string; + bindings: ShortcutBinding[]; } export const FIXED_SHORTCUTS: FixedShortcut[] = [ - { label: 'Undo', display: 'Ctrl + Z', bindings: [{ key: 'z', ctrl: true }] }, - { label: 'Redo', display: 'Ctrl + Shift + Z / Ctrl + Y', bindings: [{ key: 'z', ctrl: true, shift: true }, { key: 'y', ctrl: true }] }, - { 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: [] }, + { label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] }, + { + label: "Redo", + display: "Ctrl + Shift + Z / Ctrl + Y", + bindings: [ + { key: "z", ctrl: true, shift: true }, + { key: "y", ctrl: true }, + ], + }, + { 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 }; + | { 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 - ); + 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, + 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; + 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' }, - addSpeed: { key: 's' }, - addAnnotation: { key: 'a' }, - addKeyframe: { key: 'f' }, - deleteSelected: { key: 'd', ctrl: true }, - playPause: { key: ' ' }, + addZoom: { key: "z" }, + addTrim: { key: "t" }, + addSpeed: { key: "s" }, + addAnnotation: { key: "a" }, + addKeyframe: { key: "f" }, + deleteSelected: { key: "d", ctrl: true }, + playPause: { key: " " }, }; export const SHORTCUT_LABELS: Record = { - addZoom: 'Add Zoom', - addTrim: 'Add Trim', - addSpeed: 'Add Speed', - addAnnotation: 'Add Annotation', - addKeyframe: 'Add Keyframe', - deleteSelected: 'Delete Selected', - playPause: 'Play / Pause', + addZoom: "Add Zoom", + addTrim: "Add Trim", + addSpeed: "Add Speed", + addAnnotation: "Add Annotation", + addKeyframe: "Add Keyframe", + deleteSelected: "Delete Selected", + playPause: "Play / Pause", }; export function matchesShortcut( - e: KeyboardEvent, - binding: ShortcutBinding, - isMacPlatform: boolean, + e: KeyboardEvent, + binding: ShortcutBinding, + isMacPlatform: boolean, ): boolean { - if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false; + 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; + 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; + return true; } const KEY_LABELS: Record = { - ' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc', - 'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→', + " ": "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(' + '); + 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; + const merged = { ...DEFAULT_SHORTCUTS }; + for (const action of SHORTCUT_ACTIONS) { + if (partial[action]) { + merged[action] = partial[action] as ShortcutBinding; + } + } + return merged; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f9eb124..f26a67a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -20,8 +20,8 @@ interface Window { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; - selectSource: (source: any) => Promise; - getSelectedSource: () => Promise; + selectSource: (source: ProcessedDesktopSource) => Promise; + getSelectedSource: () => Promise; storeRecordedVideo: ( videoData: ArrayBuffer, fileName: string,