414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
|
|
|
|
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<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
|
|
const [shadowIntensity, setShadowIntensity] = useState(0);
|
|
const [showBlur, setShowBlur] = useState(false);
|
|
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
|
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
|
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
const [showExportDialog, setShowExportDialog] = useState(false);
|
|
|
|
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
|
const nextZoomIdRef = useRef(1);
|
|
const exporterRef = useRef<VideoExporter | null>(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 (
|
|
<div className="flex items-center justify-center h-screen bg-background">
|
|
<div className="text-foreground">Loading video...</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-background">
|
|
<div className="text-destructive">{error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
|
|
<div
|
|
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
|
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
|
>
|
|
<div className="flex-1" />
|
|
</div>
|
|
|
|
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
|
{/* Left Column - Video & Timeline */}
|
|
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
|
<PanelGroup direction="vertical" className="gap-3">
|
|
{/* Top section: video preview and controls */}
|
|
<Panel defaultSize={45} minSize={40}>
|
|
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
|
|
{/* Video preview */}
|
|
<div className="w-full flex justify-center items-center" style={{ flex: '1 1 auto', margin: '6px 0 0' }}>
|
|
<div className="relative" style={{ width: 'auto', height: '100%', aspectRatio: '16/9', maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}>
|
|
<VideoPlayback
|
|
ref={videoPlaybackRef}
|
|
videoPath={videoPath || ''}
|
|
onDurationChange={setDuration}
|
|
onTimeUpdate={setCurrentTime}
|
|
onPlayStateChange={setIsPlaying}
|
|
onError={setError}
|
|
wallpaper={wallpaper}
|
|
zoomRegions={zoomRegions}
|
|
selectedZoomId={selectedZoomId}
|
|
onSelectZoom={handleSelectZoom}
|
|
onZoomFocusChange={handleZoomFocusChange}
|
|
isPlaying={isPlaying}
|
|
showShadow={shadowIntensity > 0}
|
|
shadowIntensity={shadowIntensity}
|
|
showBlur={showBlur}
|
|
cropRegion={cropRegion}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* Playback controls */}
|
|
<div className="w-full flex justify-center items-center" style={{ height: '48px', flexShrink: 0, padding: '6px 12px', margin: '6px 0 6px 0' }}>
|
|
<div style={{ width: '100%', maxWidth: '700px' }}>
|
|
<PlaybackControls
|
|
isPlaying={isPlaying}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
onTogglePlayPause={togglePlayPause}
|
|
onSeek={handleSeek}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<PanelResizeHandle className="h-3 bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full mx-4 flex items-center justify-center">
|
|
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
|
</PanelResizeHandle>
|
|
|
|
{/* Timeline section */}
|
|
<Panel defaultSize={55} minSize={20}>
|
|
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
|
<TimelineEditor
|
|
videoDuration={duration}
|
|
currentTime={currentTime}
|
|
onSeek={handleSeek}
|
|
zoomRegions={zoomRegions}
|
|
onZoomAdded={handleZoomAdded}
|
|
onZoomSpanChange={handleZoomSpanChange}
|
|
onZoomDelete={handleZoomDelete}
|
|
selectedZoomId={selectedZoomId}
|
|
onSelectZoom={handleSelectZoom}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
</PanelGroup>
|
|
</div>
|
|
|
|
{/* Right section: settings panel */}
|
|
<SettingsPanel
|
|
selected={wallpaper}
|
|
onWallpaperChange={setWallpaper}
|
|
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => 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}
|
|
/>
|
|
</div>
|
|
|
|
<Toaster theme="dark" className="pointer-events-auto" />
|
|
|
|
<ExportDialog
|
|
isOpen={showExportDialog}
|
|
onClose={() => setShowExportDialog(false)}
|
|
progress={exportProgress}
|
|
isExporting={isExporting}
|
|
error={exportError}
|
|
onCancel={handleCancelExport}
|
|
/>
|
|
</div>
|
|
);
|
|
} |