From 44e65299e5d35ce3293701dfd561fdcad84de773 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 14 Aug 2025 09:18:01 +0200 Subject: [PATCH] initial commit --- icon.png | Bin 0 -> 68692 bytes srt-translate.py | 2163 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2163 insertions(+) create mode 100644 icon.png create mode 100755 srt-translate.py diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3361ec79ca43c6b5d530fa98b0ad08fd9350f843 GIT binary patch literal 68692 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 literal 0 HcmV?d00001 diff --git a/srt-translate.py b/srt-translate.py new file mode 100755 index 0000000..205b4f0 --- /dev/null +++ b/srt-translate.py @@ -0,0 +1,2163 @@ +#!/usr/bin/env python3 +""" +SRT Translator — NLLB, SeamlessM4T, or Ollama +GUI + CLI, Multithreaded, Cancel, Context-aware (Line/Cue/Smart) + +New in this build: +- FIX: Leading commas sometimes survived. Now: + * _fix_leading_punct() recognizes Unicode commas (,、) and runs until stable. + * _remove_marker_and_tagn_tokens() strips any remaining leading commas per line as a last-resort. + * NBSP / narrow NBSP / ideographic spaces normalized to regular spaces. +- FIX: Ollama 'groups' robustness. + * If the model doesn't return a valid "groups" array, we transparently fall back to per-group "translate_cues" + requests and coerce counts to the expected shape. + * No more RuntimeError spam; threads finish gracefully. +- FIX: Marker remover accidentally included TAG (would nuke 'STAGE' etc). Now only BR|CUE; Tag removal stays via TAG0..TAG9. +- (from earlier) Restore tags bug fixed; per-line cleanup removes BR/CUE tokens and TAG0..TAG9; spaces collapsed; punctuation spacing normalized. +- NEW: Ollama untranslated-line Repair-Pass: + * Language-name prompts (e.g., "English" statt "eng_Latn") to avoid false "already in target language". + * Automatic per-line detection of unchanged output + strict re-try with temperature schedule and optional model fallback. +- NEW: Cancel reliability + * Entferntes Pause-Feature (UI & Logik). + * Kooperative Queue-Abfrage mit kurzen Timeouts und sofortigem Drain bei Cancel. + * Mikrobatching in allen Workern (Transformers & Ollama), Event-Checks zwischen Mikrobatches. +""" + +import argparse +import os +import re +import sys +import time +import json +import threading +import queue +import http.client +import subprocess +import html as _html +from typing import List, Dict, Tuple, Optional + +# ---- Optional CLI progress (tqdm) ---- +try: + from tqdm.auto import tqdm +except Exception: + tqdm = None + +# ---- Optional auto-detect ---- +try: + from langdetect import detect as _ld_detect +except Exception: + _ld_detect = None + +CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".srt_translator_config.json") +SENTINEL_BR = "⟦BR⟧" # within cue: between original lines +SENTINEL_CUE = "⟦CUE⟧" # within group: between original cues +SENTINEL_TAG = "⟦TAG⟧" + +# ===================== Language code mapping ===================== +LANG2CODE = { + "english": "eng_Latn", "german": "deu_Latn", "french": "fra_Latn", "spanish": "spa_Latn", + "portuguese": "por_Latn", "italian": "ita_Latn", "dutch": "nld_Latn", "russian": "rus_Cyrl", + "japanese": "jpn_Jpan", "korean": "kor_Hang", "chinese": "zho_Hans", "chinese (simplified)": "zho_Hans", + "chinese (traditional)": "zho_Hant", "arabic": "arb_Arab", "hebrew": "heb_Hebr", "turkish": "tur_Latn", + "polish": "pol_Latn", "czech": "ces_Latn", "greek": "ell_Grek", "swedish": "swe_Latn", + "danish": "dan_Latn", "norwegian": "nob_Latn", "finnish": "fin_Latn", "romanian": "ron_Latn", + "hungarian": "hun_Latn", "ukrainian": "ukr_Cyrl", "bulgarian": "bul_Cyrl", "serbian": "srp_Latn", + "croatian": "hrv_Latn", "slovak": "slk_Latn", "slovenian": "slv_Latn", "lithuanian": "lit_Latn", + "estonian": "est_Latn", "vietnamese": "vie_Latn", "thai": "tha_Thai", "hindi": "hin_Deva", + "bengali": "ben_Beng", "indonesian": "ind_Latn", "malay": "zsm_Latn", "filipino": "tgl_Latn", + # Aliases + "jp": "jpn_Jpan", "de": "deu_Latn", "en": "eng_Latn", "fr": "fra_Latn", "es": "spa_Latn", + "pt": "por_Latn", "ru": "rus_Cyrl", "zh": "zho_Hans", "ko": "kor_Hang", "ar": "arb_Arab", +} + +# ===================== SRT parsing ===================== +CUE_RE = re.compile( + r"(?P\d+)\r?\n" + r"(?P\d{2}:\d{2}:\d{2},\d{3}\s-->\s\d{2}:\d{2}:\d{2},\d{3})(?:.*)?\r?\n" + r"(?P(?:.+(?:\r?\n)?)+?)" + r"\r?\n(?=\d+\r?\n|\Z)", + re.UNICODE +) + +def parse_srt(srt_text: str) -> List[Dict[str, str]]: + cues = [] + for m in CUE_RE.finditer(srt_text): + text = m.group("text") + cues.append({"num": m.group("num"), "ts": m.group("ts"), "text": text.rstrip("\n")}) + return cues + +def cues_to_srt(cues: List[Dict[str, str]]) -> str: + parts = [] + for c in cues: + parts.append(f"{c['num']}\n{c['ts']}\n{c['text']}\n") + return "\n".join(parts).strip() + "\n" + +# ===================== Tag protection ===================== +TAG_RE = re.compile(r"\n\r]+?>") # , etc. +ASS_TAG_RE = re.compile(r"\{\\[^}]*\}") # {\an8}, etc. + +def protect_tags(text: str) -> Tuple[str, Dict[str, str]]: + """ + Replace inline tags with placeholders so the translator doesn't mutate them. + We DO NOT mask SENTINEL_BR/CUE so we can split later. + """ + placeholders: Dict[str, str] = {} + idx = 0 + def _sub(reobj, s): + nonlocal idx + def repl(m): + nonlocal idx + token = f"⟦TAG{idx}⟧" + placeholders[token] = m.group(0) + idx += 1 + return token + return reobj.sub(repl, s) + masked = _sub(TAG_RE, text) + masked = _sub(ASS_TAG_RE, masked) + return masked, placeholders + +_TAGNUM_RE = re.compile(r"TAG(\d+)") + +def restore_tags(text: str, placeholders: Dict[str, str]) -> str: + """ + Robustly restore placeholders: + - ⟦TAG3⟧ (ideal) + - TAG3 (if model stripped brackets) + - ⟦ TAG3 ⟧ (extra spaces) + - quoted variants like "TAG3" + """ + out = text + for token, original in placeholders.items(): + m = _TAGNUM_RE.search(token) + if not m: + continue + k = m.group(1) + variants = [ + token, + f"TAG{k}", + f"⟦TAG{k}⟧", + f"⟦ TAG{k} ⟧", + f"'TAG{k}'", f"\"TAG{k}\"", + f"‘TAG{k}’", f"“TAG{k}”", + ] + for v in variants: + out = out.replace(v, original) + pattern = rf"(?:⟦\s*)?TAG{k}(?:\s*⟧)?" + out = re.sub(pattern, lambda _m, _orig=original: _orig, out) + return out + +# ===================== Language utils ===================== +def norm_lang_to_code(name_or_code: str, default: Optional[str] = None) -> Optional[str]: + if name_or_code is None: + return default + s = name_or_code.strip() + if re.match(r"^[a-z]{3}_[A-Za-z]{4}$", s): + return s + return LANG2CODE.get(s.lower(), default) + +def autodetect_code(text_sample: str) -> str: + if not _ld_detect: + return "eng_Latn" + try: + lang = _ld_detect(text_sample) + return LANG2CODE.get(lang.lower(), { + "en": "eng_Latn", "de": "deu_Latn", "fr": "fra_Latn", "es": "spa_Latn", + "pt": "por_Latn", "it": "ita_Latn", "nl": "nld_Latn", "ru": "rus_Cyrl", + "ja": "jpn_Jpan", "ko": "kor_Hang", "zh-cn": "zho_Hans", "zh-tw": "zho_Hant", "ar": "arb_Arab" + }.get(lang.lower(), "eng_Latn")) + except Exception: + return "eng_Latn" + +# ---------- Language names & detection helpers for Ollama (NEW) ---------- +def _lang_name_for_prompt(name_or_code: Optional[str]) -> str: + if not name_or_code: + return "Unknown" + s = name_or_code.strip() + if re.match(r"^[a-z]{3}_[A-Za-z]{4}$", s): + for k, v in LANG2CODE.items(): + if len(k) <= 2: + continue + if v == s: + return k[:1].upper() + k[1:] + return s + return s[:1].upper() + s[1:] + +def _build_code2iso2_map() -> Dict[str, str]: + m: Dict[str, str] = {} + for alias, code in LANG2CODE.items(): + if len(alias) <= 2: + m[code] = alias + return m + +CODE2ISO2 = _build_code2iso2_map() + +_NONWORD_RE = re.compile(r"[^\w\u00C0-\uFFFF]+", flags=re.UNICODE) +_TAG_SENTINEL_RE = re.compile(r"⟦TAG\d+⟧|⟦BR⟧|⟦CUE⟧") + +def _normalize_for_compare(s: str) -> str: + s = _TAG_SENTINEL_RE.sub("", s or "") + s = _html.unescape(s) + s = s.lower() + s = _NONWORD_RE.sub("", s) + return s + +def _looks_untranslated(src_masked: str, out_raw: str, src_code: Optional[str], tgt_code: Optional[str]) -> bool: + s_norm = _normalize_for_compare(src_masked) + t_norm = _normalize_for_compare(out_raw) + if len(s_norm) >= 4 and s_norm == t_norm: + return True + if _ld_detect: + try: + probe = _TAG_SENTINEL_RE.sub("", out_raw or "") + if len(probe) >= 12: + ld = _ld_detect(probe) + src_iso = CODE2ISO2.get(src_code or "", "") + tgt_iso = CODE2ISO2.get(tgt_code or "", "") + if ld and src_iso and ld == src_iso and (not tgt_iso or ld != tgt_iso): + return True + except Exception: + pass + return False + +# ===================== Sentence boundary heuristics ===================== +_END_PUNCT_RE = re.compile(r'[\.!\?…。!?][»”"”\)\]\}]*\s*$') +_CONTINUATION_HINT_RE = re.compile(r'([,;:—\-…]|--)\s*$') + +def _strip_tags_placeholders(s: str) -> str: + s = re.sub(r'⟦TAG\d+⟧', '', s) + s = re.sub(r'\n\r]+?>', '', s) + return s.strip() + +def looks_like_sentence_end(text: str) -> bool: + t = _strip_tags_placeholders(text) + if not t: + return True + if _END_PUNCT_RE.search(t): + return True + if _CONTINUATION_HINT_RE.search(t): + return False + return False + +# ===================== Safe splitting & cleanups ===================== +def _nearest_boundary(text: str, approx_idx: int) -> int: + if not text: + return 0 + n = len(text) + approx_idx = max(1, min(n-1, approx_idx)) + left = approx_idx + right = approx_idx + while left > 0 or right < n: + if left > 0 and (text[left-1].isspace() or not text[left-1].isalnum() or not text[left].isalnum()): + return left + if right < n and (text[right-1].isspace() or not text[right-1].isalnum() or not text[right].isalnum()): + return right + left -= 1 + right += 1 + return approx_idx + +def _split_safely(text: str, parts: int) -> List[str]: + text = text.strip() + if parts <= 1: + return [text] + total = len(text) + out = [] + start = 0 + for i in range(parts - 1): + approx = int((total / parts) * (i + 1)) + idx = _nearest_boundary(text, approx_idx=approx) + piece = text[start:idx].strip() + out.append(piece) + start = idx + out.append(text[start:].strip()) + cleaned = [] + for p in out: + if p == "" and cleaned: + cleaned[-1] = (cleaned[-1] + " ").strip() + else: + cleaned.append(p) + return cleaned if cleaned else [""] + +def _fix_midword_hyphen(s: str) -> str: + s = s.replace("\u00AD", "") + s = re.sub(r"-\s+\b", "", s) + return s + +# ----- Marker normalization & splitting (UPPERCASE ONLY) ----- +_WRAPPERS = r"""["'“”„»«\[\]\{\}\(\)<>\s]*""" +BR_UP_RE = re.compile(rf"(? str: + s = re.sub(r"<\s*unk[^>]*>?", "", s, flags=re.IGNORECASE) + return s + +def _normalize_markers(s: str) -> str: + s = _strip_unk_tokens(s) + s = BR_UP_RE.sub(f" {SENTINEL_BR} ", s) + s = CUE_UP_RE.sub(f" {SENTINEL_CUE} ", s) + return s + +def _split_on_br_normalized(s: str) -> List[str]: + parts = [p.strip() for p in s.split(SENTINEL_BR)] + return [p for p in parts if p != ""] + +def _split_on_cue_normalized(s: str) -> List[str]: + parts = [p.strip() for p in s.split(SENTINEL_CUE)] + return [p for p in parts if p != ""] + +# ----- Leading punctuation fix (iterate until stable; includes Unicode commas) ----- +_LEADING_PUNCT_RE = re.compile( + r"""^\s*([,.;:!?…,、;:]+|[’'”")\]\}》〉」』】〕])]+)\s*""", re.UNICODE +) + +def _fix_leading_punct(lines: List[str]) -> List[str]: + if not lines: + return lines + out = lines[:] + changed = True + while changed: + changed = False + for i in range(1, len(out)): + m = _LEADING_PUNCT_RE.match(out[i]) + if m: + token = m.group(1) + out[i-1] = (out[i-1].rstrip() + token).rstrip() + out[i] = out[i][m.end():].lstrip() + changed = True + return out + +# ----- Spurious brackets removal & html unescape ----- +def _unescape_and_strip_artifacts(text: str) -> str: + s = _html.unescape(text) + s = s.replace("⟦", "").replace("⟧", "") + s = s.replace("\u00A0", " ").replace("\u202F", " ").replace("\u3000", " ") + s = re.sub(r"[ \t]+", " ", s) + return s.strip() + +def _strip_spurious_pairs(orig: str, trans: str) -> str: + out = trans + if "[]" in out and "[]" not in orig: + out = out.replace("[]", "") + if "{}" in out and "{}" not in orig: + out = out.replace("{}", "") + return out.strip() + +# ----- Single-word repetition squash (SeamlessM4T only) ----- +_WORD_RE = re.compile(r"\b\w+\b", flags=re.UNICODE) +_REPEAT_LINE_RE = re.compile(r"^\s*(\w+)(?:\W+\1\b){2,}\s*$", flags=re.IGNORECASE | re.UNICODE) + +def _is_one_word_line(s: str) -> bool: + s2 = _strip_tags_placeholders(s) + return len(_WORD_RE.findall(s2)) == 1 + +def _squelch_single_word_repeat_if_needed(orig_line: str, translated_line: str) -> str: + if not _is_one_word_line(orig_line): + return translated_line + m = _REPEAT_LINE_RE.match(translated_line) + if m: + return m.group(1) + words = _WORD_RE.findall(translated_line) + if words: + lowered = [w.lower() for w in words] + if len(set(lowered)) == 1 and len(lowered) >= 5: + return words[0] + return translated_line + +# ===================== NEW: marker/token removal at per-line finalization ===================== +_MARKER_TOKEN_RE = re.compile( + r'(?:(?<=^)|(?<=\s))' + r'(?:["\'“”‘’])?' + r'\S*?(?:BR|CUE)\S*?' + r'(?:["\'“”‘’])?' + r'(?:\s*,)?' + r'(?=(?:\s|$))' +) + +_TAGN_TOKEN_RE = re.compile( + r'(?:(?<=^)|(?<=\s))' + r'(?:["\'“”‘’])?' + r'\S*?TAG[0-9]\S*?' + r'(?:["\'“”‘’])?' + r'(?:\s*,)?' + r'(?=(?:\s|$))', + flags=re.IGNORECASE +) + +def _remove_marker_and_tagn_tokens(line: str) -> str: + out = line + for _ in range(3): + new = _MARKER_TOKEN_RE.sub("", out) + new = _TAGN_TOKEN_RE.sub("", new) + if new == out: + break + out = new + out = re.sub(r"\s+,", ",", out) + out = re.sub(r"\s+([.;:!?…,、;:])", r"\1", out) + out = re.sub(r"[ \t]{2,}", " ", out) + out = re.sub(r"^\s*[,,、]+", "", out) + return out.strip() + +# ===================== Device selection (NLLB & Seamless) ===================== +def pick_device_for_workers(workers: int, device_mode: str): + workers = max(1, workers) + device_mode = (device_mode or "auto").lower() + + has_mps = has_cuda = has_hip = False + try: + import torch + has_mps = getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() + has_cuda = torch.cuda.is_available() + has_hip = getattr(torch.version, "hip", None) is not None + except Exception: + pass + + is_mac = (sys.platform == "darwin") + + def _cpu(): + return {"device": -1}, workers + + def _mps(): + return {"device": "mps"}, 1 + + def _cuda_or_rocm(): + return {"device": 0}, 1 + + if device_mode == "cpu": + return _cpu() + + if device_mode == "gpu": + if is_mac and has_mps: + return _mps() + if has_cuda or has_hip: + return _cuda_or_rocm() + return _cpu() + + if is_mac: + if has_mps: + return _mps() + if has_cuda or has_hip: + return _cuda_or_rocm() + return _cpu() + else: + if has_cuda or has_hip: + return _cuda_or_rocm() + if has_mps: + return _mps() + return _cpu() + +# ===================== NLLB via Transformers ===================== +def get_nllb_translator(model_name: str, src_code: str, tgt_code: str, device_kwargs: dict): + try: + from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline as _pipeline + import torch # noqa + except Exception: + from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + from transformers.pipelines import pipeline as _pipeline + import torch # noqa + + tok = AutoTokenizer.from_pretrained(model_name) + mdl = AutoModelForSeq2SeqLM.from_pretrained(model_name) + mdl.eval() + return _pipeline("translation", model=mdl, tokenizer=tok, + src_lang=src_code, tgt_lang=tgt_code, **device_kwargs) + +def ensure_model_downloaded(repo_id: str, revision: str = "main", tqdm_class=None): + from huggingface_hub import snapshot_download + snapshot_download(repo_id=repo_id, revision=revision, tqdm_class=tqdm_class) + +# ===================== SeamlessM4T (text-to-text) ===================== +def _to_seamless_lang(nllb_code: str) -> str: + if not nllb_code: + return "eng" + return nllb_code.split("_", 1)[0] + +def get_seamless_translator(model_name: str, src_code: str, tgt_code: str, device_kwargs: dict): + import torch + from transformers import AutoProcessor, SeamlessM4Tv2ForTextToText + + processor = AutoProcessor.from_pretrained(model_name) + model = SeamlessM4Tv2ForTextToText.from_pretrained(model_name) + model.eval() + dev = device_kwargs.get("device", -1) + + if dev == "mps": + device = torch.device("mps") + elif isinstance(dev, int) and dev >= 0 and torch.cuda.is_available(): + device = torch.device(f"cuda:{dev}") + else: + device = torch.device("cpu") + model.to(device) + + src3 = _to_seamless_lang(src_code) + tgt3 = _to_seamless_lang(tgt_code) + + def _translate(batch_texts: List[str]) -> List[str]: + texts = [" " if (t is None or str(t).strip() == "") else str(t) for t in batch_texts] + inputs = processor(text=texts, src_lang=src3, return_tensors="pt", padding=True) + inputs = {k: v.to(device) for k, v in inputs.items()} + import torch as _torch + with _torch.no_grad(): + out = model.generate(**inputs, tgt_lang=tgt3) + seqs = getattr(out, "sequences", out) + decoded = processor.batch_decode(seqs, skip_special_tokens=True) + return decoded + + return _translate + +def ensure_seamless_downloaded(repo_id: str = "facebook/seamless-m4t-v2-large", revision: str = "main", tqdm_class=None): + from huggingface_hub import snapshot_download + snapshot_download(repo_id=repo_id, revision=revision, tqdm_class=tqdm_class) + +# ===================== Ollama JSON chat (robust) ===================== +def _ollama_system_prompt(): + return ( + "You are a professional subtitle translator.\n" + "Return STRICT JSON ONLY. No Markdown, no code fences, no commentary.\n" + "CRITICAL RULES:\n" + "1) Preserve the exact array shapes and counts provided (lines per cue, cues per group).\n" + "2) Do NOT add/remove/swap items; do NOT merge or split lines.\n" + "3) Keep placeholder tokens like ⟦TAG0⟧ EXACTLY unchanged.\n" + "4) Do not hyphenate or break words across lines.\n" + "5) Keep numbers and time references as-is.\n" + "6) Unless a string is a proper name/brand/code or already in the target language, you MUST translate it.\n" + "Reply with pure JSON conforming to the requested schema.\n" + ) + +def _strip_code_fences(s: str) -> str: + s = s.strip() + if s.startswith("```"): + first = s.find("```") + if first != -1: + s = s[first+3:] + if "\n" in s: + s = s.split("\n", 1)[1] + if s.rstrip().endswith("```"): + s = s.rstrip()[:-3] + return s.strip() + +def _extract_balanced_json(s: str): + import json as _json + s = s.strip() + try: + return _json.loads(s) + except Exception: + pass + s = _strip_code_fences(s) + try: + return _json.loads(s) + except Exception: + pass + + start_obj = s.find("{") + start_arr = s.find("[") + if start_obj == -1 and start_arr == -1: + raise ValueError("No JSON start found") + start = min([i for i in [start_obj, start_arr] if i != -1]) + kind = "obj" if start == start_obj else "arr" + + depth_brace = depth_bracket = 0 + in_string = False + esc = False + for i in range(start, len(s)): + ch = s[i] + if in_string: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "{": + depth_brace += 1 + elif ch == "}": + depth_brace -= 1 + if kind == "obj" and depth_brace == 0 and depth_bracket == 0: + frag = s[start:i+1] + try: + return _json.loads(frag) + except Exception: + break + elif ch == "[": + depth_bracket += 1 + elif ch == "]": + depth_bracket -= 1 + if kind == "arr" and depth_bracket == 0 and depth_brace == 0: + frag = s[start:i+1] + try: + return _json.loads(frag) + except Exception: + break + frag = s[start:] + frag = re.sub(r",(\s*[}\]])", r"\1", frag) + return _json.loads(frag) + +def ollama_chat_json( + model: str, + system_prompt: str, + user_prompt: str, + host: str = "localhost", + port: int = 11434, + temperature: float = 0.2, + max_retries: int = 4, + timeout: int = 600, + cancel_event: Optional[threading.Event] = None, +): + last_err = None + for attempt in range(max_retries): + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + prompt = user_prompt if attempt == 0 else ( + user_prompt + "\n\nSTRICT RETRY: Previous reply was NOT valid JSON. " + "Respond with JSON ONLY (no code fences), matching the schema and counts exactly." + ) + body = json.dumps({ + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ], + "format": "json", + "stream": False, + "options": {"temperature": temperature} + }) + try: + conn = http.client.HTTPConnection(host, port, timeout=timeout) + conn.request("POST", "/api/chat", body=body, headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + data = resp.read() + conn.close() + if resp.status != 200: + last_err = RuntimeError(f"Ollama HTTP {resp.status}: {data[:200]}") + time.sleep(1.1 * (attempt + 1)) + continue + j = json.loads(data.decode("utf-8")) + content = j.get("message", {}).get("content", "") + try: + return json.loads(content) + except Exception: + return _extract_balanced_json(content) + except Exception as e: + last_err = e + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + time.sleep(1.1 * (attempt + 1)) + raise last_err or RuntimeError("Ollama call failed") + +# ===================== Ollama prompt builders (UPDATED) ===================== +def _ollama_user_prompt_lines(target_lang: str, src_hint: str, lines: List[str], force: bool=False) -> str: + tgt_name = _lang_name_for_prompt(target_lang) + src_name = _lang_name_for_prompt(src_hint) + rules = [ + f"Translate each item to {tgt_name}.", + f"Source language hint: {src_name}.", + "Preserve array length exactly as provided.", + "No Markdown, no code fences, return JSON only." + ] + if force: + rules.append( + f"If an item is not already in {tgt_name} and contains translatable words, you MUST translate it; " + f"do NOT return the source sentence unchanged (proper names/brands/codes may remain)." + ) + payload = { + "task": "translate_lines", + "target_language": tgt_name, + "source_language_hint": src_name, + "schema": {"lines": ["", "..."]}, + "expected_counts": {"lines": len(lines)}, + "rules": rules, + "lines": lines, + } + return json.dumps(payload, ensure_ascii=False) + +def _ollama_user_prompt_cues(target_lang: str, src_hint: str, cues: List[List[str]]) -> str: + tgt_name = _lang_name_for_prompt(target_lang) + src_name = _lang_name_for_prompt(src_hint) + payload = { + "task": "translate_cues", + "target_language": tgt_name, + "source_language_hint": src_name, + "schema": {"cues": [["", "..."]]}, + "expected_counts": {"cues": [len(c) for c in cues]}, + "rules": [ + f"Translate to {tgt_name}. Source language hint: {src_name}.", + "Preserve lines per cue exactly.", + "No Markdown, no code fences, return JSON only." + ], + "cues": cues, + } + return json.dumps(payload, ensure_ascii=False) + +def _ollama_user_prompt_groups(target_lang: str, src_hint: str, groups: List[List[List[str]]]) -> str: + tgt_name = _lang_name_for_prompt(target_lang) + src_name = _lang_name_for_prompt(src_hint) + payload = { + "task": "translate_groups", + "target_language": tgt_name, + "source_language_hint": src_name, + "schema": {"groups": [[["", "..."]]]}, + "expected_counts": {"groups": [[len(cue) for cue in group] for group in groups]}, + "rules": [ + f"Translate to {tgt_name}. Source language hint: {src_name}.", + "Preserve cues per group and lines per cue exactly.", + "No Markdown, no code fences, return JSON only." + ], + "groups": groups, + } + return json.dumps(payload, ensure_ascii=False) + +# ---------- Ollama strict retry for lines (NEW) ---------- +def _ollama_retry_translate_lines(model: str, host: str, port: int, + target_language: str, src_hint: str, + masked_lines: List[str], + cancel_event: Optional[threading.Event] = None) -> List[str]: + if not masked_lines: + return [] + + sys_prompt = _ollama_system_prompt() + models = [m.strip() for m in str(model).split(",") if m.strip()] or [model] + temps = (0.1, 0.3, 0.0) + + for T in temps: + for m in models: + if cancel_event is not None and cancel_event.is_set(): + return masked_lines + try: + prompt = _ollama_user_prompt_lines(target_language, src_hint, masked_lines, force=True) + obj = ollama_chat_json(m, sys_prompt, prompt, host=host, port=port, + temperature=T, max_retries=3, cancel_event=cancel_event) + lines = obj.get("lines", []) + if isinstance(lines, list) and len(lines) == len(masked_lines): + return lines + except Exception: + continue + return masked_lines + +# ===================== Builders ===================== +def build_line_items(cue_lines: List[List[str]]) -> Tuple[List[str], List[Dict[str, str]]]: + flat_masked: List[str] = [] + masks: List[Dict[str, str]] = [] + for lines in cue_lines: + for ln in lines: + masked, ph = protect_tags(ln) + flat_masked.append(masked) + masks.append(ph) + return flat_masked, masks + +def build_cue_items_for_nllb(cue_lines: List[List[str]]) -> Tuple[List[str], List[Dict[str, str]], List[int]]: + joined: List[str] = [] + masks: List[Dict[str, str]] = [] + counts: List[int] = [] + for lines in cue_lines: + combined = f"\n{SENTINEL_BR}\n".join(lines) if lines else "" + masked, ph = protect_tags(combined) + joined.append(masked) + masks.append(ph) + counts.append(len(lines)) + return joined, masks, counts + +def build_cue_items_for_llm(cue_lines: List[List[str]]) -> Tuple[List[List[str]], List[List[Dict[str, str]]]]: + cues_text: List[List[str]] = [] + cues_masks: List[List[Dict[str, str]]] = [] + for lines in cue_lines: + masked_lines, masks = [], [] + for ln in lines: + m, ph = protect_tags(ln) + masked_lines.append(m) + masks.append(ph) + cues_text.append(masked_lines) + cues_masks.append(masks) + return cues_text, cues_masks + +def build_smart_groups_for_nllb(cue_lines: List[List[str]], max_span_cues: int = 4 + ) -> Tuple[List[str], List[Dict[str, str]], List[List[int]]]: + n = len(cue_lines) + groups_text: List[str] = [] + groups_masks: List[Dict[str, str]] = [] + groups_counts: List[List[int]] = [] + + i = 0 + while i < n: + group_cues = [] + counts = [] + span = 0 + while i < n and span < max_span_cues: + lines = cue_lines[i] + counts.append(len(lines)) + cue_text = f"\n{SENTINEL_BR}\n".join(lines) + group_cues.append(cue_text) + if looks_like_sentence_end("\n".join(lines)): + i += 1 + break + span += 1 + i += 1 + combined_group = f"\n{SENTINEL_CUE}\n".join(group_cues) + masked, ph = protect_tags(combined_group) + groups_text.append(masked) + groups_masks.append(ph) + groups_counts.append(counts) + return groups_text, groups_masks, groups_counts + +def build_smart_groups_for_llm(cue_lines: List[List[str]], max_span_cues: int = 4 + ) -> Tuple[List[List[List[str]]], List[List[List[Dict[str,str]]]], List[List[int]]]: + n = len(cue_lines) + groups_text: List[List[List[str]]] = [] + groups_masks: List[List[List[Dict[str,str]]]] = [] + groups_counts: List[List[int]] = [] + + i = 0 + while i < n: + group_cues: List[List[str]] = [] + group_masks: List[List[Dict[str,str]]] = [] + counts: List[int] = [] + span = 0 + while i < n and span < max_span_cues: + lines = cue_lines[i] + counts.append(len(lines)) + masked_lines, masks = [], [] + for ln in lines: + m, ph = protect_tags(ln) + masked_lines.append(m) + masks.append(ph) + group_cues.append(masked_lines) + group_masks.append(masks) + if looks_like_sentence_end("\n".join(lines)): + i += 1 + break + span += 1 + i += 1 + groups_text.append(group_cues) + groups_masks.append(group_masks) + groups_counts.append(counts) + return groups_text, groups_masks, groups_counts + +# ===================== Queue helper ===================== +def _drain_queue(q: "queue.Queue"): + while True: + try: + _ = q.get_nowait() + q.task_done() + except queue.Empty: + break + +# ===================== Translation workers (NLLB) ===================== +def nllb_translate_lines(flat_lines, masks, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(flat_lines) + results: List[Optional[str]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [flat_lines[j] for j in idx_chunk] + batch_masks = [masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_nllb_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 8 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip() == "" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + i = sub_idx[pos] + restored = restore_tags(out["translation_text"], sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + results[i] = _fix_midword_hyphen(restored) + if on_progress: on_progress(1) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else "" for r in results] + +def nllb_translate_cues(flat_cues, cue_masks, counts, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(flat_cues) + results: List[Optional[List[str]]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [flat_cues[j] for j in idx_chunk] + batch_masks = [cue_masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_nllb_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 6 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip()=="" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + i = sub_idx[pos] + restored = restore_tags(out["translation_text"], sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + n_lines = counts[i] + lines = resplit_translated_cue_text(restored, n_lines) + results[i] = _fix_leading_punct([_fix_midword_hyphen(p) for p in lines]) + if on_progress: on_progress(n_lines) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [""]*c for r,c in zip(results, counts)] + +def nllb_translate_groups(groups_text, groups_masks, groups_counts, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(groups_text) + results: List[Optional[List[List[str]]]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [groups_text[j] for j in idx_chunk] + batch_masks = [groups_masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_nllb_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 3 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip()=="" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + gi = sub_idx[pos] + restored = restore_tags(out["translation_text"], sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + cue_chunks = _split_on_cue_normalized(restored) + counts = groups_counts[gi] + if len(cue_chunks) != len(counts): + merged = " ".join(cue_chunks).strip() + cue_chunks = _split_safely(merged, len(counts)) + rebuilt_cues = [] + for chunk, n_lines in zip(cue_chunks, counts): + lines = resplit_translated_cue_text(chunk, n_lines) + rebuilt_cues.append(_fix_leading_punct([_fix_midword_hyphen(p) for p in lines])) + results[gi] = rebuilt_cues + if on_progress: on_progress(sum(counts)) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [[""]*n for n in counts] for r,counts in zip(results, groups_counts)] + +# ===================== Translation workers (SeamlessM4T) ===================== +def seamless_translate_lines(flat_lines, masks, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(flat_lines) + results: List[Optional[str]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [flat_lines[j] for j in idx_chunk] + batch_masks = [masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_seamless_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 8 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip()=="" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + i = sub_idx[pos] + restored = restore_tags(out, sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + results[i] = _fix_midword_hyphen(restored) + if on_progress: on_progress(1) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else "" for r in results] + +def seamless_translate_cues(flat_cues, cue_masks, counts, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(flat_cues) + results: List[Optional[List[str]]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [flat_cues[j] for j in idx_chunk] + batch_masks = [cue_masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_seamless_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 6 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip()=="" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + i = sub_idx[pos] + restored = restore_tags(out, sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + n_lines = counts[i] + lines = resplit_translated_cue_text(restored, n_lines) + results[i] = _fix_leading_punct([_fix_midword_hyphen(p) for p in lines]) + if on_progress: on_progress(n_lines) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [""]*c for r,c in zip(results, counts)] + +def seamless_translate_groups(groups_text, groups_masks, groups_counts, model_name, src_code, tgt_code, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None, device_kwargs=None): + n = len(groups_text) + results: List[Optional[List[List[str]]]] = [None] * n + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + masked_batch = [groups_text[j] for j in idx_chunk] + batch_masks = [groups_masks[j] for j in idx_chunk] + q.put((idx_chunk, masked_batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); return + translator = get_seamless_translator(model_name, src_code, tgt_code, device_kwargs or {"device": -1}) + micro = 3 + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, masked_batch, batch_masks = item + to_xlate_full = [" " if s.strip()=="" else s for s in masked_batch] + for s in range(0, len(to_xlate_full), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_in = to_xlate_full[s:s+micro] + sub_masks = batch_masks[s:s+micro] + outs = translator(sub_in) + for pos, out in enumerate(outs): + gi = sub_idx[pos] + restored = restore_tags(out, sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + cue_chunks = _split_on_cue_normalized(restored) + counts = groups_counts[gi] + if len(cue_chunks) != len(counts): + merged = " ".join(cue_chunks).strip() + cue_chunks = _split_safely(merged, len(counts)) + rebuilt_cues = [] + for chunk, n_lines in zip(cue_chunks, counts): + lines = resplit_translated_cue_text(chunk, n_lines) + rebuilt_cues.append(_fix_leading_punct([_fix_midword_hyphen(p) for p in lines])) + results[gi] = rebuilt_cues + if on_progress: on_progress(sum(counts)) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [[""]*n for n in counts] for r,counts in zip(results, groups_counts)] + +# ===================== Ollama workers (UPDATED with Repair-Pass & cancel-aware) ===================== +def ollama_translate_lines(flat_lines, masks, target_language, src_code, model, host, port, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None): + n = len(flat_lines) + results: List[Optional[str]] = [None] * n + tgt_code = norm_lang_to_code(target_language, default=None) + + q: "queue.Queue[Optional[Tuple[List[int], List[str], List[Dict[str,str]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + batch = [flat_lines[j] for j in idx_chunk] + batch_masks = [masks[j] for j in idx_chunk] + q.put((idx_chunk, batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + micro = 8 + sys_prompt = _ollama_system_prompt() + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, batch, batch_masks = item + + # micro-batched calls to limit blocking time + for s in range(0, len(batch), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_batch = batch[s:s+micro] + sub_masks = batch_masks[s:s+micro] + + prompt = _ollama_user_prompt_lines(target_language, src_code, sub_batch, force=False) + out_obj = ollama_chat_json(model, sys_prompt, prompt, host=host, port=port, + temperature=0.1, cancel_event=cancel_event) + lines_out = out_obj.get("lines", []) + if not isinstance(lines_out, list) or len(lines_out) != len(sub_batch): + raise RuntimeError("Ollama returned invalid lines array.") + + # Repair pass + to_fix_idx: List[int] = [] + to_fix_src: List[str] = [] + for pos, t in enumerate(lines_out): + src_masked = sub_batch[pos] + if _looks_untranslated(src_masked, t, src_code, tgt_code): + to_fix_idx.append(pos); to_fix_src.append(src_masked) + if to_fix_src: + fixed = _ollama_retry_translate_lines(model, host, port, target_language, src_code, to_fix_src, cancel_event=cancel_event) + for k, new_t in enumerate(fixed): + lines_out[to_fix_idx[k]] = new_t + + # restore + finalize + for pos, t in enumerate(lines_out): + i = sub_idx[pos] + restored = restore_tags(t, sub_masks[pos]) + restored = _normalize_markers(restored) + restored = _unescape_and_strip_artifacts(restored) + results[i] = _fix_midword_hyphen(restored) + if on_progress: on_progress(1) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else "" for r in results] + +def ollama_translate_cues(cues_text, cues_masks, target_language, src_code, model, host, port, + workers, batch_size, on_progress, pause_event_unused, cancel_event=None): + n = len(cues_text) + results: List[Optional[List[str]]] = [None] * n + tgt_code = norm_lang_to_code(target_language, default=None) + + q: "queue.Queue[Optional[Tuple[List[int], List[List[str]], List[List[Dict[str,str]]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + batch = [cues_text[j] for j in idx_chunk] + batch_masks = [cues_masks[j] for j in idx_chunk] + q.put((idx_chunk, batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + micro = 6 + sys_prompt = _ollama_system_prompt() + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + idx_chunk, batch, batch_masks = item + + for s in range(0, len(batch), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_batch = batch[s:s+micro] + sub_masks = batch_masks[s:s+micro] + + prompt = _ollama_user_prompt_cues(target_language, src_code, sub_batch) + out_obj = ollama_chat_json(model, sys_prompt, prompt, host=host, port=port, + temperature=0.1, cancel_event=cancel_event) + cues_out = out_obj.get("cues", []) + if (not isinstance(cues_out, list)) or (len(cues_out) != len(sub_batch)): + raise RuntimeError("Ollama returned invalid cues array.") + + retry_pairs: List[Tuple[int,int]] = [] + retry_texts: List[str] = [] + for pos, cue_lines_trans in enumerate(cues_out): + masks_per_line = sub_masks[pos] + if len(cue_lines_trans) != len(masks_per_line): + merged = " ".join(cue_lines_trans) + cue_lines_trans = _split_safely(merged, len(masks_per_line)) + cues_out[pos] = cue_lines_trans + for li, t in enumerate(cue_lines_trans): + src_masked = sub_batch[pos][li] + if _looks_untranslated(src_masked, t, src_code, tgt_code): + retry_pairs.append((pos, li)) + retry_texts.append(src_masked) + + if retry_texts: + fixed = _ollama_retry_translate_lines(model, host, port, target_language, src_code, retry_texts, cancel_event=cancel_event) + for (pos, li), new_t in zip(retry_pairs, fixed): + cues_out[pos][li] = new_t + + for pos, cue_lines_trans in enumerate(cues_out): + masks_per_line = sub_masks[pos] + rebuilt = [] + for ln, ph in zip(cue_lines_trans, masks_per_line): + t = restore_tags(ln, ph) + t = _normalize_markers(t) + t = _unescape_and_strip_artifacts(t) + rebuilt.append(_fix_midword_hyphen(t)) + i = sub_idx[pos] + results[i] = _fix_leading_punct(rebuilt) + if on_progress: on_progress(len(rebuilt)) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [""]*len(cues_text[idx]) for idx,r in enumerate(results)] + +def ollama_translate_groups(groups_text, groups_masks, groups_counts, target_language, src_code, + model, host, port, workers, batch_size, on_progress, pause_event_unused, cancel_event=None): + n = len(groups_text) + results: List[Optional[List[List[str]]]] = [None] * n + tgt_code = norm_lang_to_code(target_language, default=None) + + q: "queue.Queue[Optional[Tuple[List[int], List[List[List[str]]], List[List[List[Dict[str,str]]]]]]]" = queue.Queue() + for i in range(0, n, batch_size): + idx_chunk = list(range(i, min(n, i+batch_size))) + batch = [groups_text[j] for j in idx_chunk] + batch_masks = [groups_masks[j] for j in idx_chunk] + q.put((idx_chunk, batch, batch_masks)) + for _ in range(workers): + q.put(None) + + def worker_main(): + micro = 4 + sys_prompt = _ollama_system_prompt() + while True: + if cancel_event is not None and cancel_event.is_set(): + _drain_queue(q); break + try: + item = q.get(timeout=0.2) + except queue.Empty: + continue + if item is None: + q.task_done(); break + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); break + + idx_chunk, batch, batch_masks = item + for s in range(0, len(batch), micro): + if cancel_event is not None and cancel_event.is_set(): + q.task_done(); _drain_queue(q); return + sub_idx = idx_chunk[s:s+micro] + sub_batch = batch[s:s+micro] + sub_masks = batch_masks[s:s+micro] + + prompt = _ollama_user_prompt_groups(target_language, src_code, sub_batch) + out_obj = ollama_chat_json(model, sys_prompt, prompt, host=host, port=port, + temperature=0.1, cancel_event=cancel_event) + groups_out = out_obj.get("groups", []) + + if (not isinstance(groups_out, list)) or (len(groups_out) != len(sub_batch)): + groups_out = [] + for group in sub_batch: + prompt2 = _ollama_user_prompt_cues(target_language, src_code, group) + try: + obj2 = ollama_chat_json(model, sys_prompt, prompt2, host=host, port=port, + temperature=0.1, cancel_event=cancel_event) + cues_out2 = obj2.get("cues", []) + if not isinstance(cues_out2, list) or len(cues_out2) != len(group): + cues_out2 = group + except Exception: + cues_out2 = group + groups_out.append(cues_out2) + + retry_triples: List[Tuple[int,int,int]] = [] + retry_texts: List[str] = [] + + for pos, group_out in enumerate(groups_out): + masks_for_group = sub_masks[pos] + if len(group_out) != len(masks_for_group): + merged_cues_text = [" ".join(c) if isinstance(c, list) else str(c) for c in group_out] + merged_all = " ".join(merged_cues_text) + split_cues = _split_safely(merged_all, len(masks_for_group)) + coerced_group = [] + for chunk, line_masks in zip(split_cues, masks_for_group): + coerced_group.append(_split_safely(chunk, len(line_masks))) + group_out = coerced_group + groups_out[pos] = group_out + + for ci, (cue_out, line_masks) in enumerate(zip(group_out, masks_for_group)): + if len(cue_out) != len(line_masks): + merged = " ".join(cue_out if isinstance(cue_out, list) else [str(cue_out)]) + cue_out = _split_safely(merged, len(line_masks)) + group_out[ci] = cue_out + for li, ln in enumerate(cue_out): + src_masked = sub_batch[pos][ci][li] + if _looks_untranslated(src_masked, ln, src_code, tgt_code): + retry_triples.append((pos, ci, li)) + retry_texts.append(src_masked) + + if retry_texts: + fixed = _ollama_retry_translate_lines(model, host, port, target_language, src_code, retry_texts, cancel_event=cancel_event) + for (pos, ci, li), new_t in zip(retry_triples, fixed): + groups_out[pos][ci][li] = new_t + + for pos, group_out in enumerate(groups_out): + masks_for_group = sub_masks[pos] + rebuilt_group: List[List[str]] = [] + for cue_out, line_masks in zip(group_out, masks_for_group): + rebuilt_lines = [] + for ln, ph in zip(cue_out, line_masks): + t = restore_tags(ln, ph) + t = _normalize_markers(t) + t = _unescape_and_strip_artifacts(t) + rebuilt_lines.append(_fix_midword_hyphen(t)) + rebuilt_group.append(_fix_leading_punct(rebuilt_lines)) + gi = sub_idx[pos] + results[gi] = rebuilt_group + if on_progress: on_progress(sum(len(x) for x in rebuilt_group)) + q.task_done() + + threads = [threading.Thread(target=worker_main, daemon=True) for _ in range(workers)] + [t.start() for t in threads]; q.join() + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError("Cancelled") + return [r if r is not None else [[""]*n for n in counts] for r,counts in zip(results, groups_counts)] + +# ===================== Rebuild helpers ===================== +def resplit_translated_cue_text(translated: str, n_lines: int) -> List[str]: + normalized = _normalize_markers(translated) + parts = _split_on_br_normalized(normalized) + if len(parts) == n_lines: + return [_fix_midword_hyphen(p) for p in parts] + + nl_parts = [ln.strip() for ln in re.split(r"\r?\n", normalized) if ln.strip()] + if len(nl_parts) == n_lines: + return [_fix_midword_hyphen(p) for p in nl_parts] + + merged = " ".join(parts if parts else [normalized]).strip() + return [_fix_midword_hyphen(p) for p in _split_safely(merged, n_lines)] + +def _postprocess_line(orig_line: str, trans_line: str, squelch_for_seamless: bool=False) -> str: + t = _strip_spurious_pairs(orig_line, trans_line) + t = _unescape_and_strip_artifacts(t) + t = _remove_marker_and_tagn_tokens(t) + if squelch_for_seamless: + t = _squelch_single_word_repeat_if_needed(orig_line, t) + return t + +def rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=False): + rebuilt_cues = [] + idx = 0 + for c, lines in zip(cues, cue_lines): + n = len(lines) + tlines = translated_flat[idx:idx + n] + idx += n + tlines = [_postprocess_line(o, _fix_midword_hyphen(t), squelch_single_word) for o, t in zip(lines, tlines)] + tlines = _fix_leading_punct(tlines) + c_new = dict(c); c_new["text"] = "\n".join(tlines) + rebuilt_cues.append(c_new) + return rebuilt_cues + +def rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=False): + rebuilt_cues = [] + for c, tparts, orig_parts in zip(cues, translated_per_cue, cue_lines): + tparts = [_postprocess_line(o, _fix_midword_hyphen(t), squelch_single_word) for o, t in zip(orig_parts, tparts)] + tparts = _fix_leading_punct(tparts) + c_new = dict(c); c_new["text"] = "\n".join(tparts) + rebuilt_cues.append(c_new) + return rebuilt_cues + +# ===================== Config ===================== +def load_config(): + try: + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + +def save_config(cfg: dict): + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + except Exception: + pass + +# ===================== Ollama model listing ===================== +def list_ollama_models_http(host="localhost", port=11434) -> List[str]: + try: + conn = http.client.HTTPConnection(host, port, timeout=10) + conn.request("GET", "/api/tags") + resp = conn.getresponse() + data = resp.read() + conn.close() + if resp.status != 200: + return [] + j = json.loads(data.decode("utf-8")) + models = j.get("models", []) + names = [] + for m in models: + name = m.get("name") + if isinstance(name, str): + names.append(name) + return sorted(set(names)) + except Exception: + return [] + +def list_ollama_models_cli() -> List[str]: + try: + p = subprocess.run(["ollama", "list", "--json"], capture_output=True, text=True, timeout=10) + if p.returncode == 0 and p.stdout.strip(): + txt = p.stdout.strip() + models = [] + try: + j = json.loads(txt) + if isinstance(j, dict) and "models" in j: + for m in j["models"]: + name = m.get("name") + if isinstance(name, str): + models.append(name) + elif isinstance(j, list): + for m in j: + name = m.get("name") + if isinstance(name, str): + models.append(name) + except Exception: + for line in txt.splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + name = obj.get("name") + if isinstance(name, str): + models.append(name) + except Exception: + pass + if models: + return sorted(set(models)) + except Exception: + pass + try: + p = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=10) + if p.returncode == 0 and p.stdout: + models = [] + for line in p.stdout.splitlines(): + line = line.strip() + if line.lower().startswith(("name","models")) or not line: + continue + parts = line.split() + if parts: + models.append(parts[0]) + if models: + return sorted(set(models)) + except Exception: + pass + return [] + +def get_all_ollama_models(host="localhost", port=11434) -> List[str]: + names = list_ollama_models_http(host, port) + if names: + return names + return list_ollama_models_cli() + +# ===================== CLI flow ===================== +def run_cli(args): + if not os.path.exists(args.srt_file): + print(f"File not found: {args.srt_file}", file=sys.stderr); sys.exit(1) + + with open(args.srt_file, "r", encoding="utf-8", errors="replace") as f: + srt_text = f.read() + + cues = parse_srt(srt_text) + if not cues: + print("No SRT cues detected. Is the file valid?", file=sys.stderr); sys.exit(1) + + cue_lines: List[List[str]] = [c["text"].splitlines() if c["text"] else [""] for c in cues] + tgt_code = _resolve_target(args.target_language) + src_code = _resolve_source(args.src, [ln for lines in cue_lines for ln in lines]) + base, _ = os.path.splitext(args.srt_file) + out_path = args.out or f"{base}.{tgt_code.lower()}.srt" + + total_lines = sum(len(ls) for ls in cue_lines) + pbar = tqdm(total=total_lines, unit="line", dynamic_ncols=True, desc="Translating") if (tqdm and not args.no_progress) else None + on_progress = (lambda n=1: pbar.update(n)) if pbar else None + + cancel_event = None # CLI: no cancel + + if args.engine == "nllb": + print(f"Ensuring model is cached: {args.model} …") + ensure_model_downloaded(args.model) + dev_kwargs, workers = pick_device_for_workers(max(1, args.workers), args.device) + print(f"Engine=NLLB | model={args.model} | src={src_code}→{tgt_code} | device={dev_kwargs.get('device')} | threads={args.workers} effective={workers} | context={args.context}") + + if args.context == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = nllb_translate_lines(flat_lines, masks, args.model, src_code, tgt_code, + workers, args.batch, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=False) + elif args.context == "cue": + flat_cues, cue_masks, counts = build_cue_items_for_nllb(cue_lines) + translated_per_cue = nllb_translate_cues(flat_cues, cue_masks, counts, args.model, src_code, tgt_code, + workers, min(args.batch, 32), on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=False) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_nllb(cue_lines, max_span_cues=args.max_span_cues) + translated_groups = nllb_translate_groups(groups_text, groups_masks, groups_counts, args.model, src_code, tgt_code, + workers, min(args.batch, 16), on_progress, None, cancel_event, dev_kwargs) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=False) + + elif args.engine == "seamless": + print(f"Ensuring SeamlessM4T is cached: {args.seamless_model} …") + ensure_seamless_downloaded(args.seamless_model) + dev_kwargs, workers = pick_device_for_workers(max(1, args.workers), args.device) + print(f"Engine=SeamlessM4T | model={args.seamless_model} | src={src_code}→{tgt_code} | device={dev_kwargs.get('device')} | threads={args.workers} effective={workers} | context={args.context}") + + if args.context == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = seamless_translate_lines(flat_lines, masks, args.seamless_model, src_code, tgt_code, + workers, args.batch, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=True) + elif args.context == "cue": + flat_cues, cue_masks, counts = build_cue_items_for_nllb(cue_lines) + translated_per_cue = seamless_translate_cues(flat_cues, cue_masks, counts, args.seamless_model, src_code, tgt_code, + workers, min(args.batch, 32), on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=True) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_nllb(cue_lines, max_span_cues=args.max_span_cues) + translated_groups = seamless_translate_groups(groups_text, groups_masks, groups_counts, args.seamless_model, src_code, tgt_code, + workers, min(args.batch, 16), on_progress, None, cancel_event, dev_kwargs) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=True) + + else: # Ollama + host, port = args.ollama_host, args.ollama_port + model = args.ollama_model + print(f"Engine=Ollama | model={model} | src={src_code}→{tgt_code} | host={host}:{port} | threads={args.workers} | context={args.context}") + + if args.context == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = ollama_translate_lines(flat_lines, masks, args.target_language, src_code, model, host, port, + max(1, args.workers), args.batch, on_progress, None, cancel_event) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=False) + elif args.context == "cue": + cues_text, cues_masks = build_cue_items_for_llm(cue_lines) + translated_per_cue = ollama_translate_cues(cues_text, cues_masks, args.target_language, src_code, model, host, port, + max(1, args.workers), min(args.batch, 24), on_progress, None, cancel_event) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=False) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_llm(cue_lines, max_span_cues=args.max_span_cues) + translated_groups = ollama_translate_groups(groups_text, groups_masks, groups_counts, args.target_language, src_code, + model, host, port, max(1, args.workers), min(args.batch, 12), on_progress, None, cancel_event) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=False) + + if pbar: pbar.close() + with open(out_path, "w", encoding="utf-8") as f: + f.write(cues_to_srt(rebuilt_cues)) + print(f"✅ Wrote: {out_path}") + +# ===================== GUI flow ===================== +def run_gui(): + import tkinter as tk + from tkinter import ttk, filedialog, messagebox + + root = tk.Tk() + root.title("SRT Translator — NLLB / SeamlessM4T / Ollama") + + # Detect accelerators for NLLB/Seamless + try: + import torch + has_mps = getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() + has_cuda = torch.cuda.is_available() + except Exception: + has_mps = has_cuda = False + + # Load persisted config + cfg = load_config() + last_engine = cfg.get("engine", "NLLB") + last_ollama_model = cfg.get("ollama_model", "qwen3:32b-instruct") + last_ollama_host = cfg.get("ollama_host", "localhost") + last_ollama_port = int(cfg.get("ollama_port", 11434)) + + # Vars + srt_path_var = tk.StringVar() + lang_var = tk.StringVar(value="German") + context_var = tk.StringVar(value="Smart") + engine_var = tk.StringVar(value=last_engine if last_engine in ["NLLB","SeamlessM4T","Ollama"] else "NLLB") + device_var = tk.StringVar(value="Auto") + threads_var = tk.IntVar(value=max(1, (os.cpu_count() or 4) - 2)) + max_span_var = tk.IntVar(value=4) + status_var = tk.StringVar(value="Select file, language, context, engine, device, threads.") + progress_var = tk.IntVar(value=0) + progress_max = tk.IntVar(value=100) + ollama_model_var = tk.StringVar(value=last_ollama_model) + ollama_host_var = tk.StringVar(value=last_ollama_host) + ollama_port_var = tk.IntVar(value=last_ollama_port) + + ollama_models_list: List[str] = [] + + running_flag = tk.BooleanVar(value=False) + cancel_event = threading.Event(); cancel_event.clear() + + pad = {"padx": 8, "pady": 6} + frm = ttk.Frame(root); frm.pack(fill="both", expand=True, **pad) + + # File row + file_row = ttk.Frame(frm); file_row.pack(fill="x", **pad) + ttk.Label(file_row, text="SRT file:").pack(side="left") + file_entry = ttk.Entry(file_row, textvariable=srt_path_var); file_entry.pack(side="left", fill="x", expand=True, padx=6) + ttk.Button(file_row, text="Browse…", command=lambda: _browse_file(srt_path_var)).pack(side="left") + + # Language + Context + row2 = ttk.Frame(frm); row2.pack(fill="x", **pad) + ttk.Label(row2, text="Target language:").pack(side="left") + languages = sorted({k for k in LANG2CODE.keys() if len(k) > 2}) + lang_combo = ttk.Combobox(row2, textvariable=lang_var, values=languages) + lang_combo.pack(side="left", fill="x", expand=True, padx=6) + + # ttk.Label(row2, text="Context:").pack(side="left", padx=(12, 2)) + # context_combo = ttk.Combobox(row2, textvariable=context_var, values=["Smart", "Cue", "Line"], state="readonly", width=8) + # context_combo.pack(side="left") + # ttk.Label(row2, text="Max span:").pack(side="left", padx=(12,2)) + # span_spin = ttk.Spinbox(row2, from_=2, to=12, textvariable=max_span_var, width=5) + # span_spin.pack(side="left") + # ttk.Label(row2, text="(You can type an NLLB code like deu_Latn)").pack(side="left", padx=(12,0)) + + # Engine + Device + Threads + row_engine = ttk.Frame(frm); row_engine.pack(fill="x", **pad) + ttk.Label(row_engine, text="Engine:").pack(side="left") + engine_combo = ttk.Combobox(row_engine, textvariable=engine_var, values=["NLLB", "SeamlessM4T", "Ollama"], state="readonly", width=12) + engine_combo.pack(side="left", padx=6) + ttk.Label(row_engine, text="Device:").pack(side="left") + device_combo = ttk.Combobox(row_engine, textvariable=device_var, values=["Auto", "CPU"] + (["GPU"] if (has_mps or has_cuda) else []), state="readonly", width=8) + device_combo.pack(side="left", padx=6) + # ttk.Label(row_engine, text="Threads:").pack(side="left") + # threads_spin = ttk.Spinbox(row_engine, from_=1, to=max(1, (os.cpu_count() or 8)), textvariable=threads_var, width=6) + # threads_spin.pack(side="left") + + # Ollama model row (shown only when engine=Ollama) + row_ollama = ttk.Frame(frm) + ttk.Label(row_ollama, text="Ollama model:").pack(side="left") + ollama_model_combo = ttk.Combobox(row_ollama, textvariable=ollama_model_var, values=[], width=30) + ollama_model_combo.pack(side="left", padx=6) + # refresh_btn = ttk.Button(row_ollama, text="Refresh", command=lambda: refresh_models()) + # refresh_btn.pack(side="left") + + row_ollama2 = ttk.Frame(frm) + ttk.Label(row_ollama2, text="Ollama host:").pack(side="left") + ollama_host_entry = ttk.Entry(row_ollama2, textvariable=ollama_host_var, width=12); ollama_host_entry.pack(side="left") + ttk.Label(row_ollama2, text="Port:").pack(side="left", padx=(12,2)) + ollama_port_entry = ttk.Spinbox(row_ollama2, from_=1, to=65535, textvariable=ollama_port_var, width=6); ollama_port_entry.pack(side="left") + + def on_device_change(event=None): + return + # if device_var.get().lower() == "gpu" and engine_var.get() in ["NLLB","SeamlessM4T"]: + # threads_spin.configure(state="disabled") + # else: + # threads_spin.configure(state="normal") + + def refresh_models(): + nonlocal ollama_models_list + host = ollama_host_var.get().strip() or "localhost" + port = int(ollama_port_var.get() or 11434) + status_var.set(f"Fetching models from {host}:{port} …") + root.update_idletasks() + models = get_all_ollama_models(host, port) + if not models: + from tkinter import messagebox + messagebox.showwarning("Ollama", "Keine Modelle gefunden (prüfe Ollama-Dienst oder Host/Port).") + return + ollama_models_list = models + ollama_model_combo.configure(values=ollama_models_list) + current = ollama_model_var.get().strip() + if current not in ollama_models_list: + ollama_model_var.set(ollama_models_list[0]) + status_var.set(f"{len(ollama_models_list)} Modelle geladen.") + + cfg = load_config() + cfg["ollama_host"] = host + cfg["ollama_port"] = port + cfg["ollama_model"] = ollama_model_var.get().strip() + save_config(cfg) + + def on_engine_change(event=None): + is_ollama = (engine_var.get() == "Ollama") + + if is_ollama: + # show Ollama rows + row_ollama.pack(fill="x", **pad) + row_ollama2.pack(fill="x", **pad) + + # ALWAYS enable Ollama controls when switching to Ollama + ollama_model_combo.configure(state="normal") # or "readonly" if you prefer no typing + ollama_host_entry.configure(state="normal") + ollama_port_entry.configure(state="normal") + + # Ollama ignores device selection + device_combo.configure(state="disabled") + + # Populate models if needed + if not ollama_model_combo.cget("values"): + refresh_models() + else: + # hide Ollama rows and disable its controls when not on Ollama + row_ollama.pack_forget() + row_ollama2.pack_forget() + ollama_model_combo.configure(state="disabled") + ollama_host_entry.configure(state="disabled") + ollama_port_entry.configure(state="disabled") + + # Re-enable device selection for non-Ollama engines + device_combo.configure(state="readonly") + + device_combo.bind("<>", on_device_change) + engine_combo.bind("<>", on_engine_change) + on_engine_change(); on_device_change() + + # Progress bar + pbar = ttk.Progressbar(frm, orient="horizontal", mode="determinate", maximum=progress_max.get(), variable=progress_var) + + # Controls + ctrl = ttk.Frame(frm); ctrl.pack(fill="x", **pad) + start_btn = ttk.Button(ctrl, text="Translate"); start_btn.pack(side="left") + # status label (optional UI element) + # status_label = ttk.Label(frm, textvariable=status_var, foreground="#555"); status_label.pack(fill="x", **pad) + + def set_controls(enabled: bool): + state = "normal" if enabled else "disabled" + file_entry.configure(state=state); lang_combo.configure(state=state) + # context_combo.configure(state=state); span_spin.configure(state=state) + engine_combo.configure(state=state) + model_state = "normal" if engine_var.get()=="Ollama" else "disabled" + refresh_state = model_state + ollama_model_combo.configure(state=model_state); ollama_host_entry.configure(state=model_state); ollama_port_entry.configure(state=model_state) + # try: refresh_btn.configure(state=refresh_state) + # except Exception: pass + device_combo.configure(state=("readonly" if engine_var.get()!="Ollama" else "disabled")) + # threads_spin.configure(state=("disabled" if (engine_var.get() in ["NLLB","SeamlessM4T"] and device_var.get().lower()=="gpu") else "normal")) + start_btn.configure(state=state) + + def ui_show_progress(total: Optional[int]): + pbar.configure(mode="determinate", maximum=total if total else 100) + progress_max.set(total or 100); progress_var.set(0); pbar.pack(fill="x", **pad) + + def ui_hide_progress(): + pbar.stop(); pbar.pack_forget() + + # Tk-aware tqdm for model download progress + def make_tk_tqdm(): + try: + from tqdm.auto import tqdm as base_tqdm + except Exception: + class Dummy: + def __init__(self, *a, total=None, **kw): self.total=total + def update(self, n=1): pass + def close(self): pass + return Dummy + + class TkTqdm(base_tqdm): + _global_total = 0 + _global_n = 0 + _lock = threading.Lock() + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + with TkTqdm._lock: + if self.total: + TkTqdm._global_total += int(self.total) + root.after(0, lambda: ui_show_progress(max(TkTqdm._global_total, 1))) + def update(self, n=1): + if cancel_event.is_set(): + raise RuntimeError("Cancelled") + res = super().update(n) + with TkTqdm._lock: + TkTqdm._global_n += int(n) + val = max(0, TkTqdm._global_n) + root.after(0, lambda: progress_var.set(val)) + return res + return TkTqdm + + def on_cancel(): + if not running_flag.get(): return + if not __import__("tkinter").messagebox.askyesno("Cancel", "Sicher abbrechen? Unfertige Übersetzung wird verworfen."): + return + cancel_event.set() + status_var.set("Abbruch wird ausgeführt…") + + def start_translation(): + if running_flag.get(): return + path = srt_path_var.get().strip() + if not path or not os.path.exists(path): + from tkinter import messagebox + messagebox.showerror("No file", "Please choose a valid .srt file."); return + + tgt_input = lang_var.get().strip() + tgt_code = _resolve_target_gui(tgt_input, __import__("tkinter").messagebox) + if not tgt_code: return + + context_mode = context_var.get().lower() + max_span = int(max_span_var.get() or 4) + engine = engine_var.get() + + cfg = load_config() + cfg["engine"] = engine + if engine == "Ollama": + cfg["ollama_model"] = ollama_model_var.get().strip() + cfg["ollama_host"] = ollama_host_var.get().strip() or "localhost" + cfg["ollama_port"] = int(ollama_port_var.get() or 11434) + save_config(cfg) + + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + srt_text = f.read() + except Exception as e: + from tkinter import messagebox + messagebox.showerror("Read error", str(e)); return + + cues = parse_srt(srt_text) + if not cues: + from tkinter import messagebox + messagebox.showerror("Format", "No SRT cues detected. Is the file valid?"); return + + cue_lines: List[List[str]] = [c["text"].splitlines() if c["text"] else [""] for c in cues] + flat_all_lines = [ln for lines in cue_lines for ln in lines] + sample = "\n".join([l for l in flat_all_lines if l.strip()][:20]) + src_code = autodetect_code(sample) + + base, _ = os.path.splitext(path) + out_path = f"{base}.{tgt_code.lower()}.srt" + + requested_threads = max(1, int(threads_var.get() or 1)) + device_mode = device_var.get().lower() + dev_kwargs, effective_workers = pick_device_for_workers(requested_threads, device_mode) if engine in ["NLLB","SeamlessM4T"] else ({"device": -1}, requested_threads) + total_lines = sum(len(ls) for ls in cue_lines) + + running_flag.set(True); cancel_event.clear() + set_controls(False) + start_btn.configure(text="Cancel", command=on_cancel) + start_btn.configure(state="normal") + status_var.set("Vorbereitung…") + + ui_show_progress(100) + + def worker(): + try: + if engine == "NLLB": + status_var.set("Downloading NLLB model (if needed)…") + TkTqdm = make_tk_tqdm() + ensure_model_downloaded("facebook/nllb-200-distilled-600M", tqdm_class=TkTqdm) + elif engine == "SeamlessM4T": + status_var.set("Downloading SeamlessM4T model (if needed)…") + TkTqdm = make_tk_tqdm() + ensure_seamless_downloaded("facebook/seamless-m4t-v2-large", tqdm_class=TkTqdm) + + def start_stage2(): + status_var.set( + f"Translating… (engine={engine}" + + (f", device={dev_kwargs.get('device')}, mode={device_mode}" if engine in ["NLLB","SeamlessM4T"] else "") + + f", threads={requested_threads} effective={effective_workers}, context={context_mode}, maxspan={max_span})" + ) + pbar.configure(mode="determinate", maximum=total_lines) + progress_max.set(total_lines); progress_var.set(0); pbar.pack(fill="x", **pad) + root.after(0, start_stage2) + + processed = {"n": 0} + def on_progress(n=1): + processed["n"] += n + root.after(0, lambda: progress_var.set(processed["n"])) + + # Stage 2: Translation + if engine == "NLLB": + if context_mode == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = nllb_translate_lines(flat_lines, masks, + "facebook/nllb-200-distilled-600M", src_code, tgt_code, + effective_workers, 32, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=False) + elif context_mode == "cue": + flat_cues, cue_masks, counts = build_cue_items_for_nllb(cue_lines) + translated_per_cue = nllb_translate_cues(flat_cues, cue_masks, counts, + "facebook/nllb-200-distilled-600M", src_code, tgt_code, + effective_workers, 16, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=False) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_nllb(cue_lines, max_span_cues=max_span) + translated_groups = nllb_translate_groups(groups_text, groups_masks, groups_counts, + "facebook/nllb-200-distilled-600M", src_code, tgt_code, + effective_workers, 12, on_progress, None, cancel_event, dev_kwargs) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=False) + + elif engine == "SeamlessM4T": + model_name = "facebook/seamless-m4t-v2-large" + if context_mode == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = seamless_translate_lines(flat_lines, masks, model_name, src_code, tgt_code, + effective_workers, 32, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=True) + elif context_mode == "cue": + flat_cues, cue_masks, counts = build_cue_items_for_nllb(cue_lines) + translated_per_cue = seamless_translate_cues(flat_cues, cue_masks, counts, model_name, src_code, tgt_code, + effective_workers, 16, on_progress, None, cancel_event, dev_kwargs) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=True) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_nllb(cue_lines, max_span_cues=max_span) + translated_groups = seamless_translate_groups(groups_text, groups_masks, groups_counts, model_name, src_code, tgt_code, + effective_workers, 12, on_progress, None, cancel_event, dev_kwargs) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=True) + + else: # Ollama + model = ollama_model_var.get().strip() or "qwen3:32b-instruct" + host = ollama_host_var.get().strip() or "localhost" + port = int(ollama_port_var.get() or 11434) + if context_mode == "line": + flat_lines, masks = build_line_items(cue_lines) + translated_flat = ollama_translate_lines(flat_lines, masks, lang_var.get(), src_code, model, host, port, + requested_threads, 32, on_progress, None, cancel_event) + rebuilt_cues = rebuild_from_flat_lines(cues, cue_lines, translated_flat, squelch_single_word=False) + elif context_mode == "cue": + cues_text, cues_masks = build_cue_items_for_llm(cue_lines) + translated_per_cue = ollama_translate_cues(cues_text, cues_masks, lang_var.get(), src_code, model, host, port, + requested_threads, 16, on_progress, None, cancel_event) + rebuilt_cues = rebuild_from_cue_parts(cues, translated_per_cue, cue_lines, squelch_single_word=False) + else: + groups_text, groups_masks, groups_counts = build_smart_groups_for_llm(cue_lines, max_span_cues=max_span) + translated_groups = ollama_translate_groups(groups_text, groups_masks, groups_counts, lang_var.get(), src_code, + model, host, port, requested_threads, 12, on_progress, None, cancel_event) + percue_lines: List[List[str]] = [] + for grp in translated_groups: percue_lines.extend(grp) + rebuilt_cues = rebuild_from_cue_parts(cues, percue_lines, cue_lines, squelch_single_word=False) + + with open(out_path, "w", encoding="utf-8") as f: + f.write(cues_to_srt(rebuilt_cues)) + + except RuntimeError as e: + if "Cancelled" in str(e): + return _fail("❌ Abgebrochen.") + return _fail(f"Translation failed: {e}") + except Exception as e: + return _fail(f"Translation failed: {e}") + + _done(out_path) + + def _fail(msg: str): + running_flag.set(False) + set_controls(True); status_var.set(msg); ui_hide_progress() + start_btn.configure(text="Translate", command=start_translation) + + def _done(path_out: str): + running_flag.set(False) + set_controls(True); status_var.set(f"✅ Done. Wrote: {path_out}"); ui_hide_progress() + start_btn.configure(text="Translate", command=start_translation) + + t = threading.Thread(target=worker, daemon=True); t.start() + + start_btn.configure(command=start_translation) + root.mainloop() + +# ===================== Utils ===================== +def _browse_file(var): + from tkinter import filedialog + path = filedialog.askopenfilename(title="Select .srt file", + filetypes=[("SubRip Subtitle", "*.srt"), ("All files", "*.*")]) + if path: var.set(path) + +def _resolve_target(target_language: str) -> str: + tgt_code = norm_lang_to_code(target_language) + if tgt_code is None: + if re.match(r"^[a-z]{3}_[A-Za-z]{4}$", target_language.strip()): + tgt_code = target_language.strip() + else: + raise ValueError(f"Unrecognized target language: {target_language}") + return tgt_code + +def _resolve_target_gui(target_language: str, messagebox): + try: + return _resolve_target(target_language) + except Exception: + messagebox.showerror("Language", f"Unrecognized target language: {target_language}") + return None + +def _resolve_source(src: Optional[str], flat_lines: List[str]) -> str: + if src: + code = norm_lang_to_code(src) + if code is None: + if re.match(r"^[a-z]{3}_[A-Za-z]{4}$", src.strip()): + code = src.strip() + else: + raise ValueError(f"Unrecognized source language: {src}") + return code + sample = "\n".join([l for l in flat_lines if l.strip()][:20]) + return autodetect_code(sample) + +# ===================== Main ===================== +def main(): + if len(sys.argv) == 1: + run_gui(); return + + ap = argparse.ArgumentParser(description="Translate an SRT with NLLB, SeamlessM4T, or Ollama, preserving structure & inline tags.") + ap.add_argument("srt_file", help="Path to input .srt") + ap.add_argument("target_language", help='Target language (name like "German" or NLLB code like "deu_Latn")') + ap.add_argument("--src", default=None, help="Source language (name or NLLB code). If omitted, auto-detect.") + # Engine + ap.add_argument("--engine", choices=["nllb","seamless","ollama"], default="nllb", help="Translation engine") + # NLLB opts + ap.add_argument("--model", default="facebook/nllb-200-distilled-600M", help="Transformers model repo (NLLB)") + # Seamless opts + ap.add_argument("--seamless-model", default="facebook/seamless-m4t-v2-large", help="Transformers model repo (SeamlessM4T)") + # Device (used for NLLB & SeamlessM4T) + ap.add_argument("--device", choices=["auto","cpu","gpu"], default="auto", help="Compute device for NLLB/SeamlessM4T") + # Ollama opts + ap.add_argument("--ollama-model", default="qwen3:32b-instruct", help="Ollama model tag") + ap.add_argument("--ollama-host", default="localhost") + ap.add_argument("--ollama-port", type=int, default=11434) + # Shared + ap.add_argument("--batch", type=int, default=32, help="Batch size per worker (lines/cues/groups)") + ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2), help="Number of parallel workers") + ap.add_argument("--context", choices=["line","cue","smart"], default="smart", help="Context granularity") + ap.add_argument("--max-span-cues", type=int, default=4, help="Smart mode: max cues per group") + ap.add_argument("--out", default=None, help="Output .srt path (default: ..srt)") + ap.add_argument("--no-progress", action="store_true", help="Disable CLI progress bar") + + args = ap.parse_args() + run_cli(args) + +if __name__ == "__main__": + main() \ No newline at end of file