import { useCallback, useEffect, 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 type { Span } from "dnd-timeline"; import { DEFAULT_ZOOM_DEPTH, clampFocusToDepth, DEFAULT_CROP_REGION, type ZoomDepth, type ZoomFocus, type ZoomRegion, type CropRegion, } from "./types"; import { VideoExporter, type ExportProgress } from "@/lib/exporter"; const WALLPAPER_COUNT = 20; const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); export default function VideoEditor() { 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 [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); const videoPlaybackRef = useRef(null); const nextZoomIdRef = 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; }; useEffect(() => { async function loadVideo() { try { const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const videoUrl = toFileUrl(result.path); setVideoPath(videoUrl); } else { setError('No video to load. Please record or select a video.'); } } catch (err) { setError('Error loading video: ' + String(err)); } finally { setLoading(false); } } loadVideo(); }, []); 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); }, []); 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 }, }; console.log('Zoom region added:', newRegion); setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); }, []); const handleZoomSpanChange = useCallback((id: string, span: Span) => { console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); setZoomRegions((prev) => prev.map((region) => region.id === id ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), } : region, ), ); }, []); const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { setZoomRegions((prev) => prev.map((region) => region.id === id ? { ...region, focus: clampFocusToDepth(focus, region.depth), } : region, ), ); }, []); const handleZoomDepthChange = useCallback((depth: ZoomDepth) => { if (!selectedZoomId) return; setZoomRegions((prev) => prev.map((region) => region.id === selectedZoomId ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth), } : region, ), ); }, [selectedZoomId]); const handleZoomDelete = useCallback((id: string) => { console.log('Zoom region deleted:', id); setZoomRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedZoomId === id) { setSelectedZoomId(null); } }, [selectedZoomId]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { setSelectedZoomId(null); } }, [selectedZoomId, zoomRegions]); const handleExport = useCallback(async () => { if (!videoPath) { toast.error('No video loaded'); return; } const video = videoPlaybackRef.current?.video; if (!video) { toast.error('Video not ready'); return; } setShowExportDialog(true); setIsExporting(true); setExportProgress(null); setExportError(null); try { const wasPlaying = isPlaying; if (wasPlaying) { videoPlaybackRef.current?.pause(); } // Get actual video dimensions to match recording resolution const video = videoPlaybackRef.current?.video; if (!video) { toast.error('Video not ready'); return; } const width = video.videoWidth || 1920; const height = video.videoHeight || 1080; // Calculate visually lossless bitrate matching screen recording optimization const totalPixels = width * height; let bitrate = 30_000_000; if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { bitrate = 50_000_000; } else if (totalPixels > 2560 * 1440) { bitrate = 80_000_000; } const exporter = new VideoExporter({ videoUrl: videoPath, width, height, frameRate: 60, bitrate, codec: 'avc1.640033', wallpaper, zoomRegions, showShadow: shadowIntensity > 0, shadowIntensity, showBlur, cropRegion, 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.cancelled) { toast.info('Export cancelled'); } 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; } }, [videoPath, wallpaper, zoomRegions, shadowIntensity, showBlur, cropRegion, isPlaying]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { exporterRef.current.cancel(); toast.info('Export cancelled'); 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} cropRegion={cropRegion} />
{/* Playback controls */}
{/* Timeline section */}
{/* Right section: settings panel */} z.id === selectedZoomId)?.depth : null} onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} showBlur={showBlur} onBlurChange={setShowBlur} cropRegion={cropRegion} onCropChange={setCropRegion} videoElement={videoPlaybackRef.current?.video || null} onExport={handleExport} />
setShowExportDialog(false)} progress={exportProgress} isExporting={isExporting} error={exportError} onCancel={handleCancelExport} />
); }