Spaces:
Running
Running
update: down, cut, shuffle, trans video UX
Browse files- backend/app/api/shuffle.py +15 -4
- backend/app/services/dubber/translate.py +10 -4
- backend/app/services/dubber/tts.py +40 -17
- frontend/src/pages/CutVideo.jsx +170 -138
- frontend/src/pages/DownList.jsx +24 -10
- frontend/src/pages/DubVideo.jsx +46 -22
- frontend/src/pages/ShuffleVideo.jsx +74 -21
backend/app/api/shuffle.py
CHANGED
|
@@ -104,6 +104,9 @@ class ShuffleReq(BaseModel):
|
|
| 104 |
# danh sách biến thể user đã CHỌN render (mỗi phần tử = list clip_id).
|
| 105 |
# None -> tự sinh theo cfg (giữ hành vi cũ).
|
| 106 |
sequences: list[list[int]] | None = None
|
|
|
|
|
|
|
|
|
|
| 107 |
username: str = ""
|
| 108 |
|
| 109 |
|
|
@@ -151,7 +154,8 @@ def estimate(req: ShuffleReq):
|
|
| 151 |
|
| 152 |
# ------------------------------------------------------------------- plan ---
|
| 153 |
|
| 154 |
-
PLAN_CAP =
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
@router.post("/plan")
|
|
@@ -189,14 +193,21 @@ def generate(req: ShuffleReq):
|
|
| 189 |
cfg, clip_map, _ = _build(req)
|
| 190 |
# Chỉ render các biến thể user đã CHỌN (nếu gửi lên); không thì tự sinh như cũ.
|
| 191 |
sequences = req.sequences if req.sequences else engine.generate(cfg)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
audio_paths = _audio_paths(req)
|
|
|
|
| 193 |
|
| 194 |
job = jobs.create("shuffle")
|
| 195 |
|
| 196 |
def work(h):
|
| 197 |
from concurrent.futures import ThreadPoolExecutor
|
| 198 |
import threading
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
+ (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else ""))
|
| 201 |
out_dir = sub_dir(req.session, "variations")
|
| 202 |
n = len(sequences)
|
|
@@ -213,12 +224,12 @@ def generate(req: ShuffleReq):
|
|
| 213 |
out = os.path.join(out_dir, name)
|
| 214 |
if audio_paths:
|
| 215 |
tmp = os.path.join(out_dir, f".tmp_{i+1:02d}.mp4")
|
| 216 |
-
shuffle_render._concat_reencode(paths, tmp)
|
| 217 |
track = audio_paths[i % len(audio_paths)]
|
| 218 |
shuffle_render.attach_audio(tmp, track, out)
|
| 219 |
os.remove(tmp)
|
| 220 |
else:
|
| 221 |
-
shuffle_render._concat_reencode(paths, out)
|
| 222 |
size = os.path.getsize(out)
|
| 223 |
results[i] = {"name": name, "path": out, "sequence": seq, "size": size}
|
| 224 |
h.item_status(it, "done", detail=f"{size//1024} KB", result={
|
|
|
|
| 104 |
# danh sách biến thể user đã CHỌN render (mỗi phần tử = list clip_id).
|
| 105 |
# None -> tự sinh theo cfg (giữ hành vi cũ).
|
| 106 |
sequences: list[list[int]] | None = None
|
| 107 |
+
# độ phân giải xuất ra (khung dọc TikTok). Mặc định 1080x1920.
|
| 108 |
+
width: int = 1080
|
| 109 |
+
height: int = 1920
|
| 110 |
username: str = ""
|
| 111 |
|
| 112 |
|
|
|
|
| 154 |
|
| 155 |
# ------------------------------------------------------------------- plan ---
|
| 156 |
|
| 157 |
+
PLAN_CAP = 3000 # trần số dòng bảng trả về (chỉ là danh sách nhãn, chưa render)
|
| 158 |
+
MAX_RENDER = 150 # tối đa số video được RENDER mỗi lần (nặng CPU/đĩa)
|
| 159 |
|
| 160 |
|
| 161 |
@router.post("/plan")
|
|
|
|
| 193 |
cfg, clip_map, _ = _build(req)
|
| 194 |
# Chỉ render các biến thể user đã CHỌN (nếu gửi lên); không thì tự sinh như cũ.
|
| 195 |
sequences = req.sequences if req.sequences else engine.generate(cfg)
|
| 196 |
+
# Trần cứng số video render mỗi lần để không quá tải máy.
|
| 197 |
+
capped_render = len(sequences) > MAX_RENDER
|
| 198 |
+
if capped_render:
|
| 199 |
+
sequences = sequences[:MAX_RENDER]
|
| 200 |
audio_paths = _audio_paths(req)
|
| 201 |
+
w, h_res = max(64, req.width), max(64, req.height)
|
| 202 |
|
| 203 |
job = jobs.create("shuffle")
|
| 204 |
|
| 205 |
def work(h):
|
| 206 |
from concurrent.futures import ThreadPoolExecutor
|
| 207 |
import threading
|
| 208 |
+
if capped_render:
|
| 209 |
+
h.log(f"Đã chọn quá {MAX_RENDER} biến thể — chỉ render {MAX_RENDER} video đầu.")
|
| 210 |
+
h.log(f"Sẽ render {len(sequences)} biến thể @ {w}x{h_res}"
|
| 211 |
+ (f" + lồng {len(audio_paths)} track âm thanh" if audio_paths else ""))
|
| 212 |
out_dir = sub_dir(req.session, "variations")
|
| 213 |
n = len(sequences)
|
|
|
|
| 224 |
out = os.path.join(out_dir, name)
|
| 225 |
if audio_paths:
|
| 226 |
tmp = os.path.join(out_dir, f".tmp_{i+1:02d}.mp4")
|
| 227 |
+
shuffle_render._concat_reencode(paths, tmp, w, h_res)
|
| 228 |
track = audio_paths[i % len(audio_paths)]
|
| 229 |
shuffle_render.attach_audio(tmp, track, out)
|
| 230 |
os.remove(tmp)
|
| 231 |
else:
|
| 232 |
+
shuffle_render._concat_reencode(paths, out, w, h_res)
|
| 233 |
size = os.path.getsize(out)
|
| 234 |
results[i] = {"name": name, "path": out, "sequence": seq, "size": size}
|
| 235 |
h.item_status(it, "done", detail=f"{size//1024} KB", result={
|
backend/app/services/dubber/translate.py
CHANGED
|
@@ -35,11 +35,17 @@ def char_budget(seg: Segment, target_lang: str) -> int:
|
|
| 35 |
def _prompt(seg: Segment, target_lang: str, budget: int) -> str:
|
| 36 |
name = LANG_NAME.get(target_lang, target_lang)
|
| 37 |
return (
|
| 38 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
f"It must be speakable in about {seg.dur:.1f} seconds, so keep it within "
|
| 40 |
-
f"~{budget} characters. Prefer a
|
| 41 |
-
f"translation
|
| 42 |
-
f"
|
|
|
|
| 43 |
f"Line: {seg.text}"
|
| 44 |
)
|
| 45 |
|
|
|
|
| 35 |
def _prompt(seg: Segment, target_lang: str, budget: int) -> str:
|
| 36 |
name = LANG_NAME.get(target_lang, target_lang)
|
| 37 |
return (
|
| 38 |
+
f"You are a professional advertising voice-over translator for short "
|
| 39 |
+
f"product videos (TikTok/Reels style).\n"
|
| 40 |
+
f"Translate the line below into natural, SPOKEN {name} with an ENGAGING "
|
| 41 |
+
f"ADVERTISING tone: catchy, persuasive, energetic, emphasizing the "
|
| 42 |
+
f"product's appeal and benefits — the way a native {name} marketer would "
|
| 43 |
+
f"actually pitch it — while staying faithful to the original meaning.\n"
|
| 44 |
f"It must be speakable in about {seg.dur:.1f} seconds, so keep it within "
|
| 45 |
+
f"~{budget} characters. Prefer a punchy, concise paraphrase over a literal "
|
| 46 |
+
f"translation so it fits the time AND sounds catchy. Keep proper nouns, "
|
| 47 |
+
f"brand names and numbers. Return ONLY the translated line — no quotes, "
|
| 48 |
+
f"no notes, no explanation.\n\n"
|
| 49 |
f"Line: {seg.text}"
|
| 50 |
)
|
| 51 |
|
backend/app/services/dubber/tts.py
CHANGED
|
@@ -37,7 +37,9 @@ class VoiceConfig:
|
|
| 37 |
def pick_engine(cfg: VoiceConfig) -> str:
|
| 38 |
if cfg.engine and cfg.engine != "auto":
|
| 39 |
return cfg.engine
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
class TTSEngine:
|
|
@@ -109,13 +111,30 @@ class XTTSEngine(TTSEngine): # pragma: no cover - cần model local
|
|
| 109 |
|
| 110 |
# --------------------------------------------------------------- fil: Edge ----
|
| 111 |
|
| 112 |
-
# Giọng Edge mặc định theo ngôn ngữ (Edge không clone,
|
| 113 |
_EDGE_VOICES = {
|
| 114 |
"fil": "fil-PH-BlessicaNeural",
|
| 115 |
"vi": "vi-VN-HoaiMyNeural",
|
| 116 |
"en": "en-US-AriaNeural",
|
| 117 |
}
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
class EdgeTTSEngine(TTSEngine): # pragma: no cover - cần mạng
|
| 121 |
def __init__(self):
|
|
@@ -160,7 +179,19 @@ def load_engine(cfg: VoiceConfig) -> TTSEngine:
|
|
| 160 |
if cls is None:
|
| 161 |
raise ValueError(f"engine không hợp lệ: {name}")
|
| 162 |
if name not in _INSTANCES:
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return _INSTANCES[name]
|
| 165 |
|
| 166 |
|
|
@@ -179,19 +210,9 @@ _FALLBACK_VOICES: dict[str, list[tuple[str, str]]] = {
|
|
| 179 |
("Bình An — nam, giọng điềm đạm", "Bình An"),
|
| 180 |
("Ngọc Linh — nữ, giọng tươi sáng", "Ngọc Linh"),
|
| 181 |
],
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
("Alison Dietlinde — nữ, truyền cảm", "Alison Dietlinde"),
|
| 186 |
-
("Sofia Hellen — nữ, trẻ trung", "Sofia Hellen"),
|
| 187 |
-
("Andrew Chipper — nam, vui tươi", "Andrew Chipper"),
|
| 188 |
-
("Damien Black — nam, trầm", "Damien Black"),
|
| 189 |
-
("Craig Gutsy — nam, chắc khỏe", "Craig Gutsy"),
|
| 190 |
-
("Royston Min — nam, rõ ràng", "Royston Min"),
|
| 191 |
-
("Viktor Eka — nam, điềm đạm", "Viktor Eka"),
|
| 192 |
-
("Gitta Nikolina — nữ, nhẹ nhàng", "Gitta Nikolina"),
|
| 193 |
-
],
|
| 194 |
-
"fil": [("Giọng Filipino (Edge TTS)", "fil-PH-BlessicaNeural")],
|
| 195 |
}
|
| 196 |
|
| 197 |
|
|
@@ -199,7 +220,9 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
|
|
| 199 |
"""Trả danh sách giọng cho ngôn ngữ. Mặc định KHÔNG nạp model (tránh treo request);
|
| 200 |
chỉ lấy động khi engine đã được cache sẵn (đã từng dịch) hoặc allow_load=True."""
|
| 201 |
name = pick_engine(VoiceConfig(target_lang=lang))
|
| 202 |
-
|
|
|
|
|
|
|
| 203 |
try:
|
| 204 |
eng = load_engine(VoiceConfig(target_lang=lang))
|
| 205 |
vs = eng.list_voices()
|
|
|
|
| 37 |
def pick_engine(cfg: VoiceConfig) -> str:
|
| 38 |
if cfg.engine and cfg.engine != "auto":
|
| 39 |
return cfg.engine
|
| 40 |
+
# vi -> VieNeu (local, nhẹ). en & fil -> Edge TTS (online): XTTS/Coqui chưa cài
|
| 41 |
+
# và nặng RAM, Edge cho giọng tốt sẵn dùng ngay.
|
| 42 |
+
return {"vi": "vieneu", "en": "edge", "fil": "edge"}.get(cfg.target_lang, "edge")
|
| 43 |
|
| 44 |
|
| 45 |
class TTSEngine:
|
|
|
|
| 111 |
|
| 112 |
# --------------------------------------------------------------- fil: Edge ----
|
| 113 |
|
| 114 |
+
# Giọng Edge mặc định theo ngôn ngữ (Edge không clone, dùng giọng dựng sẵn).
|
| 115 |
_EDGE_VOICES = {
|
| 116 |
"fil": "fil-PH-BlessicaNeural",
|
| 117 |
"vi": "vi-VN-HoaiMyNeural",
|
| 118 |
"en": "en-US-AriaNeural",
|
| 119 |
}
|
| 120 |
|
| 121 |
+
# Danh sách giọng Edge (nhiều lựa chọn nam/nữ) cho các ngôn ngữ chạy Edge TTS.
|
| 122 |
+
_EDGE_VOICE_LIST = {
|
| 123 |
+
"en": [
|
| 124 |
+
("Aria — nữ, tự nhiên (US)", "en-US-AriaNeural"),
|
| 125 |
+
("Jenny — nữ, ấm (US)", "en-US-JennyNeural"),
|
| 126 |
+
("Guy — nam, rõ ràng (US)", "en-US-GuyNeural"),
|
| 127 |
+
("Michelle — nữ, trẻ trung (US)", "en-US-MichelleNeural"),
|
| 128 |
+
("Ana — nữ, tươi (US)", "en-US-AnaNeural"),
|
| 129 |
+
("Ryan — nam, ấm (UK)", "en-GB-RyanNeural"),
|
| 130 |
+
("Sonia — nữ, truyền cảm (UK)", "en-GB-SoniaNeural"),
|
| 131 |
+
],
|
| 132 |
+
"fil": [
|
| 133 |
+
("Blessica — nữ, tự nhiên", "fil-PH-BlessicaNeural"),
|
| 134 |
+
("Angelo — nam, rõ ràng", "fil-PH-AngeloNeural"),
|
| 135 |
+
],
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
|
| 139 |
class EdgeTTSEngine(TTSEngine): # pragma: no cover - cần mạng
|
| 140 |
def __init__(self):
|
|
|
|
| 179 |
if cls is None:
|
| 180 |
raise ValueError(f"engine không hợp lệ: {name}")
|
| 181 |
if name not in _INSTANCES:
|
| 182 |
+
try:
|
| 183 |
+
_INSTANCES[name] = cls()
|
| 184 |
+
except Exception as e:
|
| 185 |
+
# Model nặng chưa cài / lỗi nạp (vd XTTS-Coqui) -> lùi về Edge TTS
|
| 186 |
+
# (online, nhẹ) để vẫn LỒNG ĐƯỢC tiếng thay vì hỏng cả pipeline.
|
| 187 |
+
if name == "edge":
|
| 188 |
+
raise
|
| 189 |
+
import sys
|
| 190 |
+
print(f"[tts] engine '{name}' không nạp được ({e}); dùng Edge TTS thay.",
|
| 191 |
+
file=sys.stderr)
|
| 192 |
+
if "edge" not in _INSTANCES:
|
| 193 |
+
_INSTANCES["edge"] = EdgeTTSEngine()
|
| 194 |
+
_INSTANCES[name] = _INSTANCES["edge"]
|
| 195 |
return _INSTANCES[name]
|
| 196 |
|
| 197 |
|
|
|
|
| 210 |
("Bình An — nam, giọng điềm đạm", "Bình An"),
|
| 211 |
("Ngọc Linh — nữ, giọng tươi sáng", "Ngọc Linh"),
|
| 212 |
],
|
| 213 |
+
# en & fil chạy Edge TTS -> lấy từ _EDGE_VOICE_LIST cho khớp giọng thật.
|
| 214 |
+
"en": _EDGE_VOICE_LIST["en"],
|
| 215 |
+
"fil": _EDGE_VOICE_LIST["fil"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
|
|
|
|
| 220 |
"""Trả danh sách giọng cho ngôn ngữ. Mặc định KHÔNG nạp model (tránh treo request);
|
| 221 |
chỉ lấy động khi engine đã được cache sẵn (đã từng dịch) hoặc allow_load=True."""
|
| 222 |
name = pick_engine(VoiceConfig(target_lang=lang))
|
| 223 |
+
# Edge: danh sách giọng theo NGÔN NGỮ (engine.list_voices() không biết lang),
|
| 224 |
+
# nên lấy trực tiếp từ bảng giọng Edge để đúng nam/nữ từng thứ tiếng.
|
| 225 |
+
if name != "edge" and (name in _INSTANCES or allow_load):
|
| 226 |
try:
|
| 227 |
eng = load_engine(VoiceConfig(target_lang=lang))
|
| 228 |
vs = eng.list_voices()
|
frontend/src/pages/CutVideo.jsx
CHANGED
|
@@ -29,6 +29,135 @@ const fmt = (s) => {
|
|
| 29 |
return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
|
| 30 |
};
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
export default function CutVideo() {
|
| 33 |
const [sp] = useSearchParams();
|
| 34 |
const nav = useNavigate();
|
|
@@ -43,21 +172,14 @@ export default function CutVideo() {
|
|
| 43 |
const busy = st.busy;
|
| 44 |
const setJob = (j) => set({ job: j });
|
| 45 |
const setBusy = (b) => set({ busy: b });
|
| 46 |
-
const [playFrame, setPlayFrame] = useState(0); // vị trí đầu phát (đen) chạy theo video
|
| 47 |
const [prevBusy, setPrevBusy] = useState(false); // đang tạo xem trước -> hiện overlay chờ
|
| 48 |
-
const [selCut, setSelCut] = useState(-1);
|
| 49 |
const [showNote, setShowNote] = useState(true);
|
| 50 |
-
const
|
| 51 |
-
const videoRef = useRef();
|
| 52 |
-
const dragIdx = useRef(-1);
|
| 53 |
-
const stopAt = useRef(null);
|
| 54 |
|
| 55 |
const videos = st.videos;
|
| 56 |
const active = Math.min(st.active, Math.max(0, videos.length - 1));
|
| 57 |
const cur = videos[active];
|
| 58 |
|
| 59 |
-
const patchActive = (patch) =>
|
| 60 |
-
set({ videos: videos.map((v, i) => (i === active ? { ...v, ...patch } : v)) });
|
| 61 |
const patchVideo = (index, patch) =>
|
| 62 |
set((s) => ({ videos: (s.videos || []).map((v, i) => (i === index ? { ...v, ...patch } : v)) }));
|
| 63 |
|
|
@@ -85,7 +207,10 @@ export default function CutVideo() {
|
|
| 85 |
}, []);
|
| 86 |
|
| 87 |
const onFiles = async (files) => {
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
try {
|
| 90 |
const r = await api.cutUpload(file, cur?.session);
|
| 91 |
const v = {
|
|
@@ -96,6 +221,7 @@ export default function CutVideo() {
|
|
| 96 |
set((s) => ({ videos: [...(s.videos || []), v] }));
|
| 97 |
} catch (e) { alert("Lỗi tải video: " + e.message); }
|
| 98 |
}
|
|
|
|
| 99 |
};
|
| 100 |
|
| 101 |
const removeVideo = (i) => (e) => {
|
|
@@ -104,11 +230,11 @@ export default function CutVideo() {
|
|
| 104 |
set({ videos: next, active: Math.max(0, Math.min(active, next.length - 1)) });
|
| 105 |
};
|
| 106 |
|
| 107 |
-
// Xem trước cho 1 video CỤ THỂ (theo index) ->
|
| 108 |
const doPreview = async (index) => {
|
| 109 |
const vid = videos[index];
|
| 110 |
-
if (!vid
|
| 111 |
-
patchVideo(index, { previewing: true });
|
| 112 |
try {
|
| 113 |
const r = await api.cutPreview({
|
| 114 |
session: vid.session, file: vid.file, mode: st.mode,
|
|
@@ -116,93 +242,20 @@ export default function CutVideo() {
|
|
| 116 |
});
|
| 117 |
const interior = r.segments.slice(0, -1).map((s) => s.end_frame);
|
| 118 |
patchVideo(index, { thumbs: r.thumbnails, cuts: interior, info: r.info, previewing: false });
|
| 119 |
-
} catch (e) { patchVideo(index, { previewing: false });
|
| 120 |
-
finally { setPrevBusy(false); }
|
| 121 |
-
};
|
| 122 |
-
const preview = () => { doPreview(active); setSelCut(-1); setPlayFrame(0); };
|
| 123 |
-
// Đổi tab video: nếu video đó CHƯA có dải ảnh thì tự xem trước (để tab nào cũng hiện).
|
| 124 |
-
const switchTo = (i) => {
|
| 125 |
-
set({ active: i }); setSelCut(-1); setPlayFrame(0);
|
| 126 |
-
const v = videos[i];
|
| 127 |
-
if (v && !(v.thumbs && v.thumbs.length) && !v.previewing) doPreview(i);
|
| 128 |
};
|
| 129 |
|
| 130 |
-
|
| 131 |
-
const
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
};
|
| 139 |
-
|
| 140 |
-
// bấm dải -> seek video tới đó; playhead đen sẽ nhảy theo
|
| 141 |
-
const onStripClick = (e) => {
|
| 142 |
-
if (!cur || dragIdx.current >= 0) return;
|
| 143 |
-
const f = xToFrame(e.clientX);
|
| 144 |
-
setPlayFrame(f); setSelCut(-1);
|
| 145 |
-
if (videoRef.current) videoRef.current.currentTime = f / fps;
|
| 146 |
-
};
|
| 147 |
-
|
| 148 |
-
const addCut = () => {
|
| 149 |
-
if (!cur) return;
|
| 150 |
-
const f = playFrame;
|
| 151 |
-
if (f <= 2 || f >= nf - 2) return;
|
| 152 |
-
patchActive({ cuts: [...cuts, f].sort((a, b) => a - b) });
|
| 153 |
-
};
|
| 154 |
-
const deleteCut = () => {
|
| 155 |
-
if (selCut < 0) return;
|
| 156 |
-
patchActive({ cuts: cuts.filter((_, j) => j !== selCut) });
|
| 157 |
-
setSelCut(-1);
|
| 158 |
};
|
| 159 |
|
| 160 |
-
const
|
| 161 |
-
useEffect(() => {
|
| 162 |
-
const move = (e) => {
|
| 163 |
-
if (dragIdx.current < 0 || !cur) return;
|
| 164 |
-
const f = Math.min(nf - 2, Math.max(2, xToFrame(e.clientX)));
|
| 165 |
-
patchActive({ cuts: cuts.map((c, j) => (j === dragIdx.current ? f : c)) });
|
| 166 |
-
};
|
| 167 |
-
const up = () => {
|
| 168 |
-
if (dragIdx.current >= 0) { dragIdx.current = -1; patchActive({ cuts: [...cuts].sort((a, b) => a - b) }); }
|
| 169 |
-
};
|
| 170 |
-
window.addEventListener("pointermove", move);
|
| 171 |
-
window.addEventListener("pointerup", up);
|
| 172 |
-
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
|
| 173 |
-
// eslint-disable-next-line
|
| 174 |
-
}, [cur, cuts]);
|
| 175 |
-
|
| 176 |
-
const playToNextCut = () => {
|
| 177 |
-
const v = videoRef.current; if (!v || !cur) return;
|
| 178 |
-
const times = cuts.map((f) => f / fps).sort((a, b) => a - b);
|
| 179 |
-
const next = times.find((t) => t > v.currentTime + 0.05);
|
| 180 |
-
stopAt.current = next != null ? next : cur.info.duration;
|
| 181 |
-
v.play();
|
| 182 |
-
};
|
| 183 |
-
// playhead đen chạy MƯỢT theo requestAnimationFrame (thay vì timeupdate ~4Hz giật cục)
|
| 184 |
-
useEffect(() => {
|
| 185 |
-
const v = videoRef.current;
|
| 186 |
-
if (!v || !cur) return;
|
| 187 |
-
let raf;
|
| 188 |
-
const loop = () => {
|
| 189 |
-
setPlayFrame(Math.round(v.currentTime * fps));
|
| 190 |
-
if (stopAt.current != null && v.currentTime >= stopAt.current) { v.pause(); stopAt.current = null; }
|
| 191 |
-
raf = requestAnimationFrame(loop);
|
| 192 |
-
};
|
| 193 |
-
const onPlay = () => { cancelAnimationFrame(raf); loop(); };
|
| 194 |
-
const onStop = () => { cancelAnimationFrame(raf); setPlayFrame(Math.round(v.currentTime * fps)); };
|
| 195 |
-
v.addEventListener("play", onPlay);
|
| 196 |
-
v.addEventListener("pause", onStop);
|
| 197 |
-
v.addEventListener("seeked", onStop);
|
| 198 |
-
return () => {
|
| 199 |
-
cancelAnimationFrame(raf);
|
| 200 |
-
v.removeEventListener("play", onPlay);
|
| 201 |
-
v.removeEventListener("pause", onStop);
|
| 202 |
-
v.removeEventListener("seeked", onStop);
|
| 203 |
-
};
|
| 204 |
-
// eslint-disable-next-line
|
| 205 |
-
}, [cur, fps]);
|
| 206 |
|
| 207 |
const segmentsFromCuts = (video) => {
|
| 208 |
const c = video.cuts || [];
|
|
@@ -259,6 +312,8 @@ export default function CutVideo() {
|
|
| 259 |
}, i * 300));
|
| 260 |
};
|
| 261 |
|
|
|
|
|
|
|
| 262 |
return (
|
| 263 |
<div>
|
| 264 |
<div className="h1"><IcStar size={22} /> Workspace: <span className="accent">Cut video</span></div>
|
|
@@ -270,6 +325,11 @@ export default function CutVideo() {
|
|
| 270 |
<div className="card">
|
| 271 |
<div className="panel-title">1. Tải lên video nguồn</div>
|
| 272 |
<FileDrop onFiles={onFiles} hint="MP4, MOV, AVI, MKV, WebM · có thể thêm nhiều video" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
{videos.length > 0 && (
|
| 274 |
<div className="vtabs mt12">
|
| 275 |
{videos.map((v, i) => (
|
|
@@ -318,54 +378,26 @@ export default function CutVideo() {
|
|
| 318 |
Gợi ý ~{cur.auto_count} khúc · {fmt(cur.info.duration)} · {cur.info.fps}fps
|
| 319 |
</div>
|
| 320 |
)}
|
| 321 |
-
<button className="btn right" onClick={
|
| 322 |
-
{
|
| 323 |
</button>
|
| 324 |
</div>
|
| 325 |
</div>
|
| 326 |
|
| 327 |
-
{
|
| 328 |
-
<
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
<
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
<
|
| 339 |
-
<button className="btn sm" onClick={addCut}><IcPlus size={14} /> Thêm cắt</button>
|
| 340 |
-
<button className="btn sm" onClick={deleteCut} disabled={selCut < 0}><IcTrash size={14} /> Xoá cắt</button>
|
| 341 |
-
<span className="small muted right">{fmt(playFrame / fps)} / {fmt(cur.info.duration)}</span>
|
| 342 |
-
</div>
|
| 343 |
-
|
| 344 |
-
<div className="timeline-wrap mt12">
|
| 345 |
-
<div className="timeline" ref={stripRef} onClick={onStripClick}>
|
| 346 |
-
<div className="strip">
|
| 347 |
-
{cur.thumbs.map((t, i) => <img key={i} src={fileUrl(t.url)} alt="" draggable={false} />)}
|
| 348 |
-
</div>
|
| 349 |
-
<div className="playhead" style={{ left: frameToPct(playFrame) + "%" }} />
|
| 350 |
-
{cuts.map((f, i) => (
|
| 351 |
-
<div key={i} className={"cutmark" + (i === selCut ? " sel" : "")}
|
| 352 |
-
style={{ left: frameToPct(f) + "%" }}
|
| 353 |
-
onPointerDown={startDrag(i)}
|
| 354 |
-
onClick={(e) => { e.stopPropagation(); setSelCut(i); }}>
|
| 355 |
-
<div className={"pin" + (i === selCut ? " sel" : "")}>{i + 1}</div>
|
| 356 |
-
</div>
|
| 357 |
-
))}
|
| 358 |
-
</div>
|
| 359 |
-
<div className="timeline-ticks">
|
| 360 |
-
<span>00:00</span><span>{fmt(cur.info.duration / 2)}</span><span>{fmt(cur.info.duration)}</span>
|
| 361 |
-
</div>
|
| 362 |
</div>
|
| 363 |
-
|
| 364 |
-
<button className="btn primary block mt16" onClick={executeAll} disabled={busy}>
|
| 365 |
-
{busy ? "Đang cắt..." : <>Cắt {videos.length > 1 ? `tất cả ${videos.length} video` : "video"} <IcArrow size={16} /></>}
|
| 366 |
-
</button>
|
| 367 |
-
<p className="small muted mt8">Mỗi video dùng điểm cắt riêng (xem trước từng tab). Bấm “Cắt” sẽ xử lý tất cả video đã tải.</p>
|
| 368 |
-
</div>
|
| 369 |
)}
|
| 370 |
|
| 371 |
<ProcessingOverlay busy={busy || prevBusy} job={busy ? job : null}
|
|
|
|
| 29 |
return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
|
| 30 |
};
|
| 31 |
|
| 32 |
+
// Timeline của MỘT video, tự quản lý state riêng (playhead, dấu cắt đang chọn, kéo thả)
|
| 33 |
+
// -> nhiều video hiển thị cùng lúc, điểm cắt của video nào đi theo video đó.
|
| 34 |
+
function VideoTimeline({ video, index, patchVideo }) {
|
| 35 |
+
const [playFrame, setPlayFrame] = useState(0);
|
| 36 |
+
const [selCut, setSelCut] = useState(-1);
|
| 37 |
+
const stripRef = useRef();
|
| 38 |
+
const videoRef = useRef();
|
| 39 |
+
const dragIdx = useRef(-1);
|
| 40 |
+
const stopAt = useRef(null);
|
| 41 |
+
|
| 42 |
+
const cuts = video.cuts || [];
|
| 43 |
+
const nf = video.info?.nb_frames || 1;
|
| 44 |
+
const fps = video.info?.fps || 30;
|
| 45 |
+
const dur = video.info?.duration || 0;
|
| 46 |
+
const frameToPct = (f) => (f / nf) * 100;
|
| 47 |
+
const patch = (p) => patchVideo(index, p);
|
| 48 |
+
const xToFrame = (clientX) => {
|
| 49 |
+
const rect = stripRef.current.getBoundingClientRect();
|
| 50 |
+
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
| 51 |
+
return Math.round(ratio * nf);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// bấm dải -> seek video tới đó; playhead đen nhảy theo
|
| 55 |
+
const onStripClick = (e) => {
|
| 56 |
+
if (dragIdx.current >= 0) return;
|
| 57 |
+
const f = xToFrame(e.clientX);
|
| 58 |
+
setPlayFrame(f); setSelCut(-1);
|
| 59 |
+
if (videoRef.current) videoRef.current.currentTime = f / fps;
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const addCut = () => {
|
| 63 |
+
const f = playFrame;
|
| 64 |
+
if (f <= 2 || f >= nf - 2) return;
|
| 65 |
+
patch({ cuts: [...cuts, f].sort((a, b) => a - b) });
|
| 66 |
+
};
|
| 67 |
+
const deleteCut = () => {
|
| 68 |
+
if (selCut < 0) return;
|
| 69 |
+
patch({ cuts: cuts.filter((_, j) => j !== selCut) });
|
| 70 |
+
setSelCut(-1);
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const startDrag = (i) => (e) => { e.stopPropagation(); dragIdx.current = i; setSelCut(i); };
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
const move = (e) => {
|
| 76 |
+
if (dragIdx.current < 0) return;
|
| 77 |
+
const f = Math.min(nf - 2, Math.max(2, xToFrame(e.clientX)));
|
| 78 |
+
patch({ cuts: cuts.map((c, j) => (j === dragIdx.current ? f : c)) });
|
| 79 |
+
};
|
| 80 |
+
const up = () => {
|
| 81 |
+
if (dragIdx.current >= 0) { dragIdx.current = -1; patch({ cuts: [...cuts].sort((a, b) => a - b) }); }
|
| 82 |
+
};
|
| 83 |
+
window.addEventListener("pointermove", move);
|
| 84 |
+
window.addEventListener("pointerup", up);
|
| 85 |
+
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
|
| 86 |
+
// eslint-disable-next-line
|
| 87 |
+
}, [cuts, nf]);
|
| 88 |
+
|
| 89 |
+
const playToNextCut = () => {
|
| 90 |
+
const v = videoRef.current; if (!v) return;
|
| 91 |
+
const times = cuts.map((f) => f / fps).sort((a, b) => a - b);
|
| 92 |
+
const next = times.find((t) => t > v.currentTime + 0.05);
|
| 93 |
+
stopAt.current = next != null ? next : dur;
|
| 94 |
+
v.play();
|
| 95 |
+
};
|
| 96 |
+
// playhead đen chạy MƯỢT theo requestAnimationFrame
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
const v = videoRef.current; if (!v) return;
|
| 99 |
+
let raf;
|
| 100 |
+
const loop = () => {
|
| 101 |
+
setPlayFrame(Math.round(v.currentTime * fps));
|
| 102 |
+
if (stopAt.current != null && v.currentTime >= stopAt.current) { v.pause(); stopAt.current = null; }
|
| 103 |
+
raf = requestAnimationFrame(loop);
|
| 104 |
+
};
|
| 105 |
+
const onPlay = () => { cancelAnimationFrame(raf); loop(); };
|
| 106 |
+
const onStop = () => { cancelAnimationFrame(raf); setPlayFrame(Math.round(v.currentTime * fps)); };
|
| 107 |
+
v.addEventListener("play", onPlay);
|
| 108 |
+
v.addEventListener("pause", onStop);
|
| 109 |
+
v.addEventListener("seeked", onStop);
|
| 110 |
+
return () => {
|
| 111 |
+
cancelAnimationFrame(raf);
|
| 112 |
+
v.removeEventListener("play", onPlay);
|
| 113 |
+
v.removeEventListener("pause", onStop);
|
| 114 |
+
v.removeEventListener("seeked", onStop);
|
| 115 |
+
};
|
| 116 |
+
// eslint-disable-next-line
|
| 117 |
+
}, [fps]);
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
<div className="card">
|
| 121 |
+
<div className="flex between center">
|
| 122 |
+
<div className="panel-title clip" style={{ margin: 0 }} title={video.name}>
|
| 123 |
+
Timeline — {video.name} ({cuts.length + 1} khúc)
|
| 124 |
+
</div>
|
| 125 |
+
<span className="small muted" style={{ flex: "0 0 auto" }}>kéo dấu cắt để chỉnh</span>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<video ref={videoRef} src={video.url} controls className="mt12"
|
| 129 |
+
style={{ display: "block", margin: "0 auto", maxWidth: "100%", maxHeight: 340, width: "auto", borderRadius: 10 }} />
|
| 130 |
+
|
| 131 |
+
<div className="cut-toolbar mt12">
|
| 132 |
+
<button className="btn sm" onClick={playToNextCut}><IcPlay size={14} /> Phát tới điểm cắt</button>
|
| 133 |
+
<button className="btn sm" onClick={addCut}><IcPlus size={14} /> Thêm cắt</button>
|
| 134 |
+
<button className="btn sm" onClick={deleteCut} disabled={selCut < 0}><IcTrash size={14} /> Xoá cắt</button>
|
| 135 |
+
<span className="small muted right">{fmt(playFrame / fps)} / {fmt(dur)}</span>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div className="timeline-wrap mt12">
|
| 139 |
+
<div className="timeline" ref={stripRef} onClick={onStripClick}>
|
| 140 |
+
<div className="strip">
|
| 141 |
+
{(video.thumbs || []).map((t, i) => <img key={i} src={fileUrl(t.url)} alt="" draggable={false} />)}
|
| 142 |
+
</div>
|
| 143 |
+
<div className="playhead" style={{ left: frameToPct(playFrame) + "%" }} />
|
| 144 |
+
{cuts.map((f, i) => (
|
| 145 |
+
<div key={i} className={"cutmark" + (i === selCut ? " sel" : "")}
|
| 146 |
+
style={{ left: frameToPct(f) + "%" }}
|
| 147 |
+
onPointerDown={startDrag(i)}
|
| 148 |
+
onClick={(e) => { e.stopPropagation(); setSelCut(i); }}>
|
| 149 |
+
<div className={"pin" + (i === selCut ? " sel" : "")}>{i + 1}</div>
|
| 150 |
+
</div>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
<div className="timeline-ticks">
|
| 154 |
+
<span>00:00</span><span>{fmt(dur / 2)}</span><span>{fmt(dur)}</span>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
export default function CutVideo() {
|
| 162 |
const [sp] = useSearchParams();
|
| 163 |
const nav = useNavigate();
|
|
|
|
| 172 |
const busy = st.busy;
|
| 173 |
const setJob = (j) => set({ job: j });
|
| 174 |
const setBusy = (b) => set({ busy: b });
|
|
|
|
| 175 |
const [prevBusy, setPrevBusy] = useState(false); // đang tạo xem trước -> hiện overlay chờ
|
|
|
|
| 176 |
const [showNote, setShowNote] = useState(true);
|
| 177 |
+
const [uploading, setUploading] = useState(null); // {name,i,n} — video đang tải lên
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
const videos = st.videos;
|
| 180 |
const active = Math.min(st.active, Math.max(0, videos.length - 1));
|
| 181 |
const cur = videos[active];
|
| 182 |
|
|
|
|
|
|
|
| 183 |
const patchVideo = (index, patch) =>
|
| 184 |
set((s) => ({ videos: (s.videos || []).map((v, i) => (i === index ? { ...v, ...patch } : v)) }));
|
| 185 |
|
|
|
|
| 207 |
}, []);
|
| 208 |
|
| 209 |
const onFiles = async (files) => {
|
| 210 |
+
const arr = Array.from(files);
|
| 211 |
+
for (let i = 0; i < arr.length; i++) {
|
| 212 |
+
const file = arr[i];
|
| 213 |
+
setUploading({ name: file.name, i: i + 1, n: arr.length });
|
| 214 |
try {
|
| 215 |
const r = await api.cutUpload(file, cur?.session);
|
| 216 |
const v = {
|
|
|
|
| 221 |
set((s) => ({ videos: [...(s.videos || []), v] }));
|
| 222 |
} catch (e) { alert("Lỗi tải video: " + e.message); }
|
| 223 |
}
|
| 224 |
+
setUploading(null);
|
| 225 |
};
|
| 226 |
|
| 227 |
const removeVideo = (i) => (e) => {
|
|
|
|
| 230 |
set({ videos: next, active: Math.max(0, Math.min(active, next.length - 1)) });
|
| 231 |
};
|
| 232 |
|
| 233 |
+
// Xem trước cho 1 video CỤ THỂ (theo index) -> tạo dải ảnh + điểm cắt tự động.
|
| 234 |
const doPreview = async (index) => {
|
| 235 |
const vid = videos[index];
|
| 236 |
+
if (!vid) return;
|
| 237 |
+
patchVideo(index, { previewing: true });
|
| 238 |
try {
|
| 239 |
const r = await api.cutPreview({
|
| 240 |
session: vid.session, file: vid.file, mode: st.mode,
|
|
|
|
| 242 |
});
|
| 243 |
const interior = r.segments.slice(0, -1).map((s) => s.end_frame);
|
| 244 |
patchVideo(index, { thumbs: r.thumbnails, cuts: interior, info: r.info, previewing: false });
|
| 245 |
+
} catch (e) { patchVideo(index, { previewing: false }); throw e; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
};
|
| 247 |
|
| 248 |
+
// Xem điểm cắt cho TẤT CẢ video đã tải (mỗi video có timeline riêng).
|
| 249 |
+
const previewAll = async () => {
|
| 250 |
+
if (!videos.length) return;
|
| 251 |
+
setPrevBusy(true);
|
| 252 |
+
try {
|
| 253 |
+
for (let i = 0; i < videos.length; i++) await doPreview(i);
|
| 254 |
+
} catch (e) { alert("Lỗi xem trước: " + e.message); }
|
| 255 |
+
finally { setPrevBusy(false); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
};
|
| 257 |
|
| 258 |
+
const switchTo = (i) => set({ active: i });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
const segmentsFromCuts = (video) => {
|
| 261 |
const c = video.cuts || [];
|
|
|
|
| 312 |
}, i * 300));
|
| 313 |
};
|
| 314 |
|
| 315 |
+
const anyPreviewed = videos.some((v) => v.thumbs && v.thumbs.length > 0);
|
| 316 |
+
|
| 317 |
return (
|
| 318 |
<div>
|
| 319 |
<div className="h1"><IcStar size={22} /> Workspace: <span className="accent">Cut video</span></div>
|
|
|
|
| 325 |
<div className="card">
|
| 326 |
<div className="panel-title">1. Tải lên video nguồn</div>
|
| 327 |
<FileDrop onFiles={onFiles} hint="MP4, MOV, AVI, MKV, WebM · có thể thêm nhiều video" />
|
| 328 |
+
{uploading && (
|
| 329 |
+
<div className="small mt8" style={{ color: "var(--orange-700)" }}>
|
| 330 |
+
⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
{videos.length > 0 && (
|
| 334 |
<div className="vtabs mt12">
|
| 335 |
{videos.map((v, i) => (
|
|
|
|
| 378 |
Gợi ý ~{cur.auto_count} khúc · {fmt(cur.info.duration)} · {cur.info.fps}fps
|
| 379 |
</div>
|
| 380 |
)}
|
| 381 |
+
<button className="btn right" onClick={previewAll} disabled={!videos.length || prevBusy}>
|
| 382 |
+
{prevBusy ? "Đang tạo xem trước…" : `Xem điểm cắt${videos.length > 1 ? ` (${videos.length} video)` : ""}`}
|
| 383 |
</button>
|
| 384 |
</div>
|
| 385 |
</div>
|
| 386 |
|
| 387 |
+
{anyPreviewed && (
|
| 388 |
+
<>
|
| 389 |
+
{videos.map((v, i) =>
|
| 390 |
+
(v.thumbs && v.thumbs.length > 0)
|
| 391 |
+
? <VideoTimeline key={i} video={v} index={i} patchVideo={patchVideo} />
|
| 392 |
+
: null
|
| 393 |
+
)}
|
| 394 |
+
<div className="card">
|
| 395 |
+
<button className="btn primary block" onClick={executeAll} disabled={busy}>
|
| 396 |
+
{busy ? "Đang cắt..." : <>Cắt {videos.length > 1 ? `tất cả ${videos.length} video` : "video"} <IcArrow size={16} /></>}
|
| 397 |
+
</button>
|
| 398 |
+
<p className="small muted mt8">Mỗi video dùng điểm cắt riêng của nó. Bấm “Cắt” sẽ xử lý tất cả video đã tải cùng lúc.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
</div>
|
| 400 |
+
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
)}
|
| 402 |
|
| 403 |
<ProcessingOverlay busy={busy || prevBusy} job={busy ? job : null}
|
frontend/src/pages/DownList.jsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { api, pollJob, fileUrl } from "../api";
|
| 4 |
import { useToolState } from "../store";
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
|
| 7 |
-
import {
|
| 8 |
|
| 9 |
const STEPS = [
|
| 10 |
{ t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
|
|
@@ -38,11 +38,25 @@ export default function DownList() {
|
|
| 38 |
const setJob = (j) => set({ job: j });
|
| 39 |
const setBusy = (b) => set({ busy: b });
|
| 40 |
const [showNote, setShowNote] = useState(true);
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
};
|
| 47 |
|
| 48 |
const start = async () => {
|
|
@@ -103,10 +117,10 @@ export default function DownList() {
|
|
| 103 |
<div className="panel-title">1. Dán danh sách link</div>
|
| 104 |
<textarea
|
| 105 |
placeholder={"Dán nhiều link video, mỗi link một dòng...\nhttps://www.youtube.com/watch?v=abc123\nhttps://www.tiktok.com/@user/video/123"}
|
| 106 |
-
value={st.raw} onChange={(e) =>
|
| 107 |
<div className="flex between center mt12">
|
| 108 |
<span className="muted small">Tối đa 200 link/lần ({st.items.length}/200)</span>
|
| 109 |
-
<
|
| 110 |
</div>
|
| 111 |
<div className="toggle-row mt16">
|
| 112 |
<Toggle on={st.zip} onChange={(v) => set({ zip: v })} />
|
|
@@ -186,8 +200,8 @@ export default function DownList() {
|
|
| 186 |
<td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
|
| 187 |
<td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}>Về máy</a></td>
|
| 188 |
<td>
|
| 189 |
-
<div className="flex gap8 center" style={{ flexWrap: "
|
| 190 |
-
<div className="toolchips">
|
| 191 |
{TOOLS.map((t) => (
|
| 192 |
<span key={t.id} className={"toolchip" + (sel[t.id] ? " on" : "")} onClick={() => toggleTool(r.path, t.id)}>
|
| 193 |
<span className="tick-mini">{sel[t.id] && <IcCheck size={10} />}</span>
|
|
|
|
| 1 |
+
import { useState, useRef } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { api, pollJob, fileUrl } from "../api";
|
| 4 |
import { useToolState } from "../store";
|
| 5 |
import { useAuth } from "../stores/authStore";
|
| 6 |
import { ProcessRail, Toggle, ProcessingOverlay, CacheNote } from "../components/ui";
|
| 7 |
+
import { IcTrash, IcDownload, IcArrow, IcScissors, IcMic, IcCheck } from "../components/Icons";
|
| 8 |
|
| 9 |
const STEPS = [
|
| 10 |
{ t: "Dán danh sách link", d: "Dán hoặc nhập danh sách các link video cần tải." },
|
|
|
|
| 38 |
const setJob = (j) => set({ job: j });
|
| 39 |
const setBusy = (b) => set({ busy: b });
|
| 40 |
const [showNote, setShowNote] = useState(true);
|
| 41 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 42 |
+
const parseTimer = useRef(null);
|
| 43 |
|
| 44 |
+
// Phân tích danh sách link. Gọi tự động (debounce) mỗi khi textarea đổi.
|
| 45 |
+
const runParse = async (text) => {
|
| 46 |
+
if (!text.trim()) { set({ items: [] }); return; }
|
| 47 |
+
setAnalyzing(true);
|
| 48 |
+
try {
|
| 49 |
+
const r = await api.parseLinks(text);
|
| 50 |
+
set({ items: r.items });
|
| 51 |
+
} catch (e) { /* im lặng khi đang gõ dở */ }
|
| 52 |
+
finally { setAnalyzing(false); }
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// Tự động phân tích khi dán / sửa / xóa link (debounce 500ms để không gọi API liên tục).
|
| 56 |
+
const onRawChange = (val) => {
|
| 57 |
+
set({ raw: val });
|
| 58 |
+
if (parseTimer.current) clearTimeout(parseTimer.current);
|
| 59 |
+
parseTimer.current = setTimeout(() => runParse(val), 500);
|
| 60 |
};
|
| 61 |
|
| 62 |
const start = async () => {
|
|
|
|
| 117 |
<div className="panel-title">1. Dán danh sách link</div>
|
| 118 |
<textarea
|
| 119 |
placeholder={"Dán nhiều link video, mỗi link một dòng...\nhttps://www.youtube.com/watch?v=abc123\nhttps://www.tiktok.com/@user/video/123"}
|
| 120 |
+
value={st.raw} onChange={(e) => onRawChange(e.target.value)} />
|
| 121 |
<div className="flex between center mt12">
|
| 122 |
<span className="muted small">Tối đa 200 link/lần ({st.items.length}/200)</span>
|
| 123 |
+
<span className="muted small">{analyzing ? "Đang phân tích…" : "Tự động phân tích khi dán / sửa link"}</span>
|
| 124 |
</div>
|
| 125 |
<div className="toggle-row mt16">
|
| 126 |
<Toggle on={st.zip} onChange={(v) => set({ zip: v })} />
|
|
|
|
| 200 |
<td className="muted">{(r.size / 1048576).toFixed(1)} MB</td>
|
| 201 |
<td><a className="btn sm" href={fileUrl(r.url)} download={`${r.title}.${r.ext}`}>Về máy</a></td>
|
| 202 |
<td>
|
| 203 |
+
<div className="flex gap8 center" style={{ flexWrap: "nowrap", whiteSpace: "nowrap" }}>
|
| 204 |
+
<div className="toolchips" style={{ flexWrap: "nowrap" }}>
|
| 205 |
{TOOLS.map((t) => (
|
| 206 |
<span key={t.id} className={"toolchip" + (sel[t.id] ? " on" : "")} onClick={() => toggleTool(r.path, t.id)}>
|
| 207 |
<span className="tick-mini">{sel[t.id] && <IcCheck size={10} />}</span>
|
frontend/src/pages/DubVideo.jsx
CHANGED
|
@@ -18,8 +18,8 @@ const LANGS = [
|
|
| 18 |
{ id: "en", label: "English" },
|
| 19 |
{ id: "fil", label: "Filipino" },
|
| 20 |
];
|
| 21 |
-
// Model chốt theo ngôn ngữ (không cho chọn): vi→VieNeu, en
|
| 22 |
-
const MODEL_LABEL = { vi: "VieNeu-TTS", en: "
|
| 23 |
const BG = [
|
| 24 |
{ id: "duck", label: "Giảm tiếng gốc" },
|
| 25 |
{ id: "demucs", label: "Tách nhạc (giữ nền)" },
|
|
@@ -72,6 +72,7 @@ export default function DubVideo() {
|
|
| 72 |
busy: false, ttsBusy: false, job: null,
|
| 73 |
});
|
| 74 |
const [showNote, setShowNote] = useState(true);
|
|
|
|
| 75 |
const [voices, setVoices] = useState([]); // giọng cho khu dịch video
|
| 76 |
const [ttsVoices, setTtsVoices] = useState([]); // giọng cho khu gen voice
|
| 77 |
const job = st.job;
|
|
@@ -83,7 +84,8 @@ export default function DubVideo() {
|
|
| 83 |
const sampleRef = useRef();
|
| 84 |
const ttsSampleRef = useRef();
|
| 85 |
|
| 86 |
-
|
|
|
|
| 87 |
|
| 88 |
useEffect(() => {
|
| 89 |
const sid = sp.get("session");
|
|
@@ -115,11 +117,17 @@ export default function DubVideo() {
|
|
| 115 |
const addVideos = async (files) => {
|
| 116 |
let sid = st.session;
|
| 117 |
const added = [];
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
|
|
|
| 123 |
set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
|
| 124 |
};
|
| 125 |
const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
|
|
@@ -139,19 +147,19 @@ export default function DubVideo() {
|
|
| 139 |
if (!st.videos.length) return;
|
| 140 |
setBusy(true); set({ outputs: [] });
|
| 141 |
try {
|
| 142 |
-
const
|
| 143 |
const src = st.voiceSource; // preset | video | upload
|
| 144 |
-
const doClone = !
|
| 145 |
const { job_id } = await api.dubStart({
|
| 146 |
session: st.session, files: st.videos.map((v) => v.file),
|
| 147 |
target_lang: st.target, bg_mode: st.bg,
|
| 148 |
do_clone: doClone,
|
| 149 |
-
voice_source:
|
| 150 |
speaker_file: st.speakerFile,
|
| 151 |
-
preset:
|
| 152 |
style: st.style, engine: "auto", multi_speaker: st.multiSpeaker,
|
| 153 |
username: user,
|
| 154 |
-
...qualityPayload(!
|
| 155 |
});
|
| 156 |
const done = await pollJob(job_id, setJob);
|
| 157 |
const outs = done.result?.outputs ||
|
|
@@ -184,15 +192,15 @@ export default function DubVideo() {
|
|
| 184 |
if (!tts.text.trim()) return alert("Nhập text cần đọc.");
|
| 185 |
setTtsBusy(true); setTts({ result: null });
|
| 186 |
try {
|
| 187 |
-
const
|
| 188 |
const src = tts.voiceSource; // preset | upload
|
| 189 |
const { job_id } = await api.dubTts({
|
| 190 |
session: st.session || "", text: tts.text, target_lang: tts.target,
|
| 191 |
-
voice_source:
|
| 192 |
speaker_file: tts.speakerFile,
|
| 193 |
-
preset:
|
| 194 |
style: tts.style, engine: "auto",
|
| 195 |
-
...qualityPayload(!
|
| 196 |
});
|
| 197 |
const done = await pollJob(job_id, setJob);
|
| 198 |
const out = done.result?.output ||
|
|
@@ -216,6 +224,11 @@ export default function DubVideo() {
|
|
| 216 |
<div className="card">
|
| 217 |
<div className="panel-title">1. Tải lên video cần dịch</div>
|
| 218 |
<FileDrop onFiles={addVideos} hint="MP4, MOV, AVI, MKV · có thể chọn nhiều video" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
{st.videos.length > 0 && (
|
| 220 |
<div className="filelist mt12">
|
| 221 |
{st.videos.map((v, i) => (
|
|
@@ -253,9 +266,13 @@ export default function DubVideo() {
|
|
| 253 |
</div>
|
| 254 |
</div>
|
| 255 |
|
| 256 |
-
{
|
| 257 |
-
<div className="
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
</div>
|
| 260 |
) : (
|
| 261 |
<>
|
|
@@ -374,8 +391,14 @@ export default function DubVideo() {
|
|
| 374 |
</div>
|
| 375 |
</div>
|
| 376 |
|
| 377 |
-
{
|
| 378 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
) : (
|
| 380 |
<>
|
| 381 |
<div className="mt12">
|
|
@@ -419,7 +442,8 @@ export default function DubVideo() {
|
|
| 419 |
</div>
|
| 420 |
</div>
|
| 421 |
|
| 422 |
-
<ProcessRail steps={STEPS} tip="Model theo ngôn ngữ: vi → VieNeu (10 giọng + clone); en →
|
|
|
|
| 423 |
</div>
|
| 424 |
</div>
|
| 425 |
);
|
|
|
|
| 18 |
{ id: "en", label: "English" },
|
| 19 |
{ id: "fil", label: "Filipino" },
|
| 20 |
];
|
| 21 |
+
// Model chốt theo ngôn ngữ (không cho chọn): vi→VieNeu (clone được), en & fil→Edge TTS.
|
| 22 |
+
const MODEL_LABEL = { vi: "VieNeu-TTS", en: "Edge TTS", fil: "Edge TTS" };
|
| 23 |
const BG = [
|
| 24 |
{ id: "duck", label: "Giảm tiếng gốc" },
|
| 25 |
{ id: "demucs", label: "Tách nhạc (giữ nền)" },
|
|
|
|
| 72 |
busy: false, ttsBusy: false, job: null,
|
| 73 |
});
|
| 74 |
const [showNote, setShowNote] = useState(true);
|
| 75 |
+
const [uploading, setUploading] = useState(null); // {name,i,n} — video đang tải lên
|
| 76 |
const [voices, setVoices] = useState([]); // giọng cho khu dịch video
|
| 77 |
const [ttsVoices, setTtsVoices] = useState([]); // giọng cho khu gen voice
|
| 78 |
const job = st.job;
|
|
|
|
| 84 |
const sampleRef = useRef();
|
| 85 |
const ttsSampleRef = useRef();
|
| 86 |
|
| 87 |
+
// en & fil chạy Edge TTS: có bộ chọn giọng dựng sẵn, KHÔNG clone. vi (VieNeu) clone được.
|
| 88 |
+
const isEdge = (t) => t === "fil" || t === "en";
|
| 89 |
|
| 90 |
useEffect(() => {
|
| 91 |
const sid = sp.get("session");
|
|
|
|
| 117 |
const addVideos = async (files) => {
|
| 118 |
let sid = st.session;
|
| 119 |
const added = [];
|
| 120 |
+
const arr = Array.from(files);
|
| 121 |
+
for (let i = 0; i < arr.length; i++) {
|
| 122 |
+
const file = arr[i];
|
| 123 |
+
setUploading({ name: file.name, i: i + 1, n: arr.length });
|
| 124 |
+
try {
|
| 125 |
+
const r = await api.dubUpload(file, sid);
|
| 126 |
+
sid = r.session;
|
| 127 |
+
added.push({ file: r.file, name: r.name, size: r.size, url: `/api/files/${sid}/${r.file}` });
|
| 128 |
+
} catch (e) { alert("Lỗi tải video: " + e.message); }
|
| 129 |
}
|
| 130 |
+
setUploading(null);
|
| 131 |
set((cur) => ({ session: sid, videos: [...cur.videos, ...added] }));
|
| 132 |
};
|
| 133 |
const removeVideo = (i) => set((cur) => ({ videos: cur.videos.filter((_, j) => j !== i) }));
|
|
|
|
| 147 |
if (!st.videos.length) return;
|
| 148 |
setBusy(true); set({ outputs: [] });
|
| 149 |
try {
|
| 150 |
+
const edge = isEdge(st.target);
|
| 151 |
const src = st.voiceSource; // preset | video | upload
|
| 152 |
+
const doClone = !edge && (src === "video" || src === "upload");
|
| 153 |
const { job_id } = await api.dubStart({
|
| 154 |
session: st.session, files: st.videos.map((v) => v.file),
|
| 155 |
target_lang: st.target, bg_mode: st.bg,
|
| 156 |
do_clone: doClone,
|
| 157 |
+
voice_source: edge ? "preset" : src,
|
| 158 |
speaker_file: st.speakerFile,
|
| 159 |
+
preset: edge ? st.voice : (src === "preset" ? st.voice : ""),
|
| 160 |
style: st.style, engine: "auto", multi_speaker: st.multiSpeaker,
|
| 161 |
username: user,
|
| 162 |
+
...qualityPayload(!edge && st.useQuality, st.temperature, st.topK, st.repPen),
|
| 163 |
});
|
| 164 |
const done = await pollJob(job_id, setJob);
|
| 165 |
const outs = done.result?.outputs ||
|
|
|
|
| 192 |
if (!tts.text.trim()) return alert("Nhập text cần đọc.");
|
| 193 |
setTtsBusy(true); setTts({ result: null });
|
| 194 |
try {
|
| 195 |
+
const edge = isEdge(tts.target);
|
| 196 |
const src = tts.voiceSource; // preset | upload
|
| 197 |
const { job_id } = await api.dubTts({
|
| 198 |
session: st.session || "", text: tts.text, target_lang: tts.target,
|
| 199 |
+
voice_source: edge ? "preset" : src,
|
| 200 |
speaker_file: tts.speakerFile,
|
| 201 |
+
preset: edge ? tts.voice : (src === "preset" ? tts.voice : ""),
|
| 202 |
style: tts.style, engine: "auto",
|
| 203 |
+
...qualityPayload(!edge && tts.useQuality, tts.temperature, tts.topK, tts.repPen),
|
| 204 |
});
|
| 205 |
const done = await pollJob(job_id, setJob);
|
| 206 |
const out = done.result?.output ||
|
|
|
|
| 224 |
<div className="card">
|
| 225 |
<div className="panel-title">1. Tải lên video cần dịch</div>
|
| 226 |
<FileDrop onFiles={addVideos} hint="MP4, MOV, AVI, MKV · có thể chọn nhiều video" />
|
| 227 |
+
{uploading && (
|
| 228 |
+
<div className="small mt8" style={{ color: "var(--orange-700)" }}>
|
| 229 |
+
⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
{st.videos.length > 0 && (
|
| 233 |
<div className="filelist mt12">
|
| 234 |
{st.videos.map((v, i) => (
|
|
|
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
|
| 269 |
+
{isEdge(st.target) ? (
|
| 270 |
+
<div className="mt16">
|
| 271 |
+
<span className="field-label">Chọn giọng ({voices.length})</span>
|
| 272 |
+
<select value={st.voice} onChange={(e) => set({ voice: e.target.value })} style={{ minWidth: 260 }}>
|
| 273 |
+
{voices.map((v) => <option key={v.id} value={v.id}>{v.label}</option>)}
|
| 274 |
+
</select>
|
| 275 |
+
<div className="small muted mt8">Dùng <b>Edge TTS</b> — giọng dựng sẵn (chọn nam/nữ), không clone giọng.</div>
|
| 276 |
</div>
|
| 277 |
) : (
|
| 278 |
<>
|
|
|
|
| 391 |
</div>
|
| 392 |
</div>
|
| 393 |
|
| 394 |
+
{isEdge(tts.target) ? (
|
| 395 |
+
<div className="mt12">
|
| 396 |
+
<span className="field-label">Chọn giọng ({ttsVoices.length})</span>
|
| 397 |
+
<select value={tts.voice} onChange={(e) => setTts({ voice: e.target.value })} style={{ minWidth: 260 }}>
|
| 398 |
+
{ttsVoices.map((v) => <option key={v.id} value={v.id}>{v.label}</option>)}
|
| 399 |
+
</select>
|
| 400 |
+
<div className="small muted mt8">Edge TTS — giọng dựng sẵn, không clone.</div>
|
| 401 |
+
</div>
|
| 402 |
) : (
|
| 403 |
<>
|
| 404 |
<div className="mt12">
|
|
|
|
| 442 |
</div>
|
| 443 |
</div>
|
| 444 |
|
| 445 |
+
<ProcessRail steps={STEPS} tip="Model theo ngôn ngữ: vi → VieNeu (10 giọng + clone giọng); en & fil → Edge TTS (nhiều giọng nam/nữ, không clone). Dịch theo ngữ điệu quảng cáo; tốc độ đọc máy tự khớp video." />
|
| 446 |
+
|
| 447 |
</div>
|
| 448 |
</div>
|
| 449 |
);
|
frontend/src/pages/ShuffleVideo.jsx
CHANGED
|
@@ -29,6 +29,7 @@ export default function ShuffleVideo() {
|
|
| 29 |
useSim: false, sim: 80,
|
| 30 |
useRandom: false, randomPct: 100,
|
| 31 |
maxOut: "",
|
|
|
|
| 32 |
plan: [], // bảng biến thể (mỗi dòng: {i, seq, labels, keep})
|
| 33 |
results: [],
|
| 34 |
busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
|
|
@@ -38,9 +39,14 @@ export default function ShuffleVideo() {
|
|
| 38 |
const setJob = (j) => set({ job: j });
|
| 39 |
const setBusy = (b) => set({ busy: b });
|
| 40 |
const [showNote, setShowNote] = useState(true);
|
|
|
|
| 41 |
|
| 42 |
const segs = st.segs;
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
// nhận khúc đẩy từ Cut (?session=) -> nạp seg_* có sẵn
|
| 45 |
useEffect(() => {
|
| 46 |
const sid = sp.get("session");
|
|
@@ -80,11 +86,17 @@ export default function ShuffleVideo() {
|
|
| 80 |
const addToSeg = (idx) => async (files) => {
|
| 81 |
let sid = st.session;
|
| 82 |
const added = [];
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
|
|
|
| 88 |
set((cur) => {
|
| 89 |
const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
|
| 90 |
n[idx].files.push(...added);
|
|
@@ -99,10 +111,16 @@ export default function ShuffleVideo() {
|
|
| 99 |
return alert("Hãy thêm clip vào khúc trước để khởi tạo phiên, rồi thêm âm thanh.");
|
| 100 |
}
|
| 101 |
const added = [];
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
|
|
|
| 106 |
set((cur) => ({ audio: [...cur.audio, ...added] }));
|
| 107 |
};
|
| 108 |
|
|
@@ -113,16 +131,20 @@ export default function ShuffleVideo() {
|
|
| 113 |
const removeAudio = (ai) => set((cur) => ({ audio: cur.audio.filter((_, j) => j !== ai) }));
|
| 114 |
const setOrder = (si, mode) => set((cur) => ({ segs: cur.segs.map((s, i) => (i === si ? { ...s, order_mode: mode } : s)) }));
|
| 115 |
|
| 116 |
-
const payload = () =>
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
// khúc "bỏ" thì không cần clip; các khúc còn lại cần ít nhất 1 clip
|
| 128 |
const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
|
|
@@ -157,8 +179,14 @@ export default function ShuffleVideo() {
|
|
| 157 |
const generate = async () => {
|
| 158 |
if (!ready) return;
|
| 159 |
// nếu đã mở bảng -> chỉ render các dòng được tick; chưa mở bảng -> render tất cả như cũ
|
| 160 |
-
|
| 161 |
if (st.plan.length && !sequences.length) return alert("Chưa chọn biến thể nào để render.");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
setBusy(true); set({ results: [] });
|
| 163 |
try {
|
| 164 |
const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
|
|
@@ -217,6 +245,12 @@ export default function ShuffleVideo() {
|
|
| 217 |
{s.order_mode === "drop" ? (
|
| 218 |
<div className="small muted">Khúc này bị bỏ khỏi video — không cần clip.</div>
|
| 219 |
) : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
<div className="clip-row">
|
| 221 |
<div style={{ width: 150 }}>
|
| 222 |
<FileDrop compact folder onFiles={addToSeg(si)} title="Thả clip" hint="clip hoặc cả thư mục" />
|
|
@@ -229,6 +263,7 @@ export default function ShuffleVideo() {
|
|
| 229 |
</div>
|
| 230 |
))}
|
| 231 |
</div>
|
|
|
|
| 232 |
)}
|
| 233 |
</div>
|
| 234 |
))}
|
|
@@ -243,6 +278,11 @@ export default function ShuffleVideo() {
|
|
| 243 |
<div style={{ width: 150 }}>
|
| 244 |
<FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
|
| 245 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
{st.audio.map((a, ai) => (
|
| 247 |
<div className="audio-chip" key={ai}>
|
| 248 |
<IcMusic size={16} />
|
|
@@ -310,9 +350,9 @@ export default function ShuffleVideo() {
|
|
| 310 |
</div>
|
| 311 |
{st.useSim && (
|
| 312 |
<div className="sub">
|
| 313 |
-
<span className="field-label">
|
| 314 |
<input className="slider" type="range" min="20" max="99" value={st.sim} onChange={(e) => set({ sim: +e.target.value })} />
|
| 315 |
-
<div className="small muted">
|
| 316 |
</div>
|
| 317 |
)}
|
| 318 |
</div>
|
|
@@ -333,12 +373,25 @@ export default function ShuffleVideo() {
|
|
| 333 |
<input type="number" placeholder="không giới hạn" value={st.maxOut}
|
| 334 |
onChange={(e) => set({ maxOut: e.target.value })} style={{ width: 160 }} />
|
| 335 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
<span className="small muted">Bật lọc / nhập giới hạn → bảng tự bỏ tick bớt.</span>
|
| 337 |
</div>
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
<button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
|
| 340 |
{busy ? "Đang xào..."
|
| 341 |
-
: <><IcShuffle size={16} /> Render đã chọn ({st.plan.filter((v) => v.keep).length}/{st.plan.length})</>}
|
| 342 |
</button>
|
| 343 |
</div>
|
| 344 |
)}
|
|
|
|
| 29 |
useSim: false, sim: 80,
|
| 30 |
useRandom: false, randomPct: 100,
|
| 31 |
maxOut: "",
|
| 32 |
+
res: "1080", // độ phân giải xuất ra (khung dọc)
|
| 33 |
plan: [], // bảng biến thể (mỗi dòng: {i, seq, labels, keep})
|
| 34 |
results: [],
|
| 35 |
busy: false, job: null, // giữ trong store -> chuyển tool vẫn thấy "đang xử lý"
|
|
|
|
| 39 |
const setJob = (j) => set({ job: j });
|
| 40 |
const setBusy = (b) => set({ busy: b });
|
| 41 |
const [showNote, setShowNote] = useState(true);
|
| 42 |
+
const [uploading, setUploading] = useState(null); // {name,i,n,seg} — clip đang tải lên
|
| 43 |
|
| 44 |
const segs = st.segs;
|
| 45 |
|
| 46 |
+
// độ phân giải khung dọc (TikTok) — width x height
|
| 47 |
+
const RES = { "1080": [1080, 1920], "720": [720, 1280], "480": [480, 854] };
|
| 48 |
+
const MAX_RENDER = 150;
|
| 49 |
+
|
| 50 |
// nhận khúc đẩy từ Cut (?session=) -> nạp seg_* có sẵn
|
| 51 |
useEffect(() => {
|
| 52 |
const sid = sp.get("session");
|
|
|
|
| 86 |
const addToSeg = (idx) => async (files) => {
|
| 87 |
let sid = st.session;
|
| 88 |
const added = [];
|
| 89 |
+
const arr = Array.from(files);
|
| 90 |
+
for (let i = 0; i < arr.length; i++) {
|
| 91 |
+
const file = arr[i];
|
| 92 |
+
setUploading({ name: file.name, i: i + 1, n: arr.length, seg: idx });
|
| 93 |
+
try {
|
| 94 |
+
const r = await api.shuffleUpload(file, idx, sid);
|
| 95 |
+
sid = r.session;
|
| 96 |
+
added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
|
| 97 |
+
} catch (e) { alert("Lỗi tải clip: " + e.message); }
|
| 98 |
}
|
| 99 |
+
setUploading(null);
|
| 100 |
set((cur) => {
|
| 101 |
const n = cur.segs.map((s) => ({ ...s, files: [...s.files] }));
|
| 102 |
n[idx].files.push(...added);
|
|
|
|
| 111 |
return alert("Hãy thêm clip vào khúc trước để khởi tạo phiên, rồi thêm âm thanh.");
|
| 112 |
}
|
| 113 |
const added = [];
|
| 114 |
+
const arr = Array.from(files);
|
| 115 |
+
for (let i = 0; i < arr.length; i++) {
|
| 116 |
+
const file = arr[i];
|
| 117 |
+
setUploading({ name: file.name, i: i + 1, n: arr.length, seg: "audio" });
|
| 118 |
+
try {
|
| 119 |
+
const r = await api.shuffleUploadAudio(file, sid);
|
| 120 |
+
added.push({ file: r.file, name: r.name, size: r.size, url: r.url });
|
| 121 |
+
} catch (e) { alert("Lỗi tải âm thanh: " + e.message); }
|
| 122 |
}
|
| 123 |
+
setUploading(null);
|
| 124 |
set((cur) => ({ audio: [...cur.audio, ...added] }));
|
| 125 |
};
|
| 126 |
|
|
|
|
| 131 |
const removeAudio = (ai) => set((cur) => ({ audio: cur.audio.filter((_, j) => j !== ai) }));
|
| 132 |
const setOrder = (si, mode) => set((cur) => ({ segs: cur.segs.map((s, i) => (i === si ? { ...s, order_mode: mode } : s)) }));
|
| 133 |
|
| 134 |
+
const payload = () => {
|
| 135 |
+
const [w, h] = RES[st.res] || RES["1080"];
|
| 136 |
+
return {
|
| 137 |
+
session: st.session,
|
| 138 |
+
segments: segs.map((s) => ({ files: s.files.map((f) => f.file), order_mode: s.order_mode })),
|
| 139 |
+
audio_files: st.audio.map((a) => a.file),
|
| 140 |
+
similarity_threshold: st.useSim ? st.sim / 100 : 1.0, // 1.0 = lấy hết
|
| 141 |
+
random_ratio: st.useRandom ? st.randomPct / 100 : 1.0, // 1.0 = lấy hết
|
| 142 |
+
max_outputs: st.maxOut ? parseInt(st.maxOut) : null,
|
| 143 |
+
width: w, height: h,
|
| 144 |
+
seed: 42,
|
| 145 |
+
username: user,
|
| 146 |
+
};
|
| 147 |
+
};
|
| 148 |
|
| 149 |
// khúc "bỏ" thì không cần clip; các khúc còn lại cần ít nhất 1 clip
|
| 150 |
const ready = st.session && segs.some((s) => s.order_mode !== "drop") &&
|
|
|
|
| 179 |
const generate = async () => {
|
| 180 |
if (!ready) return;
|
| 181 |
// nếu đã mở bảng -> chỉ render các dòng được tick; chưa mở bảng -> render tất cả như cũ
|
| 182 |
+
let sequences = st.plan.length ? selectedSeqs() : null;
|
| 183 |
if (st.plan.length && !sequences.length) return alert("Chưa chọn biến thể nào để render.");
|
| 184 |
+
// 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
|
| 185 |
+
if (sequences && sequences.length > MAX_RENDER) {
|
| 186 |
+
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.`))
|
| 187 |
+
return;
|
| 188 |
+
sequences = sequences.slice(0, MAX_RENDER);
|
| 189 |
+
}
|
| 190 |
setBusy(true); set({ results: [] });
|
| 191 |
try {
|
| 192 |
const { job_id } = await api.shuffleGenerate({ ...payload(), sequences });
|
|
|
|
| 245 |
{s.order_mode === "drop" ? (
|
| 246 |
<div className="small muted">Khúc này bị bỏ khỏi video — không cần clip.</div>
|
| 247 |
) : (
|
| 248 |
+
<>
|
| 249 |
+
{uploading?.seg === si && (
|
| 250 |
+
<div className="small" style={{ color: "var(--orange-700)", marginBottom: 6 }}>
|
| 251 |
+
⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
<div className="clip-row">
|
| 255 |
<div style={{ width: 150 }}>
|
| 256 |
<FileDrop compact folder onFiles={addToSeg(si)} title="Thả clip" hint="clip hoặc cả thư mục" />
|
|
|
|
| 263 |
</div>
|
| 264 |
))}
|
| 265 |
</div>
|
| 266 |
+
</>
|
| 267 |
)}
|
| 268 |
</div>
|
| 269 |
))}
|
|
|
|
| 278 |
<div style={{ width: 150 }}>
|
| 279 |
<FileDrop compact accept="audio/*" onFiles={addAudio} title="Thả âm thanh" hint="MP3, WAV, M4A" />
|
| 280 |
</div>
|
| 281 |
+
{uploading?.seg === "audio" && (
|
| 282 |
+
<div className="small" style={{ color: "var(--orange-700)", alignSelf: "center" }}>
|
| 283 |
+
⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})…
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
| 286 |
{st.audio.map((a, ai) => (
|
| 287 |
<div className="audio-chip" key={ai}>
|
| 288 |
<IcMusic size={16} />
|
|
|
|
| 350 |
</div>
|
| 351 |
{st.useSim && (
|
| 352 |
<div className="sub">
|
| 353 |
+
<span className="field-label">Ngưỡng tương đồng: {st.sim}%</span>
|
| 354 |
<input className="slider" type="range" min="20" max="99" value={st.sim} onChange={(e) => set({ sim: +e.target.value })} />
|
| 355 |
+
<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>
|
| 356 |
</div>
|
| 357 |
)}
|
| 358 |
</div>
|
|
|
|
| 373 |
<input type="number" placeholder="không giới hạn" value={st.maxOut}
|
| 374 |
onChange={(e) => set({ maxOut: e.target.value })} style={{ width: 160 }} />
|
| 375 |
</div>
|
| 376 |
+
<div>
|
| 377 |
+
<span className="field-label">Chất lượng (khung dọc)</span>
|
| 378 |
+
<select value={st.res} onChange={(e) => set({ res: e.target.value })} style={{ width: 160 }}>
|
| 379 |
+
<option value="1080">1080 × 1920 (cao)</option>
|
| 380 |
+
<option value="720">720 × 1280 (vừa)</option>
|
| 381 |
+
<option value="480">480 × 854 (nhẹ)</option>
|
| 382 |
+
</select>
|
| 383 |
+
</div>
|
| 384 |
<span className="small muted">Bật lọc / nhập giới hạn → bảng tự bỏ tick bớt.</span>
|
| 385 |
</div>
|
| 386 |
|
| 387 |
+
{st.plan.filter((v) => v.keep).length > MAX_RENDER && (
|
| 388 |
+
<p className="small mt8" style={{ color: "var(--orange-700)" }}>
|
| 389 |
+
⚠ Đ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ỏ.
|
| 390 |
+
</p>
|
| 391 |
+
)}
|
| 392 |
<button className="btn primary block mt16" onClick={generate} disabled={!ready || busy}>
|
| 393 |
{busy ? "Đang xào..."
|
| 394 |
+
: <><IcShuffle size={16} /> Render đã chọn ({Math.min(st.plan.filter((v) => v.keep).length, MAX_RENDER)}/{st.plan.filter((v) => v.keep).length})</>}
|
| 395 |
</button>
|
| 396 |
</div>
|
| 397 |
)}
|