import { useState, useCallback } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Download, Loader2, Zap, Cog, Info, Volume2 } from 'lucide-react'; import type { ExportOptions } from '../types/project'; export default function ExportDialog() { const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } = useEditorStore(); const hasCuts = cutRanges.length > 0; // Detect if input is audio-only by its extension const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']); const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : ''; const isAudioOnly = videoPath ? audioExtensions.has(inputExt) : false; const [options, setOptions] = useState & { normalizeAudio: boolean; normalizeTarget: number }>({ mode: isAudioOnly ? 'reencode' : 'fast', resolution: '1080p', format: isAudioOnly ? 'wav' : 'mp4', enhanceAudio: false, captions: 'none', normalizeAudio: false, normalizeTarget: -14, }); const [exportError, setExportError] = useState(null); const HANDLE_EXPORT_filters = useCallback(() => { const ext = options.format; const nameMap: Record = { mp4: 'MP4', mov: 'MOV', webm: 'WebM', wav: 'WAV Audio', }; return [{ name: nameMap[ext] || 'File', extensions: [ext] }]; }, [options.format]); const handleExport = useCallback(async () => { if (!videoPath) return; const defaultExt = options.format === 'wav' ? 'wav' : 'mp4'; const outputPath = await window.electronAPI?.saveFile({ defaultPath: videoPath.replace(/\.[^.]+$/, `_edited.${defaultExt}`), filters: HANDLE_EXPORT_filters(), }); if (!outputPath) return; setExporting(true, 0); setExportError(null); try { const keepSegments = getKeepSegments(); const deletedSet = new Set(); for (const range of cutRanges) { for (let i = 0; i < words.length; i++) { const w = words[i]; if (w.start >= range.start && w.end <= range.end) deletedSet.add(i); } } // Map frontend camelCase gain/speed fields to backend snake_case const backendGainRanges = gainRanges.map((r) => ({ start: r.start, end: r.end, gain_db: r.gainDb, })); const backendSpeedRanges = speedRanges.map((r) => ({ start: r.start, end: r.end, speed: r.speed, })); const res = await fetch(`${backendUrl}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input_path: videoPath, output_path: outputPath, keep_segments: keepSegments, mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined, gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined, speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined, global_gain_db: globalGainDb, words: options.captions !== 'none' ? words : undefined, deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined, mode: options.mode, resolution: options.resolution, format: options.format, enhanceAudio: options.enhanceAudio, normalize_loudness: options.normalizeAudio, normalize_target_lufs: options.normalizeTarget, captions: options.captions, }), }); if (!res.ok) { let detail = res.statusText; try { const body = await res.json(); if (body?.detail) detail = String(body.detail); } catch { // Keep statusText fallback when response body is not JSON. } throw new Error(`Export failed: ${detail}`); } setExporting(false, 100); } catch (err) { console.error('Export error:', err); setExportError(err instanceof Error ? err.message : 'Export failed'); setExporting(false); } }, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters]); return (

Export Video

{/* Mode */}
Export Mode
setOptions((o) => ({ ...o, mode: 'fast' }))} icon={} title="Fast" desc="Stream copy, seconds" /> setOptions((o) => ({ ...o, mode: 'reencode' }))} icon={} title="Re-encode" desc="Custom quality, slower" />
{/* Resolution (only for re-encode) */} {options.mode === 'reencode' && ( setOptions((o) => ({ ...o, resolution: v as ExportOptions['resolution'] }))} options={[ { value: '720p', label: '720p (HD)' }, { value: '1080p', label: '1080p (Full HD)' }, { value: '4k', label: '4K (Ultra HD)' }, ]} /> )} {/* Format */} setOptions((o) => ({ ...o, format: v as ExportOptions['format'] }))} options={[ { value: 'mp4', label: 'MP4 (H.264)' }, { value: 'mov', label: 'MOV (QuickTime)' }, { value: 'webm', label: 'WebM (VP9)' }, ...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []), ]} /> {/* Audio normalization — integrated into export */}
{options.normalizeAudio && (
)}
{/* Audio enhancement */} {/* Captions */} setOptions((o) => ({ ...o, captions: v as ExportOptions['captions'] }))} options={[ { value: 'none', label: 'No captions' }, { value: 'burn-in', label: 'Burn-in (permanent)' }, { value: 'sidecar', label: 'Sidecar SRT file' }, ]} /> {/* Export button */} {exportError && (
{exportError}
)} {options.mode === 'fast' && !hasCuts && (

Fast mode uses stream copy — no quality loss, exports in seconds.

)} {options.mode === 'fast' && hasCuts && (
Word-level cuts require re-encoding for frame-accurate output. Export will automatically use re-encode mode. This takes longer but ensures your cuts are precise.
)}
); } function ModeCard({ active, onClick, icon, title, desc, }: { active: boolean; onClick: () => void; icon: React.ReactNode; title: string; desc: string; }) { return ( ); } function SelectField({ label, value, onChange, options, }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }>; }) { return (
); }