Spaces:
Running
Running
fix(sub): CSS xem trước KHỚP burn - hệ số co cỡ chữ + giãn dòng đo thực nghiệm
Browse filesNguyên nhân: libass vẽ Fontsize=F ra cỡ pixel nhỏ hơn CSS font-size=F. Đo
render_frame(libass) vs canvas trình duyệt -> hệ số co K cố định mỗi font
(BVP .655, Roboto .829, OpenSans .692, Montserrat .641, Arimo .694, Tinos .905).
Khoảng cách dòng libass = 1.0*fsize mọi font.
- FE: cỡ chữ preview = fontSize*k*scale; lineHeight = 1/k; measureBreaks đo ở
fontSize*k; padding/bo góc hộp quy theo fpx; lề ngang 1.5% khớp measureBreaks.
- BE: box_h = n*fsize + 2*pad (bỏ *1.25*K sai) -> hộp ôm sát chữ, khớp hộp CSS.
Kiểm chứng overlay CSS đè khung burn thật: cỡ/bề rộng/ngắt dòng/giãn dòng/hộp
khớp cho cả font mặc định lẫn font lệch nhất.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
backend/app/services/dubber/subtitle_render.py
CHANGED
|
@@ -284,7 +284,11 @@ def _bg_event(seg: dict, st: dict, bg: str, cx: int, cy: int, fsize: int,
|
|
| 284 |
radius = {"boxRound": 0.73, "boxDark": 0.20, "boxColor": 0.20,
|
| 285 |
"boxSolid": 0.13, "bar": 0.0}.get(bg, 0.0) * fsize
|
| 286 |
box_w = wfrac * W # = w% vùng, y hệt CSS
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
x0 = int(round(cx - box_w / 2)); x1 = int(round(cx + box_w / 2))
|
| 289 |
y0 = int(round(cy - box_h / 2)); y1 = int(round(cy + box_h / 2))
|
| 290 |
draw = _round_rect(x0, y0, x1, y1, int(round(radius)))
|
|
|
|
| 284 |
radius = {"boxRound": 0.73, "boxDark": 0.20, "boxColor": 0.20,
|
| 285 |
"boxSolid": 0.13, "bar": 0.0}.get(bg, 0.0) * fsize
|
| 286 |
box_w = wfrac * W # = w% vùng, y hệt CSS
|
| 287 |
+
# Chiều cao hộp = n * (khoảng cách dòng) + đệm. ĐO THỰC NGHIỆM: libass giãn dòng
|
| 288 |
+
# đúng = 1.0 * fsize cho MỌI font (theo Fontsize danh nghĩa, không theo cỡ nét đã
|
| 289 |
+
# co). Nên n*fsize + đệm là ôm sát chữ (giống CapCut) và KHỚP hộp CSS xem trước
|
| 290 |
+
# (lineHeight CSS = 1/K, px = fpx*K -> chiều cao nội dung CSS = n*fpx = n*fsize).
|
| 291 |
+
box_h = n_lines * fsize + 2 * pad_y
|
| 292 |
x0 = int(round(cx - box_w / 2)); x1 = int(round(cx + box_w / 2))
|
| 293 |
y0 = int(round(cy - box_h / 2)); y1 = int(round(cy + box_h / 2))
|
| 294 |
draw = _round_rect(x0, y0, x1, y1, int(round(radius)))
|
frontend/src/components/SubtitleEditor.jsx
CHANGED
|
@@ -7,13 +7,18 @@ import { IcDownload, IcPlus, IcTrash } from "./Icons";
|
|
| 7 |
// 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
|
| 8 |
// font Microsoft. `ass` = ĐÚNG family name nội bộ của .ttf (khớp Fontname libass).
|
| 9 |
// Các file là face nét đậm sẵn, khai báo weight thường -> KHÔNG làm-đậm-giả.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const FONTS = [
|
| 11 |
-
{ label: "Be Vietnam Pro", css: "'Be Vietnam Pro', sans-serif", ass: "Be Vietnam Pro" },
|
| 12 |
-
{ label: "Roboto", css: "'Roboto', sans-serif", ass: "Roboto" },
|
| 13 |
-
{ label: "Open Sans", css: "'Open Sans', sans-serif", ass: "Open Sans" },
|
| 14 |
-
{ label: "Montserrat", css: "'Montserrat', sans-serif", ass: "Montserrat" },
|
| 15 |
-
{ label: "Arimo (giống Arial)", css: "'Arimo', sans-serif", ass: "Arimo" },
|
| 16 |
-
{ label: "Tinos (serif, giống Times)", css: "'Tinos', serif", ass: "Tinos" },
|
| 17 |
];
|
| 18 |
const BGS = [
|
| 19 |
["Viền đen mỏng", "outlineThin"], ["Viền đen đậm", "outlineThick"],
|
|
@@ -26,6 +31,7 @@ const MODES = [
|
|
| 26 |
];
|
| 27 |
const DEF = { font: "Be Vietnam Pro", fontSize: 54, color: "#ffffff", highlight: "#ffd400", bg: "outlineThin", x: 50, y: 86, w: 82 };
|
| 28 |
const cssOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).css;
|
|
|
|
| 29 |
const trunc = (t, n) => { t = (t || "").trim(); return t.length > n ? t.slice(0, n).trim() + "…" : t; };
|
| 30 |
const wordsFor = (text, start, end) => {
|
| 31 |
const words = (text || "").split(/\s+/).filter(Boolean);
|
|
@@ -183,8 +189,9 @@ export default function SubtitleEditor({ session, videoUrl, videoRel, srtRel, us
|
|
| 183 |
const cv = measureBreaks._cv || (measureBreaks._cv = document.createElement("canvas"));
|
| 184 |
const ctx = cv.getContext("2d");
|
| 185 |
// Font nhúng đã là nét đậm sẵn + libass render Bold=0 -> ĐO weight thường
|
| 186 |
-
// (không "bold") để độ rộng đo khớp đúng glyph libass dùng.
|
| 187 |
-
|
|
|
|
| 188 |
const sp = ctx.measureText(" ").width;
|
| 189 |
const breaks = []; let lineW = 0;
|
| 190 |
words.forEach((w, i) => {
|
|
@@ -273,19 +280,30 @@ export default function SubtitleEditor({ session, videoUrl, videoRel, srtRel, us
|
|
| 273 |
|
| 274 |
// ---- render 1 ô text ----
|
| 275 |
const boxStyle = (s, on) => {
|
| 276 |
-
const st = s.style;
|
| 277 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
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}`;
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
| 282 |
else if (b === "shadow") base.textShadow = "0 3px 7px rgba(0,0,0,.95)";
|
| 283 |
else if (b === "glow") base.textShadow = `0 0 9px ${st.highlight},0 0 4px ${st.highlight}`;
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
else if (b === "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
// Ảnh chuẩn đang đè -> ẩn chữ/nền/viền CSS (chỉ giữ vùng bắt sự kiện + khung
|
| 290 |
// chọn). Lúc khác luôn hiện CSS làm nền -> không bao giờ trống chữ.
|
| 291 |
if (showExact && !(editing && on)) {
|
|
|
|
| 7 |
// 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
|
| 8 |
// font Microsoft. `ass` = ĐÚNG family name nội bộ của .ttf (khớp Fontname libass).
|
| 9 |
// Các file là face nét đậm sẵn, khai báo weight thường -> KHÔNG làm-đậm-giả.
|
| 10 |
+
// k = HỆ SỐ CO CỠ CHỮ CSS↔libass (đo thực nghiệm). libass vẽ `Fontsize=F` ra cỡ
|
| 11 |
+
// pixel NHỎ hơn CSS `font-size:F` (CSS đặt EM = F px). Đo render_frame(libass) vs
|
| 12 |
+
// canvas trình duyệt cùng ngưỡng (chuỗi H/Hg/Hàng) -> tỉ lệ ink_libass/ink_css NHẤT
|
| 13 |
+
// QUÁN <1% -> là hệ số co cố định mỗi font. Nhân k vào cỡ chữ CSS preview để KHỚP
|
| 14 |
+
// libass. (KHÔNG dùng mscale=hheaH/winH lý thuyết: libass build này vẽ nhỏ hơn nữa.)
|
| 15 |
const FONTS = [
|
| 16 |
+
{ label: "Be Vietnam Pro", css: "'Be Vietnam Pro', sans-serif", ass: "Be Vietnam Pro", k: 0.655 },
|
| 17 |
+
{ label: "Roboto", css: "'Roboto', sans-serif", ass: "Roboto", k: 0.829 },
|
| 18 |
+
{ label: "Open Sans", css: "'Open Sans', sans-serif", ass: "Open Sans", k: 0.692 },
|
| 19 |
+
{ label: "Montserrat", css: "'Montserrat', sans-serif", ass: "Montserrat", k: 0.641 },
|
| 20 |
+
{ label: "Arimo (giống Arial)", css: "'Arimo', sans-serif", ass: "Arimo", k: 0.694 },
|
| 21 |
+
{ label: "Tinos (serif, giống Times)", css: "'Tinos', serif", ass: "Tinos", k: 0.905 },
|
| 22 |
];
|
| 23 |
const BGS = [
|
| 24 |
["Viền đen mỏng", "outlineThin"], ["Viền đen đậm", "outlineThick"],
|
|
|
|
| 31 |
];
|
| 32 |
const DEF = { font: "Be Vietnam Pro", fontSize: 54, color: "#ffffff", highlight: "#ffd400", bg: "outlineThin", x: 50, y: 86, w: 82 };
|
| 33 |
const cssOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).css;
|
| 34 |
+
const kOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).k;
|
| 35 |
const trunc = (t, n) => { t = (t || "").trim(); return t.length > n ? t.slice(0, n).trim() + "…" : t; };
|
| 36 |
const wordsFor = (text, start, end) => {
|
| 37 |
const words = (text || "").split(/\s+/).filter(Boolean);
|
|
|
|
| 189 |
const cv = measureBreaks._cv || (measureBreaks._cv = document.createElement("canvas"));
|
| 190 |
const ctx = cv.getContext("2d");
|
| 191 |
// Font nhúng đã là nét đậm sẵn + libass render Bold=0 -> ĐO weight thường
|
| 192 |
+
// (không "bold") để độ rộng đo khớp đúng glyph libass dùng. Nhân k để đo ở
|
| 193 |
+
// ĐÚNG cỡ libass vẽ ra -> điểm ngắt dòng khớp cách libass lấp đầy vùng.
|
| 194 |
+
ctx.font = `${st.fontSize * kOf(st.font)}px ${cssOf(st.font)}`;
|
| 195 |
const sp = ctx.measureText(" ").width;
|
| 196 |
const breaks = []; let lineW = 0;
|
| 197 |
words.forEach((w, i) => {
|
|
|
|
| 280 |
|
| 281 |
// ---- render 1 ô text ----
|
| 282 |
const boxStyle = (s, on) => {
|
| 283 |
+
const st = s.style;
|
| 284 |
+
const sc = scale();
|
| 285 |
+
const fpx = st.fontSize * sc; // = libass Fontsize ở display px (mốc hình học hộp/viền, KHÔNG co K)
|
| 286 |
+
const px = fpx * kOf(st.font); // cỡ chữ CSS đã co K -> nét chữ KHỚP libass
|
| 287 |
+
// lineHeight = 1/K: khoảng cách dòng CSS = fpx = advance libass (đo thực nghiệm
|
| 288 |
+
// libass giãn dòng đúng 1.0*fsize). px = fpx*K nên lineHeight*px = fpx -> khớp.
|
| 289 |
+
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" };
|
| 290 |
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}`;
|
| 291 |
+
// Viền: libass Outline (ScaledBorderAndShadow, PlayResY=videoH) = px ở video, quy
|
| 292 |
+
// display = value*sc. outlineThin=2, outlineThick=5, outlineWhite=3 (khớp _style_line).
|
| 293 |
+
if (b === "outlineThin") base.textShadow = ol(Math.max(0.6, 2 * sc), "#000");
|
| 294 |
+
else if (b === "outlineThick") base.textShadow = ol(Math.max(0.8, 5 * sc), "#000");
|
| 295 |
+
else if (b === "outlineWhite") base.textShadow = ol(Math.max(0.6, 3 * sc), "#fff");
|
| 296 |
else if (b === "shadow") base.textShadow = "0 3px 7px rgba(0,0,0,.95)";
|
| 297 |
else if (b === "glow") base.textShadow = `0 0 9px ${st.highlight},0 0 4px ${st.highlight}`;
|
| 298 |
+
// Hộp nền: đệm DỌC quy theo fpx (KHÔNG co K) -> KHỚP _bg_event backend
|
| 299 |
+
// (pad_y=0.14/0.23*fsize). Đệm NGANG = 1.5% bề rộng hộp (CSS % = theo width) ->
|
| 300 |
+
// nội dung = 97% vùng, đúng bằng measureBreaks (*0.97) -> ngắt dòng live khớp burn.
|
| 301 |
+
// Bo góc quy theo fpx (radius={..}*fsize trong _bg_event).
|
| 302 |
+
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"; }
|
| 303 |
+
else if (b === "boxSolid") { base.background = "#000"; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.13 * fpx + "px"; }
|
| 304 |
+
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"; }
|
| 305 |
+
else if (b === "bar") { base.background = "rgba(0,0,0,.5)"; base.padding = `${0.23 * fpx}px 1.5%`; }
|
| 306 |
+
else if (b === "boxColor") { base.background = st.highlight; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.20 * fpx + "px"; }
|
| 307 |
// Ảnh chuẩn đang đè -> ẩn chữ/nền/viền CSS (chỉ giữ vùng bắt sự kiện + khung
|
| 308 |
// chọn). Lúc khác luôn hiện CSS làm nền -> không bao giờ trống chữ.
|
| 309 |
if (showExact && !(editing && on)) {
|