OmniVoice-Studio / frontend /src /components /ExportModal.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
20.3 kB
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>
);
}