Spaces:
Running
feat(shuffle): bảng tổ hợp mirror video_cook — ảo hoá 300k + lọc bằng nút (JS)
Browse filesLàm phần hiển thị list combo giống tool video_cook:
- Thứ tự combo: tuần tự từ điển nếu ≤ trần, LẤY MẪU ĐA DẠNG nếu vượt (bỏ cách cũ
chỉ lấy N cái đầu theo product). /plan trả sequences + clip_names (payload gọn).
- Trần 300.000 combo. Bảng FE ẢO HOÁ (chỉ vẽ dòng đang thấy) -> không treo dù
hàng trăm nghìn dòng. Hiển thị "tên + tên + tên" + cột 🎵 Source (xoay vòng
theo thứ hạng trong tập đang chọn) + STT + ✓, giống combo_model.py.
- Bộ lọc bằng NÚT nối chuỗi trên tập ĐANG CHỌN (giống tab2_output.py): Đặt lại /
Chọn hết / Bỏ hết / Lọc ngẫu nhiên % / Lọc tương đồng % + "Ưu tiên khúc khác
hoàn toàn". Lọc tương đồng port Y HỆT SimilarityWorker (greedy keep-first + nén,
0%=khác hoàn toàn), chạy Web Worker -> UI không đơ. Đã cross-check 40 case khớp
100% với bản Python.
Component mới: ComboTable.jsx, worker: simFilter.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- backend/app/api/shuffle.py +26 -28
- frontend/src/components/ComboTable.jsx +265 -0
- frontend/src/pages/ShuffleVideo.jsx +25 -96
- frontend/src/workers/simFilter.js +64 -0
|
@@ -167,9 +167,10 @@ def _env_int(key: str, default: int) -> int:
|
|
| 167 |
return default
|
| 168 |
|
| 169 |
|
| 170 |
-
# Trần số dòng BẢNG trả về trình duyệt (chỉ là nhãn, chưa render).
|
| 171 |
-
#
|
| 172 |
-
|
|
|
|
| 173 |
# 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
|
| 174 |
# video = 1 file + thời gian ghép (concat copy nhanh, nhưng vẫn tốn đĩa/CPU).
|
| 175 |
# Chỉnh qua env SHUFFLE_MAX_RENDER tùy sức máy. Render 10k ≈ chục phút + nhiều GB đĩa.
|
|
@@ -178,34 +179,31 @@ MAX_RENDER = _env_int("SHUFFLE_MAX_RENDER", 10000)
|
|
| 178 |
|
| 179 |
@router.post("/plan")
|
| 180 |
def plan(req: ShuffleReq):
|
| 181 |
-
"""Sinh DANH SÁCH
|
|
|
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
|
|
|
| 185 |
"""
|
| 186 |
cfg, _, clip_meta = _build(req)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
"name": clip_meta.get(c, {}).get("name", str(c))} for c in seq]
|
| 205 |
-
variants.append({"i": i, "seq": seq, "labels": labels,
|
| 206 |
-
"keep": tuple(seq) in kept})
|
| 207 |
-
return {"count": len(variants), "capped": capped, "variants": variants,
|
| 208 |
-
"max_render": MAX_RENDER, "plan_cap": PLAN_CAP}
|
| 209 |
|
| 210 |
|
| 211 |
# --------------------------------------------------------------- generate ---
|
|
|
|
| 167 |
return default
|
| 168 |
|
| 169 |
|
| 170 |
+
# Trần số dòng BẢNG trả về trình duyệt (chỉ là nhãn, chưa render). Bảng FE ĐÃ ẢO
|
| 171 |
+
# 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
|
| 172 |
+
# chỉ là mảng chỉ số clip (id) + 1 bảng tên -> gọn (300k dòng ~ vài MB).
|
| 173 |
+
PLAN_CAP = _env_int("SHUFFLE_PLAN_CAP", 300000)
|
| 174 |
# 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
|
| 175 |
# video = 1 file + thời gian ghép (concat copy nhanh, nhưng vẫn tốn đĩa/CPU).
|
| 176 |
# Chỉnh qua env SHUFFLE_MAX_RENDER tùy sức máy. Render 10k ≈ chục phút + nhiều GB đĩa.
|
|
|
|
| 179 |
|
| 180 |
@router.post("/plan")
|
| 181 |
def plan(req: ShuffleReq):
|
| 182 |
+
"""Sinh DANH SÁCH tổ hợp (chưa render) theo ĐÚNG THỨ TỰ như tool video_cook:
|
| 183 |
+
tuần tự từ điển nếu ≤ trần, LẤY MẪU ĐA DẠNG nếu vượt trần.
|
| 184 |
|
| 185 |
+
KHÔNG áp lọc tương đồng/ngẫu nhiên ở đây — FE lọc bằng nút (JS) như video_cook.
|
| 186 |
+
Trả về mảng `sequences` (mỗi phần tử = list clip_id theo thứ tự ghép) + bảng
|
| 187 |
+
`clip_names` (index = clip_id) để FE dựng nhãn "tên + tên + tên". Payload gọn.
|
| 188 |
"""
|
| 189 |
cfg, _, clip_meta = _build(req)
|
| 190 |
+
est = engine.estimate(cfg)
|
| 191 |
+
raw_total = est.raw_total
|
| 192 |
+
# engine.generate (bê từ video_cook): ≤ trần -> tuần tự; vượt -> mẫu ngẫu nhiên
|
| 193 |
+
# đ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.
|
| 194 |
+
base_cfg = replace(cfg, similarity_threshold=1.0, random_ratio=1.0,
|
| 195 |
+
max_outputs=PLAN_CAP)
|
| 196 |
+
sequences = engine.generate(base_cfg)
|
| 197 |
+
capped = raw_total > len(sequences)
|
| 198 |
+
max_cid = max(clip_meta) if clip_meta else -1
|
| 199 |
+
clip_names = [clip_meta.get(i, {}).get("name", str(i)) for i in range(max_cid + 1)]
|
| 200 |
+
return {
|
| 201 |
+
"count": len(sequences), "capped": capped, "raw_total": raw_total,
|
| 202 |
+
"sequences": sequences, "clip_names": clip_names,
|
| 203 |
+
"n_seg": len(sequences[0]) if sequences else 0,
|
| 204 |
+
"sources": [os.path.basename(a) for a in req.audio_files],
|
| 205 |
+
"max_render": MAX_RENDER, "plan_cap": PLAN_CAP,
|
| 206 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
# --------------------------------------------------------------- generate ---
|
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
| 2 |
+
|
| 3 |
+
// Bảng tổ hợp ẢO HOÁ (chỉ vẽ dòng đang thấy) + bộ lọc bằng NÚT nối chuỗi —
|
| 4 |
+
// mirror tool video_cook (ui/combo_model.py + ui/tab2_output.py).
|
| 5 |
+
// Cột: STT | "tên + tên + tên" | [🎵 Source] | ✓
|
| 6 |
+
// Lọc: Đặt lại / Chọn hết / Bỏ hết / Lọc ngẫu nhiên % / Lọc tương đồng %
|
| 7 |
+
// + "Ưu tiên khúc khác hoàn toàn". Tất cả chạy trên tập ĐANG CHỌN.
|
| 8 |
+
// Trạng thái tick giữ trong Uint8Array (chịu được vài trăm nghìn dòng, thao tác
|
| 9 |
+
// hàng loạt chỉ vài ms). Lọc tương đồng đẩy sang Web Worker để UI không đơ.
|
| 10 |
+
|
| 11 |
+
const ROW_H = 30;
|
| 12 |
+
const VIEW_H = 384;
|
| 13 |
+
const RANK_BLOCK = 8192;
|
| 14 |
+
const JOIN = " + ";
|
| 15 |
+
|
| 16 |
+
const ComboTable = forwardRef(function ComboTable(
|
| 17 |
+
{ sequences, clipNames, nSeg, sources = [], maxRender = 10000, onCount }, ref) {
|
| 18 |
+
|
| 19 |
+
const n = sequences.length;
|
| 20 |
+
const checkedRef = useRef(new Uint8Array(0));
|
| 21 |
+
const rankRef = useRef(null); // prefix-sum số dòng chọn / block
|
| 22 |
+
const workerRef = useRef(null);
|
| 23 |
+
const [checkedCount, setCheckedCount] = useState(0);
|
| 24 |
+
const [, force] = useState(0);
|
| 25 |
+
const bump = () => force((x) => x + 1);
|
| 26 |
+
const [scrollTop, setScrollTop] = useState(0);
|
| 27 |
+
const [randPct, setRandPct] = useState(50);
|
| 28 |
+
const [simPct, setSimPct] = useState(50);
|
| 29 |
+
const [prio, setPrio] = useState([]); // bool theo từng khúc
|
| 30 |
+
const [filtering, setFiltering] = useState(null); // {msg,pct} | null
|
| 31 |
+
const [status, setStatus] = useState("");
|
| 32 |
+
|
| 33 |
+
// Nạp bộ dữ liệu mới -> tick hết, dọn mọi thứ.
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const buf = new Uint8Array(n);
|
| 36 |
+
buf.fill(1);
|
| 37 |
+
checkedRef.current = buf;
|
| 38 |
+
rankRef.current = null;
|
| 39 |
+
setCheckedCount(n);
|
| 40 |
+
setScrollTop(0);
|
| 41 |
+
setPrio(Array.from({ length: nSeg }, () => false));
|
| 42 |
+
setStatus("");
|
| 43 |
+
bump();
|
| 44 |
+
// eslint-disable-next-line
|
| 45 |
+
}, [sequences, clipNames, nSeg]);
|
| 46 |
+
|
| 47 |
+
// Worker lọc tương đồng (tạo 1 lần).
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const w = new Worker(new URL("../workers/simFilter.js", import.meta.url), { type: "module" });
|
| 50 |
+
workerRef.current = w;
|
| 51 |
+
return () => { try { w.terminate(); } catch { /* */ } };
|
| 52 |
+
}, []);
|
| 53 |
+
|
| 54 |
+
useEffect(() => { onCount && onCount(checkedCount); }, [checkedCount, onCount]);
|
| 55 |
+
|
| 56 |
+
useImperativeHandle(ref, () => ({
|
| 57 |
+
getSelected() {
|
| 58 |
+
const c = checkedRef.current;
|
| 59 |
+
const out = [];
|
| 60 |
+
for (let i = 0; i < c.length; i++) if (c[i]) out.push(sequences[i]);
|
| 61 |
+
return out;
|
| 62 |
+
},
|
| 63 |
+
selectedCount: () => checkedCount,
|
| 64 |
+
}), [sequences, checkedCount]);
|
| 65 |
+
|
| 66 |
+
// ── thao tác tick ──────────────────────────────────────────────────────
|
| 67 |
+
const invalidate = () => { rankRef.current = null; };
|
| 68 |
+
const recount = () => {
|
| 69 |
+
let s = 0; const c = checkedRef.current;
|
| 70 |
+
for (let i = 0; i < c.length; i++) s += c[i];
|
| 71 |
+
setCheckedCount(s);
|
| 72 |
+
};
|
| 73 |
+
const toggle = (i) => {
|
| 74 |
+
const c = checkedRef.current;
|
| 75 |
+
c[i] ^= 1;
|
| 76 |
+
setCheckedCount((v) => v + (c[i] ? 1 : -1));
|
| 77 |
+
invalidate(); bump();
|
| 78 |
+
};
|
| 79 |
+
const setAll = (v) => {
|
| 80 |
+
checkedRef.current.fill(v ? 1 : 0);
|
| 81 |
+
setCheckedCount(v ? n : 0);
|
| 82 |
+
invalidate(); bump();
|
| 83 |
+
setStatus(v ? `↺ Đã chọn tất cả ${n} tổ hợp` : "Đã bỏ chọn hết");
|
| 84 |
+
};
|
| 85 |
+
const setRows = (rows) => {
|
| 86 |
+
const buf = new Uint8Array(n);
|
| 87 |
+
for (const r of rows) if (r >= 0 && r < n) buf[r] = 1;
|
| 88 |
+
checkedRef.current = buf;
|
| 89 |
+
invalidate(); bump(); recount();
|
| 90 |
+
};
|
| 91 |
+
const checkedRows = () => {
|
| 92 |
+
const c = checkedRef.current; const out = [];
|
| 93 |
+
for (let i = 0; i < c.length; i++) if (c[i]) out.push(i);
|
| 94 |
+
return out;
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// ── cột 🎵 Source: xoay vòng theo THỨ HẠNG trong các dòng ĐANG CHỌN ───────
|
| 98 |
+
const buildRank = () => {
|
| 99 |
+
const c = checkedRef.current;
|
| 100 |
+
const blocks = Math.ceil(n / RANK_BLOCK) + 1;
|
| 101 |
+
const idx = new Int32Array(blocks);
|
| 102 |
+
let total = 0;
|
| 103 |
+
for (let b = 0; b < blocks - 1; b++) {
|
| 104 |
+
idx[b] = total;
|
| 105 |
+
const end = Math.min((b + 1) * RANK_BLOCK, n);
|
| 106 |
+
for (let i = b * RANK_BLOCK; i < end; i++) total += c[i];
|
| 107 |
+
}
|
| 108 |
+
idx[blocks - 1] = total;
|
| 109 |
+
rankRef.current = idx;
|
| 110 |
+
return idx;
|
| 111 |
+
};
|
| 112 |
+
const sourceFor = (row) => {
|
| 113 |
+
if (!sources.length) return null;
|
| 114 |
+
const idx = rankRef.current || buildRank();
|
| 115 |
+
const block = (row / RANK_BLOCK) | 0;
|
| 116 |
+
let rank = idx[block];
|
| 117 |
+
const c = checkedRef.current;
|
| 118 |
+
for (let i = block * RANK_BLOCK; i < row; i++) rank += c[i];
|
| 119 |
+
return sources[rank % sources.length];
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// ── bộ lọc ───────────────────────────────────────────────────────────────
|
| 123 |
+
const reset = () => setAll(true);
|
| 124 |
+
|
| 125 |
+
const randomFilter = () => {
|
| 126 |
+
const pool = checkedRows();
|
| 127 |
+
if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; }
|
| 128 |
+
const keepCount = Math.max(1, Math.round(pool.length * randPct / 100));
|
| 129 |
+
// Fisher–Yates một phần -> lấy keepCount phần tử ngẫu nhiên, không lệch.
|
| 130 |
+
for (let i = 0; i < keepCount; i++) {
|
| 131 |
+
const j = i + Math.floor(Math.random() * (pool.length - i));
|
| 132 |
+
const t = pool[i]; pool[i] = pool[j]; pool[j] = t;
|
| 133 |
+
}
|
| 134 |
+
setRows(pool.slice(0, keepCount));
|
| 135 |
+
setStatus(`🎲 Giữ ${keepCount}/${pool.length} ngẫu nhiên (${randPct}% của tập đang chọn)`);
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const similarityFilter = () => {
|
| 139 |
+
const pool = checkedRows();
|
| 140 |
+
if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; }
|
| 141 |
+
const maxSame = Math.floor(simPct / 100 * nSeg + 1e-9);
|
| 142 |
+
const priorityCols = prio.map((p, i) => (p ? i : -1)).filter((i) => i >= 0);
|
| 143 |
+
// dựng mảng phẳng các hàng đang chọn -> chuyển thẳng sang worker (transferable)
|
| 144 |
+
const flat = new Int32Array(pool.length * nSeg);
|
| 145 |
+
for (let k = 0; k < pool.length; k++) {
|
| 146 |
+
const seq = sequences[pool[k]]; const base = k * nSeg;
|
| 147 |
+
for (let j = 0; j < nSeg; j++) flat[base + j] = seq[j];
|
| 148 |
+
}
|
| 149 |
+
setFiltering({ msg: `🔍 Đang lọc ${pool.length} tổ hợp…`, pct: 0 });
|
| 150 |
+
const w = workerRef.current;
|
| 151 |
+
w.onmessage = (e) => {
|
| 152 |
+
const d = e.data;
|
| 153 |
+
if (d.type === "progress") {
|
| 154 |
+
setFiltering({ msg: `🔍 Đang lọc tương đồng… ${Math.round(d.done / d.total * 100)}%`, pct: Math.round(d.done / d.total * 100) });
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
// done
|
| 158 |
+
const keep = d.keep;
|
| 159 |
+
const keptRows = [];
|
| 160 |
+
for (let k = 0; k < pool.length; k++) if (keep[k]) keptRows.push(pool[k]);
|
| 161 |
+
setRows(keptRows);
|
| 162 |
+
setFiltering(null);
|
| 163 |
+
setStatus(`🔍 Xong! Còn ${keptRows.length}/${pool.length} (loại ${pool.length - keptRows.length} tổ hợp tương đồng)`);
|
| 164 |
+
};
|
| 165 |
+
w.postMessage({ flat, nSeg, maxSame, priorityCols }, [flat.buffer]);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
// ── ảo hoá ────────────────────────────────────────────────────────────────
|
| 169 |
+
const start = Math.max(0, ((scrollTop / ROW_H) | 0) - 6);
|
| 170 |
+
const visN = Math.ceil(VIEW_H / ROW_H) + 12;
|
| 171 |
+
const end = Math.min(n, start + visN);
|
| 172 |
+
const rows = [];
|
| 173 |
+
const c = checkedRef.current;
|
| 174 |
+
for (let i = start; i < end; i++) {
|
| 175 |
+
const on = !!c[i];
|
| 176 |
+
const names = sequences[i].map((id) => clipNames[id] || String(id)).join(JOIN);
|
| 177 |
+
const src = sourceFor(i);
|
| 178 |
+
rows.push(
|
| 179 |
+
<div key={i} onClick={() => toggle(i)}
|
| 180 |
+
style={{ display: "flex", alignItems: "center", height: ROW_H, cursor: "pointer",
|
| 181 |
+
background: i % 2 ? "var(--white)" : "var(--cream)", opacity: on ? 1 : 0.4,
|
| 182 |
+
borderBottom: "1px solid var(--line)", fontSize: 12 }}>
|
| 183 |
+
<div style={{ width: 56, textAlign: "center", color: "var(--orange-700)", flexShrink: 0 }}>{i + 1}</div>
|
| 184 |
+
<div style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }}
|
| 185 |
+
title={sequences[i].map((id) => clipNames[id] || String(id)).join("\n")}>{names}</div>
|
| 186 |
+
{src != null && (
|
| 187 |
+
<div style={{ width: 150, flexShrink: 0, color: "#0a8f3c", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }} title={src}>🎵 {src}</div>
|
| 188 |
+
)}
|
| 189 |
+
<div style={{ width: 46, textAlign: "center", flexShrink: 0 }}>
|
| 190 |
+
<input type="checkbox" checked={on} readOnly tabIndex={-1} style={{ pointerEvents: "none" }} />
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const over = checkedCount > maxRender;
|
| 197 |
+
|
| 198 |
+
return (
|
| 199 |
+
<div>
|
| 200 |
+
{/* thanh công cụ */}
|
| 201 |
+
<div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}>
|
| 202 |
+
<button className="btn sm" onClick={reset} title="Chọn lại TẤT CẢ — huỷ mọi bộ lọc đã áp">↺ Đặt lại</button>
|
| 203 |
+
<button className="btn sm" onClick={() => setAll(true)}>☑ Tất cả</button>
|
| 204 |
+
<button className="btn sm ghost" onClick={() => setAll(false)}>☐ Bỏ hết</button>
|
| 205 |
+
<span style={{ width: 1, height: 22, background: "var(--line)" }} />
|
| 206 |
+
<span className="small muted">Ngẫu nhiên</span>
|
| 207 |
+
<input type="number" min="1" max="99" value={randPct} onChange={(e) => setRandPct(Math.max(1, Math.min(99, +e.target.value || 1)))} style={{ width: 60 }} />
|
| 208 |
+
<button className="btn sm" onClick={randomFilter} disabled={!!filtering} title="Giữ lại X% video ngẫu nhiên (trong tập đang chọn)">Lọc ngẫu nhiên</button>
|
| 209 |
+
<span style={{ width: 1, height: 22, background: "var(--line)" }} />
|
| 210 |
+
<span className="small muted">Tương đồng</span>
|
| 211 |
+
<input type="number" min="0" max="100" value={simPct} onChange={(e) => setSimPct(Math.max(0, Math.min(100, +e.target.value || 0)))} style={{ width: 60 }} />
|
| 212 |
+
<button className="btn sm" onClick={similarityFilter} disabled={!!filtering}
|
| 213 |
+
title="Chỉ giữ combo có mức tương đồng ≤ ngưỡng. 0% = khác nhau HOÀN TOÀN; 100% = giữ tất cả.">
|
| 214 |
+
{filtering ? "⏳ Đang lọc…" : "Lọc tương đồng"}
|
| 215 |
+
</button>
|
| 216 |
+
<span className="small" style={{ marginLeft: "auto", color: "var(--orange-700)", fontWeight: 600 }}>
|
| 217 |
+
Đã chọn: {checkedCount.toLocaleString()} / {n.toLocaleString()}
|
| 218 |
+
</span>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* ưu tiên khúc khác hoàn toàn */}
|
| 222 |
+
{nSeg > 0 && (
|
| 223 |
+
<div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}>
|
| 224 |
+
<span className="small muted" title="Các khúc được tick sẽ bị ép KHÁC NHAU HOÀN TOÀN giữa các video giữ lại (kết hợp với ngưỡng % tương đồng).">Ưu tiên khác hoàn toàn ở khúc:</span>
|
| 225 |
+
{prio.map((p, i) => (
|
| 226 |
+
<label key={i} className="small" style={{ display: "inline-flex", alignItems: "center", gap: 4, cursor: "pointer" }}>
|
| 227 |
+
<input type="checkbox" checked={p} onChange={() => setPrio((cur) => cur.map((v, j) => (j === i ? !v : v)))} />
|
| 228 |
+
Khúc {i + 1}
|
| 229 |
+
</label>
|
| 230 |
+
))}
|
| 231 |
+
</div>
|
| 232 |
+
)}
|
| 233 |
+
|
| 234 |
+
{/* tiêu đề cột */}
|
| 235 |
+
<div style={{ display: "flex", alignItems: "center", height: 26, fontSize: 11, fontWeight: 700, color: "var(--muted)", borderBottom: "2px solid var(--line)" }}>
|
| 236 |
+
<div style={{ width: 56, textAlign: "center", flexShrink: 0 }}>STT</div>
|
| 237 |
+
<div style={{ flex: 1 }}>Tổ hợp video</div>
|
| 238 |
+
{sources.length > 0 && <div style={{ width: 150, flexShrink: 0 }}>🎵 Source</div>}
|
| 239 |
+
<div style={{ width: 46, textAlign: "center", flexShrink: 0 }}>✓</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
{/* danh sách ảo hoá */}
|
| 243 |
+
<div onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
| 244 |
+
style={{ height: VIEW_H, overflow: "auto", border: "1px solid var(--line)", borderTop: "none" }}>
|
| 245 |
+
<div style={{ height: n * ROW_H, position: "relative" }}>
|
| 246 |
+
<div style={{ position: "absolute", top: start * ROW_H, left: 0, right: 0 }}>{rows}</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{filtering && (
|
| 251 |
+
<div className="small" style={{ color: "#b45309", marginTop: 6 }}>{filtering.msg}</div>
|
| 252 |
+
)}
|
| 253 |
+
{!filtering && status && (
|
| 254 |
+
<div className="small muted" style={{ marginTop: 6 }}>{status}</div>
|
| 255 |
+
)}
|
| 256 |
+
{over && (
|
| 257 |
+
<p className="small mt8" style={{ color: "var(--orange-700)" }}>
|
| 258 |
+
⚠ Đang chọn {checkedCount.toLocaleString()} biến thể — chỉ render tối đa {maxRender.toLocaleString()} video/lần, phần dư sẽ bị bỏ.
|
| 259 |
+
</p>
|
| 260 |
+
)}
|
| 261 |
+
</div>
|
| 262 |
+
);
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
export default ComboTable;
|
|
@@ -5,6 +5,7 @@ import { useToolState } from "../store";
|
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
|
| 7 |
import { IcShuffle, IcDownload, IcCheck, IcMusic } from "../components/Icons";
|
|
|
|
| 8 |
|
| 9 |
const STEPS = [
|
| 10 |
{ t: "Nhập số khúc", d: "Khai báo video gồm bao nhiêu khúc theo kịch bản." },
|
|
@@ -32,7 +33,7 @@ export default function ShuffleVideo() {
|
|
| 32 |
maxOut: "",
|
| 33 |
maxRender: 10000, // trần render/lần (đồng bộ từ backend qua plan/estimate)
|
| 34 |
res: "1080", // độ phân giải xuất ra (khung dọc)
|
| 35 |
-
plan:
|
| 36 |
results: [],
|
| 37 |
resStamp: 0, // mốc render -> cache-bust <video> khi render lại cùng tên file
|
| 38 |
busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
|
|
@@ -42,6 +43,8 @@ export default function ShuffleVideo() {
|
|
| 42 |
const setJob = (j) => set({ job: j });
|
| 43 |
const setBusy = (b) => set({ busy: b });
|
| 44 |
const [showNote, setShowNote] = useState(true);
|
|
|
|
|
|
|
| 45 |
// Loader tải lên: {seg, done, total, pct, name} — pct tính theo BYTE (chuẩn),
|
| 46 |
// upload SONG SONG nên nhanh; seg = index khúc hoặc "audio".
|
| 47 |
const [uploading, setUploading] = useState({}); // { [segKey]: {done,total,pct,name} }
|
|
@@ -204,7 +207,7 @@ export default function ShuffleVideo() {
|
|
| 204 |
const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
|
| 205 |
segs.filter((s) => s.order_mode !== "drop").every((s) => s.files.length > 0);
|
| 206 |
|
| 207 |
-
// Xem BẢNG
|
| 208 |
const [planBusy, setPlanBusy] = useState(false);
|
| 209 |
const showPlan = async () => {
|
| 210 |
if (!ready) return alert("Mỗi khúc (trừ khúc Bỏ) cần ít nhất một clip.");
|
|
@@ -212,33 +215,19 @@ export default function ShuffleVideo() {
|
|
| 212 |
setPlanBusy(true);
|
| 213 |
try {
|
| 214 |
const r = await api.shufflePlan(payload());
|
| 215 |
-
set({ plan: r
|
| 216 |
-
if (r.capped) alert(
|
|
|
|
|
|
|
| 217 |
} catch (e) { alert("Lỗi: " + e.message); }
|
| 218 |
finally { setPlanBusy(false); }
|
| 219 |
};
|
| 220 |
-
// Đổi bộ lọc (tương đồng / ngẫu nhiên / giới hạn) -> tự cập nhật lại tick trong bảng.
|
| 221 |
-
const refreshPlan = async () => {
|
| 222 |
-
try {
|
| 223 |
-
const r = await api.shufflePlan(payload());
|
| 224 |
-
set({ plan: r.variants || [], maxRender: r.max_render || 10000 });
|
| 225 |
-
} catch { /* noop */ }
|
| 226 |
-
};
|
| 227 |
-
useEffect(() => {
|
| 228 |
-
if (!st.plan.length) return; // chỉ khi bảng đang mở
|
| 229 |
-
const t = setTimeout(refreshPlan, 350); // debounce khi kéo slider
|
| 230 |
-
return () => clearTimeout(t);
|
| 231 |
-
// eslint-disable-next-line
|
| 232 |
-
}, [st.useSim, st.sim, st.useRandom, st.randomPct, st.maxOut]);
|
| 233 |
-
const toggleRow = (i) => set((cur) => ({ plan: cur.plan.map((v) => (v.i === i ? { ...v, keep: !v.keep } : v)) }));
|
| 234 |
-
const toggleAll = (val) => set((cur) => ({ plan: cur.plan.map((v) => ({ ...v, keep: val })) }));
|
| 235 |
-
const selectedSeqs = () => st.plan.filter((v) => v.keep).map((v) => v.seq);
|
| 236 |
|
| 237 |
const generate = async () => {
|
| 238 |
if (!ready) return;
|
| 239 |
-
//
|
| 240 |
-
let sequences = st.plan.
|
| 241 |
-
if (st.plan
|
| 242 |
// trần cứng 150 video/lần — nếu chọn nhiều hơn thì cảnh báo & chỉ lấy 150 đầu
|
| 243 |
if (sequences && sequences.length > MAX_RENDER) {
|
| 244 |
if (!confirm(`Bạn đã chọn ${sequences.length} biến thể, nhưng chỉ cho phép tạo tối đa ${MAX_RENDER} video mỗi lần.\n\nBấm OK để render ${MAX_RENDER} video đầu tiên.`))
|
|
@@ -386,91 +375,31 @@ export default function ShuffleVideo() {
|
|
| 386 |
</button>
|
| 387 |
</div>
|
| 388 |
|
| 389 |
-
{/* 5. bảng
|
| 390 |
-
{st.plan.
|
| 391 |
<div className="card">
|
| 392 |
-
<div className="flex between center">
|
| 393 |
<div className="panel-title" style={{ margin: 0 }}>
|
| 394 |
-
5. Bảng
|
| 395 |
-
</div>
|
| 396 |
-
<div className="flex gap8">
|
| 397 |
-
<button className="btn sm" onClick={() => toggleAll(true)}>Chọn hết</button>
|
| 398 |
-
<button className="btn sm ghost" onClick={() => toggleAll(false)}>Bỏ hết</button>
|
| 399 |
-
</div>
|
| 400 |
-
</div>
|
| 401 |
-
<div className="tablewrap mt8" style={{ maxHeight: 340, overflow: "auto" }}>
|
| 402 |
-
<table className="list">
|
| 403 |
-
<thead><tr><th style={{ width: 40 }}>#</th><th>Chuỗi ghép (khúc → clip)</th><th style={{ width: 70 }}>Render</th></tr></thead>
|
| 404 |
-
<tbody>
|
| 405 |
-
{st.plan.map((v) => (
|
| 406 |
-
<tr key={v.i} style={{ opacity: v.keep ? 1 : 0.45 }}>
|
| 407 |
-
<td className="muted">{v.i + 1}</td>
|
| 408 |
-
<td className="small">
|
| 409 |
-
{v.labels.map((l, k) => (
|
| 410 |
-
<span key={k}>
|
| 411 |
-
{k > 0 && <span className="muted"> → </span>}
|
| 412 |
-
<b>K{l.segment}</b>:{l.name}
|
| 413 |
-
</span>
|
| 414 |
-
))}
|
| 415 |
-
</td>
|
| 416 |
-
<td><input type="checkbox" checked={v.keep} onChange={() => toggleRow(v.i)} /></td>
|
| 417 |
-
</tr>
|
| 418 |
-
))}
|
| 419 |
-
</tbody>
|
| 420 |
-
</table>
|
| 421 |
-
</div>
|
| 422 |
-
|
| 423 |
-
{/* bộ lọc: bật lên -> máy tự BỎ TICK các dòng tương ứng trong bảng */}
|
| 424 |
-
<div className="divider" />
|
| 425 |
-
<div className="small muted" style={{ marginBottom: 6 }}>Bộ lọc — bật lên sẽ tự bỏ tick bớt các biến thể trong bảng:</div>
|
| 426 |
-
<div className="filter-opt">
|
| 427 |
-
<div className={"tick" + (st.useSim ? " on" : "")} onClick={() => set({ useSim: !st.useSim })}>
|
| 428 |
-
<span className="box"><IcCheck /></span> Lọc video trùng nhau
|
| 429 |
-
</div>
|
| 430 |
-
{st.useSim && (
|
| 431 |
-
<div className="sub">
|
| 432 |
-
<span className="field-label">Ngưỡng tương đồng: {st.sim}%</span>
|
| 433 |
-
<input className="slider" type="range" min="20" max="99" value={st.sim} onChange={(e) => set({ sim: +e.target.value })} />
|
| 434 |
-
<div className="small muted">Biến thể nào giống một biến thể đã giữ <b>trên {st.sim}%</b> (tính theo số khúc trùng vị trí) sẽ bị bỏ tick. Kéo <b>thấp xuống → lọc gắt hơn → còn ít video</b>.</div>
|
| 435 |
-
</div>
|
| 436 |
-
)}
|
| 437 |
-
</div>
|
| 438 |
-
<div className="filter-opt">
|
| 439 |
-
<div className={"tick" + (st.useRandom ? " on" : "")} onClick={() => set({ useRandom: !st.useRandom })}>
|
| 440 |
-
<span className="box"><IcCheck /></span> Lấy ngẫu nhiên một phần
|
| 441 |
-
</div>
|
| 442 |
-
{st.useRandom && (
|
| 443 |
-
<div className="sub">
|
| 444 |
-
<span className="field-label">Tỉ lệ lấy: {st.randomPct}%</span>
|
| 445 |
-
<input className="slider" type="range" min="5" max="99" value={st.randomPct} onChange={(e) => set({ randomPct: +e.target.value })} />
|
| 446 |
-
</div>
|
| 447 |
-
)}
|
| 448 |
-
</div>
|
| 449 |
-
<div className="flex center gap12 mt8" style={{ flexWrap: "wrap" }}>
|
| 450 |
-
<div>
|
| 451 |
-
<span className="field-label">Số video tối đa</span>
|
| 452 |
-
<input type="number" placeholder="không giới hạn" value={st.maxOut}
|
| 453 |
-
onChange={(e) => set({ maxOut: e.target.value })} style={{ width: 160 }} />
|
| 454 |
</div>
|
| 455 |
<div>
|
| 456 |
-
<span className="field-label">Chất lượng
|
| 457 |
-
<select value={st.res} onChange={(e) => set({ res: e.target.value })} style={{ width:
|
| 458 |
<option value="1080">1080 × 1920 (cao)</option>
|
| 459 |
<option value="720">720 × 1280 (vừa)</option>
|
| 460 |
<option value="480">480 × 854 (nhẹ)</option>
|
| 461 |
</select>
|
| 462 |
</div>
|
| 463 |
-
<span className="small muted">Bật lọc / nhập giới hạn → bảng tự bỏ tick bớt.</span>
|
| 464 |
</div>
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
<button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
|
| 472 |
{busy ? "Đang xào..."
|
| 473 |
-
: <><IcShuffle size={16} /> Render đã chọn ({Math.min(
|
| 474 |
</button>
|
| 475 |
</div>
|
| 476 |
)}
|
|
|
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui";
|
| 7 |
import { IcShuffle, IcDownload, IcCheck, IcMusic } from "../components/Icons";
|
| 8 |
+
import ComboTable from "../components/ComboTable";
|
| 9 |
|
| 10 |
const STEPS = [
|
| 11 |
{ t: "Nhập số khúc", d: "Khai báo video gồm bao nhiêu khúc theo kịch bản." },
|
|
|
|
| 33 |
maxOut: "",
|
| 34 |
maxRender: 10000, // trần render/lần (đồng bộ từ backend qua plan/estimate)
|
| 35 |
res: "1080", // độ phân giải xuất ra (khung dọc)
|
| 36 |
+
plan: null, // {sequences, clip_names, n_seg, sources, count, capped, raw_total}
|
| 37 |
results: [],
|
| 38 |
resStamp: 0, // mốc render -> cache-bust <video> khi render lại cùng tên file
|
| 39 |
busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
|
|
|
|
| 43 |
const setJob = (j) => set({ job: j });
|
| 44 |
const setBusy = (b) => set({ busy: b });
|
| 45 |
const [showNote, setShowNote] = useState(true);
|
| 46 |
+
const comboRef = useRef(null); // bảng tổ hợp -> lấy các dòng đã chọn
|
| 47 |
+
const [selCount, setSelCount] = useState(0);
|
| 48 |
// Loader tải lên: {seg, done, total, pct, name} — pct tính theo BYTE (chuẩn),
|
| 49 |
// upload SONG SONG nên nhanh; seg = index khúc hoặc "audio".
|
| 50 |
const [uploading, setUploading] = useState({}); // { [segKey]: {done,total,pct,name} }
|
|
|
|
| 207 |
const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
|
| 208 |
segs.filter((s) => s.order_mode !== "drop").every((s) => s.files.length > 0);
|
| 209 |
|
| 210 |
+
// Xem BẢNG tổ hợp (chưa render) — thứ tự & lọc giống tool video_cook.
|
| 211 |
const [planBusy, setPlanBusy] = useState(false);
|
| 212 |
const showPlan = async () => {
|
| 213 |
if (!ready) return alert("Mỗi khúc (trừ khúc Bỏ) cần ít nhất một clip.");
|
|
|
|
| 215 |
setPlanBusy(true);
|
| 216 |
try {
|
| 217 |
const r = await api.shufflePlan(payload());
|
| 218 |
+
set({ plan: r, maxRender: r.max_render || 10000 });
|
| 219 |
+
if (r.capped) alert(
|
| 220 |
+
`Tổng ${Number(r.raw_total).toLocaleString()} tổ hợp — hiển thị MẪU ĐA DẠNG `
|
| 221 |
+
+ `${Number(r.count).toLocaleString()} tổ hợp (trần ${Number(r.plan_cap).toLocaleString()}).`);
|
| 222 |
} catch (e) { alert("Lỗi: " + e.message); }
|
| 223 |
finally { setPlanBusy(false); }
|
| 224 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
const generate = async () => {
|
| 227 |
if (!ready) return;
|
| 228 |
+
// Lấy đúng các tổ hợp user đã tick trong bảng; chưa mở bảng -> render tất cả như cũ.
|
| 229 |
+
let sequences = st.plan ? (comboRef.current ? comboRef.current.getSelected() : []) : null;
|
| 230 |
+
if (st.plan && (!sequences || !sequences.length)) return alert("Chưa chọn tổ hợp nào để render.");
|
| 231 |
// trần cứng 150 video/lần — nếu chọn nhiều hơn thì cảnh báo & chỉ lấy 150 đầu
|
| 232 |
if (sequences && sequences.length > MAX_RENDER) {
|
| 233 |
if (!confirm(`Bạn đã chọn ${sequences.length} biến thể, nhưng chỉ cho phép tạo tối đa ${MAX_RENDER} video mỗi lần.\n\nBấm OK để render ${MAX_RENDER} video đầu tiên.`))
|
|
|
|
| 375 |
</button>
|
| 376 |
</div>
|
| 377 |
|
| 378 |
+
{/* 5. bảng tổ hợp (ảo hoá tới 300k) + lọc bằng nút (giống video_cook) + render */}
|
| 379 |
+
{st.plan && st.plan.count > 0 && (
|
| 380 |
<div className="card">
|
| 381 |
+
<div className="flex between center" style={{ flexWrap: "wrap", gap: 8 }}>
|
| 382 |
<div className="panel-title" style={{ margin: 0 }}>
|
| 383 |
+
5. Bảng tổ hợp (Tổng: {Number(st.plan.count).toLocaleString()}{st.plan.capped ? " · mẫu đa dạng" : ""})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
</div>
|
| 385 |
<div>
|
| 386 |
+
<span className="field-label" style={{ marginRight: 6 }}>Chất lượng</span>
|
| 387 |
+
<select value={st.res} onChange={(e) => set({ res: e.target.value })} style={{ width: 150 }}>
|
| 388 |
<option value="1080">1080 × 1920 (cao)</option>
|
| 389 |
<option value="720">720 × 1280 (vừa)</option>
|
| 390 |
<option value="480">480 × 854 (nhẹ)</option>
|
| 391 |
</select>
|
| 392 |
</div>
|
|
|
|
| 393 |
</div>
|
| 394 |
+
<div className="mt8">
|
| 395 |
+
<ComboTable ref={comboRef}
|
| 396 |
+
sequences={st.plan.sequences} clipNames={st.plan.clip_names}
|
| 397 |
+
nSeg={st.plan.n_seg} sources={st.plan.sources || []}
|
| 398 |
+
maxRender={MAX_RENDER} onCount={setSelCount} />
|
| 399 |
+
</div>
|
| 400 |
<button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
|
| 401 |
{busy ? "Đang xào..."
|
| 402 |
+
: <><IcShuffle size={16} /> Render đã chọn ({Math.min(selCount, MAX_RENDER).toLocaleString()}/{selCount.toLocaleString()})</>}
|
| 403 |
</button>
|
| 404 |
</div>
|
| 405 |
)}
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Web Worker: lọc tương đồng — PORT y hệt video_cook SimilarityWorker.
|
| 2 |
+
//
|
| 3 |
+
// Thuật toán greedy giữ-combo-đầu (pivot), loại các combo sau giống pivot quá
|
| 4 |
+
// ngưỡng, rồi NÉN mảng còn lại và lặp trên phần sống sót. Kết quả giống hệt bản
|
| 5 |
+
// gốc. Chạy trong worker -> UI không đơ dù vài trăm nghìn combo.
|
| 6 |
+
//
|
| 7 |
+
// Nhận: { flat: Int32Array (n*nSeg), nSeg, maxSame, priorityCols: number[] }
|
| 8 |
+
// flat = các hàng ĐANG CHỌN nối phẳng (mỗi hàng nSeg clip_id).
|
| 9 |
+
// Trả: { type:'progress', done, total } nhiều lần, rồi { type:'done', keep }
|
| 10 |
+
// keep = Uint8Array độ dài n, 1 = giữ.
|
| 11 |
+
self.onmessage = (e) => {
|
| 12 |
+
const { flat, nSeg, maxSame, priorityCols } = e.data;
|
| 13 |
+
const n = nSeg ? (flat.length / nSeg) | 0 : 0;
|
| 14 |
+
const keep = new Uint8Array(n);
|
| 15 |
+
if (n === 0) { self.postMessage({ type: "done", keep }, [keep.buffer]); return; }
|
| 16 |
+
|
| 17 |
+
const prio = priorityCols || [];
|
| 18 |
+
// Sớm: không có khúc ưu tiên VÀ ngưỡng cho phép trùng tới (nSeg-1) khúc thì
|
| 19 |
+
// không combo nào bị loại (các combo đều phân biệt) -> giữ hết tức thì.
|
| 20 |
+
if (prio.length === 0 && maxSame >= nSeg - 1) {
|
| 21 |
+
keep.fill(1);
|
| 22 |
+
self.postMessage({ type: "done", keep }, [keep.buffer]);
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
let rem = new Int32Array(n);
|
| 27 |
+
for (let i = 0; i < n; i++) rem[i] = i;
|
| 28 |
+
let processed = 0;
|
| 29 |
+
const step = Math.max(1, (n / 200) | 0);
|
| 30 |
+
let nextEmit = 0;
|
| 31 |
+
|
| 32 |
+
while (rem.length > 0) {
|
| 33 |
+
const pivot = rem[0];
|
| 34 |
+
keep[pivot] = 1;
|
| 35 |
+
if (rem.length === 1) { processed = n; break; }
|
| 36 |
+
|
| 37 |
+
const pb = pivot * nSeg;
|
| 38 |
+
const survivors = [];
|
| 39 |
+
for (let k = 1; k < rem.length; k++) {
|
| 40 |
+
const r = rem[k], b = r * nSeg;
|
| 41 |
+
let same = 0;
|
| 42 |
+
for (let j = 0; j < nSeg; j++) if (flat[b + j] === flat[pb + j]) same++;
|
| 43 |
+
let ok = same <= maxSame;
|
| 44 |
+
if (ok && prio.length) {
|
| 45 |
+
let ps = 0;
|
| 46 |
+
for (let t = 0; t < prio.length; t++) {
|
| 47 |
+
if (flat[b + prio[t]] === flat[pb + prio[t]]) { ps++; break; }
|
| 48 |
+
}
|
| 49 |
+
if (ps > 0) ok = false; // khúc ưu tiên phải KHÁC hoàn toàn
|
| 50 |
+
}
|
| 51 |
+
if (ok) survivors.push(r);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
processed += 1 + (rem.length - 1 - survivors.length);
|
| 55 |
+
rem = Int32Array.from(survivors);
|
| 56 |
+
|
| 57 |
+
if (processed >= nextEmit) {
|
| 58 |
+
self.postMessage({ type: "progress", done: Math.min(processed, n), total: n });
|
| 59 |
+
nextEmit = processed + step;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
self.postMessage({ type: "done", keep }, [keep.buffer]);
|
| 64 |
+
};
|