Spaces:
Running
Running
| 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_<lang>_${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( | |
| <div className="export-drawer" role="dialog" aria-modal="false" aria-label="Export options"> | |
| <div className="export-drawer__sheet" ref={drawerRef}> | |
| <header className="export-drawer__head"> | |
| <span className="export-drawer__handle" aria-hidden="true" /> | |
| <span className="export-modal__title-inner"> | |
| <Download size={13} /> Export | |
| {filename && <span className="export-modal__filename">· {filename}</span>} | |
| </span> | |
| <button type="button" className="export-drawer__close" onClick={onClose} aria-label="Close export drawer"> | |
| <X size={13} /> | |
| </button> | |
| </header> | |
| <div className="export-modal export-modal--drawer"> | |
| {/* Preset chips */} | |
| <div className="export-modal__presets"> | |
| <span className="export-modal__kicker">PRESETS</span> | |
| {Object.entries(PRESETS).map(([k, v]) => ( | |
| <button key={k} type="button" className="export-modal__preset-chip" onClick={() => applyPreset(k)} title={`Jump to ${v.tab} tab with ${v.label} defaults`}> | |
| <Zap size={9} /> {v.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Track checklist — shared across tabs */} | |
| <div className="export-modal__tracks"> | |
| <div className="export-modal__section-head"> | |
| <span className="export-modal__kicker"><Globe size={9} /> TRACKS</span> | |
| <div className="export-modal__track-quick"> | |
| <button type="button" onClick={() => setAllTracks(true)}>All</button> | |
| <span>·</span> | |
| <button type="button" onClick={() => setAllTracks(false)}>None</button> | |
| <span>·</span> | |
| <button type="button" onClick={setDubsOnly}>Dubs only</button> | |
| </div> | |
| </div> | |
| <div className="export-modal__track-row"> | |
| {allTracks.map(t => { | |
| const on = exportTracks[t.code] !== false; | |
| return ( | |
| <label key={t.code} className={`export-modal__track ${on ? 'is-on' : ''} ${t.kind === 'original' ? 'is-original' : 'is-dub'}`}> | |
| <input type="checkbox" checked={on} onChange={() => toggleTrack(t.code)} /> | |
| <span className="export-modal__track-label">{t.label}</span> | |
| {t.kind === 'dub' && t.code === dubLangCode && <Badge tone="brand" size="xs">primary</Badge>} | |
| </label> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Tabs */} | |
| <div className="export-modal__tabs"> | |
| <button type="button" className={`export-modal__tab ${tab === 'video' ? 'is-active' : ''}`} onClick={() => setTab('video')}> | |
| <Film size={10} /> Video | |
| </button> | |
| <button type="button" className={`export-modal__tab ${tab === 'audio' ? 'is-active' : ''}`} onClick={() => setTab('audio')}> | |
| <Volume2 size={10} /> Audio | |
| </button> | |
| <button type="button" className={`export-modal__tab ${tab === 'subs' ? 'is-active' : ''}`} onClick={() => setTab('subs')}> | |
| <FileText size={10} /> Subtitles | |
| </button> | |
| <button type="button" className={`export-modal__tab ${tab === 'pkg' ? 'is-active' : ''}`} onClick={() => setTab('pkg')}> | |
| <Package size={10} /> Package | |
| </button> | |
| </div> | |
| {/* Tab body */} | |
| <div className="export-modal__body"> | |
| {tab === 'video' && ( | |
| <div className="export-modal__grid"> | |
| <Field label="Container"> | |
| <Segmented size="sm" value={videoFormat} onChange={setVideoFormat} items={[ | |
| { value: 'mp4', label: 'MP4 (H.264)' }, | |
| ]} /> | |
| </Field> | |
| <Field label="Default audio track" hint="Which audio stream plays by default when the viewer opens the file"> | |
| <select className="input-base input-base--xs" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}> | |
| {exportTracks['original'] !== false && <option value="original">Original</option>} | |
| {(dubTracks || []).filter(t => exportTracks[t] !== false).map(t => ( | |
| <option key={t} value={t}>{t.toUpperCase()} (Dub)</option> | |
| ))} | |
| </select> | |
| </Field> | |
| <Field label="Background audio"> | |
| <label className="export-modal__toggle"> | |
| <input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> | |
| Mix music/FX under every dubbed track | |
| </label> | |
| </Field> | |
| <Field label="Subtitles in video"> | |
| <label className="export-modal__toggle"> | |
| <input type="checkbox" checked={burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> | |
| Burn subtitles into picture (hardsub) | |
| </label> | |
| {burnSubs && ( | |
| <label className="export-modal__toggle export-modal__toggle--indent"> | |
| <input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> | |
| Dual (translated on top of italicised original) | |
| </label> | |
| )} | |
| </Field> | |
| </div> | |
| )} | |
| {tab === 'audio' && ( | |
| <div className="export-modal__grid"> | |
| <Field label="Format"> | |
| <Segmented size="sm" value={audioFormat} onChange={setAudioFormat} items={[ | |
| { value: 'wav', label: 'WAV (lossless)' }, | |
| { value: 'mp3', label: 'MP3 (compressed)' }, | |
| ]} /> | |
| </Field> | |
| {audioFormat === 'mp3' && ( | |
| <Field label="Bitrate"> | |
| <Segmented size="sm" value={mp3Bitrate} onChange={setMp3Bitrate} items={[ | |
| { value: '128', label: '128k' }, | |
| { value: '192', label: '192k' }, | |
| { value: '256', label: '256k' }, | |
| { value: '320', label: '320k' }, | |
| ]} /> | |
| </Field> | |
| )} | |
| <Field label="What to export"> | |
| <Segmented size="sm" value={audioBatch} onChange={setAudioBatch} items={[ | |
| { value: 'each', label: 'Every selected dub (separate files)' }, | |
| { value: 'primary', label: 'Single language' }, | |
| ]} /> | |
| {audioBatch === 'primary' && ( | |
| <select className="input-base input-base--xs export-modal__mt6" | |
| value={audioPrimaryLang} onChange={e => setAudioPrimaryLang(e.target.value)}> | |
| {(dubTracks || []).map(t => <option key={t} value={t}>{t.toUpperCase()}</option>)} | |
| </select> | |
| )} | |
| </Field> | |
| <Field label="Background audio"> | |
| <label className="export-modal__toggle"> | |
| <input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> | |
| Mix music/FX under the dubbed voice | |
| </label> | |
| </Field> | |
| </div> | |
| )} | |
| {tab === 'subs' && ( | |
| <div className="export-modal__grid"> | |
| <Field label="Format"> | |
| <Segmented size="sm" value={subsFormat} onChange={setSubsFormat} items={[ | |
| { value: 'srt', label: 'SRT' }, | |
| { value: 'vtt', label: 'VTT' }, | |
| { value: 'both', label: 'Both' }, | |
| ]} /> | |
| </Field> | |
| <Field label="Layout"> | |
| <Segmented size="sm" value={subsDual ? 'dual' : 'single'} onChange={v => setSubsDual(v === 'dual')} items={[ | |
| { value: 'single', label: 'Single line' }, | |
| { value: 'dual', label: 'Dual (translated + original)' }, | |
| ]} /> | |
| </Field> | |
| <Field label="Languages"> | |
| <Segmented size="sm" value={subsBatch} onChange={setSubsBatch} items={[ | |
| { value: 'target', label: `Current target (${dubLangCode || '—'})` }, | |
| { value: 'all-dubs', label: `All selected dubs (${selectedDubs.length})` }, | |
| ]} /> | |
| </Field> | |
| <div className="export-modal__note"> | |
| Subtitles are generated from segment text as-is — edit the segment table before exporting if you spotted typos. | |
| </div> | |
| </div> | |
| )} | |
| {tab === 'pkg' && ( | |
| <div className="export-modal__pkg-grid"> | |
| <PkgCard | |
| icon={<Package size={14} />} 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" | |
| /> | |
| <PkgCard | |
| icon={<Layers size={14} />} title="Stems (.zip)" | |
| body="Isolated vocal track + background (music/FX) as separate WAVs. Useful for downstream audio editing." | |
| onClick={runStems} cta="Export stems zip" | |
| /> | |
| <PkgCard | |
| icon={<Music size={14} />} 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 | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Commercial license notice */} | |
| <div className="export-modal__license-notice"> | |
| <Building2 size={11} /> | |
| <span>Commercial use requires a <button type="button" className="export-modal__license-link" onClick={() => { onClose(); onEnterprise?.(); }}>license</button>.</span> | |
| </div> | |
| {/* Summary footer */} | |
| <div className="export-modal__summary"> | |
| <div className="export-modal__summary-left"> | |
| <span className="export-modal__kicker">OUTPUT</span> | |
| <code className="export-modal__summary-name" title={filenamePreview}>{filenamePreview}</code> | |
| </div> | |
| <div className="export-modal__summary-right"> | |
| {tab !== 'pkg' && ( | |
| <> | |
| <Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button> | |
| <Button | |
| variant="primary" size="sm" | |
| onClick={active.fn} disabled={!active.can} | |
| leading={<Download size={11} />} | |
| title={active.can ? '' : 'Nothing selected or track unavailable'} | |
| > | |
| {active.label} | |
| </Button> | |
| </> | |
| )} | |
| {tab === 'pkg' && ( | |
| <Button variant="ghost" size="sm" onClick={onClose}>Close</Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div>, | |
| document.body, | |
| ); | |
| } | |
| function Field({ label, hint, children }) { | |
| return ( | |
| <div className="export-modal__field"> | |
| <div className="export-modal__field-head"> | |
| <span className="export-modal__field-label">{label}</span> | |
| {hint && <span className="export-modal__field-hint">{hint}</span>} | |
| </div> | |
| {children} | |
| </div> | |
| ); | |
| } | |
| function PkgCard({ icon, title, body, onClick, cta, ghost = false }) { | |
| return ( | |
| <div className={`export-modal__pkg-card ${ghost ? 'is-ghost' : ''}`}> | |
| <div className="export-modal__pkg-head">{icon}<span>{title}</span></div> | |
| <p className="export-modal__pkg-body">{body}</p> | |
| <Button variant={ghost ? 'subtle' : 'primary'} size="sm" onClick={onClick} leading={ghost ? null : <Check size={10} />}> | |
| {cta} | |
| </Button> | |
| </div> | |
| ); | |
| } | |