"""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}