Spaces:
Running on Zero
Running on Zero
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 Download app/web_server.py from hoangtaiii/DRIPPY4: direct link, hf CLI and curl.
- Browser
- Download file 29.6 kB
-
https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app/web_server.py
- Command line
-
hf download hf://spaces/hoangtaiii/DRIPPY4/app/web_server.py
-
curl -L -o web_server.py https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app/web_server.py
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() | |
| 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>") | |
| 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() | |
| } | |
| 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} | |
| 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} | |
| 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 | |
| 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.") | |
| 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.") | |
| 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.") | |
| 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} | |
| 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} | |
| 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") | |
| 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} | |
| 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.") | |
| async def list_jobs(): | |
| return list(jobs_db.values()) | |
| 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") | |
| 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": {}} | |
| 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", {}))} | |
| 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) | |