website_content / frontend /src /pages /TextVideo.jsx
titusgiap's picture
Claude Opus 5
feat(báo cáo): dựng lại tab thời lượng theo mẫu, CSV đọc được bằng tiếng Việt
4ffa05a
Raw History Blame Contribute Delete
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>
);
}