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
## 问题
用户反馈最终生成视频中标题字体比模板预览/选择时看到的小很多。
前端 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
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""标题渲染前后端一致性测试。
|
||
|
||
验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致:
|
||
- 字号不再设置上限,由前端/调用方控制
|
||
- 描边宽度 2px
|
||
- 阴影 blur=4, offset=2
|
||
- boolean stroke/shadow 自动转换
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
# 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 TestFontSize:
|
||
"""字号处理:默认值与保留逻辑,不再做上限截断。"""
|
||
|
||
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 ",73," in content, f"默认字号36应缩放+补偿为73(36*1.5*1.35),实际内容: {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 ",65," in content # 32*1.5*1.35=65
|
||
|
||
def test_size_60_preserved(self):
|
||
"""size=60 应原样保留(字号上限已移除)。"""
|
||
config = {"size": 60}
|
||
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(",")]
|
||
font_size = int(fields[2])
|
||
assert font_size == 122, f"字号60应缩放+补偿为122(60*1.5*1.35), 实际={font_size}"
|
||
|
||
def test_font_size_alias_normalized(self):
|
||
"""前端传 font_size 应归一化为 size。"""
|
||
config = {"font_size": 52}
|
||
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(",")]
|
||
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。"""
|
||
config = {"font_color": "#00FF00"}
|
||
content = build_ass_content(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="测试标题",
|
||
title_config=config,
|
||
)
|
||
# 绿色 → &H00FF00
|
||
assert "&H00FF00" in content
|
||
|
||
def test_size_24_preserved(self):
|
||
"""size=24 应原样使用。"""
|
||
config = {"size": 24}
|
||
content = build_ass_content(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="测试标题",
|
||
title_config=config,
|
||
)
|
||
assert ",49," in content # 24*1.5*1.35=49.4→32
|
||
|
||
|
||
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 == 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 的样式。"""
|
||
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 == 4.5, f"自定义stroke width=3 @1080p 应缩放为4.5(3*1.5), 实际={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 == 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 的样式。"""
|
||
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 == 4, f"自定义shadow offset_y=3 @1080p 应缩放为4(3*1.5), 实际={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] == "Noto Sans SC"
|
||
# 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*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
|
||
|
||
|
||
class TestTitleSlashNewline:
|
||
"""用户输入 / 或 / 应触发标题换行。"""
|
||
|
||
def test_halfwidth_slash_in_title(self):
|
||
content = build_ass_content(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="第一行/第二行",
|
||
title_config={"size": 48},
|
||
)
|
||
for line in content.splitlines():
|
||
if "Dialogue" in line and "TitleStyle" in line:
|
||
assert "\\N" in line, f"斜杠应转为换行: {line}"
|
||
assert "第一行" in line
|
||
assert "第二行" in line
|
||
break
|
||
else:
|
||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||
|
||
def test_fullwidth_slash_in_title(self):
|
||
content = build_ass_content(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="第一行/第二行",
|
||
title_config={"size": 48},
|
||
)
|
||
for line in content.splitlines():
|
||
if "Dialogue" in line and "TitleStyle" in line:
|
||
assert "\\N" in line, f"全角斜杠应转为换行: {line}"
|
||
break
|
||
else:
|
||
pytest.fail("未找到 TitleStyle Dialogue 行")
|