Fix multilingual dubbing workflow and clipped language picker (#1486)

* fix(dub): complete multilingual translation workflow

* docs: note multilingual dubbing fix

* fix(dub): serialize multilingual batch actions

* fix(dub): preserve multilingual batch context
This commit is contained in:
Palash Debnath
2026-08-11 02:43:51 +00:00
committed by GitHub
parent 95a35b8e07
commit 3df44486b2
34 changed files with 1194 additions and 208 deletions
+1
View File
@@ -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)
+1 -1
View File
@@ -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 |
+152 -73
View File
@@ -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"
>
<Globe size={9} />
<Globe size={9} aria-hidden="true" />
<span>{s.code}</span>
{!disabled && (
<button
type="button"
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex items-center rounded-full [transition:color_0.15s] hover:text-danger"
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex items-center rounded-full [transition:color_0.15s] hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
onClick={() => removeLang(s.code)}
aria-label={`Remove ${s.lang}`}
aria-label={t('common.remove', { term: s.lang })}
>
<X size={8} />
<X size={8} aria-hidden="true" />
</button>
)}
</span>
))}
{!disabled && (
<button
ref={triggerRef}
type="button"
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:all_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid"
onClick={() => setDropOpen(!dropOpen)}
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background-color_0.15s,color_0.15s,border-color_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
onClick={() => setDropOpen((open) => !open)}
title={t('dub.add_language')}
aria-label={t('dub.add_language')}
aria-haspopup="dialog"
aria-expanded={dropOpen}
aria-controls={dropOpen ? menuId : undefined}
>
<Plus size={10} />
<Plus size={10} aria-hidden="true" />
</button>
)}
</div>
@@ -110,69 +168,90 @@ export default function MultiLangPicker({
</div>
)}
{dropOpen && (
<div className="multi-lang__drop">
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
<Search size={10} />
<input
ref={inputRef}
value={query}
onChange={(e) => 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]"
/>
</div>
<div className="overflow-y-auto flex-1 py-[4px]">
{popularFiltered.length > 0 && (
<>
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.popular')}
</div>
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)]"
onClick={() => addLang(item.lang, item.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{item.code}
</span>
<span>{item.lang}</span>
</button>
))}
</>
)}
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.all_languages')}
{dropOpen &&
createPortal(
<div
ref={menuRef}
id={menuId}
className="multi-lang__drop"
role="dialog"
aria-label={t('dub.add_language')}
style={
menuPos
? {
left: menuPos.left,
width: menuPos.width,
maxHeight: menuPos.maxHeight,
...(menuPos.bottom != null ? { bottom: menuPos.bottom } : { top: menuPos.top }),
}
: { visibility: 'hidden' }
}
>
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
<Search size={10} aria-hidden="true" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('dub.search_languages')}
aria-label={t('dub.search_languages')}
name="language-search"
autoComplete="off"
spellCheck={false}
className="flex-1 bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.78rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
/>
</div>
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)]"
onClick={() => addLang(lc.label, lc.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{lc.code}
</span>
<span>{lc.label}</span>
</button>
))}
{filteredLangs.length > 50 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
<div className="overflow-y-auto overscroll-contain flex-1 py-[4px]">
{popularFiltered.length > 0 && (
<>
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.popular')}
</div>
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(item.lang, item.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{item.code}
</span>
<span className="min-w-0 truncate">{item.lang}</span>
</button>
))}
</>
)}
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.all_languages')}
</div>
)}
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.no_matches')}
</div>
)}
</div>
</div>
)}
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
onClick={() => addLang(lc.label, lc.code)}
>
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{lc.code}
</span>
<span className="min-w-0 truncate">{lc.label}</span>
</button>
))}
{filteredLangs.length > 50 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
</div>
)}
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.no_matches')}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
@@ -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(
<div data-testid="clipper" style={{ overflow: 'hidden', height: 40 }}>
<MultiLangPicker selected={[]} onChange={onChange} />
</div>,
);
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(<MultiLangPicker selected={[]} onChange={vi.fn()} />);
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();
});
});
@@ -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 && (
<div className="flex items-center gap-[4px] mb-[4px] px-[2px] flex-wrap">
<span className={OUT_TITLE}>{t('dub.language')}:</span>
{batchTargets.map((target) => (
<button
key={target.code}
type="button"
className={`px-[7px] py-[2px] rounded-full border text-[0.62rem] font-mono cursor-pointer transition-[background,color,border-color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)] disabled:cursor-not-allowed disabled:opacity-50 ${
dubLangCode === target.code
? 'bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)] border-[var(--chrome-accent)]'
: 'bg-transparent text-[var(--chrome-fg-muted)] border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]'
}`}
onClick={() => {
setDubLang(target.lang);
setDubLangCode(target.code);
}}
disabled={multiBatchBusy}
aria-pressed={dubLangCode === target.code}
title={target.lang}
>
{target.code.toUpperCase()}
</button>
))}
</div>
)}
{/* 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. */}
@@ -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 (
<DubRightColumn
t={(key) => 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');
});
});
+6 -2
View File
@@ -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": "مسرد",
+8 -4
View File
@@ -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",
+6 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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": "शब्दावली",
+6 -2
View File
@@ -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",
+8 -4
View File
@@ -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",
+6 -2
View File
@@ -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": "用語集",
+6 -2
View File
@@ -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": "용어집",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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": "Глоссарий",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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": "อภิธานศัพท์",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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": "Глосарій",
+6 -2
View File
@@ -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ữ",
+6 -2
View File
@@ -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": "术语表",
+6 -2
View File
@@ -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": "詞彙表",
+4 -5
View File
@@ -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
+128 -50
View File
@@ -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}
+51
View File
@@ -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);
});
});
+188 -13
View File
@@ -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(<DubTab {...makeProps({ handleTranslateAll, handleDubGenerate })} />);
@@ -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(
<ExportModal
open
onClose={noop}
jobId="job1"
filename="video.mp4"
dubTracks={state.dubTracks}
dubLangCode="es"
preserveBg={false}
setPreserveBg={noop}
defaultTrack="original"
setDefaultTrack={noop}
exportTracks={{}}
setExportTracks={noop}
dualSubs={false}
setDualSubs={noop}
burnSubs={false}
setBurnSubs={noop}
API=""
triggerDownload={noop}
handleDubDownload={() => 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 languages 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 () => {
@@ -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 (
<DubTab
dubVideoFile={null}
dubLocalBlobUrl={null}
transcribeElapsed={0}
translateProvider={workflow.translateProvider}
setTranslateProvider={workflow.setTranslateProvider}
showTranscript={false}
setShowTranscript={noop}
onGlossaryChange={noop}
profiles={[]}
segmentPreviewLoading={null}
selectedSegIds={new Set()}
setDubVideoFile={noop}
setDubLocalBlobUrl={noop}
handleDubAbort={workflow.handleDubAbort}
handleDubUpload={workflow.handleDubUpload}
handleDubIngestUrl={workflow.handleDubIngestUrl}
handleDubRetryTranscribe={workflow.handleDubRetryTranscribe}
handleDubStop={workflow.handleDubStop}
handleDubGenerate={workflow.handleDubGenerate}
handleDubImportSrt={workflow.handleDubImportSrt}
handleDubDownload={noop}
handleDubAudioDownload={noop}
handleAudioExport={noop}
handleSegmentPreview={noop}
onDirectSegment={noop}
handleTranslateAll={workflow.handleTranslateAll}
handleCleanupSegments={workflow.handleCleanupSegments}
incrementalPlan={null}
triggerDownload={noop}
fileToMediaUrl={noop}
editSegments={useAppStore.getState().setDubSegments}
saveProject={noop}
resetDub={noop}
segmentEditField={editing.segmentEditField}
segmentDelete={noop}
segmentRestoreOriginal={noop}
segmentSplit={noop}
segmentMerge={noop}
segmentMoveResize={noop}
timelineSelSegId={null}
setTimelineSelSegId={noop}
toggleSegSelect={noop}
selectAllSegs={noop}
clearSegSelection={noop}
bulkApplyToSelected={noop}
bulkDeleteSelected={noop}
/>
);
}
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(<Harness />);
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(<Harness />);
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(
<ExportModal
open
onClose={noop}
jobId="job1"
filename="video.mp4"
dubTracks={state.dubTracks}
dubLangCode={state.dubLangCode}
preserveBg={false}
setPreserveBg={noop}
defaultTrack="bn"
setDefaultTrack={noop}
exportTracks={{}}
setExportTracks={noop}
dualSubs={false}
setDualSubs={noop}
burnSubs={false}
setBurnSubs={noop}
API=""
triggerDownload={noop}
handleDubDownload={() => 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);
});
});
+23
View File
@@ -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;
}
+4 -4
View File
@@ -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,
}