titusgiap commited on
Commit
c55c292
·
1 Parent(s): dbc063a

update cut, guide, dub

Browse files
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 = "smart" # smart | highlight | scene | even
45
  count: int | None = None # số khúc (even) — None để máy tự chia
46
- min_len: float = 1.5
 
 
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
- kept = {tuple(s) for s in engine.generate(cfg)}
 
 
 
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
- tmp = os.path.join(out_dir, f".tmp_{i+1:02d}.mp4")
227
- shuffle_render._concat_reencode(paths, tmp, w, h_res)
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
- h.set_progress(counter["done"] / max(1, n))
 
243
 
244
- # render SONG SONG (ffmpeg nặng CPU -> giới hạn số luồng để không nghẽn)
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ẹ -> 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
- """4 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 (opencv hist-diff), khúc quá ngắn gộp lại.
 
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 theo nội dung (thị giác 3 tầng). Import lazy tránh vòng lặp import.
 
 
 
 
 
 
 
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
- return scene_cut(info, kw.get("min_len", 1.5), kw.get("sensitivity", 0.5))
 
 
 
 
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". Đổi player_client sang các client
194
- # ít bị soi hơn (android/tv/web_safari) để KHÔNG cần cookies.
195
- # Nếu vẫn bị chặn -> đặt biến môi trường YOUTUBE_COOKIES_FILE trỏ tới file
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
+ # việc CHỌN player_client phụ thuộ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
- - Xử nền nhạc 3 chế độ: none | duck | demucs.
 
 
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", f"anullsrc=r=44100:cl=stereo",
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 _demucs_separate(full_wav: str, out_dir: str, model: str) -> tuple[str, str]:
61
- """Tách nhạc & giọng bằng Demucs. Trả (bg_wav, voice_wav).
62
 
63
- Cần `pip install demucs`. Nếu thiếu -> raise để pipeline fallback duck.
 
 
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
- ["python", "-m", "demucs", "-n", model, "--two-stems", "vocals",
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 prepare_background(full_wav: str, out_dir: str, mode: str,
80
- demucs_model: str = "htdemucs") -> tuple[str, str]:
81
- """Trả (bg_wav, voice_wav) theo chế độ nền."""
82
- bg = os.path.join(out_dir, "bg.wav")
83
- if mode == "none":
84
- _silence_like(full_wav, bg)
85
- return bg, full_wav
86
- if mode == "duck":
87
- # giảm tiếng gốc ~ -12dB làm nền mờ
88
- _run([FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
89
- "-i", full_wav, "-af", "volume=-12dB", bg])
90
- return bg, full_wav
91
- if mode == "demucs":
 
 
 
92
  try:
93
- return _demucs_separate(full_wav, out_dir, demucs_model)
94
- except Exception:
95
- # fallback an toàn -> duck
96
- return prepare_background(full_wav, out_dir, "duck")
97
- raise ValueError(f"bg mode không hợp lệ: {mode}")
 
 
 
 
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
- asr_wav, full_wav = extract_tracks(video, out_dir)
113
- bg_wav, voice_wav = prepare_background(full_wav, out_dir, bg_mode, demucs_model)
114
- return AudioBundle(asr_wav, full_wav, bg_wav, voice_wav, bg_mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ 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) -> str:
113
- """Trộn giọng dịch lên trên nhạc nền."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 RE-ENCODE hình sang H.264/yuv420p + faststart.
127
 
128
- Không dùng `-c:v copy` vì video nguồn thể HEVC/H.265 trình duyệt
129
- (Chrome/Firefox) không phát được -> người dùng thấy player trơ không có hình.
130
- H.264 + faststart đảm bảo xem trực tiếp được trên web.
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
- "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
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 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
- import subprocess
118
- # re-encode H.264 để trình duyệt xem được (video gốc có thể là HEVC)
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
- def run_dub(video: str, out_dir: str, opt: DubOptions,
142
- progress: Callable[[float, str], None] | None = None) -> DubResult:
143
- """Bọc pipeline thật: nếu BẤT KỲ bước nào lỗi (thiếu model/API/mạng) thì
144
- rơi về _passthrough để người dùng vẫn tải được video. Không bao giờ treo job."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  try:
146
- return _run_dub_full(video, out_dir, opt, progress)
147
  except Exception as e:
148
  import traceback
149
- traceback.print_exc() # in chi tiết ra console uvicorn để biết tầng nào hỏng
150
  if progress:
151
  progress(1.0, "Dùng video gốc (xem ghi chú lỗi)")
152
- return _passthrough(video, out_dir, opt,
153
- note=f"Chưa lồng tiếng được nên trả video gốc: {e}")
 
 
 
154
 
155
 
156
- def _run_dub_full(video: str, out_dir: str, opt: DubOptions,
157
- progress: Callable[[float, str], None] | None = None) -> DubResult:
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.05, "Tách audio & xử lý nền nhạc")
167
  bundle = audio.build_audio(video, work, opt.bg_mode, opt.demucs_model)
168
 
169
- emit(0.25, "Nhận dạng lời thoại (Whisper)")
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
- if progress:
182
- progress(1.0, f"Video đã ngôn ngữ {opt.target_lang} giữ nguyên")
183
  import shutil as _sh2
184
  video_out = os.path.join(out_dir, f"video_translated_{opt.target_lang}.mp4")
185
  try:
186
- import subprocess
187
- subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
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
- audio_out = os.path.join(out_dir, "audio.m4a")
195
- try:
196
- import subprocess
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(segments, 1):
205
- st = _srt_time(ln.start); et = _srt_time(ln.end)
206
- f.write(f"{i}\n{st} --> {et}\n{ln.text}\n\n")
207
- return DubResult(video_out=video_out, audio_out=audio_out, srt_out=srt_out,
208
- language=language, n_lines=len(segments),
209
- note=f"Video đã là tiếng {_detected.upper()} — giữ nguyên, không dịch lại.")
210
-
211
- emit(0.45, f"Dịch sang {opt.target_lang} (vừa khít thời lượng)")
 
 
 
 
 
 
 
 
 
 
 
 
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
- # mẫu clone
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  speaker_wav = opt.speaker_wav
220
- if opt.do_clone and opt.voice_source == "video" and segments:
221
- emit(0.55, "Cắt mẫu giọng để clone")
222
  speaker_wav = audio.cut_clone_sample(
223
- bundle.voice_wav, work, start=max(0.0, segments[0].start), length=15.0)
224
 
225
- emit(0.6, "Tổng hợp giọng đọc (TTS) + clone")
226
  vcfg = tts.VoiceConfig(
227
- target_lang=opt.target_lang, voice_source=opt.voice_source,
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(translated)
239
- for i, ln in enumerate(translated):
240
- raw = os.path.join(tts_dir, f"line_{i:04d}.wav")
241
- tts.synth_line(engine, ln.text, raw, vcfg)
242
- # ĐỒNG BỘ: mỗi câu dịch phải nằm trong ĐÚNG cửa sổ thời gian câu gốc
243
- # [start, end] -> nén cho vừa thời lượng gốc, KHÔNG lấn sang câu kế
244
- # (giữ khoảng nghỉ gốc). Neo tuyệt đối tại ln.start khi ghép.
245
- own = max(0.3, ln.end - ln.start) # thời lượng nói gốc
246
- next_start = translated[i + 1].start if i + 1 < n else total
247
- slot = max(0.3, min(own, next_start - ln.start)) # không tràn quá câu kế
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  fitted = os.path.join(tts_dir, f"fit_{i:04d}.wav")
249
- mux.fit_to_slot(raw, slot, fitted)
250
- placed.append(mux.PlacedLine(start=ln.start, wav=fitted))
251
- emit(0.6 + 0.25 * (i + 1) / max(1, n), f"TTS câu {i+1}/{n}")
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, bundle.bg_wav,
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_{opt.target_lang}.mp4"))
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ử để 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) 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 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
- import google.generativeai as genai # type: ignore
57
-
58
- genai.configure(api_key=settings.GOOGLE_API_KEY)
59
- model = genai.GenerativeModel(settings.GEMINI_MODEL)
60
- resp = model.generate_content(prompt)
61
- return (resp.text or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) DỊCH GỘP: 1 lần gọi cho tất cả câu (tránh 429 do gọi từng câu).
129
  try:
130
- prompt = _batch_prompt(segs, target_lang)
131
- try:
132
- resp = _gemini(prompt)
133
- except Exception:
134
- resp = _groq_llm(prompt)
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) DỰ PHÒNG: dịch từng câu (chỉ khi gộp lỗi/không parse được).
145
- out = []
146
- for s in segs:
147
- txt = translate_line(s, target_lang)
148
- out.append(TranslatedLine(s.start, s.end, s.text, txt,
149
- char_budget(s, target_lang)))
150
- return out
 
 
 
 
 
 
 
 
 
 
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
- if cfg.speaker_wav: # clone từ mẫu
72
- audio = self.model.infer(text, ref_audio=cfg.speaker_wav, **kw)
73
- else: # giọng preset (voice nhận tên str)
 
 
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
- for (label, vid) in _FALLBACK_VOICES.get(lang, [])]
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
- Nếu KHÔNG bật lọc tương đồng (threshold>=1.0) random_ratio>=1.0
18
- thì engine trả về TOÀN BỘ tổ hợp (lấy hết video).
 
 
 
 
 
 
 
 
 
 
 
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
- # ----------------------------------------------------------- generate ---
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
- def _pad_matrix(seqs: list[list[int]]) -> np.ndarray:
133
- if not seqs:
134
- return np.empty((0, 0), dtype=np.int64)
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 _greedy_dedup(mat: np.ndarray, threshold: float) -> list[int]:
155
- n, width = mat.shape
156
- if n == 0:
 
 
 
 
 
157
  return []
158
- kept_idx: list[int] = []
 
 
159
  kept_rows = np.empty((0, width), dtype=np.int64)
160
- valid = mat != PAD
161
-
162
- for i in range(n):
163
- if not kept_idx:
164
- kept_idx.append(i)
165
- kept_rows = mat[i : i + 1].copy()
166
- continue
167
- row = mat[i]
168
- eq = (kept_rows == row) & valid[i]
169
- sims = eq.sum(axis=1) / width
170
- if sims.max() < threshold:
171
- kept_idx.append(i)
172
- kept_rows = np.vstack([kept_rows, row])
173
- return kept_idx
 
 
 
 
 
 
 
 
 
 
 
174
 
175
 
176
  def generate(cfg: ShuffleConfig) -> list[list[int]]:
177
- seqs = list(_iter_raw_sequences(cfg))
178
- if not seqs:
 
 
 
 
 
 
 
 
 
 
179
  return []
180
 
181
- rng = np.random.default_rng(cfg.seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- # 1) lọc trùng theo ngưỡng tương đồng (bỏ qua nếu >=1.0 => lấy hết)
184
- if 0.0 < cfg.similarity_threshold < 1.0:
185
- mat = _pad_matrix(seqs)
186
- perm = rng.permutation(len(seqs))
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
- result = [list(s) for s in seqs]
 
192
 
193
- # 2) lấy mẫu ngẫu nhiên theo tỉ lệ (bỏ qua nếu >=1.0 => giữ hết)
194
- if 0.0 < cfg.random_ratio < 1.0 and result:
195
  k = max(1, int(round(len(result) * cfg.random_ratio)))
196
- idx = rng.choice(len(result), size=min(k, len(result)), replace=False)
197
- result = [result[i] for i in sorted(idx.tolist())]
 
198
 
199
- # 3) trần số lượng
200
- if cfg.max_outputs is not None and len(result) > cfg.max_outputs:
201
- idx = rng.choice(len(result), size=cfg.max_outputs, replace=False)
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 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). Bắt buộc vì filter concat của ffmpeg yêu cầu các input
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) -> list[dict]:
 
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
- tmp = os.path.join(out_dir, f".tmp_{i:02d}.mp4")
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": chạy ngay bằng thị giác + đổi-màu-vật-giữa (chỉ cần opencv+numpy+ffmpeg).
 
 
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: 440 }}>
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
- Nhập link Gradio (WanGP) phía trên và kết nối để sử dụng giao diện tạo video gốc.
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: "Xử & lồng tiếng", d: "Nhận dạng, dịch lồng giọng vào video." },
14
- { t: "Tải video đã dịch", d: "Đầu ra là video đã dịch (kèm audio & phụ đề)." },
 
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
- const start = async () => {
 
148
  if (!st.videos.length) return;
149
- setBusy(true); set({ outputs: [] });
150
  try {
151
- const edge = isEdge(st.target);
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
- set({ outputs: outs });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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={start} disabled={!st.videos.length || busy}>
329
- {busy ? "Đang xử lý..." : <><IcPlay size={16} /> Bắt đầu dịch</>}
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">How it works</div>
447
- <div className="rail-step"><div className="num">1</div><div className="body"><div className="t">Gradio URL</div><div className="d">Paste your Flux/Wangp Space URL (saved automatically)</div></div></div>
448
- <div className="rail-step"><div className="num">2</div><div className="body"><div className="t">Upload image</div><div className="d">Single product image</div></div></div>
449
- <div className="rail-step"><div className="num">3</div><div className="body"><div className="t">Choose mode</div><div className="d">Manual: write your own scene prompts. Auto: GPT-4o generates them.</div></div></div>
450
- <div className="rail-step"><div className="num">4</div><div className="body"><div className="t">Configure</div><div className="d">Model, resolution, steps, guidance, ref strength, negative prompt</div></div></div>
451
- <div className="rail-step"><div className="num">5</div><div className="body"><div className="t">Generate</div><div className="d">Flux/Wangp creates backgrounds Pillow overlays text design</div></div></div>
452
- <div className="rail-step"><div className="num">6</div><div className="body"><div className="t">Download</div><div className="d">All images as zip file</div></div></div>
453
- <div className="tip"><b>Tip:</b> OpenAI key is saved in browser. Gradio URL is also saved. Set once, use many times.</div>
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 &amp; 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 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 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 sản phẩm", d: "Chọn ảnh sản phẩm để phân tích." },
9
- { t: "Tạo Prompt & Sinh ảnh", d: "GPT-4o phân tích tự động sinh ảnh với gpt-image-2." },
10
- { t: "Kết quả", d: "Tải ảnh đã tạo + text Shopee." },
 
 
 
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 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 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
- const [uploading, setUploading] = useState(null); // {name,i,n,seg} — clip đang tải lên
 
 
43
 
44
  const segs = st.segs;
45
 
@@ -83,45 +85,64 @@ export default function ShuffleVideo() {
83
  set({ segs: next, plan: [] });
84
  };
85
 
86
- const addToSeg = (idx) => async (files) => {
 
 
87
  let sid = st.session;
88
- const added = [];
89
- const arr = Array.from(files);
90
- for (let i = 0; i < arr.length; i++) {
91
- const file = arr[i];
92
- setUploading({ name: file.name, i: i + 1, n: arr.length, seg: idx });
 
 
 
 
 
 
 
 
93
  try {
94
- const r = await api.shuffleUpload(file, idx, sid);
95
- sid = r.session;
96
- added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
97
- } catch (e) { alert("Lỗi tải clip: " + e.message); }
98
- }
 
 
 
 
 
 
 
99
  setUploading(null);
 
 
 
 
 
 
 
 
 
 
 
100
  set((cur) => {
101
  const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
102
- n[idx].files.push(...added);
103
  return { session: sid, segs: n };
104
  });
105
  };
106
 
107
  const addAudio = async (files) => {
108
- let sid = st.session;
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
- for (let i = 0; i < arr.length; i++) {
116
- const file = arr[i];
117
- setUploading({ name: file.name, i: i + 1, n: arr.length, seg: "audio" });
118
- try {
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="small" style={{ color: "var(--orange-700)", marginBottom: 6 }}>
255
- Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
 
 
 
 
 
 
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="small" style={{ color: "var(--orange-700)", alignSelf: "center" }}>
287
- Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
 
 
 
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) => (