titusgiap Claude Opus 4.8 commited on
Commit
0ce96ff
·
1 Parent(s): 1b53507

fix(sub): CSS xem trước KHỚP burn - hệ số co cỡ chữ + giãn dòng đo thực nghiệm

Browse files

Nguyê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
- box_h = n_lines * fsize * 1.25 + 2 * pad_y
 
 
 
 
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
- ctx.font = `${st.fontSize}px ${cssOf(st.font)}`;
 
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; const px = st.fontSize * scale();
277
- const base = { position: "absolute", left: st.x + "%", top: st.y + "%", transform: "translate(-50%,-50%)", width: st.w + "%", textAlign: "center", lineHeight: 1.25, fontWeight: 400, fontFamily: cssOf(st.font), fontSize: px + "px", color: st.color, boxSizing: "border-box", cursor: editing ? "text" : "move", outline: on ? "1.5px dashed rgba(255,255,255,.85)" : "none", zIndex: on ? 5 : 2, wordWrap: "break-word" };
 
 
 
 
 
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
- if (b === "outlineThin") base.textShadow = ol(1.3 * scale() + 0.5, "#000");
280
- else if (b === "outlineThick") base.textShadow = ol(2.4 * scale() + 0.5, "#000");
281
- else if (b === "outlineWhite") base.textShadow = ol(1.6 * scale() + 0.5, "#fff");
 
 
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
- else if (b === "boxDark") { base.background = "rgba(0,0,0,.5)"; base.padding = "4px 10px"; base.borderRadius = "6px"; }
285
- else if (b === "boxSolid") { base.background = "#000"; base.padding = "4px 10px"; base.borderRadius = "4px"; }
286
- else if (b === "boxRound") { base.background = "rgba(0,0,0,.62)"; base.padding = "7px 14px"; base.borderRadius = "22px"; }
287
- else if (b === "bar") { base.background = "rgba(0,0,0,.5)"; base.padding = "7px 8px"; }
288
- else if (b === "boxColor") { base.background = st.highlight; base.padding = "4px 12px"; base.borderRadius = "6px"; }
 
 
 
 
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)) {