Spaces:
Running
Running
Download frontend/src/pages/TextVideo.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 16.8 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/pages/TextVideo.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/pages/TextVideo.jsx
-
curl -L -o TextVideo.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/pages/TextVideo.jsx
16.8 kB
| 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 ( | |
| <div> | |
| <div className="h1"><IcText size={22} /> Workspace: <span className="accent">Chèn chữ vào video</span></div> | |
| <p className="subtitle">Đầ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.</p> | |
| {showNote && <CacheNote onClose={() => setShowNote(false)} />} | |
| <div className="grid-2"> | |
| <div className="stack"> | |
| {/* 1. upload */} | |
| <div className="card"> | |
| <div className="panel-title">1. Tải lên video cần chèn chữ</div> | |
| <FileDrop onFiles={addVideos} hint="MP4, MOV, AVI, MKV · có thể chọn nhiều video" /> | |
| {uploading && ( | |
| <div className="small mt8" style={{ color: "var(--orange-700)" }}> | |
| ⏳ Đang tải lên: <b>{uploading.name}</b> ({uploading.i}/{uploading.n})… | |
| </div> | |
| )} | |
| {st.videos.length > 0 && ( | |
| <> | |
| <div className="small muted mt12" style={{ marginBottom: 6 }}> | |
| {st.videos.length} video · {nWithText} video đã có chữ. Bấm để mở/chèn chữ: | |
| </div> | |
| <div className="filelist"> | |
| {st.videos.map((v, i) => ( | |
| <div className="filerow" key={v.file} | |
| style={{ cursor: "pointer", borderColor: i === st.selIdx ? "var(--orange)" : undefined, background: i === st.selIdx ? "var(--orange-soft)" : undefined }} | |
| onClick={() => set({ selIdx: i })}> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div className="fn">{i === st.selIdx ? "✏ " : ""}{v.name}</div> | |
| <div className="meta"> | |
| {(v.size / 1048576).toFixed(1)} MB | |
| {nText(v.file) > 0 && <span style={{ color: "var(--green)", fontWeight: 600 }}> · {nText(v.file)} đoạn chữ</span>} | |
| </div> | |
| </div> | |
| <button className="x" onClick={(e) => { e.stopPropagation(); removeVideo(i); }} title="Xoá">✕</button> | |
| </div> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| <ProcessingOverlay busy={busy} job={job} title="Đang chèn chữ vào video…" /> | |
| {/* 2. trình chèn chữ cho video đang chọn */} | |
| {selV ? ( | |
| <SubtitleEditor | |
| key={selV.file} | |
| session={st.session} | |
| videoUrl={fileUrl(selV.url)} | |
| videoRel={selV.file} | |
| username={user} | |
| initialSegs={st.forms[selV.file]?.segs || null} | |
| initialMode={st.forms[selV.file]?.mode || null} | |
| onChange={onEditorChange(selV.file)} | |
| onFetchLines={fetchLines} | |
| onClose={null} | |
| headerExtra={ | |
| <> | |
| <span className="small muted" style={{ maxWidth: 200 }}>{selV.name}</span> | |
| {st.videos.length > 1 && ( | |
| <> | |
| <button className="btn sm" onClick={applyFormAll} | |
| title="Áp CẢ lời + kiểu + vị trí của video này cho MỌI video còn lại (khi mọi video cùng nội dung)"> | |
| ⇊ Áp cả lời + kiểu | |
| </button> | |
| <button className="btn sm" onClick={applyStyleAll} | |
| title="Chỉ áp KIỂU chữ (phông/màu/nền/vị trí), GIỮ lời riêng của từng video (khi mỗi video tự nhận dạng lời khác nhau)"> | |
| 🎨 Áp kiểu (giữ lời riêng) | |
| </button> | |
| </> | |
| )} | |
| </> | |
| } | |
| /> | |
| ) : st.videos.length > 0 ? ( | |
| <div className="card"><div className="small muted">Chọn một video ở trên để bắt đầu chèn chữ.</div></div> | |
| ) : null} | |
| {msg && ( | |
| <div className="small" style={{ | |
| padding: "8px 12px", borderRadius: 8, | |
| background: "var(--green-soft)", color: "var(--green)", fontWeight: 600, | |
| }}>{msg}</div> | |
| )} | |
| {/* 3. tạo tất cả */} | |
| {st.videos.length > 0 && ( | |
| <div className="card"> | |
| <div className="panel-title">Tạo video</div> | |
| <p className="small muted" style={{ marginTop: -6 }}> | |
| Burn chữ vào <b>{nWithText}</b> 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”."} | |
| </p> | |
| <button className="btn primary block mt8" onClick={burnAll} disabled={busy || !nWithText}> | |
| {busy ? "Đang tạo video…" : <><IcVideo size={16} /> Tạo tất cả video có chữ ({nWithText})</>} | |
| </button> | |
| </div> | |
| )} | |
| {/* kết quả */} | |
| {st.outputs.length > 0 && ( | |
| <div className="card"> | |
| <div className="flex between center" style={{ marginBottom: 10, gap: 8, flexWrap: "wrap" }}> | |
| <div className="panel-title" style={{ margin: 0 }}>Kết quả ({st.outputs.length})</div> | |
| <button className="btn sm primary" onClick={startNew} disabled={busy} | |
| title="Xoá video & kết quả hiện tại để làm mẻ khác (không cần tải lại trang)"> | |
| + Làm mẻ mới | |
| </button> | |
| </div> | |
| <div style={{ marginBottom: 10 }}> | |
| <ThumbsFeedback key={st.outStamp} label="Chữ chèn vào video có đúng ý không?" /> | |
| </div> | |
| <div className="stack"> | |
| {st.outputs.map((o) => { | |
| const bust = st.outStamp ? `?v=${st.outStamp}` : ""; | |
| return ( | |
| <div key={o.video_url} style={{ border: "1px solid var(--line)", borderRadius: 12, padding: 14 }}> | |
| <div className="flex between center" style={{ marginBottom: 10 }}> | |
| <b className="small">{o.srcName}</b> | |
| <span className="badge done"><span className="dot" /> đã chèn chữ</span> | |
| </div> | |
| <div className="row wrap"> | |
| {o.srcUrl && ( | |
| <div style={{ flex: 1, minWidth: 220 }}> | |
| <div className="small muted" style={{ marginBottom: 6 }}>Trước (gốc)</div> | |
| <video src={fileUrl(o.srcUrl)} controls style={vidStyle} /> | |
| </div> | |
| )} | |
| <div style={{ flex: 1, minWidth: 220 }}> | |
| <div className="small muted" style={{ marginBottom: 6 }}>Sau (đã chèn chữ)</div> | |
| <video key={fileUrl(o.video_url) + bust} src={fileUrl(o.video_url) + bust} controls style={vidStyle} /> | |
| </div> | |
| </div> | |
| <div className="flex gap8 mt12" style={{ flexWrap: "wrap" }}> | |
| <a className="btn sm" href={fileUrl(o.video_url)} download | |
| onClick={() => track.output("mp4", { count: 1 })}><IcDownload size={14} /> Tải video MP4</a> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <ProcessRail steps={STEPS} tip="Chèn chữ dùng chung bộ công cụ với “Dịch / đổi giọng”: đủ phông tiếng Việt, 11 kiểu nền, 3 kiểu hiển thị (cả câu / hiện dần / highlight). Kéo tay trái–phải đổi bề rộng, trên–dưới đổi cỡ chữ. Xem trước khớp 100% khi tạm dừng." /> | |
| </div> | |
| </div> | |
| ); | |
| } | |