Spaces:
Running
Running
Download frontend/src/components/ComboTable.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/ComboTable.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/components/ComboTable.jsx
-
curl -L -o ComboTable.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/ComboTable.jsx
12.8 kB
| import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; | |
| // 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 — | |
| // mirror tool video_cook (ui/combo_model.py + ui/tab2_output.py). | |
| // Cột: STT | "tên + tên + tên" | [🎵 Source] | ✓ | |
| // Lọc: Đặ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". Tất cả chạy trên tập ĐANG CHỌN. | |
| // Trạng thái tick giữ trong Uint8Array (chịu được vài trăm nghìn dòng, thao tác | |
| // hàng loạt chỉ vài ms). Lọc tương đồng đẩy sang Web Worker để UI không đơ. | |
| const ROW_H = 30; | |
| const VIEW_H = 384; | |
| const RANK_BLOCK = 8192; | |
| const JOIN = " + "; | |
| const ComboTable = forwardRef(function ComboTable( | |
| { sequences, clipNames, nSeg, sources = [], maxRender = 10000, onCount }, ref) { | |
| const n = sequences.length; | |
| const checkedRef = useRef(new Uint8Array(0)); | |
| const rankRef = useRef(null); // prefix-sum số dòng chọn / block | |
| const workerRef = useRef(null); | |
| const [checkedCount, setCheckedCount] = useState(0); | |
| const [, force] = useState(0); | |
| const bump = () => force((x) => x + 1); | |
| const [scrollTop, setScrollTop] = useState(0); | |
| const [randPct, setRandPct] = useState(50); | |
| const [simPct, setSimPct] = useState(50); | |
| const [prio, setPrio] = useState([]); // bool theo từng khúc | |
| const [filtering, setFiltering] = useState(null); // {msg,pct} | null | |
| const [status, setStatus] = useState(""); | |
| // Nạp bộ dữ liệu mới -> tick hết, dọn mọi thứ. | |
| useEffect(() => { | |
| const buf = new Uint8Array(n); | |
| buf.fill(1); | |
| checkedRef.current = buf; | |
| rankRef.current = null; | |
| setCheckedCount(n); | |
| setScrollTop(0); | |
| setPrio(Array.from({ length: nSeg }, () => false)); | |
| setStatus(""); | |
| bump(); | |
| // eslint-disable-next-line | |
| }, [sequences, clipNames, nSeg]); | |
| // Worker lọc tương đồng (tạo 1 lần). | |
| useEffect(() => { | |
| const w = new Worker(new URL("../workers/simFilter.js", import.meta.url), { type: "module" }); | |
| workerRef.current = w; | |
| return () => { try { w.terminate(); } catch { /* */ } }; | |
| }, []); | |
| useEffect(() => { onCount && onCount(checkedCount); }, [checkedCount, onCount]); | |
| useImperativeHandle(ref, () => ({ | |
| getSelected() { | |
| const c = checkedRef.current; | |
| const out = []; | |
| for (let i = 0; i < c.length; i++) if (c[i]) out.push(sequences[i]); | |
| return out; | |
| }, | |
| selectedCount: () => checkedCount, | |
| }), [sequences, checkedCount]); | |
| // ── thao tác tick ────────────────────────────────────────────────────── | |
| const invalidate = () => { rankRef.current = null; }; | |
| const recount = () => { | |
| let s = 0; const c = checkedRef.current; | |
| for (let i = 0; i < c.length; i++) s += c[i]; | |
| setCheckedCount(s); | |
| }; | |
| const toggle = (i) => { | |
| const c = checkedRef.current; | |
| c[i] ^= 1; | |
| setCheckedCount((v) => v + (c[i] ? 1 : -1)); | |
| invalidate(); bump(); | |
| }; | |
| const setAll = (v) => { | |
| checkedRef.current.fill(v ? 1 : 0); | |
| setCheckedCount(v ? n : 0); | |
| invalidate(); bump(); | |
| setStatus(v ? `↺ Đã chọn tất cả ${n} tổ hợp` : "Đã bỏ chọn hết"); | |
| }; | |
| const setRows = (rows) => { | |
| const buf = new Uint8Array(n); | |
| for (const r of rows) if (r >= 0 && r < n) buf[r] = 1; | |
| checkedRef.current = buf; | |
| invalidate(); bump(); recount(); | |
| }; | |
| const checkedRows = () => { | |
| const c = checkedRef.current; const out = []; | |
| for (let i = 0; i < c.length; i++) if (c[i]) out.push(i); | |
| return out; | |
| }; | |
| // ── cột 🎵 Source: xoay vòng theo THỨ HẠNG trong các dòng ĐANG CHỌN ─────── | |
| const buildRank = () => { | |
| const c = checkedRef.current; | |
| const blocks = Math.ceil(n / RANK_BLOCK) + 1; | |
| const idx = new Int32Array(blocks); | |
| let total = 0; | |
| for (let b = 0; b < blocks - 1; b++) { | |
| idx[b] = total; | |
| const end = Math.min((b + 1) * RANK_BLOCK, n); | |
| for (let i = b * RANK_BLOCK; i < end; i++) total += c[i]; | |
| } | |
| idx[blocks - 1] = total; | |
| rankRef.current = idx; | |
| return idx; | |
| }; | |
| const sourceFor = (row) => { | |
| if (!sources.length) return null; | |
| const idx = rankRef.current || buildRank(); | |
| const block = (row / RANK_BLOCK) | 0; | |
| let rank = idx[block]; | |
| const c = checkedRef.current; | |
| for (let i = block * RANK_BLOCK; i < row; i++) rank += c[i]; | |
| return sources[rank % sources.length]; | |
| }; | |
| // ── bộ lọc ─────────────────────────────────────────────────────────────── | |
| const reset = () => setAll(true); | |
| const randomFilter = () => { | |
| const pool = checkedRows(); | |
| if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; } | |
| const keepCount = Math.max(1, Math.round(pool.length * randPct / 100)); | |
| // Fisher–Yates một phần -> lấy keepCount phần tử ngẫu nhiên, không lệch. | |
| for (let i = 0; i < keepCount; i++) { | |
| const j = i + Math.floor(Math.random() * (pool.length - i)); | |
| const t = pool[i]; pool[i] = pool[j]; pool[j] = t; | |
| } | |
| setRows(pool.slice(0, keepCount)); | |
| setStatus(`🎲 Giữ ${keepCount}/${pool.length} ngẫu nhiên (${randPct}% của tập đang chọn)`); | |
| }; | |
| const similarityFilter = () => { | |
| const pool = checkedRows(); | |
| if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; } | |
| const maxSame = Math.floor(simPct / 100 * nSeg + 1e-9); | |
| const priorityCols = prio.map((p, i) => (p ? i : -1)).filter((i) => i >= 0); | |
| // dựng mảng phẳng các hàng đang chọn -> chuyển thẳng sang worker (transferable) | |
| const flat = new Int32Array(pool.length * nSeg); | |
| for (let k = 0; k < pool.length; k++) { | |
| const seq = sequences[pool[k]]; const base = k * nSeg; | |
| for (let j = 0; j < nSeg; j++) flat[base + j] = seq[j]; | |
| } | |
| setFiltering({ msg: `🔍 Đang lọc ${pool.length} tổ hợp…`, pct: 0 }); | |
| const w = workerRef.current; | |
| w.onmessage = (e) => { | |
| const d = e.data; | |
| if (d.type === "progress") { | |
| setFiltering({ msg: `🔍 Đang lọc tương đồng… ${Math.round(d.done / d.total * 100)}%`, pct: Math.round(d.done / d.total * 100) }); | |
| return; | |
| } | |
| // done | |
| const keep = d.keep; | |
| const keptRows = []; | |
| for (let k = 0; k < pool.length; k++) if (keep[k]) keptRows.push(pool[k]); | |
| setRows(keptRows); | |
| setFiltering(null); | |
| setStatus(`🔍 Xong! Còn ${keptRows.length}/${pool.length} (loại ${pool.length - keptRows.length} tổ hợp tương đồng)`); | |
| }; | |
| w.postMessage({ flat, nSeg, maxSame, priorityCols }, [flat.buffer]); | |
| }; | |
| // ── ảo hoá ──────────────────────────────────────────────────────────────── | |
| const start = Math.max(0, ((scrollTop / ROW_H) | 0) - 6); | |
| const visN = Math.ceil(VIEW_H / ROW_H) + 12; | |
| const end = Math.min(n, start + visN); | |
| const rows = []; | |
| const c = checkedRef.current; | |
| for (let i = start; i < end; i++) { | |
| const on = !!c[i]; | |
| const names = sequences[i].map((id) => clipNames[id] || String(id)).join(JOIN); | |
| const src = sourceFor(i); | |
| rows.push( | |
| <div key={i} onClick={() => toggle(i)} | |
| style={{ display: "flex", alignItems: "center", height: ROW_H, cursor: "pointer", | |
| background: i % 2 ? "var(--white)" : "var(--cream)", opacity: on ? 1 : 0.4, | |
| borderBottom: "1px solid var(--line)", fontSize: 12 }}> | |
| <div style={{ width: 56, textAlign: "center", color: "var(--orange-700)", flexShrink: 0 }}>{i + 1}</div> | |
| <div style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }} | |
| title={sequences[i].map((id) => clipNames[id] || String(id)).join("\n")}>{names}</div> | |
| {src != null && ( | |
| <div style={{ width: 150, flexShrink: 0, color: "#0a8f3c", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }} title={src}>🎵 {src}</div> | |
| )} | |
| <div style={{ width: 46, textAlign: "center", flexShrink: 0 }}> | |
| <input type="checkbox" checked={on} readOnly tabIndex={-1} style={{ pointerEvents: "none" }} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const over = checkedCount > maxRender; | |
| return ( | |
| <div> | |
| {/* thanh công cụ */} | |
| <div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}> | |
| <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> | |
| <button className="btn sm" onClick={() => setAll(true)}>☑ Tất cả</button> | |
| <button className="btn sm ghost" onClick={() => setAll(false)}>☐ Bỏ hết</button> | |
| <span style={{ width: 1, height: 22, background: "var(--line)" }} /> | |
| <span className="small muted">Ngẫu nhiên</span> | |
| <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 }} /> | |
| <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> | |
| <span style={{ width: 1, height: 22, background: "var(--line)" }} /> | |
| <span className="small muted">Tương đồng</span> | |
| <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 }} /> | |
| <button className="btn sm" onClick={similarityFilter} disabled={!!filtering} | |
| 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ả."> | |
| {filtering ? "⏳ Đang lọc…" : "Lọc tương đồng"} | |
| </button> | |
| <span className="small" style={{ marginLeft: "auto", color: "var(--orange-700)", fontWeight: 600 }}> | |
| Đã chọn: {checkedCount.toLocaleString()} / {n.toLocaleString()} | |
| </span> | |
| </div> | |
| {/* ưu tiên khúc khác hoàn toàn */} | |
| {nSeg > 0 && ( | |
| <div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}> | |
| <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> | |
| {prio.map((p, i) => ( | |
| <label key={i} className="small" style={{ display: "inline-flex", alignItems: "center", gap: 4, cursor: "pointer" }}> | |
| <input type="checkbox" checked={p} onChange={() => setPrio((cur) => cur.map((v, j) => (j === i ? !v : v)))} /> | |
| Khúc {i + 1} | |
| </label> | |
| ))} | |
| </div> | |
| )} | |
| {/* tiêu đề cột */} | |
| <div style={{ display: "flex", alignItems: "center", height: 26, fontSize: 11, fontWeight: 700, color: "var(--muted)", borderBottom: "2px solid var(--line)" }}> | |
| <div style={{ width: 56, textAlign: "center", flexShrink: 0 }}>STT</div> | |
| <div style={{ flex: 1 }}>Tổ hợp video</div> | |
| {sources.length > 0 && <div style={{ width: 150, flexShrink: 0 }}>🎵 Source</div>} | |
| <div style={{ width: 46, textAlign: "center", flexShrink: 0 }}>✓</div> | |
| </div> | |
| {/* danh sách ảo hoá */} | |
| <div onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)} | |
| style={{ height: VIEW_H, overflow: "auto", border: "1px solid var(--line)", borderTop: "none" }}> | |
| <div style={{ height: n * ROW_H, position: "relative" }}> | |
| <div style={{ position: "absolute", top: start * ROW_H, left: 0, right: 0 }}>{rows}</div> | |
| </div> | |
| </div> | |
| {filtering && ( | |
| <div className="small" style={{ color: "#b45309", marginTop: 6 }}>{filtering.msg}</div> | |
| )} | |
| {!filtering && status && ( | |
| <div className="small muted" style={{ marginTop: 6 }}>{status}</div> | |
| )} | |
| {over && ( | |
| <p className="small mt8" style={{ color: "var(--orange-700)" }}> | |
| ⚠ Đang chọn {checkedCount.toLocaleString()} biến thể — chỉ render tối đa {maxRender.toLocaleString()} video/lần, phần dư sẽ bị bỏ. | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| export default ComboTable; | |