import { useEffect, useRef, useState } from "react"; import { api, pollJob, fileUrl } from "../api"; import { useToolState } from "../store"; import { useAuth } from "../stores/authStore"; import { ProcessRail, FileDrop, ProcessingOverlay, CacheNote } from "../components/ui"; import { IcText, IcDownload, IcVideo } from "../components/Icons"; import SubtitleEditor from "../components/SubtitleEditor"; import track from "../lib/track"; import ThumbsFeedback from "../components/ThumbsFeedback"; const STEPS = [ { t: "Tải video", d: "Tải 1 hoặc nhiều video cần chèn chữ." }, { t: "Lấy lời & tạo kiểu chữ", d: "Tự nhận dạng lời trong video (hoặc gõ tay), chọn phông/màu/nền, kéo đặt vị trí." }, { t: "Áp form cho mọi video", d: "Làm 1 video rồi áp cùng kiểu (và cả lời nếu muốn) cho các video còn lại." }, { t: "Tạo tất cả", d: "Burn chữ vào từng video — vào bao nhiêu ra bấy nhiêu." }, ]; const clone = (x) => JSON.parse(JSON.stringify(x)); export default function TextVideo() { const { user } = useAuth(); const [st, set] = useToolState("textvid", { session: null, videos: [], // [{file,name,size,url}] selIdx: 0, // video đang mở trong trình chèn chữ (-1 = chưa mở) forms: {}, // { [file]: {segs, mode, payload} } — chữ của từng video outputs: [], // [{name, video_url, srcName, srcUrl}] outStamp: 0, busy: false, job: null, }); const [showNote, setShowNote] = useState(true); const [uploading, setUploading] = useState(null); // {name,i,n} const [msg, setMsg] = useState(""); // báo nhẹ sau khi áp form const busy = st.busy; const job = st.job; const setBusy = (b) => set({ busy: b }); const setJob = (j) => set({ job: j }); const pollAbort = useRef(new AbortController()); useEffect(() => () => pollAbort.current.abort(), []); useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(""), 4000); return () => clearTimeout(t); }, [msg]); const addVideos = async (files) => { let sid = st.session; const added = []; const arr = Array.from(files); for (let i = 0; i < arr.length; i++) { const file = arr[i]; setUploading({ name: file.name, i: i + 1, n: arr.length }); try { const r = await api.dubUpload(file, sid); sid = r.session; added.push({ file: r.file, name: r.name, size: r.size, url: `/api/files/${sid}/${r.file}` }); } catch (e) { alert("Lỗi tải video: " + e.message); } } setUploading(null); set((cur) => ({ session: sid, videos: [...cur.videos, ...added], // nếu chưa mở video nào -> mở video đầu tiên vừa thêm selIdx: cur.selIdx < 0 || !cur.videos.length ? cur.videos.length : cur.selIdx, })); if (added.length) track.step("upload_done", "prepare", { count: added.length }); }; const removeVideo = (i) => set((cur) => { const v = cur.videos[i]; const forms = { ...cur.forms }; if (v) delete forms[v.file]; const videos = cur.videos.filter((_, j) => j !== i); let selIdx = cur.selIdx; if (selIdx === i) selIdx = videos.length ? Math.min(i, videos.length - 1) : -1; else if (selIdx > i) selIdx -= 1; return { videos, forms, selIdx }; }); // Lưu chữ của video đang mở (editor gọi mỗi khi segs/mode đổi). const onEditorChange = (file) => (segs, mode, payload) => set((cur) => ({ forms: { ...cur.forms, [file]: { segs, mode, payload } } })); // Áp FORM chữ (segs + kiểu + vị trí) của video đang mở cho MỌI video còn lại — // để hàng loạt video quảng cáo cùng chiến dịch có chữ giống hệt nhau. const applyFormAll = () => { const curV = st.videos[st.selIdx]; if (!curV) return; const f = st.forms[curV.file]; if (!f || !(f.segs || []).length) { setMsg("⚠ Video này chưa có chữ để áp."); return; } let count = 0; set((cur) => { const forms = { ...cur.forms }; for (const v of cur.videos) { if (v.file === curV.file) continue; forms[v.file] = { segs: clone(f.segs), mode: f.mode, payload: clone(f.payload || []) }; count++; } return { forms }; }); setMsg(count > 0 ? `✅ Đã áp form chữ cho ${count} video khác (cùng kiểu, cùng vị trí).` : "Không có video nào khác để áp."); }; // Áp KIỂU CHỮ (phông/màu/nền/cỡ/vị trí) của video đang mở cho MỌI video còn lại, // NHƯNG GIỮ NGUYÊN lời riêng của từng video. Hợp khi mỗi video tự nhận dạng lời // khác nhau mà vẫn muốn nhìn giống nhau. Template = kiểu của đoạn đầu video đang mở. const applyStyleAll = () => { const curV = st.videos[st.selIdx]; if (!curV) return; const f = st.forms[curV.file]; const tmpl = f?.segs?.[0]?.style; if (!tmpl) { setMsg("⚠ Video này chưa có chữ để lấy kiểu."); return; } let count = 0; set((cur) => { const forms = { ...cur.forms }; for (const v of cur.videos) { if (v.file === curV.file) continue; const g = forms[v.file]; if (!g || !(g.segs || []).length) continue; // video chưa có chữ -> bỏ qua const segs = g.segs.map((s) => ({ ...s, style: clone(tmpl) })); forms[v.file] = { ...g, segs, payload: (g.payload || []).map((p) => ({ ...p, style: clone(tmpl) })) }; count++; } return { forms }; }); setMsg(count > 0 ? `✅ Đã áp KIỂU chữ cho ${count} video (giữ lời riêng từng video).` : "Không có video nào khác đang có chữ để áp kiểu."); if (count > 0) track.step("apply_form_all", "prepare", { count }); }; // Nhận dạng lời gốc trong video đang mở (ASR) -> trả lines cho editor đổ vào. const fetchLines = async () => { if (!selV) return []; setBusy(true); track.runClick({ phase: "asr" }); try { const { job_id } = await api.dubTranscribe({ session: st.session, video: selV.file }); const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal); const out = done.result?.output || (done.items || []).find((i) => i.status === "done")?.result; if (done.status === "error" || !out) throw new Error(done.items?.[0]?.error || done.error || "Không nhận dạng được lời"); return out.lines || []; } finally { setBusy(false); } }; // Burn chữ vào TẤT CẢ video có chữ -> mỗi video 1 kết quả (vào bao nhiêu ra bấy nhiêu). const burnAll = async () => { const targets = st.videos.filter((v) => (st.forms[v.file]?.payload || []).length); if (!targets.length) { alert("Chưa video nào có chữ. Mở video, thêm chữ (hoặc bấm “Áp form cho mọi video”)."); return; } setBusy(true); set({ outputs: [] }); track.runClick({ phase: "burn", videos: targets.length }); const outs = []; try { for (const v of targets) { const f = st.forms[v.file]; const { job_id } = await api.subtitleBurn({ session: st.session, video: v.file, mode: f.mode || "whole", segments: f.payload, username: user || "", }); const done = await pollJob(job_id, setJob, 1000, pollAbort.current.signal); const out = done.result?.output || (done.items || []).find((i) => i.status === "done")?.result; if (out) outs.push({ ...out, srcName: v.name, srcUrl: v.url }); } set({ outputs: outs, outStamp: Date.now() }); track.step("burn_batch_done", "review", { videos_in: targets.length, videos_out: outs.length }); if (!outs.length) alert("Không tạo được video nào (xem log)."); } catch (e) { alert("Lỗi: " + e.message); } finally { setBusy(false); } }; const startNew = () => { if (busy) return; if (!st.videos.length && !st.outputs.length) return; if (!window.confirm("Xoá video & kết quả hiện tại để làm mẻ khác?")) return; track.restart({ reason: "start_new" }); set({ videos: [], forms: {}, outputs: [], selIdx: -1, job: null, outStamp: 0 }); }; const nText = (file) => (st.forms[file]?.segs || []).length; const selV = st.selIdx >= 0 ? st.videos[st.selIdx] : null; const nWithText = st.videos.filter((v) => nText(v.file) > 0).length; const vidStyle = { display: "block", margin: "0 auto", maxWidth: "100%", maxHeight: 360, width: "auto", borderRadius: 10 }; return (
Workspace: Chèn chữ vào video

Đầu vào 1 hoặc nhiều video, tự nhận dạng lời trong video (hoặc gõ tay), chèn chữ (đủ kiểu phông/màu/nền/hiển thị), làm 1 video rồi áp form cho tất cả. Vào bao nhiêu ra bấy nhiêu.

{showNote && setShowNote(false)} />}
{/* 1. upload */}
1. Tải lên video cần chèn chữ
{uploading && (
⏳ Đang tải lên: {uploading.name} ({uploading.i}/{uploading.n})…
)} {st.videos.length > 0 && ( <>
{st.videos.length} video · {nWithText} video đã có chữ. Bấm để mở/chèn chữ:
{st.videos.map((v, i) => (
set({ selIdx: i })}>
{i === st.selIdx ? "✏ " : ""}{v.name}
{(v.size / 1048576).toFixed(1)} MB {nText(v.file) > 0 && · {nText(v.file)} đoạn chữ}
))}
)}
{/* 2. trình chèn chữ cho video đang chọn */} {selV ? ( {selV.name} {st.videos.length > 1 && ( <> )} } /> ) : st.videos.length > 0 ? (
Chọn một video ở trên để bắt đầu chèn chữ.
) : null} {msg && (
{msg}
)} {/* 3. tạo tất cả */} {st.videos.length > 0 && (
Tạo video

Burn chữ vào {nWithText} video có chữ. Mỗi video vào → một video ra. {st.videos.length > 1 && " Muốn mọi video giống nhau: chèn chữ 1 video rồi bấm “Áp form cho mọi video”."}

)} {/* kết quả */} {st.outputs.length > 0 && (
Kết quả ({st.outputs.length})
{st.outputs.map((o) => { const bust = st.outStamp ? `?v=${st.outStamp}` : ""; return (
{o.srcName} đã chèn chữ
{o.srcUrl && (
Trước (gốc)
)}
Sau (đã chèn chữ)
); })}
)}
); }