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
Raw History Blame Contribute Delete
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"}
@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 <img>