Spaces:
Running
Running
Download backend/app/api/shuffle.py from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/shuffle.py
- Command line
-
hf download hf://spaces/tntgroup/website_content/backend/app/api/shuffle.py
-
curl -L -o shuffle.py https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/app/api/shuffle.py
13.8 kB
| """API tool: Xào video. | |
| Luồng FE: | |
| 1) /upload -> nạp clip vào từng khúc (folder "seg_{i}") | |
| 2) /upload-audio -> (tùy chọn) nạp track âm thanh để lồng vào biến thể | |
| 3) /estimate -> tính số tổ hợp + số sau lọc trùng | |
| 4) /generate -> sinh tổ hợp + dedup + render (+ lồng audio) -> zip | |
| Thứ tự đảo do order_mode của TỪNG khúc quyết định (lock|shuffle|drop), | |
| không còn cờ allow_reorder toàn cục. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from dataclasses import replace | |
| 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.shuffler import engine | |
| from ..services.shuffler import render as shuffle_render | |
| from ..services.history.auto_save import auto_save_result | |
| router = APIRouter(prefix="/api/shuffle", tags=["shuffle"]) | |
| # ----------------------------------------------------------------- upload --- | |
| def upload( | |
| file: UploadFile = File(...), | |
| segment: int = Form(...), | |
| session: str = Form(None), | |
| ): | |
| """Tải 1 clip vào một khúc. Hỗ trợ tải cả thư mục (FE gửi nhiều file).""" | |
| sid = session or new_session("shuffle") | |
| folder = f"seg_{segment}" | |
| path = save_upload_stream(sid, file.filename, file.file, folder=folder, | |
| max_mb=settings.MAX_UPLOAD_MB) | |
| rel = rel_path(path, sid) | |
| return { | |
| "session": sid, "segment": segment, "file": rel, | |
| "name": os.path.basename(path), "size": os.path.getsize(path), | |
| "url": f"/api/files/{sid}/{rel}", | |
| } | |
| def upload_audio( | |
| file: UploadFile = File(...), | |
| session: str = Form(...), | |
| ): | |
| """Tải 1 track âm thanh dùng để lồng vào các biến thể.""" | |
| path = save_upload_stream(session, file.filename, file.file, folder="audio", | |
| max_mb=settings.MAX_UPLOAD_MB) | |
| rel = rel_path(path, session) | |
| return { | |
| "session": session, "file": rel, | |
| "name": os.path.basename(path), "size": os.path.getsize(path), | |
| "url": f"/api/files/{session}/{rel}", | |
| } | |
| def list_segments(sid: str): | |
| """Liệt kê các khúc (seg_*) đã có sẵn trong phiên — dùng khi đẩy từ Cut.""" | |
| base = session_dir(sid) | |
| segs = [] | |
| if os.path.isdir(base): | |
| names = sorted( | |
| [d for d in os.listdir(base) if d.startswith("seg_")], | |
| key=lambda x: int(x.split("_")[1]) if x.split("_")[1].isdigit() else 0) | |
| for d in names: | |
| folder = os.path.join(base, d) | |
| if not os.path.isdir(folder): | |
| continue | |
| files = [] | |
| for fn in sorted(os.listdir(folder)): | |
| full = os.path.join(folder, fn) | |
| if os.path.isfile(full): | |
| rel = f"{d}/{fn}" | |
| files.append({"file": rel, "name": fn, | |
| "size": os.path.getsize(full), | |
| "url": f"/api/files/{sid}/{rel}"}) | |
| segs.append({"files": files}) | |
| return {"session": sid, "segments": segs} | |
| # ------------------------------------------------ build config từ payload --- | |
| class SegmentIn(BaseModel): | |
| files: list[str] | |
| order_mode: str = "shuffle" # lock | shuffle | drop | |
| class ShuffleReq(BaseModel): | |
| session: str | |
| segments: list[SegmentIn] | |
| audio_files: list[str] = [] # track âm thanh (tùy chọn) | |
| keep_original_audio: bool = False # giữ tiếng gốc của clip khi ghép các khúc | |
| similarity_threshold: float = 0.80 # >=1.0 => tắt lọc trùng | |
| random_ratio: float = 1.0 # >=1.0 => không lấy mẫu | |
| max_outputs: int | None = None | |
| seed: int | None = None | |
| # danh sách biến thể user đã CHỌN render (mỗi phần tử = list clip_id). | |
| # None -> tự sinh theo cfg (giữ hành vi cũ). | |
| sequences: list[list[int]] | None = None | |
| # độ phân giải xuất ra (khung dọc TikTok). Mặc định 1080x1920. | |
| width: int = 1080 | |
| height: int = 1920 | |
| username: str = "" | |
| def _build(req: ShuffleReq): | |
| clip_map: dict[int, str] = {} | |
| clip_meta: dict[int, dict] = {} # clip_id -> {segment, name} | |
| seg_objs = [] | |
| cid = 0 | |
| for si, seg in enumerate(req.segments): | |
| ids = [] | |
| for rel in seg.files: | |
| clip_map[cid] = os.path.join(session_dir(req.session), rel) | |
| clip_meta[cid] = {"segment": si, "name": os.path.basename(rel)} | |
| ids.append(cid) | |
| cid += 1 | |
| seg_objs.append(engine.Segment(clips=ids, order_mode=seg.order_mode)) | |
| cfg = engine.ShuffleConfig( | |
| segments=seg_objs, | |
| similarity_threshold=req.similarity_threshold, | |
| random_ratio=req.random_ratio, | |
| max_outputs=req.max_outputs, | |
| seed=req.seed, | |
| ) | |
| return cfg, clip_map, clip_meta | |
| def _audio_paths(req: ShuffleReq) -> list[str]: | |
| return [os.path.join(session_dir(req.session), a) for a in req.audio_files] | |
| # --------------------------------------------------------------- estimate --- | |
| def estimate(req: ShuffleReq): | |
| cfg, _, _ = _build(req) | |
| est = engine.estimate(cfg) | |
| preview = engine.generate(cfg) | |
| return { | |
| "selection_count": est.selection_count, | |
| "ordering_count": est.ordering_count, | |
| "raw_total": est.raw_total, | |
| "after_dedup": len(preview), | |
| "max_render": MAX_RENDER, | |
| } | |
| # ------------------------------------------------------------------- plan --- | |
| def _env_int(key: str, default: int) -> int: | |
| try: | |
| return max(1, int(os.environ.get(key, "") or default)) | |
| except (TypeError, ValueError): | |
| return default | |
| # Trần số dòng BẢNG trả về trình duyệt (chỉ là nhãn, chưa render). Bảng FE ĐÃ ẢO | |
| # HOÁ (chỉ vẽ dòng đang thấy) + lọc bằng JS nên chịu được rất nhiều dòng. Payload | |
| # chỉ là mảng chỉ số clip (id) + 1 bảng tên -> gọn (300k dòng ~ vài MB). | |
| PLAN_CAP = _env_int("SHUFFLE_PLAN_CAP", 300000) | |
| # Tối đa số video RENDER mỗi lần. Nâng cao để "xào" ra nhiều biến thể hơn; mỗi | |
| # video = 1 file + thời gian ghép (concat copy nhanh, nhưng vẫn tốn đĩa/CPU). | |
| # Chỉnh qua env SHUFFLE_MAX_RENDER tùy sức máy. Render 10k ≈ chục phút + nhiều GB đĩa. | |
| MAX_RENDER = _env_int("SHUFFLE_MAX_RENDER", 10000) | |
| def plan(req: ShuffleReq): | |
| """Sinh DANH SÁCH tổ hợp (chưa render) theo ĐÚNG THỨ TỰ như tool video_cook: | |
| tuần tự từ điển nếu ≤ trần, LẤY MẪU ĐA DẠNG nếu vượt trần. | |
| KHÔNG áp lọc tương đồng/ngẫu nhiên ở đây — FE lọc bằng nút (JS) như video_cook. | |
| Trả về mảng `sequences` (mỗi phần tử = list clip_id theo thứ tự ghép) + bảng | |
| `clip_names` (index = clip_id) để FE dựng nhãn "tên + tên + tên". Payload gọn. | |
| """ | |
| cfg, _, clip_meta = _build(req) | |
| est = engine.estimate(cfg) | |
| raw_total = est.raw_total | |
| # engine.generate (bê từ video_cook): ≤ trần -> tuần tự; vượt -> mẫu ngẫu nhiên | |
| # đa dạng. Ép TẮT mọi lọc để lấy danh sách GỐC, mọi dòng mặc định được chọn. | |
| base_cfg = replace(cfg, similarity_threshold=1.0, random_ratio=1.0, | |
| max_outputs=PLAN_CAP) | |
| sequences = engine.generate(base_cfg) | |
| capped = raw_total > len(sequences) | |
| max_cid = max(clip_meta) if clip_meta else -1 | |
| clip_names = [clip_meta.get(i, {}).get("name", str(i)) for i in range(max_cid + 1)] | |
| return { | |
| "count": len(sequences), "capped": capped, "raw_total": raw_total, | |
| "sequences": sequences, "clip_names": clip_names, | |
| "n_seg": len(sequences[0]) if sequences else 0, | |
| "sources": [os.path.basename(a) for a in req.audio_files], | |
| "max_render": MAX_RENDER, "plan_cap": PLAN_CAP, | |
| } | |
| # --------------------------------------------------------------- generate --- | |
| def generate(req: ShuffleReq): | |
| cfg, clip_map, _ = _build(req) | |
| # Clip nằm ở vùng tạm (/tmp trên HF) -> BỊ XOÁ khi server khởi động lại/deploy. | |
| # Trình duyệt vẫn nhớ danh sách clip nên user không biết. Kiểm tra trước để báo | |
| # rõ ràng, thay vì để ffmpeg chết với "exit status 254" (ENOENT) khó hiểu. | |
| missing = sorted({os.path.basename(p) for p in clip_map.values() | |
| if not os.path.exists(p)}) | |
| if missing: | |
| raise HTTPException(400, ( | |
| f"{len(missing)} clip không còn trên máy chủ (vùng tạm đã bị xoá khi " | |
| f"server khởi động lại). Hãy TẢI LẠI clip rồi xào lại. " | |
| f"Thiếu: {', '.join(missing[:3])}" | |
| + (f" …và {len(missing) - 3} clip khác" if len(missing) > 3 else ""))) | |
| # Chỉ render các biến thể user đã CHỌN (nếu gửi lên); không thì tự sinh như cũ. | |
| sequences = req.sequences if req.sequences else engine.generate(cfg) | |
| # Trần cứng số video render mỗi lần để không quá tải máy. | |
| capped_render = len(sequences) > MAX_RENDER | |
| if capped_render: | |
| sequences = sequences[:MAX_RENDER] | |
| audio_paths = _audio_paths(req) | |
| w, h_res = max(64, req.width), max(64, req.height) | |
| job = jobs.create("shuffle") | |
| def work(h): | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| import threading | |
| if capped_render: | |
| h.log(f"Đã chọn quá {MAX_RENDER} biến thể — chỉ render {MAX_RENDER} video đầu.") | |
| h.log(f"Sẽ render {len(sequences)} biến thể @ {w}x{h_res}" | |
| + (" · giữ tiếng gốc" if req.keep_original_audio else " · tắt tiếng gốc") | |
| + (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else "")) | |
| out_dir = sub_dir(req.session, "variations") | |
| norm_dir = shuffle_render._norm_dir_for(out_dir) | |
| n = len(sequences) | |
| results = [None] * n | |
| items = [h.add_item(f"Variation_{i+1:02d}") for i in range(n)] | |
| done_lock = threading.Lock() | |
| counter = {"done": 0} | |
| # ── Đường nhanh: chuẩn hoá TRƯỚC mỗi clip nguồn ĐÚNG 1 LẦN (song song) ── | |
| # Mọi biến thể sau đó chỉ concat copy (không re-encode) nên rất nhanh. | |
| uniq_clips = {c for seq in sequences for c in seq} | |
| workers = min(4, max(1, (os.cpu_count() or 2) - 1)) | |
| if uniq_clips: | |
| h.log(f"Chuẩn hoá {len(uniq_clips)} clip nguồn (1 lần/clip)…") | |
| with ThreadPoolExecutor(max_workers=workers) as ex: | |
| futs = {ex.submit(shuffle_render.normalize_clip, | |
| clip_map[c], norm_dir, w, h_res, 30, | |
| req.keep_original_audio): c | |
| for c in uniq_clips} | |
| warmed = 0 | |
| for _ in as_completed(futs): | |
| warmed += 1 | |
| # Chiếm ~30% thanh tiến độ cho bước chuẩn hoá. | |
| h.set_progress(0.30 * warmed / max(1, len(uniq_clips))) | |
| def render_one(i, seq): | |
| it = items[i] | |
| try: | |
| paths = [clip_map[c] for c in seq] | |
| name = f"Variation_{i+1:02d}.mp4" | |
| out = os.path.join(out_dir, name) | |
| track = audio_paths[i % len(audio_paths)] if audio_paths else None | |
| shuffle_render.render_variation(paths, out, norm_dir, w, h_res, | |
| audio_path=track, | |
| keep_original=req.keep_original_audio) | |
| size = os.path.getsize(out) | |
| results[i] = {"name": name, "path": out, "sequence": seq, "size": size} | |
| h.item_status(it, "done", detail=f"{size//1024} KB", result={ | |
| "name": name, "size": size, "sequence": seq, | |
| "url": f"/api/files/{req.session}/variations/{name}"}) | |
| except Exception as e: | |
| h.item_status(it, "error", error=str(e)) | |
| with done_lock: | |
| counter["done"] += 1 | |
| # 30% chuẩn hoá + 70% ghép biến thể. | |
| h.set_progress(0.30 + 0.70 * counter["done"] / max(1, n)) | |
| # ghép SONG SONG (concat copy nhẹ -> có thể chạy nhiều luồng) | |
| with ThreadPoolExecutor(max_workers=workers) as ex: | |
| for i, seq in enumerate(sequences): | |
| ex.submit(render_one, i, seq) | |
| ok = [r for r in results if r] | |
| zp = os.path.join(session_dir(req.session), "variations.zip") | |
| shuffle_render.zip_variations(ok, zp) | |
| if _username: | |
| files = [] | |
| for r in ok: | |
| if os.path.exists(r["path"]): | |
| files.append({"path": r["path"], "name": r["name"]}) | |
| if os.path.exists(zp): | |
| files.append({"path": zp, "name": "variations.zip"}) | |
| auto_save_result(_username, "shuffle", req.session, files, | |
| title=f"Xào {len(ok)} biến thể", | |
| summary=f"{len(ok)} video · {len(req.segments)} khúc") | |
| return {"session": req.session, "count": len(ok), "zip": "variations.zip"} | |
| _username = req.username | |
| submit_for_session(job, work, req.session) | |
| return {"job_id": job.id, "session": req.session, "planned": len(sequences)} | |