tnt-group / frontend /src /components /SubtitleEditor.jsx
titusgiap's picture
feat(sub): thêm 7 phông chữ phụ đề (Oswald, Nunito, Lato, Merriweather, Playfair, Kanit, Dancing Script)
9206420
Raw
History Blame Contribute Delete
37.3 kB
import { useEffect, useRef, useState } from "react";
import { api, fileUrl, pollJob } from "../api";
import { IcDownload, IcPlus, IcTrash } from "./Icons";
// Font NHÚNG kèm app (backend/app/assets/fonts, nạp qua /fonts) — mã nguồn mở,
// hỗ trợ đầy đủ tiếng Việt/en/fil. Trình duyệt (css/canvas) và libass (ass) dùng
// CHUNG file font nên bản xem trước khớp video render trên MỌI máy chủ, không cần
// font Microsoft. `ass` = ĐÚNG family name nội bộ của .ttf (khớp Fontname libass).
// Các file là face nét đậm sẵn, khai báo weight thường -> KHÔNG làm-đậm-giả.
// k = HỆ SỐ CO CỠ CHỮ CSS↔libass (đo thực nghiệm). libass vẽ `Fontsize=F` ra cỡ
// pixel NHỎ hơn CSS `font-size:F` (CSS đặt EM = F px). Đo render_frame(libass) vs
// canvas trình duyệt cùng ngưỡng (chuỗi H/Hg/Hàng) -> tỉ lệ ink_libass/ink_css NHẤT
// QUÁN <1% -> là hệ số co cố định mỗi font. Nhân k vào cỡ chữ CSS preview để KHỚP
// libass. (KHÔNG dùng mscale=hheaH/winH lý thuyết: libass build này vẽ nhỏ hơn nữa.)
const FONTS = [
{ label: "Be Vietnam Pro", css: "'Be Vietnam Pro', sans-serif", ass: "Be Vietnam Pro", k: 0.655 },
{ label: "Roboto", css: "'Roboto', sans-serif", ass: "Roboto", k: 0.829 },
{ label: "Open Sans", css: "'Open Sans', sans-serif", ass: "Open Sans", k: 0.692 },
{ label: "Montserrat", css: "'Montserrat', sans-serif", ass: "Montserrat", k: 0.641 },
{ label: "Arimo (giống Arial)", css: "'Arimo', sans-serif", ass: "Arimo", k: 0.694 },
{ label: "Tinos (serif, giống Times)", css: "'Tinos', serif", ass: "Tinos", k: 0.905 },
{ label: "Oswald (hẹp, cho tiêu đề)", css: "'Oswald', sans-serif", ass: "Oswald", k: 0.587 },
{ label: "Nunito (bo tròn)", css: "'Nunito', sans-serif", ass: "Nunito", k: 0.724 },
{ label: "Lato", css: "'Lato', sans-serif", ass: "Lato", k: 0.714 },
{ label: "Merriweather (serif)", css: "'Merriweather', serif", ass: "Merriweather", k: 0.577 },
{ label: "Playfair Display (serif sang)", css: "'Playfair Display', serif", ass: "Playfair Display", k: 0.705 },
{ label: "Kanit (đậm, cho phụ đề)", css: "'Kanit', sans-serif", ass: "Kanit", k: 0.630 },
{ label: "Dancing Script (viết tay)", css: "'Dancing Script', cursive", ass: "Dancing Script", k: 0.710 },
];
const BGS = [
["Viền đen mỏng", "outlineThin"], ["Viền đen đậm", "outlineThick"],
["Viền trắng", "outlineWhite"], ["Bóng đổ", "shadow"], ["Phát sáng", "glow"],
["Hộp đen mờ", "boxDark"], ["Hộp đen đậm", "boxSolid"], ["Hộp bo tròn", "boxRound"],
["Thanh nền cả dòng", "bar"], ["Hộp màu highlight", "boxColor"], ["Không nền", "none"],
];
const MODES = [
["whole", "Hiện cả câu"], ["progressive", "Hiện dần"], ["highlight", "Highlight từng chữ"],
];
const DEF = { font: "Be Vietnam Pro", fontSize: 54, color: "#ffffff", highlight: "#ffd400", bg: "outlineThin", x: 50, y: 86, w: 82 };
const cssOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).css;
const kOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).k;
const trunc = (t, n) => { t = (t || "").trim(); return t.length > n ? t.slice(0, n).trim() + "…" : t; };
const wordsFor = (text, start, end) => {
const words = (text || "").split(/\s+/).filter(Boolean);
const dur = Math.max(0.2, end - start);
const wt = words.map((w) => w.length + 1); const tot = wt.reduce((a, b) => a + b, 0) || 1;
let t = start; return words.map((w, i) => { const wd = dur * wt[i] / tot; const o = { w, start: t, end: t + wd }; t += wd; return o; });
};
export default function SubtitleEditor({ session, videoUrl, videoRel, srtRel, username, onClose }) {
const [segs, setSegs] = useState([]); // {start,end,text,words,locked,style}
const [sel, setSel] = useState(0);
const [mode, setMode] = useState("whole");
const [playing, setPlaying] = useState(false);
const [t, setT] = useState(0);
const [dur, setDur] = useState(0);
const [thumbs, setThumbs] = useState([]);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [editing, setEditing] = useState(false);
const [, force] = useState(0);
// Xem trước KHỚP 100%: khi TẠM DỪNG, server render đúng 1 khung bằng CHÍNH
// ffmpeg/libass của bản burn -> ảnh giống HỆT video xuất (không dùng bộ vẽ thứ 2
// như libass-wasm vốn khác phiên bản libass server nên lệch).
const [exact, setExact] = useState(true);
const [fit, setFit] = useState({ w: 0, h: 0 }); // cỡ hiển thị video = nội dung THẬT (bỏ letterbox)
const [previewImg, setPreviewImg] = useState(null); // ảnh khung chuẩn hiện đè video
const [previewLoading, setPreviewLoading] = useState(false);
const [previewErr, setPreviewErr] = useState(false); // server render khung lỗi
const [dragging, setDragging] = useState(false); // đang kéo/giãn ô -> hiện CSS nhanh
const [frame, setFrame] = useState(null); // ảnh xem chuẩn PHÓNG TO (modal)
const [frameBusy, setFrameBusy] = useState(false);
const videoRef = useRef();
const wrapRef = useRef();
const colRef = useRef(); // cột trái -> đo bề rộng khả dụng để fit video
const frameTimer = useRef(null); // debounce auto render khung chuẩn
const frameSeq = useRef(0); // chống race: chỉ nhận kết quả mới nhất
const nat = useRef({ w: 1080, h: 1920 });
const act = useRef(null);
// Huỷ vòng poll job burn phụ đề khi đóng editor / rời trang.
const pollAbort = useRef(new AbortController());
useEffect(() => () => pollAbort.current.abort(), []);
// Tự sinh phụ đề từ lời dịch (SRT). User bấm nút này khi muốn.
const autoSubtitle = async () => {
try {
const r = await api.subtitlePlan(session, srtRel);
const loaded = (r.segments || []).map((s) => ({
...s, locked: true, words: s.words || wordsFor(s.text, s.start, s.end),
style: JSON.parse(JSON.stringify(DEF)),
}));
setSegs((cur) => [...cur.filter((x) => !x.locked), ...loaded]);
} catch (e) { alert("Lỗi tạo phụ đề: " + e.message); }
};
// chụp ~8 thumbnail từ video (video same-origin nên canvas không bị taint)
useEffect(() => {
const v = document.createElement("video");
v.src = videoUrl; v.muted = true; v.crossOrigin = "anonymous";
const seek = (time) => new Promise((res) => { const h = () => { v.removeEventListener("seeked", h); res(); }; v.addEventListener("seeked", h); v.currentTime = time; });
v.addEventListener("loadedmetadata", async () => {
try {
const N = 8, cv = document.createElement("canvas"); cv.width = 64; cv.height = 114;
const ctx = cv.getContext("2d"); const out = [];
for (let i = 0; i < N; i++) { await seek(v.duration * (i + 0.5) / N); ctx.drawImage(v, 0, 0, cv.width, cv.height); out.push(cv.toDataURL("image/jpeg", 0.5)); }
setThumbs(out);
} catch { /* bỏ qua nếu chụp lỗi */ }
});
// eslint-disable-next-line
}, [videoUrl]);
// đồng bộ thời gian video -> state (rAF cho mượt)
useEffect(() => {
const v = videoRef.current; if (!v) return; let raf;
const loop = () => { setT(v.currentTime); raf = requestAnimationFrame(loop); };
const onMeta = () => { setDur(v.duration); nat.current = { w: v.videoWidth || 1080, h: v.videoHeight || 1920 }; };
const onPlay = () => { setPlaying(true); cancelAnimationFrame(raf); loop(); };
const onPause = () => { setPlaying(false); cancelAnimationFrame(raf); setT(v.currentTime); };
v.addEventListener("loadedmetadata", onMeta); v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); v.addEventListener("seeked", () => setT(v.currentTime));
return () => { cancelAnimationFrame(raf); v.removeEventListener("loadedmetadata", onMeta); v.removeEventListener("play", onPlay); v.removeEventListener("pause", onPause); };
// eslint-disable-next-line
}, []);
const patch = (i, p) => setSegs((cur) => cur.map((s, j) => (j === i ? { ...s, ...p } : s)));
const patchStyle = (p) => setSegs((cur) => cur.map((s, j) => (j === sel ? { ...s, style: { ...s.style, ...p } } : s)));
const cur = () => segs[sel];
// đoạn đang hiển thị theo thời gian (nhiều đoạn có thể cùng lúc)
const activeIdx = segs.map((s, i) => i).filter((i) => t >= segs[i].start && t <= segs[i].end);
useEffect(() => { if (!editing && activeIdx.length && !activeIdx.includes(sel)) setSel(activeIdx[activeIdx.length - 1]); });
const total = dur || 1;
const scale = () => { const v = videoRef.current; return v && v.videoHeight ? v.clientHeight / v.videoHeight : 1; };
// ---- lanes cho timeline ----
const lanes = (() => {
const order = segs.map((s, i) => i).sort((a, b) => segs[a].start - segs[b].start);
const ends = []; const lane = {};
order.forEach((i) => { const s = segs[i]; let L = ends.findIndex((e) => s.start >= e - 0.001); if (L < 0) { L = ends.length; ends.push(s.end); } else ends[L] = s.end; lane[i] = L; });
return { lane, n: Math.max(1, ends.length) };
})();
const seekTo = (time) => { const v = videoRef.current; if (v) { v.currentTime = Math.max(0, Math.min(total, time)); } setPlaying(false); if (v) v.pause(); };
// ---- kéo/giãn ô text trên video ----
const boxDown = (i) => (e) => {
if (editing || e.target.dataset.h) return;
setSel(i); const v = videoRef.current; if (v) v.pause();
act.current = { kind: "move", i, sx: e.clientX, sy: e.clientY, moved: false };
e.currentTarget.setPointerCapture(e.pointerId);
};
// dir = "w" (tay trái/phải -> đổi BỀ RỘNG vùng) | "h" (tay trên/dưới -> đổi CỠ CHỮ).
const handleDown = (i, dir) => (e) => {
if (editing) return; e.stopPropagation(); setSel(i); const v = videoRef.current; if (v) v.pause();
// Số dòng cố định trong lúc kéo -> ánh xạ cỡ chữ ổn định, không nhảy.
const nLines = measureBreaks(segs[i]).length + 1;
act.current = { kind: "resize", dir, i, sx: e.clientX, sy: e.clientY, nLines, moved: false };
e.currentTarget.parentElement.setPointerCapture(e.pointerId);
};
const boxMove = (e) => {
const a = act.current; if (!a) return;
if (!a.moved && Math.hypot(e.clientX - a.sx, (e.clientY || 0) - (a.sy || 0)) < 3) return;
if (!a.moved) setDragging(true); // bắt đầu kéo thật -> hiện CSS nhanh cho mượt
a.moved = true; const r = wrapRef.current.getBoundingClientRect(); const s = segs[a.i].style;
if (a.kind === "move") { const hw = s.w / 2; patch(a.i, { style: { ...s, x: Math.max(hw + 1, Math.min(99 - hw, (e.clientX - r.left) / r.width * 100)), y: Math.max(6, Math.min(94, (e.clientY - r.top) / r.height * 100)) } }); }
else if (a.dir === "h") {
// Kéo cạnh TRÊN/DƯỚI -> đổi cỡ chữ (giãn to/nhỏ). Khoảng cách từ TÂM ô tới con
// trỏ (px hiển thị) ~ nửa chiều cao khối chữ = nLines*fontSize*scale/2 -> con
// trỏ luôn bám cạnh: fontSize = 2*dist / (nLines*scale).
const cyPx = r.top + (s.y / 100) * r.height;
const dist = Math.abs(e.clientY - cyPx);
const sc = scale() || 1;
const font = Math.round((2 * dist) / (Math.max(1, a.nLines) * sc));
patch(a.i, { style: { ...s, fontSize: Math.max(24, Math.min(120, font)) } });
} else {
// Kéo cạnh TRÁI/PHẢI -> đổi bề rộng vùng (đối xứng quanh tâm x).
const dx = Math.abs((e.clientX - r.left) / r.width * 100 - s.x);
patch(a.i, { style: { ...s, w: Math.max(24, Math.min(96, dx * 2)) } });
}
};
const boxUp = (i) => (e) => {
const a = act.current; if (a && !a.moved && !segs[i].locked) startEdit(i);
act.current = null; setDragging(false); // thả -> render lại khung chuẩn
};
const startEdit = (i) => { setSel(i); setEditing(true); const v = videoRef.current; if (v) v.pause(); setTimeout(() => { const el = document.getElementById("subedit-" + i); if (el) { el.focus(); const rg = document.createRange(); rg.selectNodeContents(el); const se = window.getSelection(); se.removeAllRanges(); se.addRange(rg); } }, 0); };
const endEdit = (i) => (e) => { setEditing(false); const txt = (e.currentTarget.textContent || "").trim() || "Text"; setSegs((cur) => cur.map((s, j) => (j === i ? { ...s, text: txt, words: wordsFor(txt, s.start, s.end) } : s))); };
const addText = () => {
const start = Math.max(0, Math.min(t, total - 1)); const end = Math.min(total, start + 2.5);
const style = JSON.parse(JSON.stringify(DEF)); style.y = 50;
const seg = { start, end, text: "Text mới", locked: false, style, words: wordsFor("Text mới", start, end) };
setSegs((cur) => { const n = [...cur, seg]; setSel(n.length - 1); return n; });
seekTo((start + end) / 2); setTimeout(() => startEdit(segs.length), 30);
};
const delText = () => { const s = cur(); if (!s || s.locked) return; setSegs((c) => c.filter((_, j) => j !== sel)); setSel(0); };
const applyAll = () => { const s = cur().style; setSegs((c) => c.map((sg) => ({ ...sg, style: { ...JSON.parse(JSON.stringify(s)), x: sg.style.x, y: sg.style.y } }))); };
// Ngược với applyAll: chỉ chép VỊ TRÍ + bề rộng vùng của đoạn đang chọn sang các
// đoạn còn lại, giữ nguyên font/màu/nền riêng của từng đoạn.
const applyPosAll = () => {
const s = cur(); if (!s) return;
const { x, y, w } = s.style;
setSegs((c) => c.map((sg, j) => (j === sel ? sg : { ...sg, style: { ...sg.style, x, y, w } })));
};
// Nút hành động tức thì (về giữa‑đáy / áp style / áp vị trí) chạy xong không đổi
// gì trên nút -> user không rõ đã bấm chưa. doFlash loé NHẸ nền cam ~0.6s để báo
// "đã nhấn". flash = key nút vừa bấm.
const [flash, setFlash] = useState("");
const flashTimer = useRef(null);
useEffect(() => () => clearTimeout(flashTimer.current), []);
const doFlash = (key, fn) => {
if (fn) fn();
setFlash(key);
clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(""), 600);
};
const flashStyle = (key) => (flash === key
? { background: "var(--orange-soft)", borderColor: "var(--orange)", color: "var(--orange-700)" }
: undefined);
// Đo chữ thật (canvas, đúng font/cỡ ở độ phân giải GỐC của video) -> trả về
// index các từ BẮT ĐẦU dòng mới, để bản burn xuống dòng GIỐNG HỆT preview.
const measureBreaks = (seg) => {
const v = videoRef.current; const natW = (v && v.videoWidth) || 1080;
const st = seg.style; const boxPx = Math.max(80, (st.w / 100) * natW * 0.97);
const words = (seg.text || "").split(/\s+/).filter(Boolean);
const cv = measureBreaks._cv || (measureBreaks._cv = document.createElement("canvas"));
const ctx = cv.getContext("2d");
// Font nhúng đã là nét đậm sẵn + libass render Bold=0 -> ĐO weight thường
// (không "bold") để độ rộng đo khớp đúng glyph libass dùng. Nhân k để đo ở
// ĐÚNG cỡ libass vẽ ra -> điểm ngắt dòng khớp cách libass lấp đầy vùng.
ctx.font = `${st.fontSize * kOf(st.font)}px ${cssOf(st.font)}`;
const sp = ctx.measureText(" ").width;
const breaks = []; let lineW = 0;
words.forEach((w, i) => {
const ww = ctx.measureText(w).width;
if (i > 0 && lineW + sp + ww > boxPx) { breaks.push(i); lineW = ww; }
else { lineW = i === 0 ? ww : lineW + sp + ww; }
});
return breaks;
};
// Payload đoạn phụ đề (kèm breaks đo thật) — DÙNG CHUNG cho burn, ASS libass và
// ảnh xem trước -> mọi nơi ăn cùng dữ liệu.
const segPayload = () => segs.map((s) => ({
start: s.start, end: s.end, text: s.text, style: s.style, breaks: measureBreaks(s),
}));
const burn = async () => {
if (!segs.length) return alert("Chưa có đoạn text nào. Bấm 'Tự tạo phụ đề' hoặc 'Thêm text'.");
setBusy(true); setResult(null);
try {
// Đợi font nhúng nạp xong -> measureBreaks đo bằng ĐÚNG font (không fallback).
try { await document.fonts.ready; } catch { /* trình duyệt cũ: bỏ qua */ }
const { job_id } = await api.subtitleBurn({
session, video: videoRel, mode, username: username || "",
segments: segPayload(),
});
const done = await pollJob(job_id, () => {}, 1000, pollAbort.current.signal);
const out = done.result?.output || (done.items || []).find((i) => i.status === "done")?.result;
// _v = mốc render -> cache-bust <video> khi burn LẠI cùng tên file (không thì
// trình duyệt giữ khung đen/bytes cũ; tải về vẫn đúng vì là request mới).
if (out) setResult({ ...out, _v: Date.now() }); else alert("Lỗi tạo video phụ đề (xem log).");
} catch (e) { alert("Lỗi: " + e.message); } finally { setBusy(false); }
};
// "Xem trước chuẩn": server render 1 khung tại thời điểm hiện tại = CHÍNH bản
// burn (cùng ASS+libass+font) -> ảnh khớp 100% để đối chiếu tuyệt đối.
const showFrame = async () => {
if (!segs.length) return alert("Chưa có đoạn text nào.");
setFrameBusy(true);
try {
try { await document.fonts.ready; } catch { /* bỏ qua */ }
const r = await api.subtitlePreviewFrame(session, videoRel, segPayload(), mode, t);
setFrame(fileUrl(r.image_url) + "?v=" + r.v);
} catch (e) { alert("Lỗi xem trước: " + e.message); }
finally { setFrameBusy(false); }
};
// Cỡ hiển thị video = nội dung THẬT (bỏ letterbox ngang) -> overlay (libass & CSS)
// ánh xạ đúng toạ độ video nên khớp bản burn. Trước đây overlay tính theo khung
// thẻ <video> (kèm viền đen) -> lệch ngang & sai bề rộng hộp.
useEffect(() => {
const compute = () => {
const nw = nat.current.w || 1080, nh = nat.current.h || 1920;
const avail = (colRef.current && colRef.current.clientWidth) || 300;
const h = Math.min(460, avail * nh / nw);
setFit({ w: Math.round(h * nw / nh), h: Math.round(h) });
};
compute();
window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute);
}, [dur]);
// idle = lúc NÊN render ảnh chuẩn (bật exact + tạm dừng + không gõ/kéo).
// showExact = ảnh chuẩn ĐANG hiện trên màn (đã render xong). CHỈ khi đó mới ẩn
// lớp CSS. Còn lại (đang phát / đang chờ ảnh / lỗi / gõ / kéo) -> LUÔN hiện CSS
// làm nền => KHÔNG bao giờ bị trống chữ. Ảnh chuẩn về thì đè lên CSS.
const idle = exact && !playing && !editing && !dragging;
const showExact = idle && !!previewImg;
useEffect(() => {
if (!idle) return;
const v = videoRef.current;
if (!v || !v.videoWidth || !segs.length) { setPreviewImg(null); return; }
clearTimeout(frameTimer.current);
const seq = ++frameSeq.current;
setPreviewImg(null); // xoá ảnh cũ -> hiện khung video thật trong lúc chờ
setPreviewLoading(true); setPreviewErr(false);
frameTimer.current = setTimeout(async () => {
try { await document.fonts.ready; } catch { /* */ }
try {
const r = await api.subtitlePreviewFrame(session, videoRel, segPayload(), mode, t);
if (seq === frameSeq.current) setPreviewImg(fileUrl(r.image_url) + "?v=" + r.v);
} catch (e) { if (seq === frameSeq.current) setPreviewErr(true); }
finally { if (seq === frameSeq.current) setPreviewLoading(false); }
}, 200);
return () => clearTimeout(frameTimer.current);
// eslint-disable-next-line
}, [idle, segs, mode, t, dur]);
// ---- render 1 ô text ----
const boxStyle = (s, on) => {
const st = s.style;
const sc = scale();
const fpx = st.fontSize * sc; // = libass Fontsize ở display px (mốc hình học hộp/viền, KHÔNG co K)
const px = fpx * kOf(st.font); // cỡ chữ CSS đã co K -> nét chữ KHỚP libass
// lineHeight = 1/K: khoảng cách dòng CSS = fpx = advance libass (đo thực nghiệm
// libass giãn dòng đúng 1.0*fsize). px = fpx*K nên lineHeight*px = fpx -> khớp.
const base = { position: "absolute", left: st.x + "%", top: st.y + "%", transform: "translate(-50%,-50%)", width: st.w + "%", textAlign: "center", lineHeight: 1 / kOf(st.font), fontWeight: 400, fontFamily: cssOf(st.font), fontSize: px + "px", color: st.color, boxSizing: "border-box", padding: "0 1.5%", cursor: editing ? "text" : "move", outline: on ? "1.5px dashed rgba(255,255,255,.85)" : "none", zIndex: on ? 5 : 2, wordWrap: "break-word" };
const b = st.bg; const ol = (p, c) => `${-p}px ${-p}px 0 ${c},${p}px ${-p}px 0 ${c},${-p}px ${p}px 0 ${c},${p}px ${p}px 0 ${c},0 ${p * 1.3}px 3px ${c}`;
// Viền: libass Outline (ScaledBorderAndShadow, PlayResY=videoH) = px ở video, quy
// display = value*sc. outlineThin=2, outlineThick=5, outlineWhite=3 (khớp _style_line).
if (b === "outlineThin") base.textShadow = ol(Math.max(0.6, 2 * sc), "#000");
else if (b === "outlineThick") base.textShadow = ol(Math.max(0.8, 5 * sc), "#000");
else if (b === "outlineWhite") base.textShadow = ol(Math.max(0.6, 3 * sc), "#fff");
else if (b === "shadow") base.textShadow = "0 3px 7px rgba(0,0,0,.95)";
else if (b === "glow") base.textShadow = `0 0 9px ${st.highlight},0 0 4px ${st.highlight}`;
// Hộp nền: đệm DỌC quy theo fpx (KHÔNG co K) -> KHỚP _bg_event backend
// (pad_y=0.14/0.23*fsize). Đệm NGANG = 1.5% bề rộng hộp (CSS % = theo width) ->
// nội dung = 97% vùng, đúng bằng measureBreaks (*0.97) -> ngắt dòng live khớp burn.
// Bo góc quy theo fpx (radius={..}*fsize trong _bg_event).
else if (b === "boxDark") { base.background = "rgba(0,0,0,.5)"; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.20 * fpx + "px"; }
else if (b === "boxSolid") { base.background = "#000"; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.13 * fpx + "px"; }
else if (b === "boxRound") { base.background = "rgba(0,0,0,.62)"; base.padding = `${0.23 * fpx}px 1.5%`; base.borderRadius = 0.73 * fpx + "px"; }
else if (b === "bar") { base.background = "rgba(0,0,0,.5)"; base.padding = `${0.23 * fpx}px 1.5%`; }
else if (b === "boxColor") { base.background = st.highlight; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.20 * fpx + "px"; }
// Ảnh chuẩn đang đè -> ẩn chữ/nền/viền CSS (chỉ giữ vùng bắt sự kiện + khung
// chọn). Lúc khác luôn hiện CSS làm nền -> không bao giờ trống chữ.
if (showExact && !(editing && on)) {
base.color = "transparent"; base.textShadow = "none";
base.background = "none"; base.padding = 0;
}
return base;
};
const boxContent = (s) => {
if (editing && segs[sel] === s) return null; // ô đang gõ: contentEditable tự giữ text
if (showExact) return null; // ảnh chuẩn đang đè -> CSS không vẽ (tránh 2 lớp)
if (mode === "whole") return s.text;
if (mode === "progressive") return s.words.filter((w) => t >= w.start).map((w) => w.w).join(" ") || " ";
return s.words.map((w, i) => <span key={i} style={{ color: t >= w.start ? s.style.highlight : s.style.color }}>{w.w}{i < s.words.length - 1 ? " " : ""}</span>);
};
const fmt = (x) => { x = Math.max(0, x || 0); const m = Math.floor(x / 60), ss = Math.floor(x % 60); return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; };
return (
<div className="card" style={{ borderColor: "var(--orange)" }}>
<div className="flex between center">
<div className="panel-title" style={{ margin: 0 }}>Chèn phụ đề (hard sub)</div>
<button className="btn sm ghost" onClick={onClose}>Đóng</button>
</div>
<div className="grid-2" style={{ gridTemplateColumns: "300px 1fr", gap: 16, marginTop: 10 }}>
{/* preview + timeline */}
<div ref={colRef}>
<div ref={wrapRef} style={{ position: "relative", width: fit.w ? fit.w + "px" : "100%", height: fit.h ? fit.h + "px" : "auto", margin: "0 auto", background: "#000", borderRadius: 12, overflow: "hidden" }}>
<video ref={videoRef} src={videoUrl} onClick={() => { const v = videoRef.current; v.paused ? v.play() : v.pause(); }}
style={{ display: "block", width: "100%", height: "100%", objectFit: "contain" }} />
{/* Ảnh khung CHUẨN (server ffmpeg = đúng bản burn) đè lên video khi rảnh. */}
{idle && previewImg && (
<img src={previewImg} alt="" draggable={false}
onError={() => { setPreviewImg(null); setPreviewErr(true); }}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", zIndex: 1, pointerEvents: "none" }} />
)}
{idle && previewLoading && (
<div style={{ position: "absolute", right: 6, top: 6, zIndex: 6, background: "rgba(0,0,0,.55)", color: "#fff", fontSize: 11, padding: "2px 8px", borderRadius: 10, pointerEvents: "none" }}>đang dựng khung chuẩn…</div>
)}
{idle && previewErr && !previewLoading && (
<div style={{ position: "absolute", right: 6, top: 6, zIndex: 6, background: "rgba(200,40,40,.9)", color: "#fff", fontSize: 11, padding: "2px 8px", borderRadius: 10, pointerEvents: "none" }}>Lỗi dựng khung chuẩn (server)</div>
)}
{segs.map((s, i) => (t >= s.start && t <= s.end) ? (
<div key={i} style={boxStyle(s, i === sel)} onPointerDown={boxDown(i)} onPointerMove={boxMove} onPointerUp={boxUp(i)}>
<div id={"subedit-" + i} contentEditable={editing && i === sel} suppressContentEditableWarning
onBlur={endEdit(i)} style={{ outline: "none" }}>
{boxContent(s)}
</div>
{i === sel && !editing && (() => {
const hst = { position: "absolute", background: "var(--orange)", border: "1px solid #fff", borderRadius: 2, zIndex: 6 };
return (<>
<span data-h="1" onPointerDown={handleDown(i, "w")} title="Kéo đổi bề rộng vùng"
style={{ ...hst, left: -4, top: "50%", transform: "translateY(-50%)", width: 6, height: 13, cursor: "ew-resize" }} />
<span data-h="1" onPointerDown={handleDown(i, "w")} title="Kéo đổi bề rộng vùng"
style={{ ...hst, right: -4, top: "50%", transform: "translateY(-50%)", width: 6, height: 13, cursor: "ew-resize" }} />
<span data-h="1" onPointerDown={handleDown(i, "h")} title="Kéo giãn to/nhỏ cỡ chữ"
style={{ ...hst, top: -4, left: "50%", transform: "translateX(-50%)", width: 13, height: 6, cursor: "ns-resize" }} />
<span data-h="1" onPointerDown={handleDown(i, "h")} title="Kéo giãn to/nhỏ cỡ chữ"
style={{ ...hst, bottom: -4, left: "50%", transform: "translateX(-50%)", width: 13, height: 6, cursor: "ns-resize" }} />
</>);
})()}
</div>
) : null)}
</div>
<div className="flex center gap8 mt8">
<button className="btn sm" onClick={() => { const v = videoRef.current; v.paused ? v.play() : v.pause(); }}>{playing ? "❚❚" : "►"}</button>
<span className="small muted">{fmt(t)} / {fmt(total)}</span>
<button className="btn sm" style={{ marginLeft: "auto" }} onClick={addText}><IcPlus size={13} /> Thêm text</button>
</div>
<div className="flex center gap8 mt8" style={{ flexWrap: "wrap" }}>
<label className="small" style={{ display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer" }}
title="Bật: khi DỪNG, hiện đúng khung video sẽ xuất (server render). Tắt: chỉ xem nhanh bằng CSS (gần đúng).">
<input type="checkbox" checked={exact} onChange={(e) => setExact(e.target.checked)} />
Xem trước khớp 100% (dừng để thấy)
</label>
<button className="btn sm ghost" onClick={showFrame} disabled={frameBusy || !segs.length}
title="Mở ảnh khung chuẩn phóng to (đúng video sẽ xuất)">
{frameBusy ? "Đang render…" : "🔍 Phóng to ảnh chuẩn"}
</button>
</div>
{exact && (
<p className="small muted" style={{ margin: "6px 0 0" }}>
{playing ? "▶ Đang phát: xem nhanh (gần đúng). Bấm DỪNG (⏸) để xem phụ đề CHUẨN."
: showExact ? "✅ Phụ đề đang hiện = ĐÚNG y video sẽ xuất."
: previewErr ? "⚠ Chưa dựng được khung chuẩn (đang hiện tạm bản gần đúng)."
: "⏳ Đang dựng khung chuẩn… (đang hiện tạm bản gần đúng)"}
</p>
)}
{/* timeline */}
<div style={{ position: "relative", marginTop: 10 }} >
<div style={{ display: "flex", height: 30, borderRadius: 6, overflow: "hidden", border: "0.5px solid var(--line)", cursor: "pointer" }}
onPointerDown={(e) => { const r = e.currentTarget.getBoundingClientRect(); seekTo((e.clientX - r.left) / r.width * total); }}>
{(thumbs.length ? thumbs : Array.from({ length: 8 })).map((th, i) => (
<div key={i} style={{ flex: 1, background: th ? `center/cover url(${th})` : "#334", borderRight: i < 7 ? "1px solid rgba(255,255,255,.15)" : "none" }} />
))}
</div>
<div style={{ position: "relative", height: lanes.n * 22 + (lanes.n - 1) * 3, marginTop: 4 }}>
{segs.map((s, i) => (
<div key={i} onClick={() => { seekTo((s.start + s.end) / 2); setSel(i); }}
title={s.text}
style={{ position: "absolute", left: (s.start / total * 100) + "%", width: Math.max(6, (s.end - s.start) / total * 100) + "%", top: lanes.lane[i] * 25, height: 22, borderRadius: 5, fontSize: 10, lineHeight: "20px", padding: "0 6px", boxSizing: "border-box", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", cursor: "pointer", border: i === sel ? "1.5px solid var(--orange)" : "1px solid var(--line)", borderStyle: s.locked ? "solid" : "dashed", background: i === sel ? "var(--orange-soft)" : "var(--white)", color: i === sel ? "var(--orange-700)" : "var(--muted)" }}>
{s.locked ? "🔒 " : ""}{trunc(s.text, 16)}
</div>
))}
</div>
<div style={{ position: "absolute", top: 0, width: 2, height: 34 + lanes.n * 22 + (lanes.n - 1) * 3, background: "#ff3b30", left: `calc(${t / total * 100}% - 1px)`, pointerEvents: "none" }} />
</div>
</div>
{/* controls */}
<div className="stack" style={{ gap: 12 }}>
{segs.filter((s) => s.locked).length === 0 && (
<button className="btn primary" onClick={autoSubtitle}>Tự tạo phụ đề từ lời dịch</button>
)}
<div className="flex gap8 center">
<button className="btn sm ghost" onClick={delText} style={{ display: cur() && !cur().locked ? "inline-flex" : "none" }}><IcTrash size={13} /> Xóa đoạn</button>
<span className="small muted">{cur() && cur().locked ? "🔒 text lồng tiếng — không xóa được" : ""}</span>
</div>
<div>
<span className="field-label">Kiểu hiển thị (chung cả video)</span>
<div className="seg-group">{MODES.map(([id, lb]) => <button key={id} className={mode === id ? "active" : ""} onClick={() => setMode(id)}>{lb}</button>)}</div>
</div>
{cur() && (
<>
<div className="row wrap" style={{ gap: 10 }}>
<div style={{ flex: 1 }}><span className="field-label">Phông</span>
<select value={cur().style.font} onChange={(e) => patchStyle({ font: e.target.value })} style={{ width: "100%" }}>
{FONTS.map((f) => <option key={f.ass} value={f.ass}>{f.label}</option>)}</select></div>
<div style={{ flex: 1 }}><span className="field-label">Kiểu nền</span>
<select value={cur().style.bg} onChange={(e) => patchStyle({ bg: e.target.value })} style={{ width: "100%" }}>
{BGS.map(([lb, id]) => <option key={id} value={id}>{lb}</option>)}</select></div>
</div>
<div className="row wrap" style={{ gap: 10 }}>
<label className="field-label" style={{ flex: 1 }}>Màu chữ
<input type="color" value={cur().style.color} onChange={(e) => patchStyle({ color: e.target.value })}
style={{ width: "100%", height: 34, marginTop: 4, border: "1px solid var(--line)", borderRadius: 6, padding: 2, cursor: "pointer", background: "var(--white)" }} /></label>
<label className="field-label" style={{ flex: 1 }}>Màu highlight
<input type="color" value={cur().style.highlight} onChange={(e) => patchStyle({ highlight: e.target.value })}
style={{ width: "100%", height: 34, marginTop: 4, border: "1px solid var(--line)", borderRadius: 6, padding: 2, cursor: "pointer", background: "var(--white)" }} /></label>
</div>
<div><span className="field-label">Cỡ chữ: {cur().style.fontSize}</span>
<input className="slider" type="range" min="24" max="120" value={cur().style.fontSize} onChange={(e) => patchStyle({ fontSize: +e.target.value })} /></div>
<div className="flex gap8" style={{ flexWrap: "wrap" }}>
<button className="btn sm" style={flashStyle("center")} onClick={() => doFlash("center", () => patchStyle({ x: 50, y: 86 }))}>Về giữa‑đáy</button>
<button className="btn sm" style={flashStyle("all")} onClick={() => doFlash("all", applyAll)}>Áp style cho mọi đoạn</button>
<button className="btn sm" style={flashStyle("pos")} onClick={() => doFlash("pos", applyPosAll)} disabled={segs.length < 2}
title="Chép vị trí (và bề rộng vùng) của đoạn đang chọn sang tất cả đoạn khác">
Áp vị trí cho mọi đoạn
</button>
</div>
</>
)}
<button className="btn primary block mt8" onClick={burn} disabled={busy || !segs.length}>
{busy ? "Đang tạo video có phụ đề…" : "Tạo video có phụ đề"}
</button>
<p className="small muted">Đoạn 🔒 là phụ đề từ lời dịch (không xóa). Bấm vào chữ trên video để sửa; kéo để đặt vị trí; kéo tay <b>trái/phải</b> để đổi bề rộng vùng, kéo tay <b>trên/dưới</b> để giãn to/nhỏ cỡ chữ. Đặt xong 1 đoạn, bấm “Áp vị trí cho mọi đoạn” để các đoạn kia về cùng chỗ.</p>
</div>
</div>
{result && (
<div className="mt16" style={{ borderTop: "1px solid var(--line)", paddingTop: 14 }}>
<div className="panel-title" style={{ marginBottom: 10 }}>Video đã chèn phụ đề</div>
<div className="flex" style={{ gap: 14, flexWrap: "wrap", alignItems: "flex-start" }}>
<video key={result._v} src={fileUrl(result.video_url) + (result._v ? "?v=" + result._v : "")} controls style={{ maxWidth: 260, maxHeight: 420, borderRadius: 12, background: "#000" }} />
<a className="btn primary" href={fileUrl(result.video_url)} download><IcDownload size={15} /> Tải video có phụ đề</a>
</div>
</div>
)}
{frame && (
<div onClick={() => setFrame(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.72)", zIndex: 60, display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
<div onClick={(e) => e.stopPropagation()} style={{ background: "var(--white)", borderRadius: 12, padding: 12, maxWidth: "92vw", maxHeight: "92vh", overflow: "auto" }}>
<div className="flex between center" style={{ marginBottom: 8, gap: 12 }}>
<div className="panel-title" style={{ margin: 0 }}>Xem trước chuẩn — đúng như video sẽ xuất</div>
<button className="btn sm ghost" onClick={() => setFrame(null)}>Đóng</button>
</div>
<img src={frame} alt="preview" style={{ display: "block", maxWidth: "100%", maxHeight: "78vh", borderRadius: 8, background: "#000" }} />
</div>
</div>
)}
</div>
);
}