Shahah / app.py
Kai72828's picture
Upload 11 files
e876913 verified
Raw
History Blame
92.6 kB
"""
app.py โ€” SubSync: Myanmar Subtitle Translator
Complete implementation โ€” Steps 1-6.
HF Space Secrets:
SUBTITLE_USER login username (default: admin)
SUBTITLE_PASS login password (default: subtitle2024)
GEMINI_API_KEY optional pre-fill
OPENROUTER_API_KEY optional pre-fill
"""
import io
import os
import base64
import traceback
import pandas as pd
import streamlit as st
import time
from auth import is_authenticated, render_login_page, logout
from job_store import (create_job, update_job, save_result, load_result,
load_job, list_jobs, delete_job, clear_all_jobs,
job_age_str, status_icon, status_color,
save_glossary, load_glossary,
get_latest_translate_job_with_glossary)
from audio_transcriber import (run_transcription_bg, SUPPORTED_LANGUAGES,
MODEL)
from translation import (extract_glossary, translate_all,
TranslationPaused, load_checkpoint,
clear_checkpoint, checkpoint_progress,
run_translation_bg,
build_system_prompt, PROMPT_PRESETS, SOURCE_CULTURES,
PROPER_NOUN_STYLES)
from api_handler import fetch_models, validate_api_key, call_ai
from subtitle_parser import (
parse_subtitle,
get_plain_lines,
replace_lines,
write_subtitle,
output_filename,
clean_workspace,
workspace_size_mb,
)
# โ”€โ”€ Page config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.set_page_config(
page_title="SubSync ยท Myanmar Translator",
page_icon="๐ŸŽฌ",
layout="wide",
initial_sidebar_state="collapsed",
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Global CSS
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _inject_css() -> None:
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap');
:root {
--bg: #0d0f14;
--card: #13161d;
--hover: #1a1e28;
--border: #1e2330;
--blit: #2a3040;
--gold: #e8c97e;
--gold2: #c9a84c;
--hi: #f5f0e8;
--mid: #9ca3af;
--lo: #4b5563;
--red: #ef4444;
--green: #22c55e;
--blue: #3b82f6;
}
html, body,
[data-testid="stAppViewContainer"],
[data-testid="stMain"] {
background: var(--bg) !important;
color: var(--hi) !important;
font-family: 'DM Sans', sans-serif !important;
}
[data-testid="stHeader"],
[data-testid="stToolbar"],
footer, #MainMenu { display: none !important; }
.pg-title {
font-family: 'DM Serif Display', serif;
font-size: 1.9rem; color: var(--hi);
letter-spacing: -0.02em; margin-bottom: 0.1rem;
}
.pg-sub {
font-family: 'DM Mono', monospace; font-size: 0.68rem;
color: var(--lo); letter-spacing: 0.16em;
text-transform: uppercase; margin-bottom: 2rem;
}
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 14px; padding: 1.5rem 1.8rem; margin-bottom: 1.2rem;
}
.card-title { font-weight:600; font-size:0.92rem; color:var(--hi); margin-bottom:0.5rem; }
/* Wizard strip */
.wiz { display:flex; gap:0.45rem; align-items:center; margin-bottom:2rem; flex-wrap:wrap; }
.pill {
font-family:'DM Mono',monospace; font-size:0.66rem;
padding:0.28rem 0.7rem; border-radius:100px;
border:1px solid var(--blit); color:var(--lo);
letter-spacing:0.07em; white-space:nowrap;
}
.pill.active { background:linear-gradient(135deg,var(--gold),var(--gold2));
color:var(--bg); border-color:transparent; font-weight:700; }
.pill.done { border-color:var(--green); color:var(--green); }
.arr { color:var(--blit); font-size:0.72rem; }
/* Buttons */
.stButton > button {
background: linear-gradient(135deg, var(--gold), var(--gold2)) !important;
color: var(--bg) !important; font-family:'DM Sans',sans-serif !important;
font-weight:600 !important; font-size:0.88rem !important;
border:none !important; border-radius:8px !important;
padding:0.55rem 1.3rem !important; letter-spacing:0.03em !important;
transition: opacity 0.18s !important;
}
.stButton > button:hover { opacity:0.82 !important; }
/* Danger button wrapper <div class="danger"> */
.danger .stButton > button {
background: transparent !important;
border: 1px solid var(--red) !important;
color: var(--red) !important;
}
/* Secondary / outline */
.secondary .stButton > button {
background: transparent !important;
border: 1px solid var(--blit) !important;
color: var(--mid) !important;
}
.secondary .stButton > button:hover {
border-color: var(--gold) !important;
color: var(--gold) !important;
opacity: 1 !important;
}
/* Inputs */
div[data-testid="stTextInput"] input,
div[data-testid="stSelectbox"] > div > div,
div[data-testid="stTextArea"] textarea {
background: var(--bg) !important; border:1px solid var(--blit) !important;
border-radius:8px !important; color:var(--hi) !important;
font-family:'DM Mono',monospace !important; font-size:0.88rem !important;
}
div[data-testid="stTextInput"] input:focus,
div[data-testid="stTextArea"] textarea:focus {
border-color: var(--gold) !important;
box-shadow: 0 0 0 3px rgba(232,201,126,0.1) !important;
}
/* File uploader */
[data-testid="stFileUploader"] {
background: var(--card) !important;
border: 1.5px dashed var(--blit) !important;
border-radius: 12px !important;
}
[data-testid="stFileUploader"]:hover {
border-color: var(--gold) !important;
}
/* Data editor */
[data-testid="stDataFrame"] { border-radius:10px; overflow:hidden; }
/* Progress */
.stProgress > div > div > div {
background: linear-gradient(90deg, var(--gold), var(--gold2)) !important;
}
/* Download button */
[data-testid="stDownloadButton"] > button {
background: linear-gradient(135deg, var(--green), #16a34a) !important;
color: #fff !important; font-weight:600 !important;
border:none !important; border-radius:8px !important;
font-size:0.95rem !important; padding:0.65rem 1.8rem !important;
}
hr { border-color: var(--border) !important; }
/* Info / warning / error boxes */
.stAlert { background: var(--card) !important; border-radius:10px !important; }
/* Metric */
[data-testid="stMetric"] { background:var(--card); border-radius:10px;
padding:0.8rem 1rem; border:1px solid var(--border); }
.mono { font-family:'DM Mono',monospace; font-size:0.82rem; color:var(--mid); }
.badge-ok { color:var(--green); font-family:'DM Mono',monospace; font-size:0.78rem; }
.badge-err { color:var(--red); font-family:'DM Mono',monospace; font-size:0.78rem; }
.model-count { font-family:'DM Mono',monospace; font-size:0.7rem; color:var(--lo); margin-top:0.2rem; }
</style>
""", unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Session state
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _init_session() -> None:
defaults = {
"authenticated": False,
"current_user": None,
# Multi-key: list of Gemini API keys; active index tracks which is current
"gemini_api_keys": [""], # list of keys; index 0 = Key 1
"gemini_key_idx": 0, # active key index
"openrouter_api_key": "",
"selected_provider": "gemini",
"selected_model": None,
"available_models": [],
# Limit-hit dialog state
"show_limit_dialog": False,
"limit_hit_info": None, # {reason, provider, key_masked, model}
"uploaded_file_name": None,
"subtitle_raw": None,
"subtitle_parsed": None,
"subtitle_format": None,
"plain_lines": None,
"glossary_raw": None,
"glossary_approved": False,
"glossary_edited": None,
"translation_result": None,
"translation_done": False,
"wizard_step": 1,
"active_page": "translate",
"current_job_id": None, # active job being processed
"audio_job_id": None, # current audio transcription job
"audio_srt_result": None,
"audio_srt_filename": None,
"audio_srt_to_translate": None,
"audio_srt_orig_name": None,
"tx_checkpoint": None,
"last_error": None,
# Prompt preset & source culture
"prompt_preset": "adult_18",
"source_culture": "western",
"custom_prompt": "",
"proper_noun_style": "english",
}
for k, v in defaults.items():
if k not in st.session_state:
st.session_state[k] = v
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Sidebar
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _render_header(title: str, sub: str = "") -> None:
"""
Renders a page header row:
LEFT โ€” page title + subtitle
RIGHT โ€” โš™๏ธ Settings | โ„น๏ธ About | ๐Ÿ—‘ Clean | ๐Ÿšช Out
All buttons are real st.button calls โ€” no HTML nav needed.
Active page is tracked via st.session_state["active_page"].
"""
# โ”€โ”€ CSS for the small action buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("""
<style>
/* Make header action buttons small & flat */
div.hdr-btn > div[data-testid="stHorizontalBlock"] button {
background: transparent !important;
border: 1px solid #2a3040 !important;
color: #6b7280 !important;
font-size: 0.75rem !important;
padding: 0.22rem 0.55rem !important;
border-radius: 6px !important;
font-weight: 500 !important;
min-height: 0 !important;
height: auto !important;
line-height: 1.4 !important;
white-space: nowrap !important;
}
div.hdr-btn > div[data-testid="stHorizontalBlock"] button:hover {
border-color: #e8c97e !important;
color: #e8c97e !important;
opacity: 1 !important;
}
div.hdr-btn-danger > div[data-testid="stHorizontalBlock"] button {
border-color: #374151 !important;
color: #4b5563 !important;
}
div.hdr-btn-danger > div[data-testid="stHorizontalBlock"] button:hover {
border-color: #ef4444 !important;
color: #ef4444 !important;
opacity: 1 !important;
}
</style>
""", unsafe_allow_html=True)
# โ”€โ”€ Layout: title col (wide) + buttons col (narrow) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
col_title, col_btns = st.columns([3, 2])
with col_title:
st.markdown(f'<div class="pg-title">{title}</div>', unsafe_allow_html=True)
if sub:
st.markdown(f'<div class="pg-sub">{sub}</div>', unsafe_allow_html=True)
with col_btns:
# Push buttons to the right by using nested columns
# Row 1: main nav buttons
st.markdown('<div class="hdr-btn">', unsafe_allow_html=True)
b1, b2, b3, b4, b5 = st.columns(5)
with b1:
if st.button("๐ŸŽฌ Translate", key="hb_home", use_container_width=True):
st.session_state["active_page"] = "translate"
st.rerun()
with b2:
if st.button("๐ŸŽ™ Audioโ†’SRT", key="hb_audio", use_container_width=True):
st.session_state["active_page"] = "audio_srt"
st.rerun()
with b3:
if st.button("๐Ÿ“‹ History", key="hb_history", use_container_width=True):
st.session_state["active_page"] = "history"
st.rerun()
with b4:
if st.button("โš™๏ธ Settings", key="hb_settings", use_container_width=True):
st.session_state["active_page"] = "settings"
st.rerun()
with b5:
if st.button("โ„น๏ธ About", key="hb_about", use_container_width=True):
st.session_state["active_page"] = "about"
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
# Row 2: clean + sign out
st.markdown('<div class="hdr-btn hdr-btn-danger">', unsafe_allow_html=True)
d1, d2 = st.columns(2)
with d1:
if st.button("๐Ÿ—‘ Clean", key="hb_clean", use_container_width=True):
try:
# 1. Delete subtitle temp files
clean_workspace()
# 2. Delete ALL job history + result files (_jobs/ dir)
clear_all_jobs()
# 3. Wipe every workflow-related session key
_clean_keys = [
# Subtitle workflow
"uploaded_file_name", "subtitle_raw", "subtitle_parsed",
"subtitle_format", "plain_lines",
# Glossary โ€” clear completely so no cross-job bleed
"glossary_raw", "glossary_edited", "last_error",
# Translation
"translation_result", "tx_checkpoint",
# Job tracking
"current_job_id",
# Audio SRT
"audio_job_id", "audio_srt_result", "audio_srt_filename",
"audio_srt_to_translate", "audio_srt_orig_name",
# Limit dialog
"limit_hit_info",
]
for k in _clean_keys:
st.session_state[k] = None
st.session_state["glossary_approved"] = False
st.session_state["translation_done"] = False
st.session_state["show_limit_dialog"] = False
st.session_state["wizard_step"] = 1
st.rerun()
except Exception as e:
st.error(str(e))
with d2:
if st.button("๐Ÿšช Out", key="hb_signout", use_container_width=True):
logout()
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('<hr style="border-color:#1e2330;margin:0.5rem 0 1.4rem;">', unsafe_allow_html=True)
def _wizard_strip(current: int) -> None:
steps = [(1,"Upload"),(2,"Model"),(3,"Glossary"),(4,"Review"),(5,"Translate")]
html = '<div class="wiz">'
for i, (n, label) in enumerate(steps):
cls = "done" if n < current else ("active" if n == current else "")
icon = "โœ“ " if n < current else ""
html += f'<span class="pill {cls}">{icon}{n}. {label}</span>'
if i < len(steps) - 1:
html += '<span class="arr">โ€บ</span>'
html += "</div>"
st.markdown(html, unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Helper โ€” active API key
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _active_key() -> str:
"""Return the currently-active API key string."""
p = st.session_state.get("selected_provider", "gemini")
if p == "gemini":
keys = st.session_state.get("gemini_api_keys", [""])
idx = st.session_state.get("gemini_key_idx", 0)
if not keys:
return ""
idx = max(0, min(idx, len(keys) - 1))
return keys[idx] or ""
return st.session_state.get("openrouter_api_key", "")
def _active_key_label() -> str:
"""Return display label for the active key (e.g. 'Gemini Key 2')."""
p = st.session_state.get("selected_provider", "gemini")
if p == "gemini":
idx = st.session_state.get("gemini_key_idx", 0)
return f"Gemini Key {idx + 1}"
return "OpenRouter"
def _mask_key(key: str) -> str:
"""Return masked key for display: AIza...1234"""
k = key.strip()
if len(k) > 8:
return k[:4] + "..." + k[-4:]
return "****" if k else "(empty)"
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# STEP 1 โ€” File Upload
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _step1_upload() -> None:
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown('<div class="card-title">๐Ÿ“ Step 1 โ€” Subtitle แ€–แ€ญแ€ฏแ€„แ€บ แ€แ€„แ€บแ€›แ€”แ€บ</div>',
unsafe_allow_html=True)
# โ”€โ”€ Auto-populate from Audioโ†’SRT page "Translate this SRT" button โ”€โ”€โ”€โ”€
auto_srt = st.session_state.get("audio_srt_to_translate")
auto_name = st.session_state.get("audio_srt_orig_name", "audio.srt")
if auto_srt and st.session_state.get("uploaded_file_name") != auto_name:
try:
subs, fmt = parse_subtitle(auto_srt, auto_name)
plain = get_plain_lines(subs)
st.session_state["uploaded_file_name"] = auto_name
st.session_state["subtitle_raw"] = auto_srt
st.session_state["subtitle_parsed"] = subs
st.session_state["subtitle_format"] = fmt
st.session_state["plain_lines"] = plain
# Clear audio_srt_to_translate so it doesn't re-trigger
st.session_state["audio_srt_to_translate"] = None
st.session_state["audio_srt_orig_name"] = None
st.info(f"Audioโ†’SRT แ€–แ€ญแ€ฏแ€„แ€บ '{auto_name}' แ€€แ€ญแ€ฏ auto-load แ€•แ€ผแ€ฎแ€ธ", icon="๐ŸŽ™")
except Exception as e:
st.error(f"Auto-load แ€™แ€กแ€ฑแ€ฌแ€„แ€บแ€™แ€ผแ€„แ€บ: {e}", icon="โŒ")
st.session_state["audio_srt_to_translate"] = None
uploaded = st.file_uploader(
"Subtitle แ€–แ€ญแ€ฏแ€„แ€บ แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ",
type=["srt", "vtt", "ass", "ssa"],
key="file_uploader",
label_visibility="collapsed",
help="SRT, VTT, ASS/SSA format แ€œแ€€แ€บแ€แ€ถแ€žแ€Šแ€บ",
)
if uploaded is not None:
# Read bytes once and cache โ€” avoid double-read on rerun
file_bytes = uploaded.read()
# Only re-parse if this is a new file
if st.session_state.get("uploaded_file_name") != uploaded.name:
st.session_state["uploaded_file_name"] = None # clear while parsing
parse_error = None
is_new_file = (st.session_state.get("uploaded_file_name") != uploaded.name)
if st.session_state.get("subtitle_parsed") is None or is_new_file:
if is_new_file:
# New file uploaded โ€” reset ALL glossary & job state
# Prevents old glossary bleeding into new translation
for _k in ["glossary_raw", "glossary_edited", "translation_result",
"tx_checkpoint", "current_job_id", "limit_hit_info"]:
st.session_state[_k] = None
st.session_state["glossary_approved"] = False
st.session_state["translation_done"] = False
st.session_state["wizard_step"] = 1
try:
with st.spinner("แ€–แ€ญแ€ฏแ€„แ€บ แ€–แ€แ€บแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ"):
subs, fmt = parse_subtitle(file_bytes, uploaded.name)
plain = get_plain_lines(subs)
st.session_state["uploaded_file_name"] = uploaded.name
st.session_state["subtitle_raw"] = file_bytes
st.session_state["subtitle_parsed"] = subs
st.session_state["subtitle_format"] = fmt
st.session_state["plain_lines"] = plain
except Exception as e:
parse_error = str(e)
st.session_state["last_error"] = traceback.format_exc()
if parse_error:
st.error(
f"**Subtitle แ€–แ€ญแ€ฏแ€„แ€บ แ€–แ€แ€บแ แ€™แ€›แ€•แ€ซ**\n\n{parse_error}",
icon="โŒ",
)
with st.expander("๐Ÿ› Full error details", expanded=False):
st.code(st.session_state.get("last_error",""), language="python")
else:
subs = st.session_state.get("subtitle_parsed")
fmt = st.session_state.get("subtitle_format", "srt")
plain = st.session_state.get("plain_lines") or []
if subs is not None and plain:
c1, c2, c3 = st.columns(3)
c1.metric("แ€–แ€ญแ€ฏแ€„แ€บ", uploaded.name)
c2.metric("Format", fmt.upper())
c3.metric("Lines", f"{len(plain):,}")
st.markdown("<br>", unsafe_allow_html=True)
with st.expander("๐Ÿ“‹ Preview (แ€•แ€‘แ€™ 8 แ€€แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธ)", expanded=True):
for i, ln in enumerate(plain[:8]):
display = ln if ln else "<em style=\'color:#374151\'>empty</em>"
st.markdown(
f'<div class="mono" style="padding:0.2rem 0;">' +
f'<span style="color:#4b5563;">{i+1:03d}</span>' +
f"&nbsp;&nbsp;{display}</div>",
unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
if st.button("Step 2 แ€žแ€ญแ€ฏแ€ท แ€†แ€€แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ โ†’", key="step1_next"):
st.session_state["wizard_step"] = 2
st.rerun()
else:
prev = st.session_state.get("uploaded_file_name")
if prev:
plain = st.session_state.get("plain_lines") or []
st.info(
f"แ€–แ€ญแ€ฏแ€„แ€บ '{prev}' ({len(plain):,} lines) แ€แ€„แ€บแ€‘แ€ฌแ€ธแ€•แ€ผแ€ฎแ€ธ โ€” แ€†แ€€แ€บแ€žแ€ฝแ€ฌแ€ธแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ",
icon="๐Ÿ“Ž",
)
if st.button("Step 2 แ€žแ€ญแ€ฏแ€ท แ€†แ€€แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ โ†’", key="step1_next_prev"):
st.session_state["wizard_step"] = 2
st.rerun()
else:
st.markdown(
'<div class="mono" style="color:#4b5563;margin-top:0.5rem;">' +
'SRT ยท VTT ยท ASS/SSA format แ€œแ€€แ€บแ€แ€ถแ€žแ€Šแ€บ</div>',
unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
def _step2_model() -> None:
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown('<div class="card-title">๐Ÿค– Step 2 โ€” AI Model & Translation Settings</div>',
unsafe_allow_html=True)
provider = st.session_state.get("selected_provider", "")
model = st.session_state.get("selected_model", "")
key = _active_key()
prov_lbl = "Google Gemini" if provider == "gemini" else "OpenRouter"
if key.strip() and model:
st.success(f"**{prov_lbl}** ยท `{model}`", icon="โœ…")
else:
st.warning("โš™๏ธ Settings page แ€แ€ฝแ€„แ€บ API key แ€”แ€พแ€„แ€ทแ€บ model แ€›แ€ฝแ€ฑแ€ธแ€แ€ปแ€šแ€บแ€•แ€ซ", icon="โš ๏ธ")
if st.button("โš™๏ธ Settings แ€žแ€ญแ€ฏแ€ท โ†’", key="s2_go"):
st.session_state["active_page"] = "settings"
st.rerun()
st.markdown("<hr style='border-color:#1e2330;margin:0.8rem 0;'>", unsafe_allow_html=True)
# โ”€โ”€ Prompt Preset selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("#### ๐ŸŽญ Translation Style")
preset_keys = list(PROMPT_PRESETS.keys())
preset_labels = [PROMPT_PRESETS[k]["label"] for k in preset_keys]
cur_preset = st.session_state.get("prompt_preset", "adult_18")
if cur_preset not in preset_keys:
cur_preset = "adult_18"
sel_preset = st.selectbox(
"Preset แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ:",
preset_keys,
index=preset_keys.index(cur_preset),
format_func=lambda k: PROMPT_PRESETS[k]["label"],
key="s2_preset_sel",
)
st.caption(PROMPT_PRESETS[sel_preset]["desc"])
st.session_state["prompt_preset"] = sel_preset
# Custom prompt text area
if sel_preset == "custom":
custom_txt = st.text_area(
"System prompt แ€›แ€ฑแ€ธแ€‘แ€Šแ€ทแ€บแ€•แ€ซ:",
value=st.session_state.get("custom_prompt", ""),
height=200,
placeholder="You are a subtitle translator...\n\nOutput JSON array only.",
key="s2_custom_prompt",
)
st.session_state["custom_prompt"] = custom_txt
else:
# Preview toggle
with st.expander("Prompt preview แ€€แ€ผแ€Šแ€ทแ€บแ€›แ€”แ€บ"):
from translation import build_system_prompt as _bsp
st.code(_bsp(sel_preset, st.session_state.get("source_culture", "western")),
language="text")
st.markdown("<br>", unsafe_allow_html=True)
# โ”€โ”€ Source Culture selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("#### ๐ŸŒ Source Content Origin")
culture_keys = list(SOURCE_CULTURES.keys())
cur_culture = st.session_state.get("source_culture", "western")
if cur_culture not in culture_keys:
cur_culture = "western"
sel_culture = st.selectbox(
"แ€‡แ€ฌแ€แ€บแ€€แ€ฌแ€ธแ€˜แ€šแ€บแ€”แ€ญแ€ฏแ€„แ€บแ€„แ€ถแ€€แ€†แ€ญแ€ฏแ€แ€ฌ แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ:",
culture_keys,
index=culture_keys.index(cur_culture),
format_func=lambda k: f"{SOURCE_CULTURES[k]['label']} โ€” {SOURCE_CULTURES[k]['desc']}",
key="s2_culture_sel",
)
st.session_state["source_culture"] = sel_culture
st.markdown("<br>", unsafe_allow_html=True)
# โ”€โ”€ Proper Noun Style selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("#### ๐Ÿ”ค แ€œแ€ฐแ€”แ€ฌแ€™แ€Šแ€บ / แ€”แ€ฑแ€›แ€ฌแ€”แ€ฌแ€™แ€Šแ€บ แ€•แ€ผแ€•แ€ฏแ€ถ")
cur_noun_style = st.session_state.get("proper_noun_style", "english")
noun_style_keys = list(PROPER_NOUN_STYLES.keys())
sel_noun_style = st.radio(
"Proper noun แ€€แ€ญแ€ฏ แ€˜แ€šแ€บแ€œแ€ญแ€ฏแ€•แ€ผแ€™แ€Šแ€บ:",
noun_style_keys,
index=noun_style_keys.index(cur_noun_style) if cur_noun_style in noun_style_keys else 0,
format_func=lambda k: PROPER_NOUN_STYLES[k]["label"],
key="s2_noun_style",
horizontal=True,
)
st.caption(PROPER_NOUN_STYLES[sel_noun_style]["desc"])
st.session_state["proper_noun_style"] = sel_noun_style
st.markdown("<br>", unsafe_allow_html=True)
if key.strip() and model:
c1, c2 = st.columns([1, 1])
with c1:
if st.button("โœ“ Confirm & Continue โ†’", key="s2_confirm"):
st.session_state["wizard_step"] = 3
st.rerun()
with c2:
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("โš™ Settings แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€›แ€”แ€บ", key="s2_settings"):
st.session_state["active_page"] = "settings"
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("โ† Upload แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s2_back"):
st.session_state["wizard_step"] = 1
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# STEP 3 โ€” Glossary Extraction
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _step3_glossary() -> None:
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown('<div class="card-title">๐Ÿ“– Step 3 โ€” Glossary แ€‘แ€ฏแ€แ€บแ€šแ€ฐแ€›แ€”แ€บ</div>',
unsafe_allow_html=True)
lines = st.session_state.get("plain_lines") or []
if not lines:
st.error("Subtitle แ€–แ€ญแ€ฏแ€„แ€บ แ€™แ€แ€„แ€บแ€›แ€žแ€ฑแ€ธแ€•แ€ซ", icon="โŒ")
if st.button("โ† Step 1 แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s3_back_err"):
st.session_state["wizard_step"] = 1
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
return
existing_glossary = st.session_state.get("glossary_raw")
if existing_glossary is not None:
n = len(existing_glossary)
st.success(f"Glossary แ€‘แ€ฏแ€แ€บแ€•แ€ผแ€ฎแ€ธแ€žแ€ฌแ€ธ ({n} terms) โ€” Step 4 แ€แ€ฝแ€„แ€บ แ€…แ€…แ€บแ€†แ€ฑแ€ธแ€•แ€ซ", icon="โœ…")
c1, c2 = st.columns([1, 1])
with c1:
if st.button("Step 4 Review แ€žแ€ญแ€ฏแ€ท โ†’", key="s3_next_done"):
st.session_state["wizard_step"] = 4
st.rerun()
with c2:
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("๐Ÿ”„ แ€‘แ€•แ€บแ€‘แ€ฏแ€แ€บแ€™แ€Šแ€บ", key="s3_reextract"):
st.session_state["glossary_raw"] = None
st.session_state["glossary_approved"] = False
st.session_state["glossary_edited"] = None
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
else:
total_lines = len(lines)
sample_n = min(300, total_lines)
fname = st.session_state.get("uploaded_file_name", "file")
model = st.session_state.get("selected_model", "")
prov = st.session_state.get("selected_provider", "")
prov_lbl = "Google Gemini" if prov == "gemini" else "OpenRouter"
st.markdown(
f'<div class="mono">{fname} ยท {total_lines:,} lines ยท '
f'Sample {sample_n} lines แ€€แ€ญแ€ฏ {prov_lbl}/{model} แ€–แ€ผแ€„แ€ทแ€บ แ€…แ€…แ€บแ€™แ€Šแ€บ</div>',
unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
if st.button("๐Ÿ” Glossary แ€‘แ€ฏแ€แ€บแ€šแ€ฐแ€™แ€Šแ€บ", key="s3_extract"):
key = _active_key()
if not key.strip() or not model:
st.error("Settings แ€แ€ฝแ€„แ€บ API key / model แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ", icon="โŒ")
else:
with st.spinner("AI แ€™แ€พ glossary แ€‘แ€ฏแ€แ€บแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ (15-30 sec)"):
try:
glossary = extract_glossary(
lines,
provider=prov,
model=model,
api_key=key,
culture_key=st.session_state.get("source_culture", "western"),
proper_noun_style=st.session_state.get("proper_noun_style", "english"),
)
st.session_state["glossary_raw"] = glossary
st.rerun()
except Exception as e:
st.error(f"Glossary extraction แ€™แ€กแ€ฑแ€ฌแ€„แ€บแ€™แ€ผแ€„แ€บ: {e}", icon="โŒ")
st.session_state["last_error"] = traceback.format_exc()
st.markdown("<br>", unsafe_allow_html=True)
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("โ† Step 2 แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s3_back"):
st.session_state["wizard_step"] = 2
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# STEP 4 โ€” Glossary Review & Edit
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _step4_review() -> None:
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown('<div class="card-title">โœ๏ธ Step 4 โ€” Glossary แ€…แ€…แ€บแ€†แ€ฑแ€ธ / แ€•แ€ผแ€„แ€บแ€†แ€„แ€บแ€›แ€”แ€บ</div>',
unsafe_allow_html=True)
raw_glossary = st.session_state.get("glossary_raw")
if raw_glossary is None:
st.warning("Glossary แ€™แ€›แ€พแ€ญแ€žแ€ฑแ€ธแ€•แ€ซ โ€” Step 3 แ€แ€ฝแ€„แ€บ แ€‘แ€ฏแ€แ€บแ€•แ€ซ", icon="โš ๏ธ")
if st.button("โ† Step 3 แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s4_back_err"):
st.session_state["wizard_step"] = 3
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
return
st.markdown(
'<div class="mono" style="margin-bottom:1rem;">'
'Term แ€™แ€ปแ€ฌแ€ธแ€€แ€ญแ€ฏ แ€แ€ญแ€ฏแ€€แ€บแ€›แ€ญแ€ฏแ€€แ€บ แ€•แ€ผแ€„แ€บแ€†แ€„แ€บแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บแ‹ '
'Row แ€‘แ€•แ€บแ€‘แ€Šแ€ทแ€บ แ€žแ€ญแ€ฏแ€ทแ€™แ€Ÿแ€ฏแ€แ€บ แ€–แ€ปแ€€แ€บแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บแ‹</div>',
unsafe_allow_html=True)
# Convert to DataFrame for editing
if raw_glossary:
df_init = pd.DataFrame(
list(raw_glossary.items()),
columns=["แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)", "แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ"],
)
else:
df_init = pd.DataFrame(columns=["แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)", "แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ"])
edited_df = st.data_editor(
df_init,
num_rows="dynamic",
use_container_width=True,
key="glossary_editor",
column_config={
"แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)": st.column_config.TextColumn(width="medium"),
"แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ": st.column_config.TextColumn(width="medium"),
},
)
st.markdown("<br>", unsafe_allow_html=True)
# Show count
valid_rows = edited_df.dropna(subset=["แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)"])
valid_rows = valid_rows[valid_rows["แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)"].str.strip() != ""]
st.markdown(
f'<div class="mono" style="margin-bottom:1rem;">'
f'{len(valid_rows)} terms ยท '
f'Approve แ€œแ€ฏแ€•แ€บแ€™แ€พแ€žแ€ฌ translation แ€…แ€แ€„แ€บแ€”แ€ญแ€ฏแ€„แ€บแ€™แ€Šแ€บ</div>',
unsafe_allow_html=True)
c1, c2 = st.columns([1.5, 1])
with c1:
if st.button("โœ… Approve Glossary & Continue โ†’", key="s4_approve"):
# Convert edited df back to dict
approved = {}
for _, row in valid_rows.iterrows():
orig = str(row["แ€™แ€ฐแ€›แ€„แ€บแ€ธ (Original)"]).strip()
tran = str(row["แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ"]).strip() if pd.notna(row["แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ"]) else orig
if orig:
approved[orig] = tran
st.session_state["glossary_edited"] = approved
st.session_state["glossary_approved"] = True
st.session_state["wizard_step"] = 5
# โ”€โ”€ Persist glossary to job_store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Saved separately from checkpoint so it survives model/provider
# changes. Resume always reuses this approved glossary.
_jid = st.session_state.get("current_job_id")
if not _jid or not load_job(_jid):
fname_tmp = st.session_state.get("uploaded_file_name", "file")
_jid = create_job(
"translate", fname_tmp,
model=st.session_state.get("selected_model",""),
provider=st.session_state.get("selected_provider",""),
)
st.session_state["current_job_id"] = _jid
save_glossary(_jid, approved)
st.rerun()
with c2:
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("โ† Glossary แ€‘แ€•แ€บแ€‘แ€ฏแ€แ€บแ€™แ€Šแ€บ", key="s4_back"):
st.session_state["wizard_step"] = 3
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# STEP 5 โ€” Translation & Download
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Limit-hit dialog (@st.dialog โ€” Streamlit โ‰ฅ 1.35)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@st.dialog("โธ API Limit แ€‘แ€ญแ€žแ€Šแ€บ โ€” Provider / Key / Model แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ")
def _show_limit_dialog() -> None:
"""
Popup modal shown when translation hits rate/quota limit.
User can:
A) Switch to another Gemini API key
B) Switch to OpenRouter
C) Change model only (same provider/key)
Then Resume continues from checkpoint with same glossary โ†’ consistent translation.
"""
info = st.session_state.get("limit_hit_info") or {}
reason = info.get("reason", "Rate/Quota limit")
hit_label = info.get("key_label", "?")
hit_masked = info.get("key_masked", "****")
hit_prov = info.get("provider", "gemini")
hit_model = info.get("model", "")
st.markdown(f"""
<div style="background:#1a0a0a;border:1px solid #7f1d1d;border-radius:10px;
padding:0.8rem 1rem;margin-bottom:1rem;">
<div style="font-family:'DM Mono',monospace;font-size:0.75rem;color:#fca5a5;">
โš  {reason}
</div>
<div style="font-family:'DM Mono',monospace;font-size:0.72rem;color:#6b7280;margin-top:0.3rem;">
Hit on: <span style="color:#f5f0e8;">{hit_label}</span>
&nbsp;ยท&nbsp; <span style="color:#374151;">{hit_masked}</span>
&nbsp;ยท&nbsp; <span style="color:#e8c97e;">{hit_model}</span>
</div>
</div>
""", unsafe_allow_html=True)
done_n, total_n = checkpoint_progress(
st.session_state,
len(st.session_state.get("plain_lines") or [])
)
st.markdown(
f'<div style="font-family:DM Mono,monospace;font-size:0.72rem;color:#22c55e;' +
f'margin-bottom:1rem;">โœ“ Checkpoint: {done_n}/{total_n} lines saved</div>',
unsafe_allow_html=True)
st.markdown("##### แ€˜แ€ฌแ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€œแ€ฒ แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ")
choice = st.radio(
"action", [
"๐Ÿ”‘ Gemini Key แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ",
"๐ŸŒ OpenRouter แ€žแ€ญแ€ฏแ€ท แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ",
"๐Ÿค– Model แ€žแ€ฌ แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ (same provider/key)",
],
key="limit_action",
label_visibility="collapsed",
)
st.markdown('<hr style="border-color:#1e2330;margin:0.8rem 0;">', unsafe_allow_html=True)
# โ”€โ”€ Option A: Switch Gemini key โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if "Gemini Key" in choice:
keys = st.session_state.get("gemini_api_keys", [""])
cur_idx = st.session_state.get("gemini_key_idx", 0)
# Show all keys with current status
st.markdown("**Available Gemini Keys:**")
for i, k in enumerate(keys):
is_cur = (i == cur_idx)
masked = _mask_key(k)
status = "๐Ÿ”ด (limit hit)" if is_cur else ("โœ“" if k.strip() else "empty")
col_r, col_k, col_use = st.columns([0.4, 3, 1])
with col_r:
st.markdown(
f'<div style="font-family:DM Mono,monospace;font-size:0.75rem;' +
f'color:#6b7280;padding-top:0.55rem;">Key {i+1}</div>',
unsafe_allow_html=True)
with col_k:
st.markdown(
f'<div style="font-family:DM Mono,monospace;font-size:0.75rem;' +
f'color:{"#fca5a5" if is_cur else "#9ca3af"};padding-top:0.55rem;">' +
f'{masked} {status}</div>',
unsafe_allow_html=True)
with col_use:
if not is_cur and k.strip():
if st.button(f"Use", key=f"dlg_use_{i}"):
st.session_state["gemini_key_idx"] = i
st.session_state["selected_provider"] = "gemini"
st.session_state["show_limit_dialog"] = False
st.rerun()
st.markdown("<br>", unsafe_allow_html=True)
# Add new key inline
new_key = st.text_input("New Gemini Key แ€‘แ€Šแ€ทแ€บแ€›แ€”แ€บ:", type="password",
placeholder="AIza...", key="dlg_new_key")
if st.button("๏ผ‹ Add & Use", key="dlg_add_key"):
if new_key.strip():
keys.append(new_key.strip())
new_idx = len(keys) - 1
st.session_state["gemini_api_keys"] = keys
st.session_state["gemini_key_idx"] = new_idx
st.session_state["selected_provider"] = "gemini"
st.session_state["show_limit_dialog"] = False
st.rerun()
# โ”€โ”€ Option B: Switch to OpenRouter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
elif "OpenRouter" in choice:
or_key = st.session_state.get("openrouter_api_key", "")
new_or = st.text_input("OpenRouter API Key:", value=or_key,
type="password", placeholder="sk-or-...",
key="dlg_or_key")
if new_or != or_key:
st.session_state["openrouter_api_key"] = new_or
# Fetch OR models
if st.button("๐Ÿ”„ Models แ€†แ€ฝแ€ฒแ€šแ€ฐ", key="dlg_or_fetch"):
if new_or.strip():
with st.spinner("Fetchingโ€ฆ"):
mods = fetch_models("openrouter", new_or.strip())
st.session_state["available_models"] = mods
st.session_state["selected_model"] = mods[0] if mods else None
avail = st.session_state.get("available_models") or [
"google/gemini-2.0-flash-exp:free",
"meta-llama/llama-3.3-70b-instruct:free",
"deepseek/deepseek-chat-v3-0324:free",
]
sel = st.selectbox("Model แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ:", avail, key="dlg_or_model")
st.session_state["selected_model"] = sel
if st.button("โœ“ Switch to OpenRouter", key="dlg_or_confirm"):
if new_or.strip():
st.session_state["openrouter_api_key"] = new_or.strip()
st.session_state["selected_provider"] = "openrouter"
st.session_state["selected_model"] = sel
st.session_state["show_limit_dialog"] = False
st.rerun()
else:
st.error("OpenRouter API key แ€‘แ€Šแ€ทแ€บแ€•แ€ซ")
# โ”€โ”€ Option C: Model only โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
elif "Model" in choice:
st.markdown(f"Provider/Key แ€™แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธ โ€” {_active_key_label()} / {_mask_key(_active_key())}")
cur_prov = st.session_state.get("selected_provider","gemini")
if st.button("๐Ÿ”„ Models แ€†แ€ฝแ€ฒแ€šแ€ฐ", key="dlg_fetch_models"):
k = _active_key()
if k.strip():
with st.spinner("Fetchingโ€ฆ"):
mods = fetch_models(cur_prov, k)
st.session_state["available_models"] = mods
avail = st.session_state.get("available_models") or (
["gemini-2.0-flash","gemini-1.5-pro","gemini-1.5-flash"]
if cur_prov=="gemini" else
["google/gemini-2.0-flash-exp:free","meta-llama/llama-3.3-70b-instruct:free"]
)
cur_m = st.session_state.get("selected_model") or avail[0]
if cur_m not in avail: cur_m = avail[0]
sel = st.selectbox("Model แ€›แ€ฝแ€ฑแ€ธแ€•แ€ซ:", avail,
index=avail.index(cur_m), key="dlg_model_sel")
if st.button("โœ“ Model แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€•แ€ผแ€ฎแ€ธ Resume", key="dlg_model_confirm"):
st.session_state["selected_model"] = sel
st.session_state["available_models"] = avail
st.session_state["show_limit_dialog"] = False
st.rerun()
# โ”€โ”€ Resume button (bottom) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown('<hr style="border-color:#1e2330;margin:0.8rem 0;">', unsafe_allow_html=True)
if st.button("โ–ถ Resume โ€” แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€†แ€€แ€บแ€™แ€Šแ€บ", key="dlg_resume",
use_container_width=True, type="primary"):
st.session_state["show_limit_dialog"] = False
st.rerun()
if st.button("โœ• Cancel", key="dlg_cancel"):
st.session_state["show_limit_dialog"] = False
st.rerun()
def _step5_translate() -> None:
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown('<div class="card-title">๐Ÿš€ Step 5 โ€” แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€แ€ผแ€„แ€บแ€ธ & แ€›แ€šแ€ฐแ€แ€ผแ€„แ€บแ€ธ</div>',
unsafe_allow_html=True)
# Guards
lines = st.session_state.get("plain_lines") or []
subs = st.session_state.get("subtitle_parsed")
fmt = st.session_state.get("subtitle_format", "srt")
fname = st.session_state.get("uploaded_file_name", "output.srt")
glossary = st.session_state.get("glossary_edited") or {}
model = st.session_state.get("selected_model", "")
prov = st.session_state.get("selected_provider", "gemini")
key = _active_key()
prov_lbl = "Google Gemini" if prov == "gemini" else "OpenRouter"
if not lines or subs is None:
st.error("Subtitle แ€–แ€ญแ€ฏแ€„แ€บ แ€™แ€แ€„แ€บแ€›แ€žแ€ฑแ€ธแ€•แ€ซ", icon="โŒ")
if st.button("โ† Step 1 แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s5_back_err1"):
st.session_state["wizard_step"] = 1
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
return
if not st.session_state.get("glossary_approved"):
st.warning("Glossary approve แ€™แ€œแ€ฏแ€•แ€บแ€›แ€žแ€ฑแ€ธแ€•แ€ซ", icon="โš ๏ธ")
if st.button("โ† Step 4 แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s5_back_err2"):
st.session_state["wizard_step"] = 4
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
return
# โ”€โ”€ Show limit dialog if triggered โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if st.session_state.get("show_limit_dialog"):
_show_limit_dialog()
# โ”€โ”€ Translation settings summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
cur_preset = st.session_state.get("prompt_preset", "adult_18")
cur_culture = st.session_state.get("source_culture", "western")
cur_noun = st.session_state.get("proper_noun_style", "english")
preset_lbl = PROMPT_PRESETS.get(cur_preset, {}).get("label", cur_preset)
culture_lbl = SOURCE_CULTURES.get(cur_culture, {}).get("label", cur_culture)
noun_lbl = PROPER_NOUN_STYLES.get(cur_noun, {}).get("label", cur_noun)
st.markdown(
f'<div class="mono" style="margin-bottom:0.5rem;">'
f'Style: {preset_lbl} &nbsp;ยท&nbsp; Origin: {culture_lbl} &nbsp;ยท&nbsp; '
f'Nouns: {noun_lbl}'
f'</div>',
unsafe_allow_html=True,
)
# โ”€โ”€ If already translated โ€” show download โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if st.session_state.get("translation_done") and \
st.session_state.get("translation_result") is not None:
result_bytes = st.session_state["translation_result"]
out_name = output_filename(fname)
st.success(
f"โœ… แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€™แ€พแ€ฏ แ€•แ€ผแ€ฎแ€ธแ€…แ€ฎแ€ธแ€žแ€Šแ€บ โ€” **{len(lines):,} lines** แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€•แ€ผแ€ฎแ€ธ",
icon="๐ŸŽ‰",
)
# Stats
c1, c2, c3 = st.columns(3)
c1.metric("Lines", f"{len(lines):,}")
c2.metric("Glossary Terms", len(glossary))
c3.metric("Output", out_name)
st.markdown("<br>", unsafe_allow_html=True)
st.download_button(
label=f"โฌ‡๏ธ {out_name} แ€›แ€šแ€ฐแ€™แ€Šแ€บ",
data=result_bytes,
file_name=out_name,
mime="text/plain",
use_container_width=True,
key="download_btn",
)
st.markdown("<br>", unsafe_allow_html=True)
# Preview first 10 translated lines
with st.expander("๐Ÿ“‹ แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ Preview (แ€•แ€‘แ€™ 10 แ€€แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธ)", expanded=False):
try:
preview_text = result_bytes.decode("utf-8", errors="replace")
# Show first ~50 lines of the raw file
raw_lines = preview_text.splitlines()[:50]
st.code("\n".join(raw_lines), language=None)
except Exception:
st.info("Preview แ€•แ€ผแ€”แ€ญแ€ฏแ€„แ€บแ€แ€ผแ€„แ€บแ€ธ แ€™แ€›แ€พแ€ญแ€•แ€ซ")
st.markdown("<br>", unsafe_allow_html=True)
st.markdown('<div class="danger">', unsafe_allow_html=True)
if st.button("๐Ÿ”„ แ€‘แ€•แ€บแ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ (แ€–แ€ปแ€€แ€บแ€•แ€ผแ€ฎแ€ธ แ€…แ€แ€„แ€บ)", key="s5_retranslate"):
st.session_state["translation_result"] = None
st.session_state["translation_done"] = False
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
return
# โ”€โ”€ Translation UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import math
from translation import _get_chunk_size
CHUNK_SIZE = _get_chunk_size(model)
total_chunks = math.ceil(len(lines) / CHUNK_SIZE)
st.markdown(f"""
<div class="card" style="margin-bottom:0.8rem;">
<table style="width:100%;border-collapse:collapse;
font-family:'DM Mono',monospace;font-size:0.78rem;">
<tr>
<td style="color:#4b5563;padding:0.3rem 0;width:130px;">แ€–แ€ญแ€ฏแ€„แ€บ</td>
<td style="color:#f5f0e8;">{fname}</td>
</tr>
<tr>
<td style="color:#4b5563;padding:0.3rem 0;">Lines</td>
<td style="color:#f5f0e8;">{len(lines):,}</td>
</tr>
<tr>
<td style="color:#4b5563;padding:0.3rem 0;">Chunks</td>
<td style="color:#f5f0e8;">{total_chunks} ร— {CHUNK_SIZE} lines</td>
</tr>
<tr>
<td style="color:#4b5563;padding:0.3rem 0;">Model</td>
<td style="color:#e8c97e;">{prov_lbl} / {model}</td>
</tr>
<tr>
<td style="color:#4b5563;padding:0.3rem 0;">Glossary</td>
<td style="color:#f5f0e8;">{len(glossary)} terms</td>
</tr>
</table>
</div>
""", unsafe_allow_html=True)
# โ”€โ”€ Poll background job if already running โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_jid = st.session_state.get("current_job_id")
if _jid:
_bgjob = load_job(_jid)
if _bgjob and _bgjob["status"] == "running":
_pct = _bgjob.get("progress", 0.0)
_msg = _bgjob.get("status_msg", "แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ")
st.markdown('<div class="card">', unsafe_allow_html=True)
st.markdown(
'<div class="card-title">๐Ÿ”„ RUNNING โ€” Background แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€”แ€ฑแ€žแ€Šแ€บ</div>',
unsafe_allow_html=True)
st.progress(_pct, text=_msg)
st.markdown(
f'<div class="mono" style="color:#4b5563;margin-top:0.4rem;">' +
f'Job: {_jid} ยท Browser แ€•แ€ญแ€แ€บแ€‘แ€ฌแ€ธแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ โ€” History page แ€™แ€พ แ€€แ€ผแ€Šแ€ทแ€บแ€•แ€ซ</div>',
unsafe_allow_html=True)
# Auto-refresh via HTML meta tag (no sleep โ€” does not block Streamlit)
st.markdown(
'<meta http-equiv="refresh" content="4">',
unsafe_allow_html=True)
col_ref, _ = st.columns([1, 3])
with col_ref:
if st.button("๐Ÿ”„ Status แ€…แ€…แ€บแ€†แ€ฑแ€ธแ€™แ€Šแ€บ", key="s5_poll_refresh"):
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
return
if _bgjob and _bgjob["status"] == "completed":
result_bytes = load_result(_jid)
if result_bytes and not st.session_state.get("translation_done"):
st.session_state["translation_result"] = result_bytes
st.session_state["translation_done"] = True
st.rerun()
if _bgjob and _bgjob["status"] == "failed":
st.error(
f"โŒ แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€™แ€พแ€ฏ แ€™แ€กแ€ฑแ€ฌแ€„แ€บแ€™แ€ผแ€„แ€บแ€•แ€ซ\n{_bgjob.get('error','Unknown error')}",
icon="โŒ")
st.markdown('<div class="secondary">', unsafe_allow_html=True)
if st.button("๐Ÿ”„ แ€‘แ€•แ€บแ€€แ€ผแ€ญแ€ฏแ€ธแ€…แ€ฌแ€ธแ€™แ€Šแ€บ", key="s5_retry_failed"):
st.session_state["current_job_id"] = None
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
if _bgjob and _bgjob["status"] == "paused":
cp = _bgjob.get("checkpoint", {})
done_n2 = len(cp.get("lines", {}))
st.warning(
f"โธ Limit hit ยท {done_n2}/{len(lines)} lines saved\n"
f"{_bgjob.get('status_msg','')[:200]}",
icon="โธ")
if st.button("๐Ÿ”” Key/Provider แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€™แ€Šแ€บ (Dialog)", key="s5_open_dialog"):
st.session_state["limit_hit_info"] = {
"reason": _bgjob.get("status_msg",""),
"key_label": _active_key_label(),
"key_masked": _mask_key(_active_key()),
"provider": prov, "model": model,
}
st.session_state["show_limit_dialog"] = True
st.session_state["tx_checkpoint"] = cp
st.rerun()
if st.button("โ–ถ แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€แ€ผแ€„แ€บแ€ธ แ€…แ€แ€„แ€บแ€™แ€Šแ€บ (Background)", key="s5_start",
use_container_width=True):
if not key.strip() or not model:
st.error("API key / model แ€™แ€›แ€พแ€ญแ€•แ€ซ โ€” Settings แ€…แ€…แ€บแ€†แ€ฑแ€ธแ€•แ€ซ", icon="โŒ")
else:
# Create job โ€” save subtitle data so History resume can restore it
_jid = st.session_state.get("current_job_id")
if not _jid or not load_job(_jid):
raw_bytes = st.session_state.get("subtitle_raw", b"") or b""
_jid = create_job("translate", fname, language="my",
model=model, provider=prov,
extra={
"subtitle_b64": base64.b64encode(raw_bytes).decode(),
"subtitle_format": fmt,
"subtitle_lines": lines,
"prompt_preset": st.session_state.get("prompt_preset", "adult_18"),
"source_culture": st.session_state.get("source_culture", "western"),
"custom_prompt": st.session_state.get("custom_prompt", ""),
"proper_noun_style": st.session_state.get("proper_noun_style", "english"),
})
st.session_state["current_job_id"] = _jid
# Check for existing checkpoint (resume)
cp_data = st.session_state.get("tx_checkpoint", {})
# Build system prompt from selected preset + culture
sys_prompt = build_system_prompt(
preset_key=st.session_state.get("prompt_preset", "adult_18"),
culture_key=st.session_state.get("source_culture", "western"),
custom_text=st.session_state.get("custom_prompt", ""),
proper_noun_style=st.session_state.get("proper_noun_style", "english"),
)
# Mark as running immediately
update_job(_jid, status="running", progress=0.01,
status_msg="Thread แ€…แ€แ€„แ€บแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ")
# Launch background thread
run_translation_bg(
_jid, lines, glossary,
provider=prov, model=model, api_key=key,
fmt=fmt, ssafile=subs, filename=fname,
system_prompt=sys_prompt,
preset_key=st.session_state.get("prompt_preset", "adult_18"),
)
# Stash checkpoint into job so thread can resume
if cp_data:
update_job(_jid, checkpoint=cp_data)
st.info(
"๐Ÿ”„ Background แ€แ€ฝแ€„แ€บ แ€…แ€แ€„แ€บแ€•แ€ผแ€ฎ โ€” History page แ€แ€ฝแ€„แ€บ progress แ€€แ€ผแ€Šแ€ทแ€บแ€•แ€ซ",
icon="๐Ÿ”„")
st.rerun()
if st.button("โ† Glossary Review แ€•แ€ผแ€”แ€บแ€žแ€ฝแ€ฌแ€ธแ€™แ€Šแ€บ", key="s5_back"):
st.session_state["wizard_step"] = 4
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Translate page โ€” wizard dispatcher
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _page_translate() -> None:
_render_header("Subtitle Translation", "Upload ยท Model ยท Glossary ยท Review ยท Translate")
step = st.session_state.get("wizard_step", 1)
_wizard_strip(step)
# Last error display (collapsible)
if st.session_state.get("last_error"):
with st.expander("๐Ÿ› Last error (debug)", expanded=False):
st.code(st.session_state["last_error"], language="python")
if step == 1:
_step1_upload()
elif step == 2:
_step2_model()
elif step == 3:
_step3_glossary()
elif step == 4:
_step4_review()
elif step == 5:
_step5_translate()
else:
st.error(f"Unknown step: {step}")
st.session_state["wizard_step"] = 1
st.rerun()
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Settings page
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _page_settings() -> None:
_render_header("Settings", "API Keys ยท Provider ยท Model Selection")
st.markdown("""
<style>
.key-row { display:flex; align-items:center; gap:0.5rem; margin-bottom:0.4rem; }
.key-label { font-family:'DM Mono',monospace; font-size:0.72rem;
color:#6b7280; min-width:70px; }
.key-active { color:#e8c97e !important; }
.model-count { font-family:'DM Mono',monospace;font-size:0.7rem;
color:#4b5563;margin-top:0.2rem; }
</style>
""", unsafe_allow_html=True)
# โ”€โ”€ Auto-prefill from HF Secrets (once) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
env_g = os.environ.get("GEMINI_API_KEY", "")
env_or = os.environ.get("OPENROUTER_API_KEY", "")
keys = st.session_state.get("gemini_api_keys", [""])
if env_g and keys == [""]:
st.session_state["gemini_api_keys"] = [env_g]
if env_or and not st.session_state.get("openrouter_api_key"):
st.session_state["openrouter_api_key"] = env_or
# โ”€โ”€ Provider selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("#### Provider")
c1, c2, _ = st.columns([1.2, 1.2, 3])
with c1:
if st.button("๐Ÿ”ต Google Gemini", key="prov_g", use_container_width=True):
st.session_state["selected_provider"] = "gemini"
st.session_state["selected_model"] = None
st.session_state["available_models"] = []
with c2:
if st.button("โšซ OpenRouter", key="prov_or", use_container_width=True):
st.session_state["selected_provider"] = "openrouter"
st.session_state["selected_model"] = None
st.session_state["available_models"] = []
provider = st.session_state["selected_provider"]
prov_lbl = "Google Gemini" if provider == "gemini" else "OpenRouter"
st.markdown(
f'<div style="font-family:DM Mono,monospace;font-size:0.72rem;'
f'color:#e8c97e;margin:0.4rem 0 1rem;">โ— Active: {prov_lbl}</div>',
unsafe_allow_html=True)
st.markdown('<hr style="border-color:#1e2330;margin:0 0 1rem;">', unsafe_allow_html=True)
# โ”€โ”€ Gemini multi-key management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if provider == "gemini":
st.markdown("#### Gemini API Keys")
st.caption("aistudio.google.com/apikey โ€” key แ€™แ€ปแ€ฌแ€ธ แ€‘แ€•แ€บแ€‘แ€Šแ€ทแ€บ แ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ")
keys = list(st.session_state.get("gemini_api_keys", [""]))
active_idx = st.session_state.get("gemini_key_idx", 0)
active_idx = max(0, min(active_idx, len(keys)-1))
keys_changed = False
for i, key_val in enumerate(keys):
is_active = (i == active_idx)
label_cls = "key-active" if is_active else ""
col_lbl, col_input, col_act, col_del = st.columns([1, 4, 1.2, 0.7])
with col_lbl:
badge = "โ—" if is_active else "โ—‹"
st.markdown(
f'<div class="key-label {label_cls}" style="padding-top:0.55rem;">' +
f'{badge} Key {i+1}</div>',
unsafe_allow_html=True)
with col_input:
new_val = st.text_input(
f"key_{i}", value=key_val, type="password",
placeholder="AIza...",
key=f"gkey_{i}", label_visibility="collapsed")
if new_val != key_val:
keys[i] = new_val
keys_changed = True
with col_act:
if not is_active:
if st.button("Use", key=f"gkey_use_{i}", use_container_width=True):
st.session_state["gemini_key_idx"] = i
st.session_state["available_models"] = []
st.session_state["selected_model"] = None
st.rerun()
else:
st.markdown(
'<div style="font-family:DM Mono,monospace;font-size:0.68rem;' +
'color:#e8c97e;padding-top:0.55rem;">Active</div>',
unsafe_allow_html=True)
with col_del:
if len(keys) > 1:
if st.button("โœ•", key=f"gkey_del_{i}", use_container_width=True):
keys.pop(i)
if active_idx >= len(keys):
st.session_state["gemini_key_idx"] = len(keys) - 1
st.session_state["gemini_api_keys"] = keys
st.rerun()
if keys_changed:
st.session_state["gemini_api_keys"] = keys
if st.button("๏ผ‹ Gemini Key แ€‘แ€•แ€บแ€‘แ€Šแ€ทแ€บแ€›แ€”แ€บ", key="gkey_add"):
keys.append("")
st.session_state["gemini_api_keys"] = keys
st.rerun()
cur_key = keys[active_idx] if keys else ""
else:
# OpenRouter single key
st.markdown("#### OpenRouter API Key")
st.caption("openrouter.ai/keys")
ki = st.text_input("OR key", value=st.session_state.get("openrouter_api_key",""),
type="password", placeholder="sk-or-โ€ฆ",
key="or_key_in", label_visibility="collapsed")
st.session_state["openrouter_api_key"] = ki
cur_key = ki.strip()
active_idx = 0
# โ”€โ”€ Validate + Fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("<br>", unsafe_allow_html=True)
b1, b2, _ = st.columns([1.4, 1.6, 3])
with b1:
val_clicked = st.button("๐Ÿ”‘ Key แ€…แ€…แ€บแ€†แ€ฑแ€ธ", key="btn_val", use_container_width=True)
with b2:
fetch_clicked = st.button("๐Ÿ”„ Models แ€†แ€ฝแ€ฒแ€šแ€ฐ", key="btn_fetch", use_container_width=True)
if val_clicked:
if not cur_key.strip():
st.warning("API key แ€‘แ€Šแ€ทแ€บแ€•แ€ซ", icon="โš ๏ธ")
else:
with st.spinner("Validatingโ€ฆ"):
ok, msg = validate_api_key(provider, cur_key)
(st.success if ok else st.error)(msg, icon="โœ…" if ok else "โŒ")
if fetch_clicked:
if not cur_key.strip():
st.warning("API key แ€‘แ€Šแ€ทแ€บแ€•แ€ซ", icon="โš ๏ธ")
else:
with st.spinner(f"{prov_lbl} models แ€†แ€ฝแ€ฒแ€šแ€ฐแ€”แ€ฑแ€žแ€Šแ€บโ€ฆ"):
models = fetch_models(provider, cur_key)
st.session_state["available_models"] = models
st.session_state["selected_model"] = models[0] if models else None
st.success(f"{len(models)} models", icon="โœ…")
# โ”€โ”€ Model selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown('<hr style="border-color:#1e2330;margin:1rem 0;">', unsafe_allow_html=True)
st.markdown("#### Model แ€›แ€ฝแ€ฑแ€ธแ€แ€ปแ€šแ€บแ€›แ€”แ€บ")
avail = st.session_state.get("available_models") or (
["gemini-2.0-flash","gemini-1.5-pro","gemini-1.5-flash","gemini-1.5-flash-8b"]
if provider == "gemini"
else ["google/gemini-2.0-flash-exp:free","meta-llama/llama-3.3-70b-instruct:free",
"deepseek/deepseek-chat-v3-0324:free","mistralai/mistral-7b-instruct:free"]
)
cur_model = st.session_state.get("selected_model") or avail[0]
if cur_model not in avail:
cur_model = avail[0]
sel = st.selectbox("Model", avail, index=avail.index(cur_model),
key="model_sel", label_visibility="collapsed")
st.session_state["selected_model"] = sel
# โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown('<hr style="border-color:#1e2330;margin:1rem 0;">', unsafe_allow_html=True)
masked = _mask_key(cur_key)
kstat = ('<span class="badge-ok">โœ“ Set</span>' if cur_key.strip()
else '<span class="badge-err">โœ— Not set</span>')
key_count_note = ""
if provider == "gemini":
n_keys = len([k for k in st.session_state.get("gemini_api_keys",[]) if k.strip()])
key_count_note = f" ({n_keys} keys total)"
st.markdown(f"""
<div class="card">
<div class="card-title">Current Configuration</div>
<table style="width:100%;border-collapse:collapse;
font-family:'DM Mono',monospace;font-size:0.78rem;">
<tr><td style="color:#4b5563;padding:0.28rem 0;width:110px;">Provider</td>
<td style="color:#f5f0e8;">{prov_lbl}{key_count_note}</td></tr>
<tr><td style="color:#4b5563;padding:0.28rem 0;">Active Key</td>
<td>{kstat} <span style="color:#374151;">{masked}</span>
<span style="color:#4b5563;"> ({_active_key_label()})</span></td></tr>
<tr><td style="color:#4b5563;padding:0.28rem 0;">Model</td>
<td style="color:#e8c97e;">{sel or "โ€”"}</td></tr>
</table>
</div>
""", unsafe_allow_html=True)
if cur_key.strip() and sel:
st.success("Settings แ€•แ€ผแ€Šแ€ทแ€บแ€…แ€ฏแ€ถแ€•แ€ผแ€ฎ โ€” Translate page แ€žแ€ญแ€ฏแ€ท แ€žแ€ฝแ€ฌแ€ธแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ", icon="โœ…")
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Audio โ†’ SRT page
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _page_audio_srt() -> None:
_render_header("Audio โ†’ SRT", "Audio / Video แ€–แ€ญแ€ฏแ€„แ€บแ€™แ€พ Subtitle แ€‘แ€ฏแ€แ€บแ€›แ€”แ€บ")
st.markdown("""
<style>
.info-chip {
display:inline-block; font-family:'DM Mono',monospace;
font-size:0.68rem; padding:0.12rem 0.5rem; border-radius:6px;
background:rgba(232,201,126,0.1); color:#e8c97e;
margin-right:0.3rem;
}
</style>
""", unsafe_allow_html=True)
st.markdown(
'<div class="mono" style="color:#374151;margin-bottom:1rem;">'
'๐Ÿง  Model: <span style="color:#e8c97e;">Whisper large-v3</span>'
' &nbsp;ยท&nbsp; Browser แ€•แ€ญแ€แ€บ/refresh แ€–แ€ผแ€…แ€บแ€•แ€ฑแ€™แ€ฒแ€ท background แ€แ€ฝแ€„แ€บ แ€†แ€€แ€บแ€œแ€ฏแ€•แ€บแ€žแ€Šแ€บ'
'</div>',
unsafe_allow_html=True)
# โ”€โ”€ Active job polling (running in background) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
active_jid = st.session_state.get("audio_job_id")
if active_jid:
job = load_job(active_jid)
if job and job["status"] == "running":
st.markdown(f'<div class="card">', unsafe_allow_html=True)
st.markdown(
f'<div class="card-title">๐Ÿ”„ Transcribing: {job["filename"]}</div>',
unsafe_allow_html=True)
pct = job.get("progress", 0.0)
msg = job.get("status_msg", "")
st.progress(pct, text=msg)
st.markdown(
f'<div class="mono" style="color:#4b5563;margin-top:0.5rem;">'
f'Job ID: {active_jid} ยท Browser แ€•แ€ญแ€แ€บแ€‘แ€ฌแ€ธแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ โ€” background แ€แ€ฝแ€„แ€บ แ€†แ€€แ€บแ€œแ€ฏแ€•แ€บแ€™แ€Šแ€บ</div>',
unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# Auto-refresh via HTML meta (no sleep โ€” non-blocking)
st.markdown('<meta http-equiv="refresh" content="5">',
unsafe_allow_html=True)
if st.button("๐Ÿ”„ Status แ€…แ€…แ€บแ€†แ€ฑแ€ธแ€™แ€Šแ€บ", key="audio_poll_refresh"):
st.rerun()
return
if job and job["status"] == "completed":
srt_bytes = load_result(active_jid)
if srt_bytes:
st.session_state["audio_srt_result"] = srt_bytes
st.session_state["audio_srt_filename"] = job["filename"]
# โ”€โ”€ Show result if available โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
srt_result = st.session_state.get("audio_srt_result")
srt_fname = st.session_state.get("audio_srt_filename", "audio")
if srt_result:
out_name = os.path.splitext(srt_fname)[0] + ".srt"
st.success(f"โœ… Transcription แ€•แ€ผแ€ฎแ€ธแ€žแ€Šแ€บ โ€” {out_name}", icon="๐ŸŽ‰")
st.download_button(
label=f"โฌ‡๏ธ {out_name} Download",
data=srt_result,
file_name=out_name,
mime="text/plain",
use_container_width=True,
key="audio_dl",
)
with st.expander("๐Ÿ“‹ SRT Preview (แ€•แ€‘แ€™ 30 แ€€แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธ)", expanded=False):
preview = srt_result.decode("utf-8", errors="replace")
preview_text = "\n".join(preview.splitlines()[:60])
st.code(preview_text, language=None)
st.markdown("<br>", unsafe_allow_html=True)
if st.button("๐ŸŒ แ€’แ€ฎ SRT แ€€แ€ญแ€ฏ แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€™แ€Šแ€บ", key="audio_to_translate"):
st.session_state["audio_srt_to_translate"] = srt_result
st.session_state["audio_srt_orig_name"] = out_name
st.session_state["active_page"] = "translate"
st.session_state["wizard_step"] = 1
st.rerun()
col_x, _ = st.columns([1, 3])
with col_x:
st.markdown('<div class="danger">', unsafe_allow_html=True)
if st.button("๐Ÿ—‘ Clear Result", key="audio_clear"):
st.session_state["audio_srt_result"] = None
st.session_state["audio_srt_filename"] = None
st.session_state["audio_job_id"] = None
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('<hr style="border-color:#1e2330;margin:1.5rem 0 1rem;">', unsafe_allow_html=True)
# โ”€โ”€ File upload + start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
st.markdown("#### ๐Ÿ“‚ Audio / Video แ€–แ€ญแ€ฏแ€„แ€บ แ€แ€„แ€บแ€›แ€”แ€บ")
uploaded = st.file_uploader(
"audio",
type=["mp3","mp4","wav","m4a","flac","ogg","webm","mkv","mka","aac","opus","wma"],
label_visibility="collapsed",
key="audio_uploader",
)
lang_options = list(SUPPORTED_LANGUAGES.keys())
lang_labels = list(SUPPORTED_LANGUAGES.values())
lang_idx = st.selectbox(
"แ€˜แ€ฌแ€žแ€ฌแ€…แ€€แ€ฌแ€ธ",
options=range(len(lang_options)),
format_func=lambda i: lang_labels[i],
key="audio_lang_sel",
)
selected_lang = lang_options[lang_idx]
if uploaded is not None:
file_bytes = uploaded.read()
st.markdown(
f'<div class="mono" style="color:#6b7280;margin:0.5rem 0 1rem;">'
f'{uploaded.name} ยท {len(file_bytes)/1024/1024:.1f} MB</div>',
unsafe_allow_html=True)
if st.button("โ–ถ Transcribe แ€™แ€Šแ€บ (Background)", key="audio_start",
use_container_width=True):
job_id = create_job(
job_type="audio_srt",
filename=uploaded.name,
language=selected_lang,
model=MODEL,
)
st.session_state["audio_job_id"] = job_id
st.session_state["audio_srt_result"] = None
st.session_state["audio_srt_filename"] = None
# Launch background thread โ€” survives browser disconnect
run_transcription_bg(job_id, file_bytes, uploaded.name, selected_lang)
st.info(
"๐Ÿ”„ Background แ€แ€ฝแ€„แ€บ แ€…แ€แ€„แ€บแ€•แ€ผแ€ฎ โ€” Browser แ€•แ€ญแ€แ€บแ€‘แ€ฌแ€ธแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ\\n"
"Progress แ€€แ€ญแ€ฏ History page แ€แ€ฝแ€„แ€บ แ€€แ€ผแ€Šแ€ทแ€บแ€›แ€พแ€ฏแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ",
icon="๐Ÿ”„")
st.rerun()
st.markdown(
'<div class="mono" style="color:#374151;font-size:0.68rem;margin-top:1.5rem;">'
'๐Ÿ’ก large-v3 model แ€•แ€‘แ€™แ€†แ€ฏแ€ถแ€ธแ€กแ€€แ€ผแ€ญแ€™แ€บ download ~1.5GB แ€€แ€ผแ€ฌแ€”แ€ญแ€ฏแ€„แ€บแ€žแ€Šแ€บ</div>',
unsafe_allow_html=True)
def _page_history() -> None:
_render_header("History", "Translation & Transcription Jobs")
st.markdown("""
<style>
.job-card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 1rem 1.2rem; margin-bottom: 0.8rem;
}
.job-card:hover { border-color: var(--blit); }
.job-title { font-weight:600; font-size:0.9rem; color:var(--hi); }
.job-meta { font-family:'DM Mono',monospace; font-size:0.68rem;
color:var(--lo); margin-top:0.2rem; }
.job-status { font-family:'DM Mono',monospace; font-size:0.72rem;
font-weight:600; }
</style>
""", unsafe_allow_html=True)
# โ”€โ”€ Controls row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
col_filter, col_clear, _ = st.columns([1.5, 1.2, 3])
with col_filter:
filter_type = st.selectbox(
"Filter",
["all", "audio_srt", "translate"],
format_func=lambda x: {
"all": "All Jobs",
"audio_srt": "๐ŸŽ™ Audioโ†’SRT",
"translate": "๐ŸŒ Translate",
}[x],
key="hist_filter",
label_visibility="collapsed",
)
with col_clear:
st.markdown('<div class="danger">', unsafe_allow_html=True)
if st.button("๐Ÿ—‘ Clear All", key="hist_clear_all", use_container_width=True):
n = clear_all_jobs()
st.success(f"{n} jobs deleted")
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
# โ”€โ”€ Job list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
jobs = list_jobs(job_type=None if filter_type == "all" else filter_type)
if not jobs:
st.markdown(
'<div class="mono" style="color:#374151;padding:2rem 0;text-align:center;">' +
'Jobs แ€™แ€›แ€พแ€ญแ€žแ€ฑแ€ธแ€•แ€ซ</div>',
unsafe_allow_html=True)
return
st.markdown(
f'<div class="mono" style="color:#4b5563;margin-bottom:1rem;">' +
f'{len(jobs)} jobs</div>',
unsafe_allow_html=True)
for job in jobs:
job_id = job["id"]
status = job.get("status","?")
jtype = job.get("type","?")
fname = job.get("filename","?")
model = job.get("model","")
lang = job.get("language","")
age = job_age_str(job)
sicon = status_icon(status)
scolor = status_color(status)
pct = int(job.get("progress",0)*100)
msg = job.get("status_msg","")
err = job.get("error","")
type_label = "๐ŸŽ™ Audioโ†’SRT" if jtype == "audio_srt" else "๐ŸŒ Translate"
with st.container():
st.markdown(f'<div class="job-card">', unsafe_allow_html=True)
# Title row
col_info, col_actions = st.columns([4, 2])
with col_info:
_gloss_count = len(job.get("extra",{}).get("approved_glossary",{}))
_gloss_badge = (
f' ยท ๐Ÿ“– {_gloss_count} glossary terms'
if _gloss_count > 0 else ""
)
st.markdown(
f'<div class="job-title">{fname}</div>' +
f'<div class="job-meta">{type_label} ยท {model} ยท {lang} ยท {age}{_gloss_badge}</div>' +
f'<div class="job-status" style="color:{scolor};margin-top:0.3rem;">' +
f'{sicon} {status.upper()} {pct}%</div>',
unsafe_allow_html=True)
if msg:
st.markdown(
f'<div class="mono" style="color:#4b5563;font-size:0.68rem;">{msg}</div>',
unsafe_allow_html=True)
if err and status == "failed":
st.markdown(
f'<div class="mono" style="color:#ef4444;font-size:0.68rem;">{err[:120]}</div>',
unsafe_allow_html=True)
with col_actions:
# Download if complete
if status == "completed" and job.get("has_result"):
result_bytes = load_result(job_id)
if result_bytes:
# Preserve original format (ass/srt/vtt)
out_name = output_filename(fname)
st.download_button(
label="โฌ‡๏ธ Download",
data=result_bytes,
file_name=out_name,
mime="text/plain",
key=f"dl_{job_id}",
use_container_width=True,
)
# Resume if paused (translate jobs)
if status == "paused" and jtype == "translate":
if st.button("โ–ถ Resume", key=f"res_{job_id}",
use_container_width=True):
cp = job.get("checkpoint", {})
# โ”€โ”€ Restore approved glossary (survives model/provider change)
saved_glossary = load_glossary(job_id)
if saved_glossary:
st.session_state["glossary_edited"] = saved_glossary
st.session_state["glossary_approved"] = True
else:
# Fallback: try to read from checkpoint
cp_gloss = cp.get("glossary", {})
if cp_gloss:
st.session_state["glossary_edited"] = cp_gloss
st.session_state["glossary_approved"] = True
# โ”€โ”€ Restore checkpoint for translate_all resume
st.session_state["tx_checkpoint"] = cp
# โ”€โ”€ Restore subtitle data (fixes "Subtitle แ€™แ€แ€„แ€บแ€›แ€žแ€ฑแ€ธแ€•แ€ซ" error)
job_extra = job.get("extra", {})
sub_b64 = job_extra.get("subtitle_b64", "")
sub_fmt = job_extra.get("subtitle_format", "srt")
sub_lines = job_extra.get("subtitle_lines", [])
sub_fname = job.get("filename", "output.srt")
if sub_b64 and sub_lines:
try:
raw = base64.b64decode(sub_b64)
subs_obj, _ = parse_subtitle(raw, sub_fname)
st.session_state["subtitle_raw"] = raw
st.session_state["subtitle_parsed"] = subs_obj
st.session_state["subtitle_format"] = sub_fmt
st.session_state["uploaded_file_name"] = sub_fname
st.session_state["plain_lines"] = sub_lines
except Exception as _e:
st.warning(f"Subtitle restore แ€™แ€›แ€•แ€ซ โ€” แ€–แ€ญแ€ฏแ€„แ€บแ€•แ€ผแ€”แ€บแ€แ€„แ€บแ€•แ€ซ: {_e}")
# โ”€โ”€ Restore prompt preset / culture
if job_extra.get("prompt_preset"):
st.session_state["prompt_preset"] = job_extra["prompt_preset"]
if job_extra.get("source_culture"):
st.session_state["source_culture"] = job_extra["source_culture"]
if job_extra.get("custom_prompt"):
st.session_state["custom_prompt"] = job_extra["custom_prompt"]
if job_extra.get("proper_noun_style"):
st.session_state["proper_noun_style"] = job_extra["proper_noun_style"]
# โ”€โ”€ Set current job and navigate to step 5
st.session_state["current_job_id"] = job_id
st.session_state["active_page"] = "translate"
st.session_state["wizard_step"] = 5
st.rerun()
# Delete button
st.markdown('<div class="danger">', unsafe_allow_html=True)
if st.button("โœ•", key=f"del_{job_id}", use_container_width=True):
delete_job(job_id)
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
def _page_about() -> None:
_render_header("About SubSync", "Myanmar Subtitle Translator ยท v1.0.0")
st.markdown("""
<div class="card">
<div class="card-title">SubSync แ€†แ€ญแ€ฏแ€แ€ฌแ€˜แ€ฌแ€œแ€ฒ</div>
<p style="color:#9ca3af;font-size:0.88rem;line-height:1.8;margin:0;">
SubSync แ€žแ€Šแ€บ <strong style="color:#f5f0e8;">.srt ยท .vtt ยท .ass</strong> subtitle
files แ€™แ€ปแ€ฌแ€ธแ€€แ€ญแ€ฏ แ€žแ€˜แ€ฌแ€แ€€แ€ปแ€žแ€ฑแ€ฌ <strong style="color:#e8c97e;">แ€™แ€ผแ€”แ€บแ€™แ€ฌแ€˜แ€ฌแ€žแ€ฌ</strong> แ€žแ€ญแ€ฏแ€ท
แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€•แ€ฑแ€ธแ€žแ€Šแ€ทแ€บ tool แ€–แ€ผแ€…แ€บแ€žแ€Šแ€บแ‹ AI + Human two-step glossary workflow แ€–แ€ผแ€„แ€ทแ€บ
แ€€แ€ปแ€ฝแ€™แ€บแ€ธแ€€แ€ปแ€„แ€บแ€žแ€ฑแ€ฌ แ€œแ€ฐแ€žแ€ฌแ€ธแ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บแ€”แ€พแ€„แ€ทแ€บ แ€กแ€”แ€ฎแ€ธแ€…แ€•แ€บแ€†แ€ฏแ€ถแ€ธ quality แ€›แ€›แ€พแ€ญแ€›แ€”แ€บ แ€’แ€ฎแ€‡แ€ญแ€ฏแ€„แ€บแ€ธแ€†แ€ฝแ€ฒแ€‘แ€ฌแ€ธแ€žแ€Šแ€บแ‹
</p>
</div>
<div class="card">
<div class="card-title">Workflow</div>
<ol style="color:#9ca3af;font-size:0.88rem;line-height:2.3;margin:0;padding-left:1.2rem;">
<li>Subtitle แ€–แ€ญแ€ฏแ€„แ€บ (.srt / .vtt / .ass) upload แ€แ€„แ€บแ€›แ€”แ€บ</li>
<li>AI Provider (Gemini / OpenRouter) แ€”แ€พแ€„แ€ทแ€บ Model แ€›แ€ฝแ€ฑแ€ธแ€แ€ปแ€šแ€บแ€›แ€”แ€บ</li>
<li>AI แ€€ Glossary (แ€‡แ€ฌแ€แ€บแ€€แ€ฑแ€ฌแ€„แ€บแ€กแ€™แ€Šแ€บแŠ แ€”แ€ฑแ€›แ€ฌแŠ jargon) auto-extract แ€‘แ€ฏแ€แ€บแ€™แ€Šแ€บ</li>
<li>Glossary แ€€แ€ญแ€ฏ แ€…แ€…แ€บแ€†แ€ฑแ€ธแŠ แ€•แ€ผแ€„แ€บแ€†แ€„แ€บแ€•แ€ผแ€ฎแ€ธ Approve แ€œแ€ฏแ€•แ€บแ€›แ€”แ€บ</li>
<li>500-line chunks แ€–แ€ผแ€„แ€ทแ€บ context-aware translation แ€œแ€ฏแ€•แ€บแ€™แ€Šแ€บ</li>
<li>แ€™แ€ฐแ€›แ€„แ€บแ€ธ format แ€•แ€ผแ€ฑแ€ฌแ€„แ€บแ€ธแ€œแ€ฒแ€™แ€พแ€ฏ แ€™แ€›แ€พแ€ญแ€˜แ€ฒ แ€˜แ€ฌแ€žแ€ฌแ€•แ€ผแ€”แ€บ แ€–แ€ญแ€ฏแ€„แ€บ download แ€›แ€šแ€ฐแ€›แ€”แ€บ</li>
</ol>
</div>
<div class="card">
<div class="card-title">Supported Providers</div>
<ul style="color:#9ca3af;font-size:0.88rem;line-height:2.3;margin:0;padding-left:1.2rem;">
<li><strong style="color:#f5f0e8;">Google Gemini</strong>
โ€” gemini-2.0-flash / 1.5-pro / 1.5-flash series</li>
<li><strong style="color:#f5f0e8;">OpenRouter</strong>
โ€” GPT-4o, Claude 3.5, Llama 3, DeepSeek แ€”แ€พแ€„แ€ทแ€บ 300+ models</li>
</ul>
</div>
<div class="card">
<div class="card-title">HF Space Secrets</div>
<table style="font-family:'DM Mono',monospace;font-size:0.78rem;
border-collapse:collapse;width:100%;">
<tr style="color:#4b5563;">
<td style="padding:0.3rem 1rem 0.3rem 0;">SUBTITLE_USER</td>
<td style="color:#9ca3af;">Login username</td>
</tr>
<tr style="color:#4b5563;">
<td style="padding:0.3rem 1rem 0.3rem 0;">SUBTITLE_PASS</td>
<td style="color:#9ca3af;">Login password</td>
</tr>
<tr style="color:#4b5563;">
<td style="padding:0.3rem 1rem 0.3rem 0;">GEMINI_API_KEY</td>
<td style="color:#9ca3af;">Gemini key (optional pre-fill)</td>
</tr>
<tr style="color:#4b5563;">
<td style="padding:0.3rem 1rem 0.3rem 0;">OPENROUTER_API_KEY</td>
<td style="color:#9ca3af;">OpenRouter key (optional pre-fill)</td>
</tr>
</table>
</div>
""", unsafe_allow_html=True)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Entry point
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def main() -> None:
_init_session()
_inject_css()
if not is_authenticated():
render_login_page()
return
page = st.session_state.get("active_page", "translate")
if page == "translate":
_page_translate()
elif page == "audio_srt":
_page_audio_srt()
elif page == "history":
_page_history()
elif page == "settings":
_page_settings()
elif page == "about":
_page_about()
if __name__ == "__main__":
main()