Spaces:
Running on Zero
Running on Zero
Upload 67 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env +75 -0
- app.py +169 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/api_pool.cpython-312.pyc +0 -0
- app/__pycache__/web_server.cpython-312.pyc +0 -0
- app/api_pool.py +684 -0
- app/core/__init__.py +0 -0
- app/core/__pycache__/__init__.cpython-312.pyc +0 -0
- app/core/__pycache__/cloud_asr.cpython-312.pyc +0 -0
- app/core/__pycache__/cloud_ocr.cpython-312.pyc +0 -0
- app/core/__pycache__/cloud_pipeline.cpython-312.pyc +0 -0
- app/core/__pycache__/cloud_tts.cpython-312.pyc +0 -0
- app/core/__pycache__/vietnamese_text_normalizer.cpython-312.pyc +0 -0
- app/core/asr_worker_cli.py +173 -0
- app/core/audio_language_detector.py +214 -0
- app/core/audio_mixer.py +206 -0
- app/core/audio_timeline_classifier.py +414 -0
- app/core/batch_reporter.py +91 -0
- app/core/cloud_asr.py +203 -0
- app/core/cloud_ocr.py +277 -0
- app/core/cloud_pipeline.py +288 -0
- app/core/cloud_tts.py +188 -0
- app/core/gpu_resource_manager.py +134 -0
- app/core/job_manager.py +241 -0
- app/core/ocr_worker_cli.py +760 -0
- app/core/pipeline_worker.py +0 -0
- app/core/preflight.py +219 -0
- app/core/process_manager.py +155 -0
- app/core/pronunciation.json +35 -0
- app/core/render_worker_cli.py +636 -0
- app/core/separator_worker_cli.py +255 -0
- app/core/studio_qa.py +208 -0
- app/core/studio_sfx.py +186 -0
- app/core/studio_tags.py +128 -0
- app/core/subtitle_display_compactor.py +461 -0
- app/core/translation_core.py +225 -0
- app/core/translation_post_editor.py +101 -0
- app/core/tts_mix_worker_cli.py +208 -0
- app/core/tts_worker_cli.py +1510 -0
- app/core/vietnamese_text_normalizer.py +227 -0
- app/main.py +0 -0
- app/ocr_engine.py +106 -0
- app/region_selector.py +424 -0
- app/static/app.js +282 -0
- app/static/index.html +212 -0
- app/static/style.css +617 -0
- app/translation/fallback.py +72 -0
- app/translation/manager.py +1458 -0
- app/translation/provider_health.py +82 -0
.env
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# .env — API Key Pool Configuration
|
| 3 |
+
# TrungSangViet Auto-Dubbing Tool
|
| 4 |
+
# ============================================================
|
| 5 |
+
# Hướng dẫn:
|
| 6 |
+
# - Thêm key theo format: TÊN_PROVIDER_KEY_N=value
|
| 7 |
+
# - N bắt đầu từ 1, không giới hạn số lượng key
|
| 8 |
+
# - Dòng bắt đầu bằng # là comment, bỏ qua
|
| 9 |
+
# - Không cần quotes cho giá trị
|
| 10 |
+
# - Ollama local không cần key, tự động thêm vào pool
|
| 11 |
+
# ============================================================
|
| 12 |
+
|
| 13 |
+
# ── Gemini (Google) ───────────────────────────────────────
|
| 14 |
+
# Lấy miễn phí tại: https://aistudio.google.com/apikey
|
| 15 |
+
# Model: gemini-2.0-flash (Tier 2)
|
| 16 |
+
GEMINI_KEY_1=AQ.Ab8RN6LV1J-xm0zt9G_mbC14UbVgXtM7Us6JcdslkLbulYltfw
|
| 17 |
+
GEMINI_KEY_2=
|
| 18 |
+
GEMINI_KEY_3=
|
| 19 |
+
GEMINI_KEY_4=
|
| 20 |
+
GEMINI_KEY_5=
|
| 21 |
+
|
| 22 |
+
# ── Groq ─────────────────────────────────────────────────
|
| 23 |
+
# Lấy miễn phí tại: https://console.groq.com/keys
|
| 24 |
+
# Model: llama-3.3-70b-versatile (Tier 2) — rất nhanh
|
| 25 |
+
GROQ_KEY_1=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
|
| 26 |
+
GROQ_KEY_2=
|
| 27 |
+
GROQ_KEY_3=
|
| 28 |
+
GROQ_KEY_4=
|
| 29 |
+
GROQ_KEY_5=
|
| 30 |
+
|
| 31 |
+
# ── OpenRouter ────────────────────────────────────────────
|
| 32 |
+
# Đăng ký tại: https://openrouter.ai
|
| 33 |
+
# Model: Nemotron / Gemma / GPT-OSS
|
| 34 |
+
OPENROUTER_KEY_1=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
|
| 35 |
+
OPENROUTER_KEY_2=
|
| 36 |
+
OPENROUTER_KEY_3=
|
| 37 |
+
|
| 38 |
+
# ── xKiro Gateway ──────────────────────────────────────────
|
| 39 |
+
# Đăng ký tại: https://xkiro.com
|
| 40 |
+
# Model: DeepSeek V4 Pro, Qwen 3.7 Max, GLM-5, MiniMax M2.5
|
| 41 |
+
XKIRO_KEY_1=sk-xt-5386cb555a4e5a2cd2dec16d7e81faebc8e8faae63aa9b40
|
| 42 |
+
XKIRO_KEY_2=
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── SiliconFlow ───────────────────────────────────────────
|
| 46 |
+
# Đăng ký tại: https://siliconflow.cn
|
| 47 |
+
# 2 model: Qwen2.5-72B (Pro/Tier1) + Qwen2.5-7B (Free/Tier2)
|
| 48 |
+
# Cùng key, pool tự gán vào cả 2 model
|
| 49 |
+
SILICONFLOW_KEY_1=
|
| 50 |
+
SILICONFLOW_KEY_2=
|
| 51 |
+
SILICONFLOW_KEY_3=
|
| 52 |
+
SILICONFLOW_KEY_4=
|
| 53 |
+
|
| 54 |
+
# ── TogetherAI ────────────────────────────────────────────
|
| 55 |
+
# Đăng ký tại: https://api.together.xyz
|
| 56 |
+
# Model: Meta-Llama-3.1-70B-Instruct-Turbo (Tier 2)
|
| 57 |
+
TOGETHERAI_KEY_1=
|
| 58 |
+
TOGETHERAI_KEY_2=
|
| 59 |
+
TOGETHERAI_KEY_3=
|
| 60 |
+
|
| 61 |
+
# ── NVIDIA Nemotron ───────────────────────────────────────
|
| 62 |
+
# Đăng ký tại: https://integrate.api.nvidia.com
|
| 63 |
+
# Model: nemotron-3-super-120b-a12b (Tier 3) — có reasoning
|
| 64 |
+
NVIDIA_KEY_1=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
|
| 65 |
+
NVIDIA_KEY_2=
|
| 66 |
+
|
| 67 |
+
# ── Ollama Local ──────────────────────────────────────────
|
| 68 |
+
# Không cần key — tự động thêm vào pool làm Tier 99 (chốt chặn)
|
| 69 |
+
# Cài model: ollama pull qwen2.5:7b
|
| 70 |
+
|
| 71 |
+
# ── Local API Gateway (Super AI Gate) ────────────────────
|
| 72 |
+
# Cấu hình kết nối tới cổng gateway cục bộ tại localhost.
|
| 73 |
+
# Tool sẽ tự động quét D:\TOOL GOM API\.env để lấy key và port nếu bạn để trống bên dưới.
|
| 74 |
+
SUPER_AI_GATE_PORT=8000
|
| 75 |
+
SUPER_AI_GATE_KEY_1=
|
app.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py — Gradio Cloud Studio for Hugging Face Spaces (100% Free)
|
| 3 |
+
─────────────────────────────────────────────────────────────────
|
| 4 |
+
Runs 24/7 on Hugging Face Free CPU with 0đ cost.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
import gradio as gr
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# Add project root to sys.path
|
| 14 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 15 |
+
if str(BASE_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(BASE_DIR))
|
| 17 |
+
|
| 18 |
+
from app.core.cloud_pipeline import CloudPipeline
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def process_video(
|
| 22 |
+
video_file,
|
| 23 |
+
video_url,
|
| 24 |
+
mode,
|
| 25 |
+
voice,
|
| 26 |
+
source_lang,
|
| 27 |
+
speed,
|
| 28 |
+
volume,
|
| 29 |
+
progress=gr.Progress()
|
| 30 |
+
):
|
| 31 |
+
if not video_file and not video_url:
|
| 32 |
+
return None, "❌ Vui lòng tải lên 1 video hoặc nhập đường link Douyin/TikTok!"
|
| 33 |
+
|
| 34 |
+
# Handle URL download if provided
|
| 35 |
+
actual_video_path = None
|
| 36 |
+
if video_file is not None:
|
| 37 |
+
actual_video_path = video_file
|
| 38 |
+
elif video_url and video_url.strip():
|
| 39 |
+
progress(0.05, desc="Đang tải video từ đường link...")
|
| 40 |
+
saved_path = BASE_DIR / "temp" / f"url_video_{int(time.time())}.mp4"
|
| 41 |
+
saved_path.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
try:
|
| 43 |
+
import subprocess
|
| 44 |
+
cmd = ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", str(saved_path), video_url.strip()]
|
| 45 |
+
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
|
| 46 |
+
if res.returncode == 0 and saved_path.exists():
|
| 47 |
+
actual_video_path = str(saved_path)
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
if not actual_video_path:
|
| 52 |
+
import requests
|
| 53 |
+
r = requests.get(video_url.strip(), stream=True, timeout=30)
|
| 54 |
+
if r.status_code == 200:
|
| 55 |
+
with open(saved_path, "wb") as f:
|
| 56 |
+
for chunk in r.iter_content(chunk_size=1024*1024):
|
| 57 |
+
if chunk:
|
| 58 |
+
f.write(chunk)
|
| 59 |
+
actual_video_path = str(saved_path)
|
| 60 |
+
|
| 61 |
+
if not actual_video_path or not Path(actual_video_path).exists():
|
| 62 |
+
return None, "❌ Không thể tải video đầu vào."
|
| 63 |
+
|
| 64 |
+
logs = []
|
| 65 |
+
def log_cb(msg: str):
|
| 66 |
+
logs.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
|
| 67 |
+
|
| 68 |
+
def progress_cb(pct: int, stage: str):
|
| 69 |
+
progress(pct / 100.0, desc=f"{stage} ({pct}%)")
|
| 70 |
+
|
| 71 |
+
pipeline = CloudPipeline(
|
| 72 |
+
base_dir=BASE_DIR,
|
| 73 |
+
log_callback=log_cb,
|
| 74 |
+
progress_callback=progress_cb
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
out_video = pipeline.run_video(
|
| 78 |
+
video_path=actual_video_path,
|
| 79 |
+
mode=mode,
|
| 80 |
+
source_lang=source_lang,
|
| 81 |
+
voice=voice,
|
| 82 |
+
speed=speed,
|
| 83 |
+
pitch=0,
|
| 84 |
+
volume=int(volume)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
log_output = "\n".join(logs)
|
| 88 |
+
if out_video and Path(out_video).exists():
|
| 89 |
+
return str(out_video), f"🎉 DỊCH & LỒNG TIẾNG THÀNH CÔNG!\n\n{log_output}"
|
| 90 |
+
else:
|
| 91 |
+
return None, f"❌ Xử lý thất bại.\n\n{log_output}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ── Custom CSS for Cyberpunk / Studio Theme ───────────────────────────────────
|
| 95 |
+
custom_css = """
|
| 96 |
+
body { background-color: #0b0f19; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
|
| 97 |
+
.gradio-container { max-width: 1200px !important; margin: auto; }
|
| 98 |
+
.header-box { text-align: center; padding: 20px; background: linear-gradient(135deg, #0d1b2a, #1b263b); border-radius: 12px; border: 1px solid #00f0ff; margin-bottom: 20px; }
|
| 99 |
+
.header-title { font-size: 28px; font-weight: 800; color: #00f0ff; text-shadow: 0 0 12px rgba(0, 240, 255, 0.6); }
|
| 100 |
+
.header-desc { color: #94a3b8; font-size: 14px; margin-top: 6px; }
|
| 101 |
+
.btn-primary { background: linear-gradient(135deg, #00f0ff, #3b82f6) !important; color: #000 !important; font-weight: bold !important; font-size: 16px !important; border-radius: 8px !important; }
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
with gr.Blocks(css=custom_css, title="Trung Sáng Việt Cloud Studio") as demo:
|
| 105 |
+
with gr.Column(elem_classes=["header-box"]):
|
| 106 |
+
gr.Markdown(
|
| 107 |
+
"""
|
| 108 |
+
<div class="header-title">🎬 TRUNG SÁNG VIỆT CLOUD STUDIO</div>
|
| 109 |
+
<div class="header-desc">Hệ thống Dịch & Lồng tiếng Video tự động • 100% Cloud-Native • Miễn phí 24/7</div>
|
| 110 |
+
"""
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
with gr.Row():
|
| 114 |
+
with gr.Column(scale=1):
|
| 115 |
+
gr.Markdown("### 📥 1. Video Đầu Vào")
|
| 116 |
+
video_input = gr.Video(label="Tải video từ điện thoại/máy tính", sources=["upload"])
|
| 117 |
+
url_input = gr.Textbox(label="Hoặc dán Link video (Douyin / TikTok / MP4)", placeholder="https://v.douyin.com/... hoặc https://www.tiktok.com/...")
|
| 118 |
+
|
| 119 |
+
mode_input = gr.Radio(
|
| 120 |
+
choices=[("🎙️ Cloud ASR (Groq Whisper - Bóc tách âm thanh)", "asr"), ("👁️ Cloud OCR (Gemini Vision - Quét chữ trên màn hình)", "ocr")],
|
| 121 |
+
value="asr",
|
| 122 |
+
label="Phương thức quét phụ đề"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
with gr.Column(scale=1):
|
| 126 |
+
gr.Markdown("### 🎙️ 2. Cấu Hình Giọng Đọc & Dịch")
|
| 127 |
+
voice_input = gr.Dropdown(
|
| 128 |
+
choices=[
|
| 129 |
+
("🎙️ Nam Minh (Nam - Trầm ấm, chuyên nghiệp, phim tài liệu)", "vi-VN-NamMinhNeural"),
|
| 130 |
+
("🎙️ Hoài My (Nữ - Truyền cảm, ngọt ngào, review thời trang)", "vi-VN-HoaiMyNeural")
|
| 131 |
+
],
|
| 132 |
+
value="vi-VN-NamMinhNeural",
|
| 133 |
+
label="Giọng đọc tiếng Việt (Microsoft Edge-TTS)"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
source_lang_input = gr.Dropdown(
|
| 137 |
+
choices=[("🇨🇳 Tiếng Trung (Chinese - zh)", "zh"), ("🇺🇸 Tiếng Anh (English - en)", "en")],
|
| 138 |
+
value="zh",
|
| 139 |
+
label="Ngôn ngữ gốc của video"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
with gr.Row():
|
| 143 |
+
speed_input = gr.Slider(minimum=0.8, maximum=1.5, value=1.0, step=0.05, label="Tốc độ đọc (Speed)")
|
| 144 |
+
vol_input = gr.Slider(minimum=50, maximum=150, value=100, step=5, label="Âm lượng (%)")
|
| 145 |
+
|
| 146 |
+
start_btn = gr.Button("🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD", elem_classes=["btn-primary"])
|
| 147 |
+
|
| 148 |
+
with gr.Row():
|
| 149 |
+
with gr.Column():
|
| 150 |
+
gr.Markdown("### 🎉 3. Video Thành Phẩm & Nhật Ký Xử Lý")
|
| 151 |
+
video_output = gr.Video(label="Video đã lồng tiếng hoàn chỉnh", interactive=False)
|
| 152 |
+
log_output = gr.Textbox(label="Nhật ký xử lý (Live Logs)", lines=8)
|
| 153 |
+
|
| 154 |
+
start_btn.click(
|
| 155 |
+
fn=process_video,
|
| 156 |
+
inputs=[
|
| 157 |
+
video_input,
|
| 158 |
+
url_input,
|
| 159 |
+
mode_input,
|
| 160 |
+
voice_input,
|
| 161 |
+
source_lang_input,
|
| 162 |
+
speed_input,
|
| 163 |
+
vol_input
|
| 164 |
+
],
|
| 165 |
+
outputs=[video_output, log_output]
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
demo.queue().launch(server_name="0.0.0.0", server_port=7860)
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (140 Bytes). View file
|
|
|
app/__pycache__/api_pool.cpython-312.pyc
ADDED
|
Binary file (27 kB). View file
|
|
|
app/__pycache__/web_server.cpython-312.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
app/api_pool.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
api_pool.py — Multi-Provider API Key Pool with Round-Robin Rotation & Auto-Fallback
|
| 3 |
+
======================================================================================
|
| 4 |
+
TIER STRUCTURE (Optimized for LARGE CHUNKS & TRANSLATION QUALITY as of Aug 16, 2026):
|
| 5 |
+
======================================================================================
|
| 6 |
+
TIER 0 : Local API Gateway (Super AI Gate) - localhost fallback
|
| 7 |
+
TIER 1 : BEST FREE 1M+ CONTEXT MODELS (Nemotron 3 Ultra 550B, Nemotron 3.5 Lightning, Gemma 4 31B)
|
| 8 |
+
TIER 2 : HIGH QUALITY 128K+ CONTEXT (GPT-OSS 120B, Llama 3.3 70B, Nemotron Super 120B)
|
| 9 |
+
TIER 3 : FAST & RELIABLE 128K (Groq Llama 70B, Qwen 27B, Nvidia Llama 70B)
|
| 10 |
+
TIER 4 : OPENROUTER PRO TIER (Qwen 72B, Hermes 405B, Gemini 2.5 Flash, Llama 70B)
|
| 11 |
+
TIER 5 : OPENROUTER MID TIER (Nemotron 70B, Llama 3.3 70B, etc.)
|
| 12 |
+
TIER 6 : OPENROUTER FAST TIER (Mistral, Gemma, Qwen 14B, Phi-4)
|
| 13 |
+
TIER 7 : DIRECT CLOUD (SiliconFlow, TogetherAI, Gemini API)
|
| 14 |
+
TIER 99 : Ollama Local (ultimate fallback)
|
| 15 |
+
|
| 16 |
+
Load keys từ .env ở root project. Format:
|
| 17 |
+
OPENROUTER_KEY_1=sk-or-...
|
| 18 |
+
GEMINI_KEY_1=AIza...
|
| 19 |
+
GROQ_KEY_1=gsk_...
|
| 20 |
+
SILICONFLOW_KEY_1=sk-...
|
| 21 |
+
TOGETHERAI_KEY_1=...
|
| 22 |
+
NVIDIA_KEY_1=nvapi-...
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import time
|
| 27 |
+
import threading
|
| 28 |
+
import urllib.request
|
| 29 |
+
import urllib.error
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 34 |
+
# Provider Definitions (all OpenAI-compatible endpoints)
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 36 |
+
_OR_HEADERS = {"HTTP-Referer": "https://trungsangviet.local", "X-Title": "TrungSangViet"}
|
| 37 |
+
_OR_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 38 |
+
|
| 39 |
+
def _or(name, model, tier, max_tokens=8192):
|
| 40 |
+
"""Helper tạo slot OpenRouter nhanh."""
|
| 41 |
+
return {
|
| 42 |
+
"tier": tier, "provider_group": "openrouter",
|
| 43 |
+
"url": _OR_URL, "model": model,
|
| 44 |
+
"max_tokens": max_tokens,
|
| 45 |
+
"extra_headers": _OR_HEADERS,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
def _gate(name, model, tier=0, max_tokens=8192):
|
| 49 |
+
"""Helper tạo slot local gateway (Super AI Gate) chạy localhost."""
|
| 50 |
+
return {
|
| 51 |
+
"tier": tier, "provider_group": "super_ai_gate",
|
| 52 |
+
"url": "http://127.0.0.1:8000/v1/chat/completions",
|
| 53 |
+
"model": model,
|
| 54 |
+
"max_tokens": max_tokens,
|
| 55 |
+
"extra_headers": {},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
PROVIDER_CONFIGS = {
|
| 59 |
+
|
| 60 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 61 |
+
# TIER 0 — Local API Gateway (Super AI Gate ở localhost)
|
| 62 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 63 |
+
"gate_qwen3_235b": _gate("gate_qwen3_235b", "qwen/qwen-2.5-72b-instruct:free", max_tokens=8192),
|
| 64 |
+
"gate_deepseek_r1": _gate("gate_deepseek_r1", "nousresearch/hermes-3-llama-3.1-405b:free", max_tokens=8192),
|
| 65 |
+
"gate_deepseek_v3": _gate("gate_deepseek_v3", "meta-llama/llama-3.3-70b-instruct:free", max_tokens=8192),
|
| 66 |
+
"gate_gemini25_flash": _gate("gate_gemini25_flash", "google/gemini-2.5-flash:free", max_tokens=8192),
|
| 67 |
+
"gate_nemotron_70b": _gate("gate_nemotron_70b", "meta-llama/llama-3.3-70b-instruct:free", max_tokens=8192),
|
| 68 |
+
|
| 69 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 70 |
+
# TIER 1 — BEST FREE 1M+ CONTEXT MODELS (PRIORITY FOR LARGE CHUNKS)
|
| 71 |
+
# These handle full video transcripts in ONE SHOT without chunking
|
| 72 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 73 |
+
|
| 74 |
+
# 🏆 Nemotron 3 Ultra 550B — BEST REASONING, 1M CONTEXT, free on OpenRouter
|
| 75 |
+
"or_nemotron3_ultra_550b": _or("or_nemotron3_ultra_550b", "nvidia/nemotron-3-ultra-550b-a55b:free", tier=1, max_tokens=8192),
|
| 76 |
+
|
| 77 |
+
# 🏆 Nemotron 3.5 Lightning 30B — FAST + 1M CONTEXT, free on OpenRouter
|
| 78 |
+
"or_nemotron35_lightning": _or("or_nemotron35_lightning", "nvidia/nemotron-3.5-lightning:free", tier=1, max_tokens=8192),
|
| 79 |
+
|
| 80 |
+
# 🏆 Gemma 4 31B — 262K CONTEXT, strong multilingual, free on OpenRouter
|
| 81 |
+
"or_gemma4_31b": _or("or_gemma4_31b", "google/gemma-4-31b-it:free", tier=1, max_tokens=8192),
|
| 82 |
+
|
| 83 |
+
# 🏆 Nemotron 3 Super 120B — 128K context, reasoning fine-tuned, free on OpenRouter
|
| 84 |
+
"or_nemotron3_super_120b": _or("or_nemotron3_super_120b", "nvidia/nemotron-3-super-120b-a12b:free", tier=1, max_tokens=8192),
|
| 85 |
+
|
| 86 |
+
# 🏆 GPT-OSS 120B — 128K context, open weight, free on OpenRouter
|
| 87 |
+
"or_gpt_oss_120b": _or("or_gpt_oss_120b", "openai/gpt-oss-20b:free", tier=1, max_tokens=8192),
|
| 88 |
+
|
| 89 |
+
# 🏆 Llama 3.3 70B Versatile — 128K context, reliable, free on OpenRouter
|
| 90 |
+
"or_llama33_70b_tier1": _or("or_llama33_70b_tier1", "poolside/laguna-s-2.1:free", tier=1, max_tokens=8192),
|
| 91 |
+
|
| 92 |
+
# 🏆 Qwen 2.5 72B — Strong Chinese→Vietnamese, 128K context, free on OpenRouter
|
| 93 |
+
"or_qwen25_72b": _or("or_qwen25_72b", "google/gemma-4-31b-it:free", tier=1, max_tokens=8192),
|
| 94 |
+
|
| 95 |
+
# 🏆 Gemini 2.5 Flash — 1M CONTEXT, excellent Asian languages, free on OpenRouter
|
| 96 |
+
"or_gemini25_flash_tier1": _or("or_gemini25_flash_tier1", "google/gemma-4-26b-a4b-it:free", tier=1, max_tokens=8192),
|
| 97 |
+
|
| 98 |
+
# 🏆 DeepSeek V4 Flash — 128K context, cheap/fast, free on OpenRouter
|
| 99 |
+
"or_deepseek_v4_flash": _or("or_deepseek_v4_flash", "nvidia/nemotron-3-nano-30b-a3b:free", tier=1, max_tokens=8192),
|
| 100 |
+
|
| 101 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 102 |
+
# TIER 2 — HIGH QUALITY 128K+ CONTEXT (DIRECT CLOUD - NEED KEYS)
|
| 103 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 104 |
+
|
| 105 |
+
# --- Groq: Siêu nhanh (LPU hardware), 128K context ---
|
| 106 |
+
"groq_gpt_oss_120b": {
|
| 107 |
+
"tier": 2, "provider_group": "groq",
|
| 108 |
+
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 109 |
+
"model": "openai/gpt-oss-120b",
|
| 110 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 111 |
+
},
|
| 112 |
+
"groq_llama33_70b": {
|
| 113 |
+
"tier": 2, "provider_group": "groq",
|
| 114 |
+
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 115 |
+
"model": "openai/gpt-oss-20b",
|
| 116 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
# --- Nvidia NIM: Cloud GPU, 128K context ---
|
| 120 |
+
"nvidia_nemotron3_ultra_550b": {
|
| 121 |
+
"tier": 2, "provider_group": "nvidia",
|
| 122 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 123 |
+
"model": "nvidia/nemotron-3-ultra-550b-a55b",
|
| 124 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 125 |
+
},
|
| 126 |
+
"nvidia_nemotron35_lightning": {
|
| 127 |
+
"tier": 2, "provider_group": "nvidia",
|
| 128 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 129 |
+
"model": "nvidia/nemotron-3.5-lightning-30b-a3b",
|
| 130 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 131 |
+
},
|
| 132 |
+
"nvidia_nemotron3_super": {
|
| 133 |
+
"tier": 2, "provider_group": "nvidia",
|
| 134 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 135 |
+
"model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
| 136 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 137 |
+
},
|
| 138 |
+
"nvidia_deepseek_v4_flash": {
|
| 139 |
+
"tier": 2, "provider_group": "nvidia",
|
| 140 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 141 |
+
"model": "meta/llama-3.1-70b-instruct",
|
| 142 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 143 |
+
},
|
| 144 |
+
|
| 145 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 146 |
+
# TIER 3 — FAST & RELIABLE 128K (DIRECT CLOUD)
|
| 147 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 148 |
+
|
| 149 |
+
# Groq Qwen 27B — 32K context, has reasoning mode
|
| 150 |
+
"groq_qwen3_27b": {
|
| 151 |
+
"tier": 3, "provider_group": "groq",
|
| 152 |
+
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 153 |
+
"model": "qwen/qwen3.6-27b",
|
| 154 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 155 |
+
"extra_body": {"reasoning_format": "hidden"},
|
| 156 |
+
},
|
| 157 |
+
# Groq GPT-OSS 20B — fast, verified working on this org
|
| 158 |
+
"groq_llama_8b": {
|
| 159 |
+
"tier": 3, "provider_group": "groq",
|
| 160 |
+
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 161 |
+
"model": "openai/gpt-oss-20b",
|
| 162 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 163 |
+
},
|
| 164 |
+
|
| 165 |
+
# Nvidia Llama 3.1 70B — 128K context, solid base model
|
| 166 |
+
"nvidia_llama70b": {
|
| 167 |
+
"tier": 3, "provider_group": "nvidia",
|
| 168 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 169 |
+
"model": "meta/llama-3.1-70b-instruct",
|
| 170 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 171 |
+
},
|
| 172 |
+
# Nvidia Gemma 4 31B — 128K context
|
| 173 |
+
"nvidia_gemma4": {
|
| 174 |
+
"tier": 3, "provider_group": "nvidia",
|
| 175 |
+
"url": "https://integrate.api.nvidia.com/v1/chat/completions",
|
| 176 |
+
"model": "google/gemma-4-31b-it",
|
| 177 |
+
"max_tokens": 8192, "extra_headers": {},
|
| 178 |
+
},
|
| 179 |
+
|
| 180 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 181 |
+
# TIER 4 — OPENROUTER PRO (High quality, 128K+ context)
|
| 182 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 183 |
+
|
| 184 |
+
"or_hermes_405b": _or("or_hermes_405b", "nvidia/nemotron-3-super-120b-a12b:free", tier=4, max_tokens=8192),
|
| 185 |
+
"or_llama4_maverick": _or("or_llama4_maverick", "google/gemma-4-31b-it:free", tier=4, max_tokens=8192),
|
| 186 |
+
"or_gemini20_flash": _or("or_gemini20_flash", "google/gemma-4-26b-a4b-it:free", tier=4, max_tokens=8192),
|
| 187 |
+
"or_llama4_scout": _or("or_llama4_scout", "google/gemma-4-26b-a4b-it:free", tier=4, max_tokens=8192),
|
| 188 |
+
|
| 189 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 190 |
+
# TIER 5 — OPENROUTER MID (70B class)
|
| 191 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 192 |
+
|
| 193 |
+
"or_nemotron_70b": _or("or_nemotron_70b", "nvidia/nemotron-3-nano-30b-a3b:free", tier=5, max_tokens=8192),
|
| 194 |
+
"or_llama33_70b": _or("or_llama33_70b", "poolside/laguna-s-2.1:free", tier=5, max_tokens=8192),
|
| 195 |
+
"or_deepseek_r1": _or("or_deepseek_r1", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", tier=5, max_tokens=8192),
|
| 196 |
+
"or_deepseek_v3": _or("or_deepseek_v3", "poolside/laguna-s-2.1:free", tier=5, max_tokens=8192),
|
| 197 |
+
"or_qwen3_30b": _or("or_qwen3_30b", "openai/gpt-oss-20b:free", tier=5, max_tokens=8192),
|
| 198 |
+
|
| 199 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 200 |
+
# TIER 6 — OPENROUTER FAST (14-27B)
|
| 201 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 202 |
+
|
| 203 |
+
"or_mistral_small": _or("or_mistral_small", "openai/gpt-oss-20b:free", tier=6, max_tokens=4096),
|
| 204 |
+
"or_gemma3_27b": _or("or_gemma3_27b", "google/gemma-4-26b-a4b-it:free", tier=6, max_tokens=4096),
|
| 205 |
+
"or_qwen3_14b": _or("or_qwen3_14b", "nvidia/nemotron-3-nano-30b-a3b:free", tier=6, max_tokens=4096),
|
| 206 |
+
"or_r1_distill_32b": _or("or_r1_distill_32b", "z-ai/glm-5.2:free", tier=6, max_tokens=4096),
|
| 207 |
+
"or_phi4": _or("or_phi4", "z-ai/glm-5.2:free", tier=6, max_tokens=4096),
|
| 208 |
+
|
| 209 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 210 |
+
# TIER 7 — DIRECT CLOUD (need separate keys)
|
| 211 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 212 |
+
|
| 213 |
+
"siliconflow_pro": {
|
| 214 |
+
"tier": 7, "provider_group": "siliconflow",
|
| 215 |
+
"url": "https://api.siliconflow.cn/v1/chat/completions",
|
| 216 |
+
"model": "Qwen/Qwen2.5-72B-Instruct",
|
| 217 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 218 |
+
},
|
| 219 |
+
"gemini": {
|
| 220 |
+
"tier": 7, "provider_group": "gemini",
|
| 221 |
+
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
| 222 |
+
"model": "gemini-3.5-flash",
|
| 223 |
+
"max_tokens": 4096, "extra_headers": {},
|
| 224 |
+
},
|
| 225 |
+
"togetherai": {
|
| 226 |
+
"tier": 7, "provider_group": "togetherai",
|
| 227 |
+
"url": "https://api.together.xyz/v1/chat/completions",
|
| 228 |
+
"model": "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
|
| 229 |
+
"max_tokens": 2048, "extra_headers": {},
|
| 230 |
+
},
|
| 231 |
+
"siliconflow_free": {
|
| 232 |
+
"tier": 8, "provider_group": "siliconflow",
|
| 233 |
+
"url": "https://api.siliconflow.cn/v1/chat/completions",
|
| 234 |
+
"model": "Qwen/Qwen2.5-7B-Instruct",
|
| 235 |
+
"max_tokens": 2048, "extra_headers": {},
|
| 236 |
+
},
|
| 237 |
+
|
| 238 |
+
# ════════════════════════════════════════════════════════════════════════
|
| 239 |
+
# TIER 99 — Ollama Local (ultimate fallback, never fails)
|
| 240 |
+
# ══════════════════════════════════════════════��═════════════════════════
|
| 241 |
+
"ollama": {
|
| 242 |
+
"tier": 99, "provider_group": "ollama",
|
| 243 |
+
"url": "http://127.0.0.1:11434/v1/chat/completions",
|
| 244 |
+
"model": "hf.co/lmstudio-community/Qwen3.5-9B-GGUF:Q6_K",
|
| 245 |
+
"max_tokens": 2048, "extra_headers": {},
|
| 246 |
+
},
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 252 |
+
# Map: .env key prefix → provider name(s) nhận key đó
|
| 253 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 254 |
+
KEY_ENV_MAP = {
|
| 255 |
+
"SUPER_AI_GATE_KEY": [
|
| 256 |
+
"gate_qwen3_235b", "gate_deepseek_r1", "gate_deepseek_v3",
|
| 257 |
+
"gate_gemini25_flash", "gate_nemotron_70b"
|
| 258 |
+
],
|
| 259 |
+
# 1 OpenRouter key → TẤT CẢ slot OpenRouter (Tier 1-6), xoay vòng tự động
|
| 260 |
+
# Ưu tiên Tier 1 (1M+ context models) lên đầu danh sách
|
| 261 |
+
"OPENROUTER_KEY": [
|
| 262 |
+
# TIER 1 — BEST FREE 1M+ CONTEXT (Priority for large chunks)
|
| 263 |
+
"or_nemotron3_ultra_550b",
|
| 264 |
+
"or_nemotron35_lightning",
|
| 265 |
+
"or_gemma4_31b",
|
| 266 |
+
"or_nemotron3_super_120b",
|
| 267 |
+
"or_gpt_oss_120b",
|
| 268 |
+
"or_llama33_70b_tier1",
|
| 269 |
+
"or_qwen25_72b",
|
| 270 |
+
"or_gemini25_flash_tier1",
|
| 271 |
+
"or_deepseek_v4_flash",
|
| 272 |
+
# TIER 4 — OPENROUTER PRO
|
| 273 |
+
"or_hermes_405b",
|
| 274 |
+
"or_llama4_maverick",
|
| 275 |
+
"or_gemini20_flash",
|
| 276 |
+
"or_llama4_scout",
|
| 277 |
+
# TIER 5 — OPENROUTER MID
|
| 278 |
+
"or_nemotron_70b",
|
| 279 |
+
"or_llama33_70b",
|
| 280 |
+
"or_deepseek_r1",
|
| 281 |
+
"or_deepseek_v3",
|
| 282 |
+
"or_qwen3_30b",
|
| 283 |
+
# TIER 6 — OPENROUTER FAST
|
| 284 |
+
"or_mistral_small",
|
| 285 |
+
"or_gemma3_27b",
|
| 286 |
+
"or_qwen3_14b",
|
| 287 |
+
"or_r1_distill_32b",
|
| 288 |
+
"or_phi4",
|
| 289 |
+
],
|
| 290 |
+
"SILICONFLOW_KEY": ["siliconflow_pro", "siliconflow_free"],
|
| 291 |
+
"GEMINI_KEY": ["gemini"],
|
| 292 |
+
# 1 Groq key → 4 slot groq, xoay vòng round-robin tự động
|
| 293 |
+
"GROQ_KEY": ["groq_gpt_oss_120b", "groq_llama33_70b", "groq_qwen3_27b", "groq_llama_8b"],
|
| 294 |
+
"TOGETHERAI_KEY": ["togetherai"],
|
| 295 |
+
# 1 Nvidia key → 6 slot nvidia, xoay vòng round-robin tự động
|
| 296 |
+
"NVIDIA_KEY": [
|
| 297 |
+
"nvidia_nemotron3_ultra_550b",
|
| 298 |
+
"nvidia_nemotron35_lightning",
|
| 299 |
+
"nvidia_nemotron3_super",
|
| 300 |
+
"nvidia_deepseek_v4_flash",
|
| 301 |
+
"nvidia_llama70b",
|
| 302 |
+
"nvidia_gemma4",
|
| 303 |
+
],
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
# Cooldown durations
|
| 307 |
+
COOLDOWN_429 = 60 # giây — sau khi hit rate limit
|
| 308 |
+
COOLDOWN_5XX = 30 # giây — sau khi server error
|
| 309 |
+
REQUEST_TIMEOUT = 120
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 313 |
+
# Custom Exceptions
|
| 314 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 315 |
+
class RateLimitError(Exception):
|
| 316 |
+
pass
|
| 317 |
+
|
| 318 |
+
class ServerError(Exception):
|
| 319 |
+
pass
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 323 |
+
# ApiPool — Thread-safe singleton
|
| 324 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 325 |
+
class ApiPool:
|
| 326 |
+
"""
|
| 327 |
+
Multi-provider API key pool.
|
| 328 |
+
- 1 OpenRouter key → 5 model slots, tự xoay vòng
|
| 329 |
+
- Nhiều key cùng provider → thêm slot, tăng throughput
|
| 330 |
+
- Rate limit → cooldown slot đó, nhảy sang slot tiếp theo
|
| 331 |
+
- Tất cả cloud fail → Ollama Local
|
| 332 |
+
"""
|
| 333 |
+
_instance = None
|
| 334 |
+
_init_lock = threading.Lock()
|
| 335 |
+
|
| 336 |
+
@classmethod
|
| 337 |
+
def instance(cls):
|
| 338 |
+
if cls._instance is None:
|
| 339 |
+
with cls._init_lock:
|
| 340 |
+
if cls._instance is None:
|
| 341 |
+
cls._instance = cls()
|
| 342 |
+
return cls._instance
|
| 343 |
+
|
| 344 |
+
def __init__(self):
|
| 345 |
+
self._slot_lock = threading.Lock()
|
| 346 |
+
self._rr_index = {} # provider_group → round-robin counter
|
| 347 |
+
self._env = {}
|
| 348 |
+
self._slots = []
|
| 349 |
+
self._session_slots = {} # session_id -> slot_id (cố định slot cho cùng session)
|
| 350 |
+
self._load_env()
|
| 351 |
+
self._build_slots()
|
| 352 |
+
|
| 353 |
+
# ── .env loading ────────────────���─────────────────────────────────────────
|
| 354 |
+
def _load_env(self):
|
| 355 |
+
root = Path(__file__).parent.parent
|
| 356 |
+
env_path = root / ".env"
|
| 357 |
+
self._env = {}
|
| 358 |
+
if env_path.exists():
|
| 359 |
+
with open(env_path, "r", encoding="utf-8") as f:
|
| 360 |
+
for line in f:
|
| 361 |
+
line = line.strip()
|
| 362 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 363 |
+
continue
|
| 364 |
+
k, v = line.split("=", 1)
|
| 365 |
+
k, v = k.strip(), v.strip().strip('"').strip("'")
|
| 366 |
+
if v:
|
| 367 |
+
self._env[k] = v
|
| 368 |
+
|
| 369 |
+
# Tự động nạp thêm API keys từ config.json nếu chưa có trong .env
|
| 370 |
+
cfg_path = root / "config.json"
|
| 371 |
+
if cfg_path.exists():
|
| 372 |
+
try:
|
| 373 |
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
| 374 |
+
cfg_json = json.load(f)
|
| 375 |
+
if "groq_key" in cfg_json and cfg_json["groq_key"] and "GROQ_KEY_1" not in self._env:
|
| 376 |
+
self._env["GROQ_KEY_1"] = cfg_json["groq_key"].strip()
|
| 377 |
+
if "gemini_key" in cfg_json and cfg_json["gemini_key"] and "GEMINI_KEY_1" not in self._env:
|
| 378 |
+
self._env["GEMINI_KEY_1"] = cfg_json["gemini_key"].strip()
|
| 379 |
+
if "nvidia_key" in cfg_json and cfg_json["nvidia_key"] and "NVIDIA_KEY_1" not in self._env:
|
| 380 |
+
self._env["NVIDIA_KEY_1"] = cfg_json["nvidia_key"].strip()
|
| 381 |
+
if "nim_api_key" in cfg_json and cfg_json["nim_api_key"] and "NVIDIA_KEY_1" not in self._env:
|
| 382 |
+
self._env["NVIDIA_KEY_1"] = cfg_json["nim_api_key"].strip()
|
| 383 |
+
except Exception:
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
# Tự động nạp cấu hình và key của cổng gateway từ D:\TOOL GOM API\.env nếu có
|
| 387 |
+
gate_env_path = Path("D:/TOOL GOM API/.env")
|
| 388 |
+
if gate_env_path.exists():
|
| 389 |
+
try:
|
| 390 |
+
with open(gate_env_path, "r", encoding="utf-8") as f:
|
| 391 |
+
gate_env = {}
|
| 392 |
+
for line in f:
|
| 393 |
+
line = line.strip()
|
| 394 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 395 |
+
continue
|
| 396 |
+
k, v = line.split("=", 1)
|
| 397 |
+
k, v = k.strip(), v.strip().strip('"').strip("'")
|
| 398 |
+
if v:
|
| 399 |
+
gate_env[k] = v
|
| 400 |
+
|
| 401 |
+
# Ưu tiên cấu hình trong tool .env trước, nếu chưa có thì lấy từ gate .env làm mặc định
|
| 402 |
+
if "SUPER_AI_GATE_KEY_1" not in self._env and "MASTER_API_KEY" in gate_env:
|
| 403 |
+
self._env["SUPER_AI_GATE_KEY_1"] = gate_env["MASTER_API_KEY"]
|
| 404 |
+
if "SUPER_AI_GATE_PORT" not in self._env and "PORT" in gate_env:
|
| 405 |
+
self._env["SUPER_AI_GATE_PORT"] = gate_env["PORT"]
|
| 406 |
+
# OpenRouter key thật nằm trong gate .env (OPENROUTER_API_KEY)
|
| 407 |
+
if "OPENROUTER_KEY_1" not in self._env and "OPENROUTER_API_KEY" in gate_env:
|
| 408 |
+
self._env["OPENROUTER_KEY_1"] = gate_env["OPENROUTER_API_KEY"]
|
| 409 |
+
except Exception:
|
| 410 |
+
pass
|
| 411 |
+
|
| 412 |
+
# Cài port mặc định nếu không cấu hình
|
| 413 |
+
if "SUPER_AI_GATE_PORT" not in self._env:
|
| 414 |
+
self._env["SUPER_AI_GATE_PORT"] = "8000"
|
| 415 |
+
|
| 416 |
+
def reload(self):
|
| 417 |
+
"""Reload .env và rebuild slots (dùng sau khi user thêm key)."""
|
| 418 |
+
self._load_env()
|
| 419 |
+
self._build_slots()
|
| 420 |
+
|
| 421 |
+
def _collect_keys(self, prefix):
|
| 422 |
+
"""Lấy OPENROUTER_KEY_1, _2, ... từ env."""
|
| 423 |
+
keys = []
|
| 424 |
+
for i in range(1, 50):
|
| 425 |
+
v = self._env.get(f"{prefix}_{i}")
|
| 426 |
+
if v:
|
| 427 |
+
keys.append(v)
|
| 428 |
+
return keys
|
| 429 |
+
|
| 430 |
+
# ── Slot building ─────────────────────────────────────────────────────────
|
| 431 |
+
def _build_slots(self):
|
| 432 |
+
slots = []
|
| 433 |
+
gate_port = self._env.get("SUPER_AI_GATE_PORT", "8000")
|
| 434 |
+
gate_url = f"http://127.0.0.1:{gate_port}/v1/chat/completions"
|
| 435 |
+
|
| 436 |
+
for env_prefix, provider_names in KEY_ENV_MAP.items():
|
| 437 |
+
keys = self._collect_keys(env_prefix)
|
| 438 |
+
if not keys:
|
| 439 |
+
continue
|
| 440 |
+
for key in keys:
|
| 441 |
+
for pname in provider_names:
|
| 442 |
+
cfg = PROVIDER_CONFIGS[pname]
|
| 443 |
+
|
| 444 |
+
# Tự động trỏ URL về localhost cổng gate cho các provider gate_*
|
| 445 |
+
url = gate_url if pname.startswith("gate_") else cfg["url"]
|
| 446 |
+
|
| 447 |
+
slots.append({
|
| 448 |
+
"id": f"{pname}@{self._mask_key(key)}",
|
| 449 |
+
"provider": pname,
|
| 450 |
+
"provider_group": cfg.get("provider_group", pname),
|
| 451 |
+
"tier": cfg["tier"],
|
| 452 |
+
"url": url,
|
| 453 |
+
"model": cfg["model"],
|
| 454 |
+
"max_tokens": cfg["max_tokens"],
|
| 455 |
+
"key": key,
|
| 456 |
+
"extra_headers": dict(cfg.get("extra_headers", {})),
|
| 457 |
+
"extra_body": dict(cfg.get("extra_body", {})),
|
| 458 |
+
"cooldown_until": 0.0,
|
| 459 |
+
"error_count": 0,
|
| 460 |
+
})
|
| 461 |
+
|
| 462 |
+
# Ollama luôn có, không cần key
|
| 463 |
+
ollama_cfg = PROVIDER_CONFIGS["ollama"]
|
| 464 |
+
slots.append({
|
| 465 |
+
"id": "ollama_local",
|
| 466 |
+
"provider": "ollama",
|
| 467 |
+
"provider_group": "ollama",
|
| 468 |
+
"tier": 99,
|
| 469 |
+
"url": ollama_cfg["url"],
|
| 470 |
+
"model": ollama_cfg["model"],
|
| 471 |
+
"max_tokens": ollama_cfg["max_tokens"],
|
| 472 |
+
"key": None,
|
| 473 |
+
"extra_headers": {},
|
| 474 |
+
"extra_body": {},
|
| 475 |
+
"cooldown_until": 0.0,
|
| 476 |
+
"error_count": 0,
|
| 477 |
+
})
|
| 478 |
+
|
| 479 |
+
slots.sort(key=lambda s: s["tier"])
|
| 480 |
+
with self._slot_lock:
|
| 481 |
+
self._slots = slots
|
| 482 |
+
self._rr_index = {}
|
| 483 |
+
|
| 484 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 485 |
+
def translate(self, json_text: str, system_prompt: str, log_fn=None, session_id=None, context_str=None) -> str:
|
| 486 |
+
"""
|
| 487 |
+
Dịch json_text qua pool, hỗ trợ session stickiness và truyền ngữ cảnh.
|
| 488 |
+
"""
|
| 489 |
+
with self._slot_lock:
|
| 490 |
+
snapshot = list(self._slots)
|
| 491 |
+
|
| 492 |
+
now = time.time()
|
| 493 |
+
last_err = None
|
| 494 |
+
|
| 495 |
+
# 1. Thử dùng slot đã cố định cho session này trước
|
| 496 |
+
sticky_slot = None
|
| 497 |
+
if session_id:
|
| 498 |
+
with self._slot_lock:
|
| 499 |
+
sticky_slot_id = self._session_slots.get(session_id)
|
| 500 |
+
if sticky_slot_id:
|
| 501 |
+
for s in snapshot:
|
| 502 |
+
if s["id"] == sticky_slot_id:
|
| 503 |
+
if s["cooldown_until"] <= now:
|
| 504 |
+
sticky_slot = s
|
| 505 |
+
break
|
| 506 |
+
|
| 507 |
+
if sticky_slot:
|
| 508 |
+
try:
|
| 509 |
+
result = self._call_slot(sticky_slot, json_text, system_prompt, context_str)
|
| 510 |
+
sticky_slot["error_count"] = 0
|
| 511 |
+
if log_fn:
|
| 512 |
+
log_fn(f" ✅ [Sticky: {sticky_slot['provider']}] model={sticky_slot['model'].split('/')[-1]} — OK")
|
| 513 |
+
return result
|
| 514 |
+
except Exception as e:
|
| 515 |
+
if log_fn:
|
| 516 |
+
log_fn(f" ⚠️ [Sticky: {sticky_slot['provider']}] Lỗi: {str(e)[:100]}, chuyển đổi slot khác...")
|
| 517 |
+
if isinstance(e, RateLimitError):
|
| 518 |
+
sticky_slot["cooldown_until"] = now + COOLDOWN_429
|
| 519 |
+
elif isinstance(e, ServerError):
|
| 520 |
+
sticky_slot["cooldown_until"] = now + COOLDOWN_5XX
|
| 521 |
+
else:
|
| 522 |
+
sticky_slot["cooldown_until"] = now + 10
|
| 523 |
+
with self._slot_lock:
|
| 524 |
+
if session_id in self._session_slots:
|
| 525 |
+
del self._session_slots[session_id]
|
| 526 |
+
last_err = e
|
| 527 |
+
|
| 528 |
+
# 2. Nếu không có sticky_slot hoặc sticky_slot bị lỗi, tìm slot mới từ các Tier
|
| 529 |
+
tiers = sorted(set(s["tier"] for s in snapshot))
|
| 530 |
+
for tier in tiers:
|
| 531 |
+
tier_slots = [s for s in snapshot if s["tier"] == tier]
|
| 532 |
+
by_group = {}
|
| 533 |
+
for s in tier_slots:
|
| 534 |
+
by_group.setdefault(s["provider_group"], []).append(s)
|
| 535 |
+
ordered = self._interleave_rr(by_group)
|
| 536 |
+
|
| 537 |
+
for slot in ordered:
|
| 538 |
+
if slot["cooldown_until"] > now:
|
| 539 |
+
rem = int(slot["cooldown_until"] - now)
|
| 540 |
+
if log_fn and last_err is None: # chỉ log bỏ qua ở lượt quét đầu
|
| 541 |
+
log_fn(f" ⏭ [{slot['provider']}] cooldown {rem}s, bỏ qua...")
|
| 542 |
+
continue
|
| 543 |
+
try:
|
| 544 |
+
result = self._call_slot(slot, json_text, system_prompt, context_str)
|
| 545 |
+
slot["error_count"] = 0
|
| 546 |
+
if log_fn:
|
| 547 |
+
log_fn(f" ✅ [{slot['provider']}] model={slot['model'].split('/')[-1]} — OK")
|
| 548 |
+
if session_id:
|
| 549 |
+
with self._slot_lock:
|
| 550 |
+
self._session_slots[session_id] = slot["id"]
|
| 551 |
+
return result
|
| 552 |
+
except RateLimitError as e:
|
| 553 |
+
slot["cooldown_until"] = time.time() + COOLDOWN_429
|
| 554 |
+
if log_fn:
|
| 555 |
+
log_fn(f" ��� [{slot['provider']}] Rate limit — cooldown {COOLDOWN_429}s")
|
| 556 |
+
last_err = e
|
| 557 |
+
except ServerError as e:
|
| 558 |
+
slot["cooldown_until"] = time.time() + COOLDOWN_5XX
|
| 559 |
+
slot["error_count"] += 1
|
| 560 |
+
if log_fn:
|
| 561 |
+
log_fn(f" 🔴 [{slot['provider']}] Server error — cooldown {COOLDOWN_5XX}s")
|
| 562 |
+
last_err = e
|
| 563 |
+
except Exception as e:
|
| 564 |
+
err_msg = str(e)
|
| 565 |
+
if "10061" in err_msg or "refused" in err_msg.lower() or "404" in err_msg:
|
| 566 |
+
slot["cooldown_until"] = time.time() + 300
|
| 567 |
+
slot["error_count"] += 1
|
| 568 |
+
if log_fn:
|
| 569 |
+
log_fn(f" ⚠️ [{slot['provider']}] {type(e).__name__}: {str(e)[:120]}")
|
| 570 |
+
last_err = e
|
| 571 |
+
|
| 572 |
+
raise Exception(f"Tất cả {len(snapshot)} slot đều fail. Lỗi cuối: {last_err}")
|
| 573 |
+
|
| 574 |
+
def count_healthy(self) -> int:
|
| 575 |
+
now = time.time()
|
| 576 |
+
with self._slot_lock:
|
| 577 |
+
return sum(1 for s in self._slots if s["cooldown_until"] <= now)
|
| 578 |
+
|
| 579 |
+
def count_slots(self) -> int:
|
| 580 |
+
with self._slot_lock:
|
| 581 |
+
return len(self._slots)
|
| 582 |
+
|
| 583 |
+
def status_report(self) -> str:
|
| 584 |
+
"""Trả về chuỗi báo cáo trạng thái pool."""
|
| 585 |
+
now = time.time()
|
| 586 |
+
lines = []
|
| 587 |
+
with self._slot_lock:
|
| 588 |
+
for s in self._slots:
|
| 589 |
+
cd = max(0, int(s["cooldown_until"] - now))
|
| 590 |
+
icon = "✅" if cd == 0 else "⏸"
|
| 591 |
+
cd_str = f" (cooldown {cd}s)" if cd > 0 else ""
|
| 592 |
+
lines.append(f" {icon} [{s['provider']}] {s['model'].split('/')[-1]}{cd_str}")
|
| 593 |
+
return "\n".join(lines)
|
| 594 |
+
|
| 595 |
+
# ── Internal ──────────────────────────────────────────────────────────────
|
| 596 |
+
def _interleave_rr(self, by_group: dict) -> list:
|
| 597 |
+
"""Trộn các nhóm provider theo round-robin để phân tải đều."""
|
| 598 |
+
result = []
|
| 599 |
+
groups = list(by_group.keys())
|
| 600 |
+
if not groups:
|
| 601 |
+
return result
|
| 602 |
+
max_len = max(len(v) for v in by_group.values())
|
| 603 |
+
for i in range(max_len):
|
| 604 |
+
for g in groups:
|
| 605 |
+
slots = by_group[g]
|
| 606 |
+
rr = self._rr_index.get(g, 0)
|
| 607 |
+
idx = (rr + i) % len(slots)
|
| 608 |
+
s = slots[idx]
|
| 609 |
+
if s not in result:
|
| 610 |
+
result.append(s)
|
| 611 |
+
# Advance RR index
|
| 612 |
+
for g in groups:
|
| 613 |
+
self._rr_index[g] = (self._rr_index.get(g, 0) + 1) % len(by_group[g])
|
| 614 |
+
return result
|
| 615 |
+
|
| 616 |
+
def _call_slot(self, slot: dict, json_text: str, system_prompt: str, context_str: str = None) -> str:
|
| 617 |
+
user_content = json_text
|
| 618 |
+
if context_str:
|
| 619 |
+
user_content = f"{context_str}\n\nJSON cần dịch:\n{json_text}"
|
| 620 |
+
|
| 621 |
+
payload = {
|
| 622 |
+
"model": slot["model"],
|
| 623 |
+
"messages": [
|
| 624 |
+
{"role": "system", "content": system_prompt},
|
| 625 |
+
{"role": "user", "content": user_content},
|
| 626 |
+
],
|
| 627 |
+
"temperature": 0.2,
|
| 628 |
+
"top_p": 0.95,
|
| 629 |
+
"max_tokens": slot["max_tokens"],
|
| 630 |
+
"stream": False,
|
| 631 |
+
}
|
| 632 |
+
if slot.get("extra_body"):
|
| 633 |
+
payload.update(slot["extra_body"])
|
| 634 |
+
|
| 635 |
+
data = json.dumps(payload).encode("utf-8")
|
| 636 |
+
headers = {
|
| 637 |
+
"Content-Type": "application/json",
|
| 638 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
| 639 |
+
}
|
| 640 |
+
if slot["key"]:
|
| 641 |
+
headers["Authorization"] = f"Bearer {slot['key']}"
|
| 642 |
+
headers.update(slot.get("extra_headers", {}))
|
| 643 |
+
|
| 644 |
+
req = urllib.request.Request(slot["url"], data=data, headers=headers)
|
| 645 |
+
try:
|
| 646 |
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
| 647 |
+
res = json.loads(resp.read().decode("utf-8"))
|
| 648 |
+
content = res["choices"][0]["message"]["content"]
|
| 649 |
+
return (content or "").strip()
|
| 650 |
+
except urllib.error.HTTPError as e:
|
| 651 |
+
code = e.code
|
| 652 |
+
body = ""
|
| 653 |
+
try:
|
| 654 |
+
body = e.read().decode("utf-8")
|
| 655 |
+
except Exception:
|
| 656 |
+
pass
|
| 657 |
+
if code == 429:
|
| 658 |
+
raise RateLimitError(f"429: {body[:150]}")
|
| 659 |
+
if code >= 500:
|
| 660 |
+
raise ServerError(f"{code}: {body[:150]}")
|
| 661 |
+
raise Exception(f"HTTP {code}: {body[:200]}")
|
| 662 |
+
except urllib.error.URLError as e:
|
| 663 |
+
raise Exception(f"Network error: {e.reason}")
|
| 664 |
+
|
| 665 |
+
@staticmethod
|
| 666 |
+
def _mask_key(key):
|
| 667 |
+
if not key:
|
| 668 |
+
return "nokey"
|
| 669 |
+
return key[:6] + "..." + key[-4:] if len(key) > 10 else "****"
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 673 |
+
# Shared system prompt
|
| 674 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 675 |
+
TRANSLATION_SYSTEM_PROMPT = (
|
| 676 |
+
"Bạn là một biên dịch viên xuất sắc và dịch giả phụ đề phim chuyên nghiệp từ tiếng Trung sang tiếng Việt.\n"
|
| 677 |
+
"Hãy dịch các câu thoại (value) trong JSON dưới đây sang tiếng Việt tự nhiên, mượt mà, "
|
| 678 |
+
"cuốn hút, giàu cảm xúc và diễn đạt trôi chảy theo văn phong phim ảnh.\n"
|
| 679 |
+
"Khi dịch văn bản OCR sang tiếng Việt, giữ nguyên các từ khóa tiếng Anh, thương hiệu thời trang đường phố (streetwear brands) và các thuật ngữ thịnh hành (ví dụ: Syna, outfits, BAPE, v.v.) như gốc trong phụ đề tiếng Việt cuối cùng. Không dịch hoặc chuyển âm (transliterate) các từ tiếng Anh đặc thù này sang tiếng Việt.\n"
|
| 680 |
+
"Lựa chọn đại từ nhân xưng linh hoạt, chính xác và đồng nhất theo ngữ cảnh cuộc hội thoại (anh/em, cô/tôi, ta/ngươi, sư phụ/đồ đệ...).\n"
|
| 681 |
+
"Không dịch từ-qua-từ (word-by-word) một cách khô cứng. Hãy dịch thoát ý.\n"
|
| 682 |
+
"Giữ nguyên các key (số ID). Chỉ trả về duy nhất chuỗi JSON đã dịch, "
|
| 683 |
+
"không kèm giải thích, markdown, hay ký tự thừa. JSON thuần."
|
| 684 |
+
)
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (145 Bytes). View file
|
|
|
app/core/__pycache__/cloud_asr.cpython-312.pyc
ADDED
|
Binary file (12 kB). View file
|
|
|
app/core/__pycache__/cloud_ocr.cpython-312.pyc
ADDED
|
Binary file (15.5 kB). View file
|
|
|
app/core/__pycache__/cloud_pipeline.cpython-312.pyc
ADDED
|
Binary file (15 kB). View file
|
|
|
app/core/__pycache__/cloud_tts.cpython-312.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
app/core/__pycache__/vietnamese_text_normalizer.cpython-312.pyc
ADDED
|
Binary file (11.8 kB). View file
|
|
|
app/core/asr_worker_cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
# Automatically add nvidia DLL directories to Windows search path to resolve DLL load errors for Faster-Whisper/ctranslate2
|
| 7 |
+
if sys.platform == 'win32':
|
| 8 |
+
base_dir = Path(__file__).resolve().parent
|
| 9 |
+
for parent in [base_dir] + list(base_dir.parents):
|
| 10 |
+
nvidia_path = parent / "env" / "Lib" / "site-packages" / "nvidia"
|
| 11 |
+
if nvidia_path.exists():
|
| 12 |
+
for bin_dir in nvidia_path.glob("*/bin"):
|
| 13 |
+
try:
|
| 14 |
+
os.add_dll_directory(str(bin_dir.resolve()))
|
| 15 |
+
os.environ['PATH'] = str(bin_dir.resolve()) + os.pathsep + os.environ['PATH']
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
| 18 |
+
break
|
| 19 |
+
|
| 20 |
+
# Import torch first to resolve nvidia dependencies and add DLL directories
|
| 21 |
+
try:
|
| 22 |
+
import torch
|
| 23 |
+
except ImportError:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
# Enforce UTF-8 for Windows console
|
| 27 |
+
if sys.platform == 'win32':
|
| 28 |
+
try:
|
| 29 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 30 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 31 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 32 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 33 |
+
except Exception:
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
def format_timestamp(seconds):
|
| 37 |
+
h = int(seconds // 3600)
|
| 38 |
+
m = int((seconds % 3600) // 60)
|
| 39 |
+
s = int(seconds % 60)
|
| 40 |
+
ms = int((seconds - int(seconds)) * 1000)
|
| 41 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 42 |
+
|
| 43 |
+
def main():
|
| 44 |
+
parser = argparse.ArgumentParser(description="Standalone Faster-Whisper CLI Transcriber")
|
| 45 |
+
parser.add_argument("--audio", required=True, help="Path to input audio file")
|
| 46 |
+
parser.add_argument("--output", required=True, help="Path to output SRT file")
|
| 47 |
+
parser.add_argument("--model", default="base", help="Whisper model size")
|
| 48 |
+
parser.add_argument("--device", default="auto", choices=["cuda", "cpu", "auto"], help="Computation device")
|
| 49 |
+
parser.add_argument("--compute-type", default="auto", choices=["float16", "int8_float16", "int8", "auto"], help="Computation precision type")
|
| 50 |
+
parser.add_argument("--allow-cpu-fallback", default="true", help="Allow CPU fallback (true/false)")
|
| 51 |
+
args = parser.parse_args()
|
| 52 |
+
|
| 53 |
+
audio_path = Path(args.audio)
|
| 54 |
+
output_path = Path(args.output)
|
| 55 |
+
allow_cpu_fallback = args.allow_cpu_fallback.lower() in ("true", "1", "yes", "t")
|
| 56 |
+
|
| 57 |
+
if not audio_path.exists():
|
| 58 |
+
print(f"Error: Audio file not found at {audio_path}", file=sys.stderr)
|
| 59 |
+
sys.exit(1)
|
| 60 |
+
|
| 61 |
+
print("Initializing Faster-Whisper model...")
|
| 62 |
+
try:
|
| 63 |
+
from faster_whisper import WhisperModel
|
| 64 |
+
except ImportError as e:
|
| 65 |
+
print(f"Error: faster-whisper not installed. {e}", file=sys.stderr)
|
| 66 |
+
sys.exit(1)
|
| 67 |
+
|
| 68 |
+
# Determine device and compute type
|
| 69 |
+
device = args.device
|
| 70 |
+
compute_type = args.compute_type
|
| 71 |
+
|
| 72 |
+
import ctranslate2
|
| 73 |
+
try:
|
| 74 |
+
import torch
|
| 75 |
+
has_cuda = torch.cuda.is_available() and ctranslate2.get_cuda_device_count() > 0
|
| 76 |
+
except Exception:
|
| 77 |
+
has_cuda = False
|
| 78 |
+
|
| 79 |
+
if device == "cpu":
|
| 80 |
+
print("[ASR WARNING] Running on CPU because device=cpu was explicitly requested.")
|
| 81 |
+
if compute_type == "auto":
|
| 82 |
+
compute_type = "int8"
|
| 83 |
+
elif device == "cuda":
|
| 84 |
+
if compute_type == "auto":
|
| 85 |
+
compute_type = "float16"
|
| 86 |
+
if not has_cuda:
|
| 87 |
+
print("ASR GPU requested but CUDA backend unavailable.", file=sys.stderr)
|
| 88 |
+
print("ASR GPU requested but CUDA backend unavailable.")
|
| 89 |
+
sys.exit(3)
|
| 90 |
+
else: # auto
|
| 91 |
+
if has_cuda:
|
| 92 |
+
device = "cuda"
|
| 93 |
+
if compute_type == "auto":
|
| 94 |
+
compute_type = "float16"
|
| 95 |
+
else:
|
| 96 |
+
if not allow_cpu_fallback:
|
| 97 |
+
print("ASR GPU requested but CUDA backend unavailable.", file=sys.stderr)
|
| 98 |
+
print("ASR GPU requested but CUDA backend unavailable.")
|
| 99 |
+
sys.exit(3)
|
| 100 |
+
device = "cpu"
|
| 101 |
+
if compute_type == "auto":
|
| 102 |
+
compute_type = "int8"
|
| 103 |
+
|
| 104 |
+
# Log device selection exactly as requested
|
| 105 |
+
print(f"[ASR] selected device: {device}")
|
| 106 |
+
print(f"[ASR] compute_type: {compute_type}")
|
| 107 |
+
|
| 108 |
+
print(f"Loading Whisper model '{args.model}' on {device} ({compute_type})...")
|
| 109 |
+
try:
|
| 110 |
+
model = WhisperModel(args.model, device=device, compute_type=compute_type)
|
| 111 |
+
except Exception as e:
|
| 112 |
+
import traceback
|
| 113 |
+
if device == "cuda" or not allow_cpu_fallback:
|
| 114 |
+
print("ASR GPU requested but CUDA backend unavailable.", file=sys.stderr)
|
| 115 |
+
print("ASR GPU requested but CUDA backend unavailable.")
|
| 116 |
+
traceback.print_exc(file=sys.stderr)
|
| 117 |
+
sys.exit(3)
|
| 118 |
+
else:
|
| 119 |
+
print(f"Warning: Failed to load model on {device} with compute type {compute_type}: {e}")
|
| 120 |
+
traceback.print_exc()
|
| 121 |
+
print("Falling back to CPU with int8...")
|
| 122 |
+
model = WhisperModel(args.model, device="cpu", compute_type="int8")
|
| 123 |
+
|
| 124 |
+
print("Transcribing...")
|
| 125 |
+
try:
|
| 126 |
+
segments, info = model.transcribe(str(audio_path), beam_size=5, language=None)
|
| 127 |
+
|
| 128 |
+
duration = info.duration
|
| 129 |
+
print(f"Audio duration: {duration:.2f} seconds. Detected language: {info.language}")
|
| 130 |
+
|
| 131 |
+
max_pct = 0
|
| 132 |
+
srt_blocks = []
|
| 133 |
+
for idx, segment in enumerate(segments, start=1):
|
| 134 |
+
srt_blocks.append(f"{idx}\n")
|
| 135 |
+
srt_blocks.append(f"{format_timestamp(segment.start)} --> {format_timestamp(segment.end)}\n")
|
| 136 |
+
srt_blocks.append(f"{segment.text.strip()}\n\n")
|
| 137 |
+
|
| 138 |
+
# Progress calculation and standard stdout formatting
|
| 139 |
+
if duration > 0:
|
| 140 |
+
pct = min(100, int((segment.end / duration) * 100))
|
| 141 |
+
if pct > max_pct:
|
| 142 |
+
max_pct = pct
|
| 143 |
+
print(f"PROGRESS: {max_pct}%", flush=True)
|
| 144 |
+
|
| 145 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 147 |
+
f.writelines(srt_blocks)
|
| 148 |
+
|
| 149 |
+
print("PROGRESS: 100%", flush=True)
|
| 150 |
+
print(f"ASR transcription completed successfully. SRT output saved at {output_path}")
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"ASR execution error: {e}", file=sys.stderr)
|
| 154 |
+
sys.exit(2)
|
| 155 |
+
finally:
|
| 156 |
+
# Explicit VRAM / Memory cleanup
|
| 157 |
+
try:
|
| 158 |
+
del model
|
| 159 |
+
except NameError:
|
| 160 |
+
pass
|
| 161 |
+
import gc
|
| 162 |
+
gc.collect()
|
| 163 |
+
try:
|
| 164 |
+
import torch
|
| 165 |
+
if torch.cuda.is_available():
|
| 166 |
+
torch.cuda.empty_cache()
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
|
| 170 |
+
sys.exit(0)
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
main()
|
app/core/audio_language_detector.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Automatically add nvidia DLL directories to Windows search path to resolve DLL load errors for Faster-Whisper/ctranslate2
|
| 8 |
+
if sys.platform == 'win32':
|
| 9 |
+
base_dir = Path(__file__).resolve().parent
|
| 10 |
+
preferred_nvidia_bins = [
|
| 11 |
+
"cuda_runtime",
|
| 12 |
+
"cublas",
|
| 13 |
+
"cudnn",
|
| 14 |
+
"cufft",
|
| 15 |
+
"curand",
|
| 16 |
+
"cusolver",
|
| 17 |
+
"cusparse",
|
| 18 |
+
"nvjitlink",
|
| 19 |
+
]
|
| 20 |
+
for parent in [base_dir] + list(base_dir.parents):
|
| 21 |
+
nvidia_path = parent / "env" / "Lib" / "site-packages" / "nvidia"
|
| 22 |
+
if nvidia_path.exists():
|
| 23 |
+
bin_dirs = []
|
| 24 |
+
for name in preferred_nvidia_bins:
|
| 25 |
+
p = nvidia_path / name / "bin"
|
| 26 |
+
if p.exists():
|
| 27 |
+
bin_dirs.append(p)
|
| 28 |
+
for bin_dir in sorted(nvidia_path.glob("*/bin")):
|
| 29 |
+
if bin_dir not in bin_dirs:
|
| 30 |
+
bin_dirs.append(bin_dir)
|
| 31 |
+
for bin_dir in bin_dirs:
|
| 32 |
+
try:
|
| 33 |
+
os.add_dll_directory(str(bin_dir.resolve()))
|
| 34 |
+
os.environ['PATH'] = str(bin_dir.resolve()) + os.pathsep + os.environ['PATH']
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
| 37 |
+
break
|
| 38 |
+
|
| 39 |
+
# Import torch first to resolve nvidia dependencies and add DLL directories
|
| 40 |
+
try:
|
| 41 |
+
import torch
|
| 42 |
+
except ImportError:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
# Enforce UTF-8 for Windows console
|
| 46 |
+
if sys.platform == 'win32':
|
| 47 |
+
try:
|
| 48 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 49 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 50 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 51 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 52 |
+
except Exception:
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
def is_cuda_fully_functional():
|
| 56 |
+
try:
|
| 57 |
+
import torch
|
| 58 |
+
if not torch.cuda.is_available():
|
| 59 |
+
return False
|
| 60 |
+
return True
|
| 61 |
+
except Exception:
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
def main():
|
| 65 |
+
parser = argparse.ArgumentParser(description="Standalone Faster-Whisper Audio Language Detector CLI")
|
| 66 |
+
parser.add_argument("--audio", required=True, help="Path to input audio WAV file")
|
| 67 |
+
parser.add_argument("--output", required=True, help="Path to output language segments JSON file")
|
| 68 |
+
parser.add_argument("--model", default="base", help="Faster-Whisper model size (e.g. base, small, medium)")
|
| 69 |
+
parser.add_argument("--device", default="auto", choices=["cuda", "cpu", "auto"], help="Computation device")
|
| 70 |
+
parser.add_argument("--allow-cpu-fallback", default="true", help="Allow CPU fallback (true/false)")
|
| 71 |
+
args = parser.parse_args()
|
| 72 |
+
|
| 73 |
+
audio_path = Path(args.audio)
|
| 74 |
+
output_path = Path(args.output)
|
| 75 |
+
allow_cpu_fallback = args.allow_cpu_fallback.lower() in ("true", "1", "yes", "t")
|
| 76 |
+
|
| 77 |
+
if not audio_path.exists():
|
| 78 |
+
print(f"Error: Input audio file not found at {audio_path}", file=sys.stderr)
|
| 79 |
+
sys.exit(1)
|
| 80 |
+
|
| 81 |
+
print("PROGRESS: 10%", flush=True)
|
| 82 |
+
print("Loading faster-whisper library...")
|
| 83 |
+
try:
|
| 84 |
+
from faster_whisper import WhisperModel
|
| 85 |
+
import faster_whisper
|
| 86 |
+
import ctranslate2
|
| 87 |
+
print(f"[LANG DETECT] faster-whisper: {getattr(faster_whisper, '__version__', 'unknown')}")
|
| 88 |
+
print(f"[LANG DETECT] ctranslate2: {getattr(ctranslate2, '__version__', 'unknown')}")
|
| 89 |
+
print(f"[LANG DETECT] ctranslate2 cuda devices: {ctranslate2.get_cuda_device_count()}")
|
| 90 |
+
except ImportError as e:
|
| 91 |
+
print(f"Error: faster-whisper not installed. {e}", file=sys.stderr)
|
| 92 |
+
sys.exit(1)
|
| 93 |
+
|
| 94 |
+
device = args.device
|
| 95 |
+
if device == "auto":
|
| 96 |
+
device = "cuda" if is_cuda_fully_functional() else "cpu"
|
| 97 |
+
|
| 98 |
+
compute_type = "float16" if device == "cuda" else "int8"
|
| 99 |
+
|
| 100 |
+
# Log device selection exactly as requested
|
| 101 |
+
print(f"[LANG DETECT] selected device: {device}")
|
| 102 |
+
print(f"[LANG DETECT] compute_type: {compute_type}")
|
| 103 |
+
|
| 104 |
+
print(f"Initializing WhisperModel '{args.model}' on {device} ({compute_type})...")
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
model = WhisperModel(args.model, device=device, compute_type=compute_type)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
if device == "cuda" and not allow_cpu_fallback:
|
| 110 |
+
print(f"Language detection GPU requested but CUDA backend unavailable. Error: {e}", file=sys.stderr)
|
| 111 |
+
sys.exit(3)
|
| 112 |
+
print(f"Warning: Failed to load model on {device} with compute type {compute_type}: {e}", file=sys.stderr)
|
| 113 |
+
print("Falling back to CPU with int8...", file=sys.stderr)
|
| 114 |
+
try:
|
| 115 |
+
model = WhisperModel(args.model, device="cpu", compute_type="int8")
|
| 116 |
+
except Exception as ex:
|
| 117 |
+
print(f"Error: Failed to fallback to CPU: {ex}", file=sys.stderr)
|
| 118 |
+
sys.exit(1)
|
| 119 |
+
|
| 120 |
+
print("PROGRESS: 40%", flush=True)
|
| 121 |
+
print("Transcribing audio to detect language segments...")
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
# Detect language segments
|
| 125 |
+
# vad_filter=True makes segmentation much cleaner and filters out silence/music
|
| 126 |
+
segments, info = model.transcribe(str(audio_path), vad_filter=True, beam_size=5)
|
| 127 |
+
|
| 128 |
+
print(f"Detected dominant language: {info.language} (probability: {info.language_probability:.2f})")
|
| 129 |
+
print("PROGRESS: 60%", flush=True)
|
| 130 |
+
|
| 131 |
+
result_segments = []
|
| 132 |
+
last_end = 0.0
|
| 133 |
+
for segment in segments:
|
| 134 |
+
if segment.start - last_end >= 1.0:
|
| 135 |
+
result_segments.append({
|
| 136 |
+
"start": round(last_end, 3),
|
| 137 |
+
"end": round(segment.start, 3),
|
| 138 |
+
"language": "music",
|
| 139 |
+
"text": "",
|
| 140 |
+
"confidence": "gap_no_speech"
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
# We want to know the language of each segment.
|
| 144 |
+
# In faster-whisper, segment contains the text and start/end timestamps.
|
| 145 |
+
# To get segment-level language, we check if the transcribing info returned is accurate.
|
| 146 |
+
# Wait, transcribing with faster-whisper runs on a single language detected initially.
|
| 147 |
+
# But wait, what if the audio has mixed languages (Chinese + English)?
|
| 148 |
+
# To detect mixed languages at segment level, can we run transcribe with word-level/segment-level language identification,
|
| 149 |
+
# or can we check if the transcribed text matches specific scripts (e.g. Chinese characters vs English words)?
|
| 150 |
+
# Yes! We can look at the words/characters in the transcribed segment text!
|
| 151 |
+
# If the segment text is >= 80% Chinese characters, we label it "zh".
|
| 152 |
+
# If it contains English alphabet words and no Chinese characters, we label it "en".
|
| 153 |
+
# This is an extremely clever, lightweight, and robust way to detect timeline languages in mixed audio!
|
| 154 |
+
text = segment.text.strip()
|
| 155 |
+
|
| 156 |
+
# Simple heuristic script detector:
|
| 157 |
+
has_chinese = bool(re.search(r'[\u4e00-\u9fff]', text))
|
| 158 |
+
has_english = bool(re.search(r'[a-zA-Z]', text))
|
| 159 |
+
|
| 160 |
+
# Heuristic assignment
|
| 161 |
+
if has_chinese and not has_english:
|
| 162 |
+
segment_lang = "zh"
|
| 163 |
+
elif has_english and not has_chinese:
|
| 164 |
+
segment_lang = "en"
|
| 165 |
+
elif has_chinese and has_english:
|
| 166 |
+
# Count characters to see which is dominant
|
| 167 |
+
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
|
| 168 |
+
english_words = len(re.findall(r'[a-zA-Z]+', text))
|
| 169 |
+
if chinese_chars >= english_words:
|
| 170 |
+
segment_lang = "zh"
|
| 171 |
+
else:
|
| 172 |
+
segment_lang = "mixed"
|
| 173 |
+
else:
|
| 174 |
+
segment_lang = info.language if info.language in ("zh", "en") else "unknown"
|
| 175 |
+
|
| 176 |
+
result_segments.append({
|
| 177 |
+
"start": round(segment.start, 3),
|
| 178 |
+
"end": round(segment.end, 3),
|
| 179 |
+
"language": segment_lang,
|
| 180 |
+
"text": text,
|
| 181 |
+
"confidence": "script_heuristic"
|
| 182 |
+
})
|
| 183 |
+
last_end = max(last_end, float(segment.end))
|
| 184 |
+
|
| 185 |
+
print("PROGRESS: 90%", flush=True)
|
| 186 |
+
|
| 187 |
+
# Save to JSON
|
| 188 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 189 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 190 |
+
json.dump(result_segments, f, ensure_ascii=False, indent=2)
|
| 191 |
+
|
| 192 |
+
print(f"Language detection completed. Segments saved to {output_path}")
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
print(f"Error during language transcription: {e}", file=sys.stderr)
|
| 196 |
+
sys.exit(1)
|
| 197 |
+
|
| 198 |
+
# Free VRAM/GPU Cache
|
| 199 |
+
try:
|
| 200 |
+
del model
|
| 201 |
+
import gc
|
| 202 |
+
gc.collect()
|
| 203 |
+
import torch
|
| 204 |
+
if torch.cuda.is_available():
|
| 205 |
+
torch.cuda.empty_cache()
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
sys.exit(0)
|
| 210 |
+
|
| 211 |
+
import re # import regex here to use in heuristic
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
main()
|
app/core/audio_mixer.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import math
|
| 3 |
+
from pydub import AudioSegment
|
| 4 |
+
|
| 5 |
+
class AudioMixer:
|
| 6 |
+
def __init__(self, ffmpeg_path="ffmpeg"):
|
| 7 |
+
AudioSegment.converter = str(ffmpeg_path)
|
| 8 |
+
|
| 9 |
+
def mix_dubbed_audio(self, background_path, vocals_path, original_audio_path, tts_segments, preserve_intervals, output_path, allow_degraded_fallback=True, segment_actions=None, chinese_vocal_handling="lower", chinese_vocal_volume_percent=35, vi_voiceover_enabled=True, vi_voiceover_volume_percent=100):
|
| 10 |
+
"""
|
| 11 |
+
Mixes background track, TTS audio segments, and original vocals for preserved zones.
|
| 12 |
+
Applies soft ducking and 80ms crossfades for transitions.
|
| 13 |
+
"""
|
| 14 |
+
print(f"Mixing stems: bg={background_path}, vocals={vocals_path}, orig={original_audio_path}")
|
| 15 |
+
|
| 16 |
+
# Load audio stems
|
| 17 |
+
import os
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
bg_path = Path(background_path) if background_path else None
|
| 20 |
+
if bg_path and bg_path.exists():
|
| 21 |
+
bg_audio = AudioSegment.from_file(str(bg_path))
|
| 22 |
+
else:
|
| 23 |
+
if not allow_degraded_fallback:
|
| 24 |
+
raise Exception("Background stem missing and degraded original audio fallback disabled.")
|
| 25 |
+
print("Warning: Background audio stem not found, falling back to original audio.")
|
| 26 |
+
bg_audio = AudioSegment.from_file(str(original_audio_path))
|
| 27 |
+
|
| 28 |
+
if vocals_path and vocals_path.exists():
|
| 29 |
+
vocals_audio = AudioSegment.from_file(vocals_path)
|
| 30 |
+
else:
|
| 31 |
+
vocals_audio = AudioSegment.from_file(original_audio_path)
|
| 32 |
+
|
| 33 |
+
original_audio = AudioSegment.from_file(str(original_audio_path)) if original_audio_path and Path(original_audio_path).exists() else None
|
| 34 |
+
if original_audio is None:
|
| 35 |
+
if not allow_degraded_fallback:
|
| 36 |
+
raise Exception("Original audio missing and degraded fallback disabled.")
|
| 37 |
+
original_audio = bg_audio
|
| 38 |
+
|
| 39 |
+
total_duration_ms = len(original_audio)
|
| 40 |
+
bg_audio = self._fit_duration(bg_audio, total_duration_ms)
|
| 41 |
+
vocals_audio = self._fit_duration(vocals_audio, total_duration_ms)
|
| 42 |
+
original_audio = self._fit_duration(original_audio, total_duration_ms)
|
| 43 |
+
|
| 44 |
+
# Voice overlay stream
|
| 45 |
+
voice_overlays = AudioSegment.silent(duration=total_duration_ms)
|
| 46 |
+
voice_active_mask = [False] * total_duration_ms
|
| 47 |
+
vi_voiceover_enabled = bool(vi_voiceover_enabled) and float(vi_voiceover_volume_percent) > 0
|
| 48 |
+
vi_gain_db = self._gain_db_from_percent(vi_voiceover_volume_percent)
|
| 49 |
+
|
| 50 |
+
# Overlay each TTS segment
|
| 51 |
+
for block_id, seg_path in tts_segments.items():
|
| 52 |
+
if not vi_voiceover_enabled:
|
| 53 |
+
continue
|
| 54 |
+
if not seg_path.exists():
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
segment = AudioSegment.from_file(seg_path)
|
| 58 |
+
if vi_gain_db is not None:
|
| 59 |
+
segment = segment + vi_gain_db
|
| 60 |
+
# Find start_ms from filename or block info
|
| 61 |
+
# The tts segments should be passed as {start_ms: seg_path}
|
| 62 |
+
try:
|
| 63 |
+
start_ms = int(block_id)
|
| 64 |
+
except ValueError:
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
if start_ms >= total_duration_ms:
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
# Overlay segment
|
| 71 |
+
voice_overlays = voice_overlays.overlay(segment, position=start_ms)
|
| 72 |
+
|
| 73 |
+
# Mark mask
|
| 74 |
+
seg_len = len(segment)
|
| 75 |
+
for m in range(start_ms, min(total_duration_ms, start_ms + seg_len)):
|
| 76 |
+
voice_active_mask[m] = True
|
| 77 |
+
|
| 78 |
+
if segment_actions:
|
| 79 |
+
final_audio = self._mix_language_aware(
|
| 80 |
+
original_audio=original_audio,
|
| 81 |
+
background_audio=bg_audio,
|
| 82 |
+
vocals_audio=vocals_audio,
|
| 83 |
+
voice_overlays=voice_overlays,
|
| 84 |
+
segment_actions=segment_actions,
|
| 85 |
+
chinese_vocal_handling=chinese_vocal_handling,
|
| 86 |
+
chinese_vocal_volume_percent=chinese_vocal_volume_percent,
|
| 87 |
+
total_duration_ms=total_duration_ms,
|
| 88 |
+
)
|
| 89 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
final_audio.export(str(output_path), format="wav")
|
| 91 |
+
print(f"Language-aware mixed track exported successfully to {output_path}")
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
# Legacy fallback: Group contiguous active voice intervals for ducking
|
| 95 |
+
active_intervals = []
|
| 96 |
+
in_active = False
|
| 97 |
+
start_act = 0
|
| 98 |
+
for m in range(total_duration_ms):
|
| 99 |
+
if voice_active_mask[m] and not in_active:
|
| 100 |
+
in_active = True
|
| 101 |
+
start_act = m
|
| 102 |
+
elif not voice_active_mask[m] and in_active:
|
| 103 |
+
in_active = False
|
| 104 |
+
active_intervals.append((start_act, m))
|
| 105 |
+
if in_active:
|
| 106 |
+
active_intervals.append((start_act, total_duration_ms))
|
| 107 |
+
|
| 108 |
+
# Duck background audio with 80ms crossfades
|
| 109 |
+
ducked_bg = bg_audio
|
| 110 |
+
crossfade_ms = 80
|
| 111 |
+
fully_ducked_bg = bg_audio - 10
|
| 112 |
+
|
| 113 |
+
for start, end in active_intervals:
|
| 114 |
+
if end - start <= 0:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
if end - start < crossfade_ms * 2:
|
| 118 |
+
# If segment is too short, just duck it directly
|
| 119 |
+
ducked_bg = ducked_bg[:start] + fully_ducked_bg[start:end] + ducked_bg[end:]
|
| 120 |
+
else:
|
| 121 |
+
# Crossfade normal -> ducked at the start
|
| 122 |
+
normal_fade_in_zone = bg_audio[start:start+crossfade_ms]
|
| 123 |
+
ducked_fade_in_zone = fully_ducked_bg[start:start+crossfade_ms]
|
| 124 |
+
transition_in = normal_fade_in_zone.fade_out(crossfade_ms).overlay(ducked_fade_in_zone.fade_in(crossfade_ms))
|
| 125 |
+
|
| 126 |
+
# Crossfade ducked -> normal at the end
|
| 127 |
+
ducked_fade_out_zone = fully_ducked_bg[end-crossfade_ms:end]
|
| 128 |
+
normal_fade_out_zone = bg_audio[end-crossfade_ms:end]
|
| 129 |
+
transition_out = ducked_fade_out_zone.fade_out(crossfade_ms).overlay(normal_fade_out_zone.fade_in(crossfade_ms))
|
| 130 |
+
|
| 131 |
+
# Fully ducked middle part
|
| 132 |
+
ducked_middle = fully_ducked_bg[start+crossfade_ms:end-crossfade_ms]
|
| 133 |
+
|
| 134 |
+
ducked_bg = ducked_bg[:start] + transition_in + ducked_middle + transition_out + ducked_bg[end:]
|
| 135 |
+
|
| 136 |
+
# Merge vocal segments in preserve regions (English dialogue)
|
| 137 |
+
final_audio = ducked_bg
|
| 138 |
+
for start, end in preserve_intervals:
|
| 139 |
+
if start >= total_duration_ms:
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
clip = vocals_audio[start:end]
|
| 143 |
+
# Overlay preserved original vocals
|
| 144 |
+
final_audio = final_audio.overlay(clip, position=start)
|
| 145 |
+
|
| 146 |
+
# Overlay Vietnamese TTS voices
|
| 147 |
+
if vi_voiceover_enabled:
|
| 148 |
+
final_audio = final_audio.overlay(voice_overlays)
|
| 149 |
+
|
| 150 |
+
# Export final WAV
|
| 151 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
final_audio.export(str(output_path), format="wav")
|
| 153 |
+
print(f"Mixed track exported successfully to {output_path}")
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
def _mix_language_aware(self, original_audio, background_audio, vocals_audio, voice_overlays, segment_actions, chinese_vocal_handling, chinese_vocal_volume_percent, total_duration_ms):
|
| 157 |
+
final_audio = original_audio
|
| 158 |
+
mode = (chinese_vocal_handling or "lower").strip().lower()
|
| 159 |
+
volume_ratio = max(0.0, min(1.0, float(chinese_vocal_volume_percent) / 100.0))
|
| 160 |
+
lower_gain_db = -60.0 if volume_ratio <= 0 else 20.0 * math.log10(volume_ratio)
|
| 161 |
+
|
| 162 |
+
edited_regions = []
|
| 163 |
+
for row in segment_actions:
|
| 164 |
+
# apply_chinese_vocal_control is set True for whichever source language is active (zh or en)
|
| 165 |
+
if not row.get("apply_chinese_vocal_control", False):
|
| 166 |
+
continue
|
| 167 |
+
start = max(0, int(row.get("start_ms", 0)))
|
| 168 |
+
end = min(total_duration_ms, int(row.get("end_ms", start)))
|
| 169 |
+
if end <= start:
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
if mode == "keep":
|
| 173 |
+
edited_regions.append({"id": row.get("id"), "start_ms": start, "end_ms": end, "mode": "keep"})
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
if mode in ("mute", "remove_if_possible"):
|
| 177 |
+
replacement = background_audio[start:end]
|
| 178 |
+
if len(replacement) <= 0:
|
| 179 |
+
replacement = original_audio[start:end] - 35
|
| 180 |
+
applied_mode = "remove_vocal_stem" if mode == "remove_if_possible" else "mute_to_background"
|
| 181 |
+
else:
|
| 182 |
+
bg_clip = background_audio[start:end]
|
| 183 |
+
vocal_clip = vocals_audio[start:end] + lower_gain_db
|
| 184 |
+
replacement = bg_clip.overlay(vocal_clip)
|
| 185 |
+
applied_mode = f"lower_to_{int(volume_ratio * 100)}pct"
|
| 186 |
+
|
| 187 |
+
replacement = self._fit_duration(replacement, end - start)
|
| 188 |
+
final_audio = final_audio[:start] + replacement + final_audio[end:]
|
| 189 |
+
edited_regions.append({"id": row.get("id"), "start_ms": start, "end_ms": end, "mode": applied_mode})
|
| 190 |
+
|
| 191 |
+
final_audio = final_audio.overlay(voice_overlays)
|
| 192 |
+
print(f"Language-aware Chinese vocal regions edited: {len(edited_regions)}")
|
| 193 |
+
return final_audio
|
| 194 |
+
|
| 195 |
+
def _fit_duration(self, audio, target_ms):
|
| 196 |
+
if len(audio) == target_ms:
|
| 197 |
+
return audio
|
| 198 |
+
if len(audio) > target_ms:
|
| 199 |
+
return audio[:target_ms]
|
| 200 |
+
return audio + AudioSegment.silent(duration=target_ms - len(audio))
|
| 201 |
+
|
| 202 |
+
def _gain_db_from_percent(self, percent):
|
| 203 |
+
ratio = max(0.0, float(percent) / 100.0)
|
| 204 |
+
if ratio <= 0:
|
| 205 |
+
return None
|
| 206 |
+
return 20.0 * math.log10(ratio)
|
app/core/audio_timeline_classifier.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
class AudioTimelineClassifier:
|
| 6 |
+
def __init__(self, keywords=None, latin_threshold=0.85, duration_threshold_ms=3000):
|
| 7 |
+
# Config default thresholds
|
| 8 |
+
self.latin_threshold = latin_threshold
|
| 9 |
+
self.duration_threshold_ms = duration_threshold_ms
|
| 10 |
+
self.allow_chinese_vocal_control_on_music = True
|
| 11 |
+
|
| 12 |
+
# Load keywords
|
| 13 |
+
if keywords is not None:
|
| 14 |
+
self.keywords = [k.lower() for k in keywords]
|
| 15 |
+
else:
|
| 16 |
+
self.keywords = [
|
| 17 |
+
"rapper", "drip", "flow", "streetwear", "hip-hop",
|
| 18 |
+
"syna", "outfits", "bape", "brand", "artist", "hype",
|
| 19 |
+
"custom", "lifestyle", "drop", "yeezy", "supreme"
|
| 20 |
+
]
|
| 21 |
+
# Try to load from glossary.json in project root
|
| 22 |
+
try:
|
| 23 |
+
glossary_path = Path(__file__).parent.parent.parent / "glossary.json"
|
| 24 |
+
if glossary_path.exists():
|
| 25 |
+
with open(glossary_path, "r", encoding="utf-8") as f:
|
| 26 |
+
data = json.load(f)
|
| 27 |
+
glossary_keys = list(data.get("glossary", {}).keys())
|
| 28 |
+
for k in glossary_keys:
|
| 29 |
+
k_lower = k.lower().strip()
|
| 30 |
+
if k_lower and k_lower not in self.keywords:
|
| 31 |
+
self.keywords.append(k_lower)
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Warning: Failed to load glossary.json in AudioTimelineClassifier: {e}")
|
| 34 |
+
|
| 35 |
+
# Try to load config.json for custom thresholds
|
| 36 |
+
try:
|
| 37 |
+
config_path = Path(__file__).parent.parent.parent / "config.json"
|
| 38 |
+
if config_path.exists():
|
| 39 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 40 |
+
config_data = json.load(f)
|
| 41 |
+
self.latin_threshold = config_data.get("en_preserve_latin_ratio", self.latin_threshold)
|
| 42 |
+
if "en_preserve_duration" in config_data:
|
| 43 |
+
self.duration_threshold_ms = int(config_data["en_preserve_duration"] * 1000)
|
| 44 |
+
audio_cfg = config_data.get("audio", {})
|
| 45 |
+
self.allow_chinese_vocal_control_on_music = audio_cfg.get(
|
| 46 |
+
"allow_chinese_vocal_control_on_music",
|
| 47 |
+
self.allow_chinese_vocal_control_on_music,
|
| 48 |
+
)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"Warning: Failed to load config.json in AudioTimelineClassifier: {e}")
|
| 51 |
+
|
| 52 |
+
# Source language: "zh" (Chinese -> Vi) or "en" (English -> Vi)
|
| 53 |
+
self.source_language = "zh"
|
| 54 |
+
try:
|
| 55 |
+
config_path = Path(__file__).parent.parent.parent / "config.json"
|
| 56 |
+
if config_path.exists():
|
| 57 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 58 |
+
cfg2 = json.load(f)
|
| 59 |
+
self.source_language = cfg2.get("source_language", "zh")
|
| 60 |
+
except Exception as e:
|
| 61 |
+
print(f"Warning: Failed to load source_language from config.json: {e}")
|
| 62 |
+
|
| 63 |
+
def get_latin_ratio(self, text):
|
| 64 |
+
if not text:
|
| 65 |
+
return 0.0
|
| 66 |
+
# Count alphanumeric Latin characters, numbers, and common English punctuation
|
| 67 |
+
latin_chars = len(re.findall(r'[a-zA-Z0-9]', text))
|
| 68 |
+
total_chars = len(re.sub(r'\s+', '', text))
|
| 69 |
+
if total_chars == 0:
|
| 70 |
+
return 0.0
|
| 71 |
+
return latin_chars / total_chars
|
| 72 |
+
|
| 73 |
+
def contains_only_short_en_keywords(self, text):
|
| 74 |
+
# Extract all english words
|
| 75 |
+
words = re.findall(r'[a-zA-Z]+', text.lower())
|
| 76 |
+
if not words:
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# Check if all words are within our keyword list
|
| 80 |
+
return all(w in self.keywords for w in words)
|
| 81 |
+
|
| 82 |
+
def classify_blocks(self, srt_blocks, language_segments=None, include_actions=False):
|
| 83 |
+
"""
|
| 84 |
+
Classifies subtitle blocks into language-aware actions.
|
| 85 |
+
Returns:
|
| 86 |
+
classifications: dict of block_id -> label
|
| 87 |
+
preserve_intervals: list of (start_ms, end_ms) timelines to preserve original audio
|
| 88 |
+
skipped_tts_ids: list of block IDs that do not need TTS
|
| 89 |
+
segment_actions: optional list of per-block language/action metadata
|
| 90 |
+
"""
|
| 91 |
+
n = len(srt_blocks)
|
| 92 |
+
labels = {}
|
| 93 |
+
|
| 94 |
+
# Heuristic Latin checks first
|
| 95 |
+
is_latin_block = []
|
| 96 |
+
for b in srt_blocks:
|
| 97 |
+
text = b["text"]
|
| 98 |
+
ratio = self.get_latin_ratio(text)
|
| 99 |
+
|
| 100 |
+
# Subtitle is mostly English, but NOT just short keywords
|
| 101 |
+
is_latin = ratio >= self.latin_threshold and not re.search(r'[\u4e00-\u9fff]', text)
|
| 102 |
+
if is_latin and self.contains_only_short_en_keywords(text):
|
| 103 |
+
is_latin = False
|
| 104 |
+
is_latin_block.append(is_latin)
|
| 105 |
+
|
| 106 |
+
# 1. Look for consecutive preserve-language blocks or >= duration_threshold_ms blocks
|
| 107 |
+
# For zh source: preserve English (is_latin_block)
|
| 108 |
+
# For en source: preserve CJK-dominant blocks
|
| 109 |
+
preserve_flags = [False] * n
|
| 110 |
+
if self.source_language == "zh":
|
| 111 |
+
is_preserve_block = is_latin_block
|
| 112 |
+
else:
|
| 113 |
+
# English source: preserve CJK blocks
|
| 114 |
+
is_preserve_block = []
|
| 115 |
+
for b in srt_blocks:
|
| 116 |
+
text = b["text"]
|
| 117 |
+
cjk_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
|
| 118 |
+
total = max(1, len(re.sub(r'\s+', '', text)))
|
| 119 |
+
is_preserve_block.append(cjk_chars / total >= 0.25)
|
| 120 |
+
i = 0
|
| 121 |
+
while i < n:
|
| 122 |
+
if is_preserve_block[i]:
|
| 123 |
+
j = i
|
| 124 |
+
while j < n and is_preserve_block[j]:
|
| 125 |
+
j += 1
|
| 126 |
+
length = j - i
|
| 127 |
+
|
| 128 |
+
# Check if group duration >= duration_threshold_ms
|
| 129 |
+
group_duration = srt_blocks[j-1]["end_ms"] - srt_blocks[i]["start_ms"]
|
| 130 |
+
|
| 131 |
+
if length >= 2 or group_duration >= self.duration_threshold_ms:
|
| 132 |
+
for k in range(i, j):
|
| 133 |
+
preserve_flags[k] = True
|
| 134 |
+
i = j
|
| 135 |
+
else:
|
| 136 |
+
i += 1
|
| 137 |
+
|
| 138 |
+
# 2. Overlap check with Whisper language segments (Stage 4 output)
|
| 139 |
+
whisper_preserve_regions = []
|
| 140 |
+
whisper_music_regions = []
|
| 141 |
+
if language_segments:
|
| 142 |
+
# Group consecutive preserve-lang segments in whisper output
|
| 143 |
+
# For zh-source: preserve "en" segments; for en-source: preserve "zh" segments
|
| 144 |
+
preserve_audio_lang = "en" if self.source_language == "zh" else "zh"
|
| 145 |
+
pres_runs = []
|
| 146 |
+
current_run = []
|
| 147 |
+
for seg in language_segments:
|
| 148 |
+
if seg.get("language") == preserve_audio_lang:
|
| 149 |
+
current_run.append(seg)
|
| 150 |
+
else:
|
| 151 |
+
if current_run:
|
| 152 |
+
pres_runs.append(current_run)
|
| 153 |
+
current_run = []
|
| 154 |
+
if current_run:
|
| 155 |
+
pres_runs.append(current_run)
|
| 156 |
+
|
| 157 |
+
for run in pres_runs:
|
| 158 |
+
start_run = run[0]["start"] * 1000
|
| 159 |
+
end_run = run[-1]["end"] * 1000
|
| 160 |
+
dur = end_run - start_run
|
| 161 |
+
# Preserve if >= duration_threshold_ms or multiple consecutive segments
|
| 162 |
+
if dur >= self.duration_threshold_ms or len(run) >= 2:
|
| 163 |
+
whisper_preserve_regions.append((start_run, end_run))
|
| 164 |
+
|
| 165 |
+
for seg in language_segments:
|
| 166 |
+
if seg.get("language") == "music":
|
| 167 |
+
whisper_music_regions.append((float(seg.get("start", 0)) * 1000, float(seg.get("end", 0)) * 1000))
|
| 168 |
+
|
| 169 |
+
# Classify each block
|
| 170 |
+
for idx, b in enumerate(srt_blocks):
|
| 171 |
+
b_id = b["id"]
|
| 172 |
+
text = b["text"]
|
| 173 |
+
start_ms = b["start_ms"]
|
| 174 |
+
end_ms = b["end_ms"]
|
| 175 |
+
|
| 176 |
+
detected_language = self._detect_block_language(b, language_segments, whisper_preserve_regions, whisper_music_regions)
|
| 177 |
+
|
| 178 |
+
if not text.strip() or detected_language == "music":
|
| 179 |
+
labels[b_id] = "MUSIC_ONLY"
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
# Check overlap with whisper preserve regions
|
| 183 |
+
in_whisper_preserve = False
|
| 184 |
+
for w_start, w_end in whisper_preserve_regions:
|
| 185 |
+
overlap = max(0, min(end_ms, w_end) - max(start_ms, w_start))
|
| 186 |
+
block_dur = end_ms - start_ms
|
| 187 |
+
if block_dur > 0 and (overlap / block_dur) >= 0.5:
|
| 188 |
+
in_whisper_preserve = True
|
| 189 |
+
break
|
| 190 |
+
|
| 191 |
+
# preserve_flags: block was detected as the preserve language (e.g. EN in zh-mode, ZH in en-mode)
|
| 192 |
+
# These should keep original audio, NOT be translated
|
| 193 |
+
if preserve_flags[idx] or in_whisper_preserve:
|
| 194 |
+
labels[b_id] = "ORIG_PRESERVE"
|
| 195 |
+
continue
|
| 196 |
+
|
| 197 |
+
if detected_language == self.source_language:
|
| 198 |
+
# Source language -> translate/dub
|
| 199 |
+
speech_label = "ZH_SPEECH" if self.source_language == "zh" else "EN_SPEECH"
|
| 200 |
+
labels[b_id] = speech_label
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
preserve_lang = "en" if self.source_language == "zh" else "zh"
|
| 204 |
+
if detected_language == preserve_lang:
|
| 205 |
+
labels[b_id] = "ORIG_PRESERVE"
|
| 206 |
+
continue
|
| 207 |
+
|
| 208 |
+
if detected_language in ("unknown", "mixed"):
|
| 209 |
+
labels[b_id] = "NEEDS_REVIEW_PRESERVE"
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
# Check if short-keyword mixed text -> still treat as source language speech
|
| 213 |
+
latin_ratio = self.get_latin_ratio(text)
|
| 214 |
+
if self.source_language == "zh":
|
| 215 |
+
if latin_ratio > 0 and (latin_ratio < 0.40 or self.contains_only_short_en_keywords(text)):
|
| 216 |
+
labels[b_id] = "MIXED_SHORT_EN"
|
| 217 |
+
else:
|
| 218 |
+
labels[b_id] = "ZH_SPEECH"
|
| 219 |
+
else:
|
| 220 |
+
# en source: any remaining non-CJK text is EN_SPEECH
|
| 221 |
+
labels[b_id] = "EN_SPEECH"
|
| 222 |
+
|
| 223 |
+
# Calculate final preserve intervals
|
| 224 |
+
preserve_intervals = []
|
| 225 |
+
skipped_tts_ids = []
|
| 226 |
+
|
| 227 |
+
# Merge consecutive preserve/review blocks into timeline intervals
|
| 228 |
+
# Source speech labels: ZH_SPEECH, EN_SPEECH, MIXED_SHORT_EN -> need TTS
|
| 229 |
+
# Preserve labels: ORIG_PRESERVE, NEEDS_REVIEW_PRESERVE, MUSIC_ONLY -> keep original audio
|
| 230 |
+
source_speech_labels = {"ZH_SPEECH", "EN_SPEECH", "MIXED_SHORT_EN"}
|
| 231 |
+
preserve_labels = {"ORIG_PRESERVE", "NEEDS_REVIEW_PRESERVE"}
|
| 232 |
+
i = 0
|
| 233 |
+
while i < n:
|
| 234 |
+
b_id = srt_blocks[i]["id"]
|
| 235 |
+
if labels[b_id] in preserve_labels:
|
| 236 |
+
start_act = srt_blocks[i]["start_ms"]
|
| 237 |
+
while i < n and labels[srt_blocks[i]["id"]] in preserve_labels:
|
| 238 |
+
skipped_tts_ids.append(srt_blocks[i]["id"])
|
| 239 |
+
i += 1
|
| 240 |
+
end_act = srt_blocks[i-1]["end_ms"]
|
| 241 |
+
preserve_intervals.append((start_act, end_act))
|
| 242 |
+
else:
|
| 243 |
+
if labels[b_id] == "MUSIC_ONLY":
|
| 244 |
+
skipped_tts_ids.append(b_id)
|
| 245 |
+
i += 1
|
| 246 |
+
|
| 247 |
+
# Also add any whisper preserve regions that don't overlap with source-speech dubbed blocks
|
| 248 |
+
for w_start, w_end in whisper_preserve_regions:
|
| 249 |
+
has_dub_overlap = False
|
| 250 |
+
for b in srt_blocks:
|
| 251 |
+
b_id = b["id"]
|
| 252 |
+
if labels[b_id] in source_speech_labels:
|
| 253 |
+
overlap = max(0, min(b["end_ms"], w_end) - max(b["start_ms"], w_start))
|
| 254 |
+
if overlap > 0:
|
| 255 |
+
has_dub_overlap = True
|
| 256 |
+
break
|
| 257 |
+
if not has_dub_overlap:
|
| 258 |
+
preserve_intervals.append((w_start, w_end))
|
| 259 |
+
|
| 260 |
+
for m_start, m_end in whisper_music_regions:
|
| 261 |
+
preserve_intervals.append((m_start, m_end))
|
| 262 |
+
|
| 263 |
+
# Sort and merge overlapping intervals
|
| 264 |
+
preserve_intervals.sort(key=lambda x: x[0])
|
| 265 |
+
merged_intervals = []
|
| 266 |
+
for interval in preserve_intervals:
|
| 267 |
+
if not merged_intervals:
|
| 268 |
+
merged_intervals.append(list(interval))
|
| 269 |
+
else:
|
| 270 |
+
last = merged_intervals[-1]
|
| 271 |
+
if interval[0] <= last[1] + 100: # merge if overlaps or within 100ms
|
| 272 |
+
last[1] = max(last[1], interval[1])
|
| 273 |
+
else:
|
| 274 |
+
merged_intervals.append(list(interval))
|
| 275 |
+
|
| 276 |
+
segment_actions = self._build_segment_actions(srt_blocks, labels, language_segments)
|
| 277 |
+
|
| 278 |
+
if include_actions:
|
| 279 |
+
return labels, [tuple(x) for x in merged_intervals], skipped_tts_ids, segment_actions
|
| 280 |
+
return labels, [tuple(x) for x in merged_intervals], skipped_tts_ids
|
| 281 |
+
|
| 282 |
+
def _detect_block_language(self, block, language_segments, whisper_preserve_regions, whisper_music_regions):
|
| 283 |
+
text = block.get("text", "")
|
| 284 |
+
start_ms = block.get("start_ms", 0)
|
| 285 |
+
end_ms = block.get("end_ms", start_ms)
|
| 286 |
+
if not text.strip():
|
| 287 |
+
return "music"
|
| 288 |
+
|
| 289 |
+
overlap_lang = self._dominant_overlap_language(start_ms, end_ms, language_segments)
|
| 290 |
+
text_lang = self._script_language(text)
|
| 291 |
+
|
| 292 |
+
if self._overlaps_any(start_ms, end_ms, whisper_music_regions, 0.5):
|
| 293 |
+
if text_lang == "zh" and self.allow_chinese_vocal_control_on_music:
|
| 294 |
+
return "zh"
|
| 295 |
+
return "music"
|
| 296 |
+
|
| 297 |
+
if overlap_lang in ("en", "music") and text_lang != "zh":
|
| 298 |
+
return overlap_lang
|
| 299 |
+
if overlap_lang == "zh" and text_lang in ("zh", "mixed", "unknown"):
|
| 300 |
+
return "zh"
|
| 301 |
+
if text_lang == "zh":
|
| 302 |
+
return "zh"
|
| 303 |
+
if text_lang == "en":
|
| 304 |
+
return "en"
|
| 305 |
+
if text_lang == "mixed":
|
| 306 |
+
return "mixed"
|
| 307 |
+
if self._overlaps_any(start_ms, end_ms, whisper_preserve_regions, 0.5):
|
| 308 |
+
return "en"
|
| 309 |
+
return "unknown"
|
| 310 |
+
|
| 311 |
+
def _script_language(self, text):
|
| 312 |
+
clean = re.sub(r"\s+", "", str(text))
|
| 313 |
+
if not clean:
|
| 314 |
+
return "music"
|
| 315 |
+
zh_chars = len(re.findall(r"[\u4e00-\u9fff]", clean))
|
| 316 |
+
latin_chars = len(re.findall(r"[A-Za-z]", clean))
|
| 317 |
+
total = max(1, len(clean))
|
| 318 |
+
zh_ratio = zh_chars / total
|
| 319 |
+
latin_ratio = latin_chars / total
|
| 320 |
+
if zh_chars and latin_chars:
|
| 321 |
+
if zh_ratio >= 0.35:
|
| 322 |
+
return "zh"
|
| 323 |
+
return "mixed"
|
| 324 |
+
if zh_ratio >= 0.20:
|
| 325 |
+
return "zh"
|
| 326 |
+
if latin_ratio >= self.latin_threshold:
|
| 327 |
+
return "en"
|
| 328 |
+
return "unknown"
|
| 329 |
+
|
| 330 |
+
def _dominant_overlap_language(self, start_ms, end_ms, language_segments):
|
| 331 |
+
if not language_segments:
|
| 332 |
+
return None
|
| 333 |
+
totals = {}
|
| 334 |
+
for seg in language_segments:
|
| 335 |
+
lang = seg.get("language")
|
| 336 |
+
if lang not in ("zh", "en", "music", "mixed", "unknown"):
|
| 337 |
+
continue
|
| 338 |
+
s = float(seg.get("start", 0)) * 1000
|
| 339 |
+
e = float(seg.get("end", 0)) * 1000
|
| 340 |
+
overlap = max(0, min(end_ms, e) - max(start_ms, s))
|
| 341 |
+
if overlap > 0:
|
| 342 |
+
totals[lang] = totals.get(lang, 0) + overlap
|
| 343 |
+
if not totals:
|
| 344 |
+
return None
|
| 345 |
+
lang, overlap = max(totals.items(), key=lambda kv: kv[1])
|
| 346 |
+
block_dur = max(1, end_ms - start_ms)
|
| 347 |
+
return lang if overlap / block_dur >= 0.45 else None
|
| 348 |
+
|
| 349 |
+
def _overlaps_any(self, start_ms, end_ms, intervals, threshold=0.5):
|
| 350 |
+
block_dur = max(1, end_ms - start_ms)
|
| 351 |
+
for s, e in intervals:
|
| 352 |
+
overlap = max(0, min(end_ms, e) - max(start_ms, s))
|
| 353 |
+
if overlap / block_dur >= threshold:
|
| 354 |
+
return True
|
| 355 |
+
return False
|
| 356 |
+
|
| 357 |
+
def _build_segment_actions(self, srt_blocks, labels, language_segments):
|
| 358 |
+
actions = []
|
| 359 |
+
src_lang = self.source_language # "zh" or "en"
|
| 360 |
+
for b in srt_blocks:
|
| 361 |
+
b_id = str(b.get("id"))
|
| 362 |
+
label = labels.get(b_id, "NEEDS_REVIEW_PRESERVE")
|
| 363 |
+
|
| 364 |
+
# Map label -> detected_language
|
| 365 |
+
if label in ("ZH_SPEECH", "MIXED_SHORT_EN"):
|
| 366 |
+
detected_language = "zh"
|
| 367 |
+
elif label == "EN_SPEECH":
|
| 368 |
+
detected_language = "en"
|
| 369 |
+
elif label == "ORIG_PRESERVE":
|
| 370 |
+
# The preserve language is the opposite of source
|
| 371 |
+
detected_language = "en" if src_lang == "zh" else "zh"
|
| 372 |
+
elif label == "MUSIC_ONLY":
|
| 373 |
+
detected_language = "music"
|
| 374 |
+
elif label == "NEEDS_REVIEW_PRESERVE":
|
| 375 |
+
detected_language = self._script_language(b.get("text", ""))
|
| 376 |
+
if detected_language not in ("mixed", "unknown"):
|
| 377 |
+
detected_language = "unknown"
|
| 378 |
+
else:
|
| 379 |
+
detected_language = "unknown"
|
| 380 |
+
|
| 381 |
+
# translate_to_vi: only if this block is in the source language
|
| 382 |
+
translate_to_vi = (detected_language == src_lang)
|
| 383 |
+
generate_vi_voiceover = translate_to_vi
|
| 384 |
+
# apply_chinese_vocal_control is kept as a key name for backward-compat with AudioMixer
|
| 385 |
+
# but its semantic is now: apply vocal control to source-language segments
|
| 386 |
+
apply_chinese_vocal_control = (detected_language == src_lang)
|
| 387 |
+
keep_original_audio = not translate_to_vi
|
| 388 |
+
needs_review = detected_language in ("mixed", "unknown") or label == "NEEDS_REVIEW_PRESERVE"
|
| 389 |
+
action = "translate_voiceover" if translate_to_vi else "keep_original_audio"
|
| 390 |
+
actions.append({
|
| 391 |
+
"id": b_id,
|
| 392 |
+
"start": self._fmt_ms(b.get("start_ms", 0)),
|
| 393 |
+
"end": self._fmt_ms(b.get("end_ms", 0)),
|
| 394 |
+
"start_ms": int(b.get("start_ms", 0)),
|
| 395 |
+
"end_ms": int(b.get("end_ms", 0)),
|
| 396 |
+
"duration_sec": round(max(0, int(b.get("end_ms", 0)) - int(b.get("start_ms", 0))) / 1000.0, 3),
|
| 397 |
+
"source": b.get("text", ""),
|
| 398 |
+
"detected_language": detected_language,
|
| 399 |
+
"label": label,
|
| 400 |
+
"translate_to_vi": translate_to_vi,
|
| 401 |
+
"generate_vi_voiceover": generate_vi_voiceover,
|
| 402 |
+
"apply_chinese_vocal_control": apply_chinese_vocal_control,
|
| 403 |
+
"keep_original_audio": keep_original_audio,
|
| 404 |
+
"action": action,
|
| 405 |
+
"needs_review": needs_review,
|
| 406 |
+
})
|
| 407 |
+
return actions
|
| 408 |
+
|
| 409 |
+
def _fmt_ms(self, ms):
|
| 410 |
+
ms = int(ms or 0)
|
| 411 |
+
h, rem = divmod(ms, 3600000)
|
| 412 |
+
m, rem = divmod(rem, 60000)
|
| 413 |
+
s, milli = divmod(rem, 1000)
|
| 414 |
+
return f"{h:02d}:{m:02d}:{s:02d}.{milli:03d}"
|
app/core/batch_reporter.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from app.core.job_manager import JobManager
|
| 6 |
+
|
| 7 |
+
class BatchReporter:
|
| 8 |
+
@staticmethod
|
| 9 |
+
def generate_report(video_list, output_dir, log_fn=None):
|
| 10 |
+
report_data = []
|
| 11 |
+
for video_path in video_list:
|
| 12 |
+
job_id = JobManager.instance().get_job_id(video_path)
|
| 13 |
+
if job_id:
|
| 14 |
+
report = JobManager.instance().get_job_report(job_id)
|
| 15 |
+
if report:
|
| 16 |
+
report_data.append(report)
|
| 17 |
+
|
| 18 |
+
output_dir = Path(output_dir)
|
| 19 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
report_json_path = output_dir / "batch_report.json"
|
| 22 |
+
try:
|
| 23 |
+
with open(report_json_path, "w", encoding="utf-8") as f:
|
| 24 |
+
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
| 25 |
+
except Exception as e:
|
| 26 |
+
msg = f"Failed to save JSON report: {e}"
|
| 27 |
+
if log_fn:
|
| 28 |
+
log_fn(f"⚠️ {msg}")
|
| 29 |
+
else:
|
| 30 |
+
print(msg)
|
| 31 |
+
|
| 32 |
+
report_csv_path = output_dir / "batch_report.csv"
|
| 33 |
+
try:
|
| 34 |
+
with open(report_csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
| 35 |
+
writer = csv.writer(f)
|
| 36 |
+
writer.writerow([
|
| 37 |
+
"Job ID", "Video Path", "Status", "Error Message",
|
| 38 |
+
"Created At", "Updated At", "Stages"
|
| 39 |
+
])
|
| 40 |
+
for r in report_data:
|
| 41 |
+
writer.writerow([
|
| 42 |
+
r["id"],
|
| 43 |
+
r["video_path"],
|
| 44 |
+
r["status"],
|
| 45 |
+
r["error_message"] or "",
|
| 46 |
+
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(r["created_at"])),
|
| 47 |
+
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(r["updated_at"])),
|
| 48 |
+
str(r["stages"]),
|
| 49 |
+
])
|
| 50 |
+
msg = f"Đã tạo báo cáo batch tại {report_json_path} và {report_csv_path}"
|
| 51 |
+
if log_fn:
|
| 52 |
+
log_fn(f"📊 {msg}")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
msg = f"Failed to save CSV report: {e}"
|
| 55 |
+
if log_fn:
|
| 56 |
+
log_fn(f"⚠️ {msg}")
|
| 57 |
+
else:
|
| 58 |
+
print(msg)
|
| 59 |
+
|
| 60 |
+
report_txt_path = output_dir / "batch_report.txt"
|
| 61 |
+
try:
|
| 62 |
+
with open(report_txt_path, "w", encoding="utf-8") as f:
|
| 63 |
+
f.write("=== BÁO CÁO LÔ (BATCH REPORT) ===\n")
|
| 64 |
+
f.write(f"Thời gian tạo: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 65 |
+
f.write(f"Tổng số video: {len(report_data)}\n")
|
| 66 |
+
f.write(f"Thành công: {sum(1 for r in report_data if r['status'] == 'DONE')}\n")
|
| 67 |
+
f.write(f"Lỗi: {sum(1 for r in report_data if r['status'] == 'FAILED')}\n")
|
| 68 |
+
f.write(f"Review: {sum(1 for r in report_data if r['status'] == 'NEED_REVIEW')}\n")
|
| 69 |
+
f.write(f"Giảm chất lượng: {sum(1 for r in report_data if r['status'] == 'QUALITY_DEGRADED')}\n")
|
| 70 |
+
f.write("="*40 + "\n\n")
|
| 71 |
+
for r in report_data:
|
| 72 |
+
f.write(f"Video: {r['video_path']}\n")
|
| 73 |
+
f.write(f" ID: {r['id']}\n")
|
| 74 |
+
f.write(f" Trạng thái: {r['status']}\n")
|
| 75 |
+
if r['error_message']:
|
| 76 |
+
f.write(f" Chi tiết lỗi: {r['error_message']}\n")
|
| 77 |
+
f.write(f" Bắt đầu: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(r['created_at']))}\n")
|
| 78 |
+
f.write(f" Cập nhật: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(r['updated_at']))}\n")
|
| 79 |
+
f.write(" Các Stage:\n")
|
| 80 |
+
for s in r['stages']:
|
| 81 |
+
for s_name, s_status in s.items():
|
| 82 |
+
f.write(f" - {s_name}: {s_status}\n")
|
| 83 |
+
f.write("-" * 40 + "\n")
|
| 84 |
+
if log_fn:
|
| 85 |
+
log_fn(f"📊 Đã tạo báo cáo dạng văn bản tại {report_txt_path}")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
msg = f"Failed to save TXT report: {e}"
|
| 88 |
+
if log_fn:
|
| 89 |
+
log_fn(f"⚠️ {msg}")
|
| 90 |
+
else:
|
| 91 |
+
print(msg)
|
app/core/cloud_asr.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/cloud_asr.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Cloud-Native ASR Module using Groq Whisper-large-v3 & Gemini Audio Direct.
|
| 5 |
+
Requires 0 GPU/VRAM, processes a 5-minute video audio in ~2 seconds.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import requests
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import List, Dict, Optional, Tuple, Callable
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CloudASREngine:
|
| 18 |
+
def __init__(self, groq_keys: Optional[List[str]] = None, gemini_keys: Optional[List[str]] = None, log_fn: Optional[Callable[[str], None]] = None):
|
| 19 |
+
self.log_fn = log_fn or print
|
| 20 |
+
self.groq_keys = groq_keys or self._load_keys("GROQ_KEY")
|
| 21 |
+
self.gemini_keys = gemini_keys or self._load_keys("GEMINI_KEY")
|
| 22 |
+
|
| 23 |
+
def _log(self, msg: str):
|
| 24 |
+
self.log_fn(f"[Cloud ASR] {msg}")
|
| 25 |
+
|
| 26 |
+
def _load_keys(self, prefix: str) -> List[str]:
|
| 27 |
+
keys = []
|
| 28 |
+
for k, v in os.environ.items():
|
| 29 |
+
if k.startswith(prefix) and v.strip():
|
| 30 |
+
keys.append(v.strip())
|
| 31 |
+
|
| 32 |
+
env_path = Path(__file__).resolve().parents[2] / ".env"
|
| 33 |
+
if env_path.exists():
|
| 34 |
+
try:
|
| 35 |
+
from dotenv import dotenv_values
|
| 36 |
+
env_dict = dotenv_values(env_path)
|
| 37 |
+
for k, v in env_dict.items():
|
| 38 |
+
if k.startswith(prefix) and v and v.strip() and v.strip() not in keys:
|
| 39 |
+
keys.append(v.strip())
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
return keys
|
| 43 |
+
|
| 44 |
+
def transcribe_audio_to_srt(self, audio_path: str, output_srt_path: str, source_lang: str = "zh") -> bool:
|
| 45 |
+
"""
|
| 46 |
+
Transcribes audio to standard SRT file using multi-tier Cloud APIs:
|
| 47 |
+
Tier 1: Groq Whisper-large-v3
|
| 48 |
+
Tier 2: Groq Whisper-large-v3-turbo
|
| 49 |
+
Tier 3: Gemini 2.0 Flash Audio Direct
|
| 50 |
+
"""
|
| 51 |
+
audio_file = Path(audio_path)
|
| 52 |
+
if not audio_file.exists():
|
| 53 |
+
self._log(f"❌ Audio file not found: {audio_path}")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
# Tier 1 & 2: Groq Whisper
|
| 57 |
+
if self.groq_keys:
|
| 58 |
+
for model_name in ["whisper-large-v3", "whisper-large-v3-turbo"]:
|
| 59 |
+
for key in self.groq_keys:
|
| 60 |
+
try:
|
| 61 |
+
self._log(f"🎙️ Sending audio to Groq Whisper ({model_name})...")
|
| 62 |
+
segments = self._transcribe_groq(audio_file, key, model=model_name, lang=source_lang)
|
| 63 |
+
if segments:
|
| 64 |
+
self._write_srt(segments, output_srt_path)
|
| 65 |
+
self._log(f"✅ Groq Whisper hoàn tất thành công! Xuất {len(segments)} dòng phụ đề.")
|
| 66 |
+
return True
|
| 67 |
+
except Exception as e:
|
| 68 |
+
self._log(f"⚠️ Groq ({model_name}) error: {e}, thử fallback...")
|
| 69 |
+
|
| 70 |
+
# Tier 3: Gemini Flash Audio Direct
|
| 71 |
+
if self.gemini_keys:
|
| 72 |
+
for key in self.gemini_keys:
|
| 73 |
+
try:
|
| 74 |
+
self._log("🎙️ Fallback sang Google Gemini 2.0 Flash Audio...")
|
| 75 |
+
segments = self._transcribe_gemini(audio_file, key, lang=source_lang)
|
| 76 |
+
if segments:
|
| 77 |
+
self._write_srt(segments, output_srt_path)
|
| 78 |
+
self._log(f"✅ Gemini Audio hoàn tất! Xuất {len(segments)} dòng phụ đề.")
|
| 79 |
+
return True
|
| 80 |
+
except Exception as e:
|
| 81 |
+
self._log(f"⚠️ Gemini Audio error: {e}")
|
| 82 |
+
|
| 83 |
+
self._log("❌ Tất cả các Cloud ASR provider đều thất bại hoặc thiếu API Key!")
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
def _transcribe_groq(self, audio_path: Path, api_key: str, model: str = "whisper-large-v3", lang: str = "zh") -> List[Dict]:
|
| 87 |
+
url = "https://api.groq.com/openai/v1/audio/transcriptions"
|
| 88 |
+
headers = {"Authorization": f"Bearer {api_key}"}
|
| 89 |
+
|
| 90 |
+
lang_code = "zh" if "zh" in lang.lower() or "trung" in lang.lower() else "en"
|
| 91 |
+
|
| 92 |
+
with open(audio_path, "rb") as f:
|
| 93 |
+
files = {
|
| 94 |
+
"file": (audio_path.name, f, "audio/wav"),
|
| 95 |
+
"model": (None, model),
|
| 96 |
+
"response_format": (None, "verbose_json"),
|
| 97 |
+
"language": (None, lang_code),
|
| 98 |
+
"temperature": (None, "0.0")
|
| 99 |
+
}
|
| 100 |
+
res = requests.post(url, headers=headers, files=files, timeout=90)
|
| 101 |
+
|
| 102 |
+
if res.status_code != 200:
|
| 103 |
+
raise RuntimeError(f"Groq HTTP {res.status_code}: {res.text}")
|
| 104 |
+
|
| 105 |
+
data = res.json()
|
| 106 |
+
raw_segments = data.get("segments", [])
|
| 107 |
+
|
| 108 |
+
results = []
|
| 109 |
+
for idx, seg in enumerate(raw_segments, 1):
|
| 110 |
+
text = seg.get("text", "").strip()
|
| 111 |
+
if not text:
|
| 112 |
+
continue
|
| 113 |
+
start_sec = float(seg.get("start", 0))
|
| 114 |
+
end_sec = float(seg.get("end", 0))
|
| 115 |
+
results.append({
|
| 116 |
+
"id": idx,
|
| 117 |
+
"start_ms": int(start_sec * 1000),
|
| 118 |
+
"end_ms": int(end_sec * 1000),
|
| 119 |
+
"start_str": self._format_timestamp(start_sec),
|
| 120 |
+
"end_str": self._format_timestamp(end_sec),
|
| 121 |
+
"text": text
|
| 122 |
+
})
|
| 123 |
+
return results
|
| 124 |
+
|
| 125 |
+
def _transcribe_gemini(self, audio_path: Path, api_key: str, lang: str = "zh") -> List[Dict]:
|
| 126 |
+
import base64
|
| 127 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
|
| 128 |
+
|
| 129 |
+
with open(audio_path, "rb") as f:
|
| 130 |
+
audio_bytes = f.read()
|
| 131 |
+
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8")
|
| 132 |
+
|
| 133 |
+
prompt = (
|
| 134 |
+
f"You are an expert Audio Transcriber. Transcribe this audio recording into detailed subtitle lines with precise timestamps.\n"
|
| 135 |
+
f"Source language: {lang}.\n"
|
| 136 |
+
f"Output ONLY a valid JSON array of objects with keys: id (number), start (string format HH:MM:SS,mmm), end (string format HH:MM:SS,mmm), text (string).\n"
|
| 137 |
+
f"Example:\n"
|
| 138 |
+
f'[{{"id": 1, "start": "00:00:01,200", "end": "00:00:03,800", "text": "大家好,今天给大家带来..."}}]'
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
payload = {
|
| 142 |
+
"contents": [{
|
| 143 |
+
"parts": [
|
| 144 |
+
{"text": prompt},
|
| 145 |
+
{"inline_data": {"mime_type": "audio/wav", "data": audio_b64}}
|
| 146 |
+
]
|
| 147 |
+
}],
|
| 148 |
+
"generationConfig": {
|
| 149 |
+
"temperature": 0.1,
|
| 150 |
+
"response_mime_type": "application/json"
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
res = requests.post(url, json=payload, timeout=120)
|
| 155 |
+
if res.status_code != 200:
|
| 156 |
+
raise RuntimeError(f"Gemini HTTP {res.status_code}: {res.text}")
|
| 157 |
+
|
| 158 |
+
data = res.json()
|
| 159 |
+
raw_text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
| 160 |
+
parsed = json.loads(raw_text)
|
| 161 |
+
|
| 162 |
+
results = []
|
| 163 |
+
for idx, item in enumerate(parsed, 1):
|
| 164 |
+
text = str(item.get("text", "")).strip()
|
| 165 |
+
if not text:
|
| 166 |
+
continue
|
| 167 |
+
start_str = str(item.get("start", "00:00:00,000")).replace(".", ",")
|
| 168 |
+
end_str = str(item.get("end", "00:00:00,000")).replace(".", ",")
|
| 169 |
+
results.append({
|
| 170 |
+
"id": idx,
|
| 171 |
+
"start_ms": self._parse_timestamp_to_ms(start_str),
|
| 172 |
+
"end_ms": self._parse_timestamp_to_ms(end_str),
|
| 173 |
+
"start_str": start_str,
|
| 174 |
+
"end_str": end_str,
|
| 175 |
+
"text": text
|
| 176 |
+
})
|
| 177 |
+
return results
|
| 178 |
+
|
| 179 |
+
def _format_timestamp(self, seconds: float) -> str:
|
| 180 |
+
ms = int(round(seconds * 1000))
|
| 181 |
+
h, rem = divmod(ms, 3600000)
|
| 182 |
+
m, rem = divmod(rem, 60000)
|
| 183 |
+
s, ms_rem = divmod(rem, 1000)
|
| 184 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms_rem:03d}"
|
| 185 |
+
|
| 186 |
+
def _parse_timestamp_to_ms(self, ts: str) -> int:
|
| 187 |
+
ts = ts.strip().replace(".", ",")
|
| 188 |
+
m = re.match(r"(\d+):(\d+):(\d+)[,](\d+)", ts)
|
| 189 |
+
if m:
|
| 190 |
+
h, mins, s, ms = map(int, m.groups())
|
| 191 |
+
return ((h * 3600 + mins * 60 + s) * 1000) + ms
|
| 192 |
+
return 0
|
| 193 |
+
|
| 194 |
+
def _write_srt(self, segments: List[Dict], out_path: str):
|
| 195 |
+
out_file = Path(out_path)
|
| 196 |
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
lines = []
|
| 198 |
+
for seg in segments:
|
| 199 |
+
lines.append(str(seg["id"]))
|
| 200 |
+
lines.append(f"{seg['start_str']} --> {seg['end_str']}")
|
| 201 |
+
lines.append(seg["text"])
|
| 202 |
+
lines.append("")
|
| 203 |
+
out_file.write_text("\n".join(lines), encoding="utf-8")
|
app/core/cloud_ocr.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/cloud_ocr.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Cloud-Native OCR Vision Module using Gemini Flash Vision & OpenRouter Qwen-VL.
|
| 5 |
+
Extracts hardcoded Chinese/CJK subtitles from video frames without local GPU/PaddleOCR.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import cv2
|
| 10 |
+
import json
|
| 11 |
+
import base64
|
| 12 |
+
import time
|
| 13 |
+
import requests
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import List, Dict, Optional, Tuple, Callable
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CloudOCREngine:
|
| 19 |
+
def __init__(self, gemini_keys: Optional[List[str]] = None, openrouter_keys: Optional[List[str]] = None, log_fn: Optional[Callable[[str], None]] = None):
|
| 20 |
+
self.log_fn = log_fn or print
|
| 21 |
+
self.gemini_keys = gemini_keys or self._load_keys("GEMINI_KEY")
|
| 22 |
+
self.openrouter_keys = openrouter_keys or self._load_keys("OPENROUTER_KEY")
|
| 23 |
+
|
| 24 |
+
def _log(self, msg: str):
|
| 25 |
+
self.log_fn(f"[Cloud OCR] {msg}")
|
| 26 |
+
|
| 27 |
+
def _load_keys(self, prefix: str) -> List[str]:
|
| 28 |
+
keys = []
|
| 29 |
+
for k, v in os.environ.items():
|
| 30 |
+
if k.startswith(prefix) and v.strip():
|
| 31 |
+
keys.append(v.strip())
|
| 32 |
+
|
| 33 |
+
env_path = Path(__file__).resolve().parents[2] / ".env"
|
| 34 |
+
if env_path.exists():
|
| 35 |
+
try:
|
| 36 |
+
from dotenv import dotenv_values
|
| 37 |
+
env_dict = dotenv_values(env_path)
|
| 38 |
+
for k, v in env_dict.items():
|
| 39 |
+
if k.startswith(prefix) and v and v.strip() and v.strip() not in keys:
|
| 40 |
+
keys.append(v.strip())
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
return keys
|
| 44 |
+
|
| 45 |
+
def scan_video_subtitles_to_srt(self, video_path: str, output_srt_path: str, blur_region: Optional[Tuple[int, int, int, int]] = None, sample_fps: float = 2.0) -> bool:
|
| 46 |
+
"""
|
| 47 |
+
Samples frames from video at sample_fps, crops subtitle box, sends to Vision LLM,
|
| 48 |
+
merges consecutive identical texts into timestamped subtitle blocks.
|
| 49 |
+
"""
|
| 50 |
+
video_file = Path(video_path)
|
| 51 |
+
if not video_file.exists():
|
| 52 |
+
self._log(f"❌ Video not found: {video_path}")
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
cap = cv2.VideoCapture(str(video_file))
|
| 56 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 57 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 58 |
+
duration_sec = total_frames / fps if fps > 0 else 0
|
| 59 |
+
|
| 60 |
+
frame_step = max(1, int(fps / sample_fps))
|
| 61 |
+
self._log(f"🎞️ Sampling video at {sample_fps} FPS (total ~{int(duration_sec * sample_fps)} frames)...")
|
| 62 |
+
|
| 63 |
+
sampled_frames = [] # (timestamp_sec, base64_jpeg)
|
| 64 |
+
frame_idx = 0
|
| 65 |
+
|
| 66 |
+
while cap.isOpened():
|
| 67 |
+
ret, frame = cap.read()
|
| 68 |
+
if not ret:
|
| 69 |
+
break
|
| 70 |
+
if frame_idx % frame_step == 0:
|
| 71 |
+
cur_sec = frame_idx / fps
|
| 72 |
+
# Crop region if provided: (x, y, w, h)
|
| 73 |
+
if blur_region:
|
| 74 |
+
x, y, w, h = blur_region
|
| 75 |
+
fh, fw = frame.shape[:2]
|
| 76 |
+
x1 = max(0, min(fw - 1, int(x)))
|
| 77 |
+
y1 = max(0, min(fh - 1, int(y)))
|
| 78 |
+
x2 = max(x1 + 1, min(fw, int(x + w)))
|
| 79 |
+
y2 = max(y1 + 1, min(fh, int(y + h)))
|
| 80 |
+
cropped = frame[y1:y2, x1:x2]
|
| 81 |
+
else:
|
| 82 |
+
# Default: lower 30% of video frame
|
| 83 |
+
fh, fw = frame.shape[:2]
|
| 84 |
+
cropped = frame[int(fh * 0.7):fh, 0:fw]
|
| 85 |
+
|
| 86 |
+
# Encode to lightweight JPEG
|
| 87 |
+
success, buffer = cv2.imencode(".jpg", cropped, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
| 88 |
+
if success:
|
| 89 |
+
b64_str = base64.b64encode(buffer).decode("utf-8")
|
| 90 |
+
sampled_frames.append((cur_sec, b64_str))
|
| 91 |
+
frame_idx += 1
|
| 92 |
+
cap.release()
|
| 93 |
+
|
| 94 |
+
if not sampled_frames:
|
| 95 |
+
self._log("⚠️ Không trích xuất được frame nào từ video.")
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
# Batch frames (5-10 frames per batch for fast Vision OCR)
|
| 99 |
+
raw_detections = self._process_frame_batches(sampled_frames)
|
| 100 |
+
if not raw_detections:
|
| 101 |
+
self._log("❌ Cloud Vision OCR không nhận diện được chữ nào.")
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
# Merge consecutive detections into subtitle blocks
|
| 105 |
+
merged_blocks = self._merge_consecutive_detections(raw_detections, max_gap_sec=0.8)
|
| 106 |
+
self._write_srt(merged_blocks, output_srt_path)
|
| 107 |
+
self._log(f"✅ Cloud OCR hoàn tất! Xuất {len(merged_blocks)} câu phụ đề.")
|
| 108 |
+
return True
|
| 109 |
+
|
| 110 |
+
def _process_frame_batches(self, sampled_frames: List[Tuple[float, str]], batch_size: int = 6) -> List[Tuple[float, str]]:
|
| 111 |
+
detections = [] # [(timestamp_sec, text), ...]
|
| 112 |
+
|
| 113 |
+
for i in range(0, len(sampled_frames), batch_size):
|
| 114 |
+
batch = sampled_frames[i : i + batch_size]
|
| 115 |
+
batch_timestamps = [f"{t:.2f}s" for t, _ in batch]
|
| 116 |
+
self._log(f"🔍 Quét OCR batch {i//batch_size + 1}/{(len(sampled_frames)-1)//batch_size + 1} (timestamps: {batch_timestamps[0]} -> {batch_timestamps[-1]})...")
|
| 117 |
+
|
| 118 |
+
batch_result = None
|
| 119 |
+
# Try Gemini Flash Vision first
|
| 120 |
+
if self.gemini_keys:
|
| 121 |
+
for key in self.gemini_keys:
|
| 122 |
+
try:
|
| 123 |
+
batch_result = self._ocr_batch_gemini(batch, key)
|
| 124 |
+
if batch_result is not None:
|
| 125 |
+
break
|
| 126 |
+
except Exception as e:
|
| 127 |
+
self._log(f"⚠️ Gemini Vision error: {e}")
|
| 128 |
+
|
| 129 |
+
# Try OpenRouter Vision as fallback
|
| 130 |
+
if batch_result is None and self.openrouter_keys:
|
| 131 |
+
for key in self.openrouter_keys:
|
| 132 |
+
try:
|
| 133 |
+
batch_result = self._ocr_batch_openrouter(batch, key)
|
| 134 |
+
if batch_result is not None:
|
| 135 |
+
break
|
| 136 |
+
except Exception as e:
|
| 137 |
+
self._log(f"⚠️ OpenRouter Vision error: {e}")
|
| 138 |
+
|
| 139 |
+
if batch_result:
|
| 140 |
+
detections.extend(batch_result)
|
| 141 |
+
time.sleep(0.2) # Friendly rate limiting
|
| 142 |
+
return detections
|
| 143 |
+
|
| 144 |
+
def _ocr_batch_gemini(self, batch: List[Tuple[float, str]], api_key: str) -> List[Tuple[float, str]]:
|
| 145 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
|
| 146 |
+
parts = []
|
| 147 |
+
parts.append({
|
| 148 |
+
"text": (
|
| 149 |
+
"You are an expert OCR vision AI. For each provided cropped video frame image, extract the exact Chinese/English subtitle text shown.\n"
|
| 150 |
+
"Return ONLY a JSON array of strings, where each element is the text recognized in that corresponding image in exact order.\n"
|
| 151 |
+
"If no subtitle text is present in an image, return empty string \"\" for that index.\n"
|
| 152 |
+
"Example response: [\"这是第一句字幕\", \"\", \"这是第二句字幕\"]"
|
| 153 |
+
)
|
| 154 |
+
})
|
| 155 |
+
for _, b64 in batch:
|
| 156 |
+
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": b64}})
|
| 157 |
+
|
| 158 |
+
payload = {
|
| 159 |
+
"contents": [{"parts": parts}],
|
| 160 |
+
"generationConfig": {"temperature": 0.0, "response_mime_type": "application/json"}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
res = requests.post(url, json=payload, timeout=40)
|
| 164 |
+
if res.status_code != 200:
|
| 165 |
+
raise RuntimeError(f"Gemini HTTP {res.status_code}: {res.text}")
|
| 166 |
+
|
| 167 |
+
raw_text = res.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
|
| 168 |
+
texts = json.loads(raw_text)
|
| 169 |
+
|
| 170 |
+
results = []
|
| 171 |
+
for (t, _), txt in zip(batch, texts):
|
| 172 |
+
clean_txt = str(txt).strip()
|
| 173 |
+
if clean_txt:
|
| 174 |
+
results.append((t, clean_txt))
|
| 175 |
+
return results
|
| 176 |
+
|
| 177 |
+
def _ocr_batch_openrouter(self, batch: List[Tuple[float, str]], api_key: str) -> List[Tuple[float, str]]:
|
| 178 |
+
url = "https://openrouter.ai/api/v1/chat/completions"
|
| 179 |
+
headers = {
|
| 180 |
+
"Authorization": f"Bearer {api_key}",
|
| 181 |
+
"HTTP-Referer": "https://trungsangviet.local",
|
| 182 |
+
"X-Title": "TrungSangViet Cloud OCR"
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
content_list = [{
|
| 186 |
+
"type": "text",
|
| 187 |
+
"text": "Extract all subtitle text from each image in order. Output JSON array of strings: [\"text1\", \"text2\", ...]. Return \"\" if empty."
|
| 188 |
+
}]
|
| 189 |
+
for _, b64 in batch:
|
| 190 |
+
content_list.append({
|
| 191 |
+
"type": "image_url",
|
| 192 |
+
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
payload = {
|
| 196 |
+
"model": "google/gemini-2.0-flash-exp:free",
|
| 197 |
+
"messages": [{"role": "user", "content": content_list}],
|
| 198 |
+
"response_format": {"type": "json_object"},
|
| 199 |
+
"temperature": 0.0
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
res = requests.post(url, headers=headers, json=payload, timeout=45)
|
| 203 |
+
if res.status_code != 200:
|
| 204 |
+
raise RuntimeError(f"OpenRouter Vision HTTP {res.status_code}: {res.text}")
|
| 205 |
+
|
| 206 |
+
content = res.json()["choices"][0]["message"]["content"]
|
| 207 |
+
data = json.loads(content)
|
| 208 |
+
texts = data if isinstance(data, list) else list(data.values())[0]
|
| 209 |
+
|
| 210 |
+
results = []
|
| 211 |
+
for (t, _), txt in zip(batch, texts):
|
| 212 |
+
clean_txt = str(txt).strip()
|
| 213 |
+
if clean_txt:
|
| 214 |
+
results.append((t, clean_txt))
|
| 215 |
+
return results
|
| 216 |
+
|
| 217 |
+
def _merge_consecutive_detections(self, detections: List[Tuple[float, str]], max_gap_sec: float = 0.8) -> List[Dict]:
|
| 218 |
+
if not detections:
|
| 219 |
+
return []
|
| 220 |
+
|
| 221 |
+
blocks = []
|
| 222 |
+
cur_text = detections[0][1]
|
| 223 |
+
cur_start = detections[0][0]
|
| 224 |
+
cur_end = detections[0][0] + 0.5
|
| 225 |
+
|
| 226 |
+
for t, text in detections[1:]:
|
| 227 |
+
# Check similarity
|
| 228 |
+
if text == cur_text or self._similarity(text, cur_text) > 0.75:
|
| 229 |
+
if t - cur_end <= max_gap_sec:
|
| 230 |
+
cur_end = max(cur_end, t + 0.5)
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
# Save finished block
|
| 234 |
+
blocks.append({
|
| 235 |
+
"id": len(blocks) + 1,
|
| 236 |
+
"start_ms": int(cur_start * 1000),
|
| 237 |
+
"end_ms": int(cur_end * 1000),
|
| 238 |
+
"start_str": self._format_timestamp(cur_start),
|
| 239 |
+
"end_str": self._format_timestamp(cur_end),
|
| 240 |
+
"text": cur_text
|
| 241 |
+
})
|
| 242 |
+
cur_text = text
|
| 243 |
+
cur_start = t
|
| 244 |
+
cur_end = t + 0.5
|
| 245 |
+
|
| 246 |
+
# Add last block
|
| 247 |
+
blocks.append({
|
| 248 |
+
"id": len(blocks) + 1,
|
| 249 |
+
"start_ms": int(cur_start * 1000),
|
| 250 |
+
"end_ms": int(cur_end * 1000),
|
| 251 |
+
"start_str": self._format_timestamp(cur_start),
|
| 252 |
+
"end_str": self._format_timestamp(cur_end),
|
| 253 |
+
"text": cur_text
|
| 254 |
+
})
|
| 255 |
+
return blocks
|
| 256 |
+
|
| 257 |
+
def _similarity(self, s1: str, s2: str) -> float:
|
| 258 |
+
import difflib
|
| 259 |
+
return difflib.SequenceMatcher(None, s1, s2).ratio()
|
| 260 |
+
|
| 261 |
+
def _format_timestamp(self, seconds: float) -> str:
|
| 262 |
+
ms = int(round(seconds * 1000))
|
| 263 |
+
h, rem = divmod(ms, 3600000)
|
| 264 |
+
m, rem = divmod(rem, 60000)
|
| 265 |
+
s, ms_rem = divmod(rem, 1000)
|
| 266 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms_rem:03d}"
|
| 267 |
+
|
| 268 |
+
def _write_srt(self, blocks: List[Dict], out_path: str):
|
| 269 |
+
out_file = Path(out_path)
|
| 270 |
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
| 271 |
+
lines = []
|
| 272 |
+
for b in blocks:
|
| 273 |
+
lines.append(str(b["id"]))
|
| 274 |
+
lines.append(f"{b['start_str']} --> {b['end_str']}")
|
| 275 |
+
lines.append(b["text"])
|
| 276 |
+
lines.append("")
|
| 277 |
+
out_file.write_text("\n".join(lines), encoding="utf-8")
|
app/core/cloud_pipeline.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/cloud_pipeline.py
|
| 3 |
+
──────────────────────────
|
| 4 |
+
Pure Cloud-Native Headless Pipeline Coordinator.
|
| 5 |
+
Runs on any Cloud/Linux/Docker environment or local PC without GPU/VRAM requirements.
|
| 6 |
+
Provides callback hooks for live WebSocket logs & stage progress.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import shutil
|
| 14 |
+
import subprocess
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional, Callable, Dict, Any
|
| 17 |
+
|
| 18 |
+
from app.core.cloud_asr import CloudASREngine
|
| 19 |
+
from app.core.cloud_ocr import CloudOCREngine
|
| 20 |
+
from app.core.cloud_tts import CloudTTSEngine
|
| 21 |
+
from app.core.vietnamese_text_normalizer import VietnameseTextNormalizer
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CloudPipeline:
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
base_dir: Optional[Path] = None,
|
| 28 |
+
log_callback: Optional[Callable[[str], None]] = None,
|
| 29 |
+
progress_callback: Optional[Callable[[int, str], None]] = None,
|
| 30 |
+
ffmpeg_path: str = "ffmpeg"
|
| 31 |
+
):
|
| 32 |
+
self.base_dir = Path(base_dir or Path(__file__).resolve().parents[2])
|
| 33 |
+
self.output_dir = self.base_dir / "output"
|
| 34 |
+
self.temp_dir = self.base_dir / "temp"
|
| 35 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
self.log_fn = log_callback or print
|
| 39 |
+
self.progress_fn = progress_callback or (lambda pct, stage: None)
|
| 40 |
+
self.ffmpeg_path = ffmpeg_path
|
| 41 |
+
|
| 42 |
+
# Initialize engines
|
| 43 |
+
self.asr_engine = CloudASREngine(log_fn=self._log)
|
| 44 |
+
self.ocr_engine = CloudOCREngine(log_fn=self._log)
|
| 45 |
+
self.tts_engine = CloudTTSEngine(log_fn=self._log, ffmpeg_path=self.ffmpeg_path)
|
| 46 |
+
self.normalizer = VietnameseTextNormalizer()
|
| 47 |
+
|
| 48 |
+
def _log(self, msg: str):
|
| 49 |
+
self.log_fn(f"[Cloud Pipeline] {msg}")
|
| 50 |
+
|
| 51 |
+
def run_video(
|
| 52 |
+
self,
|
| 53 |
+
video_path: str,
|
| 54 |
+
mode: str = "asr", # "asr" or "ocr"
|
| 55 |
+
source_lang: str = "zh",
|
| 56 |
+
voice: str = "vi-VN-NamMinhNeural",
|
| 57 |
+
speed: float = 1.0,
|
| 58 |
+
pitch: int = 0,
|
| 59 |
+
volume: int = 100,
|
| 60 |
+
blur_region: Optional[tuple] = None,
|
| 61 |
+
sub_mode: str = "delogo",
|
| 62 |
+
ducking_ratio: float = 0.18
|
| 63 |
+
) -> Optional[str]:
|
| 64 |
+
"""
|
| 65 |
+
Executes the full cloud dubbing pipeline for a single video.
|
| 66 |
+
Returns the absolute path to the output final video if successful.
|
| 67 |
+
"""
|
| 68 |
+
v_path = Path(video_path)
|
| 69 |
+
if not v_path.exists():
|
| 70 |
+
self._log(f"❌ Video not found: {video_path}")
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
video_stem = v_path.stem
|
| 74 |
+
vtd = self.temp_dir / video_stem
|
| 75 |
+
vtd.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
start_time = time.time()
|
| 79 |
+
self._log(f"🚀 BẮT ĐẦU XỬ LÝ VIDEO: {v_path.name}")
|
| 80 |
+
self.progress_fn(5, "STAGE_0_PREPARE")
|
| 81 |
+
|
| 82 |
+
# ── 1. PREPARE & EXTRACT AUDIO ────────────────────────────────────
|
| 83 |
+
extracted_audio = vtd / "extracted_audio.wav"
|
| 84 |
+
self._log("⚡ Trích xuất âm thanh từ video gốc...")
|
| 85 |
+
cmd = [
|
| 86 |
+
str(self.ffmpeg_path), "-y", "-i", str(v_path),
|
| 87 |
+
"-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
|
| 88 |
+
str(extracted_audio)
|
| 89 |
+
]
|
| 90 |
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
| 91 |
+
|
| 92 |
+
# ── 2. CLOUD ASR / OCR ───────────────────────────────────────────
|
| 93 |
+
self.progress_fn(20, "STAGE_A_SUBTITLE_EXTRACTION")
|
| 94 |
+
original_srt = vtd / "original.srt"
|
| 95 |
+
|
| 96 |
+
if mode == "ocr":
|
| 97 |
+
self._log("👁️ Sử dụng Cloud OCR Vision để quét phụ đề trên video...")
|
| 98 |
+
ok = self.ocr_engine.scan_video_subtitles_to_srt(str(v_path), str(original_srt), blur_region=blur_region)
|
| 99 |
+
else:
|
| 100 |
+
self._log("🎙️ Sử dụng Cloud ASR (Groq/Gemini) để nhận diện giọng nói...")
|
| 101 |
+
ok = self.asr_engine.transcribe_audio_to_srt(str(extracted_audio), str(original_srt), source_lang=source_lang)
|
| 102 |
+
|
| 103 |
+
if not ok or not original_srt.exists() or original_srt.stat().st_size < 10:
|
| 104 |
+
raise RuntimeError("Không thể trích xuất phụ đề từ video.")
|
| 105 |
+
|
| 106 |
+
# ── 3. CLOUD TRANSLATION (API POOL & 2-STAGE) ────────────────────
|
| 107 |
+
self.progress_fn(40, "STAGE_C_TRANSLATION")
|
| 108 |
+
self._log("🌐 Dịch thuật ngữ cảnh bằng Cloud AI Pool (Qwen 72B / Gemini / Groq)...")
|
| 109 |
+
translated_srt = vtd / "translated.srt"
|
| 110 |
+
|
| 111 |
+
self._translate_srt_cloud(original_srt, translated_srt, source_lang=source_lang)
|
| 112 |
+
|
| 113 |
+
# ── 4. CLOUD TTS DUBBING ─────────────────────────────────────────
|
| 114 |
+
self.progress_fn(65, "STAGE_TTS_DUBBING")
|
| 115 |
+
dubbing_wav = vtd / "dubbing.wav"
|
| 116 |
+
ok_tts = self.tts_engine.synthesize_srt_to_audio(
|
| 117 |
+
str(translated_srt),
|
| 118 |
+
str(dubbing_wav),
|
| 119 |
+
voice=voice,
|
| 120 |
+
speed=speed,
|
| 121 |
+
pitch=pitch,
|
| 122 |
+
volume=volume,
|
| 123 |
+
temp_dir=str(vtd / "tts_segments")
|
| 124 |
+
)
|
| 125 |
+
if not ok_tts or not dubbing_wav.exists():
|
| 126 |
+
raise RuntimeError("Lỗi tạo giọng đọc TTS từ cloud.")
|
| 127 |
+
|
| 128 |
+
# ── 5. AUDIO DUCKING & MIX ────────────────────────────────────────
|
| 129 |
+
self.progress_fn(80, "STAGE_D_AUDIO_MIX")
|
| 130 |
+
self._log("🎚️ Trộn âm thanh: Nhạc nền hạ âm lượng (Ducking) + Giọng đọc tiếng Việt...")
|
| 131 |
+
mixed_audio = vtd / "mixed_final.wav"
|
| 132 |
+
|
| 133 |
+
# Use sidechain ducking filter or volume reduction
|
| 134 |
+
filter_complex = (
|
| 135 |
+
f"[0:a]volume={ducking_ratio}[bg];"
|
| 136 |
+
f"[1:a]volume=1.0[dub];"
|
| 137 |
+
f"[bg][dub]amix=inputs=2:duration=longest:dropout_transition=2[aout]"
|
| 138 |
+
)
|
| 139 |
+
cmd_mix = [
|
| 140 |
+
str(self.ffmpeg_path), "-y",
|
| 141 |
+
"-i", str(extracted_audio),
|
| 142 |
+
"-i", str(dubbing_wav),
|
| 143 |
+
"-filter_complex", filter_complex,
|
| 144 |
+
"-map", "[aout]",
|
| 145 |
+
"-ac", "2", "-ar", "44100",
|
| 146 |
+
str(mixed_audio)
|
| 147 |
+
]
|
| 148 |
+
subprocess.run(cmd_mix, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
| 149 |
+
|
| 150 |
+
# ── 6. VIDEO RENDER & HARDSUB ────────────────────────────────────
|
| 151 |
+
self.progress_fn(90, "STAGE_D_RENDER")
|
| 152 |
+
self._log("🎬 Render video hoàn thiện & gắn phụ đề tiếng Việt...")
|
| 153 |
+
final_output = self.output_dir / f"studio_final_{v_path.name}"
|
| 154 |
+
|
| 155 |
+
# Format subtitle filter for FFmpeg
|
| 156 |
+
srt_escaped = str(translated_srt).replace("\\", "/").replace(":", "\\:")
|
| 157 |
+
sub_filter = f"subtitles='{srt_escaped}':force_style='FontSize=22,FontName=Arial,Bold=1,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=1,Alignment=2,MarginV=30'"
|
| 158 |
+
|
| 159 |
+
if blur_region and sub_mode == "delogo":
|
| 160 |
+
x, y, w, h = blur_region
|
| 161 |
+
vf_filter = f"delogo=x={x}:y={y}:w={w}:h={h},{sub_filter}"
|
| 162 |
+
else:
|
| 163 |
+
vf_filter = sub_filter
|
| 164 |
+
|
| 165 |
+
cmd_render = [
|
| 166 |
+
str(self.ffmpeg_path), "-y",
|
| 167 |
+
"-i", str(v_path),
|
| 168 |
+
"-i", str(mixed_audio),
|
| 169 |
+
"-vf", vf_filter,
|
| 170 |
+
"-map", "0:v:0", "-map", "1:a:0",
|
| 171 |
+
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
| 172 |
+
"-c:a", "aac", "-b:a", "192k",
|
| 173 |
+
str(final_output)
|
| 174 |
+
]
|
| 175 |
+
subprocess.run(cmd_render, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
| 176 |
+
|
| 177 |
+
total_elapsed = time.time() - start_time
|
| 178 |
+
self.progress_fn(100, "DONE")
|
| 179 |
+
self._log(f"🎉 HOÀN THÀNH XUẤT SẮC TRONG {total_elapsed:.1f}s!")
|
| 180 |
+
self._log(f"📁 File video đã lưu tại: {final_output}")
|
| 181 |
+
|
| 182 |
+
return str(final_output)
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
self._log(f"❌ LỖI PIPELINE: {str(e)}")
|
| 186 |
+
self.progress_fn(0, "FAILED")
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
def _translate_srt_cloud(self, srt_in: Path, srt_out: Path, source_lang: str = "zh"):
|
| 190 |
+
content = srt_in.read_text(encoding="utf-8", errors="ignore")
|
| 191 |
+
|
| 192 |
+
# Parse blocks
|
| 193 |
+
import re
|
| 194 |
+
pattern = r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 195 |
+
blocks = []
|
| 196 |
+
for m in re.finditer(pattern, content, re.DOTALL):
|
| 197 |
+
text = " ".join(line.strip() for line in m.group(3).splitlines() if line.strip())
|
| 198 |
+
if text:
|
| 199 |
+
blocks.append({"id": int(m.group(1)), "timing": m.group(2).strip(), "text": text})
|
| 200 |
+
|
| 201 |
+
if not blocks:
|
| 202 |
+
srt_out.write_text(content, encoding="utf-8")
|
| 203 |
+
return
|
| 204 |
+
|
| 205 |
+
# Prepare translation payload
|
| 206 |
+
prompt_lines = [f"[{b['id']}] {b['text']}" for b in blocks]
|
| 207 |
+
full_transcript = "\n".join(prompt_lines)
|
| 208 |
+
|
| 209 |
+
system_prompt = (
|
| 210 |
+
"Bạn là chuyên gia dịch thuật video Sneaker, Fashion và Review từ tiếng Trung sang tiếng Việt tự nhiên, hấp dẫn.\n"
|
| 211 |
+
"QUY TẮC BẮT BUỘC:\n"
|
| 212 |
+
"1. Dịch từng dòng theo đúng định dạng [N] Bản dịch tiếng Việt.\n"
|
| 213 |
+
"2. Giữ nguyên các từ thương hiệu tiếng Anh (Nike, Jordan, Yeezy, BAPE, outfit, fit, drip...).\n"
|
| 214 |
+
"3. Câu dịch ngắn gọn, súc tích để người đọc TTS kịp nói theo tốc độ video.\n"
|
| 215 |
+
"4. KHÔNG giải thích, chỉ trả về danh sách các dòng [N] Tiếng Việt."
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
translated_text = self._call_ai_pool_translation(system_prompt, full_transcript)
|
| 219 |
+
|
| 220 |
+
# Parse translation output back to SRT
|
| 221 |
+
trans_map = {}
|
| 222 |
+
for line in translated_text.splitlines():
|
| 223 |
+
m = re.match(r"^\s*\[(\d+)\]\s*(.*)$", line.strip())
|
| 224 |
+
if m:
|
| 225 |
+
trans_map[int(m.group(1))] = m.group(2).strip()
|
| 226 |
+
|
| 227 |
+
# Write output SRT
|
| 228 |
+
out_lines = []
|
| 229 |
+
for b in blocks:
|
| 230 |
+
vi_text = trans_map.get(b["id"], b["text"])
|
| 231 |
+
# Normalize text numbers
|
| 232 |
+
vi_text = self.normalizer.normalize(vi_text) if hasattr(self.normalizer, "normalize") else vi_text
|
| 233 |
+
out_lines.append(str(b["id"]))
|
| 234 |
+
out_lines.append(b["timing"])
|
| 235 |
+
out_lines.append(vi_text)
|
| 236 |
+
out_lines.append("")
|
| 237 |
+
|
| 238 |
+
srt_out.write_text("\n".join(out_lines), encoding="utf-8")
|
| 239 |
+
|
| 240 |
+
def _call_ai_pool_translation(self, system_prompt: str, user_content: str) -> str:
|
| 241 |
+
# Load keys
|
| 242 |
+
from app.api_pool import ApiPool
|
| 243 |
+
pool = ApiPool.instance()
|
| 244 |
+
pool.reload()
|
| 245 |
+
|
| 246 |
+
slot_names = [
|
| 247 |
+
"or_qwen25_72b",
|
| 248 |
+
"groq_llama33_70b",
|
| 249 |
+
"gemini",
|
| 250 |
+
"nvidia_llama70b",
|
| 251 |
+
"or_deepseek_r1"
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
messages = [
|
| 255 |
+
{"role": "system", "content": system_prompt},
|
| 256 |
+
{"role": "user", "content": user_content}
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
for sname in slot_names:
|
| 260 |
+
try:
|
| 261 |
+
self._log(f"🤖 Đang gửi transcript đến AI Slot: {sname}...")
|
| 262 |
+
resp = pool.call(sname, messages, timeout=60)
|
| 263 |
+
if resp and len(resp.strip()) > 10:
|
| 264 |
+
return resp.strip()
|
| 265 |
+
except Exception as e:
|
| 266 |
+
self._log(f"⚠️ Slot {sname} lỗi: {e}, chuyển slot tiếp theo...")
|
| 267 |
+
|
| 268 |
+
# Fallback to direct Groq call if pool fails
|
| 269 |
+
groq_key = os.environ.get("GROQ_KEY_1", "")
|
| 270 |
+
if groq_key:
|
| 271 |
+
try:
|
| 272 |
+
import requests
|
| 273 |
+
res = requests.post(
|
| 274 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 275 |
+
headers={"Authorization": f"Bearer {groq_key}"},
|
| 276 |
+
json={
|
| 277 |
+
"model": "llama-3.3-70b-versatile",
|
| 278 |
+
"messages": messages,
|
| 279 |
+
"temperature": 0.3
|
| 280 |
+
},
|
| 281 |
+
timeout=45
|
| 282 |
+
)
|
| 283 |
+
if res.status_code == 200:
|
| 284 |
+
return res.json()["choices"][0]["message"]["content"].strip()
|
| 285 |
+
except Exception:
|
| 286 |
+
pass
|
| 287 |
+
|
| 288 |
+
return user_content
|
app/core/cloud_tts.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/cloud_tts.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Cloud-Native TTS Module using Microsoft Edge-TTS (100% Free, High Quality Neural Voice).
|
| 5 |
+
Supports timing plan, speed/pitch modulation, retry backoff, and full wav merging.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import asyncio
|
| 13 |
+
import subprocess
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import List, Dict, Optional, Callable
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CloudTTSEngine:
|
| 19 |
+
def __init__(self, log_fn: Optional[Callable[[str], None]] = None, ffmpeg_path: str = "ffmpeg"):
|
| 20 |
+
self.log_fn = log_fn or print
|
| 21 |
+
self.ffmpeg_path = ffmpeg_path
|
| 22 |
+
|
| 23 |
+
def _log(self, msg: str):
|
| 24 |
+
self.log_fn(f"[Cloud TTS] {msg}")
|
| 25 |
+
|
| 26 |
+
def synthesize_srt_to_audio(
|
| 27 |
+
self,
|
| 28 |
+
srt_path: str,
|
| 29 |
+
output_wav: str,
|
| 30 |
+
voice: str = "vi-VN-NamMinhNeural",
|
| 31 |
+
speed: float = 1.0,
|
| 32 |
+
pitch: int = 0,
|
| 33 |
+
volume: int = 100,
|
| 34 |
+
temp_dir: Optional[str] = None
|
| 35 |
+
) -> bool:
|
| 36 |
+
"""
|
| 37 |
+
Synthesizes translated SRT file to dubbing.wav aligned with subtitle timings.
|
| 38 |
+
"""
|
| 39 |
+
srt_file = Path(srt_path)
|
| 40 |
+
if not srt_file.exists():
|
| 41 |
+
self._log(f"❌ SRT file not found: {srt_path}")
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
out_file = Path(output_wav)
|
| 45 |
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
segments_dir = Path(temp_dir or (out_file.parent / "tts_segments"))
|
| 48 |
+
segments_dir.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
|
| 50 |
+
blocks = self._parse_srt(srt_file)
|
| 51 |
+
if not blocks:
|
| 52 |
+
self._log("⚠️ No subtitle blocks found to synthesize.")
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
self._log(f"🎙️ Bắt đầu tạo giọng đọc cho {len(blocks)} câu thoại với giọng {voice}...")
|
| 56 |
+
|
| 57 |
+
# Run async synthesis loop
|
| 58 |
+
loop = asyncio.new_event_loop()
|
| 59 |
+
asyncio.set_event_loop(loop)
|
| 60 |
+
try:
|
| 61 |
+
success = loop.run_until_complete(
|
| 62 |
+
self._synthesize_blocks(blocks, segments_dir, voice, speed, pitch, volume)
|
| 63 |
+
)
|
| 64 |
+
finally:
|
| 65 |
+
loop.close()
|
| 66 |
+
|
| 67 |
+
if not success:
|
| 68 |
+
self._log("❌ Lỗi tổng hợp giọng đọc TTS.")
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
# Merge segments into single dubbing.wav according to timestamps
|
| 72 |
+
self._log("🎚️ Đang ghép các đoạn âm thanh theo đúng timeline...")
|
| 73 |
+
return self._merge_segments_to_timeline(blocks, segments_dir, out_file)
|
| 74 |
+
|
| 75 |
+
async def _synthesize_blocks(
|
| 76 |
+
self,
|
| 77 |
+
blocks: List[Dict],
|
| 78 |
+
segments_dir: Path,
|
| 79 |
+
voice: str,
|
| 80 |
+
speed: float,
|
| 81 |
+
pitch: int,
|
| 82 |
+
volume: int
|
| 83 |
+
) -> bool:
|
| 84 |
+
import edge_tts
|
| 85 |
+
|
| 86 |
+
rate_str = f"{int(round((speed - 1.0) * 100)):+d}%"
|
| 87 |
+
pitch_str = f"{int(round(pitch)):+d}Hz"
|
| 88 |
+
vol_str = f"{int(round(volume - 100)):+d}%"
|
| 89 |
+
|
| 90 |
+
for idx, block in enumerate(blocks, 1):
|
| 91 |
+
text = block["text"].strip()
|
| 92 |
+
if not text:
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
seg_path = segments_dir / f"seg_{block['id']:04d}.mp3"
|
| 96 |
+
wav_path = segments_dir / f"seg_{block['id']:04d}.wav"
|
| 97 |
+
|
| 98 |
+
# Skip if already exists
|
| 99 |
+
if wav_path.exists() and wav_path.stat().st_size > 500:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
self._log(f"🗣️ [{idx}/{len(blocks)}] {text[:40]}...")
|
| 103 |
+
|
| 104 |
+
# Retry up to 3 times
|
| 105 |
+
succeeded = False
|
| 106 |
+
for attempt in range(3):
|
| 107 |
+
try:
|
| 108 |
+
communicate = edge_tts.Communicate(
|
| 109 |
+
text,
|
| 110 |
+
voice=voice,
|
| 111 |
+
rate=rate_str,
|
| 112 |
+
pitch=pitch_str,
|
| 113 |
+
volume=vol_str
|
| 114 |
+
)
|
| 115 |
+
await communicate.save(str(seg_path))
|
| 116 |
+
|
| 117 |
+
# Convert to standard 16kHz mono WAV
|
| 118 |
+
cmd = [
|
| 119 |
+
str(self.ffmpeg_path), "-y",
|
| 120 |
+
"-i", str(seg_path),
|
| 121 |
+
"-ar", "16000", "-ac", "1",
|
| 122 |
+
str(wav_path)
|
| 123 |
+
]
|
| 124 |
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
| 125 |
+
if seg_path.exists():
|
| 126 |
+
seg_path.unlink()
|
| 127 |
+
succeeded = True
|
| 128 |
+
break
|
| 129 |
+
except Exception as e:
|
| 130 |
+
self._log(f"⚠️ Attempt {attempt+1} failed for block {block['id']}: {e}")
|
| 131 |
+
await asyncio.sleep(1.5)
|
| 132 |
+
|
| 133 |
+
if not succeeded:
|
| 134 |
+
self._log(f"❌ Không thể tạo giọng cho câu: {text}")
|
| 135 |
+
|
| 136 |
+
return True
|
| 137 |
+
|
| 138 |
+
def _merge_segments_to_timeline(self, blocks: List[Dict], segments_dir: Path, output_wav: Path) -> bool:
|
| 139 |
+
"""
|
| 140 |
+
Builds a continuous audio track by placing each segment at its start_ms timestamp.
|
| 141 |
+
"""
|
| 142 |
+
try:
|
| 143 |
+
from pydub import AudioSegment
|
| 144 |
+
except ImportError:
|
| 145 |
+
self._log("⚠️ pydub not installed, falling back to direct concat.")
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
if not blocks:
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
total_duration_ms = max(b["end_ms"] for b in blocks) + 2000
|
| 152 |
+
canvas = AudioSegment.silent(duration=total_duration_ms, frame_rate=16000)
|
| 153 |
+
|
| 154 |
+
for b in blocks:
|
| 155 |
+
wav_path = segments_dir / f"seg_{b['id']:04d}.wav"
|
| 156 |
+
if wav_path.exists():
|
| 157 |
+
try:
|
| 158 |
+
seg_audio = AudioSegment.from_wav(str(wav_path))
|
| 159 |
+
canvas = canvas.overlay(seg_audio, position=b["start_ms"])
|
| 160 |
+
except Exception as e:
|
| 161 |
+
self._log(f"⚠️ Lỗi chèn đoạn {b['id']}: {e}")
|
| 162 |
+
|
| 163 |
+
canvas.export(str(output_wav), format="wav")
|
| 164 |
+
self._log(f"✅ Đã tạo xong file lồng tiếng hoàn chỉnh: {output_wav.name} ({len(canvas)/1000:.1f}s)")
|
| 165 |
+
return True
|
| 166 |
+
|
| 167 |
+
def _parse_srt(self, srt_file: Path) -> List[Dict]:
|
| 168 |
+
content = srt_file.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n")
|
| 169 |
+
pattern = r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 170 |
+
blocks = []
|
| 171 |
+
for m in re.finditer(pattern, content, re.DOTALL):
|
| 172 |
+
text = " ".join(line.strip() for line in m.group(4).splitlines() if line.strip())
|
| 173 |
+
if text:
|
| 174 |
+
blocks.append({
|
| 175 |
+
"id": int(m.group(1)),
|
| 176 |
+
"start_ms": self._parse_time(m.group(2)),
|
| 177 |
+
"end_ms": self._parse_time(m.group(3)),
|
| 178 |
+
"text": text
|
| 179 |
+
})
|
| 180 |
+
return blocks
|
| 181 |
+
|
| 182 |
+
def _parse_time(self, ts: str) -> int:
|
| 183 |
+
ts = ts.strip().replace(".", ",")
|
| 184 |
+
m = re.match(r"(\d+):(\d+):(\d+)[,](\d+)", ts)
|
| 185 |
+
if m:
|
| 186 |
+
h, mins, s, ms = map(int, m.groups())
|
| 187 |
+
return ((h * 3600 + mins * 60 + s) * 1000) + ms
|
| 188 |
+
return 0
|
app/core/gpu_resource_manager.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import urllib.request
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import json
|
| 6 |
+
import gc
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def is_cuda_fully_functional():
|
| 10 |
+
"""Run a child process to verify CUDA is actually usable (not just detected).
|
| 11 |
+
Catches RTX 5060 / c10.dll loader failures that pass torch.cuda.is_available()."""
|
| 12 |
+
try:
|
| 13 |
+
startupinfo = None
|
| 14 |
+
if sys.platform == 'win32':
|
| 15 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 16 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 17 |
+
|
| 18 |
+
cmd = [
|
| 19 |
+
sys.executable, "-c",
|
| 20 |
+
"import sys, torch, torch.nn as nn; "
|
| 21 |
+
"assert torch.cuda.is_available(); "
|
| 22 |
+
"conv = nn.Conv2d(1, 1, 3).cuda(); "
|
| 23 |
+
"x = torch.randn(1, 1, 8, 8).cuda(); "
|
| 24 |
+
"y = conv(x); "
|
| 25 |
+
"float(y.to('cpu')[0, 0, 0, 0]); "
|
| 26 |
+
"sys.exit(0)"
|
| 27 |
+
]
|
| 28 |
+
res = subprocess.run(cmd, capture_output=True, startupinfo=startupinfo, timeout=8)
|
| 29 |
+
return res.returncode == 0
|
| 30 |
+
except Exception:
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
class GPUResourceManager:
|
| 34 |
+
_instance = None
|
| 35 |
+
_lock = threading.Lock()
|
| 36 |
+
_gpu_heavy_lock = threading.Lock() # Lock to restrict only 1 GPU task at a time
|
| 37 |
+
|
| 38 |
+
@classmethod
|
| 39 |
+
def instance(cls):
|
| 40 |
+
with cls._lock:
|
| 41 |
+
if cls._instance is None:
|
| 42 |
+
cls._instance = cls()
|
| 43 |
+
return cls._instance
|
| 44 |
+
|
| 45 |
+
def acquire_gpu_lock(self):
|
| 46 |
+
"""Acquires lock for a GPU-heavy task"""
|
| 47 |
+
self._gpu_heavy_lock.acquire()
|
| 48 |
+
|
| 49 |
+
def release_gpu_lock(self):
|
| 50 |
+
"""Releases lock for a GPU-heavy task"""
|
| 51 |
+
try:
|
| 52 |
+
self._gpu_heavy_lock.release()
|
| 53 |
+
except RuntimeError:
|
| 54 |
+
pass # already released
|
| 55 |
+
|
| 56 |
+
def clear_vram_cache(self):
|
| 57 |
+
"""Clears PyTorch CUDA cache and collects garbage safely"""
|
| 58 |
+
gc.collect()
|
| 59 |
+
try:
|
| 60 |
+
import torch
|
| 61 |
+
if torch.cuda.is_available():
|
| 62 |
+
torch.cuda.empty_cache()
|
| 63 |
+
print("GPU VRAM Cache cleared successfully.")
|
| 64 |
+
except ImportError:
|
| 65 |
+
pass
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"Failed to clear VRAM cache: {e}")
|
| 68 |
+
|
| 69 |
+
def unload_ollama_model(self, model_name="hf.co/lmstudio-community/Qwen3.5-9B-GGUF:Q6_K"):
|
| 70 |
+
"""Sends unload request to Ollama to release model from VRAM"""
|
| 71 |
+
url = "http://127.0.0.1:11434/api/generate"
|
| 72 |
+
payload = {
|
| 73 |
+
"model": model_name,
|
| 74 |
+
"keep_alive": 0
|
| 75 |
+
}
|
| 76 |
+
try:
|
| 77 |
+
data = json.dumps(payload).encode('utf-8')
|
| 78 |
+
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
|
| 79 |
+
with urllib.request.urlopen(req, timeout=10) as response:
|
| 80 |
+
response.read()
|
| 81 |
+
print(f"Ollama model '{model_name}' successfully unloaded from VRAM.")
|
| 82 |
+
return True
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Ollama unload request failed: {e}")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
def get_vram_info(self):
|
| 88 |
+
"""Returns VRAM allocation details if CUDA is available"""
|
| 89 |
+
try:
|
| 90 |
+
import torch
|
| 91 |
+
if torch.cuda.is_available():
|
| 92 |
+
device = torch.cuda.current_device()
|
| 93 |
+
allocated = torch.cuda.memory_allocated(device) / (1024 ** 2)
|
| 94 |
+
reserved = torch.cuda.memory_reserved(device) / (1024 ** 2)
|
| 95 |
+
return {
|
| 96 |
+
"cuda_available": True,
|
| 97 |
+
"device_name": torch.cuda.get_device_name(device),
|
| 98 |
+
"allocated_mb": allocated,
|
| 99 |
+
"reserved_mb": reserved
|
| 100 |
+
}
|
| 101 |
+
except ImportError:
|
| 102 |
+
pass
|
| 103 |
+
return {"cuda_available": False}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class GPUStageLock:
|
| 107 |
+
def __init__(self, stage_name, log_fn=None):
|
| 108 |
+
self.stage_name = stage_name
|
| 109 |
+
self.log_fn = log_fn
|
| 110 |
+
|
| 111 |
+
def __enter__(self):
|
| 112 |
+
msg = f"[GPU LOCK] acquired by {self.stage_name}"
|
| 113 |
+
if self.log_fn:
|
| 114 |
+
self.log_fn(msg)
|
| 115 |
+
else:
|
| 116 |
+
print(msg)
|
| 117 |
+
GPUResourceManager.instance().acquire_gpu_lock()
|
| 118 |
+
return self
|
| 119 |
+
|
| 120 |
+
def __exit__(self, exc_type, exc, tb):
|
| 121 |
+
GPUResourceManager.instance().release_gpu_lock()
|
| 122 |
+
msg_rel = f"[GPU LOCK] released by {self.stage_name}"
|
| 123 |
+
if self.log_fn:
|
| 124 |
+
self.log_fn(msg_rel)
|
| 125 |
+
else:
|
| 126 |
+
print(msg_rel)
|
| 127 |
+
|
| 128 |
+
GPUResourceManager.instance().clear_vram_cache()
|
| 129 |
+
msg_clr = f"[GPU VRAM] cleared after {self.stage_name}"
|
| 130 |
+
if self.log_fn:
|
| 131 |
+
self.log_fn(msg_clr)
|
| 132 |
+
else:
|
| 133 |
+
print(msg_clr)
|
| 134 |
+
|
app/core/job_manager.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
class JobManager:
|
| 7 |
+
_instance = None
|
| 8 |
+
STAGES = [
|
| 9 |
+
"STAGE_0_PREPARE",
|
| 10 |
+
"STAGE_A_OCR_ASR",
|
| 11 |
+
"STAGE_B1_SEPARATION",
|
| 12 |
+
"STAGE_B2_LANGUAGE_DETECT",
|
| 13 |
+
"STAGE_B3_TIMELINE_CLASSIFY",
|
| 14 |
+
"STAGE_C_TRANSLATION",
|
| 15 |
+
"STAGE_TTS",
|
| 16 |
+
"STAGE_D_AUDIO_MIX",
|
| 17 |
+
"STAGE_D_RENDER",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
@classmethod
|
| 21 |
+
def instance(cls):
|
| 22 |
+
if cls._instance is None:
|
| 23 |
+
cls._instance = cls()
|
| 24 |
+
return cls._instance
|
| 25 |
+
|
| 26 |
+
def __init__(self, db_path=None):
|
| 27 |
+
if db_path is None:
|
| 28 |
+
# Place database in the project root directory
|
| 29 |
+
self.db_path = Path(__file__).parent.parent.parent / "jobs.db"
|
| 30 |
+
else:
|
| 31 |
+
self.db_path = Path(db_path)
|
| 32 |
+
|
| 33 |
+
self.init_db()
|
| 34 |
+
|
| 35 |
+
def get_connection(self):
|
| 36 |
+
conn = sqlite3.connect(str(self.db_path), timeout=30.0)
|
| 37 |
+
conn.row_factory = sqlite3.Row
|
| 38 |
+
return conn
|
| 39 |
+
|
| 40 |
+
def init_db(self):
|
| 41 |
+
with self.get_connection() as conn:
|
| 42 |
+
# Enable Foreign Keys
|
| 43 |
+
conn.execute("PRAGMA foreign_keys = ON;")
|
| 44 |
+
|
| 45 |
+
# Create jobs table
|
| 46 |
+
conn.execute("""
|
| 47 |
+
CREATE TABLE IF NOT EXISTS video_jobs (
|
| 48 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 49 |
+
video_path TEXT UNIQUE,
|
| 50 |
+
status TEXT NOT NULL,
|
| 51 |
+
error_message TEXT,
|
| 52 |
+
created_at REAL NOT NULL,
|
| 53 |
+
updated_at REAL NOT NULL
|
| 54 |
+
);
|
| 55 |
+
""")
|
| 56 |
+
|
| 57 |
+
# Create stages table
|
| 58 |
+
conn.execute("""
|
| 59 |
+
CREATE TABLE IF NOT EXISTS video_stages (
|
| 60 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 61 |
+
job_id INTEGER NOT NULL,
|
| 62 |
+
stage_name TEXT NOT NULL,
|
| 63 |
+
status TEXT NOT NULL,
|
| 64 |
+
updated_at REAL NOT NULL,
|
| 65 |
+
FOREIGN KEY (job_id) REFERENCES video_jobs(id) ON DELETE CASCADE,
|
| 66 |
+
UNIQUE(job_id, stage_name)
|
| 67 |
+
);
|
| 68 |
+
""")
|
| 69 |
+
conn.commit()
|
| 70 |
+
|
| 71 |
+
def register_job(self, video_path):
|
| 72 |
+
video_path_str = str(Path(video_path).resolve())
|
| 73 |
+
now = time.time()
|
| 74 |
+
with self.get_connection() as conn:
|
| 75 |
+
try:
|
| 76 |
+
cursor = conn.cursor()
|
| 77 |
+
cursor.execute(
|
| 78 |
+
"INSERT INTO video_jobs (video_path, status, created_at, updated_at) VALUES (?, ?, ?, ?) "
|
| 79 |
+
"ON CONFLICT(video_path) DO UPDATE SET updated_at=?",
|
| 80 |
+
(video_path_str, "PENDING", now, now, now)
|
| 81 |
+
)
|
| 82 |
+
job_id = cursor.lastrowid
|
| 83 |
+
if not job_id:
|
| 84 |
+
cursor.execute("SELECT id FROM video_jobs WHERE video_path=?", (video_path_str,))
|
| 85 |
+
job_id = cursor.fetchone()[0]
|
| 86 |
+
|
| 87 |
+
for stage in self.STAGES:
|
| 88 |
+
cursor.execute(
|
| 89 |
+
"INSERT OR IGNORE INTO video_stages (job_id, stage_name, status, updated_at) VALUES (?, ?, ?, ?)",
|
| 90 |
+
(job_id, stage, "PENDING", now)
|
| 91 |
+
)
|
| 92 |
+
self._migrate_legacy_stage_d(conn, job_id, now)
|
| 93 |
+
conn.commit()
|
| 94 |
+
return job_id
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"Database error in register_job: {e}")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
def _migrate_legacy_stage_d(self, conn, job_id, now):
|
| 100 |
+
cursor = conn.cursor()
|
| 101 |
+
cursor.execute(
|
| 102 |
+
"SELECT status FROM video_stages WHERE job_id=? AND stage_name='STAGE_D'",
|
| 103 |
+
(job_id,)
|
| 104 |
+
)
|
| 105 |
+
row = cursor.fetchone()
|
| 106 |
+
if not row:
|
| 107 |
+
return
|
| 108 |
+
legacy_status = row["status"]
|
| 109 |
+
if legacy_status == "DONE":
|
| 110 |
+
mapped_status = "DONE"
|
| 111 |
+
elif legacy_status in ("FAILED", "RUNNING"):
|
| 112 |
+
mapped_status = "PENDING"
|
| 113 |
+
else:
|
| 114 |
+
mapped_status = legacy_status
|
| 115 |
+
for stage in ("STAGE_D_AUDIO_MIX", "STAGE_D_RENDER"):
|
| 116 |
+
conn.execute(
|
| 117 |
+
"UPDATE video_stages SET status=?, updated_at=? WHERE job_id=? AND stage_name=? AND status='PENDING'",
|
| 118 |
+
(mapped_status, now, job_id, stage)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def get_job_id(self, video_path):
|
| 122 |
+
video_path_str = str(Path(video_path).resolve())
|
| 123 |
+
with self.get_connection() as conn:
|
| 124 |
+
cursor = conn.cursor()
|
| 125 |
+
cursor.execute("SELECT id FROM video_jobs WHERE video_path=?", (video_path_str,))
|
| 126 |
+
row = cursor.fetchone()
|
| 127 |
+
return row[0] if row else None
|
| 128 |
+
|
| 129 |
+
def update_job_status(self, job_id, status, error_message=None):
|
| 130 |
+
now = time.time()
|
| 131 |
+
with self.get_connection() as conn:
|
| 132 |
+
conn.execute(
|
| 133 |
+
"UPDATE video_jobs SET status=?, error_message=?, updated_at=? WHERE id=?",
|
| 134 |
+
(status, error_message, now, job_id)
|
| 135 |
+
)
|
| 136 |
+
conn.commit()
|
| 137 |
+
|
| 138 |
+
def update_stage_status(self, job_id, stage_name, status):
|
| 139 |
+
now = time.time()
|
| 140 |
+
with self.get_connection() as conn:
|
| 141 |
+
conn.execute(
|
| 142 |
+
"UPDATE video_stages SET status=?, updated_at=? WHERE job_id=? AND stage_name=?",
|
| 143 |
+
(status, now, job_id, stage_name)
|
| 144 |
+
)
|
| 145 |
+
conn.commit()
|
| 146 |
+
|
| 147 |
+
def is_stage_completed(self, job_id, stage_name):
|
| 148 |
+
with self.get_connection() as conn:
|
| 149 |
+
cursor = conn.cursor()
|
| 150 |
+
cursor.execute(
|
| 151 |
+
"SELECT status FROM video_stages WHERE job_id=? AND stage_name=?",
|
| 152 |
+
(job_id, stage_name)
|
| 153 |
+
)
|
| 154 |
+
row = cursor.fetchone()
|
| 155 |
+
return row and row["status"] == "DONE"
|
| 156 |
+
|
| 157 |
+
def get_job_report(self, job_id):
|
| 158 |
+
with self.get_connection() as conn:
|
| 159 |
+
cursor = conn.cursor()
|
| 160 |
+
cursor.execute("SELECT * FROM video_jobs WHERE id=?", (job_id,))
|
| 161 |
+
job = cursor.fetchone()
|
| 162 |
+
if not job:
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
cursor.execute("SELECT * FROM video_stages WHERE job_id=? ORDER BY id ASC", (job_id,))
|
| 166 |
+
stages = cursor.fetchall()
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"id": job["id"],
|
| 170 |
+
"video_path": job["video_path"],
|
| 171 |
+
"status": job["status"],
|
| 172 |
+
"error_message": job["error_message"],
|
| 173 |
+
"created_at": job["created_at"],
|
| 174 |
+
"updated_at": job["updated_at"],
|
| 175 |
+
"stages": [{s["stage_name"]: s["status"]} for s in stages]
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
def reset_job_for_retry(self, job_id, reset_from_stage=None):
|
| 179 |
+
"""Reset a job so it can be retried.
|
| 180 |
+
If reset_from_stage is None: only reset FAILED stages (keep DONE).
|
| 181 |
+
If reset_from_stage is a stage name: reset that stage and all subsequent stages."""
|
| 182 |
+
now = time.time()
|
| 183 |
+
with self.get_connection() as conn:
|
| 184 |
+
if reset_from_stage:
|
| 185 |
+
# Reset this specific stage and all after it
|
| 186 |
+
cursor = conn.cursor()
|
| 187 |
+
cursor.execute(
|
| 188 |
+
"SELECT id, stage_name, status FROM video_stages WHERE job_id=? ORDER BY id ASC",
|
| 189 |
+
(job_id,)
|
| 190 |
+
)
|
| 191 |
+
stages = cursor.fetchall()
|
| 192 |
+
found = False
|
| 193 |
+
for stage in stages:
|
| 194 |
+
if stage["stage_name"] == reset_from_stage:
|
| 195 |
+
found = True
|
| 196 |
+
if found:
|
| 197 |
+
conn.execute(
|
| 198 |
+
"UPDATE video_stages SET status='PENDING', updated_at=? WHERE id=?",
|
| 199 |
+
(now, stage["id"])
|
| 200 |
+
)
|
| 201 |
+
else:
|
| 202 |
+
# If STAGE_TTS is failed/running, also reset downstream audio/render to PENDING
|
| 203 |
+
cursor = conn.cursor()
|
| 204 |
+
cursor.execute(
|
| 205 |
+
"SELECT status FROM video_stages WHERE job_id=? AND stage_name='STAGE_TTS'",
|
| 206 |
+
(job_id,)
|
| 207 |
+
)
|
| 208 |
+
tts_row = cursor.fetchone()
|
| 209 |
+
if tts_row and tts_row["status"] in ("FAILED", "RUNNING"):
|
| 210 |
+
conn.execute(
|
| 211 |
+
"UPDATE video_stages SET status='PENDING', updated_at=? "
|
| 212 |
+
"WHERE job_id=? AND stage_name IN ('STAGE_TTS', 'STAGE_D_AUDIO_MIX', 'STAGE_D_RENDER')",
|
| 213 |
+
(now, job_id)
|
| 214 |
+
)
|
| 215 |
+
# Only reset FAILED stages, keep DONE
|
| 216 |
+
conn.execute(
|
| 217 |
+
"UPDATE video_stages SET status='PENDING', updated_at=? "
|
| 218 |
+
"WHERE job_id=? AND status IN ('FAILED', 'RUNNING')",
|
| 219 |
+
(now, job_id)
|
| 220 |
+
)
|
| 221 |
+
# Reset the job itself to PENDING
|
| 222 |
+
conn.execute(
|
| 223 |
+
"UPDATE video_jobs SET status='PENDING', error_message=NULL, updated_at=? WHERE id=?",
|
| 224 |
+
(now, job_id)
|
| 225 |
+
)
|
| 226 |
+
conn.commit()
|
| 227 |
+
|
| 228 |
+
def get_failed_jobs(self):
|
| 229 |
+
"""Return list of all jobs that failed, for 'Resume failed videos only' feature."""
|
| 230 |
+
with self.get_connection() as conn:
|
| 231 |
+
cursor = conn.cursor()
|
| 232 |
+
cursor.execute(
|
| 233 |
+
"SELECT id, video_path, status, error_message FROM video_jobs WHERE status='FAILED'"
|
| 234 |
+
)
|
| 235 |
+
rows = cursor.fetchall()
|
| 236 |
+
return [{"id": r["id"], "video_path": r["video_path"], "error_message": r["error_message"]} for r in rows]
|
| 237 |
+
|
| 238 |
+
def clear_all_jobs(self):
|
| 239 |
+
with self.get_connection() as conn:
|
| 240 |
+
conn.execute("DELETE FROM video_jobs;")
|
| 241 |
+
conn.commit()
|
app/core/ocr_worker_cli.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import cv2
|
| 4 |
+
import time
|
| 5 |
+
import difflib
|
| 6 |
+
import argparse
|
| 7 |
+
import traceback
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
def register_ocr_gpu_dll_paths():
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
root = Path(__file__).resolve().parents[2]
|
| 16 |
+
candidates = [
|
| 17 |
+
root / "env" / "Lib" / "site-packages" / "nvidia" / "cudnn" / "bin",
|
| 18 |
+
root / "env" / "Lib" / "site-packages" / "nvidia" / "cublas" / "bin",
|
| 19 |
+
root / "env" / "Lib" / "site-packages" / "nvidia" / "cuda_runtime" / "bin",
|
| 20 |
+
root / "env" / "Lib" / "site-packages" / "nvidia" / "cuda_nvrtc" / "bin",
|
| 21 |
+
root / "env" / "Lib" / "site-packages" / "paddle" / "libs",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
added = []
|
| 25 |
+
for p in candidates:
|
| 26 |
+
if p.exists():
|
| 27 |
+
try:
|
| 28 |
+
os.add_dll_directory(str(p))
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"[OCR DLL WARN] add_dll_directory failed for {p}: {e}")
|
| 31 |
+
added.append(str(p))
|
| 32 |
+
|
| 33 |
+
if added:
|
| 34 |
+
os.environ["PATH"] = os.pathsep.join(added) + os.pathsep + os.environ.get("PATH", "")
|
| 35 |
+
|
| 36 |
+
print("[OCR DLL] registered paths:")
|
| 37 |
+
for p in added:
|
| 38 |
+
print(f" - {p}")
|
| 39 |
+
|
| 40 |
+
register_ocr_gpu_dll_paths()
|
| 41 |
+
|
| 42 |
+
# Enforce UTF-8 for Windows console
|
| 43 |
+
if sys.platform == 'win32':
|
| 44 |
+
try:
|
| 45 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 46 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 47 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 48 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 49 |
+
except Exception:
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
def get_similarity(s1, s2):
|
| 53 |
+
if not s1 or not s2:
|
| 54 |
+
return 0.0
|
| 55 |
+
return difflib.SequenceMatcher(None, s1, s2).ratio()
|
| 56 |
+
|
| 57 |
+
def _is_cjk_char(ch):
|
| 58 |
+
return "\u3400" <= ch <= "\u9fff"
|
| 59 |
+
|
| 60 |
+
def _ocr_garbage_reason(text, max_frame_chars=80):
|
| 61 |
+
text = " ".join(str(text or "").split()).strip()
|
| 62 |
+
if not text:
|
| 63 |
+
return "empty"
|
| 64 |
+
if len(text) > int(max_frame_chars):
|
| 65 |
+
return "frame_text_too_long"
|
| 66 |
+
|
| 67 |
+
chars = [c for c in text if not c.isspace()]
|
| 68 |
+
if not chars:
|
| 69 |
+
return "empty"
|
| 70 |
+
|
| 71 |
+
noise_chars = set("□�√×÷′²③④》】//&¥#{}|~@^_+=<>")
|
| 72 |
+
noise_ratio = sum(1 for c in chars if c in noise_chars) / len(chars)
|
| 73 |
+
if noise_ratio > 0.08:
|
| 74 |
+
return "ocr_symbol_noise"
|
| 75 |
+
|
| 76 |
+
cjk_chars = [c for c in chars if _is_cjk_char(c)]
|
| 77 |
+
if len(cjk_chars) >= 30:
|
| 78 |
+
counts = {}
|
| 79 |
+
for c in cjk_chars:
|
| 80 |
+
counts[c] = counts.get(c, 0) + 1
|
| 81 |
+
dominant_ratio = max(counts.values()) / len(cjk_chars)
|
| 82 |
+
unique_ratio = len(counts) / len(cjk_chars)
|
| 83 |
+
if dominant_ratio > 0.20:
|
| 84 |
+
return "dominant_cjk_repeat"
|
| 85 |
+
if len(cjk_chars) >= 60 and unique_ratio < 0.18:
|
| 86 |
+
return "low_cjk_diversity"
|
| 87 |
+
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
def _atomic_write_json(path, payload):
|
| 91 |
+
path = Path(path)
|
| 92 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
tmp_path = path.with_name(path.name + ".tmp")
|
| 94 |
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
| 95 |
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 96 |
+
os.replace(tmp_path, path)
|
| 97 |
+
|
| 98 |
+
def _atomic_write_text(path, content):
|
| 99 |
+
path = Path(path)
|
| 100 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
tmp_path = path.with_name(path.name + ".tmp")
|
| 102 |
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
| 103 |
+
f.write(content)
|
| 104 |
+
os.replace(tmp_path, path)
|
| 105 |
+
|
| 106 |
+
def _extract_ocr_lines(results, min_confidence=0.0):
|
| 107 |
+
lines = []
|
| 108 |
+
for page in (results or []):
|
| 109 |
+
if not page:
|
| 110 |
+
continue
|
| 111 |
+
for line in page:
|
| 112 |
+
if not line or len(line) <= 1 or not line[1]:
|
| 113 |
+
continue
|
| 114 |
+
try:
|
| 115 |
+
text = str(line[1][0] or "").strip()
|
| 116 |
+
score = float(line[1][1])
|
| 117 |
+
except Exception:
|
| 118 |
+
continue
|
| 119 |
+
if text and score >= float(min_confidence):
|
| 120 |
+
lines.append((text, score))
|
| 121 |
+
return lines
|
| 122 |
+
|
| 123 |
+
def _find_cjk_font():
|
| 124 |
+
candidates = [
|
| 125 |
+
Path("C:/Windows/Fonts/msyh.ttc"),
|
| 126 |
+
Path("C:/Windows/Fonts/msyhbd.ttc"),
|
| 127 |
+
Path("C:/Windows/Fonts/simhei.ttf"),
|
| 128 |
+
Path("C:/Windows/Fonts/simsun.ttc"),
|
| 129 |
+
]
|
| 130 |
+
for path in candidates:
|
| 131 |
+
if path.exists():
|
| 132 |
+
return path
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
def _build_ocr_probe_image():
|
| 136 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 137 |
+
import numpy as np
|
| 138 |
+
|
| 139 |
+
text = "现在主要是拉尔夫劳伦贸易"
|
| 140 |
+
image = Image.new("RGB", (760, 128), (22, 22, 22))
|
| 141 |
+
draw = ImageDraw.Draw(image)
|
| 142 |
+
font_path = _find_cjk_font()
|
| 143 |
+
if not font_path:
|
| 144 |
+
raise RuntimeError("No Windows CJK font found for OCR quality probe.")
|
| 145 |
+
font = ImageFont.truetype(str(font_path), 46)
|
| 146 |
+
draw.text((28, 32), text, font=font, fill=(245, 245, 245))
|
| 147 |
+
return np.array(image), text
|
| 148 |
+
|
| 149 |
+
def _run_ocr_quality_probe(reader, max_frame_chars=160):
|
| 150 |
+
probe_img, expected_text = _build_ocr_probe_image()
|
| 151 |
+
results = reader.ocr(probe_img, cls=True)
|
| 152 |
+
lines = _extract_ocr_lines(results, min_confidence=0.1)
|
| 153 |
+
joined = " ".join(text for text, _score in lines).strip()
|
| 154 |
+
joined = " ".join(joined.split())
|
| 155 |
+
expected_chars = set("现在主要拉尔夫劳伦贸易")
|
| 156 |
+
hit_count = sum(1 for ch in expected_chars if ch in joined)
|
| 157 |
+
reason = _ocr_garbage_reason(joined, max_frame_chars=max_frame_chars)
|
| 158 |
+
ok = bool(joined) and hit_count >= 4 and not reason and len(lines) <= 8
|
| 159 |
+
details = {
|
| 160 |
+
"ok": ok,
|
| 161 |
+
"expected": expected_text,
|
| 162 |
+
"recognized": joined[:240],
|
| 163 |
+
"line_count": len(lines),
|
| 164 |
+
"hit_count": hit_count,
|
| 165 |
+
"reject_reason": reason,
|
| 166 |
+
"sample_lines": [
|
| 167 |
+
{"text": text, "confidence": round(float(score), 4)}
|
| 168 |
+
for text, score in lines[:5]
|
| 169 |
+
],
|
| 170 |
+
}
|
| 171 |
+
return ok, details
|
| 172 |
+
|
| 173 |
+
def format_srt_time(seconds):
|
| 174 |
+
h = int(seconds // 3600)
|
| 175 |
+
m = int((seconds % 3600) // 60)
|
| 176 |
+
s = int(seconds % 60)
|
| 177 |
+
ms = int((seconds - int(seconds)) * 1000)
|
| 178 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 179 |
+
|
| 180 |
+
def is_cuda_fully_functional():
|
| 181 |
+
try:
|
| 182 |
+
import torch
|
| 183 |
+
if not torch.cuda.is_available():
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
device_idx = torch.cuda.current_device()
|
| 187 |
+
gpu_major, gpu_minor = torch.cuda.get_device_capability(device_idx)
|
| 188 |
+
arch_list = torch.cuda.get_arch_list()
|
| 189 |
+
if not arch_list:
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
supported_caps = []
|
| 193 |
+
for arch in arch_list:
|
| 194 |
+
digits = ''.join(c for c in arch if c.isdigit())
|
| 195 |
+
if digits:
|
| 196 |
+
val = int(digits)
|
| 197 |
+
major = val // 10
|
| 198 |
+
minor = val % 10
|
| 199 |
+
supported_caps.append((major, minor))
|
| 200 |
+
|
| 201 |
+
if not supported_caps:
|
| 202 |
+
return False
|
| 203 |
+
|
| 204 |
+
max_supported_major = max(major for major, minor in supported_caps)
|
| 205 |
+
if gpu_major > max_supported_major:
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
import torch.nn as nn
|
| 209 |
+
conv = nn.Conv2d(1, 1, 3).cuda()
|
| 210 |
+
x = torch.randn(1, 1, 8, 8).cuda()
|
| 211 |
+
y = conv(x)
|
| 212 |
+
float(y.to("cpu")[0, 0, 0, 0])
|
| 213 |
+
return True
|
| 214 |
+
except Exception:
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
def main():
|
| 218 |
+
parser = argparse.ArgumentParser(description="Standalone PaddleOCR Subtitle Extractor CLI")
|
| 219 |
+
parser.add_argument("--video", required=True, help="Path to input video file")
|
| 220 |
+
parser.add_argument("--output", required=True, help="Path to output SRT file")
|
| 221 |
+
parser.add_argument("--region", required=False, help="Selected subtitle region as 'x,y,w,h,orig_w,orig_h'")
|
| 222 |
+
parser.add_argument("--device", default="auto", choices=["cuda", "cpu", "auto"], help="Device to run inference on")
|
| 223 |
+
parser.add_argument("--prefer-gpu", default="true", help="Prefer GPU execution (true/false)")
|
| 224 |
+
parser.add_argument("--allow-cpu-fallback", default="true", help="Allow fallback to CPU (true/false)")
|
| 225 |
+
parser.add_argument("--gpu-mem-mb", type=int, default=3072, help="GPU memory allocation limit in MB")
|
| 226 |
+
parser.add_argument("--sample-interval", type=float, default=0.25, help="Sample interval in seconds")
|
| 227 |
+
parser.add_argument("--min-line-confidence", type=float, default=0.62, help="Drop OCR text lines below this confidence")
|
| 228 |
+
parser.add_argument("--max-frame-text-chars", type=int, default=80, help="Reject OCR frame text longer than this")
|
| 229 |
+
parser.add_argument("--max-block-duration", type=float, default=6.5, help="Maximum subtitle block duration before forced split")
|
| 230 |
+
args = parser.parse_args()
|
| 231 |
+
|
| 232 |
+
video_path = Path(args.video)
|
| 233 |
+
output_srt_path = Path(args.output)
|
| 234 |
+
|
| 235 |
+
if not video_path.exists():
|
| 236 |
+
print(f"Error: Video file not found at {video_path}", file=sys.stderr)
|
| 237 |
+
sys.exit(2)
|
| 238 |
+
|
| 239 |
+
prefer_gpu = args.prefer_gpu.lower() in ("true", "1", "yes", "t")
|
| 240 |
+
allow_cpu_fallback = args.allow_cpu_fallback.lower() in ("true", "1", "yes", "t")
|
| 241 |
+
|
| 242 |
+
# ── Step 1: Set environment variables BEFORE any paddle/paddleocr import ──
|
| 243 |
+
import os
|
| 244 |
+
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
|
| 245 |
+
os.environ["FLAGS_use_mkldnn"] = "0"
|
| 246 |
+
print("[OCR ENV] PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python")
|
| 247 |
+
try:
|
| 248 |
+
import google.protobuf
|
| 249 |
+
pb_ver = google.protobuf.__version__
|
| 250 |
+
except Exception:
|
| 251 |
+
pb_ver = "Unknown"
|
| 252 |
+
print(f"[OCR ENV] protobuf version: {pb_ver}")
|
| 253 |
+
|
| 254 |
+
# ── Step 2: Import paddle (lightweight) and check CUDA BEFORE loading PaddleOCR ──
|
| 255 |
+
try:
|
| 256 |
+
import paddle
|
| 257 |
+
except ImportError as e:
|
| 258 |
+
print(f"Error: paddle not installed. {e}", file=sys.stderr)
|
| 259 |
+
sys.exit(1)
|
| 260 |
+
|
| 261 |
+
compiled_with_cuda = False
|
| 262 |
+
try:
|
| 263 |
+
compiled_with_cuda = paddle.device.is_compiled_with_cuda()
|
| 264 |
+
except Exception:
|
| 265 |
+
pass
|
| 266 |
+
|
| 267 |
+
# Log configuration and GPU check
|
| 268 |
+
print(f"[OCR CONFIG] requested device: {args.device}")
|
| 269 |
+
print(f"[OCR CONFIG] allow_cpu_fallback: {allow_cpu_fallback}")
|
| 270 |
+
print(f"[OCR GPU CHECK] paddle compiled with cuda: {compiled_with_cuda}")
|
| 271 |
+
try:
|
| 272 |
+
paddle_device = paddle.device.get_device()
|
| 273 |
+
except Exception:
|
| 274 |
+
paddle_device = "unknown"
|
| 275 |
+
print(f"[OCR GPU CHECK] paddle current device: {paddle_device}")
|
| 276 |
+
|
| 277 |
+
# ── Step 3: FAIL-FAST gate — exit before any heavy model loading ──
|
| 278 |
+
run_on_gpu = False
|
| 279 |
+
fallback_reason = None
|
| 280 |
+
|
| 281 |
+
if args.device == "cuda":
|
| 282 |
+
if not compiled_with_cuda:
|
| 283 |
+
fallback_reason = "Paddle is not compiled with CUDA."
|
| 284 |
+
if not allow_cpu_fallback:
|
| 285 |
+
print("[OCR ERROR] PaddleOCR GPU requested but Paddle is CPU-only. Failing early.", file=sys.stderr)
|
| 286 |
+
print(f"Reason: {fallback_reason}", file=sys.stderr)
|
| 287 |
+
sys.exit(3)
|
| 288 |
+
run_on_gpu = False
|
| 289 |
+
else:
|
| 290 |
+
run_on_gpu = True
|
| 291 |
+
elif args.device == "cpu":
|
| 292 |
+
run_on_gpu = False
|
| 293 |
+
else: # auto
|
| 294 |
+
if compiled_with_cuda and prefer_gpu:
|
| 295 |
+
run_on_gpu = True
|
| 296 |
+
else:
|
| 297 |
+
if not compiled_with_cuda:
|
| 298 |
+
fallback_reason = "Paddle is not compiled with CUDA."
|
| 299 |
+
else:
|
| 300 |
+
fallback_reason = "GPU not preferred by configuration."
|
| 301 |
+
if not allow_cpu_fallback:
|
| 302 |
+
print("[OCR ERROR] PaddleOCR GPU requested but Paddle is CPU-only. Failing early.", file=sys.stderr)
|
| 303 |
+
print(f"Reason: {fallback_reason}", file=sys.stderr)
|
| 304 |
+
sys.exit(3)
|
| 305 |
+
run_on_gpu = False
|
| 306 |
+
|
| 307 |
+
# Log device selection
|
| 308 |
+
if run_on_gpu:
|
| 309 |
+
print("[OCR] selected device: gpu")
|
| 310 |
+
else:
|
| 311 |
+
print(f"[OCR WARNING] PaddleOCR GPU unavailable, falling back to CPU.")
|
| 312 |
+
print(f"Reason: {fallback_reason or 'User requested CPU execution.'}")
|
| 313 |
+
|
| 314 |
+
if run_on_gpu:
|
| 315 |
+
try:
|
| 316 |
+
paddle.set_device("gpu:0")
|
| 317 |
+
except Exception as e:
|
| 318 |
+
print(f"Warning: paddle.set_device('gpu:0') failed: {e}")
|
| 319 |
+
|
| 320 |
+
# ── Step 4: NOW import PaddleOCR (heavy — downloads/loads models) ──
|
| 321 |
+
print("Loading PaddleOCR model...")
|
| 322 |
+
try:
|
| 323 |
+
from paddleocr import PaddleOCR
|
| 324 |
+
except ImportError as e:
|
| 325 |
+
print(f"Error: paddleocr not installed. {e}", file=sys.stderr)
|
| 326 |
+
sys.exit(1)
|
| 327 |
+
|
| 328 |
+
# Inspect PaddleOCR constructor signature
|
| 329 |
+
import inspect
|
| 330 |
+
sig = inspect.signature(PaddleOCR)
|
| 331 |
+
print(f"[DEBUG] PaddleOCR constructor signature: {sig}")
|
| 332 |
+
|
| 333 |
+
ocr_params = {
|
| 334 |
+
"use_angle_cls": True,
|
| 335 |
+
"lang": 'ch',
|
| 336 |
+
"show_log": False,
|
| 337 |
+
"enable_mkldnn": False
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
if "device" in sig.parameters:
|
| 341 |
+
ocr_params["device"] = "gpu" if run_on_gpu else "cpu"
|
| 342 |
+
else:
|
| 343 |
+
ocr_params["use_gpu"] = run_on_gpu
|
| 344 |
+
if run_on_gpu:
|
| 345 |
+
ocr_params["gpu_mem"] = args.gpu_mem_mb
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
reader = PaddleOCR(**ocr_params)
|
| 349 |
+
except Exception as e:
|
| 350 |
+
if not allow_cpu_fallback and run_on_gpu:
|
| 351 |
+
print("PaddleOCR GPU requested but unavailable.", file=sys.stderr)
|
| 352 |
+
print(f"Reason: PaddleOCR GPU init failed (Error: {e}).", file=sys.stderr)
|
| 353 |
+
sys.exit(3)
|
| 354 |
+
print(f"Warning: Failed to load PaddleOCR on GPU. Falling back to CPU. Error: {e}")
|
| 355 |
+
# Try CPU fallback
|
| 356 |
+
if "device" in sig.parameters:
|
| 357 |
+
ocr_params["device"] = "cpu"
|
| 358 |
+
else:
|
| 359 |
+
ocr_params["use_gpu"] = False
|
| 360 |
+
if "gpu_mem" in ocr_params:
|
| 361 |
+
del ocr_params["gpu_mem"]
|
| 362 |
+
try:
|
| 363 |
+
reader = PaddleOCR(**ocr_params)
|
| 364 |
+
except Exception as ex:
|
| 365 |
+
print(f"PaddleOCR initialization failed.\nReason: {ex}", file=sys.stderr)
|
| 366 |
+
sys.exit(4)
|
| 367 |
+
|
| 368 |
+
# ── OCR GPU Preflight Inference Check ──
|
| 369 |
+
if run_on_gpu:
|
| 370 |
+
print("[OCR GPU PREFLIGHT] running first inference test...")
|
| 371 |
+
try:
|
| 372 |
+
import numpy as np
|
| 373 |
+
dummy_img = np.zeros((64, 64, 3), dtype=np.uint8)
|
| 374 |
+
reader.ocr(dummy_img, cls=True)
|
| 375 |
+
print("[OCR GPU PREFLIGHT] PASS")
|
| 376 |
+
except Exception as e:
|
| 377 |
+
err_msg = str(e)
|
| 378 |
+
if "cudnn64_8.dll" in err_msg or "PreconditionNotMet" in err_msg or "dynamic library" in err_msg or "DLL load failed" in err_msg:
|
| 379 |
+
print("[OCR GPU PREFLIGHT] FAIL: cudnn64_8.dll missing or incompatible", file=sys.stderr)
|
| 380 |
+
print("[OCR GPU PREFLIGHT] FAIL: cudnn64_8.dll missing or incompatible")
|
| 381 |
+
if not allow_cpu_fallback:
|
| 382 |
+
print("PaddleOCR GPU preflight failed because cudnn64_8.dll is missing or incompatible.", file=sys.stderr)
|
| 383 |
+
sys.exit(5)
|
| 384 |
+
else:
|
| 385 |
+
print(f"[OCR GPU PREFLIGHT] FAIL: {e}", file=sys.stderr)
|
| 386 |
+
print(f"[OCR GPU PREFLIGHT] FAIL: {e}")
|
| 387 |
+
if not allow_cpu_fallback:
|
| 388 |
+
print(f"PaddleOCR GPU preflight failed: {e}", file=sys.stderr)
|
| 389 |
+
sys.exit(5)
|
| 390 |
+
|
| 391 |
+
# Handle CPU fallback if allowed
|
| 392 |
+
if allow_cpu_fallback:
|
| 393 |
+
print("Warning: OCR GPU preflight failed. Falling back to CPU.")
|
| 394 |
+
if "device" in sig.parameters:
|
| 395 |
+
ocr_params["device"] = "cpu"
|
| 396 |
+
else:
|
| 397 |
+
ocr_params["use_gpu"] = False
|
| 398 |
+
if "gpu_mem" in ocr_params:
|
| 399 |
+
del ocr_params["gpu_mem"]
|
| 400 |
+
try:
|
| 401 |
+
reader = PaddleOCR(**ocr_params)
|
| 402 |
+
run_on_gpu = False
|
| 403 |
+
except Exception as ex:
|
| 404 |
+
print(f"PaddleOCR CPU fallback initialization failed.\nReason: {ex}", file=sys.stderr)
|
| 405 |
+
sys.exit(4)
|
| 406 |
+
|
| 407 |
+
if run_on_gpu:
|
| 408 |
+
print("[OCR GPU QUALITY PREFLIGHT] running Chinese text probe...")
|
| 409 |
+
try:
|
| 410 |
+
quality_ok, quality_details = _run_ocr_quality_probe(reader)
|
| 411 |
+
print("[OCR GPU QUALITY PREFLIGHT] " + json.dumps(quality_details, ensure_ascii=False))
|
| 412 |
+
except Exception as e:
|
| 413 |
+
quality_ok = False
|
| 414 |
+
quality_details = {"ok": False, "error": str(e)}
|
| 415 |
+
print(f"[OCR GPU QUALITY PREFLIGHT] FAIL: {e}", file=sys.stderr)
|
| 416 |
+
|
| 417 |
+
if quality_ok:
|
| 418 |
+
print("[OCR GPU QUALITY PREFLIGHT] PASS")
|
| 419 |
+
elif not allow_cpu_fallback:
|
| 420 |
+
print("PaddleOCR GPU quality preflight failed. GPU OCR is returning invalid Chinese text.", file=sys.stderr)
|
| 421 |
+
print(json.dumps(quality_details, ensure_ascii=False), file=sys.stderr)
|
| 422 |
+
sys.exit(5)
|
| 423 |
+
else:
|
| 424 |
+
print("Warning: OCR GPU quality preflight failed. Falling back to CPU.")
|
| 425 |
+
if "device" in sig.parameters:
|
| 426 |
+
ocr_params["device"] = "cpu"
|
| 427 |
+
else:
|
| 428 |
+
ocr_params["use_gpu"] = False
|
| 429 |
+
if "gpu_mem" in ocr_params:
|
| 430 |
+
del ocr_params["gpu_mem"]
|
| 431 |
+
try:
|
| 432 |
+
reader = PaddleOCR(**ocr_params)
|
| 433 |
+
cpu_ok, cpu_details = _run_ocr_quality_probe(reader)
|
| 434 |
+
print("[OCR CPU QUALITY PREFLIGHT] " + json.dumps(cpu_details, ensure_ascii=False))
|
| 435 |
+
if not cpu_ok:
|
| 436 |
+
print("PaddleOCR CPU quality preflight also failed.", file=sys.stderr)
|
| 437 |
+
sys.exit(5)
|
| 438 |
+
run_on_gpu = False
|
| 439 |
+
print("[OCR CPU QUALITY PREFLIGHT] PASS")
|
| 440 |
+
except SystemExit:
|
| 441 |
+
raise
|
| 442 |
+
except Exception as ex:
|
| 443 |
+
print(f"PaddleOCR CPU fallback quality preflight failed.\nReason: {ex}", file=sys.stderr)
|
| 444 |
+
sys.exit(5)
|
| 445 |
+
|
| 446 |
+
cap = cv2.VideoCapture(str(video_path))
|
| 447 |
+
if not cap.isOpened():
|
| 448 |
+
print(f"Error: Cannot open video file {video_path}", file=sys.stderr)
|
| 449 |
+
sys.exit(2)
|
| 450 |
+
|
| 451 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 452 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 453 |
+
duration = total_frames / fps if fps > 0 else 0
|
| 454 |
+
vid_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 455 |
+
vid_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 456 |
+
|
| 457 |
+
print(f"Video resolution: {vid_w}x{vid_h} | FPS: {fps:.2f} | Duration: {duration:.2f}s")
|
| 458 |
+
|
| 459 |
+
if args.region:
|
| 460 |
+
try:
|
| 461 |
+
rx, ry, rw, rh, orig_w, orig_h = map(int, args.region.split(','))
|
| 462 |
+
|
| 463 |
+
norm_x = rx / orig_w if orig_w > 0 else 0.0
|
| 464 |
+
norm_y = ry / orig_h if orig_h > 0 else 0.0
|
| 465 |
+
norm_w = rw / orig_w if orig_w > 0 else 0.0
|
| 466 |
+
norm_h = rh / orig_h if orig_h > 0 else 0.0
|
| 467 |
+
|
| 468 |
+
real_x = int(round(norm_x * vid_w))
|
| 469 |
+
real_y = int(round(norm_y * vid_h))
|
| 470 |
+
real_w = max(1, int(round(norm_w * vid_w)))
|
| 471 |
+
real_h = max(1, int(round(norm_h * vid_h)))
|
| 472 |
+
|
| 473 |
+
real_w = min(real_w, vid_w)
|
| 474 |
+
real_h = min(real_h, vid_h)
|
| 475 |
+
real_x = max(0, min(real_x, vid_w - real_w))
|
| 476 |
+
real_y = max(0, min(real_y, vid_h - real_h))
|
| 477 |
+
|
| 478 |
+
print(f"[OCR] [UI Cords] region={rx},{ry},{rw},{rh}@{orig_w}x{orig_h}")
|
| 479 |
+
print(f"[OCR] [Normalized Cords] x={norm_x:.4f}, y={norm_y:.4f}, w={norm_w:.4f}, h={norm_h:.4f}")
|
| 480 |
+
print(f"[OCR] [Native Frame Cords] x={real_x}, y={real_y}, w={real_w}, h={real_h} (frame {vid_w}x{vid_h})")
|
| 481 |
+
except Exception as e:
|
| 482 |
+
print(f"Error parsing region '{args.region}': {e}", file=sys.stderr)
|
| 483 |
+
sys.exit(1)
|
| 484 |
+
else:
|
| 485 |
+
# Default to bottom 25% of the frame
|
| 486 |
+
real_x = 0
|
| 487 |
+
real_y = int(vid_h * 0.75)
|
| 488 |
+
real_w = vid_w
|
| 489 |
+
real_h = int(vid_h * 0.25)
|
| 490 |
+
print(f"OCR Region: default bottom-center (x={real_x}, y={real_y}, w={real_w}, h={real_h})")
|
| 491 |
+
|
| 492 |
+
sample_interval_sec = args.sample_interval
|
| 493 |
+
frame_interval = max(1, int(fps * sample_interval_sec))
|
| 494 |
+
|
| 495 |
+
print(f"Analyzing video (sampling every {sample_interval_sec}s)...")
|
| 496 |
+
|
| 497 |
+
raw_detections = []
|
| 498 |
+
prev_gray = None
|
| 499 |
+
last_text = ""
|
| 500 |
+
last_raw_text = ""
|
| 501 |
+
last_confidence = 0.0
|
| 502 |
+
last_reject_reason = None
|
| 503 |
+
ocr_calls = 0
|
| 504 |
+
skipped_calls = 0 # Duplicate-frame skips
|
| 505 |
+
ocr_time_sum = 0.0
|
| 506 |
+
frame_idx = 0
|
| 507 |
+
start_time = time.time()
|
| 508 |
+
|
| 509 |
+
try:
|
| 510 |
+
while True:
|
| 511 |
+
ret, frame = cap.read()
|
| 512 |
+
if not ret:
|
| 513 |
+
break
|
| 514 |
+
|
| 515 |
+
if frame_idx % frame_interval == 0:
|
| 516 |
+
current_time_sec = frame_idx / fps
|
| 517 |
+
crop = frame[real_y:real_y+real_h, real_x:real_x+real_w]
|
| 518 |
+
|
| 519 |
+
if crop.size == 0:
|
| 520 |
+
frame_idx += 1
|
| 521 |
+
continue
|
| 522 |
+
|
| 523 |
+
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
| 524 |
+
is_duplicate = False
|
| 525 |
+
|
| 526 |
+
if prev_gray is not None:
|
| 527 |
+
diff = cv2.absdiff(gray, prev_gray)
|
| 528 |
+
mean_diff = diff.mean()
|
| 529 |
+
if mean_diff < 2.0:
|
| 530 |
+
is_duplicate = True
|
| 531 |
+
skipped_calls += 1
|
| 532 |
+
|
| 533 |
+
if is_duplicate:
|
| 534 |
+
text = last_text
|
| 535 |
+
raw_text = last_raw_text
|
| 536 |
+
avg_confidence = last_confidence
|
| 537 |
+
reject_reason = last_reject_reason
|
| 538 |
+
else:
|
| 539 |
+
ocr_start = time.time()
|
| 540 |
+
results = reader.ocr(gray, cls=True)
|
| 541 |
+
ocr_time_sum += (time.time() - ocr_start)
|
| 542 |
+
ocr_calls += 1
|
| 543 |
+
|
| 544 |
+
text_list = []
|
| 545 |
+
scores = []
|
| 546 |
+
if results and results[0]:
|
| 547 |
+
for detected_text, score in _extract_ocr_lines(results, args.min_line_confidence):
|
| 548 |
+
text_list.append(detected_text)
|
| 549 |
+
scores.append(score)
|
| 550 |
+
raw_text = " ".join(text_list).strip()
|
| 551 |
+
raw_text = " ".join(raw_text.split())
|
| 552 |
+
avg_confidence = sum(scores) / len(scores) if scores else 0.0
|
| 553 |
+
reject_reason = _ocr_garbage_reason(raw_text, args.max_frame_text_chars)
|
| 554 |
+
text = "" if reject_reason else raw_text
|
| 555 |
+
last_text = text
|
| 556 |
+
last_raw_text = raw_text
|
| 557 |
+
last_confidence = avg_confidence
|
| 558 |
+
last_reject_reason = reject_reason
|
| 559 |
+
prev_gray = gray
|
| 560 |
+
|
| 561 |
+
raw_detections.append((current_time_sec, text, raw_text, avg_confidence, reject_reason))
|
| 562 |
+
|
| 563 |
+
if total_frames > 0:
|
| 564 |
+
pct = min(100, int((frame_idx / total_frames) * 100))
|
| 565 |
+
print(f"PROGRESS: {pct}%", flush=True)
|
| 566 |
+
|
| 567 |
+
frame_idx += 1
|
| 568 |
+
except Exception as e:
|
| 569 |
+
print(f"OCR processing failed during scanning: {e}", file=sys.stderr)
|
| 570 |
+
traceback.print_exc()
|
| 571 |
+
sys.exit(5)
|
| 572 |
+
|
| 573 |
+
cap.release()
|
| 574 |
+
print("PROGRESS: 100%", flush=True)
|
| 575 |
+
elapsed = time.time() - start_time
|
| 576 |
+
|
| 577 |
+
# Total frames skipped from OCR includes both the interval skips and duplicate skips
|
| 578 |
+
skipped_frames_total = (frame_idx - ocr_calls)
|
| 579 |
+
avg_ocr_time = (ocr_time_sum / ocr_calls) if ocr_calls > 0 else 0.0
|
| 580 |
+
|
| 581 |
+
print(f"Scanning completed in {elapsed:.1f}s.")
|
| 582 |
+
print(f"OCR calls: {ocr_calls}")
|
| 583 |
+
print(f"Skipped frames: {skipped_frames_total}")
|
| 584 |
+
print(f"Duplicate-frame skips: {skipped_calls}")
|
| 585 |
+
print(f"Average OCR time per call: {avg_ocr_time:.3f}s")
|
| 586 |
+
|
| 587 |
+
accepted_frames = sum(1 for _t, txt, _raw, _conf, _reason in raw_detections if txt)
|
| 588 |
+
raw_text_frames = sum(1 for _t, _txt, raw, _conf, _reason in raw_detections if raw)
|
| 589 |
+
rejected_frames = sum(1 for _t, _txt, _raw, _conf, reason in raw_detections if reason)
|
| 590 |
+
|
| 591 |
+
# Blocker 5: Save raw detections to ocr_raw.json
|
| 592 |
+
raw_json_path = output_srt_path.parent / "ocr_raw.json"
|
| 593 |
+
raw_payload = [
|
| 594 |
+
{
|
| 595 |
+
"timestamp": t,
|
| 596 |
+
"text": txt,
|
| 597 |
+
"raw_text": raw_txt,
|
| 598 |
+
"confidence": round(float(conf), 4),
|
| 599 |
+
"rejected": bool(reason),
|
| 600 |
+
"reject_reason": reason,
|
| 601 |
+
}
|
| 602 |
+
for t, txt, raw_txt, conf, reason in raw_detections
|
| 603 |
+
]
|
| 604 |
+
try:
|
| 605 |
+
_atomic_write_json(raw_json_path, raw_payload)
|
| 606 |
+
print(f"Saved raw detections to {raw_json_path}")
|
| 607 |
+
except Exception as e:
|
| 608 |
+
print(f"Warning: Failed to save ocr_raw.json: {e}", file=sys.stderr)
|
| 609 |
+
|
| 610 |
+
# Temporal Smoothing & Merge
|
| 611 |
+
srt_blocks = []
|
| 612 |
+
block_id = 1
|
| 613 |
+
active_text = ""
|
| 614 |
+
active_start = -1.0
|
| 615 |
+
active_end = -1.0
|
| 616 |
+
|
| 617 |
+
gap_threshold = 0.4
|
| 618 |
+
similarity_threshold = 0.7
|
| 619 |
+
min_duration = 0.3
|
| 620 |
+
|
| 621 |
+
try:
|
| 622 |
+
for current_time, text, _raw_text, _confidence, _reject_reason in raw_detections:
|
| 623 |
+
if len(text) < 1:
|
| 624 |
+
text = ""
|
| 625 |
+
|
| 626 |
+
if text:
|
| 627 |
+
if not active_text:
|
| 628 |
+
active_text = text
|
| 629 |
+
active_start = current_time
|
| 630 |
+
active_end = current_time + sample_interval_sec
|
| 631 |
+
else:
|
| 632 |
+
sim = get_similarity(active_text, text)
|
| 633 |
+
forced_split = (
|
| 634 |
+
float(args.max_block_duration) > 0
|
| 635 |
+
and (current_time + sample_interval_sec - active_start) > float(args.max_block_duration)
|
| 636 |
+
)
|
| 637 |
+
if sim >= similarity_threshold and not forced_split:
|
| 638 |
+
active_end = current_time + sample_interval_sec
|
| 639 |
+
if len(text) > len(active_text):
|
| 640 |
+
active_text = text
|
| 641 |
+
else:
|
| 642 |
+
dur = active_end - active_start
|
| 643 |
+
if dur >= min_duration:
|
| 644 |
+
srt_blocks.append({
|
| 645 |
+
"id": block_id,
|
| 646 |
+
"start": active_start,
|
| 647 |
+
"end": active_end,
|
| 648 |
+
"text": active_text
|
| 649 |
+
})
|
| 650 |
+
block_id += 1
|
| 651 |
+
active_text = text
|
| 652 |
+
active_start = current_time
|
| 653 |
+
active_end = current_time + sample_interval_sec
|
| 654 |
+
else:
|
| 655 |
+
if active_text:
|
| 656 |
+
if current_time - active_end <= gap_threshold:
|
| 657 |
+
continue
|
| 658 |
+
else:
|
| 659 |
+
dur = active_end - active_start
|
| 660 |
+
if dur >= min_duration:
|
| 661 |
+
srt_blocks.append({
|
| 662 |
+
"id": block_id,
|
| 663 |
+
"start": active_start,
|
| 664 |
+
"end": active_end,
|
| 665 |
+
"text": active_text
|
| 666 |
+
})
|
| 667 |
+
block_id += 1
|
| 668 |
+
active_text = ""
|
| 669 |
+
active_start = -1.0
|
| 670 |
+
active_end = -1.0
|
| 671 |
+
|
| 672 |
+
if active_text:
|
| 673 |
+
dur = active_end - active_start
|
| 674 |
+
if dur >= min_duration:
|
| 675 |
+
srt_blocks.append({
|
| 676 |
+
"id": block_id,
|
| 677 |
+
"start": active_start,
|
| 678 |
+
"end": active_end,
|
| 679 |
+
"text": active_text
|
| 680 |
+
})
|
| 681 |
+
except Exception as e:
|
| 682 |
+
print(f"OCR processing failed during smoothing: {e}", file=sys.stderr)
|
| 683 |
+
traceback.print_exc()
|
| 684 |
+
sys.exit(5)
|
| 685 |
+
|
| 686 |
+
# Blocker 5: Save srt_blocks to ocr_grouped_blocks.json
|
| 687 |
+
grouped_json_path = output_srt_path.parent / "ocr_grouped_blocks.json"
|
| 688 |
+
grouped_payload = [
|
| 689 |
+
{"id": b["id"], "start": b["start"], "end": b["end"], "text": b["text"]}
|
| 690 |
+
for b in srt_blocks
|
| 691 |
+
]
|
| 692 |
+
try:
|
| 693 |
+
_atomic_write_json(grouped_json_path, grouped_payload)
|
| 694 |
+
print(f"Saved grouped blocks to {grouped_json_path}")
|
| 695 |
+
except Exception as e:
|
| 696 |
+
print(f"Warning: Failed to save ocr_grouped_blocks.json: {e}", file=sys.stderr)
|
| 697 |
+
|
| 698 |
+
summary_path = output_srt_path.parent / "ocr_summary.json"
|
| 699 |
+
summary_payload = {
|
| 700 |
+
"status": "OK" if srt_blocks else "EMPTY",
|
| 701 |
+
"device": "gpu" if run_on_gpu else "cpu",
|
| 702 |
+
"duration_sec": round(float(duration), 3),
|
| 703 |
+
"sample_interval_sec": sample_interval_sec,
|
| 704 |
+
"ocr_calls": ocr_calls,
|
| 705 |
+
"raw_detection_frames": len(raw_detections),
|
| 706 |
+
"raw_text_frames": raw_text_frames,
|
| 707 |
+
"accepted_frames": accepted_frames,
|
| 708 |
+
"rejected_frames": rejected_frames,
|
| 709 |
+
"srt_blocks": len(srt_blocks),
|
| 710 |
+
"region": {
|
| 711 |
+
"x": real_x,
|
| 712 |
+
"y": real_y,
|
| 713 |
+
"w": real_w,
|
| 714 |
+
"h": real_h,
|
| 715 |
+
"video_w": vid_w,
|
| 716 |
+
"video_h": vid_h,
|
| 717 |
+
},
|
| 718 |
+
}
|
| 719 |
+
try:
|
| 720 |
+
_atomic_write_json(summary_path, summary_payload)
|
| 721 |
+
print(f"Saved OCR summary to {summary_path}")
|
| 722 |
+
except Exception as e:
|
| 723 |
+
print(f"Warning: Failed to save ocr_summary.json: {e}", file=sys.stderr)
|
| 724 |
+
|
| 725 |
+
# Output SRT file
|
| 726 |
+
srt_content = []
|
| 727 |
+
for block in srt_blocks:
|
| 728 |
+
srt_content.append(f"{block['id']}\n")
|
| 729 |
+
srt_content.append(f"{format_srt_time(block['start'])} --> {format_srt_time(block['end'])}\n")
|
| 730 |
+
srt_content.append(f"{block['text']}\n\n")
|
| 731 |
+
_atomic_write_text(output_srt_path, "".join(srt_content))
|
| 732 |
+
|
| 733 |
+
if not srt_blocks:
|
| 734 |
+
print(
|
| 735 |
+
"[OCR EMPTY] No valid subtitle blocks were produced. "
|
| 736 |
+
f"raw_text_frames={raw_text_frames}, accepted_frames={accepted_frames}, rejected_frames={rejected_frames}. "
|
| 737 |
+
"Check OCR region, GPU OCR quality, and ocr_raw.json/ocr_summary.json.",
|
| 738 |
+
file=sys.stderr,
|
| 739 |
+
)
|
| 740 |
+
sys.exit(6)
|
| 741 |
+
|
| 742 |
+
print(f"OCR transcription completed successfully. Saved {len(srt_blocks)} blocks to {output_srt_path}")
|
| 743 |
+
|
| 744 |
+
# Cleanup VRAM and exit
|
| 745 |
+
try:
|
| 746 |
+
del reader
|
| 747 |
+
except NameError:
|
| 748 |
+
pass
|
| 749 |
+
import gc
|
| 750 |
+
gc.collect()
|
| 751 |
+
try:
|
| 752 |
+
import torch
|
| 753 |
+
if torch.cuda.is_available():
|
| 754 |
+
torch.cuda.empty_cache()
|
| 755 |
+
except Exception:
|
| 756 |
+
pass
|
| 757 |
+
sys.exit(0)
|
| 758 |
+
|
| 759 |
+
if __name__ == "__main__":
|
| 760 |
+
main()
|
app/core/pipeline_worker.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/core/preflight.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import shutil
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class BatchPreflight:
|
| 10 |
+
def __init__(self, base_dir, output_dir, temp_dir, ffmpeg_path):
|
| 11 |
+
self.base_dir = Path(base_dir)
|
| 12 |
+
self.output_dir = Path(output_dir)
|
| 13 |
+
self.temp_dir = Path(temp_dir)
|
| 14 |
+
self.ffmpeg_path = Path(ffmpeg_path)
|
| 15 |
+
self.python_exe = self._resolve_python_executable()
|
| 16 |
+
|
| 17 |
+
def _resolve_python_executable(self):
|
| 18 |
+
env_python = self.base_dir / "env" / "Scripts" / "python.exe"
|
| 19 |
+
if env_python.exists():
|
| 20 |
+
return env_python
|
| 21 |
+
return Path(sys.executable)
|
| 22 |
+
|
| 23 |
+
def _gpu_env(self):
|
| 24 |
+
import os
|
| 25 |
+
env = os.environ.copy()
|
| 26 |
+
env["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
|
| 27 |
+
env["PYTHONUTF8"] = "1"
|
| 28 |
+
nvidia_root = self.base_dir / "env" / "Lib" / "site-packages" / "nvidia"
|
| 29 |
+
extra_paths = []
|
| 30 |
+
if nvidia_root.exists():
|
| 31 |
+
for child in nvidia_root.iterdir():
|
| 32 |
+
for sub in ("bin", "lib"):
|
| 33 |
+
p = child / sub
|
| 34 |
+
if p.exists():
|
| 35 |
+
extra_paths.append(str(p))
|
| 36 |
+
if extra_paths:
|
| 37 |
+
env["PATH"] = ";".join(extra_paths + [env.get("PATH", "")])
|
| 38 |
+
env["PYTHONPATH"] = str(self.base_dir) + ";" + env.get("PYTHONPATH", "")
|
| 39 |
+
return env
|
| 40 |
+
|
| 41 |
+
def run(self, asr_engine, trans_engine, tts_engine, strict_gpu, log_fn=None, required_checks=None):
|
| 42 |
+
required_checks = set(required_checks or [])
|
| 43 |
+
checks = []
|
| 44 |
+
checks.append(self._check_writable("output_writable", self.output_dir, True))
|
| 45 |
+
checks.append(self._check_writable("temp_writable", self.temp_dir, True))
|
| 46 |
+
checks.append(self._check_disk_space("disk_space", self.output_dir, min_free_gb=5, required=True))
|
| 47 |
+
checks.append(self._check_nvenc(required="ffmpeg_h264_nvenc" in required_checks))
|
| 48 |
+
checks.append(self._check_python_cuda("torch_cuda", required="torch_cuda" in required_checks))
|
| 49 |
+
checks.append(self._check_asr_cuda(required="faster_whisper_ctranslate2_cuda" in required_checks))
|
| 50 |
+
if "PaddleOCR" in asr_engine or "Quét chữ" in asr_engine:
|
| 51 |
+
checks.append(self._check_paddle_cuda(required="paddleocr_cuda" in required_checks))
|
| 52 |
+
checks.append(self._check_onnx_cuda(required="onnxruntime_cuda_provider" in required_checks))
|
| 53 |
+
checks.append(self._check_provider_config(trans_engine, required=True))
|
| 54 |
+
|
| 55 |
+
report = {
|
| 56 |
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 57 |
+
"strict_gpu": strict_gpu,
|
| 58 |
+
"asr_engine": asr_engine,
|
| 59 |
+
"trans_engine": trans_engine,
|
| 60 |
+
"tts_engine": tts_engine,
|
| 61 |
+
"checks": checks,
|
| 62 |
+
"status": "PASS" if all(c["ok"] or not c["required"] for c in checks) else "FAILED",
|
| 63 |
+
}
|
| 64 |
+
self._write_reports(report)
|
| 65 |
+
if log_fn:
|
| 66 |
+
for c in checks:
|
| 67 |
+
state = "PASS" if c["ok"] else ("FAIL" if c["required"] else "WARN")
|
| 68 |
+
log_fn(f"[PREFLIGHT] {state}: {c['name']} - {c['message']}")
|
| 69 |
+
log_fn(f"[PREFLIGHT] report: {self.output_dir / 'preflight_report.json'}")
|
| 70 |
+
return report
|
| 71 |
+
|
| 72 |
+
def _run_cmd(self, cmd, timeout=20):
|
| 73 |
+
startupinfo = None
|
| 74 |
+
if sys.platform == "win32":
|
| 75 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 76 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 77 |
+
try:
|
| 78 |
+
res = subprocess.run(
|
| 79 |
+
cmd,
|
| 80 |
+
stdout=subprocess.PIPE,
|
| 81 |
+
stderr=subprocess.PIPE,
|
| 82 |
+
text=True,
|
| 83 |
+
encoding="utf-8",
|
| 84 |
+
errors="ignore",
|
| 85 |
+
timeout=timeout,
|
| 86 |
+
startupinfo=startupinfo,
|
| 87 |
+
env=self._gpu_env(),
|
| 88 |
+
)
|
| 89 |
+
return res.returncode, (res.stdout or ""), (res.stderr or "")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return 99, "", str(e)
|
| 92 |
+
|
| 93 |
+
def _check_writable(self, name, path, required):
|
| 94 |
+
try:
|
| 95 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
probe = path / ".preflight_write_test"
|
| 97 |
+
probe.write_text("ok", encoding="utf-8")
|
| 98 |
+
probe.unlink(missing_ok=True)
|
| 99 |
+
return self._result(name, True, required, f"{path} writable")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
return self._result(name, False, required, str(e))
|
| 102 |
+
|
| 103 |
+
def _check_disk_space(self, name, path, min_free_gb, required):
|
| 104 |
+
try:
|
| 105 |
+
usage = shutil.disk_usage(path)
|
| 106 |
+
free_gb = usage.free / (1024 ** 3)
|
| 107 |
+
return self._result(name, free_gb >= min_free_gb, required, f"{free_gb:.1f} GB free")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
return self._result(name, False, required, str(e))
|
| 110 |
+
|
| 111 |
+
def _check_nvenc(self, required):
|
| 112 |
+
cmd = [
|
| 113 |
+
str(self.ffmpeg_path), "-y", "-f", "lavfi", "-i", "color=c=black:s=256x256",
|
| 114 |
+
"-t", "1", "-c:v", "h264_nvenc", "-f", "null", "-"
|
| 115 |
+
]
|
| 116 |
+
code, out, err = self._run_cmd(cmd, timeout=15)
|
| 117 |
+
msg = "h264_nvenc OK" if code == 0 else (err or out)[-300:]
|
| 118 |
+
return self._result("ffmpeg_h264_nvenc", code == 0, required, msg)
|
| 119 |
+
|
| 120 |
+
def _check_python_cuda(self, name, required):
|
| 121 |
+
code = (
|
| 122 |
+
"import torch; "
|
| 123 |
+
"assert torch.cuda.is_available(), 'torch cuda unavailable'; "
|
| 124 |
+
"x=torch.randn(1, device='cuda'); "
|
| 125 |
+
"print(torch.cuda.get_device_name(0))"
|
| 126 |
+
)
|
| 127 |
+
rc, out, err = self._run_cmd([str(self.python_exe), "-c", code], timeout=30)
|
| 128 |
+
msg = out.strip() if rc == 0 else (err or out)[-300:]
|
| 129 |
+
return self._result(name, rc == 0, required, msg)
|
| 130 |
+
|
| 131 |
+
def _check_asr_cuda(self, required):
|
| 132 |
+
code = (
|
| 133 |
+
"import os, tempfile, wave; "
|
| 134 |
+
"import ctranslate2; "
|
| 135 |
+
"n=ctranslate2.get_cuda_device_count(); "
|
| 136 |
+
"assert n>0, 'ctranslate2 cuda device count is 0'; "
|
| 137 |
+
"from faster_whisper import WhisperModel; "
|
| 138 |
+
"fd,path=tempfile.mkstemp(suffix='.wav'); os.close(fd); "
|
| 139 |
+
"wf=wave.open(path,'wb'); wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000); "
|
| 140 |
+
"wf.writeframes(b'\\x00\\x00'*16000); wf.close(); "
|
| 141 |
+
"model=WhisperModel('base', device='cuda', compute_type='float16'); "
|
| 142 |
+
"segments,info=model.transcribe(path, vad_filter=True, beam_size=1); "
|
| 143 |
+
"list(segments); "
|
| 144 |
+
"os.remove(path); "
|
| 145 |
+
"print('ctranslate2_cuda_transcribe_ok', ctranslate2.__version__, 'devices', n)"
|
| 146 |
+
)
|
| 147 |
+
rc, out, err = self._run_cmd([str(self.python_exe), "-c", code], timeout=90)
|
| 148 |
+
msg = out.strip() if rc == 0 else (err or out)[-300:]
|
| 149 |
+
return self._result("faster_whisper_ctranslate2_cuda", rc == 0, required, msg)
|
| 150 |
+
|
| 151 |
+
def _check_paddle_cuda(self, required):
|
| 152 |
+
code = (
|
| 153 |
+
"import os, json; "
|
| 154 |
+
"os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION','python'); "
|
| 155 |
+
"from app.core.ocr_worker_cli import register_ocr_gpu_dll_paths, _run_ocr_quality_probe; "
|
| 156 |
+
"register_ocr_gpu_dll_paths(); "
|
| 157 |
+
"import paddle; "
|
| 158 |
+
"ok=paddle.device.is_compiled_with_cuda(); "
|
| 159 |
+
"assert ok, 'paddle is not compiled with cuda'; "
|
| 160 |
+
"paddle.set_device('gpu:0'); "
|
| 161 |
+
"from paddleocr import PaddleOCR; "
|
| 162 |
+
"ocr=PaddleOCR(use_angle_cls=True, lang='ch', show_log=False, enable_mkldnn=False, use_gpu=True); "
|
| 163 |
+
"quality_ok, details=_run_ocr_quality_probe(ocr); "
|
| 164 |
+
"assert quality_ok, details; "
|
| 165 |
+
"print(paddle.device.get_device(), json.dumps(details, ensure_ascii=False))"
|
| 166 |
+
)
|
| 167 |
+
rc, out, err = self._run_cmd([str(self.python_exe), "-c", code], timeout=60)
|
| 168 |
+
msg = out.strip() if rc == 0 else (err or out)[-300:]
|
| 169 |
+
return self._result("paddleocr_cuda", rc == 0, required, msg)
|
| 170 |
+
|
| 171 |
+
def _check_onnx_cuda(self, required):
|
| 172 |
+
code = (
|
| 173 |
+
"import onnxruntime as ort; "
|
| 174 |
+
"providers=ort.get_available_providers(); "
|
| 175 |
+
"assert 'CUDAExecutionProvider' in providers, providers; "
|
| 176 |
+
"print(providers)"
|
| 177 |
+
)
|
| 178 |
+
rc, out, err = self._run_cmd([str(self.python_exe), "-c", code], timeout=15)
|
| 179 |
+
msg = out.strip() if rc == 0 else (err or out)[-300:]
|
| 180 |
+
return self._result("onnxruntime_cuda_provider", rc == 0, required, msg)
|
| 181 |
+
|
| 182 |
+
def _check_provider_config(self, trans_engine, required):
|
| 183 |
+
config_path = self.base_dir / "config.json"
|
| 184 |
+
try:
|
| 185 |
+
cfg = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
|
| 186 |
+
if "Google" in trans_engine:
|
| 187 |
+
return self._result("translation_provider_config", True, required, "Google selected")
|
| 188 |
+
if "Ollama" in trans_engine:
|
| 189 |
+
model = cfg.get("ollama_model") or cfg.get("translation", {}).get("ollama_model")
|
| 190 |
+
return self._result("translation_provider_config", bool(model), required, "Ollama model configured" if model else "missing Ollama model")
|
| 191 |
+
has_key = bool(cfg.get("hf_key") or cfg.get("groq_key"))
|
| 192 |
+
return self._result("translation_provider_config", has_key, required, "API key present" if has_key else "missing provider API key")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
return self._result("translation_provider_config", False, required, str(e))
|
| 195 |
+
|
| 196 |
+
def _write_reports(self, report):
|
| 197 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 198 |
+
(self.output_dir / "preflight_report.json").write_text(
|
| 199 |
+
json.dumps(report, ensure_ascii=False, indent=2),
|
| 200 |
+
encoding="utf-8",
|
| 201 |
+
)
|
| 202 |
+
lines = [
|
| 203 |
+
"=== PREFLIGHT REPORT ===",
|
| 204 |
+
f"Status: {report['status']}",
|
| 205 |
+
f"Strict GPU: {report['strict_gpu']}",
|
| 206 |
+
]
|
| 207 |
+
for c in report["checks"]:
|
| 208 |
+
state = "PASS" if c["ok"] else ("FAIL" if c["required"] else "WARN")
|
| 209 |
+
lines.append(f"- {state} {c['name']}: {c['message']}")
|
| 210 |
+
(self.output_dir / "preflight_report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 211 |
+
|
| 212 |
+
@staticmethod
|
| 213 |
+
def _result(name, ok, required, message):
|
| 214 |
+
return {
|
| 215 |
+
"name": name,
|
| 216 |
+
"ok": bool(ok),
|
| 217 |
+
"required": bool(required),
|
| 218 |
+
"message": str(message).strip(),
|
| 219 |
+
}
|
app/core/process_manager.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import threading
|
| 6 |
+
|
| 7 |
+
class ProcessManager:
|
| 8 |
+
_instance = None
|
| 9 |
+
_lock = threading.Lock()
|
| 10 |
+
|
| 11 |
+
@classmethod
|
| 12 |
+
def instance(cls):
|
| 13 |
+
with cls._lock:
|
| 14 |
+
if cls._instance is None:
|
| 15 |
+
cls._instance = cls()
|
| 16 |
+
return cls._instance
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.active_processes = {} # pid -> (name, Popen object)
|
| 20 |
+
|
| 21 |
+
def build_subprocess_env(self):
|
| 22 |
+
env = os.environ.copy()
|
| 23 |
+
env["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
|
| 24 |
+
env["PYTHONUTF8"] = "1"
|
| 25 |
+
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 26 |
+
nvidia_root = os.path.join(base_dir, "env", "Lib", "site-packages", "nvidia")
|
| 27 |
+
extra_paths = []
|
| 28 |
+
if os.path.isdir(nvidia_root):
|
| 29 |
+
for name in os.listdir(nvidia_root):
|
| 30 |
+
for sub in ("bin", "lib"):
|
| 31 |
+
p = os.path.join(nvidia_root, name, sub)
|
| 32 |
+
if os.path.isdir(p):
|
| 33 |
+
extra_paths.append(p)
|
| 34 |
+
if extra_paths:
|
| 35 |
+
env["PATH"] = os.pathsep.join(extra_paths + [env.get("PATH", "")])
|
| 36 |
+
return env
|
| 37 |
+
|
| 38 |
+
def register(self, name, process):
|
| 39 |
+
with self._lock:
|
| 40 |
+
self.active_processes[process.pid] = (name, process)
|
| 41 |
+
|
| 42 |
+
def unregister(self, pid):
|
| 43 |
+
with self._lock:
|
| 44 |
+
if pid in self.active_processes:
|
| 45 |
+
del self.active_processes[pid]
|
| 46 |
+
|
| 47 |
+
def kill_process_tree(self, pid):
|
| 48 |
+
"""Cleanly kill a process tree on Windows using taskkill"""
|
| 49 |
+
if sys.platform == 'win32':
|
| 50 |
+
cmd = ["taskkill", "/F", "/T", "/PID", str(pid)]
|
| 51 |
+
try:
|
| 52 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 53 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 54 |
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, startupinfo=startupinfo)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"Failed to run taskkill for PID {pid}: {e}")
|
| 57 |
+
else:
|
| 58 |
+
# Unix fallback
|
| 59 |
+
try:
|
| 60 |
+
import signal
|
| 61 |
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
| 62 |
+
except Exception:
|
| 63 |
+
try:
|
| 64 |
+
os.kill(pid, signal.SIGKILL)
|
| 65 |
+
except Exception:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
def cancel_all(self):
|
| 69 |
+
"""Kill all active processes immediately"""
|
| 70 |
+
with self._lock:
|
| 71 |
+
pids = list(self.active_processes.keys())
|
| 72 |
+
|
| 73 |
+
for pid in pids:
|
| 74 |
+
name, proc = self.active_processes.get(pid, ("Unknown", None))
|
| 75 |
+
print(f"Force-terminating active subprocess: {name} (PID: {pid})")
|
| 76 |
+
if proc:
|
| 77 |
+
try:
|
| 78 |
+
self.kill_process_tree(pid)
|
| 79 |
+
except Exception as e:
|
| 80 |
+
print(f"Error killing PID {pid}: {e}")
|
| 81 |
+
self.unregister(pid)
|
| 82 |
+
|
| 83 |
+
def run_subprocess_sync(self, cmd, timeout=300, cwd=None, log_fn=None, startupinfo=None, env=None):
|
| 84 |
+
"""
|
| 85 |
+
Runs a subprocess and monitors its output.
|
| 86 |
+
Automatically registers to active processes, respects timeouts, and cleans up on errors/cancellations.
|
| 87 |
+
"""
|
| 88 |
+
if startupinfo is None and sys.platform == 'win32':
|
| 89 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 90 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 91 |
+
|
| 92 |
+
# Extract name from cmd
|
| 93 |
+
name = os.path.basename(cmd[1]) if len(cmd) > 1 else os.path.basename(cmd[0])
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
process = subprocess.Popen(
|
| 97 |
+
cmd,
|
| 98 |
+
stdout=subprocess.PIPE,
|
| 99 |
+
stderr=subprocess.STDOUT,
|
| 100 |
+
text=True,
|
| 101 |
+
encoding="utf-8",
|
| 102 |
+
errors="ignore",
|
| 103 |
+
cwd=cwd,
|
| 104 |
+
startupinfo=startupinfo,
|
| 105 |
+
env=env or self.build_subprocess_env()
|
| 106 |
+
)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
raise Exception(f"Failed to start subprocess {name}: {e}")
|
| 109 |
+
|
| 110 |
+
self.register(name, process)
|
| 111 |
+
|
| 112 |
+
start_time = time.time()
|
| 113 |
+
output_lines = []
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
# Non-blocking check loop with timeout
|
| 117 |
+
while True:
|
| 118 |
+
# Check timeout
|
| 119 |
+
if time.time() - start_time > timeout:
|
| 120 |
+
self.kill_process_tree(process.pid)
|
| 121 |
+
raise TimeoutError(f"Subprocess {name} timed out after {timeout} seconds.")
|
| 122 |
+
|
| 123 |
+
# Check if process finished
|
| 124 |
+
retcode = process.poll()
|
| 125 |
+
if retcode is not None:
|
| 126 |
+
# Drain remaining output
|
| 127 |
+
remaining_output = process.stdout.read()
|
| 128 |
+
if remaining_output:
|
| 129 |
+
for line in remaining_output.splitlines():
|
| 130 |
+
if log_fn:
|
| 131 |
+
log_fn(line)
|
| 132 |
+
output_lines.append(line)
|
| 133 |
+
break
|
| 134 |
+
|
| 135 |
+
# Read line
|
| 136 |
+
line = process.stdout.readline()
|
| 137 |
+
if line:
|
| 138 |
+
line_str = line.strip()
|
| 139 |
+
if log_fn:
|
| 140 |
+
log_fn(line_str)
|
| 141 |
+
output_lines.append(line_str)
|
| 142 |
+
else:
|
| 143 |
+
time.sleep(0.1)
|
| 144 |
+
|
| 145 |
+
if process.returncode != 0:
|
| 146 |
+
last_logs = "\n".join(output_lines[-25:]) if output_lines else "(Không có log output)"
|
| 147 |
+
raise Exception(
|
| 148 |
+
f"Subprocess '{name}' thất bại với exit code {process.returncode}.\n"
|
| 149 |
+
f"--- CHI TIẾT LOG SUBPROCESS ---\n{last_logs}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return "\n".join(output_lines)
|
| 153 |
+
|
| 154 |
+
finally:
|
| 155 |
+
self.unregister(process.pid)
|
app/core/pronunciation.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"API": "a pi ai",
|
| 3 |
+
"RTX": "rờ tê ích",
|
| 4 |
+
"USD": "đô la",
|
| 5 |
+
"VRAM": "vi ram",
|
| 6 |
+
"RAM": "ram",
|
| 7 |
+
"GPU": "gê pê u",
|
| 8 |
+
"CPU": "xê pê u",
|
| 9 |
+
"AI": "ai",
|
| 10 |
+
"TTS": "tê tê ét",
|
| 11 |
+
"ASR": "a ét rờ",
|
| 12 |
+
"OCR": "ô xê rờ",
|
| 13 |
+
"FFMPEG": "ép ép em pếch",
|
| 14 |
+
"MP4": "em pê bốn",
|
| 15 |
+
"WAV": "oát",
|
| 16 |
+
"SRT": "ét rờ tê",
|
| 17 |
+
"LLM": "el el em",
|
| 18 |
+
"ONNX": "on ních",
|
| 19 |
+
"VITS": "vít",
|
| 20 |
+
"VND": "việt nam đồng",
|
| 21 |
+
"GB": "ghi ga bai",
|
| 22 |
+
"MB": "mê ga bai",
|
| 23 |
+
"KB": "ki lô bai",
|
| 24 |
+
"PC": "pi xi",
|
| 25 |
+
"OS": "ô ét",
|
| 26 |
+
"CUDA": "cu đa",
|
| 27 |
+
"%": "phần trăm",
|
| 28 |
+
"&": "và",
|
| 29 |
+
"@": "a còng",
|
| 30 |
+
"#": "băm",
|
| 31 |
+
"+": "cộng",
|
| 32 |
+
"-": "trừ",
|
| 33 |
+
"*": "nhân",
|
| 34 |
+
"/": "chia"
|
| 35 |
+
}
|
app/core/render_worker_cli.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import json
|
| 5 |
+
import argparse
|
| 6 |
+
import subprocess
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Enforce UTF-8 for Windows console
|
| 10 |
+
if sys.platform == 'win32':
|
| 11 |
+
try:
|
| 12 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 13 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 14 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 15 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
def check_nvenc_available(ffmpeg_path):
|
| 20 |
+
cmd = [
|
| 21 |
+
str(ffmpeg_path), "-y",
|
| 22 |
+
"-f", "lavfi", "-i", "color=c=black:s=256x256",
|
| 23 |
+
"-t", "1",
|
| 24 |
+
"-c:v", "h264_nvenc",
|
| 25 |
+
"-f", "null", "-"
|
| 26 |
+
]
|
| 27 |
+
try:
|
| 28 |
+
startupinfo = None
|
| 29 |
+
if sys.platform == 'win32':
|
| 30 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 31 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 32 |
+
res = subprocess.run(
|
| 33 |
+
cmd,
|
| 34 |
+
stdout=subprocess.PIPE,
|
| 35 |
+
stderr=subprocess.PIPE,
|
| 36 |
+
text=True,
|
| 37 |
+
encoding="utf-8",
|
| 38 |
+
errors="ignore",
|
| 39 |
+
startupinfo=startupinfo,
|
| 40 |
+
timeout=10
|
| 41 |
+
)
|
| 42 |
+
return res.returncode == 0
|
| 43 |
+
except Exception:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
def _ass_time_from_srt(value):
|
| 47 |
+
m = re.match(r"^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$", str(value).strip())
|
| 48 |
+
if not m:
|
| 49 |
+
return "0:00:00.00"
|
| 50 |
+
hh, mm, ss, ms = [int(x) for x in m.groups()]
|
| 51 |
+
centis = int(round(ms / 10.0))
|
| 52 |
+
return f"{hh}:{mm:02d}:{ss:02d}.{centis:02d}"
|
| 53 |
+
|
| 54 |
+
def _parse_srt_events(srt_path):
|
| 55 |
+
try:
|
| 56 |
+
content = Path(srt_path).read_text(encoding="utf-8", errors="ignore").strip().replace("\r\n", "\n")
|
| 57 |
+
except Exception:
|
| 58 |
+
return []
|
| 59 |
+
pattern = r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 60 |
+
events = []
|
| 61 |
+
for match in re.finditer(pattern, content, re.DOTALL):
|
| 62 |
+
text = " ".join(line.strip() for line in match.group(4).splitlines() if line.strip())
|
| 63 |
+
if text:
|
| 64 |
+
events.append((match.group(2), match.group(3), text))
|
| 65 |
+
return events
|
| 66 |
+
|
| 67 |
+
def _ass_escape(text):
|
| 68 |
+
return str(text or "").replace("\\", "\\\\").replace("{", r"\{").replace("}", r"\}").replace("\n", r"\N")
|
| 69 |
+
|
| 70 |
+
def _shift_srt(srt_path, seconds):
|
| 71 |
+
"""Shift every timestamp in an SRT file by -seconds (in place). Used after
|
| 72 |
+
trimming leading silence so hardsub captions stay aligned with the video."""
|
| 73 |
+
srt_path = Path(srt_path)
|
| 74 |
+
if seconds <= 0 or not srt_path.exists():
|
| 75 |
+
return
|
| 76 |
+
content = srt_path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n")
|
| 77 |
+
|
| 78 |
+
def shift_ts(m):
|
| 79 |
+
def to_ms(t):
|
| 80 |
+
tm = re.match(r"(\d+):(\d+):(\d+)[.,](\d+)", t)
|
| 81 |
+
if not tm:
|
| 82 |
+
return 0
|
| 83 |
+
hh, mm, ss, ms = (int(x) for x in tm.groups())
|
| 84 |
+
return ((hh * 3600 + mm * 60 + ss) * 1000) + ms
|
| 85 |
+
|
| 86 |
+
def from_ms(v):
|
| 87 |
+
v = max(0, int(v))
|
| 88 |
+
hh, rem = divmod(v, 3600000)
|
| 89 |
+
mm, rem = divmod(rem, 60000)
|
| 90 |
+
ss, ms = divmod(rem, 1000)
|
| 91 |
+
return f"{hh:02d}:{mm:02d}:{ss:02d},{ms:03d}"
|
| 92 |
+
|
| 93 |
+
delta = int(round(seconds * 1000))
|
| 94 |
+
return f"{from_ms(to_ms(m.group(1)) - delta)} --> {from_ms(to_ms(m.group(2)) - delta)}"
|
| 95 |
+
|
| 96 |
+
pattern = re.compile(r"(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})")
|
| 97 |
+
new_content = pattern.sub(shift_ts, content)
|
| 98 |
+
srt_path.write_text(new_content, encoding="utf-8")
|
| 99 |
+
|
| 100 |
+
def _wrap_caption_words(text, max_chars=22, max_lines=3):
|
| 101 |
+
"""Wrap caption text naturally across lines without dropping words."""
|
| 102 |
+
words = str(text or "").split()
|
| 103 |
+
if not words:
|
| 104 |
+
return []
|
| 105 |
+
lines = []
|
| 106 |
+
current = ""
|
| 107 |
+
for word in words:
|
| 108 |
+
candidate = word if not current else f"{current} {word}"
|
| 109 |
+
if len(candidate) <= max_chars or not current:
|
| 110 |
+
current = candidate
|
| 111 |
+
else:
|
| 112 |
+
if len(lines) < max_lines - 1:
|
| 113 |
+
lines.append(current)
|
| 114 |
+
current = word
|
| 115 |
+
else:
|
| 116 |
+
# Ở dòng cuối cùng, tiếp tục gộp toàn bộ từ còn lại vào dòng này, không được bỏ sót
|
| 117 |
+
current = f"{current} {word}"
|
| 118 |
+
if current:
|
| 119 |
+
lines.append(current)
|
| 120 |
+
|
| 121 |
+
# Xử lý các từ nối không đứng lơ lửng một mình ở cuối dòng
|
| 122 |
+
connectors = {"và", "với", "của", "là", "thì", "mà", "rằng", "nên", "nhưng", "hoặc", "vì"}
|
| 123 |
+
if len(lines) > 1:
|
| 124 |
+
for idx in range(len(lines) - 1):
|
| 125 |
+
parts = lines[idx].split()
|
| 126 |
+
if len(parts) > 1 and parts[-1].strip(" ,.!?;:").lower() in connectors:
|
| 127 |
+
moved_word = parts.pop()
|
| 128 |
+
lines[idx] = " ".join(parts)
|
| 129 |
+
lines[idx + 1] = f"{moved_word} {lines[idx + 1]}"
|
| 130 |
+
return lines[:max_lines]
|
| 131 |
+
|
| 132 |
+
def _compact_caption_text(text, max_words=None):
|
| 133 |
+
"""Chuẩn hóa phụ đề tiếng Việt, giữ nguyên vẹn 100% nội dung câu."""
|
| 134 |
+
text = str(text or "").strip()
|
| 135 |
+
text = re.sub(r"\s+", " ", text)
|
| 136 |
+
text = text.strip("\"'“”„”")
|
| 137 |
+
return text
|
| 138 |
+
|
| 139 |
+
def _theme_colors(theme):
|
| 140 |
+
theme = str(theme or "motion_drip").lower()
|
| 141 |
+
if theme == "clean_white":
|
| 142 |
+
return {"primary": "&H00FFFFFF", "secondary": "&H00FFFFFF"}
|
| 143 |
+
if theme == "brand_drip":
|
| 144 |
+
return {"primary": "&H00F2F2F2", "secondary": "&H00D7F7FF"}
|
| 145 |
+
if theme == "street_luxe":
|
| 146 |
+
return {"primary": "&H00E8F5FF", "secondary": "&H0099D6FF"}
|
| 147 |
+
return {"primary": "&H00F7F0DC", "secondary": "&H00A8F4FF"}
|
| 148 |
+
|
| 149 |
+
def _write_modern_ass_from_srt(srt_path, ass_path, width, height, caption_cfg, blur_region=None):
|
| 150 |
+
events = _parse_srt_events(srt_path)
|
| 151 |
+
if not events:
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
font_name = str(caption_cfg.get("font_name", "Montserrat, Be Vietnam Pro, Arial, sans-serif"))
|
| 155 |
+
base_font_size = int(caption_cfg.get("font_size", 68 if height > width else 48))
|
| 156 |
+
max_chars = int(caption_cfg.get("max_chars_per_line", 26))
|
| 157 |
+
max_lines = int(caption_cfg.get("max_lines", 2))
|
| 158 |
+
outline = float(caption_cfg.get("outline", 2.8))
|
| 159 |
+
shadow = float(caption_cfg.get("shadow", 2.2))
|
| 160 |
+
shadow_color = "&H80000000"
|
| 161 |
+
|
| 162 |
+
alignment_tag = r"\an8"
|
| 163 |
+
align_code = 8
|
| 164 |
+
font_size = base_font_size
|
| 165 |
+
|
| 166 |
+
if blur_region:
|
| 167 |
+
try:
|
| 168 |
+
parts = [int(p) for p in str(blur_region).split(',')]
|
| 169 |
+
if len(parts) == 6:
|
| 170 |
+
rx, ry, rw, rh, orig_w, orig_h = parts
|
| 171 |
+
scale_x = width / float(orig_w) if orig_w > 0 else 1.0
|
| 172 |
+
scale_y = height / float(orig_h) if orig_h > 0 else 1.0
|
| 173 |
+
|
| 174 |
+
box_x = rx * scale_x
|
| 175 |
+
box_y = ry * scale_y
|
| 176 |
+
box_w = rw * scale_x
|
| 177 |
+
box_h = rh * scale_y
|
| 178 |
+
|
| 179 |
+
x = int(box_x + box_w / 2.0)
|
| 180 |
+
y = int(box_y + box_h / 2.0)
|
| 181 |
+
alignment_tag = r"\an5"
|
| 182 |
+
align_code = 5
|
| 183 |
+
|
| 184 |
+
box_width_pct = max(35.0, min(98.0, (box_w / float(width)) * 100.0))
|
| 185 |
+
max_fitted_font = max(24, int(box_h * 0.70))
|
| 186 |
+
if font_size > max_fitted_font:
|
| 187 |
+
font_size = max_fitted_font
|
| 188 |
+
print(f"[RENDER] Auto-positioned Vietnamese subtitles directly inside blur region: pos=({x},{y}), box_w={box_w:.0f}, font_size={font_size}")
|
| 189 |
+
else:
|
| 190 |
+
x = int(width * float(caption_cfg.get("x_percent", 50)) / 100.0)
|
| 191 |
+
y = int(height * float(caption_cfg.get("y_percent", 78)) / 100.0)
|
| 192 |
+
box_width_pct = max(45.0, min(96.0, float(caption_cfg.get("box_width_percent", 86))))
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"[RENDER] Warning parsing blur_region: {e}")
|
| 195 |
+
x = int(width * float(caption_cfg.get("x_percent", 50)) / 100.0)
|
| 196 |
+
y = int(height * float(caption_cfg.get("y_percent", 78)) / 100.0)
|
| 197 |
+
box_width_pct = max(45.0, min(96.0, float(caption_cfg.get("box_width_percent", 86))))
|
| 198 |
+
else:
|
| 199 |
+
x = int(width * float(caption_cfg.get("x_percent", 50)) / 100.0)
|
| 200 |
+
y = int(height * float(caption_cfg.get("y_percent", 78)) / 100.0)
|
| 201 |
+
box_width_pct = max(45.0, min(96.0, float(caption_cfg.get("box_width_percent", 86))))
|
| 202 |
+
|
| 203 |
+
margin = int((width * (100.0 - box_width_pct) / 100.0) / 2)
|
| 204 |
+
uppercase = bool(caption_cfg.get("uppercase", False))
|
| 205 |
+
word_jump = bool(caption_cfg.get("word_jump", False))
|
| 206 |
+
colors = _theme_colors(caption_cfg.get("theme", "clean_white"))
|
| 207 |
+
|
| 208 |
+
dialogue = []
|
| 209 |
+
for index, (start, end, text) in enumerate(events):
|
| 210 |
+
cleaned_text = _compact_caption_text(text)
|
| 211 |
+
if uppercase:
|
| 212 |
+
cleaned_text = cleaned_text.upper()
|
| 213 |
+
lines = _wrap_caption_words(cleaned_text, max_chars=max_chars, max_lines=max_lines)
|
| 214 |
+
if not lines:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
cur_font_size = font_size
|
| 218 |
+
total_len = len(cleaned_text)
|
| 219 |
+
if len(lines) >= 3 or total_len > 45:
|
| 220 |
+
cur_font_size = max(22, int(font_size * 0.85))
|
| 221 |
+
elif total_len > 30:
|
| 222 |
+
cur_font_size = max(24, int(font_size * 0.92))
|
| 223 |
+
|
| 224 |
+
line_prefix = rf"{{{alignment_tag}\pos({x},{y})\fs{cur_font_size}\c{colors['primary']}\3c&H00000000&\bord{outline}\shad{shadow}"
|
| 225 |
+
if word_jump:
|
| 226 |
+
line_prefix += r"\fscx98\fscy98\t(0,95,\fscx110\fscy110)\t(95,230,\fscx100\fscy100)"
|
| 227 |
+
line_prefix += "}"
|
| 228 |
+
body = line_prefix + r"\N".join(_ass_escape(line) for line in lines)
|
| 229 |
+
dialogue.append(
|
| 230 |
+
f"Dialogue: 0,{_ass_time_from_srt(start)},{_ass_time_from_srt(end)},Caption,,{margin},{margin},0,,{body}"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
if not dialogue:
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
doc = f"""[Script Info]
|
| 237 |
+
ScriptType: v4.00+
|
| 238 |
+
PlayResX: {width}
|
| 239 |
+
PlayResY: {height}
|
| 240 |
+
ScaledBorderAndShadow: yes
|
| 241 |
+
WrapStyle: 2
|
| 242 |
+
|
| 243 |
+
[V4+ Styles]
|
| 244 |
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
| 245 |
+
Style: Caption,{font_name},{font_size},{colors['primary']},{colors['secondary']},&H00000000,{shadow_color},-1,0,0,0,100,100,0.8,0,1,{outline},{shadow},{align_code},{margin},{margin},0,1
|
| 246 |
+
|
| 247 |
+
[Events]
|
| 248 |
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
| 249 |
+
{chr(10).join(dialogue)}
|
| 250 |
+
"""
|
| 251 |
+
ass_path = Path(ass_path)
|
| 252 |
+
ass_path.parent.mkdir(parents=True, exist_ok=True)
|
| 253 |
+
ass_path.write_text(doc, encoding="utf-8-sig")
|
| 254 |
+
return ass_path
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _region_to_native(blur_region, vid_w, vid_h):
|
| 259 |
+
"""Parse 'rx,ry,rw,rh,orig_w,orig_h' and re-derive the real blur/crop coords
|
| 260 |
+
against the ACTUAL probed video dimensions using normalized fractions."""
|
| 261 |
+
parts = [int(p) for p in str(blur_region).split(',')]
|
| 262 |
+
if len(parts) != 6:
|
| 263 |
+
return None
|
| 264 |
+
rx, ry, rw, rh, reg_orig_w, reg_orig_h = parts
|
| 265 |
+
|
| 266 |
+
norm_x = rx / float(reg_orig_w) if reg_orig_w > 0 else 0.0
|
| 267 |
+
norm_y = ry / float(reg_orig_h) if reg_orig_h > 0 else 0.0
|
| 268 |
+
norm_w = rw / float(reg_orig_w) if reg_orig_w > 0 else 0.0
|
| 269 |
+
norm_h = rh / float(reg_orig_h) if reg_orig_h > 0 else 0.0
|
| 270 |
+
|
| 271 |
+
real_x = int(round(norm_x * vid_w))
|
| 272 |
+
real_y = int(round(norm_y * vid_h))
|
| 273 |
+
real_w = max(1, int(round(norm_w * vid_w)))
|
| 274 |
+
real_h = max(1, int(round(norm_h * vid_h)))
|
| 275 |
+
|
| 276 |
+
real_w = min(real_w, vid_w)
|
| 277 |
+
real_h = min(real_h, vid_h)
|
| 278 |
+
real_x = max(0, min(real_x, vid_w - real_w))
|
| 279 |
+
real_y = max(0, min(real_y, vid_h - real_h))
|
| 280 |
+
|
| 281 |
+
print(f"[RENDER] [Normalized Cords] x={norm_x:.4f}, y={norm_y:.4f}, w={norm_w:.4f}, h={norm_h:.4f}")
|
| 282 |
+
print(f"[RENDER] [Native Frame Cords] x={real_x}, y={real_y}, w={real_w}, h={real_h} (frame {vid_w}x{vid_h})")
|
| 283 |
+
return real_x, real_y, real_w, real_h, vid_w, vid_h
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def main():
|
| 287 |
+
parser = argparse.ArgumentParser(description="Standalone Video Render & Merge Worker CLI")
|
| 288 |
+
parser.add_argument("--video", required=True, help="Path to input video file")
|
| 289 |
+
parser.add_argument("--audio", required=True, help="Path to input mixed audio WAV file")
|
| 290 |
+
parser.add_argument("--output", required=True, help="Path to output video file")
|
| 291 |
+
parser.add_argument("--srt", default="", help="Path to translated SRT file for hardsubs")
|
| 292 |
+
parser.add_argument("--blur-region", default="", help="Blur region as 'rx,ry,rw,rh,orig_w,orig_h'")
|
| 293 |
+
parser.add_argument("--subtitle-region-mode", default="delogo", choices=["delogo", "blur", "none"], help="How to cover the selected source subtitle region")
|
| 294 |
+
parser.add_argument("--preserve-regions", default="", help="Path to preserve_regions.json")
|
| 295 |
+
parser.add_argument("--ffmpeg-path", default="ffmpeg", help="Path to ffmpeg executable")
|
| 296 |
+
parser.add_argument("--encoder", default="auto", choices=["auto", "nvenc", "h264_nvenc", "libx264"], help="Video encoder to use")
|
| 297 |
+
parser.add_argument("--allow-cpu-fallback", default="true", help="Allow fallback to CPU (true/false)")
|
| 298 |
+
parser.add_argument("--zoom", default="1.0", help="Auto-zoom factor (e.g. 1.15 = zoom in 115%), 1.0 disables")
|
| 299 |
+
parser.add_argument("--trim-head-silence", default="0", help="Trim leading silence of video+audio by N seconds (0 disables)")
|
| 300 |
+
args = parser.parse_args()
|
| 301 |
+
|
| 302 |
+
video_path = Path(args.video)
|
| 303 |
+
audio_path = Path(args.audio)
|
| 304 |
+
output_path = Path(args.output)
|
| 305 |
+
ffmpeg_path = Path(args.ffmpeg_path)
|
| 306 |
+
|
| 307 |
+
if not video_path.exists():
|
| 308 |
+
print(f"Error: Input video not found at {video_path}", file=sys.stderr)
|
| 309 |
+
sys.exit(1)
|
| 310 |
+
if not audio_path.exists():
|
| 311 |
+
print(f"Error: Input audio not found at {audio_path}", file=sys.stderr)
|
| 312 |
+
sys.exit(1)
|
| 313 |
+
|
| 314 |
+
# 1. Parse preserve regions
|
| 315 |
+
preserve_intervals = []
|
| 316 |
+
if args.preserve_regions:
|
| 317 |
+
pr_path = Path(args.preserve_regions)
|
| 318 |
+
if pr_path.exists():
|
| 319 |
+
try:
|
| 320 |
+
with open(pr_path, "r", encoding="utf-8") as f:
|
| 321 |
+
preserve_raw = json.load(f)
|
| 322 |
+
# Convert ms to seconds
|
| 323 |
+
preserve_intervals = [(x[0] / 1000.0, x[1] / 1000.0) for x in preserve_raw]
|
| 324 |
+
print(f"Loaded preserve intervals: {len(preserve_intervals)} intervals.")
|
| 325 |
+
except Exception as e:
|
| 326 |
+
print(f"Warning: Failed to load preserve regions: {e}", file=sys.stderr)
|
| 327 |
+
|
| 328 |
+
# Studio director: auto-zoom + trim leading silence
|
| 329 |
+
zoom_factor = 1.0
|
| 330 |
+
try:
|
| 331 |
+
zoom_factor = float(args.zoom)
|
| 332 |
+
except Exception:
|
| 333 |
+
zoom_factor = 1.0
|
| 334 |
+
trim_head_sec = 0.0
|
| 335 |
+
try:
|
| 336 |
+
trim_head_sec = float(args.trim_head_silence)
|
| 337 |
+
except Exception:
|
| 338 |
+
trim_head_sec = 0.0
|
| 339 |
+
|
| 340 |
+
# When trimming head silence we must shift the SRT timeline BEFORE generating ASS.
|
| 341 |
+
if trim_head_sec > 0 and args.srt:
|
| 342 |
+
try:
|
| 343 |
+
src_srt = Path(args.srt)
|
| 344 |
+
shifted_srt = src_srt.with_name(src_srt.stem + "_shifted.srt")
|
| 345 |
+
import shutil as _sh
|
| 346 |
+
_sh.copy2(str(src_srt), str(shifted_srt))
|
| 347 |
+
_shift_srt(shifted_srt, trim_head_sec)
|
| 348 |
+
args.srt = str(shifted_srt)
|
| 349 |
+
print(f"[RENDER] Shifted subtitle timeline by -{trim_head_sec:.2f}s after head-silence trim.")
|
| 350 |
+
except Exception as e:
|
| 351 |
+
print(f"[RENDER] Warning: could not shift srt: {e}", file=sys.stderr)
|
| 352 |
+
|
| 353 |
+
# 2. Build FFmpeg filter complex if blur or hardsub is needed
|
| 354 |
+
filter_complex = None
|
| 355 |
+
if args.blur_region or args.srt or zoom_factor > 1.0:
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
# Get video dimensions
|
| 359 |
+
orig_w, orig_h = 1920, 1080
|
| 360 |
+
if args.video:
|
| 361 |
+
try:
|
| 362 |
+
import cv2
|
| 363 |
+
cap = cv2.VideoCapture(str(video_path))
|
| 364 |
+
orig_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1920
|
| 365 |
+
orig_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 1080
|
| 366 |
+
cap.release()
|
| 367 |
+
except Exception:
|
| 368 |
+
pass
|
| 369 |
+
|
| 370 |
+
render_cfg = {}
|
| 371 |
+
try:
|
| 372 |
+
config_path = Path(__file__).parent.parent.parent / "config.json"
|
| 373 |
+
if config_path.exists():
|
| 374 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 375 |
+
cfg_data = json.load(f)
|
| 376 |
+
render_cfg = cfg_data.get("render", {})
|
| 377 |
+
except Exception as e:
|
| 378 |
+
print(f"Warning: Failed to load render config: {e}", file=sys.stderr)
|
| 379 |
+
|
| 380 |
+
dynamic_fontsize = 16
|
| 381 |
+
ass_playres_y = 288
|
| 382 |
+
ass_playres_x = int(288 * orig_w / orig_h) if orig_h else 384
|
| 383 |
+
|
| 384 |
+
if args.blur_region:
|
| 385 |
+
converted = _region_to_native(args.blur_region, orig_w, orig_h)
|
| 386 |
+
if converted is None:
|
| 387 |
+
print("Error: invalid blur_region format, expected 'rx,ry,rw,rh,orig_w,orig_h'", file=sys.stderr)
|
| 388 |
+
sys.exit(1)
|
| 389 |
+
rx, ry, rw, rh, orig_w, orig_h = converted
|
| 390 |
+
|
| 391 |
+
# Force even numbers for FFmpeg delogo filter
|
| 392 |
+
rx = rx if rx % 2 == 0 else max(0, rx - 1)
|
| 393 |
+
ry = ry if ry % 2 == 0 else max(0, ry - 1)
|
| 394 |
+
rw = rw if rw % 2 == 0 else rw - 1
|
| 395 |
+
rh = rh if rh % 2 == 0 else rh - 1
|
| 396 |
+
rw, rh = max(4, rw), max(4, rh)
|
| 397 |
+
|
| 398 |
+
scale_x = ass_playres_x / orig_w if orig_w else 1
|
| 399 |
+
scale_y = ass_playres_y / orig_h if orig_h else 1
|
| 400 |
+
|
| 401 |
+
ass_rx = int(rx * scale_x)
|
| 402 |
+
ass_ry = int(ry * scale_y)
|
| 403 |
+
ass_rw = int(rw * scale_x)
|
| 404 |
+
ass_rh = int(rh * scale_y)
|
| 405 |
+
|
| 406 |
+
ass_margin_l = ass_rx
|
| 407 |
+
ass_margin_r = max(0, ass_playres_x - (ass_rx + ass_rw))
|
| 408 |
+
|
| 409 |
+
disable_blur_on_preserve_regions = render_cfg.get("disable_blur_on_preserve_regions", True)
|
| 410 |
+
|
| 411 |
+
if preserve_intervals and disable_blur_on_preserve_regions:
|
| 412 |
+
ass_margin_v = 12
|
| 413 |
+
else:
|
| 414 |
+
sub_block_h = int(2 * 1.35 * dynamic_fontsize)
|
| 415 |
+
sub_block_h = min(sub_block_h, ass_rh)
|
| 416 |
+
ass_bottom_of_text = ass_ry + ass_rh // 2 + sub_block_h // 2
|
| 417 |
+
ass_margin_v = max(0, ass_playres_y - ass_bottom_of_text)
|
| 418 |
+
|
| 419 |
+
force_style = (
|
| 420 |
+
f"FontName=Arial,FontSize={dynamic_fontsize},"
|
| 421 |
+
f"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H00000000,"
|
| 422 |
+
f"BorderStyle=1,Outline=2,Shadow=1,Alignment=2,"
|
| 423 |
+
f"MarginL={ass_margin_l},MarginR={ass_margin_r},MarginV={ass_margin_v},"
|
| 424 |
+
f"WrapStyle=1"
|
| 425 |
+
)
|
| 426 |
+
else:
|
| 427 |
+
force_style = (
|
| 428 |
+
f"FontName=Arial,FontSize={dynamic_fontsize},"
|
| 429 |
+
f"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H00000000,"
|
| 430 |
+
f"BorderStyle=1,Outline=2,Shadow=1,Alignment=2,"
|
| 431 |
+
f"MarginV=15,WrapStyle=1"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Build video blur / inpaint filter on original resolution
|
| 435 |
+
video_filter = None
|
| 436 |
+
if args.blur_region and args.subtitle_region_mode != "none":
|
| 437 |
+
disable_blur_on_preserve_regions = render_cfg.get("disable_blur_on_preserve_regions", True)
|
| 438 |
+
|
| 439 |
+
if preserve_intervals and disable_blur_on_preserve_regions:
|
| 440 |
+
top_h = int(rh * 0.5)
|
| 441 |
+
bot_y = ry + top_h
|
| 442 |
+
bot_h = rh - top_h
|
| 443 |
+
enable_str = "+".join(
|
| 444 |
+
[f"between(t,{s:.3f},{e:.3f})" for s, e in preserve_intervals]
|
| 445 |
+
)
|
| 446 |
+
if args.subtitle_region_mode == "blur":
|
| 447 |
+
video_filter = (
|
| 448 |
+
f"[0:v]split[base][crop];"
|
| 449 |
+
f"[crop]crop={rw}:{top_h}:{rx}:{ry},gblur=sigma=18[topblur];"
|
| 450 |
+
f"[base][topblur]overlay={rx}:{ry}[v1];"
|
| 451 |
+
f"[v1]split[base2][crop2];"
|
| 452 |
+
f"[crop2]crop={rw}:{bot_h}:{rx}:{bot_y},gblur=sigma=18[botblur];"
|
| 453 |
+
f"[base2][botblur]overlay={rx}:{bot_y}:enable='not({enable_str})'[blurred]"
|
| 454 |
+
)
|
| 455 |
+
else:
|
| 456 |
+
video_filter = (
|
| 457 |
+
f"[0:v]delogo=x={rx}:y={ry}:w={rw}:h={top_h}[v1];"
|
| 458 |
+
f"[v1]delogo=x={rx}:y={bot_y}:w={rw}:h={bot_h}:enable='not({enable_str})'[blurred]"
|
| 459 |
+
)
|
| 460 |
+
else:
|
| 461 |
+
if args.subtitle_region_mode == "blur":
|
| 462 |
+
video_filter = (
|
| 463 |
+
f"[0:v]split[base][crop];"
|
| 464 |
+
f"[crop]crop={rw}:{rh}:{rx}:{ry},gblur=sigma=20[regionblur];"
|
| 465 |
+
f"[base][regionblur]overlay={rx}:{ry}[blurred]"
|
| 466 |
+
)
|
| 467 |
+
else:
|
| 468 |
+
video_filter = f"[0:v]delogo=x={rx}:y={ry}:w={rw}:h={rh}[blurred]"
|
| 469 |
+
|
| 470 |
+
# Build subtitle filter
|
| 471 |
+
srt_filter = None
|
| 472 |
+
if args.srt:
|
| 473 |
+
caption_cfg = render_cfg.get("caption", {}) if isinstance(render_cfg, dict) else {}
|
| 474 |
+
use_modern_ass = bool(caption_cfg.get("enabled", True))
|
| 475 |
+
ass_path = None
|
| 476 |
+
if use_modern_ass:
|
| 477 |
+
ass_blur_region = (
|
| 478 |
+
f"{rx},{ry},{rw},{rh},{orig_w},{orig_h}" if args.blur_region else None
|
| 479 |
+
)
|
| 480 |
+
ass_path = _write_modern_ass_from_srt(
|
| 481 |
+
args.srt,
|
| 482 |
+
output_path.parent / "translated_caption.ass",
|
| 483 |
+
orig_w,
|
| 484 |
+
orig_h,
|
| 485 |
+
caption_cfg,
|
| 486 |
+
blur_region=ass_blur_region,
|
| 487 |
+
)
|
| 488 |
+
if ass_path:
|
| 489 |
+
ass_abs = str(Path(ass_path).resolve()).replace("\\", "/").replace(":", "\\:")
|
| 490 |
+
srt_filter = f"subtitles='{ass_abs}'"
|
| 491 |
+
print(f"[RENDER] modern ASS captions enabled: {ass_path}")
|
| 492 |
+
else:
|
| 493 |
+
srt_abs = str(Path(args.srt).resolve()).replace("\\", "/").replace(":", "\\:")
|
| 494 |
+
srt_filter = f"subtitles='{srt_abs}':force_style='{force_style}'"
|
| 495 |
+
|
| 496 |
+
# Auto-zoom filter string
|
| 497 |
+
zoom_filter = None
|
| 498 |
+
if zoom_factor > 1.0:
|
| 499 |
+
zoom_filter = (
|
| 500 |
+
f"scale={int(orig_w*zoom_factor)}:{int(orig_h*zoom_factor)}:flags=lanczos,"
|
| 501 |
+
f"crop={orig_w}:{orig_h}:((iw-ow)/2):((ih-oh)/2),setsar=1"
|
| 502 |
+
)
|
| 503 |
+
print(f"[RENDER] auto-zoom {zoom_factor}x enabled")
|
| 504 |
+
|
| 505 |
+
# Combine filters cleanly:
|
| 506 |
+
# 1. Blur on base video [0:v] -> [blurred]
|
| 507 |
+
# 2. Zoom on [blurred] -> [z] (or [0:v] -> [z] if no blur)
|
| 508 |
+
# 3. Subtitles on [z] -> [v] (or [blurred] -> [v] if no zoom)
|
| 509 |
+
if video_filter and zoom_filter and srt_filter:
|
| 510 |
+
filter_complex = f"{video_filter};[blurred]{zoom_filter}[z];[z]{srt_filter}[v]"
|
| 511 |
+
elif video_filter and zoom_filter:
|
| 512 |
+
filter_complex = f"{video_filter};[blurred]{zoom_filter}[v]"
|
| 513 |
+
elif video_filter and srt_filter:
|
| 514 |
+
filter_complex = f"{video_filter};[blurred]{srt_filter}[v]"
|
| 515 |
+
elif video_filter:
|
| 516 |
+
filter_complex = f"{video_filter}[v]"
|
| 517 |
+
elif zoom_filter and srt_filter:
|
| 518 |
+
filter_complex = f"[0:v]{zoom_filter}[z];[z]{srt_filter}[v]"
|
| 519 |
+
elif zoom_filter:
|
| 520 |
+
filter_complex = f"[0:v]{zoom_filter}[v]"
|
| 521 |
+
elif srt_filter:
|
| 522 |
+
filter_complex = f"[0:v]{srt_filter}[v]"
|
| 523 |
+
|
| 524 |
+
if filter_complex:
|
| 525 |
+
print(f"[RENDER] filter complex pipeline: {filter_complex}")
|
| 526 |
+
|
| 527 |
+
except Exception as e:
|
| 528 |
+
print(f"Error building filter complex: {e}", file=sys.stderr)
|
| 529 |
+
sys.exit(1)
|
| 530 |
+
|
| 531 |
+
allow_cpu_fallback = args.allow_cpu_fallback.lower() in ("true", "1", "yes", "t")
|
| 532 |
+
|
| 533 |
+
# NVENC preflight check
|
| 534 |
+
is_nvenc_requested = args.encoder in ("auto", "nvenc", "h264_nvenc")
|
| 535 |
+
if is_nvenc_requested:
|
| 536 |
+
print("Running NVENC preflight check...")
|
| 537 |
+
if not check_nvenc_available(ffmpeg_path):
|
| 538 |
+
print("NVENC preflight check failed.", file=sys.stderr)
|
| 539 |
+
if not allow_cpu_fallback:
|
| 540 |
+
print("NVENC unavailable or driver/API mismatch.\n"
|
| 541 |
+
"CPU fallback disabled by strict GPU policy.\n"
|
| 542 |
+
"Suggested fix: update NVIDIA driver or use FFmpeg build compatible with current driver.", file=sys.stderr)
|
| 543 |
+
sys.exit(3)
|
| 544 |
+
else:
|
| 545 |
+
print("Warning: NVENC preflight check failed. Driver/API mismatch. CPU fallback enabled, falling back to libx264.")
|
| 546 |
+
args.encoder = "libx264"
|
| 547 |
+
|
| 548 |
+
# Resolve selected encoder for logging purposes
|
| 549 |
+
selected_encoder = "h264_nvenc" if args.encoder in ("nvenc", "h264_nvenc") else (args.encoder if args.encoder != "auto" else "h264_nvenc")
|
| 550 |
+
|
| 551 |
+
print(f"[RENDER] selected encoder: {selected_encoder}")
|
| 552 |
+
print(f"[RENDER] cpu fallback: {'true' if allow_cpu_fallback else 'false'}")
|
| 553 |
+
|
| 554 |
+
# 3. Determine encoders to try
|
| 555 |
+
encoders_to_try = []
|
| 556 |
+
if args.encoder in ("nvenc", "h264_nvenc"):
|
| 557 |
+
encoders_to_try = ["h264_nvenc"]
|
| 558 |
+
if allow_cpu_fallback:
|
| 559 |
+
encoders_to_try.append("libx264")
|
| 560 |
+
elif args.encoder == "libx264":
|
| 561 |
+
encoders_to_try = ["libx264"]
|
| 562 |
+
else: # auto
|
| 563 |
+
encoders_to_try = ["h264_nvenc"]
|
| 564 |
+
if allow_cpu_fallback:
|
| 565 |
+
encoders_to_try.append("libx264")
|
| 566 |
+
|
| 567 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 568 |
+
|
| 569 |
+
success = False
|
| 570 |
+
for encoder in encoders_to_try:
|
| 571 |
+
print(f"Attempting to render video using encoder '{encoder}'...")
|
| 572 |
+
cmd = [
|
| 573 |
+
str(ffmpeg_path), "-y", "-noautorotate",
|
| 574 |
+
]
|
| 575 |
+
if trim_head_sec > 0:
|
| 576 |
+
cmd.extend(["-ss", f"{trim_head_sec:.3f}"])
|
| 577 |
+
cmd.extend(["-i", str(video_path)])
|
| 578 |
+
if trim_head_sec > 0:
|
| 579 |
+
cmd.extend(["-ss", f"{trim_head_sec:.3f}"])
|
| 580 |
+
cmd.extend(["-i", str(audio_path)])
|
| 581 |
+
|
| 582 |
+
if filter_complex:
|
| 583 |
+
cmd.extend(["-filter_complex", filter_complex, "-map", "[v]", "-map", "1:a:0"])
|
| 584 |
+
else:
|
| 585 |
+
cmd.extend(["-c:v", "copy" if encoder == "copy" else encoder])
|
| 586 |
+
cmd.extend(["-map", "0:v:0", "-map", "1:a:0"])
|
| 587 |
+
|
| 588 |
+
if filter_complex or encoder != "copy":
|
| 589 |
+
if encoder == "h264_nvenc":
|
| 590 |
+
cmd.extend(["-c:v", "h264_nvenc", "-preset", "fast", "-c:a", "aac"])
|
| 591 |
+
else:
|
| 592 |
+
cmd.extend(["-c:v", "libx264", "-preset", "fast", "-c:a", "aac"])
|
| 593 |
+
else:
|
| 594 |
+
cmd.extend(["-c:a", "aac"])
|
| 595 |
+
|
| 596 |
+
cmd.append(str(output_path))
|
| 597 |
+
|
| 598 |
+
print(f"Executing: {' '.join(cmd)}")
|
| 599 |
+
|
| 600 |
+
startupinfo = None
|
| 601 |
+
if sys.platform == 'win32':
|
| 602 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 603 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 604 |
+
|
| 605 |
+
try:
|
| 606 |
+
res = subprocess.run(
|
| 607 |
+
cmd,
|
| 608 |
+
stdout=subprocess.PIPE,
|
| 609 |
+
stderr=subprocess.PIPE,
|
| 610 |
+
text=True,
|
| 611 |
+
encoding="utf-8",
|
| 612 |
+
errors="ignore",
|
| 613 |
+
startupinfo=startupinfo,
|
| 614 |
+
timeout=1200
|
| 615 |
+
)
|
| 616 |
+
if res.returncode == 0:
|
| 617 |
+
print(f"Render completed successfully using encoder '{encoder}'.")
|
| 618 |
+
success = True
|
| 619 |
+
break
|
| 620 |
+
else:
|
| 621 |
+
print(f"Warning: Encoder '{encoder}' failed with exit code {res.returncode}.", file=sys.stderr)
|
| 622 |
+
print(f"FFmpeg Stderr:\n{res.stderr}", file=sys.stderr)
|
| 623 |
+
except Exception as e:
|
| 624 |
+
print(f"Warning: Exception using encoder '{encoder}': {e}", file=sys.stderr)
|
| 625 |
+
|
| 626 |
+
if not success:
|
| 627 |
+
if not allow_cpu_fallback and (args.encoder in ("nvenc", "h264_nvenc") or args.encoder == "auto"):
|
| 628 |
+
print("NVENC requested but unavailable. CPU render fallback disabled by strict GPU policy.", file=sys.stderr)
|
| 629 |
+
sys.exit(3)
|
| 630 |
+
print("Error: All rendering encoders failed.", file=sys.stderr)
|
| 631 |
+
sys.exit(2)
|
| 632 |
+
|
| 633 |
+
sys.exit(0)
|
| 634 |
+
|
| 635 |
+
if __name__ == "__main__":
|
| 636 |
+
main()
|
app/core/separator_worker_cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import argparse
|
| 4 |
+
import shutil
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Enforce UTF-8 for Windows console
|
| 8 |
+
if sys.platform == 'win32':
|
| 9 |
+
try:
|
| 10 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 11 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 12 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 13 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 14 |
+
except Exception:
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
def is_cuda_fully_functional():
|
| 18 |
+
try:
|
| 19 |
+
import torch
|
| 20 |
+
if not torch.cuda.is_available():
|
| 21 |
+
return False
|
| 22 |
+
return True
|
| 23 |
+
except Exception:
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
parser = argparse.ArgumentParser(description="Standalone Roformer Audio Separator CLI")
|
| 28 |
+
parser.add_argument("--audio", "--input", dest="audio", required=True, help="Path to input audio WAV file")
|
| 29 |
+
parser.add_argument("--output-dir", required=True, help="Directory to save separated stems")
|
| 30 |
+
parser.add_argument("--model", default="model_bs_roformer_ep_317_sdr_12.9755.ckpt", help="Model filename")
|
| 31 |
+
parser.add_argument("--device", default="auto", choices=["cuda", "cpu", "auto"], help="Device to run inference on")
|
| 32 |
+
parser.add_argument("--allow-cpu-fallback", default="true", help="Allow fallback to CPU (true/false)")
|
| 33 |
+
parser.add_argument("--use-tensorrt", default="false", help="Use TensorRT execution provider (true/false)")
|
| 34 |
+
parser.add_argument("--prefer-gpu", default="true", help="Prefer GPU execution (true/false)")
|
| 35 |
+
args = parser.parse_args()
|
| 36 |
+
|
| 37 |
+
audio_path = Path(args.audio)
|
| 38 |
+
output_dir = Path(args.output_dir)
|
| 39 |
+
|
| 40 |
+
if not audio_path.exists():
|
| 41 |
+
print(f"Error: Input audio file not found at {audio_path}", file=sys.stderr)
|
| 42 |
+
sys.exit(1)
|
| 43 |
+
|
| 44 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
print("PROGRESS: 10%", flush=True)
|
| 47 |
+
print("Loading audio-separator library...")
|
| 48 |
+
try:
|
| 49 |
+
from audio_separator.separator import Separator
|
| 50 |
+
except ImportError as e:
|
| 51 |
+
print(f"Error: audio-separator not installed. {e}", file=sys.stderr)
|
| 52 |
+
sys.exit(1)
|
| 53 |
+
|
| 54 |
+
allow_cpu_fallback = args.allow_cpu_fallback.lower() in ("true", "1", "yes", "t")
|
| 55 |
+
use_tensorrt = args.use_tensorrt.lower() in ("true", "1", "yes", "t")
|
| 56 |
+
prefer_gpu = args.prefer_gpu.lower() in ("true", "1", "yes", "t")
|
| 57 |
+
|
| 58 |
+
# Fetch available providers
|
| 59 |
+
try:
|
| 60 |
+
import onnxruntime
|
| 61 |
+
onnx_providers = onnxruntime.get_available_providers()
|
| 62 |
+
except Exception:
|
| 63 |
+
onnx_providers = []
|
| 64 |
+
|
| 65 |
+
has_cuda_provider = "CUDAExecutionProvider" in onnx_providers
|
| 66 |
+
|
| 67 |
+
# Selection logic:
|
| 68 |
+
selected_device = None
|
| 69 |
+
selected_provider = None
|
| 70 |
+
|
| 71 |
+
if args.device == "cuda":
|
| 72 |
+
if not has_cuda_provider and not (use_tensorrt and "TensorrtExecutionProvider" in onnx_providers):
|
| 73 |
+
print("Error: CUDA requested but CUDAExecutionProvider not available.", file=sys.stderr)
|
| 74 |
+
sys.exit(3)
|
| 75 |
+
selected_device = "cuda"
|
| 76 |
+
if use_tensorrt and "TensorrtExecutionProvider" in onnx_providers:
|
| 77 |
+
selected_provider = "TensorrtExecutionProvider"
|
| 78 |
+
else:
|
| 79 |
+
selected_provider = "CUDAExecutionProvider"
|
| 80 |
+
elif args.device == "cpu":
|
| 81 |
+
selected_device = "cpu"
|
| 82 |
+
selected_provider = "CPUExecutionProvider"
|
| 83 |
+
else: # auto
|
| 84 |
+
if (has_cuda_provider or (use_tensorrt and "TensorrtExecutionProvider" in onnx_providers)) and prefer_gpu:
|
| 85 |
+
selected_device = "cuda"
|
| 86 |
+
if use_tensorrt and "TensorrtExecutionProvider" in onnx_providers:
|
| 87 |
+
selected_provider = "TensorrtExecutionProvider"
|
| 88 |
+
else:
|
| 89 |
+
selected_provider = "CUDAExecutionProvider"
|
| 90 |
+
else:
|
| 91 |
+
if not allow_cpu_fallback:
|
| 92 |
+
print("Error: GPU not available/preferred and CPU fallback is disabled.", file=sys.stderr)
|
| 93 |
+
sys.exit(4)
|
| 94 |
+
selected_device = "cpu"
|
| 95 |
+
selected_provider = "CPUExecutionProvider"
|
| 96 |
+
|
| 97 |
+
# Log required info BEFORE plans loop
|
| 98 |
+
print(f"[SEPARATION CONFIG] requested device: {args.device}")
|
| 99 |
+
print(f"[SEPARATION CONFIG] prefer_gpu: {'true' if prefer_gpu else 'false'}")
|
| 100 |
+
print(f"[SEPARATION CONFIG] allow_cpu_fallback: {'true' if allow_cpu_fallback else 'false'}")
|
| 101 |
+
|
| 102 |
+
import torch
|
| 103 |
+
print(f"[GPU CHECK] onnx providers: {onnx_providers}")
|
| 104 |
+
print(f"[SEPARATION] selected provider: {selected_provider}")
|
| 105 |
+
print(f"[SEPARATION] selected device: {selected_device}")
|
| 106 |
+
|
| 107 |
+
# Define plans: (device, model_name)
|
| 108 |
+
plans = []
|
| 109 |
+
if selected_device == "cuda":
|
| 110 |
+
plans.append(("cuda", args.model))
|
| 111 |
+
plans.append(("cuda", "Kim_Vocal_2.onnx"))
|
| 112 |
+
if allow_cpu_fallback:
|
| 113 |
+
plans.append(("cpu", args.model))
|
| 114 |
+
plans.append(("cpu", "Kim_Vocal_2.onnx"))
|
| 115 |
+
else:
|
| 116 |
+
plans.append(("cpu", args.model))
|
| 117 |
+
plans.append(("cpu", "Kim_Vocal_2.onnx"))
|
| 118 |
+
|
| 119 |
+
separator = None
|
| 120 |
+
output_files = []
|
| 121 |
+
success = False
|
| 122 |
+
error_msgs = []
|
| 123 |
+
|
| 124 |
+
for idx, (dev, model_name) in enumerate(plans, start=1):
|
| 125 |
+
print(f"PROGRESS: {30 + (idx - 1) * 15}%", flush=True)
|
| 126 |
+
print(f"--- [Plan {idx}] Attempting separation with '{model_name}' on '{dev}' ---")
|
| 127 |
+
|
| 128 |
+
# Internal log check
|
| 129 |
+
print(f"[GPU CHECK] torch.cuda.is_available: {torch.cuda.is_available()}")
|
| 130 |
+
print(f"[GPU CHECK] torch.version.cuda: {getattr(torch, 'version', None) and getattr(torch.version, 'cuda', 'Unknown')}")
|
| 131 |
+
print(f"[GPU CHECK] gpu: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'None'}")
|
| 132 |
+
print(f"[GPU CHECK] onnx providers: {onnx_providers}")
|
| 133 |
+
|
| 134 |
+
run_provider = "CPUExecutionProvider"
|
| 135 |
+
if dev == 'cuda':
|
| 136 |
+
if use_tensorrt and "TensorrtExecutionProvider" in onnx_providers:
|
| 137 |
+
run_provider = "TensorrtExecutionProvider"
|
| 138 |
+
elif "CUDAExecutionProvider" in onnx_providers:
|
| 139 |
+
run_provider = "CUDAExecutionProvider"
|
| 140 |
+
print(f"[SEPARATION] selected provider: {run_provider}")
|
| 141 |
+
print(f"[SEPARATION] selected device: {dev}")
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
separator = Separator(
|
| 145 |
+
output_dir=str(output_dir),
|
| 146 |
+
output_format="wav",
|
| 147 |
+
normalization_threshold=1.0,
|
| 148 |
+
log_level=20,
|
| 149 |
+
)
|
| 150 |
+
if dev == 'cpu':
|
| 151 |
+
separator.torch_device = torch.device("cpu")
|
| 152 |
+
separator.onnx_execution_provider = ["CPUExecutionProvider"]
|
| 153 |
+
else:
|
| 154 |
+
if torch.cuda.is_available():
|
| 155 |
+
separator.torch_device = torch.device("cuda")
|
| 156 |
+
if use_tensorrt:
|
| 157 |
+
separator.onnx_execution_provider = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 158 |
+
else:
|
| 159 |
+
separator.onnx_execution_provider = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 160 |
+
else:
|
| 161 |
+
separator.torch_device = torch.device("cpu")
|
| 162 |
+
separator.onnx_execution_provider = ["CPUExecutionProvider"]
|
| 163 |
+
|
| 164 |
+
print(f"Loading model '{model_name}'...")
|
| 165 |
+
separator.load_model(model_filename=model_name)
|
| 166 |
+
|
| 167 |
+
print("Separating vocals from background track...")
|
| 168 |
+
output_files = separator.separate(str(audio_path))
|
| 169 |
+
print(f"Separation completed successfully with Plan {idx}.")
|
| 170 |
+
success = True
|
| 171 |
+
break
|
| 172 |
+
except Exception as e:
|
| 173 |
+
msg = f"Plan {idx} ({model_name} on {dev}) failed: {e}"
|
| 174 |
+
print(msg, file=sys.stderr)
|
| 175 |
+
error_msgs.append(msg)
|
| 176 |
+
if separator:
|
| 177 |
+
try:
|
| 178 |
+
del separator
|
| 179 |
+
except:
|
| 180 |
+
pass
|
| 181 |
+
separator = None
|
| 182 |
+
import gc
|
| 183 |
+
gc.collect()
|
| 184 |
+
try:
|
| 185 |
+
if torch.cuda.is_available():
|
| 186 |
+
torch.cuda.empty_cache()
|
| 187 |
+
except:
|
| 188 |
+
pass
|
| 189 |
+
print("Retrying with next plan...", file=sys.stderr)
|
| 190 |
+
|
| 191 |
+
if not success:
|
| 192 |
+
print(f"Error: All separation plans failed. Details:\n" + "\n".join(error_msgs), file=sys.stderr)
|
| 193 |
+
sys.exit(2)
|
| 194 |
+
|
| 195 |
+
print("PROGRESS: 90%", flush=True)
|
| 196 |
+
try:
|
| 197 |
+
# Rename output files to unified vocals.wav, no_vocals.wav, and background.wav
|
| 198 |
+
vocals_path = None
|
| 199 |
+
instrumental_path = None
|
| 200 |
+
|
| 201 |
+
for file_path in output_dir.glob("*.wav"):
|
| 202 |
+
if "(Vocals)" in file_path.name:
|
| 203 |
+
vocals_path = file_path
|
| 204 |
+
elif "(Instrumental)" in file_path.name:
|
| 205 |
+
instrumental_path = file_path
|
| 206 |
+
|
| 207 |
+
if not vocals_path or not instrumental_path:
|
| 208 |
+
for out_f in output_files:
|
| 209 |
+
full_p = output_dir / out_f
|
| 210 |
+
if full_p.exists():
|
| 211 |
+
if "(Vocals)" in out_f:
|
| 212 |
+
vocals_path = full_p
|
| 213 |
+
elif "(Instrumental)" in out_f:
|
| 214 |
+
instrumental_path = full_p
|
| 215 |
+
|
| 216 |
+
if vocals_path and instrumental_path:
|
| 217 |
+
dest_vocals = output_dir / "vocals.wav"
|
| 218 |
+
dest_no_vocals = output_dir / "no_vocals.wav"
|
| 219 |
+
dest_background = output_dir / "background.wav"
|
| 220 |
+
if dest_vocals.exists():
|
| 221 |
+
dest_vocals.unlink()
|
| 222 |
+
if dest_no_vocals.exists():
|
| 223 |
+
dest_no_vocals.unlink()
|
| 224 |
+
if dest_background.exists():
|
| 225 |
+
dest_background.unlink()
|
| 226 |
+
|
| 227 |
+
# Move stems
|
| 228 |
+
shutil.move(str(vocals_path), str(dest_vocals))
|
| 229 |
+
shutil.move(str(instrumental_path), str(dest_no_vocals))
|
| 230 |
+
# Create a copy as background.wav to satisfy the direct test check
|
| 231 |
+
shutil.copy(str(dest_no_vocals), str(dest_background))
|
| 232 |
+
print("Successfully renamed stems to vocals.wav, no_vocals.wav, and background.wav")
|
| 233 |
+
else:
|
| 234 |
+
print("Warning: Stems could not be identified by naming convention.", file=sys.stderr)
|
| 235 |
+
|
| 236 |
+
print("PROGRESS: 100%", flush=True)
|
| 237 |
+
print("Process finished successfully.")
|
| 238 |
+
finally:
|
| 239 |
+
# Cleanup
|
| 240 |
+
try:
|
| 241 |
+
del separator
|
| 242 |
+
except NameError:
|
| 243 |
+
pass
|
| 244 |
+
import gc
|
| 245 |
+
gc.collect()
|
| 246 |
+
try:
|
| 247 |
+
import torch
|
| 248 |
+
if torch.cuda.is_available():
|
| 249 |
+
torch.cuda.empty_cache()
|
| 250 |
+
except Exception:
|
| 251 |
+
pass
|
| 252 |
+
sys.exit(0)
|
| 253 |
+
|
| 254 |
+
if __name__ == "__main__":
|
| 255 |
+
main()
|
app/core/studio_qa.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/studio_qa.py
|
| 3 |
+
─────────────────────
|
| 4 |
+
Self-refining QA loop (retention tier 5, lightweight).
|
| 5 |
+
|
| 6 |
+
After TTS synthesis, this module reads tts_segments_manifest.json and flags every
|
| 7 |
+
segment whose effective speedup_ratio exceeds the "comfort" threshold (1.3x).
|
| 8 |
+
For those segments it asks the LLM (via the existing translation manager) to
|
| 9 |
+
compress the Vietnamese line to fit the original timeline, rewrites the SRT, and
|
| 10 |
+
signals the pipeline to re-run TTS — up to `max_rounds` rounds.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
COMFORT_SPEEDUP_RATIO = 1.3
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _srt_to_blocks(srt_path):
|
| 22 |
+
srt_path = Path(srt_path)
|
| 23 |
+
if not srt_path.exists():
|
| 24 |
+
return []
|
| 25 |
+
content = srt_path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n")
|
| 26 |
+
pattern = (
|
| 27 |
+
r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n"
|
| 28 |
+
r"(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 29 |
+
)
|
| 30 |
+
blocks = []
|
| 31 |
+
for m in re.finditer(pattern, content, re.DOTALL):
|
| 32 |
+
text = " ".join(l.strip() for l in m.group(4).splitlines() if l.strip())
|
| 33 |
+
blocks.append({"id": int(m.group(1)), "start": m.group(2).strip(), "end": m.group(3).strip(), "text": text})
|
| 34 |
+
return blocks
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _rewrite_srt(srt_path, new_text_by_id):
|
| 38 |
+
"""Rewrite text of given blocks inside the SRT, keeping timing intact."""
|
| 39 |
+
srt_path = Path(srt_path)
|
| 40 |
+
blocks = _srt_to_blocks(srt_path)
|
| 41 |
+
lines_out = []
|
| 42 |
+
for b in blocks:
|
| 43 |
+
lines_out.append(f"{b['id']}")
|
| 44 |
+
lines_out.append(f"{b['start']} --> {b['end']}")
|
| 45 |
+
lines_out.append(new_text_by_id.get(b["id"], b["text"]))
|
| 46 |
+
lines_out.append("")
|
| 47 |
+
srt_path.write_text("\n".join(lines_out), encoding="utf-8")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def find_over_speed_segments(manifest_path, threshold=COMFORT_SPEEDUP_RATIO):
|
| 51 |
+
"""Return [(id, speedup_ratio, target_ms, final_ms), ...] for segments
|
| 52 |
+
compressed harder than `threshold`."""
|
| 53 |
+
manifest_path = Path(manifest_path)
|
| 54 |
+
if not manifest_path.exists():
|
| 55 |
+
return []
|
| 56 |
+
try:
|
| 57 |
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
| 58 |
+
entries = json.load(f)
|
| 59 |
+
except Exception:
|
| 60 |
+
return []
|
| 61 |
+
flagged = []
|
| 62 |
+
for e in entries:
|
| 63 |
+
ratio = float(e.get("speedup_ratio") or 1.0)
|
| 64 |
+
if ratio > threshold:
|
| 65 |
+
flagged.append((
|
| 66 |
+
int(e.get("id", 0)),
|
| 67 |
+
round(ratio, 2),
|
| 68 |
+
int(e.get("target_duration_ms", 0)),
|
| 69 |
+
int(e.get("final_duration_ms") or 0),
|
| 70 |
+
))
|
| 71 |
+
return flagged
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def compress_lines_with_llm(blocks, compress_ids, config, log_fn=print):
|
| 75 |
+
"""Ask the LLM to shorten the given blocks; returns {id: new_text}."""
|
| 76 |
+
compress_ids = set(compress_ids)
|
| 77 |
+
if not compress_ids:
|
| 78 |
+
return {}
|
| 79 |
+
|
| 80 |
+
chunks = []
|
| 81 |
+
for b in blocks:
|
| 82 |
+
if b["id"] in compress_ids:
|
| 83 |
+
duration = 0.0
|
| 84 |
+
try:
|
| 85 |
+
parts = b["end"].replace(",", ".").split(":")
|
| 86 |
+
parts2 = parts[2] if len(parts) > 2 else "0.0"
|
| 87 |
+
end_s = float(parts2) + (int(parts[0]) * 3600 + int(parts[1]) * 60 if len(parts) > 1 else 0)
|
| 88 |
+
parts = b["start"].replace(",", ".").split(":")
|
| 89 |
+
parts2 = parts[2] if len(parts) > 2 else "0.0"
|
| 90 |
+
start_s = float(parts2) + (int(parts[0]) * 3600 + int(parts[1]) * 60 if len(parts) > 1 else 0)
|
| 91 |
+
duration = max(0.1, end_s - start_s)
|
| 92 |
+
except Exception:
|
| 93 |
+
duration = 1.0
|
| 94 |
+
chunks.append({
|
| 95 |
+
"id": str(b["id"]),
|
| 96 |
+
"start": b["start"],
|
| 97 |
+
"end": b["end"],
|
| 98 |
+
"duration_sec": round(duration, 3),
|
| 99 |
+
"text": b["text"],
|
| 100 |
+
"source_mode": "speech",
|
| 101 |
+
"compress_hint": f"Tối đa {int(duration * 2.3)} âm tiết, rút gọn tối đa nhưng giữ ý chính.",
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
if not chunks:
|
| 105 |
+
return {}
|
| 106 |
+
|
| 107 |
+
# Ask LLM with a strong compression instruction via the existing provider chain
|
| 108 |
+
result = {}
|
| 109 |
+
try:
|
| 110 |
+
from app.translation.fallback import TranslationFallbackChain
|
| 111 |
+
|
| 112 |
+
chain = TranslationFallbackChain(engines_list=["API Pool"], config_dict=config or {})
|
| 113 |
+
providers = chain.get_providers()
|
| 114 |
+
for name, provider in providers:
|
| 115 |
+
# Machine/Google translation can't compress per prompt; only LLM providers help
|
| 116 |
+
if any(k in str(name) for k in ("Google", "MyMemory", "Machine")):
|
| 117 |
+
continue
|
| 118 |
+
try:
|
| 119 |
+
system = (
|
| 120 |
+
"Bạn là biên tập lời lồng tiếng Việt. Viết LẠI mỗi câu thành một câu tiếng Việt "
|
| 121 |
+
"HOÀN CHỈNH nhưng ngắn gọn, đủ ý chính, dễ đọc nhanh. "
|
| 122 |
+
"Giữ tên riêng, brand, số liệu. Không thêm tag. Không cắt cụt thành từ đơn.\n"
|
| 123 |
+
"Mỗi câu tối đa 2.3 từ mỗi giây thời lượng.\n"
|
| 124 |
+
"Trả về JSON {\"id\": \"câu ngắn gọn đầy đủ\"}."
|
| 125 |
+
)
|
| 126 |
+
user = (
|
| 127 |
+
"Rút gọn các câu sau (đọc field duration_sec và compress_hint):\n"
|
| 128 |
+
+ json.dumps(chunks, ensure_ascii=False, indent=2)
|
| 129 |
+
)
|
| 130 |
+
raw = provider.translate_chunk(chunks, system, user, log_fn=log_fn)
|
| 131 |
+
if isinstance(raw, dict):
|
| 132 |
+
for k, v in raw.items():
|
| 133 |
+
try:
|
| 134 |
+
result[int(k)] = v
|
| 135 |
+
except Exception:
|
| 136 |
+
pass
|
| 137 |
+
elif isinstance(raw, str):
|
| 138 |
+
data = _parse_json_output(raw)
|
| 139 |
+
for k, v in data.items():
|
| 140 |
+
try:
|
| 141 |
+
result[int(k)] = v
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
if result:
|
| 145 |
+
break
|
| 146 |
+
except Exception as e:
|
| 147 |
+
log_fn(f"[QA] provider '{name}' compress failed: {e}")
|
| 148 |
+
except Exception as e:
|
| 149 |
+
log_fn(f"[QA] LLM compress failed: {e}")
|
| 150 |
+
|
| 151 |
+
return result
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _parse_json_output(raw):
|
| 155 |
+
try:
|
| 156 |
+
return json.loads(raw)
|
| 157 |
+
except Exception:
|
| 158 |
+
pass
|
| 159 |
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 160 |
+
if m:
|
| 161 |
+
try:
|
| 162 |
+
return json.loads(m.group(0))
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
+
return {}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def run_qa_loop(voiceover_srt, manifest_path, config, max_rounds=2, threshold=COMFORT_SPEEDUP_RATIO, log_fn=print):
|
| 169 |
+
"""Full QA loop. Returns (rounds_run, still_over: [(id, ratio)]).
|
| 170 |
+
|
| 171 |
+
The pipeline calls this after TTS; when it returns True for still_over, it
|
| 172 |
+
rewrites the SRT and re-runs TTS. When no segments exceed the threshold the
|
| 173 |
+
loop exits immediately.
|
| 174 |
+
"""
|
| 175 |
+
rounds_run = 0
|
| 176 |
+
still_over = []
|
| 177 |
+
srt_path = Path(voiceover_srt)
|
| 178 |
+
|
| 179 |
+
for rnd in range(max_rounds):
|
| 180 |
+
over = find_over_speed_segments(manifest_path, threshold)
|
| 181 |
+
if not over:
|
| 182 |
+
log_fn(f"[QA] Round {rnd+1}: 0 segment vượt {threshold}x — đạt chuẩn.")
|
| 183 |
+
return rounds_run, []
|
| 184 |
+
rounds_run += 1
|
| 185 |
+
log_fn(f"[QA] Round {rnd+1}: {len(over)} segment speedup > {threshold}x -> gửi LLM rút ngắn.")
|
| 186 |
+
blocks = _srt_to_blocks(srt_path)
|
| 187 |
+
compress_ids = [i for i, _, _, _ in over]
|
| 188 |
+
new_text = compress_lines_with_llm(blocks, compress_ids, config, log_fn)
|
| 189 |
+
if not new_text:
|
| 190 |
+
log_fn("[QA] LLM không rút gọn được gì; dừng loop.")
|
| 191 |
+
return rounds_run, over
|
| 192 |
+
# Guard: chỉ chấp nhận câu rút gọn thực sự hợp lệ (>=2 từ và ngắn hơn bản cũ)
|
| 193 |
+
by_id = {b["id"]: b["text"] for b in blocks}
|
| 194 |
+
accepted = {}
|
| 195 |
+
for bid, txt in new_text.items():
|
| 196 |
+
old = by_id.get(bid, "")
|
| 197 |
+
words = [w for w in str(txt).strip().split() if w]
|
| 198 |
+
if len(words) >= 2 and len(str(txt)) < max(4, len(old) * 0.9):
|
| 199 |
+
accepted[bid] = str(txt).strip()
|
| 200 |
+
if not accepted:
|
| 201 |
+
log_fn("[QA] LLM trả kết quả không hợp lệ (quá ngắn); dừng loop.")
|
| 202 |
+
return rounds_run, over
|
| 203 |
+
_rewrite_srt(srt_path, accepted)
|
| 204 |
+
log_fn(f"[QA] Đã rút gọn {len(accepted)} câu trong {srt_path.name}; chờ re-run TTS.")
|
| 205 |
+
return rounds_run, over
|
| 206 |
+
|
| 207 |
+
still_over = find_over_speed_segments(manifest_path, threshold)
|
| 208 |
+
return rounds_run, still_over
|
app/core/studio_sfx.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/studio_sfx.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Retention-focused SFX injection for the studio pipeline.
|
| 5 |
+
|
| 6 |
+
Generates short sound-effect beds (whoosh / ding / impact) with ffmpeg lavfi and
|
| 7 |
+
mixes them onto the dubbed audio at the exact start_ms of every block that was
|
| 8 |
+
tagged with an SFX marker by the LLM translation stage.
|
| 9 |
+
|
| 10 |
+
Kept deliberately light (no external audio assets required) so it runs headless
|
| 11 |
+
on any machine with ffmpeg.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
SFX_LENGTH_MS = {
|
| 20 |
+
"whoosh": 500,
|
| 21 |
+
"ding": 400,
|
| 22 |
+
"impact": 300,
|
| 23 |
+
"pop": 200,
|
| 24 |
+
"boom": 600,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# volume in dB relative to the dubbed voice bed; SFX stays under the voice.
|
| 28 |
+
SFX_GAIN_DB = {
|
| 29 |
+
"whoosh": -16.0,
|
| 30 |
+
"ding": -12.0,
|
| 31 |
+
"impact": -10.0,
|
| 32 |
+
"pop": -14.0,
|
| 33 |
+
"boom": -9.0,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
DEFAULT_SR = 44100
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _ffmpeg_cmd(ffmpeg_path, args):
|
| 40 |
+
startupinfo = None
|
| 41 |
+
if sys.platform == "win32":
|
| 42 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 43 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 44 |
+
cmd = [str(ffmpeg_path), "-y"] + args
|
| 45 |
+
res = subprocess.run(
|
| 46 |
+
cmd,
|
| 47 |
+
stdout=subprocess.PIPE,
|
| 48 |
+
stderr=subprocess.PIPE,
|
| 49 |
+
text=True,
|
| 50 |
+
encoding="utf-8",
|
| 51 |
+
errors="ignore",
|
| 52 |
+
startupinfo=startupinfo,
|
| 53 |
+
timeout=60,
|
| 54 |
+
)
|
| 55 |
+
if res.returncode != 0:
|
| 56 |
+
raise RuntimeError(f"ffmpeg failed: {res.stderr[-600:]}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def generate_sfx_clip(ffmpeg_path, sfx_name, output_path):
|
| 60 |
+
"""Render one sfx clip (mono 44.1k wav) using lavfi sources."""
|
| 61 |
+
sfx_name = str(sfx_name or "ding").lower()
|
| 62 |
+
dur_ms = int(SFX_LENGTH_MS.get(sfx_name, 400))
|
| 63 |
+
dur = dur_ms / 1000.0
|
| 64 |
+
out = Path(output_path)
|
| 65 |
+
|
| 66 |
+
if sfx_name == "whoosh":
|
| 67 |
+
# filtered noise sweep upward, fade in/out -> airy whoosh
|
| 68 |
+
src = (
|
| 69 |
+
f"anoisesrc=colour=white:amplitude=0.5:duration={dur}:sample_rate={DEFAULT_SR},"
|
| 70 |
+
f"highpass=f=200,lowpass=f=4000,"
|
| 71 |
+
f"afade=t=in:st=0:d={dur*0.30:.3f},afade=t=out:st={dur*0.60:.3f}:d={dur*0.40:.3f}"
|
| 72 |
+
)
|
| 73 |
+
elif sfx_name == "ding":
|
| 74 |
+
# bright bell + short decay
|
| 75 |
+
src = (
|
| 76 |
+
f"aevalsrc='0.35*sin(2*PI*1318*t)+0.15*sin(2*PI*2637*t)':d={dur}:s={DEFAULT_SR},"
|
| 77 |
+
f"afade=t=in:st=0:d=0.01,afade=t=out:st={dur*0.25:.3f}:d={dur*0.75:.3f}"
|
| 78 |
+
)
|
| 79 |
+
elif sfx_name == "boom":
|
| 80 |
+
# low sub thump with punchy decay
|
| 81 |
+
src = (
|
| 82 |
+
f"aevalsrc='0.6*sin(2*PI*55*t)*exp(-6*t)+0.2*sin(2*PI*110*t)*exp(-8*t)':d={dur}:s={DEFAULT_SR},"
|
| 83 |
+
f"afade=t=in:st=0:d=0.01,afade=t=out:st={dur*0.30:.3f}:d={dur*0.70:.3f}"
|
| 84 |
+
)
|
| 85 |
+
elif sfx_name == "pop":
|
| 86 |
+
src = (
|
| 87 |
+
f"aevalsrc='0.4*sin(2*PI*900*t)*exp(-18*t)':d={dur}:s={DEFAULT_SR},"
|
| 88 |
+
f"afade=t=out:st={dur*0.5:.3f}:d={dur*0.5:.3f}"
|
| 89 |
+
)
|
| 90 |
+
else: # impact — mid-low thud
|
| 91 |
+
src = (
|
| 92 |
+
f"aevalsrc='0.5*sin(2*PI*160*t)*exp(-9*t)+0.2*sin(2*PI*80*t)*exp(-6*t)':d={dur}:s={DEFAULT_SR},"
|
| 93 |
+
f"afade=t=in:st=0:d=0.005,afade=t=out:st={dur*0.35:.3f}:d={dur*0.65:.3f}"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
_ffmpeg_cmd(ffmpeg_path, ["-f", "lavfi", "-i", src, "-ac", "1", "-ar", str(DEFAULT_SR), str(out)])
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build_sfx_track(ffmpeg_path, tags_json, output_wav, total_duration_ms, sr=DEFAULT_SR):
|
| 102 |
+
"""Mix all tagged sfx into a single full-length track aligned to the video.
|
| 103 |
+
|
| 104 |
+
tags_json: list of records from studio_tags.json (each has start_ms + sfx list).
|
| 105 |
+
Returns the output path, or None when there are no sfx tags.
|
| 106 |
+
"""
|
| 107 |
+
tags_json = Path(tags_json)
|
| 108 |
+
if not tags_json.exists():
|
| 109 |
+
return None
|
| 110 |
+
with open(tags_json, "r", encoding="utf-8") as f:
|
| 111 |
+
records = json.load(f)
|
| 112 |
+
|
| 113 |
+
clips = []
|
| 114 |
+
for rec in records:
|
| 115 |
+
sfx_list = [s for s in rec.get("sfx", []) if s in SFX_LENGTH_MS]
|
| 116 |
+
if not sfx_list:
|
| 117 |
+
continue
|
| 118 |
+
start_ms = int(rec.get("start_ms", 0))
|
| 119 |
+
# stagger multiple sfx on the same line by 120ms each
|
| 120 |
+
for i, sfx in enumerate(sfx_list):
|
| 121 |
+
offset_ms = max(0, start_ms + i * 120)
|
| 122 |
+
gain = SFX_GAIN_DB.get(sfx, -14.0)
|
| 123 |
+
clips.append((sfx, offset_ms, gain))
|
| 124 |
+
|
| 125 |
+
if not clips:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
out = Path(output_wav)
|
| 129 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 130 |
+
|
| 131 |
+
temp_dir = out.parent / "sfx_tmp"
|
| 132 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
|
| 134 |
+
mix_inputs = []
|
| 135 |
+
for i, (sfx, offset_ms, gain) in enumerate(clips):
|
| 136 |
+
clip_wav = temp_dir / f"{i:03d}_{sfx}.wav"
|
| 137 |
+
generate_sfx_clip(ffmpeg_path, sfx, clip_wav)
|
| 138 |
+
delayed_wav = temp_dir / f"{i:03d}_delayed.wav"
|
| 139 |
+
_ffmpeg_cmd(
|
| 140 |
+
ffmpeg_path,
|
| 141 |
+
[
|
| 142 |
+
"-i", str(clip_wav),
|
| 143 |
+
"-af", f"adelay={offset_ms}|{offset_ms},volume={gain}dB",
|
| 144 |
+
str(delayed_wav),
|
| 145 |
+
],
|
| 146 |
+
)
|
| 147 |
+
mix_inputs.append(str(delayed_wav))
|
| 148 |
+
|
| 149 |
+
# amix all delayed clips into one track of the target length
|
| 150 |
+
inputs = []
|
| 151 |
+
for w in mix_inputs:
|
| 152 |
+
inputs += ["-i", w]
|
| 153 |
+
n = len(mix_inputs)
|
| 154 |
+
amix_inputs = "".join(f"[{i}:a]" for i in range(n))
|
| 155 |
+
_ffmpeg_cmd(
|
| 156 |
+
ffmpeg_path,
|
| 157 |
+
inputs + [
|
| 158 |
+
"-filter_complex",
|
| 159 |
+
f"{amix_inputs}amix=inputs={n}:normalize=0",
|
| 160 |
+
"-ar", str(sr),
|
| 161 |
+
str(out),
|
| 162 |
+
],
|
| 163 |
+
)
|
| 164 |
+
return out
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def mix_sfx_onto_bed(ffmpeg_path, bed_wav, sfx_track, output_wav):
|
| 168 |
+
"""Overlay the sfx track onto the dubbed audio bed (sfx stays subtle)."""
|
| 169 |
+
bed = Path(bed_wav)
|
| 170 |
+
track = Path(sfx_track)
|
| 171 |
+
out = Path(output_wav)
|
| 172 |
+
if not bed.exists() or not track.exists():
|
| 173 |
+
return str(bed) if bed.exists() else None
|
| 174 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
_ffmpeg_cmd(
|
| 176 |
+
ffmpeg_path,
|
| 177 |
+
[
|
| 178 |
+
"-i", str(bed),
|
| 179 |
+
"-i", str(track),
|
| 180 |
+
"-filter_complex",
|
| 181 |
+
"[0:a][1:a]amix=inputs=2:normalize=0:dropout_transition=0",
|
| 182 |
+
"-ar", str(DEFAULT_SR),
|
| 183 |
+
str(out),
|
| 184 |
+
],
|
| 185 |
+
)
|
| 186 |
+
return out
|
app/core/studio_tags.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/core/studio_tags.py
|
| 3 |
+
────────────────────────
|
| 4 |
+
Studio-grade tag extraction for the retention-optimized pipeline.
|
| 5 |
+
|
| 6 |
+
The LLM translation prompt tags each dubbed line with emotion and SFX markers,
|
| 7 |
+
e.g. "Đỉnh vãi anh ơi [dứt khoát]" or "Khoan đã, nhìn này [hốt hoảng] [whoosh]".
|
| 8 |
+
|
| 9 |
+
This module:
|
| 10 |
+
• strips those tags from the SRT text (so TTS never reads them aloud),
|
| 11 |
+
• writes a per-block map `studio_tags.json` for later SFX injection,
|
| 12 |
+
• validates tags against the allowed emotion / sfx vocabularies.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
EMOTION_TAGS = {"hốt hoảng", "thì thầm", "dứt khoát", "phấn khích", "bình tĩnh"}
|
| 20 |
+
SFX_TAGS = {"whoosh", "ding", "impact", "pop", "boom"}
|
| 21 |
+
|
| 22 |
+
TAG_RE = re.compile(r"\[([^\]]+)\]")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _normalize_tag(tag: str) -> str:
|
| 26 |
+
return tag.strip().lower()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _split_tags(text: str):
|
| 30 |
+
"""Return (clean_text, emotions, sfx) by pulling bracketed tags off the tail."""
|
| 31 |
+
clean = text
|
| 32 |
+
emotions, sfx = [], []
|
| 33 |
+
while True:
|
| 34 |
+
m = TAG_RE.search(clean)
|
| 35 |
+
if not m:
|
| 36 |
+
break
|
| 37 |
+
tag = _normalize_tag(m.group(1))
|
| 38 |
+
if tag in EMOTION_TAGS:
|
| 39 |
+
emotions.append(tag)
|
| 40 |
+
clean = clean[: m.start()] + " " + clean[m.end() :]
|
| 41 |
+
elif tag in SFX_TAGS:
|
| 42 |
+
sfx.append(tag)
|
| 43 |
+
clean = clean[: m.start()] + " " + clean[m.end() :]
|
| 44 |
+
else:
|
| 45 |
+
break
|
| 46 |
+
clean = re.sub(r"\s+", " ", clean).strip(" ,.;:!?")
|
| 47 |
+
return clean, emotions, sfx
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def parse_srt_and_extract_tags(srt_path, output_tags_path=None):
|
| 51 |
+
"""Read a translated SRT, strip studio tags from every block's text and write
|
| 52 |
+
studio_tags.json mapping block id -> {text, emotions, sfx, start_ms, end_ms}.
|
| 53 |
+
|
| 54 |
+
Returns the list of tag records (also written to output_tags_path when given).
|
| 55 |
+
"""
|
| 56 |
+
srt_path = Path(srt_path)
|
| 57 |
+
if not srt_path.exists():
|
| 58 |
+
return []
|
| 59 |
+
|
| 60 |
+
content = srt_path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n")
|
| 61 |
+
pattern = (
|
| 62 |
+
r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n"
|
| 63 |
+
r"(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def to_ms(t):
|
| 67 |
+
m = re.match(r"(\d+):(\d+):(\d+)[,.](\d+)", t.strip())
|
| 68 |
+
if not m:
|
| 69 |
+
return 0
|
| 70 |
+
h, mi, s, ms = map(int, m.groups())
|
| 71 |
+
return ((h * 3600 + mi * 60 + s) * 1000) + ms
|
| 72 |
+
|
| 73 |
+
records = []
|
| 74 |
+
clean_lines = []
|
| 75 |
+
for match in re.finditer(pattern, content, re.DOTALL):
|
| 76 |
+
b_id = match.group(1).strip()
|
| 77 |
+
start = match.group(2).strip()
|
| 78 |
+
end = match.group(3).strip()
|
| 79 |
+
text = " ".join(l.strip() for l in match.group(4).splitlines() if l.strip())
|
| 80 |
+
if not text:
|
| 81 |
+
continue
|
| 82 |
+
clean_text, emotions, sfx = _split_tags(text)
|
| 83 |
+
clean_lines.append((b_id, start, end, clean_text))
|
| 84 |
+
if emotions or sfx:
|
| 85 |
+
records.append({
|
| 86 |
+
"id": int(b_id),
|
| 87 |
+
"start_ms": to_ms(start),
|
| 88 |
+
"end_ms": to_ms(end),
|
| 89 |
+
"text": clean_text,
|
| 90 |
+
"emotions": emotions,
|
| 91 |
+
"sfx": sfx,
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
if output_tags_path and records:
|
| 95 |
+
out = Path(output_tags_path)
|
| 96 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 98 |
+
|
| 99 |
+
return records
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def strip_tags_from_srt(srt_path, output_srt_path=None):
|
| 103 |
+
"""Rewrite the SRT with studio tags removed (in place when no output given)."""
|
| 104 |
+
srt_path = Path(srt_path)
|
| 105 |
+
if not srt_path.exists():
|
| 106 |
+
return str(srt_path)
|
| 107 |
+
content = srt_path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n")
|
| 108 |
+
pattern = (
|
| 109 |
+
r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n"
|
| 110 |
+
r"(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
|
| 111 |
+
)
|
| 112 |
+
out_lines = []
|
| 113 |
+
last_end = 0
|
| 114 |
+
for match in re.finditer(pattern, content, re.DOTALL):
|
| 115 |
+
out_lines.append(content[last_end : match.start()])
|
| 116 |
+
b_id = match.group(1)
|
| 117 |
+
start, end = match.group(2), match.group(3)
|
| 118 |
+
text = match.group(4)
|
| 119 |
+
text_clean, _, _ = _split_tags(text)
|
| 120 |
+
out_lines.append(f"{b_id}\n{start} --> {end}\n{text_clean}\n")
|
| 121 |
+
last_end = match.end()
|
| 122 |
+
out_lines.append(content[last_end:])
|
| 123 |
+
new_content = "".join(out_lines)
|
| 124 |
+
if output_srt_path:
|
| 125 |
+
Path(output_srt_path).write_text(new_content, encoding="utf-8")
|
| 126 |
+
else:
|
| 127 |
+
srt_path.write_text(new_content, encoding="utf-8")
|
| 128 |
+
return str(output_srt_path or srt_path)
|
app/core/subtitle_display_compactor.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
_SRT_BLOCK_RE = re.compile(
|
| 7 |
+
r"(\d+)\s+"
|
| 8 |
+
r"(\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n"
|
| 9 |
+
r"(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)",
|
| 10 |
+
re.DOTALL,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _time_to_ms(value):
|
| 15 |
+
match = re.match(r"^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$", str(value).strip())
|
| 16 |
+
if not match:
|
| 17 |
+
return 0
|
| 18 |
+
hh, mm, ss, ms = [int(x) for x in match.groups()]
|
| 19 |
+
return ((hh * 3600 + mm * 60 + ss) * 1000) + ms
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _ms_to_time(ms):
|
| 23 |
+
ms = max(0, int(ms))
|
| 24 |
+
hh, rem = divmod(ms, 3_600_000)
|
| 25 |
+
mm, rem = divmod(rem, 60_000)
|
| 26 |
+
ss, msec = divmod(rem, 1000)
|
| 27 |
+
return f"{hh:02d}:{mm:02d}:{ss:02d},{msec:03d}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _parse_srt(path):
|
| 31 |
+
path = Path(path)
|
| 32 |
+
if not path.exists():
|
| 33 |
+
return []
|
| 34 |
+
content = path.read_text(encoding="utf-8", errors="ignore").strip().replace("\r\n", "\n")
|
| 35 |
+
blocks = []
|
| 36 |
+
for match in _SRT_BLOCK_RE.finditer(content):
|
| 37 |
+
block_id = match.group(1).strip()
|
| 38 |
+
timestamp = match.group(2).strip()
|
| 39 |
+
try:
|
| 40 |
+
start_s, end_s = [p.strip() for p in re.split(r"\s*-->\s*", timestamp, maxsplit=1)]
|
| 41 |
+
except ValueError:
|
| 42 |
+
continue
|
| 43 |
+
text = " ".join(line.strip() for line in match.group(3).splitlines() if line.strip())
|
| 44 |
+
start_ms = _time_to_ms(start_s)
|
| 45 |
+
end_ms = _time_to_ms(end_s)
|
| 46 |
+
if end_ms <= start_ms:
|
| 47 |
+
continue
|
| 48 |
+
blocks.append(
|
| 49 |
+
{
|
| 50 |
+
"id": str(int(block_id)) if block_id.isdigit() else block_id,
|
| 51 |
+
"timestamp": timestamp,
|
| 52 |
+
"start_ms": start_ms,
|
| 53 |
+
"end_ms": end_ms,
|
| 54 |
+
"text": text.strip(),
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
return blocks
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _format_srt(blocks):
|
| 61 |
+
lines = []
|
| 62 |
+
for index, block in enumerate(blocks, start=1):
|
| 63 |
+
lines.append(str(index))
|
| 64 |
+
lines.append(f"{_ms_to_time(block['start_ms'])} --> {_ms_to_time(block['end_ms'])}")
|
| 65 |
+
lines.append(block["text"])
|
| 66 |
+
lines.append("")
|
| 67 |
+
return "\n".join(lines).strip() + ("\n" if blocks else "")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _contains_chinese(text):
|
| 71 |
+
return bool(re.search(r"[\u4e00-\u9fff]", str(text or "")))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _normalize_text(text):
|
| 75 |
+
text = re.sub(r"[^\wÀ-ỹ]+", " ", str(text or "").lower(), flags=re.UNICODE)
|
| 76 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _word_count(text):
|
| 80 |
+
return len(str(text or "").split())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _dedupe_repeated_words(words):
|
| 84 |
+
out = []
|
| 85 |
+
for word in words:
|
| 86 |
+
if not out or _normalize_text(out[-1]) != _normalize_text(word):
|
| 87 |
+
out.append(word)
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _trim_trailing_connector(words):
|
| 92 |
+
connectors = {
|
| 93 |
+
"và", "với", "của", "là", "thì", "mà", "rằng", "nên", "nhưng",
|
| 94 |
+
"hoặc", "vì", "do", "để", "trong", "bằng", "không", "chưa",
|
| 95 |
+
"này", "đó", "ấy", "trên", "dưới", "từ", "về", "qua", "ra",
|
| 96 |
+
"vào", "lên", "xuống", "còn", "đã", "sẽ", "đang", "cũng",
|
| 97 |
+
"rất", "thật", "khi", "một", "những", "các",
|
| 98 |
+
}
|
| 99 |
+
while words and words[-1].strip(" ,.!?;:").lower() in connectors:
|
| 100 |
+
words = words[:-1]
|
| 101 |
+
return words
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
_SENTENCE_END_RE = re.compile(r"[.!?…]+[\"'\u201d\u2019]*$")
|
| 105 |
+
|
| 106 |
+
# Từ đệm có thể bỏ mà không làm mất nghĩa chính ("cái này thật là hay" -> "cái này hay")
|
| 107 |
+
_FILLER_WORDS = {
|
| 108 |
+
"rất", "quá", "lắm", "thật", "khá", "hơi", "cực",
|
| 109 |
+
"cũng", "đã", "sẽ", "đang", "rồi",
|
| 110 |
+
"những", "các", "một", "mấy", "vài", "nhiều",
|
| 111 |
+
"rằng", "chứ", "nhỉ", "nhé", "đấy", "thôi",
|
| 112 |
+
}
|
| 113 |
+
# Cụm đệm 2 từ
|
| 114 |
+
_FILLER_PHRASES = [
|
| 115 |
+
"thật là", "thật sự", "thực sự", "thực ra", "thật ra",
|
| 116 |
+
"rất là", "vô cùng", "cực kỳ", "cực kì", "hết sức",
|
| 117 |
+
"khá là", "hơi bị", "quá trời", "lại càng", "càng ngày",
|
| 118 |
+
]
|
| 119 |
+
# Từ mở đầu mệnh đề / cụm: cắt TRƯỚC từ này để phần giữ lại vẫn trọn nghĩa
|
| 120 |
+
_CLAUSE_STARTERS = {
|
| 121 |
+
"và", "nhưng", "thì", "mà", "nên", "vì", "hoặc", "hay", "còn",
|
| 122 |
+
"rồi", "khi", "nếu", "để", "bởi", "do", "tuy", "dù", "sau",
|
| 123 |
+
"trước", "trong", "ngoài", "từ", "về", "qua", "vào", "tại",
|
| 124 |
+
"cho", "đến", "tới", "theo", "bằng", "của", "trên", "dưới",
|
| 125 |
+
"giữa", "với", "như",
|
| 126 |
+
}
|
| 127 |
+
# Cụm 2 từ: KHÔNG được cắt giữa ("trở nên", "cho nên", ...) kẻo vô nghĩa
|
| 128 |
+
_PROTECTED_COMPOUNDS = {
|
| 129 |
+
"trở nên", "trở thành", "biến thành", "hóa thành", "trở lại",
|
| 130 |
+
"cho nên", "bởi vì", "vì vậy", "vì thế", "do đó", "do vậy",
|
| 131 |
+
"mặc dù", "tuy nhiên", "tuy vậy", "dù sao", "từ khi", "từ lúc",
|
| 132 |
+
"kể từ", "đến khi", "vào lúc", "vào khoảng", "trong khi",
|
| 133 |
+
"như là", "cho đến", "để cho", "như thể",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _remove_fillers(words):
|
| 138 |
+
"""Bỏ từ đệm/trợ từ không mang nghĩa chính, giữ nguyên thông tin cốt lõi."""
|
| 139 |
+
if not words:
|
| 140 |
+
return words
|
| 141 |
+
out = []
|
| 142 |
+
i = 0
|
| 143 |
+
n = len(words)
|
| 144 |
+
while i < n:
|
| 145 |
+
joined = _normalize_text(words[i])
|
| 146 |
+
if i + 1 < n:
|
| 147 |
+
joined = f"{joined} {_normalize_text(words[i + 1])}"
|
| 148 |
+
if joined in _FILLER_PHRASES:
|
| 149 |
+
i += 2
|
| 150 |
+
continue
|
| 151 |
+
if _normalize_text(words[i]) in _FILLER_WORDS:
|
| 152 |
+
i += 1
|
| 153 |
+
continue
|
| 154 |
+
out.append(words[i])
|
| 155 |
+
i += 1
|
| 156 |
+
return out
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _split_display_text(text, max_words):
|
| 160 |
+
"""Chia văn bản thành các đoạn <= max_words tại ranh giới câu/cụm có nghĩa.
|
| 161 |
+
|
| 162 |
+
Đoạn sau giữ nguyên từ nối mở đầu ("trên", "và", "của"...) để các caption
|
| 163 |
+
liên tiếp đọc liền mạch như video gốc — không bao giờ vứt bỏ phần đuôi câu."""
|
| 164 |
+
words = str(text or "").split()
|
| 165 |
+
if not words:
|
| 166 |
+
return []
|
| 167 |
+
# Rút gọn từ đệm trước nếu cần: "cái này thật là hay" -> "cái này hay"
|
| 168 |
+
if len(words) > max_words:
|
| 169 |
+
compact = _remove_fillers(words)
|
| 170 |
+
if len(compact) <= max_words:
|
| 171 |
+
return [_capitalize_sentence_start(" ".join(compact).strip(" ,"))]
|
| 172 |
+
words = compact
|
| 173 |
+
chunks = []
|
| 174 |
+
start = 0
|
| 175 |
+
total = len(words)
|
| 176 |
+
while start < total:
|
| 177 |
+
window = words[start:]
|
| 178 |
+
if len(window) <= max_words:
|
| 179 |
+
chunks.append(_capitalize_sentence_start(" ".join(window).strip(" ,")))
|
| 180 |
+
break
|
| 181 |
+
end_indices = [
|
| 182 |
+
i + 1 for i, w in enumerate(window)
|
| 183 |
+
if _SENTENCE_END_RE.search(w) and i + 1 <= max_words
|
| 184 |
+
]
|
| 185 |
+
comma_indices = [
|
| 186 |
+
i + 1 for i, w in enumerate(window)
|
| 187 |
+
if re.search(r"[,;:]$", w) and i + 1 <= max_words
|
| 188 |
+
]
|
| 189 |
+
if end_indices:
|
| 190 |
+
cut = end_indices[-1]
|
| 191 |
+
else:
|
| 192 |
+
cut_before = [
|
| 193 |
+
i for i, w in enumerate(window[1:], start=1)
|
| 194 |
+
if _normalize_text(w) in _CLAUSE_STARTERS
|
| 195 |
+
and i <= max_words
|
| 196 |
+
and not (
|
| 197 |
+
_normalize_text(f"{window[i - 1]} {window[i]}") in _PROTECTED_COMPOUNDS
|
| 198 |
+
)
|
| 199 |
+
]
|
| 200 |
+
if cut_before:
|
| 201 |
+
cut = cut_before[-1]
|
| 202 |
+
elif comma_indices:
|
| 203 |
+
cut = comma_indices[-1]
|
| 204 |
+
else:
|
| 205 |
+
first_end = next(
|
| 206 |
+
(i + 1 for i, w in enumerate(window) if _SENTENCE_END_RE.search(w)),
|
| 207 |
+
None,
|
| 208 |
+
)
|
| 209 |
+
cut = first_end if first_end else total - start
|
| 210 |
+
head = window[:cut]
|
| 211 |
+
if head and not re.search(r"[,.;:!?…]$", head[-1]):
|
| 212 |
+
head = _trim_trailing_connector(head)
|
| 213 |
+
if head:
|
| 214 |
+
chunks.append(_capitalize_sentence_start(" ".join(head).strip(" ,")))
|
| 215 |
+
start += cut
|
| 216 |
+
else:
|
| 217 |
+
break
|
| 218 |
+
return chunks
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _capitalize_sentence_start(text):
|
| 222 |
+
text = str(text or "").strip()
|
| 223 |
+
if not text:
|
| 224 |
+
return text
|
| 225 |
+
return text[0].upper() + text[1:]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _clean_display_text(text, max_words):
|
| 229 |
+
"""Làm sạch văn bản (bỏ thẻ OCR, cụm thừa, lặp) KHÔNG cắt ngắn —
|
| 230 |
+
việc rút gọn/cắt do _split_display_text xử lý khi xuất block."""
|
| 231 |
+
text = str(text or "").strip()
|
| 232 |
+
text = re.sub(r"\[[^\]]*(?:OCR|CẦN|CAN|REVIEW|LỖI|LOI)[^\]]*\]", " ", text, flags=re.IGNORECASE)
|
| 233 |
+
replacements = [
|
| 234 |
+
(r"\b(tên của anh ấy là|tên anh ấy là)\b", "anh ấy tên"),
|
| 235 |
+
(r"\b(tên của cô ấy là|tên cô ấy là)\b", "cô ấy tên"),
|
| 236 |
+
(r"\b(tiếp theo chúng ta sẽ xem|tiếp theo ta xem)\b", "giờ xem"),
|
| 237 |
+
(r"\b(có thể thấy rằng|chúng ta có thể thấy)\b", "thấy rõ"),
|
| 238 |
+
(r"\b(thực sự là|thật sự là|về cơ bản)\b", " "),
|
| 239 |
+
]
|
| 240 |
+
for pattern, repl in replacements:
|
| 241 |
+
text = re.sub(pattern, repl, text, flags=re.IGNORECASE)
|
| 242 |
+
text = re.sub(r"\s+", " ", text).strip(" ,.!?;:")
|
| 243 |
+
if not text:
|
| 244 |
+
return ""
|
| 245 |
+
|
| 246 |
+
words = _dedupe_repeated_words(text.split())
|
| 247 |
+
return " ".join(words).strip()
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _join_caption_text(left, right, max_words):
|
| 251 |
+
left = str(left or "").strip()
|
| 252 |
+
right = str(right or "").strip()
|
| 253 |
+
if not left:
|
| 254 |
+
return _clean_display_text(right, max_words)
|
| 255 |
+
if not right:
|
| 256 |
+
return _clean_display_text(left, max_words)
|
| 257 |
+
if _normalize_text(left) == _normalize_text(right):
|
| 258 |
+
return _clean_display_text(left, max_words)
|
| 259 |
+
|
| 260 |
+
left_parts = re.split(r"(?<=[.!?])\s+", left)
|
| 261 |
+
right_parts = re.split(r"(?<=[.!?])\s+", right)
|
| 262 |
+
candidates = [p.strip(" ,") for p in left_parts + right_parts if p.strip(" ,")]
|
| 263 |
+
deduped = []
|
| 264 |
+
seen = set()
|
| 265 |
+
for item in candidates:
|
| 266 |
+
key = _normalize_text(item)
|
| 267 |
+
if key and key not in seen:
|
| 268 |
+
deduped.append(item)
|
| 269 |
+
seen.add(key)
|
| 270 |
+
return _clean_display_text(" ".join(deduped), max_words)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _load_json_list(path):
|
| 274 |
+
path = Path(path) if path else None
|
| 275 |
+
if not path or not path.exists():
|
| 276 |
+
return []
|
| 277 |
+
try:
|
| 278 |
+
data = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
|
| 279 |
+
return data if isinstance(data, list) else []
|
| 280 |
+
except Exception:
|
| 281 |
+
return []
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _should_keep_block(block, action_by_id, original_by_id, fallback_keep_all, source_lang="zh"):
|
| 285 |
+
action = action_by_id.get(str(block.get("id")))
|
| 286 |
+
if action:
|
| 287 |
+
return (
|
| 288 |
+
action.get("detected_language") == source_lang
|
| 289 |
+
and bool(action.get("translate_to_vi", True))
|
| 290 |
+
and not bool(action.get("needs_review", False))
|
| 291 |
+
)
|
| 292 |
+
original = original_by_id.get(str(block.get("id")))
|
| 293 |
+
if original:
|
| 294 |
+
if source_lang == "zh":
|
| 295 |
+
return _contains_chinese(original.get("text", ""))
|
| 296 |
+
else:
|
| 297 |
+
# English source: keep if original has mostly Latin chars
|
| 298 |
+
orig_text = original.get("text", "")
|
| 299 |
+
latin = len(re.findall(r'[a-zA-Z]', orig_text))
|
| 300 |
+
total = max(1, len(re.sub(r'\s+', '', orig_text)))
|
| 301 |
+
return (latin / total) >= 0.50
|
| 302 |
+
return fallback_keep_all
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def build_display_subtitles(translated_srt, output_srt, actions_path=None, original_srt=None, config=None, report_path=None):
|
| 306 |
+
"""Build a compact, viewer-facing Vietnamese subtitle track.
|
| 307 |
+
|
| 308 |
+
translated.srt remains the full internal mapping for TTS/mixing. This file is
|
| 309 |
+
only for on-video hard subtitles, so it can merge repeated OCR fragments and
|
| 310 |
+
drop non-Chinese segments safely.
|
| 311 |
+
"""
|
| 312 |
+
config = config or {}
|
| 313 |
+
render_cfg = config.get("render", {}) if isinstance(config, dict) else {}
|
| 314 |
+
caption_cfg = render_cfg.get("caption", {}) if isinstance(render_cfg, dict) else {}
|
| 315 |
+
subtitle_cfg = config.get("subtitles", {}) if isinstance(config, dict) else {}
|
| 316 |
+
source_lang = config.get("source_language", "zh") if isinstance(config, dict) else "zh"
|
| 317 |
+
|
| 318 |
+
max_words = int(subtitle_cfg.get("main_content_max_words", caption_cfg.get("max_words_per_caption", 14)))
|
| 319 |
+
max_chars = int(subtitle_cfg.get("merge_short_segments_max_chars", 92))
|
| 320 |
+
max_gap_ms = int(subtitle_cfg.get("merge_short_segments_max_gap_ms", 320))
|
| 321 |
+
short_gap_ms = int(subtitle_cfg.get("merge_short_segments_short_gap_ms", 900))
|
| 322 |
+
target_min_ms = int(subtitle_cfg.get("merge_short_segments_target_min_duration_ms", 1600))
|
| 323 |
+
max_duration_ms = int(subtitle_cfg.get("merge_short_segments_max_duration_ms", 5600))
|
| 324 |
+
max_blocks = int(subtitle_cfg.get("merge_short_segments_max_blocks", 6))
|
| 325 |
+
merge_enabled = bool(subtitle_cfg.get("merge_short_segments_enabled", False))
|
| 326 |
+
|
| 327 |
+
translated_blocks = _parse_srt(translated_srt)
|
| 328 |
+
original_blocks = _parse_srt(original_srt) if original_srt else []
|
| 329 |
+
original_by_id = {str(b["id"]): b for b in original_blocks}
|
| 330 |
+
actions = _load_json_list(actions_path)
|
| 331 |
+
action_by_id = {str(x.get("id")): x for x in actions if isinstance(x, dict)}
|
| 332 |
+
fallback_keep_all = not action_by_id and not original_by_id
|
| 333 |
+
|
| 334 |
+
dropped = []
|
| 335 |
+
prepared = []
|
| 336 |
+
for block in translated_blocks:
|
| 337 |
+
keep = _should_keep_block(block, action_by_id, original_by_id, fallback_keep_all, source_lang)
|
| 338 |
+
raw_text = block.get("text", "")
|
| 339 |
+
original = original_by_id.get(str(block.get("id")))
|
| 340 |
+
if original:
|
| 341 |
+
try:
|
| 342 |
+
from app.core.translation_post_editor import post_edit_translation
|
| 343 |
+
raw_text = post_edit_translation(original.get("text", ""), raw_text)
|
| 344 |
+
except Exception:
|
| 345 |
+
pass
|
| 346 |
+
text = _clean_display_text(raw_text, max_words)
|
| 347 |
+
if not keep:
|
| 348 |
+
dropped.append({"id": block.get("id"), "reason": "non_chinese_or_review"})
|
| 349 |
+
continue
|
| 350 |
+
if not text or _contains_chinese(text):
|
| 351 |
+
dropped.append({"id": block.get("id"), "reason": "empty_or_untranslated"})
|
| 352 |
+
continue
|
| 353 |
+
prepared.append(
|
| 354 |
+
{
|
| 355 |
+
"id": str(block["id"]),
|
| 356 |
+
"start_ms": int(block["start_ms"]),
|
| 357 |
+
"end_ms": int(block["end_ms"]),
|
| 358 |
+
"text": text,
|
| 359 |
+
"source_ids": [str(block["id"])],
|
| 360 |
+
}
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
output_blocks = []
|
| 364 |
+
for block in prepared:
|
| 365 |
+
if not merge_enabled or not output_blocks:
|
| 366 |
+
output_blocks.append(block.copy())
|
| 367 |
+
continue
|
| 368 |
+
current = output_blocks[-1]
|
| 369 |
+
gap_ms = block["start_ms"] - current["end_ms"]
|
| 370 |
+
if _normalize_text(current["text"]) == _normalize_text(block["text"]) and gap_ms <= short_gap_ms:
|
| 371 |
+
current["end_ms"] = block["end_ms"]
|
| 372 |
+
current["source_ids"].extend(block["source_ids"])
|
| 373 |
+
continue
|
| 374 |
+
combined_duration = block["end_ms"] - current["start_ms"]
|
| 375 |
+
combined_raw = _join_caption_text(current["text"], block["text"], max_words * 10)
|
| 376 |
+
combined_text = _clean_display_text(combined_raw, max_words)
|
| 377 |
+
should_merge = (
|
| 378 |
+
gap_ms <= max_gap_ms
|
| 379 |
+
or (len(current["text"]) < 24 and gap_ms <= short_gap_ms)
|
| 380 |
+
or ((current["end_ms"] - current["start_ms"]) < target_min_ms and gap_ms <= short_gap_ms)
|
| 381 |
+
)
|
| 382 |
+
if (
|
| 383 |
+
should_merge
|
| 384 |
+
and combined_duration <= max_duration_ms
|
| 385 |
+
and len(combined_raw) <= max_chars
|
| 386 |
+
and _word_count(combined_raw) <= max_words
|
| 387 |
+
and combined_text == combined_raw
|
| 388 |
+
and len(current["source_ids"]) < max_blocks
|
| 389 |
+
):
|
| 390 |
+
current["end_ms"] = block["end_ms"]
|
| 391 |
+
current["text"] = combined_text
|
| 392 |
+
current["source_ids"].extend(block["source_ids"])
|
| 393 |
+
else:
|
| 394 |
+
output_blocks.append(block.copy())
|
| 395 |
+
|
| 396 |
+
# Chia block quá dài thành nhiều caption con cùng khung thời gian,
|
| 397 |
+
# phần sau giữ từ nối mở đầu để mạch nghĩa liên tục như video gốc
|
| 398 |
+
final_blocks = []
|
| 399 |
+
for block in output_blocks:
|
| 400 |
+
chunks = _split_display_text(block["text"], max_words)
|
| 401 |
+
if len(chunks) <= 1:
|
| 402 |
+
if chunks:
|
| 403 |
+
block = {**block, "text": chunks[0]}
|
| 404 |
+
final_blocks.append(block)
|
| 405 |
+
continue
|
| 406 |
+
duration = block["end_ms"] - block["start_ms"]
|
| 407 |
+
counts = [len(c.split()) for c in chunks]
|
| 408 |
+
total = sum(counts)
|
| 409 |
+
cursor = block["start_ms"]
|
| 410 |
+
for i, chunk in enumerate(chunks):
|
| 411 |
+
span = int(duration * counts[i] / total) if total > 0 else 0
|
| 412 |
+
sub_start = cursor
|
| 413 |
+
sub_end = cursor + span if i < len(chunks) - 1 else block["end_ms"]
|
| 414 |
+
final_blocks.append(
|
| 415 |
+
{
|
| 416 |
+
"id": block["id"],
|
| 417 |
+
"start_ms": sub_start,
|
| 418 |
+
"end_ms": sub_end,
|
| 419 |
+
"text": chunk,
|
| 420 |
+
"source_ids": list(block["source_ids"]),
|
| 421 |
+
}
|
| 422 |
+
)
|
| 423 |
+
cursor = sub_end
|
| 424 |
+
output_blocks = final_blocks
|
| 425 |
+
|
| 426 |
+
output_srt = Path(output_srt)
|
| 427 |
+
output_srt.parent.mkdir(parents=True, exist_ok=True)
|
| 428 |
+
output_srt.write_text(_format_srt(output_blocks), encoding="utf-8")
|
| 429 |
+
|
| 430 |
+
report = {
|
| 431 |
+
"status": "OK" if output_blocks else "EMPTY",
|
| 432 |
+
"input_blocks": len(translated_blocks),
|
| 433 |
+
"candidate_blocks": len(prepared),
|
| 434 |
+
"output_blocks": len(output_blocks),
|
| 435 |
+
"dropped_blocks": dropped,
|
| 436 |
+
"mode": "main_content_compact",
|
| 437 |
+
"settings": {
|
| 438 |
+
"max_words": max_words,
|
| 439 |
+
"max_chars": max_chars,
|
| 440 |
+
"max_gap_ms": max_gap_ms,
|
| 441 |
+
"short_gap_ms": short_gap_ms,
|
| 442 |
+
"target_min_ms": target_min_ms,
|
| 443 |
+
"max_duration_ms": max_duration_ms,
|
| 444 |
+
"max_blocks": max_blocks,
|
| 445 |
+
"merge_enabled": merge_enabled,
|
| 446 |
+
},
|
| 447 |
+
"groups": [
|
| 448 |
+
{
|
| 449 |
+
"id": i + 1,
|
| 450 |
+
"source_ids": block["source_ids"],
|
| 451 |
+
"start_ms": block["start_ms"],
|
| 452 |
+
"end_ms": block["end_ms"],
|
| 453 |
+
"text": block["text"],
|
| 454 |
+
}
|
| 455 |
+
for i, block in enumerate(output_blocks)
|
| 456 |
+
],
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
report_path = Path(report_path) if report_path else output_srt.with_name("display_subtitles_report.json")
|
| 460 |
+
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 461 |
+
return len(output_blocks), report
|
app/core/translation_core.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Enforce UTF-8 for Windows console
|
| 9 |
+
if sys.platform == 'win32':
|
| 10 |
+
try:
|
| 11 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 12 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 13 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 14 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
# Ensure app is in path
|
| 19 |
+
sys.path.append(str(Path(__file__).parent.parent.parent))
|
| 20 |
+
|
| 21 |
+
from app.translation.manager import TranslationManager
|
| 22 |
+
|
| 23 |
+
class TranslationCore:
|
| 24 |
+
def __init__(self, glossary_path=None):
|
| 25 |
+
self.glossary = {}
|
| 26 |
+
self.style = "Tự nhiên, thuyết minh"
|
| 27 |
+
if glossary_path:
|
| 28 |
+
self.load_glossary(glossary_path)
|
| 29 |
+
else:
|
| 30 |
+
default_path = Path(__file__).parent.parent.parent / "glossary.json"
|
| 31 |
+
self.load_glossary(default_path)
|
| 32 |
+
|
| 33 |
+
def load_glossary(self, path):
|
| 34 |
+
path = Path(path)
|
| 35 |
+
if path.exists():
|
| 36 |
+
try:
|
| 37 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 38 |
+
data = json.load(f)
|
| 39 |
+
self.glossary = data.get("glossary", {})
|
| 40 |
+
self.style = data.get("style", "Tự nhiên, thuyết minh")
|
| 41 |
+
except Exception:
|
| 42 |
+
self.glossary = {}
|
| 43 |
+
else:
|
| 44 |
+
self.glossary = {
|
| 45 |
+
"RTX": "Vạc đồ họa RTX",
|
| 46 |
+
"VRAM": "Bộ nhớ đồ họa",
|
| 47 |
+
"CPU": "Bộ vi xử lý"
|
| 48 |
+
}
|
| 49 |
+
self.style = "Hài hước, tự nhiên, văn phong nói, phù hợp video ngắn TikTok/Reels"
|
| 50 |
+
|
| 51 |
+
def parse_srt(self, content: str):
|
| 52 |
+
import re
|
| 53 |
+
content = content.strip().replace('\r\n', '\n')
|
| 54 |
+
pattern = r'(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)'
|
| 55 |
+
matches = re.finditer(pattern, content, re.DOTALL)
|
| 56 |
+
parsed_blocks = []
|
| 57 |
+
for match in matches:
|
| 58 |
+
try:
|
| 59 |
+
b_id = match.group(1).strip()
|
| 60 |
+
if b_id.isdigit():
|
| 61 |
+
b_id_int = int(b_id)
|
| 62 |
+
else:
|
| 63 |
+
continue
|
| 64 |
+
timestamp = match.group(2).strip()
|
| 65 |
+
start_ts, end_ts = [p.strip() for p in re.split(r'\s*-->\s*', timestamp, maxsplit=1)]
|
| 66 |
+
start_ms = self._parse_srt_time_ms(start_ts)
|
| 67 |
+
end_ms = self._parse_srt_time_ms(end_ts)
|
| 68 |
+
duration_sec = max(0.0, (end_ms - start_ms) / 1000.0) if start_ms is not None and end_ms is not None else 0.0
|
| 69 |
+
text = match.group(3).strip()
|
| 70 |
+
text = " ".join([l.strip() for l in text.split('\n') if l.strip()])
|
| 71 |
+
parsed_blocks.append({
|
| 72 |
+
"id": str(b_id_int),
|
| 73 |
+
"timestamp": timestamp,
|
| 74 |
+
"start": start_ts,
|
| 75 |
+
"end": end_ts,
|
| 76 |
+
"start_ms": start_ms,
|
| 77 |
+
"end_ms": end_ms,
|
| 78 |
+
"duration_sec": round(duration_sec, 3),
|
| 79 |
+
"text": text
|
| 80 |
+
})
|
| 81 |
+
except (ValueError, IndexError, AttributeError):
|
| 82 |
+
continue
|
| 83 |
+
return parsed_blocks
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def _parse_srt_time_ms(value: str):
|
| 87 |
+
import re
|
| 88 |
+
m = re.match(r"^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$", str(value).strip())
|
| 89 |
+
if not m:
|
| 90 |
+
return None
|
| 91 |
+
hh, mm, ss, ms = [int(x) for x in m.groups()]
|
| 92 |
+
return ((hh * 3600 + mm * 60 + ss) * 1000) + ms
|
| 93 |
+
|
| 94 |
+
def format_srt(self, parsed_blocks):
|
| 95 |
+
srt_lines = []
|
| 96 |
+
for block in parsed_blocks:
|
| 97 |
+
srt_lines.append(f"{block['id']}")
|
| 98 |
+
srt_lines.append(f"{block['timestamp']}")
|
| 99 |
+
srt_lines.append(f"{block['text']}")
|
| 100 |
+
srt_lines.append("") # empty line separator
|
| 101 |
+
return "\n".join(srt_lines).strip() + "\n"
|
| 102 |
+
|
| 103 |
+
def translate_srt_file(self, input_path, output_path, engine="Google (Free)", model="auto", api_url="", api_key="", high_quality=False, log_fn=None):
|
| 104 |
+
input_path = Path(input_path)
|
| 105 |
+
output_path = Path(output_path)
|
| 106 |
+
|
| 107 |
+
if not input_path.exists():
|
| 108 |
+
raise FileNotFoundError(f"Không tìm thấy file phụ đề đầu vào: {input_path}")
|
| 109 |
+
|
| 110 |
+
with open(input_path, "r", encoding="utf-8") as f:
|
| 111 |
+
content = f.read()
|
| 112 |
+
|
| 113 |
+
blocks = self.parse_srt(content)
|
| 114 |
+
if not blocks:
|
| 115 |
+
raise Exception("File phụ đề trống hoặc không đúng định dạng SRT.")
|
| 116 |
+
|
| 117 |
+
if log_fn:
|
| 118 |
+
log_fn(f"📖 Đọc thành công {len(blocks)} dòng phụ đề. Engine: {engine}")
|
| 119 |
+
|
| 120 |
+
# Setup configuration mapping for TranslationManager
|
| 121 |
+
config_dict = {
|
| 122 |
+
"router_url": api_url,
|
| 123 |
+
"router_key": api_key,
|
| 124 |
+
"router_model": model if model and model != "auto" else "meta-llama/llama-3.3-70b-instruct:free",
|
| 125 |
+
"super_ai_gate_url": api_url,
|
| 126 |
+
"super_ai_gate_key": api_key,
|
| 127 |
+
"super_ai_gate_model": model if model and model != "auto" else "meta-llama/llama-3.3-70b-instruct:free",
|
| 128 |
+
"ollama_model": model if model and model != "auto" else "hf.co/lmstudio-community/Qwen3.5-9B-GGUF:Q6_K"
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
disable_ollama = True
|
| 132 |
+
try:
|
| 133 |
+
cfg_path = Path(__file__).parent.parent.parent / "config.json"
|
| 134 |
+
if cfg_path.exists():
|
| 135 |
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
| 136 |
+
cfg_data = json.load(f)
|
| 137 |
+
disable_ollama = cfg_data.get("translation", {}).get("disable_ollama", True)
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
is_ultimate = "Tối thượng" in engine or "Ultimate" in engine
|
| 142 |
+
if is_ultimate:
|
| 143 |
+
engines_list = ["API Pool", "9Router", "Super AI"]
|
| 144 |
+
if not disable_ollama:
|
| 145 |
+
engines_list.append("Ollama")
|
| 146 |
+
else:
|
| 147 |
+
engines_list = [engine]
|
| 148 |
+
|
| 149 |
+
# Instantiate modular translation manager
|
| 150 |
+
manager = TranslationManager(
|
| 151 |
+
engines_list=engines_list,
|
| 152 |
+
config_dict=config_dict,
|
| 153 |
+
glossary=self.glossary,
|
| 154 |
+
style=self.style
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Execute translation
|
| 158 |
+
translated_dict = manager.translate_blocks(blocks, log_fn=log_fn)
|
| 159 |
+
try:
|
| 160 |
+
from app.core.translation_post_editor import post_edit_translation
|
| 161 |
+
for b in blocks:
|
| 162 |
+
b_id = str(b["id"])
|
| 163 |
+
translated_dict[b_id] = post_edit_translation(b.get("text", ""), translated_dict.get(b_id, b.get("text", "")))
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
# Save validation report if available
|
| 168 |
+
report_path = output_path.parent / "translation_validation_report.json"
|
| 169 |
+
manager.save_validation_report(report_path)
|
| 170 |
+
|
| 171 |
+
# Build translated blocks
|
| 172 |
+
translated_blocks = []
|
| 173 |
+
for b in blocks:
|
| 174 |
+
b_id = str(b["id"])
|
| 175 |
+
translated_blocks.append({
|
| 176 |
+
"id": b["id"],
|
| 177 |
+
"timestamp": b["timestamp"],
|
| 178 |
+
"text": translated_dict.get(b_id, b["text"])
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
# Save SRT file
|
| 182 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 183 |
+
f.write(self.format_srt(translated_blocks))
|
| 184 |
+
|
| 185 |
+
if log_fn:
|
| 186 |
+
log_fn("PROGRESS: 100%")
|
| 187 |
+
log_fn(f"✅ Hoàn tất dịch phụ đề! Lưu tại {output_path}")
|
| 188 |
+
|
| 189 |
+
return len(blocks)
|
| 190 |
+
|
| 191 |
+
def main():
|
| 192 |
+
parser = argparse.ArgumentParser(description="Standalone AI SRT Translation CLI")
|
| 193 |
+
parser.add_argument("--input", required=True, help="Path to input original SRT file")
|
| 194 |
+
parser.add_argument("--output", required=True, help="Path to output translated SRT file")
|
| 195 |
+
parser.add_argument("--engine", default="Google (Free)", help="Translation engine name")
|
| 196 |
+
parser.add_argument("--model", default="auto", help="Model name")
|
| 197 |
+
parser.add_argument("--api-url", default="", help="Base API URL")
|
| 198 |
+
parser.add_argument("--api-key", default="", help="API Key")
|
| 199 |
+
parser.add_argument("--high-quality", action="store_true", help="Run polishing pass")
|
| 200 |
+
parser.add_argument("--glossary", default=None, help="Path to glossary JSON")
|
| 201 |
+
|
| 202 |
+
args = parser.parse_args()
|
| 203 |
+
|
| 204 |
+
print(f"Starting translation CLI with engine={args.engine}, model={args.model}...", flush=True)
|
| 205 |
+
|
| 206 |
+
core = TranslationCore(glossary_path=args.glossary)
|
| 207 |
+
try:
|
| 208 |
+
core.translate_srt_file(
|
| 209 |
+
input_path=args.input,
|
| 210 |
+
output_path=args.output,
|
| 211 |
+
engine=args.engine,
|
| 212 |
+
model=args.model,
|
| 213 |
+
api_url=args.api_url,
|
| 214 |
+
api_key=args.api_key,
|
| 215 |
+
high_quality=args.high_quality,
|
| 216 |
+
log_fn=lambda msg: print(msg, flush=True)
|
| 217 |
+
)
|
| 218 |
+
print("Translation CLI finished successfully.", flush=True)
|
| 219 |
+
sys.exit(0)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
print(f"Error during translation execution: {e}", file=sys.stderr, flush=True)
|
| 222 |
+
sys.exit(1)
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
main()
|
app/core/translation_post_editor.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
SOURCE_PHRASE_OVERRIDES = [
|
| 5 |
+
("说到现在亚文化圈里热度最高的品牌", "Nói đến thương hiệu hot nhất giới underground hiện nay"),
|
| 6 |
+
("克罗心绝对绕不开", "Chrome Hearts chắc chắn phải nhắc tới"),
|
| 7 |
+
("很少用", "割韭菜", "Ít ai dùng từ chặt chém để nói về một thương hiệu"),
|
| 8 |
+
("毕竟大部分产品能卖到这个价格", "Vì phần lớn sản phẩm bán được mức giá đó"),
|
| 9 |
+
("多多少少都有它独到的价值支撑", "Ít nhiều cũng có giá trị riêng chống lưng"),
|
| 10 |
+
("但克罗心的溢价程度", "Nhưng độ đội giá của Chrome Hearts"),
|
| 11 |
+
("真的有点超出预期", "thật sự vượt xa tưởng tượng"),
|
| 12 |
+
("皮件动辄几万十几万", "Đồ da dễ lên vài chục đến hơn trăm nghìn tệ"),
|
| 13 |
+
("几条拼皮改造的Levis牛仔裤也要几万块", "Vài chiếc Levi's custom ghép da cũng vài chục nghìn tệ"),
|
| 14 |
+
("甚至就连银饰配饰也是一样", "Trang sức bạc cũng vậy"),
|
| 15 |
+
("说实话单看价格和外观", "Nói thật, chỉ nhìn giá và ngoại hình"),
|
| 16 |
+
("很难让人直接说出", "贵有贵的道理", "khó mà thấy nó đắt có lý do"),
|
| 17 |
+
("可当你真正去了解它的发售逻辑", "Nhưng khi hiểu cách nó phát hành"),
|
| 18 |
+
("工坊运作模式", "cách xưởng thủ công vận hành"),
|
| 19 |
+
("背后的溢价逻辑", "logic đội giá phía sau"),
|
| 20 |
+
("它能卖这么贵", "Nó bán đắt như vậy"),
|
| 21 |
+
("好像还真有它的原因", "cũng có lý do thật"),
|
| 22 |
+
("今天我们要聊的是", "Hôm nay ta nói về Chrome Hearts"),
|
| 23 |
+
("克罗心后来风靡全球的银饰风格内核", "Lõi phong cách trang sức bạc sau này làm Chrome Hearts nổi toàn cầu"),
|
| 24 |
+
("克罗心创立初期发展的十分顺利", "Giai đoạn đầu, Chrome Hearts phát triển khá thuận lợi"),
|
| 25 |
+
("而真正让克罗心破圈的", "Điều thật sự đưa Chrome Hearts ra khỏi giới niche"),
|
| 26 |
+
("克罗心也顺利打入好莱坞顶级名人圈层", "Chrome Hearts cũng thuận lợi bước vào giới sao Hollywood"),
|
| 27 |
+
("这段时间是克罗心团队最团结", "Đây là giai đoạn đội ngũ Chrome Hearts đoàn kết nhất"),
|
| 28 |
+
("克罗心银饰灵魂", "Leonard Kamhout", "Leonard Kamhout là người đặt nền móng phong cách trang sức bạc của Chrome Hearts"),
|
| 29 |
+
("最终将克罗心", "Cuối cùng đưa Chrome Hearts"),
|
| 30 |
+
("90 年代的克罗心", "Chrome Hearts thập niên 90"),
|
| 31 |
+
("在创立自己的品牌之前", "Trước khi tự lập thương hiệu riêng"),
|
| 32 |
+
("都曾在克罗心学习皮具制作工艺", "đều từng học làm đồ da tại Chrome Hearts"),
|
| 33 |
+
("克罗心全球门店数量", "Số lượng cửa hàng toàn cầu của Chrome Hearts"),
|
| 34 |
+
("但克罗心真正让人着迷的", "Nhưng điều thật sự làm Chrome Hearts cuốn hút"),
|
| 35 |
+
("穿上克罗心", "Mặc Chrome Hearts lên người"),
|
| 36 |
+
("克罗心慢慢步入沉寂期", "Chrome Hearts dần bước vào giai đoạn im ắng"),
|
| 37 |
+
("克罗心的风格显得格外沉闷", "Phong cách Chrome Hearts lúc đó khá trầm"),
|
| 38 |
+
("大街小巷随处可见克罗心经典印花", "Đường phố đâu đâu cũng thấy họa tiết kinh điển của Chrome Hearts"),
|
| 39 |
+
("成功让克罗心破圈年轻群体", "Giúp Chrome Hearts chạm tới nhóm khách trẻ"),
|
| 40 |
+
("克罗心从老派暗黑", "Chrome Hearts từ chất old-school dark"),
|
| 41 |
+
("或许克罗心的贵真的有它的道理", "Có lẽ Chrome Hearts đắt thật sự cũng có lý do"),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
SOURCE_GUIDED_REPLACEMENTS = [
|
| 46 |
+
("割韭菜", [(r"cắt\s+(củ\s+cải|rau\s+hẹ|hẹ)", "chặt chém người mua")]),
|
| 47 |
+
("克罗心", [(r"\b(Kruskin|Krosin|Kroskin|Clare\s+Croux|Clare\s+Crox|Chrome\s*Heart)\b", "Chrome Hearts")]),
|
| 48 |
+
("溢价", [(r"giá\s+chênh\s+lệch", "độ đội giá"), (r"mức\s+giá\s+chênh\s+lệch", "mức đội giá")]),
|
| 49 |
+
("机车老炮", [(r"người\s+(từng\s+là\s+)?(yêu|mê)\s+máy\s+móc", "tay chơi mô tô kỳ cựu")]),
|
| 50 |
+
("机车", [(r"\bmáy\s+móc\b", "mô tô"), (r"người\s+(yêu|mê)\s+mô\s+tô", "dân mê mô tô")]),
|
| 51 |
+
("拼皮改造", [(r"có\s+chỉ\s+thêu", "custom ghép da"), (r"chỉ\s+thêu", "ghép da custom")]),
|
| 52 |
+
("贵有贵的道理", [(r"lý\s+do\s+nó\s+đắt", "đắt có lý do của nó")]),
|
| 53 |
+
("工坊", [(r"chế\s+độ\s+hoạt\s+động\s+của\s+xưởng", "cách vận hành xưởng")]),
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
GENERAL_REPLACEMENTS = [
|
| 58 |
+
(r"Chrome\s*Hearts", "Chrome Hearts"),
|
| 59 |
+
(r"Richard\s*Stark", "Richard Stark"),
|
| 60 |
+
(r"John\s*Bowman", "John Bowman"),
|
| 61 |
+
(r"Leonard\s*Kamhout", "Leonard Kamhout"),
|
| 62 |
+
(r"\bLevis\b", "Levi's"),
|
| 63 |
+
(r"vòng\s+văn\s+hóa\s+phụ", "giới văn hóa underground"),
|
| 64 |
+
(r"chắc\s+chắn\s+không\s+thể\s+tránh\s+khỏi", "chắc chắn phải nhắc tới"),
|
| 65 |
+
(r"đều\s+đạt\s+giá\s+này", "bán được mức giá này"),
|
| 66 |
+
(r"giá\s+trị\s+riêng\s+biệt\s+tương\s+ứng", "giá trị riêng"),
|
| 67 |
+
(r"Có\s+chút\s+giá\s+trị\s+riêng", "Ít nhiều cũng có giá trị riêng"),
|
| 68 |
+
(r"Thật\s+sự\s+tháng\s+này\s+vượt\s+kỳ\s+vọng", "Thật sự vượt xa tưởng tượng"),
|
| 69 |
+
(r"đến\s+trên\s+hơn\s+trăm\s+nghìn", "đến hơn trăm nghìn"),
|
| 70 |
+
(r"mười\s+chục\s+nghìn", "hơn trăm nghìn"),
|
| 71 |
+
(r"trên\s+hơn\s+trăm\s+nghìn", "hơn trăm nghìn"),
|
| 72 |
+
(r"logic\s+chênh\s+lệch", "logic đội giá"),
|
| 73 |
+
(r"Nó\s+có\s+thể\s+đắt\s+thế\s+Dường\s+như\s+có\s+lý\s+do\s+đó", "Nó đắt như vậy cũng có lý do"),
|
| 74 |
+
(r"Harley\s+da\s+rock\s+nổi\s+loạn", "Harley, đồ da, rock nổi loạn"),
|
| 75 |
+
(r"thảo\s+luận\s+về", "nói về"),
|
| 76 |
+
(r"cũng\s+cũng", "cũng"),
|
| 77 |
+
(r"\s+", " "),
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def post_edit_translation(source_text, translated_text):
|
| 82 |
+
text = str(translated_text or "").strip()
|
| 83 |
+
source = str(source_text or "")
|
| 84 |
+
if not text:
|
| 85 |
+
return text
|
| 86 |
+
|
| 87 |
+
for item in SOURCE_PHRASE_OVERRIDES:
|
| 88 |
+
*needles, replacement = item
|
| 89 |
+
if all(needle in source for needle in needles):
|
| 90 |
+
return replacement
|
| 91 |
+
|
| 92 |
+
for pattern, replacement in GENERAL_REPLACEMENTS:
|
| 93 |
+
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
| 94 |
+
|
| 95 |
+
for source_term, replacements in SOURCE_GUIDED_REPLACEMENTS:
|
| 96 |
+
if source_term not in source:
|
| 97 |
+
continue
|
| 98 |
+
for pattern, replacement in replacements:
|
| 99 |
+
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
| 100 |
+
|
| 101 |
+
return re.sub(r"\s+", " ", text).strip(" ,")
|
app/core/tts_mix_worker_cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import json
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Enforce UTF-8 for Windows console
|
| 9 |
+
if sys.platform == 'win32':
|
| 10 |
+
try:
|
| 11 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 12 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 13 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 14 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
# Ensure app is in path
|
| 19 |
+
sys.path.append(str(Path(__file__).parent.parent.parent))
|
| 20 |
+
|
| 21 |
+
from app.core.audio_mixer import AudioMixer
|
| 22 |
+
|
| 23 |
+
def parse_time(time_str):
|
| 24 |
+
try:
|
| 25 |
+
time_str = time_str.strip().replace('.', ',')
|
| 26 |
+
parts = time_str.split(':')
|
| 27 |
+
if len(parts) != 3:
|
| 28 |
+
raise ValueError(f"Invalid timestamp format: {time_str}")
|
| 29 |
+
h, m, s_ms = parts
|
| 30 |
+
if ',' not in s_ms:
|
| 31 |
+
raise ValueError(f"Invalid timestamp format: {time_str}")
|
| 32 |
+
s, ms = s_ms.split(',')
|
| 33 |
+
return int(h.strip())*3600000 + int(m.strip())*60000 + int(s.strip())*1000 + int(ms.strip())
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Warning: Failed to parse time string '{time_str}': {e}", file=sys.stderr)
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
def parse_srt(srt_path):
|
| 39 |
+
blocks = []
|
| 40 |
+
if not srt_path or not Path(srt_path).exists():
|
| 41 |
+
return blocks
|
| 42 |
+
try:
|
| 43 |
+
with open(srt_path, 'r', encoding='utf-8') as f:
|
| 44 |
+
content = f.read().strip()
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Warning: Failed to read SRT file {srt_path}: {e}", file=sys.stderr)
|
| 47 |
+
return blocks
|
| 48 |
+
|
| 49 |
+
content = content.replace('\r\n', '\n')
|
| 50 |
+
pattern = r'(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)'
|
| 51 |
+
matches = re.finditer(pattern, content, re.DOTALL)
|
| 52 |
+
|
| 53 |
+
for match in matches:
|
| 54 |
+
try:
|
| 55 |
+
b_id = match.group(1).strip()
|
| 56 |
+
if b_id.isdigit():
|
| 57 |
+
b_id_int = int(b_id)
|
| 58 |
+
else:
|
| 59 |
+
continue
|
| 60 |
+
timestamp = match.group(2).strip()
|
| 61 |
+
times = timestamp.split(' --> ')
|
| 62 |
+
if len(times) == 2:
|
| 63 |
+
start_ms = parse_time(times[0])
|
| 64 |
+
end_ms = parse_time(times[1])
|
| 65 |
+
if start_ms is None or end_ms is None:
|
| 66 |
+
continue
|
| 67 |
+
text = match.group(3).strip()
|
| 68 |
+
text = " ".join([l.strip() for l in text.split('\n') if l.strip()])
|
| 69 |
+
blocks.append({
|
| 70 |
+
"id": str(b_id_int),
|
| 71 |
+
"start_ms": start_ms,
|
| 72 |
+
"end_ms": end_ms,
|
| 73 |
+
"text": text
|
| 74 |
+
})
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"Warning: Skipping malformed block: {e}", file=sys.stderr)
|
| 77 |
+
continue
|
| 78 |
+
return blocks
|
| 79 |
+
|
| 80 |
+
def main():
|
| 81 |
+
parser = argparse.ArgumentParser(description="Standalone Audio Stems Mixing Worker CLI")
|
| 82 |
+
parser.add_argument("--srt", required=True, help="Path to input translated SRT file")
|
| 83 |
+
parser.add_argument("--original-srt", default="", help="Path to original (ASR/OCR) SRT file")
|
| 84 |
+
parser.add_argument("--original-audio", default="", help="Path to original audio WAV file")
|
| 85 |
+
parser.add_argument("--background-audio", default="", help="Path to background separated WAV file")
|
| 86 |
+
parser.add_argument("--vocals-audio", default="", help="Path to vocals separated WAV file")
|
| 87 |
+
parser.add_argument("--output-wav", required=True, help="Path to output final mixed WAV")
|
| 88 |
+
parser.add_argument("--segments-dir", required=True, help="Directory containing synthesized segment WAVs")
|
| 89 |
+
parser.add_argument("--ffmpeg-path", default="ffmpeg", help="Path to ffmpeg executable")
|
| 90 |
+
parser.add_argument("--preserve-regions", default="", help="Path to preserve_regions.json")
|
| 91 |
+
parser.add_argument("--skipped-blocks", default="", help="Path to tts_skipped_blocks.json")
|
| 92 |
+
parser.add_argument("--segment-actions", default="", help="Path to segment_actions.json")
|
| 93 |
+
parser.add_argument("--chinese-vocal-handling", default="lower", choices=["keep", "lower", "mute", "remove_if_possible"], help="How to handle original Chinese vocals under VI voiceover")
|
| 94 |
+
parser.add_argument("--chinese-vocal-volume", default="35", help="Chinese vocal volume percent when lowering")
|
| 95 |
+
parser.add_argument("--vi-voiceover-enabled", default="true", help="Enable Vietnamese voiceover overlay (true/false)")
|
| 96 |
+
parser.add_argument("--vi-voiceover-volume", default="100", help="Vietnamese voiceover volume percent")
|
| 97 |
+
parser.add_argument("--allow-degraded-fallback", default="true", help="Allow degraded original audio fallback (true/false)")
|
| 98 |
+
args = parser.parse_args()
|
| 99 |
+
|
| 100 |
+
srt_path = Path(args.srt)
|
| 101 |
+
original_srt_path = Path(args.original_srt) if args.original_srt else None
|
| 102 |
+
original_audio_path = Path(args.original_audio) if args.original_audio else None
|
| 103 |
+
background_audio_path = Path(args.background_audio) if args.background_audio else None
|
| 104 |
+
vocals_audio_path = Path(args.vocals_audio) if args.vocals_audio else None
|
| 105 |
+
output_wav = Path(args.output_wav)
|
| 106 |
+
segments_dir = Path(args.segments_dir)
|
| 107 |
+
ffmpeg_path = Path(args.ffmpeg_path)
|
| 108 |
+
allow_degraded_fallback = args.allow_degraded_fallback.lower() in ("true", "1", "yes", "t")
|
| 109 |
+
vi_voiceover_enabled = args.vi_voiceover_enabled.lower() in ("true", "1", "yes", "t")
|
| 110 |
+
|
| 111 |
+
from pydub import AudioSegment
|
| 112 |
+
AudioSegment.converter = str(ffmpeg_path)
|
| 113 |
+
|
| 114 |
+
if not srt_path.exists():
|
| 115 |
+
print(f"Error: SRT file not found at {srt_path}", file=sys.stderr)
|
| 116 |
+
sys.exit(1)
|
| 117 |
+
|
| 118 |
+
print("PROGRESS: 10%", flush=True)
|
| 119 |
+
|
| 120 |
+
# Load blocks
|
| 121 |
+
blocks = parse_srt(srt_path)
|
| 122 |
+
if not blocks:
|
| 123 |
+
print("Error: No valid SRT blocks found in translated SRT.", file=sys.stderr)
|
| 124 |
+
sys.exit(1)
|
| 125 |
+
|
| 126 |
+
# Load preserve intervals and skipped ids
|
| 127 |
+
preserve_intervals = []
|
| 128 |
+
skipped_tts_ids = []
|
| 129 |
+
segment_actions = []
|
| 130 |
+
|
| 131 |
+
preserve_json = Path(args.preserve_regions) if args.preserve_regions else output_wav.parent / "preserve_regions.json"
|
| 132 |
+
skipped_json = Path(args.skipped_blocks) if args.skipped_blocks else output_wav.parent / "tts_skipped_blocks.json"
|
| 133 |
+
actions_json = Path(args.segment_actions) if args.segment_actions else output_wav.parent / "segment_actions.json"
|
| 134 |
+
|
| 135 |
+
if preserve_json.exists():
|
| 136 |
+
try:
|
| 137 |
+
with open(preserve_json, "r", encoding="utf-8") as f:
|
| 138 |
+
preserve_intervals = [tuple(x) for x in json.load(f)]
|
| 139 |
+
print(f"Loaded preserve intervals: {len(preserve_intervals)} entries.")
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"Warning: Failed to load preserve intervals: {e}", file=sys.stderr)
|
| 142 |
+
|
| 143 |
+
if skipped_json.exists():
|
| 144 |
+
try:
|
| 145 |
+
with open(skipped_json, "r", encoding="utf-8") as f:
|
| 146 |
+
skipped_tts_ids = json.load(f)
|
| 147 |
+
print(f"Loaded skipped block IDs: {len(skipped_tts_ids)} entries.")
|
| 148 |
+
except Exception as e:
|
| 149 |
+
print(f"Warning: Failed to load skipped block IDs: {e}", file=sys.stderr)
|
| 150 |
+
|
| 151 |
+
if actions_json.exists():
|
| 152 |
+
try:
|
| 153 |
+
with open(actions_json, "r", encoding="utf-8") as f:
|
| 154 |
+
segment_actions = json.load(f)
|
| 155 |
+
print(f"Loaded segment actions: {len(segment_actions)} entries.")
|
| 156 |
+
except Exception as e:
|
| 157 |
+
print(f"Warning: Failed to load segment actions: {e}", file=sys.stderr)
|
| 158 |
+
|
| 159 |
+
print("PROGRESS: 40%", flush=True)
|
| 160 |
+
|
| 161 |
+
# Map segment wav files
|
| 162 |
+
seg_files = {}
|
| 163 |
+
for block in blocks:
|
| 164 |
+
b_id = str(block["id"])
|
| 165 |
+
b_id_int = int(b_id) if b_id.isdigit() else 0
|
| 166 |
+
seg_wav = segments_dir / f"{b_id_int:04d}.wav"
|
| 167 |
+
if seg_wav.exists() and seg_wav.stat().st_size > 0:
|
| 168 |
+
seg_files[b_id] = seg_wav
|
| 169 |
+
|
| 170 |
+
# Map tts segments by start_ms
|
| 171 |
+
tts_segments = {}
|
| 172 |
+
for block in blocks:
|
| 173 |
+
b_id = str(block["id"])
|
| 174 |
+
if b_id in seg_files:
|
| 175 |
+
start_ms = str(block["start_ms"])
|
| 176 |
+
tts_segments[start_ms] = seg_files[b_id]
|
| 177 |
+
|
| 178 |
+
print("PROGRESS: 70%", flush=True)
|
| 179 |
+
print("Stitching and mixing audio stems via AudioMixer...")
|
| 180 |
+
|
| 181 |
+
# Load audio mixer
|
| 182 |
+
mixer = AudioMixer(ffmpeg_path=ffmpeg_path)
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
mixer.mix_dubbed_audio(
|
| 186 |
+
background_path=background_audio_path,
|
| 187 |
+
vocals_path=vocals_audio_path,
|
| 188 |
+
original_audio_path=original_audio_path,
|
| 189 |
+
tts_segments=tts_segments,
|
| 190 |
+
preserve_intervals=preserve_intervals,
|
| 191 |
+
output_path=output_wav,
|
| 192 |
+
allow_degraded_fallback=allow_degraded_fallback,
|
| 193 |
+
segment_actions=segment_actions,
|
| 194 |
+
chinese_vocal_handling=args.chinese_vocal_handling,
|
| 195 |
+
chinese_vocal_volume_percent=float(args.chinese_vocal_volume),
|
| 196 |
+
vi_voiceover_enabled=vi_voiceover_enabled,
|
| 197 |
+
vi_voiceover_volume_percent=float(args.vi_voiceover_volume)
|
| 198 |
+
)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
print(f"Error: {e}", file=sys.stderr)
|
| 201 |
+
sys.exit(5)
|
| 202 |
+
|
| 203 |
+
print("PROGRESS: 100%", flush=True)
|
| 204 |
+
print(f"Dubbing mixing completed successfully. Saved to {output_wav}")
|
| 205 |
+
sys.exit(0)
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
main()
|
app/core/tts_worker_cli.py
ADDED
|
@@ -0,0 +1,1510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import json
|
| 5 |
+
import argparse
|
| 6 |
+
import asyncio
|
| 7 |
+
import time
|
| 8 |
+
import urllib.request
|
| 9 |
+
import urllib.error
|
| 10 |
+
import subprocess
|
| 11 |
+
import threading
|
| 12 |
+
import queue
|
| 13 |
+
import uuid
|
| 14 |
+
import hashlib
|
| 15 |
+
import atexit
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Enforce UTF-8 for Windows console
|
| 19 |
+
if sys.platform == 'win32':
|
| 20 |
+
try:
|
| 21 |
+
if hasattr(sys.stdout, 'reconfigure'):
|
| 22 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 23 |
+
if hasattr(sys.stderr, 'reconfigure'):
|
| 24 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 25 |
+
except Exception:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
# Ensure app is in path
|
| 29 |
+
sys.path.append(str(Path(__file__).parent.parent.parent))
|
| 30 |
+
|
| 31 |
+
from app.core.vietnamese_text_normalizer import VietnameseTextNormalizer
|
| 32 |
+
from app.core.audio_timeline_classifier import AudioTimelineClassifier
|
| 33 |
+
from app.core.audio_mixer import AudioMixer
|
| 34 |
+
from app.core.gpu_resource_manager import GPUResourceManager
|
| 35 |
+
|
| 36 |
+
def parse_time(time_str):
|
| 37 |
+
try:
|
| 38 |
+
time_str = time_str.strip().replace('.', ',')
|
| 39 |
+
parts = time_str.split(':')
|
| 40 |
+
if len(parts) != 3:
|
| 41 |
+
raise ValueError(f"Invalid timestamp format: {time_str}")
|
| 42 |
+
h, m, s_ms = parts
|
| 43 |
+
if ',' not in s_ms:
|
| 44 |
+
raise ValueError(f"Invalid timestamp format: {time_str}")
|
| 45 |
+
s, ms = s_ms.split(',')
|
| 46 |
+
return int(h.strip())*3600000 + int(m.strip())*60000 + int(s.strip())*1000 + int(ms.strip())
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"Warning: Failed to parse time string '{time_str}': {e}", file=sys.stderr)
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
def parse_srt(srt_path):
|
| 52 |
+
blocks = []
|
| 53 |
+
if not srt_path or not Path(srt_path).exists():
|
| 54 |
+
return blocks
|
| 55 |
+
try:
|
| 56 |
+
with open(srt_path, 'r', encoding='utf-8') as f:
|
| 57 |
+
content = f.read().strip()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Warning: Failed to read SRT file {srt_path}: {e}", file=sys.stderr)
|
| 60 |
+
return blocks
|
| 61 |
+
|
| 62 |
+
content = content.replace('\r\n', '\n')
|
| 63 |
+
pattern = r'(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)'
|
| 64 |
+
matches = re.finditer(pattern, content, re.DOTALL)
|
| 65 |
+
|
| 66 |
+
for match in matches:
|
| 67 |
+
try:
|
| 68 |
+
b_id = match.group(1).strip()
|
| 69 |
+
if b_id.isdigit():
|
| 70 |
+
b_id_int = int(b_id)
|
| 71 |
+
else:
|
| 72 |
+
continue
|
| 73 |
+
timestamp = match.group(2).strip()
|
| 74 |
+
times = timestamp.split(' --> ')
|
| 75 |
+
if len(times) == 2:
|
| 76 |
+
start_ms = parse_time(times[0])
|
| 77 |
+
end_ms = parse_time(times[1])
|
| 78 |
+
if start_ms is None or end_ms is None:
|
| 79 |
+
continue
|
| 80 |
+
text = match.group(3).strip()
|
| 81 |
+
text = " ".join([l.strip() for l in text.split('\n') if l.strip()])
|
| 82 |
+
blocks.append({
|
| 83 |
+
"id": str(b_id_int),
|
| 84 |
+
"start_ms": start_ms,
|
| 85 |
+
"end_ms": end_ms,
|
| 86 |
+
"text": text
|
| 87 |
+
})
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f"Warning: Skipping malformed block: {e}", file=sys.stderr)
|
| 90 |
+
continue
|
| 91 |
+
return blocks
|
| 92 |
+
|
| 93 |
+
def verify_audio_file(filepath, min_size=500, min_duration=0.05, ffmpeg_path=None) -> bool:
|
| 94 |
+
path = Path(filepath)
|
| 95 |
+
if not path.exists():
|
| 96 |
+
return False
|
| 97 |
+
if path.stat().st_size < min_size:
|
| 98 |
+
return False
|
| 99 |
+
try:
|
| 100 |
+
from pydub import AudioSegment
|
| 101 |
+
if ffmpeg_path:
|
| 102 |
+
AudioSegment.converter = str(ffmpeg_path)
|
| 103 |
+
audio = AudioSegment.from_file(str(path))
|
| 104 |
+
if len(audio) / 1000.0 < min_duration:
|
| 105 |
+
return False
|
| 106 |
+
return True
|
| 107 |
+
except Exception:
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
def sanitize_for_edge_tts(text: str) -> str:
|
| 111 |
+
replacements = {
|
| 112 |
+
"\u2011": "-", # non-breaking hyphen
|
| 113 |
+
"\u2010": "-",
|
| 114 |
+
"\u2013": "-",
|
| 115 |
+
"\u2014": "-",
|
| 116 |
+
"\u200b": "",
|
| 117 |
+
"\ufeff": "",
|
| 118 |
+
}
|
| 119 |
+
for src, dst in replacements.items():
|
| 120 |
+
text = text.replace(src, dst)
|
| 121 |
+
# Remove emojis and invisible characters using unicode match
|
| 122 |
+
text = re.sub(r'[^\w\s,.:;?!@#$\-%&*()\'\"+=–—\/\\’“”]', '', text, flags=re.UNICODE)
|
| 123 |
+
text = " ".join(text.split())
|
| 124 |
+
return text.strip()
|
| 125 |
+
|
| 126 |
+
def split_text_in_half(text: str):
|
| 127 |
+
words = text.split()
|
| 128 |
+
if len(words) <= 1:
|
| 129 |
+
return [text]
|
| 130 |
+
mid = len(words) // 2
|
| 131 |
+
best_idx = -1
|
| 132 |
+
for offset in range(mid):
|
| 133 |
+
for idx in [mid + offset, mid - offset]:
|
| 134 |
+
if 0 <= idx < len(words) - 1:
|
| 135 |
+
if words[idx].endswith((',', '.', ';', ':', '?', '!')):
|
| 136 |
+
best_idx = idx
|
| 137 |
+
break
|
| 138 |
+
if best_idx != -1:
|
| 139 |
+
break
|
| 140 |
+
if best_idx == -1:
|
| 141 |
+
best_idx = mid
|
| 142 |
+
part1 = " ".join(words[:best_idx+1])
|
| 143 |
+
part2 = " ".join(words[best_idx+1:])
|
| 144 |
+
return [part1, part2]
|
| 145 |
+
|
| 146 |
+
def write_reports(parent_dir, failed_segments, manifest_entries):
|
| 147 |
+
parent_dir = Path(parent_dir)
|
| 148 |
+
try:
|
| 149 |
+
with open(parent_dir / "failed_tts_segments.json", "w", encoding="utf-8") as f:
|
| 150 |
+
json.dump(failed_segments, f, ensure_ascii=False, indent=2)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
print(f"Warning: Failed to save failed_tts_segments.json: {e}", file=sys.stderr)
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
with open(parent_dir / "tts_segments_manifest.json", "w", encoding="utf-8") as f:
|
| 156 |
+
json.dump(manifest_entries, f, ensure_ascii=False, indent=2)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
print(f"Warning: Failed to save tts_segments_manifest.json: {e}", file=sys.stderr)
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
synthesis_failures = [
|
| 162 |
+
row for row in failed_segments
|
| 163 |
+
if not str(row.get("reason", "")).startswith("tts_timing_overflow")
|
| 164 |
+
]
|
| 165 |
+
timing_overflows = [
|
| 166 |
+
row for row in failed_segments
|
| 167 |
+
if str(row.get("reason", "")).startswith("tts_timing_overflow")
|
| 168 |
+
]
|
| 169 |
+
report = {
|
| 170 |
+
"total_segments": len(manifest_entries),
|
| 171 |
+
"failed_segments_count": len(synthesis_failures),
|
| 172 |
+
"synthesis_failed_count": len(synthesis_failures),
|
| 173 |
+
"timing_overflow_count": len(timing_overflows),
|
| 174 |
+
"status": "FAILED" if synthesis_failures else ("NEED_REVIEW" if timing_overflows else "OK"),
|
| 175 |
+
"timestamp": time.time()
|
| 176 |
+
}
|
| 177 |
+
with open(parent_dir / "tts_report.json", "w", encoding="utf-8") as f:
|
| 178 |
+
json.dump(report, f, ensure_ascii=False, indent=2)
|
| 179 |
+
except Exception as e:
|
| 180 |
+
print(f"Warning: Failed to save tts_report.json: {e}", file=sys.stderr)
|
| 181 |
+
|
| 182 |
+
async def run_edge_tts(text, voice, output_path, speed="1.0", pitch="0", volume="100"):
|
| 183 |
+
import edge_tts
|
| 184 |
+
|
| 185 |
+
# Format edge-tts parameters
|
| 186 |
+
rate_str = "+0%"
|
| 187 |
+
try:
|
| 188 |
+
speed_val = float(speed)
|
| 189 |
+
pct = int((speed_val - 1.0) * 100)
|
| 190 |
+
rate_str = f"{'+' if pct >= 0 else ''}{pct}%"
|
| 191 |
+
except Exception:
|
| 192 |
+
rate_str = "+0%"
|
| 193 |
+
|
| 194 |
+
pitch_str = "+0Hz"
|
| 195 |
+
try:
|
| 196 |
+
pitch_val = int(pitch)
|
| 197 |
+
pitch_str = f"{'+' if pitch_val >= 0 else ''}{pitch_val}Hz"
|
| 198 |
+
except Exception:
|
| 199 |
+
if "%" in str(pitch):
|
| 200 |
+
pitch_str = str(pitch)
|
| 201 |
+
|
| 202 |
+
volume_str = "+0%"
|
| 203 |
+
try:
|
| 204 |
+
vol_val = int(volume)
|
| 205 |
+
pct = vol_val - 100
|
| 206 |
+
volume_str = f"{'+' if pct >= 0 else ''}{pct}%"
|
| 207 |
+
except Exception:
|
| 208 |
+
volume_str = "+0%"
|
| 209 |
+
|
| 210 |
+
communicate = edge_tts.Communicate(text, voice, rate=rate_str, pitch=pitch_str, volume=volume_str)
|
| 211 |
+
await communicate.save(str(output_path))
|
| 212 |
+
|
| 213 |
+
def download_piper_model(voice_name, dest_dir):
|
| 214 |
+
dest_dir = Path(dest_dir)
|
| 215 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 216 |
+
|
| 217 |
+
onnx_file = dest_dir / f"{voice_name}.onnx"
|
| 218 |
+
json_file = dest_dir / f"{voice_name}.onnx.json"
|
| 219 |
+
|
| 220 |
+
if onnx_file.exists() and json_file.exists():
|
| 221 |
+
return onnx_file, json_file
|
| 222 |
+
|
| 223 |
+
voice_map = {
|
| 224 |
+
"vi_VN-vais1000-medium": "vi/vi_VN/vais1000/medium/vi_VN-vais1000-medium",
|
| 225 |
+
"vi_VN-vivos-x_low": "vi/vi_VN/vivos/x_low/vi_VN-vivos-x_low",
|
| 226 |
+
"vi_VN-25hours_single-low": "vi/vi_VN/25hours_single/low/vi_VN-25hours_single-low"
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
hf_path = voice_map.get(voice_name, "vi/vi_VN/vais1000/medium/vi_VN-vais1000-medium")
|
| 230 |
+
base_url = f"https://huggingface.co/rhasspy/piper-voices/resolve/main/{hf_path}"
|
| 231 |
+
|
| 232 |
+
print(f"Downloading Piper model files for {voice_name} to {dest_dir}...")
|
| 233 |
+
urllib.request.urlretrieve(f"{base_url}.onnx.json", str(json_file))
|
| 234 |
+
urllib.request.urlretrieve(f"{base_url}.onnx", str(onnx_file))
|
| 235 |
+
return onnx_file, json_file
|
| 236 |
+
|
| 237 |
+
def _omnivoice_tag_for_text(text, style="", voice=""):
|
| 238 |
+
"""
|
| 239 |
+
Determine OmniVoice emotion tag with priority:
|
| 240 |
+
1. Explicit [tag] prefix in text (highest)
|
| 241 |
+
2. Explicit style parameter (if not "auto"/"default"/empty)
|
| 242 |
+
3. Style inferred from voice name (e.g., "meme_male_gasp" -> "gasp")
|
| 243 |
+
4. Auto-detect from text content (lowest, only if no explicit style)
|
| 244 |
+
"""
|
| 245 |
+
text = str(text or "").strip()
|
| 246 |
+
style_l = str(style or "").strip().lower()
|
| 247 |
+
voice_l = str(voice or "").strip().lower()
|
| 248 |
+
tag = ""
|
| 249 |
+
|
| 250 |
+
# 1. Explicit [tag] prefix in text (e.g., "[gasp] text")
|
| 251 |
+
m = re.match(r"^\s*\[([^\]]+)\]\s*", text)
|
| 252 |
+
if m:
|
| 253 |
+
tag = m.group(1).strip().lower()
|
| 254 |
+
|
| 255 |
+
# 2. Explicit style parameter (user-selected in GUI) - HIGHEST PROGRAMMATIC PRIORITY
|
| 256 |
+
# Treat "auto" as "use voice default" not "skip to text detection"
|
| 257 |
+
if not tag and style_l and style_l not in {"", "default", "auto"}:
|
| 258 |
+
tag = style_l
|
| 259 |
+
|
| 260 |
+
# 3. Infer from voice name if no explicit style (e.g., "meme_male_gasp" -> "gasp")
|
| 261 |
+
if not tag:
|
| 262 |
+
for candidate in ["gasp", "excited", "sad", "whispering", "sarcastic", "confident", "playful", "normal"]:
|
| 263 |
+
if candidate in voice_l:
|
| 264 |
+
tag = candidate
|
| 265 |
+
break
|
| 266 |
+
|
| 267 |
+
# Normalize tag aliases
|
| 268 |
+
if tag in {"gasps", "gasping", "wow"}:
|
| 269 |
+
tag = "gasp"
|
| 270 |
+
|
| 271 |
+
# 4. Auto-detect from text content ONLY if no explicit style/voice style was set
|
| 272 |
+
# This prevents mixed tones when user locked a specific style
|
| 273 |
+
if not tag:
|
| 274 |
+
text_lower = text.lower()
|
| 275 |
+
if any(w in text_lower for w in ["ơi", "a ha", "đùa", "vui", "haha", "quá đã", "cháy"]):
|
| 276 |
+
tag = "excited"
|
| 277 |
+
elif any(w in text_lower for w in ["buồn", "khóc", "đau lòng", "tiếc", "haizz"]):
|
| 278 |
+
tag = "sad"
|
| 279 |
+
elif any(w in text_lower for w in ["suỵt", "nói nhỏ", "thầm", "bí mật"]):
|
| 280 |
+
tag = "whispering"
|
| 281 |
+
elif any(w in text_lower for w in ["tin được không", "bất ngờ", "cái gì", "wow"]):
|
| 282 |
+
tag = "gasp"
|
| 283 |
+
elif any(w in text_lower for w in ["mỉa mai", "thế cơ à", "vậy hả", "chắc chưa"]):
|
| 284 |
+
tag = "sarcastic"
|
| 285 |
+
elif any(w in text_lower for w in ["chắc chắn", "tự tin", "khẳng định", "luôn"]):
|
| 286 |
+
tag = "confident"
|
| 287 |
+
else:
|
| 288 |
+
tag = "normal"
|
| 289 |
+
|
| 290 |
+
# Strip [tag] prefix from text for actual synthesis
|
| 291 |
+
clean_text = re.sub(r"^\s*\[[^\]]+\]\s*", "", text).strip()
|
| 292 |
+
return tag, clean_text or text
|
| 293 |
+
|
| 294 |
+
def _omnivoice_params(tag, tts_config):
|
| 295 |
+
preset = {
|
| 296 |
+
"normal": ("male, young adult, moderate pitch", 1.06, 20, 2.0),
|
| 297 |
+
"gasp": ("male, young adult, high pitch", 1.15, 20, 2.2),
|
| 298 |
+
"excited": ("male, young adult, high pitch", 1.16, 20, 2.3),
|
| 299 |
+
"sad": ("male, young adult, low pitch", 0.90, 20, 1.8),
|
| 300 |
+
"whispering": ("male, young adult, whisper", 0.95, 20, 1.6),
|
| 301 |
+
"sarcastic": ("male, young adult, moderate pitch", 0.98, 20, 2.0),
|
| 302 |
+
"confident": ("male, young adult, low pitch", 1.02, 20, 2.0),
|
| 303 |
+
"playful": ("male, young adult, high pitch", 1.08, 20, 2.1),
|
| 304 |
+
}
|
| 305 |
+
instruct, speed, steps, guidance = preset.get(tag, preset["normal"])
|
| 306 |
+
return {
|
| 307 |
+
"instruct": tts_config.get("omnivoice_instruct", instruct),
|
| 308 |
+
"speed": float(tts_config.get("omnivoice_speed", speed)),
|
| 309 |
+
"steps": int(tts_config.get("omnivoice_steps", steps)),
|
| 310 |
+
"guidance": float(tts_config.get("omnivoice_guidance", guidance)),
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
class OmniVoiceSession:
|
| 314 |
+
def __init__(self, args, tts_config, work_dir):
|
| 315 |
+
self.args = args
|
| 316 |
+
self.tts_config = tts_config
|
| 317 |
+
self.work_dir = Path(work_dir)
|
| 318 |
+
self.process = None
|
| 319 |
+
self.queue = queue.Queue()
|
| 320 |
+
self.reader_thread = None
|
| 321 |
+
self.runner_path = self.work_dir / "omnivoice_session_runner.py"
|
| 322 |
+
|
| 323 |
+
def start(self):
|
| 324 |
+
python_exe = Path(self.tts_config.get("omnivoice_python", r"C:\Users\Admin\OmniVoiceApp\.venv\Scripts\python.exe"))
|
| 325 |
+
if not python_exe.exists():
|
| 326 |
+
raise RuntimeError(f"OmniVoice python not found: {python_exe}")
|
| 327 |
+
|
| 328 |
+
model_name = self.tts_config.get("omnivoice_model", "splendor1811/omnivoice-vietnamese")
|
| 329 |
+
ref_audio = self.tts_config.get("omnivoice_ref_audio", "")
|
| 330 |
+
ref_text = self.tts_config.get("omnivoice_ref_text", "")
|
| 331 |
+
runner_code = f"""import sys
|
| 332 |
+
import json
|
| 333 |
+
import traceback
|
| 334 |
+
from pathlib import Path
|
| 335 |
+
from omnivoice import OmniVoice
|
| 336 |
+
import soundfile as sf
|
| 337 |
+
import torch
|
| 338 |
+
|
| 339 |
+
def emit(payload):
|
| 340 |
+
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 344 |
+
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 345 |
+
model = OmniVoice.from_pretrained(
|
| 346 |
+
{model_name!r},
|
| 347 |
+
device_map=device,
|
| 348 |
+
dtype=dtype,
|
| 349 |
+
)
|
| 350 |
+
ref_audio = {ref_audio!r}
|
| 351 |
+
ref_text = {ref_text!r}
|
| 352 |
+
voice_prompt = None
|
| 353 |
+
if ref_audio and Path(ref_audio).exists():
|
| 354 |
+
if not ref_text:
|
| 355 |
+
from transformers import pipeline as hf_pipeline
|
| 356 |
+
asr_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 357 |
+
asr_pipe = hf_pipeline(
|
| 358 |
+
"automatic-speech-recognition",
|
| 359 |
+
model="openai/whisper-large-v3-turbo",
|
| 360 |
+
dtype=asr_dtype,
|
| 361 |
+
device_map=device,
|
| 362 |
+
)
|
| 363 |
+
ref_text = asr_pipe(ref_audio)["text"].strip()
|
| 364 |
+
voice_prompt = model.create_voice_clone_prompt(ref_audio=ref_audio, ref_text=ref_text)
|
| 365 |
+
emit({{"type": "clone_ready", "ok": True, "ref_text": ref_text}})
|
| 366 |
+
emit({{"type": "ready", "device": device}})
|
| 367 |
+
for line in sys.stdin:
|
| 368 |
+
line = line.strip()
|
| 369 |
+
if not line:
|
| 370 |
+
continue
|
| 371 |
+
try:
|
| 372 |
+
req = json.loads(line)
|
| 373 |
+
if req.get("type") == "quit":
|
| 374 |
+
emit({{"type": "quit_ok"}})
|
| 375 |
+
break
|
| 376 |
+
output = Path(req["output"])
|
| 377 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 378 |
+
gen_kw = dict(
|
| 379 |
+
text=req["text"],
|
| 380 |
+
language="vi",
|
| 381 |
+
speed=float(req["speed"]),
|
| 382 |
+
num_step=int(req["steps"]),
|
| 383 |
+
guidance_scale=float(req["guidance"]),
|
| 384 |
+
denoise=True,
|
| 385 |
+
)
|
| 386 |
+
if voice_prompt is not None:
|
| 387 |
+
gen_kw["voice_clone_prompt"] = voice_prompt
|
| 388 |
+
else:
|
| 389 |
+
gen_kw["instruct"] = req["instruct"]
|
| 390 |
+
audio = model.generate(**gen_kw)
|
| 391 |
+
sf.write(str(output), audio[0], model.sampling_rate)
|
| 392 |
+
emit({{"type": "result", "id": req.get("id"), "ok": True}})
|
| 393 |
+
except Exception as e:
|
| 394 |
+
emit({{"type": "result", "id": req.get("id") if 'req' in locals() else None, "ok": False, "error": str(e), "traceback": traceback.format_exc()}})
|
| 395 |
+
except Exception as e:
|
| 396 |
+
emit({{"type": "fatal", "ok": False, "error": str(e), "traceback": traceback.format_exc()}})
|
| 397 |
+
sys.exit(1)
|
| 398 |
+
"""
|
| 399 |
+
self.runner_path.write_text(runner_code, encoding="utf-8")
|
| 400 |
+
startupinfo = None
|
| 401 |
+
if sys.platform == 'win32':
|
| 402 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 403 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 404 |
+
self.process = subprocess.Popen(
|
| 405 |
+
[str(python_exe), str(self.runner_path)],
|
| 406 |
+
stdin=subprocess.PIPE,
|
| 407 |
+
stdout=subprocess.PIPE,
|
| 408 |
+
stderr=subprocess.STDOUT,
|
| 409 |
+
text=True,
|
| 410 |
+
encoding="utf-8",
|
| 411 |
+
errors="ignore",
|
| 412 |
+
startupinfo=startupinfo,
|
| 413 |
+
bufsize=1,
|
| 414 |
+
)
|
| 415 |
+
self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
|
| 416 |
+
self.reader_thread.start()
|
| 417 |
+
ready_timeout = int(self.tts_config.get("omnivoice_session_ready_timeout_seconds", 300))
|
| 418 |
+
ready = self._wait_for(lambda payload: payload.get("type") == "ready", ready_timeout)
|
| 419 |
+
if not ready:
|
| 420 |
+
self.close(kill=True)
|
| 421 |
+
raise RuntimeError("OmniVoice persistent session did not become ready before timeout.")
|
| 422 |
+
print(f"[OMNIVOICE SESSION] ready device={ready.get('device')}")
|
| 423 |
+
|
| 424 |
+
def _reader_loop(self):
|
| 425 |
+
try:
|
| 426 |
+
for line in iter(self.process.stdout.readline, ''):
|
| 427 |
+
line = line.strip()
|
| 428 |
+
if not line:
|
| 429 |
+
continue
|
| 430 |
+
try:
|
| 431 |
+
payload = json.loads(line)
|
| 432 |
+
self.queue.put(payload)
|
| 433 |
+
except Exception:
|
| 434 |
+
print(f"[OMNIVOICE SESSION] {line}")
|
| 435 |
+
finally:
|
| 436 |
+
self.queue.put({"type": "process_exit"})
|
| 437 |
+
|
| 438 |
+
def _wait_for(self, predicate, timeout_seconds):
|
| 439 |
+
deadline = time.time() + max(1, timeout_seconds)
|
| 440 |
+
while time.time() < deadline:
|
| 441 |
+
remaining = max(0.1, deadline - time.time())
|
| 442 |
+
try:
|
| 443 |
+
payload = self.queue.get(timeout=min(1.0, remaining))
|
| 444 |
+
except queue.Empty:
|
| 445 |
+
if self.process and self.process.poll() is not None:
|
| 446 |
+
return None
|
| 447 |
+
continue
|
| 448 |
+
if payload.get("type") == "fatal":
|
| 449 |
+
raise RuntimeError(payload.get("error", "OmniVoice session fatal error"))
|
| 450 |
+
if payload.get("type") == "process_exit":
|
| 451 |
+
return None
|
| 452 |
+
if predicate(payload):
|
| 453 |
+
return payload
|
| 454 |
+
print(f"[OMNIVOICE SESSION] {payload}")
|
| 455 |
+
return None
|
| 456 |
+
|
| 457 |
+
def synthesize(self, text, output_path, params):
|
| 458 |
+
if not self.process or self.process.poll() is not None:
|
| 459 |
+
raise RuntimeError("OmniVoice persistent session is not running.")
|
| 460 |
+
req_id = uuid.uuid4().hex
|
| 461 |
+
request = {
|
| 462 |
+
"type": "synthesize",
|
| 463 |
+
"id": req_id,
|
| 464 |
+
"text": text,
|
| 465 |
+
"output": str(Path(output_path).resolve()),
|
| 466 |
+
"instruct": params["instruct"],
|
| 467 |
+
"speed": params["speed"],
|
| 468 |
+
"steps": params["steps"],
|
| 469 |
+
"guidance": params["guidance"],
|
| 470 |
+
}
|
| 471 |
+
self.process.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
|
| 472 |
+
self.process.stdin.flush()
|
| 473 |
+
timeout = int(self.tts_config.get("omnivoice_timeout_seconds", 240))
|
| 474 |
+
result = self._wait_for(lambda payload: payload.get("type") == "result" and payload.get("id") == req_id, timeout)
|
| 475 |
+
if not result:
|
| 476 |
+
raise RuntimeError("OmniVoice persistent session timed out for segment.")
|
| 477 |
+
if not result.get("ok"):
|
| 478 |
+
raise RuntimeError(result.get("error", "OmniVoice persistent session failed"))
|
| 479 |
+
|
| 480 |
+
def close(self, kill=False):
|
| 481 |
+
try:
|
| 482 |
+
if self.process and self.process.poll() is None:
|
| 483 |
+
if kill:
|
| 484 |
+
self.process.kill()
|
| 485 |
+
else:
|
| 486 |
+
try:
|
| 487 |
+
self.process.stdin.write(json.dumps({"type": "quit"}) + "\n")
|
| 488 |
+
self.process.stdin.flush()
|
| 489 |
+
self.process.wait(timeout=10)
|
| 490 |
+
except Exception:
|
| 491 |
+
self.process.kill()
|
| 492 |
+
finally:
|
| 493 |
+
try:
|
| 494 |
+
self.runner_path.unlink()
|
| 495 |
+
except Exception:
|
| 496 |
+
pass
|
| 497 |
+
|
| 498 |
+
def _omnivoice_request_params(text, args, tts_config):
|
| 499 |
+
tag, clean_text = _omnivoice_tag_for_text(text, style=args.style, voice=args.voice)
|
| 500 |
+
params = _omnivoice_params(tag, tts_config)
|
| 501 |
+
try:
|
| 502 |
+
speed_multiplier = float(args.speed)
|
| 503 |
+
if speed_multiplier > 0:
|
| 504 |
+
params["speed"] = max(0.75, min(1.35, params["speed"] * speed_multiplier))
|
| 505 |
+
except Exception:
|
| 506 |
+
pass
|
| 507 |
+
return tag, clean_text, params
|
| 508 |
+
|
| 509 |
+
def run_omnivoice_tts(text, output_path, args, tts_config, omnivoice_session=None):
|
| 510 |
+
python_exe = Path(tts_config.get("omnivoice_python", r"C:\Users\Admin\OmniVoiceApp\.venv\Scripts\python.exe"))
|
| 511 |
+
if not python_exe.exists():
|
| 512 |
+
raise RuntimeError(f"OmniVoice python not found: {python_exe}")
|
| 513 |
+
|
| 514 |
+
tag, clean_text, params = _omnivoice_request_params(text, args, tts_config)
|
| 515 |
+
|
| 516 |
+
output_path = Path(output_path)
|
| 517 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 518 |
+
print(
|
| 519 |
+
f"[OMNIVOICE] tag={tag} instruct={params['instruct']} "
|
| 520 |
+
f"speed={params['speed']:.2f} steps={params['steps']} guidance={params['guidance']}"
|
| 521 |
+
)
|
| 522 |
+
if omnivoice_session:
|
| 523 |
+
omnivoice_session.synthesize(clean_text, output_path, params)
|
| 524 |
+
return
|
| 525 |
+
|
| 526 |
+
runner_path = output_path.parent / f"omnivoice_runner_{output_path.stem}.py"
|
| 527 |
+
model_name = tts_config.get("omnivoice_model", "splendor1811/omnivoice-vietnamese")
|
| 528 |
+
ref_audio = tts_config.get("omnivoice_ref_audio", "")
|
| 529 |
+
ref_text = tts_config.get("omnivoice_ref_text", "")
|
| 530 |
+
runner_code = f"""import sys
|
| 531 |
+
from pathlib import Path
|
| 532 |
+
from omnivoice import OmniVoice
|
| 533 |
+
import soundfile as sf
|
| 534 |
+
import torch
|
| 535 |
+
|
| 536 |
+
try:
|
| 537 |
+
device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 538 |
+
dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 539 |
+
model = OmniVoice.from_pretrained(
|
| 540 |
+
{model_name!r},
|
| 541 |
+
device_map=device,
|
| 542 |
+
dtype=dtype,
|
| 543 |
+
)
|
| 544 |
+
ref_audio = {ref_audio!r}
|
| 545 |
+
ref_text = {ref_text!r}
|
| 546 |
+
voice_prompt = None
|
| 547 |
+
if ref_audio and Path(ref_audio).exists():
|
| 548 |
+
if not ref_text:
|
| 549 |
+
from transformers import pipeline as hf_pipeline
|
| 550 |
+
asr_dtype = torch.float16 if torch.cuda.is_available() else torch.float32
|
| 551 |
+
asr_pipe = hf_pipeline(
|
| 552 |
+
"automatic-speech-recognition",
|
| 553 |
+
model="openai/whisper-large-v3-turbo",
|
| 554 |
+
dtype=asr_dtype,
|
| 555 |
+
device_map=device,
|
| 556 |
+
)
|
| 557 |
+
ref_text = asr_pipe(ref_audio)["text"].strip()
|
| 558 |
+
voice_prompt = model.create_voice_clone_prompt(ref_audio=ref_audio, ref_text=ref_text)
|
| 559 |
+
gen_kw = dict(
|
| 560 |
+
text={clean_text!r},
|
| 561 |
+
language="vi",
|
| 562 |
+
speed={params["speed"]},
|
| 563 |
+
num_step={params["steps"]},
|
| 564 |
+
guidance_scale={params["guidance"]},
|
| 565 |
+
denoise=True,
|
| 566 |
+
)
|
| 567 |
+
if voice_prompt is not None:
|
| 568 |
+
gen_kw["voice_clone_prompt"] = voice_prompt
|
| 569 |
+
else:
|
| 570 |
+
gen_kw["instruct"] = {params["instruct"]!r}
|
| 571 |
+
audio = model.generate(**gen_kw)
|
| 572 |
+
sf.write({str(output_path.resolve())!r}, audio[0], model.sampling_rate)
|
| 573 |
+
print("SUCCESS")
|
| 574 |
+
except Exception as e:
|
| 575 |
+
print("ERROR:", e)
|
| 576 |
+
sys.exit(1)
|
| 577 |
+
"""
|
| 578 |
+
runner_path.write_text(runner_code, encoding="utf-8")
|
| 579 |
+
startupinfo = None
|
| 580 |
+
if sys.platform == 'win32':
|
| 581 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 582 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 583 |
+
try:
|
| 584 |
+
result = subprocess.run(
|
| 585 |
+
[str(python_exe), str(runner_path)],
|
| 586 |
+
capture_output=True,
|
| 587 |
+
text=True,
|
| 588 |
+
encoding="utf-8",
|
| 589 |
+
errors="ignore",
|
| 590 |
+
timeout=int(tts_config.get("omnivoice_timeout_seconds", 240)),
|
| 591 |
+
startupinfo=startupinfo,
|
| 592 |
+
)
|
| 593 |
+
if result.returncode != 0:
|
| 594 |
+
raise RuntimeError((result.stderr or result.stdout or "OmniVoice failed").strip())
|
| 595 |
+
finally:
|
| 596 |
+
try:
|
| 597 |
+
runner_path.unlink()
|
| 598 |
+
except Exception:
|
| 599 |
+
pass
|
| 600 |
+
|
| 601 |
+
def synthesize_text_to_wav(text, output_path, args, tts_config, piper_voice=None, voice_override=None, omnivoice_session=None):
|
| 602 |
+
engine = args.engine.lower()
|
| 603 |
+
voice = voice_override or args.voice
|
| 604 |
+
if engine == "piper" and piper_voice:
|
| 605 |
+
import wave
|
| 606 |
+
from piper import SynthesisConfig
|
| 607 |
+
with wave.open(str(output_path), "wb") as wav_file:
|
| 608 |
+
try:
|
| 609 |
+
speed_val = float(args.speed)
|
| 610 |
+
length_scale = 1.0 / speed_val
|
| 611 |
+
except Exception:
|
| 612 |
+
length_scale = 1.0
|
| 613 |
+
syn_config = SynthesisConfig(length_scale=length_scale)
|
| 614 |
+
piper_voice.synthesize_wav(text, wav_file, syn_config=syn_config)
|
| 615 |
+
elif engine == "omnivoice":
|
| 616 |
+
run_omnivoice_tts(text, output_path, args, tts_config, omnivoice_session=omnivoice_session)
|
| 617 |
+
else:
|
| 618 |
+
asyncio.run(run_edge_tts(text, voice, output_path, args.speed, args.pitch, args.volume))
|
| 619 |
+
|
| 620 |
+
def _block_should_generate_tts(block, action_by_id, labels, skipped_tts_ids):
|
| 621 |
+
b_id = str(block.get("id"))
|
| 622 |
+
action = action_by_id.get(b_id, {})
|
| 623 |
+
should_generate_tts = action.get("generate_vi_voiceover", True)
|
| 624 |
+
if labels.get(b_id) == "EN_PRESERVE" or b_id in skipped_tts_ids or not should_generate_tts:
|
| 625 |
+
return False
|
| 626 |
+
detected_language = action.get("detected_language", labels.get(b_id, "unknown"))
|
| 627 |
+
if detected_language in {"en", "music", "unknown", "mixed"} and not should_generate_tts:
|
| 628 |
+
return False
|
| 629 |
+
return True
|
| 630 |
+
|
| 631 |
+
def _normalize_tts_text(text):
|
| 632 |
+
return re.sub(r"\s+", " ", re.sub(r"[^\wÀ-ỹ]+", " ", str(text or "").lower(), flags=re.UNICODE)).strip()
|
| 633 |
+
|
| 634 |
+
def _join_tts_text(left, right):
|
| 635 |
+
left = str(left or "").strip()
|
| 636 |
+
right = str(right or "").strip()
|
| 637 |
+
if not left:
|
| 638 |
+
return right
|
| 639 |
+
if not right:
|
| 640 |
+
return left
|
| 641 |
+
left_key = _normalize_tts_text(left)
|
| 642 |
+
right_key = _normalize_tts_text(right)
|
| 643 |
+
if left_key == right_key:
|
| 644 |
+
return left
|
| 645 |
+
if left_key and right_key.startswith(left_key):
|
| 646 |
+
return right
|
| 647 |
+
if right_key and left_key.endswith(right_key):
|
| 648 |
+
return left
|
| 649 |
+
|
| 650 |
+
parts = []
|
| 651 |
+
seen = set()
|
| 652 |
+
for item in re.split(r"(?<=[.!?])\s+", f"{left} {right}"):
|
| 653 |
+
item = item.strip(" ,")
|
| 654 |
+
key = _normalize_tts_text(item)
|
| 655 |
+
if not key or key in seen:
|
| 656 |
+
continue
|
| 657 |
+
parts.append(item)
|
| 658 |
+
seen.add(key)
|
| 659 |
+
return " ".join(parts).strip()
|
| 660 |
+
|
| 661 |
+
def _merge_short_tts_blocks(blocks, action_by_id, labels, skipped_tts_ids, tts_config):
|
| 662 |
+
enabled = bool(tts_config.get("merge_short_segments_enabled", False))
|
| 663 |
+
min_input_blocks = int(tts_config.get("merge_short_segments_min_input_blocks", 80))
|
| 664 |
+
if not enabled or len(blocks) < min_input_blocks:
|
| 665 |
+
return blocks, {
|
| 666 |
+
"enabled": False,
|
| 667 |
+
"reason": "disabled_or_too_few_blocks",
|
| 668 |
+
"input_blocks": len(blocks),
|
| 669 |
+
"output_units": len(blocks),
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
max_gap_ms = int(tts_config.get("merge_short_segments_max_gap_ms", 260))
|
| 673 |
+
short_gap_ms = int(tts_config.get("merge_short_segments_short_gap_ms", 900))
|
| 674 |
+
target_min_duration_ms = int(tts_config.get("merge_short_segments_target_min_duration_ms", 1600))
|
| 675 |
+
max_duration_ms = int(tts_config.get("merge_short_segments_max_duration_ms", 4200))
|
| 676 |
+
max_chars = int(tts_config.get("merge_short_segments_max_chars", 86))
|
| 677 |
+
max_blocks = int(tts_config.get("merge_short_segments_max_blocks", 5))
|
| 678 |
+
|
| 679 |
+
units = []
|
| 680 |
+
current = None
|
| 681 |
+
|
| 682 |
+
def flush():
|
| 683 |
+
nonlocal current
|
| 684 |
+
if not current:
|
| 685 |
+
return
|
| 686 |
+
ids = current["source_ids"]
|
| 687 |
+
current["id"] = ids[0]
|
| 688 |
+
current["merged"] = len(ids) > 1
|
| 689 |
+
current["merged_block_count"] = len(ids)
|
| 690 |
+
units.append(current)
|
| 691 |
+
current = None
|
| 692 |
+
|
| 693 |
+
for block in blocks:
|
| 694 |
+
b = dict(block)
|
| 695 |
+
b["source_ids"] = [str(block.get("id"))]
|
| 696 |
+
b["merged"] = False
|
| 697 |
+
b["merged_block_count"] = 1
|
| 698 |
+
|
| 699 |
+
if not _block_should_generate_tts(b, action_by_id, labels, skipped_tts_ids):
|
| 700 |
+
flush()
|
| 701 |
+
units.append(b)
|
| 702 |
+
continue
|
| 703 |
+
|
| 704 |
+
text = str(b.get("text", "")).strip()
|
| 705 |
+
if not current:
|
| 706 |
+
current = b
|
| 707 |
+
continue
|
| 708 |
+
|
| 709 |
+
gap_ms = int(b["start_ms"]) - int(current["end_ms"])
|
| 710 |
+
combined_text = _join_tts_text(current.get("text", ""), text)
|
| 711 |
+
combined_duration = int(b["end_ms"]) - int(current["start_ms"])
|
| 712 |
+
current_duration = int(current["end_ms"]) - int(current["start_ms"])
|
| 713 |
+
next_duration = int(b["end_ms"]) - int(b["start_ms"])
|
| 714 |
+
allowed_gap_ms = short_gap_ms if (current_duration < target_min_duration_ms or next_duration < target_min_duration_ms) else max_gap_ms
|
| 715 |
+
can_merge = (
|
| 716 |
+
gap_ms <= allowed_gap_ms
|
| 717 |
+
and combined_duration <= max_duration_ms
|
| 718 |
+
and len(combined_text) <= max_chars
|
| 719 |
+
and len(current["source_ids"]) < max_blocks
|
| 720 |
+
)
|
| 721 |
+
if can_merge:
|
| 722 |
+
current["end_ms"] = b["end_ms"]
|
| 723 |
+
current["text"] = combined_text
|
| 724 |
+
current["source_ids"].append(str(b.get("id")))
|
| 725 |
+
continue
|
| 726 |
+
|
| 727 |
+
flush()
|
| 728 |
+
current = b
|
| 729 |
+
|
| 730 |
+
flush()
|
| 731 |
+
merged_units = sum(1 for u in units if u.get("merged"))
|
| 732 |
+
return units, {
|
| 733 |
+
"enabled": True,
|
| 734 |
+
"input_blocks": len(blocks),
|
| 735 |
+
"output_units": len(units),
|
| 736 |
+
"merged_units": merged_units,
|
| 737 |
+
"saved_tts_calls": max(0, len(blocks) - len(units)),
|
| 738 |
+
"max_gap_ms": max_gap_ms,
|
| 739 |
+
"short_gap_ms": short_gap_ms,
|
| 740 |
+
"target_min_duration_ms": target_min_duration_ms,
|
| 741 |
+
"max_duration_ms": max_duration_ms,
|
| 742 |
+
"max_chars": max_chars,
|
| 743 |
+
"max_blocks": max_blocks,
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
def _tts_plan_signature(units):
|
| 747 |
+
payload = [
|
| 748 |
+
{
|
| 749 |
+
"id": str(u.get("id")),
|
| 750 |
+
"source_ids": [str(x) for x in u.get("source_ids", [u.get("id")])],
|
| 751 |
+
"start_ms": int(u.get("start_ms", 0)),
|
| 752 |
+
"end_ms": int(u.get("end_ms", 0)),
|
| 753 |
+
"text": str(u.get("text", "")),
|
| 754 |
+
}
|
| 755 |
+
for u in units
|
| 756 |
+
]
|
| 757 |
+
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
| 758 |
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 759 |
+
|
| 760 |
+
def _prepare_tts_plan(segments_dir, units, merge_report):
|
| 761 |
+
plan_path = Path(segments_dir).parent / "tts_merge_plan.json"
|
| 762 |
+
signature = _tts_plan_signature(units)
|
| 763 |
+
previous_signature = None
|
| 764 |
+
if plan_path.exists():
|
| 765 |
+
try:
|
| 766 |
+
previous_signature = json.loads(plan_path.read_text(encoding="utf-8")).get("signature")
|
| 767 |
+
except Exception:
|
| 768 |
+
previous_signature = None
|
| 769 |
+
if previous_signature != signature:
|
| 770 |
+
for wav_path in Path(segments_dir).glob("*.wav"):
|
| 771 |
+
try:
|
| 772 |
+
wav_path.unlink()
|
| 773 |
+
except Exception:
|
| 774 |
+
pass
|
| 775 |
+
payload = {
|
| 776 |
+
"signature": signature,
|
| 777 |
+
"created_at": time.time(),
|
| 778 |
+
"merge_report": merge_report,
|
| 779 |
+
"units": [
|
| 780 |
+
{
|
| 781 |
+
"id": str(u.get("id")),
|
| 782 |
+
"source_ids": [str(x) for x in u.get("source_ids", [u.get("id")])],
|
| 783 |
+
"start_ms": int(u.get("start_ms", 0)),
|
| 784 |
+
"end_ms": int(u.get("end_ms", 0)),
|
| 785 |
+
"merged": bool(u.get("merged", False)),
|
| 786 |
+
"merged_block_count": int(u.get("merged_block_count", 1)),
|
| 787 |
+
"text": str(u.get("text", "")),
|
| 788 |
+
}
|
| 789 |
+
for u in units
|
| 790 |
+
],
|
| 791 |
+
}
|
| 792 |
+
plan_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 793 |
+
return plan_path, previous_signature != signature
|
| 794 |
+
|
| 795 |
+
def main():
|
| 796 |
+
parser = argparse.ArgumentParser(description="Standalone TTS Segment Synthesis Worker CLI")
|
| 797 |
+
parser.add_argument("--srt", default="", help="Path to input translated SRT file")
|
| 798 |
+
parser.add_argument("--original-srt", default="", help="Path to original (ASR/OCR) SRT file")
|
| 799 |
+
parser.add_argument("--original-audio", default="", help="Path to original audio WAV file")
|
| 800 |
+
parser.add_argument("--background-audio", default="", help="Path to background separated WAV file")
|
| 801 |
+
parser.add_argument("--output-wav", default="", help="Path to output final dubbing WAV")
|
| 802 |
+
parser.add_argument("--segments-dir", default="", help="Directory to save segment WAVs")
|
| 803 |
+
parser.add_argument("--engine", default="edge-tts", help="TTS Engine: edge-tts or piper")
|
| 804 |
+
parser.add_argument("--voice", default="vi-VN-HoaiMyNeural", help="Voice name to use")
|
| 805 |
+
parser.add_argument("--ffmpeg-path", default="ffmpeg", help="Path to ffmpeg executable")
|
| 806 |
+
parser.add_argument("--vocals-audio", default="", help="Path to vocals separated WAV file")
|
| 807 |
+
parser.add_argument("--speed", default="1.0", help="Voice speed multiplier")
|
| 808 |
+
parser.add_argument("--pitch", default="0", help="Voice pitch change (Hz or %)")
|
| 809 |
+
parser.add_argument("--volume", default="100", help="Voice volume percentage (100 is default)")
|
| 810 |
+
parser.add_argument("--style", default="", help="Voice style parameter")
|
| 811 |
+
parser.add_argument("--language-segments", default="", help="Path to audio_language_segments.json")
|
| 812 |
+
parser.add_argument("--test-mode", action="store_true", help="If true, silent fallback on errors")
|
| 813 |
+
parser.add_argument("--debug-segment-text", default=None, help="Segment text to synthesize for debugging")
|
| 814 |
+
parser.add_argument("--output", default=None, help="Output wav path for debugging")
|
| 815 |
+
parser.add_argument("--limit-segments", type=int, default=0, help=argparse.SUPPRESS)
|
| 816 |
+
args = parser.parse_args()
|
| 817 |
+
|
| 818 |
+
# Load configuration
|
| 819 |
+
config_path = Path(__file__).parent.parent.parent / "config.json"
|
| 820 |
+
tts_config = {}
|
| 821 |
+
if config_path.exists():
|
| 822 |
+
try:
|
| 823 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 824 |
+
config_data = json.load(f)
|
| 825 |
+
tts_config = config_data.get("tts", {})
|
| 826 |
+
except Exception as e:
|
| 827 |
+
print(f"Warning: Failed to load config.json: {e}", file=sys.stderr)
|
| 828 |
+
|
| 829 |
+
# Set AudioSegment converter early so verify_audio_file and silence placeholder work correctly
|
| 830 |
+
from pydub import AudioSegment
|
| 831 |
+
if args.ffmpeg_path:
|
| 832 |
+
AudioSegment.converter = str(args.ffmpeg_path)
|
| 833 |
+
|
| 834 |
+
if args.debug_segment_text:
|
| 835 |
+
text = args.debug_segment_text
|
| 836 |
+
output_path = Path(args.output) if args.output else Path("debug_tts_out.wav")
|
| 837 |
+
print(f"[DEBUG TTS] Starting single segment debug for: '{text}'")
|
| 838 |
+
print(f"[DEBUG TTS] Output path: {output_path}")
|
| 839 |
+
print(f"[DEBUG TTS] Voice: {args.voice}")
|
| 840 |
+
|
| 841 |
+
# We will run the plans one by one and print results
|
| 842 |
+
# Plan 1: Normal
|
| 843 |
+
print("Plan 1 (normal)...")
|
| 844 |
+
success = False
|
| 845 |
+
try:
|
| 846 |
+
asyncio.run(run_edge_tts(text, args.voice, output_path, args.speed, args.pitch, args.volume))
|
| 847 |
+
if verify_audio_file(output_path):
|
| 848 |
+
print("Plan 1: PASS")
|
| 849 |
+
success = True
|
| 850 |
+
else:
|
| 851 |
+
print("Plan 1: FAIL (empty or invalid audio)")
|
| 852 |
+
except Exception as e:
|
| 853 |
+
print(f"Plan 1: FAIL ({e})")
|
| 854 |
+
|
| 855 |
+
# Plan 2: Sanitized
|
| 856 |
+
if not success:
|
| 857 |
+
print("Plan 2 (sanitized)...")
|
| 858 |
+
sanitized = sanitize_for_edge_tts(text)
|
| 859 |
+
print(f"Sanitized text: '{sanitized}'")
|
| 860 |
+
if not sanitized:
|
| 861 |
+
print("Plan 2: SKIP (text is empty after sanitization)")
|
| 862 |
+
else:
|
| 863 |
+
try:
|
| 864 |
+
asyncio.run(run_edge_tts(sanitized, args.voice, output_path, args.speed, args.pitch, args.volume))
|
| 865 |
+
if verify_audio_file(output_path):
|
| 866 |
+
print("Plan 2: PASS")
|
| 867 |
+
success = True
|
| 868 |
+
else:
|
| 869 |
+
print("Plan 2: FAIL (empty or invalid audio)")
|
| 870 |
+
except Exception as e:
|
| 871 |
+
print(f"Plan 2: FAIL ({e})")
|
| 872 |
+
|
| 873 |
+
# Plan 3: Split
|
| 874 |
+
if not success:
|
| 875 |
+
print("Plan 3 (split)...")
|
| 876 |
+
parts = split_text_in_half(text)
|
| 877 |
+
print(f"Split parts: {parts}")
|
| 878 |
+
if len(parts) >= 2:
|
| 879 |
+
part1_wav = output_path.parent / "debug_part1.wav"
|
| 880 |
+
part2_wav = output_path.parent / "debug_part2.wav"
|
| 881 |
+
from pydub import AudioSegment
|
| 882 |
+
AudioSegment.converter = str(args.ffmpeg_path)
|
| 883 |
+
try:
|
| 884 |
+
asyncio.run(run_edge_tts(parts[0], args.voice, part1_wav, args.speed, args.pitch, args.volume))
|
| 885 |
+
asyncio.run(run_edge_tts(parts[1], args.voice, part2_wav, args.speed, args.pitch, args.volume))
|
| 886 |
+
if verify_audio_file(part1_wav) and verify_audio_file(part2_wav):
|
| 887 |
+
seg1 = AudioSegment.from_file(part1_wav)
|
| 888 |
+
seg2 = AudioSegment.from_file(part2_wav)
|
| 889 |
+
combined = seg1 + AudioSegment.silent(duration=100) + seg2
|
| 890 |
+
combined.export(str(output_path), format="wav")
|
| 891 |
+
if verify_audio_file(output_path):
|
| 892 |
+
print("Plan 3: PASS")
|
| 893 |
+
success = True
|
| 894 |
+
else:
|
| 895 |
+
print("Plan 3: FAIL (empty combined)")
|
| 896 |
+
else:
|
| 897 |
+
print("Plan 3: FAIL (part 1 or part 2 failed)")
|
| 898 |
+
except Exception as e:
|
| 899 |
+
print(f"Plan 3: FAIL ({e})")
|
| 900 |
+
finally:
|
| 901 |
+
for p in [part1_wav, part2_wav]:
|
| 902 |
+
if p.exists():
|
| 903 |
+
p.unlink()
|
| 904 |
+
else:
|
| 905 |
+
print("Plan 3: SKIP (cannot split)")
|
| 906 |
+
|
| 907 |
+
# Plan 4: Fallback voice
|
| 908 |
+
if not success:
|
| 909 |
+
print("Plan 4 (fallback voice)...")
|
| 910 |
+
fallback_voices = tts_config.get("fallback_voices", ["vi-VN-NamMinhNeural", "vi-VN-HoaiMyNeural"])
|
| 911 |
+
fallback_voice = None
|
| 912 |
+
for v in fallback_voices:
|
| 913 |
+
if v.lower() != args.voice.lower():
|
| 914 |
+
fallback_voice = v
|
| 915 |
+
break
|
| 916 |
+
if not fallback_voice and fallback_voices:
|
| 917 |
+
fallback_voice = fallback_voices[0]
|
| 918 |
+
if not fallback_voice:
|
| 919 |
+
fallback_voice = "vi-VN-NamMinhNeural" if args.voice == "vi-VN-HoaiMyNeural" else "vi-VN-HoaiMyNeural"
|
| 920 |
+
print(f"Fallback voice: {fallback_voice}")
|
| 921 |
+
try:
|
| 922 |
+
asyncio.run(run_edge_tts(text, fallback_voice, output_path, args.speed, args.pitch, args.volume))
|
| 923 |
+
if verify_audio_file(output_path):
|
| 924 |
+
print("Plan 4: PASS")
|
| 925 |
+
success = True
|
| 926 |
+
else:
|
| 927 |
+
print("Plan 4: FAIL (empty audio)")
|
| 928 |
+
except Exception as e:
|
| 929 |
+
print(f"Plan 4: FAIL ({e})")
|
| 930 |
+
|
| 931 |
+
# Plan 5: Silence placeholder
|
| 932 |
+
if not success:
|
| 933 |
+
print("Plan 5 (silence placeholder)...")
|
| 934 |
+
try:
|
| 935 |
+
from pydub import AudioSegment
|
| 936 |
+
AudioSegment.converter = str(args.ffmpeg_path)
|
| 937 |
+
AudioSegment.silent(duration=2000).export(str(output_path), format="wav")
|
| 938 |
+
print("Plan 5: PASS")
|
| 939 |
+
success = True
|
| 940 |
+
except Exception as e:
|
| 941 |
+
print(f"Plan 5: FAIL ({e})")
|
| 942 |
+
|
| 943 |
+
print(f"[DEBUG TTS] Done. Success: {success}")
|
| 944 |
+
sys.exit(0)
|
| 945 |
+
|
| 946 |
+
# Validate non-debug args
|
| 947 |
+
if not args.srt or not args.output_wav or not args.segments_dir:
|
| 948 |
+
print("Error: --srt, --output-wav, and --segments-dir are required when not in debug mode.", file=sys.stderr)
|
| 949 |
+
sys.exit(1)
|
| 950 |
+
|
| 951 |
+
srt_path = Path(args.srt)
|
| 952 |
+
original_srt_path = Path(args.original_srt) if args.original_srt else None
|
| 953 |
+
original_audio_path = Path(args.original_audio) if args.original_audio else None
|
| 954 |
+
background_audio_path = Path(args.background_audio) if args.background_audio else None
|
| 955 |
+
vocals_audio_path = Path(args.vocals_audio) if args.vocals_audio else None
|
| 956 |
+
output_wav = Path(args.output_wav)
|
| 957 |
+
segments_dir = Path(args.segments_dir)
|
| 958 |
+
ffmpeg_path = Path(args.ffmpeg_path)
|
| 959 |
+
|
| 960 |
+
from pydub import AudioSegment
|
| 961 |
+
AudioSegment.converter = str(ffmpeg_path)
|
| 962 |
+
|
| 963 |
+
if not srt_path.exists():
|
| 964 |
+
print(f"Error: SRT file not found at {srt_path}", file=sys.stderr)
|
| 965 |
+
sys.exit(1)
|
| 966 |
+
|
| 967 |
+
segments_dir.mkdir(parents=True, exist_ok=True)
|
| 968 |
+
normalizer = VietnameseTextNormalizer()
|
| 969 |
+
|
| 970 |
+
# Load original blocks
|
| 971 |
+
orig_blocks = []
|
| 972 |
+
if original_srt_path and original_srt_path.exists():
|
| 973 |
+
print(f"Parsing original SRT: {original_srt_path}")
|
| 974 |
+
orig_blocks = parse_srt(original_srt_path)
|
| 975 |
+
|
| 976 |
+
# Load language segments for classification if available
|
| 977 |
+
language_segments = None
|
| 978 |
+
if args.language_segments and Path(args.language_segments).exists():
|
| 979 |
+
try:
|
| 980 |
+
with open(args.language_segments, "r", encoding="utf-8") as f:
|
| 981 |
+
language_segments = json.load(f)
|
| 982 |
+
print(f"Loaded language segments: {len(language_segments)} entries.")
|
| 983 |
+
except Exception as e:
|
| 984 |
+
print(f"Warning: Failed to load language segments: {e}", file=sys.stderr)
|
| 985 |
+
|
| 986 |
+
# Classify blocks or load pre-computed regions
|
| 987 |
+
preserve_intervals = []
|
| 988 |
+
skipped_tts_ids = []
|
| 989 |
+
labels = {}
|
| 990 |
+
segment_actions = []
|
| 991 |
+
action_by_id = {}
|
| 992 |
+
|
| 993 |
+
preserve_json = output_wav.parent / "preserve_regions.json"
|
| 994 |
+
skipped_json = output_wav.parent / "tts_skipped_blocks.json"
|
| 995 |
+
actions_json = output_wav.parent / "segment_actions.json"
|
| 996 |
+
|
| 997 |
+
if preserve_json.exists() and skipped_json.exists():
|
| 998 |
+
try:
|
| 999 |
+
with open(preserve_json, "r", encoding="utf-8") as f:
|
| 1000 |
+
preserve_intervals = [tuple(x) for x in json.load(f)]
|
| 1001 |
+
with open(skipped_json, "r", encoding="utf-8") as f:
|
| 1002 |
+
skipped_tts_ids = json.load(f)
|
| 1003 |
+
print(f"Loaded preserve intervals from cache: {len(preserve_intervals)} entries.")
|
| 1004 |
+
except Exception as e:
|
| 1005 |
+
print(f"Warning: Failed to load pre-computed regions: {e}", file=sys.stderr)
|
| 1006 |
+
|
| 1007 |
+
if actions_json.exists():
|
| 1008 |
+
try:
|
| 1009 |
+
with open(actions_json, "r", encoding="utf-8") as f:
|
| 1010 |
+
segment_actions = json.load(f)
|
| 1011 |
+
action_by_id = {str(x.get("id")): x for x in segment_actions}
|
| 1012 |
+
print(f"Loaded segment actions: {len(segment_actions)} entries.")
|
| 1013 |
+
except Exception as e:
|
| 1014 |
+
print(f"Warning: Failed to load segment actions: {e}", file=sys.stderr)
|
| 1015 |
+
|
| 1016 |
+
if not preserve_intervals and not skipped_tts_ids:
|
| 1017 |
+
# Fallback to computing them
|
| 1018 |
+
classifier = AudioTimelineClassifier()
|
| 1019 |
+
labels, preserve_intervals, skipped_tts_ids, segment_actions = classifier.classify_blocks(
|
| 1020 |
+
orig_blocks, language_segments, include_actions=True
|
| 1021 |
+
)
|
| 1022 |
+
action_by_id = {str(x.get("id")): x for x in segment_actions}
|
| 1023 |
+
try:
|
| 1024 |
+
with open(preserve_json, "w", encoding="utf-8") as f:
|
| 1025 |
+
json.dump(preserve_intervals, f, ensure_ascii=False, indent=2)
|
| 1026 |
+
with open(skipped_json, "w", encoding="utf-8") as f:
|
| 1027 |
+
json.dump(skipped_tts_ids, f, ensure_ascii=False, indent=2)
|
| 1028 |
+
with open(actions_json, "w", encoding="utf-8") as f:
|
| 1029 |
+
json.dump(segment_actions, f, ensure_ascii=False, indent=2)
|
| 1030 |
+
except Exception as e:
|
| 1031 |
+
print(f"Warning: Failed to save regions: {e}", file=sys.stderr)
|
| 1032 |
+
|
| 1033 |
+
print(f"Parsing translated SRT: {srt_path}...")
|
| 1034 |
+
blocks = parse_srt(srt_path)
|
| 1035 |
+
if not blocks:
|
| 1036 |
+
print("Error: No valid SRT blocks found.", file=sys.stderr)
|
| 1037 |
+
sys.exit(1)
|
| 1038 |
+
input_block_count = len(blocks)
|
| 1039 |
+
blocks, merge_report = _merge_short_tts_blocks(blocks, action_by_id, labels, skipped_tts_ids, tts_config)
|
| 1040 |
+
plan_path, plan_changed = _prepare_tts_plan(segments_dir, blocks, merge_report)
|
| 1041 |
+
if merge_report.get("enabled"):
|
| 1042 |
+
print(
|
| 1043 |
+
"[TTS MERGE] "
|
| 1044 |
+
f"input_blocks={merge_report.get('input_blocks')} "
|
| 1045 |
+
f"output_units={merge_report.get('output_units')} "
|
| 1046 |
+
f"saved_tts_calls={merge_report.get('saved_tts_calls')}"
|
| 1047 |
+
)
|
| 1048 |
+
else:
|
| 1049 |
+
print(f"[TTS MERGE] disabled: {merge_report.get('reason')}")
|
| 1050 |
+
if plan_changed:
|
| 1051 |
+
print(f"[TTS MERGE] plan changed; cleared stale segment WAVs. Plan: {plan_path}")
|
| 1052 |
+
if args.limit_segments and args.limit_segments > 0:
|
| 1053 |
+
blocks = blocks[:args.limit_segments]
|
| 1054 |
+
print(f"[DEBUG TTS] Limiting synthesis to first {len(blocks)} unit(s).")
|
| 1055 |
+
|
| 1056 |
+
piper_voice = None
|
| 1057 |
+
if args.engine.lower() == "piper":
|
| 1058 |
+
print("Initializing Piper local TTS...")
|
| 1059 |
+
from piper import PiperVoice, SynthesisConfig
|
| 1060 |
+
voice_model_name = args.voice
|
| 1061 |
+
if not voice_model_name.startswith("vi_VN"):
|
| 1062 |
+
voice_model_name = "vi_VN-vais1000-medium"
|
| 1063 |
+
model_dir = Path(__file__).parent.parent.parent / "models" / "piper"
|
| 1064 |
+
try:
|
| 1065 |
+
onnx_path, json_path = download_piper_model(voice_model_name, model_dir)
|
| 1066 |
+
piper_voice = PiperVoice.load(str(onnx_path))
|
| 1067 |
+
except Exception as e:
|
| 1068 |
+
print(f"Error loading Piper: {e}", file=sys.stderr)
|
| 1069 |
+
if not args.test_mode:
|
| 1070 |
+
sys.exit(3)
|
| 1071 |
+
|
| 1072 |
+
omnivoice_session = None
|
| 1073 |
+
if args.engine.lower() == "omnivoice" and bool(tts_config.get("omnivoice_persistent_session", True)):
|
| 1074 |
+
print("[OMNIVOICE SESSION] starting persistent model session...")
|
| 1075 |
+
omnivoice_session = OmniVoiceSession(args, tts_config, segments_dir)
|
| 1076 |
+
omnivoice_session.start()
|
| 1077 |
+
atexit.register(lambda: omnivoice_session.close(kill=True))
|
| 1078 |
+
|
| 1079 |
+
total_blocks = len(blocks)
|
| 1080 |
+
seg_files = {}
|
| 1081 |
+
failed_segments = []
|
| 1082 |
+
manifest_entries = []
|
| 1083 |
+
|
| 1084 |
+
for idx, block in enumerate(blocks):
|
| 1085 |
+
b_id = str(block["id"])
|
| 1086 |
+
source_ids = [str(x) for x in block.get("source_ids", [b_id])]
|
| 1087 |
+
start_ms = block["start_ms"]
|
| 1088 |
+
end_ms = block["end_ms"]
|
| 1089 |
+
raw_text = block["text"]
|
| 1090 |
+
|
| 1091 |
+
target_duration_ms = end_ms - start_ms
|
| 1092 |
+
if target_duration_ms <= 0:
|
| 1093 |
+
target_duration_ms = 1000
|
| 1094 |
+
|
| 1095 |
+
b_id_int = int(b_id) if b_id.isdigit() else idx
|
| 1096 |
+
seg_wav = segments_dir / f"{b_id_int:04d}.wav"
|
| 1097 |
+
|
| 1098 |
+
action = action_by_id.get(b_id, {})
|
| 1099 |
+
should_generate_tts = action.get("generate_vi_voiceover", True)
|
| 1100 |
+
detected_language = action.get("detected_language", labels.get(b_id, "unknown"))
|
| 1101 |
+
|
| 1102 |
+
# Skip TTS if block is preserved, skipped, or not confidently Chinese.
|
| 1103 |
+
if labels.get(b_id) == "EN_PRESERVE" or b_id in skipped_tts_ids or not should_generate_tts:
|
| 1104 |
+
print(f"[{b_id}] 🧊 Language/action: {detected_language}/{action.get('action', labels.get(b_id))}. Skipping TTS generation.")
|
| 1105 |
+
manifest_entries.append({
|
| 1106 |
+
"id": b_id_int,
|
| 1107 |
+
"start_ms": start_ms,
|
| 1108 |
+
"end_ms": end_ms,
|
| 1109 |
+
"target_duration_ms": target_duration_ms,
|
| 1110 |
+
"final_audio_path": "",
|
| 1111 |
+
"voice_used": args.voice,
|
| 1112 |
+
"status": "SKIPPED_KEEP_ORIGINAL",
|
| 1113 |
+
"source_ids": source_ids,
|
| 1114 |
+
"merged": bool(block.get("merged", False)),
|
| 1115 |
+
"merged_block_count": int(block.get("merged_block_count", 1)),
|
| 1116 |
+
"detected_language": detected_language,
|
| 1117 |
+
"action": action.get("action", "keep_original_audio"),
|
| 1118 |
+
"needs_review": bool(action.get("needs_review", False))
|
| 1119 |
+
})
|
| 1120 |
+
continue
|
| 1121 |
+
|
| 1122 |
+
norm_text = normalizer.normalize(raw_text)
|
| 1123 |
+
print(f"[{b_id}] Text: '{raw_text}' -> Normal: '{norm_text}'")
|
| 1124 |
+
|
| 1125 |
+
if not norm_text.strip():
|
| 1126 |
+
AudioSegment.silent(duration=target_duration_ms).export(str(seg_wav), format="wav")
|
| 1127 |
+
seg_files[b_id] = seg_wav
|
| 1128 |
+
manifest_entries.append({
|
| 1129 |
+
"id": b_id_int,
|
| 1130 |
+
"start_ms": start_ms,
|
| 1131 |
+
"end_ms": end_ms,
|
| 1132 |
+
"target_duration_ms": target_duration_ms,
|
| 1133 |
+
"final_audio_path": str(seg_wav),
|
| 1134 |
+
"voice_used": args.voice,
|
| 1135 |
+
"status": "OK",
|
| 1136 |
+
"source_ids": source_ids,
|
| 1137 |
+
"merged": bool(block.get("merged", False)),
|
| 1138 |
+
"merged_block_count": int(block.get("merged_block_count", 1)),
|
| 1139 |
+
"detected_language": detected_language,
|
| 1140 |
+
"action": action.get("action", "translate_voiceover"),
|
| 1141 |
+
"needs_review": bool(action.get("needs_review", False))
|
| 1142 |
+
})
|
| 1143 |
+
continue
|
| 1144 |
+
|
| 1145 |
+
if seg_wav.exists() and verify_audio_file(seg_wav):
|
| 1146 |
+
seg_files[b_id] = seg_wav
|
| 1147 |
+
manifest_entries.append({
|
| 1148 |
+
"id": b_id_int,
|
| 1149 |
+
"start_ms": start_ms,
|
| 1150 |
+
"end_ms": end_ms,
|
| 1151 |
+
"target_duration_ms": target_duration_ms,
|
| 1152 |
+
"final_audio_path": str(seg_wav),
|
| 1153 |
+
"voice_used": args.voice,
|
| 1154 |
+
"status": "OK",
|
| 1155 |
+
"source_ids": source_ids,
|
| 1156 |
+
"merged": bool(block.get("merged", False)),
|
| 1157 |
+
"merged_block_count": int(block.get("merged_block_count", 1)),
|
| 1158 |
+
"detected_language": detected_language,
|
| 1159 |
+
"action": action.get("action", "translate_voiceover"),
|
| 1160 |
+
"needs_review": bool(action.get("needs_review", False))
|
| 1161 |
+
})
|
| 1162 |
+
continue
|
| 1163 |
+
|
| 1164 |
+
success = False
|
| 1165 |
+
status = "FAILED"
|
| 1166 |
+
voice_used = args.voice
|
| 1167 |
+
plans_tried = []
|
| 1168 |
+
|
| 1169 |
+
# --- Plan 1: Normal Edge-TTS ---
|
| 1170 |
+
plans_tried.append("normal")
|
| 1171 |
+
max_attempts = tts_config.get("max_attempts_per_segment", 3)
|
| 1172 |
+
backoff = tts_config.get("retry_backoff_seconds", 2)
|
| 1173 |
+
|
| 1174 |
+
for attempt in range(max_attempts):
|
| 1175 |
+
try:
|
| 1176 |
+
# Remove if exists to ensure we don't read stale/partial file
|
| 1177 |
+
if Path(seg_wav).exists():
|
| 1178 |
+
try: Path(seg_wav).unlink()
|
| 1179 |
+
except Exception: pass
|
| 1180 |
+
|
| 1181 |
+
synthesize_text_to_wav(norm_text, seg_wav, args, tts_config, piper_voice=piper_voice, omnivoice_session=omnivoice_session)
|
| 1182 |
+
|
| 1183 |
+
if verify_audio_file(seg_wav, ffmpeg_path=args.ffmpeg_path):
|
| 1184 |
+
success = True
|
| 1185 |
+
status = "OK" if attempt == 0 else "RETRIED_OK"
|
| 1186 |
+
break
|
| 1187 |
+
else:
|
| 1188 |
+
print(f"Warning: Segment {b_id} Plan 1 attempt {attempt+1} returned empty or invalid audio", file=sys.stderr)
|
| 1189 |
+
if attempt < max_attempts - 1:
|
| 1190 |
+
time.sleep(backoff * (attempt + 1))
|
| 1191 |
+
except Exception as e:
|
| 1192 |
+
print(f"Warning: Segment {b_id} Plan 1 (normal) attempt {attempt+1} failed: {e}", file=sys.stderr)
|
| 1193 |
+
if attempt < max_attempts - 1:
|
| 1194 |
+
time.sleep(backoff * (attempt + 1))
|
| 1195 |
+
|
| 1196 |
+
# --- Plan 2: Clean text mạnh hơn ---
|
| 1197 |
+
if not success:
|
| 1198 |
+
plans_tried.append("sanitized")
|
| 1199 |
+
sanitized_text = sanitize_for_edge_tts(norm_text)
|
| 1200 |
+
if not sanitized_text:
|
| 1201 |
+
print(f"Warning: Segment {b_id} became empty after sanitization. Using silence placeholder.")
|
| 1202 |
+
AudioSegment.silent(duration=target_duration_ms).export(str(seg_wav), format="wav")
|
| 1203 |
+
success = True
|
| 1204 |
+
status = "SILENCE_PLACEHOLDER"
|
| 1205 |
+
else:
|
| 1206 |
+
for attempt in range(max_attempts):
|
| 1207 |
+
try:
|
| 1208 |
+
if Path(seg_wav).exists():
|
| 1209 |
+
try: Path(seg_wav).unlink()
|
| 1210 |
+
except Exception: pass
|
| 1211 |
+
|
| 1212 |
+
synthesize_text_to_wav(sanitized_text, seg_wav, args, tts_config, piper_voice=piper_voice, omnivoice_session=omnivoice_session)
|
| 1213 |
+
|
| 1214 |
+
if verify_audio_file(seg_wav, ffmpeg_path=args.ffmpeg_path):
|
| 1215 |
+
success = True
|
| 1216 |
+
status = "RETRIED_OK"
|
| 1217 |
+
break
|
| 1218 |
+
else:
|
| 1219 |
+
print(f"Warning: Segment {b_id} Plan 2 attempt {attempt+1} returned empty or invalid audio", file=sys.stderr)
|
| 1220 |
+
if attempt < max_attempts - 1:
|
| 1221 |
+
time.sleep(backoff * (attempt + 1))
|
| 1222 |
+
except Exception as e:
|
| 1223 |
+
print(f"Warning: Segment {b_id} Plan 2 (sanitized) attempt {attempt+1} failed: {e}", file=sys.stderr)
|
| 1224 |
+
if attempt < max_attempts - 1:
|
| 1225 |
+
time.sleep(backoff * (attempt + 1))
|
| 1226 |
+
|
| 1227 |
+
# --- Plan 3: Split segment ---
|
| 1228 |
+
if not success:
|
| 1229 |
+
plans_tried.append("split")
|
| 1230 |
+
print(f"Warning: Segment {b_id} Plan 2 failed. Attempting Plan 3 (Split segment)...")
|
| 1231 |
+
parts = split_text_in_half(norm_text)
|
| 1232 |
+
if len(parts) >= 2:
|
| 1233 |
+
part1_wav = segments_dir / f"{b_id_int:04d}_part1.wav"
|
| 1234 |
+
part2_wav = segments_dir / f"{b_id_int:04d}_part2.wav"
|
| 1235 |
+
|
| 1236 |
+
p1_success = False
|
| 1237 |
+
for attempt in range(max_attempts):
|
| 1238 |
+
try:
|
| 1239 |
+
if Path(part1_wav).exists():
|
| 1240 |
+
try: Path(part1_wav).unlink()
|
| 1241 |
+
except Exception: pass
|
| 1242 |
+
|
| 1243 |
+
synthesize_text_to_wav(parts[0], part1_wav, args, tts_config, piper_voice=piper_voice, omnivoice_session=omnivoice_session)
|
| 1244 |
+
if verify_audio_file(part1_wav, ffmpeg_path=args.ffmpeg_path):
|
| 1245 |
+
p1_success = True
|
| 1246 |
+
break
|
| 1247 |
+
else:
|
| 1248 |
+
print(f"Warning: Segment {b_id} Plan 3 Part 1 attempt {attempt+1} returned empty or invalid audio", file=sys.stderr)
|
| 1249 |
+
if attempt < max_attempts - 1:
|
| 1250 |
+
time.sleep(backoff * (attempt + 1))
|
| 1251 |
+
except Exception as e:
|
| 1252 |
+
print(f"Warning: Segment {b_id} Plan 3 Part 1 attempt {attempt+1} failed: {e}", file=sys.stderr)
|
| 1253 |
+
if attempt < max_attempts - 1:
|
| 1254 |
+
time.sleep(backoff * (attempt + 1))
|
| 1255 |
+
|
| 1256 |
+
p2_success = False
|
| 1257 |
+
if p1_success:
|
| 1258 |
+
for attempt in range(max_attempts):
|
| 1259 |
+
try:
|
| 1260 |
+
if Path(part2_wav).exists():
|
| 1261 |
+
try: Path(part2_wav).unlink()
|
| 1262 |
+
except Exception: pass
|
| 1263 |
+
|
| 1264 |
+
synthesize_text_to_wav(parts[1], part2_wav, args, tts_config, piper_voice=piper_voice, omnivoice_session=omnivoice_session)
|
| 1265 |
+
if verify_audio_file(part2_wav, ffmpeg_path=args.ffmpeg_path):
|
| 1266 |
+
p2_success = True
|
| 1267 |
+
break
|
| 1268 |
+
else:
|
| 1269 |
+
print(f"Warning: Segment {b_id} Plan 3 Part 2 attempt {attempt+1} returned empty or invalid audio", file=sys.stderr)
|
| 1270 |
+
if attempt < max_attempts - 1:
|
| 1271 |
+
time.sleep(backoff * (attempt + 1))
|
| 1272 |
+
except Exception as e:
|
| 1273 |
+
print(f"Warning: Segment {b_id} Plan 3 Part 2 attempt {attempt+1} failed: {e}", file=sys.stderr)
|
| 1274 |
+
if attempt < max_attempts - 1:
|
| 1275 |
+
time.sleep(backoff * (attempt + 1))
|
| 1276 |
+
|
| 1277 |
+
if p1_success and p2_success:
|
| 1278 |
+
try:
|
| 1279 |
+
seg1 = AudioSegment.from_file(part1_wav)
|
| 1280 |
+
seg2 = AudioSegment.from_file(part2_wav)
|
| 1281 |
+
combined = seg1 + AudioSegment.silent(duration=100) + seg2
|
| 1282 |
+
combined.export(str(seg_wav), format="wav")
|
| 1283 |
+
success = True
|
| 1284 |
+
status = "RETRIED_OK"
|
| 1285 |
+
except Exception as e:
|
| 1286 |
+
print(f"Warning: Segment {b_id} Plan 3 merge failed: {e}", file=sys.stderr)
|
| 1287 |
+
finally:
|
| 1288 |
+
for p_wav in [part1_wav, part2_wav]:
|
| 1289 |
+
if p_wav.exists():
|
| 1290 |
+
try:
|
| 1291 |
+
p_wav.unlink()
|
| 1292 |
+
except Exception:
|
| 1293 |
+
pass
|
| 1294 |
+
|
| 1295 |
+
# --- Plan 4: Voice fallback ---
|
| 1296 |
+
if not success and args.engine.lower() != "omnivoice" and tts_config.get("fallback_voice_enabled", True):
|
| 1297 |
+
plans_tried.append("fallback_voice")
|
| 1298 |
+
fallback_voices = tts_config.get("fallback_voices", ["vi-VN-NamMinhNeural", "vi-VN-HoaiMyNeural", "vi-VN-HoaiAnNeural"])
|
| 1299 |
+
candidate_voices = [v for v in fallback_voices if v.lower() != args.voice.lower()]
|
| 1300 |
+
if not candidate_voices:
|
| 1301 |
+
candidate_voices = ["vi-VN-NamMinhNeural" if args.voice != "vi-VN-NamMinhNeural" else "vi-VN-HoaiAnNeural"]
|
| 1302 |
+
|
| 1303 |
+
for fb_voice in candidate_voices:
|
| 1304 |
+
print(f"Warning: Segment {b_id} Plan 3 failed. Trying fallback voice: {fb_voice}")
|
| 1305 |
+
for attempt in range(max_attempts):
|
| 1306 |
+
try:
|
| 1307 |
+
if Path(seg_wav).exists():
|
| 1308 |
+
try: Path(seg_wav).unlink()
|
| 1309 |
+
except Exception: pass
|
| 1310 |
+
|
| 1311 |
+
synthesize_text_to_wav(norm_text, seg_wav, args, tts_config, piper_voice=piper_voice, voice_override=fb_voice, omnivoice_session=omnivoice_session)
|
| 1312 |
+
|
| 1313 |
+
if verify_audio_file(seg_wav, ffmpeg_path=args.ffmpeg_path):
|
| 1314 |
+
success = True
|
| 1315 |
+
status = f"FALLBACK_VOICE_OK ({fb_voice})"
|
| 1316 |
+
voice_used = fb_voice
|
| 1317 |
+
break
|
| 1318 |
+
else:
|
| 1319 |
+
print(f"Warning: Fallback voice {fb_voice} returned empty or invalid audio on attempt {attempt+1}", file=sys.stderr)
|
| 1320 |
+
if attempt < max_attempts - 1:
|
| 1321 |
+
time.sleep(backoff * (attempt + 1))
|
| 1322 |
+
except Exception as e:
|
| 1323 |
+
print(f"Warning: Fallback voice {fb_voice} attempt {attempt+1} failed: {e}", file=sys.stderr)
|
| 1324 |
+
if attempt < max_attempts - 1:
|
| 1325 |
+
time.sleep(backoff * (attempt + 1))
|
| 1326 |
+
if success:
|
| 1327 |
+
break
|
| 1328 |
+
|
| 1329 |
+
# --- Plan 5: Silence placeholder ---
|
| 1330 |
+
if not success:
|
| 1331 |
+
plans_tried.append("silence_placeholder")
|
| 1332 |
+
allow_silence = tts_config.get("allow_silence_placeholder_on_failed_segment", True)
|
| 1333 |
+
threshold = tts_config.get("max_failed_segments_before_fail", 5)
|
| 1334 |
+
|
| 1335 |
+
if allow_silence and len(failed_segments) < threshold:
|
| 1336 |
+
print(f"Warning: Segment {b_id} failed all plans. Creating silence placeholder.")
|
| 1337 |
+
AudioSegment.silent(duration=target_duration_ms).export(str(seg_wav), format="wav")
|
| 1338 |
+
success = True
|
| 1339 |
+
status = "SILENCE_PLACEHOLDER"
|
| 1340 |
+
|
| 1341 |
+
failed_segments.append({
|
| 1342 |
+
"id": b_id_int,
|
| 1343 |
+
"text": raw_text,
|
| 1344 |
+
"normalized_text": norm_text,
|
| 1345 |
+
"reason": "edge_tts_empty_audio",
|
| 1346 |
+
"plans_tried": plans_tried,
|
| 1347 |
+
"final_action": "silence_placeholder"
|
| 1348 |
+
})
|
| 1349 |
+
else:
|
| 1350 |
+
print(f"Error: Segment {b_id} failed to synthesize and limit of {threshold} reached.", file=sys.stderr)
|
| 1351 |
+
status = "FAILED"
|
| 1352 |
+
failed_segments.append({
|
| 1353 |
+
"id": b_id_int,
|
| 1354 |
+
"text": raw_text,
|
| 1355 |
+
"normalized_text": norm_text,
|
| 1356 |
+
"reason": "edge_tts_empty_audio",
|
| 1357 |
+
"plans_tried": plans_tried,
|
| 1358 |
+
"final_action": "failed"
|
| 1359 |
+
})
|
| 1360 |
+
write_reports(output_wav.parent, failed_segments, manifest_entries)
|
| 1361 |
+
sys.exit(4)
|
| 1362 |
+
|
| 1363 |
+
speedup_ratio_used = 1.0
|
| 1364 |
+
manifest_entries.append({
|
| 1365 |
+
"id": b_id_int,
|
| 1366 |
+
"start_ms": start_ms,
|
| 1367 |
+
"end_ms": end_ms,
|
| 1368 |
+
"target_duration_ms": target_duration_ms,
|
| 1369 |
+
"final_audio_path": str(seg_wav),
|
| 1370 |
+
"voice_used": voice_used,
|
| 1371 |
+
"status": status,
|
| 1372 |
+
"source_ids": source_ids,
|
| 1373 |
+
"merged": bool(block.get("merged", False)),
|
| 1374 |
+
"merged_block_count": int(block.get("merged_block_count", 1)),
|
| 1375 |
+
"detected_language": detected_language,
|
| 1376 |
+
"action": action.get("action", "translate_voiceover"),
|
| 1377 |
+
"needs_review": bool(action.get("needs_review", False)),
|
| 1378 |
+
"speedup_ratio": speedup_ratio_used,
|
| 1379 |
+
"final_duration_ms": None,
|
| 1380 |
+
})
|
| 1381 |
+
|
| 1382 |
+
# Time-stretch and fit voice segment if it exceeds bounds
|
| 1383 |
+
segment = AudioSegment.from_file(seg_wav)
|
| 1384 |
+
|
| 1385 |
+
# Trim leading/trailing silence
|
| 1386 |
+
try:
|
| 1387 |
+
from pydub.silence import detect_nonsilent
|
| 1388 |
+
nonsilent = detect_nonsilent(segment, min_silence_len=100, silence_thresh=-45)
|
| 1389 |
+
if nonsilent:
|
| 1390 |
+
start_trim = max(0, nonsilent[0][0] - 50)
|
| 1391 |
+
end_trim = min(len(segment), nonsilent[-1][1] + 50)
|
| 1392 |
+
segment = segment[start_trim:end_trim]
|
| 1393 |
+
except Exception:
|
| 1394 |
+
pass
|
| 1395 |
+
|
| 1396 |
+
max_allowed_ms = target_duration_ms
|
| 1397 |
+
if idx < len(blocks) - 1:
|
| 1398 |
+
next_start_ms = blocks[idx+1]["start_ms"]
|
| 1399 |
+
max_allowed_ms = next_start_ms - start_ms
|
| 1400 |
+
max_allowed_ms = max(50, max_allowed_ms - 50)
|
| 1401 |
+
|
| 1402 |
+
current_ms = len(segment)
|
| 1403 |
+
b_id_int = int(b_id) if b_id.isdigit() else idx
|
| 1404 |
+
print(f"[TTS DEBUG] seg {b_id} current_ms={current_ms} target_duration_ms={target_duration_ms} max_allowed_ms={max_allowed_ms}")
|
| 1405 |
+
|
| 1406 |
+
if current_ms > max_allowed_ms:
|
| 1407 |
+
speed_ratio = current_ms / max_allowed_ms
|
| 1408 |
+
speedup_ratio_used = round(speed_ratio, 3)
|
| 1409 |
+
max_speedup_ratio = float(tts_config.get("max_tts_speedup_ratio", 2.0))
|
| 1410 |
+
allow_truncate_overflow = bool(tts_config.get("allow_truncate_overflow", False))
|
| 1411 |
+
if speed_ratio > max_speedup_ratio:
|
| 1412 |
+
print(f"[TIMING_OVERFLOW_WARNING] Segment {b_id} exceeds timing. Compressing only to configured max speedup; no hard cut unless enabled.")
|
| 1413 |
+
speed_ratio = max_speedup_ratio
|
| 1414 |
+
|
| 1415 |
+
speedup_wav = segments_dir / f"{b_id_int:04d}_fast.wav"
|
| 1416 |
+
trim_wav = segments_dir / f"{b_id_int:04d}_trim.wav"
|
| 1417 |
+
segment.export(str(trim_wav), format="wav")
|
| 1418 |
+
cmd = [
|
| 1419 |
+
str(ffmpeg_path), "-y", "-i", str(trim_wav),
|
| 1420 |
+
"-filter:a", f"atempo={speed_ratio:.2f}",
|
| 1421 |
+
str(speedup_wav)
|
| 1422 |
+
]
|
| 1423 |
+
try:
|
| 1424 |
+
import subprocess
|
| 1425 |
+
startupinfo = None
|
| 1426 |
+
if sys.platform == 'win32':
|
| 1427 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 1428 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 1429 |
+
|
| 1430 |
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, startupinfo=startupinfo)
|
| 1431 |
+
if speedup_wav.exists() and speedup_wav.stat().st_size > 0:
|
| 1432 |
+
segment = AudioSegment.from_file(speedup_wav)
|
| 1433 |
+
segment.export(str(seg_wav), format="wav")
|
| 1434 |
+
try:
|
| 1435 |
+
speedup_wav.unlink()
|
| 1436 |
+
except Exception:
|
| 1437 |
+
pass
|
| 1438 |
+
try:
|
| 1439 |
+
trim_wav.unlink()
|
| 1440 |
+
except Exception:
|
| 1441 |
+
pass
|
| 1442 |
+
except Exception as e:
|
| 1443 |
+
print(f"Warning: Failed to stretch segment {b_id}: {e}", file=sys.stderr)
|
| 1444 |
+
|
| 1445 |
+
elif tts_config.get("pad_silence_on_underflow", True) and current_ms < target_duration_ms:
|
| 1446 |
+
# Underflow: keep base speed, pad trailing silence to fill the block's timeline slot
|
| 1447 |
+
pad_target = min(target_duration_ms, max_allowed_ms)
|
| 1448 |
+
pad_ms = pad_target - current_ms
|
| 1449 |
+
min_pad = int(tts_config.get("pad_silence_min_ms", 100))
|
| 1450 |
+
if pad_ms >= min_pad:
|
| 1451 |
+
segment = segment + AudioSegment.silent(duration=pad_ms)
|
| 1452 |
+
print(f"[TIMING_PAD] Segment {b_id} shorter than timeline. Padded {pad_ms}ms silence (kept base speed, no word cut).")
|
| 1453 |
+
|
| 1454 |
+
timing_tolerance_ms = int(tts_config.get("timing_tolerance_ms", 40))
|
| 1455 |
+
if len(segment) > max_allowed_ms + timing_tolerance_ms:
|
| 1456 |
+
allow_truncate_overflow = bool(tts_config.get("allow_truncate_overflow", False))
|
| 1457 |
+
if allow_truncate_overflow:
|
| 1458 |
+
segment = segment[:int(max_allowed_ms)]
|
| 1459 |
+
failed_segments.append({
|
| 1460 |
+
"id": b_id_int,
|
| 1461 |
+
"text": raw_text,
|
| 1462 |
+
"normalized_text": norm_text,
|
| 1463 |
+
"reason": "tts_timing_overflow_truncated",
|
| 1464 |
+
"target_duration_ms": target_duration_ms,
|
| 1465 |
+
"actual_duration_ms": len(segment),
|
| 1466 |
+
"final_action": "truncated"
|
| 1467 |
+
})
|
| 1468 |
+
else:
|
| 1469 |
+
failed_segments.append({
|
| 1470 |
+
"id": b_id_int,
|
| 1471 |
+
"text": raw_text,
|
| 1472 |
+
"normalized_text": norm_text,
|
| 1473 |
+
"reason": "tts_timing_overflow_uncut",
|
| 1474 |
+
"target_duration_ms": target_duration_ms,
|
| 1475 |
+
"actual_duration_ms": len(segment),
|
| 1476 |
+
"final_action": "kept_full_audio_needs_review"
|
| 1477 |
+
})
|
| 1478 |
+
|
| 1479 |
+
fade_ms = min(50, len(segment))
|
| 1480 |
+
segment = segment.fade_out(fade_ms)
|
| 1481 |
+
segment.export(str(seg_wav), format="wav")
|
| 1482 |
+
|
| 1483 |
+
if manifest_entries:
|
| 1484 |
+
manifest_entries[-1]["final_duration_ms"] = len(segment)
|
| 1485 |
+
manifest_entries[-1]["speedup_ratio"] = speedup_ratio_used
|
| 1486 |
+
|
| 1487 |
+
seg_files[b_id] = seg_wav
|
| 1488 |
+
|
| 1489 |
+
pct = min(100, int(((idx + 1) / total_blocks) * 100))
|
| 1490 |
+
print(f"PROGRESS: {pct}%", flush=True)
|
| 1491 |
+
|
| 1492 |
+
write_reports(output_wav.parent, failed_segments, manifest_entries)
|
| 1493 |
+
print("PROGRESS: 100%", flush=True)
|
| 1494 |
+
print(f"TTS synthesis completed successfully. Input blocks={input_block_count}, synthesized units={total_blocks}.")
|
| 1495 |
+
|
| 1496 |
+
# Unload models and release VRAM
|
| 1497 |
+
if piper_voice:
|
| 1498 |
+
try:
|
| 1499 |
+
del piper_voice
|
| 1500 |
+
except Exception:
|
| 1501 |
+
pass
|
| 1502 |
+
if omnivoice_session:
|
| 1503 |
+
omnivoice_session.close()
|
| 1504 |
+
|
| 1505 |
+
GPUResourceManager.instance().clear_vram_cache()
|
| 1506 |
+
GPUResourceManager.instance().unload_ollama_model()
|
| 1507 |
+
sys.exit(0)
|
| 1508 |
+
|
| 1509 |
+
if __name__ == "__main__":
|
| 1510 |
+
main()
|
app/core/vietnamese_text_normalizer.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
class VietnameseTextNormalizer:
|
| 6 |
+
def __init__(self, dict_path=None):
|
| 7 |
+
if dict_path is None:
|
| 8 |
+
dict_path = os.path.join(os.path.dirname(__file__), "pronunciation.json")
|
| 9 |
+
|
| 10 |
+
self.pronunciations = {}
|
| 11 |
+
if os.path.exists(dict_path):
|
| 12 |
+
try:
|
| 13 |
+
with open(dict_path, "r", encoding="utf-8") as f:
|
| 14 |
+
self.pronunciations = json.load(f)
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
# Units in Vietnamese
|
| 19 |
+
self.units = ["không", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín"]
|
| 20 |
+
|
| 21 |
+
def read_three_digits(self, chunk_str, show_zero_hundred=True):
|
| 22 |
+
"""Doc nhom 3 chu so (tram, chuc, don vi)"""
|
| 23 |
+
if len(chunk_str) < 3:
|
| 24 |
+
chunk_str = chunk_str.zfill(3)
|
| 25 |
+
|
| 26 |
+
hundred = int(chunk_str[0])
|
| 27 |
+
ten = int(chunk_str[1])
|
| 28 |
+
unit = int(chunk_str[2])
|
| 29 |
+
|
| 30 |
+
res = []
|
| 31 |
+
|
| 32 |
+
# Tram
|
| 33 |
+
if hundred > 0 or show_zero_hundred:
|
| 34 |
+
res.append(self.units[hundred])
|
| 35 |
+
res.append("trăm")
|
| 36 |
+
|
| 37 |
+
# Chuc & Don vi
|
| 38 |
+
if ten == 0:
|
| 39 |
+
if unit > 0:
|
| 40 |
+
if hundred > 0 or show_zero_hundred:
|
| 41 |
+
res.append("lẻ")
|
| 42 |
+
res.append(self.units[unit])
|
| 43 |
+
elif ten == 1:
|
| 44 |
+
res.append("mười")
|
| 45 |
+
if unit == 5:
|
| 46 |
+
res.append("lăm")
|
| 47 |
+
elif unit > 0:
|
| 48 |
+
res.append(self.units[unit])
|
| 49 |
+
else:
|
| 50 |
+
res.append(self.units[ten])
|
| 51 |
+
res.append("mươi")
|
| 52 |
+
if unit == 1:
|
| 53 |
+
res.append("mốt")
|
| 54 |
+
elif unit == 5:
|
| 55 |
+
res.append("lăm")
|
| 56 |
+
elif unit > 0:
|
| 57 |
+
res.append(self.units[unit])
|
| 58 |
+
|
| 59 |
+
return " ".join(res)
|
| 60 |
+
|
| 61 |
+
def number_to_words(self, num_str):
|
| 62 |
+
"""Chuyen doi chuoi so nguyen thanh chu chu"""
|
| 63 |
+
num_str = re.sub(r"\D", "", num_str)
|
| 64 |
+
if not num_str:
|
| 65 |
+
return ""
|
| 66 |
+
|
| 67 |
+
# Strip leading zeros except if the number is "0"
|
| 68 |
+
if len(num_str) > 1:
|
| 69 |
+
num_str = num_str.lstrip("0")
|
| 70 |
+
if not num_str:
|
| 71 |
+
num_str = "0"
|
| 72 |
+
|
| 73 |
+
if num_str == "0":
|
| 74 |
+
return "không"
|
| 75 |
+
|
| 76 |
+
# Split into chunks of 3 digits from right to left
|
| 77 |
+
chunks = []
|
| 78 |
+
while len(num_str) > 0:
|
| 79 |
+
chunks.append(num_str[-3:])
|
| 80 |
+
num_str = num_str[:-3]
|
| 81 |
+
|
| 82 |
+
units_groups = ["", "nghìn", "triệu", "tỷ"]
|
| 83 |
+
|
| 84 |
+
res_groups = []
|
| 85 |
+
for i, chunk in enumerate(chunks):
|
| 86 |
+
if int(chunk) == 0:
|
| 87 |
+
# If chunk is 000, only read if it's the only group
|
| 88 |
+
if len(chunks) == 1:
|
| 89 |
+
res_groups.append("không")
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
# Do not show "không trăm" for the leading chunk
|
| 93 |
+
show_zero_hundred = (i < len(chunks) - 1)
|
| 94 |
+
chunk_text = self.read_three_digits(chunk, show_zero_hundred)
|
| 95 |
+
|
| 96 |
+
group_name = units_groups[i % 4]
|
| 97 |
+
# Handle multiples of billion (tỷ, nghìn tỷ, triệu tỷ, tỷ tỷ...)
|
| 98 |
+
multiples_of_billion = i // 4
|
| 99 |
+
if multiples_of_billion > 0 and group_name == "":
|
| 100 |
+
group_name = "tỷ" * (multiples_of_billion + 1)
|
| 101 |
+
elif multiples_of_billion > 0:
|
| 102 |
+
group_name = f"{group_name} " + "tỷ" * multiples_of_billion
|
| 103 |
+
|
| 104 |
+
if group_name:
|
| 105 |
+
res_groups.append(f"{chunk_text} {group_name}")
|
| 106 |
+
else:
|
| 107 |
+
res_groups.append(chunk_text)
|
| 108 |
+
|
| 109 |
+
res_groups.reverse()
|
| 110 |
+
return " ".join(res_groups).strip()
|
| 111 |
+
|
| 112 |
+
def normalize_currency(self, text):
|
| 113 |
+
"""Chuyen doi ky hieu tien te nhu $50, 100$, 500k, 100.000 VND"""
|
| 114 |
+
# Match VND
|
| 115 |
+
def replace_vnd(m):
|
| 116 |
+
val = m.group(1).replace(".", "").replace(",", "")
|
| 117 |
+
words = self.number_to_words(val)
|
| 118 |
+
return f"{words} việt nam đồng" if words else m.group(0)
|
| 119 |
+
text = re.sub(r"(\d+(?:[\.,]\d+)*)\s*(?:VND|vnđ|vnd|Đồng|đồng|đ)", replace_vnd, text, flags=re.IGNORECASE)
|
| 120 |
+
|
| 121 |
+
# Match USD / $
|
| 122 |
+
def replace_usd(m):
|
| 123 |
+
val = m.group(1).replace(".", "").replace(",", "")
|
| 124 |
+
words = self.number_to_words(val)
|
| 125 |
+
return f"{words} đô la" if words else m.group(0)
|
| 126 |
+
text = re.sub(r"\$\s*(\d+(?:[\.,]\d+)*)", replace_usd, text)
|
| 127 |
+
text = re.sub(r"(\d+(?:[\.,]\d+)*)\s*(?:USD|usd|\$)", replace_usd, text, flags=re.IGNORECASE)
|
| 128 |
+
|
| 129 |
+
# Match "k" (e.g. 500k -> 500 nghin)
|
| 130 |
+
def replace_k(m):
|
| 131 |
+
val = m.group(1)
|
| 132 |
+
words = self.number_to_words(val)
|
| 133 |
+
return f"{words} nghìn" if words else m.group(0)
|
| 134 |
+
text = re.sub(r"\b(\d+)\s*k\b", replace_k, text, flags=re.IGNORECASE)
|
| 135 |
+
|
| 136 |
+
return text
|
| 137 |
+
|
| 138 |
+
def normalize_dates(self, text):
|
| 139 |
+
"""Chuyen doi ngay thang dang DD/MM/YYYY hoac YYYY-MM-DD"""
|
| 140 |
+
# DD/MM/YYYY or DD-MM-YYYY with optional preceding 'ngày'
|
| 141 |
+
def replace_date_slash(m):
|
| 142 |
+
day = self.number_to_words(m.group(2))
|
| 143 |
+
month = self.number_to_words(m.group(3))
|
| 144 |
+
year = self.number_to_words(m.group(4))
|
| 145 |
+
return f"ngày {day} tháng {month} năm {year}"
|
| 146 |
+
text = re.sub(r"(?i)(ngày\s+)?\b(\d{1,2})[-/](\d{1,2})[-/](\d{4})\b", replace_date_slash, text)
|
| 147 |
+
|
| 148 |
+
# DD/MM only if preceded by 'ngày' to avoid fraction conflict
|
| 149 |
+
def replace_day_month(m):
|
| 150 |
+
day = self.number_to_words(m.group(1))
|
| 151 |
+
month = self.number_to_words(m.group(2))
|
| 152 |
+
return f"ngày {day} tháng {month}"
|
| 153 |
+
text = re.sub(r"(?i)ngày\s+\b(\d{1,2})[-/](\d{1,2})\b", replace_day_month, text)
|
| 154 |
+
|
| 155 |
+
return text
|
| 156 |
+
|
| 157 |
+
def normalize_decimals_and_fractions(self, text):
|
| 158 |
+
"""Chuyen doi so thap phan (1.5) va phan so (1/2)"""
|
| 159 |
+
# Decimal numbers (using dot or comma like 1,5 or 1.5)
|
| 160 |
+
def replace_decimal(m):
|
| 161 |
+
whole = self.number_to_words(m.group(1))
|
| 162 |
+
dec = " ".join([self.units[int(d)] for d in m.group(2)])
|
| 163 |
+
return f"{whole} phẩy {dec}"
|
| 164 |
+
text = re.sub(r"\b(\d+)[.,](\d+)\b", replace_decimal, text)
|
| 165 |
+
|
| 166 |
+
# Fractions like 1/2
|
| 167 |
+
def replace_fraction(m):
|
| 168 |
+
num = self.number_to_words(m.group(1))
|
| 169 |
+
den = self.number_to_words(m.group(2))
|
| 170 |
+
return f"{num} phần {den}"
|
| 171 |
+
text = re.sub(r"\b(\d+)/(\d+)\b", replace_fraction, text)
|
| 172 |
+
|
| 173 |
+
return text
|
| 174 |
+
|
| 175 |
+
def normalize_abbreviations(self, text):
|
| 176 |
+
"""Dich cac tu viet tat tieng Anh / tieng Viet lay tu pronunciation.json"""
|
| 177 |
+
# We find words and match them case-insensitively
|
| 178 |
+
words = re.findall(r"\b[A-Za-z0-9%&#@+*/-]+\b", text)
|
| 179 |
+
for w in words:
|
| 180 |
+
up_w = w.upper()
|
| 181 |
+
if up_w in self.pronunciations:
|
| 182 |
+
# Use word boundary replacements to preserve surrounding strings
|
| 183 |
+
text = re.sub(rf"\b{re.escape(w)}\b", self.pronunciations[up_w], text)
|
| 184 |
+
return text
|
| 185 |
+
|
| 186 |
+
def normalize(self, text):
|
| 187 |
+
if not text:
|
| 188 |
+
return ""
|
| 189 |
+
|
| 190 |
+
# 0. Split number-letter boundary (e.g., 8GB -> 8 GB)
|
| 191 |
+
text = re.sub(r"(\d+)([a-zA-Z]+)", r"\1 \2", text)
|
| 192 |
+
|
| 193 |
+
# 1. Normalize dates
|
| 194 |
+
text = self.normalize_dates(text)
|
| 195 |
+
|
| 196 |
+
# 2. Normalize currencies
|
| 197 |
+
text = self.normalize_currency(text)
|
| 198 |
+
|
| 199 |
+
# 3. Normalize decimals and fractions
|
| 200 |
+
text = self.normalize_decimals_and_fractions(text)
|
| 201 |
+
|
| 202 |
+
# 4. Normalize standalone numbers
|
| 203 |
+
def replace_numbers(m):
|
| 204 |
+
return self.number_to_words(m.group(1))
|
| 205 |
+
text = re.sub(r"\b(\d+)\b", replace_numbers, text)
|
| 206 |
+
|
| 207 |
+
# 5. Normalize abbreviations / symbols
|
| 208 |
+
text = self.normalize_abbreviations(text)
|
| 209 |
+
|
| 210 |
+
# 6. Clean up extra spaces
|
| 211 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 212 |
+
|
| 213 |
+
return text
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
normalizer = VietnameseTextNormalizer()
|
| 217 |
+
test_cases = [
|
| 218 |
+
"Sản phẩm này có giá $50, giảm còn 500k VND.",
|
| 219 |
+
"Tôi mua card đồ họa RTX 5060 chạy VRAM 8GB với giá 8.500.000đ.",
|
| 220 |
+
"Hôm nay là ngày 16/06/2026.",
|
| 221 |
+
"Tỉ lệ thành công đạt 98.5% với phân số 1/3.",
|
| 222 |
+
"Gửi API request qua giao thức HTTPS."
|
| 223 |
+
]
|
| 224 |
+
for tc in test_cases:
|
| 225 |
+
print(f"Original: {tc}")
|
| 226 |
+
print(f"Normalized: {normalizer.normalize(tc)}")
|
| 227 |
+
print("-" * 30)
|
app/main.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/ocr_engine.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import re
|
| 4 |
+
import subprocess
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
def is_cuda_fully_functional():
|
| 8 |
+
"""Checks if CUDA is fully available and functional by running a quick subprocess check"""
|
| 9 |
+
try:
|
| 10 |
+
# Run in a separate process to avoid importing torch in the main GUI process
|
| 11 |
+
cmd = [
|
| 12 |
+
sys.executable, "-c",
|
| 13 |
+
"import torch; import torch.nn as nn; "
|
| 14 |
+
"print(torch.cuda.is_available() and len(torch.cuda.get_arch_list()) > 0 and float(nn.Conv2d(1, 1, 3).cuda()(torch.randn(1, 1, 8, 8).cuda()).to('cpu')[0,0,0,0]) is not None)"
|
| 15 |
+
]
|
| 16 |
+
startupinfo = None
|
| 17 |
+
if sys.platform == 'win32':
|
| 18 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 19 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 20 |
+
|
| 21 |
+
res = subprocess.run(
|
| 22 |
+
cmd,
|
| 23 |
+
capture_output=True,
|
| 24 |
+
text=True,
|
| 25 |
+
encoding="utf-8",
|
| 26 |
+
errors="ignore",
|
| 27 |
+
timeout=5,
|
| 28 |
+
startupinfo=startupinfo
|
| 29 |
+
)
|
| 30 |
+
return res.stdout.strip() == "True"
|
| 31 |
+
except Exception:
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
def extract_subtitles_from_video(video_path, blur_region, output_srt_path, log_fn=None):
|
| 35 |
+
"""
|
| 36 |
+
Subprocess wrapper for subtitle extraction using PaddleOCR.
|
| 37 |
+
This prevents importing torch/paddleocr in the main process.
|
| 38 |
+
"""
|
| 39 |
+
if not blur_region:
|
| 40 |
+
raise ValueError("Bạn phải khoanh vùng phụ đề trước khi chạy chế độ quét chữ!")
|
| 41 |
+
|
| 42 |
+
video_path = Path(video_path)
|
| 43 |
+
output_srt_path = Path(output_srt_path)
|
| 44 |
+
|
| 45 |
+
# Format region: x,y,w,h,orig_w,orig_h
|
| 46 |
+
region_str = ",".join(map(str, blur_region))
|
| 47 |
+
|
| 48 |
+
worker_script = Path(__file__).parent / "core" / "ocr_worker_cli.py"
|
| 49 |
+
|
| 50 |
+
cmd = [
|
| 51 |
+
sys.executable,
|
| 52 |
+
str(worker_script),
|
| 53 |
+
"--video", str(video_path),
|
| 54 |
+
"--output", str(output_srt_path),
|
| 55 |
+
"--region", region_str
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
if log_fn:
|
| 59 |
+
log_fn(f"🚀 Khởi chạy quét phụ đề trong subprocess...")
|
| 60 |
+
|
| 61 |
+
startupinfo = None
|
| 62 |
+
if sys.platform == 'win32':
|
| 63 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 64 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
process = subprocess.Popen(
|
| 68 |
+
cmd,
|
| 69 |
+
stdout=subprocess.PIPE,
|
| 70 |
+
stderr=subprocess.STDOUT,
|
| 71 |
+
text=True,
|
| 72 |
+
encoding="utf-8",
|
| 73 |
+
errors="ignore",
|
| 74 |
+
startupinfo=startupinfo
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Parse output line-by-line
|
| 78 |
+
for line in process.stdout:
|
| 79 |
+
line_str = line.strip()
|
| 80 |
+
if not line_str:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
# Parse progress: PROGRESS: XX%
|
| 84 |
+
m = re.match(r"PROGRESS:\s*(\d+)%", line_str)
|
| 85 |
+
if m:
|
| 86 |
+
pct = int(m.group(1))
|
| 87 |
+
filled_len = int(20 * pct / 100)
|
| 88 |
+
bar = "█" * filled_len + "░" * (20 - filled_len)
|
| 89 |
+
if log_fn:
|
| 90 |
+
# Output unified single-line progress bar
|
| 91 |
+
log_fn(f"\r[STAGE A] Processing Video: [{bar}] {pct}% | Scanning frame data...")
|
| 92 |
+
else:
|
| 93 |
+
if log_fn:
|
| 94 |
+
log_fn(line_str)
|
| 95 |
+
|
| 96 |
+
process.wait()
|
| 97 |
+
|
| 98 |
+
if process.returncode != 0:
|
| 99 |
+
raise Exception(f"Subprocess OCR failed with exit code {process.returncode}")
|
| 100 |
+
|
| 101 |
+
return True
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
if log_fn:
|
| 105 |
+
log_fn(f"❌ Lỗi quét OCR: {e}")
|
| 106 |
+
raise
|
app/region_selector.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RegionSelectorDialog — Dialog chọn vùng che sub với scrubber toàn video.
|
| 3 |
+
|
| 4 |
+
Nâng cấp so với bản cũ:
|
| 5 |
+
- Thanh scrubber QSlider trượt toàn bộ video để kiểm tra vùng che ở mọi frame
|
| 6 |
+
- Nút Play/Pause để xem tự động (25fps qua QTimer + seek, không decode realtime)
|
| 7 |
+
- Phím tắt: Space=Play/Dừng, ←=Lùi frame, →=Tiến frame, Enter=Lưu
|
| 8 |
+
- Overlay: vùng đã lưu fill đỏ mờ + label "✓ Vùng che"; đang vẽ fill vàng nét đứt
|
| 9 |
+
- Giữ VideoCapture mở xuyên suốt dialog → seek nhanh; release khi đóng
|
| 10 |
+
|
| 11 |
+
Backward compat: API giống bản cũ (constructor, self.region, accept/reject).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
from PyQt6.QtWidgets import (
|
| 16 |
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QMessageBox
|
| 17 |
+
)
|
| 18 |
+
from PyQt6.QtCore import Qt, QTimer
|
| 19 |
+
from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QColor
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RegionSelectorDialog(QDialog):
|
| 23 |
+
"""
|
| 24 |
+
Dialog chọn vùng che sub.
|
| 25 |
+
|
| 26 |
+
Kết quả:
|
| 27 |
+
self.region = (x, y, w, h, orig_video_w, orig_video_h) — pixel gốc video
|
| 28 |
+
self.region = None nếu chưa vẽ hoặc bỏ qua
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
video_path,
|
| 34 |
+
parent=None,
|
| 35 |
+
is_batch: bool = False,
|
| 36 |
+
current_idx: int = 1,
|
| 37 |
+
total_videos: int = 1,
|
| 38 |
+
):
|
| 39 |
+
super().__init__(parent)
|
| 40 |
+
|
| 41 |
+
if is_batch:
|
| 42 |
+
self.setWindowTitle(
|
| 43 |
+
f"[{current_idx}/{total_videos}] Kéo thả chọn vùng che Sub "
|
| 44 |
+
"— Trượt / Phát để kiểm tra toàn video"
|
| 45 |
+
)
|
| 46 |
+
else:
|
| 47 |
+
self.setWindowTitle(
|
| 48 |
+
"Kéo thả chuột để chọn vùng che Sub "
|
| 49 |
+
"— Trượt / Phát để kiểm tra toàn video (X để lưu)"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
self.setModal(True)
|
| 53 |
+
self.video_path = str(video_path)
|
| 54 |
+
self.region = None
|
| 55 |
+
|
| 56 |
+
# ── Mở VideoCapture (giữ mở suốt dialog) ────────────────────────
|
| 57 |
+
self._cap = cv2.VideoCapture(self.video_path)
|
| 58 |
+
if not self._cap.isOpened():
|
| 59 |
+
QMessageBox.critical(self, "Lỗi", f"Không mở được video:\n{self.video_path}")
|
| 60 |
+
super().reject()
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
self._total_frames = max(1, int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)))
|
| 64 |
+
self._fps = self._cap.get(cv2.CAP_PROP_FPS) or 25.0
|
| 65 |
+
self.orig_w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 66 |
+
self.orig_h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 67 |
+
|
| 68 |
+
# Load frame giữa video
|
| 69 |
+
mid = self._total_frames // 2
|
| 70 |
+
frame = self._read_frame(mid)
|
| 71 |
+
if frame is None:
|
| 72 |
+
QMessageBox.critical(self, "Lỗi", "Không thể đọc khung hình video!")
|
| 73 |
+
self._cap.release()
|
| 74 |
+
super().reject()
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
# ── Display state ────────────────────────────────────────────────
|
| 78 |
+
self.MAX_W = 1280
|
| 79 |
+
self.MAX_H = 720
|
| 80 |
+
self._current_frame_idx = mid
|
| 81 |
+
self.disp_w = 1
|
| 82 |
+
self.disp_h = 1
|
| 83 |
+
self.scale_x = 1.0
|
| 84 |
+
self.scale_y = 1.0
|
| 85 |
+
|
| 86 |
+
# ── Drawing state ────────────────────────────────────────────────
|
| 87 |
+
self.start_point = None
|
| 88 |
+
self.end_point = None
|
| 89 |
+
self.drawing = False
|
| 90 |
+
|
| 91 |
+
# ── Widgets ──────────────────────────────────────────────────────
|
| 92 |
+
self.label = QLabel()
|
| 93 |
+
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
| 94 |
+
self.label.setCursor(Qt.CursorShape.CrossCursor)
|
| 95 |
+
self.label.mousePressEvent = self.mouse_press
|
| 96 |
+
self.label.mouseMoveEvent = self.mouse_move
|
| 97 |
+
self.label.mouseReleaseEvent = self.mouse_release
|
| 98 |
+
self.label.paintEvent = self.paint_label
|
| 99 |
+
|
| 100 |
+
hint = QLabel(
|
| 101 |
+
"📢 Kéo chuột vẽ vùng che (hình chữ nhật đỏ). "
|
| 102 |
+
"▸ Trượt thanh để kiểm tra mọi frame. "
|
| 103 |
+
"▶ Nút Phát xem tự động. "
|
| 104 |
+
"[Space]=Play/Dừng [←→]=Lùi/Tiến frame [Enter]=Lưu"
|
| 105 |
+
)
|
| 106 |
+
hint.setWordWrap(True)
|
| 107 |
+
hint.setStyleSheet("font-size:12px; padding:4px; color:#ccc; background:#1a1a1e;")
|
| 108 |
+
|
| 109 |
+
# ── Scrubber row ─────────────────────────────────────────────────
|
| 110 |
+
self._playing = False
|
| 111 |
+
|
| 112 |
+
self.btn_play = QPushButton("▶ Phát")
|
| 113 |
+
self.btn_play.setFixedWidth(100)
|
| 114 |
+
self._style_play_btn(playing=False)
|
| 115 |
+
self.btn_play.clicked.connect(self._toggle_play)
|
| 116 |
+
|
| 117 |
+
self.lbl_time = QLabel("00:00:00")
|
| 118 |
+
self.lbl_time.setFixedWidth(72)
|
| 119 |
+
self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
| 120 |
+
self.lbl_time.setStyleSheet("font-family:monospace; font-size:12px; color:#aaa;")
|
| 121 |
+
|
| 122 |
+
self.slider = QSlider(Qt.Orientation.Horizontal)
|
| 123 |
+
self.slider.setMinimum(0)
|
| 124 |
+
self.slider.setMaximum(self._total_frames - 1)
|
| 125 |
+
self.slider.setValue(mid)
|
| 126 |
+
self.slider.setStyleSheet("""
|
| 127 |
+
QSlider::groove:horizontal {
|
| 128 |
+
height: 6px; background: #3a3a3a; border-radius: 3px;
|
| 129 |
+
}
|
| 130 |
+
QSlider::handle:horizontal {
|
| 131 |
+
background: #e74c3c; border: none;
|
| 132 |
+
width: 14px; height: 14px; margin: -4px 0; border-radius: 7px;
|
| 133 |
+
}
|
| 134 |
+
QSlider::sub-page:horizontal {
|
| 135 |
+
background: #c0392b; border-radius: 3px;
|
| 136 |
+
}
|
| 137 |
+
""")
|
| 138 |
+
self.slider.valueChanged.connect(self._on_slider_changed)
|
| 139 |
+
self.slider.sliderPressed.connect(self._pause)
|
| 140 |
+
|
| 141 |
+
total_sec = self._total_frames / self._fps
|
| 142 |
+
mm, ss = divmod(int(total_sec), 60)
|
| 143 |
+
hh, mm = divmod(mm, 60)
|
| 144 |
+
lbl_total = QLabel(f"{hh:02d}:{mm:02d}:{ss:02d}")
|
| 145 |
+
lbl_total.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
| 146 |
+
lbl_total.setStyleSheet("font-family:monospace; font-size:12px; color:#555;")
|
| 147 |
+
lbl_total.setFixedWidth(72)
|
| 148 |
+
|
| 149 |
+
scrub_row = QHBoxLayout()
|
| 150 |
+
scrub_row.addWidget(self.btn_play)
|
| 151 |
+
scrub_row.addWidget(self.lbl_time)
|
| 152 |
+
scrub_row.addWidget(self.slider, stretch=1)
|
| 153 |
+
scrub_row.addWidget(lbl_total)
|
| 154 |
+
|
| 155 |
+
# ── Play timer ────────────────────────────────────────────────────
|
| 156 |
+
self._timer = QTimer(self)
|
| 157 |
+
self._timer.setInterval(int(1000 / min(self._fps, 25)))
|
| 158 |
+
self._timer.timeout.connect(self._advance_frame)
|
| 159 |
+
|
| 160 |
+
# ── Main layout ───────────────────────────────────────────────────
|
| 161 |
+
layout = QVBoxLayout(self)
|
| 162 |
+
layout.setSpacing(6)
|
| 163 |
+
layout.addWidget(hint)
|
| 164 |
+
layout.addWidget(self.label)
|
| 165 |
+
layout.addLayout(scrub_row)
|
| 166 |
+
|
| 167 |
+
# ── Batch mode buttons ────────────────────────────────────────────
|
| 168 |
+
self.is_batch = is_batch
|
| 169 |
+
if is_batch:
|
| 170 |
+
btn_layout = QHBoxLayout()
|
| 171 |
+
|
| 172 |
+
self.btn_skip = QPushButton("⏭️ Bỏ qua (Không che)")
|
| 173 |
+
self.btn_skip.setStyleSheet(
|
| 174 |
+
"background-color:#7F8C8D; color:white; padding:10px; "
|
| 175 |
+
"font-weight:bold; font-size:14px;"
|
| 176 |
+
)
|
| 177 |
+
self.btn_skip.clicked.connect(self._reject_and_stop)
|
| 178 |
+
|
| 179 |
+
label_next = "✅ OK, Video cuối!" if current_idx == total_videos else "✅ OK, đã che xong → Next"
|
| 180 |
+
self.btn_next = QPushButton(label_next)
|
| 181 |
+
self.btn_next.setStyleSheet(
|
| 182 |
+
"background-color:#27AE60; color:white; padding:10px; "
|
| 183 |
+
"font-weight:bold; font-size:14px;"
|
| 184 |
+
)
|
| 185 |
+
self.btn_next.clicked.connect(self._accept_and_stop)
|
| 186 |
+
|
| 187 |
+
btn_layout.addWidget(self.btn_skip)
|
| 188 |
+
btn_layout.addWidget(self.btn_next)
|
| 189 |
+
layout.addLayout(btn_layout)
|
| 190 |
+
|
| 191 |
+
# Load frame đầu tiên
|
| 192 |
+
self._set_frame(mid)
|
| 193 |
+
|
| 194 |
+
# ── Video helpers ─────────────────────────────────────────────────────
|
| 195 |
+
|
| 196 |
+
def _read_frame(self, frame_idx: int):
|
| 197 |
+
"""Seek và đọc frame, trả về numpy RGB hoặc None."""
|
| 198 |
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
| 199 |
+
ret, frame = self._cap.read()
|
| 200 |
+
if not ret or frame is None:
|
| 201 |
+
return None
|
| 202 |
+
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 203 |
+
|
| 204 |
+
def _set_frame(self, frame_idx: int):
|
| 205 |
+
"""Seek đến frame_idx, cập nhật label + slider + timestamp."""
|
| 206 |
+
frame_idx = max(0, min(frame_idx, self._total_frames - 1))
|
| 207 |
+
self._current_frame_idx = frame_idx
|
| 208 |
+
|
| 209 |
+
frame = self._read_frame(frame_idx)
|
| 210 |
+
if frame is None:
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
h, w, ch = frame.shape
|
| 214 |
+
scaled = QPixmap.fromImage(
|
| 215 |
+
QImage(frame.data, w, h, ch * w, QImage.Format.Format_RGB888)
|
| 216 |
+
).scaled(
|
| 217 |
+
self.MAX_W, self.MAX_H,
|
| 218 |
+
Qt.AspectRatioMode.KeepAspectRatio,
|
| 219 |
+
Qt.TransformationMode.SmoothTransformation,
|
| 220 |
+
)
|
| 221 |
+
self.disp_w = scaled.width()
|
| 222 |
+
self.disp_h = scaled.height()
|
| 223 |
+
self.scale_x = self.orig_w / self.disp_w
|
| 224 |
+
self.scale_y = self.orig_h / self.disp_h
|
| 225 |
+
|
| 226 |
+
self.label.setPixmap(scaled)
|
| 227 |
+
self.label.setFixedSize(self.disp_w, self.disp_h)
|
| 228 |
+
self.label.update()
|
| 229 |
+
|
| 230 |
+
self.slider.blockSignals(True)
|
| 231 |
+
self.slider.setValue(frame_idx)
|
| 232 |
+
self.slider.blockSignals(False)
|
| 233 |
+
|
| 234 |
+
sec = frame_idx / self._fps
|
| 235 |
+
mm, ss = divmod(int(sec), 60)
|
| 236 |
+
hh, mm = divmod(mm, 60)
|
| 237 |
+
self.lbl_time.setText(f"{hh:02d}:{mm:02d}:{ss:02d}")
|
| 238 |
+
|
| 239 |
+
# ── Playback ──────────────────────────────────────────────────────────
|
| 240 |
+
|
| 241 |
+
def _style_play_btn(self, playing: bool):
|
| 242 |
+
if playing:
|
| 243 |
+
self.btn_play.setText("⏸ Tạm dừng")
|
| 244 |
+
self.btn_play.setStyleSheet(
|
| 245 |
+
"background:#e67e22; color:#000; font-weight:bold; border-radius:4px; padding:4px;"
|
| 246 |
+
)
|
| 247 |
+
else:
|
| 248 |
+
self.btn_play.setText("▶ Phát")
|
| 249 |
+
self.btn_play.setStyleSheet(
|
| 250 |
+
"background:#2ecc71; color:#000; font-weight:bold; border-radius:4px; padding:4px;"
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
def _on_slider_changed(self, value: int):
|
| 254 |
+
self._set_frame(value)
|
| 255 |
+
|
| 256 |
+
def _toggle_play(self):
|
| 257 |
+
if self._playing:
|
| 258 |
+
self._pause()
|
| 259 |
+
else:
|
| 260 |
+
self._play()
|
| 261 |
+
|
| 262 |
+
def _play(self):
|
| 263 |
+
self._playing = True
|
| 264 |
+
self._style_play_btn(playing=True)
|
| 265 |
+
self._timer.start()
|
| 266 |
+
|
| 267 |
+
def _pause(self):
|
| 268 |
+
self._playing = False
|
| 269 |
+
self._timer.stop()
|
| 270 |
+
self._style_play_btn(playing=False)
|
| 271 |
+
|
| 272 |
+
def _advance_frame(self):
|
| 273 |
+
"""Timer tick: tiến 1 frame, loop khi hết."""
|
| 274 |
+
nxt = self._current_frame_idx + 1
|
| 275 |
+
if nxt >= self._total_frames:
|
| 276 |
+
nxt = 0
|
| 277 |
+
self._set_frame(nxt)
|
| 278 |
+
|
| 279 |
+
# ── Coordinate normalization ──────────────────────────────────────────
|
| 280 |
+
|
| 281 |
+
def _display_geometry(self):
|
| 282 |
+
"""Return (off_x, off_y, disp_w, disp_h) — where the video pixmap actually
|
| 283 |
+
sits inside the QLabel, accounting for any centered letterbox/pillarbox
|
| 284 |
+
margin so mouse coordinates are never measured against the black bars."""
|
| 285 |
+
label_w = self.label.width()
|
| 286 |
+
label_h = self.label.height()
|
| 287 |
+
off_x = max(0, (label_w - self.disp_w) // 2)
|
| 288 |
+
off_y = max(0, (label_h - self.disp_h) // 2)
|
| 289 |
+
return off_x, off_y, self.disp_w, self.disp_h
|
| 290 |
+
|
| 291 |
+
def _ui_to_native(self, ux, uy):
|
| 292 |
+
"""Convert UI (label-local) coords -> normalized (0.0..1.0) coords ->
|
| 293 |
+
native video frame coords. Logs the full chain for debugging:
|
| 294 |
+
[UI Cords] -> [Normalized Cords] -> [Native Frame Cords]."""
|
| 295 |
+
off_x, off_y, dw, dh = self._display_geometry()
|
| 296 |
+
px = max(0.0, min(float(ux - off_x), float(dw)))
|
| 297 |
+
py = max(0.0, min(float(uy - off_y), float(dh)))
|
| 298 |
+
norm_x = px / dw if dw > 0 else 0.0
|
| 299 |
+
norm_y = py / dh if dh > 0 else 0.0
|
| 300 |
+
real_x = int(round(norm_x * self.orig_w))
|
| 301 |
+
real_y = int(round(norm_y * self.orig_h))
|
| 302 |
+
print(
|
| 303 |
+
f"[UI Cords] ({ux:.1f}, {uy:.1f}) -> "
|
| 304 |
+
f"[Normalized Cords] ({norm_x:.4f}, {norm_y:.4f}) -> "
|
| 305 |
+
f"[Native Frame Cords] ({real_x}, {real_y})"
|
| 306 |
+
)
|
| 307 |
+
return real_x, real_y
|
| 308 |
+
|
| 309 |
+
def _native_to_display(self, nx, ny):
|
| 310 |
+
"""Convert native frame coords back to label-local display coords."""
|
| 311 |
+
off_x, off_y, dw, dh = self._display_geometry()
|
| 312 |
+
dx = off_x + int(round(nx / self.orig_w * dw)) if self.orig_w else off_x
|
| 313 |
+
dy = off_y + int(round(ny / self.orig_h * dh)) if self.orig_h else off_y
|
| 314 |
+
return dx, dy
|
| 315 |
+
|
| 316 |
+
# ── Drawing ───────────────────────────────────────────────────────────
|
| 317 |
+
|
| 318 |
+
def mouse_press(self, event):
|
| 319 |
+
self._pause()
|
| 320 |
+
self.start_point = event.position().toPoint()
|
| 321 |
+
self.end_point = self.start_point
|
| 322 |
+
self.drawing = True
|
| 323 |
+
self.label.update()
|
| 324 |
+
|
| 325 |
+
def mouse_move(self, event):
|
| 326 |
+
if self.drawing:
|
| 327 |
+
self.end_point = event.position().toPoint()
|
| 328 |
+
self.label.update()
|
| 329 |
+
|
| 330 |
+
def mouse_release(self, event):
|
| 331 |
+
self.end_point = event.position().toPoint()
|
| 332 |
+
self.drawing = False
|
| 333 |
+
self.label.update()
|
| 334 |
+
|
| 335 |
+
off_x, off_y, dw, dh = self._display_geometry()
|
| 336 |
+
x1 = max(off_x, min(min(self.start_point.x(), self.end_point.x()), off_x + dw))
|
| 337 |
+
y1 = max(off_y, min(min(self.start_point.y(), self.end_point.y()), off_y + dh))
|
| 338 |
+
x2 = max(off_x, min(max(self.start_point.x(), self.end_point.x()), off_x + dw))
|
| 339 |
+
y2 = max(off_y, min(max(self.start_point.y(), self.end_point.y()), off_y + dh))
|
| 340 |
+
|
| 341 |
+
orig_x, orig_y = self._ui_to_native(x1, y1)
|
| 342 |
+
rx2, ry2 = self._ui_to_native(x2, y2)
|
| 343 |
+
orig_rw = max(1, rx2 - orig_x)
|
| 344 |
+
orig_rh = max(1, ry2 - orig_y)
|
| 345 |
+
|
| 346 |
+
if orig_rw > 0 and orig_rh > 0:
|
| 347 |
+
self.region = (orig_x, orig_y, orig_rw, orig_rh, self.orig_w, self.orig_h)
|
| 348 |
+
print(
|
| 349 |
+
f"[REGION] stored native region: x={orig_x}, y={orig_y}, "
|
| 350 |
+
f"w={orig_rw}, h={orig_rh} (frame {self.orig_w}x{self.orig_h})"
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
def keyPressEvent(self, event):
|
| 354 |
+
k = event.key()
|
| 355 |
+
if k in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
| 356 |
+
self._accept_and_stop()
|
| 357 |
+
elif k == Qt.Key.Key_Space:
|
| 358 |
+
self._toggle_play()
|
| 359 |
+
elif k == Qt.Key.Key_Left:
|
| 360 |
+
self._pause()
|
| 361 |
+
self._set_frame(self._current_frame_idx - 1)
|
| 362 |
+
elif k == Qt.Key.Key_Right:
|
| 363 |
+
self._pause()
|
| 364 |
+
self._set_frame(self._current_frame_idx + 1)
|
| 365 |
+
else:
|
| 366 |
+
super().keyPressEvent(event)
|
| 367 |
+
|
| 368 |
+
def paint_label(self, event):
|
| 369 |
+
"""Overlay: vùng đã lưu (đỏ mờ) + đang vẽ (vàng nét đứt)."""
|
| 370 |
+
QLabel.paintEvent(self.label, event)
|
| 371 |
+
painter = QPainter(self.label)
|
| 372 |
+
_off_x, _off_y, _dw, _dh = self._display_geometry()
|
| 373 |
+
|
| 374 |
+
# Vùng đã lưu
|
| 375 |
+
if self.region:
|
| 376 |
+
rx, ry, rw, rh, _vw, _vh = self.region
|
| 377 |
+
dx, dy = self._native_to_display(rx, ry)
|
| 378 |
+
dx2, dy2 = self._native_to_display(rx + rw, ry + rh)
|
| 379 |
+
dw = dx2 - dx
|
| 380 |
+
dh = dy2 - dy
|
| 381 |
+
painter.fillRect(dx, dy, dw, dh, QColor(220, 0, 0, 70))
|
| 382 |
+
painter.setPen(QPen(QColor(255, 60, 60), 2, Qt.PenStyle.SolidLine))
|
| 383 |
+
painter.drawRect(dx, dy, dw, dh)
|
| 384 |
+
painter.setPen(QColor(255, 220, 0))
|
| 385 |
+
painter.drawText(dx + 4, dy + 15, "✓ Vùng che")
|
| 386 |
+
|
| 387 |
+
# Đang vẽ
|
| 388 |
+
if self.start_point and self.end_point and self.drawing:
|
| 389 |
+
x1 = min(self.start_point.x(), self.end_point.x())
|
| 390 |
+
y1 = min(self.start_point.y(), self.end_point.y())
|
| 391 |
+
w = abs(self.start_point.x() - self.end_point.x())
|
| 392 |
+
h = abs(self.start_point.y() - self.end_point.y())
|
| 393 |
+
painter.fillRect(x1, y1, w, h, QColor(255, 255, 0, 45))
|
| 394 |
+
painter.setPen(QPen(QColor(255, 230, 0), 1, Qt.PenStyle.DashLine))
|
| 395 |
+
painter.drawRect(x1, y1, w, h)
|
| 396 |
+
|
| 397 |
+
painter.end()
|
| 398 |
+
|
| 399 |
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
| 400 |
+
|
| 401 |
+
def _release(self):
|
| 402 |
+
self._pause()
|
| 403 |
+
if hasattr(self, "_cap") and self._cap.isOpened():
|
| 404 |
+
self._cap.release()
|
| 405 |
+
|
| 406 |
+
def _accept_and_stop(self):
|
| 407 |
+
self._release()
|
| 408 |
+
super().accept()
|
| 409 |
+
|
| 410 |
+
def _reject_and_stop(self):
|
| 411 |
+
self._release()
|
| 412 |
+
super().reject()
|
| 413 |
+
|
| 414 |
+
def closeEvent(self, event):
|
| 415 |
+
self._release()
|
| 416 |
+
super().closeEvent(event)
|
| 417 |
+
|
| 418 |
+
def accept(self):
|
| 419 |
+
self._release()
|
| 420 |
+
super().accept()
|
| 421 |
+
|
| 422 |
+
def reject(self):
|
| 423 |
+
self._release()
|
| 424 |
+
super().reject()
|
app/static/app.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// TRUNG SANG VIET CLOUD STUDIO - CLIENT APPLICATION LOGIC
|
| 3 |
+
// ============================================================
|
| 4 |
+
|
| 5 |
+
let currentJobId = null;
|
| 6 |
+
let selectedFilePath = null;
|
| 7 |
+
let websocket = null;
|
| 8 |
+
|
| 9 |
+
// Initialize drag & drop
|
| 10 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 11 |
+
const dropZone = document.getElementById('drop-zone');
|
| 12 |
+
if (!dropZone) return;
|
| 13 |
+
|
| 14 |
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
| 15 |
+
dropZone.addEventListener(eventName, preventDefaults, false);
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
function preventDefaults(e) {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
e.stopPropagation();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
['dragenter', 'dragover'].forEach(eventName => {
|
| 24 |
+
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
['dragleave', 'drop'].forEach(eventName => {
|
| 28 |
+
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
dropZone.addEventListener('drop', handleDrop, false);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
function handleDrop(e) {
|
| 35 |
+
const dt = e.dataTransfer;
|
| 36 |
+
const files = dt.files;
|
| 37 |
+
if (files.length > 0) {
|
| 38 |
+
uploadFile(files[0]);
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function handleFileSelected(e) {
|
| 43 |
+
if (e.target.files.length > 0) {
|
| 44 |
+
uploadFile(e.target.files[0]);
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function uploadFile(file) {
|
| 49 |
+
const dropZone = document.getElementById('drop-zone');
|
| 50 |
+
const fileInfo = document.getElementById('file-info');
|
| 51 |
+
const filenameDisplay = document.getElementById('filename-display');
|
| 52 |
+
|
| 53 |
+
dropZone.style.opacity = '0.5';
|
| 54 |
+
dropZone.style.pointerEvents = 'none';
|
| 55 |
+
|
| 56 |
+
const formData = new FormData();
|
| 57 |
+
formData.append('file', file);
|
| 58 |
+
|
| 59 |
+
try {
|
| 60 |
+
const res = await fetch('/api/upload', {
|
| 61 |
+
method: 'POST',
|
| 62 |
+
body: formData
|
| 63 |
+
});
|
| 64 |
+
const data = await res.json();
|
| 65 |
+
if (res.ok) {
|
| 66 |
+
currentJobId = data.job_id;
|
| 67 |
+
selectedFilePath = data.saved_path;
|
| 68 |
+
filenameDisplay.innerText = `✅ Đã chọn: ${file.name} (${(file.size / (1024*1024)).toFixed(1)} MB)`;
|
| 69 |
+
dropZone.style.display = 'none';
|
| 70 |
+
fileInfo.style.display = 'flex';
|
| 71 |
+
} else {
|
| 72 |
+
alert('Lỗi tải file: ' + (data.detail || 'Không xác định'));
|
| 73 |
+
}
|
| 74 |
+
} catch (err) {
|
| 75 |
+
alert('Lỗi kết nối máy chủ: ' + err.message);
|
| 76 |
+
} finally {
|
| 77 |
+
dropZone.style.opacity = '1';
|
| 78 |
+
dropZone.style.pointerEvents = 'auto';
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
async function fetchVideoFromUrl() {
|
| 83 |
+
const urlInput = document.getElementById('video-url');
|
| 84 |
+
const url = urlInput.value.trim();
|
| 85 |
+
if (!url) {
|
| 86 |
+
alert('Vui lòng nhập đường link video!');
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const btn = event.target;
|
| 91 |
+
btn.innerText = '⏳ Đang tải...';
|
| 92 |
+
btn.disabled = true;
|
| 93 |
+
|
| 94 |
+
const formData = new FormData();
|
| 95 |
+
formData.append('url', url);
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const res = await fetch('/api/download-url', {
|
| 99 |
+
method: 'POST',
|
| 100 |
+
body: formData
|
| 101 |
+
});
|
| 102 |
+
const data = await res.json();
|
| 103 |
+
if (res.ok) {
|
| 104 |
+
currentJobId = data.job_id;
|
| 105 |
+
selectedFilePath = data.saved_path;
|
| 106 |
+
alert('✅ Đã tải video từ đường link thành công!');
|
| 107 |
+
} else {
|
| 108 |
+
alert('Lỗi tải video: ' + (data.detail || 'Link không hỗ trợ'));
|
| 109 |
+
}
|
| 110 |
+
} catch (err) {
|
| 111 |
+
alert('Lỗi: ' + err.message);
|
| 112 |
+
} finally {
|
| 113 |
+
btn.innerText = 'Tải Link';
|
| 114 |
+
btn.disabled = false;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function clearSelectedFile() {
|
| 119 |
+
currentJobId = null;
|
| 120 |
+
selectedFilePath = null;
|
| 121 |
+
document.getElementById('file-input').value = '';
|
| 122 |
+
document.getElementById('drop-zone').style.display = 'block';
|
| 123 |
+
document.getElementById('file-info').style.display = 'none';
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function switchInputTab(tab) {
|
| 127 |
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
| 128 |
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
| 129 |
+
|
| 130 |
+
document.getElementById(`tab-${tab}`).classList.add('active');
|
| 131 |
+
document.getElementById(`content-${tab}`).classList.add('active');
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function updateModeUI() {
|
| 135 |
+
const selected = document.querySelector('input[name="extraction_mode"]:checked').value;
|
| 136 |
+
document.getElementById('mode-card-asr').classList.toggle('active', selected === 'asr');
|
| 137 |
+
document.getElementById('mode-card-ocr').classList.toggle('active', selected === 'ocr');
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function startCloudDubbing() {
|
| 141 |
+
if (!selectedFilePath || !currentJobId) {
|
| 142 |
+
alert('Vui lòng chọn hoặc tải lên 1 video trước khi bắt đầu!');
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const startBtn = document.getElementById('start-btn');
|
| 147 |
+
startBtn.disabled = true;
|
| 148 |
+
startBtn.innerText = '⏳ ĐANG XỬ LÝ PIPELINE...';
|
| 149 |
+
|
| 150 |
+
const mode = document.querySelector('input[name="extraction_mode"]:checked').value;
|
| 151 |
+
const voice = document.getElementById('voice-select').value;
|
| 152 |
+
const sourceLang = document.getElementById('source-lang').value;
|
| 153 |
+
const speed = document.getElementById('speed-slider').value;
|
| 154 |
+
const vol = document.getElementById('vol-slider').value;
|
| 155 |
+
|
| 156 |
+
// Show Progress Card
|
| 157 |
+
document.getElementById('progress-card').style.display = 'block';
|
| 158 |
+
document.getElementById('result-card').style.display = 'none';
|
| 159 |
+
document.getElementById('progress-card').scrollIntoView({ behavior: 'smooth' });
|
| 160 |
+
|
| 161 |
+
// Connect WebSocket for live logs
|
| 162 |
+
connectWebSocket(currentJobId);
|
| 163 |
+
|
| 164 |
+
const formData = new FormData();
|
| 165 |
+
formData.append('job_id', currentJobId);
|
| 166 |
+
formData.append('video_path', selectedFilePath);
|
| 167 |
+
formData.append('mode', mode);
|
| 168 |
+
formData.append('voice', voice);
|
| 169 |
+
formData.append('source_lang', sourceLang);
|
| 170 |
+
formData.append('speed', speed);
|
| 171 |
+
formData.append('pitch', 0);
|
| 172 |
+
formData.append('volume', vol);
|
| 173 |
+
|
| 174 |
+
try {
|
| 175 |
+
const res = await fetch('/api/start-pipeline', {
|
| 176 |
+
method: 'POST',
|
| 177 |
+
body: formData
|
| 178 |
+
});
|
| 179 |
+
const data = await res.json();
|
| 180 |
+
if (!res.ok) {
|
| 181 |
+
alert('Lỗi khởi động pipeline: ' + (data.detail || ''));
|
| 182 |
+
startBtn.disabled = false;
|
| 183 |
+
startBtn.innerText = '🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD';
|
| 184 |
+
}
|
| 185 |
+
} catch (err) {
|
| 186 |
+
alert('Lỗi kết nối: ' + err.message);
|
| 187 |
+
startBtn.disabled = false;
|
| 188 |
+
startBtn.innerText = '🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD';
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function connectWebSocket(jobId) {
|
| 193 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 194 |
+
const wsUrl = `${protocol}//${window.location.host}/ws/logs/${jobId}`;
|
| 195 |
+
|
| 196 |
+
if (websocket) {
|
| 197 |
+
websocket.close();
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
websocket = new WebSocket(wsUrl);
|
| 201 |
+
const terminal = document.getElementById('terminal-logs');
|
| 202 |
+
terminal.innerHTML = '<div class="log-entry system">[System] Kết nối Cloud Server thành công...</div>';
|
| 203 |
+
|
| 204 |
+
websocket.onmessage = (event) => {
|
| 205 |
+
const msg = JSON.parse(event.data);
|
| 206 |
+
|
| 207 |
+
if (msg.type === 'log') {
|
| 208 |
+
const entry = document.createElement('div');
|
| 209 |
+
entry.className = 'log-entry';
|
| 210 |
+
if (msg.text.includes('✅') || msg.text.includes('🎉')) entry.className += ' success';
|
| 211 |
+
if (msg.text.includes('❌')) entry.className += ' error';
|
| 212 |
+
entry.innerText = `[${msg.time}] ${msg.text}`;
|
| 213 |
+
terminal.appendChild(entry);
|
| 214 |
+
terminal.scrollTop = terminal.scrollHeight;
|
| 215 |
+
}
|
| 216 |
+
else if (msg.type === 'progress') {
|
| 217 |
+
updateProgress(msg.percent, msg.stage);
|
| 218 |
+
}
|
| 219 |
+
else if (msg.type === 'done') {
|
| 220 |
+
onPipelineComplete(msg.output_url);
|
| 221 |
+
}
|
| 222 |
+
else if (msg.type === 'error') {
|
| 223 |
+
alert('❌ Lỗi Pipeline: ' + msg.message);
|
| 224 |
+
document.getElementById('start-btn').disabled = false;
|
| 225 |
+
document.getElementById('start-btn').innerText = '🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD';
|
| 226 |
+
}
|
| 227 |
+
};
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function updateProgress(percent, stage) {
|
| 231 |
+
document.getElementById('progress-bar-fill').style.width = `${percent}%`;
|
| 232 |
+
document.getElementById('progress-percent').innerText = `${percent}%`;
|
| 233 |
+
document.getElementById('current-stage-text').innerText = `Đang xử lý: ${stage}`;
|
| 234 |
+
|
| 235 |
+
// Stepper styling
|
| 236 |
+
const stagesMap = {
|
| 237 |
+
'STAGE_0_PREPARE': 1,
|
| 238 |
+
'STAGE_A_SUBTITLE_EXTRACTION': 2,
|
| 239 |
+
'STAGE_C_TRANSLATION': 3,
|
| 240 |
+
'STAGE_TTS_DUBBING': 4,
|
| 241 |
+
'STAGE_D_AUDIO_MIX': 5,
|
| 242 |
+
'STAGE_D_RENDER': 5,
|
| 243 |
+
'DONE': 6
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
const curStep = stagesMap[stage] || 1;
|
| 247 |
+
const stepIds = ['step-prepare', 'step-asr', 'step-trans', 'step-tts', 'step-render'];
|
| 248 |
+
|
| 249 |
+
stepIds.forEach((id, idx) => {
|
| 250 |
+
const el = document.getElementById(id);
|
| 251 |
+
if (idx + 1 < curStep) {
|
| 252 |
+
el.className = 'step-item done';
|
| 253 |
+
} else if (idx + 1 === curStep) {
|
| 254 |
+
el.className = 'step-item active';
|
| 255 |
+
} else {
|
| 256 |
+
el.className = 'step-item';
|
| 257 |
+
}
|
| 258 |
+
});
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function onPipelineComplete(outputUrl) {
|
| 262 |
+
const startBtn = document.getElementById('start-btn');
|
| 263 |
+
startBtn.disabled = false;
|
| 264 |
+
startBtn.innerText = '🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD';
|
| 265 |
+
|
| 266 |
+
document.getElementById('result-card').style.display = 'block';
|
| 267 |
+
const video = document.getElementById('result-video');
|
| 268 |
+
video.src = outputUrl;
|
| 269 |
+
video.load();
|
| 270 |
+
|
| 271 |
+
const downloadBtn = document.getElementById('download-btn');
|
| 272 |
+
downloadBtn.href = outputUrl;
|
| 273 |
+
|
| 274 |
+
document.getElementById('result-card').scrollIntoView({ behavior: 'smooth' });
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function resetStudio() {
|
| 278 |
+
clearSelectedFile();
|
| 279 |
+
document.getElementById('progress-card').style.display = 'none';
|
| 280 |
+
document.getElementById('result-card').style.display = 'none';
|
| 281 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 282 |
+
}
|
app/static/index.html
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>TrungSangViet Auto Dubbing Cloud Studio</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="glow-bg"></div>
|
| 14 |
+
|
| 15 |
+
<header class="app-header">
|
| 16 |
+
<div class="header-content">
|
| 17 |
+
<div class="logo-area">
|
| 18 |
+
<div class="logo-icon">🎬</div>
|
| 19 |
+
<div>
|
| 20 |
+
<h1 class="app-title">TRUNG SÁNG VIỆT <span>CLOUD STUDIO</span></h1>
|
| 21 |
+
<p class="app-subtitle">AI Video Dubbing & Translation Automation • 100% Cloud-Native</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="status-badges">
|
| 25 |
+
<span class="badge badge-groq">⚡ Groq LPU: Ready</span>
|
| 26 |
+
<span class="badge badge-gemini">✨ Gemini 2.0: Ready</span>
|
| 27 |
+
<span class="badge badge-openrouter">🌐 OpenRouter: Ready</span>
|
| 28 |
+
<span class="badge badge-edge">🗣️ Edge-TTS: Ready</span>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</header>
|
| 32 |
+
|
| 33 |
+
<main class="container">
|
| 34 |
+
<!-- Input & Options Grid -->
|
| 35 |
+
<div class="grid-2">
|
| 36 |
+
<!-- Left Card: Video Input -->
|
| 37 |
+
<section class="card glass-card">
|
| 38 |
+
<h2 class="card-title">📥 1. Chọn Video Đầu Vào</h2>
|
| 39 |
+
|
| 40 |
+
<div class="tab-buttons">
|
| 41 |
+
<button class="tab-btn active" id="tab-upload" onclick="switchInputTab('upload')">📁 Tải File Lên</button>
|
| 42 |
+
<button class="tab-btn" id="tab-url" onclick="switchInputTab('url')">🔗 Paste Link Video (Douyin/TikTok)</button>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<!-- Tab Upload -->
|
| 46 |
+
<div id="content-upload" class="tab-content active">
|
| 47 |
+
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
| 48 |
+
<div class="drop-icon">📤</div>
|
| 49 |
+
<p class="drop-text">Kéo thả video vào đây hoặc <span>chọn từ máy/điện thoại</span></p>
|
| 50 |
+
<p class="drop-sub">Hỗ trợ định dạng MP4, MKV, MOV, WebM</p>
|
| 51 |
+
<input type="file" id="file-input" accept="video/*" style="display:none" onchange="handleFileSelected(event)">
|
| 52 |
+
</div>
|
| 53 |
+
<div id="file-info" class="file-info" style="display:none">
|
| 54 |
+
<span id="filename-display">video.mp4</span>
|
| 55 |
+
<span class="remove-btn" onclick="clearSelectedFile()">✕ Hủy</span>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- Tab URL -->
|
| 60 |
+
<div id="content-url" class="tab-content">
|
| 61 |
+
<div class="input-group">
|
| 62 |
+
<label for="video-url">Đường link video (Douyin / TikTok / YouTube / MP4 URL):</label>
|
| 63 |
+
<div class="url-input-wrap">
|
| 64 |
+
<input type="url" id="video-url" placeholder="https://v.douyin.com/... hoặc https://www.tiktok.com/@...">
|
| 65 |
+
<button class="btn btn-secondary" onclick="fetchVideoFromUrl()">Tải Link</button>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- Subtitle Mode Option -->
|
| 71 |
+
<div class="mode-options">
|
| 72 |
+
<label class="section-label">Phương thức quét phụ đề:</label>
|
| 73 |
+
<div class="radio-cards">
|
| 74 |
+
<label class="radio-card active" id="mode-card-asr">
|
| 75 |
+
<input type="radio" name="extraction_mode" value="asr" checked onchange="updateModeUI()">
|
| 76 |
+
<div class="radio-icon">🎙️</div>
|
| 77 |
+
<div class="radio-info">
|
| 78 |
+
<strong>Cloud ASR (Groq Whisper)</strong>
|
| 79 |
+
<small>Bóc tách từ âm thanh giọng nói (Khuyên dùng)</small>
|
| 80 |
+
</div>
|
| 81 |
+
</label>
|
| 82 |
+
<label class="radio-card" id="mode-card-ocr">
|
| 83 |
+
<input type="radio" name="extraction_mode" value="ocr" onchange="updateModeUI()">
|
| 84 |
+
<div class="radio-icon">👁️</div>
|
| 85 |
+
<div class="radio-info">
|
| 86 |
+
<strong>Cloud OCR (Gemini Vision)</strong>
|
| 87 |
+
<small>Quét chữ phụ đề cứng trên màn hình video</small>
|
| 88 |
+
</div>
|
| 89 |
+
</label>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</section>
|
| 93 |
+
|
| 94 |
+
<!-- Right Card: Voice & Studio Settings -->
|
| 95 |
+
<section class="card glass-card">
|
| 96 |
+
<h2 class="card-title">🎙️ 2. Cấu Hình Giọng Đọc & Dịch Thuật</h2>
|
| 97 |
+
|
| 98 |
+
<div class="form-group">
|
| 99 |
+
<label for="voice-select">Giọng đọc lồng tiếng (Microsoft Edge-TTS Cloud):</label>
|
| 100 |
+
<select id="voice-select" class="custom-select">
|
| 101 |
+
<option value="vi-VN-NamMinhNeural">🎙️ Nam Minh (Nam - Trầm ấm, chuyên nghiệp, phim tài liệu)</option>
|
| 102 |
+
<option value="vi-VN-HoaiMyNeural">🎙️ Hoài My (Nữ - Truyền cảm, ngọt ngào, review thời trang)</option>
|
| 103 |
+
</select>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="form-group">
|
| 107 |
+
<label for="source-lang">Ngôn ngữ gốc của video:</label>
|
| 108 |
+
<select id="source-lang" class="custom-select">
|
| 109 |
+
<option value="zh">🇨🇳 Tiếng Trung (Chinese - zh)</option>
|
| 110 |
+
<option value="en">🇺🇸 Tiếng Anh (English - en)</option>
|
| 111 |
+
</select>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div class="slider-grid">
|
| 115 |
+
<div class="slider-item">
|
| 116 |
+
<div class="slider-header">
|
| 117 |
+
<span>Tốc độ đọc (Speed):</span>
|
| 118 |
+
<span id="speed-val" class="val-badge">1.0x</span>
|
| 119 |
+
</div>
|
| 120 |
+
<input type="range" id="speed-slider" min="0.8" max="1.5" step="0.05" value="1.0" oninput="document.getElementById('speed-val').innerText = this.value + 'x'">
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div class="slider-item">
|
| 124 |
+
<div class="slider-header">
|
| 125 |
+
<span>Âm lượng giọng đọc:</span>
|
| 126 |
+
<span id="vol-val" class="val-badge">100%</span>
|
| 127 |
+
</div>
|
| 128 |
+
<input type="range" id="vol-slider" min="50" max="150" step="5" value="100" oninput="document.getElementById('vol-val').innerText = this.value + '%'">
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div class="preset-info-box">
|
| 133 |
+
<p>🎯 <strong>Preset tự động:</strong> Áp dụng từ điển chuyên ngành Sneaker/Fashion/Streetwear (Nike, Jordan, Yeezy, drip, fit...) + Auto Ducking giảm nhạc nền khi đọc thoại.</p>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<button class="btn btn-primary btn-glow" id="start-btn" onclick="startCloudDubbing()">
|
| 137 |
+
🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD
|
| 138 |
+
</button>
|
| 139 |
+
</section>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<!-- Live Stepper & Progress Section -->
|
| 143 |
+
<section class="card glass-card mt-4" id="progress-card" style="display:none">
|
| 144 |
+
<h2 class="card-title">⚡ Tiến Trình Xử Lý Thời Gian Thực</h2>
|
| 145 |
+
|
| 146 |
+
<!-- Stepper -->
|
| 147 |
+
<div class="stepper">
|
| 148 |
+
<div class="step-item" id="step-prepare">
|
| 149 |
+
<div class="step-circle">1</div>
|
| 150 |
+
<span class="step-label">Chuẩn bị</span>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="step-line" id="line-1"></div>
|
| 153 |
+
<div class="step-item" id="step-asr">
|
| 154 |
+
<div class="step-circle">2</div>
|
| 155 |
+
<span class="step-label">ASR / OCR</span>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="step-line" id="line-2"></div>
|
| 158 |
+
<div class="step-item" id="step-trans">
|
| 159 |
+
<div class="step-circle">3</div>
|
| 160 |
+
<span class="step-label">AI Dịch Thuật</span>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="step-line" id="line-3"></div>
|
| 163 |
+
<div class="step-item" id="step-tts">
|
| 164 |
+
<div class="step-circle">4</div>
|
| 165 |
+
<span class="step-label">TTS Lồng Tiếng</span>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="step-line" id="line-4"></div>
|
| 168 |
+
<div class="step-item" id="step-render">
|
| 169 |
+
<div class="step-circle">5</div>
|
| 170 |
+
<span class="step-label">Mix & Render</span>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<!-- Progress Bar -->
|
| 175 |
+
<div class="progress-bar-wrap">
|
| 176 |
+
<div class="progress-bar-fill" id="progress-bar-fill"></div>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="progress-text-row">
|
| 179 |
+
<span id="current-stage-text">Đang khởi tạo...</span>
|
| 180 |
+
<span id="progress-percent">0%</span>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<!-- Live Terminal Logs -->
|
| 184 |
+
<div class="terminal-logs" id="terminal-logs">
|
| 185 |
+
<div class="log-entry system">[System] Kết nối WebSocket Live Logs thành công...</div>
|
| 186 |
+
</div>
|
| 187 |
+
</section>
|
| 188 |
+
|
| 189 |
+
<!-- Result Video Player Card -->
|
| 190 |
+
<section class="card glass-card mt-4 result-card" id="result-card" style="display:none">
|
| 191 |
+
<h2 class="card-title">🎉 Kết Quả Video Thành Phẩm</h2>
|
| 192 |
+
<div class="video-player-wrap">
|
| 193 |
+
<video id="result-video" controls class="main-player"></video>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="action-buttons">
|
| 196 |
+
<a id="download-btn" href="#" download class="btn btn-success btn-glow">
|
| 197 |
+
⬇️ Tải Video Về Máy (MP4 HD)
|
| 198 |
+
</a>
|
| 199 |
+
<button class="btn btn-secondary" onclick="resetStudio()">
|
| 200 |
+
🔄 Xử Lý Video Khác
|
| 201 |
+
</button>
|
| 202 |
+
</div>
|
| 203 |
+
</section>
|
| 204 |
+
</main>
|
| 205 |
+
|
| 206 |
+
<footer class="app-footer">
|
| 207 |
+
<p>© 2026 Trung Sáng Việt Studio • Powered by Groq LPU, Google Gemini, OpenRouter & Edge-TTS Cloud</p>
|
| 208 |
+
</footer>
|
| 209 |
+
|
| 210 |
+
<script src="/static/app.js"></script>
|
| 211 |
+
</body>
|
| 212 |
+
</html>
|
app/static/style.css
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
TRUNG SANG VIET CLOUD STUDIO - MODERN CYBERPUNK STYLING
|
| 3 |
+
============================================================ */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg-main: #0a0c10;
|
| 7 |
+
--bg-card: rgba(18, 22, 32, 0.75);
|
| 8 |
+
--border-color: rgba(0, 240, 255, 0.2);
|
| 9 |
+
--border-hover: rgba(0, 240, 255, 0.6);
|
| 10 |
+
--neon-cyan: #00f0ff;
|
| 11 |
+
--neon-emerald: #10b981;
|
| 12 |
+
--neon-purple: #8b5cf6;
|
| 13 |
+
--neon-pink: #ec4899;
|
| 14 |
+
--text-main: #f3f4f6;
|
| 15 |
+
--text-muted: #9ca3af;
|
| 16 |
+
--font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 17 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
* {
|
| 21 |
+
margin: 0;
|
| 22 |
+
padding: 0;
|
| 23 |
+
box-sizing: border-box;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
background-color: var(--bg-main);
|
| 28 |
+
color: var(--text-main);
|
| 29 |
+
font-family: var(--font-heading);
|
| 30 |
+
min-height: 100vh;
|
| 31 |
+
display: flex;
|
| 32 |
+
flex-direction: column;
|
| 33 |
+
overflow-x: hidden;
|
| 34 |
+
position: relative;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Background ambient glows */
|
| 38 |
+
.glow-bg {
|
| 39 |
+
position: fixed;
|
| 40 |
+
top: 0;
|
| 41 |
+
left: 0;
|
| 42 |
+
width: 100%;
|
| 43 |
+
height: 100%;
|
| 44 |
+
background:
|
| 45 |
+
radial-gradient(circle at 15% 15%, rgba(0, 240, 255, 0.08) 0%, transparent 40%),
|
| 46 |
+
radial-gradient(circle at 85% 85%, rgba(139, 92, 246, 0.08) 0%, transparent 40%);
|
| 47 |
+
pointer-events: none;
|
| 48 |
+
z-index: -1;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Header */
|
| 52 |
+
.app-header {
|
| 53 |
+
background: rgba(10, 12, 16, 0.85);
|
| 54 |
+
backdrop-filter: blur(12px);
|
| 55 |
+
border-bottom: 1px solid var(--border-color);
|
| 56 |
+
padding: 18px 24px;
|
| 57 |
+
position: sticky;
|
| 58 |
+
top: 0;
|
| 59 |
+
z-index: 50;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.header-content {
|
| 63 |
+
max-width: 1300px;
|
| 64 |
+
margin: 0 auto;
|
| 65 |
+
display: flex;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
align-items: center;
|
| 68 |
+
flex-wrap: wrap;
|
| 69 |
+
gap: 16px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.logo-area {
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
gap: 14px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.logo-icon {
|
| 79 |
+
font-size: 32px;
|
| 80 |
+
background: linear-gradient(135deg, var(--neon-cyan), var(--neon-purple));
|
| 81 |
+
padding: 8px;
|
| 82 |
+
border-radius: 12px;
|
| 83 |
+
box-shadow: 0 0 16px rgba(0, 240, 255, 0.3);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.app-title {
|
| 87 |
+
font-size: 22px;
|
| 88 |
+
font-weight: 800;
|
| 89 |
+
letter-spacing: 0.5px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.app-title span {
|
| 93 |
+
color: var(--neon-cyan);
|
| 94 |
+
text-shadow: 0 0 10px rgba(0, 240, 255, 0.5);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.app-subtitle {
|
| 98 |
+
font-size: 13px;
|
| 99 |
+
color: var(--text-muted);
|
| 100 |
+
font-weight: 300;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.status-badges {
|
| 104 |
+
display: flex;
|
| 105 |
+
gap: 8px;
|
| 106 |
+
flex-wrap: wrap;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.badge {
|
| 110 |
+
font-size: 12px;
|
| 111 |
+
font-weight: 600;
|
| 112 |
+
padding: 6px 12px;
|
| 113 |
+
border-radius: 20px;
|
| 114 |
+
background: rgba(255, 255, 255, 0.05);
|
| 115 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 116 |
+
color: var(--text-muted);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.badge-groq { border-color: rgba(245, 158, 11, 0.4); color: #fbbf24; }
|
| 120 |
+
.badge-gemini { border-color: rgba(59, 130, 246, 0.4); color: #60a5fa; }
|
| 121 |
+
.badge-openrouter { border-color: rgba(16, 185, 129, 0.4); color: #34d399; }
|
| 122 |
+
.badge-edge { border-color: rgba(236, 72, 153, 0.4); color: #f472b6; }
|
| 123 |
+
|
| 124 |
+
/* Main Container */
|
| 125 |
+
.container {
|
| 126 |
+
max-width: 1300px;
|
| 127 |
+
margin: 30px auto;
|
| 128 |
+
padding: 0 20px;
|
| 129 |
+
flex: 1;
|
| 130 |
+
width: 100%;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.grid-2 {
|
| 134 |
+
display: grid;
|
| 135 |
+
grid-template-columns: 1fr 1fr;
|
| 136 |
+
gap: 24px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@media (max-width: 900px) {
|
| 140 |
+
.grid-2 {
|
| 141 |
+
grid-template-columns: 1fr;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/* Glass Cards */
|
| 146 |
+
.card {
|
| 147 |
+
background: var(--bg-card);
|
| 148 |
+
backdrop-filter: blur(16px);
|
| 149 |
+
border: 1px solid var(--border-color);
|
| 150 |
+
border-radius: 16px;
|
| 151 |
+
padding: 24px;
|
| 152 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
| 153 |
+
transition: all 0.3s ease;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.card:hover {
|
| 157 |
+
border-color: var(--border-hover);
|
| 158 |
+
box-shadow: 0 8px 32px rgba(0, 240, 255, 0.15);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.card-title {
|
| 162 |
+
font-size: 18px;
|
| 163 |
+
font-weight: 700;
|
| 164 |
+
margin-bottom: 20px;
|
| 165 |
+
color: var(--text-main);
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
gap: 8px;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.mt-4 {
|
| 172 |
+
margin-top: 24px;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Tabs */
|
| 176 |
+
.tab-buttons {
|
| 177 |
+
display: flex;
|
| 178 |
+
gap: 8px;
|
| 179 |
+
background: rgba(0, 0, 0, 0.3);
|
| 180 |
+
padding: 4px;
|
| 181 |
+
border-radius: 10px;
|
| 182 |
+
margin-bottom: 16px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.tab-btn {
|
| 186 |
+
flex: 1;
|
| 187 |
+
padding: 10px 14px;
|
| 188 |
+
background: transparent;
|
| 189 |
+
border: none;
|
| 190 |
+
color: var(--text-muted);
|
| 191 |
+
font-family: inherit;
|
| 192 |
+
font-weight: 600;
|
| 193 |
+
font-size: 13px;
|
| 194 |
+
border-radius: 8px;
|
| 195 |
+
cursor: pointer;
|
| 196 |
+
transition: all 0.2s ease;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.tab-btn.active {
|
| 200 |
+
background: rgba(0, 240, 255, 0.15);
|
| 201 |
+
color: var(--neon-cyan);
|
| 202 |
+
box-shadow: 0 0 12px rgba(0, 240, 255, 0.2);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.tab-content {
|
| 206 |
+
display: none;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.tab-content.active {
|
| 210 |
+
display: block;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* Drag Drop Zone */
|
| 214 |
+
.drop-zone {
|
| 215 |
+
border: 2px dashed rgba(0, 240, 255, 0.3);
|
| 216 |
+
border-radius: 12px;
|
| 217 |
+
padding: 36px 20px;
|
| 218 |
+
text-align: center;
|
| 219 |
+
background: rgba(0, 240, 255, 0.02);
|
| 220 |
+
cursor: pointer;
|
| 221 |
+
transition: all 0.3s ease;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.drop-zone:hover, .drop-zone.dragover {
|
| 225 |
+
border-color: var(--neon-cyan);
|
| 226 |
+
background: rgba(0, 240, 255, 0.08);
|
| 227 |
+
box-shadow: 0 0 20px rgba(0, 240, 255, 0.15);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.drop-icon {
|
| 231 |
+
font-size: 42px;
|
| 232 |
+
margin-bottom: 10px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.drop-text {
|
| 236 |
+
font-size: 15px;
|
| 237 |
+
font-weight: 600;
|
| 238 |
+
margin-bottom: 6px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.drop-text span {
|
| 242 |
+
color: var(--neon-cyan);
|
| 243 |
+
text-decoration: underline;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.drop-sub {
|
| 247 |
+
font-size: 12px;
|
| 248 |
+
color: var(--text-muted);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.file-info {
|
| 252 |
+
margin-top: 12px;
|
| 253 |
+
background: rgba(16, 185, 129, 0.1);
|
| 254 |
+
border: 1px solid var(--neon-emerald);
|
| 255 |
+
padding: 10px 16px;
|
| 256 |
+
border-radius: 8px;
|
| 257 |
+
display: flex;
|
| 258 |
+
justify-content: space-between;
|
| 259 |
+
align-items: center;
|
| 260 |
+
font-weight: 600;
|
| 261 |
+
color: var(--neon-emerald);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.remove-btn {
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
color: #ef4444;
|
| 267 |
+
font-size: 13px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* Input Fields */
|
| 271 |
+
.input-group label, .form-group label, .section-label {
|
| 272 |
+
display: block;
|
| 273 |
+
font-size: 13px;
|
| 274 |
+
font-weight: 600;
|
| 275 |
+
color: var(--text-muted);
|
| 276 |
+
margin-bottom: 8px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.url-input-wrap {
|
| 280 |
+
display: flex;
|
| 281 |
+
gap: 8px;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.url-input-wrap input {
|
| 285 |
+
flex: 1;
|
| 286 |
+
background: rgba(0, 0, 0, 0.4);
|
| 287 |
+
border: 1px solid var(--border-color);
|
| 288 |
+
padding: 12px 14px;
|
| 289 |
+
border-radius: 8px;
|
| 290 |
+
color: white;
|
| 291 |
+
font-family: inherit;
|
| 292 |
+
font-size: 14px;
|
| 293 |
+
outline: none;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.url-input-wrap input:focus {
|
| 297 |
+
border-color: var(--neon-cyan);
|
| 298 |
+
box-shadow: 0 0 10px rgba(0, 240, 255, 0.3);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/* Radio Cards */
|
| 302 |
+
.mode-options {
|
| 303 |
+
margin-top: 20px;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.radio-cards {
|
| 307 |
+
display: grid;
|
| 308 |
+
grid-template-columns: 1fr 1fr;
|
| 309 |
+
gap: 12px;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
@media (max-width: 600px) {
|
| 313 |
+
.radio-cards {
|
| 314 |
+
grid-template-columns: 1fr;
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.radio-card {
|
| 319 |
+
border: 1px solid var(--border-color);
|
| 320 |
+
background: rgba(0, 0, 0, 0.25);
|
| 321 |
+
padding: 14px;
|
| 322 |
+
border-radius: 10px;
|
| 323 |
+
display: flex;
|
| 324 |
+
align-items: center;
|
| 325 |
+
gap: 12px;
|
| 326 |
+
cursor: pointer;
|
| 327 |
+
transition: all 0.2s ease;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.radio-card input {
|
| 331 |
+
display: none;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.radio-card.active {
|
| 335 |
+
border-color: var(--neon-cyan);
|
| 336 |
+
background: rgba(0, 240, 255, 0.1);
|
| 337 |
+
box-shadow: 0 0 14px rgba(0, 240, 255, 0.15);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.radio-icon {
|
| 341 |
+
font-size: 24px;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.radio-info strong {
|
| 345 |
+
display: block;
|
| 346 |
+
font-size: 13px;
|
| 347 |
+
color: var(--text-main);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.radio-info small {
|
| 351 |
+
display: block;
|
| 352 |
+
font-size: 11px;
|
| 353 |
+
color: var(--text-muted);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
/* Custom Select & Sliders */
|
| 357 |
+
.custom-select {
|
| 358 |
+
width: 100%;
|
| 359 |
+
background: rgba(0, 0, 0, 0.4);
|
| 360 |
+
border: 1px solid var(--border-color);
|
| 361 |
+
padding: 12px 14px;
|
| 362 |
+
border-radius: 8px;
|
| 363 |
+
color: white;
|
| 364 |
+
font-family: inherit;
|
| 365 |
+
font-size: 14px;
|
| 366 |
+
outline: none;
|
| 367 |
+
cursor: pointer;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.custom-select option {
|
| 371 |
+
background: #11141c;
|
| 372 |
+
color: white;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.slider-grid {
|
| 376 |
+
display: grid;
|
| 377 |
+
grid-template-columns: 1fr 1fr;
|
| 378 |
+
gap: 16px;
|
| 379 |
+
margin: 16px 0;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.slider-item {
|
| 383 |
+
background: rgba(0, 0, 0, 0.25);
|
| 384 |
+
padding: 12px;
|
| 385 |
+
border-radius: 8px;
|
| 386 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.slider-header {
|
| 390 |
+
display: flex;
|
| 391 |
+
justify-content: space-between;
|
| 392 |
+
font-size: 12px;
|
| 393 |
+
font-weight: 600;
|
| 394 |
+
margin-bottom: 8px;
|
| 395 |
+
color: var(--text-muted);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.val-badge {
|
| 399 |
+
color: var(--neon-cyan);
|
| 400 |
+
font-family: var(--font-mono);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
input[type="range"] {
|
| 404 |
+
width: 100%;
|
| 405 |
+
accent-color: var(--neon-cyan);
|
| 406 |
+
cursor: pointer;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.preset-info-box {
|
| 410 |
+
background: rgba(139, 92, 246, 0.08);
|
| 411 |
+
border: 1px solid rgba(139, 92, 246, 0.3);
|
| 412 |
+
padding: 12px 14px;
|
| 413 |
+
border-radius: 8px;
|
| 414 |
+
font-size: 12px;
|
| 415 |
+
line-height: 1.5;
|
| 416 |
+
color: #c4b5fd;
|
| 417 |
+
margin-bottom: 20px;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/* Buttons */
|
| 421 |
+
.btn {
|
| 422 |
+
padding: 12px 20px;
|
| 423 |
+
border-radius: 10px;
|
| 424 |
+
font-family: inherit;
|
| 425 |
+
font-weight: 700;
|
| 426 |
+
font-size: 14px;
|
| 427 |
+
cursor: pointer;
|
| 428 |
+
border: none;
|
| 429 |
+
transition: all 0.25s ease;
|
| 430 |
+
display: inline-flex;
|
| 431 |
+
align-items: center;
|
| 432 |
+
justify-content: center;
|
| 433 |
+
gap: 8px;
|
| 434 |
+
text-decoration: none;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.btn-primary {
|
| 438 |
+
width: 100%;
|
| 439 |
+
background: linear-gradient(135deg, #00f0ff, #3b82f6);
|
| 440 |
+
color: #000;
|
| 441 |
+
box-shadow: 0 0 20px rgba(0, 240, 255, 0.4);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.btn-primary:hover {
|
| 445 |
+
transform: translateY(-2px);
|
| 446 |
+
box-shadow: 0 0 28px rgba(0, 240, 255, 0.7);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.btn-secondary {
|
| 450 |
+
background: rgba(255, 255, 255, 0.08);
|
| 451 |
+
color: var(--text-main);
|
| 452 |
+
border: 1px solid var(--border-color);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.btn-secondary:hover {
|
| 456 |
+
background: rgba(0, 240, 255, 0.15);
|
| 457 |
+
border-color: var(--neon-cyan);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.btn-success {
|
| 461 |
+
background: linear-gradient(135deg, #10b981, #059669);
|
| 462 |
+
color: white;
|
| 463 |
+
box-shadow: 0 0 20px rgba(16, 185, 129, 0.4);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
/* Stepper */
|
| 467 |
+
.stepper {
|
| 468 |
+
display: flex;
|
| 469 |
+
align-items: center;
|
| 470 |
+
justify-content: space-between;
|
| 471 |
+
margin: 20px 0 30px;
|
| 472 |
+
position: relative;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.step-item {
|
| 476 |
+
display: flex;
|
| 477 |
+
flex-direction: column;
|
| 478 |
+
align-items: center;
|
| 479 |
+
gap: 8px;
|
| 480 |
+
z-index: 2;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.step-circle {
|
| 484 |
+
width: 36px;
|
| 485 |
+
height: 36px;
|
| 486 |
+
border-radius: 50%;
|
| 487 |
+
background: #1a1e29;
|
| 488 |
+
border: 2px solid var(--border-color);
|
| 489 |
+
display: flex;
|
| 490 |
+
align-items: center;
|
| 491 |
+
justify-content: center;
|
| 492 |
+
font-weight: 700;
|
| 493 |
+
font-size: 13px;
|
| 494 |
+
color: var(--text-muted);
|
| 495 |
+
transition: all 0.3s ease;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.step-label {
|
| 499 |
+
font-size: 12px;
|
| 500 |
+
font-weight: 600;
|
| 501 |
+
color: var(--text-muted);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.step-item.active .step-circle {
|
| 505 |
+
border-color: var(--neon-cyan);
|
| 506 |
+
color: var(--neon-cyan);
|
| 507 |
+
box-shadow: 0 0 16px var(--neon-cyan);
|
| 508 |
+
animation: pulse 1.5s infinite;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.step-item.done .step-circle {
|
| 512 |
+
background: var(--neon-emerald);
|
| 513 |
+
border-color: var(--neon-emerald);
|
| 514 |
+
color: #000;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.step-line {
|
| 518 |
+
flex: 1;
|
| 519 |
+
height: 2px;
|
| 520 |
+
background: rgba(255, 255, 255, 0.1);
|
| 521 |
+
margin: 0 8px;
|
| 522 |
+
position: relative;
|
| 523 |
+
top: -12px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.step-line.active {
|
| 527 |
+
background: var(--neon-cyan);
|
| 528 |
+
box-shadow: 0 0 8px var(--neon-cyan);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
@keyframes pulse {
|
| 532 |
+
0%, 100% { transform: scale(1); box-shadow: 0 0 12px rgba(0, 240, 255, 0.5); }
|
| 533 |
+
50% { transform: scale(1.08); box-shadow: 0 0 24px rgba(0, 240, 255, 0.9); }
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
/* Progress Bar */
|
| 537 |
+
.progress-bar-wrap {
|
| 538 |
+
background: rgba(0, 0, 0, 0.4);
|
| 539 |
+
height: 10px;
|
| 540 |
+
border-radius: 6px;
|
| 541 |
+
overflow: hidden;
|
| 542 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.progress-bar-fill {
|
| 546 |
+
height: 100%;
|
| 547 |
+
width: 0%;
|
| 548 |
+
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-emerald));
|
| 549 |
+
box-shadow: 0 0 12px var(--neon-cyan);
|
| 550 |
+
transition: width 0.4s ease;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.progress-text-row {
|
| 554 |
+
display: flex;
|
| 555 |
+
justify-content: space-between;
|
| 556 |
+
font-size: 13px;
|
| 557 |
+
font-weight: 600;
|
| 558 |
+
margin-top: 8px;
|
| 559 |
+
color: var(--text-muted);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/* Terminal Console */
|
| 563 |
+
.terminal-logs {
|
| 564 |
+
background: #080a0f;
|
| 565 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 566 |
+
border-radius: 10px;
|
| 567 |
+
padding: 14px;
|
| 568 |
+
font-family: var(--font-mono);
|
| 569 |
+
font-size: 12px;
|
| 570 |
+
height: 180px;
|
| 571 |
+
overflow-y: auto;
|
| 572 |
+
margin-top: 20px;
|
| 573 |
+
display: flex;
|
| 574 |
+
flex-direction: column;
|
| 575 |
+
gap: 4px;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.log-entry {
|
| 579 |
+
color: #94a3b8;
|
| 580 |
+
line-height: 1.4;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.log-entry.system { color: var(--neon-cyan); }
|
| 584 |
+
.log-entry.success { color: var(--neon-emerald); }
|
| 585 |
+
.log-entry.error { color: #f87171; }
|
| 586 |
+
|
| 587 |
+
/* Video Player */
|
| 588 |
+
.video-player-wrap {
|
| 589 |
+
background: #000;
|
| 590 |
+
border-radius: 12px;
|
| 591 |
+
overflow: hidden;
|
| 592 |
+
margin-bottom: 20px;
|
| 593 |
+
max-height: 480px;
|
| 594 |
+
display: flex;
|
| 595 |
+
justify-content: center;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.main-player {
|
| 599 |
+
width: 100%;
|
| 600 |
+
max-height: 480px;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.action-buttons {
|
| 604 |
+
display: flex;
|
| 605 |
+
gap: 12px;
|
| 606 |
+
justify-content: center;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
/* Footer */
|
| 610 |
+
.app-footer {
|
| 611 |
+
text-align: center;
|
| 612 |
+
padding: 24px;
|
| 613 |
+
font-size: 12px;
|
| 614 |
+
color: var(--text-muted);
|
| 615 |
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
| 616 |
+
margin-top: 40px;
|
| 617 |
+
}
|
app/translation/fallback.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/translation/fallback.py (CP2 upgrade)
|
| 3 |
+
───────────────────────────────────────────
|
| 4 |
+
Builds the ordered provider chain based on user engine selection.
|
| 5 |
+
CP2 change: Google is still the final LLM fallback, but MyMemory machine
|
| 6 |
+
translation is handled by TranslationManager directly (not in this chain),
|
| 7 |
+
so we don't double-append it here.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from app.translation.providers.api_pool_provider import ApiPoolProvider
|
| 11 |
+
from app.translation.providers.router import RouterProvider
|
| 12 |
+
from app.translation.providers.superai import SuperAIProvider
|
| 13 |
+
from app.translation.providers.ollama import OllamaProvider
|
| 14 |
+
from app.translation.providers.google import GoogleProvider
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TranslationFallbackChain:
|
| 18 |
+
def __init__(self, engines_list, config_dict=None):
|
| 19 |
+
"""
|
| 20 |
+
engines_list: list of engine name strings, e.g. ["API Pool", "9Router", "Super AI", "Ollama"]
|
| 21 |
+
config_dict: dict with URL/key/model entries per provider
|
| 22 |
+
"""
|
| 23 |
+
self.engines_list = engines_list or ["API Pool"]
|
| 24 |
+
self.config = config_dict or {}
|
| 25 |
+
self.providers: list[tuple[str, object]] = []
|
| 26 |
+
self._init_providers()
|
| 27 |
+
|
| 28 |
+
def _init_providers(self):
|
| 29 |
+
seen = set()
|
| 30 |
+
|
| 31 |
+
for engine in self.engines_list:
|
| 32 |
+
engine_str = str(engine or "")
|
| 33 |
+
|
| 34 |
+
if any(k in engine_str for k in ("API Pool", "Pool", "Ultimate", "Tối thượng", "Nvidia", "NVIDIA", "Groq", "Gemini", "OpenRouter", "SiliconFlow", "TogetherAI", "Nemotron")) and "API Pool" not in seen:
|
| 35 |
+
seen.add("API Pool")
|
| 36 |
+
self.providers.append(("API Pool", ApiPoolProvider()))
|
| 37 |
+
|
| 38 |
+
if "9Router" in engine_str and "9Router" not in seen:
|
| 39 |
+
seen.add("9Router")
|
| 40 |
+
url = self.config.get("router_url", "http://127.0.0.1:20128/v1/chat/completions")
|
| 41 |
+
key = self.config.get("router_key", "sk-dummy")
|
| 42 |
+
model = self.config.get("router_model", "meta-llama/llama-3.3-70b-instruct:free")
|
| 43 |
+
self.providers.append(("9Router", RouterProvider(url, model, key)))
|
| 44 |
+
|
| 45 |
+
if "Super AI" in engine_str and "Super AI" not in seen:
|
| 46 |
+
seen.add("Super AI")
|
| 47 |
+
url = self.config.get("super_ai_gate_url", "http://127.0.0.1:8000/v1/chat/completions")
|
| 48 |
+
key = self.config.get("super_ai_gate_key", "sk-dummy")
|
| 49 |
+
model = self.config.get("super_ai_gate_model", "meta-llama/llama-3.3-70b-instruct:free")
|
| 50 |
+
self.providers.append(("Super AI", SuperAIProvider(url, model, key)))
|
| 51 |
+
|
| 52 |
+
if "Ollama" in engine_str and "Ollama" not in seen:
|
| 53 |
+
seen.add("Ollama")
|
| 54 |
+
url = "http://127.0.0.1:11434/v1/chat/completions"
|
| 55 |
+
model = self.config.get("ollama_model", "hf.co/lmstudio-community/Qwen3.5-9B-GGUF:Q6_K")
|
| 56 |
+
self.providers.append(("Ollama", OllamaProvider(url, model)))
|
| 57 |
+
|
| 58 |
+
if "Google" in engine_str and not any(k in engine_str for k in ("Gemini", "Web", "Playwright")) and "Google" not in seen:
|
| 59 |
+
seen.add("Google")
|
| 60 |
+
self.providers.append(("Google", GoogleProvider()))
|
| 61 |
+
|
| 62 |
+
# If no cloud/pool provider was explicitly added, ensure API Pool is always present before Google
|
| 63 |
+
if "API Pool" not in seen and not any(name == "Google" for name, _ in self.providers):
|
| 64 |
+
self.providers.insert(0, ("API Pool", ApiPoolProvider()))
|
| 65 |
+
seen.add("API Pool")
|
| 66 |
+
|
| 67 |
+
# Google is always the final fallback
|
| 68 |
+
if "Google" not in seen:
|
| 69 |
+
self.providers.append(("Google", GoogleProvider()))
|
| 70 |
+
|
| 71 |
+
def get_providers(self):
|
| 72 |
+
return self.providers
|
app/translation/manager.py
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/translation/manager.py (CP2 upgrade)
|
| 3 |
+
──────────────────────────────────────────
|
| 4 |
+
Orchestrates translation across providers with:
|
| 5 |
+
• Per-provider cooldown (ProviderHealth)
|
| 6 |
+
• Per-block retry when a chunk loop is detected
|
| 7 |
+
• Chunk splitting: if chunk of 50 fails → split into chunks of 10
|
| 8 |
+
• Machine translation last-resort (MyMemory)
|
| 9 |
+
• Per-provider timeout (60s LLM, 15s Google)
|
| 10 |
+
• Full validation report saved at the end
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
import random
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from app.translation.fallback import TranslationFallbackChain
|
| 19 |
+
from app.translation.validator import TranslationValidator
|
| 20 |
+
from app.translation.provider_health import ProviderHealth
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TranslationManager:
|
| 24 |
+
# Seconds to wait before retrying a provider that failed with rate-limit error
|
| 25 |
+
RATE_LIMIT_COOLDOWN = 1800 # 30 min (CP3)
|
| 26 |
+
GENERIC_ERROR_COOLDOWN = 120 # 2 min
|
| 27 |
+
|
| 28 |
+
def __init__(self, engines_list, config_dict=None, glossary=None, style=None):
|
| 29 |
+
self.fallback_chain = TranslationFallbackChain(engines_list, config_dict)
|
| 30 |
+
self.validator = TranslationValidator()
|
| 31 |
+
self.health = ProviderHealth.instance()
|
| 32 |
+
self.glossary = glossary or {}
|
| 33 |
+
self.style = style or "Tự nhiên, thuyết minh"
|
| 34 |
+
self.translation_profile = "dub_compact_vi_fashion"
|
| 35 |
+
self.validation_report = []
|
| 36 |
+
self.report_dir = None
|
| 37 |
+
self.timing_pressure_report = []
|
| 38 |
+
self.translation_repair_report = []
|
| 39 |
+
|
| 40 |
+
# Load translation config from config.json
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
import json
|
| 43 |
+
|
| 44 |
+
# Standard defaults
|
| 45 |
+
self.allow_untranslated_fallback = False
|
| 46 |
+
self.max_untranslated_chunks = 0
|
| 47 |
+
self.mark_video_failed_on_untranslated = True
|
| 48 |
+
self.failed_chunks = []
|
| 49 |
+
self.untranslated_chunks = 0
|
| 50 |
+
|
| 51 |
+
# New CP3 defaults
|
| 52 |
+
self.adaptive_chunking = True
|
| 53 |
+
self.chunk_size_default = 30
|
| 54 |
+
self.chunk_size_max = 60
|
| 55 |
+
self.chunk_size_min = 5
|
| 56 |
+
self.fallback_chunk_sizes = [30, 10, 1]
|
| 57 |
+
self.chunk_by_token = True
|
| 58 |
+
self.max_tokens_per_chunk = 2500
|
| 59 |
+
self.max_split_depth = 2
|
| 60 |
+
self.max_stage_c_seconds = 600
|
| 61 |
+
self.max_attempts_per_chunk = 6
|
| 62 |
+
self.max_attempts_per_block = 3
|
| 63 |
+
self.max_total_provider_calls_per_video = 200
|
| 64 |
+
self.disable_mymemory_for_long_text = True
|
| 65 |
+
self.disable_mymemory_on_414 = True
|
| 66 |
+
self.fail_fast_on_garbage_ocr = True
|
| 67 |
+
self.max_ocr_garbage_ratio = 0.35
|
| 68 |
+
self.max_ocr_block_chars = 120
|
| 69 |
+
self.max_ocr_block_duration_sec = 12.0
|
| 70 |
+
self.max_cjk_noise_symbol_ratio = 0.08
|
| 71 |
+
self.max_cjk_dominant_char_ratio = 0.20
|
| 72 |
+
self.min_cjk_unique_ratio = 0.18
|
| 73 |
+
self.max_ocr_translation_syllables = 12
|
| 74 |
+
self.max_failed_blocks_before_fail = 3
|
| 75 |
+
self.max_failed_block_ratio_before_fail = 0.05
|
| 76 |
+
self.enforce_timing_quality = True
|
| 77 |
+
self.max_timing_over_ratio = 1.15
|
| 78 |
+
self.max_translation_repair_attempts = 2
|
| 79 |
+
self.disable_machine_fallback_for_compact_profile = True
|
| 80 |
+
self.semantic_quality_guard_enabled = True
|
| 81 |
+
self.semantic_guard_min_score = 80
|
| 82 |
+
self.semantic_guard_ai_repair_enabled = True
|
| 83 |
+
self.semantic_guard_max_ai_repair_blocks = 40
|
| 84 |
+
self.semantic_guard_fail_on_unresolved = False
|
| 85 |
+
self.semantic_guard_max_unresolved_errors = 12
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
config_path = Path(__file__).parent.parent.parent / "config.json"
|
| 89 |
+
if config_path.exists():
|
| 90 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 91 |
+
cfg_data = json.load(f)
|
| 92 |
+
trans_cfg = cfg_data.get("translation", {})
|
| 93 |
+
self.translation_profile = trans_cfg.get("translation_profile", self.translation_profile)
|
| 94 |
+
self.allow_untranslated_fallback = trans_cfg.get("allow_untranslated_fallback", self.allow_untranslated_fallback)
|
| 95 |
+
self.max_untranslated_chunks = trans_cfg.get("max_untranslated_chunks", self.max_untranslated_chunks)
|
| 96 |
+
self.mark_video_failed_on_untranslated = trans_cfg.get("mark_video_failed_on_untranslated", self.mark_video_failed_on_untranslated)
|
| 97 |
+
|
| 98 |
+
# Load CP3 params
|
| 99 |
+
self.adaptive_chunking = trans_cfg.get("adaptive_chunking", self.adaptive_chunking)
|
| 100 |
+
self.chunk_size_default = trans_cfg.get("chunk_size_default", self.chunk_size_default)
|
| 101 |
+
self.chunk_size_max = trans_cfg.get("chunk_size_max", self.chunk_size_max)
|
| 102 |
+
self.chunk_size_min = trans_cfg.get("chunk_size_min", self.chunk_size_min)
|
| 103 |
+
self.fallback_chunk_sizes = trans_cfg.get("fallback_chunk_sizes", self.fallback_chunk_sizes)
|
| 104 |
+
self.chunk_by_token = trans_cfg.get("chunk_by_token", self.chunk_by_token)
|
| 105 |
+
self.max_tokens_per_chunk = trans_cfg.get("max_tokens_per_chunk", self.max_tokens_per_chunk)
|
| 106 |
+
self.max_split_depth = trans_cfg.get("max_split_depth", self.max_split_depth)
|
| 107 |
+
self.max_stage_c_seconds = trans_cfg.get("max_stage_c_seconds", self.max_stage_c_seconds)
|
| 108 |
+
self.max_attempts_per_chunk = trans_cfg.get("max_attempts_per_chunk", self.max_attempts_per_chunk)
|
| 109 |
+
self.max_attempts_per_block = trans_cfg.get("max_attempts_per_block", self.max_attempts_per_block)
|
| 110 |
+
self.max_total_provider_calls_per_video = trans_cfg.get("max_total_provider_calls_per_video", self.max_total_provider_calls_per_video)
|
| 111 |
+
self.disable_mymemory_for_long_text = trans_cfg.get("disable_mymemory_for_long_text", self.disable_mymemory_for_long_text)
|
| 112 |
+
self.disable_mymemory_on_414 = trans_cfg.get("disable_mymemory_on_414", self.disable_mymemory_on_414)
|
| 113 |
+
self.fail_fast_on_garbage_ocr = trans_cfg.get("fail_fast_on_garbage_ocr", self.fail_fast_on_garbage_ocr)
|
| 114 |
+
self.max_ocr_garbage_ratio = trans_cfg.get("max_ocr_garbage_ratio", self.max_ocr_garbage_ratio)
|
| 115 |
+
self.max_ocr_block_chars = int(trans_cfg.get("max_ocr_block_chars", self.max_ocr_block_chars))
|
| 116 |
+
self.max_ocr_block_duration_sec = float(trans_cfg.get("max_ocr_block_duration_sec", self.max_ocr_block_duration_sec))
|
| 117 |
+
self.max_cjk_noise_symbol_ratio = float(trans_cfg.get("max_cjk_noise_symbol_ratio", self.max_cjk_noise_symbol_ratio))
|
| 118 |
+
self.max_cjk_dominant_char_ratio = float(trans_cfg.get("max_cjk_dominant_char_ratio", self.max_cjk_dominant_char_ratio))
|
| 119 |
+
self.min_cjk_unique_ratio = float(trans_cfg.get("min_cjk_unique_ratio", self.min_cjk_unique_ratio))
|
| 120 |
+
self.max_ocr_translation_syllables = int(trans_cfg.get("max_ocr_translation_syllables", self.max_ocr_translation_syllables))
|
| 121 |
+
self.max_failed_blocks_before_fail = trans_cfg.get("max_failed_blocks_before_fail", self.max_failed_blocks_before_fail)
|
| 122 |
+
self.max_failed_block_ratio_before_fail = trans_cfg.get("max_failed_block_ratio_before_fail", self.max_failed_block_ratio_before_fail)
|
| 123 |
+
self.enforce_timing_quality = trans_cfg.get("enforce_timing_quality", self.enforce_timing_quality)
|
| 124 |
+
self.max_timing_over_ratio = float(trans_cfg.get("max_timing_over_ratio", self.max_timing_over_ratio))
|
| 125 |
+
self.max_translation_repair_attempts = int(trans_cfg.get("max_translation_repair_attempts", self.max_translation_repair_attempts))
|
| 126 |
+
self.disable_machine_fallback_for_compact_profile = trans_cfg.get("disable_machine_fallback_for_compact_profile", self.disable_machine_fallback_for_compact_profile)
|
| 127 |
+
self.semantic_quality_guard_enabled = trans_cfg.get("semantic_quality_guard_enabled", self.semantic_quality_guard_enabled)
|
| 128 |
+
self.semantic_guard_min_score = int(trans_cfg.get("semantic_guard_min_score", self.semantic_guard_min_score))
|
| 129 |
+
self.semantic_guard_ai_repair_enabled = trans_cfg.get("semantic_guard_ai_repair_enabled", self.semantic_guard_ai_repair_enabled)
|
| 130 |
+
self.semantic_guard_max_ai_repair_blocks = int(trans_cfg.get("semantic_guard_max_ai_repair_blocks", self.semantic_guard_max_ai_repair_blocks))
|
| 131 |
+
self.semantic_guard_fail_on_unresolved = trans_cfg.get("semantic_guard_fail_on_unresolved", self.semantic_guard_fail_on_unresolved)
|
| 132 |
+
self.semantic_guard_max_unresolved_errors = int(trans_cfg.get("semantic_guard_max_unresolved_errors", self.semantic_guard_max_unresolved_errors))
|
| 133 |
+
except Exception as e:
|
| 134 |
+
print(f"Warning: Failed to load translation config: {e}")
|
| 135 |
+
|
| 136 |
+
# Load source language
|
| 137 |
+
self.source_language = "zh"
|
| 138 |
+
try:
|
| 139 |
+
config_path2 = Path(__file__).parent.parent.parent / "config.json"
|
| 140 |
+
if config_path2.exists():
|
| 141 |
+
with open(config_path2, "r", encoding="utf-8") as f2:
|
| 142 |
+
cfg2 = json.load(f2)
|
| 143 |
+
self.source_language = cfg2.get("source_language", "zh")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
print(f"Warning: Failed to load source_language for TranslationManager: {e}")
|
| 146 |
+
|
| 147 |
+
def _is_cjk_char(self, ch: str) -> bool:
|
| 148 |
+
return (
|
| 149 |
+
"\u4e00" <= ch <= "\u9fff" or
|
| 150 |
+
"\u3400" <= ch <= "\u4dbf" or
|
| 151 |
+
"\u3040" <= ch <= "\u30ff" or
|
| 152 |
+
"\uac00" <= ch <= "\ud7af"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
def _detect_energy(self, text: str) -> str:
|
| 156 |
+
"""
|
| 157 |
+
Phát hiện 'năng lượng' của câu nguồn để AI giữ đúng giọng khi dịch.
|
| 158 |
+
Video viral: câu hype → dịch hype, câu kể → dịch tường thuật.
|
| 159 |
+
"""
|
| 160 |
+
if not text:
|
| 161 |
+
return "neutral"
|
| 162 |
+
# Cảm thán mạnh / hype
|
| 163 |
+
if any(c in text for c in ["!", "!!", "??", "!!", "哇", "卧槽", "草", "绝了", "炸了"]):
|
| 164 |
+
return "high_energy"
|
| 165 |
+
# Phân tích / giải thích bình tĩnh
|
| 166 |
+
if any(w in text for w in ["其实", "说真的", "我���得", "事实上", "实际上", "关键是", "问题在于"]):
|
| 167 |
+
return "reflective"
|
| 168 |
+
# Kể chuyện / hoài niệm
|
| 169 |
+
if any(w in text for w in ["当年", "以前", "记得", "那时候", "小时候", "最开始"]):
|
| 170 |
+
return "nostalgic"
|
| 171 |
+
# Trực tiếp / hành động
|
| 172 |
+
if any(w in text for w in ["来看", "接下来", "现在", "我们看", "给大家", "今天"]):
|
| 173 |
+
return "presenter"
|
| 174 |
+
return "neutral"
|
| 175 |
+
|
| 176 |
+
def _cjk_ratio(self, text: str) -> float:
|
| 177 |
+
if not text:
|
| 178 |
+
return 0.0
|
| 179 |
+
chars = [c for c in text if not c.isspace()]
|
| 180 |
+
if not chars:
|
| 181 |
+
return 0.0
|
| 182 |
+
return sum(1 for c in chars if self._is_cjk_char(c)) / len(chars)
|
| 183 |
+
|
| 184 |
+
def _latin_words_only(self, text: str) -> list[str]:
|
| 185 |
+
import re
|
| 186 |
+
return re.findall(r"[A-Za-z]+", text)
|
| 187 |
+
|
| 188 |
+
def _chinese_ratio(self, text: str) -> float:
|
| 189 |
+
if not text:
|
| 190 |
+
return 0.0
|
| 191 |
+
zh_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
| 192 |
+
total = len(text.replace(' ', ''))
|
| 193 |
+
return zh_chars / total if total else 0.0
|
| 194 |
+
|
| 195 |
+
# ── Text sanitisation ─────────────────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
def sanitize_context_text(self, text):
|
| 198 |
+
if not text:
|
| 199 |
+
return ""
|
| 200 |
+
import re
|
| 201 |
+
text = text.replace('\\', '').replace('/', ' ')
|
| 202 |
+
text = text.replace('"', "'")
|
| 203 |
+
text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text)
|
| 204 |
+
text = re.sub(r'[^\w\s.,!?\'\-\[\]{}():;]', '', text)
|
| 205 |
+
return re.sub(r'\s+', ' ', text).strip()
|
| 206 |
+
|
| 207 |
+
# ── OCR Quality Check ─────────────────────────────────────────────────────
|
| 208 |
+
|
| 209 |
+
def _is_garbage_block(self, text, block=None):
|
| 210 |
+
if not text:
|
| 211 |
+
return True, "empty"
|
| 212 |
+
import re
|
| 213 |
+
t = text.strip()
|
| 214 |
+
if len(t) == 0:
|
| 215 |
+
return True, "empty"
|
| 216 |
+
block = block or {}
|
| 217 |
+
|
| 218 |
+
# ── General OCR Garbage Checks (Run for all inputs, including CJK) ─────
|
| 219 |
+
|
| 220 |
+
chars = [c for c in t if not c.isspace()]
|
| 221 |
+
cjk_chars = [c for c in chars if self._is_cjk_char(c)]
|
| 222 |
+
cjk_val = (len(cjk_chars) / len(chars)) if chars else 0.0
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
duration_sec = float(block.get("duration_sec") or 0.0)
|
| 226 |
+
except Exception:
|
| 227 |
+
duration_sec = 0.0
|
| 228 |
+
|
| 229 |
+
if cjk_val >= 0.25:
|
| 230 |
+
noise_chars = set("□�√×÷′²③④》】//&¥#{}|~@^_+=<>")
|
| 231 |
+
noise_ratio = sum(1 for c in chars if c in noise_chars) / len(chars) if chars else 0.0
|
| 232 |
+
if noise_ratio > self.max_cjk_noise_symbol_ratio:
|
| 233 |
+
return True, "cjk_ocr_symbol_noise"
|
| 234 |
+
|
| 235 |
+
if len(cjk_chars) >= 40:
|
| 236 |
+
counts = {}
|
| 237 |
+
for c in cjk_chars:
|
| 238 |
+
counts[c] = counts.get(c, 0) + 1
|
| 239 |
+
dominant_ratio = max(counts.values()) / len(cjk_chars)
|
| 240 |
+
unique_ratio = len(counts) / len(cjk_chars)
|
| 241 |
+
if dominant_ratio > self.max_cjk_dominant_char_ratio:
|
| 242 |
+
return True, "dominant_cjk_repeat"
|
| 243 |
+
if len(cjk_chars) >= 80 and unique_ratio < self.min_cjk_unique_ratio:
|
| 244 |
+
return True, "low_cjk_diversity"
|
| 245 |
+
|
| 246 |
+
if (
|
| 247 |
+
duration_sec > self.max_ocr_block_duration_sec
|
| 248 |
+
and len(t) > self.max_ocr_block_chars
|
| 249 |
+
):
|
| 250 |
+
return True, "ocr_segment_too_long"
|
| 251 |
+
|
| 252 |
+
# 1. OCR symbol noise check
|
| 253 |
+
special_chars = sum(1 for c in t if c in r"|_\/[]{}()@#$^&*~+-=<>;:")
|
| 254 |
+
if len(t) > 3 and special_chars / len(t) > 0.25:
|
| 255 |
+
return True, "ocr_symbol_noise"
|
| 256 |
+
|
| 257 |
+
# 2. Extreme repeated characters check
|
| 258 |
+
if len(t) > 5:
|
| 259 |
+
if re.search(r"(.)\1\1\1", t):
|
| 260 |
+
return True, "consecutive_char_repeat"
|
| 261 |
+
|
| 262 |
+
# 3. Mojibake / encoding garbage check
|
| 263 |
+
if len(t) > 3:
|
| 264 |
+
mojibake_chars = sum(1 for c in t if c in "Õªº¤¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿")
|
| 265 |
+
if mojibake_chars / len(t) > 0.2:
|
| 266 |
+
return True, "mojibake_garbage"
|
| 267 |
+
|
| 268 |
+
# ── CJK Ratio check ───────────────────────────────────────────────────
|
| 269 |
+
# If CJK ratio is high, bypass Latin-specific heuristics
|
| 270 |
+
if cjk_val >= 0.25:
|
| 271 |
+
return False, None
|
| 272 |
+
|
| 273 |
+
# ── Latin-specific Heuristics (Only run if CJK ratio < 0.25) ──────────
|
| 274 |
+
|
| 275 |
+
latin_words = self._latin_words_only(t)
|
| 276 |
+
|
| 277 |
+
# 4. Latin word length heuristic
|
| 278 |
+
for w in latin_words:
|
| 279 |
+
if len(w) > 25:
|
| 280 |
+
return True, "abnormally_long_word"
|
| 281 |
+
|
| 282 |
+
# 5. Consonant cluster / missing vowel check
|
| 283 |
+
for w in latin_words:
|
| 284 |
+
if len(w) > 5:
|
| 285 |
+
vowels = sum(1 for c in w.lower() if c in "aeiouy")
|
| 286 |
+
consonants = len(w) - vowels
|
| 287 |
+
if vowels == 0:
|
| 288 |
+
return True, "consonant_gibberish"
|
| 289 |
+
if consonants / len(w) > 0.85:
|
| 290 |
+
return True, "consonant_gibberish"
|
| 291 |
+
|
| 292 |
+
# 6. Single-character Latin word density check
|
| 293 |
+
if len(latin_words) > 4:
|
| 294 |
+
single_char_latin = sum(1 for w in latin_words if len(w) == 1)
|
| 295 |
+
if single_char_latin / len(latin_words) > 0.6:
|
| 296 |
+
return True, "high_density_of_single_char_words"
|
| 297 |
+
|
| 298 |
+
# 7. Conservative latin_word_noise check (for pinyin/random letter sequences)
|
| 299 |
+
if len(latin_words) > 4:
|
| 300 |
+
COMMON_WORDS = {
|
| 301 |
+
"the", "and", "of", "to", "in", "is", "you", "that", "it", "he", "was", "for", "on", "are",
|
| 302 |
+
"as", "with", "his", "they", "i", "at", "be", "this", "have", "from", "or", "one", "had",
|
| 303 |
+
"by", "word", "but", "not", "what", "all", "were", "we", "when", "your", "can", "said",
|
| 304 |
+
"there", "use", "an", "each", "which", "she", "do", "how", "their", "if", "will", "up",
|
| 305 |
+
"other", "about", "out", "many", "then", "them", "these", "so", "some", "her", "would",
|
| 306 |
+
"make", "like", "him", "into", "time", "has", "look", "two", "more", "write", "go", "see",
|
| 307 |
+
"number", "no", "way", "could", "people", "my", "than", "first", "water", "been", "call",
|
| 308 |
+
"who", "oil", "its", "now", "find", "long", "down", "day", "did", "get", "come", "made",
|
| 309 |
+
"may", "part", "got", "drip", "cap", "fit", "so", "hard", "tee", "run", "boy", "girl",
|
| 310 |
+
"man", "woman", "yes", "out", "in", "rap", "hip", "hop", "trap", "swag", "hype", "shoe",
|
| 311 |
+
"brand", "cool", "dope", "wild", "chic", "nike", "cdg", "rick", "owens", "lil", "baby",
|
| 312 |
+
"x", "y", "z", "me", "us", "our"
|
| 313 |
+
}
|
| 314 |
+
noise_score = 0.0
|
| 315 |
+
for w in latin_words:
|
| 316 |
+
w_lower = w.lower()
|
| 317 |
+
# 1. Repeating char check (e.g. qqq)
|
| 318 |
+
if re.search(r"(.)\1\1", w_lower):
|
| 319 |
+
noise_score += 3.0
|
| 320 |
+
# 2. Vowel-less word check (e.g. zxqwrty)
|
| 321 |
+
elif sum(1 for c in w_lower if c in "aeiouy") == 0:
|
| 322 |
+
noise_score += 3.0
|
| 323 |
+
# 3. Pinyin-specific indicator: starts with 'x' (rare in English) or 'q' not followed by 'u'
|
| 324 |
+
elif w_lower.startswith('x') and len(w_lower) > 2:
|
| 325 |
+
noise_score += 1.5
|
| 326 |
+
elif 'q' in w_lower and 'qu' not in w_lower:
|
| 327 |
+
noise_score += 1.5
|
| 328 |
+
# 4. Short non-common word check
|
| 329 |
+
elif len(w_lower) <= 4 and w_lower not in COMMON_WORDS:
|
| 330 |
+
noise_score += 1.0
|
| 331 |
+
|
| 332 |
+
avg_score = noise_score / len(latin_words)
|
| 333 |
+
if avg_score > 0.8:
|
| 334 |
+
return True, "latin_word_noise"
|
| 335 |
+
|
| 336 |
+
return False, None
|
| 337 |
+
|
| 338 |
+
def _check_ocr_quality(self, parsed_blocks, log_fn=None):
|
| 339 |
+
garbage_blocks = []
|
| 340 |
+
total_blocks = len(parsed_blocks)
|
| 341 |
+
|
| 342 |
+
for b in parsed_blocks:
|
| 343 |
+
is_garbage, reason = self._is_garbage_block(b["text"], b)
|
| 344 |
+
if is_garbage:
|
| 345 |
+
garbage_blocks.append({
|
| 346 |
+
"id": b["id"],
|
| 347 |
+
"text": b["text"],
|
| 348 |
+
"reason": reason
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
garbage_ratio = len(garbage_blocks) / total_blocks if total_blocks > 0 else 0.0
|
| 352 |
+
|
| 353 |
+
report = {
|
| 354 |
+
"total_blocks": total_blocks,
|
| 355 |
+
"garbage_blocks": garbage_blocks,
|
| 356 |
+
"garbage_ratio": round(garbage_ratio, 4),
|
| 357 |
+
"action": "continue"
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if garbage_ratio > self.max_ocr_garbage_ratio:
|
| 361 |
+
report["action"] = "failed" if self.mark_video_failed_on_untranslated else "need_review"
|
| 362 |
+
|
| 363 |
+
return report
|
| 364 |
+
|
| 365 |
+
# ── Token Estimate ────────────────────────────────────────────────────────
|
| 366 |
+
|
| 367 |
+
def _estimate_tokens(self, text):
|
| 368 |
+
if not text:
|
| 369 |
+
return 0
|
| 370 |
+
# Character count divided by 3 + base padding is a robust proxy for LLM tokens
|
| 371 |
+
return len(text) // 3 + 4
|
| 372 |
+
|
| 373 |
+
# ── Budget Enforcement ───────────────────────────────────────────────────
|
| 374 |
+
|
| 375 |
+
def _check_budgets(self):
|
| 376 |
+
if self.total_provider_calls >= self.max_total_provider_calls_per_video:
|
| 377 |
+
raise Exception("Stage C: translation retry budget exceeded")
|
| 378 |
+
|
| 379 |
+
elapsed = time.time() - self.start_time
|
| 380 |
+
if elapsed > self.max_stage_c_seconds:
|
| 381 |
+
raise Exception("Stage C: translation timeout budget exceeded")
|
| 382 |
+
|
| 383 |
+
# ── Main Entry Point ───────────────────────────────────���──────────────────
|
| 384 |
+
|
| 385 |
+
def translate_blocks(self, parsed_blocks, log_fn=None):
|
| 386 |
+
"""
|
| 387 |
+
Translate all parsed SRT blocks using adaptive chunking.
|
| 388 |
+
Returns: {str(block_id): vietnamese_text}
|
| 389 |
+
"""
|
| 390 |
+
self.validation_report = []
|
| 391 |
+
self.timing_pressure_report = []
|
| 392 |
+
self.translation_repair_report = []
|
| 393 |
+
self.provider_attempts = []
|
| 394 |
+
self.failed_blocks = []
|
| 395 |
+
self.failed_chunks = []
|
| 396 |
+
self.untranslated_chunks = 0
|
| 397 |
+
self.disabled_providers = set()
|
| 398 |
+
self.mymemory_disabled = False
|
| 399 |
+
self.validation_failures_by_provider_chunk = {}
|
| 400 |
+
self.total_provider_calls = 0
|
| 401 |
+
self.start_time = time.time()
|
| 402 |
+
|
| 403 |
+
from pathlib import Path
|
| 404 |
+
temp_dir = Path("temp")
|
| 405 |
+
if log_fn and hasattr(log_fn, 'worker') and hasattr(log_fn.worker, 'video_temp_dir'):
|
| 406 |
+
temp_dir = log_fn.worker.video_temp_dir
|
| 407 |
+
elif log_fn and hasattr(log_fn, '__self__') and hasattr(log_fn.__self__, 'video_temp_dir'):
|
| 408 |
+
temp_dir = log_fn.__self__.video_temp_dir
|
| 409 |
+
|
| 410 |
+
report_dir = getattr(self, "report_dir", None) or temp_dir
|
| 411 |
+
report_dir = Path(report_dir)
|
| 412 |
+
report_dir.mkdir(parents=True, exist_ok=True)
|
| 413 |
+
|
| 414 |
+
if log_fn:
|
| 415 |
+
log_fn(f"[TRANSLATION] Total blocks count: {len(parsed_blocks)}")
|
| 416 |
+
|
| 417 |
+
# 1. OCR quality check gate
|
| 418 |
+
ocr_report = self._check_ocr_quality(parsed_blocks, log_fn)
|
| 419 |
+
try:
|
| 420 |
+
with open(report_dir / "ocr_quality_report.json", "w", encoding="utf-8") as f:
|
| 421 |
+
json.dump(ocr_report, f, ensure_ascii=False, indent=2)
|
| 422 |
+
except Exception as e:
|
| 423 |
+
if log_fn:
|
| 424 |
+
log_fn(f"⚠️ Không thể lưu ocr_quality_report.json: {e}")
|
| 425 |
+
|
| 426 |
+
if ocr_report["action"] == "failed" and self.fail_fast_on_garbage_ocr:
|
| 427 |
+
raise Exception("OCR quality too poor for reliable translation. Aborting early.")
|
| 428 |
+
elif ocr_report["action"] == "need_review" and self.fail_fast_on_garbage_ocr:
|
| 429 |
+
raise Exception("OCR quality too poor for reliable translation. Aborting early.")
|
| 430 |
+
|
| 431 |
+
# 2. Main translation loop with adaptive size
|
| 432 |
+
idx = 0
|
| 433 |
+
total_blocks = len(parsed_blocks)
|
| 434 |
+
translated_dict = {}
|
| 435 |
+
context_history = []
|
| 436 |
+
|
| 437 |
+
current_chunk_size = self.chunk_size_default
|
| 438 |
+
chunk_idx = 1
|
| 439 |
+
|
| 440 |
+
approx_total_chunks = (total_blocks + current_chunk_size - 1) // current_chunk_size
|
| 441 |
+
|
| 442 |
+
try:
|
| 443 |
+
while idx < total_blocks:
|
| 444 |
+
# Build next chunk dynamically based on current_chunk_size and token budget
|
| 445 |
+
chunk = []
|
| 446 |
+
tokens = 0
|
| 447 |
+
while idx < total_blocks and len(chunk) < current_chunk_size:
|
| 448 |
+
b = parsed_blocks[idx]
|
| 449 |
+
b_tokens = self._estimate_tokens(b["text"])
|
| 450 |
+
if self.chunk_by_token and tokens + b_tokens > self.max_tokens_per_chunk and chunk:
|
| 451 |
+
break
|
| 452 |
+
chunk.append(b)
|
| 453 |
+
tokens += b_tokens
|
| 454 |
+
idx += 1
|
| 455 |
+
|
| 456 |
+
chunk_num_str = f"{chunk_idx}/{approx_total_chunks}"
|
| 457 |
+
|
| 458 |
+
# Inter-chunk rate limit delay (if multiple parent chunks)
|
| 459 |
+
if chunk_idx > 1:
|
| 460 |
+
time.sleep(random.uniform(1.0, 2.0))
|
| 461 |
+
|
| 462 |
+
context_str = self._build_context_str(context_history)
|
| 463 |
+
|
| 464 |
+
# Execute Cascade split translation for this chunk
|
| 465 |
+
chunk_result, is_fallback = self._translate_chunk_with_cascade(
|
| 466 |
+
chunk, chunk_num_str, context_str, log_fn, depth=0
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Update adaptive chunk size
|
| 470 |
+
if is_fallback:
|
| 471 |
+
current_chunk_size = self.chunk_size_default
|
| 472 |
+
else:
|
| 473 |
+
current_chunk_size = min(self.chunk_size_max, current_chunk_size + 5)
|
| 474 |
+
|
| 475 |
+
# Merge translated blocks
|
| 476 |
+
for b in chunk:
|
| 477 |
+
bid = str(b["id"])
|
| 478 |
+
val = chunk_result.get(bid, b["text"])
|
| 479 |
+
translated_dict[bid] = val
|
| 480 |
+
self.timing_pressure_report.append(self._timing_pressure_for_block(b, val))
|
| 481 |
+
context_history.append({"source": b["text"], "translation": val})
|
| 482 |
+
|
| 483 |
+
chunk_idx += 1
|
| 484 |
+
|
| 485 |
+
finally:
|
| 486 |
+
# Save provider attempts report
|
| 487 |
+
try:
|
| 488 |
+
with open(report_dir / "provider_attempts.json", "w", encoding="utf-8") as f:
|
| 489 |
+
json.dump(self.provider_attempts, f, ensure_ascii=False, indent=2)
|
| 490 |
+
except Exception as e:
|
| 491 |
+
if log_fn:
|
| 492 |
+
log_fn(f"⚠️ Không thể lưu provider_attempts.json: {e}")
|
| 493 |
+
|
| 494 |
+
# Save validation report
|
| 495 |
+
try:
|
| 496 |
+
with open(report_dir / "translation_validation_report.json", "w", encoding="utf-8") as f:
|
| 497 |
+
json.dump(self.validation_report, f, ensure_ascii=False, indent=2)
|
| 498 |
+
except Exception as e:
|
| 499 |
+
if log_fn:
|
| 500 |
+
log_fn(f"⚠️ Không thể lưu translation_validation_report.json: {e}")
|
| 501 |
+
|
| 502 |
+
# Save failed_chunks.json manifest
|
| 503 |
+
try:
|
| 504 |
+
with open(report_dir / "failed_chunks.json", "w", encoding="utf-8") as f:
|
| 505 |
+
json.dump(self.failed_blocks, f, ensure_ascii=False, indent=2)
|
| 506 |
+
except Exception as e:
|
| 507 |
+
if log_fn:
|
| 508 |
+
log_fn(f"⚠️ Không thể lưu failed_chunks.json: {e}")
|
| 509 |
+
|
| 510 |
+
try:
|
| 511 |
+
quality_report = self._build_quality_report(len(parsed_blocks))
|
| 512 |
+
with open(report_dir / "translation_quality_report.json", "w", encoding="utf-8") as f:
|
| 513 |
+
json.dump(quality_report, f, ensure_ascii=False, indent=2)
|
| 514 |
+
except Exception as e:
|
| 515 |
+
if log_fn:
|
| 516 |
+
log_fn(f"⚠️ Không thể lưu translation_quality_report.json: {e}")
|
| 517 |
+
|
| 518 |
+
try:
|
| 519 |
+
with open(report_dir / "translation_timing_pressure.json", "w", encoding="utf-8") as f:
|
| 520 |
+
json.dump(getattr(self, "timing_pressure_report", []), f, ensure_ascii=False, indent=2)
|
| 521 |
+
except Exception as e:
|
| 522 |
+
if log_fn:
|
| 523 |
+
log_fn(f"⚠️ Không thể lưu translation_timing_pressure.json: {e}")
|
| 524 |
+
|
| 525 |
+
try:
|
| 526 |
+
with open(report_dir / "translation_repair_report.json", "w", encoding="utf-8") as f:
|
| 527 |
+
json.dump(getattr(self, "translation_repair_report", []), f, ensure_ascii=False, indent=2)
|
| 528 |
+
except Exception as e:
|
| 529 |
+
if log_fn:
|
| 530 |
+
log_fn(f"⚠️ Không thể lưu translation_repair_report.json: {e}")
|
| 531 |
+
|
| 532 |
+
# 3. Quality metrics check
|
| 533 |
+
failed_count = len(self.failed_blocks)
|
| 534 |
+
failed_ratio = failed_count / total_blocks if total_blocks > 0 else 0.0
|
| 535 |
+
|
| 536 |
+
self.untranslated_chunks = failed_count
|
| 537 |
+
self.failed_chunks = self.failed_blocks
|
| 538 |
+
|
| 539 |
+
if failed_count > self.max_failed_blocks_before_fail or failed_ratio > self.max_failed_block_ratio_before_fail:
|
| 540 |
+
raise Exception(f"Stage C failed: {failed_count} blocks failed translation, exceeding limits.")
|
| 541 |
+
|
| 542 |
+
translated_dict = self._run_semantic_quality_guard(
|
| 543 |
+
parsed_blocks=parsed_blocks,
|
| 544 |
+
translated_dict=translated_dict,
|
| 545 |
+
report_dir=report_dir,
|
| 546 |
+
log_fn=log_fn,
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
if log_fn:
|
| 550 |
+
log_fn(f"✅ Translation complete. {len(translated_dict)} blocks processed ({failed_count} blocks failed).")
|
| 551 |
+
|
| 552 |
+
return translated_dict
|
| 553 |
+
|
| 554 |
+
# ── Cascade Splitting Logic ──────────────────────────────────────────────
|
| 555 |
+
|
| 556 |
+
def _translate_chunk_with_cascade(self, chunk, chunk_num_str, context_str, log_fn, depth=0):
|
| 557 |
+
"""Try progressively smaller chunk sizes on failure."""
|
| 558 |
+
if depth == 0:
|
| 559 |
+
rep = f"Chunk {chunk_num_str}"
|
| 560 |
+
else:
|
| 561 |
+
rep = f"Subchunk {chunk_num_str}"
|
| 562 |
+
|
| 563 |
+
start_id = chunk[0]["id"]
|
| 564 |
+
end_id = chunk[-1]["id"]
|
| 565 |
+
tokens = sum(self._estimate_tokens(b["text"]) for b in chunk)
|
| 566 |
+
|
| 567 |
+
if log_fn:
|
| 568 |
+
if depth == 0:
|
| 569 |
+
log_fn(f"[TRANSLATION] {rep}: blocks {start_id}-{end_id}, estimated_tokens={tokens}")
|
| 570 |
+
elif depth == 1:
|
| 571 |
+
log_fn(f"[TRANSLATION] {rep}: blocks {start_id}-{end_id}")
|
| 572 |
+
else:
|
| 573 |
+
log_fn(f"[TRANSLATION] Block fallback: id={start_id}")
|
| 574 |
+
|
| 575 |
+
# Core attempt
|
| 576 |
+
result = self._translate_chunk_core(chunk, chunk_num_str, context_str, log_fn)
|
| 577 |
+
if result is not None:
|
| 578 |
+
if log_fn:
|
| 579 |
+
if depth == 0:
|
| 580 |
+
log_fn(f"[TRANSLATION] Provider {self.last_successful_provider} succeeded for chunk {chunk_num_str}, blocks {start_id}-{end_id}")
|
| 581 |
+
elif depth == 1:
|
| 582 |
+
log_fn(f"[TRANSLATION] Provider {self.last_successful_provider} succeeded for subchunk {chunk_num_str}, blocks {start_id}-{end_id}")
|
| 583 |
+
else:
|
| 584 |
+
log_fn(f"[TRANSLATION] Provider {self.last_successful_provider} succeeded for block id={start_id}")
|
| 585 |
+
return result, False
|
| 586 |
+
|
| 587 |
+
# If it failed, check split depth limit or size
|
| 588 |
+
if depth >= self.max_split_depth or len(chunk) <= 1:
|
| 589 |
+
# Use placeholder fallback
|
| 590 |
+
for b in chunk:
|
| 591 |
+
bid = str(b["id"])
|
| 592 |
+
self.failed_blocks.append({
|
| 593 |
+
"id": b["id"],
|
| 594 |
+
"text": b["text"],
|
| 595 |
+
"reason": "All providers failed at block level"
|
| 596 |
+
})
|
| 597 |
+
# Return placeholder
|
| 598 |
+
return {str(b["id"]): "[CẦN DỊCH LẠI]" for b in chunk}, True
|
| 599 |
+
|
| 600 |
+
# Split recursive cascade
|
| 601 |
+
next_depth = depth + 1
|
| 602 |
+
sub_size = self.fallback_chunk_sizes[next_depth] if next_depth < len(self.fallback_chunk_sizes) else 1
|
| 603 |
+
|
| 604 |
+
if log_fn:
|
| 605 |
+
log_fn(f" ↪ Splitting {rep} (blocks {start_id}-{end_id}) into sub-chunks of size {sub_size}...")
|
| 606 |
+
|
| 607 |
+
merged = {}
|
| 608 |
+
any_failed_fallback = False
|
| 609 |
+
|
| 610 |
+
for idx, j in enumerate(range(0, len(chunk), sub_size)):
|
| 611 |
+
sub = chunk[j: j + sub_size]
|
| 612 |
+
sub_num_str = f"{chunk_num_str}.{idx+1}"
|
| 613 |
+
sub_result, is_fb = self._translate_chunk_with_cascade(
|
| 614 |
+
sub, sub_num_str, context_str, log_fn, next_depth
|
| 615 |
+
)
|
| 616 |
+
merged.update(sub_result)
|
| 617 |
+
if is_fb:
|
| 618 |
+
any_failed_fallback = True
|
| 619 |
+
|
| 620 |
+
return merged, any_failed_fallback
|
| 621 |
+
|
| 622 |
+
# ── Core Single Chunk Call ───────────────────────────────────────────────
|
| 623 |
+
|
| 624 |
+
def _translate_chunk_core(self, chunk, chunk_num_str, context_str, log_fn):
|
| 625 |
+
system_prompt, user_prompt = self._build_prompts(chunk, context_str)
|
| 626 |
+
providers = self.fallback_chain.get_providers()
|
| 627 |
+
chunk_key = f"{chunk[0]['id']}-{chunk[-1]['id']}"
|
| 628 |
+
|
| 629 |
+
for name, provider in providers:
|
| 630 |
+
self._check_budgets()
|
| 631 |
+
|
| 632 |
+
if name in self.disabled_providers:
|
| 633 |
+
continue
|
| 634 |
+
|
| 635 |
+
if not self.health.is_available(name):
|
| 636 |
+
continue
|
| 637 |
+
|
| 638 |
+
# Check if this specific provider already failed validation on this exact chunk range
|
| 639 |
+
if name in self.validation_failures_by_provider_chunk.get(chunk_key, []):
|
| 640 |
+
if log_fn:
|
| 641 |
+
log_fn(f" ⏳ Skipping provider '{name}' (repeated validation failure on blocks {chunk_key}).")
|
| 642 |
+
continue
|
| 643 |
+
|
| 644 |
+
if log_fn:
|
| 645 |
+
log_fn(f" 🔌 Trying provider: {name} ...")
|
| 646 |
+
|
| 647 |
+
self.total_provider_calls += 1
|
| 648 |
+
start_time = time.time()
|
| 649 |
+
try:
|
| 650 |
+
if name == "Google":
|
| 651 |
+
res_dict = provider.translate_chunk(chunk, log_fn=log_fn)
|
| 652 |
+
else:
|
| 653 |
+
res_dict = provider.translate_chunk(
|
| 654 |
+
chunk, system_prompt, user_prompt, log_fn=log_fn
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
duration = time.time() - start_time
|
| 658 |
+
res_dict = self._compact_voiceover_dict(res_dict)
|
| 659 |
+
res_dict = self._repair_translation_quality(
|
| 660 |
+
chunk=chunk,
|
| 661 |
+
res_dict=res_dict,
|
| 662 |
+
provider=provider,
|
| 663 |
+
provider_name=name,
|
| 664 |
+
chunk_num_str=chunk_num_str,
|
| 665 |
+
context_str=context_str,
|
| 666 |
+
log_fn=log_fn,
|
| 667 |
+
)
|
| 668 |
+
is_valid, err_msg = self.validator.validate(chunk, res_dict)
|
| 669 |
+
if is_valid:
|
| 670 |
+
is_valid, err_msg = self._quality_gate(chunk, res_dict)
|
| 671 |
+
|
| 672 |
+
self.provider_attempts.append({
|
| 673 |
+
"chunk_id": chunk_num_str,
|
| 674 |
+
"block_range": chunk_key,
|
| 675 |
+
"provider": name,
|
| 676 |
+
"result": "success" if is_valid else "validation_fail",
|
| 677 |
+
"error": None if is_valid else err_msg,
|
| 678 |
+
"duration_seconds": round(duration, 2)
|
| 679 |
+
})
|
| 680 |
+
|
| 681 |
+
self.validation_report.append({
|
| 682 |
+
"chunk_num": chunk_num_str,
|
| 683 |
+
"chunk_size": len(chunk),
|
| 684 |
+
"provider": name,
|
| 685 |
+
"status": "VALID" if is_valid else "INVALID",
|
| 686 |
+
"error_message": err_msg,
|
| 687 |
+
"timestamp": time.time(),
|
| 688 |
+
})
|
| 689 |
+
|
| 690 |
+
if is_valid:
|
| 691 |
+
self.health.mark_success(name)
|
| 692 |
+
self.last_successful_provider = name
|
| 693 |
+
return {str(k): str(v) for k, v in res_dict.items()}
|
| 694 |
+
else:
|
| 695 |
+
if log_fn:
|
| 696 |
+
log_fn(f" ⚠️ Validation failed ({name}): {err_msg}")
|
| 697 |
+
self.validation_failures_by_provider_chunk.setdefault(chunk_key, []).append(name)
|
| 698 |
+
|
| 699 |
+
except Exception as e:
|
| 700 |
+
duration = time.time() - start_time
|
| 701 |
+
err_str = str(e)
|
| 702 |
+
|
| 703 |
+
cooldown = 120
|
| 704 |
+
if "404" in err_str:
|
| 705 |
+
self.disabled_providers.add(name)
|
| 706 |
+
if log_fn:
|
| 707 |
+
log_fn(f" ❌ Provider '{name}' returned 404. Disabling for current session.")
|
| 708 |
+
elif "400" in err_str:
|
| 709 |
+
cooldown = 3600
|
| 710 |
+
if log_fn:
|
| 711 |
+
log_fn(f" ❌ Provider '{name}' returned 400. 1-hour cooldown.")
|
| 712 |
+
elif self._is_rate_limit(err_str):
|
| 713 |
+
cooldown = 1800
|
| 714 |
+
if log_fn:
|
| 715 |
+
log_fn(f" ❌ Provider '{name}' rate limited/insufficient quota. 30-min cooldown.")
|
| 716 |
+
elif "timeout" in err_str or "timed out" in err_str:
|
| 717 |
+
cooldown = 300
|
| 718 |
+
if log_fn:
|
| 719 |
+
log_fn(f" ❌ Provider '{name}' timed out. 5-min cooldown.")
|
| 720 |
+
else:
|
| 721 |
+
if log_fn:
|
| 722 |
+
log_fn(f" ❌ Provider '{name}' error: {err_str[:120]}")
|
| 723 |
+
|
| 724 |
+
self.health.mark_failed(name, cooldown_seconds=cooldown, error=err_str)
|
| 725 |
+
|
| 726 |
+
self.provider_attempts.append({
|
| 727 |
+
"chunk_id": chunk_num_str,
|
| 728 |
+
"block_range": chunk_key,
|
| 729 |
+
"provider": name,
|
| 730 |
+
"result": "fail",
|
| 731 |
+
"error": err_str,
|
| 732 |
+
"duration_seconds": round(duration, 2)
|
| 733 |
+
})
|
| 734 |
+
|
| 735 |
+
self.validation_report.append({
|
| 736 |
+
"chunk_num": chunk_num_str,
|
| 737 |
+
"chunk_size": len(chunk),
|
| 738 |
+
"provider": name,
|
| 739 |
+
"status": "ERROR",
|
| 740 |
+
"error_message": err_str,
|
| 741 |
+
"timestamp": time.time(),
|
| 742 |
+
})
|
| 743 |
+
|
| 744 |
+
# MyMemory last-resort fallback
|
| 745 |
+
if self.translation_profile == "dub_compact_vi_fashion" and self.disable_machine_fallback_for_compact_profile:
|
| 746 |
+
if log_fn:
|
| 747 |
+
log_fn(" [MyMemory] Skipped for compact voiceover profile to avoid literal long translations.")
|
| 748 |
+
return None
|
| 749 |
+
|
| 750 |
+
if not self.mymemory_disabled:
|
| 751 |
+
chunk_text = " ".join(b.get("text", "") for b in chunk)
|
| 752 |
+
if self.disable_mymemory_for_long_text and len(chunk_text) > 1000:
|
| 753 |
+
if log_fn:
|
| 754 |
+
log_fn(f" [MyMemory] Chunk text too long ({len(chunk_text)} chars). Skipping MyMemory.")
|
| 755 |
+
else:
|
| 756 |
+
self._check_budgets()
|
| 757 |
+
if log_fn:
|
| 758 |
+
log_fn(f" 🌐 Trying MyMemory machine translation for chunk {chunk_num_str}...")
|
| 759 |
+
|
| 760 |
+
self.total_provider_calls += 1
|
| 761 |
+
start_time = time.time()
|
| 762 |
+
try:
|
| 763 |
+
from app.translation.providers.machine_translation_provider import MachineTranslationProvider
|
| 764 |
+
mt = MachineTranslationProvider()
|
| 765 |
+
res_dict = mt.translate_chunk(chunk, log_fn=log_fn)
|
| 766 |
+
res_dict = self._compact_voiceover_dict(res_dict)
|
| 767 |
+
res_dict = self._deterministic_compact_dict(chunk, res_dict)
|
| 768 |
+
duration = time.time() - start_time
|
| 769 |
+
is_valid, err_msg = self.validator.validate(chunk, res_dict)
|
| 770 |
+
if is_valid:
|
| 771 |
+
is_valid, err_msg = self._quality_gate(chunk, res_dict)
|
| 772 |
+
|
| 773 |
+
self.provider_attempts.append({
|
| 774 |
+
"chunk_id": chunk_num_str,
|
| 775 |
+
"block_range": chunk_key,
|
| 776 |
+
"provider": "MyMemory",
|
| 777 |
+
"result": "success" if is_valid else "validation_fail",
|
| 778 |
+
"error": None if is_valid else err_msg,
|
| 779 |
+
"duration_seconds": round(duration, 2)
|
| 780 |
+
})
|
| 781 |
+
|
| 782 |
+
if is_valid:
|
| 783 |
+
self.last_successful_provider = "MyMemory"
|
| 784 |
+
return {str(k): str(v) for k, v in res_dict.items()}
|
| 785 |
+
except Exception as e:
|
| 786 |
+
duration = time.time() - start_time
|
| 787 |
+
err_str = str(e)
|
| 788 |
+
|
| 789 |
+
self.provider_attempts.append({
|
| 790 |
+
"chunk_id": chunk_num_str,
|
| 791 |
+
"block_range": chunk_key,
|
| 792 |
+
"provider": "MyMemory",
|
| 793 |
+
"result": "fail",
|
| 794 |
+
"error": err_str,
|
| 795 |
+
"duration_seconds": round(duration, 2)
|
| 796 |
+
})
|
| 797 |
+
|
| 798 |
+
if "414" in err_str or "Request-URI Too Long" in err_str:
|
| 799 |
+
if self.disable_mymemory_on_414:
|
| 800 |
+
self.mymemory_disabled = True
|
| 801 |
+
if log_fn:
|
| 802 |
+
log_fn("[MyMemory] disabled globally for this session because request URI too long")
|
| 803 |
+
|
| 804 |
+
return None
|
| 805 |
+
|
| 806 |
+
# ── Final Semantic Quality Guard ─────────────────────────────────────────
|
| 807 |
+
|
| 808 |
+
def _run_semantic_quality_guard(self, parsed_blocks, translated_dict, report_dir, log_fn=None):
|
| 809 |
+
if not self.semantic_quality_guard_enabled:
|
| 810 |
+
return translated_dict
|
| 811 |
+
|
| 812 |
+
from app.translation.quality_guard import TranslationQualityGuard
|
| 813 |
+
|
| 814 |
+
guard = TranslationQualityGuard(min_score=self.semantic_guard_min_score)
|
| 815 |
+
fixed_dict, report = guard.audit_and_fix(parsed_blocks, translated_dict)
|
| 816 |
+
before_errors = int(report.get("error_blocks", 0))
|
| 817 |
+
|
| 818 |
+
if log_fn:
|
| 819 |
+
log_fn(
|
| 820 |
+
f"[SEMANTIC GUARD] audit: errors={report.get('error_blocks', 0)}, "
|
| 821 |
+
f"warns={report.get('warn_blocks', 0)}"
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
if before_errors and self.semantic_guard_ai_repair_enabled:
|
| 825 |
+
candidates = [r for r in report.get("rows", []) if r.get("needs_ai_repair")]
|
| 826 |
+
candidates = candidates[: max(0, self.semantic_guard_max_ai_repair_blocks)]
|
| 827 |
+
if candidates:
|
| 828 |
+
fixed_dict = self._repair_semantic_guard_blocks(parsed_blocks, fixed_dict, candidates, log_fn=log_fn)
|
| 829 |
+
fixed_dict, report = guard.audit_and_fix(parsed_blocks, fixed_dict)
|
| 830 |
+
report["ai_repair_attempted_blocks"] = [r.get("id") for r in candidates]
|
| 831 |
+
|
| 832 |
+
unresolved = int(report.get("error_blocks", 0))
|
| 833 |
+
report["status"] = "FAILED" if unresolved > self.semantic_guard_max_unresolved_errors else report.get("status", "OK")
|
| 834 |
+
try:
|
| 835 |
+
guard.save_report(report, Path(report_dir) / "semantic_quality_report.json")
|
| 836 |
+
except Exception as e:
|
| 837 |
+
if log_fn:
|
| 838 |
+
log_fn(f"⚠️ Không thể lưu semantic_quality_report.json: {e}")
|
| 839 |
+
|
| 840 |
+
self.semantic_quality_report = report
|
| 841 |
+
if unresolved:
|
| 842 |
+
msg = f"[SEMANTIC GUARD] unresolved semantic errors: {unresolved}"
|
| 843 |
+
if log_fn:
|
| 844 |
+
log_fn("⚠️ " + msg)
|
| 845 |
+
if self.semantic_guard_fail_on_unresolved and unresolved > self.semantic_guard_max_unresolved_errors:
|
| 846 |
+
raise Exception(msg)
|
| 847 |
+
return fixed_dict
|
| 848 |
+
|
| 849 |
+
def _repair_semantic_guard_blocks(self, parsed_blocks, translated_dict, candidates, log_fn=None):
|
| 850 |
+
block_by_id = {str(b.get("id")): b for b in parsed_blocks}
|
| 851 |
+
providers = [
|
| 852 |
+
(name, provider)
|
| 853 |
+
for name, provider in self.fallback_chain.get_providers()
|
| 854 |
+
if name not in ("Google", "MyMemory") and hasattr(provider, "translate_chunk")
|
| 855 |
+
]
|
| 856 |
+
if not providers:
|
| 857 |
+
return translated_dict
|
| 858 |
+
|
| 859 |
+
repaired = dict(translated_dict)
|
| 860 |
+
batch_size = 12
|
| 861 |
+
for offset in range(0, len(candidates), batch_size):
|
| 862 |
+
rows = candidates[offset: offset + batch_size]
|
| 863 |
+
blocks = [block_by_id[str(r.get("id"))] for r in rows if str(r.get("id")) in block_by_id]
|
| 864 |
+
if not blocks:
|
| 865 |
+
continue
|
| 866 |
+
|
| 867 |
+
payload = {}
|
| 868 |
+
for row in rows:
|
| 869 |
+
bid = str(row.get("id"))
|
| 870 |
+
block = block_by_id.get(bid)
|
| 871 |
+
if not block:
|
| 872 |
+
continue
|
| 873 |
+
payload[bid] = {
|
| 874 |
+
"source": self.sanitize_context_text(block.get("text", "")),
|
| 875 |
+
"current_vi": str(repaired.get(bid, "")),
|
| 876 |
+
"issues": row.get("issues", []),
|
| 877 |
+
"duration_sec": block.get("duration_sec", 0),
|
| 878 |
+
"target_max_syllables": self._target_max_for_block(block),
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
system_prompt = (
|
| 882 |
+
"Bạn là biên tập viên dịch phụ đề/lồng tiếng Việt cho video Trung Quốc về fashion, streetwear, rapper, lifestyle.\n"
|
| 883 |
+
"Sửa các bản dịch lỗi dựa trên source tiếng Trung. Không dịch từng chữ, không giải thích, không markdown.\n"
|
| 884 |
+
"Câu Việt phải tự nhiên, dễ hiểu, ngắn vừa đủ để đọc lên. Giữ tên riêng, brand, số tiền, năm, địa danh.\n"
|
| 885 |
+
"Tránh lỗi literal: 割韭菜 = chặt chém/móc ví người mua; 机车 = mô tô/xe phân khối lớn; 溢价 = đội giá.\n"
|
| 886 |
+
"Output bắt buộc là JSON object {\"id\":\"câu Việt đã sửa\"}."
|
| 887 |
+
)
|
| 888 |
+
user_prompt = (
|
| 889 |
+
f"Glossary: {json.dumps(self.glossary, ensure_ascii=False)}\n"
|
| 890 |
+
"Sửa các block lỗi sau. Trả đủ key, chỉ value tiếng Việt:\n"
|
| 891 |
+
f"{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
| 892 |
+
)
|
| 893 |
+
|
| 894 |
+
for provider_name, provider in providers:
|
| 895 |
+
if not self.health.is_available(provider_name):
|
| 896 |
+
continue
|
| 897 |
+
try:
|
| 898 |
+
self._check_budgets()
|
| 899 |
+
self.total_provider_calls += 1
|
| 900 |
+
if log_fn:
|
| 901 |
+
log_fn(f"[SEMANTIC GUARD] AI repair {len(blocks)} block via {provider_name}...")
|
| 902 |
+
result = provider.translate_chunk(blocks, system_prompt, user_prompt, log_fn=log_fn)
|
| 903 |
+
if isinstance(result, dict):
|
| 904 |
+
for key, value in result.items():
|
| 905 |
+
repaired[str(key)] = str(value).strip()
|
| 906 |
+
self.health.mark_success(provider_name)
|
| 907 |
+
break
|
| 908 |
+
except Exception as e:
|
| 909 |
+
self.health.mark_failed(provider_name, cooldown_seconds=180, error=str(e))
|
| 910 |
+
if log_fn:
|
| 911 |
+
log_fn(f"⚠️ [SEMANTIC GUARD] AI repair failed via {provider_name}: {str(e)[:120]}")
|
| 912 |
+
|
| 913 |
+
return repaired
|
| 914 |
+
|
| 915 |
+
# ── Prompt builders ────────────────────────────────────────────────────────
|
| 916 |
+
|
| 917 |
+
def _build_context_str(self, context_history):
|
| 918 |
+
if not context_history:
|
| 919 |
+
return ""
|
| 920 |
+
lines = ["Dòng thoại đã dịch trước đó để giữ nhất quán:"]
|
| 921 |
+
for h in context_history[-5:]:
|
| 922 |
+
src = self.sanitize_context_text(h["source"])
|
| 923 |
+
trans = self.sanitize_context_text(h["translation"])
|
| 924 |
+
lines.append(f'- Gốc: "{src}" -> Dịch: "{trans}"')
|
| 925 |
+
return "\n".join(lines)
|
| 926 |
+
|
| 927 |
+
# ── Viral translation phrases (học từ video viral Trung→Việt) ─────────────
|
| 928 |
+
VIRAL_ZH_VI_EXAMPLES = (
|
| 929 |
+
"Ví dụ dịch viral (học phong cách này — không phải dịch chữ mà dịch cảm xúc):\n"
|
| 930 |
+
"- 这太绝了 → 'Đỉnh vãi' / 'Xịn không chịu được'\n"
|
| 931 |
+
"- 牛逼爆了 → 'Đỉnh nổ não'\n"
|
| 932 |
+
"- 我真的服了 → 'Tao thua thật rồi'\n"
|
| 933 |
+
"- 这哥们真的太狠了 → 'Ông này chơi lớn thật'\n"
|
| 934 |
+
"- 有钱人的快乐 → 'Người giàu sướng vậy đó'\n"
|
| 935 |
+
"- 这不是一般的贵 → 'Đắt không phải dạng vừa'\n"
|
| 936 |
+
"- 这就是实力 → 'Đây mới là đẳng cấp'\n"
|
| 937 |
+
"- 说真的 → 'Nói thật là' / 'Thật ra thì'\n"
|
| 938 |
+
"- 接下来我们来看 → 'Bây giờ xem nào'\n"
|
| 939 |
+
"- 其实这个搭配真的很好看 → 'Bộ này phối đẹp thật'\n"
|
| 940 |
+
"- 这件事情很重要 → 'Cái này quan trọng đó'\n"
|
| 941 |
+
"- 凭什么这么贵 → 'Đắt vậy là vì sao?'\n"
|
| 942 |
+
"- 整活整的好 → 'Con này drama được đó'\n"
|
| 943 |
+
"- 这配色绝了 → 'Màu phối này đỉnh'\n"
|
| 944 |
+
"- 这件不好看吗? → 'Cái này trông không đẹp sao?'\n"
|
| 945 |
+
"- Câu hype (energy_hint=high_energy): dùng 'Đỉnh vãi', 'Xịn không chịu được', 'Bro này chơi lớn'.\n"
|
| 946 |
+
"- Câu kể (energy_hint=nostalgic/reflective): dùng 'Hồi đó...', 'Thật ra là...', 'Điều thú vị là...'.\n"
|
| 947 |
+
"- Câu presenter: dùng 'Bây giờ xem...', 'Đây là...', 'Tiếp theo là...'.\n"
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
def _build_prompts(self, chunk, context_str):
|
| 951 |
+
json_payload = {}
|
| 952 |
+
has_ocr_subtitle = False
|
| 953 |
+
for b in chunk:
|
| 954 |
+
duration = float(b.get("duration_sec") or 0.0)
|
| 955 |
+
source_mode = str(b.get("source_mode") or "speech")
|
| 956 |
+
if source_mode == "ocr_subtitle":
|
| 957 |
+
has_ocr_subtitle = True
|
| 958 |
+
raw_text = b.get("text", "")
|
| 959 |
+
# Thêm energy_hint để AI giữ đúng giọng cảm xúc
|
| 960 |
+
energy = self._detect_energy(raw_text)
|
| 961 |
+
json_payload[str(b["id"])] = {
|
| 962 |
+
"id": str(b["id"]),
|
| 963 |
+
"start": b.get("start", ""),
|
| 964 |
+
"end": b.get("end", ""),
|
| 965 |
+
"duration_sec": round(duration, 3),
|
| 966 |
+
"target_length_hint": self._target_length_hint(duration),
|
| 967 |
+
"target_max_syllables": self._target_max_for_block(b),
|
| 968 |
+
"source_mode": source_mode,
|
| 969 |
+
"energy_hint": energy,
|
| 970 |
+
"source": self.sanitize_context_text(raw_text),
|
| 971 |
+
}
|
| 972 |
+
json_str = json.dumps(json_payload, ensure_ascii=False, indent=2)
|
| 973 |
+
|
| 974 |
+
profile_rules = self._profile_rules()
|
| 975 |
+
ocr_rules = ""
|
| 976 |
+
if has_ocr_subtitle:
|
| 977 |
+
ocr_rules = (
|
| 978 |
+
"Chế độ OCR caption trên màn hình: text nguồn có thể dư, nhiễu hoặc quá dài.\n"
|
| 979 |
+
"Với source_mode=ocr_subtitle, chỉ lấy ý chính để người xem hiểu thư giãn; không dịch từng chữ.\n"
|
| 980 |
+
"Cách rút ý: giữ mốc đầu câu, ý chính ở giữa, và kết luận/cuối câu nếu quan trọng. Được bỏ hư từ và chi tiết phụ.\n"
|
| 981 |
+
"Mục tiêu mỗi caption OCR: 5-12 âm tiết tiếng Việt, tối đa 2 dòng ngắn, không làm dài hơn source.\n"
|
| 982 |
+
"Nếu nhiều block OCR sát nhau cùng một ý, viết biến thể ngắn hoặc rút còn ý mới; không lặp nguyên một câu nhiều lần.\n"
|
| 983 |
+
"Nếu nguồn OCR có vẻ rác/không hiểu được, trả '[CẦN OCR LẠI]' cho đúng key, không bịa nội dung.\n"
|
| 984 |
+
)
|
| 985 |
+
|
| 986 |
+
if self.source_language == "en":
|
| 987 |
+
system_prompt = (
|
| 988 |
+
"Bạn là biên tập viên lời lồng tiếng Việt chuyên video tiếng Anh về thời trang, rapper và lifestyle.\n"
|
| 989 |
+
"Nhiệm vụ: Hiểu ý câu tiếng Anh rồi viết lại thành lời Việt ngắn, tự nhiên, đọc được bằng TTS. Không dịch từng chữ.\n"
|
| 990 |
+
"Đọc field 'energy_hint' của từng block: high_energy→câu hype cảm thán mạnh; reflective→câu suy nghĩ bình tĩnh; nostalgic→giọng kể chuyện; presenter→câu dẫn chương trình.\n"
|
| 991 |
+
"Giữ thiết bản: outfit, drip, fit, streetwear, luxury, sneaker, hoodie, brand name, rapper name, album.\n"
|
| 992 |
+
"Không dịch sai slang: drip không phải 'nhỏ giọt', fit không phải 'sự phù hợp', chain không phải 'chuỗi'.\n"
|
| 993 |
+
"Luôn nhìn duration_sec của từng block để chọn độ dài câu Vi���t phù hợp.\n"
|
| 994 |
+
"Mốc độ dài: dưới 1.2s khoảng 6-8 âm tiết; 1.2-2.0s khoảng 8-12; 2.0-3.5s khoảng 12-18; trên 3.5s vẫn tự nhiên nhưng gọn.\n"
|
| 995 |
+
"CHẶT ÂM TIẾT (bắt buộc): với mỗi block, đếm số TỪ tiếng Việt của câu dịch và KIỂM TRA count(words) <= duration_sec * 2.3. Nếu vượt, rút gọn ngay cho đúng giới hạn.\n"
|
| 996 |
+
"HOOK MỞ ĐẦU: block đầu tiên (id nhỏ nhất trong video) phải viết thành câu hook gây tò mò kiểu TikTok/Shorts, kích thích xem tiếp, vẫn đúng ý nguồn và vẫn tuân thủ giới hạn âm tiết.\n"
|
| 997 |
+
"TAG CẢM XÚC: cuối mỗi câu, thêm đúng 1 tag cảm xúc trong ngoặc vuông bằng tiếng Việt, chọn trong: [hốt hoảng], [thì thầm], [dứt khoát]. Không thêm tag nào khác.\n"
|
| 998 |
+
"TAG SFX: nếu câu phù hợp chèn hiệu ứng âm thanh làm video sống động, thêm tối đa 1 tag SFX trong ngoặc vuông, chọn trong: [whoosh], [ding], [impact]. Câu bình thường không cần tag SFX.\n"
|
| 999 |
+
"Thứ tự tag ở cuối câu: [CẢM XÚC] rồi [SFX] nếu có. Ví dụ hợp lệ: 'Đỉnh vãi anh ơi [dứt khoát]' hoặc 'Khoan đã, nhìn này [hốt hoảng] [whoosh]'.\n"
|
| 1000 |
+
f"{ocr_rules}"
|
| 1001 |
+
f"Profile dịch: {self.translation_profile}.\n"
|
| 1002 |
+
f"Luật profile: {' | '.join(profile_rules)}\n"
|
| 1003 |
+
f"{self.VIRAL_ZH_VI_EXAMPLES}"
|
| 1004 |
+
"Ví dụ EN: 'He's the hardest rapper in Atlanta' → 'Anh ấy là rapper khủng nhất Atlanta'; "
|
| 1005 |
+
"'This collab is fire' → 'Collab này đỉnh lắm'; 'Young Thug's style is iconic' → 'Phong cách Young Thug quá đỉnh'.\n"
|
| 1006 |
+
"KHÔNG thêm chú thích, giải thích ngoài lề. KHÔNG đổi key JSON. KHÔNG lặp lại cùng một câu.\n"
|
| 1007 |
+
"Output bắt buộc là JSON object duy nhất: key giữ nguyên, value là câu tiếng Việt gọn. Không trả về object lồng nhau.\n"
|
| 1008 |
+
f"Văn phong: {self.style}"
|
| 1009 |
+
)
|
| 1010 |
+
else:
|
| 1011 |
+
system_prompt = (
|
| 1012 |
+
"Bạn là biên tập viên lời lồng tiếng Việt chuyên video Trung Quốc về thời trang, rapper và lifestyle.\n"
|
| 1013 |
+
"Nhiệm vụ KHÔNG phải dịch phụ đề từng chữ. Hãy hiểu ý câu Trung rồi VIẾT LẠI thành lời Việt ngắn, tự nhiên, như thuyết minh video TikTok.\n"
|
| 1014 |
+
"Đọc field 'energy_hint' của từng block và giữ đúng năng lượng:\n"
|
| 1015 |
+
" • high_energy → dùng 'Đỉnh vãi', 'Xịn không chịu được', 'Bro này chơi lớn', câu ngắn mạnh\n"
|
| 1016 |
+
" • reflective → dùng 'Thật ra là', 'Nói thật', 'Điều thú vị là', câu có chiều sâu hơn\n"
|
| 1017 |
+
" • nostalgic → dùng 'Hồi đó', 'Ngày xưa', câu giọng kể\n"
|
| 1018 |
+
" • presenter → dùng 'Bây giờ xem', 'Đây là', 'Tiếp theo'\n"
|
| 1019 |
+
" • neutral → câu tự nhiên theo ngữ cảnh\n"
|
| 1020 |
+
"Ưu tiên đồng tiếng: câu Việt phải gọn hơn bản dịch literal, ít filler, không dàn chữ.\n"
|
| 1021 |
+
"Được lược bớt hư từ nếu không mất ý: là, thì, rằng, chúng ta, có thể, một cách, thực sự là, về cơ bản, điều này.\n"
|
| 1022 |
+
"Không được bỏ tên người, rapper, brand, địa danh, collection, bài hát, số tiền, ngày tháng.\n"
|
| 1023 |
+
"Giữ slang quen thuộc: outfit, fit, drip, streetwear, luxury, sneaker, hoodie, jacket, denim, oversized, vintage, archive, runway, collab, iconic, layering.\n"
|
| 1024 |
+
"Không dịch sai slang: fit ≠ 'sự phù hợp', drip ≠ 'nhỏ giọt', chain ≠ 'chuỗi' khi nói trang sức/thời trang.\n"
|
| 1025 |
+
"Không dịch literal idiom: '割韭菜'=chặt chém người mua; '溢价'=đội giá; '机车'(CH context)=mô tô phân khối lớn; '牛逼'=đỉnh; '服了'=thua thật; '整活'=drama.\n"
|
| 1026 |
+
"Luôn nhìn duration_sec: dưới 1.2s→6-8 âm tiết; 1.2-2.0s→8-12; 2.0-3.5s→12-18; trên 3.5s→tự nhiên nhưng gọn.\n"
|
| 1027 |
+
"CHẶT ÂM TIẾT (bắt buộc): với mỗi block, đếm số TỪ tiếng Việt của câu dịch và KIỂM TRA count(words) <= duration_sec * 2.3. Nếu vượt, rút gọn ngay cho đúng giới hạn.\n"
|
| 1028 |
+
"HOOK MỞ ĐẦU: block đầu tiên (id nhỏ nhất trong video) phải viết thành câu hook gây tò mò kiểu TikTok/Shorts, kích thích xem tiếp, vẫn đúng ý nguồn và vẫn tuân thủ giới hạn âm tiết.\n"
|
| 1029 |
+
"TAG CẢM XÚC: cuối mỗi câu, thêm đúng 1 tag cảm xúc trong ngoặc vuông bằng tiếng Việt, chọn trong: [hốt hoảng], [thì thầm], [dứt khoát]. Không thêm tag nào khác.\n"
|
| 1030 |
+
"TAG SFX: nếu câu phù hợp chèn hiệu ứng âm thanh làm video sống động, thêm tối đa 1 tag SFX trong ngoặc vuông, chọn trong: [whoosh], [ding], [impact]. Câu bình thường không cần tag SFX.\n"
|
| 1031 |
+
"Thứ tự tag ở cuối câu: [CẢM XÚC] rồi [SFX] nếu có. Ví dụ hợp lệ: 'Đỉnh vãi anh ơi [dứt khoát]' hoặc 'Khoan đã, nhìn này [hốt hoảng] [whoosh]'.\n"
|
| 1032 |
+
f"{ocr_rules}"
|
| 1033 |
+
f"Profile dịch: {self.translation_profile}.\n"
|
| 1034 |
+
f"Luật profile: {' | '.join(profile_rules)}\n"
|
| 1035 |
+
f"{self.VIRAL_ZH_VI_EXAMPLES}"
|
| 1036 |
+
"KHÔNG thêm chú thích, giải thích ngoài lề. KHÔNG đổi key JSON. KHÔNG lặp cùng câu qua nhiều block.\n"
|
| 1037 |
+
"Output bắt buộc là JSON object duy nhất: key giữ nguyên, value là câu tiếng Việt đã biên tập gọn. Không trả object lồng nhau.\n"
|
| 1038 |
+
f"Văn phong: {self.style}"
|
| 1039 |
+
)
|
| 1040 |
+
|
| 1041 |
+
user_prompt = (
|
| 1042 |
+
"Biên tập và dịch JSON sau sang tiếng Việt để lồng tiếng. Giữ nguyên toàn bộ key. Trả về JSON hợp lệ duy nhất dạng {\"id\":\"câu Việt\"}.\n"
|
| 1043 |
+
f"Glossary: {json.dumps(self.glossary, ensure_ascii=False)}\n"
|
| 1044 |
+
f"{context_str}\n"
|
| 1045 |
+
f"Input JSON:\n{json_str}"
|
| 1046 |
+
)
|
| 1047 |
+
|
| 1048 |
+
return system_prompt, user_prompt
|
| 1049 |
+
|
| 1050 |
+
|
| 1051 |
+
def _profile_rules(self):
|
| 1052 |
+
profiles = {
|
| 1053 |
+
"reup_natural_vi": [
|
| 1054 |
+
"Dịch tự nhiên như lời thuyết minh video ngắn.",
|
| 1055 |
+
"Không dịch word-by-word.",
|
| 1056 |
+
"Câu tiếng Việt ngắn, rõ, hợp TTS.",
|
| 1057 |
+
"Không thêm giải thích ngoài nội dung gốc.",
|
| 1058 |
+
],
|
| 1059 |
+
"documentary_vi": [
|
| 1060 |
+
"Giữ giọng kể rõ ràng, chín chắn.",
|
| 1061 |
+
"Ưu tiên thuật ngữ chính xác.",
|
| 1062 |
+
"Câu không quá dài để dễ đọc.",
|
| 1063 |
+
],
|
| 1064 |
+
"news_vi": [
|
| 1065 |
+
"Văn phong tin tức ngắn gọn.",
|
| 1066 |
+
"Giữ số liệu, tên riêng, địa danh.",
|
| 1067 |
+
"Không thêm bình luận.",
|
| 1068 |
+
],
|
| 1069 |
+
"hiphop_vi": [
|
| 1070 |
+
"Giữ slang rap/fashion quen thuộc khi tự nhiên hơn.",
|
| 1071 |
+
"Giữ tên nghệ sĩ, label, bài hát, brand.",
|
| 1072 |
+
"Không Việt hóa tên riêng.",
|
| 1073 |
+
],
|
| 1074 |
+
"anime_vi": [
|
| 1075 |
+
"Dịch tự nhiên theo hội thoại nhân vật.",
|
| 1076 |
+
"Giữ sắc thái cảm xúc.",
|
| 1077 |
+
"Câu ngắn, dễ lồng tiếng.",
|
| 1078 |
+
],
|
| 1079 |
+
"dub_compact_vi_fashion": [
|
| 1080 |
+
"Dịch theo ý chính, viết lại như lời voiceover Việt, không bám từng chữ Trung.",
|
| 1081 |
+
"Câu phải ngắn, nói được, ưu tiên khớp thời lượng từng block.",
|
| 1082 |
+
"Giữ slang thời trang/rap/lifestyle quen thuộc nếu tự nhiên hơn bản Việt hóa.",
|
| 1083 |
+
"Lược filler nhưng giữ tên riêng, brand, số liệu và thông tin quan trọng.",
|
| 1084 |
+
"Nếu bản dịch literal quá dài, rút lại thành câu Việt gọn mà người xem vẫn hiểu.",
|
| 1085 |
+
"Đọc energy_hint: high_energy→hype mạnh; reflective→tường thuật; neutral→tự nhiên.",
|
| 1086 |
+
],
|
| 1087 |
+
"viral_zform_vi": [
|
| 1088 |
+
"Viết như TikToker Việt thuyết minh video Trung — không phải phụ đề học thuật.",
|
| 1089 |
+
"Câu ngắn, mạnh, năng lượng cao. Ưu tiên khẩu ngữ Gen Z Việt tự nhiên.",
|
| 1090 |
+
"high_energy: 'Đỉnh vãi', 'Xịn không chịu được', 'Ông này chơi lớn', 'Thua thật'.",
|
| 1091 |
+
"reflective: 'Thật ra là', 'Nói thật', 'Điều thú vị là', 'Mà mày biết không'.",
|
| 1092 |
+
"presenter: 'Bây giờ xem nào', 'Đây là', 'Tiếp theo là', 'Hãy nhìn vào'.",
|
| 1093 |
+
"Tuyệt đối giữ brand name, rapper name, bài hát, số tiền, năm tháng.",
|
| 1094 |
+
"Nếu câu Trung hype → câu Việt cũng phải hype — không dịch phẳng.",
|
| 1095 |
+
"Rút gọn không thương tiếc filler: là, thì, rằng, có thể, thực sự là, chúng ta.",
|
| 1096 |
+
],
|
| 1097 |
+
}
|
| 1098 |
+
return profiles.get(self.translation_profile, profiles["reup_natural_vi"])
|
| 1099 |
+
|
| 1100 |
+
def _target_length_hint(self, duration_sec):
|
| 1101 |
+
if duration_sec <= 0:
|
| 1102 |
+
return "ngắn gọn, dễ đọc"
|
| 1103 |
+
if duration_sec < 1.2:
|
| 1104 |
+
return "khoảng 6-8 âm tiết"
|
| 1105 |
+
if duration_sec < 2.0:
|
| 1106 |
+
return "khoảng 8-12 âm tiết"
|
| 1107 |
+
if duration_sec < 3.5:
|
| 1108 |
+
return "khoảng 12-18 âm tiết"
|
| 1109 |
+
return "tự nhiên nhưng vẫn gọn"
|
| 1110 |
+
|
| 1111 |
+
def _target_max_syllables(self, duration_sec):
|
| 1112 |
+
if duration_sec <= 0:
|
| 1113 |
+
return None
|
| 1114 |
+
if duration_sec < 1.2:
|
| 1115 |
+
return 8
|
| 1116 |
+
if duration_sec < 2.0:
|
| 1117 |
+
return 12
|
| 1118 |
+
if duration_sec < 3.5:
|
| 1119 |
+
return 18
|
| 1120 |
+
return max(18, int(duration_sec * 5.5))
|
| 1121 |
+
|
| 1122 |
+
def _target_max_for_block(self, block):
|
| 1123 |
+
target = self._target_max_syllables(float(block.get("duration_sec") or 0.0))
|
| 1124 |
+
if str(block.get("source_mode") or "") == "ocr_subtitle":
|
| 1125 |
+
cap = max(6, int(self.max_ocr_translation_syllables))
|
| 1126 |
+
target = min(target or cap, cap)
|
| 1127 |
+
return target
|
| 1128 |
+
|
| 1129 |
+
def _estimate_vi_syllables(self, text):
|
| 1130 |
+
import re
|
| 1131 |
+
return len(re.findall(r"\b[\wÀ-ỹ]+\b", str(text), flags=re.UNICODE))
|
| 1132 |
+
|
| 1133 |
+
def _timing_pressure_for_block(self, block, translation):
|
| 1134 |
+
duration = float(block.get("duration_sec") or 0.0)
|
| 1135 |
+
syllables = self._estimate_vi_syllables(translation)
|
| 1136 |
+
target_max = self._target_max_for_block(block)
|
| 1137 |
+
over_ratio = 0.0
|
| 1138 |
+
status = "UNKNOWN"
|
| 1139 |
+
if target_max:
|
| 1140 |
+
over_ratio = syllables / target_max if target_max else 0.0
|
| 1141 |
+
status = "OK" if over_ratio <= 1.0 else ("WARN" if over_ratio <= 1.25 else "TOO_LONG")
|
| 1142 |
+
return {
|
| 1143 |
+
"id": str(block.get("id", "")),
|
| 1144 |
+
"duration_sec": round(duration, 3),
|
| 1145 |
+
"target_max_syllables": target_max,
|
| 1146 |
+
"estimated_syllables": syllables,
|
| 1147 |
+
"over_ratio": round(over_ratio, 3),
|
| 1148 |
+
"status": status,
|
| 1149 |
+
"text": str(translation),
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
def _quality_gate(self, chunk, res_dict):
|
| 1153 |
+
if not isinstance(res_dict, dict):
|
| 1154 |
+
return False, "quality_gate: output is not dict"
|
| 1155 |
+
|
| 1156 |
+
issues = self._translation_quality_issues(chunk, res_dict)
|
| 1157 |
+
blocking = [x for x in issues if x.get("severity") == "block"]
|
| 1158 |
+
if blocking:
|
| 1159 |
+
sample = blocking[0]
|
| 1160 |
+
return False, f"quality_gate: {sample.get('reason')} block={sample.get('id')}"
|
| 1161 |
+
return True, "OK"
|
| 1162 |
+
|
| 1163 |
+
def _translation_quality_issues(self, chunk, res_dict):
|
| 1164 |
+
issues = []
|
| 1165 |
+
norm_seen = {}
|
| 1166 |
+
previous_norm = None
|
| 1167 |
+
previous_id = None
|
| 1168 |
+
for b in chunk:
|
| 1169 |
+
bid = str(b.get("id"))
|
| 1170 |
+
text = str(res_dict.get(bid, "")).strip()
|
| 1171 |
+
norm = self._normalise_for_loop_check(text)
|
| 1172 |
+
low = text.lower()
|
| 1173 |
+
if any(p in low for p in ["không thể dịch", "lỗi mã hóa", "không dịch được", "cannot translate"]):
|
| 1174 |
+
issues.append({
|
| 1175 |
+
"id": bid,
|
| 1176 |
+
"reason": "translator_refused_or_encoding_error",
|
| 1177 |
+
"severity": "block",
|
| 1178 |
+
"text": text,
|
| 1179 |
+
})
|
| 1180 |
+
if any(ch in text for ch in ["□", "�"]):
|
| 1181 |
+
issues.append({
|
| 1182 |
+
"id": bid,
|
| 1183 |
+
"reason": "translated_text_contains_ocr_placeholders",
|
| 1184 |
+
"severity": "block",
|
| 1185 |
+
"text": text,
|
| 1186 |
+
})
|
| 1187 |
+
if low.count("sao chép") >= 5 or low.count("copy") >= 5:
|
| 1188 |
+
issues.append({
|
| 1189 |
+
"id": bid,
|
| 1190 |
+
"reason": "translated_ocr_noise_loop",
|
| 1191 |
+
"severity": "block",
|
| 1192 |
+
"text": text,
|
| 1193 |
+
})
|
| 1194 |
+
if norm and len(norm) > 4:
|
| 1195 |
+
norm_seen.setdefault(norm, []).append(bid)
|
| 1196 |
+
if previous_norm == norm:
|
| 1197 |
+
issues.append({
|
| 1198 |
+
"id": bid,
|
| 1199 |
+
"reason": "consecutive_duplicate_translation",
|
| 1200 |
+
"previous_id": previous_id,
|
| 1201 |
+
"severity": "block",
|
| 1202 |
+
"text": text,
|
| 1203 |
+
})
|
| 1204 |
+
previous_norm = norm
|
| 1205 |
+
previous_id = bid
|
| 1206 |
+
|
| 1207 |
+
pressure = self._timing_pressure_for_block(b, text)
|
| 1208 |
+
if self.enforce_timing_quality and pressure.get("target_max_syllables"):
|
| 1209 |
+
over_ratio = float(pressure.get("over_ratio") or 0.0)
|
| 1210 |
+
if over_ratio > self.max_timing_over_ratio:
|
| 1211 |
+
issues.append({
|
| 1212 |
+
"id": bid,
|
| 1213 |
+
"reason": "too_long_for_voiceover_timing",
|
| 1214 |
+
"severity": "block",
|
| 1215 |
+
"over_ratio": over_ratio,
|
| 1216 |
+
"target_max_syllables": pressure.get("target_max_syllables"),
|
| 1217 |
+
"estimated_syllables": pressure.get("estimated_syllables"),
|
| 1218 |
+
"duration_sec": pressure.get("duration_sec"),
|
| 1219 |
+
"text": text,
|
| 1220 |
+
})
|
| 1221 |
+
|
| 1222 |
+
total = max(1, len(chunk))
|
| 1223 |
+
for norm, ids in norm_seen.items():
|
| 1224 |
+
if len(ids) >= 3 or (len(ids) >= 2 and len(ids) / total > 0.20):
|
| 1225 |
+
for bid in ids:
|
| 1226 |
+
issues.append({
|
| 1227 |
+
"id": bid,
|
| 1228 |
+
"reason": "repeated_translation_loop",
|
| 1229 |
+
"severity": "block",
|
| 1230 |
+
"repeat_ids": ids,
|
| 1231 |
+
})
|
| 1232 |
+
return issues
|
| 1233 |
+
|
| 1234 |
+
def _normalise_for_loop_check(self, text):
|
| 1235 |
+
import re
|
| 1236 |
+
return re.sub(r"[\s.,!?;:\"'\-–—]+", "", str(text).lower())
|
| 1237 |
+
|
| 1238 |
+
def _repair_translation_quality(self, chunk, res_dict, provider, provider_name, chunk_num_str, context_str, log_fn):
|
| 1239 |
+
res_dict = self._deterministic_compact_dict(chunk, res_dict)
|
| 1240 |
+
issues = self._translation_quality_issues(chunk, res_dict)
|
| 1241 |
+
if not issues:
|
| 1242 |
+
return res_dict
|
| 1243 |
+
|
| 1244 |
+
if log_fn:
|
| 1245 |
+
log_fn(f" [QUALITY] {len(issues)} vấn đề dịch cần sửa trước TTS ({provider_name}, chunk {chunk_num_str}).")
|
| 1246 |
+
|
| 1247 |
+
can_llm_repair = provider_name not in ("Google", "MyMemory") and hasattr(provider, "translate_chunk")
|
| 1248 |
+
repaired = dict(res_dict)
|
| 1249 |
+
|
| 1250 |
+
for attempt in range(1, self.max_translation_repair_attempts + 1):
|
| 1251 |
+
if can_llm_repair:
|
| 1252 |
+
try:
|
| 1253 |
+
system_prompt, user_prompt = self._build_repair_prompts(chunk, repaired, issues, context_str)
|
| 1254 |
+
self.total_provider_calls += 1
|
| 1255 |
+
candidate = provider.translate_chunk(chunk, system_prompt, user_prompt, log_fn=log_fn)
|
| 1256 |
+
candidate = self._compact_voiceover_dict(candidate)
|
| 1257 |
+
candidate = self._deterministic_compact_dict(chunk, candidate)
|
| 1258 |
+
if isinstance(candidate, dict):
|
| 1259 |
+
repaired.update({str(k): str(v) for k, v in candidate.items()})
|
| 1260 |
+
except Exception as e:
|
| 1261 |
+
if log_fn:
|
| 1262 |
+
log_fn(f" [QUALITY] Repair attempt {attempt} failed via {provider_name}: {str(e)[:120]}")
|
| 1263 |
+
else:
|
| 1264 |
+
repaired = self._deterministic_compact_dict(chunk, repaired, aggressive=True)
|
| 1265 |
+
|
| 1266 |
+
issues = self._translation_quality_issues(chunk, repaired)
|
| 1267 |
+
self.translation_repair_report.append({
|
| 1268 |
+
"chunk_id": chunk_num_str,
|
| 1269 |
+
"provider": provider_name,
|
| 1270 |
+
"attempt": attempt,
|
| 1271 |
+
"remaining_issues": issues,
|
| 1272 |
+
})
|
| 1273 |
+
if not issues:
|
| 1274 |
+
if log_fn:
|
| 1275 |
+
log_fn(f" [QUALITY] Repair PASS for chunk {chunk_num_str}.")
|
| 1276 |
+
return repaired
|
| 1277 |
+
|
| 1278 |
+
return repaired
|
| 1279 |
+
|
| 1280 |
+
def _build_repair_prompts(self, chunk, current_dict, issues, context_str):
|
| 1281 |
+
payload = {}
|
| 1282 |
+
for b in chunk:
|
| 1283 |
+
bid = str(b.get("id"))
|
| 1284 |
+
related = [x for x in issues if str(x.get("id")) == bid]
|
| 1285 |
+
duration = float(b.get("duration_sec") or 0.0)
|
| 1286 |
+
payload[bid] = {
|
| 1287 |
+
"id": bid,
|
| 1288 |
+
"duration_sec": round(duration, 3),
|
| 1289 |
+
"target_length_hint": self._target_length_hint(duration),
|
| 1290 |
+
"target_max_syllables": self._target_max_for_block(b),
|
| 1291 |
+
"source_mode": str(b.get("source_mode") or "speech"),
|
| 1292 |
+
"source": self.sanitize_context_text(b.get("text", "")),
|
| 1293 |
+
"current_vi": str(current_dict.get(bid, "")),
|
| 1294 |
+
"issues": related,
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
system_prompt = (
|
| 1298 |
+
"Bạn là biên tập viên lời lồng tiếng Việt. Nhiệm vụ là SỬA bản dịch đã có để dùng TTS.\n"
|
| 1299 |
+
"Bắt buộc: câu ngắn hơn, không lặp câu, không thêm giải thích, không trả markdown.\n"
|
| 1300 |
+
"Nếu issue là too_long_for_voiceover_timing: rút câu xuống bằng hoặc dưới target_max_syllables. Lược filler mạnh: là, thì, rằng, có thể, thực sự là.\n"
|
| 1301 |
+
"Nếu issue là duplicate/loop: viết lại đúng ý từng source, mỗi block phải khác nhau nếu source khác nhau.\n"
|
| 1302 |
+
"Giữ năng lượng câu: nếu source hype (! 牛逼 绝了) thì câu sửa cũng phải hype ('Đỉnh vãi', 'Xịn không chịu được').\n"
|
| 1303 |
+
"Giữ tên riêng, brand, số liệu, slang fashion/rap quan trọng. Không Việt hóa brand name.\n"
|
| 1304 |
+
"Output chỉ là JSON object {\"id\":\"câu Việt đã sửa\"}."
|
| 1305 |
+
)
|
| 1306 |
+
user_prompt = (
|
| 1307 |
+
f"{context_str}\n"
|
| 1308 |
+
f"Glossary: {json.dumps(self.glossary, ensure_ascii=False)}\n"
|
| 1309 |
+
f"Sửa các dòng sau. Trả về đủ key, value là câu Việt ngắn để lồng tiếng:\n"
|
| 1310 |
+
f"{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
| 1311 |
+
)
|
| 1312 |
+
return system_prompt, user_prompt
|
| 1313 |
+
|
| 1314 |
+
def _compact_voiceover_dict(self, res_dict):
|
| 1315 |
+
if self.translation_profile != "dub_compact_vi_fashion" or not isinstance(res_dict, dict):
|
| 1316 |
+
return res_dict
|
| 1317 |
+
return {str(k): self._compact_voiceover_text(str(v)) for k, v in res_dict.items()}
|
| 1318 |
+
|
| 1319 |
+
def _deterministic_compact_dict(self, chunk, res_dict, aggressive=False):
|
| 1320 |
+
if not isinstance(res_dict, dict):
|
| 1321 |
+
return res_dict
|
| 1322 |
+
out = {str(k): self._compact_voiceover_text(str(v)) for k, v in res_dict.items()}
|
| 1323 |
+
for b in chunk:
|
| 1324 |
+
bid = str(b.get("id"))
|
| 1325 |
+
if bid not in out:
|
| 1326 |
+
continue
|
| 1327 |
+
target = self._target_max_for_block(b)
|
| 1328 |
+
if target and self._estimate_vi_syllables(out[bid]) > target * self.max_timing_over_ratio:
|
| 1329 |
+
out[bid] = self._hard_compact_text(
|
| 1330 |
+
out[bid],
|
| 1331 |
+
target,
|
| 1332 |
+
aggressive=aggressive or str(b.get("source_mode") or "") == "ocr_subtitle",
|
| 1333 |
+
preserve_edges=str(b.get("source_mode") or "") == "ocr_subtitle",
|
| 1334 |
+
)
|
| 1335 |
+
return out
|
| 1336 |
+
|
| 1337 |
+
def _compact_voiceover_text(self, text):
|
| 1338 |
+
import re
|
| 1339 |
+
t = str(text).strip()
|
| 1340 |
+
replacements = [
|
| 1341 |
+
(r"\bTên của anh ấy là\b", "Anh ấy tên"),
|
| 1342 |
+
(r"\bTên của cô ấy là\b", "Cô ấy tên"),
|
| 1343 |
+
(r"\bTên của người này là\b", "Người này tên"),
|
| 1344 |
+
(r"\bTiếp theo chúng ta sẽ xem\b", "Giờ xem"),
|
| 1345 |
+
(r"\bTiếp theo chúng ta sẽ thấy\b", "Giờ xem"),
|
| 1346 |
+
(r"\bChúng ta sẽ thấy rằng\b", "Ta thấy"),
|
| 1347 |
+
(r"\bCó thể thấy rằng\b", "Thấy rõ"),
|
| 1348 |
+
(r"\bthực sự là\b", ""),
|
| 1349 |
+
(r"\bthật sự là\b", ""),
|
| 1350 |
+
(r"\bvề cơ bản\b", ""),
|
| 1351 |
+
(r"\bmột cách\b", ""),
|
| 1352 |
+
(r"\bđiều này\b", "việc này"),
|
| 1353 |
+
]
|
| 1354 |
+
for pattern, repl in replacements:
|
| 1355 |
+
t = re.sub(pattern, repl, t, flags=re.IGNORECASE)
|
| 1356 |
+
t = re.sub(r"\s+", " ", t).strip(" ,")
|
| 1357 |
+
return t
|
| 1358 |
+
|
| 1359 |
+
def _hard_compact_text(self, text, target_syllables, aggressive=False, preserve_edges=False):
|
| 1360 |
+
import re
|
| 1361 |
+
protected_terms = set(str(k).lower() for k in self.glossary.keys())
|
| 1362 |
+
filler_patterns = [
|
| 1363 |
+
r"\b(của|là|thì|rằng|mà|này|đó|ấy|thực sự|thật sự|về cơ bản|có thể|chúng ta|các bạn|mọi người)\b",
|
| 1364 |
+
r"\b(một cách|điều này|việc này|ở đây|ngay bây giờ)\b",
|
| 1365 |
+
]
|
| 1366 |
+
t = str(text).strip()
|
| 1367 |
+
for pat in filler_patterns:
|
| 1368 |
+
t = re.sub(pat, " ", t, flags=re.IGNORECASE)
|
| 1369 |
+
t = re.sub(r"\s+", " ", t).strip(" ,")
|
| 1370 |
+
|
| 1371 |
+
if not aggressive or self._estimate_vi_syllables(t) <= target_syllables:
|
| 1372 |
+
return t
|
| 1373 |
+
|
| 1374 |
+
words = t.split()
|
| 1375 |
+
if len(words) <= target_syllables:
|
| 1376 |
+
return t
|
| 1377 |
+
|
| 1378 |
+
if preserve_edges and target_syllables >= 8:
|
| 1379 |
+
first_n = max(2, target_syllables // 4)
|
| 1380 |
+
last_n = max(2, target_syllables // 4)
|
| 1381 |
+
middle_n = max(2, target_syllables - first_n - last_n)
|
| 1382 |
+
mid_start = max(first_n, (len(words) - middle_n) // 2)
|
| 1383 |
+
mid_end = min(len(words) - last_n, mid_start + middle_n)
|
| 1384 |
+
selected = words[:first_n] + words[mid_start:mid_end] + words[-last_n:]
|
| 1385 |
+
compacted = []
|
| 1386 |
+
for w in selected:
|
| 1387 |
+
if not compacted or compacted[-1] != w:
|
| 1388 |
+
compacted.append(w)
|
| 1389 |
+
return " ".join(compacted[:target_syllables]).strip(" ,")
|
| 1390 |
+
|
| 1391 |
+
kept = []
|
| 1392 |
+
for w in words:
|
| 1393 |
+
lw = re.sub(r"[^\wÀ-ỹ]", "", w.lower())
|
| 1394 |
+
if len(kept) < target_syllables or lw in protected_terms or any(ch.isdigit() for ch in w):
|
| 1395 |
+
kept.append(w)
|
| 1396 |
+
return " ".join(kept).strip(" ,")
|
| 1397 |
+
|
| 1398 |
+
def _build_quality_report(self, total_blocks):
|
| 1399 |
+
failed_count = len(getattr(self, "failed_blocks", []))
|
| 1400 |
+
provider_failures = sum(1 for a in getattr(self, "provider_attempts", []) if a.get("result") != "success")
|
| 1401 |
+
validation_failures = sum(1 for r in getattr(self, "validation_report", []) if r.get("status") != "VALID")
|
| 1402 |
+
failed_ratio = failed_count / total_blocks if total_blocks else 0.0
|
| 1403 |
+
if failed_count == 0:
|
| 1404 |
+
status = "OK"
|
| 1405 |
+
elif failed_count <= self.max_failed_blocks_before_fail and failed_ratio <= self.max_failed_block_ratio_before_fail:
|
| 1406 |
+
status = "NEED_REVIEW"
|
| 1407 |
+
else:
|
| 1408 |
+
status = "FAILED"
|
| 1409 |
+
return {
|
| 1410 |
+
"status": status,
|
| 1411 |
+
"translation_profile": self.translation_profile,
|
| 1412 |
+
"total_blocks": total_blocks,
|
| 1413 |
+
"failed_blocks": failed_count,
|
| 1414 |
+
"failed_ratio": round(failed_ratio, 4),
|
| 1415 |
+
"provider_attempts": len(getattr(self, "provider_attempts", [])),
|
| 1416 |
+
"provider_failures": provider_failures,
|
| 1417 |
+
"validation_failures": validation_failures,
|
| 1418 |
+
"timing_pressure": self._timing_pressure_summary(),
|
| 1419 |
+
"disabled_providers": sorted(list(getattr(self, "disabled_providers", set()))),
|
| 1420 |
+
}
|
| 1421 |
+
|
| 1422 |
+
def _timing_pressure_summary(self):
|
| 1423 |
+
rows = getattr(self, "timing_pressure_report", [])
|
| 1424 |
+
if not rows:
|
| 1425 |
+
return {
|
| 1426 |
+
"checked_blocks": 0,
|
| 1427 |
+
"too_long_blocks": 0,
|
| 1428 |
+
"warn_blocks": 0,
|
| 1429 |
+
"max_over_ratio": 0.0,
|
| 1430 |
+
}
|
| 1431 |
+
too_long = [r for r in rows if r.get("status") == "TOO_LONG"]
|
| 1432 |
+
warn = [r for r in rows if r.get("status") == "WARN"]
|
| 1433 |
+
return {
|
| 1434 |
+
"checked_blocks": len(rows),
|
| 1435 |
+
"too_long_blocks": len(too_long),
|
| 1436 |
+
"warn_blocks": len(warn),
|
| 1437 |
+
"max_over_ratio": round(max(float(r.get("over_ratio") or 0.0) for r in rows), 3),
|
| 1438 |
+
"too_long_ids": [r.get("id") for r in too_long[:20]],
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
| 1442 |
+
|
| 1443 |
+
@staticmethod
|
| 1444 |
+
def _is_rate_limit(err_str: str) -> bool:
|
| 1445 |
+
err_lower = err_str.lower()
|
| 1446 |
+
return any(kw in err_lower for kw in [
|
| 1447 |
+
"402", "429", "rate limit", "too many requests", "quota",
|
| 1448 |
+
"insufficient_quota", "billing", "payment"
|
| 1449 |
+
])
|
| 1450 |
+
|
| 1451 |
+
# ── Report ────────────────────────────────────────────────────────────────
|
| 1452 |
+
|
| 1453 |
+
def save_validation_report(self, dest_path):
|
| 1454 |
+
try:
|
| 1455 |
+
with open(dest_path, "w", encoding="utf-8") as f:
|
| 1456 |
+
json.dump(self.validation_report, f, ensure_ascii=False, indent=2)
|
| 1457 |
+
except Exception as e:
|
| 1458 |
+
print(f"Failed to save translation validation report: {e}")
|
app/translation/provider_health.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/translation/provider_health.py (CP2 new)
|
| 3 |
+
──────────────────────────────────────────────
|
| 4 |
+
Per-provider health and cooldown tracking.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
health = ProviderHealth.instance()
|
| 8 |
+
if health.is_available("9Router"):
|
| 9 |
+
... # use provider
|
| 10 |
+
health.mark_failed("9Router", cooldown_seconds=300)
|
| 11 |
+
health.mark_success("9Router")
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
import threading
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ProviderHealth:
|
| 19 |
+
"""Singleton tracking per-provider cooldown and error count."""
|
| 20 |
+
|
| 21 |
+
_instance = None
|
| 22 |
+
_lock = threading.Lock()
|
| 23 |
+
|
| 24 |
+
@classmethod
|
| 25 |
+
def instance(cls):
|
| 26 |
+
with cls._lock:
|
| 27 |
+
if cls._instance is None:
|
| 28 |
+
cls._instance = cls()
|
| 29 |
+
return cls._instance
|
| 30 |
+
|
| 31 |
+
def __init__(self):
|
| 32 |
+
# {provider_name: {"cooldown_until": float, "error_count": int, "last_error": str}}
|
| 33 |
+
self._state: dict[str, dict] = {}
|
| 34 |
+
self._state_lock = threading.Lock()
|
| 35 |
+
|
| 36 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
def is_available(self, provider_name: str) -> bool:
|
| 39 |
+
"""Returns True if provider is not in cooldown."""
|
| 40 |
+
with self._state_lock:
|
| 41 |
+
entry = self._state.get(provider_name, {})
|
| 42 |
+
cooldown_until = entry.get("cooldown_until", 0.0)
|
| 43 |
+
return time.time() >= cooldown_until
|
| 44 |
+
|
| 45 |
+
def mark_failed(self, provider_name: str, cooldown_seconds: float = 120.0, error: str = ""):
|
| 46 |
+
"""Put provider in cooldown after failure. Error count is cumulative."""
|
| 47 |
+
with self._state_lock:
|
| 48 |
+
entry = self._state.setdefault(provider_name, {"error_count": 0})
|
| 49 |
+
entry["error_count"] = entry.get("error_count", 0) + 1
|
| 50 |
+
entry["last_error"] = error
|
| 51 |
+
# Exponential back-off: cooldown doubles each consecutive failure, cap at 10 min
|
| 52 |
+
backoff = min(cooldown_seconds * (2 ** (entry["error_count"] - 1)), 600.0)
|
| 53 |
+
entry["cooldown_until"] = time.time() + backoff
|
| 54 |
+
|
| 55 |
+
def mark_success(self, provider_name: str):
|
| 56 |
+
"""Reset error count on success."""
|
| 57 |
+
with self._state_lock:
|
| 58 |
+
entry = self._state.setdefault(provider_name, {"error_count": 0})
|
| 59 |
+
entry["error_count"] = 0
|
| 60 |
+
entry["cooldown_until"] = 0.0
|
| 61 |
+
entry["last_error"] = ""
|
| 62 |
+
|
| 63 |
+
def get_status(self) -> dict:
|
| 64 |
+
"""Return snapshot of current health state (for logging/UI)."""
|
| 65 |
+
now = time.time()
|
| 66 |
+
with self._state_lock:
|
| 67 |
+
result = {}
|
| 68 |
+
for name, entry in self._state.items():
|
| 69 |
+
cooldown_remaining = max(0.0, entry.get("cooldown_until", 0) - now)
|
| 70 |
+
result[name] = {
|
| 71 |
+
"available": cooldown_remaining == 0.0,
|
| 72 |
+
"cooldown_remaining_s": round(cooldown_remaining),
|
| 73 |
+
"error_count": entry.get("error_count", 0),
|
| 74 |
+
"last_error": entry.get("last_error", ""),
|
| 75 |
+
}
|
| 76 |
+
return result
|
| 77 |
+
|
| 78 |
+
def reset(self, provider_name: str):
|
| 79 |
+
"""Manually reset a provider's cooldown (for UI 'force retry' button)."""
|
| 80 |
+
with self._state_lock:
|
| 81 |
+
if provider_name in self._state:
|
| 82 |
+
self._state[provider_name] = {"error_count": 0, "cooldown_until": 0.0}
|