Add GIF export feature to video editor

Implements GIF export alongside MP4, including new export types, a GIF exporter module, UI components for format selection and GIF options, and integration into the export dialog and video editor. Adds property-based and unit tests for GIF export correctness, updates dependencies to include gif.js and related types, and refines Electron save dialog to support GIF files.
This commit is contained in:
Nikhil Solanki
2025-12-25 01:50:02 +05:30
parent 2ca99136ba
commit 6e6ecba172
21 changed files with 3381 additions and 495 deletions
+91 -16
View File
@@ -10,6 +10,7 @@ interface ExportDialogProps {
isExporting: boolean;
error: string | null;
onCancel?: () => void;
exportFormat?: 'mp4' | 'gif';
}
export function ExportDialog({
@@ -19,6 +20,7 @@ export function ExportDialog({
isExporting,
error,
onCancel,
exportFormat = 'mp4',
}: ExportDialogProps) {
const [showSuccess, setShowSuccess] = useState(false);
@@ -35,6 +37,32 @@ export function ExportDialog({
if (!isOpen) return null;
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
// Determine if we're in the compiling phase (frames done but still exporting)
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
const isFinalizing = progress?.phase === 'finalizing';
const renderProgress = progress?.renderProgress;
// Get status message based on phase
const getStatusMessage = () => {
if (error) return 'Please try again';
if (isCompiling || isFinalizing) {
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
}
return 'Compiling GIF... This may take a while';
}
return 'This may take a moment...';
};
// Get title based on phase
const getTitle = () => {
if (error) return 'Export Failed';
if (isCompiling || isFinalizing) return 'Compiling GIF';
return `Exporting ${formatLabel}`;
};
return (
<>
<div
@@ -42,7 +70,7 @@ export function ExportDialog({
onClick={isExporting ? undefined : onClose}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{showSuccess ? (
<>
@@ -51,7 +79,7 @@ export function ExportDialog({
</div>
<div>
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-sm text-slate-400">Your video is ready</span>
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
</div>
</>
) : (
@@ -67,10 +95,10 @@ export function ExportDialog({
)}
<div>
<span className="text-xl font-bold text-slate-200 block">
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
{getTitle()}
</span>
<span className="text-sm text-slate-400">
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
{getStatusMessage()}
</span>
</div>
</>
@@ -103,23 +131,68 @@ export function ExportDialog({
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>Progress</span>
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
`${renderProgress}%`
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
</span>
)
) : (
`${progress.percentage.toFixed(0)}%`
)}
</span>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
{isCompiling || isFinalizing ? (
// Show render progress if available, otherwise animated indeterminate bar
renderProgress !== undefined && renderProgress > 0 ? (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${renderProgress}%` }}
/>
) : (
<div className="h-full w-full relative overflow-hidden">
<div
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
style={{
animation: 'indeterminate 1.5s ease-in-out infinite',
}}
/>
<style>{`
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
`}</style>
</div>
)
) : (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
Processing
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? 'Status' : 'Format'}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
</div>
</div>
</div>
@@ -140,7 +213,9 @@ export function ExportDialog({
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
<p className="text-lg text-slate-200 font-medium">
{formatLabel} saved successfully!
</p>
</div>
)}
</div>
@@ -0,0 +1,77 @@
import { Film, Image } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ExportFormat } from '@/lib/exporter/types';
interface FormatSelectorProps {
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: 'mp4',
label: 'MP4 Video',
description: 'High quality video file',
icon: <Film className="w-5 h-5" />,
},
{
value: 'gif',
label: 'GIF Animation',
description: 'Animated image for sharing',
icon: <Image className="w-5 h-5" />,
},
];
export function FormatSelector({
selectedFormat,
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
isSelected
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,110 @@
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
interface GifOptionsPanelProps {
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
}
export function GifOptionsPanel({
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
}: GifOptionsPanelProps) {
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Frame Rate
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Output Size
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
Output: {outputDimensions.width} × {outputDimensions.height}px
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
<p className="text-xs text-slate-500">GIF will play continuously</p>
</div>
<Switch
checked={loop}
onCheckedChange={onLoopChange}
disabled={disabled}
/>
</div>
</div>
);
}
+154 -39
View File
@@ -7,14 +7,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star } from "lucide-react";
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportQuality } from "@/lib/exporter";
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
@@ -70,6 +71,16 @@ interface SettingsPanelProps {
videoElement?: HTMLVideoElement | null;
exportQuality?: ExportQuality;
onExportQualityChange?: (quality: ExportQuality) => void;
// Export format settings
exportFormat?: ExportFormat;
onExportFormatChange?: (format: ExportFormat) => void;
gifFrameRate?: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
gifLoop?: boolean;
onGifLoopChange?: (loop: boolean) => void;
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -116,6 +127,15 @@ export function SettingsPanel({
videoElement,
exportQuality = 'good',
onExportQualityChange,
exportFormat = 'mp4',
onExportFormatChange,
gifFrameRate = 15,
onGifFrameRateChange,
gifLoop = true,
onGifLoopChange,
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -550,43 +570,138 @@ export function SettingsPanel({
</Tabs>
<div className="mt-4 pt-4 border-t border-white/5">
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
{/* Export Quality Button Group */}
<div className="mb-2.5 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'medium'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'good'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'source'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
High
</button>
{/* Format Selection */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-slate-400 uppercase tracking-wider">Export Format</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => onExportFormatChange?.('mp4')}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
exportFormat === 'mp4'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Film className="w-5 h-5" />
<span className="text-xs font-medium">MP4</span>
</button>
<button
onClick={() => onExportFormatChange?.('gif')}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all",
exportFormat === 'gif'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Image className="w-5 h-5" />
<span className="text-xs font-medium">GIF</span>
</button>
</div>
</div>
{/* MP4 Quality Options */}
{exportFormat === 'mp4' && (
<>
<div className="mb-2 text-xs font-medium text-slate-400">Export Quality</div>
<div className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'medium'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'good'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"py-2 rounded-lg transition-all text-xs font-medium",
exportQuality === 'source'
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
High
</button>
</div>
</>
)}
{/* GIF Options */}
{exportFormat === 'gif' && (
<div className="mb-4 space-y-3">
{/* Frame Rate */}
<div>
<div className="mb-1.5 text-xs font-medium text-slate-400">Frame Rate</div>
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-5 h-auto rounded-xl">
{GIF_FRAME_RATES.map((rate) => (
<button
key={rate.value}
onClick={() => onGifFrameRateChange?.(rate.value)}
className={cn(
"py-1.5 rounded-lg transition-all text-xs font-medium",
gifFrameRate === rate.value
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
{rate.value}
</button>
))}
</div>
</div>
{/* Size Preset */}
<div>
<div className="mb-1.5 text-xs font-medium text-slate-400">Output Size</div>
<div className="bg-white/5 border border-white/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl">
{Object.entries(GIF_SIZE_PRESETS).map(([key, preset]) => (
<button
key={key}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"py-1.5 rounded-lg transition-all text-xs font-medium",
gifSizePreset === key
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200"
)}
>
{key === 'original' ? 'Orig' : key.charAt(0).toUpperCase() + key.slice(1, 3)}
</button>
))}
</div>
<div className="mt-1 text-[10px] text-slate-500">
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-1">
<span className="text-xs font-medium text-slate-200">Loop Animation</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
className="data-[state=checked]:bg-[#34B27B]"
/>
</div>
</div>
)}
<Button
type="button"
@@ -595,7 +710,7 @@ export function SettingsPanel({
className="w-full py-6 text-lg font-semibold flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-5 h-5" />
<span>Export Video</span>
<span>Export {exportFormat === 'gif' ? 'GIF' : 'Video'}</span>
</Button>
<div className="flex gap-2 mt-4">
<button
+231 -124
View File
@@ -28,7 +28,7 @@ import {
type CropRegion,
type FigureData,
} from "./types";
import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter";
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
@@ -61,6 +61,10 @@ export default function VideoEditor() {
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>('medium');
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -434,7 +438,7 @@ export default function VideoEditor() {
}
}, [selectedAnnotationId, annotationRegions]);
const handleExport = useCallback(async () => {
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
toast.error('No video loaded');
return;
@@ -446,7 +450,42 @@ export default function VideoEditor() {
return;
}
// Build export settings from current state
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS);
const settings: ExportSettings = {
format: exportFormat,
quality: exportFormat === 'mp4' ? exportQuality : undefined,
gifConfig: exportFormat === 'gif' ? {
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
} : undefined,
};
setShowExportDialog(true);
setExportError(null);
// Start export immediately
handleExport(settings);
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset]);
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
setIsExporting(true);
setExportProgress(null);
setExportError(null);
@@ -468,138 +507,187 @@ export default function VideoEditor() {
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;
if (settings.format === 'gif' && settings.gifConfig) {
// GIF Export
const gifExporter = new GifExporter({
videoUrl: videoPath,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
loop: settings.gifConfig.loop,
sizePreset: settings.gifConfig.sizePreset,
wallpaper,
zoomRegions,
trimRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
borderRadius,
padding,
videoPadding: padding,
cropRegion,
annotationRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
});
exporterRef.current = gifExporter as unknown as VideoExporter;
const result = await gifExporter.export();
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}`);
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.cancelled) {
toast.info('Export cancelled');
} else if (saveResult.success) {
toast.success(`GIF exported successfully to ${saveResult.path}`);
} else {
setExportError(saveResult.message || 'Failed to save GIF');
toast.error(saveResult.message || 'Failed to save GIF');
}
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
setExportError(result.error || 'GIF export failed');
toast.error(result.error || 'GIF export failed');
}
} else {
setExportError(result.error || 'Export failed');
toast.error(result.error || 'Export failed');
// MP4 Export
const quality = settings.quality || exportQuality;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (quality === 'source') {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = quality === 'medium' ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000;
} else {
bitrate = 30_000_000;
}
}
const exporter = new VideoExporter({
videoUrl: videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
trimRegions,
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) {
@@ -613,6 +701,10 @@ export default function VideoEditor() {
} finally {
setIsExporting(false);
exporterRef.current = null;
// Reset dialog state to ensure it can be opened again on next export
// This fixes the bug where second export doesn't show save dialog
setShowExportDialog(false);
setExportProgress(null);
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
@@ -771,7 +863,21 @@ export default function VideoEditor() {
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
onExport={handleExport}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
@@ -791,6 +897,7 @@ export default function VideoEditor() {
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
exportFormat={exportFormat}
/>
</div>
);