Spaces:
Running
Running
Claude
Claude Opus 5.5
fix(dub): Edge bị chặn 403 không còn kéo VieNeu và cả tool câm theo
8b45574 unverified Download backend/app/api/dub.py from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 33.5 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/dub.py
- Command line
-
hf download hf://spaces/tntgroup/website_content/backend/app/api/dub.py
-
curl -L -o dub.py https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/dub.py
33.5 kB
| """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"} | |
| 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 | |
| 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 --- | |
| 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)} | |
| 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 = "" | |
| 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 --- | |
| 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 = "" | |
| 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() | |
| 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 | |
| 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 | |
| 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 | |
| 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 = "" | |
| 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] | |
| 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 <img> | |