"""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 --- @router.post("/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}", } @router.post("/upload-audio") 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}", } @router.get("/segments/{sid}") 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 --- @router.post("/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) @router.post("/plan") 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 --- @router.post("/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)}