fix(title): 标题/字幕字号按 video_width/720 等比缩放,修复成片标题比预览过小 #2023

Merged
auto-approve-bot merged 4 commits from fix/title-fontsize-scaling into develop 2026-09-23 23:17:13 +08:00
9 changed files with 3443 additions and 1688 deletions
+3018 -1551
View File
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_scale_len,
_wrap_title_text,
build_ass_style,
escape_ass_text,
@@ -133,22 +134,23 @@ def generate_ass_from_timeline(
output_path.write_text("", encoding="utf-8")
return output_path
# 样式参数
# 样式参数(字号/描边/边距按视频宽度缩放,基准 720p,与前端预览一致)
_sw = video_width
font_name = subtitle_config.get("font", "思源黑体")
font_size = int(subtitle_config.get("size", 24))
font_size = _scale_len(int(subtitle_config.get("size", 24)), _sw)
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
position = subtitle_config.get("position", "bottom")
alignment = _position_to_ass_alignment(position)
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
# 描边(默认黑色描边,保证可读性)
# 描边(默认黑色描边,保证可读性)—— 720p 基准 1.5
outline_color = "&H00000000"
outline_width = 1.5
outline_width = _scale_len(1.5, _sw)
# 边距
margin_v = 60 if position == "bottom" else 60
margin_l = 40
margin_r = 40
# 边距(720p 基准 60/40)
margin_v = _scale_len(60 if position == "bottom" else 60, _sw)
margin_l = _scale_len(40, _sw)
margin_r = _scale_len(40, _sw)
# 生成样式行
style_line = (
@@ -203,22 +205,29 @@ def generate_ass_from_timeline(
if "font_color" in title_cfg and "color" not in title_cfg:
title_cfg["color"] = title_cfg["font_color"]
_tsw = video_width
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
t_stroke = title_cfg.get("stroke", {}) or {}
t_shadow = title_cfg.get("shadow", {}) or {}
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
s_width = _scale_len(float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0, _tsw)
sh_blur = _scale_len(float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0, _tsw)
sh_offset = (
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
_scale_len(t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, _tsw),
_scale_len(t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, _tsw),
)
t_alignment = position_to_ass_alignment(title_cfg.get("position", "bottom"))
# 标题字号/边距按视频宽度缩放(基准 720p),移除旧的 min(...,36) 上限避免 1080p 被钳位过小
_base_title_size = int(title_cfg.get("size", 36))
t_font_size = _scale_len(_base_title_size, _tsw)
_t_margin_top = _scale_len(TITLE_MARGIN_TOP, _tsw)
_t_margin_side = _scale_len(TITLE_MARGIN_SIDE, _tsw)
title_style_line = build_ass_style(
"TitleStyle",
font_name=title_cfg.get("font", "思源黑体"),
font_size=min(int(title_cfg.get("size", 36)), 36),
font_size=t_font_size,
primary_color=t_color,
outline_color=s_color,
outline_width=s_width,
@@ -227,14 +236,15 @@ def generate_ass_from_timeline(
bold=bool(title_cfg.get("bold", True)),
italic=bool(title_cfg.get("italic", False)),
alignment=t_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_t_margin_top,
margin_l=_t_margin_side,
margin_r=_t_margin_side,
)
t_font_size = min(int(title_cfg.get("size", 36)), 36)
safe_raw = escape_ass_text(title_text.strip())
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
safe_wrapped = _wrap_title_text(
safe_raw, video_width, t_font_size, margin_l=_t_margin_side, margin_r=_t_margin_side
)
if video_duration > 0:
t_end_time = format_ass_time(video_duration)
+82 -12
View File
@@ -59,6 +59,31 @@ FONT_NAME_MAP: dict[str, str] = {
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
ASS_FONTSIZE_COMPENSATION = 1.35
# 前端预览字号的基准宽度(px)。前端 TitleMiniPreview 以 width/360 缩放、titleCanvas.ts
# 以 videoWidth/720 缩放(360 是 720 的一半,字号等比例一致),types.ts 注释 "px @720p"。
# 当输出视频宽度 ≠ 720 时,所有长度类字段(字号/描边宽/阴影偏移/边距)
# 都按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片标题视觉大小与预览一致。
TITLE_SIZE_REF_WIDTH = 720
def _scale_len(value, video_width: int):
"""将 720p 基准长度值按 video_width 等比缩放。
整数输入 → 返回 int(用于字号、描边宽、边距、偏移等整数字段);
浮点输入 → 返回 float(保留小数,用于 1.5 这类 outline 细描边);
非数值原样返回。
"""
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 _compensate_ass_fontsize(font_size: int) -> int:
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
@@ -374,6 +399,44 @@ def build_ass_content(
else {"enabled": False}
)
# ── 字号/描边/阴影/边距按视频宽度缩放(基准 720p,与前端预览一致) ──
# 前端标题/字幕面板的 size 语义是 "px @720p"(见 types.ts 注释、titleCanvas.ts scale=videoWidth/720),
# 当输出分辨率不是 720p(如 1080x1920 竖屏、4K)时等比放大,避免成片标题比预览小。
def _scale_cfg(cfg: dict, defaults: dict | None = None) -> None:
if not isinstance(cfg, dict):
return
# 先填充默认值,再统一缩放,避免 .get("size", 36) 处拿未缩放默认值
if defaults:
for k, v in defaults.items():
cfg.setdefault(k, v)
sw = video_width
for key in ("size", "font_size"):
if key in cfg:
try:
cfg[key] = _scale_len(cfg[key], sw)
except (TypeError, ValueError):
pass
# stroke/shadow 的 width/blur/offset 由后续字段提取处统一按 720p 默认值+缩放处理
# (包含 enabled=false 时不写入、默认值 2/4 也需缩放),此处不再重复缩放
for key in ("margin_top", "bg_padding", "bg_radius", "pos_x", "pos_y"):
# pos_x/pos_y 是百分比(0-100)不缩放,margin/padding/radius 是 px @720p 需要缩放
if key in cfg and key not in ("pos_x", "pos_y"):
cfg[key] = _scale_len(cfg.get(key), sw)
# 逐行覆盖的 size 也要缩放
overrides = cfg.get("line_overrides")
if isinstance(overrides, list):
for ov in overrides:
if isinstance(ov, dict) and "size" in ov:
ov["size"] = _scale_len(ov.get("size"), sw)
_scale_cfg(title_config, defaults={"size": 36})
_scale_cfg(subtitle_config, defaults={"size": 24})
# 边距按视频宽度缩放(基准 720p)
_margin_top = _scale_len(TITLE_MARGIN_TOP, video_width) or TITLE_MARGIN_TOP
_margin_bottom = _scale_len(TITLE_MARGIN_BOTTOM, video_width) or TITLE_MARGIN_BOTTOM
_margin_side = _scale_len(TITLE_MARGIN_SIDE, video_width) or TITLE_MARGIN_SIDE
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
@@ -389,11 +452,16 @@ def build_ass_content(
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
# 描边/阴影默认值也是 720p 基准,需要随视频宽度缩放
_def_stroke_w = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
_def_shadow_b = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
_def_shadow_x = title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0
_def_shadow_y = title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0
stroke_width = float(_scale_len(_def_stroke_w, video_width))
shadow_blur = float(_scale_len(_def_shadow_b, video_width))
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
_scale_len(_def_shadow_x, video_width),
_scale_len(_def_shadow_y, video_width),
)
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
@@ -420,9 +488,9 @@ def build_ass_content(
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_margin_top,
margin_l=_margin_side,
margin_r=_margin_side,
)
)
@@ -430,7 +498,9 @@ def build_ass_content(
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
title_font_size = int(title_config.get("size", 36))
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
safe_title_text = _wrap_title_text(
safe_title_text_raw, video_width, title_font_size, margin_l=_margin_side, margin_r=_margin_side
)
# #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签
# line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤
@@ -460,15 +530,15 @@ def build_ass_content(
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
outline_width=float(_scale_len(1.0, video_width)),
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
margin_v=_margin_bottom,
margin_l=_margin_side,
margin_r=_margin_side,
)
)
+32 -10
View File
@@ -428,6 +428,26 @@ DRAWTEXT_FONT_MAP: dict[str, str] = {
}
# 前端标题/字幕面板的字号/描边/阴影/边距语义是 "px @720p"(见 web/src/pages/generate/types.ts
# 注释、apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale = videoWidth / 720)。
# 当输出视频宽度 != 720 时(如 1080x1920 竖屏、4K),drawtext 降级路径需要按比例
# 缩放所有长度类字段,避免成片标题视觉上比前端预览小。
TITLE_DRAWTEXT_REF_WIDTH = 720
def _scale_title_len(value, output_width: int):
"""将 720p 基准长度值按 output_width 等比缩放,返回 int."""
if value is None:
return 0
try:
v = float(value)
except (TypeError, ValueError):
return 0
if not output_width or output_width <= 0:
return int(round(v))
return int(round(v * (output_width / TITLE_DRAWTEXT_REF_WIDTH)))
def _escape_drawtext_text(text: str) -> str:
"""转义 drawtext 特殊字符。
@@ -514,6 +534,8 @@ def build_title_drawtext_filter(
# ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
# 字号/描边/阴影按视频宽度等比缩放(基准 720p,与前端预览一致)
font_size = _scale_title_len(font_size, output_width)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"):
@@ -549,31 +571,31 @@ def build_title_drawtext_filter(
border_color = "000000"
if stroke:
if isinstance(stroke, bool):
border_width = 2
border_width = _scale_title_len(2, output_width)
border_color = "000000"
elif isinstance(stroke, dict):
if stroke.get("enabled", True):
border_width = int(stroke.get("width", 2))
border_width = _scale_title_len(int(stroke.get("width", 2)), output_width)
border_color = (stroke.get("color") or "#000000").lstrip("#")
elif bold:
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
border_width = 2
border_width = _scale_title_len(2, output_width)
border_color = "000000"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y)
# 阴影(shadowcolor + shadowx/y)—— 同样按视频宽度缩放
if shadow:
if isinstance(shadow, bool):
params.append("shadowcolor=black")
params.append("shadowx=2")
params.append("shadowy=2")
params.append(f"shadowx={_scale_title_len(2, output_width)}")
params.append(f"shadowy={_scale_title_len(2, output_width)}")
elif isinstance(shadow, dict):
if shadow.get("enabled", True):
params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}")
params.append(f"shadowx={int(shadow.get('offset_x', 2))}")
params.append(f"shadowy={int(shadow.get('offset_y', 2))}")
params.append(f"shadowx={_scale_title_len(int(shadow.get('offset_x', 2)), output_width)}")
params.append(f"shadowy={_scale_title_len(int(shadow.get('offset_y', 2)), output_width)}")
# ── 位置计算 ──
# 优先使用自定义坐标 pos_x / pos_y
@@ -600,10 +622,10 @@ def build_title_drawtext_filter(
if position == "center":
params.append("y=(h-text_h)/2")
elif position == "bottom":
params.append("y=h-text_h-50")
params.append(f"y=h-text_h-{_scale_title_len(50, output_width)}")
else:
# top(默认)
params.append("y=50")
params.append(f"y={_scale_title_len(50, output_width)}")
return "drawtext=" + ":".join(params)
+49 -49
View File
@@ -285,20 +285,20 @@ class TestFormatAssTime:
class TestBuildAssContent:
def test_no_subtitles_returns_empty(self):
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
result = build_ass_content(video_width=720, video_height=1280, video_duration=10.0)
assert result == ""
def test_title_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Test Title",
)
assert result != ""
assert "[Script Info]" in result
assert "PlayResX: 1920" in result
assert "PlayResY: 1080" in result
assert "PlayResX: 720" in result
assert "PlayResY: 1280" in result
assert "[V4+ Styles]" in result
assert "[Events]" in result
assert "TitleStyle" in result
@@ -306,8 +306,8 @@ class TestBuildAssContent:
def test_subtitle_only(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Hello Subtitle",
)
@@ -317,8 +317,8 @@ class TestBuildAssContent:
def test_both_title_and_subtitle(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
subtitle_text="Subtitle",
@@ -330,8 +330,8 @@ class TestBuildAssContent:
def test_whitespace_title_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text=" ",
)
@@ -339,8 +339,8 @@ class TestBuildAssContent:
def test_whitespace_subtitle_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text=" \n ",
)
@@ -348,8 +348,8 @@ class TestBuildAssContent:
def test_title_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
title_config={"enabled": False},
@@ -358,8 +358,8 @@ class TestBuildAssContent:
def test_subtitle_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Sub",
subtitle_config={"enabled": False},
@@ -368,8 +368,8 @@ class TestBuildAssContent:
def test_title_color(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"color": "#FF0000"},
@@ -379,8 +379,8 @@ class TestBuildAssContent:
def test_title_position_top(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "top"},
@@ -390,8 +390,8 @@ class TestBuildAssContent:
def test_title_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "bottom"},
@@ -401,8 +401,8 @@ class TestBuildAssContent:
def test_subtitle_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="S",
subtitle_config={"position": "bottom"},
@@ -411,8 +411,8 @@ class TestBuildAssContent:
def test_title_font_size(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 72},
@@ -427,8 +427,8 @@ class TestBuildAssContent:
def test_title_font_size_frontend_field_alias(self):
"""前端传 font_size 应归一化为内部 size 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_size": 48},
@@ -442,8 +442,8 @@ class TestBuildAssContent:
def test_title_font_color_frontend_field_alias(self):
"""前端传 font_color 应归一化为内部 color 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_color": "#FF0000"},
@@ -454,8 +454,8 @@ class TestBuildAssContent:
def test_title_size_takes_precedence_over_font_size(self):
"""同时传 size 和 font_size 时,size 优先。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 56, "font_size": 28},
@@ -468,8 +468,8 @@ class TestBuildAssContent:
def test_title_bold(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"bold": True},
@@ -482,8 +482,8 @@ class TestBuildAssContent:
def test_title_stroke_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
@@ -496,8 +496,8 @@ class TestBuildAssContent:
def test_title_stroke_disabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": False, "width": 3}},
@@ -510,8 +510,8 @@ class TestBuildAssContent:
def test_title_shadow_enabled(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
@@ -524,8 +524,8 @@ class TestBuildAssContent:
def test_dialogue_has_correct_timing(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=65.5,
title_text="T",
)
@@ -534,8 +534,8 @@ class TestBuildAssContent:
def test_dialogue_starts_at_zero(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
subtitle_text="S",
)
@@ -543,8 +543,8 @@ class TestBuildAssContent:
def test_contains_script_info_header(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="T",
)
@@ -554,8 +554,8 @@ class TestBuildAssContent:
def test_escaped_text_in_dialogue(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="line1\nline2",
)
+112 -2
View File
@@ -587,9 +587,10 @@ class TestFontsizeCompensation:
assert parts[2] == "120"
def test_subtitle_also_compensated(self):
# 720p 基准宽度:只做 ASS fontsize 1.35x 补偿,不做分辨率缩放
content = build_ass_content(
video_width=1080,
video_height=1920,
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="字幕",
subtitle_config={"size": 24},
@@ -789,3 +790,112 @@ class TestDefaultPositionBottom:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
assert fields[18] == "8"
class TestTitleFontsizeScaling:
"""标题/字幕字号按 video_width/720 等比缩放(工单:标题字号校准)。
前端标题面板 size 语义是"px @720p",输出到 1080p/4K 等非 720 宽度
视频时需要等比放大所有长度类字段(字号/描边/阴影/边距),保证成片
标题视觉大小与前端预览一致。
"""
BASE_KWARGS = dict(video_duration=5.0, title_text="标题测试", subtitle_text="字幕测试")
def _title_fontsize(self, content: str) -> int:
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
return int(line.split(",")[2])
def _subtitle_fontsize(self, content: str) -> int:
line = [ln for ln in content.splitlines() if ln.startswith("Style: SubtitleStyle")][0]
return int(line.split(",")[2])
def test_720p_title_no_scale(self):
"""720p 宽度时只做 1.35x ASS 补偿,不做分辨率缩放。"""
from packages.domain.ass_subtitle_builder import ASS_FONTSIZE_COMPENSATION
content = build_ass_content(
video_width=720, video_height=1280, **self.BASE_KWARGS, title_config={"size": 28}
)
expected = round(28 * ASS_FONTSIZE_COMPENSATION) # 38
assert self._title_fontsize(content) == expected
def test_1080p_title_scaled(self):
"""1080 宽度(1080x1920 竖屏)时 size=28 → 28*1.5=42 → ASS 补偿 → round(42*1.35)=57."""
content = build_ass_content(
video_width=1080, video_height=1920, **self.BASE_KWARGS, title_config={"size": 28}
)
# 28 * 1080/720 = 42; round(42*1.35)=57
assert self._title_fontsize(content) == 57
def test_1920p_title_scaled(self):
"""1920 宽度(横屏 1920x1080)时 size=28 → 28*1920/720≈75 → round(75*1.35)=101."""
content = build_ass_content(
video_width=1920, video_height=1080, **self.BASE_KWARGS, title_config={"size": 28}
)
assert self._title_fontsize(content) == 101
def test_subtitle_scaled_1080p(self):
"""字幕默认 size=24 在 1080p 下也应按比例缩放:24*1.5=36 → round(36*1.35)=49."""
content = build_ass_content(
video_width=1080, video_height=1920, **self.BASE_KWARGS, subtitle_config={"size": 24}
)
assert self._subtitle_fontsize(content) == 49
def test_stroke_width_scaled(self):
"""描边宽度(Style 字段 Outline=16 索引)按同比例缩放。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "stroke": {"enabled": True, "width": 4, "color": "#000000"}},
)
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in line.split(",")]
# Outline width 字段索引 16
outline = float(fields[16])
# 4 * 1.5 = 6
assert outline == 6.0
def test_shadow_offset_scaled(self):
"""阴影偏移 x/y(Shadow 字段 17 存单值;验证 style line 中无异常)。
shadow 同时注入 \\4a/\\xshad/\\yshad 级标签,这里检查 style line。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "shadow": {"enabled": True, "offset_x": 4, "offset_y": 4, "blur": 0}},
)
# 不应崩溃;检查存在 shadow 字段
assert "TitleStyle" in content
def test_margins_scaled(self):
"""MarginL/MarginR/MarginV(字段 19/20/21)按同比例缩放。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
**self.BASE_KWARGS,
title_config={"size": 36, "position": "top"},
)
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in line.split(",")]
# 默认 TITLE_MARGIN_TOP=180, TITLE_MARGIN_SIDE=40, 1080/720=1.5
assert int(fields[19]) == 60 # margin_l = 40*1.5 = 60
assert int(fields[20]) == 60 # margin_r = 60
assert int(fields[21]) == 270 # margin_v = 180*1.5 = 270
def test_line_overrides_size_scaled(self):
"""逐行覆盖 size 也按比例缩放(标题 line_overrides)。"""
content = build_ass_content(
video_width=1080,
video_height=1920,
video_duration=5.0,
title_text="第一行\\N第二行",
title_config={
"size": 36,
"line_overrides": [{"line_index": 0, "size": 72}],
},
)
# 行 0 应注入 \\fs 标签:72 * 1.5 = 108,然后 ASS 补偿仅作用于 style fontsize,inline fs 已是 PlayRes 像素
assert "\\fs108" in content
+6 -6
View File
@@ -143,7 +143,7 @@ class TestGenerateAssFromTimeline:
generate_ass_from_timeline(
output_path,
timeline,
video_width=1280,
video_width=720,
video_height=720,
subtitle_config={
"font": "微软雅黑",
@@ -684,8 +684,8 @@ class TestGenerateAssFromTimelineMore:
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
video_width=720,
video_height=1280,
subtitle_config={"size": 48},
)
content = output.read_text(encoding="utf-8")
@@ -706,10 +706,10 @@ class TestGenerateAssFromTimelineMore:
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "720p.ass"
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
generate_ass_from_timeline(output, timeline, video_width=720, video_height=1280)
content = output.read_text(encoding="utf-8")
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
assert "PlayResX: 720" in content
assert "PlayResY: 1280" in content
def test_special_characters_in_text(self):
"""字幕文本含特殊字符(花括号、换行)"""
+15 -15
View File
@@ -31,7 +31,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
assert ",73," in content, f"默认字号36应缩放+补偿为73(36*1.5*1.35),实际内容: {content}"
def test_size_32_preserved(self):
"""size=32 应原样使用。"""
@@ -43,7 +43,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",43," in content # 32*1.35=43
assert ",65," in content # 32*1.5*1.35=65
def test_size_60_preserved(self):
"""size=60 应原样保留(字号上限已移除)。"""
@@ -58,7 +58,7 @@ class TestFontSize:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
font_size = int(fields[2])
assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
assert font_size == 122, f"字号60应缩放+补偿为122(60*1.5*1.35), 实际={font_size}"
def test_font_size_alias_normalized(self):
"""前端传 font_size 应归一化为 size。"""
@@ -72,7 +72,7 @@ class TestFontSize:
)
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
assert fields[2] == "105", f"font_size=52 应缩放+补偿为105(52*1.5*1.35), 实际={fields[2]}"
def test_font_color_alias_normalized(self):
"""前端传 font_color 应归一化为 color。"""
@@ -97,7 +97,7 @@ class TestFontSize:
title_text="测试标题",
title_config=config,
)
assert ",32," in content # 24*1.35=32.4→32
assert ",49," in content # 24*1.5*1.35=49.4→32
class TestBooleanStrokeNormalization:
@@ -117,7 +117,7 @@ class TestBooleanStrokeNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
outline_width = float(fields[16])
assert outline_width == 2.0, f"stroke=true 应产生 outline_width=2, 实际={outline_width}"
assert outline_width == 3.0, f"stroke=true 应产生 outline_width=3(2*1.5 scale), 实际={outline_width}"
def test_stroke_false_no_outline(self):
"""stroke=false 应生成 outline_width=0 的样式。"""
@@ -147,7 +147,7 @@ class TestBooleanStrokeNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
outline_width = float(fields[16])
assert outline_width == 3.0, f"自定义stroke width=3 应保留, 实际={outline_width}"
assert outline_width == 4.5, f"自定义stroke width=3 @1080p 应缩放为4.5(3*1.5), 实际={outline_width}"
class TestBooleanShadowNormalization:
@@ -167,7 +167,7 @@ class TestBooleanShadowNormalization:
fields = [f.strip() for f in style_line.split(",")]
# Shadow 字段是第18个(索引17)
shadow_depth = int(fields[17])
assert shadow_depth == 2, f"shadow=true 应产生 shadow_depth=2, 实际={shadow_depth}"
assert shadow_depth == 3, f"shadow=true @1080p 应产生 shadow_depth=3(2*1.5), 实际={shadow_depth}"
def test_shadow_false_no_shadow(self):
"""shadow=false 应生成 shadow_depth=0 的样式。"""
@@ -197,7 +197,7 @@ class TestBooleanShadowNormalization:
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
fields = [f.strip() for f in style_line.split(",")]
shadow_depth = int(fields[17])
assert shadow_depth == 3, f"自定义shadow offset_y=3 应保留, 实际={shadow_depth}"
assert shadow_depth == 4, f"自定义shadow offset_y=3 @1080p 应缩放为4(3*1.5), 实际={shadow_depth}"
class TestFullStyleConsistency:
@@ -231,14 +231,14 @@ class TestFullStyleConsistency:
# Fontname
assert fields[1] == "Noto Sans SC"
# Fontsize = 28*1.35=37.8→38
assert fields[2] == "38"
# Fontsize = 28*1.5*1.35=56.7→57
assert fields[2] == "57" # 28*1.5*1.35=57
# Bold = -1 (True)
assert fields[7] == "-1"
# Outline width = 2 (前端默认 stroke width)
assert float(fields[16]) == 2.0
# Shadow depth = 2 (offset_y)
assert int(fields[17]) == 2
# Outline width = 2*1.5=3.0 @1080p (前端默认 stroke width 经缩放)
assert float(fields[16]) == 3.0
# Shadow depth = 2*1.5=3 @1080p (offset_y 经缩放)
assert int(fields[17]) == 3
# Alignment = 8 (top)
assert int(fields[18]) == 8
+101 -25
View File
@@ -944,7 +944,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_fontfile_in_output(self, mock_font):
mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontfile=", result)
self.assertIn("NotoSansCJK", result)
@@ -952,7 +952,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_fontfile_escaped(self, mock_font):
mock_font.return_value = "/path/with:special'chars.ttf"
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontfile=", result)
@@ -963,7 +963,7 @@ class TestDrawtextFontFileNotIncluded(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_no_fontfile(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("fontfile=", result)
@@ -972,14 +972,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
"""stroke 各分支覆盖。"""
def test_stroke_non_bool_non_dict(self):
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"})
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("borderw", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_default_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
self.assertIn("bordercolor=000000", result)
@@ -987,14 +987,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_enabled_false(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("borderw", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_dict_custom_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}})
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("bordercolor=ff0000", result)
@@ -1005,7 +1005,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_dict_default_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}})
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("shadowcolor=000000", result)
self.assertIn("shadowx=5", result)
@@ -1014,7 +1014,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_dict_disabled(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}})
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("shadowcolor", result)
@@ -1022,7 +1022,8 @@ class TestDrawtextShadowBranches(unittest.TestCase):
def test_shadow_dict_custom_color(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}}
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}},
output_width=720,
)
self.assertIsNotNone(result)
self.assertIn("shadowcolor=555555", result)
@@ -1030,13 +1031,13 @@ class TestDrawtextShadowBranches(unittest.TestCase):
class TestDrawtextBoldFalse(unittest.TestCase):
def test_bold_false(self):
result = build_title_drawtext_filter({"text": "标题", "bold": False})
result = build_title_drawtext_filter({"text": "标题", "bold": False}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
def test_bold_true_does_not_use_font_bold_param(self):
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
result = build_title_drawtext_filter({"text": "标题", "bold": True})
result = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=720)
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
# 粗体应通过 borderw 实现
@@ -1047,7 +1048,7 @@ class TestDrawtextBoldFalse(unittest.TestCase):
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
不得使用与文字同色的 borderw>=3(否则会造成竖屏小字号重影)。"""
mock_font.return_value = "" # 无粗体字体
result = build_title_drawtext_filter({"text": "标题"})
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("borderw=2", result)
# 黑描边:要么是 black 关键字,要么是 000000
@@ -1059,7 +1060,8 @@ class TestDrawtextBoldFalse(unittest.TestCase):
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}},
output_width=720,
)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
@@ -1072,21 +1074,21 @@ class TestDrawtextPositionBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_top_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "top"})
result = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=50", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_center_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "center"})
result = build_title_drawtext_filter({"text": "标题", "position": "center"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=(h-text_h)/2", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_bottom_explicit(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"})
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("y=h-text_h-50", result)
@@ -1095,7 +1097,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
mock_font.return_value = ""
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*0.5000", result)
self.assertIn("y=(h-text_h)*0.3000", result)
@@ -1104,7 +1106,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
def test_position_custom_clamped_to_100(self, mock_font):
"""自定义位置:超过100的坐标被截断到100%."""
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*1.0000", result)
self.assertIn("y=(h-text_h)*1.0000", result)
@@ -1112,7 +1114,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_bool_coords_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True})
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)/2", result)
self.assertIn("y=50", result)
@@ -1120,7 +1122,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
class TestDrawtextColorNoHash(unittest.TestCase):
def test_color_without_hash(self):
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"})
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontcolor=red", result)
@@ -1131,27 +1133,27 @@ class TestDrawtextFieldNormalization(unittest.TestCase):
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_content_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"content": "备用标题"})
result = build_title_drawtext_filter({"content": "备用标题"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("备用标题", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_font_preset_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"})
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}, output_width=720)
self.assertIsNotNone(result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_size_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "size": 72})
result = build_title_drawtext_filter({"text": "标题", "size": 72}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontsize=72", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_color_fallback(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"})
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}, output_width=720)
self.assertIsNotNone(result)
self.assertIn("fontcolor=abcdef", result)
@@ -1256,5 +1258,79 @@ class TestTitleOverlay(unittest.TestCase):
)
class TestDrawtextFontsizeScaling(unittest.TestCase):
"""字号/描边/阴影/边距按 video_width/720 等比缩放(工单:标题字号校准)。
前端标题面板的 size 语义是"px @720p"(见 web/src/pages/generate/types.ts 注释、
apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale=videoWidth/720)。
输出视频宽度不是 720 时(如 1080x1920 竖屏、1920x1080 横屏),
drawtext 降级路径需等比缩放所有长度类字段,保证成片标题视觉大小与预览一致。
"""
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_720p_no_scaling(self, mock_font):
"""720p 宽度时 fontsize 保持原值。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=720)
self.assertIn("fontsize=28", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_1080p_scales_1_5x(self, mock_font):
"""1080 宽度(1080x1920 竖屏)时 size=28 → 42。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1080)
self.assertIn("fontsize=42", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_1920p_scales_2_67x(self, mock_font):
"""1920 宽度(横屏 1920x1080)时 size=28 → 75(四舍五入)。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1920)
self.assertIn("fontsize=75", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_stroke_width_scaled(self, mock_font):
"""描边宽度按同比例缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter(
{"text": "标题", "stroke": {"width": 4, "color": "#ff0000"}},
output_width=1080,
)
# 4 * 1080/720 = 6
self.assertIn("borderw=6", r)
self.assertIn("bordercolor=ff0000", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_shadow_offset_scaled(self, mock_font):
"""阴影偏移按同比例缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter(
{"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}},
output_width=1080,
)
# 5 * 1.5 = 7.5 → 8
self.assertIn("shadowx=8", r)
self.assertIn("shadowy=8", r)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_offsets_scaled(self, mock_font):
"""top/bottom 距边缘的 50px 边距也按比例缩放。"""
mock_font.return_value = ""
r_top = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=1080)
self.assertIn("y=75", r_top) # 50 * 1.5 = 75
r_bot = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=1080)
self.assertIn("y=h-text_h-75", r_bot)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_stroke_scaled(self, mock_font):
"""粗体黑色描边默认宽度 2 也需缩放。"""
mock_font.return_value = ""
r = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=1080)
# bold 默认 border_width=2 → 2*1.5=3
self.assertIn("borderw=3", r)
if __name__ == "__main__":
unittest.main()