diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index 85fa85a9d..a2579c1b9 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -247,6 +247,27 @@ def build_ass_content( title_config = title_config or {} subtitle_config = subtitle_config or {} + # ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ── + # 前端 TitleSettings 发送 stroke=true/false, shadow=true/false + # 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y} + if title_config: + _stroke_val = title_config.get("stroke") + if isinstance(_stroke_val, bool): + title_config["stroke"] = { + "enabled": _stroke_val, + "color": "#000000", + "width": 2, + } if _stroke_val else {"enabled": False} + _shadow_val = title_config.get("shadow") + if isinstance(_shadow_val, bool): + title_config["shadow"] = { + "enabled": _shadow_val, + "color": "#000000", + "blur": 4, + "offset_x": 2, + "offset_y": 2, + } if _shadow_val else {"enabled": False} + title_enabled = title_config.get("enabled", True) and bool(title_text.strip()) subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip()) @@ -262,7 +283,7 @@ 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", 1)) if title_stroke.get("enabled", False) else 0.0 + 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 shadow_offset = ( title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0, @@ -275,7 +296,7 @@ def build_ass_content( build_ass_style( "TitleStyle", font_name=title_config.get("font", "思源黑体"), - font_size=int(title_config.get("size", 48)), + font_size=min(int(title_config.get("size", 36)), 36), primary_color=title_color, outline_color=stroke_color, outline_width=stroke_width, @@ -292,7 +313,7 @@ def build_ass_content( # 根据视频宽度和字号自动换行标题,防止超出画面 # 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义 - title_font_size = int(title_config.get("size", 48)) + title_font_size = min(int(title_config.get("size", 36)), 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) diff --git a/tests/unit/test_title_render_consistency.py b/tests/unit/test_title_render_consistency.py new file mode 100644 index 000000000..320be819f --- /dev/null +++ b/tests/unit/test_title_render_consistency.py @@ -0,0 +1,215 @@ +"""标题渲染前后端一致性测试。 + +验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致: +- 字号上限 36px +- 描边宽度 2px +- 阴影 blur=4, offset=2 +- boolean stroke/shadow 自动转换 +""" + +import sys +from pathlib import Path + +# Ensure packages is importable +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages")) + +from domain.ass_subtitle_builder import build_ass_content, build_ass_style + + +class TestFontSizeCap: + """字号上限应与前端 Math.min(settings.size, 36) 一致。""" + + def test_default_font_size_is_36(self): + """无 size 字段时,默认字号应为 36。""" + config = {"text": "test"} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + assert ",36," in content, f"默认字号应为36,实际内容: {content}" + + def test_size_32_preserved(self): + """size=32 应原样使用。""" + config = {"size": 32} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + assert ",32," in content + + def test_size_60_capped_at_36(self): + """size=60 应被 cap 到 36。""" + config = {"size": 60} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + # 解析 Style 行的 Fontsize 字段(第3个字段,索引2) + 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 == 36, f"字号60应被cap到36, 实际={font_size}" + + def test_size_24_preserved(self): + """size=24 应原样使用(小于36,不cap)。""" + config = {"size": 24} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + assert ",24," in content + + +class TestBooleanStrokeNormalization: + """前端 stroke=true/false 应自动转换为标准 dict。""" + + def test_stroke_true_enables_outline(self): + """stroke=true 应生成 outline_width=2 的样式。""" + config = {"stroke": True} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + # 解析 Style 行的 Outline 字段(第17个字段,索引16) + 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}" + + def test_stroke_false_no_outline(self): + """stroke=false 应生成 outline_width=0 的样式。""" + config = {"stroke": False} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + 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 == 0.0, f"stroke=false 应产生 outline_width=0, 实际={outline_width}" + + def test_stroke_dict_still_works(self): + """stroke={enabled:true, width:3} 仍应正常工作。""" + config = {"stroke": {"enabled": True, "width": 3, "color": "#FF0000"}} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + 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}" + + +class TestBooleanShadowNormalization: + """前端 shadow=true/false 应自动转换为标准 dict。""" + + def test_shadow_true_enables_shadow(self): + """shadow=true 应生成 shadow_depth=2 的样式。""" + config = {"shadow": True} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + 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}" + + def test_shadow_false_no_shadow(self): + """shadow=false 应生成 shadow_depth=0 的样式。""" + config = {"shadow": False} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + 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 == 0, f"shadow=false 应产生 shadow_depth=0, 实际={shadow_depth}" + + def test_shadow_dict_still_works(self): + """shadow={enabled:true, blur:8} 仍应正常工作。""" + config = {"shadow": {"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3}} + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, + ) + 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}" + + +class TestFullStyleConsistency: + """完整样式参数一致性测试。""" + + def test_frontend_default_style_matches_backend(self): + """前端默认样式参数应在后端产生一致的 ASS 输出。 + + 前端默认:font_size=24(或用户设置), bold=false, stroke=true, shadow=true, color=#FFFFFF + """ + config = { + "text": "标题文本", + "font": "思源黑体", + "size": 28, + "color": "#FFFFFF", + "bold": True, + "italic": False, + "stroke": True, + "shadow": True, + "position": "top", + } + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="标题文本", + title_config=config, + ) + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + fields = [f.strip() for f in style_line.split(",")] + + # Fontname + assert fields[1] == "思源黑体" + # Fontsize = 28 (小于36,不cap) + assert fields[2] == "28" + # 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 + # Alignment = 8 (top) + assert int(fields[18]) == 8