diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf8d32ab..f860acc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
+- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
diff --git a/README.md b/README.md
index a609f45b..af709946 100644
--- a/README.md
+++ b/README.md
@@ -436,7 +436,7 @@ Ships two [skills](https://skills.sh):
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
-| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
+| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
diff --git a/frontend/src/components/MultiLangPicker.jsx b/frontend/src/components/MultiLangPicker.jsx
index 36458c2f..bf0bfa62 100644
--- a/frontend/src/components/MultiLangPicker.jsx
+++ b/frontend/src/components/MultiLangPicker.jsx
@@ -1,4 +1,5 @@
-import React, { useState, useMemo, useRef, useEffect } from 'react';
+import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useId } from 'react';
+import { createPortal } from 'react-dom';
import { X, Search, Globe, Plus } from 'lucide-react';
import { POPULAR_LANGS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
@@ -19,16 +20,68 @@ export default function MultiLangPicker({
const [dropOpen, setDropOpen] = useState(false);
const [query, setQuery] = useState('');
const dropRef = useRef(null);
+ const menuRef = useRef(null);
+ const triggerRef = useRef(null);
const inputRef = useRef(null);
+ const menuId = useId();
+ const [menuPos, setMenuPos] = useState(null);
// Close dropdown on outside click
useEffect(() => {
if (!dropOpen) return;
const handler = (e) => {
- if (dropRef.current && !dropRef.current.contains(e.target)) setDropOpen(false);
+ const insideTrigger = dropRef.current?.contains(e.target);
+ const insideMenu = menuRef.current?.contains(e.target);
+ if (!insideTrigger && !insideMenu) setDropOpen(false);
+ };
+ const onKeyDown = (e) => {
+ if (e.key !== 'Escape') return;
+ setDropOpen(false);
+ triggerRef.current?.focus();
};
document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
+ document.addEventListener('keydown', onKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handler);
+ document.removeEventListener('keydown', onKeyDown);
+ };
+ }, [dropOpen]);
+
+ // The picker appears inside scrollable/clipping dubbing panels. A body
+ // portal escapes every overflow ancestor; fixed positioning plus an
+ // above/below flip keeps the menu inside the viewport at either edge.
+ useLayoutEffect(() => {
+ if (!dropOpen) return;
+ const place = () => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+ const rect = trigger.getBoundingClientRect();
+ const margin = 8;
+ const gap = 4;
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const width = Math.min(Math.max(rect.width, 220), viewportWidth - margin * 2);
+ const left = Math.min(
+ Math.max(margin, rect.left),
+ Math.max(margin, viewportWidth - width - margin),
+ );
+ const below = viewportHeight - rect.bottom - gap - margin;
+ const above = rect.top - gap - margin;
+ const openUp = below < 260 && above > below;
+ const maxHeight = Math.max(0, Math.min(260, openUp ? above : below));
+ setMenuPos(
+ openUp
+ ? { bottom: viewportHeight - rect.top + gap, left, width, maxHeight }
+ : { top: rect.bottom + gap, left, width, maxHeight },
+ );
+ };
+ place();
+ window.addEventListener('scroll', place, true);
+ window.addEventListener('resize', place);
+ return () => {
+ window.removeEventListener('scroll', place, true);
+ window.removeEventListener('resize', place);
+ };
}, [dropOpen]);
// Focus search when dropdown opens
@@ -40,12 +93,12 @@ export default function MultiLangPicker({
const addLang = (lang, code) => {
if (selectedCodes.has(code)) return;
- onChange([...selected, { lang, code }]);
+ onChange?.([...selected, { lang, code }]);
setQuery('');
};
const removeLang = (code) => {
- onChange(selected.filter((s) => s.code !== code));
+ onChange?.(selected.filter((s) => s.code !== code));
};
const filteredLangs = useMemo(() => {
@@ -78,28 +131,33 @@ export default function MultiLangPicker({
key={s.code}
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
>
-
+
{s.code}
{!disabled && (
)}
))}
{!disabled && (
)}
@@ -110,69 +168,90 @@ export default function MultiLangPicker({
)}
- {dropOpen && (
-
-
-
- setQuery(e.target.value)}
- placeholder={t('dub.search_languages')}
- spellCheck={false}
- className="flex-1 bg-transparent border-0 outline-none text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.78rem]"
- />
-
-
- {popularFiltered.length > 0 && (
- <>
-
- {t('dub.popular')}
-
- {popularFiltered.map((item) => (
-
- ))}
- >
- )}
-
- {t('dub.all_languages')}
+ {dropOpen &&
+ createPortal(
+
+
,
+ document.body,
+ )}
);
}
diff --git a/frontend/src/components/MultiLangPicker.test.jsx b/frontend/src/components/MultiLangPicker.test.jsx
new file mode 100644
index 00000000..2cc1e08f
--- /dev/null
+++ b/frontend/src/components/MultiLangPicker.test.jsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, fireEvent, screen } from '@testing-library/react';
+import '../i18n';
+import MultiLangPicker from './MultiLangPicker';
+
+const rect = (overrides = {}) => ({
+ x: 40,
+ y: 720,
+ top: 720,
+ right: 64,
+ bottom: 744,
+ left: 40,
+ width: 24,
+ height: 24,
+ toJSON: () => {},
+ ...overrides,
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('MultiLangPicker viewport-safe menu', () => {
+ it('portals outside clipping ancestors and flips above a bottom-edge trigger', () => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
+ const onChange = vi.fn();
+ const { container } = render(
+
+
+
,
+ );
+ const trigger = screen.getByRole('button', { name: 'Add language' });
+ vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(rect());
+
+ fireEvent.click(trigger);
+
+ const menu = screen.getByRole('dialog', { name: 'Add language' });
+ expect(container).not.toContainElement(menu);
+ expect(menu).toHaveStyle({ bottom: '84px', left: '40px', width: '220px' });
+ expect(menu.style.top).toBe('');
+ expect(menu.style.maxHeight).toBe('260px');
+
+ fireEvent.click(screen.getAllByRole('button', { name: /Spanish/ })[0]);
+ expect(onChange).toHaveBeenCalledWith([{ lang: 'Spanish', code: 'es' }]);
+ });
+
+ it('opens below when space permits and Escape closes then restores trigger focus', () => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
+ render(
);
+ const trigger = screen.getByRole('button', { name: 'Add language' });
+ vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(
+ rect({ y: 20, top: 20, bottom: 44 }),
+ );
+
+ fireEvent.click(trigger);
+ const menu = screen.getByRole('dialog', { name: 'Add language' });
+ expect(menu).toHaveStyle({ top: '48px' });
+ expect(menu.style.bottom).toBe('');
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('dialog', { name: 'Add language' })).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+});
diff --git a/frontend/src/components/dub/DubRightColumn.jsx b/frontend/src/components/dub/DubRightColumn.jsx
index 28492e74..01d12646 100644
--- a/frontend/src/components/dub/DubRightColumn.jsx
+++ b/frontend/src/components/dub/DubRightColumn.jsx
@@ -32,6 +32,11 @@ export default function DubRightColumn({
defaultTrack,
setDefaultTrack,
dubLangCode,
+ multiLangMode,
+ batchTargets,
+ multiBatchBusy,
+ setDubLang,
+ setDubLangCode,
dubTracks,
timingStrategy,
setTimingStrategy,
@@ -329,6 +334,32 @@ export default function DubRightColumn({
/>
)}
+ {multiLangMode && batchTargets?.length > 1 && (
+
+ {t('dub.language')}:
+ {batchTargets.map((target) => (
+
+ ))}
+
+ )}
+
{/* Segment-table toolbar. "Paste translation" is the manual counterpart
to Translate All: the user translated elsewhere (ChatGPT/DeepL/a
human) and pastes the result onto the timing we already have. */}
diff --git a/frontend/src/components/dub/DubRightColumn.test.jsx b/frontend/src/components/dub/DubRightColumn.test.jsx
new file mode 100644
index 00000000..c9d82c6a
--- /dev/null
+++ b/frontend/src/components/dub/DubRightColumn.test.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../DubSegmentTable', () => ({ default: () => null }));
+
+import DubRightColumn from './DubRightColumn';
+
+const noop = () => {};
+
+function column(multiBatchBusy, setDubLang = noop, setDubLangCode = noop) {
+ return (
+
key}
+ preserveBg={false}
+ setPreserveBg={noop}
+ dualSubs={false}
+ setDualSubs={noop}
+ burnSubs={false}
+ setBurnSubs={noop}
+ defaultTrack="original"
+ setDefaultTrack={noop}
+ dubLangCode="bn"
+ multiLangMode
+ batchTargets={[
+ { lang: 'Bengali', code: 'bn' },
+ { lang: 'Spanish', code: 'es' },
+ ]}
+ multiBatchBusy={multiBatchBusy}
+ setDubLang={setDubLang}
+ setDubLangCode={setDubLangCode}
+ dubTracks={[]}
+ timingStrategy="concise"
+ setTimingStrategy={noop}
+ voiceMatch="per_line"
+ setVoiceMatch={noop}
+ dubTranscript=""
+ showTranscript={false}
+ setShowTranscript={noop}
+ dubJobId={null}
+ glossaryVisible={false}
+ selectedSegIds={new Set()}
+ speakerClones={{}}
+ profiles={[]}
+ showCheckpoint={false}
+ isTranslating={false}
+ dubSegments={[]}
+ dubStep="editing"
+ />
+ );
+}
+
+describe('DubRightColumn language targets', () => {
+ it('disables language switches for the full shared batch lock', async () => {
+ const setDubLang = vi.fn();
+ const setDubLangCode = vi.fn();
+ let rerender;
+ await act(async () => {
+ ({ rerender } = render(column(true, setDubLang, setDubLangCode)));
+ await Promise.resolve();
+ });
+ const spanish = screen.getByRole('button', { name: 'ES' });
+
+ expect(spanish).toBeDisabled();
+ fireEvent.click(spanish);
+ expect(setDubLangCode).not.toHaveBeenCalled();
+
+ await act(async () => {
+ rerender(column(false, setDubLang, setDubLangCode));
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'ES' }));
+ expect(setDubLang).toHaveBeenCalledWith('Spanish');
+ expect(setDubLangCode).toHaveBeenCalledWith('es');
+ });
+});
diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json
index 730b1afa..3455c50c 100644
--- a/frontend/src/i18n/locales/ar.json
+++ b/frontend/src/i18n/locales/ar.json
@@ -330,7 +330,8 @@
"no": "لا",
"more_info": "مزيد من المعلومات",
"learn_more": "تعلم المزيد",
- "saving": "جارٍ الحفظ…"
+ "saving": "جارٍ الحفظ…",
+ "remove": "إزالة {{term}}"
},
"launchpad": {
"greeting": "مرحبا هناك",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "لا مطابقة — دون تغيير",
"paste_translation_apply": "تطبيق على {{count}} مقاطع",
"paste_translation_cancel": "إلغاء",
- "paste_translation_more_rows": "+ {{count}} صفوف إضافية"
+ "paste_translation_more_rows": "+ {{count}} صفوف إضافية",
+ "languages_selected": "تم تحديد {{count}} لغة",
+ "multi_translating": "جارٍ الترجمة ← {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "فشلت ترجمة {{langs}}، لذا تم تخطي عمليات الدبلجة هذه لتجنب إنشاء مسار بلغة خاطئة."
},
"glossary": {
"title": "مسرد",
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 9c05b4b9..075601b4 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -330,7 +330,8 @@
"no": "Nein",
"more_info": "Weitere Informationen",
"learn_more": "Erfahren Sie mehr",
- "saving": "Speichern…"
+ "saving": "Speichern…",
+ "remove": "{{term}} entfernen"
},
"launchpad": {
"greeting": "Hallo",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} abgelaufen",
"add_language": "Sprache hinzufügen",
"search_languages": "Sprachen suchen…",
- "languages_selected_one": "{{count}} Sprache ausgewählt",
- "languages_selected_other": "{{count}} Sprachen ausgewählt",
"more_to_narrow": "+{{count}} mehr – zum Eingrenzen eingeben",
"no_matches": "Keine Übereinstimmungen",
"diagnostic_copied": "Diagnose kopiert",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "keine Zuordnung — unverändert",
"paste_translation_apply": "Auf {{count}} Segmente anwenden",
"paste_translation_cancel": "Abbrechen",
- "paste_translation_more_rows": "+ {{count}} weitere Zeilen"
+ "paste_translation_more_rows": "+ {{count}} weitere Zeilen",
+ "languages_selected": "Ausgewählte Sprachen: {{count}}",
+ "languages_selected_one": "{{count}} Sprache ausgewählt",
+ "languages_selected_other": "{{count}} Sprachen ausgewählt",
+ "multi_translating": "Übersetzung → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Die Übersetzung für {{langs}} ist fehlgeschlagen. Diese Synchronfassungen wurden übersprungen, damit keine Spur in der falschen Sprache entsteht."
},
"glossary": {
"title": "Glossar",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index a33edb98..eaae1e5e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1121,8 +1121,6 @@
"all_languages": "All languages",
"add_language": "Add language",
"search_languages": "Search languages…",
- "languages_selected_one": "{{count}} language selected",
- "languages_selected_other": "{{count}} languages selected",
"more_to_narrow": "+{{count}} more — type to narrow",
"no_matches": "No matches",
"engine_label": "Engine",
@@ -1212,7 +1210,10 @@
"paste_translation_row_unmatched": "no match — left unchanged",
"paste_translation_apply": "Apply to {{count}} segments",
"paste_translation_cancel": "Cancel",
- "paste_translation_more_rows": "+ {{count}} more rows"
+ "paste_translation_more_rows": "+ {{count}} more rows",
+ "languages_selected": "Languages selected: {{count}}",
+ "languages_selected_one": "{{count}} language selected",
+ "languages_selected_other": "{{count}} languages selected"
},
"glossary": {
"title": "Glossary",
@@ -2013,7 +2014,8 @@
"more_info": "More info",
"learn_more": "Learn more",
"saving": "Saving…",
- "unknown": "unknown"
+ "unknown": "unknown",
+ "remove": "Remove {{term}}"
},
"keyboard": {
"title": "Keyboard shortcuts",
diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json
index b1ce7860..6a248681 100644
--- a/frontend/src/i18n/locales/es.json
+++ b/frontend/src/i18n/locales/es.json
@@ -330,7 +330,8 @@
"no": "No",
"more_info": "Más información",
"learn_more": "Más información",
- "saving": "Guardando…"
+ "saving": "Guardando…",
+ "remove": "Eliminar {{term}}"
},
"launchpad": {
"greeting": "hola",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} transcurrido",
"add_language": "Agregar idioma",
"search_languages": "Buscar idiomas…",
- "languages_selected_one": "{{count}} idioma seleccionado",
- "languages_selected_other": "{{count}} idiomas seleccionados",
"more_to_narrow": "+{{count}} más — escribe para limitar",
"no_matches": "No hay coincidencias",
"diagnostic_copied": "Diagnóstico copiado",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "sin coincidencia — sin cambios",
"paste_translation_apply": "Aplicar a {{count}} segmentos",
"paste_translation_cancel": "Cancelar",
- "paste_translation_more_rows": "+ {{count}} filas más"
+ "paste_translation_more_rows": "+ {{count}} filas más",
+ "languages_selected": "Idiomas seleccionados: {{count}}",
+ "languages_selected_one": "{{count}} idioma seleccionado",
+ "languages_selected_other": "{{count}} idiomas seleccionados",
+ "multi_translating": "Traduciendo → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Falló la traducción de {{langs}}; esos doblajes se omitieron para evitar pistas en el idioma incorrecto."
},
"glossary": {
"title": "Glosario",
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index bb729514..d90a39a0 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -330,7 +330,8 @@
"no": "Non",
"more_info": "Plus d'informations",
"learn_more": "En savoir plus",
- "saving": "Enregistrement…"
+ "saving": "Enregistrement…",
+ "remove": "Supprimer {{term}}"
},
"launchpad": {
"greeting": "bonjour",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} écoulé",
"add_language": "Ajouter une langue",
"search_languages": "Rechercher des langues…",
- "languages_selected_one": "{{count}} langue sélectionnée",
- "languages_selected_other": "{{count}} langues sélectionnées",
"more_to_narrow": "+{{count}} plus — tapez pour affiner",
"no_matches": "Aucune correspondance",
"diagnostic_copied": "Diagnostic copié",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "aucune correspondance — inchangé",
"paste_translation_apply": "Appliquer à {{count}} segments",
"paste_translation_cancel": "Annuler",
- "paste_translation_more_rows": "+ {{count}} lignes supplémentaires"
+ "paste_translation_more_rows": "+ {{count}} lignes supplémentaires",
+ "languages_selected": "Langues sélectionnées : {{count}}",
+ "languages_selected_one": "{{count}} langue sélectionnée",
+ "languages_selected_other": "{{count}} langues sélectionnées",
+ "multi_translating": "Traduction → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "La traduction a échoué pour {{langs}} ; ces doublages ont été ignorés afin d’éviter une piste dans la mauvaise langue."
},
"glossary": {
"title": "Glossaire",
diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json
index 9da1077e..8ab69b9e 100644
--- a/frontend/src/i18n/locales/hi.json
+++ b/frontend/src/i18n/locales/hi.json
@@ -330,7 +330,8 @@
"no": "नहीं",
"more_info": "अधिक जानकारी",
"learn_more": "और जानें",
- "saving": "सहेजा जा रहा है…"
+ "saving": "सहेजा जा रहा है…",
+ "remove": "{{term}} हटाएं"
},
"launchpad": {
"greeting": "नमस्ते",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} बीत गया",
"add_language": "भाषा जोड़ें",
"search_languages": "भाषाएँ खोजें...",
- "languages_selected_one": "{{count}} भाषा चयनित",
- "languages_selected_other": "{{count}} भाषाएँ चयनित",
"more_to_narrow": "+{{count}} अधिक - संकीर्ण करने के लिए टाइप करें",
"no_matches": "कोई मेल नहीं",
"diagnostic_copied": "डायग्नोस्टिक कॉपी किया गया",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "कोई मिलान नहीं — अपरिवर्तित",
"paste_translation_apply": "{{count}} सेगमेंट पर लागू करें",
"paste_translation_cancel": "रद्द करें",
- "paste_translation_more_rows": "+ {{count}} और पंक्तियाँ"
+ "paste_translation_more_rows": "+ {{count}} और पंक्तियाँ",
+ "languages_selected": "चयनित भाषाएँ: {{count}}",
+ "languages_selected_one": "{{count}} भाषा चुनी गई",
+ "languages_selected_other": "{{count}} भाषाएँ चुनी गईं",
+ "multi_translating": "अनुवाद हो रहा है → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} के लिए अनुवाद विफल रहा—गलत भाषा का ट्रैक बनने से रोकने के लिए उन डब को छोड़ दिया गया।"
},
"glossary": {
"title": "शब्दावली",
diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json
index 8869c9e2..bad9135b 100644
--- a/frontend/src/i18n/locales/id.json
+++ b/frontend/src/i18n/locales/id.json
@@ -330,7 +330,8 @@
"no": "Tidak",
"more_info": "Informasi lebih lanjut",
"learn_more": "Pelajari lebih lanjut",
- "saving": "Menyimpan…"
+ "saving": "Menyimpan…",
+ "remove": "Hapus {{term}}"
},
"launchpad": {
"greeting": "halo",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "tidak cocok — tidak diubah",
"paste_translation_apply": "Terapkan ke {{count}} segmen",
"paste_translation_cancel": "Batal",
- "paste_translation_more_rows": "+ {{count}} baris lagi"
+ "paste_translation_more_rows": "+ {{count}} baris lagi",
+ "languages_selected": "{{count}} bahasa dipilih",
+ "multi_translating": "Menerjemahkan → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Terjemahan untuk {{langs}} gagal; sulih suara tersebut dilewati agar tidak menghasilkan trek dalam bahasa yang salah."
},
"glossary": {
"title": "Glosarium",
diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json
index c4d036d9..5e894346 100644
--- a/frontend/src/i18n/locales/it.json
+++ b/frontend/src/i18n/locales/it.json
@@ -330,7 +330,8 @@
"no": "No",
"more_info": "Maggiori informazioni",
"learn_more": "Scopri di più",
- "saving": "Salvataggio…"
+ "saving": "Salvataggio…",
+ "remove": "Rimuovi {{term}}"
},
"launchpad": {
"greeting": "ciao a tutti",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} trascorso",
"add_language": "Aggiungi lingua",
"search_languages": "Cerca lingue…",
- "languages_selected_one": "{{count}} lingua selezionata",
- "languages_selected_other": "{{count}} lingue selezionate",
"more_to_narrow": "+{{count}} altro: digita per restringere",
"no_matches": "Nessuna corrispondenza",
"diagnostic_copied": "Diagnostica copiata",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "nessuna corrispondenza — invariato",
"paste_translation_apply": "Applica a {{count}} segmenti",
"paste_translation_cancel": "Annulla",
- "paste_translation_more_rows": "+ altre {{count}} righe"
+ "paste_translation_more_rows": "Altre righe: {{count}}",
+ "languages_selected": "Lingue selezionate: {{count}}",
+ "languages_selected_one": "{{count}} lingua selezionata",
+ "languages_selected_other": "{{count}} lingue selezionate",
+ "multi_translating": "Traduzione → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "La traduzione non è riuscita per {{langs}}; i relativi doppiaggi sono stati ignorati per evitare una traccia nella lingua sbagliata."
},
"glossary": {
"title": "Glossario",
diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json
index 726adddb..d76e4815 100644
--- a/frontend/src/i18n/locales/ja.json
+++ b/frontend/src/i18n/locales/ja.json
@@ -330,7 +330,8 @@
"no": "いいえ",
"more_info": "詳細情報",
"learn_more": "さらに詳しく",
- "saving": "保存中…"
+ "saving": "保存中…",
+ "remove": "{{term}} を削除"
},
"launchpad": {
"greeting": "こんにちは。",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "一致なし — 変更しません",
"paste_translation_apply": "{{count}} セグメントに適用",
"paste_translation_cancel": "キャンセル",
- "paste_translation_more_rows": "他 {{count}} 行"
+ "paste_translation_more_rows": "他 {{count}} 行",
+ "languages_selected": "{{count}} 言語を選択",
+ "multi_translating": "翻訳中 → {{lang}}({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} の翻訳に失敗したため、誤った言語のトラックが生成されないよう該当する吹き替えをスキップしました。"
},
"glossary": {
"title": "用語集",
diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json
index 3a8d4d34..a696b3c6 100644
--- a/frontend/src/i18n/locales/ko.json
+++ b/frontend/src/i18n/locales/ko.json
@@ -330,7 +330,8 @@
"no": "아니요",
"more_info": "추가 정보",
"learn_more": "자세히 알아보기",
- "saving": "저장 중…"
+ "saving": "저장 중…",
+ "remove": "{{term}} 삭제"
},
"launchpad": {
"greeting": "안녕하세요",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "일치 없음 — 변경 안 함",
"paste_translation_apply": "{{count}}개 세그먼트에 적용",
"paste_translation_cancel": "취소",
- "paste_translation_more_rows": "+ {{count}}개 행 더"
+ "paste_translation_more_rows": "+ {{count}}개 행 더",
+ "languages_selected": "{{count}}개 언어 선택됨",
+ "multi_translating": "번역 중 → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} 번역에 실패하여 잘못된 언어의 트랙이 생성되지 않도록 해당 더빙을 건너뛰었습니다."
},
"glossary": {
"title": "용어집",
diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json
index 7d35e0ac..49462f95 100644
--- a/frontend/src/i18n/locales/nl.json
+++ b/frontend/src/i18n/locales/nl.json
@@ -330,7 +330,8 @@
"no": "Nee",
"more_info": "Meer informatie",
"learn_more": "Meer informatie",
- "saving": "Opslaan…"
+ "saving": "Opslaan…",
+ "remove": "{{term}} verwijderen"
},
"launchpad": {
"greeting": "Hallo daar",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} verstreken",
"add_language": "Taal toevoegen",
"search_languages": "Talen zoeken…",
- "languages_selected_one": "{{count}} taal geselecteerd",
- "languages_selected_other": "{{count}} talen geselecteerd",
"more_to_narrow": "+{{count}} meer — typ om te verkleinen",
"no_matches": "Geen overeenkomsten",
"diagnostic_copied": "Diagnostiek gekopieerd",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "geen match — ongewijzigd",
"paste_translation_apply": "Toepassen op {{count}} segmenten",
"paste_translation_cancel": "Annuleren",
- "paste_translation_more_rows": "+ nog {{count}} rijen"
+ "paste_translation_more_rows": "Extra rijen: {{count}}",
+ "languages_selected": "Geselecteerde talen: {{count}}",
+ "languages_selected_one": "{{count}} taal geselecteerd",
+ "languages_selected_other": "{{count}} talen geselecteerd",
+ "multi_translating": "Vertalen → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "De vertaling voor {{langs}} is mislukt. Deze nasynchronisaties zijn overgeslagen om een track in de verkeerde taal te voorkomen."
},
"glossary": {
"title": "Woordenlijst",
diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json
index 6afb9f0b..1e74d7c4 100644
--- a/frontend/src/i18n/locales/pl.json
+++ b/frontend/src/i18n/locales/pl.json
@@ -330,7 +330,8 @@
"no": "Nie",
"more_info": "Więcej informacji",
"learn_more": "Dowiedz się więcej",
- "saving": "Zapisywanie…"
+ "saving": "Zapisywanie…",
+ "remove": "Usuń {{term}}"
},
"launchpad": {
"greeting": "cześć",
@@ -884,8 +885,6 @@
"prep_elapsed": "upłynął {{time}}",
"add_language": "Dodaj język",
"search_languages": "Wyszukaj języki…",
- "languages_selected_one": "Wybrano {{count}} język",
- "languages_selected_other": "Wybrano {{count}} języki",
"more_to_narrow": "+{{count}} więcej — wpisz, aby zawęzić",
"no_matches": "Brak dopasowań",
"diagnostic_copied": "Diagnostyka skopiowana",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "brak dopasowania — bez zmian",
"paste_translation_apply": "Zastosuj do {{count}} segmentów",
"paste_translation_cancel": "Anuluj",
- "paste_translation_more_rows": "+ jeszcze {{count}} wierszy"
+ "paste_translation_more_rows": "Dodatkowe wiersze: {{count}}",
+ "languages_selected": "Wybrane języki: {{count}}",
+ "languages_selected_one": "Wybrano {{count}} język",
+ "languages_selected_other": "Wybrano {{count}} języka",
+ "multi_translating": "Tłumaczenie → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Tłumaczenie dla {{langs}} nie powiodło się. Te dubbingi pominięto, aby nie utworzyć ścieżki w niewłaściwym języku."
},
"glossary": {
"title": "Glosariusz",
diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json
index 671fdad7..8baa2cb5 100644
--- a/frontend/src/i18n/locales/pt.json
+++ b/frontend/src/i18n/locales/pt.json
@@ -330,7 +330,8 @@
"no": "Não",
"more_info": "Mais informações",
"learn_more": "Saiba mais",
- "saving": "Salvando…"
+ "saving": "Salvando…",
+ "remove": "Remover {{term}}"
},
"launchpad": {
"greeting": "Olá",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} decorrido",
"add_language": "Adicionar idioma",
"search_languages": "Pesquisar idiomas…",
- "languages_selected_one": "{{count}} idioma selecionado",
- "languages_selected_other": "{{count}} idiomas selecionados",
"more_to_narrow": "+{{count}} mais — digite para restringir",
"no_matches": "Nenhuma correspondência",
"diagnostic_copied": "Diagnóstico copiado",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "sem correspondência — inalterado",
"paste_translation_apply": "Aplicar a {{count}} segmentos",
"paste_translation_cancel": "Cancelar",
- "paste_translation_more_rows": "+ {{count}} linhas adicionais"
+ "paste_translation_more_rows": "Linhas adicionais: {{count}}",
+ "languages_selected": "Idiomas selecionados: {{count}}",
+ "languages_selected_one": "{{count}} idioma selecionado",
+ "languages_selected_other": "{{count}} idiomas selecionados",
+ "multi_translating": "Traduzindo → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "A tradução de {{langs}} falhou; essas dublagens foram ignoradas para evitar uma faixa no idioma errado."
},
"glossary": {
"title": "Glossário",
diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json
index e0183348..067a1abb 100644
--- a/frontend/src/i18n/locales/ru.json
+++ b/frontend/src/i18n/locales/ru.json
@@ -330,7 +330,8 @@
"no": "Нет",
"more_info": "Дополнительная информация",
"learn_more": "Узнать больше",
- "saving": "Сохранение…"
+ "saving": "Сохранение…",
+ "remove": "Удалить {{term}}"
},
"launchpad": {
"greeting": "привет",
@@ -884,8 +885,6 @@
"prep_elapsed": "{{time}} прошло",
"add_language": "Добавить язык",
"search_languages": "Поиск языков…",
- "languages_selected_one": "{{count}} выбран язык",
- "languages_selected_other": "Выбрано {{count}} языков",
"more_to_narrow": "+{{count}} еще — введите текст, чтобы сузить",
"no_matches": "Нет совпадений",
"diagnostic_copied": "Диагностика скопирована",
@@ -924,7 +923,12 @@
"paste_translation_row_unmatched": "нет совпадения — без изменений",
"paste_translation_apply": "Применить к {{count}} сегментам",
"paste_translation_cancel": "Отмена",
- "paste_translation_more_rows": "+ ещё {{count}} строк"
+ "paste_translation_more_rows": "Дополнительных строк: {{count}}",
+ "languages_selected": "Выбрано языков: {{count}}",
+ "languages_selected_one": "{{count}} выбран язык",
+ "languages_selected_other": "Выбрано {{count}} языка",
+ "multi_translating": "Перевод → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Не удалось перевести {{langs}}. Эти дорожки пропущены, чтобы не создать озвучку на неверном языке."
},
"glossary": {
"title": "Глоссарий",
diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json
index 096ff9f6..8e4a3cc6 100644
--- a/frontend/src/i18n/locales/sv.json
+++ b/frontend/src/i18n/locales/sv.json
@@ -330,7 +330,8 @@
"no": "Nej",
"more_info": "Mer info",
"learn_more": "Läs mer",
- "saving": "Sparar…"
+ "saving": "Sparar…",
+ "remove": "Ta bort {{term}}"
},
"launchpad": {
"greeting": "hej då",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "ingen träff — oförändrad",
"paste_translation_apply": "Använd på {{count}} segment",
"paste_translation_cancel": "Avbryt",
- "paste_translation_more_rows": "+ {{count}} rader till"
+ "paste_translation_more_rows": "+ {{count}} rader till",
+ "languages_selected": "{{count}} språk har valts",
+ "multi_translating": "Översätter → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Översättningen misslyckades för {{langs}}. Dessa dubbningar hoppades över för att undvika ett spår på fel språk."
},
"glossary": {
"title": "Ordlista",
diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json
index b1321198..5036a126 100644
--- a/frontend/src/i18n/locales/th.json
+++ b/frontend/src/i18n/locales/th.json
@@ -330,7 +330,8 @@
"no": "ไม่",
"more_info": "ข้อมูลเพิ่มเติม",
"learn_more": "เรียนรู้เพิ่มเติม",
- "saving": "กำลังบันทึก…"
+ "saving": "กำลังบันทึก…",
+ "remove": "ลบ {{term}}"
},
"launchpad": {
"greeting": "สวัสดี",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "ไม่จับคู่ — คงเดิม",
"paste_translation_apply": "ใช้กับ {{count}} เซกเมนต์",
"paste_translation_cancel": "ยกเลิก",
- "paste_translation_more_rows": "+ อีก {{count}} แถว"
+ "paste_translation_more_rows": "+ อีก {{count}} แถว",
+ "languages_selected": "เลือก {{count}} ภาษาแล้ว",
+ "multi_translating": "กำลังแปล → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "การแปลสำหรับ {{langs}} ล้มเหลว จึงข้ามเสียงพากย์เหล่านั้นเพื่อป้องกันไม่ให้สร้างแทร็กผิดภาษา"
},
"glossary": {
"title": "อภิธานศัพท์",
diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json
index 9beb0e71..dac71508 100644
--- a/frontend/src/i18n/locales/tr.json
+++ b/frontend/src/i18n/locales/tr.json
@@ -330,7 +330,8 @@
"no": "Hayır",
"more_info": "Daha fazla bilgi",
"learn_more": "Daha fazla bilgi edinin",
- "saving": "Kaydediliyor…"
+ "saving": "Kaydediliyor…",
+ "remove": "{{term}} öğesini kaldır"
},
"launchpad": {
"greeting": "merhaba orada",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "eşleşme yok — değişmedi",
"paste_translation_apply": "{{count}} segmente uygula",
"paste_translation_cancel": "İptal",
- "paste_translation_more_rows": "+ {{count}} satır daha"
+ "paste_translation_more_rows": "+ {{count}} satır daha",
+ "languages_selected": "{{count}} dil seçildi",
+ "multi_translating": "Çevriliyor → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} için çeviri başarısız oldu; yanlış dilde parça oluşmaması için bu dublajlar atlandı."
},
"glossary": {
"title": "Sözlük",
diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json
index 0d958189..a93cd2cd 100644
--- a/frontend/src/i18n/locales/uk.json
+++ b/frontend/src/i18n/locales/uk.json
@@ -330,7 +330,8 @@
"no": "немає",
"more_info": "Більше інформації",
"learn_more": "Дізнайтесь більше",
- "saving": "Збереження…"
+ "saving": "Збереження…",
+ "remove": "Видалити {{term}}"
},
"launchpad": {
"greeting": "привіт",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "немає відповідності — без змін",
"paste_translation_apply": "Застосувати до {{count}} сегментів",
"paste_translation_cancel": "Скасувати",
- "paste_translation_more_rows": "+ ще {{count}} рядків"
+ "paste_translation_more_rows": "+ ще {{count}} рядків",
+ "languages_selected": "Вибрано {{count}} мов",
+ "multi_translating": "Переклад → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Не вдалося перекласти {{langs}}. Ці доріжки пропущено, щоб не створити дубляж неправильною мовою."
},
"glossary": {
"title": "Глосарій",
diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json
index 6200a876..322c3ab4 100644
--- a/frontend/src/i18n/locales/vi.json
+++ b/frontend/src/i18n/locales/vi.json
@@ -330,7 +330,8 @@
"no": "Không",
"more_info": "Thêm thông tin",
"learn_more": "Tìm hiểu thêm",
- "saving": "Đang lưu…"
+ "saving": "Đang lưu…",
+ "remove": "Xóa {{term}}"
},
"launchpad": {
"greeting": "xin chào ở đó",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "không khớp — giữ nguyên",
"paste_translation_apply": "Áp dụng cho {{count}} phân đoạn",
"paste_translation_cancel": "Hủy",
- "paste_translation_more_rows": "+ {{count}} dòng nữa"
+ "paste_translation_more_rows": "+ {{count}} dòng nữa",
+ "languages_selected": "Đã chọn {{count}} ngôn ngữ",
+ "multi_translating": "Đang dịch → {{lang}} ({{current}}/{{total}})…",
+ "multi_lang_skipped": "Không thể dịch {{langs}}; các bản lồng tiếng đó đã được bỏ qua để tránh tạo bản nhạc sai ngôn ngữ."
},
"glossary": {
"title": "Thuật ngữ",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index a88d0366..8daec57d 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -112,7 +112,8 @@
"no": "否",
"more_info": "更多信息",
"learn_more": "了解更多",
- "saving": "保存中…"
+ "saving": "保存中…",
+ "remove": "删除 {{term}}"
},
"nav": {
"launchpad": "工作台",
@@ -883,7 +884,10 @@
"paste_translation_row_unmatched": "无匹配——保持不变",
"paste_translation_apply": "应用到 {{count}} 个片段",
"paste_translation_cancel": "取消",
- "paste_translation_more_rows": "还有 {{count}} 行"
+ "paste_translation_more_rows": "还有 {{count}} 行",
+ "languages_selected": "已选择 {{count}} 种语言",
+ "multi_translating": "正在翻译 → {{lang}}({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} 翻译失败;已跳过这些配音,以免生成语言错误的音轨。"
},
"glossary": {
"title": "术语表",
diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json
index f97ca52a..6d05f598 100644
--- a/frontend/src/i18n/locales/zh-TW.json
+++ b/frontend/src/i18n/locales/zh-TW.json
@@ -330,7 +330,8 @@
"no": "否",
"more_info": "更多資訊",
"learn_more": "了解更多",
- "saving": "儲存中…"
+ "saving": "儲存中…",
+ "remove": "刪除 {{term}}"
},
"launchpad": {
"greeting": "你好",
@@ -924,7 +925,10 @@
"paste_translation_row_unmatched": "無對應——維持原樣",
"paste_translation_apply": "套用到 {{count}} 個片段",
"paste_translation_cancel": "取消",
- "paste_translation_more_rows": "還有 {{count}} 行"
+ "paste_translation_more_rows": "還有 {{count}} 行",
+ "languages_selected": "已選擇 {{count}} 種語言",
+ "multi_translating": "正在翻譯 → {{lang}}({{current}}/{{total}})…",
+ "multi_lang_skipped": "{{langs}} 翻譯失敗;已略過這些配音,以免產生語言錯誤的音軌。"
},
"glossary": {
"title": "詞彙表",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index dcaddd3c..bdfbf9d5 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2493,12 +2493,8 @@ input[type="file"]::file-selector-button:hover {
/* from MultiLangPicker (components/MultiLangPicker.css) */
/* Dropdown */
.multi-lang__drop {
- position: absolute;
- top: 100%;
- left: 0;
- right: 0;
+ position: fixed;
z-index: var(--z-overlay);
- margin-top: 4px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-radius: 8px;
@@ -2513,6 +2509,9 @@ input[type="file"]::file-selector-button:hover {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
+@media (prefers-reduced-motion: reduce) {
+ .multi-lang__drop { animation: none; }
+}
/* from ReadinessChecklist (components/ReadinessChecklist.css) */
/* System readiness panel showing pass/warn/fail gates
diff --git a/frontend/src/pages/DubTab.jsx b/frontend/src/pages/DubTab.jsx
index 344b4699..e732cabe 100644
--- a/frontend/src/pages/DubTab.jsx
+++ b/frontend/src/pages/DubTab.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
import { API } from '../api/client';
@@ -14,6 +14,7 @@ import DubHeader from '../components/dub/DubHeader';
import DubLeftColumn from '../components/dub/DubLeftColumn';
import DubRightColumn from '../components/dub/DubRightColumn';
import DubFooter from '../components/dub/DubFooter';
+import { hasCompleteTranslation, multiLangTargets } from '../utils/multiLang';
export default function DubTab(props) {
const { t, i18n } = useTranslation();
@@ -175,6 +176,40 @@ export default function DubTab(props) {
const setMultiLangMode = useAppStore((s) => s.setMultiLangMode);
const multiLangs = useAppStore((s) => s.multiLangs);
const setMultiLangs = useAppStore((s) => s.setMultiLangs);
+ // The primary target picker and the segment-language switch share the
+ // legacy visible-text slot, but only the picker changes batch membership.
+ const primaryTargetRef = useRef({ lang: dubLang, code: dubLangCode });
+ useEffect(() => {
+ primaryTargetRef.current = { lang: dubLang, code: dubLangCode };
+ // A new backend job starts with the store's restored primary target.
+ // Segment-language switches never change dubJobId and therefore leave it.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dubJobId]);
+ const setPrimaryDubLang = useCallback(
+ (lang) => {
+ primaryTargetRef.current = { ...primaryTargetRef.current, lang };
+ setDubLang(lang);
+ },
+ [setDubLang],
+ );
+ const setPrimaryDubLangCode = useCallback(
+ (code) => {
+ primaryTargetRef.current = { ...primaryTargetRef.current, code };
+ switchDubLangCode(code);
+ },
+ [switchDubLangCode],
+ );
+ // The primary target dropdown is part of a multi-language job too. The
+ // chip picker adds extra targets; it does not replace the active one.
+ const batchTargets = useMemo(
+ () =>
+ multiLangTargets(primaryTargetRef.current.lang, primaryTargetRef.current.code, multiLangs),
+ // The store language values intentionally invalidate this memo after a
+ // primary-picker change; segment switches recompute the same ref-backed
+ // target list without changing batch membership.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [dubLang, dubLangCode, multiLangs],
+ );
// Landing "Advanced" disclosure (pre-upload options).
const [landingAdvOpen, setLandingAdvOpen] = useState(false);
@@ -188,58 +223,71 @@ export default function DubTab(props) {
// A pick whose translate fails is skipped (never render a wrong-language
// track); the batch continues and the skips are reported at the end.
const multiBatchRunningRef = useRef(false);
+ const [multiBatchBusy, setMultiBatchBusy] = useState(false);
const onGenerateClick = useCallback(async () => {
- if (multiLangMode && multiLangs.length > 0) {
+ if (multiLangMode && batchTargets.length > 0) {
if (multiBatchRunningRef.current) return; // ignore re-clicks mid-batch
multiBatchRunningRef.current = true;
+ setMultiBatchBusy(true);
const skipped = [];
- // Skip the redundant translate ONLY for the first pick, and only when
- // it targets the language the editor text is already in (every segment
- // carries a translation differing from its original — i.e. the user
- // just ran Translate All into this exact language). After the first
- // pick the editor text is the previous pick's language, so every later
- // pick always translates. Correctness beats cleverness.
- const editorAlreadyTranslated =
- dubSegments.length > 0 &&
- dubSegments.every((s) => s.text_original && s.text !== s.text_original);
+ const { lang: primaryLanguage, code: primaryCode } = primaryTargetRef.current;
try {
- for (let i = 0; i < multiLangs.length; i++) {
- const l = multiLangs[i];
- setDubLang(l.lang);
- // Keep UI/exports in sync AND snapshot the previous pick's
- // translations before this pick's translate pass overwrites the
- // visible text (P1.2).
- switchDubLangCode(l.code);
- const skipTranslate = i === 0 && l.code === dubLangCode && editorAlreadyTranslated;
- if (!skipTranslate) {
- // Honest phase label: this pill slot otherwise only says
- // "Generating…", hiding the translate pass entirely.
- useAppStore.getState().showPill(
- 'translating',
- t('dub.multi_translating', {
- lang: l.lang,
- current: i + 1,
- total: multiLangs.length,
- }),
- { homeMode: 'dub' },
- );
- // eslint-disable-next-line no-await-in-loop
- const ok = await handleTranslateAll(l.code);
- if (!ok) {
- // Error already surfaced by handleTranslateAll (banner/toast);
- // drop the phase pill and move on to the next language.
- useAppStore.getState().dismissPill();
+ for (let i = 0; i < batchTargets.length; i++) {
+ const l = batchTargets[i];
+ try {
+ setDubLang(l.lang);
+ // Keep UI/exports in sync AND snapshot the previous pick's
+ // translations before this pick's translate pass overwrites the
+ // visible text (P1.2).
+ switchDubLangCode(l.code);
+ // A changed visible text is not proof that it belongs to this
+ // target (switching to an untranslated language intentionally
+ // leaves the previous text visible). Only the per-language map
+ // can safely suppress a redundant translation request.
+ const cached = hasCompleteTranslation(useAppStore.getState().dubSegments, l.code);
+ if (!cached) {
+ // Honest phase label: this pill slot otherwise only says
+ // "Generating…", hiding the translate pass entirely.
+ useAppStore.getState().showPill(
+ 'translating',
+ t('dub.multi_translating', {
+ lang: l.lang,
+ current: i + 1,
+ total: batchTargets.length,
+ }),
+ { homeMode: 'dub' },
+ );
+ const ok = await handleTranslateAll(l.code);
+ // Partial translations are useful for manual review, but an
+ // automated batch must never synthesize the untranslated rows
+ // into a mixed-language track.
+ const complete = hasCompleteTranslation(useAppStore.getState().dubSegments, l.code);
+ if (!ok || !complete) {
+ // Error already surfaced by handleTranslateAll (banner/toast);
+ // drop the phase pill and move on to the next language.
+ useAppStore.getState().dismissPill();
+ skipped.push(l.lang);
+ continue;
+ }
+ }
+ await handleDubGenerate({
+ langOverride: { language: l.lang, language_code: l.code },
+ });
+ } catch {
+ // Keep independent target languages moving after one unexpected
+ // failure; the underlying action already surfaces its error.
+ useAppStore.getState().dismissPill();
+ if (!skipped.includes(l.lang)) {
skipped.push(l.lang);
- continue;
}
}
- // eslint-disable-next-line no-await-in-loop
- await handleDubGenerate({ langOverride: { language: l.lang, language_code: l.code } });
}
- } catch {
- /* a failed language stops the batch; its error is already surfaced */
+ } finally {
+ setDubLang(primaryLanguage);
+ switchDubLangCode(primaryCode);
+ multiBatchRunningRef.current = false;
+ setMultiBatchBusy(false);
}
- multiBatchRunningRef.current = false;
if (skipped.length) {
toast.error(t('dub.multi_lang_skipped', { langs: skipped.join(', ') }), {
duration: 8000,
@@ -250,9 +298,7 @@ export default function DubTab(props) {
}
}, [
multiLangMode,
- multiLangs,
- dubSegments,
- dubLangCode,
+ batchTargets,
handleTranslateAll,
handleDubGenerate,
setDubLang,
@@ -260,6 +306,33 @@ export default function DubTab(props) {
t,
]);
+ // In multi-language mode, "Translate All" means all configured targets,
+ // while still restoring the primary target in the editor afterwards. Each
+ // translation lands in segments[].translations[code], so Generate can
+ // reuse the complete maps without retranslating or losing another language.
+ const onTranslateClick = useCallback(async () => {
+ if (!multiLangMode) return handleTranslateAll();
+ if (multiBatchRunningRef.current) return false;
+ multiBatchRunningRef.current = true;
+ setMultiBatchBusy(true);
+ const { lang: primaryLanguage, code: primaryCode } = primaryTargetRef.current;
+ let allOk = true;
+ try {
+ for (const target of batchTargets) {
+ setDubLang(target.lang);
+ switchDubLangCode(target.code);
+ const ok = await handleTranslateAll(target.code);
+ if (!ok) allOk = false;
+ }
+ } finally {
+ setDubLang(primaryLanguage);
+ switchDubLangCode(primaryCode);
+ multiBatchRunningRef.current = false;
+ setMultiBatchBusy(false);
+ }
+ return allOk;
+ }, [multiLangMode, batchTargets, handleTranslateAll, setDubLang, switchDubLangCode]);
+
// 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.
@@ -618,7 +691,7 @@ export default function DubTab(props) {
onGenerateClick={onGenerateClick}
isTranslating={isTranslating}
multiLangMode={multiLangMode}
- multiLangs={multiLangs}
+ multiLangs={batchTargets}
incrementalPlan={incrementalPlan}
handleDubGenerate={handleDubGenerate}
qcRunning={qcRunning}
@@ -662,12 +735,12 @@ export default function DubTab(props) {
translateProvider={translateProvider}
dubInstruct={dubInstruct}
setDubInstruct={setDubInstruct}
- handleTranslateAll={handleTranslateAll}
+ handleTranslateAll={onTranslateClick}
isTranslating={isTranslating}
hasAnyTranslation={hasAnyTranslation}
handleCleanupSegments={handleCleanupSegments}
- setDubLang={setDubLang}
- setDubLangCode={switchDubLangCode}
+ setDubLang={setPrimaryDubLang}
+ setDubLangCode={setPrimaryDubLangCode}
dubDialect={dubDialect}
setDubDialect={setDubDialect}
i18n={i18n}
@@ -695,6 +768,11 @@ export default function DubTab(props) {
defaultTrack={defaultTrack}
setDefaultTrack={setDefaultTrack}
dubLangCode={dubLangCode}
+ multiLangMode={multiLangMode}
+ batchTargets={batchTargets}
+ multiBatchBusy={multiBatchBusy}
+ setDubLang={setDubLang}
+ setDubLangCode={switchDubLangCode}
dubTracks={dubTracks}
timingStrategy={timingStrategy}
setTimingStrategy={setTimingStrategy}
diff --git a/frontend/src/test/dubLocalePlural.test.js b/frontend/src/test/dubLocalePlural.test.js
new file mode 100644
index 00000000..c2f13639
--- /dev/null
+++ b/frontend/src/test/dubLocalePlural.test.js
@@ -0,0 +1,51 @@
+import i18next from 'i18next';
+import { describe, expect, it } from 'vitest';
+import de from '../i18n/locales/de.json';
+import en from '../i18n/locales/en.json';
+import es from '../i18n/locales/es.json';
+import fr from '../i18n/locales/fr.json';
+import hi from '../i18n/locales/hi.json';
+import itLocale from '../i18n/locales/it.json';
+import nl from '../i18n/locales/nl.json';
+import pl from '../i18n/locales/pl.json';
+import pt from '../i18n/locales/pt.json';
+import ru from '../i18n/locales/ru.json';
+
+const resources = { de, en, es, fr, hi, it: itLocale, nl, pl, pt, ru };
+
+async function translate(locale, key, count) {
+ const instance = i18next.createInstance();
+ await instance.init({
+ lng: locale,
+ fallbackLng: false,
+ resources: { [locale]: { translation: resources[locale] } },
+ });
+ return instance.t(key, { count });
+}
+
+describe('dubbing count translations', () => {
+ it.each([
+ ['en', '1 language selected', '2 languages selected'],
+ ['de', '1 Sprache ausgewählt', '2 Sprachen ausgewählt'],
+ ['es', '1 idioma seleccionado', '2 idiomas seleccionados'],
+ ['fr', '1 langue sélectionnée', '2 langues sélectionnées'],
+ ['hi', '1 भाषा चुनी गई', '2 भाषाएँ चुनी गईं'],
+ ['it', '1 lingua selezionata', '2 lingue selezionate'],
+ ['nl', '1 taal geselecteerd', '2 talen geselecteerd'],
+ ['pt', '1 idioma selecionado', '2 idiomas selecionados'],
+ ])('%s selects singular and plural language wording', async (locale, one, other) => {
+ expect(await translate(locale, 'dub.languages_selected', 1)).toBe(one);
+ expect(await translate(locale, 'dub.languages_selected', 2)).toBe(other);
+ });
+
+ it.each([
+ ['it', 'Altre righe: 1', 'Altre righe: 2'],
+ ['nl', 'Extra rijen: 1', 'Extra rijen: 2'],
+ ['pl', 'Dodatkowe wiersze: 1', 'Dodatkowe wiersze: 2'],
+ ['pt', 'Linhas adicionais: 1', 'Linhas adicionais: 2'],
+ ['ru', 'Дополнительных строк: 1', 'Дополнительных строк: 2'],
+ ])('%s keeps paste row counts grammatical for one and many', async (locale, one, other) => {
+ expect(await translate(locale, 'dub.paste_translation_more_rows', 1)).toBe(one);
+ expect(await translate(locale, 'dub.paste_translation_more_rows', 2)).toBe(other);
+ });
+});
diff --git a/frontend/src/test/dubMultiLangGenerate.test.jsx b/frontend/src/test/dubMultiLangGenerate.test.jsx
index 13a4a4ff..3c779509 100644
--- a/frontend/src/test/dubMultiLangGenerate.test.jsx
+++ b/frontend/src/test/dubMultiLangGenerate.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, act } from '@testing-library/react';
+import { render, act, fireEvent, screen } from '@testing-library/react';
import toast from 'react-hot-toast';
import { useAppStore } from '../store';
@@ -26,7 +26,6 @@ vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null }));
vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
vi.mock('../components/dub/DubPipelineStepper', () => ({ default: () => null }));
vi.mock('../components/dub/IdleSkeleton', () => ({ default: () => null }));
-vi.mock('../components/ExportModal', () => ({ default: () => null }));
vi.mock('../hooks/useTimelineOnsets', () => ({ default: () => ({ onsets: [] }) }));
vi.mock('../api/dub', () => ({ dubQc: vi.fn() }));
// Never-resolving async deps keep the render synchronous (no post-test act noise).
@@ -40,6 +39,7 @@ vi.mock('../api/client', async (importOriginal) => {
});
import DubTab from '../pages/DubTab';
+import ExportModal from '../components/ExportModal';
const noop = () => {};
function makeProps(over = {}) {
@@ -96,27 +96,50 @@ function makeProps(over = {}) {
const baseState = useAppStore.getState();
const PICKS = [
- { lang: 'Bengali', code: 'bn' },
+ { lang: 'Hindi', code: 'hi' },
{ lang: 'Spanish', code: 'es' },
+ { lang: 'French', code: 'fr' },
+ { lang: 'German', code: 'de' },
];
/** Render DubTab in multi-lang mode and return { onGenerateClick, calls, mocks }. */
-function setup({ translateOk = () => true, langCode = 'en', segments } = {}) {
+function setup({
+ translateOk = () => true,
+ translatedText = (code, segment) => `${code}:${segment.text_original || segment.text}`,
+ langCode = 'bn',
+ segments,
+} = {}) {
const calls = [];
const handleTranslateAll = vi.fn(async (code) => {
calls.push(`translate:${code}`);
- return translateOk(code);
+ const ok = translateOk(code);
+ if (ok) {
+ useAppStore.getState().setDubSegments((prev) =>
+ prev.map((segment, index) => {
+ const text = translatedText(code, segment, index);
+ return text
+ ? { ...segment, text, translations: { ...segment.translations, [code]: text } }
+ : segment;
+ }),
+ );
+ }
+ return ok;
});
const handleDubGenerate = vi.fn(async (opts) => {
- calls.push(`generate:${opts?.langOverride?.language_code ?? 'default'}`);
+ const code = opts?.langOverride?.language_code ?? 'default';
+ calls.push(`generate:${code}`);
+ if (code !== 'default') {
+ useAppStore.getState().setDubTracks((prev) => (prev.includes(code) ? prev : [...prev, code]));
+ }
});
useAppStore.setState({
dubJobId: 'job1',
dubStep: 'editing',
dubLangCode: langCode,
- dubLang: 'English',
+ dubLang: langCode === 'bn' ? 'Bengali' : 'English',
multiLangMode: true,
multiLangs: PICKS,
+ dubTracks: [],
dubSegments: segments ?? [{ id: '1', text: 'hello', text_original: 'hello' }],
});
render();
@@ -143,14 +166,70 @@ describe('DubTab — multi-language generate translates each language first (P1.
await onGenerateClick();
});
// Pre-fix this was ['generate:bn', 'generate:es'] — translate never ran.
- expect(calls).toEqual(['translate:bn', 'generate:bn', 'translate:es', 'generate:es']);
+ expect(calls).toEqual([
+ 'translate:bn',
+ 'generate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'translate:es',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
// langOverride keeps the existing handleDubGenerate call shape.
expect(handleDubGenerate).toHaveBeenNthCalledWith(1, {
langOverride: { language: 'Bengali', language_code: 'bn' },
});
expect(handleDubGenerate).toHaveBeenNthCalledWith(2, {
- langOverride: { language: 'Spanish', language_code: 'es' },
+ langOverride: { language: 'Hindi', language_code: 'hi' },
});
+ const state = useAppStore.getState();
+ expect(state.dubTracks).toEqual(['bn', 'hi', 'es', 'fr', 'de']);
+ expect(state.dubSegments[0].translations).toEqual({
+ bn: 'bn:hello',
+ hi: 'hi:hello',
+ es: 'es:hello',
+ fr: 'fr:hello',
+ de: 'de:hello',
+ });
+
+ // The generated per-language track state feeds the export drawer, where
+ // both tracks are selected by default and handed to the export action.
+ const exported = [];
+ render(
+ exported.push(...useAppStore.getState().dubTracks)}
+ handleAudioExport={noop}
+ segmentCount={1}
+ />,
+ );
+ expect(screen.getByLabelText(/^BN/)).toBeChecked();
+ expect(screen.getByLabelText(/^HI/)).toBeChecked();
+ expect(screen.getByLabelText(/^ES/)).toBeChecked();
+ expect(screen.getByLabelText(/^FR/)).toBeChecked();
+ expect(screen.getByLabelText(/^DE/)).toBeChecked();
+ fireEvent.click(screen.getByRole('button', { name: 'Export MP4' }));
+ expect(exported).toEqual(['bn', 'hi', 'es', 'fr', 'de']);
});
it('a failed translate skips ONLY that language’s generate, continues, and reports it', async () => {
@@ -159,7 +238,17 @@ describe('DubTab — multi-language generate translates each language first (P1.
await act(async () => {
await onGenerateClick();
});
- expect(calls).toEqual(['translate:bn', 'translate:es', 'generate:es']);
+ expect(calls).toEqual([
+ 'translate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'translate:es',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0][0]).toContain('Bengali');
});
@@ -169,12 +258,87 @@ describe('DubTab — multi-language generate translates each language first (P1.
langCode: 'bn',
// text differs from text_original on every segment = a translation into
// dubLangCode ('bn') is already applied — pick 1 can go straight to generate.
- segments: [{ id: '1', text: 'ওহে', text_original: 'hello' }],
+ segments: [
+ {
+ id: '1',
+ text: 'ওহে',
+ text_original: 'hello',
+ translations: { bn: 'ওহে' },
+ },
+ ],
});
await act(async () => {
await onGenerateClick();
});
- expect(calls).toEqual(['generate:bn', 'translate:es', 'generate:es']);
+ expect(calls).toEqual([
+ 'generate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'translate:es',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
+ });
+
+ it('changed text from another language never suppresses the selected target translation', async () => {
+ const { onGenerateClick, calls } = setup({
+ langCode: 'bn',
+ segments: [
+ {
+ id: '1',
+ text: 'hola',
+ text_original: 'hello',
+ translations: { es: 'hola' },
+ },
+ ],
+ });
+ await act(async () => {
+ await onGenerateClick();
+ });
+ expect(calls).toEqual([
+ 'translate:bn',
+ 'generate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
+ });
+
+ it('a partial translation never generates a mixed-language track and later targets continue', async () => {
+ const errorSpy = vi.spyOn(toast, 'error');
+ const { onGenerateClick, calls } = setup({
+ segments: [
+ { id: '1', text: 'one', text_original: 'one' },
+ { id: '2', text: 'two', text_original: 'two' },
+ ],
+ translatedText: (code, segment, index) =>
+ code === 'bn' && index === 1 ? null : `${code}:${segment.text_original}`,
+ });
+ await act(async () => {
+ await onGenerateClick();
+ });
+ expect(calls).toEqual([
+ 'translate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'translate:es',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
+ expect(useAppStore.getState().dubTracks).toEqual(['hi', 'es', 'fr', 'de']);
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Bengali'), {
+ duration: 8000,
+ });
});
it('untranslated editor text is ALWAYS translated, even when the first pick matches dubLangCode', async () => {
@@ -185,7 +349,18 @@ describe('DubTab — multi-language generate translates each language first (P1.
await act(async () => {
await onGenerateClick();
});
- expect(calls).toEqual(['translate:bn', 'generate:bn', 'translate:es', 'generate:es']);
+ expect(calls).toEqual([
+ 'translate:bn',
+ 'generate:bn',
+ 'translate:hi',
+ 'generate:hi',
+ 'translate:es',
+ 'generate:es',
+ 'translate:fr',
+ 'generate:fr',
+ 'translate:de',
+ 'generate:de',
+ ]);
});
it('single-language mode is untouched: generate only, no translate, no override', async () => {
diff --git a/frontend/src/test/dubMultiLangWorkflow.integration.test.jsx b/frontend/src/test/dubMultiLangWorkflow.integration.test.jsx
new file mode 100644
index 00000000..e7a4b219
--- /dev/null
+++ b/frontend/src/test/dubMultiLangWorkflow.integration.test.jsx
@@ -0,0 +1,325 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useAppStore } from '../store';
+import '../i18n';
+
+const captured = vi.hoisted(() => ({ header: [], left: [], right: [] }));
+const pipeline = vi.hoisted(() => ({ tasks: new Map(), generated: [] }));
+const dubApi = vi.hoisted(() => ({
+ dubUpload: vi.fn(),
+ dubIngestUrl: vi.fn(),
+ dubAbort: vi.fn(),
+ dubCleanupSegments: vi.fn(),
+ dubTranslate: vi.fn(),
+ dubGenerate: vi.fn(),
+ tasksStreamUrl: vi.fn((id) => `/tasks/${id}`),
+ tasksCancel: vi.fn(),
+ transcribeStreamUrl: vi.fn(),
+ dubImportSrt: vi.fn(),
+ dubQc: vi.fn(),
+ DUB_COOKIE_TRANSPORT_ERROR: 'transport',
+ DUB_COOKIE_SIZE_ERROR: 'size',
+}));
+const clientApi = vi.hoisted(() => ({
+ API: '',
+ apiPost: vi.fn(),
+ apiJson: vi.fn(() => new Promise(() => {})),
+ apiFetch: vi.fn(),
+}));
+
+vi.mock('../api/dub', () => dubApi);
+vi.mock('../api/client', () => clientApi);
+vi.mock('../api/engines', () => ({
+ listTranslationEngines: vi.fn(() => new Promise(() => {})),
+ installTranslationEngine: vi.fn(),
+}));
+vi.mock('../hooks/useTimelineOnsets', () => ({ default: () => ({ onsets: [] }) }));
+vi.mock('../utils/media', () => ({ playPing: vi.fn() }));
+vi.mock('../utils/donationMoments', () => ({ recordValueMoment: vi.fn() }));
+vi.mock('../utils/breadcrumbs', () => ({ addBreadcrumb: vi.fn() }));
+vi.mock('../components/dub/DubHeader', () => ({
+ default: (props) => {
+ captured.header.push(props);
+ return null;
+ },
+}));
+vi.mock('../components/dub/DubLeftColumn', () => ({
+ default: (props) => {
+ captured.left.push(props);
+ return null;
+ },
+}));
+vi.mock('../components/dub/DubRightColumn', () => ({
+ default: (props) => {
+ captured.right.push(props);
+ return null;
+ },
+}));
+vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
+vi.mock('../components/dub/DubPipelineStepper', () => ({ default: () => null }));
+vi.mock('../components/dub/IdleSkeleton', () => ({ default: () => null }));
+
+import useDubWorkflow from '../hooks/useDubWorkflow';
+import useSegmentEditing from '../hooks/useSegmentEditing';
+import DubTab from '../pages/DubTab';
+import ExportModal from '../components/ExportModal';
+
+const noop = () => {};
+const PICKS = [
+ { lang: 'Hindi', code: 'hi' },
+ { lang: 'Spanish', code: 'es' },
+ { lang: 'French', code: 'fr' },
+ { lang: 'German', code: 'de' },
+];
+const EXPECTED_CODES = ['bn', 'hi', 'es', 'fr', 'de'];
+const baseState = useAppStore.getState();
+
+function sseResponse(event) {
+ const chunks = [new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)];
+ return {
+ body: {
+ getReader: () => ({
+ read: async () =>
+ chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined },
+ }),
+ },
+ };
+}
+
+function Harness() {
+ const workflow = useDubWorkflow({
+ loadProjects: vi.fn(),
+ loadProfiles: vi.fn(),
+ loadDubHistory: vi.fn(),
+ setLastGenFingerprints: vi.fn(),
+ });
+ const editing = useSegmentEditing();
+ return (
+
+ );
+}
+
+beforeEach(() => {
+ useAppStore.setState(baseState, true);
+ captured.header.length = 0;
+ captured.left.length = 0;
+ captured.right.length = 0;
+ pipeline.tasks.clear();
+ pipeline.generated.length = 0;
+ vi.clearAllMocks();
+ useAppStore.setState({
+ dubJobId: 'job1',
+ dubStep: 'editing',
+ dubLang: 'Bengali',
+ dubLangCode: 'bn',
+ multiLangMode: true,
+ multiLangs: PICKS,
+ dubTracks: [],
+ dubSegments: [
+ { id: '1', text: 'hello', text_original: 'hello', start: 0, end: 1 },
+ { id: '2', text: 'world', text_original: 'world', start: 1, end: 2 },
+ ],
+ });
+ dubApi.dubTranslate.mockImplementation(async (body) => ({
+ translated: body.segments.map((segment) => ({
+ id: segment.id,
+ text: `${body.target_lang}:${segment.text}`,
+ })),
+ target_lang: body.target_lang,
+ }));
+ dubApi.dubGenerate.mockImplementation(async (_jobId, body) => {
+ const taskId = `task-${body.language_code}`;
+ pipeline.tasks.set(taskId, body);
+ return { task_id: taskId };
+ });
+ clientApi.apiFetch.mockImplementation(async (url) => {
+ const taskId = url.split('/').at(-1);
+ const body = pipeline.tasks.get(taskId);
+ pipeline.generated.push(body.language_code);
+ return sseResponse({
+ type: 'done',
+ language_code: body.language_code,
+ tracks: [...pipeline.generated],
+ seg_hashes: { 1: `hash-${body.language_code}` },
+ });
+ });
+});
+
+describe('multi-language workflow integration', () => {
+ it('shares one lock between translate and generate while a language batch is active', async () => {
+ let releaseFirstTranslation;
+ dubApi.dubTranslate.mockImplementationOnce(
+ (body) =>
+ new Promise((resolve) => {
+ releaseFirstTranslation = () =>
+ resolve({
+ translated: body.segments.map((segment) => ({
+ id: segment.id,
+ text: `${body.target_lang}:${segment.text}`,
+ })),
+ target_lang: body.target_lang,
+ });
+ }),
+ );
+ render();
+
+ let inFlight;
+ await act(async () => {
+ inFlight = captured.left.at(-1).handleTranslateAll();
+ await Promise.resolve();
+ });
+
+ expect(captured.right.at(-1).multiBatchBusy).toBe(true);
+ await act(async () => {
+ await captured.header.at(-1).onGenerateClick();
+ await captured.left.at(-1).handleTranslateAll();
+ });
+ expect(dubApi.dubTranslate).toHaveBeenCalledTimes(1);
+ expect(dubApi.dubGenerate).not.toHaveBeenCalled();
+
+ releaseFirstTranslation();
+ await act(async () => {
+ await inFlight;
+ });
+ expect(captured.right.at(-1).multiBatchBusy).toBe(false);
+ });
+
+ it('translates primary + 4 chips, generates their own texts/tracks, and exposes all to export', async () => {
+ render();
+
+ act(() => {
+ captured.right.at(-1).setDubLang('Spanish');
+ captured.right.at(-1).setDubLangCode('es');
+ });
+ await act(async () => {
+ await captured.left.at(-1).handleTranslateAll();
+ });
+
+ expect(dubApi.dubTranslate.mock.calls.map(([body]) => body.target_lang)).toEqual(
+ EXPECTED_CODES,
+ );
+ let state = useAppStore.getState();
+ expect(state.dubLangCode).toBe('bn');
+ for (const code of EXPECTED_CODES) {
+ expect(state.dubSegments.map((segment) => segment.translations[code])).toEqual([
+ `${code}:hello`,
+ `${code}:world`,
+ ]);
+ }
+
+ act(() => {
+ captured.right.at(-1).setDubLang('Spanish');
+ captured.right.at(-1).setDubLangCode('es');
+ captured.right.at(-1).segmentEditField('1', 'text', 'es:edited');
+ captured.right.at(-1).setDubLang('Hindi');
+ captured.right.at(-1).setDubLangCode('hi');
+ });
+ expect(useAppStore.getState().dubSegments[0].text).toBe('hi:hello');
+ act(() => {
+ captured.right.at(-1).setDubLang('Spanish');
+ captured.right.at(-1).setDubLangCode('es');
+ });
+ expect(useAppStore.getState().dubSegments[0].text).toBe('es:edited');
+ expect(useAppStore.getState().dubSegments[0].translations.hi).toBe('hi:hello');
+
+ await act(async () => {
+ await captured.header.at(-1).onGenerateClick();
+ });
+
+ // Complete cached translations are reused; generation receives the text
+ // belonging to each code rather than the currently visible language.
+ expect(dubApi.dubTranslate).toHaveBeenCalledTimes(5);
+ expect(dubApi.dubGenerate.mock.calls.map(([, body]) => body.language_code)).toEqual(
+ EXPECTED_CODES,
+ );
+ for (const [, body] of dubApi.dubGenerate.mock.calls) {
+ const firstText = body.language_code === 'es' ? 'es:edited' : `${body.language_code}:hello`;
+ expect(body.segments.map((segment) => segment.text)).toEqual([
+ firstText,
+ `${body.language_code}:world`,
+ ]);
+ }
+ state = useAppStore.getState();
+ expect(state.dubTracks).toEqual(EXPECTED_CODES);
+ expect(state.dubLangCode).toBe('bn');
+
+ const exported = [];
+ render(
+ exported.push(...useAppStore.getState().dubTracks)}
+ handleAudioExport={noop}
+ segmentCount={2}
+ />,
+ );
+ for (const code of EXPECTED_CODES)
+ expect(screen.getByLabelText(new RegExp(`^${code.toUpperCase()}`))).toBeChecked();
+ fireEvent.click(screen.getByRole('button', { name: 'Export MP4' }));
+ expect(exported).toEqual(EXPECTED_CODES);
+ });
+});
diff --git a/frontend/src/utils/multiLang.js b/frontend/src/utils/multiLang.js
new file mode 100644
index 00000000..84f78c3f
--- /dev/null
+++ b/frontend/src/utils/multiLang.js
@@ -0,0 +1,23 @@
+export function hasCompleteTranslation(segments, languageCode) {
+ return (
+ !!languageCode &&
+ segments.length > 0 &&
+ segments.every((segment) => {
+ const text = segment.translations?.[languageCode];
+ return typeof text === 'string' && text.trim().length > 0;
+ })
+ );
+}
+
+export function multiLangTargets(activeLanguage, activeCode, selected) {
+ const targets = [];
+ const seen = new Set();
+ const add = (lang, code) => {
+ if (!code || code === 'und' || seen.has(code)) return;
+ seen.add(code);
+ targets.push({ lang: lang || code.toUpperCase(), code });
+ };
+ if (activeLanguage && activeLanguage !== 'Auto') add(activeLanguage, activeCode);
+ for (const item of selected || []) add(item?.lang, item?.code);
+ return targets;
+}
diff --git a/tests/test_locale_parity.py b/tests/test_locale_parity.py
index 0455ec88..ed4ded0b 100644
--- a/tests/test_locale_parity.py
+++ b/tests/test_locale_parity.py
@@ -99,10 +99,10 @@ _ENGINE_AGNOSTIC_KEYS = (
# Never raise one: if this fails after adding en.json keys, add the keys to
# every locale (translated) in the same change instead.
_MISSING_BASELINE = {
- "ar": 504, "de": 504, "es": 504, "fr": 504, "hi": 504, "id": 504,
- "it": 504, "ja": 504, "ko": 504, "nl": 504, "pl": 504, "pt": 504,
- "ru": 504, "sv": 504, "th": 504, "tr": 504, "uk": 504, "vi": 504,
- "zh-CN": 497, "zh-TW": 504,
+ "ar": 502, "de": 502, "es": 502, "fr": 502, "hi": 502, "id": 502,
+ "it": 502, "ja": 502, "ko": 502, "nl": 502, "pl": 502, "pt": 502,
+ "ru": 502, "sv": 502, "th": 502, "tr": 502, "uk": 502, "vi": 502,
+ "zh-CN": 495, "zh-TW": 502,
}