From 5674487fae4b8e124ce63a3e09757162cc6603a6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 27 Jul 2026 09:17:23 +0800 Subject: [PATCH] =?UTF-8?q?test(wave120):=20=E6=8A=BD=E7=A6=BBass=5Fsubtit?= =?UTF-8?q?le=5Fbuilder=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2068?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 render_subtitles.py 抽离 ASS 字幕纯逻辑到 packages/domain/ass_subtitle_builder.py - render_subtitles.py: 255→89行 (-65%) - 新增 68 个单测,覆盖颜色转换/位置映射/Style构建/文本转义/时间格式化/完整ASS生成 - 原有 78 个测试无回归 - 保持向后兼容:render_subtitles 模块保留全部公开接口作为薄包装 --- .../video_processing/render_subtitles.py | 240 ++-------- packages/domain/ass_subtitle_builder.py | 306 ++++++++++++ tests/unit/test_ass_subtitle_builder.py | 448 ++++++++++++++++++ 3 files changed, 791 insertions(+), 203 deletions(-) mode change 100644 => 100755 apps/worker/video_processing/render_subtitles.py create mode 100755 packages/domain/ass_subtitle_builder.py create mode 100755 tests/unit/test_ass_subtitle_builder.py diff --git a/apps/worker/video_processing/render_subtitles.py b/apps/worker/video_processing/render_subtitles.py old mode 100644 new mode 100755 index 0ace09a38..cc35a0b69 --- a/apps/worker/video_processing/render_subtitles.py +++ b/apps/worker/video_processing/render_subtitles.py @@ -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 diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py new file mode 100755 index 000000000..143d56f80 --- /dev/null +++ b/packages/domain/ass_subtitle_builder.py @@ -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)} +""" diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py new file mode 100755 index 000000000..971255014 --- /dev/null +++ b/tests/unit/test_ass_subtitle_builder.py @@ -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 -- 2.54.0