titusgiap commited on
Commit
07d3e14
·
1 Parent(s): ef0bf7f

fix(cut): cắt 2+ video không còn 500; tách khúc tự động nhanh gấp ~3 lần

Browse files

Lỗi: thả nhiều video một lượt lúc chưa có video nào -> `cur` là biến chụp
lúc render nên mọi lần upload đều gửi session rỗng -> backend tạo mỗi video
một phiên riêng. Lúc cắt, FE chỉ gửi phiên của video đầu nên video thứ 2 trở
đi không tìm thấy file, ffprobe chết, cả request 500 "Internal Server Error".
Một video thì không bao giờ dính.

Sửa lỗi:
- FE giữ sid chạy theo vòng lặp upload -> mọi video vào cùng một phiên; và
gửi kèm session của TỪNG video khi cắt để các phiên cũ đã lỡ tách vẫn chạy.
- resolve_src() chặn thoát thư mục phiên; probe() báo lý do đọc được thay vì
CalledProcessError trống trơn.
- execute-batch đưa việc nặng vào job: 1 video hỏng chỉ hỏng phần của nó,
hỏng hết thì job báo lỗi thật.
- Khúc end <= start trước đây bị ffmpeg nuốt im lặng (-frames:v âm = không
giới hạn) và xuất ra clip dài tới hết video -> nay chặn.
- alert hiện `detail` thay vì nguyên cục JSON.

Tăng tốc (đo thật, video QC 1080x1920 30s):
- Tách khúc tự động 11.9s -> 3.9s/video. PySceneDetect đọc bằng OpenCV một
luồng ở độ phân giải gốc; nay dựng bản 360p bằng ffmpeg rồi dò trên đó,
điểm cắt ra y hệt (có test). Gộp luôn thumbnail + bản nhỏ vào 1 lượt ffmpeg.
- FE tách khúc 2 video song song.
- zip clip: DEFLATE -> STORED, nhanh 11x, kích thước y nguyên (mp4 đã nén).
- ffprobe nhiều video chạy song song; /execute-batch trả về ngay.
- Cắt khúc chạy song song (+11%). Khâu này là mã hoá x264 (86% thời gian) nên
gần chạm trần; đã thử gộp mọi khúc vào 1 lượt ffmpeg nhưng chậm hơn -> bỏ.

105/105 test PASS (thêm 10 test).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@

backend/app/api/cut.py CHANGED
@@ -9,8 +9,9 @@ Luồng FE:
9
  from __future__ import annotations
10
 
11
  import os
 
12
 
13
- from fastapi import APIRouter, File, Form, UploadFile
14
  from pydantic import BaseModel
15
 
16
  from ..core.files import new_session, rel_path, save_upload_stream, session_dir, sub_dir
@@ -18,18 +19,41 @@ from ..core.jobs import jobs, submit_for_session
18
  from ..config import settings
19
  from ..services.cutter import cut as cutter
20
  from ..services.cutter import modes
21
- from ..services.cutter.probe import probe
22
  from ..services.history.auto_save import auto_save_result
23
 
24
  router = APIRouter(prefix="/api/cut", tags=["cut"])
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  @router.post("/upload")
28
  def upload(file: UploadFile = File(...), session: str = Form(None)):
29
  sid = session or new_session("cut")
30
  path = save_upload_stream(sid, file.filename, file.file, folder="inputs",
31
  max_mb=settings.MAX_UPLOAD_MB)
32
- info = probe(path)
33
  return {
34
  "session": sid,
35
  "file": rel_path(path, sid),
@@ -51,7 +75,7 @@ class SplitAudioReq(BaseModel):
51
  @router.post("/split-audio")
52
  def split_audio(req: SplitAudioReq):
53
  """Tách 1 video -> VIDEO KHÔNG LỜI (mp4) + ÂM THANH (mp3), cho user tải cái nào tuỳ ý."""
54
- src = os.path.join(session_dir(req.session), req.file)
55
  job = jobs.create("cut")
56
  _username = req.username
57
 
@@ -107,16 +131,20 @@ class PreviewReq(BaseModel):
107
  @router.post("/preview")
108
  def preview(req: PreviewReq):
109
  import hashlib
110
- src = os.path.join(session_dir(req.session), req.file)
111
- info = probe(src)
112
- segs = modes.build_segments(
113
- info, req.mode, n=req.count, threshold=req.threshold, min_len=req.min_len,
114
- sensitivity=req.sensitivity, top_ratio=req.top_ratio)
115
  # Mỗi video có thư mục thumbnail RIÊNG (theo hash đường dẫn file) để nhiều
116
  # video trong cùng phiên KHÔNG ghi đè thumbnail của nhau (thumb_000.jpg...).
117
  vkey = hashlib.md5(req.file.encode("utf-8")).hexdigest()[:8]
118
- thumbs = cutter.thumbnail_strip(
119
- info, sub_dir(req.session, "thumbs", vkey), count=req.thumbnails)
 
 
 
 
 
 
 
120
  rel_thumbs = [{"time": t["time"],
121
  "url": f"/api/files/{req.session}/thumbs/{vkey}/{os.path.basename(t['path'])}"}
122
  for t in thumbs]
@@ -142,45 +170,63 @@ class ExecuteReq(BaseModel):
142
  count: int | None = None
143
 
144
 
145
- def _plan_segments(info, item):
146
  """Dựng danh sách Segment từ điểm cắt của UI (nếu có), LỌC khúc lỗi
147
  (end<=start / vượt biên) để 1 điểm cắt sai không làm hỏng cả job.
148
- Không còn khúc hợp lệ -> tự cắt auto."""
 
 
 
149
  if item.segments:
150
  raw = [(s.start_frame, s.end_frame) for s in item.segments]
151
  valid = [(a, b) for a, b in raw if 0 <= a < b <= info.nb_frames]
152
  if valid:
153
  return [modes.Segment(i, a, b, info.fps) for i, (a, b) in enumerate(valid)]
154
- return modes.build_segments(info, item.mode or "auto", n=item.count)
 
 
 
 
 
 
 
155
 
156
 
157
  @router.post("/execute")
158
  def execute(req: ExecuteReq):
159
- src = os.path.join(session_dir(req.session), req.file)
160
- info = probe(src)
161
- segs = _plan_segments(info, req)
162
 
163
  job = jobs.create("cut")
164
 
165
  def work(h):
166
  out_dir = sub_dir(req.session, "clips")
167
- results = []
168
  n = len(segs)
169
- for i, seg in enumerate(segs):
170
- it = h.add_item(f"clip_{i+1:03d}")
171
- try:
172
- name = f"clip_{seg.index + 1:03d}.mp4"
173
- r = cutter.cut_segment(src, seg, os.path.join(out_dir, name))
174
- results.append(r)
175
- h.item_status(it, "done", detail=f"{r.duration}s", result={
176
- "name": name, "duration": r.duration, "size": r.size,
177
- "url": f"/api/files/{req.session}/clips/{name}"})
178
- except Exception as e:
179
- h.item_status(it, "error", error=str(e))
180
- h.set_progress((i + 1) / max(1, n))
 
 
 
 
 
 
 
 
 
181
  zp = os.path.join(session_dir(req.session), "clips.zip")
182
- cutter.zip_clips(results, zp)
183
- return {"session": req.session, "clips": len(results),
184
  "zip": "clips.zip"}
185
 
186
  submit_for_session(job, work, req.session)
@@ -190,6 +236,10 @@ def execute(req: ExecuteReq):
190
 
191
  class BatchItem(BaseModel):
192
  file: str
 
 
 
 
193
  segments: list[CutPoint] | None = None
194
  mode: str = "auto" # mặc định: cắt tự động theo nội dung (thị giác 3 tầng)
195
  count: int | None = None
@@ -206,57 +256,113 @@ def execute_batch(req: ExecuteBatchReq):
206
  """Cắt NHIỀU video cùng lúc trong một phiên -> 1 danh sách clip gộp.
207
 
208
  Tên clip có tiền tố theo video để không đè nhau: v{vi}_clip_{i}.mp4.
 
 
 
209
  """
210
- plans = []
211
- for vi, item in enumerate(req.videos):
212
- src = os.path.join(session_dir(req.session), item.file)
213
- info = probe(src)
214
- segs = _plan_segments(info, item)
215
- plans.append((vi, item.file, src, segs))
216
 
217
  job = jobs.create("cut")
 
218
 
219
  def work(h):
 
 
220
  out_dir = sub_dir(req.session, "clips")
221
- groups = [] # gom kết quả theo TỪNG video
222
- total = sum(len(p[3]) for p in plans) or 1
223
- done = 0
224
- for vi, fname, src, segs in plans:
225
- base = os.path.splitext(os.path.basename(fname))[0]
226
- clips = []
227
- for i, seg in enumerate(segs):
228
- it = h.add_item(f"v{vi+1}_clip_{i+1:03d}")
229
- try:
230
- name = f"v{vi+1}_clip_{i+1:03d}.mp4"
231
- r = cutter.cut_segment(src, seg, os.path.join(out_dir, name))
232
- clip = {"name": name, "duration": r.duration, "size": r.size,
233
- "video": base,
234
- "url": f"/api/files/{req.session}/clips/{name}"}
235
- clips.append(clip)
236
- h.item_status(it, "done", detail=f"{r.duration}s", result=clip)
237
- except Exception as e:
238
- h.item_status(it, "error", error=str(e))
239
- done += 1
240
- h.set_progress(done / total)
241
- groups.append({"video": base, "file": fname, "clips": clips})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  total_clips = sum(len(g["clips"]) for g in groups)
 
 
 
 
 
 
 
 
 
 
 
 
243
  if _username:
244
- files = []
245
- for g in groups:
246
- for clip in g["clips"]:
247
- cp = os.path.join(session_dir(req.session), "clips", clip["name"])
248
- if os.path.exists(cp):
249
- files.append({"path": cp, "name": clip["name"]})
250
- zp = os.path.join(session_dir(req.session), "clips.zip")
251
- if os.path.exists(zp):
252
  files.append({"path": zp, "name": "clips.zip"})
253
  auto_save_result(_username, "cut", req.session, files,
254
  title=f"Cắt {total_clips} clip từ {len(groups)} video",
255
  summary=f"{total_clips} clip · {len(groups)} video")
256
 
257
- return {"session": req.session, "clips": total_clips, "videos": groups}
 
258
 
259
- _username = req.username
260
  submit_for_session(job, work, req.session)
261
  return {"job_id": job.id, "session": req.session}
262
 
 
9
  from __future__ import annotations
10
 
11
  import os
12
+ import threading
13
 
14
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
15
  from pydantic import BaseModel
16
 
17
  from ..core.files import new_session, rel_path, save_upload_stream, session_dir, sub_dir
 
19
  from ..config import settings
20
  from ..services.cutter import cut as cutter
21
  from ..services.cutter import modes
22
+ from ..services.cutter.probe import ProbeError, probe
23
  from ..services.history.auto_save import auto_save_result
24
 
25
  router = APIRouter(prefix="/api/cut", tags=["cut"])
26
 
27
 
28
+ def resolve_src(sid: str, rel: str) -> str:
29
+ """Đường dẫn THẬT của video trong phiên, chặn thoát ra ngoài thư mục phiên.
30
+
31
+ Trước đây chỉ os.path.join rồi đưa thẳng cho ffprobe: file không tồn tại
32
+ (phiên hết hạn bị dọn, hoặc video được tải lên ở PHIÊN KHÁC) thì ffprobe
33
+ chết và cả request 500 kèm traceback — không ai hiểu chuyện gì.
34
+ """
35
+ base = os.path.abspath(session_dir(sid or ""))
36
+ clean = (rel or "").replace("\\", "/").strip("/")
37
+ full = os.path.abspath(os.path.join(base, *[p for p in clean.split("/") if p not in ("", ".", "..")]))
38
+ if not full.startswith(base):
39
+ raise HTTPException(400, f"Đường dẫn video không hợp lệ: {rel}")
40
+ return full
41
+
42
+
43
+ def probe_or_400(path: str):
44
+ """probe() nhưng lỗi đọc file -> 400 kèm lời giải thích, KHÔNG phải 500."""
45
+ try:
46
+ return probe(path)
47
+ except ProbeError as e:
48
+ raise HTTPException(400, str(e))
49
+
50
+
51
  @router.post("/upload")
52
  def upload(file: UploadFile = File(...), session: str = Form(None)):
53
  sid = session or new_session("cut")
54
  path = save_upload_stream(sid, file.filename, file.file, folder="inputs",
55
  max_mb=settings.MAX_UPLOAD_MB)
56
+ info = probe_or_400(path)
57
  return {
58
  "session": sid,
59
  "file": rel_path(path, sid),
 
75
  @router.post("/split-audio")
76
  def split_audio(req: SplitAudioReq):
77
  """Tách 1 video -> VIDEO KHÔNG LỜI (mp4) + ÂM THANH (mp3), cho user tải cái nào tuỳ ý."""
78
+ src = resolve_src(req.session, req.file)
79
  job = jobs.create("cut")
80
  _username = req.username
81
 
 
131
  @router.post("/preview")
132
  def preview(req: PreviewReq):
133
  import hashlib
134
+ src = resolve_src(req.session, req.file)
135
+ info = probe_or_400(src)
 
 
 
136
  # Mỗi video có thư mục thumbnail RIÊNG (theo hash đường dẫn file) để nhiều
137
  # video trong cùng phiên KHÔNG ghi đè thumbnail của nhau (thumb_000.jpg...).
138
  vkey = hashlib.md5(req.file.encode("utf-8")).hexdigest()[:8]
139
+ tdir = sub_dir(req.session, "thumbs", vkey)
140
+ # MỘT lượt ffmpeg ra cả dải thumbnail lẫn bản nhỏ; dò chuyển cảnh trên bản
141
+ # nhỏ nhanh gấp ~3 lần mà điểm cắt không đổi.
142
+ thumbs, proxy = cutter.preview_pass(
143
+ info, tdir, os.path.join(tdir, "proxy.mp4"), count=req.thumbnails)
144
+ segs = modes.build_segments(
145
+ cutter.detect_info(info, proxy) if proxy else info,
146
+ req.mode, n=req.count, threshold=req.threshold, min_len=req.min_len,
147
+ sensitivity=req.sensitivity, top_ratio=req.top_ratio)
148
  rel_thumbs = [{"time": t["time"],
149
  "url": f"/api/files/{req.session}/thumbs/{vkey}/{os.path.basename(t['path'])}"}
150
  for t in thumbs]
 
170
  count: int | None = None
171
 
172
 
173
+ def _plan_segments(info, item, proxy_dir: str = ""):
174
  """Dựng danh sách Segment từ điểm cắt của UI (nếu có), LỌC khúc lỗi
175
  (end<=start / vượt biên) để 1 điểm cắt sai không làm hỏng cả job.
176
+ Không còn khúc hợp lệ -> tự cắt auto.
177
+
178
+ `proxy_dir`: nơi để bản nhỏ dò chuyển cảnh — chỉ dùng khi phải tự tách khúc.
179
+ """
180
  if item.segments:
181
  raw = [(s.start_frame, s.end_frame) for s in item.segments]
182
  valid = [(a, b) for a, b in raw if 0 <= a < b <= info.nb_frames]
183
  if valid:
184
  return [modes.Segment(i, a, b, info.fps) for i, (a, b) in enumerate(valid)]
185
+ det = info
186
+ if proxy_dir:
187
+ import hashlib
188
+ key = hashlib.md5(info.path.encode("utf-8")).hexdigest()[:8]
189
+ proxy = cutter.build_proxy(info, os.path.join(proxy_dir, f"proxy_{key}.mp4"))
190
+ if proxy:
191
+ det = cutter.detect_info(info, proxy)
192
+ return modes.build_segments(det, item.mode or "auto", n=item.count)
193
 
194
 
195
  @router.post("/execute")
196
  def execute(req: ExecuteReq):
197
+ src = resolve_src(req.session, req.file)
198
+ info = probe_or_400(src)
199
+ segs = _plan_segments(info, req, proxy_dir=sub_dir(req.session, "proxy"))
200
 
201
  job = jobs.create("cut")
202
 
203
  def work(h):
204
  out_dir = sub_dir(req.session, "clips")
 
205
  n = len(segs)
206
+ names = [f"clip_{seg.index + 1:03d}.mp4" for seg in segs]
207
+ its = [h.add_item(nm[:-4]) for nm in names]
208
+ results: list = [None] * n
209
+ done = {"n": 0}
210
+ lock = threading.Lock() # on_done chạy từ nhiều luồng cắt
211
+
212
+ def on_done(i, r, err):
213
+ if r is not None:
214
+ results[i] = r
215
+ h.item_status(its[i], "done", detail=f"{r.duration}s", result={
216
+ "name": names[i], "duration": r.duration, "size": r.size,
217
+ "url": f"/api/files/{req.session}/clips/{names[i]}"})
218
+ else:
219
+ h.item_status(its[i], "error", error=str(err))
220
+ with lock:
221
+ done["n"] += 1
222
+ h.set_progress(done["n"] / max(1, n))
223
+
224
+ cutter.cut_segments(src, segs, [os.path.join(out_dir, nm) for nm in names],
225
+ on_done=on_done)
226
+ ok = [r for r in results if r]
227
  zp = os.path.join(session_dir(req.session), "clips.zip")
228
+ cutter.zip_clips(ok, zp)
229
+ return {"session": req.session, "clips": len(ok),
230
  "zip": "clips.zip"}
231
 
232
  submit_for_session(job, work, req.session)
 
236
 
237
  class BatchItem(BaseModel):
238
  file: str
239
+ # Phiên CHỨA file này. Bình thường trùng phiên chung, nhưng khi người dùng
240
+ # thả nhiều video một lượt thì trước đây mỗi video có thể rơi vào một phiên
241
+ # khác nhau -> nhận cả phiên riêng để vẫn cắt được thay vì 500.
242
+ session: str = ""
243
  segments: list[CutPoint] | None = None
244
  mode: str = "auto" # mặc định: cắt tự động theo nội dung (thị giác 3 tầng)
245
  count: int | None = None
 
256
  """Cắt NHIỀU video cùng lúc trong một phiên -> 1 danh sách clip gộp.
257
 
258
  Tên clip có tiền tố theo video để không đè nhau: v{vi}_clip_{i}.mp4.
259
+
260
+ Mọi việc nặng (ffprobe, tách khúc, cắt) chạy TRONG JOB: request trả ngay,
261
+ và 1 video hỏng chỉ hỏng phần của nó chứ không giết cả mẻ.
262
  """
263
+ if not req.videos:
264
+ raise HTTPException(400, "Chưa có video nào để cắt.")
265
+ # Chỉ kiểm đường dẫn (rẻ, chặn thoát thư mục); đọc file để trong job.
266
+ srcs = [resolve_src(v.session or req.session, v.file) for v in req.videos]
 
 
267
 
268
  job = jobs.create("cut")
269
+ _username = req.username
270
 
271
  def work(h):
272
+ from concurrent.futures import ThreadPoolExecutor
273
+
274
  out_dir = sub_dir(req.session, "clips")
275
+ n_vid = len(req.videos)
276
+
277
+ # ── 1) Đọc metadata SONG SONG (ffprobe nhẹ, chờ I/O là chính) ────────
278
+ infos: list = [None] * n_vid
279
+ errors: list = [None] * n_vid
280
+
281
+ def read_info(vi: int):
282
+ try:
283
+ infos[vi] = probe(srcs[vi])
284
+ except Exception as e:
285
+ errors[vi] = str(e)
286
+
287
+ with ThreadPoolExecutor(max_workers=min(4, n_vid)) as ex:
288
+ list(ex.map(read_info, range(n_vid)))
289
+
290
+ # ── 2) Dựng điểm cắt (mode auto phải quét hình -> chạy tuần tự) ──────
291
+ plans: list = [None] * n_vid
292
+ for vi in range(n_vid):
293
+ if infos[vi] is None:
294
+ continue
295
+ try:
296
+ plans[vi] = _plan_segments(infos[vi], req.videos[vi],
297
+ proxy_dir=sub_dir(req.session, "proxy"))
298
+ except Exception as e:
299
+ errors[vi] = f"Không tách được khúc: {e}"
300
+
301
+ total = sum(len(p) for p in plans if p) or 1
302
+ done = {"n": 0}
303
+ lock = threading.Lock() # on_done chạy từ nhiều luồng cắt
304
+
305
+ # ── 3) Cắt từng video ────────────────────────────────────────────────
306
+ groups = []
307
+ for vi, item in enumerate(req.videos):
308
+ base = os.path.splitext(os.path.basename(item.file))[0]
309
+ if errors[vi] or not plans[vi]:
310
+ msg = errors[vi] or "Không có khúc nào để cắt."
311
+ h.item_status(h.add_item(base), "error", error=msg)
312
+ groups.append({"video": base, "file": item.file, "clips": [], "error": msg})
313
+ continue
314
+ segs = plans[vi]
315
+ names = [f"v{vi+1}_clip_{i+1:03d}.mp4" for i in range(len(segs))]
316
+ its = [h.add_item(nm[:-4]) for nm in names]
317
+ clips: list = [None] * len(segs)
318
+
319
+ def on_done(i, r, err, _vi=vi, _names=names, _its=its, _clips=clips, _base=base):
320
+ if r is not None:
321
+ clip = {"name": _names[i], "duration": r.duration, "size": r.size,
322
+ "video": _base,
323
+ "url": f"/api/files/{req.session}/clips/{_names[i]}"}
324
+ _clips[i] = clip
325
+ h.item_status(_its[i], "done", detail=f"{r.duration}s", result=clip)
326
+ else:
327
+ h.item_status(_its[i], "error", error=str(err))
328
+ with lock:
329
+ done["n"] += 1
330
+ h.set_progress(done["n"] / total)
331
+
332
+ cutter.cut_segments(
333
+ srcs[vi], segs, [os.path.join(out_dir, nm) for nm in names],
334
+ on_done=on_done)
335
+ ok = [c for c in clips if c]
336
+ group = {"video": base, "file": item.file, "clips": ok}
337
+ if len(ok) < len(segs):
338
+ group["error"] = f"{len(segs) - len(ok)}/{len(segs)} khúc cắt lỗi."
339
+ groups.append(group)
340
+
341
  total_clips = sum(len(g["clips"]) for g in groups)
342
+ if not total_clips:
343
+ raise RuntimeError(
344
+ next((g["error"] for g in groups if g.get("error")), "Không cắt được khúc nào."))
345
+
346
+ # Gói 1 file zip cho cả mẻ (lưu trữ, không nén — mp4 nén thêm chẳng lợi).
347
+ zp = os.path.join(session_dir(req.session), "clips.zip")
348
+ try:
349
+ cutter.zip_paths_stored(
350
+ [os.path.join(out_dir, c["name"]) for g in groups for c in g["clips"]], zp)
351
+ except Exception:
352
+ zp = ""
353
+
354
  if _username:
355
+ files = [{"path": os.path.join(out_dir, c["name"]), "name": c["name"]}
356
+ for g in groups for c in g["clips"]]
357
+ if zp and os.path.exists(zp):
 
 
 
 
 
358
  files.append({"path": zp, "name": "clips.zip"})
359
  auto_save_result(_username, "cut", req.session, files,
360
  title=f"Cắt {total_clips} clip từ {len(groups)} video",
361
  summary=f"{total_clips} clip · {len(groups)} video")
362
 
363
+ return {"session": req.session, "clips": total_clips, "videos": groups,
364
+ "zip": "clips.zip" if zp else ""}
365
 
 
366
  submit_for_session(job, work, req.session)
367
  return {"job_id": job.id, "session": req.session}
368
 
backend/app/services/cutter/cut.py CHANGED
@@ -28,6 +28,13 @@ def cut_segment(src: str, seg: Segment, out_path: str) -> ClipResult:
28
  start = seg.start_frame / seg.fps
29
  # dùng số frame để chốt độ dài chính xác thay vì -to (tránh sai số float)
30
  nframes = seg.end_frame - seg.start_frame
 
 
 
 
 
 
 
31
  cmd = [
32
  FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
33
  "-ss", f"{start:.6f}", "-i", src,
@@ -39,6 +46,10 @@ def cut_segment(src: str, seg: Segment, out_path: str) -> ClipResult:
39
  out_path,
40
  ]
41
  subprocess.run(cmd, check=True, capture_output=True)
 
 
 
 
42
  return ClipResult(
43
  index=seg.index,
44
  path=out_path,
@@ -59,6 +70,141 @@ def cut_all(src: str, segments: list[Segment], out_dir: str,
59
  return results
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def thumbnail_strip(info: VideoInfo, out_dir: str, count: int = 40) -> list[dict]:
63
  """Sinh dải thumbnail để hiển thị nền timeline (preview điểm cắt).
64
 
@@ -80,12 +226,7 @@ def thumbnail_strip(info: VideoInfo, out_dir: str, count: int = 40) -> list[dict
80
  ]
81
  try:
82
  subprocess.run(cmd, check=True, capture_output=True)
83
- thumbs = []
84
- for i in range(count):
85
- path = os.path.join(out_dir, f"thumb_{i+1:03d}.jpg") # ffmpeg đánh số từ 1
86
- if not os.path.exists(path):
87
- break
88
- thumbs.append({"time": round(dur * i / count, 3), "path": path})
89
  if thumbs:
90
  return thumbs
91
  except subprocess.CalledProcessError:
@@ -110,10 +251,17 @@ def _thumbnail_strip_slow(info: VideoInfo, out_dir: str, count: int) -> list[dic
110
  return thumbs
111
 
112
 
 
 
113
  def zip_clips(clips: list[ClipResult], zip_path: str) -> str:
114
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
115
- for c in clips:
116
- zf.write(c.path, arcname=os.path.basename(c.path))
 
 
 
 
 
117
  return zip_path
118
 
119
 
 
28
  start = seg.start_frame / seg.fps
29
  # dùng số frame để chốt độ dài chính xác thay vì -to (tránh sai số float)
30
  nframes = seg.end_frame - seg.start_frame
31
+ # Khúc ngược/rỗng: ffmpeg KHÔNG báo lỗi, nó coi "-frames:v -40" là không giới
32
+ # hạn và xuất ra clip dài tới hết video -> người dùng nhận khúc sai mà tưởng
33
+ # đúng. Chặn ngay tại đây.
34
+ if nframes <= 0:
35
+ raise ValueError(
36
+ f"Khúc {seg.index + 1} không hợp lệ: điểm kết thúc ({seg.end_frame}) "
37
+ f"phải sau điểm bắt đầu ({seg.start_frame}).")
38
  cmd = [
39
  FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
40
  "-ss", f"{start:.6f}", "-i", src,
 
46
  out_path,
47
  ]
48
  subprocess.run(cmd, check=True, capture_output=True)
49
+ # ffmpeg thoát mã 0 nhưng file rỗng (vd điểm cắt vượt quá cuối video) cũng là hỏng.
50
+ if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
51
+ raise RuntimeError(
52
+ f"Khúc {seg.index + 1} cắt ra rỗng — điểm cắt nằm ngoài video?")
53
  return ClipResult(
54
  index=seg.index,
55
  path=out_path,
 
70
  return results
71
 
72
 
73
+ # ─────────────────────── Cắt cả mẻ: SONG SONG theo số lõi ────────────────────
74
+ # Đo thật trên 900 frame 720x1280: giải mã hết video 0.6s, mã hoá lại 3.8s —
75
+ # tức ~86% thời gian nằm ở MÃ HOÁ. Nên cách nhanh nhất không phải gộp các khúc
76
+ # vào một lượt ffmpeg (đã thử: chỉ tiết kiệm phần giải mã, lại chạy mã hoá nối
77
+ # đuôi nhau nên còn CHẬM HƠN), mà là cắt nhiều khúc CÙNG LÚC để dùng hết lõi.
78
+
79
+ def _cut_workers() -> int:
80
+ env = os.environ.get("CUT_WORKERS")
81
+ if env and env.strip().isdigit():
82
+ return max(1, int(env))
83
+ cpu = os.cpu_count() or 2
84
+ # Trần 4: nhiều hơn nữa thì các tiến trình x264 giành lõi của nhau, không lợi.
85
+ return min(4, max(2, cpu - 1))
86
+
87
+
88
+ def cut_segments(src: str, segments: list[Segment], out_paths: list[str],
89
+ on_done=None, workers: int | None = None) -> list[ClipResult | None]:
90
+ """Cắt cả danh sách khúc SONG SONG, KHÔNG để 1 khúc lỗi giết cả mẻ.
91
+
92
+ `on_done(i, result, error)` gọi sau mỗi khúc để cập nhật tiến độ (được gọi
93
+ từ nhiều luồng, nên bên gọi chỉ nên cộng dồn/ghi trạng thái).
94
+ """
95
+ from concurrent.futures import ThreadPoolExecutor
96
+
97
+ n = len(segments)
98
+ results: list[ClipResult | None] = [None] * n
99
+ workers = workers or _cut_workers()
100
+
101
+ def one(i: int):
102
+ try:
103
+ results[i] = cut_segment(src, segments[i], out_paths[i])
104
+ if on_done:
105
+ on_done(i, results[i], None)
106
+ except Exception as e:
107
+ if isinstance(e, subprocess.CalledProcessError):
108
+ tail = (e.stderr or b"").decode("utf-8", "ignore").strip().splitlines()
109
+ e = RuntimeError(tail[-1] if tail else f"ffmpeg thoát mã {e.returncode}")
110
+ if on_done:
111
+ on_done(i, None, e)
112
+
113
+ if n <= 1 or workers <= 1:
114
+ for i in range(n):
115
+ one(i)
116
+ else:
117
+ with ThreadPoolExecutor(max_workers=min(workers, n)) as ex:
118
+ list(ex.map(one, range(n)))
119
+ return results
120
+
121
+
122
+ # ───────────── Bản XEM TRƯỚC nhỏ để dò chuyển cảnh cho nhanh ─────────────────
123
+ # PySceneDetect đọc video bằng OpenCV — giải mã MỘT LUỒNG ở độ phân giải gốc.
124
+ # Đo thật trên video QC 1080x1920 dài 30s: dò thẳng trên bản gốc mất 8.4s, còn
125
+ # hạ xuống 360p bằng ffmpeg (đa luồng) rồi dò chỉ mất 2.6s — RA ĐÚNG CÙNG điểm
126
+ # cắt, vì bản thân PySceneDetect cũng thu nhỏ khung hình trước khi so sánh.
127
+ PROXY_HEIGHT = 360
128
+ PROXY_MIN_HEIGHT = 480 # video vốn đã nhỏ thì khỏi hạ, dò thẳng cho nhanh
129
+
130
+
131
+ def _proxy_filter(info: VideoInfo) -> str | None:
132
+ short = min(info.width, info.height) or 0
133
+ if short and short <= PROXY_MIN_HEIGHT:
134
+ return None
135
+ return (f"scale=-2:{PROXY_HEIGHT}" if info.height >= info.width
136
+ else f"scale={PROXY_HEIGHT}:-2")
137
+
138
+
139
+ def detect_info(info: VideoInfo, proxy_path: str) -> VideoInfo:
140
+ """VideoInfo trỏ vào BẢN NHỎ nhưng giữ nguyên fps/số frame của bản GỐC.
141
+
142
+ Bản nhỏ cùng fps nên chỉ số frame khớp 1-1; giữ nb_frames gốc để khúc cuối
143
+ chạm đúng cuối video thật.
144
+ """
145
+ return VideoInfo(path=proxy_path, duration=info.duration, fps=info.fps,
146
+ nb_frames=info.nb_frames, width=info.width, height=info.height)
147
+
148
+
149
+ def build_proxy(info: VideoInfo, proxy_path: str) -> str | None:
150
+ """Dựng bản nhỏ để dò cảnh. Trả None nếu không cần / không dựng được."""
151
+ vf = _proxy_filter(info)
152
+ if not vf:
153
+ return None
154
+ cmd = [FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", info.path,
155
+ "-an", "-vf", vf, "-fps_mode", "cfr",
156
+ "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
157
+ "-pix_fmt", "yuv420p", proxy_path]
158
+ try:
159
+ subprocess.run(cmd, check=True, capture_output=True)
160
+ return proxy_path if os.path.getsize(proxy_path) > 0 else None
161
+ except (subprocess.CalledProcessError, OSError):
162
+ return None
163
+
164
+
165
+ def preview_pass(info: VideoInfo, thumb_dir: str, proxy_path: str,
166
+ count: int = 40) -> tuple[list[dict], str | None]:
167
+ """MỘT lượt ffmpeg -> vừa dải thumbnail vừa bản nhỏ dò cảnh.
168
+
169
+ Trước đây là 2 lượt, mỗi lượt giải mã lại cả video. Gộp lại chỉ giải mã 1 lần.
170
+ Hỏng -> lùi về làm rời từng thứ (vẫn chạy được, chỉ chậm hơn).
171
+ """
172
+ os.makedirs(thumb_dir, exist_ok=True)
173
+ vf = _proxy_filter(info)
174
+ dur = info.duration or 0.0
175
+ count = max(2, count)
176
+ if vf and dur > 0:
177
+ pattern = os.path.join(thumb_dir, "thumb_%03d.jpg")
178
+ cmd = [
179
+ FFMPEG, "-y", "-hide_banner", "-loglevel", "error", "-i", info.path,
180
+ # đầu ra 1: bản nhỏ để dò chuyển cảnh
181
+ "-an", "-vf", vf, "-fps_mode", "cfr",
182
+ "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
183
+ "-pix_fmt", "yuv420p", proxy_path,
184
+ # đầu ra 2: dải thumbnail cho timeline
185
+ "-an", "-vf", f"fps={count}/{dur:.6f},scale=160:-1",
186
+ "-frames:v", str(count), "-fps_mode", "passthrough", pattern,
187
+ ]
188
+ try:
189
+ subprocess.run(cmd, check=True, capture_output=True)
190
+ thumbs = _collect_thumbs(thumb_dir, dur, count)
191
+ if thumbs and os.path.exists(proxy_path) and os.path.getsize(proxy_path) > 0:
192
+ return thumbs, proxy_path
193
+ except (subprocess.CalledProcessError, OSError):
194
+ pass
195
+ return thumbnail_strip(info, thumb_dir, count), build_proxy(info, proxy_path)
196
+
197
+
198
+ def _collect_thumbs(out_dir: str, dur: float, count: int) -> list[dict]:
199
+ thumbs = []
200
+ for i in range(count):
201
+ path = os.path.join(out_dir, f"thumb_{i+1:03d}.jpg") # ffmpeg đánh số từ 1
202
+ if not os.path.exists(path):
203
+ break
204
+ thumbs.append({"time": round(dur * i / count, 3), "path": path})
205
+ return thumbs
206
+
207
+
208
  def thumbnail_strip(info: VideoInfo, out_dir: str, count: int = 40) -> list[dict]:
209
  """Sinh dải thumbnail để hiển thị nền timeline (preview điểm cắt).
210
 
 
226
  ]
227
  try:
228
  subprocess.run(cmd, check=True, capture_output=True)
229
+ thumbs = _collect_thumbs(out_dir, dur, count)
 
 
 
 
 
230
  if thumbs:
231
  return thumbs
232
  except subprocess.CalledProcessError:
 
251
  return thumbs
252
 
253
 
254
+ # mp4 đã nén sẵn: DEFLATE gần như không giảm được byte nào mà ngốn CPU + thời
255
+ # gian (mẻ vài trăm MB mất cả chục giây). ZIP_STORED = gói lại, không nén.
256
  def zip_clips(clips: list[ClipResult], zip_path: str) -> str:
257
+ return zip_paths_stored([c.path for c in clips], zip_path)
258
+
259
+
260
+ def zip_paths_stored(paths: list[str], zip_path: str) -> str:
261
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf:
262
+ for p in paths:
263
+ if os.path.isfile(p):
264
+ zf.write(p, arcname=os.path.basename(p))
265
  return zip_path
266
 
267
 
backend/app/services/cutter/probe.py CHANGED
@@ -41,7 +41,19 @@ def _parse_rate(rate: str) -> float:
41
  _PROBE_CACHE: dict[tuple, "VideoInfo"] = {}
42
 
43
 
 
 
 
 
44
  def probe(path: str) -> VideoInfo:
 
 
 
 
 
 
 
 
45
  try:
46
  stt = os.stat(path)
47
  key = (os.path.abspath(path), stt.st_mtime_ns, stt.st_size)
@@ -50,15 +62,28 @@ def probe(path: str) -> VideoInfo:
50
  return cached
51
  except OSError:
52
  key = None
 
 
 
53
 
54
  cmd = [
55
  FFPROBE, "-v", "error", "-print_format", "json",
56
  "-show_streams", "-show_format", path,
57
  ]
58
- out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
59
- data = json.loads(out)
 
 
 
 
 
 
 
60
 
61
- vstream = next(s for s in data["streams"] if s["codec_type"] == "video")
 
 
 
62
  fps = _parse_rate(vstream.get("avg_frame_rate") or "") or _parse_rate(
63
  vstream.get("r_frame_rate") or ""
64
  )
@@ -66,12 +91,16 @@ def probe(path: str) -> VideoInfo:
66
  vstream.get("duration") or data.get("format", {}).get("duration") or 0.0
67
  )
68
  nb = vstream.get("nb_frames")
69
- nb_frames = int(nb) if nb and nb.isdigit() else int(round(duration * fps))
 
 
 
 
70
 
71
  info = VideoInfo(
72
  path=path,
73
  duration=duration,
74
- fps=fps or 30.0,
75
  nb_frames=nb_frames,
76
  width=int(vstream.get("width", 0)),
77
  height=int(vstream.get("height", 0)),
 
41
  _PROBE_CACHE: dict[tuple, "VideoInfo"] = {}
42
 
43
 
44
+ class ProbeError(RuntimeError):
45
+ """ffprobe không đọc được file — kèm lý do người dùng hiểu được."""
46
+
47
+
48
  def probe(path: str) -> VideoInfo:
49
+ name = os.path.basename(path)
50
+ # Kiểm tra file TRƯỚC khi gọi ffprobe: thiếu file là lỗi hay gặp nhất (phiên
51
+ # hết hạn bị dọn, hoặc video nằm ở phiên khác) và ffprobe chỉ trả về
52
+ # CalledProcessError trống trơn -> 500 không ai đoán được nguyên nhân.
53
+ if not os.path.isfile(path):
54
+ raise ProbeError(
55
+ f"Không tìm thấy video '{name}' trên máy chủ — phiên làm việc có thể "
56
+ f"đã hết hạn. Hãy tải lại video rồi cắt.")
57
  try:
58
  stt = os.stat(path)
59
  key = (os.path.abspath(path), stt.st_mtime_ns, stt.st_size)
 
62
  return cached
63
  except OSError:
64
  key = None
65
+ stt = None
66
+ if stt is not None and stt.st_size == 0:
67
+ raise ProbeError(f"Video '{name}' rỗng (0 byte) — tải lên chưa xong hoặc đã hỏng.")
68
 
69
  cmd = [
70
  FFPROBE, "-v", "error", "-print_format", "json",
71
  "-show_streams", "-show_format", path,
72
  ]
73
+ proc = subprocess.run(cmd, capture_output=True, text=True)
74
+ if proc.returncode != 0:
75
+ err = (proc.stderr or "").strip().splitlines()
76
+ detail = err[-1] if err else f"ffprobe thoát mã {proc.returncode}"
77
+ raise ProbeError(f"Không đọc được video '{name}': {detail}")
78
+ try:
79
+ data = json.loads(proc.stdout)
80
+ except json.JSONDecodeError:
81
+ raise ProbeError(f"Không đọc được thông tin video '{name}' (ffprobe trả về dữ liệu lạ).")
82
 
83
+ vstream = next(
84
+ (s for s in data.get("streams", []) if s.get("codec_type") == "video"), None)
85
+ if vstream is None:
86
+ raise ProbeError(f"File '{name}' không có luồng hình — đây không phải video.")
87
  fps = _parse_rate(vstream.get("avg_frame_rate") or "") or _parse_rate(
88
  vstream.get("r_frame_rate") or ""
89
  )
 
91
  vstream.get("duration") or data.get("format", {}).get("duration") or 0.0
92
  )
93
  nb = vstream.get("nb_frames")
94
+ eff_fps = fps or 30.0
95
+ nb_frames = int(nb) if nb and str(nb).isdigit() else int(round(duration * eff_fps))
96
+ if nb_frames <= 0:
97
+ raise ProbeError(
98
+ f"Video '{name}' không có khung hình nào đọc được (file hỏng hoặc tải lên dở dang).")
99
 
100
  info = VideoInfo(
101
  path=path,
102
  duration=duration,
103
+ fps=eff_fps,
104
  nb_frames=nb_frames,
105
  width=int(vstream.get("width", 0)),
106
  height=int(vstream.get("height", 0)),
backend/tests/test_cut.py CHANGED
@@ -203,3 +203,194 @@ def test_multi_video_unique_names():
203
  cutter.cut_segment(v, seg, os.path.join(outdir, name))
204
  names.append(name)
205
  assert len(names) == len(set(names)) == 4 # 2 video × 2 khúc, không trùng tên
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  cutter.cut_segment(v, seg, os.path.join(outdir, name))
204
  names.append(name)
205
  assert len(names) == len(set(names)) == 4 # 2 video × 2 khúc, không trùng tên
206
+
207
+
208
+ def _wait(job, secs=120):
209
+ import time
210
+ for _ in range(secs * 4):
211
+ if job.status in ("done", "error"):
212
+ break
213
+ time.sleep(0.25)
214
+ return job
215
+
216
+
217
+ def test_execute_batch_videos_in_different_sessions():
218
+ """Ca gây lỗi thật: thả nhiều video một lượt -> mỗi video rơi vào MỘT PHIÊN
219
+ khác nhau, nhưng lúc cắt chỉ gửi phiên của video đầu. Trước đây ffprobe
220
+ không thấy file -> CalledProcessError -> 500 "Lỗi cắt: Internal Server Error".
221
+ """
222
+ from app.api import cut as cutapi
223
+ from app.core import files as F
224
+ from app.core.jobs import jobs
225
+
226
+ sid_a = F.new_session("cut")
227
+ sid_b = F.new_session("cut")
228
+ make_video(os.path.join(F.sub_dir(sid_a, "inputs"), "a.mp4"), dur=3.0, rate=20, color="red")
229
+ make_video(os.path.join(F.sub_dir(sid_b, "inputs"), "b.mp4"), dur=3.0, rate=20, color="blue")
230
+
231
+ req = cutapi.ExecuteBatchReq(session=sid_a, videos=[
232
+ cutapi.BatchItem(file="inputs/a.mp4", mode="even", count=2),
233
+ cutapi.BatchItem(file="inputs/b.mp4", session=sid_b, mode="even", count=2),
234
+ ])
235
+ job = _wait(jobs.get(cutapi.execute_batch(req)["job_id"]))
236
+ assert job.status == "done", job.error
237
+ counts = {v["video"]: len(v["clips"]) for v in job.result["videos"]}
238
+ assert counts == {"a": 2, "b": 2}, counts
239
+ # Mọi clip nằm gọn trong phiên chung -> tải về / đẩy sang Xào đều thấy.
240
+ for v in job.result["videos"]:
241
+ for c in v["clips"]:
242
+ assert os.path.exists(os.path.join(F.session_dir(sid_a), "clips", c["name"]))
243
+
244
+
245
+ def test_execute_batch_one_bad_video_does_not_kill_batch():
246
+ """Một video mất file (phiên hết hạn) chỉ hỏng phần của nó — video còn lại
247
+ vẫn ra clip, và lỗi là câu tiếng Việt đọc được chứ không phải 500."""
248
+ from app.api import cut as cutapi
249
+ from app.core import files as F
250
+ from app.core.jobs import jobs
251
+
252
+ sid = F.new_session("cut")
253
+ make_video(os.path.join(F.sub_dir(sid, "inputs"), "ok.mp4"), dur=3.0, rate=20)
254
+
255
+ req = cutapi.ExecuteBatchReq(session=sid, videos=[
256
+ cutapi.BatchItem(file="inputs/mat_tieu.mp4", mode="even", count=2),
257
+ cutapi.BatchItem(file="inputs/ok.mp4", mode="even", count=2),
258
+ ])
259
+ job = _wait(jobs.get(cutapi.execute_batch(req)["job_id"]))
260
+ assert job.status == "done", job.error
261
+ by = {v["video"]: v for v in job.result["videos"]}
262
+ assert by["mat_tieu"]["clips"] == [] and "Không tìm thấy video" in by["mat_tieu"]["error"]
263
+ assert len(by["ok"]["clips"]) == 2
264
+
265
+
266
+ def test_execute_batch_all_bad_reports_error():
267
+ """Hỏng hết thì job phải BÁO LỖI (không im lặng trả 0 clip)."""
268
+ from app.api import cut as cutapi
269
+ from app.core import files as F
270
+ from app.core.jobs import jobs
271
+
272
+ sid = F.new_session("cut")
273
+ F.sub_dir(sid, "inputs")
274
+ req = cutapi.ExecuteBatchReq(session=sid, videos=[
275
+ cutapi.BatchItem(file="inputs/khong_co.mp4", mode="even", count=2)])
276
+ job = _wait(jobs.get(cutapi.execute_batch(req)["job_id"]))
277
+ assert job.status == "error" and "Không tìm thấy video" in (job.error or "")
278
+
279
+
280
+ def test_probe_errors_are_readable():
281
+ """probe() phải nói rõ lý do, không ném CalledProcessError trống trơn."""
282
+ from app.services.cutter.probe import ProbeError
283
+
284
+ d = tempfile.mkdtemp()
285
+ for name, content in [("mat.mp4", None), ("rong.mp4", b""), ("rac.mp4", b"khong phai video")]:
286
+ p = os.path.join(d, name)
287
+ if content is not None:
288
+ open(p, "wb").write(content)
289
+ try:
290
+ probe(p)
291
+ raise AssertionError(f"{name}: đáng lẽ phải báo lỗi")
292
+ except ProbeError as e:
293
+ assert name in str(e) or "Không tìm thấy" in str(e), str(e)
294
+
295
+
296
+ def test_resolve_src_blocks_escape():
297
+ """Đường dẫn file không được thoát khỏi thư mục phiên."""
298
+ from fastapi import HTTPException
299
+ from app.api import cut as cutapi
300
+ from app.core import files as F
301
+
302
+ sid = F.new_session("cut")
303
+ assert cutapi.resolve_src(sid, "inputs/a.mp4").startswith(F.session_dir(sid))
304
+ try:
305
+ cutapi.resolve_src(sid, "../../etc/passwd")
306
+ except HTTPException:
307
+ pass
308
+ # ".." bị lọc bỏ -> vẫn nằm trong phiên
309
+ assert cutapi.resolve_src(sid, "a/../b.mp4").startswith(F.session_dir(sid))
310
+
311
+
312
+ def test_cut_segments_parallel_matches_sequential():
313
+ """Cắt song song cho ra ĐÚNG file như cắt tuần tự, và khúc lỗi chỉ hỏng một mình."""
314
+ import hashlib
315
+
316
+ d = tempfile.mkdtemp()
317
+ v = make_video(os.path.join(d, "in.mp4"), dur=4.0, rate=20)
318
+ info = probe(v)
319
+ segs = modes.build_segments(info, "even", n=4)
320
+
321
+ seq_dir = os.path.join(d, "seq"); os.makedirs(seq_dir)
322
+ seq = cutter.cut_all(v, segs, seq_dir)
323
+ par = [os.path.join(d, f"p{i}.mp4") for i in range(len(segs))]
324
+ res = cutter.cut_segments(v, segs, par)
325
+ assert all(r is not None for r in res)
326
+ for a, b in zip(seq, par):
327
+ assert hashlib.md5(open(a.path, "rb").read()).hexdigest() == \
328
+ hashlib.md5(open(b, "rb").read()).hexdigest()
329
+
330
+ bad = [modes.Segment(0, 0, 20, info.fps),
331
+ modes.Segment(1, 50, 10, info.fps), # end < start -> ffmpeg lỗi
332
+ modes.Segment(2, 20, 40, info.fps)]
333
+ outs = [os.path.join(d, f"b{i}.mp4") for i in range(3)]
334
+ seen = {}
335
+ cutter.cut_segments(v, bad, outs, on_done=lambda i, r, e: seen.__setitem__(i, r is not None))
336
+ assert seen[0] and seen[2] and not seen[1], seen
337
+
338
+
339
+ def test_preview_pass_gives_thumbs_and_proxy():
340
+ """1 lượt ffmpeg ra cả dải thumbnail lẫn bản nhỏ dò cảnh; bản nhỏ giữ đúng
341
+ fps/số frame nên điểm cắt tìm trên đó dùng thẳng cho bản gốc được."""
342
+ d = tempfile.mkdtemp()
343
+ v = make_video(os.path.join(d, "in.mp4"), dur=4.0, size="1080x1920", rate=20)
344
+ info = probe(v)
345
+ tdir = os.path.join(d, "thumbs")
346
+ thumbs, proxy = cutter.preview_pass(info, tdir, os.path.join(tdir, "proxy.mp4"), count=10)
347
+ assert len(thumbs) == 10 and all(os.path.getsize(t["path"]) > 0 for t in thumbs)
348
+ assert proxy and os.path.getsize(proxy) > 0
349
+ pinfo = probe(proxy)
350
+ assert pinfo.height < info.height # đã thu nhỏ thật
351
+ assert abs(pinfo.nb_frames - info.nb_frames) <= 1 # cùng fps -> khung khớp 1-1
352
+
353
+ det = cutter.detect_info(info, proxy)
354
+ assert det.path == proxy
355
+ assert det.nb_frames == info.nb_frames and det.fps == info.fps
356
+ # khúc dò trên bản nhỏ vẫn phủ trọn video GỐC
357
+ segs = modes.build_segments(det, "auto")
358
+ assert segs[0].start_frame == 0 and segs[-1].end_frame == info.nb_frames
359
+
360
+
361
+ def test_preview_proxy_finds_same_cuts_as_original():
362
+ """Thu nhỏ để dò cho nhanh KHÔNG được làm đổi điểm cắt."""
363
+ d = tempfile.mkdtemp()
364
+ v = _multi_scene_video(os.path.join(d, "in.mp4"),
365
+ colors=("red", "green", "blue", "yellow"), each=2.0, rate=20)
366
+ info = probe(v)
367
+ full = [s.start_frame for s in modes.build_segments(info, "auto")]
368
+ proxy = cutter.build_proxy(info, os.path.join(d, "proxy.mp4"))
369
+ if not proxy: # video test nhỏ sẵn -> không cần bản nhỏ
370
+ return
371
+ small = [s.start_frame for s in modes.build_segments(cutter.detect_info(info, proxy), "auto")]
372
+ assert len(full) == len(small), (full, small)
373
+ for a, b in zip(full, small):
374
+ assert abs(a - b) <= 2, (full, small) # lệch tối đa 2 frame
375
+
376
+
377
+ def test_small_video_skips_proxy():
378
+ """Video vốn đã nhỏ thì khỏi dựng bản nhỏ (dựng còn tốn hơn dò thẳng)."""
379
+ d = tempfile.mkdtemp()
380
+ v = make_video(os.path.join(d, "in.mp4"), dur=2.0, size="320x240", rate=15)
381
+ assert cutter.build_proxy(probe(v), os.path.join(d, "p.mp4")) is None
382
+
383
+
384
+ def test_zip_clips_is_stored_not_deflated():
385
+ """mp4 nén lại chẳng lợi byte nào mà tốn CPU -> phải gói kiểu STORED."""
386
+ import zipfile
387
+
388
+ d = tempfile.mkdtemp()
389
+ v = make_video(os.path.join(d, "in.mp4"), dur=2.0, rate=20)
390
+ info = probe(v)
391
+ segs = modes.build_segments(info, "even", n=2)
392
+ clips = cutter.cut_all(v, segs, os.path.join(d, "clips"))
393
+ zp = cutter.zip_clips(clips, os.path.join(d, "c.zip"))
394
+ with zipfile.ZipFile(zp) as zf:
395
+ assert len(zf.namelist()) == 2
396
+ assert all(i.compress_type == zipfile.ZIP_STORED for i in zf.infolist())
frontend/src/api.js CHANGED
@@ -3,13 +3,26 @@ import { currentCtx as trackCtx } from "./lib/track";
3
 
4
  const BASE = "";
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  async function jpost(path, body) {
7
  const r = await fetch(BASE + path, {
8
  method: "POST",
9
  headers: { "Content-Type": "application/json" },
10
  body: JSON.stringify(body),
11
  });
12
- if (!r.ok) throw new Error((await r.text()) || r.statusText);
13
  return r.json();
14
  }
15
 
@@ -18,7 +31,7 @@ async function upload(path, file, fields = {}) {
18
  fd.append("file", file);
19
  for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
20
  const r = await fetch(BASE + path, { method: "POST", body: fd });
21
- if (!r.ok) throw new Error((await r.text()) || r.statusText);
22
  return r.json();
23
  }
24
 
 
3
 
4
  const BASE = "";
5
 
6
+ // Lỗi FastAPI trả về dạng {"detail": "..."} — lấy ra câu chữ cho người đọc,
7
+ // đừng ném nguyên cục JSON vào alert.
8
+ async function errFrom(r) {
9
+ const text = await r.text().catch(() => "");
10
+ try {
11
+ const j = JSON.parse(text);
12
+ const d = j?.detail ?? j?.error ?? j?.message;
13
+ if (typeof d === "string" && d) return new Error(d);
14
+ if (Array.isArray(d) && d.length) return new Error(d.map((x) => x.msg || "").join("; "));
15
+ } catch { /* không phải JSON -> dùng nguyên văn */ }
16
+ return new Error(text || r.statusText || `HTTP ${r.status}`);
17
+ }
18
+
19
  async function jpost(path, body) {
20
  const r = await fetch(BASE + path, {
21
  method: "POST",
22
  headers: { "Content-Type": "application/json" },
23
  body: JSON.stringify(body),
24
  });
25
+ if (!r.ok) throw await errFrom(r);
26
  return r.json();
27
  }
28
 
 
31
  fd.append("file", file);
32
  for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
33
  const r = await fetch(BASE + path, { method: "POST", body: fd });
34
+ if (!r.ok) throw await errFrom(r);
35
  return r.json();
36
  }
37
 
frontend/src/pages/CutVideo.jsx CHANGED
@@ -208,11 +208,18 @@ export default function CutVideo() {
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 = {
217
  session: r.session, file: r.file, name: file.name,
218
  url: fileUrl(`/api/files/${r.session}/${r.file}`),
@@ -251,10 +258,21 @@ export default function CutVideo() {
251
  if (!videos.length) return;
252
  setPrevBusy(true);
253
  track.runClick({ phase: "auto_segment", videos: videos.length });
 
 
 
 
 
 
 
 
 
 
 
254
  try {
255
- for (let i = 0; i < videos.length; i++) await doPreview(i);
256
- } catch (e) { alert("Lỗi tách khúc: " + e.message); }
257
- finally { setPrevBusy(false); }
258
  };
259
 
260
  const segmentsFromCuts = (video) => {
@@ -279,6 +297,9 @@ export default function CutVideo() {
279
  session: sid,
280
  videos: videos.map((v) => ({
281
  file: v.file,
 
 
 
282
  // ưu tiên điểm cắt trên timeline; nếu chưa tách thì để backend tự cắt auto
283
  segments: (v.cuts && v.cuts.length) ? segmentsFromCuts(v) : null,
284
  mode: "auto",
 
208
 
209
  const onFiles = async (files) => {
210
  const arr = Array.from(files);
211
+ // MỌI video của lần thả này phải vào CÙNG MỘT PHIÊN. `cur` là biến chụp lúc
212
+ // render nên không đổi trong vòng lặp: trước đây thả 2 video lúc chưa có
213
+ // video nào -> cả 2 lần upload đều gửi session rỗng -> backend tạo 2 phiên
214
+ // riêng, mà lúc cắt lại gửi chung phiên của video đầu -> video thứ 2 không
215
+ // tìm thấy file -> "Lỗi cắt: Internal Server Error". Giữ sid chạy theo vòng lặp.
216
+ let sid = videos[0]?.session || cur?.session || null;
217
  for (let i = 0; i < arr.length; i++) {
218
  const file = arr[i];
219
  setUploading({ name: file.name, i: i + 1, n: arr.length });
220
  try {
221
+ const r = await api.cutUpload(file, sid);
222
+ sid = r.session;
223
  const v = {
224
  session: r.session, file: r.file, name: file.name,
225
  url: fileUrl(`/api/files/${r.session}/${r.file}`),
 
258
  if (!videos.length) return;
259
  setPrevBusy(true);
260
  track.runClick({ phase: "auto_segment", videos: videos.length });
261
+ // Chạy 2 video CÙNG LÚC (không phải xếp hàng từng cái) — đây là bước chờ lâu
262
+ // nhất của tool. Giữ ở 2 để máy chủ còn phục vụ người khác.
263
+ // Một video hỏng KHÔNG được chặn các video còn lại — báo gọn ở cuối.
264
+ const failed = [];
265
+ const queue = videos.map((_, i) => i);
266
+ const worker = async () => {
267
+ for (let i = queue.shift(); i !== undefined; i = queue.shift()) {
268
+ try { await doPreview(i); }
269
+ catch (e) { failed.push(`${videos[i]?.name || `video ${i + 1}`}: ${e.message}`); }
270
+ }
271
+ };
272
  try {
273
+ await Promise.all(Array.from({ length: Math.min(2, videos.length) }, worker));
274
+ if (failed.length) alert("Không tách khúc được:\n• " + failed.join("\n• "));
275
+ } finally { setPrevBusy(false); }
276
  };
277
 
278
  const segmentsFromCuts = (video) => {
 
297
  session: sid,
298
  videos: videos.map((v) => ({
299
  file: v.file,
300
+ // Gửi kèm phiên CỦA TỪNG VIDEO: các phiên cũ (tải lên trước bản sửa)
301
+ // có thể nằm rải rác, backend tìm đúng chỗ thay vì báo lỗi.
302
+ session: v.session || sid,
303
  // ưu tiên điểm cắt trên timeline; nếu chưa tách thì để backend tự cắt auto
304
  segments: (v.cuts && v.cuts.length) ? segmentsFromCuts(v) : null,
305
  mode: "auto",