Spaces:
Running
Running
Download frontend/src/components/SubtitleEditor.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 40.1 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/SubtitleEditor.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/components/SubtitleEditor.jsx
-
curl -L -o SubtitleEditor.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/SubtitleEditor.jsx
40.1 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, | |
| // ---- Chế độ dùng lại cho tab "Chèn chữ vào video" (nhiều video) ---- | |
| initialSegs = null, // segs khởi tạo (khôi phục khi chuyển qua lại video) | |
| initialMode = null, // mode khởi tạo | |
| onChange = null, // (segs, mode, payload) -> đẩy state lên cha để lưu/áp form | |
| headerExtra = null, // JSX chèn thêm ở header (vd nút "Áp form cho mọi video") | |
| onFetchLines = null, // async () -> [{start,end,text}] : nhận dạng lời từ video (ASR) | |
| }) { | |
| // Nhân bản sâu segs khởi tạo -> không dùng chung tham chiếu với state cha. | |
| const [segs, setSegs] = useState(() => | |
| initialSegs ? JSON.parse(JSON.stringify(initialSegs)) : []); | |
| const [sel, setSel] = useState(0); | |
| const [mode, setMode] = useState(initialMode || "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(), []); | |
| // Nhận dạng LỜI GỐC trong video (ASR) -> đổ vào editor thành các đoạn chữ SỬA | |
| // ĐƯỢC (không khoá), để user tạo kiểu & burn. Dùng ở tab "Chèn chữ vào video". | |
| const [fetchingLines, setFetchingLines] = useState(false); | |
| const loadLines = async () => { | |
| if (!onFetchLines) return; | |
| if (segs.some((s) => !s.locked) && | |
| !window.confirm("Thay các đoạn chữ đang có bằng lời nhận dạng từ video?")) return; | |
| setFetchingLines(true); | |
| try { | |
| const lines = await onFetchLines(); | |
| const loaded = (lines || []).map((s) => ({ | |
| start: +s.start, end: +s.end, text: s.text || "", locked: false, | |
| words: wordsFor(s.text, s.start, s.end), | |
| style: JSON.parse(JSON.stringify(DEF)), | |
| })); | |
| if (!loaded.length) { alert("Không nhận được lời nào từ video (có thể video không có tiếng nói)."); return; } | |
| setSegs((cur) => [...cur.filter((x) => x.locked), ...loaded]); | |
| setSel(0); | |
| } catch (e) { alert("Lỗi nhận dạng lời: " + (e?.message || e)); } | |
| finally { setFetchingLines(false); } | |
| }; | |
| // 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), | |
| })); | |
| // Đẩy state (segs + mode + payload đã đo breaks) lên component cha khi có onChange | |
| // (tab "Chèn chữ nhiều video"): cha lưu theo từng video + để "áp form cho mọi video". | |
| // payload đo breaks bằng ĐÚNG video đang mở -> burn hàng loạt khớp bản xem trước. | |
| useEffect(() => { | |
| if (!onChange) return; | |
| try { onChange(segs, mode, segPayload()); } | |
| catch { onChange(segs, mode, null); } | |
| // eslint-disable-next-line | |
| }, [segs, mode]); | |
| 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" style={{ gap: 8, flexWrap: "wrap" }}> | |
| <div className="panel-title" style={{ margin: 0 }}>Chèn chữ / phụ đề (hard sub)</div> | |
| <div className="flex center gap8" style={{ flexWrap: "wrap" }}> | |
| {headerExtra} | |
| {onClose && <button className="btn sm ghost" onClick={onClose}>Đóng</button>} | |
| </div> | |
| </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 }}> | |
| {srtRel && segs.filter((s) => s.locked).length === 0 && ( | |
| <button className="btn primary" onClick={autoSubtitle}>Tự tạo phụ đề từ lời dịch</button> | |
| )} | |
| {onFetchLines && ( | |
| <button className="btn primary" onClick={loadLines} disabled={fetchingLines} | |
| title="Nhận dạng lời thoại trong video (Whisper) và đổ sẵn vào để sửa"> | |
| {fetchingLines ? "Đang nhận dạng lời…" : "🎙 Tự lấy lời từ video (nhận dạng)"} | |
| </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> | |
| ); | |
| } | |