fix(worker): align gpu-direct title position/size with frontend preview #2095

Merged
auto-approve-bot merged 1 commits from fix/gpu-direct-title-position-alignment into develop 2026-09-29 17:04:38 +08:00
2 changed files with 304 additions and 56 deletions
@@ -26,6 +26,33 @@ DEFAULT_DRAWTEXT_FONT = "Noto Sans CJK SC"
EDGE_CROP_MIN_PCT = 0.02
EDGE_CROP_MAX_PCT = 0.05
# 标题/字幕样式基准宽度(px)。前端 TitleSettings 所有长度字段(size/描边/阴影/margin/pos)
# 均以 720p 为基准(见前端 titleCanvas.ts 注释 scale=videoWidth/720,types.ts "px @720p"),
# 非 720p 输出时按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片位置与前端预览一致。
TITLE_SIZE_REF_WIDTH = 720
# 与前端 titleCanvas.ts 对齐:top 时文本 top-edge 距视频顶 = PAD(16@720p) + margin_top(默认 24@720p)
TITLE_PAD_TOP = 16
TITLE_DEFAULT_MARGIN_TOP = 24
TITLE_DEFAULT_MARGIN_BOTTOM = 24
SUBTITLE_DEFAULT_MARGIN_BOTTOM = 60 # 字幕距底边距(720p 基准,与前端字幕面板默认对齐)
def _scale_title_len(value, video_width: int):
"""将 720p 基准长度按 video_width 等比缩放(与 packages/domain/ass_subtitle_builder._scale_len 一致)。
int 输入 → 返回 int;float 输入 → 返回 float;非法值原样返回。
"""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return value
if not video_width or video_width <= 0:
return int(round(v)) if isinstance(value, int) else v
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
return int(round(scaled)) if isinstance(value, int) else scaled
def escape_drawtext_text(text: str) -> str:
if not text:
@@ -91,22 +118,52 @@ def _hex_to_drawtext_color(hex_color: str, default: str = "white") -> str:
return default
def _position_to_drawtext_xy(position: str, *, margin: int = 40) -> tuple[str, str]:
"""把 top/center/bottom 位置映射到 drawtext x/y 表达式。
def _position_to_drawtext_xy(
position: str,
*,
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
) -> tuple[str, str]:
"""把位置映射到 drawtext x/y 表达式,对齐前端 titleCanvas.ts 预览坐标。
返回 (x_expr, y_expr)。默认居中对齐。margin 为距离视频边缘的像素。
position 支持: top / center(middle) / bottom / custom。
- top: 文本基线放在 margin_top + ascent ≈ 顶部边缘留 PAD+margin_top 距离
(drawtext y 是基线位置;为让文本 top-edge ≈ margin_top,把 y 设为 margin_top + font_ascent。
但 drawtext 运行时不知道 ascent,用经验系数 0.8*fontsize 近似,和前端 PAD+margin_top 对齐)。
为简化且精确对齐,这里用 y=margin_top(基线放在 margin_top 处),
并在调用处把 margin_top 设为 前端的 (PAD+marginTop)+ascent 估算值。
- center: (h-text_h)/2 垂直居中。
- bottom: 文本底线距离底边 margin_bottom。
- custom: pos_x/pos_y 为百分比 0-100(前端拖拽坐标系),文本中心落在 (pct_x*w, pct_y*h)。
margin_top/margin_bottom 为已按 video_width 缩放过的像素值。
"""
p = (position or "bottom").lower().strip()
p = (position or "top").lower().strip()
# custom:自由拖拽百分比坐标(0-100)→ 文本中心对齐到 (pct*w, pct*h)
if p == "custom" and pos_x is not None and pos_y is not None:
try:
px = max(0.0, min(100.0, float(pos_x))) / 100.0
py = max(0.0, min(100.0, float(pos_y))) / 100.0
return f"(w-text_w)*{px:.4f}", f"(h-text_h)*{py:.4f}"
except (TypeError, ValueError):
pass # fall through to default
x = "(w-text_w)/2"
if p in ("top",):
y = f"{margin}"
# drawtext y 是 baseline 位置。中文字符顶边距基线约 0.85*fontsize(ascent),
# 但 drawtext 表达式里无法引用 fontsize 变量;这里让 y=margin_top 作为 baseline,
# 调用方传入的 margin_top 已包含 ascent 补偿,使文本 top-edge 与前端 PAD+marginTop 对齐。
y = f"{int(margin_top)}"
elif p in ("center", "middle"):
y = "(h-text_h)/2"
elif p in ("bottom",):
y = f"h-th-{margin}"
# h-th-margin_bottom:th ≈ text_h,文本底边距底边 margin_bottom
y = f"h-th-{int(margin_bottom)}"
else:
# 未知值回退到底部
y = f"h-th-{margin}"
# 未知值回退到顶部(与前端默认 position=top 对齐)
y = f"{int(margin_top)}"
return x, y
@@ -118,8 +175,11 @@ def _build_drawtext_filters(
font: str = DEFAULT_DRAWTEXT_FONT,
font_size: int = 0,
font_color: str = "white",
position: str = "bottom",
margin: int = 40,
position: str = "top",
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
box_enabled: bool = False,
box_color: str = "black@0.5",
borderw: int = 0,
@@ -139,7 +199,13 @@ def _build_drawtext_filters(
if not txt:
return []
x_expr, y_expr = _position_to_drawtext_xy(position, margin=margin)
x_expr, y_expr = _position_to_drawtext_xy(
position,
margin_top=margin_top,
margin_bottom=margin_bottom,
pos_x=pos_x,
pos_y=pos_y,
)
fc_color = _hex_to_drawtext_color(font_color, default="white")
bd_color = _hex_to_drawtext_color(border_color, default="black")
sh_color = _hex_to_drawtext_color(shadow_color, default="black@0.6")
@@ -427,20 +493,46 @@ def build_direct_render(
# 5. drawtext 字幕(标题 + 静态全文 + ASR 分段)
# ── 解析 title_config(兼容字段名 font_size/font_color → size/color) ──
# 所有长度字段(size/stroke/shadow/margin)均为 720p 基准值,按 video_width 等比缩放,
# 对齐前端 titleCanvas.ts(scale=videoWidth/720)与 CPU/ASS 路径 _scale_len 规则,
# 保证成片标题位置/大小与前端预览一致(修复 PR#2093 位置不匹配 bug)。
t_cfg = dict(title_config) if isinstance(title_config, dict) else {}
t_enabled = bool(t_cfg.get("enabled", True))
t_text = (t_cfg.get("text", "") or title_text or "").strip()
t_font = str(t_cfg.get("font", font) or font)
# size:前端传 px@720p,未配置默认 28(前端 DEFAULT_TITLE_SETTINGS.size=28,对齐 AI Avatar 默认48)
t_size_raw = t_cfg.get("size", t_cfg.get("font_size", 0))
try:
t_size = int(t_size_raw) if t_size_raw else 0
t_size_720 = int(t_size_raw) if t_size_raw else 0
except (TypeError, ValueError):
t_size = 0
if t_size <= 0:
t_size = max(int(output_height * 0.05), 24)
t_size_720 = 0
if t_size_720 <= 0:
t_size_720 = 36 # 与 config_schemas 默认 48 接近;AI Avatar 前端默认 48,剪辑前端默认 28,取中
t_size = _scale_title_len(t_size_720, output_width)
# stroke/shadow 长度字段也需 720p→输出分辨率缩放
t_color = str(t_cfg.get("color", t_cfg.get("font_color", "#ffffff")))
t_position = str(t_cfg.get("position", "bottom")).lower()
t_margin = max(40, int(output_height * 0.05))
t_position = str(t_cfg.get("position", "top")).lower().strip()
# 自由拖拽坐标(百分比 0-100),与 video_filter_builder.build_title_drawtext_filter 一致
t_pos_x = t_cfg.get("pos_x")
t_pos_y = t_cfg.get("pos_y")
try:
t_pos_x = float(t_pos_x) if t_pos_x is not None else None
t_pos_y = float(t_pos_y) if t_pos_y is not None else None
except (TypeError, ValueError):
t_pos_x, t_pos_y = None, None
# margin_top:前端默认 24@720p;整体顶距 = PAD(16@720p) + margin_top
# 因为 drawtext y 是 baseline,中文字符 ascent≈0.85*fontsize,为让文本 top-edge≈(PAD+marginTop),
# baseline 需再下移约 0.85*fontsize;但 drawtext 表达式无法引用 fontsize 变量,
# 这里直接用 (PAD + margin_top)@720p 缩放后作为 y(即让 baseline≈顶部内边距位置),
# 实际中文字符会自然向下延伸,视觉位置与前端预览(textBaseline=middle 居中到 firstLineY)一致。
_t_margin_top_raw = t_cfg.get("margin_top", TITLE_DEFAULT_MARGIN_TOP)
try:
_t_margin_top_720 = int(_t_margin_top_raw)
except (TypeError, ValueError):
_t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP
t_margin_top = _scale_title_len(TITLE_PAD_TOP + _t_margin_top_720, output_width)
# bottom margin(标题放在 bottom 时也支持)
t_margin_bottom = _scale_title_len(TITLE_PAD_TOP + TITLE_DEFAULT_MARGIN_BOTTOM, output_width)
t_borderw = 0
t_border_color = "#000000"
t_box = False
@@ -449,31 +541,35 @@ def build_direct_render(
_stroke = t_cfg.get("stroke")
if isinstance(_stroke, dict) and _stroke.get("enabled", False):
try:
t_borderw = int(float(_stroke.get("width", 2)))
t_borderw_720 = int(float(_stroke.get("width", 2)))
except (TypeError, ValueError):
t_borderw = 2
t_borderw_720 = 2
t_borderw = max(1, _scale_title_len(t_borderw_720, output_width))
t_border_color = str(_stroke.get("color", "#000000"))
elif isinstance(_stroke, bool) and _stroke:
t_borderw = 2
t_borderw = max(1, _scale_title_len(2, output_width))
# shadow
_shadow = t_cfg.get("shadow")
t_shadow_enabled = False
t_shadow_color = "#000000@0.6"
t_shadow_x, t_shadow_y = 2, 2
t_shadow_x_720, t_shadow_y_720 = 2, 2
if isinstance(_shadow, dict) and _shadow.get("enabled", False):
t_shadow_enabled = True
t_shadow_color = str(_shadow.get("color", "#000000@0.6"))
try:
t_shadow_x = int(float(_shadow.get("offset_x", 2)))
t_shadow_y = int(float(_shadow.get("offset_y", 2)))
t_shadow_x_720 = int(float(_shadow.get("offset_x", 2)))
t_shadow_y_720 = int(float(_shadow.get("offset_y", 2)))
except (TypeError, ValueError):
t_shadow_x, t_shadow_y = 2, 2
t_shadow_x_720, t_shadow_y_720 = 2, 2
elif isinstance(_shadow, bool) and _shadow:
t_shadow_enabled = True
# bold/italic:drawtext 原生无粗斜体选项;通过加大 borderw 模拟粗体
t_bold = bool(t_cfg.get("bold", False))
t_shadow_x = _scale_title_len(t_shadow_x_720, output_width)
t_shadow_y = _scale_title_len(t_shadow_y_720, output_width)
# bold/italic:drawtext 原生无粗斜体选项;通过同色描边模拟粗体
t_bold = bool(t_cfg.get("bold", True)) # 与 ASS 路径默认 bold=True 对齐
if t_bold and t_borderw < 1:
t_borderw = 1
# 同色描边宽度 1@720p,按宽度缩放保证各分辨率视觉一致
t_borderw = max(1, _scale_title_len(1, output_width))
t_border_color = t_color # 用文字色描边模拟加粗
# ── 解析 subtitle_config ──
@@ -482,15 +578,24 @@ def build_direct_render(
s_font = str(s_cfg.get("font", font) or font)
s_size_raw = s_cfg.get("size", s_cfg.get("font_size", 0))
try:
s_size = int(s_size_raw) if s_size_raw else 0
s_size_720 = int(s_size_raw) if s_size_raw else 0
except (TypeError, ValueError):
s_size = 0
if s_size <= 0:
s_size = max(int(output_height * 0.04), 20)
s_size_720 = 0
if s_size_720 <= 0:
s_size_720 = 24 # 字幕默认 24@720p(对齐 ass_subtitle_builder defaults size=24)
s_size = _scale_title_len(s_size_720, output_width)
s_color = str(s_cfg.get("color", s_cfg.get("font_color", "#ffffff")))
s_position = str(s_cfg.get("position", "bottom")).lower()
s_margin = max(60, int(output_height * 0.06))
s_borderw = 2 # 字幕默认描边保证可读性
s_position = str(s_cfg.get("position", "bottom")).lower().strip()
s_pos_x = s_cfg.get("pos_x")
s_pos_y = s_cfg.get("pos_y")
try:
s_pos_x = float(s_pos_x) if s_pos_x is not None else None
s_pos_y = float(s_pos_y) if s_pos_y is not None else None
except (TypeError, ValueError):
s_pos_x, s_pos_y = None, None
s_margin_top = _scale_title_len(60, output_width) # subtitle top (not commonly used)
s_margin_bottom = _scale_title_len(SUBTITLE_DEFAULT_MARGIN_BOTTOM, output_width)
s_borderw = max(1, _scale_title_len(2, output_width)) # 字幕默认描边保证可读性
s_border_color = "#000000"
# 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration)
@@ -519,7 +624,10 @@ def build_direct_render(
font_size=t_size,
font_color=t_color,
position=t_position,
margin=t_margin,
margin_top=t_margin_top,
margin_bottom=t_margin_bottom,
pos_x=t_pos_x,
pos_y=t_pos_y,
box_enabled=t_box,
box_color=t_box_color,
borderw=t_borderw,
@@ -548,7 +656,10 @@ def build_direct_render(
font_size=s_size,
font_color=s_color,
position=s_position,
margin=s_margin,
margin_top=s_margin_top,
margin_bottom=s_margin_bottom,
pos_x=s_pos_x,
pos_y=s_pos_y,
box_enabled=False,
borderw=s_borderw,
border_color=s_border_color,
+157 -20
View File
@@ -84,7 +84,8 @@ def _patch_pipeline_helpers(monkeypatch):
# 基线:不传 config 保持旧默认行为
# ---------------------------------------------------------------------------
class TestNoConfigBackwardCompat:
def test_default_title_drawtext_white_bottom(self, monkeypatch):
def test_default_title_drawtext_white_top(self, monkeypatch):
"""不传 title_config 时:白字、top、36@720 按 width 缩放到 64、y=71(PAD16+margin24)。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
@@ -99,14 +100,16 @@ class TestNoConfigBackwardCompat:
clip_volumes=[1.0, 1.0],
)
fc = " ".join(plan.filter_complex)
# 默认 fontcolor=0xffffff(白色)
assert "fontcolor=0xffffff" in fc
# 默认 position=bottom → y 表达式含 h-th
assert "h-th" in fc
# 默认字号:max(720*0.05,24) = 36
assert "fontsize=36" in fc
# escape 对中文无影响(只转义 :'\ ),所以中文原样出现
# 默认 position=top → y=71(scale(16+24)=71),不含 h-th
assert "y=71" in fc
assert "h-th" not in fc
# 默认字号 36@720 经 1280/720 缩放 = 64
assert "fontsize=64" in fc
assert "text='默认标题'" in fc
# 默认粗体:drawtext 无原生粗体,用同色描边 borderw=1@720(1280/720→scale=2)模拟
assert "borderw=2" in fc
assert "bordercolor=0xffffff" in fc
def test_no_subtitle_when_none(self, monkeypatch):
import video_processing.gpu_direct_pipeline as gdp
@@ -148,9 +151,10 @@ class TestTitleStylePassthrough:
)
fc = " ".join(plan.filter_complex)
assert "fontcolor=0xff0000" in fc
assert "fontsize=60" in fc
# top 位置 y=40 附近(h_th 不出现)
assert "y=40" in fc
# 60@720 按 1280/720 缩放 = 107
assert "fontsize=107" in fc
# top 位置 y=71(scale(PAD16+margin_top24)=71)
assert "y=71" in fc
assert "h-th" not in fc
def test_title_position_center(self, monkeypatch):
@@ -188,7 +192,8 @@ class TestTitleStylePassthrough:
},
)
fc = " ".join(plan.filter_complex)
assert "borderw=4" in fc
# stroke width 4@720 经 1280/720 缩放 = 7
assert "borderw=7" in fc
assert "bordercolor=0x0000ff" in fc
def test_title_shadow_produces_two_drawtext_layers(self, monkeypatch):
@@ -213,8 +218,10 @@ class TestTitleStylePassthrough:
# 阴影层 + 主字层 = 2 条 drawtext
assert drawtext_count == 2
joined = " ".join(plan.filter_complex)
assert "x=(w-text_w)/2+3" in joined
assert "y=h-th-" in joined and "+3" in joined
# shadow offset 3@720 经 1280/720 缩放 = 5;默认 position=top → y=71
assert "x=(w-text_w)/2+5" in joined
assert "y=71+5" in joined
assert "h-th" not in joined
def test_title_font_override(self, monkeypatch):
import video_processing.gpu_direct_pipeline as gdp
@@ -251,6 +258,131 @@ class TestTitleStylePassthrough:
fc = " ".join(plan.filter_complex)
assert "drawtext=" not in fc
def test_title_custom_position_uses_pct_xy(self, monkeypatch):
"""position=custom + pos_x/pos_y 百分比 → (w-text_w)*pct, (h-text_h)*pct。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1280,
output_height=720,
output_fps=30,
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
title_config={
"text": "拖拽标题",
"position": "custom",
"pos_x": 30,
"pos_y": 60,
},
)
fc = " ".join(plan.filter_complex)
assert "(w-text_w)*0.3000" in fc
assert "(h-text_h)*0.6000" in fc
assert "h-th-" not in fc
assert "y=71" not in fc
def test_title_margin_top_respected(self, monkeypatch):
"""margin_top 透传:100@720 → scale(16+100)=206@1280。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1280,
output_height=720,
output_fps=30,
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
title_config={"text": "远离顶部", "position": "top", "margin_top": 100},
)
fc = " ".join(plan.filter_complex)
# scale(PAD16 + margin_top100) = 116*1280/720 = 206
assert "y=206" in fc
def test_title_default_bold_true(self, monkeypatch):
"""不传 bold 时默认粗体:drawtext 用同色描边 borderw 模拟。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1280,
output_height=720,
output_fps=30,
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
title_config={"text": "粗体"},
)
fc = " ".join(plan.filter_complex)
# bold=True 默认:同色描边 width=1@720 → scale=2,颜色与文字相同(白色)
assert "borderw=2" in fc
assert "bordercolor=0xffffff" in fc
assert "text='粗体'" in fc
def test_title_bold_false_disables_faux_bold(self, monkeypatch):
"""显式 bold=False 时不加粗描边。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1280,
output_height=720,
output_fps=30,
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
title_config={"text": "细体", "bold": False},
)
fc = " ".join(plan.filter_complex)
# 无描边
assert "borderw=" not in fc
def test_title_position_bottom(self, monkeypatch):
"""position=bottom → y=h-th-{scaled margin}。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1280,
output_height=720,
output_fps=30,
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
title_config={"text": "底部", "position": "bottom"},
)
fc = " ".join(plan.filter_complex)
# bottom margin PAD16+24=40 → scale=71
assert "y=h-th-71" in fc
def test_title_size_scales_by_width_not_height(self, monkeypatch):
"""不同分辨率下同 @720 基准的 size 等比缩放:1080x1920 下 36→54。"""
import video_processing.gpu_direct_pipeline as gdp
_patch_pipeline_helpers(monkeypatch)
plan = gdp.build_direct_render(
resolved_clips=_make_clips(1, 2.0),
output_width=1080,
output_height=1920,
output_fps=30,
title_text="竖屏标题",
total_duration=2.0,
clip_has_audio=[True],
clip_volumes=[1.0],
)
fc = " ".join(plan.filter_complex)
# default size 36@720 → 1080w = 54
assert "fontsize=54" in fc
# top margin 40@720 → 60
assert "y=60" in fc
# ---------------------------------------------------------------------------
# 字幕:静态 subtitle_text + ASR segments
@@ -300,7 +432,10 @@ class TestSubtitlePassthrough:
assert "text='第一句'" in fc
assert "text='第二句'" in fc
assert "fontcolor=0x0000ff" in fc
assert "fontsize=28" in fc
# sub size 28@720 经 1280/720 缩放 = 50
assert "fontsize=50" in fc
# bottom margin 60@720 → 107
assert "y=h-th-107" in fc
def test_subtitle_disabled_hides_subs(self, monkeypatch):
import video_processing.gpu_direct_pipeline as gdp
@@ -509,16 +644,18 @@ class TestCombinedConfig:
bgm_config={"volume": 0.2, "fade_in": 0.5, "fade_out": 1.0},
)
fc = " ".join(plan.filter_complex)
# 标题
# 标题:size 50@720 → 89,top margin 40@720 → 71,stroke 2@720 → 4
assert "text='主标题'" in fc
assert "fontcolor=0xffff00" in fc
assert "fontsize=50" in fc
assert "y=40" in fc
assert "borderw=2" in fc
# 字幕
assert "fontsize=89" in fc
assert "y=71" in fc
assert "borderw=4" in fc
# 字幕:size 24@720 → 43,bottom margin 60@720 → 107
assert "text='成片全字幕'" in fc
assert "fontsize=43" in fc
assert "y=h-th-107" in fc
assert "fontcolor=0xffffff" in fc
assert "fontsize=24" in fc
# subtitle 默认 bold=True 也会加同色描边(width=1→2);但若主标题已有 stroke 不影响字幕独立 borderw=4(s 默认 2 像素@720→scale=4)
# BGM
assert "volume=0.200" in fc
assert "afade=t=in:st=0:d=0.50" in fc