| import { useEffect, useRef, useState } from "react"; |
| import { api, fileUrl, pollJob } from "../api"; |
| import { IcDownload, IcPlus, IcTrash } from "./Icons"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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([]); |
| 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); |
| |
| |
| |
| const [exact, setExact] = useState(true); |
| const [fit, setFit] = useState({ w: 0, h: 0 }); |
| const [previewImg, setPreviewImg] = useState(null); |
| const [previewLoading, setPreviewLoading] = useState(false); |
| const [previewErr, setPreviewErr] = useState(false); |
| const [dragging, setDragging] = useState(false); |
| const [frame, setFrame] = useState(null); |
| const [frameBusy, setFrameBusy] = useState(false); |
| const videoRef = useRef(); |
| const wrapRef = useRef(); |
| const colRef = useRef(); |
| const frameTimer = useRef(null); |
| const frameSeq = useRef(0); |
| const nat = useRef({ w: 1080, h: 1920 }); |
| const act = useRef(null); |
| |
| const pollAbort = useRef(new AbortController()); |
| useEffect(() => () => pollAbort.current.abort(), []); |
|
|
| |
| 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); } |
| }; |
|
|
| |
| 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 { } |
| }); |
| |
| }, [videoUrl]); |
|
|
| |
| 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); }; |
| |
| }, []); |
|
|
| 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]; |
|
|
| |
| 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; }; |
|
|
| |
| 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(); }; |
|
|
| |
| 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); |
| }; |
| |
| const handleDown = (i, dir) => (e) => { |
| if (editing) return; e.stopPropagation(); setSel(i); const v = videoRef.current; if (v) v.pause(); |
| |
| 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); |
| 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") { |
| |
| |
| |
| 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 { |
| |
| 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); |
| }; |
|
|
| 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 } }))); }; |
| |
| |
| 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 } }))); |
| }; |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| 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"); |
| |
| |
| |
| 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; |
| }; |
|
|
| |
| |
| 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 { |
| |
| try { await document.fonts.ready; } catch { } |
| 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; |
| |
| |
| 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); } |
| }; |
|
|
| |
| |
| 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 { } |
| 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); } |
| }; |
|
|
| |
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| 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); |
| 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); |
| |
| }, [idle, segs, mode, t, dur]); |
|
|
| |
| const boxStyle = (s, on) => { |
| const st = s.style; |
| const sc = scale(); |
| const fpx = st.fontSize * sc; |
| const px = fpx * kOf(st.font); |
| |
| |
| 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}`; |
| |
| |
| 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}`; |
| |
| |
| |
| |
| 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"; } |
| |
| |
| 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; |
| if (showExact) return null; |
| 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> |
| ); |
| } |
|
|