From a9b93cc220a46dd8c780193c1b4447a5b6a9d48a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 16 Aug 2026 18:38:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=A0=87=E9=A2=98=E6=B8=B2=E6=9F=93=E5=8F=82=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8F=8F=E8=BE=B9/=E9=98=B4?= =?UTF-8?q?=E5=BD=B1=E6=A0=B7=E5=BC=8F=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: 1. 前端 TitleSettings 发送 stroke/shadow 为 boolean (true/false), 后端 build_ass_content 期望 stroke={enabled,color,width} dict 格式 → 类型不匹配导致描边/阴影样式被丢弃 2. 前端字号上限 Math.min(size,36),后端默认48 → 字号不一致 3. 前端描边宽度2px,后端默认1px 修复: - ass_subtitle_builder.py: boolean stroke/shadow 自动转换为标准dict stroke=true → {enabled:true, color:#000, width:2} shadow=true → {enabled:true, color:#000, blur:4, offset:2} - 字号上限从48改为36(与前端一致) - 描边宽度默认从1改为2(与前端一致) - 11个新测试验证前后端样式一致性 --- packages/domain/ass_subtitle_builder.py | 27 ++- tests/unit/test_title_render_consistency.py | 181 ++++++++++++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_title_render_consistency.py 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..2edd58fe8 --- /dev/null +++ b/tests/unit/test_title_render_consistency.py @@ -0,0 +1,181 @@ +"""标题渲染前后端一致性测试。 + +验证 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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 = [l for l in content.splitlines() if l.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 -- 2.54.0 From 02d2c7ea6579b7370dca6c366083b18f1d5ffe42 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 16 Aug 2026 10:43:51 +0000 Subject: [PATCH 2/3] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_title_render_consistency.py | 78 +++++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/tests/unit/test_title_render_consistency.py b/tests/unit/test_title_render_consistency.py index 2edd58fe8..801dc77fa 100644 --- a/tests/unit/test_title_render_consistency.py +++ b/tests/unit/test_title_render_consistency.py @@ -6,6 +6,7 @@ - 阴影 blur=4, offset=2 - boolean stroke/shadow 自动转换 """ + import sys from pathlib import Path @@ -22,8 +23,11 @@ class TestFontSizeCap: """无 size 字段时,默认字号应为 36。""" config = {"text": "test"} content = build_ass_content( - video_width=1080, video_height=1920, video_duration=10.0, - title_text="测试标题", title_config=config, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) assert ",36," in content, f"默认字号应为36,实际内容: {content}" @@ -31,8 +35,11 @@ class TestFontSizeCap: """size=32 应原样使用。""" config = {"size": 32} content = build_ass_content( - video_width=1080, video_height=1920, video_duration=10.0, - title_text="测试标题", title_config=config, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) assert ",32," in content @@ -40,8 +47,11 @@ class TestFontSizeCap: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) # 解析 Style 行的 Fontsize 字段(第3个字段,索引2) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] @@ -53,8 +63,11 @@ class TestFontSizeCap: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) assert ",24," in content @@ -66,8 +79,11 @@ class TestBooleanStrokeNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) # 解析 Style 行的 Outline 字段(第17个字段,索引16) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] @@ -79,8 +95,11 @@ class TestBooleanStrokeNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] @@ -91,8 +110,11 @@ class TestBooleanStrokeNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] @@ -107,8 +129,11 @@ class TestBooleanShadowNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] @@ -120,8 +145,11 @@ class TestBooleanShadowNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] @@ -132,8 +160,11 @@ class TestBooleanShadowNormalization: """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, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="测试标题", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] @@ -161,8 +192,11 @@ class TestFullStyleConsistency: "position": "top", } content = build_ass_content( - video_width=1080, video_height=1920, video_duration=10.0, - title_text="标题文本", title_config=config, + video_width=1080, + video_height=1920, + video_duration=10.0, + title_text="标题文本", + title_config=config, ) style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] -- 2.54.0 From 2f7789f10b8299a3cac9d52201e1fbc7bfa3443f Mon Sep 17 00:00:00 2001 From: xiaoxia-ci Date: Sun, 16 Aug 2026 18:52:02 +0800 Subject: [PATCH 3/3] fix: rename ambiguous variable 'l' to 'line' in tests (E741) Fix 8 flake8 E741 errors in test_title_render_consistency.py where list comprehension used 'l' as variable name. --- tests/unit/test_title_render_consistency.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_title_render_consistency.py b/tests/unit/test_title_render_consistency.py index 801dc77fa..320be819f 100644 --- a/tests/unit/test_title_render_consistency.py +++ b/tests/unit/test_title_render_consistency.py @@ -54,7 +54,7 @@ class TestFontSizeCap: title_config=config, ) # 解析 Style 行的 Fontsize 字段(第3个字段,索引2) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -86,7 +86,7 @@ class TestBooleanStrokeNormalization: title_config=config, ) # 解析 Style 行的 Outline 字段(第17个字段,索引16) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -101,7 +101,7 @@ class TestBooleanStrokeNormalization: title_text="测试标题", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -116,7 +116,7 @@ class TestBooleanStrokeNormalization: title_text="测试标题", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -135,7 +135,7 @@ class TestBooleanShadowNormalization: title_text="测试标题", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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]) @@ -151,7 +151,7 @@ class TestBooleanShadowNormalization: title_text="测试标题", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -166,7 +166,7 @@ class TestBooleanShadowNormalization: title_text="测试标题", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + 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}" @@ -198,7 +198,7 @@ class TestFullStyleConsistency: title_text="标题文本", title_config=config, ) - style_line = [l for l in content.splitlines() if l.startswith("Style: TitleStyle")][0] + style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] # Fontname -- 2.54.0