update cut, guide, dub
Browse files- backend/app/api/cut.py +5 -3
- backend/app/api/dub.py +150 -0
- backend/app/api/shuffle.py +30 -14
- backend/app/assets/voices/vi/README.md +33 -0
- backend/app/assets/voices/vi/adam.wav +3 -0
- backend/app/config.py +15 -0
- backend/app/services/cutter/modes.py +64 -4
- backend/app/services/downloader/ydl.py +19 -10
- backend/app/services/dubber/align.py +111 -0
- backend/app/services/dubber/audio.py +145 -30
- backend/app/services/dubber/mux.py +64 -7
- backend/app/services/dubber/pipeline.py +232 -68
- backend/app/services/dubber/translate.py +74 -25
- backend/app/services/dubber/tts.py +41 -6
- backend/app/services/shuffler/engine.py +140 -65
- backend/app/services/shuffler/render.py +165 -14
- backend/requirements.txt +5 -2
- frontend/src/api.js +47 -0
- frontend/src/pages/CreateVideo.jsx +25 -3
- frontend/src/pages/DubVideo.jsx +122 -20
- frontend/src/pages/Guide.jsx +35 -1
- frontend/src/pages/ListingFree.jsx +8 -8
- frontend/src/pages/ListingImage.jsx +7 -4
- frontend/src/pages/ShuffleVideo.jsx +62 -32
backend/app/api/cut.py
CHANGED
|
@@ -41,9 +41,11 @@ async def upload(file: UploadFile = File(...), session: str = Form(None)):
|
|
| 41 |
class PreviewReq(BaseModel):
|
| 42 |
session: str
|
| 43 |
file: str
|
| 44 |
-
mode: str = "
|
| 45 |
count: int | None = None # số khúc (even) — None để máy tự chia
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
sensitivity: float = 0.5
|
| 48 |
top_ratio: float = 0.4
|
| 49 |
thumbnails: int = 40
|
|
@@ -55,7 +57,7 @@ def preview(req: PreviewReq):
|
|
| 55 |
src = os.path.join(session_dir(req.session), req.file)
|
| 56 |
info = probe(src)
|
| 57 |
segs = modes.build_segments(
|
| 58 |
-
info, req.mode, n=req.count, min_len=req.min_len,
|
| 59 |
sensitivity=req.sensitivity, top_ratio=req.top_ratio)
|
| 60 |
# Mỗi video có thư mục thumbnail RIÊNG (theo hash đường dẫn file) để nhiều
|
| 61 |
# video trong cùng phiên KHÔNG ghi đè thumbnail của nhau (thumb_000.jpg...).
|
|
|
|
| 41 |
class PreviewReq(BaseModel):
|
| 42 |
session: str
|
| 43 |
file: str
|
| 44 |
+
mode: str = "auto" # auto | scene | smart | highlight | even
|
| 45 |
count: int | None = None # số khúc (even) — None để máy tự chia
|
| 46 |
+
# PySceneDetect AdaptiveDetector (auto/scene): ngưỡng 3.0 + khúc tối thiểu 1.0s.
|
| 47 |
+
threshold: float = 3.0
|
| 48 |
+
min_len: float = 1.0
|
| 49 |
sensitivity: float = 0.5
|
| 50 |
top_ratio: float = 0.4
|
| 51 |
thumbnails: int = 40
|
|
|
|
| 57 |
src = os.path.join(session_dir(req.session), req.file)
|
| 58 |
info = probe(src)
|
| 59 |
segs = modes.build_segments(
|
| 60 |
+
info, req.mode, n=req.count, threshold=req.threshold, min_len=req.min_len,
|
| 61 |
sensitivity=req.sensitivity, top_ratio=req.top_ratio)
|
| 62 |
# Mỗi video có thư mục thumbnail RIÊNG (theo hash đường dẫn file) để nhiều
|
| 63 |
# video trong cùng phiên KHÔNG ghi đè thumbnail của nhau (thumb_000.jpg...).
|
backend/app/api/dub.py
CHANGED
|
@@ -181,6 +181,156 @@ def _auto_save_dub(username: str, session: str, outputs: list[dict]):
|
|
| 181 |
pass
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
# ------------------------------------------------------- TTS từ text ---
|
| 185 |
|
| 186 |
class TtsReq(BaseModel):
|
|
|
|
| 181 |
pass
|
| 182 |
|
| 183 |
|
| 184 |
+
# ---------------------------------------------- 2 PHA: prepare + synthesize ---
|
| 185 |
+
|
| 186 |
+
@router.post("/prepare")
|
| 187 |
+
def prepare(req: DubReq):
|
| 188 |
+
"""PHA 1: tách + ASR + dịch -> TRẢ CÂU DỊCH + MỐC cho user xem/sửa (CHƯA đọc).
|
| 189 |
+
|
| 190 |
+
Kết quả mỗi video: {lines:[{i,start,end,src,text}], state, same_language, ...}.
|
| 191 |
+
Gửi `state` + `lines` (đã sửa) sang /synthesize để tạo giọng & ghép video.
|
| 192 |
+
"""
|
| 193 |
+
target = req.target_lang if req.target_lang in LANGS else "vi"
|
| 194 |
+
opt = pipeline.DubOptions(
|
| 195 |
+
target_lang=target, bg_mode=req.bg_mode, do_clone=req.do_clone,
|
| 196 |
+
voice_source=req.voice_source, preset=req.preset, style=req.style,
|
| 197 |
+
engine=req.engine, multi_speaker=req.multi_speaker,
|
| 198 |
+
temperature=req.temperature, top_k=req.top_k,
|
| 199 |
+
repetition_penalty=req.repetition_penalty)
|
| 200 |
+
|
| 201 |
+
job = jobs.create("dub")
|
| 202 |
+
|
| 203 |
+
def work(h):
|
| 204 |
+
n = len(req.files)
|
| 205 |
+
outputs = []
|
| 206 |
+
for idx, rel in enumerate(req.files):
|
| 207 |
+
src = os.path.join(session_dir(req.session), rel)
|
| 208 |
+
base = os.path.splitext(os.path.basename(rel))[0]
|
| 209 |
+
it = h.add_item(os.path.basename(rel))
|
| 210 |
+
out_dir = sub_dir(req.session, "out", base)
|
| 211 |
+
try:
|
| 212 |
+
def prog(p, m, _i=idx, _it=it):
|
| 213 |
+
h.set_progress((_i + p) / max(1, n))
|
| 214 |
+
h.item_status(_it, "running", detail=m)
|
| 215 |
+
|
| 216 |
+
ar = pipeline.analyze(src, out_dir, opt, prog)
|
| 217 |
+
|
| 218 |
+
def rel_to(path):
|
| 219 |
+
return os.path.relpath(path, session_dir(req.session)).replace(os.sep, "/")
|
| 220 |
+
|
| 221 |
+
def url(path):
|
| 222 |
+
return f"/api/files/{req.session}/" + rel_to(path)
|
| 223 |
+
|
| 224 |
+
result = {
|
| 225 |
+
"name": base,
|
| 226 |
+
"language": ar.language,
|
| 227 |
+
"note": ar.note,
|
| 228 |
+
"same_language": ar.same_language,
|
| 229 |
+
"state": rel_to(ar.state_path) if ar.state_path else "",
|
| 230 |
+
"lines": [{"i": i, "start": l.start, "end": l.end,
|
| 231 |
+
"src": l.src, "text": l.text}
|
| 232 |
+
for i, l in enumerate(ar.lines)],
|
| 233 |
+
"srt_url": url(ar.srt_out) if ar.srt_out else "",
|
| 234 |
+
"video_url": url(ar.video_out) if ar.video_out else "",
|
| 235 |
+
}
|
| 236 |
+
outputs.append(result)
|
| 237 |
+
detail = ("giữ nguyên (cùng ngôn ngữ)" if ar.same_language
|
| 238 |
+
else f"{len(ar.lines)} câu · {ar.language}")
|
| 239 |
+
h.item_status(it, "done", detail=detail, result=result)
|
| 240 |
+
except Exception as e:
|
| 241 |
+
h.item_status(it, "error", error=str(e))
|
| 242 |
+
h.set_progress((idx + 1) / max(1, n))
|
| 243 |
+
return {"session": req.session, "outputs": outputs}
|
| 244 |
+
|
| 245 |
+
jobs.submit(job, work)
|
| 246 |
+
return {"job_id": job.id, "session": req.session, "videos": len(req.files)}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class SynthLineIn(BaseModel):
|
| 250 |
+
start: float
|
| 251 |
+
end: float
|
| 252 |
+
text: str
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class SynthItemIn(BaseModel):
|
| 256 |
+
state: str # rel path state.json (từ /prepare)
|
| 257 |
+
name: str = ""
|
| 258 |
+
lines: list[SynthLineIn]
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
class SynthReq(BaseModel):
|
| 262 |
+
session: str
|
| 263 |
+
items: list[SynthItemIn]
|
| 264 |
+
# Chọn GIỌNG sau khi đã xem bản dịch
|
| 265 |
+
voice_source: str = "video" # preset | video | upload
|
| 266 |
+
speaker_file: str = ""
|
| 267 |
+
preset: str = ""
|
| 268 |
+
style: str = "natural"
|
| 269 |
+
engine: str = "auto"
|
| 270 |
+
do_clone: bool = True
|
| 271 |
+
temperature: float | None = None
|
| 272 |
+
top_k: int | None = None
|
| 273 |
+
repetition_penalty: float | None = None
|
| 274 |
+
username: str = ""
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@router.post("/synthesize")
|
| 278 |
+
def synthesize(req: SynthReq):
|
| 279 |
+
"""PHA 2: nhận câu ĐÃ SỬA + chọn giọng -> TTS + neo mốc + ghép -> video."""
|
| 280 |
+
speaker_wav = ""
|
| 281 |
+
if req.voice_source == "upload" and req.speaker_file:
|
| 282 |
+
speaker_wav = os.path.join(session_dir(req.session), req.speaker_file)
|
| 283 |
+
|
| 284 |
+
opt = pipeline.DubOptions(
|
| 285 |
+
target_lang="vi", # thật sự lấy từ state; đây chỉ là chỗ giữ
|
| 286 |
+
do_clone=req.do_clone, voice_source=req.voice_source,
|
| 287 |
+
speaker_wav=speaker_wav, preset=req.preset, style=req.style,
|
| 288 |
+
engine=req.engine, temperature=req.temperature, top_k=req.top_k,
|
| 289 |
+
repetition_penalty=req.repetition_penalty)
|
| 290 |
+
|
| 291 |
+
job = jobs.create("dub")
|
| 292 |
+
_username = req.username
|
| 293 |
+
|
| 294 |
+
def work(h):
|
| 295 |
+
n = len(req.items)
|
| 296 |
+
outputs = []
|
| 297 |
+
for idx, item in enumerate(req.items):
|
| 298 |
+
base = item.name or f"video_{idx+1}"
|
| 299 |
+
it = h.add_item(base)
|
| 300 |
+
state_abs = os.path.join(session_dir(req.session), item.state)
|
| 301 |
+
try:
|
| 302 |
+
def prog(p, m, _i=idx, _it=it):
|
| 303 |
+
h.set_progress((_i + p) / max(1, n))
|
| 304 |
+
h.item_status(_it, "running", detail=m)
|
| 305 |
+
|
| 306 |
+
lines = [{"start": ln.start, "end": ln.end, "text": ln.text}
|
| 307 |
+
for ln in item.lines]
|
| 308 |
+
res = pipeline.synthesize(state_abs, lines, opt, prog)
|
| 309 |
+
|
| 310 |
+
def url(path):
|
| 311 |
+
return f"/api/files/{req.session}/" + os.path.relpath(
|
| 312 |
+
path, session_dir(req.session)).replace(os.sep, "/")
|
| 313 |
+
|
| 314 |
+
result = {
|
| 315 |
+
"name": base, "language": res.language, "n_lines": res.n_lines,
|
| 316 |
+
"video_url": url(res.video_out), "audio_url": url(res.audio_out),
|
| 317 |
+
"srt_url": url(res.srt_out), "note": res.note,
|
| 318 |
+
}
|
| 319 |
+
outputs.append(result)
|
| 320 |
+
h.item_status(it, "done",
|
| 321 |
+
detail=f"{res.n_lines} câu · {res.language}", result=result)
|
| 322 |
+
except Exception as e:
|
| 323 |
+
h.item_status(it, "error", error=str(e))
|
| 324 |
+
h.set_progress((idx + 1) / max(1, n))
|
| 325 |
+
|
| 326 |
+
if _username:
|
| 327 |
+
_auto_save_dub(_username, req.session, outputs)
|
| 328 |
+
return {"session": req.session, "outputs": outputs}
|
| 329 |
+
|
| 330 |
+
jobs.submit(job, work)
|
| 331 |
+
return {"job_id": job.id, "session": req.session, "videos": len(req.items)}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
# ------------------------------------------------------- TTS từ text ---
|
| 335 |
|
| 336 |
class TtsReq(BaseModel):
|
backend/app/api/shuffle.py
CHANGED
|
@@ -13,6 +13,7 @@ không còn cờ allow_reorder toàn cục.
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
|
|
|
| 16 |
|
| 17 |
from fastapi import APIRouter, File, Form, UploadFile
|
| 18 |
from pydantic import BaseModel
|
|
@@ -175,8 +176,11 @@ def plan(req: ShuffleReq):
|
|
| 175 |
capped = True
|
| 176 |
raw = raw[:PLAN_CAP]
|
| 177 |
break
|
| 178 |
-
# tập biến thể SỐNG SÓT theo bộ lọc hiện tại (tương đồng/ngẫu nhiên/giới hạn)
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
variants = []
|
| 181 |
for i, seq in enumerate(raw):
|
| 182 |
labels = [{"segment": clip_meta.get(c, {}).get("segment", 0) + 1,
|
|
@@ -203,33 +207,45 @@ def generate(req: ShuffleReq):
|
|
| 203 |
job = jobs.create("shuffle")
|
| 204 |
|
| 205 |
def work(h):
|
| 206 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 207 |
import threading
|
| 208 |
if capped_render:
|
| 209 |
h.log(f"Đã chọn quá {MAX_RENDER} biến thể — chỉ render {MAX_RENDER} video đầu.")
|
| 210 |
h.log(f"Sẽ render {len(sequences)} biến thể @ {w}x{h_res}"
|
| 211 |
+ (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else ""))
|
| 212 |
out_dir = sub_dir(req.session, "variations")
|
|
|
|
| 213 |
n = len(sequences)
|
| 214 |
results = [None] * n
|
| 215 |
items = [h.add_item(f"Variation_{i+1:02d}") for i in range(n)]
|
| 216 |
done_lock = threading.Lock()
|
| 217 |
counter = {"done": 0}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
def render_one(i, seq):
|
| 220 |
it = items[i]
|
| 221 |
try:
|
| 222 |
paths = [clip_map[c] for c in seq]
|
| 223 |
name = f"Variation_{i+1:02d}.mp4"
|
| 224 |
out = os.path.join(out_dir, name)
|
| 225 |
-
if audio_paths
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
track = audio_paths[i % len(audio_paths)]
|
| 229 |
-
shuffle_render.attach_audio(tmp, track, out)
|
| 230 |
-
os.remove(tmp)
|
| 231 |
-
else:
|
| 232 |
-
shuffle_render._concat_reencode(paths, out, w, h_res)
|
| 233 |
size = os.path.getsize(out)
|
| 234 |
results[i] = {"name": name, "path": out, "sequence": seq, "size": size}
|
| 235 |
h.item_status(it, "done", detail=f"{size//1024} KB", result={
|
|
@@ -239,10 +255,10 @@ def generate(req: ShuffleReq):
|
|
| 239 |
h.item_status(it, "error", error=str(e))
|
| 240 |
with done_lock:
|
| 241 |
counter["done"] += 1
|
| 242 |
-
|
|
|
|
| 243 |
|
| 244 |
-
#
|
| 245 |
-
workers = min(4, max(1, (os.cpu_count() or 2) - 1))
|
| 246 |
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 247 |
for i, seq in enumerate(sequences):
|
| 248 |
ex.submit(render_one, i, seq)
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
| 16 |
+
from dataclasses import replace
|
| 17 |
|
| 18 |
from fastapi import APIRouter, File, Form, UploadFile
|
| 19 |
from pydantic import BaseModel
|
|
|
|
| 176 |
capped = True
|
| 177 |
raw = raw[:PLAN_CAP]
|
| 178 |
break
|
| 179 |
+
# tập biến thể SỐNG SÓT theo bộ lọc hiện tại (tương đồng/ngẫu nhiên/giới hạn).
|
| 180 |
+
# Dùng trần = PLAN_CAP (không phải DEFAULT_CAP nhỏ) để tick phản ánh ĐÚNG bộ
|
| 181 |
+
# lọc trên toàn bảng: không lọc gì -> tick hết; có lọc -> chỉ tick phần sống sót.
|
| 182 |
+
keep_cfg = replace(cfg, max_outputs=(req.max_outputs or PLAN_CAP))
|
| 183 |
+
kept = {tuple(s) for s in engine.generate(keep_cfg)}
|
| 184 |
variants = []
|
| 185 |
for i, seq in enumerate(raw):
|
| 186 |
labels = [{"segment": clip_meta.get(c, {}).get("segment", 0) + 1,
|
|
|
|
| 207 |
job = jobs.create("shuffle")
|
| 208 |
|
| 209 |
def work(h):
|
| 210 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 211 |
import threading
|
| 212 |
if capped_render:
|
| 213 |
h.log(f"Đã chọn quá {MAX_RENDER} biến thể — chỉ render {MAX_RENDER} video đầu.")
|
| 214 |
h.log(f"Sẽ render {len(sequences)} biến thể @ {w}x{h_res}"
|
| 215 |
+ (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else ""))
|
| 216 |
out_dir = sub_dir(req.session, "variations")
|
| 217 |
+
norm_dir = shuffle_render._norm_dir_for(out_dir)
|
| 218 |
n = len(sequences)
|
| 219 |
results = [None] * n
|
| 220 |
items = [h.add_item(f"Variation_{i+1:02d}") for i in range(n)]
|
| 221 |
done_lock = threading.Lock()
|
| 222 |
counter = {"done": 0}
|
| 223 |
|
| 224 |
+
# ── Đường nhanh: chuẩn hoá TRƯỚC mỗi clip nguồn ĐÚNG 1 LẦN (song song) ──
|
| 225 |
+
# Mọi biến thể sau đó chỉ concat copy (không re-encode) nên rất nhanh.
|
| 226 |
+
uniq_clips = {c for seq in sequences for c in seq}
|
| 227 |
+
workers = min(4, max(1, (os.cpu_count() or 2) - 1))
|
| 228 |
+
if uniq_clips:
|
| 229 |
+
h.log(f"Chuẩn hoá {len(uniq_clips)} clip nguồn (1 lần/clip)…")
|
| 230 |
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 231 |
+
futs = {ex.submit(shuffle_render.normalize_clip,
|
| 232 |
+
clip_map[c], norm_dir, w, h_res): c
|
| 233 |
+
for c in uniq_clips}
|
| 234 |
+
warmed = 0
|
| 235 |
+
for _ in as_completed(futs):
|
| 236 |
+
warmed += 1
|
| 237 |
+
# Chiếm ~30% thanh tiến độ cho bước chuẩn hoá.
|
| 238 |
+
h.set_progress(0.30 * warmed / max(1, len(uniq_clips)))
|
| 239 |
+
|
| 240 |
def render_one(i, seq):
|
| 241 |
it = items[i]
|
| 242 |
try:
|
| 243 |
paths = [clip_map[c] for c in seq]
|
| 244 |
name = f"Variation_{i+1:02d}.mp4"
|
| 245 |
out = os.path.join(out_dir, name)
|
| 246 |
+
track = audio_paths[i % len(audio_paths)] if audio_paths else None
|
| 247 |
+
shuffle_render.render_variation(paths, out, norm_dir, w, h_res,
|
| 248 |
+
audio_path=track)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
size = os.path.getsize(out)
|
| 250 |
results[i] = {"name": name, "path": out, "sequence": seq, "size": size}
|
| 251 |
h.item_status(it, "done", detail=f"{size//1024} KB", result={
|
|
|
|
| 255 |
h.item_status(it, "error", error=str(e))
|
| 256 |
with done_lock:
|
| 257 |
counter["done"] += 1
|
| 258 |
+
# 30% chuẩn hoá + 70% ghép biến thể.
|
| 259 |
+
h.set_progress(0.30 + 0.70 * counter["done"] / max(1, n))
|
| 260 |
|
| 261 |
+
# ghép SONG SONG (concat copy nhẹ -> có thể chạy nhiều luồng)
|
|
|
|
| 262 |
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 263 |
for i, seq in enumerate(sequences):
|
| 264 |
ex.submit(render_one, i, seq)
|
backend/app/assets/voices/vi/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Giọng clone-preset (tiếng Việt)
|
| 2 |
+
|
| 3 |
+
Thả **file mẫu giọng** vào đây để nó tự xuất hiện trong danh sách "Giọng có sẵn"
|
| 4 |
+
của phần Dịch/Đổi giọng (ngôn ngữ Việt). Model VieNeu sẽ **clone** theo file này.
|
| 5 |
+
|
| 6 |
+
## Đang khai báo sẵn
|
| 7 |
+
|
| 8 |
+
| Tên hiện trên UI | id | File cần thả (đặt đúng tên) |
|
| 9 |
+
|-----------------------------------------|--------|-----------------------------|
|
| 10 |
+
| Adam — nam, giọng trầm ấm | `adam` | `adam.wav` |
|
| 11 |
+
|
| 12 |
+
> Mục chỉ hiện khi file tương ứng đã tồn tại. Thiếu file → không hiện (tránh lỗi).
|
| 13 |
+
|
| 14 |
+
## Yêu cầu file mẫu (để clone đạt chất lượng)
|
| 15 |
+
|
| 16 |
+
- Định dạng: `.wav` (khuyến nghị) — 16–48 kHz, mono hoặc stereo đều được.
|
| 17 |
+
- Độ dài: **10–20 giây**, chỉ 1 người nói, **sạch** (không nhạc nền, không tạp âm).
|
| 18 |
+
- Nội dung nói tự nhiên, đủ ngữ điệu (một đoạn đọc bình thường là được).
|
| 19 |
+
|
| 20 |
+
## Thêm giọng clone-preset mới
|
| 21 |
+
|
| 22 |
+
1. Bỏ file `<tên>.wav` vào thư mục này.
|
| 23 |
+
2. Khai báo trong `backend/app/services/dubber/tts.py` → `_CLONE_PRESETS["vi"]`:
|
| 24 |
+
```python
|
| 25 |
+
"ten_id": ("Nhãn hiện trên UI", "ten_file.wav"),
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Lưu ý về giọng "Adam"
|
| 29 |
+
|
| 30 |
+
"Adam" là **giọng AI của ElevenLabs** (dịch vụ thương mại) — không phải người thật
|
| 31 |
+
và không có bản tải miễn phí chính thức. Hãy tự chuẩn bị `adam.wav` từ nguồn bạn
|
| 32 |
+
CÓ QUYỀN sử dụng (ví dụ: bản ghi bạn tự tạo/được cấp phép). Đặt tên đúng `adam.wav`
|
| 33 |
+
là chạy được ngay, không cần sửa code.
|
backend/app/assets/voices/vi/adam.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:48a04ea3b8157817f97a34e190a60d3af9fe0539b0df5657a738d9629ec596e6
|
| 3 |
+
size 864078
|
backend/app/config.py
CHANGED
|
@@ -53,10 +53,25 @@ class Settings:
|
|
| 53 |
|
| 54 |
# model TTS / config
|
| 55 |
DEMUCS_MODEL: str = field(default_factory=lambda: _env("DEMUCS_MODEL", "htdemucs"))
|
|
|
|
|
|
|
| 56 |
WHISPER_MODEL: str = field(default_factory=lambda: _env("WHISPER_MODEL", "whisper-large-v3"))
|
| 57 |
GEMINI_MODEL: str = field(default_factory=lambda: _env("GEMINI_MODEL", "gemini-2.0-flash"))
|
| 58 |
GROQ_LLM_FALLBACK: str = field(default_factory=lambda: _env("GROQ_LLM_FALLBACK", "openai/gpt-oss-20b"))
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
# ASR chạy LOCAL (faster-whisper) khi không có/không gọi được Groq
|
| 61 |
LOCAL_WHISPER_MODEL: str = field(default_factory=lambda: _env("LOCAL_WHISPER_MODEL", "base"))
|
| 62 |
|
|
|
|
| 53 |
|
| 54 |
# model TTS / config
|
| 55 |
DEMUCS_MODEL: str = field(default_factory=lambda: _env("DEMUCS_MODEL", "htdemucs"))
|
| 56 |
+
# model tách dự phòng khi model chính lỗi/thiếu (nhẹ hơn, chắc chạy)
|
| 57 |
+
DEMUCS_FALLBACK: str = field(default_factory=lambda: _env("DEMUCS_FALLBACK", "htdemucs"))
|
| 58 |
WHISPER_MODEL: str = field(default_factory=lambda: _env("WHISPER_MODEL", "whisper-large-v3"))
|
| 59 |
GEMINI_MODEL: str = field(default_factory=lambda: _env("GEMINI_MODEL", "gemini-2.0-flash"))
|
| 60 |
GROQ_LLM_FALLBACK: str = field(default_factory=lambda: _env("GROQ_LLM_FALLBACK", "openai/gpt-oss-20b"))
|
| 61 |
|
| 62 |
+
# --- Đồng bộ mốc thời gian & chống lẫn vị trí khi tách nhạc ---
|
| 63 |
+
# Chạy ASR trên GIỌNG ĐÃ TÁCH (sạch nhạc/tạp âm) thay vì bản trộn -> mốc chuẩn hơn.
|
| 64 |
+
# MẶC ĐỊNH TẮT: bật lên = MỌI job đều chạy Demucs (rất nặng trên CPU) chỉ để ASR
|
| 65 |
+
# sạch hơn chút. Whisper large-v3 vốn nghe tốt trên nền nhạc nên tắt để nhanh;
|
| 66 |
+
# Demucs chỉ chạy khi thật sự cần giữ nền (bg_mode=demucs).
|
| 67 |
+
ASR_ON_VOCALS: bool = field(default_factory=lambda: _env("ASR_ON_VOCALS", "0") not in ("0", "false", "False", ""))
|
| 68 |
+
# Hít mốc bắt đầu/kết thúc câu về đúng chỗ có giọng thật (VAD trên giọng tách).
|
| 69 |
+
VAD_SNAP: bool = field(default_factory=lambda: _env("VAD_SNAP", "1") not in ("0", "false", "False", ""))
|
| 70 |
+
# Ép nhạc nền nhỏ lại khi có giọng dịch (sidechain duck) -> nghe rõ lời, đỡ chồng.
|
| 71 |
+
BG_SIDECHAIN: bool = field(default_factory=lambda: _env("BG_SIDECHAIN", "1") not in ("0", "false", "False", ""))
|
| 72 |
+
# Ngưỡng (dB) coi là im lặng khi dò VAD trên giọng tách.
|
| 73 |
+
VAD_SILENCE_DB: float = field(default_factory=lambda: float(_env("VAD_SILENCE_DB", "-32")))
|
| 74 |
+
|
| 75 |
# ASR chạy LOCAL (faster-whisper) khi không có/không gọi được Groq
|
| 76 |
LOCAL_WHISPER_MODEL: str = field(default_factory=lambda: _env("LOCAL_WHISPER_MODEL", "base"))
|
| 77 |
|
backend/app/services/cutter/modes.py
CHANGED
|
@@ -1,11 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
- even : chia N khúc bằng nhau.
|
| 4 |
-
- scene : nhận diện CHUYỂN CẢNH
|
|
|
|
| 5 |
- highlight : tìm đoạn CHUYỂN ĐỘNG MẠNH (motion energy), tách thành khúc.
|
| 6 |
- smart : dùng model CLIP cắt theo ngữ nghĩa (cần torch + open_clip; có fallback).
|
|
|
|
| 7 |
|
| 8 |
Boundary = (start_frame, end_frame) — end_frame exclusive theo frame index.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -16,6 +23,10 @@ import numpy as np
|
|
| 16 |
|
| 17 |
from .probe import VideoInfo
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
@dataclass
|
| 21 |
class Segment:
|
|
@@ -128,6 +139,42 @@ def scene_cut(info: VideoInfo, min_len: float = 1.5, sensitivity: float = 0.5) -
|
|
| 128 |
return _merge_short(segs, info.fps, min_len)
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def highlight_cut(info: VideoInfo, top_ratio: float = 0.4,
|
| 132 |
min_len: float = 2.0, pad: float = 0.3) -> list[Segment]:
|
| 133 |
"""Giữ các đoạn CHUYỂN ĐỘNG MẠNH (cao trào) thành khúc riêng."""
|
|
@@ -253,15 +300,28 @@ def _merge_short(segs: list[Segment], fps: float, min_len: float) -> list[Segmen
|
|
| 253 |
|
| 254 |
|
| 255 |
def build_segments(info: VideoInfo, mode: str, **kw) -> list[Segment]:
|
|
|
|
|
|
|
| 256 |
if mode == "auto":
|
| 257 |
-
# Cắt tự động
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
from .autocut import auto_cut
|
| 259 |
return auto_cut(info)
|
| 260 |
if mode == "even":
|
| 261 |
n = kw.get("n") or auto_segment_count(info)
|
| 262 |
return even_cut(info, int(n))
|
| 263 |
if mode == "scene":
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
if mode == "highlight":
|
| 266 |
return highlight_cut(info, kw.get("top_ratio", 0.4), kw.get("min_len", 2.0))
|
| 267 |
if mode == "smart":
|
|
|
|
| 1 |
+
"""Các chế độ cắt -> trả về danh sách KHÚC (start_frame, end_frame) frame-accurate.
|
| 2 |
|
| 3 |
- even : chia N khúc bằng nhau.
|
| 4 |
+
- scene : nhận diện CHUYỂN CẢNH bằng PySceneDetect (AdaptiveDetector); fallback
|
| 5 |
+
opencv hist-diff khi thiếu thư viện.
|
| 6 |
- highlight : tìm đoạn CHUYỂN ĐỘNG MẠNH (motion energy), tách thành khúc.
|
| 7 |
- smart : dùng model CLIP cắt theo ngữ nghĩa (cần torch + open_clip; có fallback).
|
| 8 |
+
- auto : cắt tự động theo nội dung — ưu tiên PySceneDetect, fallback thị-giác.
|
| 9 |
|
| 10 |
Boundary = (start_frame, end_frame) — end_frame exclusive theo frame index.
|
| 11 |
+
|
| 12 |
+
Cấu hình PySceneDetect mặc định cho tool Cut: dùng AdaptiveDetector với
|
| 13 |
+
adaptive_threshold = 3.0, độ dài khúc tối thiểu (min_len) = 1.0 giây. Ngưỡng THẤP
|
| 14 |
+
-> nhạy, bắt cả chuyển cảnh nhẹ; min_len chặn khúc vụn. Đổi qua tham số
|
| 15 |
+
``threshold`` / ``min_len`` khi cần.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
|
|
| 23 |
|
| 24 |
from .probe import VideoInfo
|
| 25 |
|
| 26 |
+
# Cấu hình PySceneDetect mặc định (theo yêu cầu tool Cut).
|
| 27 |
+
SCENE_THRESHOLD = 3.0 # AdaptiveDetector.adaptive_threshold: thấp = nhạy hơn
|
| 28 |
+
SCENE_MIN_LEN = 1.0 # giây: khúc ngắn hơn mức này sẽ bị gộp
|
| 29 |
+
|
| 30 |
|
| 31 |
@dataclass
|
| 32 |
class Segment:
|
|
|
|
| 139 |
return _merge_short(segs, info.fps, min_len)
|
| 140 |
|
| 141 |
|
| 142 |
+
def pyscene_cut(info: VideoInfo, threshold: float = SCENE_THRESHOLD,
|
| 143 |
+
min_len: float = SCENE_MIN_LEN) -> list[Segment]:
|
| 144 |
+
"""Cắt chuyển cảnh bằng PySceneDetect (AdaptiveDetector) — nhanh & bám nội dung.
|
| 145 |
+
|
| 146 |
+
AdaptiveDetector so mỗi frame với BASELINE cục bộ (cửa sổ lân cận) nên tự thích
|
| 147 |
+
ứng theo độ "động" từng đoạn — ít cắt nhầm ở video quay tay / rung nhẹ, mà vẫn
|
| 148 |
+
bắt được chuyển cảnh; thực nghiệm cho kết quả vừa nhanh vừa tốt hơn ContentDetector.
|
| 149 |
+
|
| 150 |
+
- threshold: ``adaptive_threshold`` (mặc định 3.0) = bội số so với baseline cục
|
| 151 |
+
bộ. THẤP -> nhạy hơn (cắt nhiều hơn).
|
| 152 |
+
- min_len : độ dài khúc tối thiểu (giây, mặc định 1.0) -> quy ra frame theo fps
|
| 153 |
+
thật, khúc ngắn hơn tự bị gộp.
|
| 154 |
+
|
| 155 |
+
Trả về các khúc liền mạch phủ trọn [0, nb_frames]. Raise ImportError nếu chưa
|
| 156 |
+
cài `scenedetect` (caller tự fallback). Không có điểm cắt -> 1 khúc cả video.
|
| 157 |
+
"""
|
| 158 |
+
from scenedetect import AdaptiveDetector, detect # ImportError -> caller fallback
|
| 159 |
+
|
| 160 |
+
fps = info.fps or 30.0
|
| 161 |
+
min_frames = max(1, int(round(min_len * fps)))
|
| 162 |
+
scenes = detect(info.path,
|
| 163 |
+
AdaptiveDetector(adaptive_threshold=float(threshold),
|
| 164 |
+
min_scene_len=min_frames))
|
| 165 |
+
if not scenes:
|
| 166 |
+
return [Segment(0, 0, info.nb_frames, info.fps)]
|
| 167 |
+
|
| 168 |
+
def _fnum(tc) -> int:
|
| 169 |
+
# PySceneDetect >=0.6: FrameTimecode.frame_num; bản cũ: get_frames().
|
| 170 |
+
return int(getattr(tc, "frame_num", None) if getattr(tc, "frame_num", None) is not None
|
| 171 |
+
else tc.get_frames())
|
| 172 |
+
|
| 173 |
+
# Điểm cắt trong = biên phải mỗi khúc trừ khúc cuối (khúc cuối chạm hết video).
|
| 174 |
+
cut_frames = [_fnum(end) for _, end in scenes[:-1]]
|
| 175 |
+
return _segments_from_cuts(cut_frames, info.nb_frames, info.fps)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
def highlight_cut(info: VideoInfo, top_ratio: float = 0.4,
|
| 179 |
min_len: float = 2.0, pad: float = 0.3) -> list[Segment]:
|
| 180 |
"""Giữ các đoạn CHUYỂN ĐỘNG MẠNH (cao trào) thành khúc riêng."""
|
|
|
|
| 300 |
|
| 301 |
|
| 302 |
def build_segments(info: VideoInfo, mode: str, **kw) -> list[Segment]:
|
| 303 |
+
threshold = kw.get("threshold", SCENE_THRESHOLD)
|
| 304 |
+
min_len = kw.get("min_len", SCENE_MIN_LEN)
|
| 305 |
if mode == "auto":
|
| 306 |
+
# Cắt tự động: ƯU TIÊN PySceneDetect (ngưỡng 6.0, min_len 1.0). Thiếu thư
|
| 307 |
+
# viện / lỗi -> fallback pipeline thị-giác 3 tầng. Import lazy tránh vòng lặp.
|
| 308 |
+
try:
|
| 309 |
+
segs = pyscene_cut(info, threshold, min_len)
|
| 310 |
+
if segs:
|
| 311 |
+
return segs
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
from .autocut import auto_cut
|
| 315 |
return auto_cut(info)
|
| 316 |
if mode == "even":
|
| 317 |
n = kw.get("n") or auto_segment_count(info)
|
| 318 |
return even_cut(info, int(n))
|
| 319 |
if mode == "scene":
|
| 320 |
+
# Scene = PySceneDetect; fallback opencv hist-diff nếu thiếu scenedetect.
|
| 321 |
+
try:
|
| 322 |
+
return pyscene_cut(info, threshold, min_len)
|
| 323 |
+
except Exception:
|
| 324 |
+
return scene_cut(info, min_len, kw.get("sensitivity", 0.5))
|
| 325 |
if mode == "highlight":
|
| 326 |
return highlight_cut(info, kw.get("top_ratio", 0.4), kw.get("min_len", 2.0))
|
| 327 |
if mode == "smart":
|
backend/app/services/downloader/ydl.py
CHANGED
|
@@ -190,22 +190,31 @@ def _build_opts(out_dir: str, cookies_file: str | None = None,
|
|
| 190 |
opts["proxy"] = proxy.strip()
|
| 191 |
if source == "youtube":
|
| 192 |
# Server cloud (HF Spaces...) hay bị YouTube chặn IP datacenter với lỗi
|
| 193 |
-
# "Sign in to confirm you're not a bot".
|
| 194 |
-
#
|
| 195 |
-
#
|
| 196 |
-
# cookies.txt (Netscape format) export từ trình duyệt đã đăng nhập.
|
| 197 |
-
opts["extractor_args"] = {
|
| 198 |
-
"youtube": {"player_client": ["android", "tv", "web_safari", "web"]}
|
| 199 |
-
}
|
| 200 |
if not cookies_file:
|
| 201 |
cookies_file = _youtube_cookiefile()
|
| 202 |
-
# Chưa có file cookies -> thử đọc THẲNG từ trình duyệt trên MÁY (chỉ chạy
|
| 203 |
-
# được ở local, HF không có trình duyệt). Đặt YOUTUBE_COOKIES_BROWSER=
|
| 204 |
-
# edge | chrome | firefox | brave | opera | vivaldi
|
| 205 |
if not cookies_file:
|
|
|
|
|
|
|
| 206 |
browser = os.environ.get("YOUTUBE_COOKIES_BROWSER", "").strip().lower()
|
| 207 |
if browser:
|
| 208 |
opts["cookiesfrombrowser"] = (browser, None, None, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if cookies_file and os.path.exists(cookies_file):
|
| 210 |
opts["cookiefile"] = cookies_file
|
| 211 |
return opts
|
|
|
|
| 190 |
opts["proxy"] = proxy.strip()
|
| 191 |
if source == "youtube":
|
| 192 |
# Server cloud (HF Spaces...) hay bị YouTube chặn IP datacenter với lỗi
|
| 193 |
+
# "Sign in to confirm you're not a bot".
|
| 194 |
+
# Xác định file cookies TRƯỚC (secret HF / file / trình duyệt local),
|
| 195 |
+
# vì việc CHỌN player_client phụ thuộc có cookies hay không.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
if not cookies_file:
|
| 197 |
cookies_file = _youtube_cookiefile()
|
|
|
|
|
|
|
|
|
|
| 198 |
if not cookies_file:
|
| 199 |
+
# Đọc thẳng cookies từ trình duyệt trên MÁY (chỉ chạy được ở local).
|
| 200 |
+
# YOUTUBE_COOKIES_BROWSER = edge | chrome | firefox | brave | opera | vivaldi
|
| 201 |
browser = os.environ.get("YOUTUBE_COOKIES_BROWSER", "").strip().lower()
|
| 202 |
if browser:
|
| 203 |
opts["cookiesfrombrowser"] = (browser, None, None, None)
|
| 204 |
+
|
| 205 |
+
have_cookies = bool(
|
| 206 |
+
(cookies_file and os.path.exists(cookies_file))
|
| 207 |
+
or opts.get("cookiesfrombrowser")
|
| 208 |
+
)
|
| 209 |
+
# QUAN TRỌNG: client 'android' KHÔNG dùng cookies (cần PO token), nên nếu
|
| 210 |
+
# có cookies mà để android trước -> cookies bị bỏ qua -> vẫn dính bot-check.
|
| 211 |
+
# Có cookies -> ưu tiên web/tv (dùng được cookies).
|
| 212 |
+
# Không cookies -> thử android/tv để né mà không cần đăng nhập.
|
| 213 |
+
if have_cookies:
|
| 214 |
+
clients = ["web_safari", "web", "mweb", "tv"]
|
| 215 |
+
else:
|
| 216 |
+
clients = ["android", "tv", "web_safari", "web"]
|
| 217 |
+
opts["extractor_args"] = {"youtube": {"player_client": clients}}
|
| 218 |
if cookies_file and os.path.exists(cookies_file):
|
| 219 |
opts["cookiefile"] = cookies_file
|
| 220 |
return opts
|
backend/app/services/dubber/align.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Căn mốc thời gian câu thoại về ĐÚNG chỗ có giọng thật (VAD nhẹ).
|
| 2 |
+
|
| 3 |
+
Vì sao cần: Whisper hay báo mốc bắt đầu SỚM hơn thực tế khi có tạp âm/nhạc
|
| 4 |
+
dẫn trước (vd tiếng xịt nước 1-3s rồi mới nói 3-5s -> câu bị đẩy lên 1-3s).
|
| 5 |
+
Chạy trên GIỌNG ĐÃ TÁCH (đã bỏ nhạc/tạp âm), dùng ffmpeg `silencedetect` để tìm
|
| 6 |
+
các khoảng CÓ TIẾNG rồi "hít" start/end mỗi câu về biên giọng gần nhất.
|
| 7 |
+
|
| 8 |
+
Không cần model nặng -> chạy tốt trên server (chỉ dùng ffmpeg).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
|
| 17 |
+
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 18 |
+
|
| 19 |
+
_SIL_START = re.compile(r"silence_start:\s*([0-9.]+)")
|
| 20 |
+
_SIL_END = re.compile(r"silence_end:\s*([0-9.]+)")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def speech_intervals(voice_wav: str, silence_db: float = -32.0,
|
| 24 |
+
min_silence: float = 0.18) -> list[tuple[float, float]]:
|
| 25 |
+
"""Trả danh sách [(start, end)] các khoảng CÓ GIỌNG (nghịch đảo silencedetect)."""
|
| 26 |
+
proc = subprocess.run(
|
| 27 |
+
[FFMPEG, "-hide_banner", "-nostats", "-i", voice_wav,
|
| 28 |
+
"-af", f"silencedetect=noise={silence_db}dB:d={min_silence}",
|
| 29 |
+
"-f", "null", "-"],
|
| 30 |
+
capture_output=True, text=True)
|
| 31 |
+
log = (proc.stderr or "") + (proc.stdout or "")
|
| 32 |
+
|
| 33 |
+
# Ghép các mốc silence_start / silence_end theo thứ tự xuất hiện.
|
| 34 |
+
sil: list[tuple[float, float]] = []
|
| 35 |
+
cur_start: float | None = None
|
| 36 |
+
for line in log.splitlines():
|
| 37 |
+
ms = _SIL_START.search(line)
|
| 38 |
+
me = _SIL_END.search(line)
|
| 39 |
+
if ms:
|
| 40 |
+
cur_start = float(ms.group(1))
|
| 41 |
+
if me:
|
| 42 |
+
end = float(me.group(1))
|
| 43 |
+
start = cur_start if cur_start is not None else 0.0
|
| 44 |
+
sil.append((max(0.0, start), end))
|
| 45 |
+
cur_start = None
|
| 46 |
+
|
| 47 |
+
dur = _duration(voice_wav)
|
| 48 |
+
if not sil:
|
| 49 |
+
return [(0.0, dur)] if dur > 0 else []
|
| 50 |
+
|
| 51 |
+
# Nghịch đảo: khoảng CÓ giọng = phần giữa các khoảng im lặng.
|
| 52 |
+
speech: list[tuple[float, float]] = []
|
| 53 |
+
prev_end = 0.0
|
| 54 |
+
for s, e in sil:
|
| 55 |
+
if s - prev_end > 0.05:
|
| 56 |
+
speech.append((prev_end, s))
|
| 57 |
+
prev_end = max(prev_end, e)
|
| 58 |
+
if dur - prev_end > 0.05:
|
| 59 |
+
speech.append((prev_end, dur))
|
| 60 |
+
return speech
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _duration(wav: str) -> float:
|
| 64 |
+
out = subprocess.run(
|
| 65 |
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
| 66 |
+
"-of", "csv=p=0", wav], capture_output=True, text=True).stdout.strip()
|
| 67 |
+
try:
|
| 68 |
+
return float(out)
|
| 69 |
+
except ValueError:
|
| 70 |
+
return 0.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def snap_segments(segments, voice_wav: str, silence_db: float = -32.0,
|
| 74 |
+
tol: float = 1.5):
|
| 75 |
+
"""Hít mốc start/end mỗi câu về biên giọng thật gần nhất.
|
| 76 |
+
|
| 77 |
+
- start: dời tới ONSET giọng đầu tiên trong cửa sổ [start-tol, end] (sửa lỗi đẩy sớm).
|
| 78 |
+
- end: dời tới OFFSET giọng cuối cùng trong cửa sổ [start, end+tol].
|
| 79 |
+
- Chỉ dời khi có bằng chứng giọng; giữ nguyên nếu không tìm thấy (an toàn).
|
| 80 |
+
- Không để câu đè lên start câu kế (đã kẹp ở pipeline; ở đây chỉ chỉnh trong bản thân câu).
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
intervals = speech_intervals(voice_wav, silence_db)
|
| 84 |
+
except Exception:
|
| 85 |
+
return segments
|
| 86 |
+
if not intervals:
|
| 87 |
+
return segments
|
| 88 |
+
|
| 89 |
+
for seg in segments:
|
| 90 |
+
s0, e0 = seg.start, seg.end
|
| 91 |
+
# ONSET: khoảng giọng đầu tiên KẾT THÚC sau (s0 - tol) và bắt đầu trước e0.
|
| 92 |
+
onset = None
|
| 93 |
+
for a, b in intervals:
|
| 94 |
+
if b >= s0 - tol and a <= e0:
|
| 95 |
+
onset = a
|
| 96 |
+
break
|
| 97 |
+
# OFFSET: khoảng giọng cuối cùng BẮT ĐẦU trước (e0 + tol) và kết thúc sau s0.
|
| 98 |
+
offset = None
|
| 99 |
+
for a, b in intervals:
|
| 100 |
+
if a <= e0 + tol and b >= s0:
|
| 101 |
+
offset = b
|
| 102 |
+
if onset is not None:
|
| 103 |
+
# chỉ dời start trong phạm vi tol; không vượt quá end
|
| 104 |
+
new_start = min(max(onset, s0 - tol), e0 - 0.2)
|
| 105 |
+
if abs(new_start - s0) <= tol:
|
| 106 |
+
seg.start = max(0.0, new_start)
|
| 107 |
+
if offset is not None:
|
| 108 |
+
new_end = max(min(offset, e0 + tol), seg.start + 0.2)
|
| 109 |
+
if abs(new_end - e0) <= tol:
|
| 110 |
+
seg.end = new_end
|
| 111 |
+
return segments
|
backend/app/services/dubber/audio.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""Bước AUDIO của pipeline lồng tiếng.
|
| 2 |
|
| 3 |
- Tách audio ra 2 bản: 16kHz mono (cho ASR) và 44.1kHz stereo (giữ chất gốc làm nền).
|
| 4 |
-
-
|
|
|
|
|
|
|
| 5 |
- Cắt mẫu clone giọng (~10-20s) từ lúc bắt đầu nói (ưu tiên bản giọng tách sạch).
|
| 6 |
"""
|
| 7 |
|
|
@@ -10,18 +12,22 @@ from __future__ import annotations
|
|
| 10 |
import os
|
| 11 |
import shutil
|
| 12 |
import subprocess
|
|
|
|
| 13 |
from dataclasses import dataclass
|
| 14 |
|
|
|
|
|
|
|
| 15 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass
|
| 19 |
class AudioBundle:
|
| 20 |
-
asr_wav: str # 16k mono cho Whisper
|
| 21 |
full_wav: str # 44.1k stereo gốc
|
| 22 |
bg_wav: str # nền nhạc theo chế độ (none -> im lặng)
|
| 23 |
voice_wav: str # giọng đã tách (demucs) hoặc bản gốc
|
| 24 |
mode: str
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def _run(cmd: list[str]):
|
|
@@ -32,18 +38,26 @@ def extract_tracks(video: str, out_dir: str) -> tuple[str, str]:
|
|
| 32 |
os.makedirs(out_dir, exist_ok=True)
|
| 33 |
asr_wav = os.path.join(out_dir, "asr_16k_mono.wav")
|
| 34 |
full_wav = os.path.join(out_dir, "full_44k_stereo.wav")
|
|
|
|
|
|
|
| 35 |
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", video,
|
| 36 |
-
"-vn", "-ac", "1", "-ar", "16000", asr_wav
|
| 37 |
-
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", video,
|
| 38 |
"-vn", "-ac", "2", "-ar", "44100", full_wav])
|
| 39 |
return asr_wav, full_wav
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def _silence_like(ref_wav: str, out: str):
|
| 43 |
"""Tạo track im lặng dài bằng ref (cho chế độ none)."""
|
| 44 |
dur = _duration(ref_wav)
|
| 45 |
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 46 |
-
"-f", "lavfi", "-i",
|
| 47 |
"-t", f"{dur:.3f}", out])
|
| 48 |
|
| 49 |
|
|
@@ -57,44 +71,100 @@ def _duration(wav: str) -> float:
|
|
| 57 |
return 0.0
|
| 58 |
|
| 59 |
|
| 60 |
-
def
|
| 61 |
-
"""Tách
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
import demucs.separate # type: ignore # noqa: F401
|
| 66 |
|
| 67 |
sep_dir = os.path.join(out_dir, "demucs")
|
| 68 |
os.makedirs(sep_dir, exist_ok=True)
|
| 69 |
subprocess.run(
|
| 70 |
-
[
|
| 71 |
"-o", sep_dir, full_wav], check=True, capture_output=True)
|
| 72 |
stem_root = os.path.join(sep_dir, model,
|
| 73 |
os.path.splitext(os.path.basename(full_wav))[0])
|
| 74 |
vocals = os.path.join(stem_root, "vocals.wav")
|
| 75 |
no_vocals = os.path.join(stem_root, "no_vocals.wav")
|
|
|
|
|
|
|
| 76 |
return no_vocals, vocals
|
| 77 |
|
| 78 |
|
| 79 |
-
def
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
return
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
-
return
|
| 94 |
-
except Exception:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def cut_clone_sample(voice_wav: str, out_dir: str, start: float = 0.0,
|
|
@@ -109,6 +179,51 @@ def cut_clone_sample(voice_wav: str, out_dir: str, start: float = 0.0,
|
|
| 109 |
|
| 110 |
def build_audio(video: str, out_dir: str, bg_mode: str,
|
| 111 |
demucs_model: str = "htdemucs") -> AudioBundle:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Bước AUDIO của pipeline lồng tiếng.
|
| 2 |
|
| 3 |
- Tách audio ra 2 bản: 16kHz mono (cho ASR) và 44.1kHz stereo (giữ chất gốc làm nền).
|
| 4 |
+
- TÁCH GIỌNG/NHẠC bằng Demucs (có fallback model) NGAY TỪ ĐẦU để:
|
| 5 |
+
* ASR chạy trên GIỌNG SẠCH (không lẫn nhạc/tạp âm) -> mốc thời gian chuẩn.
|
| 6 |
+
* "Giữ nền" dùng đúng bản no_vocals (đã bỏ giọng gốc), không còn giọng gốc chồng lệch.
|
| 7 |
- Cắt mẫu clone giọng (~10-20s) từ lúc bắt đầu nói (ưu tiên bản giọng tách sạch).
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 12 |
import os
|
| 13 |
import shutil
|
| 14 |
import subprocess
|
| 15 |
+
import sys
|
| 16 |
from dataclasses import dataclass
|
| 17 |
|
| 18 |
+
from ...config import settings
|
| 19 |
+
|
| 20 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 21 |
|
| 22 |
|
| 23 |
@dataclass
|
| 24 |
class AudioBundle:
|
| 25 |
+
asr_wav: str # 16k mono cho Whisper (ưu tiên giọng đã tách)
|
| 26 |
full_wav: str # 44.1k stereo gốc
|
| 27 |
bg_wav: str # nền nhạc theo chế độ (none -> im lặng)
|
| 28 |
voice_wav: str # giọng đã tách (demucs) hoặc bản gốc
|
| 29 |
mode: str
|
| 30 |
+
voice_is_clean: bool = False # True khi voice_wav là giọng đã tách sạch (dùng cho VAD)
|
| 31 |
|
| 32 |
|
| 33 |
def _run(cmd: list[str]):
|
|
|
|
| 38 |
os.makedirs(out_dir, exist_ok=True)
|
| 39 |
asr_wav = os.path.join(out_dir, "asr_16k_mono.wav")
|
| 40 |
full_wav = os.path.join(out_dir, "full_44k_stereo.wav")
|
| 41 |
+
# 1 LẦN decode video -> 2 output (ASR 16k mono + nền 44.1k stereo) để KHÔNG
|
| 42 |
+
# phải decode toàn bộ video hai lượt (tiết kiệm rõ với video dài).
|
| 43 |
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", video,
|
| 44 |
+
"-vn", "-ac", "1", "-ar", "16000", asr_wav,
|
|
|
|
| 45 |
"-vn", "-ac", "2", "-ar", "44100", full_wav])
|
| 46 |
return asr_wav, full_wav
|
| 47 |
|
| 48 |
|
| 49 |
+
def to_16k_mono(src: str, out: str) -> str:
|
| 50 |
+
"""Chuyển 1 wav bất kỳ về 16k mono cho Whisper."""
|
| 51 |
+
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", src,
|
| 52 |
+
"-vn", "-ac", "1", "-ar", "16000", out])
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
def _silence_like(ref_wav: str, out: str):
|
| 57 |
"""Tạo track im lặng dài bằng ref (cho chế độ none)."""
|
| 58 |
dur = _duration(ref_wav)
|
| 59 |
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 60 |
+
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
|
| 61 |
"-t", f"{dur:.3f}", out])
|
| 62 |
|
| 63 |
|
|
|
|
| 71 |
return 0.0
|
| 72 |
|
| 73 |
|
| 74 |
+
def _demucs_api(full_wav: str, out_dir: str, model: str) -> tuple[str, str]:
|
| 75 |
+
"""Tách bằng API demucs, ĐỌC/GHI wav bằng soundfile.
|
| 76 |
|
| 77 |
+
Vì sao không dùng CLI: torchaudio 2.8+ đẩy việc lưu wav sang `torchcodec`;
|
| 78 |
+
thiếu torchcodec -> CLI `-m demucs` chết ở bước ghi file dù model chạy xong.
|
| 79 |
+
Đọc/ghi bằng soundfile (libsndfile) né hẳn torchaudio.save -> chạy ổn mọi nơi.
|
| 80 |
"""
|
| 81 |
+
import numpy as np # noqa: F401
|
| 82 |
+
import soundfile as sf
|
| 83 |
+
import torch
|
| 84 |
+
from demucs.apply import apply_model
|
| 85 |
+
from demucs.pretrained import get_model
|
| 86 |
+
|
| 87 |
+
m = get_model(model) # tải weight lần đầu nếu chưa có (cache demucs)
|
| 88 |
+
m.eval()
|
| 89 |
+
sr_model = m.samplerate
|
| 90 |
+
ch_model = m.audio_channels
|
| 91 |
+
|
| 92 |
+
data, sr = sf.read(full_wav, dtype="float32", always_2d=True) # [n, ch]
|
| 93 |
+
wav = torch.from_numpy(data.T) # [ch, n]
|
| 94 |
+
if wav.shape[0] == 1 and ch_model == 2:
|
| 95 |
+
wav = wav.repeat(2, 1)
|
| 96 |
+
elif wav.shape[0] > ch_model:
|
| 97 |
+
wav = wav[:ch_model]
|
| 98 |
+
if sr != sr_model:
|
| 99 |
+
import torchaudio # chỉ dùng DSP resample, KHÔNG đụng save/torchcodec
|
| 100 |
+
wav = torchaudio.functional.resample(wav, sr, sr_model)
|
| 101 |
+
|
| 102 |
+
ref = wav.mean(0)
|
| 103 |
+
w = (wav - ref.mean()) / (ref.std() + 1e-8)
|
| 104 |
+
with torch.no_grad():
|
| 105 |
+
srcs = apply_model(m, w[None], device="cpu", split=True,
|
| 106 |
+
overlap=0.25, progress=False)[0]
|
| 107 |
+
srcs = srcs * ref.std() + ref.mean()
|
| 108 |
+
|
| 109 |
+
names = list(m.sources)
|
| 110 |
+
vi = names.index("vocals")
|
| 111 |
+
vocals_t = srcs[vi]
|
| 112 |
+
no_vocals_t = sum(srcs[i] for i in range(len(names)) if i != vi)
|
| 113 |
+
|
| 114 |
+
stem_root = os.path.join(out_dir, "demucs", model,
|
| 115 |
+
os.path.splitext(os.path.basename(full_wav))[0])
|
| 116 |
+
os.makedirs(stem_root, exist_ok=True)
|
| 117 |
+
vocals = os.path.join(stem_root, "vocals.wav")
|
| 118 |
+
no_vocals = os.path.join(stem_root, "no_vocals.wav")
|
| 119 |
+
sf.write(vocals, vocals_t.T.cpu().numpy(), sr_model)
|
| 120 |
+
sf.write(no_vocals, no_vocals_t.T.cpu().numpy(), sr_model)
|
| 121 |
+
return no_vocals, vocals
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _demucs_cli(full_wav: str, out_dir: str, model: str) -> tuple[str, str]:
|
| 125 |
+
"""Tách bằng CLI demucs (dự phòng — chỉ chạy được nếu torchaudio ghi wav được)."""
|
| 126 |
import demucs.separate # type: ignore # noqa: F401
|
| 127 |
|
| 128 |
sep_dir = os.path.join(out_dir, "demucs")
|
| 129 |
os.makedirs(sep_dir, exist_ok=True)
|
| 130 |
subprocess.run(
|
| 131 |
+
[sys.executable, "-m", "demucs", "-n", model, "--two-stems", "vocals",
|
| 132 |
"-o", sep_dir, full_wav], check=True, capture_output=True)
|
| 133 |
stem_root = os.path.join(sep_dir, model,
|
| 134 |
os.path.splitext(os.path.basename(full_wav))[0])
|
| 135 |
vocals = os.path.join(stem_root, "vocals.wav")
|
| 136 |
no_vocals = os.path.join(stem_root, "no_vocals.wav")
|
| 137 |
+
if not (os.path.exists(vocals) and os.path.exists(no_vocals)):
|
| 138 |
+
raise RuntimeError("Demucs không xuất đủ stem vocals/no_vocals")
|
| 139 |
return no_vocals, vocals
|
| 140 |
|
| 141 |
|
| 142 |
+
def _demucs_once(full_wav: str, out_dir: str, model: str) -> tuple[str, str]:
|
| 143 |
+
"""Tách 1 lần: ưu tiên API (né torchcodec), lỗi thì thử CLI. Raise nếu cả hai lỗi."""
|
| 144 |
+
try:
|
| 145 |
+
return _demucs_api(full_wav, out_dir, model)
|
| 146 |
+
except Exception as e_api:
|
| 147 |
+
print(f"[audio] demucs API lỗi ({e_api}); thử CLI…", file=sys.stderr)
|
| 148 |
+
return _demucs_cli(full_wav, out_dir, model)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def separate(full_wav: str, out_dir: str, model: str) -> tuple[str, str]:
|
| 152 |
+
"""Tách giọng/nhạc với FALLBACK model. Trả (no_vocals, vocals) hoặc raise nếu hết cách."""
|
| 153 |
+
tried = []
|
| 154 |
+
for m in [model, settings.DEMUCS_FALLBACK, "htdemucs"]:
|
| 155 |
+
if not m or m in tried:
|
| 156 |
+
continue
|
| 157 |
+
tried.append(m)
|
| 158 |
try:
|
| 159 |
+
return _demucs_once(full_wav, out_dir, m)
|
| 160 |
+
except Exception as e: # noqa: PERF203
|
| 161 |
+
print(f"[audio] tách bằng '{m}' lỗi: {e}", file=sys.stderr)
|
| 162 |
+
raise RuntimeError(f"Tách giọng thất bại với các model: {tried}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _duck(full_wav: str, bg: str, db: float = -12.0):
|
| 166 |
+
_run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 167 |
+
"-i", full_wav, "-af", f"volume={db}dB", bg])
|
| 168 |
|
| 169 |
|
| 170 |
def cut_clone_sample(voice_wav: str, out_dir: str, start: float = 0.0,
|
|
|
|
| 179 |
|
| 180 |
def build_audio(video: str, out_dir: str, bg_mode: str,
|
| 181 |
demucs_model: str = "htdemucs") -> AudioBundle:
|
| 182 |
+
"""Chuẩn bị audio cho pipeline.
|
| 183 |
+
|
| 184 |
+
Chiến lược mới:
|
| 185 |
+
1) Tách giọng/nhạc (Demucs) khi cần -> để ASR chạy trên GIỌNG SẠCH.
|
| 186 |
+
2) Nền theo chế độ: none -> im lặng; demucs -> no_vocals (đã bỏ giọng gốc).
|
| 187 |
+
3) Nếu tách lỗi -> lùi an toàn: ASR trên bản trộn, nền = duck (-12dB).
|
| 188 |
+
"""
|
| 189 |
+
asr_full, full_wav = extract_tracks(video, out_dir)
|
| 190 |
+
|
| 191 |
+
# Có cần tách không? Cần khi giữ nền (demucs) HOẶC muốn ASR trên giọng sạch.
|
| 192 |
+
want_sep = (bg_mode == "demucs") or settings.ASR_ON_VOCALS
|
| 193 |
+
no_vocals = vocals = None
|
| 194 |
+
if want_sep:
|
| 195 |
+
try:
|
| 196 |
+
no_vocals, vocals = separate(full_wav, out_dir, demucs_model)
|
| 197 |
+
except Exception as e:
|
| 198 |
+
print(f"[audio] bỏ tách, dùng bản trộn: {e}", file=sys.stderr)
|
| 199 |
+
no_vocals = vocals = None
|
| 200 |
+
|
| 201 |
+
# --- chọn nguồn cho ASR + mẫu clone ---
|
| 202 |
+
voice_is_clean = False
|
| 203 |
+
if vocals and settings.ASR_ON_VOCALS:
|
| 204 |
+
asr_wav = to_16k_mono(vocals, os.path.join(out_dir, "asr_16k_vocals.wav"))
|
| 205 |
+
voice_wav = vocals
|
| 206 |
+
voice_is_clean = True
|
| 207 |
+
else:
|
| 208 |
+
asr_wav = asr_full
|
| 209 |
+
voice_wav = vocals or full_wav
|
| 210 |
+
voice_is_clean = bool(vocals)
|
| 211 |
+
|
| 212 |
+
# --- chọn NỀN theo chế độ ---
|
| 213 |
+
bg = os.path.join(out_dir, "bg.wav")
|
| 214 |
+
if bg_mode == "none":
|
| 215 |
+
_silence_like(full_wav, bg) # bỏ hẳn tiếng gốc
|
| 216 |
+
elif bg_mode == "demucs":
|
| 217 |
+
if no_vocals:
|
| 218 |
+
shutil.copy(no_vocals, bg) # giữ nhạc/tiếng động, bỏ giọng gốc
|
| 219 |
+
else:
|
| 220 |
+
# Tách LỖI: KHÔNG lùi về duck — duck giữ NGUYÊN giọng gốc (-12dB) nên
|
| 221 |
+
# nghe như "chưa dịch". Thà bỏ nền, dùng im lặng để bản dub SẠCH.
|
| 222 |
+
_silence_like(full_wav, bg)
|
| 223 |
+
elif bg_mode == "duck":
|
| 224 |
+
_duck(full_wav, bg)
|
| 225 |
+
else:
|
| 226 |
+
raise ValueError(f"bg mode không hợp lệ: {bg_mode}")
|
| 227 |
+
|
| 228 |
+
return AudioBundle(asr_wav=asr_wav, full_wav=full_wav, bg_wav=bg,
|
| 229 |
+
voice_wav=voice_wav, mode=bg_mode, voice_is_clean=voice_is_clean)
|
backend/app/services/dubber/mux.py
CHANGED
|
@@ -109,8 +109,34 @@ def assemble_voice(lines: list[PlacedLine], total_dur: float, out_wav: str) -> s
|
|
| 109 |
return out_wav
|
| 110 |
|
| 111 |
|
| 112 |
-
def mix_over_bg(voice_wav: str, bg_wav: str, out_wav: str
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
subprocess.run(
|
| 115 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 116 |
"-i", bg_wav, "-i", voice_wav,
|
|
@@ -122,18 +148,49 @@ def mix_over_bg(voice_wav: str, bg_wav: str, out_wav: str) -> str:
|
|
| 122 |
return out_wav
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def mux_to_video(video: str, audio_wav: str, out_mp4: str) -> str:
|
| 126 |
-
"""Ghép audio mới vào video
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
"""
|
| 132 |
subprocess.run(
|
| 133 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 134 |
"-i", video, "-i", audio_wav,
|
| 135 |
"-map", "0:v:0", "-map", "1:a:0",
|
| 136 |
-
|
| 137 |
"-movflags", "+faststart",
|
| 138 |
"-c:a", "aac", "-b:a", "192k",
|
| 139 |
"-shortest", out_mp4], check=True, capture_output=True)
|
|
|
|
| 109 |
return out_wav
|
| 110 |
|
| 111 |
|
| 112 |
+
def mix_over_bg(voice_wav: str, bg_wav: str, out_wav: str,
|
| 113 |
+
sidechain: bool = True) -> str:
|
| 114 |
+
"""Trộn giọng dịch LÊN TRÊN nhạc nền.
|
| 115 |
+
|
| 116 |
+
sidechain=True: ép nền NHỎ LẠI mỗi khi có giọng dịch (sidechaincompress) rồi
|
| 117 |
+
trộn -> lời rõ, nhạc/tiếng động vẫn còn nhưng không lấn giọng. Đây là cách
|
| 118 |
+
xử lý tốt cho nền là NHẠC CÓ LỜI (không cần tách sạch tuyệt đối vẫn nghe ổn).
|
| 119 |
+
Nếu sidechain lỗi (build ffmpeg thiếu filter) -> tự lùi về amix thường.
|
| 120 |
+
"""
|
| 121 |
+
if sidechain:
|
| 122 |
+
fg = (
|
| 123 |
+
"[0:a]aresample=44100,aformat=channel_layouts=stereo[bg];"
|
| 124 |
+
"[1:a]aresample=44100,aformat=channel_layouts=stereo[vx];"
|
| 125 |
+
"[vx]asplit=2[vx1][vxsc];"
|
| 126 |
+
# nén nền theo giọng: khi có giọng -> nền hạ ~ratio; nhả mượt tránh bụp
|
| 127 |
+
"[bg][vxsc]sidechaincompress=threshold=0.02:ratio=12:attack=5:"
|
| 128 |
+
"release=350:makeup=1[bgd];"
|
| 129 |
+
"[bgd][vx1]amix=inputs=2:normalize=0:duration=longest[mix]"
|
| 130 |
+
)
|
| 131 |
+
try:
|
| 132 |
+
subprocess.run(
|
| 133 |
+
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 134 |
+
"-i", bg_wav, "-i", voice_wav,
|
| 135 |
+
"-filter_complex", fg, "-map", "[mix]", out_wav],
|
| 136 |
+
check=True, capture_output=True)
|
| 137 |
+
return out_wav
|
| 138 |
+
except Exception:
|
| 139 |
+
pass # lùi về trộn thường
|
| 140 |
subprocess.run(
|
| 141 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 142 |
"-i", bg_wav, "-i", voice_wav,
|
|
|
|
| 148 |
return out_wav
|
| 149 |
|
| 150 |
|
| 151 |
+
def video_codec(path: str) -> str:
|
| 152 |
+
"""Tên codec luồng hình (vd 'h264', 'hevc'). Rỗng nếu không đọc được."""
|
| 153 |
+
out = subprocess.run(
|
| 154 |
+
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
| 155 |
+
"-show_entries", "stream=codec_name", "-of", "csv=p=0", path],
|
| 156 |
+
capture_output=True, text=True).stdout.strip()
|
| 157 |
+
return out.split(",")[0].strip().lower()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _vcodec_args(src: str) -> list[str]:
|
| 161 |
+
"""Chọn cách xử lý luồng HÌNH:
|
| 162 |
+
|
| 163 |
+
- Nguồn đã H.264 -> `-c:v copy`: KHÔNG re-encode (nhanh gần như tức thì và
|
| 164 |
+
giữ NGUYÊN chất lượng hình). Trình duyệt phát được sẵn.
|
| 165 |
+
- Nguồn khác (HEVC/H.265, VP9…) -> encode H.264 để web xem được.
|
| 166 |
+
"""
|
| 167 |
+
if video_codec(src) == "h264":
|
| 168 |
+
return ["-c:v", "copy"]
|
| 169 |
+
return ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p"]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def normalize_playable(src: str, out_mp4: str) -> str:
|
| 173 |
+
"""Chuẩn hoá video cho web: giữ nguyên hình nếu đã H.264 (copy), chỉ encode
|
| 174 |
+
khi cần; luôn xuất audio AAC + faststart. Dùng cho nhánh giữ-nguyên/dự-phòng."""
|
| 175 |
+
subprocess.run(
|
| 176 |
+
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", src,
|
| 177 |
+
*_vcodec_args(src), "-c:a", "aac", "-movflags", "+faststart", out_mp4],
|
| 178 |
+
check=True, capture_output=True)
|
| 179 |
+
return out_mp4
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def mux_to_video(video: str, audio_wav: str, out_mp4: str) -> str:
|
| 183 |
+
"""Ghép audio mới vào video cho web xem được.
|
| 184 |
|
| 185 |
+
Copy luồng hình nếu nguồn đã là H.264 (nhanh + không mất chất); chỉ RE-ENCODE
|
| 186 |
+
sang H.264/yuv420p khi nguồn là HEVC/H.265 — trình duyệt (Chrome/Firefox)
|
| 187 |
+
không phát HEVC được. Luôn +faststart để xem trực tiếp.
|
| 188 |
"""
|
| 189 |
subprocess.run(
|
| 190 |
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 191 |
"-i", video, "-i", audio_wav,
|
| 192 |
"-map", "0:v:0", "-map", "1:a:0",
|
| 193 |
+
*_vcodec_args(video),
|
| 194 |
"-movflags", "+faststart",
|
| 195 |
"-c:a", "aac", "-b:a", "192k",
|
| 196 |
"-shortest", out_mp4], check=True, capture_output=True)
|
backend/app/services/dubber/pipeline.py
CHANGED
|
@@ -7,12 +7,14 @@ Trả về đường dẫn: video đã dịch, audio giọng clone, subtitle SRT
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import os
|
| 11 |
from dataclasses import dataclass
|
| 12 |
from typing import Callable, Optional
|
| 13 |
|
| 14 |
-
from . import asr, audio, mux, translate, tts
|
| 15 |
from .probe_dur import video_duration
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass
|
|
@@ -110,22 +112,17 @@ def _passthrough(video: str, out_dir: str, opt: DubOptions, note: str) -> DubRes
|
|
| 110 |
Đảm bảo người dùng luôn TẢI ĐƯỢC video, không bị treo/job lỗi.
|
| 111 |
"""
|
| 112 |
import shutil as _sh
|
|
|
|
| 113 |
os.makedirs(out_dir, exist_ok=True)
|
| 114 |
ffmpeg = _sh.which("ffmpeg") or "ffmpeg"
|
| 115 |
video_out = os.path.join(out_dir, f"video_translated_{opt.target_lang}.mp4")
|
| 116 |
try:
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
subprocess.run([ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
|
| 120 |
-
"-i", video, "-c:v", "libx264", "-preset", "veryfast",
|
| 121 |
-
"-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
| 122 |
-
"-c:a", "aac", video_out],
|
| 123 |
-
check=True, capture_output=True)
|
| 124 |
except Exception:
|
| 125 |
_sh.copy(video, video_out)
|
| 126 |
audio_out = os.path.join(out_dir, "audio.m4a")
|
| 127 |
try:
|
| 128 |
-
import subprocess
|
| 129 |
subprocess.run([ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
|
| 130 |
"-i", video, "-vn", "-c:a", "aac", audio_out],
|
| 131 |
check=True, capture_output=True)
|
|
@@ -138,23 +135,66 @@ def _passthrough(video: str, out_dir: str, opt: DubOptions, note: str) -> DubRes
|
|
| 138 |
language="unknown", n_lines=0, note=note)
|
| 139 |
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
try:
|
| 146 |
-
return
|
| 147 |
except Exception as e:
|
| 148 |
import traceback
|
| 149 |
-
traceback.print_exc()
|
| 150 |
if progress:
|
| 151 |
progress(1.0, "Dùng video gốc (xem ghi chú lỗi)")
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
-
def
|
| 157 |
-
|
| 158 |
def emit(p, m):
|
| 159 |
if progress:
|
| 160 |
progress(p, m)
|
|
@@ -163,68 +203,156 @@ def _run_dub_full(video: str, out_dir: str, opt: DubOptions,
|
|
| 163 |
os.makedirs(work, exist_ok=True)
|
| 164 |
total = video_duration(video)
|
| 165 |
|
| 166 |
-
emit(0.
|
| 167 |
bundle = audio.build_audio(video, work, opt.bg_mode, opt.demucs_model)
|
| 168 |
|
| 169 |
-
emit(0.
|
| 170 |
try:
|
| 171 |
segments, language = asr.transcribe(bundle.asr_wav)
|
| 172 |
except Exception as e:
|
| 173 |
raise RuntimeError(f"[Bước ASR/Groq Whisper] {e}") from e
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# ── Nếu ngôn ngữ gốc TRÙNG ngôn ngữ đích → giữ nguyên video gốc ──
|
| 176 |
_lang_map = {"vi": "vi", "vie": "vi", "english": "en", "en": "en",
|
| 177 |
"filipino": "fil", "fil": "fil", "tagalog": "fil"}
|
| 178 |
_detected = _lang_map.get(language.lower().strip(), language.lower().strip())
|
| 179 |
_target = _lang_map.get(opt.target_lang.lower().strip(), opt.target_lang.lower().strip())
|
| 180 |
if _detected and _detected == _target:
|
| 181 |
-
|
| 182 |
-
|
| 183 |
import shutil as _sh2
|
| 184 |
video_out = os.path.join(out_dir, f"video_translated_{opt.target_lang}.mp4")
|
| 185 |
try:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
"-i", video, "-c:v", "libx264", "-preset", "veryfast",
|
| 189 |
-
"-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
| 190 |
-
"-c:a", "aac", video_out],
|
| 191 |
-
check=True, capture_output=True)
|
| 192 |
except Exception:
|
| 193 |
_sh2.copy(video, video_out)
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 198 |
-
"-i", video, "-vn", "-c:a", "aac", audio_out],
|
| 199 |
-
check=True, capture_output=True)
|
| 200 |
-
except Exception:
|
| 201 |
-
audio_out = video_out
|
| 202 |
srt_out = os.path.join(out_dir, "subtitle.srt")
|
| 203 |
with open(srt_out, "w", encoding="utf-8") as f:
|
| 204 |
-
for i, ln in enumerate(
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
try:
|
| 213 |
translated = translate.translate_all(segments, opt.target_lang)
|
| 214 |
except Exception as e:
|
| 215 |
raise RuntimeError(f"[Bước Dịch/Gemini] {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
srt_out = write_srt(translated, os.path.join(out_dir, "subtitle.srt"))
|
| 217 |
|
| 218 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
speaker_wav = opt.speaker_wav
|
| 220 |
-
if opt.do_clone and opt.voice_source == "video" and
|
| 221 |
-
emit(0.
|
| 222 |
speaker_wav = audio.cut_clone_sample(
|
| 223 |
-
|
| 224 |
|
| 225 |
-
emit(0.
|
| 226 |
vcfg = tts.VoiceConfig(
|
| 227 |
-
target_lang=
|
| 228 |
speaker_wav=speaker_wav, preset=opt.preset, style=opt.style,
|
| 229 |
engine=opt.engine, temperature=opt.temperature, top_k=opt.top_k,
|
| 230 |
repetition_penalty=opt.repetition_penalty)
|
|
@@ -235,31 +363,67 @@ def _run_dub_full(video: str, out_dir: str, opt: DubOptions,
|
|
| 235 |
|
| 236 |
placed: list[mux.PlacedLine] = []
|
| 237 |
tts_dir = os.path.join(work, "tts"); os.makedirs(tts_dir, exist_ok=True)
|
| 238 |
-
n = len(
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
fitted = os.path.join(tts_dir, f"fit_{i:04d}.wav")
|
| 249 |
-
mux.fit_to_slot(
|
| 250 |
-
placed.append(mux.PlacedLine(start=ln
|
| 251 |
-
|
| 252 |
|
| 253 |
emit(0.9, "Neo mốc thời gian & trộn nền")
|
| 254 |
voice_track = mux.assemble_voice(placed, total, os.path.join(work, "voice.wav"))
|
| 255 |
-
final_audio = mux.mix_over_bg(voice_track,
|
| 256 |
-
os.path.join(work, "final_audio.wav")
|
|
|
|
| 257 |
|
| 258 |
emit(0.96, "Ghép vào video")
|
| 259 |
video_out = mux.mux_to_video(
|
| 260 |
video, final_audio,
|
| 261 |
-
os.path.join(out_dir, f"video_translated_{
|
| 262 |
|
| 263 |
emit(1.0, "Hoàn tất")
|
| 264 |
return DubResult(video_out=video_out, audio_out=final_audio, srt_out=srt_out,
|
| 265 |
-
language=language, n_lines=n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import json
|
| 11 |
import os
|
| 12 |
from dataclasses import dataclass
|
| 13 |
from typing import Callable, Optional
|
| 14 |
|
| 15 |
+
from . import align, asr, audio, mux, translate, tts
|
| 16 |
from .probe_dur import video_duration
|
| 17 |
+
from ...config import settings
|
| 18 |
|
| 19 |
|
| 20 |
@dataclass
|
|
|
|
| 112 |
Đảm bảo người dùng luôn TẢI ĐƯỢC video, không bị treo/job lỗi.
|
| 113 |
"""
|
| 114 |
import shutil as _sh
|
| 115 |
+
import subprocess
|
| 116 |
os.makedirs(out_dir, exist_ok=True)
|
| 117 |
ffmpeg = _sh.which("ffmpeg") or "ffmpeg"
|
| 118 |
video_out = os.path.join(out_dir, f"video_translated_{opt.target_lang}.mp4")
|
| 119 |
try:
|
| 120 |
+
# Copy hình nếu đã H.264, chỉ encode khi nguồn là HEVC (xem mux.normalize_playable)
|
| 121 |
+
mux.normalize_playable(video, video_out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
except Exception:
|
| 123 |
_sh.copy(video, video_out)
|
| 124 |
audio_out = os.path.join(out_dir, "audio.m4a")
|
| 125 |
try:
|
|
|
|
| 126 |
subprocess.run([ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
|
| 127 |
"-i", video, "-vn", "-c:a", "aac", audio_out],
|
| 128 |
check=True, capture_output=True)
|
|
|
|
| 135 |
language="unknown", n_lines=0, note=note)
|
| 136 |
|
| 137 |
|
| 138 |
+
# ================================================================= 2 PHA =====
|
| 139 |
+
# Pha 1 (analyze): tách audio -> ASR -> VAD -> dịch => trả CÂU DỊCH + MỐC cho
|
| 140 |
+
# user XEM/SỬA trước khi đọc. Lưu "state.json" để nối pha 2.
|
| 141 |
+
# Pha 2 (synthesize): nhận câu (đã sửa) -> TTS + neo mốc + trộn nền -> video.
|
| 142 |
+
# run_dub() = chạy liền 2 pha (giữ đường một-phát cũ).
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@dataclass
|
| 146 |
+
class EditableLine:
|
| 147 |
+
start: float
|
| 148 |
+
end: float
|
| 149 |
+
src: str # câu GỐC (tham khảo, không sửa)
|
| 150 |
+
text: str # bản DỊCH (user sửa được)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@dataclass
|
| 154 |
+
class AnalyzeResult:
|
| 155 |
+
state_path: str # đường dẫn state.json để gọi pha 2 (rỗng nếu không cần)
|
| 156 |
+
language: str
|
| 157 |
+
note: str
|
| 158 |
+
same_language: bool # True -> đã ra video luôn, khỏi TTS
|
| 159 |
+
lines: list # list[EditableLine]
|
| 160 |
+
video_out: str = ""
|
| 161 |
+
audio_out: str = ""
|
| 162 |
+
srt_out: str = ""
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _save_state(out_dir: str, data: dict) -> str:
|
| 166 |
+
path = os.path.join(out_dir, "state.json")
|
| 167 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 168 |
+
json.dump(data, f, ensure_ascii=False)
|
| 169 |
+
return path
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _load_state(path: str) -> dict:
|
| 173 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 174 |
+
return json.load(f)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ----------------------------------------------------------------- PHA 1 -----
|
| 178 |
+
|
| 179 |
+
def analyze(video: str, out_dir: str, opt: DubOptions,
|
| 180 |
+
progress: Callable[[float, str], None] | None = None) -> AnalyzeResult:
|
| 181 |
+
"""Bọc pha 1: lỗi -> trả video gốc (passthrough) để FE vẫn có cái hiển thị."""
|
| 182 |
try:
|
| 183 |
+
return _analyze_full(video, out_dir, opt, progress)
|
| 184 |
except Exception as e:
|
| 185 |
import traceback
|
| 186 |
+
traceback.print_exc()
|
| 187 |
if progress:
|
| 188 |
progress(1.0, "Dùng video gốc (xem ghi chú lỗi)")
|
| 189 |
+
pr = _passthrough(video, out_dir, opt,
|
| 190 |
+
note=f"Chưa xử lý để sửa được nên trả video gốc: {e}")
|
| 191 |
+
return AnalyzeResult(state_path="", language=pr.language, note=pr.note,
|
| 192 |
+
same_language=False, lines=[], video_out=pr.video_out,
|
| 193 |
+
audio_out=pr.audio_out, srt_out=pr.srt_out)
|
| 194 |
|
| 195 |
|
| 196 |
+
def _analyze_full(video: str, out_dir: str, opt: DubOptions,
|
| 197 |
+
progress: Callable[[float, str], None] | None = None) -> AnalyzeResult:
|
| 198 |
def emit(p, m):
|
| 199 |
if progress:
|
| 200 |
progress(p, m)
|
|
|
|
| 203 |
os.makedirs(work, exist_ok=True)
|
| 204 |
total = video_duration(video)
|
| 205 |
|
| 206 |
+
emit(0.08, "Tách audio & xử lý nền nhạc")
|
| 207 |
bundle = audio.build_audio(video, work, opt.bg_mode, opt.demucs_model)
|
| 208 |
|
| 209 |
+
emit(0.4, "Nhận dạng lời thoại (Whisper)")
|
| 210 |
try:
|
| 211 |
segments, language = asr.transcribe(bundle.asr_wav)
|
| 212 |
except Exception as e:
|
| 213 |
raise RuntimeError(f"[Bước ASR/Groq Whisper] {e}") from e
|
| 214 |
|
| 215 |
+
# Hít mốc thời gian về đúng chỗ có GIỌNG THẬT (chỉ khi có giọng đã tách sạch)
|
| 216 |
+
if settings.VAD_SNAP and bundle.voice_is_clean and segments:
|
| 217 |
+
try:
|
| 218 |
+
segments = align.snap_segments(
|
| 219 |
+
segments, bundle.voice_wav, silence_db=settings.VAD_SILENCE_DB)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
import sys
|
| 222 |
+
print(f"[pipeline] VAD snap bỏ qua: {e}", file=sys.stderr)
|
| 223 |
+
|
| 224 |
# ── Nếu ngôn ngữ gốc TRÙNG ngôn ngữ đích → giữ nguyên video gốc ──
|
| 225 |
_lang_map = {"vi": "vi", "vie": "vi", "english": "en", "en": "en",
|
| 226 |
"filipino": "fil", "fil": "fil", "tagalog": "fil"}
|
| 227 |
_detected = _lang_map.get(language.lower().strip(), language.lower().strip())
|
| 228 |
_target = _lang_map.get(opt.target_lang.lower().strip(), opt.target_lang.lower().strip())
|
| 229 |
if _detected and _detected == _target:
|
| 230 |
+
emit(0.85, f"Video đã là ngôn ngữ {opt.target_lang} — không cần dịch")
|
| 231 |
+
# Vẫn xuất bản video "giữ nguyên" sẵn để dùng nếu user chọn không đọc lại.
|
| 232 |
import shutil as _sh2
|
| 233 |
video_out = os.path.join(out_dir, f"video_translated_{opt.target_lang}.mp4")
|
| 234 |
try:
|
| 235 |
+
# Copy hình nếu đã H.264 (không re-encode), chỉ encode khi nguồn là HEVC.
|
| 236 |
+
mux.normalize_playable(video, video_out)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
except Exception:
|
| 238 |
_sh2.copy(video, video_out)
|
| 239 |
+
# KHÔNG dịch (giữ nguyên lời gốc) nhưng VẪN trả transcript + mốc để user xem/sửa.
|
| 240 |
+
lines = [EditableLine(start=float(s.start), end=float(s.end),
|
| 241 |
+
src=s.text, text=s.text) for s in segments]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
srt_out = os.path.join(out_dir, "subtitle.srt")
|
| 243 |
with open(srt_out, "w", encoding="utf-8") as f:
|
| 244 |
+
for i, ln in enumerate(lines, 1):
|
| 245 |
+
f.write(f"{i}\n{_srt_time(ln.start)} --> {_srt_time(ln.end)}\n{ln.text}\n\n")
|
| 246 |
+
state = {
|
| 247 |
+
"video": video, "out_dir": out_dir, "work": work,
|
| 248 |
+
"total_dur": total, "language": language,
|
| 249 |
+
"bg_wav": bundle.bg_wav, "voice_wav": bundle.voice_wav,
|
| 250 |
+
"voice_is_clean": bundle.voice_is_clean,
|
| 251 |
+
"target_lang": opt.target_lang, "bg_mode": opt.bg_mode,
|
| 252 |
+
"lines": [{"start": l.start, "end": l.end, "src": l.src, "text": l.text}
|
| 253 |
+
for l in lines],
|
| 254 |
+
}
|
| 255 |
+
state_path = _save_state(out_dir, state)
|
| 256 |
+
emit(1.0, "Đã có lời gốc — chờ bạn kiểm/sửa")
|
| 257 |
+
return AnalyzeResult(
|
| 258 |
+
state_path=state_path, language=language,
|
| 259 |
+
note=f"Video đã là tiếng {_detected.upper()} — có thể giữ nguyên hoặc sửa lời để đọc lại.",
|
| 260 |
+
same_language=True, lines=lines, video_out=video_out,
|
| 261 |
+
audio_out=video_out, srt_out=srt_out)
|
| 262 |
+
|
| 263 |
+
emit(0.6, f"Dịch sang {opt.target_lang} (vừa khít thời lượng)")
|
| 264 |
try:
|
| 265 |
translated = translate.translate_all(segments, opt.target_lang)
|
| 266 |
except Exception as e:
|
| 267 |
raise RuntimeError(f"[Bước Dịch/Gemini] {e}") from e
|
| 268 |
+
|
| 269 |
+
# Ghép câu dịch + câu gốc (tham khảo) theo thứ tự
|
| 270 |
+
lines: list[EditableLine] = []
|
| 271 |
+
for i, tl in enumerate(translated):
|
| 272 |
+
src = segments[i].text if i < len(segments) else ""
|
| 273 |
+
lines.append(EditableLine(start=float(tl.start), end=float(tl.end),
|
| 274 |
+
src=src, text=tl.text))
|
| 275 |
+
|
| 276 |
srt_out = write_srt(translated, os.path.join(out_dir, "subtitle.srt"))
|
| 277 |
|
| 278 |
+
# Lưu state để pha 2 (TTS) nối tiếp mà không phải tách/ASR/dịch lại
|
| 279 |
+
state = {
|
| 280 |
+
"video": video, "out_dir": out_dir, "work": work,
|
| 281 |
+
"total_dur": total, "language": language,
|
| 282 |
+
"bg_wav": bundle.bg_wav, "voice_wav": bundle.voice_wav,
|
| 283 |
+
"voice_is_clean": bundle.voice_is_clean,
|
| 284 |
+
"target_lang": opt.target_lang, "bg_mode": opt.bg_mode,
|
| 285 |
+
"lines": [{"start": l.start, "end": l.end, "src": l.src, "text": l.text}
|
| 286 |
+
for l in lines],
|
| 287 |
+
}
|
| 288 |
+
state_path = _save_state(out_dir, state)
|
| 289 |
+
|
| 290 |
+
emit(1.0, "Đã dịch xong — chờ bạn kiểm/sửa")
|
| 291 |
+
return AnalyzeResult(state_path=state_path, language=language, note="",
|
| 292 |
+
same_language=False, lines=lines, srt_out=srt_out)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ----------------------------------------------------------------- PHA 2 -----
|
| 296 |
+
|
| 297 |
+
def synthesize(state_path: str, lines: list[dict], opt: DubOptions,
|
| 298 |
+
progress: Callable[[float, str], None] | None = None) -> DubResult:
|
| 299 |
+
"""Bọc pha 2: lỗi -> trả video gốc từ state để không treo job."""
|
| 300 |
+
try:
|
| 301 |
+
return _synthesize_full(state_path, lines, opt, progress)
|
| 302 |
+
except Exception as e:
|
| 303 |
+
import traceback
|
| 304 |
+
traceback.print_exc()
|
| 305 |
+
try:
|
| 306 |
+
st = _load_state(state_path)
|
| 307 |
+
return _passthrough(st["video"], st["out_dir"], opt,
|
| 308 |
+
note=f"Không tổng hợp giọng được nên trả video gốc: {e}")
|
| 309 |
+
except Exception:
|
| 310 |
+
raise
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _synthesize_full(state_path: str, lines: list[dict], opt: DubOptions,
|
| 314 |
+
progress: Callable[[float, str], None] | None = None) -> DubResult:
|
| 315 |
+
def emit(p, m):
|
| 316 |
+
if progress:
|
| 317 |
+
progress(p, m)
|
| 318 |
+
|
| 319 |
+
st = _load_state(state_path)
|
| 320 |
+
out_dir = st["out_dir"]; work = st["work"]; total = float(st["total_dur"])
|
| 321 |
+
target = st.get("target_lang", opt.target_lang)
|
| 322 |
+
bg_wav = st["bg_wav"]; voice_wav = st["voice_wav"]; video = st["video"]
|
| 323 |
+
|
| 324 |
+
# Chuẩn hoá câu người dùng gửi lên (đã sửa text/mốc) -> sắp theo start
|
| 325 |
+
norm = []
|
| 326 |
+
for ln in (lines or []):
|
| 327 |
+
try:
|
| 328 |
+
s = float(ln.get("start", 0.0)); e = float(ln.get("end", s))
|
| 329 |
+
t = (ln.get("text") or "").strip()
|
| 330 |
+
except Exception:
|
| 331 |
+
continue
|
| 332 |
+
if e < s:
|
| 333 |
+
e = s
|
| 334 |
+
norm.append({"start": max(0.0, s), "end": e, "text": t})
|
| 335 |
+
norm.sort(key=lambda x: x["start"])
|
| 336 |
+
if not norm: # không có câu -> lấy từ state
|
| 337 |
+
norm = [{"start": l["start"], "end": l["end"], "text": l["text"]}
|
| 338 |
+
for l in st.get("lines", [])]
|
| 339 |
+
|
| 340 |
+
# SRT theo bản đã sửa
|
| 341 |
+
srt_out = os.path.join(out_dir, "subtitle.srt")
|
| 342 |
+
with open(srt_out, "w", encoding="utf-8") as f:
|
| 343 |
+
for i, ln in enumerate(norm, 1):
|
| 344 |
+
f.write(f"{i}\n{_srt_time(ln['start'])} --> {_srt_time(ln['end'])}\n{ln['text']}\n\n")
|
| 345 |
+
|
| 346 |
+
# Mẫu clone (cắt từ giọng đã tách sạch nếu voice_source=video)
|
| 347 |
speaker_wav = opt.speaker_wav
|
| 348 |
+
if opt.do_clone and opt.voice_source == "video" and norm:
|
| 349 |
+
emit(0.08, "Cắt mẫu giọng để clone")
|
| 350 |
speaker_wav = audio.cut_clone_sample(
|
| 351 |
+
voice_wav, work, start=max(0.0, norm[0]["start"]), length=15.0)
|
| 352 |
|
| 353 |
+
emit(0.15, "Tổng hợp giọng đọc (TTS)")
|
| 354 |
vcfg = tts.VoiceConfig(
|
| 355 |
+
target_lang=target, voice_source=opt.voice_source,
|
| 356 |
speaker_wav=speaker_wav, preset=opt.preset, style=opt.style,
|
| 357 |
engine=opt.engine, temperature=opt.temperature, top_k=opt.top_k,
|
| 358 |
repetition_penalty=opt.repetition_penalty)
|
|
|
|
| 363 |
|
| 364 |
placed: list[mux.PlacedLine] = []
|
| 365 |
tts_dir = os.path.join(work, "tts"); os.makedirs(tts_dir, exist_ok=True)
|
| 366 |
+
n = len(norm)
|
| 367 |
+
raws = [os.path.join(tts_dir, f"line_{i:04d}.wav") for i in range(n)]
|
| 368 |
+
|
| 369 |
+
# ── Sinh giọng từng câu ──
|
| 370 |
+
# Edge TTS (en/fil) là request MẠNG độc lập -> chạy SONG SONG (nhanh gấp nhiều
|
| 371 |
+
# lần cho video nhiều câu). Model LOCAL (VieNeu/XTTS) tính trên CPU đã ăn hết
|
| 372 |
+
# nhân sẵn -> chạy song song không lợi mà dễ tràn RAM, nên giữ TUẦN TỰ.
|
| 373 |
+
engine_name = tts.pick_engine(vcfg)
|
| 374 |
+
if engine_name == "edge" and n > 1:
|
| 375 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 376 |
+
workers = min(8, n)
|
| 377 |
+
done = 0
|
| 378 |
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 379 |
+
futs = {ex.submit(tts.synth_line, engine, norm[i]["text"], raws[i], vcfg): i
|
| 380 |
+
for i in range(n)}
|
| 381 |
+
for fut in as_completed(futs):
|
| 382 |
+
fut.result() # synth_line tự nuốt lỗi -> không ném
|
| 383 |
+
done += 1
|
| 384 |
+
emit(0.15 + 0.6 * done / max(1, n), f"TTS câu {done}/{n}")
|
| 385 |
+
else:
|
| 386 |
+
for i in range(n):
|
| 387 |
+
tts.synth_line(engine, norm[i]["text"], raws[i], vcfg)
|
| 388 |
+
emit(0.15 + 0.6 * (i + 1) / max(1, n), f"TTS câu {i+1}/{n}")
|
| 389 |
+
|
| 390 |
+
# ── Ép mỗi câu vào khe thời gian & neo mốc (theo đúng thứ tự) ──
|
| 391 |
+
for i, ln in enumerate(norm):
|
| 392 |
+
own = max(0.3, ln["end"] - ln["start"])
|
| 393 |
+
next_start = norm[i + 1]["start"] if i + 1 < n else total
|
| 394 |
+
slot = max(0.3, min(own, next_start - ln["start"]))
|
| 395 |
fitted = os.path.join(tts_dir, f"fit_{i:04d}.wav")
|
| 396 |
+
mux.fit_to_slot(raws[i], slot, fitted)
|
| 397 |
+
placed.append(mux.PlacedLine(start=ln["start"], wav=fitted))
|
| 398 |
+
emit(0.8, "Ghép các câu đã đọc")
|
| 399 |
|
| 400 |
emit(0.9, "Neo mốc thời gian & trộn nền")
|
| 401 |
voice_track = mux.assemble_voice(placed, total, os.path.join(work, "voice.wav"))
|
| 402 |
+
final_audio = mux.mix_over_bg(voice_track, bg_wav,
|
| 403 |
+
os.path.join(work, "final_audio.wav"),
|
| 404 |
+
sidechain=settings.BG_SIDECHAIN)
|
| 405 |
|
| 406 |
emit(0.96, "Ghép vào video")
|
| 407 |
video_out = mux.mux_to_video(
|
| 408 |
video, final_audio,
|
| 409 |
+
os.path.join(out_dir, f"video_translated_{target}.mp4"))
|
| 410 |
|
| 411 |
emit(1.0, "Hoàn tất")
|
| 412 |
return DubResult(video_out=video_out, audio_out=final_audio, srt_out=srt_out,
|
| 413 |
+
language=st.get("language", ""), n_lines=n)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def run_dub(video: str, out_dir: str, opt: DubOptions,
|
| 417 |
+
progress: Callable[[float, str], None] | None = None) -> DubResult:
|
| 418 |
+
"""Chạy LIỀN 2 pha (đường một-phát cũ, không có bước sửa tay).
|
| 419 |
+
|
| 420 |
+
Nếu BẤT KỲ bước nào lỗi -> _passthrough để người dùng vẫn tải được video.
|
| 421 |
+
"""
|
| 422 |
+
ar = analyze(video, out_dir, opt, progress)
|
| 423 |
+
if ar.same_language or not ar.state_path:
|
| 424 |
+
# cùng ngôn ngữ (đã có video) HOẶC analyze đã passthrough
|
| 425 |
+
return DubResult(video_out=ar.video_out, audio_out=ar.audio_out or ar.video_out,
|
| 426 |
+
srt_out=ar.srt_out, language=ar.language,
|
| 427 |
+
n_lines=len(ar.lines), note=ar.note)
|
| 428 |
+
lines = [{"start": l.start, "end": l.end, "text": l.text} for l in ar.lines]
|
| 429 |
+
return synthesize(ar.state_path, lines, opt, progress)
|
backend/app/services/dubber/translate.py
CHANGED
|
@@ -53,12 +53,34 @@ def _prompt(seg: Segment, target_lang: str, budget: int) -> str:
|
|
| 53 |
|
| 54 |
|
| 55 |
def _gemini(prompt: str) -> str:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def _groq_llm(prompt: str) -> str:
|
|
@@ -119,32 +141,59 @@ def _parse_batch(text: str) -> dict[int, str]:
|
|
| 119 |
return out
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def translate_all(segs: list[Segment], target_lang: str) -> list[TranslatedLine]:
|
| 123 |
if target_lang not in LANG_NAME:
|
| 124 |
raise ValueError(f"Ngôn ngữ đích chưa hỗ trợ: {target_lang}")
|
| 125 |
if not segs:
|
| 126 |
return []
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
# 1)
|
| 129 |
try:
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
parsed = _parse_batch(resp)
|
| 136 |
-
if all(i in parsed for i in range(1, len(segs) + 1)):
|
| 137 |
-
return [TranslatedLine(s.start, s.end, s.text,
|
| 138 |
-
parsed.get(i + 1) or s.text,
|
| 139 |
-
char_budget(s, target_lang))
|
| 140 |
-
for i, s in enumerate(segs)]
|
| 141 |
except Exception:
|
| 142 |
pass
|
| 143 |
|
| 144 |
-
# 2)
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def _gemini(prompt: str) -> str:
|
| 56 |
+
"""Gọi Gemini qua REST v1beta, TẮT 'thinking' (thinkingBudget=0).
|
| 57 |
+
|
| 58 |
+
Vì sao REST: gemini-2.5-flash bật thinking MẶC ĐỊNH -> mỗi lần dịch 6-8s (batch
|
| 59 |
+
cả video có thể 40s). Dịch không cần suy luận sâu; tắt thinking nhanh ~7x mà chất
|
| 60 |
+
lượng như nhau. SDK cũ `google.generativeai` không cho set thinkingConfig, nên gọi
|
| 61 |
+
thẳng REST (chỉ cần `requests`). Model nào không nhận config thì tự bỏ, gọi lại.
|
| 62 |
+
"""
|
| 63 |
+
import requests # có sẵn trong requirements
|
| 64 |
+
|
| 65 |
+
model = settings.GEMINI_MODEL
|
| 66 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
| 67 |
+
base = {"contents": [{"parts": [{"text": prompt}]}]}
|
| 68 |
+
for gen_cfg in ({"thinkingConfig": {"thinkingBudget": 0}}, None):
|
| 69 |
+
body = dict(base)
|
| 70 |
+
if gen_cfg:
|
| 71 |
+
body["generationConfig"] = gen_cfg
|
| 72 |
+
r = requests.post(url, params={"key": settings.GOOGLE_API_KEY},
|
| 73 |
+
json=body, timeout=60)
|
| 74 |
+
if r.status_code == 400 and gen_cfg:
|
| 75 |
+
continue # model không hỗ trợ thinkingConfig -> thử lại
|
| 76 |
+
r.raise_for_status()
|
| 77 |
+
data = r.json()
|
| 78 |
+
cands = data.get("candidates") or []
|
| 79 |
+
if not cands:
|
| 80 |
+
raise RuntimeError(f"Gemini không trả nội dung: {str(data)[:200]}")
|
| 81 |
+
parts = (cands[0].get("content") or {}).get("parts") or []
|
| 82 |
+
return "".join(p.get("text", "") for p in parts).strip()
|
| 83 |
+
return ""
|
| 84 |
|
| 85 |
|
| 86 |
def _groq_llm(prompt: str) -> str:
|
|
|
|
| 141 |
return out
|
| 142 |
|
| 143 |
|
| 144 |
+
def _run_batch(segs: list[Segment], target_lang: str) -> dict[int, str]:
|
| 145 |
+
"""Gọi DỊCH GỘP 1 lần cho danh sách câu -> {1-based index: bản dịch}.
|
| 146 |
+
|
| 147 |
+
Ưu tiên map theo SỐ THỨ TỰ model trả; nếu model quên đánh số nhưng số DÒNG
|
| 148 |
+
khớp số câu thì map theo VỊ TRÍ (đỡ phải rơi về dịch từng câu).
|
| 149 |
+
"""
|
| 150 |
+
prompt = _batch_prompt(segs, target_lang)
|
| 151 |
+
try:
|
| 152 |
+
resp = _gemini(prompt)
|
| 153 |
+
except Exception:
|
| 154 |
+
resp = _groq_llm(prompt) # tự động dùng dự phòng Groq
|
| 155 |
+
parsed = _parse_batch(resp)
|
| 156 |
+
if len(parsed) < len(segs): # thiếu số -> thử map theo vị trí
|
| 157 |
+
rows = [r.strip() for r in (resp or "").splitlines() if r.strip()]
|
| 158 |
+
if len(rows) == len(segs):
|
| 159 |
+
parsed = {}
|
| 160 |
+
for i, r in enumerate(rows, 1):
|
| 161 |
+
m = re.match(r"\s*\d+[.)]\s*(.+)", r)
|
| 162 |
+
parsed[i] = (m.group(1).strip() if m else r)
|
| 163 |
+
return parsed
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def translate_all(segs: list[Segment], target_lang: str) -> list[TranslatedLine]:
|
| 167 |
if target_lang not in LANG_NAME:
|
| 168 |
raise ValueError(f"Ngôn ngữ đích chưa hỗ trợ: {target_lang}")
|
| 169 |
if not segs:
|
| 170 |
return []
|
| 171 |
+
n = len(segs)
|
| 172 |
+
results: dict[int, str] = {} # 0-based index -> bản dịch
|
| 173 |
|
| 174 |
+
# 1) GỘP 1 LẦN cho TẤT CẢ câu (không gọi từng câu).
|
| 175 |
try:
|
| 176 |
+
parsed = _run_batch(segs, target_lang)
|
| 177 |
+
for i in range(1, n + 1):
|
| 178 |
+
t = (parsed.get(i) or "").strip()
|
| 179 |
+
if t:
|
| 180 |
+
results[i - 1] = t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
except Exception:
|
| 182 |
pass
|
| 183 |
|
| 184 |
+
# 2) Câu nào còn THIẾU -> GỘP THÊM 1 LẦN chỉ cho các câu thiếu (vẫn 1 request).
|
| 185 |
+
missing = [i for i in range(n) if i not in results]
|
| 186 |
+
if missing:
|
| 187 |
+
try:
|
| 188 |
+
parsed2 = _run_batch([segs[i] for i in missing], target_lang)
|
| 189 |
+
for k, gi in enumerate(missing, 1):
|
| 190 |
+
t = (parsed2.get(k) or "").strip()
|
| 191 |
+
if t:
|
| 192 |
+
results[gi] = t
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
# 3) Vẫn thiếu -> giữ nguyên lời gốc (KHÔNG gọi thêm request nào).
|
| 197 |
+
return [TranslatedLine(s.start, s.end, s.text,
|
| 198 |
+
results.get(i, s.text), char_budget(s, target_lang))
|
| 199 |
+
for i, s in enumerate(segs)]
|
backend/app/services/dubber/tts.py
CHANGED
|
@@ -19,6 +19,37 @@ from typing import Optional
|
|
| 19 |
|
| 20 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
@dataclass
|
| 24 |
class VoiceConfig:
|
|
@@ -68,9 +99,11 @@ class VieNeuEngine(TTSEngine): # pragma: no cover - cần model local
|
|
| 68 |
kw["top_k"] = cfg.top_k
|
| 69 |
if cfg.repetition_penalty is not None:
|
| 70 |
kw["repetition_penalty"] = cfg.repetition_penalty
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
audio = self.model.infer(text, voice=cfg.preset or None, **kw)
|
| 75 |
self.model.save(audio, out_wav) # ghi wav ở sample_rate của model
|
| 76 |
return out_wav
|
|
@@ -220,6 +253,8 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
|
|
| 220 |
"""Trả danh sách giọng cho ngôn ngữ. Mặc định KHÔNG nạp model (tránh treo request);
|
| 221 |
chỉ lấy động khi engine đã được cache sẵn (đã từng dịch) hoặc allow_load=True."""
|
| 222 |
name = pick_engine(VoiceConfig(target_lang=lang))
|
|
|
|
|
|
|
| 223 |
# Edge: danh sách giọng theo NGÔN NGỮ (engine.list_voices() không biết lang),
|
| 224 |
# nên lấy trực tiếp từ bảng giọng Edge để đúng nam/nữ từng thứ tiếng.
|
| 225 |
if name != "edge" and (name in _INSTANCES or allow_load):
|
|
@@ -227,11 +262,11 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
|
|
| 227 |
eng = load_engine(VoiceConfig(target_lang=lang))
|
| 228 |
vs = eng.list_voices()
|
| 229 |
if vs:
|
| 230 |
-
return [{"id": vid, "label": label} for (label, vid) in vs]
|
| 231 |
except Exception:
|
| 232 |
pass
|
| 233 |
-
return [{"id": vid, "label": label}
|
| 234 |
-
|
| 235 |
|
| 236 |
|
| 237 |
def synth_line(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
|
|
|
|
| 19 |
|
| 20 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 21 |
|
| 22 |
+
# --------------------------------------------------------------- clone presets ---
|
| 23 |
+
# Giọng "có sẵn" nhưng CLONE TỪ FILE MẪU (khác preset built-in của VieNeu).
|
| 24 |
+
# Thả file wav/mp3 mẫu vào backend/app/assets/voices/vi/ là mục tự hiện trong danh sách.
|
| 25 |
+
_ASSET_VOICE_DIR = os.path.normpath(
|
| 26 |
+
os.path.join(os.path.dirname(__file__), "..", "..", "assets", "voices"))
|
| 27 |
+
|
| 28 |
+
# id -> (label, tên file trong <assets>/voices/<lang>/)
|
| 29 |
+
_CLONE_PRESETS: dict[str, dict[str, tuple[str, str]]] = {
|
| 30 |
+
"vi": {
|
| 31 |
+
"adam": ("Adam — nam, giọng trầm ấm (clone)", "adam.wav"),
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _clone_preset_path(lang: str, preset_id: str) -> str | None:
|
| 37 |
+
"""Trả đường dẫn file mẫu nếu preset_id là giọng clone-preset và file TỒN TẠI."""
|
| 38 |
+
entry = _CLONE_PRESETS.get(lang, {}).get((preset_id or "").strip())
|
| 39 |
+
if not entry:
|
| 40 |
+
return None
|
| 41 |
+
path = os.path.join(_ASSET_VOICE_DIR, lang, entry[1])
|
| 42 |
+
return path if os.path.exists(path) else None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _clone_preset_voices(lang: str) -> list[dict]:
|
| 46 |
+
"""Danh sách giọng clone-preset ĐÃ CÓ FILE cho ngôn ngữ (để ghép vào /voices)."""
|
| 47 |
+
out = []
|
| 48 |
+
for pid, (label, fname) in _CLONE_PRESETS.get(lang, {}).items():
|
| 49 |
+
if os.path.exists(os.path.join(_ASSET_VOICE_DIR, lang, fname)):
|
| 50 |
+
out.append({"id": pid, "label": label})
|
| 51 |
+
return out
|
| 52 |
+
|
| 53 |
|
| 54 |
@dataclass
|
| 55 |
class VoiceConfig:
|
|
|
|
| 99 |
kw["top_k"] = cfg.top_k
|
| 100 |
if cfg.repetition_penalty is not None:
|
| 101 |
kw["repetition_penalty"] = cfg.repetition_penalty
|
| 102 |
+
# Ưu tiên mẫu upload/video; nếu preset là giọng clone-preset (vd Adam) -> clone file mẫu.
|
| 103 |
+
ref = cfg.speaker_wav or _clone_preset_path(cfg.target_lang, cfg.preset)
|
| 104 |
+
if ref: # clone từ mẫu
|
| 105 |
+
audio = self.model.infer(text, ref_audio=ref, **kw)
|
| 106 |
+
else: # giọng preset built-in (voice nhận tên str)
|
| 107 |
audio = self.model.infer(text, voice=cfg.preset or None, **kw)
|
| 108 |
self.model.save(audio, out_wav) # ghi wav ở sample_rate của model
|
| 109 |
return out_wav
|
|
|
|
| 253 |
"""Trả danh sách giọng cho ngôn ngữ. Mặc định KHÔNG nạp model (tránh treo request);
|
| 254 |
chỉ lấy động khi engine đã được cache sẵn (đã từng dịch) hoặc allow_load=True."""
|
| 255 |
name = pick_engine(VoiceConfig(target_lang=lang))
|
| 256 |
+
# Giọng clone-preset (thả file mẫu vào assets) đặt LÊN ĐẦU danh sách.
|
| 257 |
+
clone = _clone_preset_voices(lang)
|
| 258 |
# Edge: danh sách giọng theo NGÔN NGỮ (engine.list_voices() không biết lang),
|
| 259 |
# nên lấy trực tiếp từ bảng giọng Edge để đúng nam/nữ từng thứ tiếng.
|
| 260 |
if name != "edge" and (name in _INSTANCES or allow_load):
|
|
|
|
| 262 |
eng = load_engine(VoiceConfig(target_lang=lang))
|
| 263 |
vs = eng.list_voices()
|
| 264 |
if vs:
|
| 265 |
+
return clone + [{"id": vid, "label": label} for (label, vid) in vs]
|
| 266 |
except Exception:
|
| 267 |
pass
|
| 268 |
+
return clone + [{"id": vid, "label": label}
|
| 269 |
+
for (label, vid) in _FALLBACK_VOICES.get(lang, [])]
|
| 270 |
|
| 271 |
|
| 272 |
def synth_line(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
|
backend/app/services/shuffler/engine.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Engine "Xào video" — tạo nhiều biến thể video bằng cách:
|
| 3 |
1) chọn 1 clip cho mỗi KHÚC (tổ hợp Cartesian các pool clip), và
|
| 4 |
2) ĐẢO THỨ TỰ các khúc theo chế độ của TỪNG khúc (không còn cờ toàn cục).
|
| 5 |
|
|
@@ -14,21 +13,41 @@ Ví dụ với 5 khúc 1..5:
|
|
| 14 |
- lock 1 & 4, còn lại shuffle -> 1,_,_,4,_ + hoán vị {2,3,5} -> 1,5,2,4,3 ...
|
| 15 |
- lock 1, drop 4, còn lại shuffle -> 1 + hoán vị {2,3,5} -> 1,3,5,2 ...
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
|
|
|
| 23 |
from dataclasses import dataclass
|
| 24 |
from itertools import permutations, product
|
| 25 |
-
from math import factorial, prod
|
| 26 |
from typing import Iterable
|
| 27 |
|
| 28 |
import numpy as np
|
| 29 |
|
| 30 |
PAD = -9 # sentinel pad cho bằng độ dài, không bao giờ trùng clip thật
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@dataclass
|
| 34 |
class Segment:
|
|
@@ -101,6 +120,8 @@ def estimate(cfg: ShuffleConfig) -> ShuffleEstimate:
|
|
| 101 |
active = _active(cfg)
|
| 102 |
pools = [s.pool() for s in active]
|
| 103 |
selection_count = prod(len(p) for p in pools) if pools else 0
|
|
|
|
|
|
|
| 104 |
|
| 105 |
free = sum(0 if s.locked else 1 for s in active)
|
| 106 |
ordering_count = factorial(free) if free > 1 else 1
|
|
@@ -112,34 +133,43 @@ def estimate(cfg: ShuffleConfig) -> ShuffleEstimate:
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
-
# -------------------------------------------
|
| 116 |
-
|
| 117 |
-
def _iter_raw_sequences(cfg: ShuffleConfig) -> Iterable[list[int]]:
|
| 118 |
-
active = _active(cfg)
|
| 119 |
-
if not active:
|
| 120 |
-
return
|
| 121 |
-
pools = [s.pool() for s in active]
|
| 122 |
-
locked_flags = [s.locked for s in active]
|
| 123 |
-
orderings = _ordering_indices(locked_flags)
|
| 124 |
-
|
| 125 |
-
for selection in product(*pools):
|
| 126 |
-
for order in orderings:
|
| 127 |
-
seq = [selection[i] for i in order]
|
| 128 |
-
if seq:
|
| 129 |
-
yield seq
|
| 130 |
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
width = max(len(s) for s in seqs)
|
| 136 |
-
mat = np.full((len(seqs), width), PAD, dtype=np.int64)
|
| 137 |
-
for r, s in enumerate(seqs):
|
| 138 |
-
mat[r, : len(s)] = s
|
| 139 |
-
return mat
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
def similarity(a: list[int], b: list[int]) -> float:
|
|
|
|
| 143 |
width = max(len(a), len(b))
|
| 144 |
if width == 0:
|
| 145 |
return 1.0
|
|
@@ -151,54 +181,99 @@ def similarity(a: list[int], b: list[int]) -> float:
|
|
| 151 |
return float(eq.sum()) / width
|
| 152 |
|
| 153 |
|
| 154 |
-
def
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
return []
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
kept_rows = np.empty((0, width), dtype=np.int64)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
def generate(cfg: ShuffleConfig) -> list[list[int]]:
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
return []
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
mat = mat[perm]
|
| 188 |
-
keep = _greedy_dedup(mat, cfg.similarity_threshold)
|
| 189 |
-
result = [[int(c) for c in mat[k] if c != PAD] for k in keep]
|
| 190 |
else:
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
-
#
|
| 194 |
-
if
|
| 195 |
k = max(1, int(round(len(result) * cfg.random_ratio)))
|
| 196 |
-
|
| 197 |
-
|
|
|
|
| 198 |
|
| 199 |
-
#
|
| 200 |
-
if
|
| 201 |
-
|
| 202 |
-
result = [result[i] for i in sorted(idx.tolist())]
|
| 203 |
|
| 204 |
return result
|
|
|
|
| 1 |
+
"""Engine "Xào video" — tạo nhiều biến thể video bằng cách:
|
|
|
|
| 2 |
1) chọn 1 clip cho mỗi KHÚC (tổ hợp Cartesian các pool clip), và
|
| 3 |
2) ĐẢO THỨ TỰ các khúc theo chế độ của TỪNG khúc (không còn cờ toàn cục).
|
| 4 |
|
|
|
|
| 13 |
- lock 1 & 4, còn lại shuffle -> 1,_,_,4,_ + hoán vị {2,3,5} -> 1,5,2,4,3 ...
|
| 14 |
- lock 1, drop 4, còn lại shuffle -> 1 + hoán vị {2,3,5} -> 1,3,5,2 ...
|
| 15 |
|
| 16 |
+
THỨ TỰ SẮP XẾP là MỘT PHẦN của không gian tổ hợp: mỗi bộ clip + mỗi cách sắp xếp
|
| 17 |
+
= một biến thể riêng. Không gian = selection_count × ordering_count.
|
| 18 |
+
|
| 19 |
+
Tốc độ (bê từ project video_cook)
|
| 20 |
+
---------------------------------
|
| 21 |
+
Thay vì materialize TOÀN BỘ product() rồi lọc/lấy mẫu (bùng nổ bộ nhớ + treo khi
|
| 22 |
+
thả cả thư mục nhiều clip), engine LẤY MẪU NGẪU NHIÊN TRÊN KHÔNG GIAN CHỈ SỐ:
|
| 23 |
+
mỗi tổ hợp có một chỉ số duy nhất, giải mã tức thời qua ``_seq_at`` — không bao
|
| 24 |
+
giờ sinh hết. Lọc tương đồng dùng greedy + dừng sớm khi đã đủ ``cap`` biến thể.
|
| 25 |
+
|
| 26 |
+
Nếu KHÔNG bật lọc tương đồng (threshold>=1.0) và random_ratio>=1.0 thì engine trả
|
| 27 |
+
về TOÀN BỘ tổ hợp khi còn trong trần ``DEFAULT_CAP``; vượt trần thì lấy mẫu ngẫu
|
| 28 |
+
nhiên ``DEFAULT_CAP`` biến thể (không treo dù tổ hợp lên tới hàng tỉ).
|
| 29 |
"""
|
| 30 |
|
| 31 |
from __future__ import annotations
|
| 32 |
|
| 33 |
+
import random
|
| 34 |
from dataclasses import dataclass
|
| 35 |
from itertools import permutations, product
|
| 36 |
+
from math import ceil, factorial, prod
|
| 37 |
from typing import Iterable
|
| 38 |
|
| 39 |
import numpy as np
|
| 40 |
|
| 41 |
PAD = -9 # sentinel pad cho bằng độ dài, không bao giờ trùng clip thật
|
| 42 |
|
| 43 |
+
# Trần mặc định số biến thể sinh ra khi KHÔNG đặt max_outputs (tránh bùng nổ khi
|
| 44 |
+
# thả cả thư mục → tổ hợp khổng lồ). Là số dòng generate() trả về tối đa.
|
| 45 |
+
DEFAULT_CAP = 500
|
| 46 |
+
|
| 47 |
+
# Trần số ứng viên bốc ra để LỌC (tương đồng/ngẫu nhiên). Đủ lớn để lọc có ý
|
| 48 |
+
# nghĩa, đủ nhỏ để không treo dù không gian tổ hợp là hàng triệu/tỉ.
|
| 49 |
+
MATERIALIZE_CAP = 20000
|
| 50 |
+
|
| 51 |
|
| 52 |
@dataclass
|
| 53 |
class Segment:
|
|
|
|
| 120 |
active = _active(cfg)
|
| 121 |
pools = [s.pool() for s in active]
|
| 122 |
selection_count = prod(len(p) for p in pools) if pools else 0
|
| 123 |
+
if any(len(p) == 0 for p in pools):
|
| 124 |
+
selection_count = 0
|
| 125 |
|
| 126 |
free = sum(0 if s.locked else 1 for s in active)
|
| 127 |
ordering_count = factorial(free) if free > 1 else 1
|
|
|
|
| 133 |
)
|
| 134 |
|
| 135 |
|
| 136 |
+
# ------------------------------------------- giải mã tổ hợp theo chỉ số ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
class _Space:
|
| 139 |
+
"""Không gian tổ hợp có thể giải mã tức thời theo chỉ số (không sinh hết).
|
| 140 |
|
| 141 |
+
Chỉ số ``index`` mã hoá cả (bộ clip đã chọn, cách sắp xếp):
|
| 142 |
+
sel_idx, ord_idx = divmod(index, ord_total)
|
| 143 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
def __init__(self, cfg: ShuffleConfig):
|
| 146 |
+
active = _active(cfg)
|
| 147 |
+
self.pools = [s.pool() for s in active]
|
| 148 |
+
self.locked_flags = [s.locked for s in active]
|
| 149 |
+
self.orderings = _ordering_indices(self.locked_flags)
|
| 150 |
+
self.ord_total = len(self.orderings)
|
| 151 |
+
self.sel_total = prod(len(p) for p in self.pools) if self.pools else 0
|
| 152 |
+
if any(len(p) == 0 for p in self.pools):
|
| 153 |
+
self.sel_total = 0
|
| 154 |
+
self.total = self.sel_total * self.ord_total
|
| 155 |
+
self.width = len(active)
|
| 156 |
+
|
| 157 |
+
def seq_at(self, index: int) -> list[int]:
|
| 158 |
+
"""Giải mã biến thể thứ ``index`` — 1 phép chia/khúc, tức thời."""
|
| 159 |
+
sel_idx, ord_idx = divmod(index, self.ord_total)
|
| 160 |
+
sel: list[int] = []
|
| 161 |
+
for p in reversed(self.pools):
|
| 162 |
+
sel_idx, r = divmod(sel_idx, len(p))
|
| 163 |
+
sel.append(p[r])
|
| 164 |
+
sel.reverse()
|
| 165 |
+
order = self.orderings[ord_idx]
|
| 166 |
+
return [sel[i] for i in order]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ------------------------------------------------------- lọc tương đồng ---
|
| 170 |
|
| 171 |
def similarity(a: list[int], b: list[int]) -> float:
|
| 172 |
+
"""Tỉ lệ khúc trùng vị trí giữa 2 biến thể (0..1). Giữ để tương thích API."""
|
| 173 |
width = max(len(a), len(b))
|
| 174 |
if width == 0:
|
| 175 |
return 1.0
|
|
|
|
| 181 |
return float(eq.sum()) / width
|
| 182 |
|
| 183 |
|
| 184 |
+
def _dedup_take(rows: list[list[int]], threshold: float, cap: int) -> list[int]:
|
| 185 |
+
"""Greedy giữ-biến-thể-đầu, loại biến thể sau giống > ngưỡng, DỪNG SỚM khi đủ.
|
| 186 |
+
|
| 187 |
+
Trả về danh sách chỉ số (theo ``rows``) được giữ, tối đa ``cap`` phần tử.
|
| 188 |
+
Dừng sớm giúp khỏi chạy O(n²) khi ngưỡng lỏng (không loại được nhiều) mà chỉ
|
| 189 |
+
cần vài biến thể — điểm khác biệt tốc độ so với lọc toàn bộ rồi mới cắt.
|
| 190 |
+
"""
|
| 191 |
+
if not rows:
|
| 192 |
return []
|
| 193 |
+
width = len(rows[0])
|
| 194 |
+
thr = threshold * width # số khúc trùng tối đa được phép (dạng float)
|
| 195 |
+
kept: list[int] = []
|
| 196 |
kept_rows = np.empty((0, width), dtype=np.int64)
|
| 197 |
+
for i, row in enumerate(rows):
|
| 198 |
+
r = np.asarray(row, dtype=np.int64)
|
| 199 |
+
if kept_rows.shape[0]:
|
| 200 |
+
same = np.count_nonzero(kept_rows == r, axis=1)
|
| 201 |
+
if float(same.max()) >= thr: # giống 1 biến thể đã giữ >= ngưỡng
|
| 202 |
+
continue
|
| 203 |
+
kept.append(i)
|
| 204 |
+
kept_rows = np.vstack([kept_rows, r])
|
| 205 |
+
if len(kept) >= cap:
|
| 206 |
+
break
|
| 207 |
+
return kept
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ----------------------------------------------------------- generate ---
|
| 211 |
+
|
| 212 |
+
def _iter_raw_sequences(cfg: ShuffleConfig) -> Iterable[list[int]]:
|
| 213 |
+
"""Sinh LƯỜI toàn bộ biến thể thô (dùng cho bảng xem trước, có cắt trần ngoài)."""
|
| 214 |
+
space = _Space(cfg)
|
| 215 |
+
if not space.pools or space.total == 0:
|
| 216 |
+
return
|
| 217 |
+
for selection in product(*space.pools):
|
| 218 |
+
for order in space.orderings:
|
| 219 |
+
seq = [selection[i] for i in order]
|
| 220 |
+
if seq:
|
| 221 |
+
yield seq
|
| 222 |
|
| 223 |
|
| 224 |
def generate(cfg: ShuffleConfig) -> list[list[int]]:
|
| 225 |
+
space = _Space(cfg)
|
| 226 |
+
total = space.total
|
| 227 |
+
if total == 0:
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
rng = random.Random(cfg.seed)
|
| 231 |
+
|
| 232 |
+
sim_on = 0.0 < cfg.similarity_threshold < 1.0
|
| 233 |
+
rand_on = 0.0 < cfg.random_ratio < 1.0
|
| 234 |
+
cap = cfg.max_outputs if cfg.max_outputs is not None else DEFAULT_CAP
|
| 235 |
+
cap = max(0, cap)
|
| 236 |
+
if cap == 0:
|
| 237 |
return []
|
| 238 |
|
| 239 |
+
# ── Đường nhanh: KHÔNG lọc gì → lấy hết (nếu trong trần) hoặc lấy mẫu ─────
|
| 240 |
+
if not sim_on and not rand_on:
|
| 241 |
+
if total <= cap:
|
| 242 |
+
return [space.seq_at(i) for i in range(total)]
|
| 243 |
+
idx = rng.sample(range(total), cap)
|
| 244 |
+
return [space.seq_at(i) for i in idx]
|
| 245 |
+
|
| 246 |
+
# ── Có lọc: bốc một pool ứng viên (có trần) rồi lọc trên đó ───────────────
|
| 247 |
+
pool_size = min(total, MATERIALIZE_CAP)
|
| 248 |
+
if total <= pool_size:
|
| 249 |
+
indices = list(range(total))
|
| 250 |
+
rng.shuffle(indices) # xáo để lọc/lấy mẫu công bằng, tất định theo seed
|
| 251 |
+
else:
|
| 252 |
+
indices = rng.sample(range(total), pool_size)
|
| 253 |
+
|
| 254 |
+
# Cần bao nhiêu biến thể "sống sót" trước bước lấy ngẫu nhiên để vẫn đủ cap.
|
| 255 |
+
if rand_on:
|
| 256 |
+
want = min(len(indices), ceil(cap / cfg.random_ratio) + 1)
|
| 257 |
+
else:
|
| 258 |
+
want = cap
|
| 259 |
|
| 260 |
+
if sim_on:
|
| 261 |
+
rows = [space.seq_at(i) for i in indices]
|
| 262 |
+
keep = _dedup_take(rows, cfg.similarity_threshold, want)
|
| 263 |
+
result = [rows[k] for k in keep]
|
|
|
|
|
|
|
|
|
|
| 264 |
else:
|
| 265 |
+
take = min(len(indices), want)
|
| 266 |
+
result = [space.seq_at(indices[i]) for i in range(take)]
|
| 267 |
|
| 268 |
+
# Lấy ngẫu nhiên một phần theo tỉ lệ.
|
| 269 |
+
if rand_on and result:
|
| 270 |
k = max(1, int(round(len(result) * cfg.random_ratio)))
|
| 271 |
+
k = min(k, len(result))
|
| 272 |
+
sel = rng.sample(range(len(result)), k)
|
| 273 |
+
result = [result[i] for i in sorted(sel)]
|
| 274 |
|
| 275 |
+
# Trần cứng số lượng.
|
| 276 |
+
if len(result) > cap:
|
| 277 |
+
result = result[:cap]
|
|
|
|
| 278 |
|
| 279 |
return result
|
backend/app/services/shuffler/render.py
CHANGED
|
@@ -4,27 +4,136 @@
|
|
| 4 |
- Mỗi sequence (list clip_id) -> 1 video biến thể (ảnh, KHÔNG tiếng).
|
| 5 |
- Nếu có pool âm thanh: mỗi biến thể được gán 1 track (xoay vòng theo thứ tự),
|
| 6 |
ví dụ 3 âm thanh s1,s2,s3 -> biến thể 1->s1, 2->s2, 3->s3, 4->s1 ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
|
|
|
| 11 |
import os
|
| 12 |
import shutil
|
| 13 |
import subprocess
|
|
|
|
| 14 |
import zipfile
|
| 15 |
|
| 16 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def _concat_reencode(clip_paths: list[str], out_path: str,
|
| 20 |
w: int = 1080, h: int = 1920):
|
| 21 |
-
"""Ghép nhiều clip bằng concat filter (re-encode, video-only).
|
| 22 |
|
| 23 |
Chuẩn hoá MỌI clip về cùng khung WxH bằng scale + pad (giữ tỉ lệ gốc, chèn
|
| 24 |
-
nền đen phần thừa).
|
| 25 |
-
cùng width/height/SAR — clip khác kích thước sẽ làm concat lỗi (mọi biến thể
|
| 26 |
-
fail -> không ra kết quả). Mặc định khung dọc 1080x1920 (TikTok/Shorts);
|
| 27 |
-
xào video ngang thì đổi w=1920, h=1080.
|
| 28 |
"""
|
| 29 |
inputs = []
|
| 30 |
for p in clip_paths:
|
|
@@ -45,6 +154,52 @@ def _concat_reencode(clip_paths: list[str], out_path: str,
|
|
| 45 |
check=True, capture_output=True)
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def attach_audio(video_path: str, audio_path: str, out_path: str):
|
| 49 |
"""Lồng 1 track âm thanh vào video, GIỮ NGUYÊN độ dài video.
|
| 50 |
|
|
@@ -63,21 +218,17 @@ def attach_audio(video_path: str, audio_path: str, out_path: str):
|
|
| 63 |
|
| 64 |
def render_variations(sequences: list[list[int]], clip_map: dict[int, str],
|
| 65 |
out_dir: str, prefix: str = "Variation",
|
| 66 |
-
audio_paths: list[str] | None = None
|
|
|
|
| 67 |
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
| 68 |
results = []
|
| 69 |
for i, seq in enumerate(sequences, 1):
|
| 70 |
paths = [clip_map[c] for c in seq]
|
| 71 |
name = f"{prefix}_{i:02d}.mp4"
|
| 72 |
out = os.path.join(out_dir, name)
|
| 73 |
-
if audio_paths
|
| 74 |
-
|
| 75 |
-
_concat_reencode(paths, tmp)
|
| 76 |
-
track = audio_paths[(i - 1) % len(audio_paths)]
|
| 77 |
-
attach_audio(tmp, track, out)
|
| 78 |
-
os.remove(tmp)
|
| 79 |
-
else:
|
| 80 |
-
_concat_reencode(paths, out)
|
| 81 |
results.append({
|
| 82 |
"name": name, "path": out,
|
| 83 |
"sequence": seq, "size": os.path.getsize(out),
|
|
|
|
| 4 |
- Mỗi sequence (list clip_id) -> 1 video biến thể (ảnh, KHÔNG tiếng).
|
| 5 |
- Nếu có pool âm thanh: mỗi biến thể được gán 1 track (xoay vòng theo thứ tự),
|
| 6 |
ví dụ 3 âm thanh s1,s2,s3 -> biến thể 1->s1, 2->s2, 3->s3, 4->s1 ...
|
| 7 |
+
|
| 8 |
+
Tốc độ (bê "đường nhanh" từ project video_cook)
|
| 9 |
+
-----------------------------------------------
|
| 10 |
+
Ghép cũ re-encode LẠI mọi clip cho TỪNG biến thể → 1 clip nguồn dùng ở N biến thể
|
| 11 |
+
sẽ bị encode N lần (rất chậm). Đường nhanh:
|
| 12 |
+
|
| 13 |
+
1) CHUẨN HOÁ MỖI CLIP NGUỒN ĐÚNG 1 LẦN (``normalize_clip``) về cùng khung
|
| 14 |
+
WxH / fps / pixfmt / codec, cache theo (đường dẫn, độ phân giải). Nhiều biến
|
| 15 |
+
thể dùng chung cache — encode 1 lần thay vì N lần.
|
| 16 |
+
2) GHÉP bằng concat demuxer ``-c copy`` (``concat_copy``) — KHÔNG re-encode ở
|
| 17 |
+
bước ghép, chỉ nối luồng nên gần như tức thời.
|
| 18 |
+
|
| 19 |
+
Vì mọi clip đã chuẩn hoá về cùng tham số, concat copy nối được sạch. Có validate
|
| 20 |
+
output; nếu vì lý do nào đó copy hỏng thì tự fallback sang ghép re-encode.
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
+
import hashlib
|
| 26 |
+
import json
|
| 27 |
import os
|
| 28 |
import shutil
|
| 29 |
import subprocess
|
| 30 |
+
import threading
|
| 31 |
import zipfile
|
| 32 |
|
| 33 |
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
| 34 |
+
FFPROBE = shutil.which("ffprobe") or "ffprobe"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# --------------------------------------------------------------- validate ---
|
| 38 |
+
|
| 39 |
+
def _valid_output(path: str) -> bool:
|
| 40 |
+
"""True nếu file mở được: có stream video và duration > 0."""
|
| 41 |
+
if not os.path.isfile(path) or os.path.getsize(path) < 1024:
|
| 42 |
+
return False
|
| 43 |
+
try:
|
| 44 |
+
r = subprocess.run(
|
| 45 |
+
[FFPROBE, "-v", "error",
|
| 46 |
+
"-show_entries", "stream=codec_type",
|
| 47 |
+
"-show_entries", "format=duration",
|
| 48 |
+
"-of", "json", path],
|
| 49 |
+
capture_output=True, timeout=30)
|
| 50 |
+
data = json.loads(r.stdout or b"{}")
|
| 51 |
+
has_v = any(s.get("codec_type") == "video" for s in data.get("streams", []))
|
| 52 |
+
dur = float(data.get("format", {}).get("duration", 0) or 0)
|
| 53 |
+
return has_v and dur > 0.05
|
| 54 |
+
except Exception:
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --------------------------------------------- chuẩn hoá 1 lần / clip nguồn ---
|
| 59 |
+
|
| 60 |
+
# Khoá theo từng file cache để nhiều luồng render KHÔNG cùng encode 1 clip
|
| 61 |
+
# (tránh 2 ffmpeg cùng ghi 1 file → hỏng / lỗi khoá file trên Windows).
|
| 62 |
+
_norm_locks_guard = threading.Lock()
|
| 63 |
+
_norm_locks: dict[str, threading.Lock] = {}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _lock_for(key: str) -> threading.Lock:
|
| 67 |
+
with _norm_locks_guard:
|
| 68 |
+
lk = _norm_locks.get(key)
|
| 69 |
+
if lk is None:
|
| 70 |
+
lk = threading.Lock()
|
| 71 |
+
_norm_locks[key] = lk
|
| 72 |
+
return lk
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _norm_key(src: str, w: int, h: int, fps: int) -> str:
|
| 76 |
+
sig = f"{os.path.abspath(src)}|{os.path.getmtime(src) if os.path.exists(src) else 0}|{w}x{h}@{fps}"
|
| 77 |
+
return hashlib.md5(sig.encode("utf-8")).hexdigest()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def normalize_clip(src: str, norm_dir: str, w: int = 1080, h: int = 1920,
|
| 81 |
+
fps: int = 30) -> str:
|
| 82 |
+
"""Chuẩn hoá 1 clip nguồn về khung WxH/fps/yuv420p/libx264 (video-only), cache.
|
| 83 |
+
|
| 84 |
+
Chạy tối đa 1 lần/(clip, độ phân giải) nhờ cache + khoá theo key. Trả về đường
|
| 85 |
+
dẫn clip đã chuẩn hoá (dùng lại cho mọi biến thể chứa clip đó).
|
| 86 |
+
"""
|
| 87 |
+
os.makedirs(norm_dir, exist_ok=True)
|
| 88 |
+
key = _norm_key(src, w, h, fps)
|
| 89 |
+
cache = os.path.join(norm_dir, f"{key}.mp4")
|
| 90 |
+
if _valid_output(cache):
|
| 91 |
+
return cache
|
| 92 |
+
with _lock_for(cache):
|
| 93 |
+
if _valid_output(cache): # luồng khác có thể vừa encode xong
|
| 94 |
+
return cache
|
| 95 |
+
vf = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
| 96 |
+
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps},format=yuv420p")
|
| 97 |
+
subprocess.run(
|
| 98 |
+
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", src,
|
| 99 |
+
"-an", "-vf", vf,
|
| 100 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
| 101 |
+
"-video_track_timescale", "15360", cache],
|
| 102 |
+
check=True, capture_output=True)
|
| 103 |
+
return cache
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# -------------------------------------------------------- ghép nhanh (copy) ---
|
| 107 |
+
|
| 108 |
+
def concat_copy(norm_paths: list[str], out_path: str) -> None:
|
| 109 |
+
"""Nối các clip ĐÃ CHUẨN HOÁ bằng concat demuxer ``-c copy`` (không re-encode)."""
|
| 110 |
+
if len(norm_paths) == 1:
|
| 111 |
+
shutil.copy2(norm_paths[0], out_path)
|
| 112 |
+
return
|
| 113 |
+
list_file = out_path + ".txt"
|
| 114 |
+
with open(list_file, "w", encoding="utf-8") as f:
|
| 115 |
+
for p in norm_paths:
|
| 116 |
+
safe = os.path.abspath(p).replace("\\", "/").replace("'", "'\\''")
|
| 117 |
+
f.write(f"file '{safe}'\n")
|
| 118 |
+
try:
|
| 119 |
+
subprocess.run(
|
| 120 |
+
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
|
| 121 |
+
"-f", "concat", "-safe", "0", "-i", list_file,
|
| 122 |
+
"-c", "copy", "-movflags", "+faststart", out_path],
|
| 123 |
+
check=True, capture_output=True)
|
| 124 |
+
finally:
|
| 125 |
+
try:
|
| 126 |
+
os.remove(list_file)
|
| 127 |
+
except OSError:
|
| 128 |
+
pass
|
| 129 |
|
| 130 |
|
| 131 |
def _concat_reencode(clip_paths: list[str], out_path: str,
|
| 132 |
w: int = 1080, h: int = 1920):
|
| 133 |
+
"""Ghép nhiều clip bằng concat filter (re-encode, video-only) — đường FALLBACK.
|
| 134 |
|
| 135 |
Chuẩn hoá MỌI clip về cùng khung WxH bằng scale + pad (giữ tỉ lệ gốc, chèn
|
| 136 |
+
nền đen phần thừa). Dùng khi concat copy không nối được (hiếm).
|
|
|
|
|
|
|
|
|
|
| 137 |
"""
|
| 138 |
inputs = []
|
| 139 |
for p in clip_paths:
|
|
|
|
| 154 |
check=True, capture_output=True)
|
| 155 |
|
| 156 |
|
| 157 |
+
def _norm_dir_for(out_dir: str) -> str:
|
| 158 |
+
"""Thư mục cache clip đã chuẩn hoá — đặt cạnh out_dir (cùng phiên, tự dọn theo TTL)."""
|
| 159 |
+
return os.path.join(os.path.dirname(os.path.abspath(out_dir)), "_norm")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def render_variation(clip_paths: list[str], out_path: str, norm_dir: str,
|
| 163 |
+
w: int = 1080, h: int = 1920,
|
| 164 |
+
audio_path: str | None = None) -> None:
|
| 165 |
+
"""Render 1 biến thể theo đường nhanh: chuẩn hoá 1 lần/clip + concat copy.
|
| 166 |
+
|
| 167 |
+
Có audio: ghép video-only ra tmp rồi lồng track (giữ độ dài video). Không
|
| 168 |
+
audio: concat copy thẳng ra out. Fallback re-encode nếu copy lỗi/hỏng.
|
| 169 |
+
"""
|
| 170 |
+
try:
|
| 171 |
+
norm = [normalize_clip(p, norm_dir, w, h) for p in clip_paths]
|
| 172 |
+
if audio_path:
|
| 173 |
+
tmp = out_path + ".v.mp4"
|
| 174 |
+
concat_copy(norm, tmp)
|
| 175 |
+
try:
|
| 176 |
+
attach_audio(tmp, audio_path, out_path)
|
| 177 |
+
finally:
|
| 178 |
+
try:
|
| 179 |
+
os.remove(tmp)
|
| 180 |
+
except OSError:
|
| 181 |
+
pass
|
| 182 |
+
else:
|
| 183 |
+
concat_copy(norm, out_path)
|
| 184 |
+
if _valid_output(out_path):
|
| 185 |
+
return
|
| 186 |
+
except subprocess.CalledProcessError:
|
| 187 |
+
pass
|
| 188 |
+
# Fallback: re-encode trực tiếp từ clip gốc.
|
| 189 |
+
if audio_path:
|
| 190 |
+
tmp = out_path + ".v.mp4"
|
| 191 |
+
_concat_reencode(clip_paths, tmp, w, h)
|
| 192 |
+
try:
|
| 193 |
+
attach_audio(tmp, audio_path, out_path)
|
| 194 |
+
finally:
|
| 195 |
+
try:
|
| 196 |
+
os.remove(tmp)
|
| 197 |
+
except OSError:
|
| 198 |
+
pass
|
| 199 |
+
else:
|
| 200 |
+
_concat_reencode(clip_paths, out_path, w, h)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
def attach_audio(video_path: str, audio_path: str, out_path: str):
|
| 204 |
"""Lồng 1 track âm thanh vào video, GIỮ NGUYÊN độ dài video.
|
| 205 |
|
|
|
|
| 218 |
|
| 219 |
def render_variations(sequences: list[list[int]], clip_map: dict[int, str],
|
| 220 |
out_dir: str, prefix: str = "Variation",
|
| 221 |
+
audio_paths: list[str] | None = None,
|
| 222 |
+
w: int = 1080, h: int = 1920) -> list[dict]:
|
| 223 |
os.makedirs(out_dir, exist_ok=True)
|
| 224 |
+
norm_dir = _norm_dir_for(out_dir)
|
| 225 |
results = []
|
| 226 |
for i, seq in enumerate(sequences, 1):
|
| 227 |
paths = [clip_map[c] for c in seq]
|
| 228 |
name = f"{prefix}_{i:02d}.mp4"
|
| 229 |
out = os.path.join(out_dir, name)
|
| 230 |
+
track = audio_paths[(i - 1) % len(audio_paths)] if audio_paths else None
|
| 231 |
+
render_variation(paths, out, norm_dir, w, h, audio_path=track)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
results.append({
|
| 233 |
"name": name, "path": out,
|
| 234 |
"sequence": seq, "size": os.path.getsize(out),
|
backend/requirements.txt
CHANGED
|
@@ -6,9 +6,12 @@ pydantic>=2.6
|
|
| 6 |
|
| 7 |
# ───────────────────────── Tính toán ────────────────────────
|
| 8 |
numpy>=1.26
|
| 9 |
-
opencv-python-headless>=4.9 # cut: scene/highlight/auto detection
|
|
|
|
| 10 |
imageio-ffmpeg>=0.4.9 # fallback ffmpeg binary nếu hệ thống chưa có
|
| 11 |
-
# cut "auto":
|
|
|
|
|
|
|
| 12 |
# OCR gộp-theo-nội-dung là TÙY CHỌN, cài riêng để tăng độ chính xác nội dung:
|
| 13 |
# pip install rapidocr-onnxruntime
|
| 14 |
# Chưa cài -> auto-cut tự fallback về tách theo chuyển cảnh (không lỗi).
|
|
|
|
| 6 |
|
| 7 |
# ───────────────────────── Tính toán ────────────────────────
|
| 8 |
numpy>=1.26
|
| 9 |
+
opencv-python-headless>=4.9 # cut: scene/highlight/auto detection + backend cho PySceneDetect
|
| 10 |
+
scenedetect>=0.6.4 # cut scene/auto: PySceneDetect AdaptiveDetector (ngưỡng 3.0, min_len 1.0s)
|
| 11 |
imageio-ffmpeg>=0.4.9 # fallback ffmpeg binary nếu hệ thống chưa có
|
| 12 |
+
# cut "auto"/"scene": ưu tiên PySceneDetect (AdaptiveDetector, threshold=3.0, min_len=1.0s).
|
| 13 |
+
# Thiếu scenedetect -> tự fallback pipeline thị-giác (opencv+numpy+ffmpeg), không lỗi.
|
| 14 |
+
# cut "auto" (fallback): thị giác + đổi-màu-vật-giữa (chỉ cần opencv+numpy+ffmpeg).
|
| 15 |
# OCR gộp-theo-nội-dung là TÙY CHỌN, cài riêng để tăng độ chính xác nội dung:
|
| 16 |
# pip install rapidocr-onnxruntime
|
| 17 |
# Chưa cài -> auto-cut tự fallback về tách theo chuyển cảnh (không lỗi).
|
frontend/src/api.js
CHANGED
|
@@ -21,6 +21,45 @@ async function upload(path, file, fields = {}) {
|
|
| 21 |
return r.json();
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export const fileUrl = (u) => BASE + u;
|
| 25 |
|
| 26 |
export const api = {
|
|
@@ -50,6 +89,11 @@ export const api = {
|
|
| 50 |
upload("/api/shuffle/upload", file, { segment, session }),
|
| 51 |
shuffleUploadAudio: (file, session) =>
|
| 52 |
upload("/api/shuffle/upload-audio", file, { session }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
shuffleSegments: (sid) => fetch(BASE + `/api/shuffle/segments/${sid}`).then((r) => r.json()),
|
| 54 |
shuffleEstimate: (req) => jpost("/api/shuffle/estimate", req),
|
| 55 |
shufflePlan: (req) => jpost("/api/shuffle/plan", req),
|
|
@@ -61,6 +105,9 @@ export const api = {
|
|
| 61 |
upload("/api/dub/voice-sample", file, { session }),
|
| 62 |
dubVoices: (lang) => fetch(BASE + `/api/dub/voices?lang=${lang}`).then((r) => r.json()),
|
| 63 |
dubStart: (req) => jpost("/api/dub/start", req),
|
|
|
|
|
|
|
|
|
|
| 64 |
dubTts: (req) => jpost("/api/dub/tts", req),
|
| 65 |
// Hard subtitle
|
| 66 |
subtitlePlan: (session, srt) => jpost("/api/dub/subtitle/plan", { session, srt }),
|
|
|
|
| 21 |
return r.json();
|
| 22 |
}
|
| 23 |
|
| 24 |
+
// Upload 1 file kèm tiến độ BYTE (fetch không có progress event -> dùng XHR).
|
| 25 |
+
// onProgress(loaded, total) gọi liên tục khi bytes đẩy lên.
|
| 26 |
+
export function uploadWithProgress(path, file, fields = {}, onProgress) {
|
| 27 |
+
return new Promise((resolve, reject) => {
|
| 28 |
+
const fd = new FormData();
|
| 29 |
+
fd.append("file", file);
|
| 30 |
+
for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
|
| 31 |
+
const xhr = new XMLHttpRequest();
|
| 32 |
+
xhr.open("POST", BASE + path);
|
| 33 |
+
if (xhr.upload && onProgress) {
|
| 34 |
+
xhr.upload.onprogress = (e) => {
|
| 35 |
+
if (e.lengthComputable) onProgress(e.loaded, e.total);
|
| 36 |
+
};
|
| 37 |
+
}
|
| 38 |
+
xhr.onload = () => {
|
| 39 |
+
if (xhr.status >= 200 && xhr.status < 300) {
|
| 40 |
+
try { resolve(JSON.parse(xhr.responseText)); }
|
| 41 |
+
catch { resolve({}); }
|
| 42 |
+
} else reject(new Error(xhr.responseText || xhr.statusText || `HTTP ${xhr.status}`));
|
| 43 |
+
};
|
| 44 |
+
xhr.onerror = () => reject(new Error("Lỗi mạng khi tải lên"));
|
| 45 |
+
xhr.onabort = () => reject(new Error("Đã huỷ tải lên"));
|
| 46 |
+
xhr.send(fd);
|
| 47 |
+
});
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Chạy nhiều tác vụ song song có GIỚI HẠN luồng (mặc định 4) — nhanh hơn tuần tự
|
| 51 |
+
// mà không làm nghẽn trình duyệt (mỗi host chỉ ~6 kết nối đồng thời).
|
| 52 |
+
export async function runPool(count, worker, limit = 4) {
|
| 53 |
+
let next = 0;
|
| 54 |
+
const run = async () => {
|
| 55 |
+
while (next < count) {
|
| 56 |
+
const i = next++;
|
| 57 |
+
await worker(i);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
await Promise.all(Array.from({ length: Math.min(limit, count) }, run));
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
export const fileUrl = (u) => BASE + u;
|
| 64 |
|
| 65 |
export const api = {
|
|
|
|
| 89 |
upload("/api/shuffle/upload", file, { segment, session }),
|
| 90 |
shuffleUploadAudio: (file, session) =>
|
| 91 |
upload("/api/shuffle/upload-audio", file, { session }),
|
| 92 |
+
// Bản có tiến độ byte (dùng cho loader nhanh + chuẩn khi thả clip/thư mục).
|
| 93 |
+
shuffleUploadP: (file, segment, session, onProgress) =>
|
| 94 |
+
uploadWithProgress("/api/shuffle/upload", file, { segment, session }, onProgress),
|
| 95 |
+
shuffleUploadAudioP: (file, session, onProgress) =>
|
| 96 |
+
uploadWithProgress("/api/shuffle/upload-audio", file, { session }, onProgress),
|
| 97 |
shuffleSegments: (sid) => fetch(BASE + `/api/shuffle/segments/${sid}`).then((r) => r.json()),
|
| 98 |
shuffleEstimate: (req) => jpost("/api/shuffle/estimate", req),
|
| 99 |
shufflePlan: (req) => jpost("/api/shuffle/plan", req),
|
|
|
|
| 105 |
upload("/api/dub/voice-sample", file, { session }),
|
| 106 |
dubVoices: (lang) => fetch(BASE + `/api/dub/voices?lang=${lang}`).then((r) => r.json()),
|
| 107 |
dubStart: (req) => jpost("/api/dub/start", req),
|
| 108 |
+
// 2 pha: dịch trước (xem/sửa) rồi mới tạo giọng
|
| 109 |
+
dubPrepare: (req) => jpost("/api/dub/prepare", req),
|
| 110 |
+
dubSynthesize: (req) => jpost("/api/dub/synthesize", req),
|
| 111 |
dubTts: (req) => jpost("/api/dub/tts", req),
|
| 112 |
// Hard subtitle
|
| 113 |
subtitlePlan: (session, srt) => jpost("/api/dub/subtitle/plan", { session, srt }),
|
frontend/src/pages/CreateVideo.jsx
CHANGED
|
@@ -100,7 +100,7 @@ export default function CreateVideo() {
|
|
| 100 |
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
| 101 |
background: "var(--cream)",
|
| 102 |
}}>
|
| 103 |
-
<div style={{ textAlign: "center", maxWidth:
|
| 104 |
<div style={{
|
| 105 |
width: 64, height: 64, borderRadius: "50%",
|
| 106 |
background: "var(--orange-soft)", color: "var(--orange)",
|
|
@@ -109,9 +109,31 @@ export default function CreateVideo() {
|
|
| 109 |
<IcVideo size={28} />
|
| 110 |
</div>
|
| 111 |
<h2 style={{ margin: "0 0 8px", fontSize: 20 }}>Tạo video với AI</h2>
|
| 112 |
-
<p style={{ color: "var(--ink-2)", fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
| 113 |
-
|
| 114 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
)}
|
|
|
|
| 100 |
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
| 101 |
background: "var(--cream)",
|
| 102 |
}}>
|
| 103 |
+
<div style={{ textAlign: "center", maxWidth: 480 }}>
|
| 104 |
<div style={{
|
| 105 |
width: 64, height: 64, borderRadius: "50%",
|
| 106 |
background: "var(--orange-soft)", color: "var(--orange)",
|
|
|
|
| 109 |
<IcVideo size={28} />
|
| 110 |
</div>
|
| 111 |
<h2 style={{ margin: "0 0 8px", fontSize: 20 }}>Tạo video với AI</h2>
|
| 112 |
+
<p style={{ color: "var(--ink-2)", fontSize: 14, lineHeight: 1.6, margin: "0 0 20px" }}>
|
| 113 |
+
Kết nối tới server WanGP đang chạy để dùng giao diện tạo video gốc ngay trong trang.
|
| 114 |
</p>
|
| 115 |
+
<ol style={{
|
| 116 |
+
textAlign: "left", margin: "0 auto", padding: 0, listStyle: "none",
|
| 117 |
+
display: "flex", flexDirection: "column", gap: 12,
|
| 118 |
+
}}>
|
| 119 |
+
{[
|
| 120 |
+
["Chạy WanGP", "Khởi động công cụ sinh video WanGP và lấy link Gradio công khai của nó (dạng https://xxxx.gradio.live/)."],
|
| 121 |
+
["Dán link & Kết nối", "Dán link vào ô phía trên rồi bấm “🔌 Kết nối”. Trạng thái sẽ chuyển sang “Đã kết nối”."],
|
| 122 |
+
["Tạo video", "Giao diện WanGP gốc hiện ngay trong trang — nhập prompt và tạo video như bình thường."],
|
| 123 |
+
["Ngắt kết nối", "Xong việc bấm “🔌 Ngắt kết nối”. Link được ghi nhớ để lần sau vào lại nhanh."],
|
| 124 |
+
].map(([t, d], i) => (
|
| 125 |
+
<li key={i} style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
| 126 |
+
<span style={{
|
| 127 |
+
flex: "0 0 24px", width: 24, height: 24, borderRadius: "50%",
|
| 128 |
+
background: "var(--orange)", color: "#fff", fontSize: 12, fontWeight: 700,
|
| 129 |
+
display: "grid", placeItems: "center", marginTop: 1,
|
| 130 |
+
}}>{i + 1}</span>
|
| 131 |
+
<span style={{ fontSize: 13, lineHeight: 1.5 }}>
|
| 132 |
+
<b>{t}.</b> <span style={{ color: "var(--ink-2)" }}>{d}</span>
|
| 133 |
+
</span>
|
| 134 |
+
</li>
|
| 135 |
+
))}
|
| 136 |
+
</ol>
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
)}
|
frontend/src/pages/DubVideo.jsx
CHANGED
|
@@ -10,8 +10,9 @@ import SubtitleEditor from "../components/SubtitleEditor";
|
|
| 10 |
const STEPS = [
|
| 11 |
{ t: "Tải video", d: "Tải video nguồn cần dịch." },
|
| 12 |
{ t: "Chọn ngôn ngữ & giọng", d: "Chọn ngôn ngữ đích và giọng đọc (có sẵn hoặc clone)." },
|
| 13 |
-
{ t: "
|
| 14 |
-
{ t: "
|
|
|
|
| 15 |
];
|
| 16 |
|
| 17 |
const LANGS = [
|
|
@@ -62,6 +63,7 @@ export default function DubVideo() {
|
|
| 62 |
speakerFile: "", speakerName: "",
|
| 63 |
bg: "none", style: "natural", multiSpeaker: false,
|
| 64 |
useQuality: false, temperature: 0.8, topK: 25, repPen: 1.2,
|
|
|
|
| 65 |
outputs: [],
|
| 66 |
// khu gen voice từ text
|
| 67 |
tts: {
|
|
@@ -144,28 +146,68 @@ export default function DubVideo() {
|
|
| 144 |
const qualityPayload = (useQ, temperature, topK, repPen) =>
|
| 145 |
useQ ? { temperature, top_k: topK, repetition_penalty: repPen } : {};
|
| 146 |
|
| 147 |
-
|
|
|
|
| 148 |
if (!st.videos.length) return;
|
| 149 |
-
setBusy(true); set({ outputs: [] });
|
| 150 |
try {
|
| 151 |
-
const
|
| 152 |
-
const src = st.voiceSource; // preset | video | upload
|
| 153 |
-
const doClone = !edge && (src === "video" || src === "upload");
|
| 154 |
-
const { job_id } = await api.dubStart({
|
| 155 |
session: st.session, files: st.videos.map((v) => v.file),
|
| 156 |
target_lang: st.target, bg_mode: st.bg,
|
| 157 |
-
do_clone: doClone,
|
| 158 |
-
voice_source: edge ? "preset" : src,
|
| 159 |
-
speaker_file: st.speakerFile,
|
| 160 |
-
preset: edge ? st.voice : (src === "preset" ? st.voice : ""),
|
| 161 |
-
style: st.style, engine: "auto", multi_speaker: st.multiSpeaker,
|
| 162 |
-
username: user,
|
| 163 |
-
...qualityPayload(!edge && st.useQuality, st.temperature, st.topK, st.repPen),
|
| 164 |
});
|
| 165 |
const done = await pollJob(job_id, setJob);
|
| 166 |
const outs = done.result?.outputs ||
|
| 167 |
(done.items || []).filter((i) => i.status === "done").map((i) => i.result);
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
} catch (e) { alert("Lỗi: " + e.message); }
|
| 170 |
finally { setBusy(false); }
|
| 171 |
};
|
|
@@ -251,7 +293,7 @@ export default function DubVideo() {
|
|
| 251 |
<div className="row wrap center">
|
| 252 |
<div>
|
| 253 |
<span className="field-label">Ngôn ngữ đích</span>
|
| 254 |
-
<select value={st.target} onChange={(e) => set({ target: e.target.value })} style={{ width: 160 }}>
|
| 255 |
{LANGS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
| 256 |
</select>
|
| 257 |
</div>
|
|
@@ -321,17 +363,77 @@ export default function DubVideo() {
|
|
| 321 |
<div className="mt16">
|
| 322 |
<span className="field-label">Xử lý nhạc nền</span>
|
| 323 |
<div className="seg-group">
|
| 324 |
-
{BG.map((b) => <button key={b.id} className={st.bg === b.id ? "active" : ""} onClick={() => set({ bg: b.id })}>{b.label}</button>)}
|
| 325 |
</div>
|
| 326 |
</div>
|
| 327 |
|
| 328 |
-
<button className="btn primary block mt16" onClick={
|
| 329 |
-
{busy ? "Đang xử lý..." : <><IcPlay size={16} />
|
| 330 |
</button>
|
| 331 |
</div>
|
| 332 |
|
| 333 |
<ProcessingOverlay busy={busy || ttsBusy} job={job} title="Đang xử lý giọng…" />
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
{/* kết quả */}
|
| 336 |
{st.outputs.length > 0 && (
|
| 337 |
<div className="card">
|
|
|
|
| 10 |
const STEPS = [
|
| 11 |
{ t: "Tải video", d: "Tải video nguồn cần dịch." },
|
| 12 |
{ t: "Chọn ngôn ngữ & giọng", d: "Chọn ngôn ngữ đích và giọng đọc (có sẵn hoặc clone)." },
|
| 13 |
+
{ t: "Dịch & xem trước", d: "Nhận dạng + dịch, trả câu dịch kèm mốc thời gian." },
|
| 14 |
+
{ t: "Sửa lời & mốc", d: "Kiểm/sửa bản dịch trước khi đọc cho khớp video." },
|
| 15 |
+
{ t: "Tạo giọng & tải về", d: "Đọc lại + ghép vào video (kèm audio & phụ đề)." },
|
| 16 |
];
|
| 17 |
|
| 18 |
const LANGS = [
|
|
|
|
| 63 |
speakerFile: "", speakerName: "",
|
| 64 |
bg: "none", style: "natural", multiSpeaker: false,
|
| 65 |
useQuality: false, temperature: 0.8, topK: 25, repPen: 1.2,
|
| 66 |
+
prepared: [], // pha 1: câu dịch + mốc cho user sửa
|
| 67 |
outputs: [],
|
| 68 |
// khu gen voice từ text
|
| 69 |
tts: {
|
|
|
|
| 146 |
const qualityPayload = (useQ, temperature, topK, repPen) =>
|
| 147 |
useQ ? { temperature, top_k: topK, repetition_penalty: repPen } : {};
|
| 148 |
|
| 149 |
+
// PHA 1: dịch + trả câu/mốc để user xem & sửa (chưa đọc)
|
| 150 |
+
const doPrepare = async () => {
|
| 151 |
if (!st.videos.length) return;
|
| 152 |
+
setBusy(true); set({ prepared: [], outputs: [] });
|
| 153 |
try {
|
| 154 |
+
const { job_id } = await api.dubPrepare({
|
|
|
|
|
|
|
|
|
|
| 155 |
session: st.session, files: st.videos.map((v) => v.file),
|
| 156 |
target_lang: st.target, bg_mode: st.bg,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
});
|
| 158 |
const done = await pollJob(job_id, setJob);
|
| 159 |
const outs = done.result?.outputs ||
|
| 160 |
(done.items || []).filter((i) => i.status === "done").map((i) => i.result);
|
| 161 |
+
// cùng ngôn ngữ -> mặc định GIỮ NGUYÊN (không đọc lại), user bỏ tick nếu muốn đọc lại
|
| 162 |
+
set({ prepared: (outs || []).map((p) => ({ ...p, keep: !!p.same_language })) });
|
| 163 |
+
} catch (e) { alert("Lỗi: " + e.message); }
|
| 164 |
+
finally { setBusy(false); }
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
// sửa 1 dòng (text hoặc mốc) trong bản dịch của video thứ pi
|
| 168 |
+
const setLine = (pi, li, patch) => set((cur) => ({
|
| 169 |
+
prepared: cur.prepared.map((p, j) => j !== pi ? p
|
| 170 |
+
: { ...p, lines: p.lines.map((l, k) => k !== li ? l : { ...l, ...patch }) }),
|
| 171 |
+
}));
|
| 172 |
+
const setPrep = (pi, patch) => set((cur) => ({
|
| 173 |
+
prepared: cur.prepared.map((p, j) => j !== pi ? p : { ...p, ...patch }),
|
| 174 |
+
}));
|
| 175 |
+
|
| 176 |
+
// PHA 2: dùng bản dịch (đã sửa) + giọng đã chọn -> TTS + ghép video
|
| 177 |
+
const doSynthesize = async () => {
|
| 178 |
+
// các video sẽ ĐỌC LẠI = có state, có lời, và KHÔNG chọn giữ nguyên
|
| 179 |
+
const editable = st.prepared.filter((p) => p.state && (p.lines || []).length && !p.keep);
|
| 180 |
+
// các video GIỮ NGUYÊN (cùng ngôn ngữ, user tick giữ) -> dùng luôn video gốc
|
| 181 |
+
const kept = st.prepared.filter((p) => p.keep && p.video_url).map((p) => ({
|
| 182 |
+
name: p.name, language: p.language, n_lines: (p.lines || []).length,
|
| 183 |
+
video_url: p.video_url, audio_url: p.video_url, srt_url: p.srt_url, note: p.note,
|
| 184 |
+
}));
|
| 185 |
+
if (!editable.length && !kept.length) return;
|
| 186 |
+
setBusy(true);
|
| 187 |
+
try {
|
| 188 |
+
let synthOuts = [];
|
| 189 |
+
if (editable.length) {
|
| 190 |
+
const edge = isEdge(st.target);
|
| 191 |
+
const src = st.voiceSource; // preset | video | upload
|
| 192 |
+
const doClone = !edge && (src === "video" || src === "upload");
|
| 193 |
+
const { job_id } = await api.dubSynthesize({
|
| 194 |
+
session: st.session,
|
| 195 |
+
items: editable.map((p) => ({
|
| 196 |
+
state: p.state, name: p.name,
|
| 197 |
+
lines: p.lines.map((l) => ({ start: +l.start, end: +l.end, text: l.text })),
|
| 198 |
+
})),
|
| 199 |
+
do_clone: doClone,
|
| 200 |
+
voice_source: edge ? "preset" : src,
|
| 201 |
+
speaker_file: st.speakerFile,
|
| 202 |
+
preset: edge ? st.voice : (src === "preset" ? st.voice : ""),
|
| 203 |
+
style: st.style, engine: "auto", username: user,
|
| 204 |
+
...qualityPayload(!edge && st.useQuality, st.temperature, st.topK, st.repPen),
|
| 205 |
+
});
|
| 206 |
+
const done = await pollJob(job_id, setJob);
|
| 207 |
+
synthOuts = done.result?.outputs ||
|
| 208 |
+
(done.items || []).filter((i) => i.status === "done").map((i) => i.result);
|
| 209 |
+
}
|
| 210 |
+
set({ outputs: [...(synthOuts || []), ...kept] });
|
| 211 |
} catch (e) { alert("Lỗi: " + e.message); }
|
| 212 |
finally { setBusy(false); }
|
| 213 |
};
|
|
|
|
| 293 |
<div className="row wrap center">
|
| 294 |
<div>
|
| 295 |
<span className="field-label">Ngôn ngữ đích</span>
|
| 296 |
+
<select value={st.target} onChange={(e) => set({ target: e.target.value, prepared: [], outputs: [] })} style={{ width: 160 }}>
|
| 297 |
{LANGS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
| 298 |
</select>
|
| 299 |
</div>
|
|
|
|
| 363 |
<div className="mt16">
|
| 364 |
<span className="field-label">Xử lý nhạc nền</span>
|
| 365 |
<div className="seg-group">
|
| 366 |
+
{BG.map((b) => <button key={b.id} className={st.bg === b.id ? "active" : ""} onClick={() => set({ bg: b.id, prepared: [], outputs: [] })}>{b.label}</button>)}
|
| 367 |
</div>
|
| 368 |
</div>
|
| 369 |
|
| 370 |
+
<button className="btn primary block mt16" onClick={doPrepare} disabled={!st.videos.length || busy}>
|
| 371 |
+
{busy ? "Đang xử lý..." : <><IcPlay size={16} /> Dịch (xem trước & sửa)</>}
|
| 372 |
</button>
|
| 373 |
</div>
|
| 374 |
|
| 375 |
<ProcessingOverlay busy={busy || ttsBusy} job={job} title="Đang xử lý giọng…" />
|
| 376 |
|
| 377 |
+
{/* 3. XEM & SỬA bản dịch trước khi đọc (pha giữa) */}
|
| 378 |
+
{st.prepared.length > 0 && st.outputs.length === 0 && (
|
| 379 |
+
<div className="card">
|
| 380 |
+
<div className="panel-title">3. Xem & sửa bản dịch trước khi đọc</div>
|
| 381 |
+
<p className="small muted" style={{ marginTop: -6 }}>
|
| 382 |
+
Sửa lời dịch và mốc thời gian (giây) cho khớp, rồi bấm tạo giọng. Câu gốc để tham khảo.
|
| 383 |
+
</p>
|
| 384 |
+
<div className="stack mt12">
|
| 385 |
+
{st.prepared.map((p, pi) => (
|
| 386 |
+
<div key={p.name} style={{ border: "1px solid var(--line)", borderRadius: 12, padding: 12 }}>
|
| 387 |
+
<div className="flex between center" style={{ marginBottom: 8 }}>
|
| 388 |
+
<b className="small">{p.name}</b>
|
| 389 |
+
<span className="small muted">{p.same_language ? "cùng ngôn ngữ" : `${(p.lines || []).length} câu · ${p.language || ""}`}</span>
|
| 390 |
+
</div>
|
| 391 |
+
{!p.state ? (
|
| 392 |
+
<div className="small" style={{ color: "var(--orange-700)" }}>⚠ {p.note || "Không xử lý được để sửa."}</div>
|
| 393 |
+
) : (
|
| 394 |
+
<div className="stack" style={{ gap: 8 }}>
|
| 395 |
+
{p.same_language && (
|
| 396 |
+
<div style={{ background: "var(--green-soft)", borderRadius: 8, padding: "8px 10px" }}>
|
| 397 |
+
<div className="small" style={{ color: "var(--green)", marginBottom: 6 }}>✅ {p.note}</div>
|
| 398 |
+
<label className="small" style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
|
| 399 |
+
<input type="checkbox" checked={!!p.keep} onChange={(e) => setPrep(pi, { keep: e.target.checked })} />
|
| 400 |
+
Giữ nguyên video gốc (không đọc lại). Bỏ tick nếu muốn sửa lời & đọc lại bằng giọng đã chọn.
|
| 401 |
+
</label>
|
| 402 |
+
</div>
|
| 403 |
+
)}
|
| 404 |
+
{(p.lines || []).map((l, li) => (
|
| 405 |
+
<div key={li} style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
| 406 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 4, width: 78, flexShrink: 0 }}>
|
| 407 |
+
<input type="number" step="0.1" min="0" className="sm" value={Number(l.start).toFixed(1)}
|
| 408 |
+
title="Bắt đầu (giây)"
|
| 409 |
+
onChange={(e) => setLine(pi, li, { start: +e.target.value })}
|
| 410 |
+
style={{ width: 74, padding: "4px 6px", fontSize: 12 }} />
|
| 411 |
+
<input type="number" step="0.1" min="0" className="sm" value={Number(l.end).toFixed(1)}
|
| 412 |
+
title="Kết thúc (giây)"
|
| 413 |
+
onChange={(e) => setLine(pi, li, { end: +e.target.value })}
|
| 414 |
+
style={{ width: 74, padding: "4px 6px", fontSize: 12 }} />
|
| 415 |
+
</div>
|
| 416 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 417 |
+
{l.src && <div className="small muted" title="Câu gốc" style={{ marginBottom: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>↳ {l.src}</div>}
|
| 418 |
+
<textarea value={l.text} onChange={(e) => setLine(pi, li, { text: e.target.value })}
|
| 419 |
+
style={{ width: "100%", minHeight: 40, fontSize: 13 }} />
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
))}
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
</div>
|
| 426 |
+
))}
|
| 427 |
+
</div>
|
| 428 |
+
<div className="flex gap8 mt16" style={{ flexWrap: "wrap" }}>
|
| 429 |
+
<button className="btn primary" onClick={doSynthesize} disabled={busy}>
|
| 430 |
+
{busy ? "Đang tạo giọng..." : <><IcMic size={16} /> Tạo giọng & ghép video</>}
|
| 431 |
+
</button>
|
| 432 |
+
<button className="btn sm" onClick={() => set({ prepared: [] })} disabled={busy}>Dịch lại</button>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
)}
|
| 436 |
+
|
| 437 |
{/* kết quả */}
|
| 438 |
{st.outputs.length > 0 && (
|
| 439 |
<div className="card">
|
frontend/src/pages/Guide.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { IcDownload, IcScissors, IcShuffle, IcMic } from "../components/Icons";
|
| 2 |
|
| 3 |
const guides = [
|
| 4 |
{
|
|
@@ -12,6 +12,40 @@ const guides = [
|
|
| 12 |
"Có thể chuyển thẳng từng video sang tool Cut hoặc Dịch/đổi giọng bằng nút bên cạnh.",
|
| 13 |
],
|
| 14 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
{
|
| 16 |
icon: IcScissors,
|
| 17 |
title: "Cut video",
|
|
|
|
| 1 |
+
import { IcDownload, IcScissors, IcShuffle, IcMic, IcImage, IcStar, IcVideo } from "../components/Icons";
|
| 2 |
|
| 3 |
const guides = [
|
| 4 |
{
|
|
|
|
| 12 |
"Có thể chuyển thẳng từng video sang tool Cut hoặc Dịch/đổi giọng bằng nút bên cạnh.",
|
| 13 |
],
|
| 14 |
},
|
| 15 |
+
{
|
| 16 |
+
icon: IcImage,
|
| 17 |
+
title: "Listing ảnh (OpenAI)",
|
| 18 |
+
steps: [
|
| 19 |
+
"Tải ảnh sản phẩm (bắt buộc) — chụp chính diện, đủ sáng, rõ nhãn mác. Tuỳ chọn thêm ảnh người mẫu và ảnh bối cảnh để AI dùng đúng gương mặt / khung cảnh cho các ảnh có người.",
|
| 20 |
+
"Nhập API Key OpenAI của bạn: GPT Vision Key (phân tích ảnh + sinh prompt) và Image Gen Key (sinh ảnh — để trống nếu dùng chung một key). Key được lưu sẵn trên trình duyệt cho lần sau.",
|
| 21 |
+
"Chọn số lượng ảnh (1–9), ngôn ngữ prompt, và các loại ảnh muốn tạo: Ảnh bìa, Chi tiết SP, Trưng bày, Sử dụng, Mở hộp, Nỗi phiền lo, Trước & Sau, Phong cách sống, Tính năng.",
|
| 22 |
+
"Bấm “Phân tích & Tạo Prompt”, có thể dán thêm thông tin sản phẩm để prompt chính xác hơn, rồi “Xác nhận & Phân tích”. GPT-4o phân tích và tự sinh ảnh bằng gpt-image-2. AI GIỮ NGUYÊN thiết kế, màu sắc, chữ trên nhãn.",
|
| 23 |
+
"Ở mỗi ảnh kết quả: bấm 📄 để xem/sửa prompt rồi tạo lại; bấm ✏️ để yêu cầu chỉnh sửa (kèm ảnh tham chiếu nếu cần) mà vẫn giữ nguyên mẫu mã. Bấm vào ảnh để phóng to.",
|
| 24 |
+
"Bấm “Tải tất cả về” để lấy file zip, hoặc “Làm mới” để bắt đầu lại.",
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
icon: IcStar,
|
| 29 |
+
title: "Listing ảnh Free (Flux / Wangp)",
|
| 30 |
+
steps: [
|
| 31 |
+
"Dán link Gradio của server Flux/Wangp bạn đang chạy rồi bấm “Connect” để kiểm tra kết nối. Link được lưu sẵn trên trình duyệt. Đây là cách sinh ảnh miễn phí (không tốn phí OpenAI), nhưng cần server Flux/Wangp đang chạy.",
|
| 32 |
+
"Tải ảnh sản phẩm (bắt buộc), tuỳ chọn thêm ảnh nền và ảnh người mẫu, rồi bấm “Upload & Start”.",
|
| 33 |
+
"Chọn số lượng ảnh và chế độ: Manual — tự viết prompt mô tả bối cảnh cho từng ảnh; Auto (GPT) — nhập OpenAI key để GPT-4o tự sinh prompt.",
|
| 34 |
+
"Tinh chỉnh cấu hình: Model (Flux2 Klein 9B/4B, Flux 1.1 Pro, Flux Schnell), độ phân giải, Steps, Guidance, Ref Strength và Negative Prompt.",
|
| 35 |
+
"Bấm “Generate” — Flux/Wangp tạo nền theo prompt rồi hệ thống phủ chữ thiết kế lên ảnh.",
|
| 36 |
+
"Bấm “Download all” để lấy file zip, hoặc “Reset” để làm lại.",
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
icon: IcVideo,
|
| 41 |
+
title: "Tạo video",
|
| 42 |
+
steps: [
|
| 43 |
+
"Chạy WanGP (công cụ sinh video) và lấy link Gradio công khai của nó (dạng https://xxxx.gradio.live/).",
|
| 44 |
+
"Dán link vào ô phía trên rồi bấm “🔌 Kết nối”. Trạng thái sẽ chuyển sang “Đã kết nối”.",
|
| 45 |
+
"Sau khi kết nối, giao diện WanGP gốc hiện ngay trong trang để bạn nhập prompt và tạo video.",
|
| 46 |
+
"Khi dùng xong bấm “🔌 Ngắt kết nối”. Link kết nối được ghi nhớ để lần sau vào lại nhanh.",
|
| 47 |
+
],
|
| 48 |
+
},
|
| 49 |
{
|
| 50 |
icon: IcScissors,
|
| 51 |
title: "Cut video",
|
frontend/src/pages/ListingFree.jsx
CHANGED
|
@@ -443,14 +443,14 @@ export default function ListingFree() {
|
|
| 443 |
</div>
|
| 444 |
|
| 445 |
<div className="rail">
|
| 446 |
-
<div className="rail-title">
|
| 447 |
-
<div className="rail-step"><div className="num">1</div><div className="body"><div className="t">
|
| 448 |
-
<div className="rail-step"><div className="num">2</div><div className="body"><div className="t">
|
| 449 |
-
<div className="rail-step"><div className="num">3</div><div className="body"><div className="t">
|
| 450 |
-
<div className="rail-step"><div className="num">4</div><div className="body"><div className="t">
|
| 451 |
-
<div className="rail-step"><div className="num">5</div><div className="body"><div className="t">
|
| 452 |
-
<div className="rail-step"><div className="num">6</div><div className="body"><div className="t">
|
| 453 |
-
<div className="tip"><b>
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
</div>
|
|
|
|
| 443 |
</div>
|
| 444 |
|
| 445 |
<div className="rail">
|
| 446 |
+
<div className="rail-title">Hướng dẫn sử dụng</div>
|
| 447 |
+
<div className="rail-step"><div className="num">1</div><div className="body"><div className="t">Kết nối Gradio</div><div className="d">Dán link server Flux/Wangp bạn đang chạy rồi bấm Connect để kiểm tra. Link được lưu sẵn trên trình duyệt.</div></div></div>
|
| 448 |
+
<div className="rail-step"><div className="num">2</div><div className="body"><div className="t">Tải ảnh</div><div className="d">Ảnh sản phẩm bắt buộc; ảnh nền và ảnh người mẫu là tuỳ chọn. Rồi bấm “Upload & Start”.</div></div></div>
|
| 449 |
+
<div className="rail-step"><div className="num">3</div><div className="body"><div className="t">Chọn chế độ</div><div className="d">Manual: tự viết prompt mô tả bối cảnh. Auto (GPT): nhập OpenAI key để GPT-4o tự sinh prompt.</div></div></div>
|
| 450 |
+
<div className="rail-step"><div className="num">4</div><div className="body"><div className="t">Cấu hình</div><div className="d">Model, độ phân giải, Steps, Guidance, Ref Strength và Negative Prompt.</div></div></div>
|
| 451 |
+
<div className="rail-step"><div className="num">5</div><div className="body"><div className="t">Sinh ảnh</div><div className="d">Flux/Wangp tạo nền theo prompt rồi hệ thống phủ chữ thiết kế lên ảnh.</div></div></div>
|
| 452 |
+
<div className="rail-step"><div className="num">6</div><div className="body"><div className="t">Tải về</div><div className="d">Bấm “Download all” để lấy toàn bộ ảnh dạng file zip.</div></div></div>
|
| 453 |
+
<div className="tip"><b>Mẹo:</b> Đây là cách sinh ảnh miễn phí (không tốn phí OpenAI) nhưng cần server Flux/Wangp đang chạy. OpenAI key và link Gradio đều được lưu trên trình duyệt — cài một lần, dùng nhiều lần.</div>
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
</div>
|
frontend/src/pages/ListingImage.jsx
CHANGED
|
@@ -5,9 +5,12 @@ import { useAuth } from "../stores/authStore";
|
|
| 5 |
import { IcImage, IcUpload, IcDownload, IcRefresh } from "../components/Icons";
|
| 6 |
|
| 7 |
const STEPS = [
|
| 8 |
-
{ t: "Tải ảnh
|
| 9 |
-
{ t: "
|
| 10 |
-
{ t: "
|
|
|
|
|
|
|
|
|
|
| 11 |
];
|
| 12 |
|
| 13 |
const PROMPT_TYPES = [
|
|
@@ -569,7 +572,7 @@ export default function ListingImage() {
|
|
| 569 |
)}
|
| 570 |
</div>
|
| 571 |
|
| 572 |
-
<ProcessRail steps={STEPS} />
|
| 573 |
</div>
|
| 574 |
|
| 575 |
{lightboxUrl && (
|
|
|
|
| 5 |
import { IcImage, IcUpload, IcDownload, IcRefresh } from "../components/Icons";
|
| 6 |
|
| 7 |
const STEPS = [
|
| 8 |
+
{ t: "Tải ảnh", d: "Ảnh sản phẩm là bắt buộc — chụp chính diện, đủ sáng, rõ nhãn. Thêm ảnh người mẫu / bối cảnh nếu muốn AI dùng đúng gương mặt và khung cảnh." },
|
| 9 |
+
{ t: "Nhập API Key", d: "GPT Vision Key để phân tích + sinh prompt, Image Gen Key để sinh ảnh (để trống nếu dùng chung một key). Key lưu sẵn trên trình duyệt." },
|
| 10 |
+
{ t: "Cấu hình", d: "Chọn số lượng ảnh, ngôn ngữ prompt và các loại ảnh (bìa, chi tiết, trưng bày, sử dụng, lifestyle…)." },
|
| 11 |
+
{ t: "Phân tích & Sinh ảnh", d: "GPT-4o phân tích, tạo prompt rồi tự sinh ảnh bằng gpt-image-2 — giữ nguyên thiết kế, màu sắc, chữ trên nhãn." },
|
| 12 |
+
{ t: "Chỉnh sửa", d: "📄 xem/sửa prompt rồi tạo lại · ✏️ yêu cầu chỉnh sửa (kèm ảnh tham chiếu) vẫn giữ mẫu mã · bấm ảnh để phóng to." },
|
| 13 |
+
{ t: "Tải kết quả", d: "Bấm “Tải tất cả về” để lấy file zip, hoặc “Làm mới” để bắt đầu lại." },
|
| 14 |
];
|
| 15 |
|
| 16 |
const PROMPT_TYPES = [
|
|
|
|
| 572 |
)}
|
| 573 |
</div>
|
| 574 |
|
| 575 |
+
<ProcessRail steps={STEPS} tip="Ảnh sản phẩm càng rõ nét, đủ sáng thì AI càng giữ đúng nhãn mác và màu sắc. Muốn sinh ảnh miễn phí (không tốn phí OpenAI)? Dùng tab “Listing ảnh Free” với server Flux/Wangp của bạn." />
|
| 576 |
</div>
|
| 577 |
|
| 578 |
{lightboxUrl && (
|
frontend/src/pages/ShuffleVideo.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
-
import { api, pollJob, fileUrl } from "../api";
|
| 4 |
import { useToolState } from "../store";
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
|
|
@@ -39,7 +39,9 @@ export default function ShuffleVideo() {
|
|
| 39 |
const setJob = (j) => set({ job: j });
|
| 40 |
const setBusy = (b) => set({ busy: b });
|
| 41 |
const [showNote, setShowNote] = useState(true);
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
|
| 44 |
const segs = st.segs;
|
| 45 |
|
|
@@ -83,45 +85,64 @@ export default function ShuffleVideo() {
|
|
| 83 |
set({ segs: next, plan: [] });
|
| 84 |
};
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
let sid = st.session;
|
| 88 |
-
const
|
| 89 |
-
const
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try {
|
| 94 |
-
const r = await
|
| 95 |
-
sid = r.session;
|
| 96 |
-
added
|
| 97 |
-
} catch (e) {
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
setUploading(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
set((cur) => {
|
| 101 |
const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
|
| 102 |
-
n[idx].files.push(...
|
| 103 |
return { session: sid, segs: n };
|
| 104 |
});
|
| 105 |
};
|
| 106 |
|
| 107 |
const addAudio = async (files) => {
|
| 108 |
-
|
| 109 |
-
if (!sid) { // cần session trước -> tạo bằng cách upload clip rỗng? thay vào dùng audio tạo session
|
| 110 |
-
// upload audio sẽ cần session; nếu chưa có, tải clip trước. Ở đây yêu cầu có clip trước.
|
| 111 |
return alert("Hãy thêm clip vào khúc trước để khởi tạo phiên, rồi thêm âm thanh.");
|
| 112 |
}
|
| 113 |
-
const added = [];
|
| 114 |
const arr = Array.from(files);
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
const r = await api.shuffleUploadAudio(file, sid);
|
| 120 |
-
added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
|
| 121 |
-
} catch (e) { alert("Lỗi tải âm thanh: " + e.message); }
|
| 122 |
-
}
|
| 123 |
-
setUploading(null);
|
| 124 |
-
set((cur) => ({ audio: [...cur.audio, ...added] }));
|
| 125 |
};
|
| 126 |
|
| 127 |
const removeClip = (si, fi) => set((cur) => {
|
|
@@ -251,8 +272,14 @@ export default function ShuffleVideo() {
|
|
| 251 |
) : (
|
| 252 |
<>
|
| 253 |
{uploading?.seg === si && (
|
| 254 |
-
<div className="
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
</div>
|
| 257 |
)}
|
| 258 |
<div className="clip-row">
|
|
@@ -283,8 +310,11 @@ export default function ShuffleVideo() {
|
|
| 283 |
<FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
|
| 284 |
</div>
|
| 285 |
{uploading?.seg === "audio" && (
|
| 286 |
-
<div className="
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
| 288 |
</div>
|
| 289 |
)}
|
| 290 |
{st.audio.map((a, ai) => (
|
|
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
+
import { api, pollJob, fileUrl, runPool } from "../api";
|
| 4 |
import { useToolState } from "../store";
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
|
|
|
|
| 39 |
const setJob = (j) => set({ job: j });
|
| 40 |
const setBusy = (b) => set({ busy: b });
|
| 41 |
const [showNote, setShowNote] = useState(true);
|
| 42 |
+
// Loader tải lên: {seg, done, total, pct, name} — pct tính theo BYTE (chuẩn),
|
| 43 |
+
// upload SONG SONG nên nhanh; seg = index khúc hoặc "audio".
|
| 44 |
+
const [uploading, setUploading] = useState(null);
|
| 45 |
|
| 46 |
const segs = st.segs;
|
| 47 |
|
|
|
|
| 85 |
set({ segs: next, plan: [] });
|
| 86 |
};
|
| 87 |
|
| 88 |
+
// Upload SONG SONG (4 luồng) + tiến độ byte gộp -> loader nhanh & chuẩn.
|
| 89 |
+
// one(i): tải file thứ i, cập nhật bytes đã tải; paint(): vẽ lại loader.
|
| 90 |
+
const uploadMany = async (arr, upFn, segKey) => {
|
| 91 |
let sid = st.session;
|
| 92 |
+
const totalBytes = arr.reduce((a, f) => a + (f.size || 0), 0) || 1;
|
| 93 |
+
const loaded = new Array(arr.length).fill(0);
|
| 94 |
+
const added = new Array(arr.length);
|
| 95 |
+
let done = 0;
|
| 96 |
+
const paint = () => {
|
| 97 |
+
const lb = loaded.reduce((a, b) => a + b, 0);
|
| 98 |
+
setUploading({
|
| 99 |
+
seg: segKey, done, total: arr.length,
|
| 100 |
+
pct: Math.min(100, Math.round((lb / totalBytes) * 100)),
|
| 101 |
+
name: arr[Math.min(done, arr.length - 1)]?.name || "",
|
| 102 |
+
});
|
| 103 |
+
};
|
| 104 |
+
const one = async (i) => {
|
| 105 |
try {
|
| 106 |
+
const r = await upFn(arr[i], sid, (l) => { loaded[i] = l; paint(); });
|
| 107 |
+
sid = r.session || sid;
|
| 108 |
+
added[i] = { file: r.file, name: r.name, size: r.size, url: r.url };
|
| 109 |
+
} catch (e) { console.error("Tải lỗi:", arr[i]?.name, e); }
|
| 110 |
+
loaded[i] = arr[i].size || 0; done++; paint();
|
| 111 |
+
};
|
| 112 |
+
paint();
|
| 113 |
+
// File đầu chạy TRƯỚC để khoá session; các file sau chia 4 luồng dùng chung session.
|
| 114 |
+
let start = 0;
|
| 115 |
+
if (!sid) { await one(0); start = 1; }
|
| 116 |
+
const limit = sid ? 4 : 1; // nếu file đầu lỗi (chưa có session) -> tuần tự cho an toàn
|
| 117 |
+
await runPool(arr.length - start, (k) => one(start + k), limit);
|
| 118 |
setUploading(null);
|
| 119 |
+
const ok = added.filter(Boolean);
|
| 120 |
+
const failed = arr.length - ok.length;
|
| 121 |
+
if (failed) alert(`Có ${failed}/${arr.length} tệp tải lỗi (đã bỏ qua).`);
|
| 122 |
+
return { sid, ok };
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const addToSeg = (idx) => async (files) => {
|
| 126 |
+
const arr = Array.from(files);
|
| 127 |
+
if (!arr.length) return;
|
| 128 |
+
const { sid, ok } = await uploadMany(
|
| 129 |
+
arr, (file, sid, onP) => api.shuffleUploadP(file, idx, sid, onP), idx);
|
| 130 |
set((cur) => {
|
| 131 |
const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
|
| 132 |
+
n[idx].files.push(...ok);
|
| 133 |
return { session: sid, segs: n };
|
| 134 |
});
|
| 135 |
};
|
| 136 |
|
| 137 |
const addAudio = async (files) => {
|
| 138 |
+
if (!st.session) {
|
|
|
|
|
|
|
| 139 |
return alert("Hãy thêm clip vào khúc trước để khởi tạo phiên, rồi thêm âm thanh.");
|
| 140 |
}
|
|
|
|
| 141 |
const arr = Array.from(files);
|
| 142 |
+
if (!arr.length) return;
|
| 143 |
+
const { ok } = await uploadMany(
|
| 144 |
+
arr, (file, sid, onP) => api.shuffleUploadAudioP(file, sid, onP), "audio");
|
| 145 |
+
set((cur) => ({ audio: [...cur.audio, ...ok] }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
};
|
| 147 |
|
| 148 |
const removeClip = (si, fi) => set((cur) => {
|
|
|
|
| 272 |
) : (
|
| 273 |
<>
|
| 274 |
{uploading?.seg === si && (
|
| 275 |
+
<div className="upload-loader" style={{ marginBottom: 8 }}>
|
| 276 |
+
<div className="small flex between" style={{ marginBottom: 4 }}>
|
| 277 |
+
<span style={{ color: "var(--orange-700)" }}>
|
| 278 |
+
⏳ Đang tải {uploading.done}/{uploading.total} clip · {uploading.pct}%
|
| 279 |
+
</span>
|
| 280 |
+
<span className="muted" style={{ maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{uploading.name}</span>
|
| 281 |
+
</div>
|
| 282 |
+
<div className="progress"><i style={{ width: uploading.pct + "%" }} /></div>
|
| 283 |
</div>
|
| 284 |
)}
|
| 285 |
<div className="clip-row">
|
|
|
|
| 310 |
<FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
|
| 311 |
</div>
|
| 312 |
{uploading?.seg === "audio" && (
|
| 313 |
+
<div className="upload-loader" style={{ alignSelf: "center", minWidth: 220 }}>
|
| 314 |
+
<div className="small" style={{ color: "var(--orange-700)", marginBottom: 4 }}>
|
| 315 |
+
⏳ Đang tải {uploading.done}/{uploading.total} · {uploading.pct}%
|
| 316 |
+
</div>
|
| 317 |
+
<div className="progress"><i style={{ width: uploading.pct + "%" }} /></div>
|
| 318 |
</div>
|
| 319 |
)}
|
| 320 |
{st.audio.map((a, ai) => (
|