import { useState, useCallback } from 'react'; import { useEditorStore } from '../store/editorStore'; import { Download, Loader2, Zap, Cog, Info, Volume2, FileText } 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 [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt'); const [isTranscribingTranscript, setIsTranscribingTranscript] = useState(false); const handleTranscriptExport = useCallback(async () => { if (!videoPath || words.length === 0) return; const defaultExt = transcriptFormat === 'srt' ? 'srt' : 'txt'; const outputPath = await window.electronAPI?.saveFile({ defaultPath: videoPath.replace(/\.[^.]+$/, `_transcript.${defaultExt}`), filters: transcriptFormat === 'srt' ? [{ name: 'SRT Subtitles', extensions: ['srt'] }] : [{ name: 'Text File', extensions: ['txt'] }], }); if (!outputPath) return; setIsTranscribingTranscript(true); try { // Compute deleted word set const deletedSet = new Set(); for (const range of cutRanges) { for (let i = 0; i < words.length; i++) { if (words[i].start >= range.start && words[i].end <= range.end) { deletedSet.add(i); } } } // Generate content entirely on the frontend — no backend needed let content: string; if (transcriptFormat === 'srt') { const lines: string[] = []; let counter = 1; const activeWords: Array<[number, typeof words[0]]> = []; for (let i = 0; i < words.length; i++) { if (!deletedSet.has(i)) activeWords.push([i, words[i]]); } const wordsPerLine = 8; for (let ci = 0; ci < activeWords.length; ci += wordsPerLine) { const chunk = activeWords.slice(ci, ci + wordsPerLine); if (chunk.length === 0) continue; const startTime = chunk[0][1].start; const endTime = chunk[chunk.length - 1][1].end; const fmt = (s: number) => { const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); const ms = Math.floor((s % 1) * 1000); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')},${String(ms).padStart(3, '0')}`; }; lines.push(String(counter)); lines.push(`${fmt(startTime)} --> ${fmt(endTime)}`); lines.push(chunk.map(([, w]) => w.word).join(' ')); lines.push(''); counter++; } content = lines.join('\n'); } else { // Plain text const activeWords: string[] = []; for (let i = 0; i < words.length; i++) { if (!deletedSet.has(i)) activeWords.push(words[i].word); } content = activeWords.join(' '); } // Write directly via Tauri — instant, no backend round-trip await window.electronAPI?.writeFile(outputPath, content); } catch (err) { console.error('Transcript export error:', err); setExportError(err instanceof Error ? err.message : 'Transcript export failed'); } finally { setIsTranscribingTranscript(false); } }, [videoPath, words, cutRanges, transcriptFormat]); 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' }, ]} /> {/* Transcript-only export */}

Export Transcript Only

Export the edited transcript as plain text or SRT without rendering video.

{/* Export video 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 (
); }