test(wave120): 抽离ass_subtitle_builder领域模型 + 68单测 #1016
Regular → Executable
+37
-203
@@ -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=1(outline + 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
|
||||
|
||||
Executable
+306
@@ -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)}
|
||||
"""
|
||||
Executable
+448
@@ -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
|
||||
Reference in New Issue
Block a user