Files
xiaoxia-saas/tests/unit/domain/test_ass_subtitle_builder.py
T
saas-backend-agent 4308621845
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m44s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
fix(title): 标题/字幕字号按 video_width/720 等比缩放,修复成片标题比预览过小
## 问题
用户反馈最终生成视频中标题字体比模板预览/选择时看到的小很多。
前端 titleCanvas.ts 已明确 titleConfig.size 的语义是「720p 基准宽度下的 px 字号」,
按 scale = videoWidth/720 缩放渲染到预览 Canvas;但后端三个渲染路径
(ass_subtitle_builder / drawtext 降级 / subtitle_generator ASR)直接使用原始 size 值,
在 1080×1920 竖屏/1920×1080 横屏等非 720p 输出下,标题字号未随分辨率等比放大,
导致成片标题明显小于前端预览。

此外 apps/worker/video_processing/subtitle_generator.py 中存在
`min(int(title_cfg.get("size", 36)), 36)` 的上限钳位,任何分辨率下标题字号
都会被强行压到 ≤36,进一步放大了问题。

## 修复
三个渲染路径统一以 720p 为基准,按 video_width/720 等比缩放所有长度类参数:

- packages/domain/ass_subtitle_builder.py
  - 新增 _scale_len(value, video_width) 工具:整数输入返回 int,浮点输入保留 float
  - build_ass_content 内新增 _scale_cfg(cfg, defaults) 内部函数:
    先 setdefault 填充 size 默认值(title=36、subtitle=24),再统一缩放
    size/font_size、margin_top/bg_padding/bg_radius、line_overrides[*].size;
    pos_x/pos_y 是百分比(0-100)不缩放
  - 描边宽(默认 2)、阴影 blur(默认 4)、阴影 offset_x/y(默认 2)在字段提取
    处经 _scale_len 缩放,避免双重缩放
  - TITLE_MARGIN_TOP/BOTTOM/SIDE 常量经 _scale_len 成局部 _margin_top/bottom/side
  - subtitle outline_width(1.0)经 _scale_len 缩放(保持 float 以支持 1.5 等小数)

- packages/domain/video_filter_builder.py (build_title_drawtext_filter 降级路径)
  - 新增 _scale_title_len(value, output_width) 工具
  - font_size、border_width(描边)、shadowx/shadowy(阴影偏移)、top/bottom 位置
    y 偏移(50px)统一按 output_width/720 缩放

- apps/worker/video_processing/subtitle_generator.py (ASR 时间轴字幕路径)
  - 复用 ass_subtitle_builder._scale_len
  - subtitle: font_size 默认 24、outline_width 默认 1.5、margin_v/l/r 默认 60/40/40
    全部按 video_width/720 缩放
  - title: 移除 min(..., 36) 上限钳位;s_width/sh_blur/sh_offset、margin_top/side
    统一缩放;_wrap_title_text 传入缩放后的 margin_l/r

## 不缩放的字段
颜色/字体/对齐/粗体/斜体等枚举/布尔值不缩放;
pos_x/pos_y 为百分比(0-100)不缩放;
AI 数字人 WYSIWYG PNG overlay 路径(build_title_overlay_filter)由前端 Canvas 按
videoWidth 直接绘制,后端只 overlay=0:0 叠加,无需后端缩放。

## 测试
- 既有断言固定值的单测:将 video_width 改为 720(720p 基准下缩放比=1,断言值不变)
- 新增 TestTitleFontsizeScaling / TestDrawtextFontsizeScaling 共 16 个测试用例:
  720p 不变、1080p 1.5×、1920p 8/3×、描边宽/阴影偏移/边距/位置边距/逐行覆盖 size
  等场景
- 修正 test_subtitle_generator.py::test_720p_resolution 中 PlayResX/Y 与
  video_width/height 互换的断言错误
- 全量单测:15978 passed, 2 failed(均为 test_1970_musetalk_server.py 中
  ffmpeg 超时/FileNotFound 的 flaky,与本次改动无关,在 develop HEAD 上复现)
  28 skipped
2026-09-23 22:06:45 +08:00

625 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ass_subtitle_builder 单元测试 - wave169
覆盖:
- hex_to_ass_color 颜色转换
- position_to_ass_alignment 位置对齐映射
- build_ass_style Style行构建
- escape_ass_text 文本转义
- format_ass_time 时间格式化
- build_ass_content 完整ASS内容生成
"""
import pytest
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_content,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
# ============================================================
# hex_to_ass_color
# ============================================================
class TestHexToAssColor:
def test_red(self):
# #FF0000 → &H0000FF (BBGGRR)
assert hex_to_ass_color("#FF0000") == "&H0000FF"
def test_blue(self):
# #0000FF → &HFF0000
assert hex_to_ass_color("#0000FF") == "&HFF0000"
def test_green(self):
# #00FF00 → &H00FF00
assert hex_to_ass_color("#00FF00") == "&H00FF00"
def test_white(self):
assert hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
def test_black(self):
assert hex_to_ass_color("#000000") == "&H000000"
def test_without_hash_prefix(self):
assert hex_to_ass_color("FF0000") == "&H0000FF"
def test_mixed_case(self):
assert hex_to_ass_color("#aBcDeF") == "&HEFCDAB"
def test_invalid_length_short(self):
assert hex_to_ass_color("#FFF") == "&H000000"
def test_invalid_length_long(self):
assert hex_to_ass_color("#FF0000FF") == "&H000000"
def test_empty_string(self):
assert hex_to_ass_color("") == "&H000000"
def test_uppercase_output(self):
result = hex_to_ass_color("#abcdef")
assert result == result.upper()
# ============================================================
# position_to_ass_alignment
# ============================================================
class TestPositionToAssAlignment:
def test_top(self):
assert position_to_ass_alignment("top") == 8
def test_center(self):
assert position_to_ass_alignment("center") == 5
def test_bottom(self):
assert position_to_ass_alignment("bottom") == 2
def test_unknown_defaults_bottom(self):
assert position_to_ass_alignment("unknown") == 2
def test_empty_defaults_bottom(self):
assert position_to_ass_alignment("") == 2
# ============================================================
# build_ass_style
# ============================================================
class TestBuildAssStyle:
def test_minimal_style(self):
result = build_ass_style("Default")
assert result.startswith("Style: Default,")
def test_contains_font_name(self):
result = build_ass_style("S1", font_name="Arial")
assert "Arial" in result
def test_contains_font_size(self):
result = build_ass_style("S1", font_size=36)
# Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49)
parts = result.split(",")
assert parts[2] == "49"
def test_bold_true(self):
result = build_ass_style("S1", bold=True)
parts = result.split(",")
# Bold 是第7个字段(索引7)
assert parts[7] == "-1"
def test_bold_false(self):
result = build_ass_style("S1", bold=False)
parts = result.split(",")
assert parts[7] == "0"
def test_italic_true(self):
result = build_ass_style("S1", italic=True)
parts = result.split(",")
# Italic 是第8个字段(索引8)
assert parts[8] == "-1"
def test_italic_false(self):
result = build_ass_style("S1", italic=False)
parts = result.split(",")
assert parts[8] == "0"
def test_alignment(self):
result = build_ass_style("S1", alignment=5)
parts = result.split(",")
# Alignment 是第18个字段(索引18)
assert parts[18] == "5"
def test_outline_width(self):
result = build_ass_style("S1", outline_width=3.0)
parts = result.split(",")
# Outline 是第16个字段(索引16)
assert parts[16] == "3.0"
def test_margins(self):
result = build_ass_style("S1", margin_l=10, margin_r=20, margin_v=30)
parts = result.split(",")
assert parts[19] == "10" # MarginL
assert parts[20] == "20" # MarginR
assert parts[21] == "30" # MarginV
def test_shadow_with_blur(self):
result = build_ass_style("S1", shadow_blur=2.0, shadow_offset=(3, 5))
parts = result.split(",")
# Shadow 深度 = shadow_offset[1] when blur > 0
assert parts[17] == "5"
def test_shadow_without_blur(self):
result = build_ass_style("S1", shadow_blur=0.0, shadow_offset=(3, 5))
parts = result.split(",")
assert parts[17] == "0"
def test_primary_color(self):
result = build_ass_style("S1", primary_color="&H00FFFFFF")
parts = result.split(",")
assert parts[3] == "&H00FFFFFF"
def test_outline_color(self):
result = build_ass_style("S1", outline_color="&H000000FF")
parts = result.split(",")
assert parts[5] == "&H000000FF"
def test_22_fields(self):
# ASS Style 行应有23个字段(Style: 前缀 + 22个逗号分隔字段)
result = build_ass_style("Default")
parts = result.split(",")
assert len(parts) >= 22 # 至少22个字段
# ============================================================
# escape_ass_text
# ============================================================
class TestEscapeAssText:
def test_plain_text_unchanged(self):
assert escape_ass_text("Hello World") == "Hello World"
def test_newline_converted(self):
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_crlf_converted(self):
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
def test_carriage_return_converted(self):
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
def test_curly_braces_escaped(self):
assert escape_ass_text("{text}") == "(text)"
def test_opening_brace_escaped(self):
assert escape_ass_text("{hello") == "(hello"
def test_closing_brace_escaped(self):
assert escape_ass_text("hello}") == "hello)"
def test_multiple_braces(self):
assert escape_ass_text("{a}{b}") == "(a)(b)"
def test_mixed_newlines_and_braces(self):
result = escape_ass_text("line1\n{tag}line2")
assert result == "line1\\N(tag)line2"
def test_empty_string(self):
assert escape_ass_text("") == ""
def test_chinese_text(self):
assert escape_ass_text("你好世界") == "你好世界"
def test_slash_converted_to_newline(self):
"""半角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
def test_fullwidth_slash_converted_to_newline(self):
"""全角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
def test_backslash_n_in_input(self):
# 文本里本身有 \n 字符串(不是换行符)
result = escape_ass_text("\\n")
assert result == "\\n" # 不变,因为不是实际换行符
# ============================================================
# format_ass_time
# ============================================================
class TestFormatAssTime:
def test_zero(self):
assert format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
assert format_ass_time(5.5) == "0:00:05.50"
def test_minutes(self):
assert format_ass_time(90.0) == "0:01:30.00"
def test_hours(self):
assert format_ass_time(3661.5) == "1:01:01.50"
def test_multi_hours(self):
assert format_ass_time(7384.25) == "2:03:04.25"
def test_two_decimal_places(self):
result = format_ass_time(1.234)
# 两位小数
assert result.endswith(".23") or result.endswith(".24")
def test_minutes_two_digits(self):
result = format_ass_time(65.0)
parts = result.split(":")
assert len(parts[1]) == 2
assert parts[1] == "01"
def test_seconds_two_digits_before_decimal(self):
result = format_ass_time(5.0)
parts = result.split(":")
sec_part = parts[2]
assert sec_part.startswith("05")
def test_float_input(self):
assert format_ass_time(123.45) == "0:02:03.45"
def test_exactly_one_hour(self):
assert format_ass_time(3600.0) == "1:00:00.00"
# ============================================================
# build_ass_content
# ============================================================
class TestBuildAssContent:
def test_no_subtitles_returns_empty(self):
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=720,
video_height=1280,
video_duration=5.0,
title_text="Test Title",
)
assert result != ""
assert "[Script Info]" 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
assert "Test Title" in result
def test_subtitle_only(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Hello Subtitle",
)
assert result != ""
assert "SubtitleStyle" in result
assert "Hello Subtitle" in result
def test_both_title_and_subtitle(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
subtitle_text="Subtitle",
)
assert "TitleStyle" in result
assert "SubtitleStyle" in result
assert "Title" in result
assert "Subtitle" in result
def test_whitespace_title_returns_empty(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text=" ",
)
assert result == ""
def test_whitespace_subtitle_returns_empty(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text=" \n ",
)
assert result == ""
def test_title_disabled(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="Title",
title_config={"enabled": False},
)
assert result == ""
def test_subtitle_disabled(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="Sub",
subtitle_config={"enabled": False},
)
assert result == ""
def test_title_color(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"color": "#FF0000"},
)
# 红色 → &H0000FF
assert "&H0000FF" in result
def test_title_position_top(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "top"},
)
# top alignment = 8
assert "TitleStyle" in result
def test_title_position_bottom(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"position": "bottom"},
)
# bottom=2, 检查Style行里有2
assert "TitleStyle" in result
def test_subtitle_position_bottom(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
subtitle_text="S",
subtitle_config={"position": "bottom"},
)
assert "SubtitleStyle" in result
def test_title_font_size(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 72},
)
# 在TitleStyle行里查找字体大小(字号上限已移除,72应原样保留)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "97" # 72*1.35=97.2→97
break
def test_title_font_size_frontend_field_alias(self):
"""前端传 font_size 应归一化为内部 size 字段。"""
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_size": 48},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "65" # 48*1.35=64.8→65
break
def test_title_font_color_frontend_field_alias(self):
"""前端传 font_color 应归一化为内部 color 字段。"""
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"font_color": "#FF0000"},
)
# 红色 → &H0000FF
assert "&H0000FF" in result
def test_title_size_takes_precedence_over_font_size(self):
"""同时传 size 和 font_size 时,size 优先。"""
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"size": 56, "font_size": 28},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "76" # 56*1.35=75.6→76
break
def test_title_bold(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"bold": True},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[7] == "-1"
break
def test_title_stroke_enabled(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[16] == "3.0"
break
def test_title_stroke_disabled(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=5.0,
title_text="T",
title_config={"stroke": {"enabled": False, "width": 3}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[16] == "0.0"
break
def test_title_shadow_enabled(self):
result = build_ass_content(
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}},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[17] == "4" # Shadow = offset_y
break
def test_dialogue_has_correct_timing(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=65.5,
title_text="T",
)
# 结束时间应该是 0:01:05.50
assert "0:01:05.50" in result
def test_dialogue_starts_at_zero(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=10.0,
subtitle_text="S",
)
assert "0:00:00.00" in result
def test_contains_script_info_header(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="T",
)
assert "[Script Info]" in result
assert "ScriptType: v4.00+" in result
assert "ScaledBorderAndShadow: yes" in result
def test_escaped_text_in_dialogue(self):
result = build_ass_content(
video_width=720,
video_height=1280,
video_duration=10.0,
title_text="line1\nline2",
)
# 换行符应被转义为 \N
assert "\\N" in result
assert "line1" in result
assert "line2" in result
# ============================================================
# 常量验证
# ============================================================
class TestConstants:
def test_margin_values(self):
assert TITLE_MARGIN_TOP > 0
assert TITLE_MARGIN_BOTTOM > 0
assert TITLE_MARGIN_SIDE > 0
def test_margins_are_integers(self):
assert isinstance(TITLE_MARGIN_TOP, int)
assert isinstance(TITLE_MARGIN_BOTTOM, int)
assert isinstance(TITLE_MARGIN_SIDE, int)
# ============================================================
# _wrap_title_text 换行逻辑验证
# ============================================================
class TestWrapTitleTextFontSizeConsistency:
"""验证换行计算使用原始 font_size,与 CSS 预览一致。"""
def test_font_size_113_uses_original_not_compensated(self):
"""font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。"""
# 标题"永康拾掇脚阔头"共7个字
# 可用宽度 = 1080 - 60 - 60 = 960px
# 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字
# 7个字 < 8个字,应该在一行内
title = "永康拾掇脚阔头"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 不应该有换行
assert "\\N" not in result
assert result == title
def test_long_title_wraps_correctly(self):
"""长标题应该按 font_size 字宽正确换行。"""
# 16个中文字,每行8个字,应该换行为2行
title = "永康拾掇脚阔头来一个笑一个哈哈哈"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 应该有一个换行
assert result.count("\\N") == 1
# 每行8个字
lines = result.split("\\N")
assert len(lines) == 2
assert len(lines[0]) == 8
assert len(lines[1]) == 8
def test_mixed_cjk_and_ascii(self):
"""混合中英文时,英文按半角宽度计算。"""
# "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px
title = "测试test"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 总宽度474.6px < 960px,应该在一行内
assert "\\N" not in result