| """ |
| 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, |
| ) |
|
|
| |
| st.set_page_config( |
| page_title="SubSync ยท Myanmar Translator", |
| page_icon="๐ฌ", |
| layout="wide", |
| initial_sidebar_state="collapsed", |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| def _init_session() -> None: |
| defaults = { |
| "authenticated": False, |
| "current_user": None, |
| |
| "gemini_api_keys": [""], |
| "gemini_key_idx": 0, |
| "openrouter_api_key": "", |
| "selected_provider": "gemini", |
| "selected_model": None, |
| "available_models": [], |
| |
| "show_limit_dialog": False, |
| "limit_hit_info": None, |
| "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, |
| "audio_job_id": None, |
| "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": "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 |
|
|
|
|
| |
| |
| |
|
|
| 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"]. |
| """ |
| |
| 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) |
|
|
| |
| 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: |
| |
| |
| 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) |
|
|
| |
| 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: |
| |
| clean_workspace() |
| |
| clear_all_jobs() |
| |
| _clean_keys = [ |
| |
| "uploaded_file_name", "subtitle_raw", "subtitle_parsed", |
| "subtitle_format", "plain_lines", |
| |
| "glossary_raw", "glossary_edited", "last_error", |
| |
| "translation_result", "tx_checkpoint", |
| |
| "current_job_id", |
| |
| "audio_job_id", "audio_srt_result", "audio_srt_filename", |
| "audio_srt_to_translate", "audio_srt_orig_name", |
| |
| "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) |
|
|
|
|
| |
| |
| |
|
|
| 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)" |
|
|
|
|
| |
| |
| |
|
|
| 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_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 |
| |
| 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: |
| |
| file_bytes = uploaded.read() |
|
|
| |
| if st.session_state.get("uploaded_file_name") != uploaded.name: |
| st.session_state["uploaded_file_name"] = None |
|
|
| 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: |
| |
| |
| 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" {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) |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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"): |
| |
| 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 |
|
|
| |
| |
| |
| _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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| |
| |
| |
|
|
| @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> |
| ยท <span style="color:#374151;">{hit_masked}</span> |
| ยท <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) |
|
|
| |
| if "Gemini Key" in choice: |
| keys = st.session_state.get("gemini_api_keys", [""]) |
| cur_idx = st.session_state.get("gemini_key_idx", 0) |
|
|
| |
| 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) |
| |
| 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() |
|
|
| |
| 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 |
|
|
| |
| 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 แแแทแบแแซ") |
|
|
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| if st.session_state.get("show_limit_dialog"): |
| _show_limit_dialog() |
|
|
| |
| 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} ยท Origin: {culture_lbl} ยท ' |
| f'Nouns: {noun_lbl}' |
| f'</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| 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="๐", |
| ) |
|
|
| |
| 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) |
|
|
| |
| with st.expander("๐ แแฌแแฌแแผแแบ Preview (แแแ 10 แแผแฑแฌแแบแธ)", expanded=False): |
| try: |
| preview_text = result_bytes.decode("utf-8", errors="replace") |
| |
| 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 |
|
|
| |
| 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) |
| |
| _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) |
|
|
| |
| 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: |
| |
| _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 |
|
|
| |
| cp_data = st.session_state.get("tx_checkpoint", {}) |
|
|
| |
| 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"), |
| ) |
|
|
| |
| update_job(_jid, status="running", progress=0.01, |
| status_msg="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"), |
| ) |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| def _page_translate() -> None: |
| _render_header("Subtitle Translation", "Upload ยท Model ยท Glossary ยท Review ยท Translate") |
| step = st.session_state.get("wizard_step", 1) |
| _wizard_strip(step) |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| 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 |
|
|
| |
| 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="โ
") |
|
|
| |
| 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 |
|
|
| |
| 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="โ
") |
|
|
|
|
|
|
|
|
| |
| |
| |
|
|
| 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>' |
| ' ยท Browser แแญแแบ/refresh แแผแ
แบแแฑแแฒแท background แแฝแแบ แแแบแแฏแแบแแแบ' |
| '</div>', |
| unsafe_allow_html=True) |
|
|
| |
| 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) |
| |
| 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"] |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| if status == "completed" and job.get("has_result"): |
| result_bytes = load_result(job_id) |
| if result_bytes: |
| |
| 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, |
| ) |
|
|
| |
| if status == "paused" and jtype == "translate": |
| if st.button("โถ Resume", key=f"res_{job_id}", |
| use_container_width=True): |
| cp = job.get("checkpoint", {}) |
|
|
| |
| saved_glossary = load_glossary(job_id) |
| if saved_glossary: |
| st.session_state["glossary_edited"] = saved_glossary |
| st.session_state["glossary_approved"] = True |
| else: |
| |
| cp_gloss = cp.get("glossary", {}) |
| if cp_gloss: |
| st.session_state["glossary_edited"] = cp_gloss |
| st.session_state["glossary_approved"] = True |
|
|
| |
| st.session_state["tx_checkpoint"] = cp |
|
|
| |
| 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}") |
|
|
| |
| 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"] |
|
|
| |
| st.session_state["current_job_id"] = job_id |
| st.session_state["active_page"] = "translate" |
| st.session_state["wizard_step"] = 5 |
| st.rerun() |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|