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