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 8ffe412..a812b9a 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,24 +1,10 @@ import { HelpCircle, Settings2 } from "lucide-react"; -import { useEffect, useState } from "react"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; -import { formatShortcut } from "@/utils/platformUtils"; +import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; export function KeyboardShortcutsHelp() { const { shortcuts, isMac, openConfig } = useShortcuts(); - const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", - zoom: "Ctrl + Scroll", - }); - - useEffect(() => { - Promise.all([ - formatShortcut(["shift", "mod", "Scroll"]), - formatShortcut(["mod", "Scroll"]), - ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); - }, []); - return (
@@ -47,25 +33,20 @@ export function KeyboardShortcutsHelp() {
))} -
-
- Pan Timeline - - {scrollLabels.pan} - -
-
- Zoom Timeline - - {scrollLabels.zoom} - -
-
- Cycle Annotations - - Tab - -
+
+ {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 3e80507..15be76e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -38,6 +38,7 @@ import type { AnnotationRegion, AnnotationType, CropRegion, + FigureData, PlaybackSpeed, ZoomDepth, } from "./types"; @@ -86,14 +87,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; @@ -118,7 +122,7 @@ interface SettingsPanelProps { onAnnotationContentChange?: (id: string, content: string) => void; onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; onAnnotationStyleChange?: (id: string, style: Partial) => void; - onAnnotationFigureDataChange?: (id: string, figureData: any) => void; + onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; onAnnotationDelete?: (id: string) => void; selectedSpeedId?: string | null; selectedSpeedValue?: PlaybackSpeed | null; @@ -148,14 +152,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, @@ -196,7 +203,7 @@ export function SettingsPanel({ try { const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p))); if (mounted) setWallpaperPaths(resolved); - } catch { + } catch (_err) { if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`)); } })(); @@ -480,6 +487,7 @@ export function SettingsPanel({ onShadowChange?.(values[0])} + onValueCommit={() => onShadowCommit?.()} min={0} max={1} step={0.01} @@ -494,6 +502,7 @@ export function SettingsPanel({ onBorderRadiusChange?.(values[0])} + onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} step={0.5} @@ -508,6 +517,7 @@ export function SettingsPanel({ onPaddingChange?.(values[0])} + onValueCommit={() => onPaddingCommit?.()} min={0} max={100} step={1} @@ -620,7 +630,9 @@ export function SettingsPanel({ s.replace(/^file:\/\//, "").replace(/^\//, ""); if (clean(selected).endsWith(clean(path))) return true; if (clean(path).endsWith(clean(selected))) return true; - } catch {} + } catch { + // Best-effort comparison; fallback to strict match. + } return false; })(); return ( 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 2702100..fe8b521 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -4,7 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { getAssetPath } from "@/lib/assetPath"; +import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { calculateOutputDimensions, type ExportFormat, @@ -18,7 +18,7 @@ import { VideoExporter, } from "@/lib/exporter"; import { matchesShortcut } from "@/lib/shortcuts"; -import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { @@ -28,19 +28,16 @@ import { normalizeProjectEditor, toFileUrl, validateProjectData, - WALLPAPER_PATHS, } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { type AnnotationRegion, - type CropRegion, type CursorTelemetryPoint, clampFocusToDepth, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, - DEFAULT_CROP_REGION, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -55,6 +52,31 @@ import { 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); @@ -63,33 +85,20 @@ export default function VideoEditor() { 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 [speedRegions, setSpeedRegions] = useState([]); const [selectedSpeedId, setSelectedSpeedId] = 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); const [gifLoop, setGifLoop] = useState(true); const [gifSizePreset, setGifSizePreset] = useState("medium"); - const [exportedFilePath, setExportedFilePath] = useState(undefined); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const videoPlaybackRef = useRef(null); @@ -99,78 +108,85 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const nextAnnotationIdRef = useRef(1); - const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order + const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); - const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { - if (!validateProjectData(candidate)) { - return false; - } + 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); + 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); + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); - setCurrentProjectPath(path ?? null); + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(path ?? null); - setWallpaper(normalizedEditor.wallpaper); - setShadowIntensity(normalizedEditor.shadowIntensity); - setShowBlur(normalizedEditor.showBlur); - setMotionBlurEnabled(normalizedEditor.motionBlurEnabled); - setBorderRadius(normalizedEditor.borderRadius); - setPadding(normalizedEditor.padding); - setCropRegion(normalizedEditor.cropRegion); - setZoomRegions(normalizedEditor.zoomRegions); - setTrimRegions(normalizedEditor.trimRegions); - setSpeedRegions(normalizedEditor.speedRegions); - setAnnotationRegions(normalizedEditor.annotationRegions); - setAspectRatio(normalizedEditor.aspectRatio); - setExportQuality(normalizedEditor.exportQuality); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); + 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); + 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; + 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; - }, []); + setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); + return true; + }, + [pushState], + ); const currentProjectSnapshot = useMemo(() => { const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); @@ -346,18 +362,19 @@ export default function VideoEditor() { ], ); - // Sync unsaved changes state to main process for close dialog useEffect(() => { - window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges); - }, [hasUnsavedChanges]); + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasUnsavedChanges) { + return; + } - // Handle save request from main process before close - useEffect(() => { - const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => { - await saveProject(false); - }); - return () => cleanup(); - }, [saveProject]); + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasUnsavedChanges]); const handleSaveProject = useCallback(async () => { await saveProject(false); @@ -431,25 +448,6 @@ export default function VideoEditor() { }; }, [videoPath]); - // 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; - }; - }, []); - function togglePlayPause() { const playback = videoPlaybackRef.current; const video = playback?.video; @@ -489,126 +487,128 @@ export default function VideoEditor() { } }, []); - 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 }, - }; - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(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), - }; - setZoomRegions((prev) => [...prev, newRegion]); - setSelectedZoomId(id); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, []); + const handleZoomSuggested = useCallback( + (span: Span, focus: ZoomFocus) => { + const id = `zoom-${nextZoomIdRef.current++}`; + const newRegion: ZoomRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + depth: DEFAULT_ZOOM_DEPTH, + focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), + }; + 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), - }; - setTrimRegions((prev) => [...prev, newRegion]); - setSelectedTrimId(id); - setSelectedZoomId(null); - setSelectedAnnotationId(null); - }, []); + 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) => { - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); + 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) => { - setTrimRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); + 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], + ); - const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { - setZoomRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - focus: clampFocusToDepth(focus, region.depth), - } - : region, - ), - ); - }, []); + // 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; - 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 handleSelectSpeed = useCallback((id: string | null) => { @@ -620,209 +620,238 @@ export default function VideoEditor() { } }, []); - const handleSpeedAdded = useCallback((span: Span) => { - const id = `speed-${nextSpeedIdRef.current++}`; - const newRegion: SpeedRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - speed: DEFAULT_PLAYBACK_SPEED, - }; - setSpeedRegions((prev) => [...prev, newRegion]); - setSelectedSpeedId(id); - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedAnnotationId(null); - }, []); + const 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) => { - setSpeedRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); + 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) => { - 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) => (region.id === selectedSpeedId ? { ...region, speed } : 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++}`; - const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order - 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, - }; - setAnnotationRegions((prev) => [...prev, newRegion]); - setSelectedAnnotationId(id); - setSelectedZoomId(null); - setSelectedTrimId(null); - }, []); + 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) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); + 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) => { - 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) => { - 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; - }); - }, []); - - const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion["type"]) => { - setAnnotationRegions((prev) => { - const updated = prev.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") { - updatedRegion.content = region.imageContent || ""; - } else if (type === "figure") { - updatedRegion.content = ""; - if (!region.figureData) { - updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; + 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], + ); - return updatedRegion; - }); - return updated; - }); - }, []); + 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) => { - 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)), - ); - }, []); + 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 }) => { - 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; - } + 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 (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (isInput) { return; } 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 }); - }, [shortcuts, isMac]); + }, [undo, redo, shortcuts, isMac]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { @@ -925,9 +954,8 @@ export default function VideoEditor() { if (saveResult.canceled) { toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { - showExportSuccessToast(saveResult.path); - setExportedFilePath(saveResult.path); + } 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"); @@ -1052,9 +1080,8 @@ export default function VideoEditor() { if (saveResult.canceled) { toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { - showExportSuccessToast(saveResult.path); - setExportedFilePath(saveResult.path); + } 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"); @@ -1153,35 +1180,9 @@ export default function VideoEditor() { setIsExporting(false); setExportProgress(null); setExportError(null); - setExportedFilePath(undefined); } }, []); - const handleExportDialogClose = useCallback(() => { - setShowExportDialog(false); - setExportedFilePath(undefined); - }, []); - - const showExportSuccessToast = useCallback((filePath: string) => { - toast.success(`Exported successfully to ${filePath}`, { - action: { - label: "Show in Folder", - onClick: async () => { - try { - const result = await window.electronAPI.revealInFolder(filePath); - if (!result.success) { - const errorMessage = - result.error || result.message || "Failed to reveal item in folder."; - toast.error(errorMessage); - } - } catch (err) { - toast.error(`Error revealing in folder: ${String(err)}`); - } - }, - }, - }); - }, []); - if (loading) { return (
@@ -1253,6 +1254,7 @@ export default function VideoEditor() { selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} onZoomFocusChange={handleZoomFocusChange} + onZoomFocusDragEnd={commitState} isPlaying={isPlaying} showShadow={shadowIntensity > 0} shadowIntensity={shadowIntensity} @@ -1332,7 +1334,7 @@ export default function VideoEditor() { selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} aspectRatio={aspectRatio} - onAspectRatioChange={setAspectRatio} + onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })} />
@@ -1342,7 +1344,7 @@ export default function VideoEditor() { {/* Right section: settings panel */} pushState({ wallpaper: w })} selectedZoomDepth={ selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } @@ -1352,17 +1354,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} @@ -1406,13 +1411,12 @@ export default function VideoEditor() { setShowExportDialog(false)} progress={exportProgress} isExporting={isExporting} error={exportError} onCancel={handleCancelExport} exportFormat={exportFormat} - exportedFilePath={exportedFilePath} /> ); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 50a95f5..c4598ac 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -50,6 +50,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; @@ -92,6 +93,7 @@ const VideoPlayback = forwardRef( selectedZoomId, onSelectZoom, onZoomFocusChange, + onZoomFocusDragEnd, isPlaying, showShadow, shadowIntensity = 0, @@ -339,7 +341,10 @@ const VideoPlayback = forwardRef( isDraggingFocusRef.current = false; try { event.currentTarget.releasePointerCapture(event.pointerId); - } catch {} + } catch { + // Pointer may already be released. + } + onZoomFocusDragEnd?.(); }; const handleOverlayPointerUp = (event: React.PointerEvent) => { @@ -437,14 +442,16 @@ const VideoPlayback = forwardRef( requestAnimationFrame(() => { const finalApp = appRef.current; if (wasPlaying && video) { - video.play().catch(() => {}); + video.play().catch(() => { + // Ignore autoplay restoration failures. + }); } if (tickerWasStarted && finalApp?.ticker) { finalApp.ticker.start(); } }); }); - }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); + }, [pixiReady, videoReady, layoutVideoContent]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -549,7 +556,7 @@ const VideoPlayback = forwardRef( cancelAnimationFrame(videoReadyRafRef.current); videoReadyRafRef.current = null; } - }, [videoPath]); + }, []); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -644,7 +651,7 @@ const VideoPlayback = forwardRef( videoSpriteRef.current = null; }; - }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); + }, [pixiReady, videoReady, onTimeUpdate, onPlayStateChange, layoutVideoContent]); useEffect(() => { if (!pixiReady || !videoReady) return; @@ -827,7 +834,7 @@ const VideoPlayback = forwardRef( } const p = await getAssetPath(wallpaper.replace(/^\//, "")); if (mounted) setResolvedWallpaper(p); - } catch { + } catch (_err) { if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg"); } })(); 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 new file mode 100644 index 0000000..38a1d4a --- /dev/null +++ b/src/hooks/useEditorHistory.ts @@ -0,0 +1,124 @@ +import { useCallback, useRef, useState } from "react"; +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; +} + +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", +}; + +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, + }; +} 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 9640c61..69af499 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -27,6 +27,15 @@ export interface FixedShortcut { } 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", 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,