Upload 10 files
Browse files- Dockerfile +5 -3
- app.py +478 -120
- audio_transcriber.py +196 -0
- job_store.py +258 -0
- requirements.txt +1 -3
- translation.py +120 -8
Dockerfile
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
# Dockerfile — SubSync on Hugging Face Docker Space
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
build-essential curl \
|
|
|
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
RUN useradd -m -u 1000 appuser
|
|
@@ -13,11 +13,13 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
| 13 |
|
| 14 |
COPY . .
|
| 15 |
|
| 16 |
-
#
|
|
|
|
|
|
|
| 17 |
RUN mkdir -p /home/appuser/.streamlit && \
|
| 18 |
printf '[server]\nport = 7860\naddress = "0.0.0.0"\nheadless = true\nenableCORS = false\nenableXsrfProtection = false\nmaxUploadSize = 500\n\n[browser]\ngatherUsageStats = false\n\n[theme]\nbase = "dark"\n' \
|
| 19 |
> /home/appuser/.streamlit/config.toml && \
|
| 20 |
-
chown -R appuser:appuser /home/appuser/.streamlit
|
| 21 |
|
| 22 |
EXPOSE 7860
|
| 23 |
USER appuser
|
|
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
build-essential curl \
|
| 5 |
+
ffmpeg \
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
RUN useradd -m -u 1000 appuser
|
|
|
|
| 13 |
|
| 14 |
COPY . .
|
| 15 |
|
| 16 |
+
# Create data dir for job storage
|
| 17 |
+
RUN mkdir -p /app/_jobs && chown -R appuser:appuser /app
|
| 18 |
+
|
| 19 |
RUN mkdir -p /home/appuser/.streamlit && \
|
| 20 |
printf '[server]\nport = 7860\naddress = "0.0.0.0"\nheadless = true\nenableCORS = false\nenableXsrfProtection = false\nmaxUploadSize = 500\n\n[browser]\ngatherUsageStats = false\n\n[theme]\nbase = "dark"\n' \
|
| 21 |
> /home/appuser/.streamlit/config.toml && \
|
| 22 |
+
chown -R appuser:appuser /home/appuser/.streamlit
|
| 23 |
|
| 24 |
EXPOSE 7860
|
| 25 |
USER appuser
|
app.py
CHANGED
|
@@ -16,7 +16,19 @@ import traceback
|
|
| 16 |
import pandas as pd
|
| 17 |
import streamlit as st
|
| 18 |
|
|
|
|
| 19 |
from auth import is_authenticated, render_login_page, logout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
from api_handler import fetch_models, validate_api_key, call_ai
|
| 21 |
from subtitle_parser import (
|
| 22 |
parse_subtitle,
|
|
@@ -27,9 +39,6 @@ from subtitle_parser import (
|
|
| 27 |
clean_workspace,
|
| 28 |
workspace_size_mb,
|
| 29 |
)
|
| 30 |
-
from translation import (extract_glossary, translate_all,
|
| 31 |
-
TranslationPaused, load_checkpoint,
|
| 32 |
-
clear_checkpoint, checkpoint_progress)
|
| 33 |
|
| 34 |
# ── Page config ───────────────────────────────────────────────────────────────
|
| 35 |
st.set_page_config(
|
|
@@ -222,6 +231,13 @@ def _init_session() -> None:
|
|
| 222 |
"translation_done": False,
|
| 223 |
"wizard_step": 1,
|
| 224 |
"active_page": "translate",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
"last_error": None,
|
| 226 |
}
|
| 227 |
for k, v in defaults.items():
|
|
@@ -287,19 +303,27 @@ def _render_header(title: str, sub: str = "") -> None:
|
|
| 287 |
# Push buttons to the right by using nested columns
|
| 288 |
# Row 1: main nav buttons
|
| 289 |
st.markdown('<div class="hdr-btn">', unsafe_allow_html=True)
|
| 290 |
-
b1, b2, b3 = st.columns(
|
| 291 |
with b1:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
if st.button("⚙️ Settings", key="hb_settings", use_container_width=True):
|
| 293 |
st.session_state["active_page"] = "settings"
|
| 294 |
st.rerun()
|
| 295 |
-
with
|
| 296 |
if st.button("ℹ️ About", key="hb_about", use_container_width=True):
|
| 297 |
st.session_state["active_page"] = "about"
|
| 298 |
st.rerun()
|
| 299 |
-
with b3:
|
| 300 |
-
if st.button("🎬 Home", key="hb_home", use_container_width=True):
|
| 301 |
-
st.session_state["active_page"] = "translate"
|
| 302 |
-
st.rerun()
|
| 303 |
st.markdown('</div>', unsafe_allow_html=True)
|
| 304 |
|
| 305 |
# Row 2: clean + sign out
|
|
@@ -308,13 +332,32 @@ def _render_header(title: str, sub: str = "") -> None:
|
|
| 308 |
with d1:
|
| 309 |
if st.button("🗑 Clean", key="hb_clean", use_container_width=True):
|
| 310 |
try:
|
|
|
|
| 311 |
clean_workspace()
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
st.session_state[k] = None
|
| 316 |
st.session_state["glossary_approved"] = False
|
| 317 |
st.session_state["translation_done"] = False
|
|
|
|
| 318 |
st.session_state["wizard_step"] = 1
|
| 319 |
st.rerun()
|
| 320 |
except Exception as e:
|
|
@@ -384,6 +427,27 @@ def _step1_upload() -> None:
|
|
| 384 |
st.markdown('<div class="card-title">📁 Step 1 — Subtitle ဖိုင် တင်ရန်</div>',
|
| 385 |
unsafe_allow_html=True)
|
| 386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
uploaded = st.file_uploader(
|
| 388 |
"Subtitle ဖိုင် ရွေးပါ",
|
| 389 |
type=["srt", "vtt", "ass", "ssa"],
|
|
@@ -401,8 +465,20 @@ def _step1_upload() -> None:
|
|
| 401 |
st.session_state["uploaded_file_name"] = None # clear while parsing
|
| 402 |
|
| 403 |
parse_error = None
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
try:
|
| 407 |
with st.spinner("ဖိုင် ဖတ်နေသည်…"):
|
| 408 |
subs, fmt = parse_subtitle(file_bytes, uploaded.name)
|
|
@@ -661,6 +737,21 @@ def _step4_review() -> None:
|
|
| 661 |
st.session_state["glossary_edited"] = approved
|
| 662 |
st.session_state["glossary_approved"] = True
|
| 663 |
st.session_state["wizard_step"] = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
st.rerun()
|
| 665 |
|
| 666 |
with c2:
|
|
@@ -943,8 +1034,8 @@ def _step5_translate() -> None:
|
|
| 943 |
return
|
| 944 |
|
| 945 |
# ── Translation UI ────────────────────────────────────────────────────
|
| 946 |
-
from translation import BASE_CHUNK_SIZE as CHUNK_SIZE
|
| 947 |
import math
|
|
|
|
| 948 |
total_chunks = math.ceil(len(lines) / CHUNK_SIZE)
|
| 949 |
|
| 950 |
st.markdown(f"""
|
|
@@ -974,119 +1065,81 @@ def _step5_translate() -> None:
|
|
| 974 |
</table>
|
| 975 |
</div>
|
| 976 |
""", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
error_slot = st.empty()
|
| 985 |
-
|
| 986 |
-
def progress_cb(fraction: float, msg: str) -> None:
|
| 987 |
-
try:
|
| 988 |
-
progress_bar.progress(min(float(fraction), 1.0), text=msg)
|
| 989 |
-
status_slot.markdown(
|
| 990 |
-
f'<div class="mono" style="color:#9ca3af;">{msg}</div>',
|
| 991 |
-
unsafe_allow_html=True)
|
| 992 |
-
except Exception:
|
| 993 |
-
pass # never let progress_cb itself crash the run
|
| 994 |
-
|
| 995 |
-
debug_log = []
|
| 996 |
-
out_bytes = None
|
| 997 |
-
paused_error = None
|
| 998 |
-
|
| 999 |
-
# ── Check if resuming from checkpoint ──────────────────────
|
| 1000 |
-
cp = load_checkpoint(st.session_state)
|
| 1001 |
-
is_resume = bool(cp.get("chunks_done"))
|
| 1002 |
-
done_n, total_n = checkpoint_progress(st.session_state, len(lines))
|
| 1003 |
-
if is_resume and done_n > 0:
|
| 1004 |
-
status_slot.markdown(
|
| 1005 |
-
f'<div class="mono" style="color:#e8c97e;">' +
|
| 1006 |
-
f'Checkpoint မှ ဆက်ပြန်နေသည် ({done_n}/{total_n} lines done)…' +
|
| 1007 |
-
'</div>', unsafe_allow_html=True)
|
| 1008 |
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
)
|
| 1018 |
-
except TranslationPaused as e:
|
| 1019 |
-
paused_error = e
|
| 1020 |
-
translated_lines = None
|
| 1021 |
-
progress_cb(1.0, f"⏸ ရပ်နေသည်: {e}")
|
| 1022 |
-
# Store context for dialog
|
| 1023 |
st.session_state["limit_hit_info"] = {
|
| 1024 |
-
"reason":
|
| 1025 |
-
"key_label":
|
| 1026 |
"key_masked": _mask_key(_active_key()),
|
| 1027 |
-
"provider":
|
| 1028 |
-
"model": model,
|
| 1029 |
}
|
| 1030 |
st.session_state["show_limit_dialog"] = True
|
| 1031 |
-
|
| 1032 |
-
st.session_state["last_error"] = traceback.format_exc()
|
| 1033 |
-
progress_cb(1.0, f"⚠ Error: {e}")
|
| 1034 |
-
translated_lines = None
|
| 1035 |
-
|
| 1036 |
-
# ── Verify & build output ─────────────────────────────────
|
| 1037 |
-
if translated_lines:
|
| 1038 |
-
changed = sum(1 for o, t in zip(lines, translated_lines) if o != t)
|
| 1039 |
-
if changed == 0:
|
| 1040 |
-
# Show debug info
|
| 1041 |
-
error_slot.warning(
|
| 1042 |
-
"⚠️ ဘာသာပြန်မထားပါ — AI response parse မရပါ\n"
|
| 1043 |
-
"gemini-1.5-pro / gemini-2.0-flash ကို ပြောင်းစမ်းပါ",
|
| 1044 |
-
icon="⚠️")
|
| 1045 |
-
with st.expander("🐛 AI Raw Response", expanded=True):
|
| 1046 |
-
for d in debug_log[:3]:
|
| 1047 |
-
st.markdown(f"**Chunk {d.get('chunk')}** — {d.get('status','')} ")
|
| 1048 |
-
st.code(d.get('raw_preview', d.get('error','(empty)')), language=None)
|
| 1049 |
-
else:
|
| 1050 |
-
try:
|
| 1051 |
-
out_subs = replace_lines(subs, translated_lines)
|
| 1052 |
-
out_bytes = write_subtitle(out_subs, fmt)
|
| 1053 |
-
clear_checkpoint(st.session_state) # clean up on success
|
| 1054 |
-
except Exception as e:
|
| 1055 |
-
st.session_state["last_error"] = traceback.format_exc()
|
| 1056 |
-
error_slot.error(f"❌ File build error: {e}", icon="❌")
|
| 1057 |
-
|
| 1058 |
-
# ── Paused — dialog triggered on next rerun ───────────────
|
| 1059 |
-
if paused_error:
|
| 1060 |
-
is_res = getattr(paused_error, 'is_resumable', False)
|
| 1061 |
-
if is_res:
|
| 1062 |
-
done_n2, _ = checkpoint_progress(st.session_state, len(lines))
|
| 1063 |
-
st.info(
|
| 1064 |
-
f"⏸ Limit hit — {done_n2}/{len(lines)} lines checkpoint သိမ်းပြီ\n"
|
| 1065 |
-
"Key/Provider/Model ပြောင်းမည်ဆိုလျှင် dialog ဖွင့်ပါ",
|
| 1066 |
-
icon="⏸")
|
| 1067 |
-
else:
|
| 1068 |
-
st.error(f"❌ {paused_error}", icon="❌")
|
| 1069 |
-
|
| 1070 |
-
# ── Parse fail debug ──────────────────────────────────────
|
| 1071 |
-
fails = [d for d in debug_log if d.get("status") in
|
| 1072 |
-
("PARSE_FAIL","FALLBACK_ORIGINAL","API_ERROR")]
|
| 1073 |
-
if fails:
|
| 1074 |
-
with st.expander(f"⚠ {len(fails)} chunks မအောင်မြင်ပါ (debug)", expanded=False):
|
| 1075 |
-
for d in fails:
|
| 1076 |
-
st.markdown(f"**Chunk {d.get('chunk')}** — `{d.get('status','')}`")
|
| 1077 |
-
if d.get('raw_preview'):
|
| 1078 |
-
st.code(d['raw_preview'], language=None)
|
| 1079 |
-
if d.get('error'):
|
| 1080 |
-
st.code(d['error'], language=None)
|
| 1081 |
-
|
| 1082 |
-
if out_bytes:
|
| 1083 |
-
st.session_state["translation_result"] = out_bytes
|
| 1084 |
-
st.session_state["translation_done"] = True
|
| 1085 |
-
status_slot.empty()
|
| 1086 |
st.rerun()
|
| 1087 |
|
| 1088 |
-
st.
|
| 1089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
if st.button("← Glossary Review ပြန်သွားမည်", key="s5_back"):
|
| 1091 |
st.session_state["wizard_step"] = 4
|
| 1092 |
st.rerun()
|
|
@@ -1323,6 +1376,307 @@ def _page_settings() -> None:
|
|
| 1323 |
|
| 1324 |
|
| 1325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1326 |
def _page_about() -> None:
|
| 1327 |
_render_header("About SubSync", "Myanmar Subtitle Translator · v1.0.0")
|
| 1328 |
st.markdown("""
|
|
@@ -1396,6 +1750,10 @@ def main() -> None:
|
|
| 1396 |
|
| 1397 |
if page == "translate":
|
| 1398 |
_page_translate()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1399 |
elif page == "settings":
|
| 1400 |
_page_settings()
|
| 1401 |
elif page == "about":
|
|
|
|
| 16 |
import pandas as pd
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
+
import time
|
| 20 |
from auth import is_authenticated, render_login_page, logout
|
| 21 |
+
from job_store import (create_job, update_job, save_result, load_result,
|
| 22 |
+
load_job, list_jobs, delete_job, clear_all_jobs,
|
| 23 |
+
job_age_str, status_icon, status_color,
|
| 24 |
+
save_glossary, load_glossary,
|
| 25 |
+
get_latest_translate_job_with_glossary)
|
| 26 |
+
from audio_transcriber import (run_transcription_bg, SUPPORTED_LANGUAGES,
|
| 27 |
+
MODEL)
|
| 28 |
+
from translation import (extract_glossary, translate_all,
|
| 29 |
+
TranslationPaused, load_checkpoint,
|
| 30 |
+
clear_checkpoint, checkpoint_progress,
|
| 31 |
+
run_translation_bg)
|
| 32 |
from api_handler import fetch_models, validate_api_key, call_ai
|
| 33 |
from subtitle_parser import (
|
| 34 |
parse_subtitle,
|
|
|
|
| 39 |
clean_workspace,
|
| 40 |
workspace_size_mb,
|
| 41 |
)
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# ── Page config ───────────────────────────────────────────────────────────────
|
| 44 |
st.set_page_config(
|
|
|
|
| 231 |
"translation_done": False,
|
| 232 |
"wizard_step": 1,
|
| 233 |
"active_page": "translate",
|
| 234 |
+
"current_job_id": None, # active job being processed
|
| 235 |
+
"audio_job_id": None, # current audio transcription job
|
| 236 |
+
"audio_srt_result": None,
|
| 237 |
+
"audio_srt_filename": None,
|
| 238 |
+
"audio_srt_to_translate": None,
|
| 239 |
+
"audio_srt_orig_name": None,
|
| 240 |
+
"tx_checkpoint": None,
|
| 241 |
"last_error": None,
|
| 242 |
}
|
| 243 |
for k, v in defaults.items():
|
|
|
|
| 303 |
# Push buttons to the right by using nested columns
|
| 304 |
# Row 1: main nav buttons
|
| 305 |
st.markdown('<div class="hdr-btn">', unsafe_allow_html=True)
|
| 306 |
+
b1, b2, b3, b4, b5 = st.columns(5)
|
| 307 |
with b1:
|
| 308 |
+
if st.button("🎬 Translate", key="hb_home", use_container_width=True):
|
| 309 |
+
st.session_state["active_page"] = "translate"
|
| 310 |
+
st.rerun()
|
| 311 |
+
with b2:
|
| 312 |
+
if st.button("🎙 Audio→SRT", key="hb_audio", use_container_width=True):
|
| 313 |
+
st.session_state["active_page"] = "audio_srt"
|
| 314 |
+
st.rerun()
|
| 315 |
+
with b3:
|
| 316 |
+
if st.button("📋 History", key="hb_history", use_container_width=True):
|
| 317 |
+
st.session_state["active_page"] = "history"
|
| 318 |
+
st.rerun()
|
| 319 |
+
with b4:
|
| 320 |
if st.button("⚙️ Settings", key="hb_settings", use_container_width=True):
|
| 321 |
st.session_state["active_page"] = "settings"
|
| 322 |
st.rerun()
|
| 323 |
+
with b5:
|
| 324 |
if st.button("ℹ️ About", key="hb_about", use_container_width=True):
|
| 325 |
st.session_state["active_page"] = "about"
|
| 326 |
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
st.markdown('</div>', unsafe_allow_html=True)
|
| 328 |
|
| 329 |
# Row 2: clean + sign out
|
|
|
|
| 332 |
with d1:
|
| 333 |
if st.button("🗑 Clean", key="hb_clean", use_container_width=True):
|
| 334 |
try:
|
| 335 |
+
# 1. Delete subtitle temp files
|
| 336 |
clean_workspace()
|
| 337 |
+
# 2. Delete ALL job history + result files (_jobs/ dir)
|
| 338 |
+
clear_all_jobs()
|
| 339 |
+
# 3. Wipe every workflow-related session key
|
| 340 |
+
_clean_keys = [
|
| 341 |
+
# Subtitle workflow
|
| 342 |
+
"uploaded_file_name", "subtitle_raw", "subtitle_parsed",
|
| 343 |
+
"subtitle_format", "plain_lines",
|
| 344 |
+
# Glossary — clear completely so no cross-job bleed
|
| 345 |
+
"glossary_raw", "glossary_edited", "last_error",
|
| 346 |
+
# Translation
|
| 347 |
+
"translation_result", "tx_checkpoint",
|
| 348 |
+
# Job tracking
|
| 349 |
+
"current_job_id",
|
| 350 |
+
# Audio SRT
|
| 351 |
+
"audio_job_id", "audio_srt_result", "audio_srt_filename",
|
| 352 |
+
"audio_srt_to_translate", "audio_srt_orig_name",
|
| 353 |
+
# Limit dialog
|
| 354 |
+
"limit_hit_info",
|
| 355 |
+
]
|
| 356 |
+
for k in _clean_keys:
|
| 357 |
st.session_state[k] = None
|
| 358 |
st.session_state["glossary_approved"] = False
|
| 359 |
st.session_state["translation_done"] = False
|
| 360 |
+
st.session_state["show_limit_dialog"] = False
|
| 361 |
st.session_state["wizard_step"] = 1
|
| 362 |
st.rerun()
|
| 363 |
except Exception as e:
|
|
|
|
| 427 |
st.markdown('<div class="card-title">📁 Step 1 — Subtitle ဖိုင် တင်ရန်</div>',
|
| 428 |
unsafe_allow_html=True)
|
| 429 |
|
| 430 |
+
# ── Auto-populate from Audio→SRT page "Translate this SRT" button ────
|
| 431 |
+
auto_srt = st.session_state.get("audio_srt_to_translate")
|
| 432 |
+
auto_name = st.session_state.get("audio_srt_orig_name", "audio.srt")
|
| 433 |
+
if auto_srt and st.session_state.get("uploaded_file_name") != auto_name:
|
| 434 |
+
try:
|
| 435 |
+
from subtitle_parser import parse_subtitle, get_plain_lines
|
| 436 |
+
subs, fmt = parse_subtitle(auto_srt, auto_name)
|
| 437 |
+
plain = get_plain_lines(subs)
|
| 438 |
+
st.session_state["uploaded_file_name"] = auto_name
|
| 439 |
+
st.session_state["subtitle_raw"] = auto_srt
|
| 440 |
+
st.session_state["subtitle_parsed"] = subs
|
| 441 |
+
st.session_state["subtitle_format"] = fmt
|
| 442 |
+
st.session_state["plain_lines"] = plain
|
| 443 |
+
# Clear audio_srt_to_translate so it doesn't re-trigger
|
| 444 |
+
st.session_state["audio_srt_to_translate"] = None
|
| 445 |
+
st.session_state["audio_srt_orig_name"] = None
|
| 446 |
+
st.info(f"Audio→SRT ဖိုင် '{auto_name}' ကို auto-load ပြီး", icon="🎙")
|
| 447 |
+
except Exception as e:
|
| 448 |
+
st.error(f"Auto-load မအောင်မြင်: {e}", icon="❌")
|
| 449 |
+
st.session_state["audio_srt_to_translate"] = None
|
| 450 |
+
|
| 451 |
uploaded = st.file_uploader(
|
| 452 |
"Subtitle ဖိုင် ရွေးပါ",
|
| 453 |
type=["srt", "vtt", "ass", "ssa"],
|
|
|
|
| 465 |
st.session_state["uploaded_file_name"] = None # clear while parsing
|
| 466 |
|
| 467 |
parse_error = None
|
| 468 |
+
is_new_file = (st.session_state.get("uploaded_file_name") != uploaded.name)
|
| 469 |
+
|
| 470 |
+
if st.session_state.get("subtitle_parsed") is None or is_new_file:
|
| 471 |
+
|
| 472 |
+
if is_new_file:
|
| 473 |
+
# New file uploaded — reset ALL glossary & job state
|
| 474 |
+
# Prevents old glossary bleeding into new translation
|
| 475 |
+
for _k in ["glossary_raw", "glossary_edited", "translation_result",
|
| 476 |
+
"tx_checkpoint", "current_job_id", "limit_hit_info"]:
|
| 477 |
+
st.session_state[_k] = None
|
| 478 |
+
st.session_state["glossary_approved"] = False
|
| 479 |
+
st.session_state["translation_done"] = False
|
| 480 |
+
st.session_state["wizard_step"] = 1
|
| 481 |
+
|
| 482 |
try:
|
| 483 |
with st.spinner("ဖိုင် ဖတ်နေသည်…"):
|
| 484 |
subs, fmt = parse_subtitle(file_bytes, uploaded.name)
|
|
|
|
| 737 |
st.session_state["glossary_edited"] = approved
|
| 738 |
st.session_state["glossary_approved"] = True
|
| 739 |
st.session_state["wizard_step"] = 5
|
| 740 |
+
|
| 741 |
+
# ── Persist glossary to job_store ──────────────────────────
|
| 742 |
+
# Saved separately from checkpoint so it survives model/provider
|
| 743 |
+
# changes. Resume always reuses this approved glossary.
|
| 744 |
+
_jid = st.session_state.get("current_job_id")
|
| 745 |
+
if not _jid or not load_job(_jid):
|
| 746 |
+
fname_tmp = st.session_state.get("uploaded_file_name", "file")
|
| 747 |
+
_jid = create_job(
|
| 748 |
+
"translate", fname_tmp,
|
| 749 |
+
model=st.session_state.get("selected_model",""),
|
| 750 |
+
provider=st.session_state.get("selected_provider",""),
|
| 751 |
+
)
|
| 752 |
+
st.session_state["current_job_id"] = _jid
|
| 753 |
+
save_glossary(_jid, approved)
|
| 754 |
+
|
| 755 |
st.rerun()
|
| 756 |
|
| 757 |
with c2:
|
|
|
|
| 1034 |
return
|
| 1035 |
|
| 1036 |
# ── Translation UI ────────────────────────────────────────────────────
|
|
|
|
| 1037 |
import math
|
| 1038 |
+
CHUNK_SIZE = 150 # must match BASE_CHUNK_SIZE in translation.py
|
| 1039 |
total_chunks = math.ceil(len(lines) / CHUNK_SIZE)
|
| 1040 |
|
| 1041 |
st.markdown(f"""
|
|
|
|
| 1065 |
</table>
|
| 1066 |
</div>
|
| 1067 |
""", unsafe_allow_html=True)
|
| 1068 |
+
# ── Poll background job if already running ───────────────────────────
|
| 1069 |
+
_jid = st.session_state.get("current_job_id")
|
| 1070 |
+
if _jid:
|
| 1071 |
+
_bgjob = load_job(_jid)
|
| 1072 |
+
if _bgjob and _bgjob["status"] in ("running", "queued"):
|
| 1073 |
+
st.markdown('<div class="card">', unsafe_allow_html=True)
|
| 1074 |
+
st.markdown('<div class="card-title">🔄 Background တွင် ဘာသာပြန်နေသည်</div>', unsafe_allow_html=True)
|
| 1075 |
+
st.progress(_bgjob.get("progress",0.0), text=_bgjob.get("status_msg",""))
|
| 1076 |
+
st.markdown(
|
| 1077 |
+
f'<div class="mono" style="color:#4b5563;margin-top:0.4rem;">' +
|
| 1078 |
+
f'Browser ပိတ်/refresh — ဆက်လုပ်မည် · Job: {_jid}</div>',
|
| 1079 |
+
unsafe_allow_html=True)
|
| 1080 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1081 |
+
time.sleep(3)
|
| 1082 |
+
st.rerun()
|
| 1083 |
+
return
|
| 1084 |
|
| 1085 |
+
if _bgjob and _bgjob["status"] == "completed":
|
| 1086 |
+
result_bytes = load_result(_jid)
|
| 1087 |
+
if result_bytes and not st.session_state.get("translation_done"):
|
| 1088 |
+
st.session_state["translation_result"] = result_bytes
|
| 1089 |
+
st.session_state["translation_done"] = True
|
| 1090 |
+
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
|
| 1092 |
+
if _bgjob and _bgjob["status"] == "paused":
|
| 1093 |
+
cp = _bgjob.get("checkpoint", {})
|
| 1094 |
+
done_n2 = len(cp.get("lines", {}))
|
| 1095 |
+
st.warning(
|
| 1096 |
+
f"⏸ Limit hit · {done_n2}/{len(lines)} lines saved\n"
|
| 1097 |
+
f"{_bgjob.get('status_msg','')[:200]}",
|
| 1098 |
+
icon="⏸")
|
| 1099 |
+
if st.button("🔔 Key/Provider ပြောင်းမည် (Dialog)", key="s5_open_dialog"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1100 |
st.session_state["limit_hit_info"] = {
|
| 1101 |
+
"reason": _bgjob.get("status_msg",""),
|
| 1102 |
+
"key_label": _active_key_label(),
|
| 1103 |
"key_masked": _mask_key(_active_key()),
|
| 1104 |
+
"provider": prov, "model": model,
|
|
|
|
| 1105 |
}
|
| 1106 |
st.session_state["show_limit_dialog"] = True
|
| 1107 |
+
st.session_state["tx_checkpoint"] = cp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1108 |
st.rerun()
|
| 1109 |
|
| 1110 |
+
if st.button("▶ ဘာသာပြန်ခြင်း စတင်မည် (Background)", key="s5_start",
|
| 1111 |
+
use_container_width=True):
|
| 1112 |
+
if not key.strip() or not model:
|
| 1113 |
+
st.error("API key / model မရှိပါ — Settings စစ်ဆေးပါ", icon="❌")
|
| 1114 |
+
else:
|
| 1115 |
+
# Create job
|
| 1116 |
+
_jid = st.session_state.get("current_job_id")
|
| 1117 |
+
if not _jid or not load_job(_jid):
|
| 1118 |
+
_jid = create_job("translate", fname, language="my",
|
| 1119 |
+
model=model, provider=prov)
|
| 1120 |
+
st.session_state["current_job_id"] = _jid
|
| 1121 |
+
|
| 1122 |
+
# Check for existing checkpoint (resume)
|
| 1123 |
+
cp_data = st.session_state.get("tx_checkpoint", {})
|
| 1124 |
+
|
| 1125 |
+
# Launch background thread
|
| 1126 |
+
run_translation_bg(
|
| 1127 |
+
_jid, lines, glossary,
|
| 1128 |
+
provider=prov, model=model, api_key=key,
|
| 1129 |
+
fmt=fmt, ssafile=subs, filename=fname,
|
| 1130 |
+
)
|
| 1131 |
+
# Stash checkpoint into job so thread can resume
|
| 1132 |
+
if cp_data:
|
| 1133 |
+
update_job(_jid, checkpoint=cp_data)
|
| 1134 |
+
|
| 1135 |
+
st.info(
|
| 1136 |
+
"🔄 Background တွင် စတင်ပြီ\n"
|
| 1137 |
+
"Browser ပိတ်/refresh ဖြစ်ပေမဲ့ ဆက်လုပ်မည်\n"
|
| 1138 |
+
"Progress ကို History page တွင် ကြည့်ရှုနိုင်သည်",
|
| 1139 |
+
icon="🔄")
|
| 1140 |
+
time.sleep(1)
|
| 1141 |
+
st.rerun()
|
| 1142 |
+
|
| 1143 |
if st.button("← Glossary Review ပြန်သွားမည်", key="s5_back"):
|
| 1144 |
st.session_state["wizard_step"] = 4
|
| 1145 |
st.rerun()
|
|
|
|
| 1376 |
|
| 1377 |
|
| 1378 |
|
| 1379 |
+
|
| 1380 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 1381 |
+
# Audio → SRT page
|
| 1382 |
+
# ────────────────────��────────────────────────────────────────────────────────
|
| 1383 |
+
|
| 1384 |
+
def _page_audio_srt() -> None:
|
| 1385 |
+
_render_header("Audio → SRT", "Audio / Video ဖိုင်မှ Subtitle ထုတ်ရန်")
|
| 1386 |
+
|
| 1387 |
+
st.markdown("""
|
| 1388 |
+
<style>
|
| 1389 |
+
.info-chip {
|
| 1390 |
+
display:inline-block; font-family:'DM Mono',monospace;
|
| 1391 |
+
font-size:0.68rem; padding:0.12rem 0.5rem; border-radius:6px;
|
| 1392 |
+
background:rgba(232,201,126,0.1); color:#e8c97e;
|
| 1393 |
+
margin-right:0.3rem;
|
| 1394 |
+
}
|
| 1395 |
+
</style>
|
| 1396 |
+
""", unsafe_allow_html=True)
|
| 1397 |
+
|
| 1398 |
+
st.markdown(
|
| 1399 |
+
'<div class="mono" style="color:#374151;margin-bottom:1rem;">'
|
| 1400 |
+
'🧠 Model: <span style="color:#e8c97e;">Whisper large-v3</span>'
|
| 1401 |
+
' · Browser ပိတ်/refresh ဖြစ်ပေမဲ့ background တွင် ဆက်လုပ်သည်'
|
| 1402 |
+
'</div>',
|
| 1403 |
+
unsafe_allow_html=True)
|
| 1404 |
+
|
| 1405 |
+
# ── Active job polling (running in background) ─────────────────────
|
| 1406 |
+
active_jid = st.session_state.get("audio_job_id")
|
| 1407 |
+
if active_jid:
|
| 1408 |
+
job = load_job(active_jid)
|
| 1409 |
+
if job and job["status"] in ("running", "queued"):
|
| 1410 |
+
st.markdown(f'<div class="card">', unsafe_allow_html=True)
|
| 1411 |
+
st.markdown(
|
| 1412 |
+
f'<div class="card-title">🔄 Transcribing: {job["filename"]}</div>',
|
| 1413 |
+
unsafe_allow_html=True)
|
| 1414 |
+
pct = job.get("progress", 0.0)
|
| 1415 |
+
msg = job.get("status_msg", "")
|
| 1416 |
+
st.progress(pct, text=msg)
|
| 1417 |
+
st.markdown(
|
| 1418 |
+
f'<div class="mono" style="color:#4b5563;margin-top:0.5rem;">'
|
| 1419 |
+
f'Job ID: {active_jid} · Browser ပိတ်ထားနိုင်သည် — background တွင် ဆက်လုပ်မည်</div>',
|
| 1420 |
+
unsafe_allow_html=True)
|
| 1421 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1422 |
+
# Auto-refresh every 3 seconds
|
| 1423 |
+
time.sleep(3)
|
| 1424 |
+
st.rerun()
|
| 1425 |
+
return
|
| 1426 |
+
|
| 1427 |
+
if job and job["status"] == "completed":
|
| 1428 |
+
srt_bytes = load_result(active_jid)
|
| 1429 |
+
if srt_bytes:
|
| 1430 |
+
st.session_state["audio_srt_result"] = srt_bytes
|
| 1431 |
+
st.session_state["audio_srt_filename"] = job["filename"]
|
| 1432 |
+
|
| 1433 |
+
# ── Show result if available ───────────────────────────────────────
|
| 1434 |
+
srt_result = st.session_state.get("audio_srt_result")
|
| 1435 |
+
srt_fname = st.session_state.get("audio_srt_filename", "audio")
|
| 1436 |
+
|
| 1437 |
+
if srt_result:
|
| 1438 |
+
out_name = os.path.splitext(srt_fname)[0] + ".srt"
|
| 1439 |
+
st.success(f"✅ Transcription ပြီးသည် — {out_name}", icon="🎉")
|
| 1440 |
+
|
| 1441 |
+
st.download_button(
|
| 1442 |
+
label=f"⬇️ {out_name} Download",
|
| 1443 |
+
data=srt_result,
|
| 1444 |
+
file_name=out_name,
|
| 1445 |
+
mime="text/plain",
|
| 1446 |
+
use_container_width=True,
|
| 1447 |
+
key="audio_dl",
|
| 1448 |
+
)
|
| 1449 |
+
|
| 1450 |
+
with st.expander("📋 SRT Preview (ပထမ 30 ကြောင်း)", expanded=False):
|
| 1451 |
+
preview = srt_result.decode("utf-8", errors="replace")
|
| 1452 |
+
preview_text = "\n".join(preview.splitlines()[:60])
|
| 1453 |
+
st.code(preview_text, language=None)
|
| 1454 |
+
|
| 1455 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 1456 |
+
if st.button("🌐 ဒီ SRT ကို မြန်မာဘာသာပြန်မည်", key="audio_to_translate"):
|
| 1457 |
+
st.session_state["audio_srt_to_translate"] = srt_result
|
| 1458 |
+
st.session_state["audio_srt_orig_name"] = out_name
|
| 1459 |
+
st.session_state["active_page"] = "translate"
|
| 1460 |
+
st.session_state["wizard_step"] = 1
|
| 1461 |
+
st.rerun()
|
| 1462 |
+
|
| 1463 |
+
col_x, _ = st.columns([1, 3])
|
| 1464 |
+
with col_x:
|
| 1465 |
+
st.markdown('<div class="danger">', unsafe_allow_html=True)
|
| 1466 |
+
if st.button("🗑 Clear Result", key="audio_clear"):
|
| 1467 |
+
st.session_state["audio_srt_result"] = None
|
| 1468 |
+
st.session_state["audio_srt_filename"] = None
|
| 1469 |
+
st.session_state["audio_job_id"] = None
|
| 1470 |
+
st.rerun()
|
| 1471 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1472 |
+
|
| 1473 |
+
st.markdown('<hr style="border-color:#1e2330;margin:1.5rem 0 1rem;">', unsafe_allow_html=True)
|
| 1474 |
+
|
| 1475 |
+
# ── File upload + start ────────────────────────────────────────────
|
| 1476 |
+
st.markdown("#### 📂 Audio / Video ဖိုင် တင်ရန်")
|
| 1477 |
+
uploaded = st.file_uploader(
|
| 1478 |
+
"audio",
|
| 1479 |
+
type=["mp3","mp4","wav","m4a","flac","ogg","webm","mkv","aac"],
|
| 1480 |
+
label_visibility="collapsed",
|
| 1481 |
+
key="audio_uploader",
|
| 1482 |
+
)
|
| 1483 |
+
|
| 1484 |
+
lang_options = list(SUPPORTED_LANGUAGES.keys())
|
| 1485 |
+
lang_labels = list(SUPPORTED_LANGUAGES.values())
|
| 1486 |
+
lang_idx = st.selectbox(
|
| 1487 |
+
"ဘာသာစကား",
|
| 1488 |
+
options=range(len(lang_options)),
|
| 1489 |
+
format_func=lambda i: lang_labels[i],
|
| 1490 |
+
key="audio_lang_sel",
|
| 1491 |
+
)
|
| 1492 |
+
selected_lang = lang_options[lang_idx]
|
| 1493 |
+
|
| 1494 |
+
if uploaded is not None:
|
| 1495 |
+
file_bytes = uploaded.read()
|
| 1496 |
+
st.markdown(
|
| 1497 |
+
f'<div class="mono" style="color:#6b7280;margin:0.5rem 0 1rem;">'
|
| 1498 |
+
f'{uploaded.name} · {len(file_bytes)/1024/1024:.1f} MB</div>',
|
| 1499 |
+
unsafe_allow_html=True)
|
| 1500 |
+
|
| 1501 |
+
if st.button("▶ Transcribe မည် (Background)", key="audio_start",
|
| 1502 |
+
use_container_width=True):
|
| 1503 |
+
job_id = create_job(
|
| 1504 |
+
job_type="audio_srt",
|
| 1505 |
+
filename=uploaded.name,
|
| 1506 |
+
language=selected_lang,
|
| 1507 |
+
model=MODEL,
|
| 1508 |
+
)
|
| 1509 |
+
st.session_state["audio_job_id"] = job_id
|
| 1510 |
+
st.session_state["audio_srt_result"] = None
|
| 1511 |
+
st.session_state["audio_srt_filename"] = None
|
| 1512 |
+
|
| 1513 |
+
# Launch background thread — survives browser disconnect
|
| 1514 |
+
run_transcription_bg(job_id, file_bytes, uploaded.name, selected_lang)
|
| 1515 |
+
|
| 1516 |
+
st.info(
|
| 1517 |
+
"🔄 Background တွင် စတင်ပြီ — Browser ပိတ်ထားနိုင်သည်\\n"
|
| 1518 |
+
"Progress ကို History page တွင် ကြည့်ရှုနိုင်သည်",
|
| 1519 |
+
icon="🔄")
|
| 1520 |
+
time.sleep(1)
|
| 1521 |
+
st.rerun()
|
| 1522 |
+
|
| 1523 |
+
st.markdown(
|
| 1524 |
+
'<div class="mono" style="color:#374151;font-size:0.68rem;margin-top:1.5rem;">'
|
| 1525 |
+
'💡 large-v3 model ပထမဆုံးအကြိမ် download ~1.5GB ကြာနိုင်သည်</div>',
|
| 1526 |
+
unsafe_allow_html=True)
|
| 1527 |
+
|
| 1528 |
+
|
| 1529 |
+
|
| 1530 |
+
def _page_history() -> None:
|
| 1531 |
+
_render_header("History", "Translation & Transcription Jobs")
|
| 1532 |
+
|
| 1533 |
+
st.markdown("""
|
| 1534 |
+
<style>
|
| 1535 |
+
.job-card {
|
| 1536 |
+
background: var(--card); border: 1px solid var(--border);
|
| 1537 |
+
border-radius: 12px; padding: 1rem 1.2rem; margin-bottom: 0.8rem;
|
| 1538 |
+
}
|
| 1539 |
+
.job-card:hover { border-color: var(--blit); }
|
| 1540 |
+
.job-title { font-weight:600; font-size:0.9rem; color:var(--hi); }
|
| 1541 |
+
.job-meta { font-family:'DM Mono',monospace; font-size:0.68rem;
|
| 1542 |
+
color:var(--lo); margin-top:0.2rem; }
|
| 1543 |
+
.job-status { font-family:'DM Mono',monospace; font-size:0.72rem;
|
| 1544 |
+
font-weight:600; }
|
| 1545 |
+
</style>
|
| 1546 |
+
""", unsafe_allow_html=True)
|
| 1547 |
+
|
| 1548 |
+
# ── Controls row ───────────────────────────────────────────────────
|
| 1549 |
+
col_filter, col_clear, _ = st.columns([1.5, 1.2, 3])
|
| 1550 |
+
with col_filter:
|
| 1551 |
+
filter_type = st.selectbox(
|
| 1552 |
+
"Filter",
|
| 1553 |
+
["all", "audio_srt", "translate"],
|
| 1554 |
+
format_func=lambda x: {
|
| 1555 |
+
"all": "All Jobs",
|
| 1556 |
+
"audio_srt": "🎙 Audio→SRT",
|
| 1557 |
+
"translate": "🌐 Translate",
|
| 1558 |
+
}[x],
|
| 1559 |
+
key="hist_filter",
|
| 1560 |
+
label_visibility="collapsed",
|
| 1561 |
+
)
|
| 1562 |
+
with col_clear:
|
| 1563 |
+
st.markdown('<div class="danger">', unsafe_allow_html=True)
|
| 1564 |
+
if st.button("🗑 Clear All", key="hist_clear_all", use_container_width=True):
|
| 1565 |
+
n = clear_all_jobs()
|
| 1566 |
+
st.success(f"{n} jobs deleted")
|
| 1567 |
+
st.rerun()
|
| 1568 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1569 |
+
|
| 1570 |
+
# ── Job list ───────────────────────────────────────────────────────
|
| 1571 |
+
jobs = list_jobs(job_type=None if filter_type == "all" else filter_type)
|
| 1572 |
+
|
| 1573 |
+
if not jobs:
|
| 1574 |
+
st.markdown(
|
| 1575 |
+
'<div class="mono" style="color:#374151;padding:2rem 0;text-align:center;">' +
|
| 1576 |
+
'Jobs မရှိသေးပါ</div>',
|
| 1577 |
+
unsafe_allow_html=True)
|
| 1578 |
+
return
|
| 1579 |
+
|
| 1580 |
+
st.markdown(
|
| 1581 |
+
f'<div class="mono" style="color:#4b5563;margin-bottom:1rem;">' +
|
| 1582 |
+
f'{len(jobs)} jobs</div>',
|
| 1583 |
+
unsafe_allow_html=True)
|
| 1584 |
+
|
| 1585 |
+
for job in jobs:
|
| 1586 |
+
job_id = job["id"]
|
| 1587 |
+
status = job.get("status","?")
|
| 1588 |
+
jtype = job.get("type","?")
|
| 1589 |
+
fname = job.get("filename","?")
|
| 1590 |
+
model = job.get("model","")
|
| 1591 |
+
lang = job.get("language","")
|
| 1592 |
+
age = job_age_str(job)
|
| 1593 |
+
sicon = status_icon(status)
|
| 1594 |
+
scolor = status_color(status)
|
| 1595 |
+
pct = int(job.get("progress",0)*100)
|
| 1596 |
+
msg = job.get("status_msg","")
|
| 1597 |
+
err = job.get("error","")
|
| 1598 |
+
|
| 1599 |
+
type_label = "🎙 Audio→SRT" if jtype == "audio_srt" else "🌐 Translate"
|
| 1600 |
+
|
| 1601 |
+
with st.container():
|
| 1602 |
+
st.markdown(f'<div class="job-card">', unsafe_allow_html=True)
|
| 1603 |
+
|
| 1604 |
+
# Title row
|
| 1605 |
+
col_info, col_actions = st.columns([4, 2])
|
| 1606 |
+
with col_info:
|
| 1607 |
+
_gloss_count = len(job.get("extra",{}).get("approved_glossary",{}))
|
| 1608 |
+
_gloss_badge = (
|
| 1609 |
+
f' · 📖 {_gloss_count} glossary terms'
|
| 1610 |
+
if _gloss_count > 0 else ""
|
| 1611 |
+
)
|
| 1612 |
+
st.markdown(
|
| 1613 |
+
f'<div class="job-title">{fname}</div>' +
|
| 1614 |
+
f'<div class="job-meta">{type_label} · {model} · {lang} · {age}{_gloss_badge}</div>' +
|
| 1615 |
+
f'<div class="job-status" style="color:{scolor};margin-top:0.3rem;">' +
|
| 1616 |
+
f'{sicon} {status.upper()} {pct}%</div>',
|
| 1617 |
+
unsafe_allow_html=True)
|
| 1618 |
+
if msg:
|
| 1619 |
+
st.markdown(
|
| 1620 |
+
f'<div class="mono" style="color:#4b5563;font-size:0.68rem;">{msg}</div>',
|
| 1621 |
+
unsafe_allow_html=True)
|
| 1622 |
+
if err and status == "failed":
|
| 1623 |
+
st.markdown(
|
| 1624 |
+
f'<div class="mono" style="color:#ef4444;font-size:0.68rem;">{err[:120]}</div>',
|
| 1625 |
+
unsafe_allow_html=True)
|
| 1626 |
+
|
| 1627 |
+
with col_actions:
|
| 1628 |
+
# Download if complete
|
| 1629 |
+
if status == "completed" and job.get("has_result"):
|
| 1630 |
+
result_bytes = load_result(job_id)
|
| 1631 |
+
if result_bytes:
|
| 1632 |
+
out_ext = ".srt"
|
| 1633 |
+
out_name = os.path.splitext(fname)[0] + out_ext
|
| 1634 |
+
st.download_button(
|
| 1635 |
+
label="⬇️ Download",
|
| 1636 |
+
data=result_bytes,
|
| 1637 |
+
file_name=out_name,
|
| 1638 |
+
mime="text/plain",
|
| 1639 |
+
key=f"dl_{job_id}",
|
| 1640 |
+
use_container_width=True,
|
| 1641 |
+
)
|
| 1642 |
+
|
| 1643 |
+
# Resume if paused (translate jobs)
|
| 1644 |
+
if status == "paused" and jtype == "translate":
|
| 1645 |
+
if st.button("▶ Resume", key=f"res_{job_id}",
|
| 1646 |
+
use_container_width=True):
|
| 1647 |
+
cp = job.get("checkpoint", {})
|
| 1648 |
+
|
| 1649 |
+
# ── Restore approved glossary (survives model/provider change)
|
| 1650 |
+
saved_glossary = load_glossary(job_id)
|
| 1651 |
+
if saved_glossary:
|
| 1652 |
+
st.session_state["glossary_edited"] = saved_glossary
|
| 1653 |
+
st.session_state["glossary_approved"] = True
|
| 1654 |
+
else:
|
| 1655 |
+
# Fallback: try to read from checkpoint
|
| 1656 |
+
cp_gloss = cp.get("glossary", {})
|
| 1657 |
+
if cp_gloss:
|
| 1658 |
+
st.session_state["glossary_edited"] = cp_gloss
|
| 1659 |
+
st.session_state["glossary_approved"] = True
|
| 1660 |
+
|
| 1661 |
+
# ── Restore checkpoint for translate_all resume
|
| 1662 |
+
st.session_state["tx_checkpoint"] = cp
|
| 1663 |
+
|
| 1664 |
+
# ── Set current job and navigate to step 5
|
| 1665 |
+
st.session_state["current_job_id"] = job_id
|
| 1666 |
+
st.session_state["active_page"] = "translate"
|
| 1667 |
+
st.session_state["wizard_step"] = 5
|
| 1668 |
+
st.rerun()
|
| 1669 |
+
|
| 1670 |
+
# Delete button
|
| 1671 |
+
st.markdown('<div class="danger">', unsafe_allow_html=True)
|
| 1672 |
+
if st.button("✕", key=f"del_{job_id}", use_container_width=True):
|
| 1673 |
+
delete_job(job_id)
|
| 1674 |
+
st.rerun()
|
| 1675 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1676 |
+
|
| 1677 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 1678 |
+
|
| 1679 |
+
|
| 1680 |
def _page_about() -> None:
|
| 1681 |
_render_header("About SubSync", "Myanmar Subtitle Translator · v1.0.0")
|
| 1682 |
st.markdown("""
|
|
|
|
| 1750 |
|
| 1751 |
if page == "translate":
|
| 1752 |
_page_translate()
|
| 1753 |
+
elif page == "audio_srt":
|
| 1754 |
+
_page_audio_srt()
|
| 1755 |
+
elif page == "history":
|
| 1756 |
+
_page_history()
|
| 1757 |
elif page == "settings":
|
| 1758 |
_page_settings()
|
| 1759 |
elif page == "about":
|
audio_transcriber.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audio_transcriber.py — Background Audio → SRT Transcription
|
| 3 |
+
|
| 4 |
+
run_transcription_bg(job_id, audio_bytes, filename, language)
|
| 5 |
+
→ Spawns a daemon thread that runs independently of the browser session.
|
| 6 |
+
→ Progress saved to job_store → UI polls job_store to show status.
|
| 7 |
+
→ Browser close / refresh does NOT stop transcription.
|
| 8 |
+
|
| 9 |
+
Model: large-v3 only (best accuracy for JA/KO/ZH/TH/HI/EN).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import logging
|
| 14 |
+
import tempfile
|
| 15 |
+
import threading
|
| 16 |
+
from typing import Callable
|
| 17 |
+
|
| 18 |
+
from job_store import update_job, save_result
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# ── Fixed model: large-v3 ─────────────────────────────────────────────────
|
| 23 |
+
MODEL = "large-v3"
|
| 24 |
+
MODEL_DIR = os.path.join(tempfile.gettempdir(), "whisper_models")
|
| 25 |
+
|
| 26 |
+
SUPPORTED_LANGUAGES = {
|
| 27 |
+
"auto": "Auto-detect",
|
| 28 |
+
"en": "English",
|
| 29 |
+
"ja": "Japanese (日本語)",
|
| 30 |
+
"ko": "Korean (한국어)",
|
| 31 |
+
"zh": "Chinese (中文)",
|
| 32 |
+
"th": "Thai (ภาษาไทย)",
|
| 33 |
+
"hi": "Hindi (हिन्दी)",
|
| 34 |
+
"fr": "French",
|
| 35 |
+
"de": "German",
|
| 36 |
+
"es": "Spanish",
|
| 37 |
+
"it": "Italian",
|
| 38 |
+
"pt": "Portuguese",
|
| 39 |
+
"ru": "Russian",
|
| 40 |
+
"ar": "Arabic",
|
| 41 |
+
"vi": "Vietnamese",
|
| 42 |
+
"id": "Indonesian",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 47 |
+
# Time formatting
|
| 48 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
def _fmt(seconds: float) -> str:
|
| 51 |
+
ms = int(round((seconds % 1) * 1000))
|
| 52 |
+
s = int(seconds)
|
| 53 |
+
h, m, s = s // 3600, (s % 3600) // 60, s % 60
|
| 54 |
+
return f"{h:02}:{m:02}:{s:02},{ms:03}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 58 |
+
# Segments → SRT string
|
| 59 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
def _segments_to_srt(segments: list) -> str:
|
| 62 |
+
lines = []
|
| 63 |
+
idx = 1
|
| 64 |
+
for seg in segments:
|
| 65 |
+
text = seg.text.strip()
|
| 66 |
+
if not text:
|
| 67 |
+
continue
|
| 68 |
+
lines += [str(idx), f"{_fmt(seg.start)} --> {_fmt(seg.end)}", text, ""]
|
| 69 |
+
idx += 1
|
| 70 |
+
return "\n".join(lines)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 74 |
+
# Core transcription (runs in background thread)
|
| 75 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 76 |
+
|
| 77 |
+
def _transcribe_worker(
|
| 78 |
+
job_id: str,
|
| 79 |
+
audio_bytes: bytes,
|
| 80 |
+
filename: str,
|
| 81 |
+
language: str,
|
| 82 |
+
) -> None:
|
| 83 |
+
"""
|
| 84 |
+
Background worker — runs in its own thread, independent of browser session.
|
| 85 |
+
Updates job_store so UI can poll progress even after refresh.
|
| 86 |
+
Thread is daemon=False so it finishes even if main thread exits
|
| 87 |
+
(as long as Docker container is alive).
|
| 88 |
+
"""
|
| 89 |
+
tmp_path = None
|
| 90 |
+
try:
|
| 91 |
+
from faster_whisper import WhisperModel
|
| 92 |
+
|
| 93 |
+
def cb(frac: float, msg: str) -> None:
|
| 94 |
+
update_job(job_id, progress=frac, status_msg=msg)
|
| 95 |
+
|
| 96 |
+
cb(0.0, "Model load လုပ်နေသည် (large-v3, ပထမဆုံးအကြိမ် ကြာနိုင်သည်)…")
|
| 97 |
+
|
| 98 |
+
# Write audio to temp file
|
| 99 |
+
suffix = os.path.splitext(filename)[1] or ".mp3"
|
| 100 |
+
fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
| 101 |
+
os.write(fd, audio_bytes)
|
| 102 |
+
os.close(fd)
|
| 103 |
+
|
| 104 |
+
# Load Whisper large-v3
|
| 105 |
+
model = WhisperModel(
|
| 106 |
+
MODEL,
|
| 107 |
+
device="cpu",
|
| 108 |
+
compute_type="int8", # fastest CPU quantization
|
| 109 |
+
download_root=MODEL_DIR,
|
| 110 |
+
)
|
| 111 |
+
cb(0.05, f"large-v3 load ပြီး — Transcribing…")
|
| 112 |
+
|
| 113 |
+
lang_code = None if language == "auto" else language
|
| 114 |
+
|
| 115 |
+
segments_gen, info = model.transcribe(
|
| 116 |
+
tmp_path,
|
| 117 |
+
language=lang_code,
|
| 118 |
+
beam_size=5,
|
| 119 |
+
temperature=0.0,
|
| 120 |
+
condition_on_previous_text=False, # reduce hallucination
|
| 121 |
+
vad_filter=True,
|
| 122 |
+
vad_parameters={
|
| 123 |
+
"min_silence_duration_ms": 500,
|
| 124 |
+
"threshold": 0.35,
|
| 125 |
+
},
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
detected = info.language
|
| 129 |
+
duration = max(info.duration or 1.0, 1.0)
|
| 130 |
+
cb(0.08, f"ဘာသာစကား: {detected} · {duration:.0f}s — Segments ထုတ်နေသည်…")
|
| 131 |
+
|
| 132 |
+
segments = []
|
| 133 |
+
for seg in segments_gen:
|
| 134 |
+
segments.append(seg)
|
| 135 |
+
pct = min(0.08 + (seg.end / duration) * 0.90, 0.98)
|
| 136 |
+
elapsed = f"{seg.end:.0f}s/{duration:.0f}s"
|
| 137 |
+
cb(pct, f"Transcribing… {elapsed} [{len(segments)} segments]")
|
| 138 |
+
|
| 139 |
+
if not segments:
|
| 140 |
+
update_job(job_id, status="failed",
|
| 141 |
+
error="Audio မှ speech မတွေ့ပါ",
|
| 142 |
+
status_msg="⚠ Speech မတွေ့ပါ")
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
cb(0.99, f"SRT ဖန်တီးနေသည်… ({len(segments)} segments)")
|
| 146 |
+
srt_text = _segments_to_srt(segments)
|
| 147 |
+
srt_bytes = srt_text.encode("utf-8")
|
| 148 |
+
|
| 149 |
+
save_result(job_id, srt_bytes)
|
| 150 |
+
# save_result already sets status=completed
|
| 151 |
+
|
| 152 |
+
except ImportError:
|
| 153 |
+
err = ("faster-whisper မထည့်ရသေးပါ — "
|
| 154 |
+
"requirements.txt မှာ faster-whisper>=1.0.0 ပါပြီး redeploy လုပ်ပါ")
|
| 155 |
+
update_job(job_id, status="failed", error=err, status_msg="❌ Import Error")
|
| 156 |
+
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
logger.exception("Transcription worker error")
|
| 159 |
+
update_job(job_id, status="failed",
|
| 160 |
+
error=str(exc)[:400],
|
| 161 |
+
status_msg=f"❌ Error: {exc}")
|
| 162 |
+
finally:
|
| 163 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 164 |
+
try: os.unlink(tmp_path)
|
| 165 |
+
except Exception: pass
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 169 |
+
# Public API — launch background thread
|
| 170 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
def run_transcription_bg(
|
| 173 |
+
job_id: str,
|
| 174 |
+
audio_bytes: bytes,
|
| 175 |
+
filename: str,
|
| 176 |
+
language: str = "auto",
|
| 177 |
+
) -> threading.Thread:
|
| 178 |
+
"""
|
| 179 |
+
Spawn a background thread to transcribe audio.
|
| 180 |
+
The thread runs independently of the Streamlit browser session.
|
| 181 |
+
Browser close / page refresh does NOT stop the transcription.
|
| 182 |
+
|
| 183 |
+
Returns the Thread object (caller can ignore it).
|
| 184 |
+
"""
|
| 185 |
+
update_job(job_id, status="running", progress=0.0,
|
| 186 |
+
status_msg="Background thread started…")
|
| 187 |
+
|
| 188 |
+
t = threading.Thread(
|
| 189 |
+
target=_transcribe_worker,
|
| 190 |
+
args=(job_id, audio_bytes, filename, language),
|
| 191 |
+
daemon=True, # daemon=True: thread exits with container (correct behavior)
|
| 192 |
+
name=f"whisper-{job_id}",
|
| 193 |
+
)
|
| 194 |
+
t.start()
|
| 195 |
+
logger.info("Transcription thread started: job=%s file=%s", job_id, filename)
|
| 196 |
+
return t
|
job_store.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
job_store.py — Thread-Safe Persistent Job Storage
|
| 3 |
+
|
| 4 |
+
Files stored in _jobs/ dir → survives browser refresh/close.
|
| 5 |
+
Cleared only on Docker container restart.
|
| 6 |
+
|
| 7 |
+
Thread safety: atomic write (temp file → rename) prevents corruption
|
| 8 |
+
when background threads update jobs concurrently.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
import tempfile
|
| 16 |
+
import threading
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
JOBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_jobs")
|
| 20 |
+
os.makedirs(JOBS_DIR, exist_ok=True)
|
| 21 |
+
|
| 22 |
+
# Per-job locks so concurrent updates to the same job don't corrupt
|
| 23 |
+
_locks: dict[str, threading.Lock] = {}
|
| 24 |
+
_locks_mutex = threading.Lock()
|
| 25 |
+
|
| 26 |
+
def _job_lock(job_id: str) -> threading.Lock:
|
| 27 |
+
with _locks_mutex:
|
| 28 |
+
if job_id not in _locks:
|
| 29 |
+
_locks[job_id] = threading.Lock()
|
| 30 |
+
return _locks[job_id]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 34 |
+
# Internal atomic write
|
| 35 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
def _save_job(job: dict) -> None:
|
| 38 |
+
"""Atomic write: write to temp → rename. Thread-safe."""
|
| 39 |
+
target = os.path.join(JOBS_DIR, f"{job['id']}.json")
|
| 40 |
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=JOBS_DIR, suffix=".tmp")
|
| 41 |
+
try:
|
| 42 |
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
| 43 |
+
json.dump(job, f, ensure_ascii=False, indent=2)
|
| 44 |
+
os.replace(tmp_path, target) # atomic on POSIX
|
| 45 |
+
except Exception:
|
| 46 |
+
try:
|
| 47 |
+
os.unlink(tmp_path)
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
raise
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 54 |
+
# Job creation
|
| 55 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
def create_job(
|
| 58 |
+
job_type: str,
|
| 59 |
+
filename: str,
|
| 60 |
+
language: str = "",
|
| 61 |
+
model: str = "",
|
| 62 |
+
provider: str = "",
|
| 63 |
+
extra: dict | None = None,
|
| 64 |
+
) -> str:
|
| 65 |
+
job_id = str(uuid.uuid4())[:12]
|
| 66 |
+
now = time.time()
|
| 67 |
+
job = {
|
| 68 |
+
"id": job_id,
|
| 69 |
+
"type": job_type, # "audio_srt" | "translate"
|
| 70 |
+
"filename": filename,
|
| 71 |
+
"language": language,
|
| 72 |
+
"model": model,
|
| 73 |
+
"provider": provider,
|
| 74 |
+
"status": "queued", # queued|running|paused|completed|failed
|
| 75 |
+
"progress": 0.0,
|
| 76 |
+
"status_msg": "",
|
| 77 |
+
"created_at": now,
|
| 78 |
+
"updated_at": now,
|
| 79 |
+
"error": None,
|
| 80 |
+
"checkpoint": {},
|
| 81 |
+
"extra": extra or {},
|
| 82 |
+
"has_result": False,
|
| 83 |
+
}
|
| 84 |
+
_save_job(job)
|
| 85 |
+
return job_id
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 89 |
+
# Job updates (thread-safe)
|
| 90 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
def update_job(
|
| 93 |
+
job_id: str,
|
| 94 |
+
status: str | None = None,
|
| 95 |
+
progress: float | None = None,
|
| 96 |
+
status_msg: str | None = None,
|
| 97 |
+
error: str | None = None,
|
| 98 |
+
checkpoint: dict | None = None,
|
| 99 |
+
extra_update: dict | None = None,
|
| 100 |
+
) -> None:
|
| 101 |
+
with _job_lock(job_id):
|
| 102 |
+
job = load_job(job_id)
|
| 103 |
+
if job is None:
|
| 104 |
+
return
|
| 105 |
+
if status is not None: job["status"] = status
|
| 106 |
+
if progress is not None: job["progress"] = round(float(progress), 4)
|
| 107 |
+
if status_msg is not None: job["status_msg"] = str(status_msg)[:300]
|
| 108 |
+
if error is not None: job["error"] = str(error)[:500]
|
| 109 |
+
if checkpoint is not None: job["checkpoint"] = checkpoint
|
| 110 |
+
if extra_update:
|
| 111 |
+
job.setdefault("extra", {}).update(extra_update)
|
| 112 |
+
job["updated_at"] = time.time()
|
| 113 |
+
_save_job(job)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def save_result(job_id: str, data: bytes) -> None:
|
| 117 |
+
result_path = os.path.join(JOBS_DIR, f"{job_id}.result")
|
| 118 |
+
# Atomic write for result too
|
| 119 |
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=JOBS_DIR, suffix=".tmp")
|
| 120 |
+
try:
|
| 121 |
+
os.write(tmp_fd, data)
|
| 122 |
+
os.close(tmp_fd)
|
| 123 |
+
os.replace(tmp_path, result_path)
|
| 124 |
+
except Exception:
|
| 125 |
+
try: os.close(tmp_fd)
|
| 126 |
+
except Exception: pass
|
| 127 |
+
try: os.unlink(tmp_path)
|
| 128 |
+
except Exception: pass
|
| 129 |
+
raise
|
| 130 |
+
|
| 131 |
+
with _job_lock(job_id):
|
| 132 |
+
job = load_job(job_id)
|
| 133 |
+
if job:
|
| 134 |
+
job["has_result"] = True
|
| 135 |
+
job["status"] = "completed"
|
| 136 |
+
job["progress"] = 1.0
|
| 137 |
+
job["status_msg"] = "ပြီးစီးသည် ✓"
|
| 138 |
+
job["updated_at"] = time.time()
|
| 139 |
+
_save_job(job)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def load_result(job_id: str) -> bytes | None:
|
| 143 |
+
path = os.path.join(JOBS_DIR, f"{job_id}.result")
|
| 144 |
+
if not os.path.exists(path):
|
| 145 |
+
return None
|
| 146 |
+
with open(path, "rb") as f:
|
| 147 |
+
return f.read()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 151 |
+
# Reading
|
| 152 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
def load_job(job_id: str) -> dict | None:
|
| 155 |
+
path = os.path.join(JOBS_DIR, f"{job_id}.json")
|
| 156 |
+
if not os.path.exists(path):
|
| 157 |
+
return None
|
| 158 |
+
try:
|
| 159 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 160 |
+
return json.load(f)
|
| 161 |
+
except Exception:
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def list_jobs(job_type: str | None = None) -> list[dict]:
|
| 166 |
+
jobs = []
|
| 167 |
+
for fname in os.listdir(JOBS_DIR):
|
| 168 |
+
if not fname.endswith(".json"):
|
| 169 |
+
continue
|
| 170 |
+
job = load_job(fname[:-5])
|
| 171 |
+
if job is None:
|
| 172 |
+
continue
|
| 173 |
+
if job_type and job.get("type") != job_type:
|
| 174 |
+
continue
|
| 175 |
+
jobs.append(job)
|
| 176 |
+
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=True)
|
| 177 |
+
return jobs
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def delete_job(job_id: str) -> None:
|
| 181 |
+
for ext in (".json", ".result"):
|
| 182 |
+
path = os.path.join(JOBS_DIR, f"{job_id}{ext}")
|
| 183 |
+
if os.path.exists(path):
|
| 184 |
+
try: os.remove(path)
|
| 185 |
+
except Exception: pass
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def clear_all_jobs() -> int:
|
| 189 |
+
count = 0
|
| 190 |
+
for fname in os.listdir(JOBS_DIR):
|
| 191 |
+
if fname.endswith(".tmp"):
|
| 192 |
+
continue
|
| 193 |
+
try:
|
| 194 |
+
os.remove(os.path.join(JOBS_DIR, fname))
|
| 195 |
+
count += 1
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
return count
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 203 |
+
# Glossary persistence
|
| 204 |
+
# Glossary is saved independently of checkpoint so it survives
|
| 205 |
+
# model/provider changes. Resume always reuses the approved glossary.
|
| 206 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 207 |
+
|
| 208 |
+
def save_glossary(job_id: str, glossary: dict) -> None:
|
| 209 |
+
"""
|
| 210 |
+
Persist the approved glossary for a translation job.
|
| 211 |
+
Stored inside the job JSON under "approved_glossary".
|
| 212 |
+
Thread-safe (uses update_job which is already atomic).
|
| 213 |
+
"""
|
| 214 |
+
update_job(job_id, extra_update={"approved_glossary": glossary})
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def load_glossary(job_id: str) -> dict:
|
| 218 |
+
"""
|
| 219 |
+
Load the saved glossary for a job.
|
| 220 |
+
Returns empty dict if not found.
|
| 221 |
+
"""
|
| 222 |
+
job = load_job(job_id)
|
| 223 |
+
if job is None:
|
| 224 |
+
return {}
|
| 225 |
+
return job.get("extra", {}).get("approved_glossary", {})
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def get_latest_translate_job_with_glossary() -> dict | None:
|
| 229 |
+
"""
|
| 230 |
+
Return the most recent translate job that has a saved glossary.
|
| 231 |
+
Used to offer glossary reuse when starting a new translation.
|
| 232 |
+
"""
|
| 233 |
+
jobs = list_jobs(job_type="translate")
|
| 234 |
+
for job in jobs:
|
| 235 |
+
g = job.get("extra", {}).get("approved_glossary", {})
|
| 236 |
+
if g:
|
| 237 |
+
return job
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 241 |
+
# Display helpers
|
| 242 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 243 |
+
|
| 244 |
+
def job_age_str(job: dict) -> str:
|
| 245 |
+
delta = time.time() - job.get("updated_at", job.get("created_at", 0))
|
| 246 |
+
if delta < 60: return "just now"
|
| 247 |
+
if delta < 3600: return f"{int(delta/60)} min ago"
|
| 248 |
+
if delta < 86400: return f"{int(delta/3600)} hr ago"
|
| 249 |
+
return f"{int(delta/86400)} days ago"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def status_icon(status: str) -> str:
|
| 253 |
+
return {"queued":"⏳","running":"🔄","paused":"⏸","completed":"✅","failed":"❌"}.get(status,"❓")
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def status_color(status: str) -> str:
|
| 257 |
+
return {"queued":"#4b5563","running":"#3b82f6","paused":"#f59e0b",
|
| 258 |
+
"completed":"#22c55e","failed":"#ef4444"}.get(status,"#4b5563")
|
requirements.txt
CHANGED
|
@@ -1,6 +1,3 @@
|
|
| 1 |
-
# SubSync — requirements.txt
|
| 2 |
-
# HF Docker Space: secrets come via env vars, no python-dotenv needed.
|
| 3 |
-
|
| 4 |
streamlit>=1.35.0,<2.0.0
|
| 5 |
pysubs2>=1.7.0,<2.0.0
|
| 6 |
google-generativeai>=0.7.0,<1.0.0
|
|
@@ -9,3 +6,4 @@ requests>=2.31.0,<3.0.0
|
|
| 9 |
httpx>=0.27.0,<1.0.0
|
| 10 |
pandas>=2.2.0,<3.0.0
|
| 11 |
tenacity>=8.3.0,<9.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
streamlit>=1.35.0,<2.0.0
|
| 2 |
pysubs2>=1.7.0,<2.0.0
|
| 3 |
google-generativeai>=0.7.0,<1.0.0
|
|
|
|
| 6 |
httpx>=0.27.0,<1.0.0
|
| 7 |
pandas>=2.2.0,<3.0.0
|
| 8 |
tenacity>=8.3.0,<9.0.0
|
| 9 |
+
faster-whisper>=1.0.0,<2.0.0
|
translation.py
CHANGED
|
@@ -34,7 +34,9 @@ from api_handler import (
|
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
BASE_CHUNK_SIZE = 150 # default — may shrink on TokenLimitError
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
MAX_PARSE_RETRY = 2 # parse-only retries per chunk
|
| 39 |
|
| 40 |
|
|
@@ -347,21 +349,50 @@ def _translate_chunk_safe(
|
|
| 347 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 348 |
|
| 349 |
def extract_glossary(lines: list, *, provider, model, api_key) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
non_empty = [l for l in lines if l.strip()]
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
)
|
|
|
|
| 357 |
try:
|
| 358 |
raw = call_ai(prompt, provider=provider, model=model, api_key=api_key,
|
| 359 |
-
system_prompt=_SYS_GLOSSARY, timeout=
|
| 360 |
r = _parse_dict(raw)
|
| 361 |
if r:
|
| 362 |
return {str(k): str(v) for k, v in r.items()}
|
| 363 |
except Exception as exc:
|
| 364 |
-
logger.warning("Glossary failed: %s", exc)
|
| 365 |
return {}
|
| 366 |
|
| 367 |
|
|
@@ -506,3 +537,84 @@ def translate_all(
|
|
| 506 |
f"Length invariant broken: {len(result)} != {len(lines)}"
|
| 507 |
|
| 508 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
BASE_CHUNK_SIZE = 150 # default — may shrink on TokenLimitError
|
| 37 |
+
# Glossary uses full file — Gemini 1M context handles it
|
| 38 |
+
# For very large files (5000+ lines) we spread-sample to stay within limits
|
| 39 |
+
GLOSSARY_MAX_LINES = 3000 # hard cap: beyond this we spread-sample
|
| 40 |
MAX_PARSE_RETRY = 2 # parse-only retries per chunk
|
| 41 |
|
| 42 |
|
|
|
|
| 349 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 350 |
|
| 351 |
def extract_glossary(lines: list, *, provider, model, api_key) -> dict:
|
| 352 |
+
"""
|
| 353 |
+
Extract translation glossary from the FULL subtitle file.
|
| 354 |
+
Sends all non-empty lines so terms appearing late in the file
|
| 355 |
+
(episode-specific locations, late-introduced characters, slang) are captured.
|
| 356 |
+
|
| 357 |
+
For very large files (>GLOSSARY_MAX_LINES), we spread-sample:
|
| 358 |
+
- First 1/3 + Middle 1/3 + Last 1/3
|
| 359 |
+
This preserves coverage across the whole file while staying within token limits.
|
| 360 |
+
Gemini 1.5/2.0 handles 1M tokens so most files fit entirely.
|
| 361 |
+
"""
|
| 362 |
non_empty = [l for l in lines if l.strip()]
|
| 363 |
+
total = len(non_empty)
|
| 364 |
+
|
| 365 |
+
if total <= GLOSSARY_MAX_LINES:
|
| 366 |
+
# ── Full file ──────────────────────────────────────────────────
|
| 367 |
+
sample = non_empty
|
| 368 |
+
else:
|
| 369 |
+
# ── Spread sample: beginning + middle + end ────────────────────
|
| 370 |
+
third = GLOSSARY_MAX_LINES // 3
|
| 371 |
+
mid_s = (total // 2) - (third // 2)
|
| 372 |
+
sample = (
|
| 373 |
+
non_empty[:third] +
|
| 374 |
+
non_empty[mid_s: mid_s + third] +
|
| 375 |
+
non_empty[-third:]
|
| 376 |
+
)
|
| 377 |
+
logger.info(
|
| 378 |
+
"File has %d lines > %d limit — spread sampling %d lines",
|
| 379 |
+
total, GLOSSARY_MAX_LINES, len(sample)
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
numbered = "\n".join(f"{i+1}. {l}" for i, l in enumerate(sample))
|
| 383 |
+
prompt = (
|
| 384 |
+
f"Extract Myanmar translation glossary from these {len(sample)} subtitle lines "
|
| 385 |
+
f"(full file: {total} lines total):\n\n{numbered}"
|
| 386 |
)
|
| 387 |
+
|
| 388 |
try:
|
| 389 |
raw = call_ai(prompt, provider=provider, model=model, api_key=api_key,
|
| 390 |
+
system_prompt=_SYS_GLOSSARY, timeout=120)
|
| 391 |
r = _parse_dict(raw)
|
| 392 |
if r:
|
| 393 |
return {str(k): str(v) for k, v in r.items()}
|
| 394 |
except Exception as exc:
|
| 395 |
+
logger.warning("Glossary extraction failed: %s", exc)
|
| 396 |
return {}
|
| 397 |
|
| 398 |
|
|
|
|
| 537 |
f"Length invariant broken: {len(result)} != {len(lines)}"
|
| 538 |
|
| 539 |
return result
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 543 |
+
# Background translation thread
|
| 544 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 545 |
+
|
| 546 |
+
import threading as _threading
|
| 547 |
+
|
| 548 |
+
def run_translation_bg(
|
| 549 |
+
job_id: str,
|
| 550 |
+
lines: list,
|
| 551 |
+
glossary: dict,
|
| 552 |
+
*,
|
| 553 |
+
provider: str,
|
| 554 |
+
model: str,
|
| 555 |
+
api_key: str,
|
| 556 |
+
fmt: str,
|
| 557 |
+
ssafile, # pysubs2.SSAFile
|
| 558 |
+
filename: str,
|
| 559 |
+
) -> _threading.Thread:
|
| 560 |
+
"""
|
| 561 |
+
Spawn background thread for translation.
|
| 562 |
+
Survives browser disconnect — progress saved to job_store.
|
| 563 |
+
On limit-hit (TranslationPaused): saves checkpoint, sets status=paused.
|
| 564 |
+
User can resume from History page.
|
| 565 |
+
"""
|
| 566 |
+
from job_store import update_job, save_result, load_job
|
| 567 |
+
from subtitle_parser import replace_lines, write_subtitle, output_filename
|
| 568 |
+
|
| 569 |
+
def _worker():
|
| 570 |
+
debug_log = []
|
| 571 |
+
# Load existing checkpoint if resuming
|
| 572 |
+
job = load_job(job_id)
|
| 573 |
+
cp_init = job.get("checkpoint", {}) if job else {}
|
| 574 |
+
session_st = {"tx_checkpoint": cp_init} if cp_init else {}
|
| 575 |
+
|
| 576 |
+
def pcb(frac: float, msg: str) -> None:
|
| 577 |
+
update_job(job_id, progress=frac, status_msg=msg)
|
| 578 |
+
# Save checkpoint periodically
|
| 579 |
+
cp = session_st.get("tx_checkpoint", {})
|
| 580 |
+
if cp:
|
| 581 |
+
update_job(job_id, checkpoint=cp)
|
| 582 |
+
|
| 583 |
+
update_job(job_id, status="running", progress=0.0)
|
| 584 |
+
|
| 585 |
+
try:
|
| 586 |
+
translated = translate_all(
|
| 587 |
+
lines, glossary,
|
| 588 |
+
provider=provider, model=model, api_key=api_key,
|
| 589 |
+
progress_cb=pcb,
|
| 590 |
+
debug_log=debug_log,
|
| 591 |
+
session_state=session_st,
|
| 592 |
+
resume=bool(cp_init),
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
# Build output file
|
| 596 |
+
out_subs = replace_lines(ssafile, translated)
|
| 597 |
+
out_bytes = write_subtitle(out_subs, fmt)
|
| 598 |
+
save_result(job_id, out_bytes) # sets status=completed
|
| 599 |
+
|
| 600 |
+
except TranslationPaused as e:
|
| 601 |
+
cp = session_st.get("tx_checkpoint", {})
|
| 602 |
+
update_job(job_id,
|
| 603 |
+
status="paused",
|
| 604 |
+
checkpoint=cp,
|
| 605 |
+
status_msg=f"⏸ Limit hit — {str(e)[:200]}",
|
| 606 |
+
error=str(e)[:300])
|
| 607 |
+
|
| 608 |
+
except Exception as e:
|
| 609 |
+
update_job(job_id,
|
| 610 |
+
status="failed",
|
| 611 |
+
error=str(e)[:400],
|
| 612 |
+
status_msg=f"❌ {str(e)[:200]}")
|
| 613 |
+
|
| 614 |
+
t = _threading.Thread(
|
| 615 |
+
target=_worker,
|
| 616 |
+
daemon=True,
|
| 617 |
+
name=f"translate-{job_id}",
|
| 618 |
+
)
|
| 619 |
+
t.start()
|
| 620 |
+
return t
|