DRIPPY4 / app /web_server.py
hoangtaiii's picture
Fix 2p->20s + 20 bugs: pad audio, OCR/ASR coverage gates, TTS cache/placeholder, omni cloud, ASS escape, timeout, preflight, pool locks, SSRF guards
c5e5fda verified
Raw History Blame Contribute Delete
29.6 kB
"""
app/web_server.py
──────────────────
FastAPI Web Application & API Server for Cloud-Native Auto Dubbing Studio.
Accessible from any mobile phone, tablet, or browser anywhere.
"""
import os
import sys
import json
import time
import shutil
import asyncio
import uuid
import base64
from pathlib import Path
from typing import Dict, Any, Optional, List
# Add project root to sys.path
project_root = Path(__file__).resolve().parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from fastapi import FastAPI, UploadFile, File, Form, WebSocket, WebSocketDisconnect, BackgroundTasks, HTTPException, Body
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from app.core.cloud_pipeline import CloudPipeline
from app.api_pool import ApiPool
from app.core.job_manager import JobManager
app = FastAPI(title="TrungSangViet Auto Dubbing Cloud Studio", version="2.5.0")
# Enable CORS for remote access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
BASE_DIR = project_root
STATIC_DIR = BASE_DIR / "app" / "static"
UPLOAD_DIR = BASE_DIR / "temp" / "uploads"
OUTPUT_DIR = BASE_DIR / "output"
TEMP_DIR = BASE_DIR / "temp"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
# Mount static assets
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# WebSocket connection manager for live logs
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, list[WebSocket]] = {}
async def connect(self, job_id: str, websocket: WebSocket):
await websocket.accept()
if job_id not in self.active_connections:
self.active_connections[job_id] = []
self.active_connections[job_id].append(websocket)
def disconnect(self, job_id: str, websocket: WebSocket):
if job_id in self.active_connections:
if websocket in self.active_connections[job_id]:
self.active_connections[job_id].remove(websocket)
async def broadcast(self, job_id: str, message: dict):
if job_id in self.active_connections:
for connection in self.active_connections[job_id]:
try:
await connection.send_json(message)
except Exception:
pass
manager = ConnectionManager()
# In-memory jobs cache & database sync
jobs_db: Dict[str, Dict[str, Any]] = {}
job_manager = JobManager.instance()
_JOBS_SNAPSHOT = BASE_DIR / "temp" / "jobs_snapshot.json"
def _save_jobs_snapshot():
# FIX: jobs_db thuần in-memory -> restart server là mất hết job/log.
# Snapshot JSON để khởi động lại vẫn còn trạng thái DONE trước đó.
try:
serializable = {}
for k, v in jobs_db.items():
serializable[k] = {kk: vv for kk, vv in v.items() if kk != "subtitles"}
_JOBS_SNAPSHOT.write_text(json.dumps(serializable, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
def _load_jobs_snapshot():
try:
if _JOBS_SNAPSHOT.exists():
data = json.loads(_JOBS_SNAPSHOT.read_text(encoding="utf-8"))
if isinstance(data, dict):
jobs_db.update(data)
except Exception:
pass
_load_jobs_snapshot()
@app.get("/", response_class=HTMLResponse)
async def serve_index():
index_file = STATIC_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
return HTMLResponse(content="<h1>TrungSangViet Auto Dubbing Cloud Studio is running!</h1>")
@app.get("/api/system-status")
async def get_system_status():
pool = ApiPool.instance()
pool.reload()
return {
"status": "online",
"healthy_slots": pool.count_healthy(),
"total_slots": pool.count_slots(),
"report": pool.status_report()
}
@app.post("/api/upload-logo")
async def upload_logo(file: UploadFile = File(...)):
"""Upload logo image/video (PNG/JPG/MP4) for overlay. Returns saved_path for preview/render."""
ext = Path(file.filename).suffix.lower() or ".png"
if ext not in (".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".webm"):
ext = ".png"
saved_name = f"logo_{uuid.uuid4().hex[:8]}{ext}"
saved_path = UPLOAD_DIR / saved_name
with open(saved_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"status": "ok", "logo_path": str(saved_path), "filename": saved_name}
@app.post("/api/upload-cta")
async def upload_cta(file: UploadFile = File(...)):
"""Upload CTA video (MP4/MOV) for overlay. Returns saved_path."""
ext = Path(file.filename).suffix.lower() or ".mp4"
if ext not in (".mp4", ".mov", ".webm", ".avi"):
ext = ".mp4"
saved_name = f"cta_{uuid.uuid4().hex[:8]}{ext}"
saved_path = UPLOAD_DIR / saved_name
with open(saved_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"status": "ok", "cta_path": str(saved_path), "filename": saved_name}
@app.post("/api/upload")
async def upload_video(file: UploadFile = File(...)):
job_id = str(uuid.uuid4())[:8]
ext = Path(file.filename).suffix or ".mp4"
saved_name = f"{job_id}_{file.filename}"
saved_path = UPLOAD_DIR / saved_name
with open(saved_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {
"job_id": job_id,
"filename": file.filename,
"saved_path": str(saved_path)
}
def _url_host_is_private(url: str) -> bool:
# Chặn SSRF: không cho request tới IP nội bộ/loopback/link-local.
try:
import ipaddress
import socket
from urllib.parse import urlparse
host = urlparse(url).hostname or ""
if host in ("localhost",):
return True
try:
ip = ipaddress.ip_address(host)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
except ValueError:
pass
try:
resolved = socket.getaddrinfo(host, None, family=socket.AF_UNSPEC)
for fam, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except Exception:
return True
return False
except Exception:
return True
@app.post("/api/download-url")
async def download_video_url(url: str = Form(...)):
from urllib.parse import urlparse
scheme = urlparse(url).scheme.lower()
if scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL phải bắt đầu bằng http:// hoặc https://")
if _url_host_is_private(url):
raise HTTPException(status_code=400, detail="URL nội bộ không được phép (chống SSRF).")
job_id = str(uuid.uuid4())[:8]
saved_name = f"{job_id}_video.mp4"
saved_path = UPLOAD_DIR / saved_name
part_path = UPLOAD_DIR / f"{saved_name}.part"
# Try downloading with yt-dlp (atomic .part + rename)
try:
import subprocess
cmd = ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", str(part_path), url]
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
if res.returncode == 0 and part_path.exists() and part_path.stat().st_size > 5000:
part_path.replace(saved_path)
return {"job_id": job_id, "saved_path": str(saved_path), "filename": saved_name}
except Exception:
pass
finally:
try:
if part_path.exists() and not saved_path.exists():
part_path.unlink()
except Exception:
pass
# Fallback direct download (chỉ nhận content-type video, giới hạn 500MB)
import requests
try:
r = requests.get(url, stream=True, timeout=30, headers={"User-Agent": "Mozilla/5.0"})
if r.status_code == 200:
ctype = (r.headers.get("Content-Type", "") or "").lower()
if "video" not in ctype and "octet-stream" not in ctype:
raise HTTPException(status_code=400, detail=f"URL không trỏ tới file video (Content-Type: {ctype or 'unknown'}).")
total = 0
with open(part_path, "wb") as f:
for chunk in r.iter_content(chunk_size=1024*1024):
if chunk:
total += len(chunk)
if total > 500 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File video vượt quá 500MB.")
f.write(chunk)
if part_path.exists() and part_path.stat().st_size > 5000:
part_path.replace(saved_path)
return {"job_id": job_id, "saved_path": str(saved_path), "filename": saved_name}
except HTTPException:
raise
except Exception:
pass
finally:
try:
if part_path.exists() and not saved_path.exists():
part_path.unlink()
except Exception:
pass
raise HTTPException(status_code=400, detail="Không thể tải video từ đường link này. Vui lòng kiểm tra lại URL.")
@app.post("/api/preview-frame")
async def get_preview_frame(
video_path: str = Form(...),
sec: float = Form(2.0),
region_preset: str = Form("custom"),
custom_y_pct: float = Form(75.0),
custom_h_pct: float = Form(20.0),
custom_x_pct: float = Form(0.0),
custom_w_pct: float = Form(100.0),
logo_enabled: bool = Form(False),
logo_path: str = Form(""),
logo_preset: str = Form("bottom_right"),
logo_scale: float = Form(18.0),
logo_opacity: float = Form(1.0),
logo_x_pct: float = Form(80.0),
logo_y_pct: float = Form(80.0),
logo_chromakey: bool = Form(True),
cta_enabled: bool = Form(False),
cta_path: str = Form(""),
cta_preset: str = Form("bottom_center"),
cta_scale: float = Form(48.0),
cta_opacity: float = Form(1.0),
cta_x_pct: float = Form(50.0),
cta_y_pct: float = Form(82.0),
cta_interval: float = Form(5.0),
cta_duration: float = Form(2.0),
cta_chromakey: bool = Form(True)
):
if not Path(video_path).exists():
raise HTTPException(status_code=404, detail="File video không tồn tại.")
# Fallback to default bundled assets if enabled but no path supplied
if logo_enabled and not logo_path:
default_logo = BASE_DIR / "assets" / "logo_ins_drippy4.png"
if default_logo.exists():
logo_path = str(default_logo)
if cta_enabled and not cta_path:
default_cta = BASE_DIR / "assets" / "cta_ins_drippy4.mp4"
if default_cta.exists():
cta_path = str(default_cta)
pipeline = CloudPipeline(base_dir=BASE_DIR)
b64_img = pipeline.generate_preview_frame(
video_path=video_path,
region_preset=region_preset,
custom_y_pct=custom_y_pct,
custom_h_pct=custom_h_pct,
custom_x_pct=custom_x_pct,
custom_w_pct=custom_w_pct,
sec=sec,
return_type="base64",
logo_enabled=logo_enabled,
logo_path=logo_path,
logo_preset=logo_preset,
logo_scale=logo_scale,
logo_opacity=logo_opacity,
logo_x_pct=logo_x_pct,
logo_y_pct=logo_y_pct,
logo_chromakey=logo_chromakey,
cta_enabled=cta_enabled,
cta_path=cta_path,
cta_preset=cta_preset,
cta_scale=cta_scale,
cta_opacity=cta_opacity,
cta_x_pct=cta_x_pct,
cta_y_pct=cta_y_pct,
cta_interval=cta_interval,
cta_duration=cta_duration,
cta_chromakey=cta_chromakey
)
if b64_img:
return {"status": "ok", "image_base64": f"data:image/jpeg;base64,{b64_img}"}
raise HTTPException(status_code=500, detail="Không thể trích xuất khung hình từ video.")
@app.post("/api/tts-preview")
async def tts_preview(
text: str = Form("Xin chào, đây là giọng đọc thử nghiệm của Trung Sáng Việt Studio."),
voice: str = Form("vi-VN-NamMinhNeural"),
speed: float = Form(1.0),
pitch: int = Form(0),
volume: int = Form(100)
):
"""Generates an instant TTS audio preview as base64 MP3."""
import edge_tts
import tempfile
rate_str = f"{int(round((speed - 1.0) * 100)):+d}%"
pitch_str = f"{int(round(pitch)):+d}Hz"
vol_str = f"{int(round(volume - 100)):+d}%"
temp_mp3 = Path(tempfile.gettempdir()) / f"tts_sample_{int(time.time()*1000)}.mp3"
try:
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_str, pitch=pitch_str, volume=vol_str)
# FIX: hàm đã là async — await trực tiếp thay vì tạo event loop mới (leak + lỗi loop).
await communicate.save(str(temp_mp3))
if temp_mp3.exists() and temp_mp3.stat().st_size > 100:
audio_bytes = temp_mp3.read_bytes()
b64_audio = base64.b64encode(audio_bytes).decode('utf-8')
temp_mp3.unlink()
return {"status": "ok", "audio_base64": f"data:audio/mp3;base64,{b64_audio}"}
except Exception as e:
if temp_mp3.exists():
temp_mp3.unlink()
raise HTTPException(status_code=500, detail=f"Lỗi tạo giọng đọc: {str(e)}")
raise HTTPException(status_code=500, detail="Không thể tạo file âm thanh mẫu.")
@app.post("/api/start-pipeline")
async def start_pipeline(
background_tasks: BackgroundTasks,
job_id: str = Form(...),
video_path: str = Form(...),
mode: str = Form("asr"),
source_lang: str = Form("zh"),
voice: str = Form("vi-VN-NamMinhNeural"),
speed: float = Form(1.0),
pitch: int = Form(0),
volume: int = Form(100),
region_preset: str = Form("custom"),
sub_mask_mode: str = Form("box"),
sub_style: str = Form("motion_drip"),
custom_y_pct: float = Form(75.0),
custom_h_pct: float = Form(20.0),
custom_x_pct: float = Form(0.0),
custom_w_pct: float = Form(100.0),
ducking_ratio: float = Form(0.18),
enable_sfx: bool = Form(True),
mute_original_audio: bool = Form(False),
logo_enabled: bool = Form(False),
logo_path: str = Form(""),
logo_preset: str = Form("bottom_right"),
logo_scale: float = Form(18.0),
logo_opacity: float = Form(1.0),
logo_x_pct: float = Form(80.0),
logo_y_pct: float = Form(80.0),
logo_chromakey: bool = Form(True),
cta_enabled: bool = Form(False),
cta_path: str = Form(""),
cta_preset: str = Form("bottom_center"),
cta_scale: float = Form(48.0),
cta_opacity: float = Form(1.0),
cta_x_pct: float = Form(50.0),
cta_y_pct: float = Form(82.0),
cta_interval: float = Form(5.0),
cta_duration: float = Form(2.5),
cta_chromakey: bool = Form(True)
):
if not Path(video_path).exists():
raise HTTPException(status_code=404, detail="File video không tồn tại.")
jobs_db[job_id] = {
"job_id": job_id,
"video_path": video_path,
"filename": Path(video_path).name,
"status": "PROCESSING",
"progress": 0,
"stage": "STAGE_0_PREPARE",
"output_path": None,
"error": None,
"created_at": time.time()
}
_save_jobs_snapshot()
def run_job():
# NOTE: loop thừa (tạo mà không run/close -> leak) đã xóa.
# Callbacks dùng asyncio.run (loop riêng) vì chạy trong worker thread của BackgroundTasks.
def log_cb(msg: str):
asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")}))
def progress_cb(pct: int, stage_name: str):
jobs_db[job_id]["progress"] = pct
jobs_db[job_id]["stage"] = stage_name
asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name}))
pipeline = CloudPipeline(
base_dir=BASE_DIR,
log_callback=log_cb,
progress_callback=progress_cb
)
# Default logo/cta fallback
if logo_enabled and not logo_path:
_dl = BASE_DIR / "assets" / "logo_ins_drippy4.png"
if _dl.exists():
logo_path = str(_dl)
if cta_enabled and not cta_path:
_dc = BASE_DIR / "assets" / "cta_ins_drippy4.mp4"
if _dc.exists():
cta_path = str(_dc)
out_path = pipeline.run_video(
video_path=video_path,
mode=mode,
source_lang=source_lang,
voice=voice,
speed=speed,
pitch=pitch,
volume=volume,
region_preset=region_preset,
sub_mask_mode=sub_mask_mode,
sub_style=sub_style,
custom_y_pct=custom_y_pct,
custom_h_pct=custom_h_pct,
custom_x_pct=custom_x_pct,
custom_w_pct=custom_w_pct,
ducking_ratio=ducking_ratio,
enable_sfx=enable_sfx,
mute_original_audio=mute_original_audio,
logo_enabled=logo_enabled,
logo_path=logo_path,
logo_preset=logo_preset,
logo_scale=logo_scale,
logo_opacity=logo_opacity,
logo_x_pct=logo_x_pct,
logo_y_pct=logo_y_pct,
logo_chromakey=logo_chromakey,
cta_enabled=cta_enabled,
cta_path=cta_path,
cta_preset=cta_preset,
cta_scale=cta_scale,
cta_opacity=cta_opacity,
cta_x_pct=cta_x_pct,
cta_y_pct=cta_y_pct,
cta_interval=cta_interval,
cta_duration=cta_duration,
cta_chromakey=cta_chromakey
)
if out_path:
jobs_db[job_id]["status"] = "DONE"
jobs_db[job_id]["output_path"] = out_path
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {
"type": "done",
"output_url": f"/api/download-result/{Path(out_path).name}"
}))
else:
jobs_db[job_id]["status"] = "FAILED"
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Pipeline execution failed."}))
background_tasks.add_task(run_job)
return {"status": "started", "job_id": job_id}
@app.post("/api/start-translation-only")
async def start_translation_only(
background_tasks: BackgroundTasks,
job_id: str = Form(...),
video_path: str = Form(...),
mode: str = Form("asr"),
source_lang: str = Form("zh"),
region_preset: str = Form("custom"),
custom_y_pct: float = Form(75.0),
custom_h_pct: float = Form(20.0),
custom_x_pct: float = Form(0.0),
custom_w_pct: float = Form(100.0)
):
"""Runs extraction & translation only, then stops for user review in Subtitle Editor."""
if not Path(video_path).exists():
raise HTTPException(status_code=404, detail="File video không tồn tại.")
jobs_db[job_id] = {
"job_id": job_id,
"video_path": video_path,
"filename": Path(video_path).name,
"status": "WAITING_REVIEW",
"progress": 50,
"stage": "STAGE_C_TRANSLATION",
"subtitles": [],
"output_path": None,
"created_at": time.time()
}
_save_jobs_snapshot()
def run_trans():
def log_cb(msg: str):
asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")}))
def progress_cb(pct: int, stage_name: str):
jobs_db[job_id]["progress"] = pct
jobs_db[job_id]["stage"] = stage_name
asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name}))
pipeline = CloudPipeline(base_dir=BASE_DIR, log_callback=log_cb, progress_callback=progress_cb)
res = pipeline.extract_subtitles_only(
video_path=video_path,
mode=mode,
source_lang=source_lang,
region_preset=region_preset,
custom_y_pct=custom_y_pct,
custom_h_pct=custom_h_pct,
custom_x_pct=custom_x_pct,
custom_w_pct=custom_w_pct
)
if res and "blocks" in res:
jobs_db[job_id]["status"] = "WAITING_REVIEW"
jobs_db[job_id]["subtitles"] = res["blocks"]
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {
"type": "subtitles_ready",
"blocks": res["blocks"]
}))
else:
jobs_db[job_id]["status"] = "FAILED"
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Bóc tách & dịch thuật thất bại."}))
background_tasks.add_task(run_trans)
return {"status": "started", "job_id": job_id}
@app.get("/api/jobs/{job_id}/subtitles")
async def get_job_subtitles(job_id: str):
if job_id in jobs_db and "subtitles" in jobs_db[job_id]:
return {"status": "ok", "blocks": jobs_db[job_id]["subtitles"]}
raise HTTPException(status_code=404, detail="Subtitles not found for this job")
@app.post("/api/jobs/{job_id}/continue-render")
async def continue_render(
background_tasks: BackgroundTasks,
job_id: str,
payload: Dict[str, Any] = Body(...)
):
"""Takes reviewed subtitles and renders final video."""
if job_id not in jobs_db:
raise HTTPException(status_code=404, detail="Job not found")
video_path = jobs_db[job_id]["video_path"]
subtitles = payload.get("subtitles", jobs_db[job_id].get("subtitles", []))
voice = payload.get("voice", "vi-VN-NamMinhNeural")
speed = float(payload.get("speed", 1.0))
pitch = int(payload.get("pitch", 0))
volume = int(payload.get("volume", 100))
region_preset = payload.get("region_preset", "custom")
sub_mask_mode = payload.get("sub_mask_mode", "box")
sub_style = payload.get("sub_style", "motion_drip")
custom_y_pct = float(payload.get("custom_y_pct", 75.0))
custom_h_pct = float(payload.get("custom_h_pct", 20.0))
custom_x_pct = float(payload.get("custom_x_pct", 0.0))
custom_w_pct = float(payload.get("custom_w_pct", 100.0))
ducking_ratio = float(payload.get("ducking_ratio", 0.18))
enable_sfx = bool(payload.get("enable_sfx", True))
mute_original_audio = bool(payload.get("mute_original_audio", False))
logo_enabled = bool(payload.get("logo_enabled", False))
logo_path = str(payload.get("logo_path", "") or "")
logo_preset = str(payload.get("logo_preset", "bottom_right"))
logo_scale = float(payload.get("logo_scale", 18.0))
logo_opacity = float(payload.get("logo_opacity", 1.0))
logo_x_pct = float(payload.get("logo_x_pct", 80.0))
logo_y_pct = float(payload.get("logo_y_pct", 80.0))
logo_chromakey = bool(payload.get("logo_chromakey", True))
cta_enabled = bool(payload.get("cta_enabled", False))
cta_path = str(payload.get("cta_path", "") or "")
cta_preset = str(payload.get("cta_preset", "bottom_center"))
cta_scale = float(payload.get("cta_scale", 48.0))
cta_opacity = float(payload.get("cta_opacity", 1.0))
cta_x_pct = float(payload.get("cta_x_pct", 50.0))
cta_y_pct = float(payload.get("cta_y_pct", 82.0))
cta_interval = float(payload.get("cta_interval", 5.0))
cta_duration = float(payload.get("cta_duration", 2.0))
cta_chromakey = bool(payload.get("cta_chromakey", True))
if logo_enabled and not logo_path:
_dl = BASE_DIR / "assets" / "logo_ins_drippy4.png"
if _dl.exists():
logo_path = str(_dl)
if cta_enabled and not cta_path:
_dc = BASE_DIR / "assets" / "cta_ins_drippy4.mp4"
if _dc.exists():
cta_path = str(_dc)
jobs_db[job_id]["status"] = "PROCESSING"
_save_jobs_snapshot()
def run_render():
def log_cb(msg: str):
asyncio.run(manager.broadcast(job_id, {"type": "log", "text": msg, "time": time.strftime("%H:%M:%S")}))
def progress_cb(pct: int, stage_name: str):
jobs_db[job_id]["progress"] = pct
jobs_db[job_id]["stage"] = stage_name
asyncio.run(manager.broadcast(job_id, {"type": "progress", "percent": pct, "stage": stage_name}))
pipeline = CloudPipeline(base_dir=BASE_DIR, log_callback=log_cb, progress_callback=progress_cb)
out_path = pipeline.render_from_subtitles(
video_path=video_path,
subtitles_data=subtitles,
voice=voice,
speed=speed,
pitch=pitch,
volume=volume,
region_preset=region_preset,
sub_mask_mode=sub_mask_mode,
sub_style=sub_style,
custom_y_pct=custom_y_pct,
custom_h_pct=custom_h_pct,
custom_x_pct=custom_x_pct,
custom_w_pct=custom_w_pct,
ducking_ratio=ducking_ratio,
enable_sfx=enable_sfx,
mute_original_audio=mute_original_audio,
logo_enabled=logo_enabled,
logo_path=logo_path,
logo_preset=logo_preset,
logo_scale=logo_scale,
logo_opacity=logo_opacity,
logo_x_pct=logo_x_pct,
logo_y_pct=logo_y_pct,
logo_chromakey=logo_chromakey,
cta_enabled=cta_enabled,
cta_path=cta_path,
cta_preset=cta_preset,
cta_scale=cta_scale,
cta_opacity=cta_opacity,
cta_x_pct=cta_x_pct,
cta_y_pct=cta_y_pct,
cta_interval=cta_interval,
cta_duration=cta_duration,
cta_chromakey=cta_chromakey
)
if out_path:
jobs_db[job_id]["status"] = "DONE"
jobs_db[job_id]["output_path"] = out_path
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {
"type": "done",
"output_url": f"/api/download-result/{Path(out_path).name}"
}))
else:
jobs_db[job_id]["status"] = "FAILED"
_save_jobs_snapshot()
asyncio.run(manager.broadcast(job_id, {"type": "error", "message": "Render video thất bại."}))
background_tasks.add_task(run_render)
return {"status": "started", "job_id": job_id}
@app.get("/api/download-result/{filename}")
async def download_result(filename: str):
# FIX path traversal: filename chứa ../ hoặc separator -> 404 ngay.
if not filename or ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=404, detail="File không tồn tại.")
file_path = OUTPUT_DIR / filename
if file_path.exists():
return FileResponse(path=file_path, filename=filename, media_type="video/mp4")
raise HTTPException(status_code=404, detail="File không tồn tại.")
@app.get("/api/jobs")
async def list_jobs():
return list(jobs_db.values())
@app.get("/api/jobs/{job_id}")
async def get_job_status(job_id: str):
if job_id in jobs_db:
return jobs_db[job_id]
raise HTTPException(status_code=404, detail="Job not found")
@app.get("/api/glossary")
async def get_glossary():
glossary_path = BASE_DIR / "glossary.json"
if glossary_path.exists():
return json.loads(glossary_path.read_text(encoding="utf-8"))
return {"glossary": {}}
@app.post("/api/glossary")
async def update_glossary(data: Dict[str, Any] = Body(...)):
glossary_path = BASE_DIR / "glossary.json"
glossary_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return {"status": "ok", "count": len(data.get("glossary", {}))}
@app.websocket("/ws/logs/{job_id}")
async def websocket_logs(websocket: WebSocket, job_id: str):
await manager.connect(job_id, websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(job_id, websocket)
def get_all_ips():
import socket
ips = {"lan": "127.0.0.1", "tailscale": None}
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ips["lan"] = s.getsockname()[0]
s.close()
except Exception:
pass
try:
hostname = socket.gethostname()
for ip in socket.gethostbyname_ex(hostname)[2]:
if ip.startswith("100."):
ips["tailscale"] = ip
break
except Exception:
pass
return ips
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
ip_info = get_all_ips()
lan_ip = ip_info.get("lan", "127.0.0.1")
ts_ip = ip_info.get("tailscale")
print("\n" + "=" * 65)
print(" 🎬 TRUNG SÁNG VIỆT - AUTO DUBBING CLOUD STUDIO (v2.5)")
print("=" * 65)
print(f" 💻 Trên máy tính này : http://localhost:{port} hoặc http://127.0.0.1:{port}")
print(f" 📱 Trên ĐIỆN THOẠI : http://{lan_ip}:{port} (Bắt buộc chung Wi-Fi)")
if ts_ip:
print(f" 🌐 Qua Tailscale VPN : http://{ts_ip}:{port}")
print("=" * 65 + "\n")
uvicorn.run(app, host="0.0.0.0", port=port)