website_content / backend /app /api /shuffle.py
titusgiap's picture
Claude Opus 5
fix(core): mọi tool đều giữ chỗ phiên khi chạy, thêm lớp chặn cho việc chạy đồng bộ
c8ce1a0
Raw History Blame Contribute Delete
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 ---
@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)}