Shahah / translation.py
Kai72828's picture
Upload 11 files
e876913 verified
Raw
History Blame
50.7 kB
"""
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__)
# Adaptive chunk size per model RPM limit
# Small chunk = fewer merge errors but more API calls
# Large chunk = fewer calls but higher merge risk
BASE_CHUNK_SIZE = 50 # default (gemini-2.0-flash free: 15 RPM โ†’ ok)
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()
# gemini-1.5-pro / 2.5-pro: free tier = 2 RPM โ†’ use 100
if "pro" in m and "flash" not in m:
return 100
# gemini-1.5-flash-8b: slightly higher limit but slower response
if "8b" in m or "lite" in m:
return 80
# gemini-2.0-flash / 1.5-flash / openrouter: 15+ RPM โ†’ use 50
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 # openrouter / unknown
# Glossary uses full file โ€” Gemini 1M context handles it
# For very large files (5000+ lines) we spread-sample to stay within limits
GLOSSARY_MAX_LINES = 3000 # hard cap: beyond this we spread-sample
MAX_PARSE_RETRY = 2 # parse-only retries per chunk
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Checkpoint helpers
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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": {}, # global_index โ†’ translated_text
"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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# JSON / text extraction โ€” 5 strategies
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _parse_array(raw: str, n: int) -> list | None:
text = raw.strip()
# S1 โ€” markdown fence strip then json.loads
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
# S2 โ€” first [...] block
m = re.search(r"\[.*?\]", text, re.DOTALL)
if m:
try:
r = json.loads(m.group())
if isinstance(r, list):
return r
except Exception:
pass
# S3 โ€” all "quoted strings"
quoted = [q for q in re.findall(r'"((?:[^"\\]|\\.)*)"', text) if q.strip()]
if quoted:
return quoted
# S4 โ€” numbered/bulleted lines
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
# S5 โ€” Myanmar Unicode lines
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)]
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Prompts โ€” Multi-Preset System (zero redundancy, each rule said once)
#
# Architecture: build_system_prompt() assembles 4 non-overlapping layers:
# 1. STYLE โ€” tone / register / swearing (preset-specific)
# 2. TERMS โ€” special terms to preserve (culture-specific)
# 3. NOUNS โ€” proper noun handling (noun_style ร— culture, generated once)
# 4. TECH โ€” count rule / tags / glossary / output / examples (always identical)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# โ”€โ”€ Core philosophy (shared, referenced only by TECH) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Written once here for maintainability โ€” injected into _TECH below.
# โ”€โ”€ Layer 1: STYLE blocks (preset-specific) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Covers: who is speaking, what register, how to handle strong language.
# Does NOT repeat: "don't add/remove" (that's TECH), nouns (that's NOUNS).
#
# DEFAULT PRONOUN RULE (applies to all presets):
# แ€„แ€ซ/แ€™แ€„แ€บแ€ธ is the natural default for most dialogue.
# แ€€แ€ปแ€ฝแ€”แ€บแ€แ€ฑแ€ฌแ€บ/แ€€แ€ปแ€ฝแ€”แ€บแ€™ only when the character is genuinely being servile/formal
# (e.g. servant to emperor, soldier to commanding officer, student to professor).
# Unknown gender โ†’ แ€„แ€ซ/แ€™แ€„แ€บแ€ธ. Ambiguous context โ†’ แ€„แ€ซ/แ€™แ€„แ€บแ€ธ.
# แ€™แ€†แ€ฏแ€ถแ€ธแ€–แ€ผแ€แ€บแ€”แ€ญแ€ฏแ€„แ€บแ€›แ€„แ€บ แ€„แ€ซ/แ€™แ€„แ€บแ€ธ แ€žแ€ฏแ€ถแ€ธ โ€” แ€€แ€ปแ€ฝแ€”แ€บแ€แ€ฑแ€ฌแ€บ/แ€€แ€ปแ€ฝแ€”แ€บแ€™ แ€™แ€žแ€ฏแ€ถแ€ธแ‹
_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.\
"""
# โ”€โ”€ Layer 2: TERMS blocks (culture-specific special terms) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_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 = ""
# โ”€โ”€ Layer 3: NOUNS block (generated from noun_style ร— culture) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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} แ€™แ€ฐแ€›แ€„แ€บแ€ธ"
)
# โ”€โ”€ Layer 4: TECH block (always identical) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_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"]\
"""
# โ”€โ”€ Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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":
# Custom: user writes the full prompt; only TECH appended
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"}'
)
# Backward-compatible defaults
_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)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Single chunk translation โ€” with error-type handling
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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
# Placeholder used to encode subtitle-internal newlines in the numbered prompt.
# Prevents multi-line subtitles from confusing the AI's line count.
_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 substitution table โ€” applied after translation.
# Each entry: (pattern_string, replacement)
# Skipped for "formal" / "custom" presets.
_PRONOUN_SUBS: list[tuple] = [
# 1st person formal โ†’ natural
(r'แ€€แ€ปแ€ฝแ€”แ€บแ€แ€ฑแ€ฌแ€บ', 'แ€„แ€ซ'),
(r'แ€€แ€ปแ€ฝแ€”แ€บแ€™', 'แ€„แ€ซ'),
# 2nd person formal โ†’ natural
(r'แ€แ€„แ€บแ€—แ€ปแ€ฌแ€ธ', 'แ€™แ€„แ€บแ€ธ'),
]
# Compile once
_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.
"""
# Don't touch content inside ASS override tag blocks
# Split on { } boundaries to protect tags
parts = re.split(r'(\{[^}]*\})', text)
out = []
for part in parts:
if part.startswith('{'):
out.append(part) # ASS tag โ€” untouched
else:
for pattern, replacement in _PRONOUN_RE:
part = pattern.sub(replacement, part)
out.append(part)
return ''.join(out)
# Thread-local override for count-mismatch retry feedback
# _translate_chunk_safe sets [0] = error message; _call_chunk_once injects it
_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
# Strip HTML tags, encode internal newlines โ†’ placeholder (one subtitle = one numbered line)
encoded = [_encode_nl(_strip_html(line)) for line in chunk]
numbered = "\n".join(f"{i+1}. {line}" for i, line in enumerate(encoded))
# Build glossary reminder for user prompt (ensures AI sees it right before input)
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:'
)
# Inject count-error feedback from previous attempt if set
override_msg = _chunk_once_override[0]
if override_msg:
prompt = override_msg + "\n\n" + prompt
_chunk_once_override[0] = None # consume once
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)
# โ”€โ”€ Try normal call โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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:
# Daily quota done โ€” must stop, checkpoint already saved
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:
# Chunk too big โ€” split in half and retry each half
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:
# api_handler already retried โ€” if still raised, it's exhausted
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
# โ”€โ”€ Parse the raw response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
parsed = _parse_array(raw, n)
if parsed is not None:
# Decode โŽ placeholder back to \n (multi-line subtitle restore)
parsed = [_decode_nl(str(p)) for p in parsed]
# โ”€โ”€ COUNT VALIDATION (root cause of timestamp shift) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# If AI returned wrong count, retry with explicit error feedback
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,
})
# Rebuild prompt with explicit error message
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."
)
# Inject error into next attempt's prompt
_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
# All attempts failed โ€” fallback to originals (length-safe)
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])
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Glossary extraction
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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:
# โ”€โ”€ Full file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
sample = non_empty
else:
# โ”€โ”€ Spread sample: beginning + middle + end โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 {}
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# translate_all โ€” main entry point with checkpoint/resume
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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", # used to decide pronoun post-processing
) -> 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 []
# โ”€โ”€ Separate translatable lines from empty/skip lines โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Build two parallel structures:
# translatable_lines : list of (original_index, text) for non-empty lines
# result : final output, pre-filled with originals
result = list(lines) # start with originals everywhere
translatable = [] # [(original_index, text), ...]
for idx, line in enumerate(lines):
if line.strip(): # non-empty = needs translation
translatable.append((idx, line))
# empty = keep original (already in result)
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)
)
# โ”€โ”€ Extract just the text for chunking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
trans_texts = [text for _, text in translatable]
trans_indices = [idx for idx, _ in translatable]
# โ”€โ”€ Load checkpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cp = load_checkpoint(session_state)
done_chunks = set(cp.get("chunks_done", []))
saved_lines = cp.get("lines", {})
# Apply saved translations
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))
# Init checkpoint
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 only the translatable lines โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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):
# chunk_indices = positions within trans_texts / trans_indices
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] # original event positions
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}) แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ"
)
# โ”€โ”€ Translate this chunk โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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,
)
# Length enforcement
if len(translated_chunk) != n:
translated_chunk = _enforce_length(translated_chunk, n, chunk_texts)
assert len(translated_chunk) == n
# โ”€โ”€ Re-insert at original event positions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# This is the key: trans_indices[chunk_indices[j]] = original event index
for j, orig_idx in enumerate(chunk_orig_ix):
result[orig_idx] = translated_chunk[j]
# โ”€โ”€ Checkpoint save โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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}%")
# โ”€โ”€ Cooldown between chunks to avoid RPM rate limit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if ci < total - 1: # skip sleep after last chunk
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 "")
)
# โ”€โ”€ Post-process: naturalize pronouns (skip for formal/custom presets) โ”€โ”€
_SKIP_NATURALIZE = {"formal", "custom"}
if preset_key not in _SKIP_NATURALIZE:
result = [_naturalize_pronouns(line) for line in result]
# Final length guarantee
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 = []
# Load existing checkpoint if resuming
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)
# Save checkpoint periodically
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,
)
# Build output file
out_subs = replace_lines(ssafile, translated)
out_bytes = write_subtitle(out_subs, fmt)
save_result(job_id, out_bytes) # sets status=completed
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