import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { X, Download, FolderOpen, Film, Package, Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react' import { Button } from './ui/button' import { DEFAULT_SUBTITLE_STYLE } from '../types/project-model' import type { Track, TimelineClip } from '../types/project-model' import { selectActiveTimeline, selectAssets, selectClipPathFromAssets, selectClips, selectShowExportModal, selectSubtitles, selectTracks, } from '../views/editor/editor-selectors' import { useEditorActions, useEditorStore } from '../views/editor/editor-store' interface ExportModalProps { projectName: string } type ExportStatus = 'idle' | 'exporting' | 'done' | 'error' type ExportCodec = 'h264' | 'prores' | 'vp9' interface ExportSettings { codec: ExportCodec width: number height: number fps: number quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9 } const CODEC_INFO: Record = { h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' }, prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' }, vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' }, } const RESOLUTIONS = [ { label: '4K (3840 x 2160)', width: 3840, height: 2160 }, { label: '1080p (1920 x 1080)', width: 1920, height: 1080 }, { label: '720p (1280 x 720)', width: 1280, height: 720 }, ] const FRAME_RATES = [24, 25, 30, 60] const PRORES_PROFILES = [ { value: 0, label: 'Proxy' }, { value: 1, label: 'LT' }, { value: 2, label: 'Standard' }, { value: 3, label: 'HQ' }, ] const LETTERBOX_RATIO_MAP: Record = { '2.35:1': 2.35, '2.39:1': 2.39, '2.76:1': 2.76, '1.85:1': 1.85, '4:3': 4 / 3, } // Generate FCPXML for Premiere / DaVinci function generateFCPXML( clips: TimelineClip[], tracks: Track[], projectName: string, timelineName: string, fps: number = 24 ): string { const frameDuration = `${Math.round(100 * fps)}/${100 * fps}s` const totalDuration = clips.reduce((max, c) => Math.max(max, c.startTime + c.duration), 0) const totalFrames = Math.ceil(totalDuration * fps) const assetEntries: string[] = [] const seenAssets = new Set() for (const clip of clips) { const assetId = clip.assetId || clip.id if (seenAssets.has(assetId)) continue seenAssets.add(assetId) const assetPath = clip.asset?.path || '' const dur = clip.asset?.duration || clip.duration const durFrames = Math.ceil(dur * fps) const format = clip.type === 'audio' ? 'audio' : 'video' assetEntries.push( ` ` ) } const trackGroups: Map = new Map() for (const clip of clips) { if (!trackGroups.has(clip.trackIndex)) trackGroups.set(clip.trackIndex, []) trackGroups.get(clip.trackIndex)!.push(clip) } const laneXml: string[] = [] const sortedTrackIndices = [...trackGroups.keys()].sort((a, b) => a - b) for (const trackIdx of sortedTrackIndices) { const trackClips = trackGroups.get(trackIdx)!.sort((a, b) => a.startTime - b.startTime) const clipElements: string[] = [] for (const clip of trackClips) { const assetId = clip.assetId || clip.id const startFrame = Math.round(clip.startTime * fps) const durFrames = Math.round(clip.duration * fps) const trimStartFrame = Math.round(clip.trimStart * fps) const name = clip.asset?.prompt?.slice(0, 60) || clip.importedName || 'Clip' let clipXml = ` ` clipElements.push(clipXml) } const trackName = tracks[trackIdx]?.name || `Track ${trackIdx + 1}` laneXml.push( ` \n` + clipElements.join('\n') ) } return ` ${assetEntries.join('\n')} ${laneXml.join('\n')} ` } function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function ExportModal({ projectName }: ExportModalProps) { const { closeExportModal } = useEditorActions() const isOpen = useEditorStore(selectShowExportModal) const timeline = useEditorStore(selectActiveTimeline) const assets = useEditorStore(selectAssets) const clips = useEditorStore(selectClips) const tracks = useEditorStore(selectTracks) const subtitles = useEditorStore(selectSubtitles) const exportClips = useMemo(() => ( clips .filter(clip => clip.type === 'video' || clip.type === 'image' || clip.type === 'audio') .filter(clip => tracks[clip.trackIndex]?.enabled !== false) .map(clip => ({ path: selectClipPathFromAssets(assets, clip), type: clip.type, startTime: clip.startTime, duration: clip.duration, trimStart: clip.trimStart, speed: clip.speed || 1, reversed: clip.reversed || false, flipH: clip.flipH || false, flipV: clip.flipV || false, opacity: clip.opacity ?? 100, trackIndex: clip.trackIndex, muted: clip.muted || false, volume: clip.volume ?? 1, })) ), [assets, clips, tracks]) const subtitleData = useMemo(() => ( subtitles.map(subtitle => { const track = tracks[subtitle.trackIndex] return { text: subtitle.text, startTime: subtitle.startTime, endTime: subtitle.endTime, style: { ...DEFAULT_SUBTITLE_STYLE, ...(track?.subtitleStyle || {}), ...(subtitle.style || {}), }, } }) ), [subtitles, tracks]) const letterbox = useMemo(() => { const adjustmentClips = clips.filter( clip => clip.type === 'adjustment' && clip.letterbox?.enabled && tracks[clip.trackIndex]?.enabled !== false, ) if (adjustmentClips.length === 0) return null const best = adjustmentClips.reduce((currentBest, candidate) => ( candidate.duration > currentBest.duration ? candidate : currentBest )) const config = best.letterbox! return { ratio: config.aspectRatio === 'custom' ? (config.customRatio || 2.35) : (LETTERBOX_RATIO_MAP[config.aspectRatio] || 2.35), color: config.color || '#000000', opacity: (config.opacity ?? 100) / 100, } }, [clips, tracks]) const [exportStatus, setExportStatus] = useState('idle') const [exportType, setExportType] = useState<'package' | 'video' | null>(null) const [exportProgress, setExportProgress] = useState(0) const [exportError, setExportError] = useState(null) const [exportPath, setExportPath] = useState(null) const [exportFrameInfo, setExportFrameInfo] = useState('') const abortRef = useRef(false) // Export settings const [settings, setSettings] = useState({ codec: 'h264', width: 1920, height: 1080, fps: 24, quality: 18, // CRF 18 for h264 }) const [burnSubtitles, setBurnSubtitles] = useState(true) const closeModal = useCallback(() => { closeExportModal() }, [closeExportModal]) const hasSubtitles = subtitleData.length > 0 useEffect(() => { if (!isOpen) return setExportStatus('idle') setExportType(null) setExportProgress(0) setExportError(null) setExportPath(null) setExportFrameInfo('') abortRef.current = false }, [isOpen]) // Update quality default when codec changes const handleCodecChange = useCallback((codec: ExportCodec) => { let quality = 18 if (codec === 'prores') quality = 3 // HQ profile if (codec === 'vp9') quality = 8 // 8 Mbps setSettings(prev => ({ ...prev, codec, quality })) }, []) const handleExportPackage = useCallback(async () => { if (!timeline) return setExportType('package') setExportStatus('exporting') setExportProgress(0) setExportError(null) try { const filePath = await window.electronAPI?.showSaveDialog({ title: 'Export FCPXML Package', defaultPath: `${projectName}_${timeline.name}.fcpxml`, filters: [ { name: 'Final Cut Pro XML', extensions: ['fcpxml'] }, { name: 'All Files', extensions: ['*'] }, ], }) if (!filePath) { setExportStatus('idle') return } setExportProgress(50) const xml = generateFCPXML(clips, tracks, projectName, timeline.name) const result = await window.electronAPI?.saveFile({ filePath, data: xml }) if (result?.success) { setExportProgress(100) setExportPath(filePath) setExportStatus('done') } else { throw new Error(result && !result.success ? result.error : 'Failed to save file') } } catch (err) { setExportError(String(err)) setExportStatus('error') } }, [clips, tracks, timeline, projectName]) const handleExportVideo = useCallback(async () => { if (!timeline || clips.length === 0) return setExportType('video') setExportStatus('exporting') setExportProgress(0) setExportError(null) setExportFrameInfo('Preparing...') abortRef.current = false try { const codecInfo = CODEC_INFO[settings.codec] const filePath = await window.electronAPI?.showSaveDialog({ title: `Export ${codecInfo.label}`, defaultPath: `${projectName}_${timeline.name}.${codecInfo.ext}`, filters: [ { name: codecInfo.filterName, extensions: [codecInfo.ext] }, { name: 'All Files', extensions: ['*'] }, ], }) if (!filePath) { setExportStatus('idle') return } // Build clip data for ffmpeg native export (video/image + audio clips) setExportFrameInfo('Starting ffmpeg...') const result = await window.electronAPI?.exportNative({ clips: exportClips, outputPath: filePath, codec: settings.codec, width: settings.width, height: settings.height, fps: settings.fps, quality: settings.quality, letterbox: letterbox || undefined, subtitles: burnSubtitles && subtitleData.length > 0 ? subtitleData : undefined, }) if (result && !result.success) { throw new Error(result.error) } setExportProgress(100) setExportPath(filePath) setExportFrameInfo('Export complete') setExportStatus('done') } catch (err) { setExportError(String(err)) setExportStatus('error') } }, [burnSubtitles, exportClips, letterbox, projectName, settings, subtitleData, timeline]) const handleCancel = useCallback(async () => { abortRef.current = true window.electronAPI?.exportCancel({ sessionId: 'current' }).catch(() => {}) setExportStatus('idle') }, []) return (
e.stopPropagation()} > {/* Header */}

Export

{/* Exporting state */} {exportStatus === 'exporting' && (
{exportType === 'package' ? 'Generating FCPXML...' : 'Rendering video...'}

{exportProgress}% complete

{exportFrameInfo &&

{exportFrameInfo}

}
{exportType === 'video' && ( )}
)} {/* Done state */} {exportStatus === 'done' && (

Export complete

{exportPath}

{exportFrameInfo &&

{exportFrameInfo}

}
)} {/* Error state */} {exportStatus === 'error' && (

Export failed

{exportError}

)} {/* Idle state — settings */} {exportStatus === 'idle' && (
{/* Package export (compact) */} {/* Divider */}
Video Export
{/* Format selector */}
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => ( ))}
{/* Resolution & Frame rate row */}
{/* Quality */}
{settings.codec === 'h264' && (
setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} className="flex-1 h-1.5 accent-blue-500 cursor-pointer" // Note: lower CRF = higher quality (inverted display) /> {settings.quality <= 18 ? 'High' : settings.quality <= 23 ? 'Medium' : 'Low'} ({settings.quality})
)} {settings.codec === 'prores' && (
{PRORES_PROFILES.map(p => ( ))}
)} {settings.codec === 'vp9' && (
setSettings(prev => ({ ...prev, quality: parseInt(e.target.value) }))} className="flex-1 h-1.5 accent-blue-500 cursor-pointer" /> {settings.quality} Mbps
)}
{/* Options */} {hasSubtitles && (
Options
)} {/* Export button */} {clips.length === 0 && (

Add clips to the timeline to export.

)}
)}
) }