Spaces:
Running
Running
Download backend/app/api/cut.py from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/cut.py
- Command line
-
hf download hf://spaces/tntgroup/website_content/backend/app/api/cut.py
-
curl -L -o cut.py https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/cut.py
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)) | |
| 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 = "" | |
| 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 | |
| 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) | |
| 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 = "" | |
| 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]] | |
| 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} | |