"""API tool: Dịch / đổi giọng video. Luồng FE: 1) /upload -> tải 1+ video nguồn (gọi nhiều lần) + (tuỳ chọn) mẫu giọng 2) /voice-sample -> tải file giọng mẫu user tự up (clone) 3) /start -> chạy pipeline lồng tiếng cho từng video -> trả video+audio+srt Mỗi video là một item trong job để FE theo dõi tiến độ riêng. """ from __future__ import annotations import os import re import threading from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel from ..core.files import new_session, rel_path, save_upload, save_upload_stream, session_dir, sub_dir from ..core.jobs import jobs, submit_for_session from ..config import settings from ..services.dubber import pipeline from ..services.dubber import tts as tts_svc from ..services.history import storage as hist from ..services.drive import service as drive_svc router = APIRouter(prefix="/api/dub", tags=["dub"]) LANGS = {"en", "vi", "fil"} @router.get("/voices") def voices(lang: str = "vi"): """Danh sách giọng + engine SẼ THẬT SỰ CHẠY cho ngôn ngữ đó. vi = VieNeu; en/fil = OmniVoice nếu đã cài gói, không thì Edge TTS. Trả kèm `model_label` và `can_clone` để giao diện khỏi phải đoán theo ngôn ngữ — có clone hay không là do ENGINE quyết, không phải do tiếng gì. """ lang = lang if lang in LANGS else "vi" return {"lang": lang, "voices": tts_svc.list_voices(lang), **tts_svc.engine_info(lang)} # ─────────────────────── Nghe thử giọng (voice preview) ───────────────────── # Câu mẫu tự GIỚI THIỆU TÊN GIỌNG, tạo 1 lần rồi CACHE (dùng chung mọi user). # Tạo SẴN toàn bộ lúc khởi động (warm_voice_previews) -> user khỏi phải chờ. _PREVIEW_FORM = { "vi": "Xin chào, tôi tên là {name}, đây là giọng đọc của tôi.", "en": "Hello, my name is {name}. This is my voice.", "fil": "Kumusta, ang pangalan ko ay {name}. Ito ang boses ko.", } _preview_lock = threading.Lock() def _voice_speak_name(lang: str, voice_id: str) -> str: """Tên đọc trong câu mẫu: lấy phần đầu label trước '—' (vd 'Aria'), hoặc chính id (vi: id đã là tên như 'Ngọc Lan').""" try: for v in tts_svc.list_voices(lang): if v.get("id") == voice_id: return (v.get("label") or voice_id).split("—")[0].strip() or voice_id except Exception: pass return voice_id or "" def _preview_stem(voice: str) -> str: return re.sub(r"[^0-9A-Za-z]+", "_", (voice or "default")).strip("_")[:60] or "default" def _preview_path(lang: str, voice: str) -> str: """Đường dẫn cache giọng thử, CÓ VÂN TAY của định nghĩa giọng trong tên. Sửa định nghĩa giọng (đổi engine, sửa chuỗi instruct) -> tên file đổi -> bản cũ tự hết hiệu lực. Cần thế vì bản do Edge đọc đỡ lúc OmniVoice hỏng nghe vẫn ra tiếng, không phép kiểm "hỏng" nào bắt được, sẽ nằm lì trong cache. """ sig = tts_svc.voice_signature(lang, voice) return os.path.join(settings.STORAGE_DIR, "voice_previews", lang, f"{_preview_stem(voice)}_{sig}.wav") def _drop_stale_previews(lang: str, voice: str, keep: str) -> None: """Xoá bản cache cũ của CÙNG giọng (khác vân tay) cho khỏi đọng rác.""" import glob for old in glob.glob(os.path.join(os.path.dirname(keep), f"{_preview_stem(voice)}_*.wav")): if os.path.abspath(old) != os.path.abspath(keep): try: os.remove(old) except OSError: pass def _cached_preview(out: str) -> bool: """File giọng mẫu trong cache có DÙNG ĐƯỢC không. Không chỉ xét "có file và > 44 byte": lần TTS hỏng trước đó ghi ra một bản LẶNG rồi cache lại, và STORAGE_DIR sống qua cả lần build lại -> giọng thử câm vĩnh viễn dù model đã chạy được. File câm coi như chưa có, xoá đi làm lại. """ if not (os.path.exists(out) and os.path.getsize(out) > 44): return False if tts_svc.is_silent_wav(out): try: os.remove(out) except OSError: pass return False return True def _ensure_preview(lang: str, voice: str) -> str: """Trả đường dẫn file WAV giọng mẫu — tạo (và cache) nếu chưa có.""" out = _preview_path(lang, voice) if _cached_preview(out): return out with _preview_lock: # tránh 2 request cùng tạo 1 giọng if _cached_preview(out): return out os.makedirs(os.path.dirname(out), exist_ok=True) _drop_stale_previews(lang, voice, out) name = _voice_speak_name(lang, voice) text = _PREVIEW_FORM.get(lang, _PREVIEW_FORM["vi"]).format(name=name) vcfg = tts_svc.VoiceConfig(target_lang=lang, voice_source="preset", preset=voice) engine = tts_svc.load_engine(vcfg) tts_svc.synth_line(engine, text, out, vcfg) # KHÔNG cache một bản câm: TTS hỏng thì synth_line trả khoảng lặng, cache # lại là giọng thử đó câm vĩnh viễn kể cả sau khi model đã chạy được. if tts_svc.is_silent_wav(out): err = tts_svc.last_error(tts_svc.pick_engine(vcfg)) or "không rõ nguyên nhân" try: os.remove(out) except OSError: pass raise RuntimeError(f"TTS trả về tiếng lặng ({err})") return out @router.get("/voice-preview") def voice_preview(lang: str = "vi", voice: str = ""): """Trả 1 file WAV đọc câu mẫu bằng giọng chọn — để user nghe trước khi lựa.""" lang = lang if lang in LANGS else "vi" try: out = _ensure_preview(lang, voice) except Exception as e: raise HTTPException(500, f"Không tạo được giọng thử: {e}") if not (os.path.exists(out) and os.path.getsize(out) > 44): raise HTTPException(500, "Giọng thử rỗng") return FileResponse(out, media_type="audio/wav", filename=f"preview_{os.path.basename(out)}") def warm_voice_previews(): """Tạo SẴN giọng mẫu cho MỌI giọng preset (chạy nền lúc khởi động). Edge (en/fil) nhanh -> làm trước; VieNeu (vi) chậm -> làm sau. Best-effort, lỗi 1 giọng không ảnh hưởng giọng khác. Có sẵn thì user bấm Nghe thử là phát ngay, khỏi chờ.""" import sys for lang in ("en", "fil", "vi"): try: voices = tts_svc.list_voices(lang) except Exception: continue for v in voices: vid = v.get("id") if not vid: continue try: _ensure_preview(lang, vid) except Exception as e: print(f"[preview] bỏ qua {lang}/{vid}: {e}", file=sys.stderr) # ----------------------------------------------------------------- upload --- @router.post("/upload") def upload(file: UploadFile = File(...), session: str = Form(None)): sid = session or new_session("dub") # stream xuống đĩa (không nạp cả video vào RAM) + chặn file quá lớn path = save_upload_stream(sid, file.filename, file.file, folder="inputs", max_mb=settings.MAX_UPLOAD_MB) rel = rel_path(path, sid) return {"session": sid, "file": rel, "name": os.path.basename(path), "size": os.path.getsize(path)} @router.post("/voice-sample") def voice_sample(file: UploadFile = File(...), session: str = Form(None)): """File giọng mẫu (WAV/MP3) user tự up để clone. Tạo phiên nếu chưa có.""" sid = session or new_session("dub") path = save_upload_stream(sid, file.filename, file.file, folder="voice", max_mb=settings.MAX_UPLOAD_MB) rel = rel_path(path, sid) return {"session": sid, "file": rel, "name": os.path.basename(path)} # ------------------------------------------------------------------ start --- class DubReq(BaseModel): session: str files: list[str] # danh sách video tương đối cần dịch target_lang: str = "vi" # en | vi | fil bg_mode: str = "duck" # none | duck | demucs do_clone: bool = True voice_source: str = "video" # preset | video | upload speaker_file: str = "" # rel path nếu voice_source=upload preset: str = "" # tên preset nếu voice_source=preset style: str = "natural" engine: str = "auto" multi_speaker: bool = False temperature: float | None = None top_k: int | None = None repetition_penalty: float | None = None # ngưỡng lọc ASR (mục "Nâng cao") — mặc định = hành vi cũ (0.6 / -1.0) asr_no_speech_max: float = 0.6 asr_logprob_min: float = -1.0 username: str = "" @router.post("/start") def start(req: DubReq): target = req.target_lang if req.target_lang in LANGS else "vi" speaker_wav = "" if req.voice_source == "upload" and req.speaker_file: speaker_wav = os.path.join(session_dir(req.session), req.speaker_file) opt = pipeline.DubOptions( target_lang=target, bg_mode=req.bg_mode, do_clone=req.do_clone, voice_source=req.voice_source, speaker_wav=speaker_wav, preset=req.preset, style=req.style, engine=req.engine, multi_speaker=req.multi_speaker, temperature=req.temperature, top_k=req.top_k, repetition_penalty=req.repetition_penalty, asr_no_speech_max=req.asr_no_speech_max, asr_logprob_min=req.asr_logprob_min, ) job = jobs.create("dub") _username = req.username def work(h): n = len(req.files) outputs = [] for idx, rel in enumerate(req.files): src = os.path.join(session_dir(req.session), rel) base = os.path.splitext(os.path.basename(rel))[0] it = h.add_item(os.path.basename(rel)) # Tên thư mục AN TOÀN cho URL/đường dẫn: bỏ '#', '[', ']', dấu cách... # Caption TikTok chứa '#' làm HỎNG URL (# = fragment) -> file endpoint # nhận nhầm thư mục -> 500 / không tải được. Giữ `base` để HIỂN THỊ. # (Cùng cách luồng "Chèn phụ đề" đang dùng, + hash để không đè nhau.) import hashlib _safe = (re.sub(r"[^0-9A-Za-z_.-]+", "_", base) or "video")[:40] _folder = _safe + "_" + hashlib.md5(rel.encode("utf-8")).hexdigest()[:6] out_dir = sub_dir(req.session, "out", _folder) try: def prog(p, m, _i=idx, _it=it): h.set_progress((_i + p) / max(1, n)) h.item_status(_it, "running", detail=m) res = pipeline.run_dub(src, out_dir, opt, prog) def url(path): return f"/api/files/{req.session}/" + rel_path(path, req.session) result = { "name": base, "language": res.language, "n_lines": res.n_lines, "video_url": url(res.video_out), "audio_url": url(res.audio_out), "srt_url": url(res.srt_out), "note": res.note, } outputs.append(result) h.item_status(it, "done", detail=f"{res.n_lines} câu · {res.language}", result=result) except Exception as e: h.item_status(it, "error", error=str(e)) h.set_progress((idx + 1) / max(1, n)) # Auto-save to history + Drive if _username: _auto_save_dub(_username, req.session, outputs) return {"session": req.session, "outputs": outputs} submit_for_session(job, work, req.session) return {"job_id": job.id, "session": req.session, "videos": len(req.files)} def _auto_save_dub(username: str, session: str, outputs: list[dict]): from datetime import datetime sd = session_dir(session) now_str = datetime.now().strftime("%Y-%m-%d %H:%M") for o in outputs: files_meta = [] for key in ("video_url", "audio_url", "srt_url"): # URL dạng /api/files/{session}/... -> bỏ ĐÚNG tiền tố có session # (trước chỉ bỏ '/api/files/' nên còn dư session -> path sai, file_meta # rỗng -> KHÔNG lưu được file lên History/Drive). rel_url = o.get(key, "").replace(f"/api/files/{session}/", "").lstrip("/") fpath = os.path.join(sd, rel_url) if os.path.exists(fpath): files_meta.append({"path": fpath, "name": os.path.basename(fpath)}) hist.add_entry(username, { "type": "tool_result", "tool": "dub", "title": f"Dịch video: {o['name']}", "summary": f"{o.get('n_lines', 0)} câu · {o.get('language', '')} · {now_str}", "files": files_meta, "session": session, }) # Upload to Drive folder_id = hist.get_drive_folder(username) if folder_id and drive_svc.is_configured(): try: tool_fid = drive_svc.ensure_path(folder_id, ["dub"]) date_str = datetime.now().strftime("%Y-%m-%d") time_str = datetime.now().strftime("%H-%M-%S") run_fid = drive_svc.ensure_path(tool_fid, [date_str, time_str]) if run_fid: for fm in files_meta: drive_svc.upload_file(run_fid, fm["path"], fm["name"]) except Exception: pass # ---------------------------------------------- 2 PHA: prepare + synthesize --- @router.post("/prepare") def prepare(req: DubReq): """PHA 1: tách + ASR + dịch -> TRẢ CÂU DỊCH + MỐC cho user xem/sửa (CHƯA đọc). Kết quả mỗi video: {lines:[{i,start,end,src,text}], state, same_language, ...}. Gửi `state` + `lines` (đã sửa) sang /synthesize để tạo giọng & ghép video. """ target = req.target_lang if req.target_lang in LANGS else "vi" opt = pipeline.DubOptions( target_lang=target, bg_mode=req.bg_mode, do_clone=req.do_clone, voice_source=req.voice_source, preset=req.preset, style=req.style, engine=req.engine, multi_speaker=req.multi_speaker, temperature=req.temperature, top_k=req.top_k, repetition_penalty=req.repetition_penalty, asr_no_speech_max=req.asr_no_speech_max, asr_logprob_min=req.asr_logprob_min) job = jobs.create("dub") def work(h): n = len(req.files) outputs = [] for idx, rel in enumerate(req.files): src = os.path.join(session_dir(req.session), rel) base = os.path.splitext(os.path.basename(rel))[0] it = h.add_item(os.path.basename(rel)) # Tên thư mục AN TOÀN cho URL/đường dẫn: bỏ '#', '[', ']', dấu cách... # Caption TikTok chứa '#' làm HỎNG URL (# = fragment) -> file endpoint # nhận nhầm thư mục -> 500 / không tải được. Giữ `base` để HIỂN THỊ. # (Cùng cách luồng "Chèn phụ đề" đang dùng, + hash để không đè nhau.) import hashlib _safe = (re.sub(r"[^0-9A-Za-z_.-]+", "_", base) or "video")[:40] _folder = _safe + "_" + hashlib.md5(rel.encode("utf-8")).hexdigest()[:6] out_dir = sub_dir(req.session, "out", _folder) try: def prog(p, m, _i=idx, _it=it): h.set_progress((_i + p) / max(1, n)) h.item_status(_it, "running", detail=m) ar = pipeline.analyze(src, out_dir, opt, prog) def rel_to(path): return rel_path(path, req.session) def url(path): return f"/api/files/{req.session}/" + rel_to(path) result = { "name": base, "language": ar.language, "note": ar.note, "same_language": ar.same_language, "state": rel_to(ar.state_path) if ar.state_path else "", "lines": [{"i": i, "start": l.start, "end": l.end, "src": l.src, "text": l.text} for i, l in enumerate(ar.lines)], "srt_url": url(ar.srt_out) if ar.srt_out else "", "video_url": url(ar.video_out) if ar.video_out else "", } outputs.append(result) detail = ("giữ nguyên (cùng ngôn ngữ)" if ar.same_language else f"{len(ar.lines)} câu · {ar.language}") h.item_status(it, "done", detail=detail, result=result) except Exception as e: h.item_status(it, "error", error=str(e)) h.set_progress((idx + 1) / max(1, n)) return {"session": req.session, "outputs": outputs} submit_for_session(job, work, req.session) return {"job_id": job.id, "session": req.session, "videos": len(req.files)} class SynthLineIn(BaseModel): start: float end: float text: str class SynthItemIn(BaseModel): state: str # rel path state.json (từ /prepare) name: str = "" lines: list[SynthLineIn] class SynthReq(BaseModel): session: str items: list[SynthItemIn] # Chọn GIỌNG sau khi đã xem bản dịch voice_source: str = "video" # preset | video | upload speaker_file: str = "" preset: str = "" style: str = "natural" engine: str = "auto" do_clone: bool = True temperature: float | None = None top_k: int | None = None repetition_penalty: float | None = None username: str = "" @router.post("/synthesize") def synthesize(req: SynthReq): """PHA 2: nhận câu ĐÃ SỬA + chọn giọng -> TTS + neo mốc + ghép -> video.""" speaker_wav = "" if req.voice_source == "upload" and req.speaker_file: speaker_wav = os.path.join(session_dir(req.session), req.speaker_file) opt = pipeline.DubOptions( target_lang="vi", # thật sự lấy từ state; đây chỉ là chỗ giữ do_clone=req.do_clone, voice_source=req.voice_source, speaker_wav=speaker_wav, preset=req.preset, style=req.style, engine=req.engine, temperature=req.temperature, top_k=req.top_k, repetition_penalty=req.repetition_penalty) job = jobs.create("dub") _username = req.username def work(h): n = len(req.items) outputs = [] for idx, item in enumerate(req.items): base = item.name or f"video_{idx+1}" it = h.add_item(base) state_abs = os.path.join(session_dir(req.session), item.state) try: def prog(p, m, _i=idx, _it=it): h.set_progress((_i + p) / max(1, n)) h.item_status(_it, "running", detail=m) lines = [{"start": ln.start, "end": ln.end, "text": ln.text} for ln in item.lines] res = pipeline.synthesize(state_abs, lines, opt, prog) def url(path): return f"/api/files/{req.session}/" + rel_path(path, req.session) result = { "name": base, "language": res.language, "n_lines": res.n_lines, "video_url": url(res.video_out), "audio_url": url(res.audio_out), "srt_url": url(res.srt_out), "note": res.note, } outputs.append(result) h.item_status(it, "done", detail=f"{res.n_lines} câu · {res.language}", result=result) except Exception as e: h.item_status(it, "error", error=str(e)) h.set_progress((idx + 1) / max(1, n)) if _username: _auto_save_dub(_username, req.session, outputs) return {"session": req.session, "outputs": outputs} submit_for_session(job, work, req.session) return {"job_id": job.id, "session": req.session, "videos": len(req.items)} # ---------------------------------------- Nạp SRT tự tải (thay bản dịch) --- _BRACKET = re.compile(r"\[[^\]]*\]") def _clean_srt_text(t: str) -> str: """Bỏ phần trong [ ] (vd nhãn '[Nam bán hàng]: ...') + dấu ':' còn sót ở đầu.""" t = _BRACKET.sub("", t or "") t = re.sub(r"\s+", " ", t).strip() return t.lstrip("::-–—•").strip() @router.post("/subtitle/import") def subtitle_import(file: UploadFile = File(...), session: str = Form(...)): """Nạp SRT do user tự tải -> danh sách câu (đã BỎ nhãn trong []) để THAY bản dịch. Dùng khi user không ưng bản dịch tự động và có file SRT riêng. Giữ đúng mốc thời gian + nội dung của file; chỉ loại phần nằm trong dấu []. Trả lines cùng dạng với /prepare để FE hiển thị & sửa như bản dịch thường (rồi tạo giọng). """ from ..services.dubber import subtitle_render as sub sid = session path = save_upload_stream(sid, file.filename, file.file, folder="srt_import", max_mb=settings.MAX_UPLOAD_MB) try: raw = sub.parse_srt(path) # [{start,end,text}] (giây) except Exception as e: raise HTTPException(400, f"Không đọc được file SRT: {e}") lines = [] for r in raw: txt = _clean_srt_text(r.get("text", "")) if not txt: continue lines.append({"start": float(r["start"]), "end": float(r["end"]), "src": "", "text": txt}) if not lines: raise HTTPException(400, "File SRT không có câu hợp lệ (kiểm tra định dạng).") return {"session": sid, "lines": lines, "n": len(lines)} # ------------------------------------------------------- TTS từ text --- class TtsReq(BaseModel): session: str = "" text: str target_lang: str = "vi" # en | vi | fil voice_source: str = "preset" # preset | upload speaker_file: str = "" # rel path nếu voice_source=upload (clone) preset: str = "" # tên giọng có sẵn style: str = "natural" engine: str = "auto" # auto | vieneu | xtts | edge temperature: float | None = None top_k: int | None = None repetition_penalty: float | None = None @router.post("/tts") def tts_from_text(req: TtsReq): """Gen voice từ text: đầu vào text + giọng (clone/preset) -> đoạn voice.""" sid = req.session or new_session("tts") target = req.target_lang if req.target_lang in LANGS else "vi" speaker_wav = "" if req.voice_source == "upload" and req.speaker_file: speaker_wav = os.path.join(session_dir(sid), req.speaker_file) opt = pipeline.TtsOptions( text=req.text, target_lang=target, voice_source=req.voice_source, speaker_wav=speaker_wav, preset=req.preset, style=req.style, engine=req.engine, temperature=req.temperature, top_k=req.top_k, repetition_penalty=req.repetition_penalty) job = jobs.create("tts") def work(h): it = h.add_item("voice") out_dir = sub_dir(sid, "tts_out") try: def prog(p, m, _it=it): h.set_progress(p) h.item_status(_it, "running", detail=m) wav = pipeline.run_tts(opt, out_dir, prog) # run_tts không bao giờ ném lỗi: engine hỏng thì nó trả clip LẶNG để # pipeline lồng tiếng đi tiếp. Ở đây user chỉ cần đúng đoạn voice -> # trả clip câm là giấu lỗi (thấy "audio 0 giây" mà không biết vì sao). if tts_svc.is_silent_wav(wav): vcfg = tts_svc.VoiceConfig(target_lang=target, voice_source=req.voice_source, speaker_wav=speaker_wav, preset=req.preset, engine=req.engine) name = tts_svc.pick_engine(vcfg) tried = [name, "edge"] + (["omni"] if target in ("en", "fil") else []) errs = [f"{n}: {tts_svc.last_error(n)}" for n in dict.fromkeys(tried) if tts_svc.last_error(n)] raise RuntimeError("Không tạo được giọng — " + ("; ".join(errs) or "engine trả về tiếng lặng")) rel = rel_path(wav, sid) result = {"name": "voice.wav", "audio_url": f"/api/files/{sid}/{rel}"} h.item_status(it, "done", detail="đã tạo voice", result=result) return {"session": sid, "output": result} except Exception as e: h.item_status(it, "error", error=str(e)) return {"session": sid, "error": str(e)} submit_for_session(job, work, sid) return {"job_id": job.id, "session": sid} # ------------------------------- NHẬN DẠNG LỜI TỪ VIDEO (ASR, không dịch) --- class SubTranscribeReq(BaseModel): session: str video: str # rel path video cần lấy lời no_speech_max: float = 0.6 logprob_min: float = -1.0 @router.post("/subtitle/transcribe") def subtitle_transcribe(req: SubTranscribeReq): """Nhận dạng LỜI GỐC trong video (Whisper) -> danh sách câu + mốc, KHÔNG dịch. Dùng cho tab 'Chèn chữ vào video': đổ sẵn lời thoại vào trình chèn chữ để user sửa & tạo kiểu, thay vì gõ tay. Chỉ tách audio 16k mono (không demucs/không TTS) nên nhẹ. Cần GROQ_API_KEY (giống bước ASR của lồng tiếng). """ import hashlib job = jobs.create("subtitle") def work(h): it = h.add_item("transcribe") try: from ..services.dubber import audio as _audio, asr as _asr vid = os.path.join(session_dir(req.session), req.video) if not os.path.exists(vid): raise FileNotFoundError("Không thấy video") key = hashlib.md5(req.video.encode("utf-8")).hexdigest()[:8] work_dir = sub_dir(req.session, "asr_work", key) h.item_status(it, "running", detail="Tách audio") h.set_progress(0.15) asr_wav, _full = _audio.extract_tracks(vid, work_dir) h.item_status(it, "running", detail="Nhận dạng lời (Whisper)") h.set_progress(0.45) segs, language = _asr.transcribe( asr_wav, no_speech_max=req.no_speech_max, logprob_min=req.logprob_min) lines = [{"start": float(s.start), "end": float(s.end), "text": (s.text or "").strip()} for s in segs if (s.text or "").strip()] result = {"lines": lines, "language": language, "n": len(lines)} h.item_status(it, "done", detail=f"{len(lines)} câu · {language}", result=result) h.set_progress(1.0) return {"session": req.session, "output": result} except Exception as e: h.item_status(it, "error", error=str(e)) return {"session": req.session, "error": str(e)} submit_for_session(job, work, req.session) return {"job_id": job.id, "session": req.session} # --------------------------------------------------- HARD SUBTITLE --- class SubPlanReq(BaseModel): session: str srt: str # rel path file SRT (từ kết quả dịch) max_words: int = 7 max_dur: float = 3.6 @router.post("/subtitle/plan") def subtitle_plan(req: SubPlanReq): """Tự sinh danh sách ĐOẠN phụ đề (tách câu dài + mốc từng từ) để FE dựng editor.""" from ..services.dubber import subtitle_render as sub path = os.path.join(session_dir(req.session), req.srt) if not os.path.exists(path): return {"segments": []} lines = sub.parse_srt(path) segs = sub.split_lines(lines, req.max_words, req.max_dur) return {"segments": segs} class SubSegIn(BaseModel): start: float end: float text: str style: dict = {} # {font,fontSize,color,highlight,bg,x,y,w} breaks: list[int] = [] # index từ bắt đầu dòng mới (FE đo chữ thật) class SubBurnReq(BaseModel): session: str video: str # rel path video đã dịch cần chèn phụ đề segments: list[SubSegIn] mode: str = "whole" # whole | progressive | highlight username: str = "" @router.post("/subtitle") def subtitle_burn(req: SubBurnReq): """Burn cứng phụ đề (theo style/vị trí từng đoạn) vào video.""" from ..services.dubber import subtitle_render as sub job = jobs.create("subtitle") def work(h): it = h.add_item("subtitle") try: h.item_status(it, "running", detail="Chuẩn bị phụ đề") vid = os.path.join(session_dir(req.session), req.video) res = sub.probe_size(vid) segs = [] for s in req.segments: segs.append({"start": s.start, "end": s.end, "text": s.text, "style": s.style, "breaks": s.breaks, "words": sub.words_for(s.text, s.start, s.end)}) # Thư mục ra RIÊNG cho từng video nguồn (theo tên + hash đường dẫn) -> # burn NHIỀU video trong 1 phiên không ghi đè lên nhau (tab "Chèn chữ"). import hashlib vbase = os.path.splitext(os.path.basename(req.video))[0] safe = (re.sub(r"[^0-9A-Za-z_.-]+", "_", vbase) or "video")[:40] key = safe + "_" + hashlib.md5(req.video.encode("utf-8")).hexdigest()[:6] out_dir = sub_dir(req.session, "subtitled", key) ass = os.path.join(out_dir, "sub.ass") sub.build_ass(segs, ass, mode=req.mode, res=res) h.item_status(it, "running", detail="Đang burn phụ đề (ffmpeg)") h.set_progress(0.3) name = f"{safe}_sub.mp4" out = os.path.join(out_dir, name) sub.burn(vid, ass, out) result = {"name": name, "video_url": f"/api/files/{req.session}/subtitled/{key}/{name}"} h.item_status(it, "done", detail="xong", result=result) h.set_progress(1.0) return {"session": req.session, "output": result} except Exception as e: h.item_status(it, "error", error=str(e)) return {"session": req.session, "error": str(e)} submit_for_session(job, work, req.session) return {"job_id": job.id, "session": req.session} class SubRenderReq(BaseModel): session: str video: str segments: list[SubSegIn] mode: str = "whole" class SubFrameReq(SubRenderReq): time: float = 0.0 # thời điểm (giây) muốn xem trước def _sub_segs(items: list[SubSegIn]) -> list[dict]: """Đổi segments FE -> dict cho subtitle_render (kèm mốc từng từ). Giống hệt subtitle_burn -> preview và burn ăn CÙNG dữ liệu.""" from ..services.dubber import subtitle_render as sub return [{"start": s.start, "end": s.end, "text": s.text, "style": s.style, "breaks": s.breaks, "words": sub.words_for(s.text, s.start, s.end)} for s in items] @router.post("/subtitle/preview-frame") def subtitle_preview_frame(req: SubFrameReq): """Render ĐÚNG 1 khung tại `time` = video + phụ đề (chính là bản burn) -> PNG. Dùng cho nút 'Xem trước chuẩn' để đối chiếu tuyệt đối.""" from ..services.dubber import subtitle_render as sub vid = os.path.join(session_dir(req.session), req.video) if not os.path.exists(vid): raise HTTPException(status_code=404, detail="Không thấy video") res = sub.probe_size(vid) # Khung xem trước RIÊNG cho từng video (theo hash đường dẫn) -> xem trước nhiều # video trong 1 phiên không đá nhau (ảnh cũ của video khác đè lên). import hashlib key = hashlib.md5(req.video.encode("utf-8")).hexdigest()[:8] out_dir = sub_dir(req.session, "subprev", key) ass = os.path.join(out_dir, "prev.ass") sub.build_ass(_sub_segs(req.segments), ass, mode=req.mode, res=res) png = os.path.join(out_dir, "prev.png") sub.render_frame(vid, ass, req.time, png) import time as _t return {"image_url": f"/api/files/{req.session}/subprev/{key}/prev.png", "v": int(_t.time() * 1000)} # cache-buster cho