import React, { useMemo, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Film, Volume2, FileText, Package, Music, Layers, Download, Check, Globe, Zap, X, Building2, } from 'lucide-react'; import { Button, Segmented, Badge } from '../ui'; import './ExportModal.css'; /** * ExportModal — comprehensive export panel for the dubbing studio. * * Tabs: Video · Audio · Subtitles · Package. Each tab owns a small bundle of * format/track/quality controls. The shared track list at the top lets the * user pick which languages participate in whatever tab they land on — so * "export all dubs as SRT" and "mux these 3 tracks into the MP4" share one * source of truth instead of living as three separate dropdowns. */ const PRESETS = { youtube: { label: 'YouTube', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, defaultTrack: 'dub' }, archive: { label: 'Archive', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, includeAll: true }, web: { label: 'Web', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: true, dualSubs: false }, podcast: { label: 'Podcast', tab: 'audio', audioFormat: 'mp3', mp3Bitrate: '192', preserveBg: false }, studyset: { label: 'Study set',tab: 'subs', subsFormat: 'srt', subsDual: true }, }; export default function ExportModal({ open, onClose, jobId, filename, dubTracks, dubLangCode, preserveBg, setPreserveBg, defaultTrack, setDefaultTrack, exportTracks, setExportTracks, dualSubs, setDualSubs, burnSubs, setBurnSubs, API, triggerDownload, handleDubDownload, handleDubAudioDownload, handleAudioExport, segmentCount = 0, onEnterprise, }) { const [tab, setTab] = useState('video'); // ── Tab-local state (not persisted across sessions — each open is fresh). const [videoFormat, setVideoFormat] = useState('mp4'); // future: webm/mov const [audioFormat, setAudioFormat] = useState('wav'); // wav | mp3 const [mp3Bitrate, setMp3Bitrate] = useState('192'); // 128/192/256/320 const [audioBatch, setAudioBatch] = useState('each'); // each | primary — per-lang or single file const [audioPrimaryLang, setAudioPrimaryLang] = useState(dubLangCode || ''); const [subsFormat, setSubsFormat] = useState('srt'); // srt | vtt | both const [subsDual, setSubsDual] = useState(!!dualSubs); const [subsBatch, setSubsBatch] = useState('target'); // target | all-dubs // Reflect the parent's dual/burn once, then own them locally so the modal // can toy with them without committing on cancel. useEffect(() => { setSubsDual(!!dualSubs); }, [open, dualSubs]); // ── Drawer dismiss — ESC closes; click-outside closes. The drawer is a // bottom sheet (non-blocking), so background interactions stay live. const drawerRef = useRef(null); useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose?.(); } }; const onDown = (e) => { if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.(); }; window.addEventListener('keydown', onKey); window.addEventListener('mousedown', onDown); return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('mousedown', onDown); }; }, [open, onClose]); const allTracks = useMemo(() => { const out = [{ code: 'original', label: 'Original', kind: 'original' }]; (dubTracks || []).forEach(t => out.push({ code: t, label: t.toUpperCase(), kind: 'dub' })); return out; }, [dubTracks]); const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]); const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false); const selectedDubs = selectedTracks.filter(t => t.kind === 'dub'); const toggleTrack = (code) => setExportTracks(prev => ({ ...prev, [code]: prev[code] === false ? true : false })); const setAllTracks = (on) => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, on]))); const setDubsOnly = () => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, t.kind === 'dub']))); // ── Presets — map label → state deltas and jump to the right tab. const applyPreset = (key) => { const p = PRESETS[key]; if (!p) return; setTab(p.tab); if (p.preserveBg !== undefined) setPreserveBg(!!p.preserveBg); if (p.burnSubs !== undefined) setBurnSubs(!!p.burnSubs); if (p.dualSubs !== undefined) setSubsDual(!!p.dualSubs); if (p.audioFormat) setAudioFormat(p.audioFormat); if (p.mp3Bitrate) setMp3Bitrate(p.mp3Bitrate); if (p.subsFormat) setSubsFormat(p.subsFormat); if (p.subsDual !== undefined) setSubsDual(!!p.subsDual); if (p.includeAll) setAllTracks(true); if (p.defaultTrack === 'dub' && dubLangCode) setDefaultTrack(dubLangCode); }; // ── Filename preview — purely cosmetic, mirrors how the server names files. const baseName = useMemo(() => { const raw = (filename || 'output').replace(/\.[^.]+$/, ''); return raw.replace(/[^A-Za-z0-9 _-]/g, '').trim() || 'output'; }, [filename]); const filenamePreview = (() => { if (tab === 'video') return `dubbed_${baseName}_…mp4`; if (tab === 'audio') { const ext = audioFormat; if (audioBatch === 'each') return `dubbed__${baseName}_…${ext} (${selectedDubs.length} files)`; return `dubbed_${audioPrimaryLang || dubLangCode}_${baseName}_…${ext}`; } if (tab === 'subs') { const langs = subsBatch === 'all-dubs' ? selectedDubs.length || 1 : 1; const exts = subsFormat === 'both' ? 'srt+vtt' : subsFormat; return `subtitles${subsDual ? '_dual' : ''}.${exts} (${langs} file${langs === 1 ? '' : 's'})`; } return 'archive.zip'; })(); // ── Validity: what's runnable right now? const canVideo = selectedTracks.length > 0 && (dubTracks || []).length > 0; const canAudio = audioBatch === 'each' ? selectedDubs.length > 0 : !!audioPrimaryLang && (dubTracks || []).includes(audioPrimaryLang); const canSubs = segmentCount > 0 && (subsBatch !== 'all-dubs' || selectedDubs.length > 0); // ── Runners — fire backend calls based on tab. Each returns quickly; // toasts inside triggerDownload keep the user informed. const runVideo = () => { handleDubDownload?.(); onClose?.(); }; const runAudio = () => { const langs = audioBatch === 'each' ? selectedDubs.map(t => t.code) : [audioPrimaryLang || dubLangCode]; langs.forEach(lang => { if (!lang) return; const q = `preserve_bg=${preserveBg ? 1 : 0}&lang=${encodeURIComponent(lang)}`; if (audioFormat === 'wav') { const url = `${API}/dub/download-audio/${jobId}/dubbed_${lang}.wav?${q}`; handleAudioExport?.(url, `dubbed_${lang}.wav`); } else { const url = `${API}/dub/download-mp3/${jobId}/dubbed_${lang}.mp3?${q}&bitrate=${mp3Bitrate}k`; handleAudioExport?.(url, `dubbed_${lang}.mp3`); } }); onClose?.(); }; const runSubs = () => { const targets = subsBatch === 'all-dubs' ? selectedDubs.map(t => t.code) : [dubLangCode]; const formats = subsFormat === 'both' ? ['srt', 'vtt'] : [subsFormat]; targets.forEach(lang => { formats.forEach(ext => { const name = `subtitles${subsDual ? '_dual' : ''}_${lang}.${ext}`; const url = `${API}/dub/${ext}/${jobId}/${name}?dual=${subsDual ? 1 : 0}`; triggerDownload?.(url, name); }); }); onClose?.(); }; const runStems = () => { handleAudioExport?.(`${API}/dub/export-stems/${jobId}`, 'stems.zip'); onClose?.(); }; const runClips = () => { handleAudioExport?.(`${API}/dub/export-segments/${jobId}`, 'segments.zip'); onClose?.(); }; const runMap = { video: { fn: runVideo, can: canVideo, label: 'Export MP4' }, audio: { fn: runAudio, can: canAudio, label: audioBatch === 'each' ? `Export ${selectedDubs.length} audio file${selectedDubs.length === 1 ? '' : 's'}` : 'Export audio' }, subs: { fn: runSubs, can: canSubs, label: 'Export subtitles' }, pkg: { fn: null, can: false, label: 'Export' }, }; const active = runMap[tab]; if (!open) return null; return createPortal(
{/* Preset chips */}
PRESETS {Object.entries(PRESETS).map(([k, v]) => ( ))}
{/* Track checklist — shared across tabs */}
TRACKS
· ·
{allTracks.map(t => { const on = exportTracks[t.code] !== false; return ( ); })}
{/* Tabs */}
{/* Tab body */}
{tab === 'video' && (
{burnSubs && ( )}
)} {tab === 'audio' && (
{audioFormat === 'mp3' && ( )} {audioBatch === 'primary' && ( )}
)} {tab === 'subs' && (
setSubsDual(v === 'dual')} items={[ { value: 'single', label: 'Single line' }, { value: 'dual', label: 'Dual (translated + original)' }, ]} />
Subtitles are generated from segment text as-is — edit the segment table before exporting if you spotted typos.
)} {tab === 'pkg' && (
} title="Per-segment clips (.zip)" body="Every generated segment as a numbered WAV inside a zip — good for review, voice-over post, or dataset building." onClick={runClips} cta="Export clips zip" /> } title="Stems (.zip)" body="Isolated vocal track + background (music/FX) as separate WAVs. Useful for downstream audio editing." onClick={runStems} cta="Export stems zip" /> } title="Audio tracks (individual files)" body={`Jump to the Audio tab to export per-language dubs in WAV or MP3 (${(dubTracks || []).length} dub${(dubTracks || []).length === 1 ? '' : 's'} available).`} onClick={() => setTab('audio')} cta="Open audio tab" ghost />
)}
{/* Commercial license notice */}
Commercial use requires a .
{/* Summary footer */}
OUTPUT {filenamePreview}
{tab !== 'pkg' && ( <> )} {tab === 'pkg' && ( )}
, document.body, ); } function Field({ label, hint, children }) { return (
{label} {hint && {hint}}
{children}
); } function PkgCard({ icon, title, body, onClick, cta, ghost = false }) { return (
{icon}{title}

{body}

); }