| """ |
| translation.py โ Chunked Translation with Checkpoint / Resume |
| |
| Checkpoint system |
| ----------------- |
| After each chunk succeeds, results are written to a checkpoint dict in |
| st.session_state["tx_checkpoint"]. If the session is interrupted (rate |
| limit, network drop, user refresh), the next run can call |
| load_checkpoint() and resume from the last completed chunk. |
| |
| Error handling per chunk |
| ------------------------ |
| RateLimitError โ wait + retry (api_handler handles this internally) |
| TokenLimitError โ split chunk in half, retry each half separately |
| QuotaError โ pause everything, surface to user with clear message |
| AuthError โ surface to user immediately |
| NetworkError โ retry (handled in api_handler) |
| ParseError โ retry with simplified prompt, then fallback to originals |
| |
| Timestamp safety โ see translation.py header for full guarantee doc. |
| """ |
|
|
| import json |
| import logging |
| import re |
| import time |
| import threading |
| from typing import Callable |
|
|
| from api_handler import ( |
| call_ai, |
| RateLimitError, TokenLimitError, QuotaError, AuthError, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| BASE_CHUNK_SIZE = 50 |
|
|
| def _get_chunk_size(model: str) -> int: |
| """ |
| Return safe chunk size based on model RPM limit. |
| Pro models have lower RPM on free tier โ use larger chunks |
| to avoid hitting rate limits with too many API calls. |
| """ |
| m = (model or "").lower() |
| |
| if "pro" in m and "flash" not in m: |
| return 100 |
| |
| if "8b" in m or "lite" in m: |
| return 80 |
| |
| return 50 |
|
|
|
|
| def _cooldown_seconds(model: str) -> float: |
| """ |
| Return inter-request sleep in seconds to stay safely under RPM limits. |
| Free tier limits (April 2026): |
| Pro โ 5 RPM โ 12s gap (+1s buffer = 13s) |
| Flash โ 10 RPM โ 6s gap (+0.5s buffer = 6.5s) |
| Flash-Lite / 8b โ 15 RPM โ 4s gap (+0.5s buffer = 4.5s) |
| OpenRouter / unknown โ 3s (usually higher RPM) |
| """ |
| m = (model or "").lower() |
| if "pro" in m and "flash" not in m: |
| return 13.0 |
| if "lite" in m or "8b" in m: |
| return 4.5 |
| if "flash" in m: |
| return 6.5 |
| return 3.0 |
|
|
|
|
| |
| |
| GLOSSARY_MAX_LINES = 3000 |
| MAX_PARSE_RETRY = 2 |
|
|
|
|
| |
| |
| |
|
|
| def save_checkpoint(state: dict, chunk_idx: int, translated: list, |
| offset: int) -> None: |
| """Save per-chunk progress to session_state.""" |
| cp = state.setdefault("tx_checkpoint", { |
| "chunks_done": [], |
| "lines": {}, |
| "total_lines": 0, |
| "failed_chunks": [], |
| }) |
| cp["chunks_done"].append(chunk_idx) |
| for local_i, text in enumerate(translated): |
| cp["lines"][offset + local_i] = text |
|
|
|
|
| def load_checkpoint(state: dict) -> dict: |
| """Return checkpoint dict or empty dict if none.""" |
| return state.get("tx_checkpoint", {}) |
|
|
|
|
| def clear_checkpoint(state: dict) -> None: |
| state.pop("tx_checkpoint", None) |
|
|
|
|
| def checkpoint_progress(state: dict, total_lines: int) -> tuple[int, int]: |
| """Return (n_done, total) from checkpoint.""" |
| cp = load_checkpoint(state) |
| done = len(cp.get("lines", {})) |
| return done, total_lines |
|
|
|
|
| |
| |
| |
|
|
| def _parse_array(raw: str, n: int) -> list | None: |
| text = raw.strip() |
|
|
| |
| clean = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE) |
| clean = re.sub(r"\s*```$", "", clean).strip() |
| try: |
| r = json.loads(clean) |
| if isinstance(r, list): |
| return r |
| except Exception: |
| pass |
|
|
| |
| m = re.search(r"\[.*?\]", text, re.DOTALL) |
| if m: |
| try: |
| r = json.loads(m.group()) |
| if isinstance(r, list): |
| return r |
| except Exception: |
| pass |
|
|
| |
| quoted = [q for q in re.findall(r'"((?:[^"\\]|\\.)*)"', text) if q.strip()] |
| if quoted: |
| return quoted |
|
|
| |
| cleaned = [] |
| for ln in text.splitlines(): |
| ln = ln.strip() |
| if not ln: |
| continue |
| ln = re.sub(r"^\d+[\.\)\-\:]\s*", "", ln) |
| ln = re.sub(r"^[โข\*\-]\s*", "", ln) |
| ln = ln.strip("\"'") |
| if ln: |
| cleaned.append(ln) |
| if cleaned: |
| return cleaned |
|
|
| |
| myanmar_re = re.compile(r'[\u1000-\u109f]') |
| my_lines = [l.strip() for l in text.splitlines() |
| if myanmar_re.search(l) and l.strip()] |
| if my_lines: |
| return my_lines |
|
|
| return None |
|
|
|
|
| def _parse_dict(raw: str) -> dict | None: |
| text = raw.strip() |
| clean = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE) |
| clean = re.sub(r"\s*```$", "", clean).strip() |
| try: |
| r = json.loads(clean) |
| if isinstance(r, dict): |
| return r |
| except Exception: |
| pass |
| m = re.search(r"\{.*?\}", text, re.DOTALL) |
| if m: |
| try: |
| r = json.loads(m.group()) |
| if isinstance(r, dict): |
| return r |
| except Exception: |
| pass |
| return None |
|
|
|
|
| def _enforce_length(lst: list, n: int, originals: list) -> list: |
| """Always return exactly n items. Pad with originals if short.""" |
| result = [str(x) for x in lst] |
| if len(result) > n: |
| return result[:n] |
| if len(result) < n: |
| result += list(originals[len(result):n]) |
| return result |
|
|
|
|
| def _chunk(lst: list, size: int) -> list: |
| return [lst[i: i + size] for i in range(0, len(lst), size)] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _STYLE_STANDARD = """\ |
| You are a Myanmar subtitle translator. Output natural spoken Myanmar โ the way a real person talks, not the way a textbook translates. |
| |
| VOICE: Reproduce the character's actual voice. Ask yourself: "แแผแแบแแฌแแฐแแ
แบแแฑแฌแแบ แแฎ situation แแพแฌ แแแแบ แแฌแแผแฑแฌแแแฒ?" |
| DEFAULT pronouns โ แแซ/แแแบแธ for most characters. Only use แแปแฝแแบแแฑแฌแบ/แแปแฝแแบแ when the character is genuinely being servile or very formal (servant โ master, cadet โ commander). Unknown gender โ แแซ/แแแบแธ. |
| โข Rough / villain โ แแซ/แแแบแธ/แแฑแฌแแบ + แแฝ/แแป/แแฝแฌ (แแแฒแ โ family-safe) |
| โข Female casual โ แแซ/แแปแ + แแฝแฌ/แแฑแท/แแฑแฌแบ |
| โข Gentle / romantic โ แแซ/แแแบแธ + แแฐแธแแถแทแแฑแฌแแแบ |
| โข Comic โ แแผแฑแฌแแผแฑแฌแแญแฏแแญแฏ แแฑแซแทแแฑแซแทแแซแธแแซแธ |
| Strong language โ source intensity แแญแฏ family-safe แแผแแบแแฌ equivalent แแผแแทแบ แแญแแบแธแแญแแบแธ\ |
| """ |
|
|
| _STYLE_ADULT = """\ |
| You are a Myanmar subtitle translator. Output natural spoken Myanmar โ the way a real person talks, not the way a textbook translates. |
| |
| VOICE: Reproduce the character's actual voice. Ask yourself: "แแผแแบแแฌแแฐแแ
แบแแฑแฌแแบ แแฎ situation แแพแฌ แแแแบ แแฌแแผแฑแฌแแแฒ?" |
| DEFAULT pronouns โ แแซ/แแแบแธ for most characters. Only use แแปแฝแแบแแฑแฌแบ/แแปแฝแแบแ when the character is genuinely being servile or formally subordinate. Unknown gender โ แแซ/แแแบแธ. |
| โข Rough / gang / villain โ แแซ/แแแบแธ/แแฑแฌแแบ/แแฑแทแแฑแฌแแบ + แแฝ/แแป/แแฝแฌ |
| โข Female casual / hostile โ แแซ/แแแบ + แแฝแฌ/แแฑแท/แ |
| โข Dominant / boss โ แแซ + แแผแแบแธแแแบแแแบ |
| โข Light / comic โ แแผแฑแฌแแผแฑแฌแแญแฏแแญแฏ แแฑแซแทแแฑแซแทแแซแธแแซแธ |
| Strong language โ keep the exact weight of the source: |
| English source โ English as-is (Fuck / Shit / Bitch / Bastard / Asshole) |
| Other language โ nearest English equivalent\ |
| """ |
|
|
| _STYLE_ANIME = """\ |
| You are a Myanmar subtitle translator. Output natural spoken Myanmar โ the way a real person talks, not the way a textbook translates. |
| |
| VOICE: Reproduce the character's vocal personality. Ask yourself: "แแผแแบแแฌแแฐแแ
แบแแฑแฌแแบ แแฎ character แแญแฏ แแฌแแผแฑแฌแแแฒ?" |
| DEFAULT pronouns โ แแซ/แแแบแธ. Only use แแปแฝแแบแแฑแฌแบ/แแปแฝแแบแ for genuinely servile/formal moments (student โ sensei in apology, servant to lord). |
| โข Energetic / shonen โ แแซ/แแแบแธ + แแฝ/แแป แกแแฑแทแแป |
| โข Tsundere โ แแผแแบแแฌแธแแผแฎแธ แแฑแซแแแปแฑแฌแแบแแแบ โ แแซ/แแแบแธ แแแบแธแแแบแธ |
| โข Kuudere / cold โ แแญแฏแแญแฏ แแผแแบแแฌแธ แแญแฏแธแแญแฏแธ |
| โข Cute / moe โ แแซ/แแปแ + particle: แแฑแฌแบ/แแพแแบ + แแถแแปแญแฏ |
| โข Villain โ แแผแแบแธ แแซ/แแแบแธ/แแฑแฌแแบ |
| Strong language โ English as-is (Fuck / Shit / Bitch)\ |
| """ |
|
|
| _STYLE_CDRAMA = """\ |
| You are a Myanmar subtitle translator. Output natural spoken Myanmar โ the way a real person talks, not the way a textbook translates. |
| |
| VOICE: Preserve the dramatic weight and emotional layering of Chinese drama. Ask yourself: "แแผแแบแแฌแแฐแแ
แบแแฑแฌแแบ แแฎ scene แแพแฌ แแฌแแผแฑแฌแแแฒ?" |
| DEFAULT pronouns โ แแซ/แแแบแธ. แแปแฝแแบแแฑแฌแบ/แแปแฝแแบแ only for genuine servile situations (servant before emperor, disciple before master in formal address). |
| โข Commoner / young โ แแซ/แแแบแธ colloquial |
| โข Villain / schemer โ แแซ/แแแบแธ + แแผแแบแธแแแบแธแแแบ |
| โข Noble in private โ แแซ/แแแบแธ + dignified |
| โข Noble in court ceremony โ แแปแฝแแบแแฑแฌแบ (genuine formal ritual only) |
| Poetic / dramatic lines โ preserve emotional weight in natural Myanmar; แแแบแแแบแแแบ formal Myanmar แแแฏแถแธ\ |
| """ |
|
|
| _STYLE_KDRAMA = """\ |
| You are a Myanmar subtitle translator. Output natural spoken Myanmar โ the way a real person talks, not the way a textbook translates. |
| |
| VOICE: Preserve K-drama emotional intensity and sincerity. Ask yourself: "แแผแแบแแฌแแฐแแ
แบแแฑแฌแแบ แแฎ feeling แแพแฌ แแฌแแผแฑแฌแแแฒ?" |
| DEFAULT pronouns โ แแซ/แแแบแธ. แแปแฝแแบแแฑแฌแบ/แแปแฝแแบแ only for genuine formal subordinate moments (employee to CEO in official setting, child formal apology to elder). |
| โข Young adult / peers โ แแซ/แแแบแธ + แแฝแฌ/แแฑแท/แแป |
| โข Emotional / romantic โ แแซ/แแแบแธ + weight แแญแฏแแญแแบแธ |
| โข Chaebol / CEO (private) โ แแซ/แแแบแธ + measured |
| โข Elder / authority (official) โ แแปแฝแแบแแฑแฌแบ/แแปแ (genuine only) |
| Strong language โ English as-is\ |
| """ |
|
|
| _STYLE_FORMAL = """\ |
| You are a Myanmar subtitle translator for documentary, news, and educational content. Output clear, objective, educated standard Myanmar. |
| |
| Register: Formal written Myanmar โ แแแฌแแแบ standard. No colloquial แแซ/แแแบแธ โ use แแแบ/แแปแฝแแบแฏแแบ or avoid pronouns where natural. |
| โข Narration โ formal, neutral, third-person where possible |
| โข Expert dialogue โ semi-formal educated register |
| โข Interview subject โ natural but measured |
| Technical terms โ precise Myanmar equivalent; if none exists, keep (English term) in parentheses. |
| Numbers / dates / statistics โ exact.\ |
| """ |
|
|
| |
|
|
| _TERMS_WESTERN = "" |
|
|
| _TERMS_JAPANESE = """\ |
| Japanese terms (keep as-is โ these are NOT proper nouns, noun-style rule does not apply): |
| โข Honorific suffixes: -san / -kun / -chan / -senpai / -sensei / -sama / -dono / -nii / -nee โ keep or use natural Myanmar equivalent (Glossary wins) |
| โข Anime/otaku: baka, kawaii, nakama, sugoi, yare yare, ara ara โ English as-is |
| โข Game/isekai system: HP, MP, Level, Skill, Quest, Guild, Status, Rank โ English as-is |
| โข Cultural: onsen, matsuri, bento, senpai-kouhai โ English/Romaji as-is\ |
| """ |
|
|
| _TERMS_CHINESE = """\ |
| Chinese terms (keep as-is โ these are NOT proper nouns, noun-style rule does not apply): |
| โข Relationship/title: Shifu, Shidi/Shixiong/Shijie, Gege/Jiejie/Meimei/Didi, A'niang, Wangye, Gongzi, Guniang โ Pinyin as-is |
| โข Wuxia/Xianxia: Golden Core, Nascent Soul, cultivation, qi, meridians, sect, immortal, spiritual root โ English/Pinyin as-is |
| โข Court titles: Emperor, Empress, Crown Prince, Noble Consort, Imperial Guard โ English as-is\ |
| """ |
|
|
| _TERMS_KOREAN = """\ |
| Korean terms (keep as-is โ these are NOT proper nouns, noun-style rule does not apply): |
| โข Relationship/honorifics: Oppa, Unnie, Hyung, Noona, Sunbae, Hoobae, Sajangnim, Seonsaengnim โ Romanized as-is |
| โข Speech level: ์กด๋๋ง โ formal Myanmar register | ๋ฐ๋ง โ colloquial Myanmar register |
| โข Cultural: Ramyeon, Tteokbokki, Hanbok, Jjimjilbang, Aegyo โ Romanized as-is\ |
| """ |
|
|
| _TERMS_THAI = """\ |
| Thai/SE Asian terms (keep as-is โ these are NOT proper nouns, noun-style rule does not apply): |
| โข Honorifics: Khun, Phi, Nong โ Romanized as-is or natural Myanmar equivalent |
| โข Cultural: Wai, Songkran, Muay Thai, Tuk-tuk โ Romanized as-is\ |
| """ |
|
|
| _TERMS_UNIVERSAL = "" |
|
|
| |
|
|
| def _noun_block(style: str, culture_key: str) -> str: |
| script = { |
| "japanese": "Romaji", |
| "chinese": "Pinyin", |
| "korean": "Romanized Korean", |
| "thai": "Romanized Thai", |
| "western": "English", |
| "universal": "romanized", |
| }.get(culture_key, "romanized") |
|
|
| examples = { |
| "western": [("Victor","แแ
แบแแฌ"), ("Hope Street","แแฏแแบแแบแ
แแแญแแบ"), ("London","แแแบแแแบ")], |
| "japanese": [("Sasuke","แแแฐแแฑ"), ("Tokyo","แแญแฏแแปแญแฏ"), ("Konoha","แแญแฏแแญแฏแแฌ")], |
| "chinese": [("Wei Wuxian","แแฑแแฐแแพแปแฌแธ"), ("Beijing","แแฑแแปแแบแธ"), ("Yunmeng","แแฝแแบแแญแแบ")], |
| "korean": [("Park Seo-jun","แแซแทแแฑแแปแฝแแบ"), ("Seoul","แแญแฏแธแแบ"), ("Busan","แแฐแแแบ")], |
| "thai": [("Prayuth","แแผแฌแแแบ"), ("Chiang Mai","แแปแแบแธแแญแฏแแบ"), ("Bangkok","แแแบแแฑแฌแแบ")], |
| "universal": [("Victor","แแ
แบแแฌ"), ("Sakura","แแฌแแฐแแฌ"), ("Madison","แแแบแแ
แบแแแบ")], |
| }.get(culture_key, [("Victor","แแ
แบแแฌ"), ("Hope Street","แแฏแแบแแบแ
แแแญแแบ"), ("London","แแแบแแแบ")]) |
|
|
| if style == "phonetic": |
| ex_lines = "\n".join(f" {e} โ {p}" for e, p in examples) |
| return ( |
| f"PROPER NOUNS โ Phonetic Myanmar:\n" |
| f"Names / places / organizations โ Myanmar phonetic transliteration only (meaning-translation เธซเนเธฒเธก)\n" |
| f"{ex_lines}\n" |
| f"Glossary แแซ โ Glossary | แแแซ โ phonetic" |
| ) |
| else: |
| ex_lines = "\n".join(f' "{e}" not "{p}"' for e, p in examples) |
| return ( |
| f"PROPER NOUNS โ {script} spelling:\n" |
| f"Names / places / organizations โ keep {script} original spelling unchanged\n" |
| f"{ex_lines}\n" |
| f"Glossary แแซ โ Glossary | แแแซ โ {script} แแฐแแแบแธ" |
| ) |
|
|
| |
|
|
| _TECH = """\ |
| ABSOLUTE RULES: |
| 1. แแแญแฏแแแทแบ แแแฏแแบ โ source แแแซแแฑแฌแกแแฌ แแแผแแทแบแแ แแซแแฑแฌแกแแฌ แแแแบแ |
| 2. แแผแแบแแฌ word order โ source language sentence structure copy แแแฐแธแ |
| 3. แแญแฏแแบแแฑแธ consistency โ same character แแ
แบแแปแแบแแแบแธ pronoun + style แแฏแถแธ |
| 4. ASS tags ({\\an8} {\\pos} {\\be5} {\\fad} etc.) + โช + โ โ as-is แแฌแธ |
| 5. Glossary term แแซ โ Glossary value แแญแฏแแฌ แแฏแถแธแ แแฝแฒแแผแฌแธแแฑแฌแแฌแแฌแแผแแบ แแแฏแถแธแ |
| |
| COUNT: Input N โ output EXACTLY N. Merge / split เธซเนเธฒเธกแ |
| |
| NATURAL vs AI โ แแฎ pattern แแฑแแญแฏ แแพแฑแฌแแบ: |
| โ "แแซแแแบ แแฑแฌแแบแธแแฝแแบแแฑแฌแแแแบแแ
แบแแฏแแผแ
แบแแแบ" โ
"แแซแแแฑแฌแท แแฑแฌแแบแธแแฒแท outcome แแฒ" |
| โ "แแแบแแแบ แคแแฑแแฌแแญแฏแท แแฌแแผแฑแฌแแทแบแแฑแฌแแบแแฌแแแแบแธ" โ
"แแแบแธ แแฎแแพแฌ แแฌแแฌแแฏแแบแแฌแแฒ" |
| โ "แแปแฝแแบแฏแแบแแญแฏ แแฐแแฎแแฑแธแแซ แแปแฑแธแแฐแธแแผแฏแ" โ
"แแฐแแฎแแซ" / "แแฐแแฎแแฝแฌ" |
| โ "แแปแฝแแบแแฑแฌแบ แแแทแบแแญแฏ แแปแ
แบแแซแแแบ" โ
"แแซ แแแบแธแแญแฏ แแปแ
แบแแแบ" |
| โ "แแญแฏแแแบแธแกแแญแฏแแบแธ แแปแฝแแบแฏแแบแแญแฏแท แแฑแฌแแบแแฝแแบแแแแบ" โ
"แกแฒแทแแญแฏแแญแฏแแแบ แแซแแญแฏแท แแฏแแบแแแพแฌแแฒ" |
| |
| Output: JSON array, EXACTLY n items. No markdown, no preamble. |
| ["แแฌแแฌแแผแแบ แ", "แแฌแแฌแแผแแบ แ", ..., "แแฌแแฌแแผแแบ n"]\ |
| """ |
|
|
| |
|
|
| PROMPT_PRESETS: dict = { |
| "standard": {"label": "๐ฌ Standard", "desc": "General / family-safe content", "style": _STYLE_STANDARD}, |
| "adult_18": {"label": "๐ 18+ Adult", "desc": "Mature content โ strong language in English", "style": _STYLE_ADULT}, |
| "anime": {"label": "โฉ Anime / J-Drama", "desc": "Japanese anime, manga, light novels", "style": _STYLE_ANIME}, |
| "cdrama": {"label": "๐ฎ C-Drama / Movie", "desc": "Chinese drama, wuxia, xianxia", "style": _STYLE_CDRAMA}, |
| "kdrama": {"label": "๐ธ K-Drama / Movie", "desc": "Korean drama, webtoon, Korean movies", "style": _STYLE_KDRAMA}, |
| "formal": {"label": "๐ฐ Documentary / Formal","desc": "News, documentary, educational narration", "style": _STYLE_FORMAL}, |
| "custom": {"label": "โ๏ธ Custom", "desc": "แแญแฏแแบแแญแฏแแบ system prompt แแฑแธแแแทแบแแแฌ", "style": ""}, |
| } |
|
|
| SOURCE_CULTURES: dict = { |
| "western": {"label": "๐ Western", "desc": "English/European productions", "terms": _TERMS_WESTERN}, |
| "japanese": {"label": "๐ฏ๐ต Japanese", "desc": "Anime, J-Drama, Japanese movies", "terms": _TERMS_JAPANESE}, |
| "chinese": {"label": "๐จ๐ณ Chinese", "desc": "C-Drama, Chinese movies, Donghua", "terms": _TERMS_CHINESE}, |
| "korean": {"label": "๐ฐ๐ท Korean", "desc": "K-Drama, Korean movies", "terms": _TERMS_KOREAN}, |
| "thai": {"label": "๐น๐ญ Thai / SE Asian", "desc": "Thai drama, SE Asian content", "terms": _TERMS_THAI}, |
| "universal": {"label": "๐ Unknown / Mixed", "desc": "Multi-cultural or unknown origin", "terms": _TERMS_UNIVERSAL}, |
| } |
|
|
| PROPER_NOUN_STYLES: dict = { |
| "english": { |
| "label": "๐ค English / Romanized (แแฐแแแบแธ spelling)", |
| "desc": "Victor, Sakura, Hope Street โ แแผแแบแแฌ phonetic แแแผแฑแฌแแบแธ (default)", |
| }, |
| "phonetic": { |
| "label": "๐ Myanmar Phonetic (แกแแถแแฝแแบแแผแแบแแฌ)", |
| "desc": "Victor โ แแ
แบแแฌ, Sakura โ แแฌแแฐแแฌ, Hope Street โ แแฏแแบแแบแ
แแแญแแบ", |
| }, |
| } |
|
|
|
|
| def build_system_prompt( |
| preset_key: str = "adult_18", |
| culture_key: str = "western", |
| custom_text: str = "", |
| proper_noun_style: str = "english", |
| ) -> str: |
| """ |
| Assemble final system prompt from 4 non-overlapping layers: |
| STYLE โ TERMS โ NOUNS โ TECH |
| Each rule appears exactly once. No redundancy. |
| """ |
| if preset_key == "custom": |
| |
| body = custom_text.strip() or PROMPT_PRESETS["adult_18"]["style"] |
| return body + "\n\n" + _TECH |
|
|
| style = PROMPT_PRESETS.get(preset_key, PROMPT_PRESETS["adult_18"])["style"] |
| terms = SOURCE_CULTURES.get(culture_key, SOURCE_CULTURES["western"])["terms"] |
| nouns = _noun_block(proper_noun_style, culture_key) |
|
|
| parts = [style] |
| if terms: |
| parts.append(terms) |
| parts.append(nouns) |
| parts.append(_TECH) |
| return "\n\n".join(parts) |
|
|
|
|
| def _sys_glossary_for_culture( |
| culture_key: str = "western", |
| proper_noun_style: str = "english", |
| ) -> str: |
| """Culture + noun-style aware glossary extraction prompt.""" |
| noun_rule = _noun_block(proper_noun_style, culture_key) |
| return ( |
| "Extract a Myanmar subtitle translation glossary.\n" |
| "Include: character names, place names, organization names, " |
| "recurring special terms / slang.\n\n" |
| + noun_rule + "\n\n" |
| "For recurring special terms / slang: Myanmar แแผแแบแแฌ translation as value.\n" |
| 'Return ONLY JSON: {"source term": "value"}' |
| ) |
|
|
|
|
| |
| _SYS_TRANSLATE = build_system_prompt("adult_18", "western", proper_noun_style="english") |
| _SYS_GLOSSARY = _sys_glossary_for_culture("western", "english") |
|
|
| def _glossary_block(glossary: dict) -> str: |
| if not glossary: |
| return "" |
| lines = ["=== GLOSSARY (use exactly) ==="] |
| for o, t in glossary.items(): |
| lines.append(f' "{o}" โ "{t}"') |
| lines.append("=" * 30) |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| class TranslationPaused(Exception): |
| """Raised when translation must stop (quota exhausted, auth error).""" |
| def __init__(self, reason: str, is_resumable: bool = False): |
| super().__init__(reason) |
| self.is_resumable = is_resumable |
|
|
|
|
| |
| |
| _NL = "โ" |
|
|
|
|
| def _encode_nl(text: str) -> str: |
| """Replace internal \n with placeholder so each subtitle = exactly 1 line.""" |
| return text.replace("\n", _NL) |
|
|
|
|
| def _decode_nl(text: str) -> str: |
| """Restore placeholder to \n in translated text.""" |
| return text.replace(_NL, "\n") |
|
|
|
|
| def _strip_html(text: str) -> str: |
| """Strip HTML tags (e.g. <i>, </i>, <b>, </b>) from subtitle text.""" |
| return re.sub(r'<[^>]+>', '', text) |
|
|
|
|
| |
| |
| |
| _PRONOUN_SUBS: list[tuple] = [ |
| |
| (r'แแปแฝแแบแแฑแฌแบ', 'แแซ'), |
| (r'แแปแฝแแบแ', 'แแซ'), |
| |
| (r'แแแบแแปแฌแธ', 'แแแบแธ'), |
| ] |
| |
| _PRONOUN_RE: list[tuple] = [ |
| (re.compile(p), r) for p, r in _PRONOUN_SUBS |
| ] |
|
|
| def _naturalize_pronouns(text: str) -> str: |
| """ |
| Replace formal pronouns with natural spoken equivalents. |
| แแปแฝแแบแแฑแฌแบ โ แแซ | แแปแฝแแบแ โ แแปแ | แแแบแแปแฌแธ โ แแแบแธ |
| Applied after translation for all non-formal presets. |
| Does NOT touch ASS tags ({...}) or music markers. |
| """ |
| |
| |
| parts = re.split(r'(\{[^}]*\})', text) |
| out = [] |
| for part in parts: |
| if part.startswith('{'): |
| out.append(part) |
| else: |
| for pattern, replacement in _PRONOUN_RE: |
| part = pattern.sub(replacement, part) |
| out.append(part) |
| return ''.join(out) |
|
|
|
|
| |
| |
| _chunk_once_override: list = [None] |
|
|
|
|
| def _call_chunk_once(chunk: list, chunk_idx: int, total: int, |
| offset: int, glossary: dict, |
| provider: str, model: str, api_key: str, |
| system_prompt: str | None = None) -> str: |
| """ |
| Single API call for a chunk. Returns raw response string. |
| system_prompt: pre-built prompt from build_system_prompt(); falls back to _SYS_TRANSLATE. |
| """ |
| n = len(chunk) |
| system = system_prompt if system_prompt else _SYS_TRANSLATE |
| gloss = _glossary_block(glossary) |
| if gloss: |
| system = gloss + "\n\n" + system |
|
|
| |
| encoded = [_encode_nl(_strip_html(line)) for line in chunk] |
| numbered = "\n".join(f"{i+1}. {line}" for i, line in enumerate(encoded)) |
|
|
| |
| gloss_reminder = "" |
| if glossary: |
| gloss_reminder = ( |
| "GLOSSARY REMINDER โ use these EXACT terms, no alternatives:\n" |
| + "\n".join(f' "{k}" โ "{v}"' for k, v in glossary.items()) |
| + "\n (Proper nouns NOT in glossary โ keep original English spelling)\n\n" |
| ) |
|
|
| prompt = ( |
| f"Translate {n} subtitle lines to Myanmar.\n" |
| f"[Chunk {chunk_idx+1}/{total} | Lines {offset+1}โ{offset+n}]\n\n" |
| f"{gloss_reminder}" |
| f"INPUT ({n} lines):\n{numbered}\n\n" |
| f"JSON array of EXACTLY {n} Myanmar strings:\n" |
| f'["แแผแแบแแฌ แ", ..., "แแผแแบแแฌ {n}"]\nOUTPUT:' |
| ) |
| |
| override_msg = _chunk_once_override[0] |
| if override_msg: |
| prompt = override_msg + "\n\n" + prompt |
| _chunk_once_override[0] = None |
|
|
| return call_ai(prompt, provider=provider, model=model, |
| api_key=api_key, system_prompt=system, timeout=180) |
|
|
|
|
| def _translate_chunk_safe( |
| chunk: list, |
| chunk_idx: int, |
| total_chunks: int, |
| offset: int, |
| glossary: dict, |
| *, |
| provider: str, |
| model: str, |
| api_key: str, |
| debug_log: list, |
| system_prompt: str | None = None, |
| chunk_size_override: int | None = None, |
| ) -> list: |
| """ |
| Translate one chunk with full error handling. |
| Returns exactly len(chunk) strings. |
| Raises TranslationPaused for unrecoverable errors (quota, auth). |
| """ |
| n = len(chunk) |
|
|
| |
| raw = None |
| last_err = None |
|
|
| for parse_attempt in range(1, MAX_PARSE_RETRY + 1): |
| try: |
| raw = _call_chunk_once(chunk, chunk_idx, total_chunks, |
| offset, glossary, provider, model, api_key, |
| system_prompt=system_prompt) |
| except QuotaError as e: |
| |
| debug_log.append({"chunk": chunk_idx+1, "status": "QUOTA_EXHAUSTED", |
| "error": str(e)}) |
| raise TranslationPaused( |
| f"Quota แแฏแแบแแผแฎ โ {e}\n\n" |
| "แแปแแบแแฑแฌ chunks แแปแฌแธแแญแฏ checkpoint แแพ แแแบแแผแแบแแญแฏแแบแแแบ", |
| is_resumable=True |
| ) from e |
|
|
| except AuthError as e: |
| debug_log.append({"chunk": chunk_idx+1, "status": "AUTH_ERROR", |
| "error": str(e)}) |
| raise TranslationPaused(f"API key แแแพแแบแแซ โ {e}", |
| is_resumable=False) from e |
|
|
| except TokenLimitError: |
| |
| logger.warning("Chunk %d: token limit โ splitting in half", chunk_idx+1) |
| debug_log.append({"chunk": chunk_idx+1, "status": "TOKEN_LIMIT_SPLIT"}) |
| mid = n // 2 |
| half1 = _translate_chunk_safe( |
| chunk[:mid], chunk_idx, total_chunks, offset, |
| glossary, provider=provider, model=model, |
| api_key=api_key, debug_log=debug_log, |
| system_prompt=system_prompt, |
| ) |
| half2 = _translate_chunk_safe( |
| chunk[mid:], chunk_idx, total_chunks, offset + mid, |
| glossary, provider=provider, model=model, |
| api_key=api_key, debug_log=debug_log, |
| system_prompt=system_prompt, |
| ) |
| result = half1 + half2 |
| return _enforce_length(result, n, chunk) |
|
|
| except RateLimitError as e: |
| |
| debug_log.append({"chunk": chunk_idx+1, "status": "RATE_LIMIT_EXHAUSTED", |
| "error": str(e)}) |
| raise TranslationPaused( |
| f"Rate limit แแฏแแบแแผแฎ โ {e}\n\nCheckpoint แแพ แแแบแแผแแบแแญแฏแแบแแแบ", |
| is_resumable=True |
| ) from e |
|
|
| except Exception as e: |
| last_err = e |
| debug_log.append({"chunk": chunk_idx+1, "status": "API_ERROR", |
| "attempt": parse_attempt, "error": str(e)}) |
| if parse_attempt < MAX_PARSE_RETRY: |
| time.sleep(3) |
| continue |
| break |
|
|
| |
| parsed = _parse_array(raw, n) |
| if parsed is not None: |
| |
| parsed = [_decode_nl(str(p)) for p in parsed] |
|
|
| |
| |
| if len(parsed) != n and parse_attempt < MAX_PARSE_RETRY: |
| wrong = len(parsed) |
| debug_log.append({ |
| "chunk": chunk_idx+1, "status": "COUNT_MISMATCH", |
| "expected": n, "got": wrong, "attempt": parse_attempt, |
| }) |
| |
| if wrong < n: |
| count_err = ( |
| f"CORRECTION NEEDED: You returned {wrong} items " |
| f"but I sent {n}. You MERGED some lines. " |
| f"Please re-translate keeping EXACTLY {n} separate items. " |
| f"Even short lines must remain separate." |
| ) |
| else: |
| count_err = ( |
| f"CORRECTION NEEDED: You returned {wrong} items " |
| f"but I sent {n}. You SPLIT some lines. " |
| f"Please re-translate keeping EXACTLY {n} separate items. " |
| f"Do not split any single line into multiple items." |
| ) |
| |
| _chunk_once_override[0] = count_err |
| time.sleep(1) |
| continue |
|
|
| result = _enforce_length(parsed, n, chunk) |
| assert len(result) == n |
| changed = sum(1 for o, t in zip(chunk, result) if o != t) |
| debug_log.append({ |
| "chunk": chunk_idx+1, "status": "OK", |
| "translated": changed, "total": n, |
| "raw_preview": raw[:200], |
| }) |
| if changed == 0 and parse_attempt < MAX_PARSE_RETRY: |
| debug_log[-1]["status"] = "RETRY_ZERO_CHANGE" |
| time.sleep(2) |
| continue |
| return result |
| else: |
| debug_log.append({ |
| "chunk": chunk_idx+1, "status": "PARSE_FAIL", |
| "attempt": parse_attempt, "raw_preview": (raw or "")[:400], |
| }) |
| if parse_attempt < MAX_PARSE_RETRY: |
| time.sleep(2) |
| continue |
|
|
| |
| logger.error("Chunk %d: all attempts failed. Keeping originals.", chunk_idx+1) |
| debug_log.append({"chunk": chunk_idx+1, "status": "FALLBACK_ORIGINAL"}) |
| return list(chunk[:n]) |
|
|
|
|
| |
| |
| |
|
|
| def extract_glossary(lines: list, *, provider, model, api_key, |
| culture_key: str = "western", |
| proper_noun_style: str = "english") -> dict: |
| """ |
| Extract translation glossary from the FULL subtitle file. |
| Sends all non-empty lines so terms appearing late in the file |
| (episode-specific locations, late-introduced characters, slang) are captured. |
| |
| For very large files (>GLOSSARY_MAX_LINES), we spread-sample: |
| - First 1/3 + Middle 1/3 + Last 1/3 |
| This preserves coverage across the whole file while staying within token limits. |
| Gemini 1.5/2.0 handles 1M tokens so most files fit entirely. |
| """ |
| non_empty = [l for l in lines if l.strip()] |
| total = len(non_empty) |
|
|
| if total <= GLOSSARY_MAX_LINES: |
| |
| sample = non_empty |
| else: |
| |
| third = GLOSSARY_MAX_LINES // 3 |
| mid_s = (total // 2) - (third // 2) |
| sample = ( |
| non_empty[:third] + |
| non_empty[mid_s: mid_s + third] + |
| non_empty[-third:] |
| ) |
| logger.info( |
| "File has %d lines > %d limit โ spread sampling %d lines", |
| total, GLOSSARY_MAX_LINES, len(sample) |
| ) |
|
|
| numbered = "\n".join(f"{i+1}. {l}" for i, l in enumerate(sample)) |
| prompt = ( |
| f"Extract Myanmar translation glossary from these {len(sample)} subtitle lines " |
| f"(full file: {total} lines total):\n\n{numbered}" |
| ) |
|
|
| try: |
| raw = call_ai(prompt, provider=provider, model=model, api_key=api_key, |
| system_prompt=_sys_glossary_for_culture(culture_key, proper_noun_style), timeout=120) |
| r = _parse_dict(raw) |
| if r: |
| return {str(k): str(v) for k, v in r.items()} |
| except Exception as exc: |
| logger.warning("Glossary extraction failed: %s", exc) |
| return {} |
|
|
|
|
| |
| |
| |
|
|
| def translate_all( |
| lines: list, |
| glossary: dict, |
| *, |
| provider: str, |
| model: str, |
| api_key: str, |
| progress_cb: Callable | None = None, |
| debug_log: list | None = None, |
| session_state: dict | None = None, |
| resume: bool = False, |
| cooldown: float | None = None, |
| system_prompt: str | None = None, |
| preset_key: str = "adult_18", |
| ) -> list: |
| """ |
| Translate subtitle text lines in chunks. |
| |
| EMPTY LINE HANDLING (critical for timestamp alignment): |
| - Empty strings "" (Comment events, skip-style events) are filtered OUT |
| before sending to AI. Their positions are tracked separately. |
| - After translation, translated text is re-inserted at original positions. |
| - This prevents AI from receiving empty lines, which could cause it to |
| return fewer items โ padding โ position shift โ wrong timestamp mapping. |
| |
| Returns list[str] of EXACTLY len(lines) strings. |
| """ |
| if debug_log is None: |
| debug_log = [] |
| if session_state is None: |
| session_state = {} |
|
|
| if not lines: |
| if progress_cb: |
| progress_cb(1.0, "แแผแฎแธแ
แฎแธแแแบ") |
| return [] |
|
|
| |
| |
| |
| |
| result = list(lines) |
|
|
| translatable = [] |
| for idx, line in enumerate(lines): |
| if line.strip(): |
| translatable.append((idx, line)) |
| |
|
|
| if not translatable: |
| if progress_cb: |
| progress_cb(1.0, "แแผแฎแธแ
แฎแธแแแบ (แแฌแแฌแแผแแบแแแแทแบ lines แแแพแญ)") |
| return result |
|
|
| logger.info( |
| "translate_all: %d total lines, %d translatable, %d skipped (empty/comment)", |
| len(lines), len(translatable), len(lines) - len(translatable) |
| ) |
|
|
| |
| trans_texts = [text for _, text in translatable] |
| trans_indices = [idx for idx, _ in translatable] |
|
|
| |
| cp = load_checkpoint(session_state) |
| done_chunks = set(cp.get("chunks_done", [])) |
| saved_lines = cp.get("lines", {}) |
|
|
| |
| for global_idx, text in saved_lines.items(): |
| if isinstance(global_idx, int) and global_idx < len(result): |
| result[global_idx] = text |
|
|
| if resume and done_chunks: |
| logger.info("Resuming from checkpoint: %d chunks done", len(done_chunks)) |
|
|
| |
| if "tx_checkpoint" not in session_state: |
| session_state["tx_checkpoint"] = { |
| "chunks_done": [], |
| "lines": {}, |
| "total_lines": len(lines), |
| "failed_chunks": [], |
| "glossary": dict(glossary), |
| "chunk_size": BASE_CHUNK_SIZE, |
| } |
| elif "glossary" not in session_state["tx_checkpoint"]: |
| session_state["tx_checkpoint"]["glossary"] = dict(glossary) |
|
|
| |
| chunk_size = _get_chunk_size(model) |
| chunks = _chunk(list(range(len(trans_texts))), chunk_size) |
| total = len(chunks) |
| chunk_stats = [] |
|
|
| try: |
| for ci, chunk_indices in enumerate(chunks): |
| |
|
|
| if ci in done_chunks: |
| chunk_stats.append((ci+1, "SKIPPED", len(chunk_indices), len(chunk_indices))) |
| if progress_cb: |
| progress_cb((ci+1)/total, f"Chunk {ci+1}/{total} โ (checkpoint) {int((ci+1)/total*100)}%") |
| continue |
|
|
| chunk_texts = [trans_texts[i] for i in chunk_indices] |
| chunk_orig_ix = [trans_indices[i] for i in chunk_indices] |
| n = len(chunk_texts) |
|
|
| if progress_cb: |
| first_line = chunk_orig_ix[0] + 1 |
| last_line = chunk_orig_ix[-1] + 1 |
| progress_cb( |
| ci / total, |
| f"Chunk {ci+1}/{total} ({n} lines | Events {first_line}โ{last_line}) แแฌแแฌแแผแแบแแฑแแแบโฆ" |
| ) |
|
|
| |
| translated_chunk = _translate_chunk_safe( |
| chunk_texts, ci, total, chunk_orig_ix[0], glossary, |
| provider=provider, model=model, api_key=api_key, |
| debug_log=debug_log, |
| system_prompt=system_prompt, |
| ) |
|
|
| |
| if len(translated_chunk) != n: |
| translated_chunk = _enforce_length(translated_chunk, n, chunk_texts) |
| assert len(translated_chunk) == n |
|
|
| |
| |
| for j, orig_idx in enumerate(chunk_orig_ix): |
| result[orig_idx] = translated_chunk[j] |
|
|
| |
| checkpoint_entries = {orig_idx: translated_chunk[j] |
| for j, orig_idx in enumerate(chunk_orig_ix)} |
| cp_state = session_state["tx_checkpoint"] |
| cp_state["chunks_done"].append(ci) |
| cp_state["lines"].update(checkpoint_entries) |
|
|
| changed = sum(1 for o, t in zip(chunk_texts, translated_chunk) if o != t) |
| chunk_stats.append((ci+1, "OK" if changed > 0 else "ORIGINAL", changed, n)) |
|
|
| if progress_cb: |
| pct = int((ci+1)/total*100) |
| st_label = "โ" if changed > 0 else "โ original" |
| progress_cb((ci+1)/total, |
| f"Chunk {ci+1}/{total} {st_label} {changed}/{n} {pct}%") |
|
|
| |
| if ci < total - 1: |
| wait = cooldown if cooldown is not None else _cooldown_seconds(model) |
| if wait > 0: |
| if progress_cb: |
| progress_cb( |
| (ci+1)/total, |
| f"Chunk {ci+1}/{total} โ โณ cooldown {wait:.0f}sโฆ" |
| ) |
| time.sleep(wait) |
|
|
| except TranslationPaused: |
| raise |
|
|
| finally: |
| if progress_cb: |
| done_n = sum(c for _, s, c, _ in chunk_stats if s != "SKIPPED") |
| fallbacks = sum(1 for _, s, c, _ in chunk_stats if c == 0 and s != "SKIPPED") |
| progress_cb( |
| 1.0, |
| f"แแผแฎแธแ
แฎแธแแแบ โ {done_n}/{len(translatable)} translatable lines" |
| + (f" | {fallbacks} chunks original" if fallbacks else "") |
| ) |
|
|
| |
| _SKIP_NATURALIZE = {"formal", "custom"} |
| if preset_key not in _SKIP_NATURALIZE: |
| result = [_naturalize_pronouns(line) for line in result] |
|
|
| |
| assert len(result) == len(lines), \ |
| f"Length invariant broken: {len(result)} != {len(lines)}" |
|
|
| return result |
|
|
|
|
|
|
| def run_translation_bg( |
| job_id: str, |
| lines: list, |
| glossary: dict, |
| *, |
| provider: str, |
| model: str, |
| api_key: str, |
| fmt: str, |
| ssafile, |
| filename: str, |
| system_prompt: str | None = None, |
| preset_key: str = "adult_18", |
| ) -> threading.Thread: |
| """ |
| Spawn background thread for translation. |
| Survives browser disconnect โ progress saved to job_store. |
| On limit-hit (TranslationPaused): saves checkpoint, sets status=paused. |
| User can resume from History page. |
| """ |
| from job_store import update_job, save_result, load_job |
| from subtitle_parser import replace_lines, write_subtitle, output_filename |
|
|
| def _worker(): |
| debug_log = [] |
| |
| job = load_job(job_id) |
| cp_init = job.get("checkpoint", {}) if job else {} |
| session_st = {"tx_checkpoint": cp_init} if cp_init else {} |
|
|
| def pcb(frac: float, msg: str) -> None: |
| update_job(job_id, progress=frac, status_msg=msg) |
| |
| cp = session_st.get("tx_checkpoint", {}) |
| if cp: |
| update_job(job_id, checkpoint=cp) |
|
|
| update_job(job_id, status="running", progress=0.0) |
|
|
| try: |
| translated = translate_all( |
| lines, glossary, |
| provider=provider, model=model, api_key=api_key, |
| progress_cb=pcb, |
| debug_log=debug_log, |
| session_state=session_st, |
| resume=bool(cp_init), |
| system_prompt=system_prompt, |
| preset_key=preset_key, |
| ) |
|
|
| |
| out_subs = replace_lines(ssafile, translated) |
| out_bytes = write_subtitle(out_subs, fmt) |
| save_result(job_id, out_bytes) |
|
|
| except TranslationPaused as e: |
| cp = session_st.get("tx_checkpoint", {}) |
| update_job(job_id, |
| status="paused", |
| checkpoint=cp, |
| status_msg=f"โธ Limit hit โ {str(e)[:200]}", |
| error=str(e)[:300]) |
|
|
| except Exception as e: |
| update_job(job_id, |
| status="failed", |
| error=str(e)[:400], |
| status_msg=f"โ {str(e)[:200]}") |
|
|
| t = threading.Thread( |
| target=_worker, |
| daemon=True, |
| name=f"translate-{job_id}", |
| ) |
| t.start() |
| return t |
|
|