titusgiap Claude Opus 4.8 commited on
Commit
824497d
·
1 Parent(s): aab8e67

feat(shuffle): bảng tổ hợp mirror video_cook — ảo hoá 300k + lọc bằng nút (JS)

Browse files

Là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 CHANGED
@@ -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). KHÔNG nên quá
171
- # lớn: mỗi dòng là JSON nhiều object -> vài chục nghìn dòng đã nặng payload/DOM.
172
- PLAN_CAP = _env_int("SHUFFLE_PLAN_CAP", 5000)
 
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 biến thể (chưa render) để user xem bảng & tick chọn.
 
182
 
183
- Mỗi biến thể: sequence (list clip_id) + nhãn "Khúc i: <clip>" theo thứ tự ghép.
184
- Số dòng = sau khi áp lọc tương đồng/ngẫu nhiên hiện tại (mặc định đều được tick).
 
185
  """
186
  cfg, _, clip_meta = _build(req)
187
- # TẤT CẢ biến thể thô (chưa lọc) để hiện đủ bảng; cắt trần tránh payload lớn.
188
- raw: list[list[int]] = []
189
- capped = False
190
- for s in engine._iter_raw_sequences(cfg):
191
- raw.append(s)
192
- if len(raw) > PLAN_CAP:
193
- capped = True
194
- raw = raw[:PLAN_CAP]
195
- break
196
- # tập biến thể SỐNG SÓT theo bộ lọc hiện tại (tương đồng/ngẫu nhiên/giới hạn).
197
- # Dùng trần = PLAN_CAP (không phải DEFAULT_CAP nhỏ) để tick phản ánh ĐÚNG bộ
198
- # lọc trên toàn bảng: không lọc gì -> tick hết; có lọc -> chỉ tick phần sống sót.
199
- keep_cfg = replace(cfg, max_outputs=(req.max_outputs or PLAN_CAP))
200
- kept = {tuple(s) for s in engine.generate(keep_cfg)}
201
- variants = []
202
- for i, seq in enumerate(raw):
203
- labels = [{"segment": clip_meta.get(c, {}).get("segment", 0) + 1,
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 ---
frontend/src/components/ComboTable.jsx ADDED
@@ -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;
frontend/src/pages/ShuffleVideo.jsx CHANGED
@@ -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: [], // bảng biến thể (mỗi dòng: {i, seq, labels, keep})
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 biến thể (chưa render). keep = do bộ lọc hiện tại quyết định.
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.variants || [], maxRender: r.max_render || 10000 });
216
- if (r.capped) alert(`Danh sách lớn — chỉ hiển thị ${r.variants.length} biến thể đầu.`);
 
 
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
- // nếu đã mở bảng -> chỉ render các dòng được tick; chưa mở bảng -> render tất cả như cũ
240
- let sequences = st.plan.length ? selectedSeqs() : null;
241
- if (st.plan.length && !sequences.length) return alert("Chưa chọn biến thể nào để render.");
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 biến thể + bộ lọc (bật lọc -> tự bỏ tick) + render */}
390
- {st.plan.length > 0 && (
391
  <div className="card">
392
- <div className="flex between center">
393
  <div className="panel-title" style={{ margin: 0 }}>
394
- 5. Bảng biến thể ({st.plan.filter((v) => v.keep).length}/{st.plan.length} sẽ render)
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 (khung dọc)</span>
457
- <select value={st.res} onChange={(e) => set({ res: e.target.value })} style={{ width: 160 }}>
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
- {st.plan.filter((v) => v.keep).length > MAX_RENDER && (
467
- <p className="small mt8" style={{ color: "var(--orange-700)" }}>
468
- ⚠ Đang chọn {st.plan.filter((v) => v.keep).length} biến thể — chỉ render tối đa {MAX_RENDER} video/lần, phần dư sẽ bị bỏ.
469
- </p>
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(st.plan.filter((v) => v.keep).length, MAX_RENDER)}/{st.plan.filter((v) => v.keep).length})</>}
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
  )}
frontend/src/workers/simFilter.js ADDED
@@ -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
+ };