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, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_FIGURE_DATA, type ZoomDepth, type ZoomFocus, type ZoomRegion, type TrimRegion, type AnnotationRegion, type CropRegion, type FigureData, } from "./types"; import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter"; import { type AspectRatio, 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`); 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 [motionBlurEnabled, setMotionBlurEnabled] = useState(true); const [borderRadius, setBorderRadius] = useState(0); const [padding, setPadding] = useState(50); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = 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 videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order 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(); }, []); // 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; 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 }, }; setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); setSelectedTrimId(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), }; setTrimRegions((prev) => [...prev, newRegion]); setSelectedTrimId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); }, []); 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 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 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) => { setZoomRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedZoomId === id) { setSelectedZoomId(null); } }, [selectedZoomId]); const handleTrimDelete = useCallback((id: string) => { setTrimRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedTrimId === id) { setSelectedTrimId(null); } }, [selectedTrimId]); 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 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 handleAnnotationDelete = useCallback((id: string) => { setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } }, [selectedAnnotationId]); 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 }; } } return updatedRegion; }); return updated; }); }, []); const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => { setAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, style: { ...region.style, ...style } } : region, ), ); }, []); const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { setAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, figureData } : region, ), ); }, []); const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => { setAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, position } : region, ), ); }, []); const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => { setAnnotationRegions((prev) => prev.map((region) => region.id === id ? { ...region, size } : region, ), ); }, []); // 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(); } if (e.key === ' ' || e.code === 'Space') { // Allow space only in inputs/textareas if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } e.preventDefault(); const playback = videoPlaybackRef.current; if (playback?.video) { if (playback.video.paused) { playback.play().catch(console.error); } else { playback.pause(); } } } }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); }, []); 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]); 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 aspectRatioValue = getAspectRatioValue(aspectRatio); const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; let exportWidth: number; let exportHeight: number; let bitrate: number; if (exportQuality === '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; // Iterate down from baseWidth to find exact match 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; // Iterate down from baseHeight to find exact match 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 = exportQuality === 'medium' ? 720 : 1080; // Calculate dimensions maintaining aspect ratio exportHeight = Math.floor(targetHeight / 2) * 2; // Ensure even exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; // Ensure even // Adjust bitrate for lower resolutions const totalPixels = exportWidth * exportHeight; if (totalPixels <= 1280 * 720) { bitrate = 10_000_000; // 10 Mbps for 720p } else if (totalPixels <= 1920 * 1080) { bitrate = 20_000_000; // 20 Mbps for 1080p } else { bitrate = 30_000_000; } } // Get preview CONTAINER dimensions for scaling // Annotations render in HTML overlay matching container, not PixiJS canvas const playbackRef = videoPlaybackRef.current; const containerElement = playbackRef?.containerRef?.current; const previewWidth = containerElement?.clientWidth || 1920; const previewHeight = containerElement?.clientHeight || 1080; const exporter = new VideoExporter({ videoUrl: videoPath, width: exportWidth, height: exportHeight, frameRate: 60, bitrate, codec: 'avc1.640033', wallpaper, zoomRegions, trimRegions, 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.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, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]); 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} motionBlurEnabled={motionBlurEnabled} borderRadius={borderRadius} padding={padding} cropRegion={cropRegion} trimRegions={trimRegions} annotationRegions={annotationRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} />
{/* Playback controls */}
{/* Timeline section */}
{/* Right section: settings panel */} z.id === selectedZoomId)?.depth : null} onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} selectedTrimId={selectedTrimId} onTrimDelete={handleTrimDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} showBlur={showBlur} onBlurChange={setShowBlur} motionBlurEnabled={motionBlurEnabled} onMotionBlurChange={setMotionBlurEnabled} borderRadius={borderRadius} onBorderRadiusChange={setBorderRadius} padding={padding} onPaddingChange={setPadding} cropRegion={cropRegion} onCropChange={setCropRegion} aspectRatio={aspectRatio} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} onExport={handleExport} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationRegions} onAnnotationContentChange={handleAnnotationContentChange} onAnnotationTypeChange={handleAnnotationTypeChange} onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} />
setShowExportDialog(false)} progress={exportProgress} isExporting={isExporting} error={exportError} onCancel={handleCancelExport} />
); }