test(wave120): 抽离ass_subtitle_builder领域模型 + 68单测 #1016

Closed
xiaoxia wants to merge 1 commits from test/wave120-ass-subtitle-builder into develop
3 changed files with 791 additions and 203 deletions
+37 -203
View File
@@ -1,8 +1,8 @@
"""ASS 字幕生成模块 — 从 unified_render_service.py 拆分.
"""ASS 字幕生成模块 — 薄包装,实际逻辑在 packages/domain/ass_subtitle_builder.py.
职责:
- 将 title / subtitle 配置转换为 ASS 字幕文件
- 提供样式计算(颜色、对齐、描边/阴影)
- 文件IO 在此模块,纯逻辑已抽离到 domain
- 供 UnifiedRenderService._maybe_generate_ass 调用
"""
@@ -12,107 +12,40 @@ import logging
from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import ( # noqa: F401 — 向后兼容
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
build_ass_style as _build_ass_style_base,
escape_ass_text as _escape_ass_text_base,
format_ass_time as _format_ass_time_base,
hex_to_ass_color as _hex_to_ass_color_base,
position_to_ass_alignment as _position_to_ass_alignment_base,
)
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── ASS 字幕工具 ─────────────────────────────────────────────────────────────
# 向后兼容:模块级函数保留为薄包装
def _hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
return _hex_to_ass_color_base(hex_color)
def _position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号。
ASS 对齐编号(数字小键盘布局):
7 8 9
4 5 6
1 2 3
"""
mapping = {
"top": 8, # 顶部居中
"center": 5, # 居中
"bottom": 2, # 底部居中
}
return mapping.get(position, 8)
return _position_to_ass_alignment_base(position)
def _build_ass_style(
style_name: str,
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行。
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制)
# Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素),
# 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现
# 简化:BorderStyle=1outline + drop shadow),Shadow 字段表示阴影深度
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
def _build_ass_style(*args, **kwargs) -> str:
return _build_ass_style_base(*args, **kwargs)
def _escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符。
ASS 中换行用 \N(硬换行)或 \n(软换行),
大括号 {} 用于覆盖样式,需要转义。
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
return _escape_ass_text_base(text)
def _format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
return _format_ass_time_base(seconds)
def generate_ass_subtitles(
@@ -126,130 +59,31 @@ def generate_ass_subtitles(
subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None,
) -> Path:
"""生成 ASS 字幕文件
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
各自可独立配置样式、位置和内容。
"""生成 ASS 字幕文件.
Args:
output_path: 输出 ASS 文件路径
video_width: 视频宽度(用于 ASS PlayResX
video_height: 视频高度(用于 ASS PlayResY
video_duration: 视频总时长(秒),字幕显示整个时长
video_width: 视频宽度
video_height: 视频高度
video_duration: 视频总时长(秒)
title_text: 标题文本
title_config: 标题样式配置TitleConfig dict
title_config: 标题样式配置
subtitle_text: 字幕文本
subtitle_config: 字幕样式配置SubtitleConfig dict
subtitle_config: 字幕样式配置
Returns:
生成的 ASS 文件路径
"""
title_config = title_config or {}
subtitle_config = subtitle_config or {}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
if not title_enabled and not subtitle_enabled:
# 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用)
output_path.write_text("", encoding="utf-8")
return output_path
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = _hex_to_ass_color(title_config.get("color", "#ffffff"))
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
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,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = _position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
_build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
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,
)
)
# 转义 ASS 特殊字符
safe_title_text = _escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{_format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = _position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
_build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
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,
)
)
safe_subtitle_text = _escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{_format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
content = build_ass_content(
video_width=video_width,
video_height=video_height,
video_duration=video_duration,
title_text=title_text,
title_config=title_config,
subtitle_text=subtitle_text,
subtitle_config=subtitle_config,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8")
output_path.write_text(content, encoding="utf-8")
return output_path
+306
View File
@@ -0,0 +1,306 @@
"""ASS 字幕构建领域模型 — 纯逻辑,无文件IO依赖.
抽离自 render_subtitles.py,包含:
- 颜色转换(hex → ASS &HBBGGRR
- 位置对齐映射
- ASS Style 行构建
- 文本转义
- 时间格式化
- 完整 ASS 内容生成(返回字符串,不写文件)
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
def hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式.
Args:
hex_color: HEX 颜色字符串,支持 #RRGGBB 或 RRGGBB 格式
Returns:
ASS 格式颜色,如 &H0000FF(红色)
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
# ── 位置对齐 ──────────────────────────────────────────────────────────────────
def position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \\an 对齐编号.
ASS 对齐编号(数字小键盘布局):
7 8 9
4 5 6
1 2 3
Args:
position: 位置字符串 top/center/bottom
Returns:
ASS 对齐编号,默认 8(顶部居中)
"""
mapping = {
"top": 8,
"center": 5,
"bottom": 2,
}
return mapping.get(position, 8)
# ── Style 行构建 ──────────────────────────────────────────────────────────────
def build_ass_style(
style_name: str,
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行.
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Args:
style_name: 样式名称
font_name: 字体名称
font_size: 字体大小
primary_color: 主色(文字颜色)
outline_color: 描边颜色
outline_width: 描边宽度
shadow_blur: 阴影模糊度(>0 时启用阴影)
shadow_offset: 阴影偏移 (x, y)
bold: 是否粗体
italic: 是否斜体
alignment: 对齐方式(ASS \an 编号)
margin_v: 垂直边距
margin_l: 左边距
margin_r: 右边距
Returns:
完整的 Style: 行字符串
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color
# Shadow 深度:shadow_offset[1] 作为纵向偏移
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
# ── 文本转义 ──────────────────────────────────────────────────────────────────
def escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符.
ASS 中换行用 \N(硬换行)或 \n(软换行),
大括号 {} 用于覆盖样式,需要转义.
Args:
text: 原始文本
Returns:
转义后的 ASS 文本
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
# ── 时间格式化 ────────────────────────────────────────────────────────────────
def format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc.
Args:
seconds: 秒数
Returns:
ASS 格式时间,如 "1:23:45.67"
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
def build_ass_content(
*,
video_width: int,
video_height: int,
video_duration: float,
title_text: str = "",
title_config: dict[str, Any] | None = None,
subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None,
) -> str:
"""生成 ASS 字幕文件内容(纯字符串,不写文件).
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
各自可独立配置样式、位置和内容.
Args:
video_width: 视频宽度(用于 ASS PlayResX
video_height: 视频高度(用于 ASS PlayResY
video_duration: 视频总时长(秒),字幕显示整个时长
title_text: 标题文本
title_config: 标题样式配置
subtitle_text: 字幕文本
subtitle_config: 字幕样式配置
Returns:
完整的 ASS 文件内容字符串;无字幕时返回空字符串
"""
title_config = title_config or {}
subtitle_config = subtitle_config or {}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
if not title_enabled and not subtitle_enabled:
return ""
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = hex_to_ass_color(title_config.get("color", "#ffffff"))
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
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,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
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,
)
)
safe_title_text = escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
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,
)
)
safe_subtitle_text = escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
return f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
+448
View File
@@ -0,0 +1,448 @@
"""ASS 字幕构建领域模型单元测试 — 纯逻辑,无文件IO."""
from __future__ import annotations
import pytest
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
class TestHexToAssColor:
def test_red(self):
assert hex_to_ass_color("#FF0000") == "&H0000FF"
def test_green(self):
assert hex_to_ass_color("#00FF00") == "&H00FF00"
def test_blue(self):
assert hex_to_ass_color("#0000FF") == "&HFF0000"
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(self):
assert hex_to_ass_color("FF0000") == "&H0000FF"
def test_lowercase(self):
assert hex_to_ass_color("#ff0000") == "&H0000FF"
def test_invalid_length_short(self):
assert hex_to_ass_color("#FFF") == "&H000000"
def test_invalid_length_long(self):
assert hex_to_ass_color("#FFFFFFFF") == "&H000000"
def test_empty(self):
assert hex_to_ass_color("") == "&H000000"
# ── 位置对齐 ──────────────────────────────────────────────────────────────────
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_default_top(self):
assert position_to_ass_alignment("unknown") == 8
def test_empty_default_top(self):
assert position_to_ass_alignment("") == 8
# ── Style 行构建 ─────────────────────────────────────────────────────────────
class TestBuildAssStyle:
def test_minimal_style(self):
result = build_ass_style("TestStyle")
assert result.startswith("Style: TestStyle,")
assert "思源黑体" in result
assert ",48," in result
def test_custom_font_size(self):
result = build_ass_style("Title", font_size=64)
assert ",64," in result
def test_bold_enabled(self):
result = build_ass_style("BoldStyle", bold=True)
parts = result.split(",")
# Bold 是第 8 个字段(index 7
assert parts[7] == "-1"
def test_bold_disabled(self):
result = build_ass_style("NormalStyle", bold=False)
parts = result.split(",")
assert parts[7] == "0"
def test_italic_enabled(self):
result = build_ass_style("ItalicStyle", italic=True)
parts = result.split(",")
assert parts[8] == "-1"
def test_alignment(self):
result = build_ass_style("AlignBottom", alignment=2)
parts = result.split(",")
# Alignment 是第 19 个字段(index 18
assert parts[18] == "2"
def test_margins(self):
result = build_ass_style("MarginStyle", margin_v=100, margin_l=50, margin_r=50)
parts = result.split(",")
# MarginL, MarginR, MarginV 分别是 index 19, 20, 21
assert parts[19] == "50"
assert parts[20] == "50"
assert parts[21] == "100"
def test_outline_width(self):
result = build_ass_style("OutlineStyle", outline_width=3.5)
# Outline 是 index 16
parts = result.split(",")
assert parts[16] == "3.5"
def test_shadow_with_blur(self):
result = build_ass_style("ShadowStyle", shadow_blur=4.0, shadow_offset=(2, 3))
parts = result.split(",")
# Shadow 深度(纵向偏移)是 index 17
assert parts[17] == "3"
def test_shadow_without_blur(self):
result = build_ass_style("NoShadowStyle", shadow_blur=0.0, shadow_offset=(2, 3))
parts = result.split(",")
assert parts[17] == "0"
def test_primary_color(self):
result = build_ass_style("ColorStyle", primary_color="&H00FFFFFF")
# PrimaryColour 是 index 3
parts = result.split(",")
assert parts[3] == "&H00FFFFFF"
def test_outline_color(self):
result = build_ass_style("StrokeStyle", outline_color="&H00000000")
# OutlineColour 是 index 5
parts = result.split(",")
assert parts[5] == "&H00000000"
def test_field_count(self):
"""验证 Style 行有正确的字段数(23 个字段)."""
result = build_ass_style("FullStyle")
parts = result.split(",")
# Style: 行有 23 个字段(去掉 "Style: " 前缀后)
assert len(parts) == 23
# ── 文本转义 ──────────────────────────────────────────────────────────────────
class TestEscapeAssText:
def test_plain_text(self):
assert escape_ass_text("Hello World") == "Hello World"
def test_newline_lf(self):
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_newline_crlf(self):
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
def test_newline_cr(self):
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
def test_curly_braces(self):
assert escape_ass_text("text {tag} text") == "text (tag) text"
def test_left_brace_only(self):
assert escape_ass_text("{start") == "(start"
def test_right_brace_only(self):
assert escape_ass_text("end}") == "end)"
def test_multiple_braces(self):
assert escape_ass_text("{a}{b}{c}") == "(a)(b)(c)"
def test_mixed_newline_and_braces(self):
assert escape_ass_text("line1\n{tag}\nline2") == "line1\\N(tag)\\Nline2"
def test_empty_string(self):
assert escape_ass_text("") == ""
def test_chinese_text(self):
assert escape_ass_text("你好世界") == "你好世界"
# ── 时间格式化 ────────────────────────────────────────────────────────────────
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(125.0) == "0:02:05.00"
def test_hours(self):
assert format_ass_time(3661.5) == "1:01:01.50"
def test_one_hour_exact(self):
assert format_ass_time(3600) == "1:00:00.00"
def test_sub_second_precision(self):
result = format_ass_time(1.23)
assert result == "0:00:01.23"
def test_59_seconds(self):
assert format_ass_time(59.99) == "0:00:59.99"
def test_60_seconds(self):
assert format_ass_time(60.0) == "0:01:00.00"
def test_90_minutes(self):
assert format_ass_time(5400.0) == "1:30:00.00"
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
class TestBuildAssContent:
def test_no_subtitles_returns_empty(self):
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
assert result == ""
def test_title_disabled_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Title",
title_config={"enabled": False},
)
assert result == ""
def test_empty_title_text_returns_empty(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text=" ",
title_config={"enabled": True},
)
assert result == ""
def test_with_title(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=30.0,
title_text="My Title",
title_config={"enabled": True, "color": "#FFFFFF"},
)
assert "[Script Info]" in result
assert "PlayResX: 1920" in result
assert "PlayResY: 1080" in result
assert "[V4+ Styles]" in result
assert "TitleStyle" in result
assert "[Events]" in result
assert "Dialogue:" in result
assert "My Title" in result
def test_with_subtitle(self):
result = build_ass_content(
video_width=1280,
video_height=720,
video_duration=15.0,
subtitle_text="Subtitle Text",
subtitle_config={"enabled": True},
)
assert "PlayResX: 1280" in result
assert "PlayResY: 720" in result
assert "SubtitleStyle" in result
assert "Subtitle Text" in result
def test_with_both_title_and_subtitle(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=60.0,
title_text="Big Title",
title_config={"enabled": True},
subtitle_text="Small subtitle",
subtitle_config={"enabled": True},
)
assert "TitleStyle" in result
assert "SubtitleStyle" in result
assert "Big Title" in result
assert "Small subtitle" in result
# 两个 Dialogue 行
assert result.count("Dialogue:") == 2
def test_title_position_bottom(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Bottom Title",
title_config={"enabled": True, "position": "bottom"},
)
# 对齐方式为 2(底部居中)
assert "TitleStyle" in result
def test_title_with_stroke(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Stroke Title",
title_config={
"enabled": True,
"stroke": {"enabled": True, "color": "#000000", "width": 3},
},
)
assert "Stroke Title" in result
def test_title_with_shadow(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Shadow Title",
title_config={
"enabled": True,
"shadow": {"enabled": True, "blur": 4, "offset_x": 2, "offset_y": 3},
},
)
assert "Shadow Title" in result
def test_title_bold_default(self):
"""标题默认启用粗体."""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Bold Title",
title_config={"enabled": True},
)
# 在 TitleStyle 行中找 bold=-1
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[7] == "-1"
break
else:
pytest.fail("TitleStyle not found")
def test_subtitle_not_bold(self):
"""字幕默认不启用粗体."""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
subtitle_text="Normal Subtitle",
subtitle_config={"enabled": True},
)
for line in result.split("\n"):
if line.startswith("Style: SubtitleStyle"):
parts = line.split(",")
assert parts[7] == "0"
break
else:
pytest.fail("SubtitleStyle not found")
def test_duration_format_in_dialogue(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=125.5,
title_text="Timed",
title_config={"enabled": True},
)
# 结束时间应该是 0:02:05.50
assert "0:02:05.50" in result
def test_title_text_escaped(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Line1\n{tag}Line2",
title_config={"enabled": True},
)
assert "Line1\\N(tag)Line2" in result
def test_default_title_enabled(self):
"""不传 enabled 时默认为 True."""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Default Enabled",
title_config={},
)
assert result != ""
assert "Default Enabled" in result
def test_subtitle_position_top(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
subtitle_text="Top Subtitle",
subtitle_config={"enabled": True, "position": "top"},
)
assert "Top Subtitle" in result
def test_scaled_border_and_shadow(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Test",
title_config={"enabled": True},
)
assert "ScaledBorderAndShadow: yes" in result
def test_wrap_style(self):
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=10.0,
title_text="Test",
title_config={"enabled": True},
)
assert "WrapStyle: 2" in result
# ── 常量 ──────────────────────────────────────────────────────────────────────
class TestConstants:
def test_title_margin_top(self):
assert TITLE_MARGIN_TOP == 60
def test_title_margin_bottom(self):
assert TITLE_MARGIN_BOTTOM == 60
def test_title_margin_side(self):
assert TITLE_MARGIN_SIDE == 40