titusgiap commited on
Commit
00a7377
·
1 Parent(s): bed5a83

update: down, cut, shuffle, trans video UX

Browse files
backend/app/api/shuffle.py CHANGED
@@ -104,6 +104,9 @@ class ShuffleReq(BaseModel):
104
  # danh sách biến thể user đã CHỌN render (mỗi phần tử = list clip_id).
105
  # None -> tự sinh theo cfg (giữ hành vi cũ).
106
  sequences: list[list[int]] | None = None
 
 
 
107
  username: str = ""
108
 
109
 
@@ -151,7 +154,8 @@ def estimate(req: ShuffleReq):
151
 
152
  # ------------------------------------------------------------------- plan ---
153
 
154
- PLAN_CAP = 500 # trần số dòng bảng trả về (tránh payload khổng lồ)
 
155
 
156
 
157
  @router.post("/plan")
@@ -189,14 +193,21 @@ def generate(req: ShuffleReq):
189
  cfg, clip_map, _ = _build(req)
190
  # Chỉ render các biến thể user đã CHỌN (nếu gửi lên); không thì tự sinh như cũ.
191
  sequences = req.sequences if req.sequences else engine.generate(cfg)
 
 
 
 
192
  audio_paths = _audio_paths(req)
 
193
 
194
  job = jobs.create("shuffle")
195
 
196
  def work(h):
197
  from concurrent.futures import ThreadPoolExecutor
198
  import threading
199
- h.log(f"Sẽ render {len(sequences)} biến thể"
 
 
200
  + (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else ""))
201
  out_dir = sub_dir(req.session, "variations")
202
  n = len(sequences)
@@ -213,12 +224,12 @@ def generate(req: ShuffleReq):
213
  out = os.path.join(out_dir, name)
214
  if audio_paths:
215
  tmp = os.path.join(out_dir, f".tmp_{i+1:02d}.mp4")
216
- shuffle_render._concat_reencode(paths, tmp)
217
  track = audio_paths[i % len(audio_paths)]
218
  shuffle_render.attach_audio(tmp, track, out)
219
  os.remove(tmp)
220
  else:
221
- shuffle_render._concat_reencode(paths, out)
222
  size = os.path.getsize(out)
223
  results[i] = {"name": name, "path": out, "sequence": seq, "size": size}
224
  h.item_status(it, "done", detail=f"{size//1024} KB", result={
 
104
  # danh sách biến thể user đã CHỌN render (mỗi phần tử = list clip_id).
105
  # None -> tự sinh theo cfg (giữ hành vi cũ).
106
  sequences: list[list[int]] | None = None
107
+ # độ phân giải xuất ra (khung dọc TikTok). Mặc định 1080x1920.
108
+ width: int = 1080
109
+ height: int = 1920
110
  username: str = ""
111
 
112
 
 
154
 
155
  # ------------------------------------------------------------------- plan ---
156
 
157
+ PLAN_CAP = 3000 # trần số dòng bảng trả về (chỉ là danh sách nhãn, chưa render)
158
+ MAX_RENDER = 150 # tối đa số video được RENDER mỗi lần (nặng CPU/đĩa)
159
 
160
 
161
  @router.post("/plan")
 
193
  cfg, clip_map, _ = _build(req)
194
  # Chỉ render các biến thể user đã CHỌN (nếu gửi lên); không thì tự sinh như cũ.
195
  sequences = req.sequences if req.sequences else engine.generate(cfg)
196
+ # Trần cứng số video render mỗi lần để không quá tải máy.
197
+ capped_render = len(sequences) > MAX_RENDER
198
+ if capped_render:
199
+ sequences = sequences[:MAX_RENDER]
200
  audio_paths = _audio_paths(req)
201
+ w, h_res = max(64, req.width), max(64, req.height)
202
 
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)
 
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={
backend/app/services/dubber/translate.py CHANGED
@@ -35,11 +35,17 @@ def char_budget(seg: Segment, target_lang: str) -> int:
35
  def _prompt(seg: Segment, target_lang: str, budget: int) -> str:
36
  name = LANG_NAME.get(target_lang, target_lang)
37
  return (
38
- f"Translate the following line into natural {name} for video dubbing.\n"
 
 
 
 
 
39
  f"It must be speakable in about {seg.dur:.1f} seconds, so keep it within "
40
- f"~{budget} characters. Prefer a concise, natural paraphrase over a literal "
41
- f"translation if needed to fit the time. Keep proper nouns, numbers and "
42
- f"punctuation. Return ONLY the translated line, no quotes, no notes.\n\n"
 
43
  f"Line: {seg.text}"
44
  )
45
 
 
35
  def _prompt(seg: Segment, target_lang: str, budget: int) -> str:
36
  name = LANG_NAME.get(target_lang, target_lang)
37
  return (
38
+ f"You are a professional advertising voice-over translator for short "
39
+ f"product videos (TikTok/Reels style).\n"
40
+ f"Translate the line below into natural, SPOKEN {name} with an ENGAGING "
41
+ f"ADVERTISING tone: catchy, persuasive, energetic, emphasizing the "
42
+ f"product's appeal and benefits — the way a native {name} marketer would "
43
+ f"actually pitch it — while staying faithful to the original meaning.\n"
44
  f"It must be speakable in about {seg.dur:.1f} seconds, so keep it within "
45
+ f"~{budget} characters. Prefer a punchy, concise paraphrase over a literal "
46
+ f"translation so it fits the time AND sounds catchy. Keep proper nouns, "
47
+ f"brand names and numbers. Return ONLY the translated line — no quotes, "
48
+ f"no notes, no explanation.\n\n"
49
  f"Line: {seg.text}"
50
  )
51
 
backend/app/services/dubber/tts.py CHANGED
@@ -37,7 +37,9 @@ class VoiceConfig:
37
  def pick_engine(cfg: VoiceConfig) -> str:
38
  if cfg.engine and cfg.engine != "auto":
39
  return cfg.engine
40
- return {"vi": "vieneu", "en": "xtts", "fil": "edge"}.get(cfg.target_lang, "edge")
 
 
41
 
42
 
43
  class TTSEngine:
@@ -109,13 +111,30 @@ class XTTSEngine(TTSEngine): # pragma: no cover - cần model local
109
 
110
  # --------------------------------------------------------------- fil: Edge ----
111
 
112
- # Giọng Edge mặc định theo ngôn ngữ (Edge không clone, một giọng dựng sẵn).
113
  _EDGE_VOICES = {
114
  "fil": "fil-PH-BlessicaNeural",
115
  "vi": "vi-VN-HoaiMyNeural",
116
  "en": "en-US-AriaNeural",
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  class EdgeTTSEngine(TTSEngine): # pragma: no cover - cần mạng
121
  def __init__(self):
@@ -160,7 +179,19 @@ def load_engine(cfg: VoiceConfig) -> TTSEngine:
160
  if cls is None:
161
  raise ValueError(f"engine không hợp lệ: {name}")
162
  if name not in _INSTANCES:
163
- _INSTANCES[name] = cls()
 
 
 
 
 
 
 
 
 
 
 
 
164
  return _INSTANCES[name]
165
 
166
 
@@ -179,19 +210,9 @@ _FALLBACK_VOICES: dict[str, list[tuple[str, str]]] = {
179
  ("Bình An — nam, giọng điềm đạm", "Bình An"),
180
  ("Ngọc Linh — nữ, giọng tươi sáng", "Ngọc Linh"),
181
  ],
182
- "en": [
183
- ("Ana Florence — nữ, ấm", "Ana Florence"),
184
- ("Claribel Dervla — nữ, trong", "Claribel Dervla"),
185
- ("Alison Dietlinde — nữ, truyền cảm", "Alison Dietlinde"),
186
- ("Sofia Hellen — nữ, trẻ trung", "Sofia Hellen"),
187
- ("Andrew Chipper — nam, vui tươi", "Andrew Chipper"),
188
- ("Damien Black — nam, trầm", "Damien Black"),
189
- ("Craig Gutsy — nam, chắc khỏe", "Craig Gutsy"),
190
- ("Royston Min — nam, rõ ràng", "Royston Min"),
191
- ("Viktor Eka — nam, điềm đạm", "Viktor Eka"),
192
- ("Gitta Nikolina — nữ, nhẹ nhàng", "Gitta Nikolina"),
193
- ],
194
- "fil": [("Giọng Filipino (Edge TTS)", "fil-PH-BlessicaNeural")],
195
  }
196
 
197
 
@@ -199,7 +220,9 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
199
  """Trả danh sách giọng cho ngôn ngữ. Mặc định KHÔNG nạp model (tránh treo request);
200
  chỉ lấy động khi engine đã được cache sẵn (đã từng dịch) hoặc allow_load=True."""
201
  name = pick_engine(VoiceConfig(target_lang=lang))
202
- if name in _INSTANCES or allow_load:
 
 
203
  try:
204
  eng = load_engine(VoiceConfig(target_lang=lang))
205
  vs = eng.list_voices()
 
37
  def pick_engine(cfg: VoiceConfig) -> str:
38
  if cfg.engine and cfg.engine != "auto":
39
  return cfg.engine
40
+ # vi -> VieNeu (local, nhẹ). en & fil -> Edge TTS (online): XTTS/Coqui chưa cài
41
+ # và nặng RAM, Edge cho giọng tốt sẵn dùng ngay.
42
+ return {"vi": "vieneu", "en": "edge", "fil": "edge"}.get(cfg.target_lang, "edge")
43
 
44
 
45
  class TTSEngine:
 
111
 
112
  # --------------------------------------------------------------- fil: Edge ----
113
 
114
+ # Giọng Edge mặc định theo ngôn ngữ (Edge không clone, dùng giọng dựng sẵn).
115
  _EDGE_VOICES = {
116
  "fil": "fil-PH-BlessicaNeural",
117
  "vi": "vi-VN-HoaiMyNeural",
118
  "en": "en-US-AriaNeural",
119
  }
120
 
121
+ # Danh sách giọng Edge (nhiều lựa chọn nam/nữ) cho các ngôn ngữ chạy Edge TTS.
122
+ _EDGE_VOICE_LIST = {
123
+ "en": [
124
+ ("Aria — nữ, tự nhiên (US)", "en-US-AriaNeural"),
125
+ ("Jenny — nữ, ấm (US)", "en-US-JennyNeural"),
126
+ ("Guy — nam, rõ ràng (US)", "en-US-GuyNeural"),
127
+ ("Michelle — nữ, trẻ trung (US)", "en-US-MichelleNeural"),
128
+ ("Ana — nữ, tươi (US)", "en-US-AnaNeural"),
129
+ ("Ryan — nam, ấm (UK)", "en-GB-RyanNeural"),
130
+ ("Sonia — nữ, truyền cảm (UK)", "en-GB-SoniaNeural"),
131
+ ],
132
+ "fil": [
133
+ ("Blessica — nữ, tự nhiên", "fil-PH-BlessicaNeural"),
134
+ ("Angelo — nam, rõ ràng", "fil-PH-AngeloNeural"),
135
+ ],
136
+ }
137
+
138
 
139
  class EdgeTTSEngine(TTSEngine): # pragma: no cover - cần mạng
140
  def __init__(self):
 
179
  if cls is None:
180
  raise ValueError(f"engine không hợp lệ: {name}")
181
  if name not in _INSTANCES:
182
+ try:
183
+ _INSTANCES[name] = cls()
184
+ except Exception as e:
185
+ # Model nặng chưa cài / lỗi nạp (vd XTTS-Coqui) -> lùi về Edge TTS
186
+ # (online, nhẹ) để vẫn LỒNG ĐƯỢC tiếng thay vì hỏng cả pipeline.
187
+ if name == "edge":
188
+ raise
189
+ import sys
190
+ print(f"[tts] engine '{name}' không nạp được ({e}); dùng Edge TTS thay.",
191
+ file=sys.stderr)
192
+ if "edge" not in _INSTANCES:
193
+ _INSTANCES["edge"] = EdgeTTSEngine()
194
+ _INSTANCES[name] = _INSTANCES["edge"]
195
  return _INSTANCES[name]
196
 
197
 
 
210
  ("Bình An — nam, giọng điềm đạm", "Bình An"),
211
  ("Ngọc Linh — nữ, giọng tươi sáng", "Ngọc Linh"),
212
  ],
213
+ # en & fil chạy Edge TTS -> lấy từ _EDGE_VOICE_LIST cho khớp giọng thật.
214
+ "en": _EDGE_VOICE_LIST["en"],
215
+ "fil": _EDGE_VOICE_LIST["fil"],
 
 
 
 
 
 
 
 
 
 
216
  }
217
 
218
 
 
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):
226
  try:
227
  eng = load_engine(VoiceConfig(target_lang=lang))
228
  vs = eng.list_voices()
frontend/src/pages/CutVideo.jsx CHANGED
@@ -29,6 +29,135 @@ const fmt = (s) => {
29
  return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
30
  };
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  export default function CutVideo() {
33
  const [sp] = useSearchParams();
34
  const nav = useNavigate();
@@ -43,21 +172,14 @@ export default function CutVideo() {
43
  const busy = st.busy;
44
  const setJob = (j) => set({ job: j });
45
  const setBusy = (b) => set({ busy: b });
46
- const [playFrame, setPlayFrame] = useState(0); // vị trí đầu phát (đen) chạy theo video
47
  const [prevBusy, setPrevBusy] = useState(false); // đang tạo xem trước -> hiện overlay chờ
48
- const [selCut, setSelCut] = useState(-1);
49
  const [showNote, setShowNote] = useState(true);
50
- const stripRef = useRef();
51
- const videoRef = useRef();
52
- const dragIdx = useRef(-1);
53
- const stopAt = useRef(null);
54
 
55
  const videos = st.videos;
56
  const active = Math.min(st.active, Math.max(0, videos.length - 1));
57
  const cur = videos[active];
58
 
59
- const patchActive = (patch) =>
60
- set({ videos: videos.map((v, i) => (i === active ? { ...v, ...patch } : v)) });
61
  const patchVideo = (index, patch) =>
62
  set((s) => ({ videos: (s.videos || []).map((v, i) => (i === index ? { ...v, ...patch } : v)) }));
63
 
@@ -85,7 +207,10 @@ export default function CutVideo() {
85
  }, []);
86
 
87
  const onFiles = async (files) => {
88
- for (const file of files) {
 
 
 
89
  try {
90
  const r = await api.cutUpload(file, cur?.session);
91
  const v = {
@@ -96,6 +221,7 @@ export default function CutVideo() {
96
  set((s) => ({ videos: [...(s.videos || []), v] }));
97
  } catch (e) { alert("Lỗi tải video: " + e.message); }
98
  }
 
99
  };
100
 
101
  const removeVideo = (i) => (e) => {
@@ -104,11 +230,11 @@ export default function CutVideo() {
104
  set({ videos: next, active: Math.max(0, Math.min(active, next.length - 1)) });
105
  };
106
 
107
- // Xem trước cho 1 video CỤ THỂ (theo index) -> mỗi tab có dải ảnh riêng.
108
  const doPreview = async (index) => {
109
  const vid = videos[index];
110
- if (!vid || vid.previewing) return;
111
- patchVideo(index, { previewing: true }); setPrevBusy(true);
112
  try {
113
  const r = await api.cutPreview({
114
  session: vid.session, file: vid.file, mode: st.mode,
@@ -116,93 +242,20 @@ export default function CutVideo() {
116
  });
117
  const interior = r.segments.slice(0, -1).map((s) => s.end_frame);
118
  patchVideo(index, { thumbs: r.thumbnails, cuts: interior, info: r.info, previewing: false });
119
- } catch (e) { patchVideo(index, { previewing: false }); alert("Lỗi xem trước: " + e.message); }
120
- finally { setPrevBusy(false); }
121
- };
122
- const preview = () => { doPreview(active); setSelCut(-1); setPlayFrame(0); };
123
- // Đổi tab video: nếu video đó CHƯA có dải ảnh thì tự xem trước (để tab nào cũng hiện).
124
- const switchTo = (i) => {
125
- set({ active: i }); setSelCut(-1); setPlayFrame(0);
126
- const v = videos[i];
127
- if (v && !(v.thumbs && v.thumbs.length) && !v.previewing) doPreview(i);
128
  };
129
 
130
- const cuts = cur?.cuts || [];
131
- const nf = cur?.info?.nb_frames || 1;
132
- const fps = cur?.info?.fps || 30;
133
- const frameToPct = (f) => (f / nf) * 100;
134
- const xToFrame = (clientX) => {
135
- const rect = stripRef.current.getBoundingClientRect();
136
- const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
137
- return Math.round(ratio * nf);
138
- };
139
-
140
- // bấm dải -> seek video tới đó; playhead đen sẽ nhảy theo
141
- const onStripClick = (e) => {
142
- if (!cur || dragIdx.current >= 0) return;
143
- const f = xToFrame(e.clientX);
144
- setPlayFrame(f); setSelCut(-1);
145
- if (videoRef.current) videoRef.current.currentTime = f / fps;
146
- };
147
-
148
- const addCut = () => {
149
- if (!cur) return;
150
- const f = playFrame;
151
- if (f <= 2 || f >= nf - 2) return;
152
- patchActive({ cuts: [...cuts, f].sort((a, b) => a - b) });
153
- };
154
- const deleteCut = () => {
155
- if (selCut < 0) return;
156
- patchActive({ cuts: cuts.filter((_, j) => j !== selCut) });
157
- setSelCut(-1);
158
  };
159
 
160
- const startDrag = (i) => (e) => { e.stopPropagation(); dragIdx.current = i; setSelCut(i); };
161
- useEffect(() => {
162
- const move = (e) => {
163
- if (dragIdx.current < 0 || !cur) return;
164
- const f = Math.min(nf - 2, Math.max(2, xToFrame(e.clientX)));
165
- patchActive({ cuts: cuts.map((c, j) => (j === dragIdx.current ? f : c)) });
166
- };
167
- const up = () => {
168
- if (dragIdx.current >= 0) { dragIdx.current = -1; patchActive({ cuts: [...cuts].sort((a, b) => a - b) }); }
169
- };
170
- window.addEventListener("pointermove", move);
171
- window.addEventListener("pointerup", up);
172
- return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
173
- // eslint-disable-next-line
174
- }, [cur, cuts]);
175
-
176
- const playToNextCut = () => {
177
- const v = videoRef.current; if (!v || !cur) return;
178
- const times = cuts.map((f) => f / fps).sort((a, b) => a - b);
179
- const next = times.find((t) => t > v.currentTime + 0.05);
180
- stopAt.current = next != null ? next : cur.info.duration;
181
- v.play();
182
- };
183
- // playhead đen chạy MƯỢT theo requestAnimationFrame (thay vì timeupdate ~4Hz giật cục)
184
- useEffect(() => {
185
- const v = videoRef.current;
186
- if (!v || !cur) return;
187
- let raf;
188
- const loop = () => {
189
- setPlayFrame(Math.round(v.currentTime * fps));
190
- if (stopAt.current != null && v.currentTime >= stopAt.current) { v.pause(); stopAt.current = null; }
191
- raf = requestAnimationFrame(loop);
192
- };
193
- const onPlay = () => { cancelAnimationFrame(raf); loop(); };
194
- const onStop = () => { cancelAnimationFrame(raf); setPlayFrame(Math.round(v.currentTime * fps)); };
195
- v.addEventListener("play", onPlay);
196
- v.addEventListener("pause", onStop);
197
- v.addEventListener("seeked", onStop);
198
- return () => {
199
- cancelAnimationFrame(raf);
200
- v.removeEventListener("play", onPlay);
201
- v.removeEventListener("pause", onStop);
202
- v.removeEventListener("seeked", onStop);
203
- };
204
- // eslint-disable-next-line
205
- }, [cur, fps]);
206
 
207
  const segmentsFromCuts = (video) => {
208
  const c = video.cuts || [];
@@ -259,6 +312,8 @@ export default function CutVideo() {
259
  }, i * 300));
260
  };
261
 
 
 
262
  return (
263
  <div>
264
  <div className="h1"><IcStar size={22} /> Workspace: <span className="accent">Cut video</span></div>
@@ -270,6 +325,11 @@ export default function CutVideo() {
270
  <div className="card">
271
  <div className="panel-title">1. Tải lên video nguồn</div>
272
  <FileDrop onFiles={onFiles} hint="MP4, MOV, AVI, MKV, WebM · có thể thêm nhiều video" />
 
 
 
 
 
273
  {videos.length > 0 && (
274
  <div className="vtabs mt12">
275
  {videos.map((v, i) => (
@@ -318,54 +378,26 @@ export default function CutVideo() {
318
  Gợi ý ~{cur.auto_count} khúc · {fmt(cur.info.duration)} · {cur.info.fps}fps
319
  </div>
320
  )}
321
- <button className="btn right" onClick={preview} disabled={!cur || cur?.previewing}>
322
- {cur?.previewing ? "Đang tạo xem trước…" : "Xem trước điểm cắt"}
323
  </button>
324
  </div>
325
  </div>
326
 
327
- {cur && cur.thumbs?.length > 0 && (
328
- <div className="card">
329
- <div className="flex between center">
330
- <div className="panel-title clip" style={{ margin: 0 }} title={cur.name}>3. Timeline — {cur.name} ({cuts.length + 1} khúc)</div>
331
- <span className="small muted" style={{ flex: "0 0 auto" }}>kéo dấu cắt để chỉnh</span>
332
- </div>
333
-
334
- <video ref={videoRef} src={cur.url} controls className="mt12"
335
- style={{ display: "block", margin: "0 auto", maxWidth: "100%", maxHeight: 340, width: "auto", borderRadius: 10 }} />
336
-
337
- <div className="cut-toolbar mt12">
338
- <button className="btn sm" onClick={playToNextCut}><IcPlay size={14} /> Phát tới điểm cắt</button>
339
- <button className="btn sm" onClick={addCut}><IcPlus size={14} /> Thêm cắt</button>
340
- <button className="btn sm" onClick={deleteCut} disabled={selCut < 0}><IcTrash size={14} /> Xoá cắt</button>
341
- <span className="small muted right">{fmt(playFrame / fps)} / {fmt(cur.info.duration)}</span>
342
- </div>
343
-
344
- <div className="timeline-wrap mt12">
345
- <div className="timeline" ref={stripRef} onClick={onStripClick}>
346
- <div className="strip">
347
- {cur.thumbs.map((t, i) => <img key={i} src={fileUrl(t.url)} alt="" draggable={false} />)}
348
- </div>
349
- <div className="playhead" style={{ left: frameToPct(playFrame) + "%" }} />
350
- {cuts.map((f, i) => (
351
- <div key={i} className={"cutmark" + (i === selCut ? " sel" : "")}
352
- style={{ left: frameToPct(f) + "%" }}
353
- onPointerDown={startDrag(i)}
354
- onClick={(e) => { e.stopPropagation(); setSelCut(i); }}>
355
- <div className={"pin" + (i === selCut ? " sel" : "")}>{i + 1}</div>
356
- </div>
357
- ))}
358
- </div>
359
- <div className="timeline-ticks">
360
- <span>00:00</span><span>{fmt(cur.info.duration / 2)}</span><span>{fmt(cur.info.duration)}</span>
361
- </div>
362
  </div>
363
-
364
- <button className="btn primary block mt16" onClick={executeAll} disabled={busy}>
365
- {busy ? "Đang cắt..." : <>Cắt {videos.length > 1 ? `tất cả ${videos.length} video` : "video"} <IcArrow size={16} /></>}
366
- </button>
367
- <p className="small muted mt8">Mỗi video dùng điểm cắt riêng (xem trước từng tab). Bấm “Cắt” sẽ xử lý tất cả video đã tải.</p>
368
- </div>
369
  )}
370
 
371
  <ProcessingOverlay busy={busy || prevBusy} job={busy ? job : null}
 
29
  return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
30
  };
31
 
32
+ // Timeline của MỘT video, tự quản lý state riêng (playhead, dấu cắt đang chọn, kéo thả)
33
+ // -> nhiều video hiển thị cùng lúc, điểm cắt của video nào đi theo video đó.
34
+ function VideoTimeline({ video, index, patchVideo }) {
35
+ const [playFrame, setPlayFrame] = useState(0);
36
+ const [selCut, setSelCut] = useState(-1);
37
+ const stripRef = useRef();
38
+ const videoRef = useRef();
39
+ const dragIdx = useRef(-1);
40
+ const stopAt = useRef(null);
41
+
42
+ const cuts = video.cuts || [];
43
+ const nf = video.info?.nb_frames || 1;
44
+ const fps = video.info?.fps || 30;
45
+ const dur = video.info?.duration || 0;
46
+ const frameToPct = (f) => (f / nf) * 100;
47
+ const patch = (p) => patchVideo(index, p);
48
+ const xToFrame = (clientX) => {
49
+ const rect = stripRef.current.getBoundingClientRect();
50
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
51
+ return Math.round(ratio * nf);
52
+ };
53
+
54
+ // bấm dải -> seek video tới đó; playhead đen nhảy theo
55
+ const onStripClick = (e) => {
56
+ if (dragIdx.current >= 0) return;
57
+ const f = xToFrame(e.clientX);
58
+ setPlayFrame(f); setSelCut(-1);
59
+ if (videoRef.current) videoRef.current.currentTime = f / fps;
60
+ };
61
+
62
+ const addCut = () => {
63
+ const f = playFrame;
64
+ if (f <= 2 || f >= nf - 2) return;
65
+ patch({ cuts: [...cuts, f].sort((a, b) => a - b) });
66
+ };
67
+ const deleteCut = () => {
68
+ if (selCut < 0) return;
69
+ patch({ cuts: cuts.filter((_, j) => j !== selCut) });
70
+ setSelCut(-1);
71
+ };
72
+
73
+ const startDrag = (i) => (e) => { e.stopPropagation(); dragIdx.current = i; setSelCut(i); };
74
+ useEffect(() => {
75
+ const move = (e) => {
76
+ if (dragIdx.current < 0) return;
77
+ const f = Math.min(nf - 2, Math.max(2, xToFrame(e.clientX)));
78
+ patch({ cuts: cuts.map((c, j) => (j === dragIdx.current ? f : c)) });
79
+ };
80
+ const up = () => {
81
+ if (dragIdx.current >= 0) { dragIdx.current = -1; patch({ cuts: [...cuts].sort((a, b) => a - b) }); }
82
+ };
83
+ window.addEventListener("pointermove", move);
84
+ window.addEventListener("pointerup", up);
85
+ return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
86
+ // eslint-disable-next-line
87
+ }, [cuts, nf]);
88
+
89
+ const playToNextCut = () => {
90
+ const v = videoRef.current; if (!v) return;
91
+ const times = cuts.map((f) => f / fps).sort((a, b) => a - b);
92
+ const next = times.find((t) => t > v.currentTime + 0.05);
93
+ stopAt.current = next != null ? next : dur;
94
+ v.play();
95
+ };
96
+ // playhead đen chạy MƯỢT theo requestAnimationFrame
97
+ useEffect(() => {
98
+ const v = videoRef.current; if (!v) return;
99
+ let raf;
100
+ const loop = () => {
101
+ setPlayFrame(Math.round(v.currentTime * fps));
102
+ if (stopAt.current != null && v.currentTime >= stopAt.current) { v.pause(); stopAt.current = null; }
103
+ raf = requestAnimationFrame(loop);
104
+ };
105
+ const onPlay = () => { cancelAnimationFrame(raf); loop(); };
106
+ const onStop = () => { cancelAnimationFrame(raf); setPlayFrame(Math.round(v.currentTime * fps)); };
107
+ v.addEventListener("play", onPlay);
108
+ v.addEventListener("pause", onStop);
109
+ v.addEventListener("seeked", onStop);
110
+ return () => {
111
+ cancelAnimationFrame(raf);
112
+ v.removeEventListener("play", onPlay);
113
+ v.removeEventListener("pause", onStop);
114
+ v.removeEventListener("seeked", onStop);
115
+ };
116
+ // eslint-disable-next-line
117
+ }, [fps]);
118
+
119
+ return (
120
+ <div className="card">
121
+ <div className="flex between center">
122
+ <div className="panel-title clip" style={{ margin: 0 }} title={video.name}>
123
+ Timeline — {video.name} ({cuts.length + 1} khúc)
124
+ </div>
125
+ <span className="small muted" style={{ flex: "0 0 auto" }}>kéo dấu cắt để chỉnh</span>
126
+ </div>
127
+
128
+ <video ref={videoRef} src={video.url} controls className="mt12"
129
+ style={{ display: "block", margin: "0 auto", maxWidth: "100%", maxHeight: 340, width: "auto", borderRadius: 10 }} />
130
+
131
+ <div className="cut-toolbar mt12">
132
+ <button className="btn sm" onClick={playToNextCut}><IcPlay size={14} /> Phát tới điểm cắt</button>
133
+ <button className="btn sm" onClick={addCut}><IcPlus size={14} /> Thêm cắt</button>
134
+ <button className="btn sm" onClick={deleteCut} disabled={selCut < 0}><IcTrash size={14} /> Xoá cắt</button>
135
+ <span className="small muted right">{fmt(playFrame / fps)} / {fmt(dur)}</span>
136
+ </div>
137
+
138
+ <div className="timeline-wrap mt12">
139
+ <div className="timeline" ref={stripRef} onClick={onStripClick}>
140
+ <div className="strip">
141
+ {(video.thumbs || []).map((t, i) => <img key={i} src={fileUrl(t.url)} alt="" draggable={false} />)}
142
+ </div>
143
+ <div className="playhead" style={{ left: frameToPct(playFrame) + "%" }} />
144
+ {cuts.map((f, i) => (
145
+ <div key={i} className={"cutmark" + (i === selCut ? " sel" : "")}
146
+ style={{ left: frameToPct(f) + "%" }}
147
+ onPointerDown={startDrag(i)}
148
+ onClick={(e) => { e.stopPropagation(); setSelCut(i); }}>
149
+ <div className={"pin" + (i === selCut ? " sel" : "")}>{i + 1}</div>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ <div className="timeline-ticks">
154
+ <span>00:00</span><span>{fmt(dur / 2)}</span><span>{fmt(dur)}</span>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
  export default function CutVideo() {
162
  const [sp] = useSearchParams();
163
  const nav = useNavigate();
 
172
  const busy = st.busy;
173
  const setJob = (j) => set({ job: j });
174
  const setBusy = (b) => set({ busy: b });
 
175
  const [prevBusy, setPrevBusy] = useState(false); // đang tạo xem trước -> hiện overlay chờ
 
176
  const [showNote, setShowNote] = useState(true);
177
+ const [uploading, setUploading] = useState(null); // {name,i,n} — video đang tải lên
 
 
 
178
 
179
  const videos = st.videos;
180
  const active = Math.min(st.active, Math.max(0, videos.length - 1));
181
  const cur = videos[active];
182
 
 
 
183
  const patchVideo = (index, patch) =>
184
  set((s) => ({ videos: (s.videos || []).map((v, i) => (i === index ? { ...v, ...patch } : v)) }));
185
 
 
207
  }, []);
208
 
209
  const onFiles = async (files) => {
210
+ const arr = Array.from(files);
211
+ for (let i = 0; i < arr.length; i++) {
212
+ const file = arr[i];
213
+ setUploading({ name: file.name, i: i + 1, n: arr.length });
214
  try {
215
  const r = await api.cutUpload(file, cur?.session);
216
  const v = {
 
221
  set((s) => ({ videos: [...(s.videos || []), v] }));
222
  } catch (e) { alert("Lỗi tải video: " + e.message); }
223
  }
224
+ setUploading(null);
225
  };
226
 
227
  const removeVideo = (i) => (e) => {
 
230
  set({ videos: next, active: Math.max(0, Math.min(active, next.length - 1)) });
231
  };
232
 
233
+ // Xem trước cho 1 video CỤ THỂ (theo index) -> tạo dải ảnh + điểm cắt tự động.
234
  const doPreview = async (index) => {
235
  const vid = videos[index];
236
+ if (!vid) return;
237
+ patchVideo(index, { previewing: true });
238
  try {
239
  const r = await api.cutPreview({
240
  session: vid.session, file: vid.file, mode: st.mode,
 
242
  });
243
  const interior = r.segments.slice(0, -1).map((s) => s.end_frame);
244
  patchVideo(index, { thumbs: r.thumbnails, cuts: interior, info: r.info, previewing: false });
245
+ } catch (e) { patchVideo(index, { previewing: false }); throw e; }
 
 
 
 
 
 
 
 
246
  };
247
 
248
+ // Xem điểm cắt cho TẤT CẢ video đã tải (mỗi video có timeline riêng).
249
+ const previewAll = async () => {
250
+ if (!videos.length) return;
251
+ setPrevBusy(true);
252
+ try {
253
+ for (let i = 0; i < videos.length; i++) await doPreview(i);
254
+ } catch (e) { alert("Lỗi xem trước: " + e.message); }
255
+ finally { setPrevBusy(false); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  };
257
 
258
+ const switchTo = (i) => set({ active: i });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  const segmentsFromCuts = (video) => {
261
  const c = video.cuts || [];
 
312
  }, i * 300));
313
  };
314
 
315
+ const anyPreviewed = videos.some((v) => v.thumbs && v.thumbs.length > 0);
316
+
317
  return (
318
  <div>
319
  <div className="h1"><IcStar size={22} /> Workspace: <span className="accent">Cut video</span></div>
 
325
  <div className="card">
326
  <div className="panel-title">1. Tải lên video nguồn</div>
327
  <FileDrop onFiles={onFiles} hint="MP4, MOV, AVI, MKV, WebM · có thể thêm nhiều video" />
328
+ {uploading && (
329
+ <div className="small mt8" style={{ color: "var(--orange-700)" }}>
330
+ ⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
331
+ </div>
332
+ )}
333
  {videos.length > 0 && (
334
  <div className="vtabs mt12">
335
  {videos.map((v, i) => (
 
378
  Gợi ý ~{cur.auto_count} khúc · {fmt(cur.info.duration)} · {cur.info.fps}fps
379
  </div>
380
  )}
381
+ <button className="btn right" onClick={previewAll} disabled={!videos.length || prevBusy}>
382
+ {prevBusy ? "Đang tạo xem trước…" : `Xem điểm cắt${videos.length > 1 ? ` (${videos.length} video)` : ""}`}
383
  </button>
384
  </div>
385
  </div>
386
 
387
+ {anyPreviewed && (
388
+ <>
389
+ {videos.map((v, i) =>
390
+ (v.thumbs && v.thumbs.length > 0)
391
+ ? <VideoTimeline key={i} video={v} index={i} patchVideo={patchVideo} />
392
+ : null
393
+ )}
394
+ <div className="card">
395
+ <button className="btn primary block" onClick={executeAll} disabled={busy}>
396
+ {busy ? "Đang cắt..." : <>Cắt {videos.length > 1 ? `tất cả ${videos.length} video` : "video"} <IcArrow size={16} /></>}
397
+ </button>
398
+ <p className="small muted mt8">Mỗi video dùng điểm cắt riêng của nó. Bấm “Cắt” sẽ xử lý tất cả video đã tải cùng lúc.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  </div>
400
+ </>
 
 
 
 
 
401
  )}
402
 
403
  <ProcessingOverlay busy={busy || prevBusy} job={busy ? job : null}
frontend/src/pages/DownList.jsx CHANGED
@@ -1,10 +1,10 @@
1
- import { useState } from "react";
2
  import { useNavigate } 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, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
- import { IcRefresh, IcTrash, IcDownload, IcArrow, IcScissors, IcMic, IcCheck } from "../components/Icons";
8
 
9
  const STEPS = [
10
  { t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
@@ -38,11 +38,25 @@ export default function DownList() {
38
  const setJob = (j) => set({ job: j });
39
  const setBusy = (b) => set({ busy: b });
40
  const [showNote, setShowNote] = useState(true);
 
 
41
 
42
- const parse = async () => {
43
- if (!st.raw.trim()) return;
44
- const r = await api.parseLinks(st.raw);
45
- set({ items: r.items });
 
 
 
 
 
 
 
 
 
 
 
 
46
  };
47
 
48
  const start = async () => {
@@ -103,10 +117,10 @@ export default function DownList() {
103
  <div className="panel-title">1. Dán danh sách link</div>
104
  <textarea
105
  placeholder={"Dán nhiều link video, mỗi link một dòng...\nhttps://www.youtube.com/watch?v=abc123\nhttps://www.tiktok.com/@user/video/123"}
106
- value={st.raw} onChange={(e) => set({ raw: e.target.value })} />
107
  <div className="flex between center mt12">
108
  <span className="muted small">Tối đa 200 link/lần ({st.items.length}/200)</span>
109
- <button className="btn sm" onClick={parse}><IcRefresh size={15} /> Phân tích</button>
110
  </div>
111
  <div className="toggle-row mt16">
112
  <Toggle on={st.zip} onChange={(v) => set({ zip: v })} />
@@ -186,8 +200,8 @@ export default function DownList() {
186
  <td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
187
  <td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}>Về máy</a></td>
188
  <td>
189
- <div className="flex gap8 center" style={{ flexWrap: "wrap" }}>
190
- <div className="toolchips">
191
  {TOOLS.map((t) => (
192
  <span key={t.id} className={"toolchip" + (sel[t.id] ? " on" : "")} onClick={() => toggleTool(r.path, t.id)}>
193
  <span className="tick-mini">{sel[t.id] && <IcCheck size={10} />}</span>
 
1
+ import { useState, useRef } from "react";
2
  import { useNavigate } 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, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
7
+ import { IcTrash, IcDownload, IcArrow, IcScissors, IcMic, IcCheck } from "../components/Icons";
8
 
9
  const STEPS = [
10
  { t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
 
38
  const setJob = (j) => set({ job: j });
39
  const setBusy = (b) => set({ busy: b });
40
  const [showNote, setShowNote] = useState(true);
41
+ const [analyzing, setAnalyzing] = useState(false);
42
+ const parseTimer = useRef(null);
43
 
44
+ // Phân tích danh sách link. Gọi tự động (debounce) mỗi khi textarea đổi.
45
+ const runParse = async (text) => {
46
+ if (!text.trim()) { set({ items: [] }); return; }
47
+ setAnalyzing(true);
48
+ try {
49
+ const r = await api.parseLinks(text);
50
+ set({ items: r.items });
51
+ } catch (e) { /* im lặng khi đang gõ dở */ }
52
+ finally { setAnalyzing(false); }
53
+ };
54
+
55
+ // Tự động phân tích khi dán / sửa / xóa link (debounce 500ms để không gọi API liên tục).
56
+ const onRawChange = (val) => {
57
+ set({ raw: val });
58
+ if (parseTimer.current) clearTimeout(parseTimer.current);
59
+ parseTimer.current = setTimeout(() => runParse(val), 500);
60
  };
61
 
62
  const start = async () => {
 
117
  <div className="panel-title">1. Dán danh sách link</div>
118
  <textarea
119
  placeholder={"Dán nhiều link video, mỗi link một dòng...\nhttps://www.youtube.com/watch?v=abc123\nhttps://www.tiktok.com/@user/video/123"}
120
+ value={st.raw} onChange={(e) => onRawChange(e.target.value)} />
121
  <div className="flex between center mt12">
122
  <span className="muted small">Tối đa 200 link/lần ({st.items.length}/200)</span>
123
+ <span className="muted small">{analyzing ? "Đang phân tích…" : "Tự động phân tích khi dán / sửa link"}</span>
124
  </div>
125
  <div className="toggle-row mt16">
126
  <Toggle on={st.zip} onChange={(v) => set({ zip: v })} />
 
200
  <td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
201
  <td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}>Về máy</a></td>
202
  <td>
203
+ <div className="flex gap8 center" style={{ flexWrap: "nowrap", whiteSpace: "nowrap" }}>
204
+ <div className="toolchips" style={{ flexWrap: "nowrap" }}>
205
  {TOOLS.map((t) => (
206
  <span key={t.id} className={"toolchip" + (sel[t.id] ? " on" : "")} onClick={() => toggleTool(r.path, t.id)}>
207
  <span className="tick-mini">{sel[t.id] && <IcCheck size={10} />}</span>
frontend/src/pages/DubVideo.jsx CHANGED
@@ -18,8 +18,8 @@ const LANGS = [
18
  { id: "en", label: "English" },
19
  { id: "fil", label: "Filipino" },
20
  ];
21
- // Model chốt theo ngôn ngữ (không cho chọn): vi→VieNeu, en→XTTS-v2, fil→Edge TTS.
22
- const MODEL_LABEL = { vi: "VieNeu-TTS", en: "XTTS-v2", fil: "Edge TTS" };
23
  const BG = [
24
  { id: "duck", label: "Giảm tiếng gốc" },
25
  { id: "demucs", label: "Tách nhạc (giữ nền)" },
@@ -72,6 +72,7 @@ export default function DubVideo() {
72
  busy: false, ttsBusy: false, job: null,
73
  });
74
  const [showNote, setShowNote] = useState(true);
 
75
  const [voices, setVoices] = useState([]); // giọng cho khu dịch video
76
  const [ttsVoices, setTtsVoices] = useState([]); // giọng cho khu gen voice
77
  const job = st.job;
@@ -83,7 +84,8 @@ export default function DubVideo() {
83
  const sampleRef = useRef();
84
  const ttsSampleRef = useRef();
85
 
86
- const isFil = (t) => t === "fil";
 
87
 
88
  useEffect(() => {
89
  const sid = sp.get("session");
@@ -115,11 +117,17 @@ export default function DubVideo() {
115
  const addVideos = async (files) => {
116
  let sid = st.session;
117
  const added = [];
118
- for (const file of files) {
119
- const r = await api.dubUpload(file, sid);
120
- sid = r.session;
121
- added.push({ file: r.file, name: r.name, size: r.size, url: `/api/files/${sid}/${r.file}` });
 
 
 
 
 
122
  }
 
123
  set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
124
  };
125
  const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
@@ -139,19 +147,19 @@ export default function DubVideo() {
139
  if (!st.videos.length) return;
140
  setBusy(true); set({ outputs: [] });
141
  try {
142
- const fil = isFil(st.target);
143
  const src = st.voiceSource; // preset | video | upload
144
- const doClone = !fil && (src === "video" || src === "upload");
145
  const { job_id } = await api.dubStart({
146
  session: st.session, files: st.videos.map((v) => v.file),
147
  target_lang: st.target, bg_mode: st.bg,
148
  do_clone: doClone,
149
- voice_source: fil ? "preset" : src,
150
  speaker_file: st.speakerFile,
151
- preset: (!fil && src === "preset") ? st.voice : "",
152
  style: st.style, engine: "auto", multi_speaker: st.multiSpeaker,
153
  username: user,
154
- ...qualityPayload(!fil && st.useQuality, st.temperature, st.topK, st.repPen),
155
  });
156
  const done = await pollJob(job_id, setJob);
157
  const outs = done.result?.outputs ||
@@ -184,15 +192,15 @@ export default function DubVideo() {
184
  if (!tts.text.trim()) return alert("Nhập text cần đọc.");
185
  setTtsBusy(true); setTts({ result: null });
186
  try {
187
- const fil = isFil(tts.target);
188
  const src = tts.voiceSource; // preset | upload
189
  const { job_id } = await api.dubTts({
190
  session: st.session || "", text: tts.text, target_lang: tts.target,
191
- voice_source: fil ? "preset" : src,
192
  speaker_file: tts.speakerFile,
193
- preset: (!fil && src === "preset") ? tts.voice : "",
194
  style: tts.style, engine: "auto",
195
- ...qualityPayload(!fil && tts.useQuality, tts.temperature, tts.topK, tts.repPen),
196
  });
197
  const done = await pollJob(job_id, setJob);
198
  const out = done.result?.output ||
@@ -216,6 +224,11 @@ export default function DubVideo() {
216
  <div className="card">
217
  <div className="panel-title">1. Tải lên video cần dịch</div>
218
  <FileDrop onFiles={addVideos} hint="MP4, MOV, AVI, MKV · có thể chọn nhiều video" />
 
 
 
 
 
219
  {st.videos.length > 0 && (
220
  <div className="filelist mt12">
221
  {st.videos.map((v, i) => (
@@ -253,9 +266,13 @@ export default function DubVideo() {
253
  </div>
254
  </div>
255
 
256
- {isFil(st.target) ? (
257
- <div className="small muted mt16">
258
- Filipino dùng <b>Edge TTS</b> với một giọng dựng sẵn — không chọn nam/nữ, không clone.
 
 
 
 
259
  </div>
260
  ) : (
261
  <>
@@ -374,8 +391,14 @@ export default function DubVideo() {
374
  </div>
375
  </div>
376
 
377
- {isFil(tts.target) ? (
378
- <div className="small muted mt12">Filipino dùng Edge TTS, một giọng dựng sẵn — không clone.</div>
 
 
 
 
 
 
379
  ) : (
380
  <>
381
  <div className="mt12">
@@ -419,7 +442,8 @@ export default function DubVideo() {
419
  </div>
420
  </div>
421
 
422
- <ProcessRail steps={STEPS} tip="Model theo ngôn ngữ: vi → VieNeu (10 giọng + clone); en → XTTS-v2 (giọng studio + clone); fil → Edge TTS (1 giọng). Tốc độ đọc máy tự khớp video." />
 
423
  </div>
424
  </div>
425
  );
 
18
  { id: "en", label: "English" },
19
  { id: "fil", label: "Filipino" },
20
  ];
21
+ // Model chốt theo ngôn ngữ (không cho chọn): vi→VieNeu (clone được), en & fil→Edge TTS.
22
+ const MODEL_LABEL = { vi: "VieNeu-TTS", en: "Edge TTS", fil: "Edge TTS" };
23
  const BG = [
24
  { id: "duck", label: "Giảm tiếng gốc" },
25
  { id: "demucs", label: "Tách nhạc (giữ nền)" },
 
72
  busy: false, ttsBusy: false, job: null,
73
  });
74
  const [showNote, setShowNote] = useState(true);
75
+ const [uploading, setUploading] = useState(null); // {name,i,n} — video đang tải lên
76
  const [voices, setVoices] = useState([]); // giọng cho khu dịch video
77
  const [ttsVoices, setTtsVoices] = useState([]); // giọng cho khu gen voice
78
  const job = st.job;
 
84
  const sampleRef = useRef();
85
  const ttsSampleRef = useRef();
86
 
87
+ // en & fil chạy Edge TTS: có bộ chọn giọng dựng sẵn, KHÔNG clone. vi (VieNeu) clone được.
88
+ const isEdge = (t) => t === "fil" || t === "en";
89
 
90
  useEffect(() => {
91
  const sid = sp.get("session");
 
117
  const addVideos = async (files) => {
118
  let sid = st.session;
119
  const added = [];
120
+ const arr = Array.from(files);
121
+ for (let i = 0; i < arr.length; i++) {
122
+ const file = arr[i];
123
+ setUploading({ name: file.name, i: i + 1, n: arr.length });
124
+ try {
125
+ const r = await api.dubUpload(file, sid);
126
+ sid = r.session;
127
+ added.push({ file: r.file, name: r.name, size: r.size, url: `/api/files/${sid}/${r.file}` });
128
+ } catch (e) { alert("Lỗi tải video: " + e.message); }
129
  }
130
+ setUploading(null);
131
  set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
132
  };
133
  const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
 
147
  if (!st.videos.length) return;
148
  setBusy(true); set({ outputs: [] });
149
  try {
150
+ const edge = isEdge(st.target);
151
  const src = st.voiceSource; // preset | video | upload
152
+ const doClone = !edge && (src === "video" || src === "upload");
153
  const { job_id } = await api.dubStart({
154
  session: st.session, files: st.videos.map((v) => v.file),
155
  target_lang: st.target, bg_mode: st.bg,
156
  do_clone: doClone,
157
+ voice_source: edge ? "preset" : src,
158
  speaker_file: st.speakerFile,
159
+ preset: edge ? st.voice : (src === "preset" ? st.voice : ""),
160
  style: st.style, engine: "auto", multi_speaker: st.multiSpeaker,
161
  username: user,
162
+ ...qualityPayload(!edge && st.useQuality, st.temperature, st.topK, st.repPen),
163
  });
164
  const done = await pollJob(job_id, setJob);
165
  const outs = done.result?.outputs ||
 
192
  if (!tts.text.trim()) return alert("Nhập text cần đọc.");
193
  setTtsBusy(true); setTts({ result: null });
194
  try {
195
+ const edge = isEdge(tts.target);
196
  const src = tts.voiceSource; // preset | upload
197
  const { job_id } = await api.dubTts({
198
  session: st.session || "", text: tts.text, target_lang: tts.target,
199
+ voice_source: edge ? "preset" : src,
200
  speaker_file: tts.speakerFile,
201
+ preset: edge ? tts.voice : (src === "preset" ? tts.voice : ""),
202
  style: tts.style, engine: "auto",
203
+ ...qualityPayload(!edge && tts.useQuality, tts.temperature, tts.topK, tts.repPen),
204
  });
205
  const done = await pollJob(job_id, setJob);
206
  const out = done.result?.output ||
 
224
  <div className="card">
225
  <div className="panel-title">1. Tải lên video cần dịch</div>
226
  <FileDrop onFiles={addVideos} hint="MP4, MOV, AVI, MKV · có thể chọn nhiều video" />
227
+ {uploading && (
228
+ <div className="small mt8" style={{ color: "var(--orange-700)" }}>
229
+ ⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
230
+ </div>
231
+ )}
232
  {st.videos.length > 0 && (
233
  <div className="filelist mt12">
234
  {st.videos.map((v, i) => (
 
266
  </div>
267
  </div>
268
 
269
+ {isEdge(st.target) ? (
270
+ <div className="mt16">
271
+ <span className="field-label">Chọn giọng ({voices.length})</span>
272
+ <select value={st.voice} onChange={(e) => set({ voice: e.target.value })} style={{ minWidth: 260 }}>
273
+ {voices.map((v) => <option key={v.id} value={v.id}>{v.label}</option>)}
274
+ </select>
275
+ <div className="small muted mt8">Dùng <b>Edge TTS</b> — giọng dựng sẵn (chọn nam/nữ), không clone giọng.</div>
276
  </div>
277
  ) : (
278
  <>
 
391
  </div>
392
  </div>
393
 
394
+ {isEdge(tts.target) ? (
395
+ <div className="mt12">
396
+ <span className="field-label">Chọn giọng ({ttsVoices.length})</span>
397
+ <select value={tts.voice} onChange={(e) => setTts({ voice: e.target.value })} style={{ minWidth: 260 }}>
398
+ {ttsVoices.map((v) => <option key={v.id} value={v.id}>{v.label}</option>)}
399
+ </select>
400
+ <div className="small muted mt8">Edge TTS — giọng dựng sẵn, không clone.</div>
401
+ </div>
402
  ) : (
403
  <>
404
  <div className="mt12">
 
442
  </div>
443
  </div>
444
 
445
+ <ProcessRail steps={STEPS} tip="Model theo ngôn ngữ: vi → VieNeu (10 giọng + clone giọng); en & fil → Edge TTS (nhiều giọng nam/nữ, không clone). Dịch theo ngữ điệu quảng cáo; tốc độ đọc máy tự khớp video." />
446
+
447
  </div>
448
  </div>
449
  );
frontend/src/pages/ShuffleVideo.jsx CHANGED
@@ -29,6 +29,7 @@ export default function ShuffleVideo() {
29
  useSim: false, sim: 80,
30
  useRandom: false, randomPct: 100,
31
  maxOut: "",
 
32
  plan: [], // bảng biến thể (mỗi dòng: {i, seq, labels, keep})
33
  results: [],
34
  busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
@@ -38,9 +39,14 @@ export default function ShuffleVideo() {
38
  const setJob = (j) => set({ job: j });
39
  const setBusy = (b) => set({ busy: b });
40
  const [showNote, setShowNote] = useState(true);
 
41
 
42
  const segs = st.segs;
43
 
 
 
 
 
44
  // nhận khúc đẩy từ Cut (?session=) -> nạp seg_* có sẵn
45
  useEffect(() => {
46
  const sid = sp.get("session");
@@ -80,11 +86,17 @@ export default function ShuffleVideo() {
80
  const addToSeg = (idx) => async (files) => {
81
  let sid = st.session;
82
  const added = [];
83
- for (const file of files) {
84
- const r = await api.shuffleUpload(file, idx, sid);
85
- sid = r.session;
86
- added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
 
 
 
 
 
87
  }
 
88
  set((cur) => {
89
  const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
90
  n[idx].files.push(...added);
@@ -99,10 +111,16 @@ export default function ShuffleVideo() {
99
  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.");
100
  }
101
  const added = [];
102
- for (const file of files) {
103
- const r = await api.shuffleUploadAudio(file, sid);
104
- added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
 
 
 
 
 
105
  }
 
106
  set((cur) => ({ audio: [...cur.audio, ...added] }));
107
  };
108
 
@@ -113,16 +131,20 @@ export default function ShuffleVideo() {
113
  const removeAudio = (ai) => set((cur) => ({ audio: cur.audio.filter((_, j) => j !== ai) }));
114
  const setOrder = (si, mode) => set((cur) => ({ segs: cur.segs.map((s, i) => (i === si ? { ...s, order_mode: mode } : s)) }));
115
 
116
- const payload = () => ({
117
- session: st.session,
118
- segments: segs.map((s) => ({ files: s.files.map((f) => f.file), order_mode: s.order_mode })),
119
- audio_files: st.audio.map((a) => a.file),
120
- similarity_threshold: st.useSim ? st.sim / 100 : 1.0, // 1.0 = lấy hết
121
- random_ratio: st.useRandom ? st.randomPct / 100 : 1.0, // 1.0 = lấy hết
122
- max_outputs: st.maxOut ? parseInt(st.maxOut) : null,
123
- seed: 42,
124
- username: user,
125
- });
 
 
 
 
126
 
127
  // khúc "bỏ" thì không cần clip; các khúc còn lại cần ít nhất 1 clip
128
  const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
@@ -157,8 +179,14 @@ export default function ShuffleVideo() {
157
  const generate = async () => {
158
  if (!ready) return;
159
  // nếu đã mở bảng -> chỉ render các dòng được tick; chưa mở bảng -> render tất cả như cũ
160
- const sequences = st.plan.length ? selectedSeqs() : null;
161
  if (st.plan.length && !sequences.length) return alert("Chưa chọn biến thể nào để render.");
 
 
 
 
 
 
162
  setBusy(true); set({ results: [] });
163
  try {
164
  const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
@@ -217,6 +245,12 @@ export default function ShuffleVideo() {
217
  {s.order_mode === "drop" ? (
218
  <div className="small muted">Khúc này bị bỏ khỏi video — không cần clip.</div>
219
  ) : (
 
 
 
 
 
 
220
  <div className="clip-row">
221
  <div style={{ width: 150 }}>
222
  <FileDrop compact folder onFiles={addToSeg(si)} title="Thả clip" hint="clip hoặc cả thư mục" />
@@ -229,6 +263,7 @@ export default function ShuffleVideo() {
229
  </div>
230
  ))}
231
  </div>
 
232
  )}
233
  </div>
234
  ))}
@@ -243,6 +278,11 @@ export default function ShuffleVideo() {
243
  <div style={{ width: 150 }}>
244
  <FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
245
  </div>
 
 
 
 
 
246
  {st.audio.map((a, ai) => (
247
  <div className="audio-chip" key={ai}>
248
  <IcMusic size={16} />
@@ -310,9 +350,9 @@ export default function ShuffleVideo() {
310
  </div>
311
  {st.useSim && (
312
  <div className="sub">
313
- <span className="field-label">Độ tương đồng tối đa: {st.sim}%</span>
314
  <input className="slider" type="range" min="20" max="99" value={st.sim} onChange={(e) => set({ sim: +e.target.value })} />
315
- <div className="small muted">Bỏ tick các biến thể giống nhau quá mức.</div>
316
  </div>
317
  )}
318
  </div>
@@ -333,12 +373,25 @@ export default function ShuffleVideo() {
333
  <input type="number" placeholder="không giới hạn" value={st.maxOut}
334
  onChange={(e) => set({ maxOut: e.target.value })} style={{ width: 160 }} />
335
  </div>
 
 
 
 
 
 
 
 
336
  <span className="small muted">Bật lọc / nhập giới hạn → bảng tự bỏ tick bớt.</span>
337
  </div>
338
 
 
 
 
 
 
339
  <button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
340
  {busy ? "Đang xào..."
341
- : <><IcShuffle size={16} /> Render đã chọn ({st.plan.filter((v) => v.keep).length}/{st.plan.length})</>}
342
  </button>
343
  </div>
344
  )}
 
29
  useSim: false, sim: 80,
30
  useRandom: false, randomPct: 100,
31
  maxOut: "",
32
+ res: "1080", // độ phân giải xuất ra (khung dọc)
33
  plan: [], // bảng biến thể (mỗi dòng: {i, seq, labels, keep})
34
  results: [],
35
  busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
 
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
 
46
+ // độ phân giải khung dọc (TikTok) — width x height
47
+ const RES = { "1080": [1080, 1920], "720": [720, 1280], "480": [480, 854] };
48
+ const MAX_RENDER = 150;
49
+
50
  // nhận khúc đẩy từ Cut (?session=) -> nạp seg_* có sẵn
51
  useEffect(() => {
52
  const sid = sp.get("session");
 
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);
 
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
 
 
131
  const removeAudio = (ai) => set((cur) => ({ audio: cur.audio.filter((_, j) => j !== ai) }));
132
  const setOrder = (si, mode) => set((cur) => ({ segs: cur.segs.map((s, i) => (i === si ? { ...s, order_mode: mode } : s)) }));
133
 
134
+ const payload = () => {
135
+ const [w, h] = RES[st.res] || RES["1080"];
136
+ return {
137
+ session: st.session,
138
+ segments: segs.map((s) => ({ files: s.files.map((f) => f.file), order_mode: s.order_mode })),
139
+ audio_files: st.audio.map((a) => a.file),
140
+ similarity_threshold: st.useSim ? st.sim / 100 : 1.0, // 1.0 = lấy hết
141
+ random_ratio: st.useRandom ? st.randomPct / 100 : 1.0, // 1.0 = lấy hết
142
+ max_outputs: st.maxOut ? parseInt(st.maxOut) : null,
143
+ width: w, height: h,
144
+ seed: 42,
145
+ username: user,
146
+ };
147
+ };
148
 
149
  // khúc "bỏ" thì không cần clip; các khúc còn lại cần ít nhất 1 clip
150
  const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
 
179
  const generate = async () => {
180
  if (!ready) return;
181
  // nếu đã mở bảng -> chỉ render các dòng được tick; chưa mở bảng -> render tất cả như cũ
182
+ let sequences = st.plan.length ? selectedSeqs() : null;
183
  if (st.plan.length && !sequences.length) return alert("Chưa chọn biến thể nào để render.");
184
+ // trần cứng 150 video/lần — nếu chọn nhiều hơn thì cảnh báo & chỉ lấy 150 đầu
185
+ if (sequences && sequences.length > MAX_RENDER) {
186
+ if (!confirm(`Bạn đã chọn ${sequences.length} biến thể, nhưng chỉ cho phép tạo tối đa ${MAX_RENDER} video mỗi lần.\n\nBấm OK để render ${MAX_RENDER} video đầu tiên.`))
187
+ return;
188
+ sequences = sequences.slice(0, MAX_RENDER);
189
+ }
190
  setBusy(true); set({ results: [] });
191
  try {
192
  const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
 
245
  {s.order_mode === "drop" ? (
246
  <div className="small muted">Khúc này bị bỏ khỏi video — không cần clip.</div>
247
  ) : (
248
+ <>
249
+ {uploading?.seg === si && (
250
+ <div className="small" style={{ color: "var(--orange-700)", marginBottom: 6 }}>
251
+ ⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
252
+ </div>
253
+ )}
254
  <div className="clip-row">
255
  <div style={{ width: 150 }}>
256
  <FileDrop compact folder onFiles={addToSeg(si)} title="Thả clip" hint="clip hoặc cả thư mục" />
 
263
  </div>
264
  ))}
265
  </div>
266
+ </>
267
  )}
268
  </div>
269
  ))}
 
278
  <div style={{ width: 150 }}>
279
  <FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
280
  </div>
281
+ {uploading?.seg === "audio" && (
282
+ <div className="small" style={{ color: "var(--orange-700)", alignSelf: "center" }}>
283
+ ⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
284
+ </div>
285
+ )}
286
  {st.audio.map((a, ai) => (
287
  <div className="audio-chip" key={ai}>
288
  <IcMusic size={16} />
 
350
  </div>
351
  {st.useSim && (
352
  <div className="sub">
353
+ <span className="field-label">Ngưỡng tương đồng: {st.sim}%</span>
354
  <input className="slider" type="range" min="20" max="99" value={st.sim} onChange={(e) => set({ sim: +e.target.value })} />
355
+ <div className="small muted">Biến thể nào giống một biến thể đã giữ <b>trên {st.sim}%</b> (tính theo số khúc trùng vị trí) sẽ bị bỏ tick. Kéo <b>thấp xuống → lọc gắt hơn → còn ít video</b>.</div>
356
  </div>
357
  )}
358
  </div>
 
373
  <input type="number" placeholder="không giới hạn" value={st.maxOut}
374
  onChange={(e) => set({ maxOut: e.target.value })} style={{ width: 160 }} />
375
  </div>
376
+ <div>
377
+ <span className="field-label">Chất lượng (khung dọc)</span>
378
+ <select value={st.res} onChange={(e) => set({ res: e.target.value })} style={{ width: 160 }}>
379
+ <option value="1080">1080 × 1920 (cao)</option>
380
+ <option value="720">720 × 1280 (vừa)</option>
381
+ <option value="480">480 × 854 (nhẹ)</option>
382
+ </select>
383
+ </div>
384
  <span className="small muted">Bật lọc / nhập giới hạn → bảng tự bỏ tick bớt.</span>
385
  </div>
386
 
387
+ {st.plan.filter((v) => v.keep).length > MAX_RENDER && (
388
+ <p className="small mt8" style={{ color: "var(--orange-700)" }}>
389
+ ⚠ Đang chọn {st.plan.filter((v) => v.keep).length} biến thể — chỉ render tối đa {MAX_RENDER} video/lần, phần dư sẽ bị bỏ.
390
+ </p>
391
+ )}
392
  <button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
393
  {busy ? "Đang xào..."
394
+ : <><IcShuffle size={16} /> Render đã chọn ({Math.min(st.plan.filter((v) => v.keep).length, MAX_RENDER)}/{st.plan.filter((v) => v.keep).length})</>}
395
  </button>
396
  </div>
397
  )}