diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ec5fb8..dbcc305a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ the frozen-backend fallback mirror it for their toolchains. **Highlights** - VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646) +- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644) +- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter! +- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y! +- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd! - Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui! - WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui! - Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd! diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index d22f54d5..f5421d9a 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -134,6 +134,82 @@ _save_job = dub_pipeline.save_job # paste (or a mis-aimed binary) burn CPU in the parser. _MAX_SUBTITLE_PASTE_CHARS = 2_000_000 +_SRT_REPLACED_FIELDS = { + "id", + "start", + "end", + "text", + "text_original", + "translations", + "translate_error", + "translate_degraded", +} + + +def _best_overlapping_segment(cue: dict, existing: list[dict]) -> dict | None: + """Return the prior segment with the strongest temporal overlap.""" + cue_start = float(cue.get("start") or 0.0) + cue_end = float(cue.get("end") or cue_start) + cue_mid = (cue_start + cue_end) / 2.0 + best = None + best_key = None + for index, segment in enumerate(existing): + start = float(segment.get("start") or 0.0) + end = float(segment.get("end") or start) + overlap = min(cue_end, end) - max(cue_start, start) + if overlap <= 0: + continue + midpoint_distance = abs(cue_mid - ((start + end) / 2.0)) + key = (overlap, -midpoint_distance, -index) + if best_key is None or key > best_key: + best = segment + best_key = key + return best + + +def _carry_srt_voice_metadata( + cues: list[dict], + existing: list[dict], + segment_clones: dict | None, + speaker_clones: dict | None = None, +) -> tuple[list[dict], dict]: + """Replace subtitle content while retaining the source cast assignment.""" + source_clones = dict(segment_clones or {}) + source_speaker_clones = dict(speaker_clones or {}) + # Replacement cues get new positional ids. Starting from the old map would + # let an unmatched cue whose new id happens to equal an old id inherit an + # unrelated reference. Only explicitly overlap-matched references survive. + clones = {} + merged_segments = [] + for new_id, cue in enumerate(cues): + prior = _best_overlapping_segment(cue, existing) + metadata = { + key: value + for key, value in (prior or {}).items() + if key not in _SRT_REPLACED_FIELDS + } + merged = { + **metadata, + "id": new_id, + "start": cue.get("start", 0.0), + "end": cue.get("end", 0.0), + "text": cue.get("text", ""), + "text_original": cue.get("text", ""), + } + if not merged.get("speaker_id"): + merged["speaker_id"] = cue.get("speaker_id") or "Speaker 1" + if prior is not None: + prior_id = str(prior.get("id", "")) + clone = source_clones.get(prior_id) + if clone is None: + clone = source_speaker_clones.get(prior.get("speaker_id")) + if clone is not None: + clones[str(new_id)] = clone + if merged.get("profile_id") == f"auto-seg:{prior_id}": + merged["profile_id"] = f"auto-seg:{new_id}" + merged_segments.append(merged) + return merged_segments, clones + @router.post("/dub/parse-subtitle-text") def dub_parse_subtitle_text(req: ParseSubtitleTextRequest): @@ -234,7 +310,32 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)): else: segments = result.segments + prior_segments = [ + segment for segment in (job.get("segments") or []) if isinstance(segment, dict) + ] + segments, segment_clones = _carry_srt_voice_metadata( + segments, + prior_segments, + job.get("segment_clones"), + job.get("speaker_clones"), + ) job["segments"] = segments + job["segment_clones"] = segment_clones + # A pooled speaker clone is keyed only by a display label. Replacement + # cues can reuse that label without overlapping the original speaker, so + # retain matched pooled references as segment-specific clones above and + # drop the global map before rebuilding the cast. + job["speaker_clones"] = {} + if segment_clones: + from services.speaker_clone import build_cast_sources + + job["cast_sources"] = build_cast_sources( + segments, + None, + segment_clones, + ) + else: + job.pop("cast_sources", None) # `source_lang` stays whatever the user (or the upload step) set; we # don't try to language-detect off the cue text — that's noisy and the # user usually knows what their .srt is. @@ -351,12 +452,13 @@ async def preview_upload(video: UploadFile = File(...)): safe_name = f"{uuid.uuid4().hex[:12]}" vid_path = os.path.join(PREVIEW_DIR, f"{safe_name}{ext}") wav_path = os.path.join(PREVIEW_DIR, f"{safe_name}.wav") - - with open(vid_path, "wb") as f: - f.write(await video.read()) - - has_audio = False - if ext not in [".wav", ".mp3", ".m4a", ".aac"]: + payload = await video.read() + + def _write_and_extract() -> bool: + with open(vid_path, "wb") as f: + f.write(payload) + if ext in {".wav", ".mp3", ".m4a", ".aac"}: + return False try: ffmpeg_cmd = [ find_ffmpeg(), "-y", "-i", vid_path, @@ -368,10 +470,16 @@ async def preview_upload(video: UploadFile = File(...)): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300, ) - has_audio = True + return True except Exception as e: logger.warning("FFmpeg extraction failed: %s", log_safe(e)) - pass + return False + + # File writes and ffmpeg are blocking operations. Keep them on the bounded + # CPU pool so a large preview cannot stall unrelated API requests (#1667). + has_audio = await asyncio.get_running_loop().run_in_executor( + _cpu_pool, _write_and_extract + ) return { "url": f"/preview/{safe_name}{ext}", @@ -410,12 +518,38 @@ _ingest_gen = dub_pipeline.ingest_pipeline #: container so a mislabelled video can't slip past the video-skipping branch. _AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"} +# Source-language choices exposed by the first-party dub UI. Keeping this an +# allow-list rejects language names and private-use BCP-47 tags before they are +# persisted as ASR overrides. Values are normalized to lowercase below. +_DUB_SOURCE_LANG_CODES = frozenset({ + "af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg", + "my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en", + "et", "fi", "fr", "gl", "ka", "de", "el", "gu", "ht", "ha", "haw", + "he", "hi", "hu", "is", "id", "it", "ja", "jw", "kn", "kk", "km", + "ko", "ku", "ky", "lo", "la", "lv", "lt", "mk", "ms", "ml", "mt", + "mi", "mr", "mn", "ne", "no", "ps", "fa", "pl", "pt", "pa", "ro", + "ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es", + "su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz", + "vi", "cy", "xh", "yi", "yo", "zu", +}) + + +def _source_lang_override(value: str | None) -> str | None: + """Normalize a user-selected source language; auto/und means detect.""" + code = (value or "").strip().lower() + if code in {"", "auto", "und"}: + return None + if code not in _DUB_SOURCE_LANG_CODES: + raise HTTPException(status_code=400, detail="Invalid source language code") + return code + @router.post("/dub/upload") async def dub_upload( video: UploadFile = File(...), job_id: Optional[str] = Form(None), input_type: str = Form("video"), + source_lang: Optional[str] = Form(None), ): """Accept a media upload, write to disk, queue background prep task. @@ -445,6 +579,7 @@ async def dub_upload( detail=f"Audio-only dubbing needs an audio file ({', '.join(sorted(_AUDIO_EXTS))}); got '{ext or 'no extension'}'.", ) + source_lang_override = _source_lang_override(source_lang) os.makedirs(job_dir, exist_ok=True) video_path = os.path.join(job_dir, f"original{ext}") @@ -456,7 +591,13 @@ async def dub_upload( await task_manager.add_task( task_id, "prep", _ingest_gen, job_id, job_dir, - {"kind": "file", "path": video_path, "input_type": input_type}, filename, + { + "kind": "file", + "path": video_path, + "input_type": input_type, + "source_lang": source_lang_override, + }, + filename, ) return JSONResponse( status_code=202, @@ -478,6 +619,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request): status_code=400, detail="URL must start with http:// or https://. Paste a full video link (e.g. https://youtube.com/watch?v=…) or drop a local file instead.", ) + source_lang_override = _source_lang_override(req.source_lang) try: import yt_dlp # noqa: F401 @@ -513,6 +655,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request): "fetch_subs": bool(req.fetch_subs), "sub_langs": req.sub_langs or None, "cookie_file": cookie_path, + "source_lang": source_lang_override, } try: await task_manager.add_task( @@ -1669,7 +1812,9 @@ async def dub_transcribe_stream( except Exception as e: logger.warning("speaker_clone extraction skipped: %s", e) - job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower() + job["source_lang"] = job.get("source_lang_override") or ( + (detected_lang or "en").split("_")[0][:2] or "en" + ).lower() job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs) _save_job(job_id, job) @@ -1866,7 +2011,9 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None): except Exception as e: logger.warning("Failed to unload ASR backend: %s", e) - job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower() + job["source_lang"] = job.get("source_lang_override") or ( + (detected_lang or "en").split("_")[0][:2] or "en" + ).lower() scene_cuts = job.get("scene_cuts") or [] segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts) diff --git a/backend/schemas/requests.py b/backend/schemas/requests.py index 020bc64e..3f978d16 100644 --- a/backend/schemas/requests.py +++ b/backend/schemas/requests.py @@ -190,6 +190,7 @@ class ParseSubtitleTextRequest(BaseModel): class DubIngestUrlRequest(BaseModel): url: str job_id: Optional[str] = None + source_lang: Optional[str] = None # When true and the URL is a caption-bearing host (YouTube, Vimeo, TED…), # ask yt-dlp to also download the original-language + any additional # sub_langs as VTT. The UI uses this to seed a transcript without running diff --git a/backend/services/dub_pipeline.py b/backend/services/dub_pipeline.py index ac5580bb..a755dce7 100644 --- a/backend/services/dub_pipeline.py +++ b/backend/services/dub_pipeline.py @@ -742,6 +742,74 @@ def _ensure_browser_playable_mp4(video_path: str) -> str: return video_path +async def _ensure_browser_playable_mp4_for_job(job_id: str, video_path: str) -> str: + """Normalize an upload through the job's cancellable process registry.""" + is_mp4 = video_path.lower().endswith(".mp4") + vcodec, acodec = await asyncio.to_thread(_probe_codecs, video_path) + if is_mp4 and vcodec in _BROWSER_VIDEO_CODECS and acodec in _BROWSER_AUDIO_CODECS: + return video_path + + target = os.path.splitext(video_path)[0] + ".mp4" + if target == video_path: + target = os.path.splitext(video_path)[0] + ".browser.mp4" + run_proc = run_proc_factory(job_id) + ffmpeg_bin = find_ffmpeg() + + async def attempt(cmd: list[str]) -> int: + try: + proc, _stdout, _stderr = await run_proc(cmd, timeout=1800.0) + return proc.returncode + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Browser-media normalization process failed for %s: %s", + log_safe(video_path), + log_safe(exc), + ) + return 1 + + rc = 1 + if not is_mp4: + rc = await attempt( + [ + ffmpeg_bin, "-y", "-i", video_path, + "-c:v", "copy", "-c:a", "copy", + "-movflags", "+faststart", target, + ] + ) + if rc == 0 and os.path.exists(target): + target_vcodec, target_acodec = await asyncio.to_thread(_probe_codecs, target) + if ( + target_vcodec not in _BROWSER_VIDEO_CODECS + or target_acodec not in _BROWSER_AUDIO_CODECS + ): + rc = 1 + else: + rc = 1 + if rc != 0: + rc = await attempt( + [ + ffmpeg_bin, "-y", "-i", video_path, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", target, + ] + ) + if rc == 0 and os.path.exists(target) and target != video_path: + try: + os.remove(video_path) + except OSError: + pass # Best effort: the normalized target is already complete. + return target + logger.warning( + "Could not transcode %s to browser-playable mp4 — the in-app " + "video player may render this file as a black box.", + log_safe(video_path), + ) + return video_path + + # Bounded retry for transient download failures (#579/#598). yt-dlp's own # `retries`/`fragment_retries` cover per-fragment HTTP flakes, but a broken # pipe ([Errno 32]) raised while the write side of a pipe closes mid-stream @@ -1257,6 +1325,13 @@ async def ingest_pipeline( except Exception: dur = 0.0 + # URL downloads already pass through this guard in yt_download_sync. + # Uploaded videos did not, so a valid VP9/AV1/Opus upload could be + # processed successfully but remain undecodable by the in-app WebView. + # Codec probing/transcoding is blocking; keep it off the event loop. + if source.get("kind") != "url" and input_type != "audio": + video_path = await _ensure_browser_playable_mp4_for_job(job_id, video_path) + # Content-hash cache: reuse artifacts from previous matching jobs. content_hash = await asyncio.to_thread(compute_file_hash, audio_path) cached = find_cached_job(content_hash, job_id) @@ -1295,6 +1370,7 @@ async def ingest_pipeline( "scene_cuts": scene_cuts, "youtube_subs": youtube_subs_by_lang or None, "input_type": input_type, + "source_lang_override": source.get("source_lang"), } if not put_and_save_job( job_id, full_job, filename=filename, duration=dur, content_hash=content_hash, @@ -1323,6 +1399,7 @@ async def ingest_pipeline( "scene_cuts": [], "youtube_subs": youtube_subs_by_lang or None, "input_type": input_type, + "source_lang_override": source.get("source_lang"), } if not put_and_save_job( job_id, partial, filename=filename, duration=dur, content_hash=content_hash, diff --git a/backend/services/segmentation.py b/backend/services/segmentation.py index 85800a17..8c2a5879 100644 --- a/backend/services/segmentation.py +++ b/backend/services/segmentation.py @@ -79,6 +79,7 @@ class Segment: text: str speaker_id: str = "Speaker 1" id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) + extra: dict = field(default_factory=dict) @property def duration(self) -> float: @@ -90,6 +91,7 @@ class Segment: def to_dict(self) -> dict: return { + **self.extra, "id": self.id, "start": round(self.start, 2), "end": round(self.end, 2), @@ -98,6 +100,46 @@ class Segment: } +def _merge_segment_extra(target: Segment, incoming: Segment, *, prepend: bool) -> None: + """Preserve editor metadata when cleanup folds ``incoming`` into ``target``.""" + for key, value in incoming.extra.items(): + target.extra.setdefault(key, value) + + def joined(left: object, right: object) -> str: + return _clean(f"{left or ''} {right or ''}") + + target_original = target.extra.get("text_original") + incoming_original = incoming.extra.get("text_original") + if target_original is not None or incoming_original is not None: + target.extra["text_original"] = ( + joined(incoming_original, target_original) + if prepend + else joined(target_original, incoming_original) + ) + + raw_target_translations = target.extra.get("translations") + raw_incoming_translations = incoming.extra.get("translations") + target_translations = raw_target_translations if isinstance(raw_target_translations, dict) else {} + incoming_translations = ( + raw_incoming_translations if isinstance(raw_incoming_translations, dict) else {} + ) + if target_translations or incoming_translations: + merged = {} + languages = { + *target_translations.keys(), + *incoming_translations.keys(), + } + for language in languages: + target_text = target_translations.get(language) + incoming_text = incoming_translations.get(language) + merged[language] = ( + joined(incoming_text, target_text) + if prepend + else joined(target_text, incoming_text) + ) + target.extra["translations"] = merged + + def _clean(text: str) -> str: return _WS.sub(" ", (text or "").strip()) @@ -317,12 +359,14 @@ def _merge_short(segments: List[Segment]) -> List[Segment]: i += 1 continue if target is prev: + _merge_segment_extra(prev, s, prepend=False) prev.text = _clean(prev.text + " " + s.text) prev.end = max(prev.end, s.end) segments.pop(i) did_merge = True continue if target is nxt: + _merge_segment_extra(nxt, s, prepend=True) nxt.text = _clean(s.text + " " + nxt.text) nxt.start = min(nxt.start, s.start) segments.pop(i) @@ -360,6 +404,7 @@ def _stitch_adjacent_shorts(segments: List[Segment]) -> List[Segment]: and b.duration <= STITCH_DUR and combined_dur <= MAX_DUR ): + _merge_segment_extra(a, b, prepend=False) a.text = _clean(a.text + " " + b.text) a.end = b.end segments.pop(i + 1) @@ -386,6 +431,11 @@ def clean_up_segments(segments: List[dict]) -> List[dict]: text=_clean(str(s.get("text", ""))), speaker_id=str(s.get("speaker_id") or "Speaker 1"), id=str(s.get("id") or uuid.uuid4().hex[:8]), + extra={ + key: value + for key, value in s.items() + if key not in {"id", "start", "end", "text", "speaker_id"} + }, )) except (TypeError, ValueError): continue diff --git a/backend/tests/test_dub_preview_upload.py b/backend/tests/test_dub_preview_upload.py new file mode 100644 index 00000000..b49c442f --- /dev/null +++ b/backend/tests/test_dub_preview_upload.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import asyncio +import io +import threading + +import pytest +from fastapi import UploadFile + + +@pytest.mark.asyncio +async def test_preview_ffmpeg_does_not_block_event_loop(monkeypatch, tmp_path): + from api.routers import dub_core + + loop = asyncio.get_running_loop() + started = asyncio.Event() + release = threading.Event() + + def slow_ffmpeg(*_args, **_kwargs): + loop.call_soon_threadsafe(started.set) + assert release.wait(timeout=2) + + monkeypatch.setattr(dub_core, "PREVIEW_DIR", str(tmp_path)) + monkeypatch.setattr(dub_core, "find_ffmpeg", lambda: "ffmpeg") + monkeypatch.setattr(dub_core.subprocess, "run", slow_ffmpeg) + upload = UploadFile(filename="preview.mp4", file=io.BytesIO(b"video")) + + before = loop.time() + task = asyncio.create_task(dub_core.preview_upload(upload)) + try: + await asyncio.wait_for(started.wait(), timeout=1) + assert loop.time() - before < 0.5 + finally: + release.set() + + result = await task + assert result["audioUrl"].endswith(".wav") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3d0f4e02..239954d8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -486,6 +486,8 @@ function App() { const setDubLang = useAppStore((s) => s.setDubLang); const dubLangCode = useAppStore((s) => s.dubLangCode); const setDubLangCode = useAppStore((s) => s.setDubLangCode); + const dubSourceLangCode = useAppStore((s) => s.dubSourceLangCode); + const setDubSourceLangCode = useAppStore((s) => s.setDubSourceLangCode); const dubDialect = useAppStore((s) => s.dubDialect); const setDubDialect = useAppStore((s) => s.setDubDialect); const dubInstruct = useAppStore((s) => s.dubInstruct); @@ -982,6 +984,7 @@ function App() { setDubTracks([]); setDubProgress({ current: 0, total: 0, text: '' }); setDubTranscript(''); + setDubSourceLangCode('auto'); setShowTranscript(false); setPreviewAudios({}); setDubLocalBlobUrl((prev) => { @@ -1011,6 +1014,7 @@ function App() { dubSegments, dubLang, dubLangCode, + dubSourceLangCode, dubDialect, dubInstruct, dubTracks, @@ -1062,6 +1066,7 @@ function App() { ); setDubLang(s.dubLang || 'Auto'); setDubLangCode(s.dubLangCode || 'en'); + setDubSourceLangCode(s.dubSourceLangCode || 'auto'); setDubDialect(s.dubDialect || ''); setDubInstruct(s.dubInstruct || ''); setDubTracks(s.dubTracks || []); @@ -1144,6 +1149,7 @@ function App() { // restores existing rows correctly without a migration. setDubLang(item.language || job.language || 'Auto'); setDubLangCode(item.language_code || job.language_code || 'und'); + setDubSourceLangCode(job.source_lang_override || job.source_lang || 'auto'); setDubTracks(Object.keys(job.dubbed_tracks || {})); setDubStep(Object.keys(job.dubbed_tracks || {}).length > 0 ? 'done' : 'editing'); // Phase 4.5 — seg_hashes are written per successful segment by diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index 53aafd95..4d515f09 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -4,12 +4,17 @@ import type { DubHistoryResponse, DubTranslateResponse } from './types'; export async function dubUpload( file: File | Blob, jobId: string, - { signal, inputType = 'video' }: { signal?: AbortSignal; inputType?: 'video' | 'audio' } = {}, + { + signal, + inputType = 'video', + sourceLang, + }: { signal?: AbortSignal; inputType?: 'video' | 'audio'; sourceLang?: string } = {}, ): Promise { const fd = new FormData(); fd.append('video', file); fd.append('job_id', jobId); fd.append('input_type', inputType); // #119: audio-only dubbing + if (sourceLang && sourceLang !== 'auto') fd.append('source_lang', sourceLang); return apiPost('/dub/upload', fd, { signal }); } @@ -21,6 +26,8 @@ export interface IngestUrlOptions { subLangs?: string[]; /** Explicit cookies.txt export used only for this import. */ cookieFile?: File; + /** Explicit spoken language, or auto/undefined to use ASR detection. */ + sourceLang?: string; } export const DUB_COOKIE_TRANSPORT_ERROR = 'DUB_COOKIE_TRANSPORT'; @@ -46,7 +53,7 @@ export async function dubIngestUrl( jobId: string, opts: IngestUrlOptions = {}, ): Promise { - const { signal, fetchSubs, subLangs, cookieFile } = opts; + const { signal, fetchSubs, subLangs, cookieFile, sourceLang } = opts; if (cookieFile && !_cookieTransportAllowed(API)) { throw cookieSelectionError(DUB_COOKIE_TRANSPORT_ERROR); } @@ -62,6 +69,7 @@ export async function dubIngestUrl( fetch_subs: fetchSubs || undefined, sub_langs: subLangs && subLangs.length ? subLangs : undefined, cookie_file: cookieText, + source_lang: sourceLang && sourceLang !== 'auto' ? sourceLang : undefined, }, { signal }, ); diff --git a/frontend/src/components/dub/DubLeftColumn.jsx b/frontend/src/components/dub/DubLeftColumn.jsx index 6d1f6e19..94a9007c 100644 --- a/frontend/src/components/dub/DubLeftColumn.jsx +++ b/frontend/src/components/dub/DubLeftColumn.jsx @@ -117,6 +117,7 @@ export default function DubLeftColumn({ // classifies as impossible to fit (default OFF — needs an LLM). const condenseSuggest = useAppStore((s) => s.condenseSuggest); const setCondenseSuggest = useAppStore((s) => s.setCondenseSuggest); + const failedTranslationCount = dubSegments.filter((segment) => segment.translate_error).length; // Frozen-build (packaged/signed, read-only site-packages) escape-hatch // popover: pip install is impossible, so we surface the copyable command + // a one-click switch to the always-bundled Argos engine + a docs deeplink. @@ -803,6 +804,34 @@ export default function DubLeftColumn({
+ {failedTranslationCount > 0 && ( + <> + + + + )}