titusgiap's picture
fix(cut): cắt 2+ video không còn 500; tách khúc tự động nhanh gấp ~3 lần
07d3e14
Raw History Blame Contribute Delete
16.7 kB
"""API tool: Cut video.
Luồng FE:
1) upload video -> /upload (trả session + info)
2) /preview -> sinh khúc theo mode + thumbnail strip để xem trước/kéo điểm cắt
3) /execute -> cắt theo các điểm cắt (đã chỉnh tay) -> zip
"""
from __future__ import annotations
import os
import threading
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
from ..core.files import new_session, rel_path, save_upload_stream, session_dir, sub_dir
from ..core.jobs import jobs, submit_for_session
from ..config import settings
from ..services.cutter import cut as cutter
from ..services.cutter import modes
from ..services.cutter.probe import ProbeError, probe
from ..services.history.auto_save import auto_save_result
router = APIRouter(prefix="/api/cut", tags=["cut"])
def resolve_src(sid: str, rel: str) -> str:
"""Đường dẫn THẬT của video trong phiên, chặn thoát ra ngoài thư mục phiên.
Trước đây chỉ os.path.join rồi đưa thẳng cho ffprobe: file không tồn tại
(phiên hết hạn bị dọn, hoặc video được tải lên ở PHIÊN KHÁC) thì ffprobe
chết và cả request 500 kèm traceback — không ai hiểu chuyện gì.
"""
base = os.path.abspath(session_dir(sid or ""))
clean = (rel or "").replace("\\", "/").strip("/")
full = os.path.abspath(os.path.join(base, *[p for p in clean.split("/") if p not in ("", ".", "..")]))
if not full.startswith(base):
raise HTTPException(400, f"Đường dẫn video không hợp lệ: {rel}")
return full
def probe_or_400(path: str):
"""probe() nhưng lỗi đọc file -> 400 kèm lời giải thích, KHÔNG phải 500."""
try:
return probe(path)
except ProbeError as e:
raise HTTPException(400, str(e))
@router.post("/upload")
def upload(file: UploadFile = File(...), session: str = Form(None)):
sid = session or new_session("cut")
path = save_upload_stream(sid, file.filename, file.file, folder="inputs",
max_mb=settings.MAX_UPLOAD_MB)
info = probe_or_400(path)
return {
"session": sid,
"file": rel_path(path, sid),
"info": {"duration": info.duration, "fps": info.fps,
"nb_frames": info.nb_frames,
"width": info.width, "height": info.height},
"auto_count": modes.auto_segment_count(info),
}
# ─────────────────── Tách âm thanh: video không lời + mp3 ────────────────────
class SplitAudioReq(BaseModel):
session: str
file: str
username: str = ""
@router.post("/split-audio")
def split_audio(req: SplitAudioReq):
"""Tách 1 video -> VIDEO KHÔNG LỜI (mp4) + ÂM THANH (mp3), cho user tải cái nào tuỳ ý."""
src = resolve_src(req.session, req.file)
job = jobs.create("cut")
_username = req.username
def work(h):
it = h.add_item(os.path.basename(req.file))
out_dir = sub_dir(req.session, "audio_split")
try:
h.item_status(it, "running", detail="Đang tách âm thanh…")
res = cutter.split_audio(src, out_dir)
def url(p):
return f"/api/files/{req.session}/" + rel_path(p, req.session)
result = {
"muted_video_url": url(res.muted_video),
"muted_video_name": os.path.basename(res.muted_video),
"audio_url": url(res.audio_mp3) if res.audio_mp3 else "",
"audio_name": os.path.basename(res.audio_mp3) if res.audio_mp3 else "",
"has_audio": res.has_audio,
}
detail = "video không lời + mp3" if res.has_audio else "video không lời (video gốc không có tiếng)"
h.item_status(it, "done", detail=detail, result=result)
if _username:
files = [{"path": res.muted_video, "name": result["muted_video_name"]}]
if res.audio_mp3:
files.append({"path": res.audio_mp3, "name": result["audio_name"]})
auto_save_result(_username, "cut", req.session, files,
title=f"Tách âm thanh: {os.path.basename(req.file)}",
summary=detail)
return {"session": req.session, "output": result}
except Exception as e:
h.item_status(it, "error", error=str(e))
return {"session": req.session, "error": str(e)}
submit_for_session(job, work, req.session)
return {"job_id": job.id, "session": req.session}
class PreviewReq(BaseModel):
session: str
file: str
mode: str = "auto" # auto | scene | smart | highlight | even
count: int | None = None # số khúc (even) — None để máy tự chia
# PySceneDetect AdaptiveDetector (auto/scene): ngưỡng 3.0 + khúc tối thiểu 1.0s.
threshold: float = 3.0
min_len: float = 1.0
sensitivity: float = 0.5
top_ratio: float = 0.4
thumbnails: int = 40
@router.post("/preview")
def preview(req: PreviewReq):
import hashlib
src = resolve_src(req.session, req.file)
info = probe_or_400(src)
# Mỗi video có thư mục thumbnail RIÊNG (theo hash đường dẫn file) để nhiều
# video trong cùng phiên KHÔNG ghi đè thumbnail của nhau (thumb_000.jpg...).
vkey = hashlib.md5(req.file.encode("utf-8")).hexdigest()[:8]
tdir = sub_dir(req.session, "thumbs", vkey)
# MỘT lượt ffmpeg ra cả dải thumbnail lẫn bản nhỏ; dò chuyển cảnh trên bản
# nhỏ nhanh gấp ~3 lần mà điểm cắt không đổi.
thumbs, proxy = cutter.preview_pass(
info, tdir, os.path.join(tdir, "proxy.mp4"), count=req.thumbnails)
segs = modes.build_segments(
cutter.detect_info(info, proxy) if proxy else info,
req.mode, n=req.count, threshold=req.threshold, min_len=req.min_len,
sensitivity=req.sensitivity, top_ratio=req.top_ratio)
rel_thumbs = [{"time": t["time"],
"url": f"/api/files/{req.session}/thumbs/{vkey}/{os.path.basename(t['path'])}"}
for t in thumbs]
return {
"info": {"duration": info.duration, "fps": info.fps,
"nb_frames": info.nb_frames},
"segments": [s.to_dict() for s in segs],
"thumbnails": rel_thumbs,
}
class CutPoint(BaseModel):
start_frame: int
end_frame: int
class ExecuteReq(BaseModel):
session: str
file: str
# điểm cắt cuối cùng (đã chỉnh tay / thêm khúc trong UI). Nếu rỗng -> dùng mode.
segments: list[CutPoint] | None = None
mode: str = "auto" # mặc định: cắt tự động
count: int | None = None
def _plan_segments(info, item, proxy_dir: str = ""):
"""Dựng danh sách Segment từ điểm cắt của UI (nếu có), LỌC khúc lỗi
(end<=start / vượt biên) để 1 điểm cắt sai không làm hỏng cả job.
Không còn khúc hợp lệ -> tự cắt auto.
`proxy_dir`: nơi để bản nhỏ dò chuyển cảnh — chỉ dùng khi phải tự tách khúc.
"""
if item.segments:
raw = [(s.start_frame, s.end_frame) for s in item.segments]
valid = [(a, b) for a, b in raw if 0 <= a < b <= info.nb_frames]
if valid:
return [modes.Segment(i, a, b, info.fps) for i, (a, b) in enumerate(valid)]
det = info
if proxy_dir:
import hashlib
key = hashlib.md5(info.path.encode("utf-8")).hexdigest()[:8]
proxy = cutter.build_proxy(info, os.path.join(proxy_dir, f"proxy_{key}.mp4"))
if proxy:
det = cutter.detect_info(info, proxy)
return modes.build_segments(det, item.mode or "auto", n=item.count)
@router.post("/execute")
def execute(req: ExecuteReq):
src = resolve_src(req.session, req.file)
info = probe_or_400(src)
segs = _plan_segments(info, req, proxy_dir=sub_dir(req.session, "proxy"))
job = jobs.create("cut")
def work(h):
out_dir = sub_dir(req.session, "clips")
n = len(segs)
names = [f"clip_{seg.index + 1:03d}.mp4" for seg in segs]
its = [h.add_item(nm[:-4]) for nm in names]
results: list = [None] * n
done = {"n": 0}
lock = threading.Lock() # on_done chạy từ nhiều luồng cắt
def on_done(i, r, err):
if r is not None:
results[i] = r
h.item_status(its[i], "done", detail=f"{r.duration}s", result={
"name": names[i], "duration": r.duration, "size": r.size,
"url": f"/api/files/{req.session}/clips/{names[i]}"})
else:
h.item_status(its[i], "error", error=str(err))
with lock:
done["n"] += 1
h.set_progress(done["n"] / max(1, n))
cutter.cut_segments(src, segs, [os.path.join(out_dir, nm) for nm in names],
on_done=on_done)
ok = [r for r in results if r]
zp = os.path.join(session_dir(req.session), "clips.zip")
cutter.zip_clips(ok, zp)
return {"session": req.session, "clips": len(ok),
"zip": "clips.zip"}
submit_for_session(job, work, req.session)
return {"job_id": job.id, "session": req.session,
"segments": [s.to_dict() for s in segs]}
class BatchItem(BaseModel):
file: str
# Phiên CHỨA file này. Bình thường trùng phiên chung, nhưng khi người dùng
# 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
# khác nhau -> nhận cả phiên riêng để vẫn cắt được thay vì 500.
session: str = ""
segments: list[CutPoint] | None = None
mode: str = "auto" # mặc định: cắt tự động theo nội dung (thị giác 3 tầng)
count: int | None = None
class ExecuteBatchReq(BaseModel):
session: str
videos: list[BatchItem]
username: str = ""
@router.post("/execute-batch")
def execute_batch(req: ExecuteBatchReq):
"""Cắt NHIỀU video cùng lúc trong một phiên -> 1 danh sách clip gộp.
Tên clip có tiền tố theo video để không đè nhau: v{vi}_clip_{i}.mp4.
Mọi việc nặng (ffprobe, tách khúc, cắt) chạy TRONG JOB: request trả ngay,
và 1 video hỏng chỉ hỏng phần của nó chứ không giết cả mẻ.
"""
if not req.videos:
raise HTTPException(400, "Chưa có video nào để cắt.")
# Chỉ kiểm đường dẫn (rẻ, chặn thoát thư mục); đọc file để trong job.
srcs = [resolve_src(v.session or req.session, v.file) for v in req.videos]
job = jobs.create("cut")
_username = req.username
def work(h):
from concurrent.futures import ThreadPoolExecutor
out_dir = sub_dir(req.session, "clips")
n_vid = len(req.videos)
# ── 1) Đọc metadata SONG SONG (ffprobe nhẹ, chờ I/O là chính) ────────
infos: list = [None] * n_vid
errors: list = [None] * n_vid
def read_info(vi: int):
try:
infos[vi] = probe(srcs[vi])
except Exception as e:
errors[vi] = str(e)
with ThreadPoolExecutor(max_workers=min(4, n_vid)) as ex:
list(ex.map(read_info, range(n_vid)))
# ── 2) Dựng điểm cắt (mode auto phải quét hình -> chạy tuần tự) ──────
plans: list = [None] * n_vid
for vi in range(n_vid):
if infos[vi] is None:
continue
try:
plans[vi] = _plan_segments(infos[vi], req.videos[vi],
proxy_dir=sub_dir(req.session, "proxy"))
except Exception as e:
errors[vi] = f"Không tách được khúc: {e}"
total = sum(len(p) for p in plans if p) or 1
done = {"n": 0}
lock = threading.Lock() # on_done chạy từ nhiều luồng cắt
# ── 3) Cắt từng video ────────────────────────────────────────────────
groups = []
for vi, item in enumerate(req.videos):
base = os.path.splitext(os.path.basename(item.file))[0]
if errors[vi] or not plans[vi]:
msg = errors[vi] or "Không có khúc nào để cắt."
h.item_status(h.add_item(base), "error", error=msg)
groups.append({"video": base, "file": item.file, "clips": [], "error": msg})
continue
segs = plans[vi]
names = [f"v{vi+1}_clip_{i+1:03d}.mp4" for i in range(len(segs))]
its = [h.add_item(nm[:-4]) for nm in names]
clips: list = [None] * len(segs)
def on_done(i, r, err, _vi=vi, _names=names, _its=its, _clips=clips, _base=base):
if r is not None:
clip = {"name": _names[i], "duration": r.duration, "size": r.size,
"video": _base,
"url": f"/api/files/{req.session}/clips/{_names[i]}"}
_clips[i] = clip
h.item_status(_its[i], "done", detail=f"{r.duration}s", result=clip)
else:
h.item_status(_its[i], "error", error=str(err))
with lock:
done["n"] += 1
h.set_progress(done["n"] / total)
cutter.cut_segments(
srcs[vi], segs, [os.path.join(out_dir, nm) for nm in names],
on_done=on_done)
ok = [c for c in clips if c]
group = {"video": base, "file": item.file, "clips": ok}
if len(ok) < len(segs):
group["error"] = f"{len(segs) - len(ok)}/{len(segs)} khúc cắt lỗi."
groups.append(group)
total_clips = sum(len(g["clips"]) for g in groups)
if not total_clips:
raise RuntimeError(
next((g["error"] for g in groups if g.get("error")), "Không cắt được khúc nào."))
# Gói 1 file zip cho cả mẻ (lưu trữ, không nén — mp4 nén thêm chẳng lợi).
zp = os.path.join(session_dir(req.session), "clips.zip")
try:
cutter.zip_paths_stored(
[os.path.join(out_dir, c["name"]) for g in groups for c in g["clips"]], zp)
except Exception:
zp = ""
if _username:
files = [{"path": os.path.join(out_dir, c["name"]), "name": c["name"]}
for g in groups for c in g["clips"]]
if zp and os.path.exists(zp):
files.append({"path": zp, "name": "clips.zip"})
auto_save_result(_username, "cut", req.session, files,
title=f"Cắt {total_clips} clip từ {len(groups)} video",
summary=f"{total_clips} clip · {len(groups)} video")
return {"session": req.session, "clips": total_clips, "videos": groups,
"zip": "clips.zip" if zp else ""}
submit_for_session(job, work, req.session)
return {"job_id": job.id, "session": req.session}
class PushShuffleReq(BaseModel):
session: str
# segments[i] = danh sách khúc thứ i CỦA MỌI VIDEO (cùng vị trí) -> chung 1 seg_i.
# Nhờ vậy vị trí 1 gồm {khúc1 video1, khúc1 video2,...} làm các lựa chọn để Xào,
# KHÔNG nối đuôi thành muôn vàn khúc.
segments: list[list[str]]
@router.post("/push-shuffle")
def push_shuffle(req: PushShuffleReq):
"""Đẩy các khúc đã cắt sang tool Xào: mỗi VỊ TRÍ thành 1 segment (seg_i),
chứa khúc cùng chỉ số của tất cả video (các lựa chọn hoán đổi cho vị trí đó).
FE mở Xào với session này; tool Xào tự quét seg_* và coi mọi file trong 1 seg
là các clip thay thế cho vị trí đó.
"""
import shutil
new_sid = new_session("shuffle")
for i, group in enumerate(req.segments):
folder = sub_dir(new_sid, f"seg_{i}")
for rel in group:
src = os.path.join(session_dir(req.session), rel)
if not os.path.exists(src):
continue
# tránh trùng tên giữa các video -> tên clip đã có tiền tố v{n}_ nên an toàn
dst = os.path.join(folder, os.path.basename(src))
shutil.copy(src, dst)
return {"session": new_sid}