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" > - +