Files
VoiceStudio/frontend/src/pages/DubTab.jsx
T
Palash DebnathandClaude Fable 5 d04c1fdd0d fix(dub): Timing strategy options never rendered — wrong prop name on Segmented (#313) (#320)
The Timing control passed `options=` to <Segmented>, whose prop is
`items=` (defaulting to []), so the toggle group rendered as a single
empty pill with nothing to click — users had no way to pick
Concise / Stretch Video / Strict slot. Broken since the control was
introduced; every other Segmented call site already uses `items=`.

Closes #313

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:28:08 +05:30

1324 lines
65 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Suspense, lazy, useState, useEffect, useCallback, useRef } from 'react';
import { copyText } from "../utils/copyText";
import { useTranslation } from 'react-i18next';
import {
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, Users,
FileText, Play, DownloadIcon, Volume2, Link2,
Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle,
ExternalLink, Copy,
} from 'lucide-react';
// lucide-react exports DownloadIcon as "Download"; alias here to match App.jsx naming.
import { Download as Download, RotateCcw } from 'lucide-react';
import SearchableSelect from '../components/SearchableSelect';
import WaveformTimeline from '../components/WaveformTimeline';
import CheckpointBanner from '../components/CheckpointBanner';
import { useAppStore } from '../store';
import ALL_LANGUAGES from '../languages.json';
import { POPULAR_LANGS, POPULAR_ISO, PRESETS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import { formatTime } from '../utils/format';
import { API } from '../api/client';
import { listTranslationEngines, installTranslationEngine } from '../api/engines';
import toast from 'react-hot-toast';
import { Button, Segmented, Badge, Progress } from '../ui';
import { openDocsFor, classifyError } from '../utils/errorDocsMap';
import GlossaryPanel from '../components/GlossaryPanel';
import ExportModal from '../components/ExportModal';
import MultiLangPicker from '../components/MultiLangPicker';
import DubbingDemo from '../components/DubbingDemo';
import './DubTab.css';
const DubSegmentTable = lazy(() => import('../components/DubSegmentTable'));
const LazyFallback = () => (
<div className="dub-lazy-fallback">Loading</div>
);
/** plan-04 (#131): actionable failure detail — hint + docs deeplink + a copyable
* diagnostic block — shown beneath the error badge when the backend sent a
* structured failure. */
function DubFailureNotice({ failure }) {
const { t } = useTranslation();
if (!failure) return null;
const topic = failure.docsTopic || classifyError(failure.reason);
const copyDiagnostic = async () => {
try {
await copyText(failure.diagnostic || failure.reason);
toast.success(t('dub.diagnostic_copied'));
} catch {
toast.error(t('dub.copy_failed'));
}
};
return (
<div className="dub-failure-notice">
{failure.hint && <span className="dub-failure-notice__hint">{failure.hint}</span>}
<div className="dub-failure-notice__actions">
{topic && (
<Button variant="subtle" size="sm" onClick={() => openDocsFor(topic)}>
<ExternalLink size={11} /> {t('dub.open_docs')}
</Button>
)}
{failure.diagnostic && (
<Button variant="subtle" size="sm" onClick={copyDiagnostic}>
<Copy size={11} /> {t('dub.copy_diagnostic')}
</Button>
)}
</div>
</div>
);
}
export default function DubTab(props) {
const { t } = useTranslation();
const {
// Props that stay prop-threaded: non-serialisable state + handlers that
// close over App.jsx's scope (uploads, SSE wiring, project CRUD, etc.).
dubVideoFile, dubLocalBlobUrl,
transcribeElapsed, translateProvider, setTranslateProvider,
showTranscript, setShowTranscript,
onGlossaryChange,
profiles,
segmentPreviewLoading,
selectedSegIds,
setDubVideoFile, setDubLocalBlobUrl,
handleDubAbort, handleDubUpload, handleDubIngestUrl, handleDubRetryTranscribe, handleDubStop, handleDubGenerate, handleDubImportSrt,
handleDubDownload, handleDubAudioDownload, handleAudioExport,
speakerClones = {},
handleSegmentPreview, onDirectSegment, handleTranslateAll, handleCleanupSegments,
incrementalPlan,
triggerDownload, fileToMediaUrl,
editSegments, saveProject, resetDub,
segmentEditField, segmentDelete, segmentRestoreOriginal, segmentSplit, segmentMerge,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
} = props;
// ── Store reads (Phase 2.2) — drop ~30 props from the App.jsx contract.
const dubJobId = useAppStore(s => s.dubJobId);
const dubStep = useAppStore(s => s.dubStep);
const setDubStep = useAppStore(s => s.setDubStep);
const setDubInputType = useAppStore(s => s.setDubInputType);
const dubPrepStage = useAppStore(s => s.dubPrepStage);
const dubPrepProgress = useAppStore(s => s.dubPrepProgress);
const dubFilename = useAppStore(s => s.dubFilename);
const dubDuration = useAppStore(s => s.dubDuration);
const dubSegments = useAppStore(s => s.dubSegments);
const setDubSegments = useAppStore(s => s.setDubSegments);
const dubTranscript = useAppStore(s => s.dubTranscript);
const dubLang = useAppStore(s => s.dubLang);
const setDubLang = useAppStore(s => s.setDubLang);
const dubLangCode = useAppStore(s => s.dubLangCode);
const setDubLangCode = useAppStore(s => s.setDubLangCode);
const dubNumSpeakers = useAppStore(s => s.dubNumSpeakers);
const setDubNumSpeakers = useAppStore(s => s.setDubNumSpeakers);
const dubInstruct = useAppStore(s => s.dubInstruct);
const setDubInstruct = useAppStore(s => s.setDubInstruct);
const dubTracks = useAppStore(s => s.dubTracks);
const dubError = useAppStore(s => s.dubError);
const dubFailure = useAppStore(s => s.dubFailure);
const dubProgress = useAppStore(s => s.dubProgress);
const isTranslating = useAppStore(s => s.isTranslating);
const preserveBg = useAppStore(s => s.preserveBg);
const setPreserveBg = useAppStore(s => s.setPreserveBg);
const defaultTrack = useAppStore(s => s.defaultTrack);
const setDefaultTrack = useAppStore(s => s.setDefaultTrack);
const exportTracks = useAppStore(s => s.exportTracks);
const setExportTracks = useAppStore(s => s.setExportTracks);
const activeProjectName = useAppStore(s => s.activeProjectName);
const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed);
const setIsSidebarCollapsed = useAppStore(s => s.setIsSidebarCollapsed);
const translateQuality = useAppStore(s => s.translateQuality);
const setTranslateQuality = useAppStore(s => s.setTranslateQuality);
const dualSubs = useAppStore(s => s.dualSubs);
const setDualSubs = useAppStore(s => s.setDualSubs);
const burnSubs = useAppStore(s => s.burnSubs);
const setBurnSubs = useAppStore(s => s.setBurnSubs);
const timingStrategy = useAppStore(s => s.timingStrategy);
const setTimingStrategy = useAppStore(s => s.setTimingStrategy);
const showIdleSkeleton = !(dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done'));
// Imperative handle to the post-job waveform so the transcript table can
// seek the player when the user clicks a row.
const waveformRef = useRef(null);
const seekWaveform = useCallback((time) => {
waveformRef.current?.seekTo?.(time);
}, []);
const [ingestUrl, setIngestUrl] = useState('');
// Dubbing demo: show the side-by-side player above the drop zone on
// first-run / no-project state. localStorage flag persists dismissal
// across sessions so power users don't see it every launch.
const [demoDismissed, setDemoDismissed] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('omnivoice.dubbingDemoDismissed') === '1';
});
const dismissDubDemo = () => {
setDemoDismissed(true);
try { localStorage.setItem('omnivoice.dubbingDemoDismissed', '1'); } catch { /* noop */ }
};
const [previewMode, setPreviewMode] = useState('original'); // 'original' | 'dubbed'
const [exportOpen, setExportOpen] = useState(false);
// Multi-language mode
const [multiLangMode, setMultiLangMode] = useState(false);
const [multiLangs, setMultiLangs] = useState([]);
// Live ETA while generating — elapsed ticks each second; remaining is
// extrapolated from the current/total rate so it's only meaningful once
// at least one segment has rendered and ~2s of clock has passed.
const [genElapsed, setGenElapsed] = useState(0);
useEffect(() => {
if (dubStep !== 'generating') { setGenElapsed(0); return; }
const start = Date.now();
setGenElapsed(0);
const id = setInterval(() => setGenElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
return () => clearInterval(id);
}, [dubStep]);
const genRemaining = (() => {
if (dubStep !== 'generating') return null;
if (!dubProgress.total || !dubProgress.current || genElapsed < 2) return null;
const perSeg = genElapsed / dubProgress.current;
return Math.max(0, Math.round(perSeg * (dubProgress.total - dubProgress.current)));
})();
// Translation-engine availability → drives the Engine dropdown's disabled
// state and the inline Install chip. Lazy-fetched once; refreshed after
// any install/uninstall so the chip disappears on success.
const [engines, setEngines] = useState([]);
const [enginesSandboxed, setEnginesSandboxed] = useState(false);
const [engineInstalling, setEngineInstalling] = useState(null); // engine id being installed
const refreshEngines = useCallback(async () => {
try {
const res = await listTranslationEngines();
setEngines(res.engines || []);
setEnginesSandboxed(!!res.sandboxed);
} catch {
setEngines([]);
}
}, []);
useEffect(() => { refreshEngines(); }, [refreshEngines]);
const activeEngineEntry = engines.find(e => e.id === translateProvider);
const activeEngineUnavailable = activeEngineEntry && !activeEngineEntry.installed;
const handleInstallEngine = async (engineId) => {
if (!engineId || enginesSandboxed) return;
setEngineInstalling(engineId);
const progressToast = toast.loading(t('dub.install_progress', { engine: engineId }));
try {
const res = await installTranslationEngine(engineId);
await refreshEngines();
if (res.restart_required) {
toast(t('dub.install_restart', { engine: engineId }), { icon: '🔄', id: progressToast, duration: 7000 });
} else if (res.status === 'already_installed') {
toast(t('dub.install_already', { engine: engineId }), { icon: '️', id: progressToast });
} else {
toast.success(t('dub.install_ok', { engine: engineId }), { id: progressToast });
}
} catch (err) {
toast.error(t('dub.install_failed', { message: String(err.message || err).slice(0, 200) }), { id: progressToast, duration: 8000 });
} finally {
setEngineInstalling(null);
}
};
// Secondary settings (Language/ISO/Style/Engine/Quality/Multi-lang) are
// expanded by default so the user can pick a target language and quality
// without an extra click on first open. They stay an accordion so the
// user can collapse them once happy with the choice.
const [settingsOpen, setSettingsOpen] = useState(true);
const hasAnyTranslation = dubSegments.some(s => s.text_original && s.text_original !== s.text);
// Glossary: hide behind a chip when empty, auto-open once terms exist.
const glossaryTermCount = useAppStore(s => s.glossaryTerms.length);
const [glossaryOpen, setGlossaryOpen] = useState(false);
const [glossaryHidden, setGlossaryHidden] = useState(false);
const glossaryVisible = glossaryOpen || (glossaryTermCount > 0 && !glossaryHidden);
// Phase 4.3 — between-stage checkpoint banner.
const reviewMode = useAppStore(s => s.reviewMode);
const [dismissedStages, setDismissedStages] = useState(() => new Set());
const hasTranslations = dubSegments.some(s => s.text_original && s.text_original !== s.text);
const checkpointStage =
dubStep === 'editing' && !hasTranslations ? 'asr'
: dubStep === 'editing' && hasTranslations ? 'translate'
: dubStep === 'done' ? 'done'
: null;
const showCheckpoint = reviewMode === 'on' && checkpointStage && !dismissedStages.has(checkpointStage);
const onCheckpointContinue = () => {
if (checkpointStage === 'asr') handleTranslateAll?.();
else if (checkpointStage === 'translate') handleDubGenerate?.();
};
const onCheckpointDismiss = () => {
setDismissedStages(prev => {
const next = new Set(prev);
if (checkpointStage) next.add(checkpointStage);
return next;
});
};
// Persist the "pull YouTube captions" intent across ingests — it's opt-in
// per-URL but almost always on once the user discovers it. Stored on the
// component instead of the global store to avoid polluting cross-project
// prefs with what's really a per-ingest choice.
const [fetchYtSubs, setFetchYtSubs] = useState(false);
const onIngestUrl = () => {
if (!ingestUrl.trim() || !handleDubIngestUrl) return;
handleDubIngestUrl(ingestUrl.trim(), {
fetchSubs: fetchYtSubs,
subLangs: undefined,
});
setIngestUrl('');
};
const hasDubbedTrack = dubStep === 'done' && dubLangCode && dubLangCode !== 'und' && (dubTracks?.length > 0 || !!dubTracks);
const videoSrc = (previewMode === 'dubbed' && hasDubbedTrack)
? `${API}/dub/preview-video/${dubJobId}?lang=${encodeURIComponent(dubLangCode)}&preserve_bg=${preserveBg ? 1 : 0}`
: `${API}/dub/media/${dubJobId}`;
return (
<div className="dub-col">
{/* ── Idle: show full editor skeleton with drop zone ── */}
{showIdleSkeleton && (
<div className="dub-col">
{/* Header bar */}
<div className="dub-head">
<div className="label-row dub-head__title">
<Button
variant="icon"
iconSize="sm"
active={isSidebarCollapsed}
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
title={t('dub.sidebar_toggle')}
>
{isSidebarCollapsed ? <PanelLeftOpen size={12} /> : <PanelLeftClose size={12} />}
</Button>
<Film className="label-icon" size={11} />
<span className="dub-head__filename">{dubVideoFile ? dubVideoFile.name : t('dub.video_dubbing_studio')}</span>
{dubVideoFile && <span className="dub-head__meta">· {(dubVideoFile.size / 1024 / 1024).toFixed(1)} MB</span>}
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
<Button variant="subtle" size="sm" disabled title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="ghost" size="sm" disabled title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
</div>
</div>
{/* Transcription failure banner — shown in the idle state when a
job exists but transcription produced zero segments (or threw).
Surfaces the backend error detail and offers one-click retry,
which re-runs the ASR stream on the same job without re-uploading. */}
{dubError && dubJobId && dubStep === 'idle' && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
{handleDubRetryTranscribe && (
<Button
variant="subtle"
size="sm"
onClick={handleDubRetryTranscribe}
leading={<Sparkles size={10} />}
>
{t('dub.retry_transcription')}
</Button>
)}
{handleDubImportSrt && (
<label
htmlFor="srt-import-banner-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={11} /> {t('dub.import_srt_alt')}
<input
id="srt-import-banner-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
}}
/>
</label>
)}
</div>
)}
{/* SPLIT LAYOUT skeleton */}
<div className={`dub-split-grid ${dubVideoFile ? 'dub-split-2' : 'dub-split-1'}`}>
{/* LEFT */}
<div className="studio-panel dub-panel-col">
{dubVideoFile ? (
<>
<WaveformTimeline
audioSrc={dubLocalBlobUrl?.audioUrl}
videoSrc={dubLocalBlobUrl?.videoUrl}
segments={[]}
onSegmentsChange={() => { }}
disabled={true}
overlayContent={
dubStep === 'uploading' ? (
<PrepOverlay stage={dubPrepStage} progress={dubPrepProgress} onAbort={handleDubAbort} />
) : dubStep === 'transcribing' ? (
<TranscribeOverlay
elapsed={transcribeElapsed}
duration={dubDuration}
onAbort={handleDubAbort}
/>
) : null
}
/>
<div className="dub-change-row">
<label htmlFor="video-upload" className="dub-idle-upload-label">
<Film size={13} /> {t('dub.change_file')}
</label>
{dubJobId && handleDubImportSrt && (
<label
htmlFor="srt-import-input"
className="dub-idle-upload-label"
title={t('dub.import_srt')}
style={{ cursor: 'pointer' }}
>
<FileText size={13} /> {t('dub.import_srt')}
<input
id="srt-import-input"
type="file"
accept=".srt,text/srt,text/plain"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleDubImportSrt(f);
e.target.value = '';
}}
/>
</label>
)}
<label className="dub-speakers-hint" title={t('dub.num_speakers_help')}>
<Users size={13} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className="dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<button className="btn-primary dub-change-row__cta"
onClick={handleDubUpload}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}>
{dubStep === 'uploading' || dubStep === 'transcribing'
? <><Loader className="spinner" size={14} /> {t('common.loading')}</>
: <><Sparkles size={14} /> {t('dub.upload_transcribe')}</>}
</button>
</div>
</>
) : dubStep === 'uploading' ? (
<PrepOverlay stage={dubPrepStage} progress={dubPrepProgress} onAbort={handleDubAbort} large />
) : (
<>
{!demoDismissed && (
<DubbingDemo onDismiss={dismissDubDemo} />
)}
<label htmlFor="video-upload" className="dub-idle-drop"
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-dragging'); }}
onDragLeave={e => { e.currentTarget.classList.remove('is-dragging'); }}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('video/') || file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name))) {
setDubVideoFile(file);
// #119: an audio file → audio-only dubbing (skip video work, output audio).
setDubInputType(file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name) ? 'audio' : 'video');
setDubStep('idle');
fileToMediaUrl(file, null).then(urls => setDubLocalBlobUrl(urls));
}
}}>
<div className="dub-idle-drop__puck">
<UploadCloud color="#d3869b" size={28} />
</div>
<div className="dub-idle-drop__lines">
<div className="dub-idle-drop__title">{t('dub.drop_here')}</div>
<div className="dub-idle-drop__sub">{t('dub.supported_formats')}</div>
</div>
<div
className="dub-ingest-row"
onClick={e => e.preventDefault()}
>
<Link2 size={13} color="#a89984" />
<input
type="text"
placeholder={t('dub.paste_url')}
value={ingestUrl}
onChange={e => setIngestUrl(e.target.value)}
onClick={e => { e.preventDefault(); e.stopPropagation(); }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onIngestUrl(); } }}
className="dub-ingest-row__input"
/>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); onIngestUrl(); }}
disabled={!ingestUrl.trim()}
className={`dub-ingest-row__cta ${ingestUrl.trim() ? 'is-ready' : ''}`}
>
{t('dub.ingest')}
</button>
</div>
<label
className="dub-ingest-sub-opt"
title={t('dub.pull_captions_title')}
onClick={e => { e.stopPropagation(); }}
>
<input
type="checkbox"
checked={fetchYtSubs}
onChange={e => setFetchYtSubs(e.target.checked)}
onClick={e => e.stopPropagation()}
/>
<span>{t('dub.pull_captions')}</span>
</label>
</label>
</>
)}
<input type="file" accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma" id="video-upload" className="dub-hidden-file"
onChange={e => {
const file = e.target.files[0];
if (!file) return;
setDubVideoFile(file);
// #119: an audio file → audio-only dubbing (skip video work, output audio).
setDubInputType(file.type.startsWith('audio/') || /\.(mp3|wav|flac|m4a|aac|ogg|opus|wma)$/i.test(file.name) ? 'audio' : 'video');
setDubStep('idle');
setDubLocalBlobUrl(prev => { fileToMediaUrl(file, prev).then(urls => setDubLocalBlobUrl(urls)); return prev; });
}} />
<div className="dub-cast dub-cast--muted">
<div className="dub-cast__row">
<span className="dub-cast__kicker">{t('dub.cast')}</span>
<span className="dub-cast__label">{t('dub.speaker', { n: 1 })}</span>
<span className="dub-cast--muted__chip">{t('dub.default')}</span>
</div>
</div>
</div>
{/* RIGHT: Ghost settings + segment table (only when video loaded) */}
{dubVideoFile ? (
<div className="studio-panel dub-panel-col">
<div className="dub-skel-settings">
<div className="dub-skel-field">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select className="input-base input-base--xs" disabled>
<option>{t('dub.auto')}</option>
</select>
</div>
<div className="dub-skel-field--sm">
<div className="label-row">{t('dub.iso_code')}</div>
<select className="input-base input-base--xs" disabled>
<option>en {t('dub.original_audio')}</option>
</select>
</div>
<div className="dub-skel-field">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')}</div>
<input className="input-base input-base--xs" disabled placeholder={t('dub.style_placeholder')} />
</div>
<button disabled className="dub-skel-translate-btn">
<Languages size={10} /> {t('dub.translate_all')}
</button>
</div>
<div className="dub-skel-transcript-toggle">
<div className="override-toggle dub-skel-transcript-toggle__inner">
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
<ChevronDown size={10} />
</div>
</div>
<div className="segment-table dub-skel-table">
<div className="segment-header">
<span className="dub-skel-header-time">{t('dub.time_col')}</span>
<span className="dub-skel-header-spkr">{t('dub.spkr_col')}</span>
<span className="dub-skel-header-text">{t('dub.text_col')}</span>
<span className="dub-skel-header-voice">{t('dub.voice_col')}</span>
<span className="dub-skel-header-acts"></span>
</div>
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
<div key={i} className="segment-row" style={{ opacity: 0.15 + (0.04 * (8 - i)) }}>
<span className="segment-time dub-skel-cell-time">0:00.00:00.0</span>
<span className="dub-skel-cell-spkr">Speaker 1</span>
<div className="dub-skel-cell-text" />
<span className="dub-skel-cell-voice">Default</span>
<div className="dub-skel-cell-acts">
<span className="segment-del dub-skel-cell-acts__icon"><Trash2 size={9} /></span>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
{/* Ghost footer */}
<div className="studio-panel dub-ghost-footer">
<div className="dub-skel-gen-row">
<button className="btn-primary dub-skel-gen-btn" disabled>
<Play size={11} /> {t('dub.generate_dub')}
</button>
<button className="btn-primary dub-skel-gen-btn" disabled>
<Download size={11} /> {t('dub.export_mp4')}
</button>
<button className="btn-primary dub-skel-gen-btn" disabled>
<Volume2 size={11} /> {t('dub.export_wav')}
</button>
<button className="btn-primary dub-skel-gen-btn" disabled>
<FileText size={11} /> {t('dub.export_srt')}
</button>
</div>
</div>
</div>
)}
{/* ── After transcription: side-by-side editor ── */}
{dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done') && (
<div className="dub-col">
<div className="dub-head">
<div className="label-row dub-head__title">
<Button
variant="icon"
iconSize="sm"
active={isSidebarCollapsed}
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
title={t('dub.sidebar_toggle')}
>
{isSidebarCollapsed ? <PanelLeftOpen size={12} /> : <PanelLeftClose size={12} />}
</Button>
<FileText className="label-icon" size={11} />
<span className="dub-head__filename">{dubFilename}</span>
<span className="dub-head__meta">· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="dub-head__project"> {activeProjectName}</span>
)}
</div>
<div className="dub-head__actions">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
<Button variant="subtle" size="sm" onClick={saveProject}
title={t('dub.save')} aria-label={t('dub.save')}><Save size={12} /></Button>
<Button variant="danger" size="sm" onClick={resetDub}
title={t('dub.reset')} aria-label={t('dub.reset')}><RotateCcw size={12} /></Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="dub-head__primary">
{dubStep === 'stopping' ? (
<FooterBtn sm tone="stopping" disabled icon={<Loader className="spinner" size={9} />} label={t('dub.stopping')} />
) : dubStep === 'generating' ? (
<FooterBtn sm tone="danger" onClick={handleDubStop} icon={<Square size={9} />}
label={t('dub.stop_progress', { current: dubProgress.current, total: dubProgress.total })} />
) : (
<>
<FooterBtn sm tone={dubSegments.length ? 'pink' : 'idle'} onClick={() => handleDubGenerate()}
disabled={!dubSegments.length} icon={<Play size={11} />} label={t('dub.generate_dub')} />
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
<FooterBtn sm tone="pink"
onClick={() => handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })}
icon={<Play size={11} />}
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })} />
)}
</>
)}
<FooterBtn sm tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
onClick={() => setExportOpen(true)}
icon={<Download size={12} />}
title={t('dub.export_btn')} aria-label={t('dub.export_btn')} />
</div>
</div>
</div>
<div className="dub-split-grid dub-split-2">
{/* LEFT: Waveform + Video */}
<div className="studio-panel dub-panel-col">
{hasDubbedTrack && (
<div className="dub-preview-toggle">
<span className="dub-preview-toggle__kicker">{t('dub.preview')}</span>
<Segmented
size="sm"
value={previewMode}
onChange={setPreviewMode}
items={[
{ value: 'original', label: t('dub.original_audio') },
{ value: 'dubbed', label: t('dub.dubbed_audio', { code: dubLangCode }) },
]}
/>
{previewMode === 'dubbed' && (
<span className="dub-preview-toggle__hint">{t('dub.first_play_hint')}</span>
)}
</div>
)}
<WaveformTimeline
key={videoSrc}
ref={waveformRef}
audioSrc={`${API}/dub/audio/${dubJobId}`}
videoSrc={videoSrc}
segments={dubSegments}
onSegmentsChange={setDubSegments}
disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div className="dub-gen-overlay">
<div className="dub-gen-overlay__head">
{dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984" /> : <Sparkles className="spinner" size={14} color="#d3869b" />}
<span className={`dub-gen-overlay__title ${dubStep === 'stopping' ? 'is-stopping' : ''}`}>
{dubStep === 'stopping' ? t('dub.stopping') : t('dub.generate_dub') + ` ${dubProgress.current}/${dubProgress.total}…`}
</span>
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span> {fmtDur(genElapsed)} {t('dub.elapsed')}</span>
{genRemaining !== null && <span>~{fmtDur(genRemaining)} {t('dub.remaining')}</span>}
</div>
<div className="dub-gen-overlay__bar">
<Progress
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
tone="brand"
size="sm"
/>
</div>
{dubProgress.text && <span className="dub-gen-overlay__text">{dubProgress.text}</span>}
</>
)}
</div>
) : null}
/>
{/* Cast — per-speaker voice assignment. When the auto-clone
extractor found a usable passage per speaker (≥5s from the
isolated vocals), that option becomes first-class in the
dropdown. It's also pre-selected on the segments so "new
language = same speaker's voice" works by default. */}
{dubSegments.some(s => s.speaker_id) && (
<div className="dub-cast">
<div className="dub-cast__row">
<span className="dub-cast__kicker" title={t('dub.cast_title')}>{t('dub.cast')}</span>
{[...new Set(dubSegments.map(s => s.speaker_id).filter(Boolean))].map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
const clone = speakerClones[spk];
return (
<div key={spk} className="dub-cast__pair">
<span className="dub-cast__label">{spk}:</span>
<select className="input-base dub-cast__select"
value={dubSegments.find(s => s.speaker_id === spk)?.profile_id || ''}
onChange={e => {
const val = e.target.value;
setDubSegments(dubSegments.map(s => s.speaker_id === spk ? { ...s, profile_id: val } : s));
}}>
{clone && (
<option value={autoId}>{t('dub.from_video', { duration: clone.duration.toFixed(1) })}</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map(p => <option key={p.id} value={`preset:${p.id}`}>{p.name}</option>)}
</optgroup>
)}
</select>
</div>
);
})}
</div>
</div>
)}
{/* Translation settings — collapsed or expanded */}
{!settingsOpen && (
<div className="dub-settings-summary">
<button
type="button"
className="dub-settings-summary__trigger"
onClick={() => setSettingsOpen(true)}
title={t('dub.edit_settings')}
>
<ChevronDown size={10} />
<span><strong>{dubLang}</strong> · {dubLangCode} · {translateQuality} · <span style={{ color: activeEngineUnavailable ? '#fb4934' : '#b8bb26' }}></span> {translateProvider}</span>
{dubInstruct && <span className="dub-settings-summary__style">{t('dub.style_label_prefix')}{dubInstruct}</span>}
</button>
<Button
variant="subtle" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : hasAnyTranslation ? t('dub.retranslate') : t('dub.translate_all')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
</div>
)}
{settingsOpen && (
<div className="dub-settings-bar">
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field dub-settings-field--lang">
<div className="label-row"><Globe className="label-icon" size={9} /> {t('dub.language')}</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
if (match) setDubLangCode(match.code);
}}
>
<optgroup label={t('dub.popular')}>
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
</optgroup>
<optgroup label={t('dub.all_languages')}>
{ALL_LANGUAGES
.filter(l => !POPULAR_LANGS.includes(l))
.map(l => <option key={l} value={l}>{l}</option>)}
</optgroup>
</select>
</div>
<div className="dub-settings-field dub-settings-field--iso">
<div className="label-row">{t('dub.iso_code')}</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => setDubLangCode(e.target.value)}
>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code} {lc.label}</option>
))}
</select>
</div>
<div className="dub-settings-field dub-settings-field--engine">
<div className="label-row">
{t('dub.engine_label')}
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={t('dub.install_engine')}
>
{engineInstalling === translateProvider ? t('dub.installing_engine') : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title={t('dub.install_disabled_title')}>
{t('dub.needs_dev_install')}
</span>
)}
</div>
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
{(engines.length ? engines : []).map(p => (
<option key={p.id} value={p.id}>
{p.installed ? p.display_name : `${p.display_name}${t('dub.needs_install_suffix')}`}
</option>
))}
</select>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title={t('dub.quality_title')}>{t('dub.quality_label')}</div>
<Segmented
size="sm"
value={translateQuality}
onChange={setTranslateQuality}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{ value: 'cinematic', label: t('dub.cinematic_quality') },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> {t('dub.style')} <span className="dub-settings-field__hint">{t('dub.optional')}</span></div>
<input className="input-base input-base--xs" placeholder={t('dub.style_placeholder')} value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
<div className="dub-settings-field dub-settings-field--multi">
<label className="dub-multi-toggle">
<input
type="checkbox"
checked={multiLangMode}
onChange={e => setMultiLangMode(e.target.checked)}
/>
<span>{t('dub.multi_lang')}</span>
</label>
{multiLangMode && (
<MultiLangPicker
selected={multiLangs}
onChange={setMultiLangs}
disabled={dubStep === 'generating'}
/>
)}
</div>
</div>
<div className="dub-settings-bar__actions">
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
>
{t('dub.restore')}
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
>
{t('dub.clean_up')}
</Button>
<Button
variant="primary" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? t('dub.translating') : t('dub.translate_all')}
</Button>
</div>
</div>
)}
</div>
{/* RIGHT: Segment Table */}
<div className="studio-panel dub-panel-col">
{/* Output options + timing — moved to the top of the right section. */}
<div className="dub-right-outputs">
<div className="dub-outputs-row">
<span className="dub-outputs-title-strong">{t('dub.output_options')}</span>
<label>
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} /> {t('dub.mix_bg_audio')}
</label>
<label title={t('dub.dual_subs_title')}>
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> {t('dub.dual_subs')}
</label>
<label title={t('dub.burn_subs_title')}>
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> {t('dub.burn_subs')}
</label>
<label>
{t('dub.default_track')}
<select className="input-base dub-outputs-default" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
<option value="original">{t('dub.original_track')}</option>
{dubLangCode && <option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option>}
{dubTracks.filter(tr => tr !== dubLangCode).map(tr => (
<option key={tr} value={tr}>{t('dub.dub_track', { code: tr })}</option>
))}
</select>
</label>
</div>
<div className="dub-outputs-row" title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline.">
<span className="dub-outputs-title-strong">Timing:</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
items={[
{ value: 'concise', label: 'Concise', title: 'Translator trims text to fit at natural rate. Overflows surface in the row badge so you can shorten the segment.' },
{ value: 'stretch_video', label: 'Stretch Video', title: 'Audio plays at natural rate; each segment of the video is stretched (per-segment ffmpeg setpts) to fit. Total video duration grows. Requires a re-encode pass.' },
{ value: 'strict_slot', label: 'Strict slot', title: 'Legacy: compress audio to fit the original timing. Can sound rushed/chipmunky on high-density target languages.' },
]}
/>
</div>
</div>
{dubTranscript && (
<div className="dub-transcript-toggle-wrap">
<div className="override-toggle dub-transcript-toggle__inner" onClick={() => setShowTranscript(!showTranscript)}>
<span><FileText size={10} className="dub-inline-icon" /> {t('dub.transcript')}</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && (
<div className="dub-transcript-body">
{dubTranscript}
</div>
)}
</div>
)}
{/* Phase 1.3 — Project glossary. Hidden behind a chip until
the user wants it (or terms already exist). */}
{dubJobId && !glossaryVisible && (
<button
type="button"
className="dub-glossary-chip"
onClick={() => { setGlossaryOpen(true); setGlossaryHidden(false); }}
title={t('dub.glossary_title')}
>
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
{dubJobId && glossaryVisible && (
<div className="dub-glossary-wrap">
<GlossaryPanel
projectId={dubJobId}
sourceLang={dubLangCode && dubLang ? (dubLang.slice(0, 2).toLowerCase() || 'en') : 'en'}
targetLang={dubLangCode}
segments={dubSegments}
onChange={onGlossaryChange}
onClose={() => { setGlossaryHidden(true); setGlossaryOpen(false); }}
/>
</div>
)}
{/* "Apply Voice to All" row removed 2026-04-21 — redundant
with the CAST strip in the left column, which does the same
thing per-speaker (and handles the multi-speaker case cleanly). */}
{selectedSegIds.size > 0 && (
<div className="dub-bulk-row dub-bulk-row--select">
<span className="dub-bulk-row__label-brand">{t('dub.selected_count', { count: selectedSegIds.size })}</span>
<select className="input-base dub-bulk-select dub-bulk-select--voice"
value="" onChange={(e) => { const v = e.target.value; if (v === '__clear__') bulkApplyToSelected({ profile_id: '' }); else if (v) bulkApplyToSelected({ profile_id: v }); }}>
<option value="">{t('dub.set_voice')}</option>
<option value="__clear__">{t('dub.clear_voice')}</option>
{speakerClones && Object.keys(speakerClones).length > 0 && (
<optgroup label={t('dub.cast')}>
{Object.keys(speakerClones).map(spk => {
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
})}
</optgroup>
)}
{profiles.filter(p => !p.instruct).length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.filter(p => !p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
{profiles.filter(p => !!p.instruct).length > 0 && (
<optgroup label={t('dub.design_presets')}>
{profiles.filter(p => !!p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</optgroup>
)}
</select>
<select className="input-base dub-bulk-select dub-bulk-select--lang"
value="" onChange={(e) => { if (e.target.value === '__def__') bulkApplyToSelected({ target_lang: null }); else if (e.target.value) bulkApplyToSelected({ target_lang: e.target.value }); }}>
<option value="">{t('dub.set_lang')}</option>
<option value="__def__">{t('dub.default_lang')}</option>
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>)}
</select>
<Button variant="danger" size="sm" onClick={bulkDeleteSelected}>{t('dub.delete_selected')}</Button>
<Button variant="ghost" size="sm" onClick={clearSegSelection} className="dub-bulk-row__clear">{t('dub.clear_selection')}</Button>
</div>
)}
{showCheckpoint && (
<CheckpointBanner
stage={checkpointStage}
count={dubSegments.length}
onContinue={checkpointStage === 'done' ? null : onCheckpointContinue}
onDismiss={onCheckpointDismiss}
continueLoading={isTranslating}
/>
)}
<Suspense fallback={<LazyFallback />}>
<DubSegmentTable
segments={dubSegments}
profiles={profiles}
speakerClones={speakerClones}
dubStep={dubStep}
dubProgress={dubProgress}
previewLoadingId={segmentPreviewLoading}
selectedIds={selectedSegIds}
onSelect={toggleSegSelect}
onSelectAll={selectAllSegs}
onClearSelection={clearSegSelection}
onEditField={segmentEditField}
onDelete={segmentDelete}
onRestore={segmentRestoreOriginal}
onPreview={handleSegmentPreview}
onDirect={onDirectSegment}
onSplit={segmentSplit}
onMerge={segmentMerge}
onSeek={seekWaveform}
/>
</Suspense>
</div>
</div>
{/* Actions footer */}
<div className="studio-panel dub-footer-panel">
{dubStep === 'done' && (
<div className="dub-footer-banner">
<Badge tone="success">
<Check size={11} /> {t('dub.tracks_done', { tracks: dubTracks.join(', ') })}
</Badge>
{incrementalPlan && incrementalPlan.stale?.length > 0 && (
<Badge tone="warn" className="dub-footer-banner__badge-gap">
{t('dub.segments_changed', { count: incrementalPlan.stale.length })}
</Badge>
)}
{incrementalPlan && incrementalPlan.stale?.length === 0 && incrementalPlan.fresh?.length > 0 && (
<Badge tone="neutral" className="dub-footer-banner__badge-gap">
{t('dub.all_up_to_date', { count: incrementalPlan.fresh.length })}
</Badge>
)}
</div>
)}
{dubError && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<DubFailureNotice failure={dubFailure} />
</div>
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="dub-tracks-row">
<span className="dub-tracks-row__title">{t('dub.export_tracks')}</span>
<label className={exportTracks['original'] !== false ? 'is-on' : 'is-off'}>
<input type="checkbox" checked={exportTracks['original'] !== false} onChange={e => setExportTracks(prev => ({ ...prev, original: e.target.checked }))} />
<span>{t('dub.original_track')}</span>
</label>
{dubTracks.map(t => (
<label key={t} className={exportTracks[t] !== false ? 'is-on is-success' : 'is-off'}>
<input type="checkbox" checked={exportTracks[t] !== false} onChange={e => setExportTracks(prev => ({ ...prev, [t]: e.target.checked }))} />
<span className="code">{t}</span>
</label>
))}
</div>
)}
{(() => {
// Pre-generation compression warning. Predicted by the
// translate response (see services/speech_rate.rate_ratio
// + dub_translate._maybe_cinematic), populated whenever
// segments carry a slot_seconds and translated text.
// Surfaces here so the user can act (re-translate in
// Cinematic, edit text, allow longer slots) before
// committing to a full Generate Dub run.
const hot = dubSegments.filter(s => (s.rate_ratio || 0) > 1.3);
if (hot.length === 0 || !dubSegments.length) return null;
const pctHot = Math.round((hot.length / dubSegments.length) * 100);
if (pctHot < 10) return null;
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div className="dub-compression-warn" role="status">
<span className="dub-compression-warn__icon"></span>
<span className="dub-compression-warn__body">
<strong>{hot.length} of {dubSegments.length}</strong> segments need {'>'}1.3× compression
(worst: <span style={{ fontVariantNumeric: 'tabular-nums' }}>{worst.rate_ratio.toFixed(2)}×</span>).
Output will be intelligible (pitch-preserving stretch) but stressed
{translateQuality === 'fast' ? ' switch to Cinematic and Re-translate' : ' shorten the worst segments'}
{' '}for cleaner audio.
</span>
</div>
);
})()}
{/* Generate / Export / Stop actions moved to the header bar (dub-head__primary). */}
</div>
</div>
)}
<ExportModal
open={exportOpen}
onClose={() => setExportOpen(false)}
jobId={dubJobId}
filename={dubFilename}
dubTracks={dubTracks}
dubLangCode={dubLangCode}
preserveBg={preserveBg} setPreserveBg={setPreserveBg}
defaultTrack={defaultTrack} setDefaultTrack={setDefaultTrack}
exportTracks={exportTracks} setExportTracks={setExportTracks}
dualSubs={dualSubs} setDualSubs={setDualSubs}
burnSubs={burnSubs} setBurnSubs={setBurnSubs}
API={API}
triggerDownload={triggerDownload}
handleDubDownload={handleDubDownload}
handleDubAudioDownload={handleDubAudioDownload}
handleAudioExport={handleAudioExport}
segmentCount={dubSegments.length}
onEnterprise={() => useAppStore.getState().setMode?.('enterprise')}
/>
</div>
);
}
function fmtDur(s) {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const sec = s % 60;
return sec ? `${m}m ${sec}s` : `${m}m`;
}
const PREP_FULL = ['download', 'extract', 'demucs', 'scene'];
const PREP_CACHED = ['download', 'extract', 'cached'];
function fmtBytesRate(bps) {
if (!bps || bps <= 0) return null;
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let v = bps, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
function fmtEta(seconds) {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
const s = Math.round(seconds);
if (s < 60) return `${s}s left`;
const m = Math.floor(s / 60), rem = s % 60;
return rem ? `${m}m ${rem}s left` : `${m}m left`;
}
/**
* PrepOverlay — the prepare-upload stage indicator.
* `large` makes the surrounding frame bigger (used for the empty-state drop zone).
*/
function PrepOverlay({ stage, progress, onAbort, large = false }) {
const { t } = useTranslation();
const LABEL = {
download: t('dub.prep_download'),
extract: t('dub.prep_extract'),
demucs: t('dub.prep_demucs'),
scene: t('dub.prep_scene'),
cached: t('dub.prep_cached'),
};
const stages = stage === 'cached' ? PREP_CACHED : PREP_FULL;
// Elapsed-time ticker for the current stage. Reset whenever
// stageStartedAt changes (i.e. the backend transitions stages).
const [elapsedS, setElapsedS] = useState(0);
const startedAt = progress?.stageStartedAt ?? null;
useEffect(() => {
if (!startedAt) { setElapsedS(0); return undefined; }
setElapsedS(Math.floor((Date.now() - startedAt) / 1000));
const iv = setInterval(() => {
setElapsedS(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(iv);
}, [startedAt]);
const pct = progress?.percent;
const hasPct = typeof pct === 'number' && pct >= 0 && pct <= 100;
const speed = stage === 'download' ? fmtBytesRate(progress?.speedBps) : null;
const eta = fmtEta(progress?.etaS);
const elapsedLabel = startedAt ? (elapsedS < 60 ? `${elapsedS}s` : `${Math.floor(elapsedS / 60)}m ${elapsedS % 60}s`) : null;
const detailBits = [
hasPct ? `${pct}%` : null,
elapsedLabel ? t('dub.prep_elapsed', { time: elapsedLabel }) : null,
speed,
eta,
].filter(Boolean);
const note = stage === 'demucs' && !hasPct
? t('dub.prep_demucs_note')
: null;
const body = (
<>
<Loader className="spinner" size={large ? 28 : 20} color="#d3869b" />
<span className="dub-prep-overlay__title" style={{ fontSize: large ? '0.95rem' : '0.85rem' }}>
{LABEL[stage] || t('dub.prep_preparing')}
</span>
{hasPct && (
<div className="dub-prep-bar" aria-label={`${pct}%`}>
<div className="dub-prep-bar__fill" style={{ width: `${pct}%` }} />
</div>
)}
{detailBits.length > 0 && (
<span className="dub-prep-overlay__detail">{detailBits.join(' · ')}</span>
)}
<div className={`dub-prep-chips ${large ? 'dub-prep-chips--lg' : ''}`}>
{stages.map(s => (
<span
key={s}
className={`dub-prep-chip ${stage === s ? 'is-active' : ''} ${s === 'cached' ? 'is-cached' : ''}`}
>
{s === 'cached' ? '⚡' : ''}{t(`dub.prep_chip_${s}`, { defaultValue: s })}
</span>
))}
</div>
{note && (
<span className="dub-prep-overlay__note">{note}</span>
)}
<Button variant="danger" size="sm" onClick={onAbort} leading={<Square size={11} />}>
{t('dub.prep_stop')}
</Button>
</>
);
return large
? <div className="dub-prep-overlay dub-prep-overlay--large">{body}</div>
: <div className="dub-prep-overlay">{body}</div>;
}
/**
* TranscribeOverlay — Whisper progress + ETA while transcribing.
*/
function TranscribeOverlay({ elapsed, duration, onAbort }) {
const { t } = useTranslation();
const est = duration > 0 ? Math.max(10, Math.ceil(duration / 60) * 3 + 8) : 0;
const mm = Math.floor(elapsed / 60);
const ss = String(elapsed % 60).padStart(2, '0');
return (
<div className="dub-trans-overlay">
<div className="dub-trans-overlay__head">
<Loader className="spinner" size={18} color="#d3869b" />
<span className="dub-trans-overlay__title">{t('dub.transcribing')}</span>
</div>
<div className="dub-trans-overlay__stats">
<span> {mm}:{ss} {t('dub.elapsed')}</span>
{est > 0 && <span>~{Math.max(0, est - elapsed)}{t('dub.remaining')}</span>}
</div>
{duration > 0 && (
<div className="dub-trans-overlay__bar">
<Progress value={Math.min(95, (elapsed / est) * 100)} tone="brand" size="sm" />
</div>
)}
<Button variant="danger" size="sm" onClick={onAbort} leading={<Square size={11} />}>
{t('dub.prep_stop')}
</Button>
</div>
);
}
/**
* FooterBtn — the gradient-per-tone download button family in the action footer.
* Uses the legacy .btn-primary as the shape/hover base, just picks a tone class.
* forwardRef so <Menu> can wire its triggerRef to the underlying button —
* without this the Export menu can't compute coords and never opens.
*/
const FooterBtn = React.forwardRef(function FooterBtn(
{ tone = 'idle', sm = false, disabled, onClick, icon, label, ...rest },
ref,
) {
const cls = [
'btn-primary',
'dub-footer-btn',
sm && 'dub-footer-btn--sm',
`dub-footer-btn--${tone}`,
].filter(Boolean).join(' ');
return (
<button ref={ref} className={cls} disabled={disabled} onClick={onClick} {...rest}>
{icon} {label}
</button>
);
});