From 3d9a337d2a42cff31aeea254099fa7441781de25 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 26 Jul 2026 22:45:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wave107):=20=E6=8A=BD=E7=A6=BBsubtitle?= =?UTF-8?q?=5Fstyle=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2076=E5=8D=95?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从subtitle_render_engine.py抽离SubtitleStyle/SubtitleSegment/常量/ASS工具函数 - subtitle_render_engine保留全部导出,完全向后兼容 - 新增76个纯逻辑单测,覆盖颜色转换、时间格式化、文本换行、样式解析 - subtitle_render_engine.py: 685→454行 (-231行, -34%) --- .../subtitle_render_engine.py | 271 +----------- packages/domain/subtitle_style.py | 272 ++++++++++++ tests/unit/test_subtitle_style.py | 401 ++++++++++++++++++ 3 files changed, 693 insertions(+), 251 deletions(-) create mode 100755 packages/domain/subtitle_style.py create mode 100755 tests/unit/test_subtitle_style.py diff --git a/apps/worker/video_processing/subtitle_render_engine.py b/apps/worker/video_processing/subtitle_render_engine.py index cb4b82e31..d15994991 100755 --- a/apps/worker/video_processing/subtitle_render_engine.py +++ b/apps/worker/video_processing/subtitle_render_engine.py @@ -24,264 +24,33 @@ from __future__ import annotations import logging -from dataclasses import dataclass from pathlib import Path -from typing import Any +from packages.domain.subtitle_style import ( # noqa: F401 向后兼容导出 + ALLOWED_SUBTITLE_EXTENSIONS, + DEFAULT_COLOR, + DEFAULT_FONT, + DEFAULT_FONT_SIZE, + DEFAULT_MAX_CHARS_PER_LINE, + DEFAULT_POSITION, + DEFAULT_STROKE_COLOR, + DEFAULT_STROKE_WIDTH, + POSITION_ALIGNMENT, + POSITION_ALIASES, + SubtitleSegment, + SubtitleStyle, + escape_ass_text as _escape_ass_text, + format_ass_time as _format_ass_time, + hex_to_ass_bgr as _hex_to_ass_bgr, + hex_to_ass_color as _hex_to_ass_color, + opacity_to_ass_alpha as _opacity_to_ass_alpha, + wrap_text as _wrap_text, +) from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"} - -# 9宫格位置映射(ASS alignment 编号) -POSITION_ALIGNMENT = { - "top_left": 7, - "top_center": 8, - "top_right": 9, - "middle_left": 4, - "center": 5, - "middle_right": 6, - "bottom_left": 1, - "bottom_center": 2, - "bottom_right": 3, -} - -# 位置简称兼容 -POSITION_ALIASES = { - "top": "top_center", - "bottom": "bottom_center", - "middle": "center", - "left": "middle_left", - "right": "middle_right", -} - -DEFAULT_FONT = "思源黑体" -DEFAULT_FONT_SIZE = 24 -DEFAULT_COLOR = "#FFFFFF" -DEFAULT_STROKE_COLOR = "#000000" -DEFAULT_STROKE_WIDTH = 1.5 -DEFAULT_POSITION = "bottom_center" -DEFAULT_MAX_CHARS_PER_LINE = 20 - - -# ── 字幕样式配置 ──────────────────────────────────────────────────────────── - - -@dataclass -class SubtitleStyle: - """字幕样式配置.""" - - font_name: str = DEFAULT_FONT - font_size: int = DEFAULT_FONT_SIZE - font_color: str = DEFAULT_COLOR - bold: bool = False - italic: bool = False - - # 描边 - stroke_enabled: bool = True - stroke_color: str = DEFAULT_STROKE_COLOR - stroke_width: float = DEFAULT_STROKE_WIDTH - - # 阴影 - shadow_enabled: bool = False - shadow_color: str = "#000000" - shadow_offset_x: int = 2 - shadow_offset_y: int = 2 - shadow_blur: float = 0.0 - - # 背景框 - background_enabled: bool = False - background_color: str = "#000000" - background_opacity: float = 0.5 # 0.0 ~ 1.0 - background_padding: int = 8 - background_radius: int = 4 - - # 位置 - position: str = DEFAULT_POSITION # 9宫格位置名 - margin_v: int = 60 # 垂直边距 - margin_l: int = 40 # 左边距 - margin_r: int = 40 # 右边距 - - # 多行 - max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE - line_spacing: int = 0 # 行间距 - - # 动画 - fade_in: float = 0.0 # 淡入时长(秒) - fade_out: float = 0.0 # 淡出时长(秒) - animation_type: str = "none" # none/fade/slide/typewriter - - @classmethod - def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle": - """从字典创建样式配置,带安全类型转换.""" - if not config or not isinstance(config, dict): - return cls() - - def safe_str(key: str, default: str) -> str: - val = config.get(key, default) - return str(val) if val is not None else default - - def safe_int(key: str, default: int) -> int: - try: - return int(config.get(key, default)) - except (TypeError, ValueError): - return default - - def safe_float(key: str, default: float) -> float: - try: - return float(config.get(key, default)) - except (TypeError, ValueError): - return default - - def safe_bool(key: str, default: bool) -> bool: - return bool(config.get(key, default)) - - position = safe_str("position", DEFAULT_POSITION) - position = POSITION_ALIASES.get(position, position) - if position not in POSITION_ALIGNMENT: - position = DEFAULT_POSITION - - return cls( - font_name=safe_str("font", DEFAULT_FONT), - font_size=safe_int("size", DEFAULT_FONT_SIZE), - font_color=safe_str("color", DEFAULT_COLOR), - bold=safe_bool("bold", False), - italic=safe_bool("italic", False), - stroke_enabled=safe_bool("stroke_enabled", True), - stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR), - stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH), - shadow_enabled=safe_bool("shadow_enabled", False), - shadow_color=safe_str("shadow_color", "#000000"), - shadow_offset_x=safe_int("shadow_offset_x", 2), - shadow_offset_y=safe_int("shadow_offset_y", 2), - shadow_blur=safe_float("shadow_blur", 0.0), - background_enabled=safe_bool("background_enabled", False), - background_color=safe_str("background_color", "#000000"), - background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))), - background_padding=safe_int("background_padding", 8), - background_radius=safe_int("background_radius", 4), - position=position, - margin_v=safe_int("margin_v", 60), - margin_l=safe_int("margin_l", 40), - margin_r=safe_int("margin_r", 40), - max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE), - line_spacing=safe_int("line_spacing", 0), - fade_in=max(0.0, safe_float("fade_in", 0.0)), - fade_out=max(0.0, safe_float("fade_out", 0.0)), - animation_type=safe_str("animation_type", "none"), - ) - - @property - def alignment(self) -> int: - """获取 ASS alignment 编号.""" - return POSITION_ALIGNMENT.get(self.position, 2) - - @property - def ass_font_color(self) -> str: - """ASS 格式颜色 &HAABBGGRR.""" - return _hex_to_ass_color(self.font_color) - - @property - def ass_stroke_color(self) -> str: - return _hex_to_ass_color(self.stroke_color) - - @property - def ass_shadow_color(self) -> str: - return _hex_to_ass_color(self.shadow_color) - - @property - def ass_background_color(self) -> str: - """背景框颜色(ASS BackColour),带透明度.""" - alpha_hex = _opacity_to_ass_alpha(self.background_opacity) - color_bgr = _hex_to_ass_bgr(self.background_color) - return f"&H{alpha_hex}{color_bgr}" - - -# ── 工具函数 ────────────────────────────────────────────────────────────────── - - -def _hex_to_ass_color(hex_color: str) -> str: - """HEX → ASS 颜色 &HAABBGGRR(默认不透明).""" - hex_color = hex_color.lstrip("#") - if len(hex_color) != 6: - return "&H00FFFFFF" - r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] - return f"&H00{b.upper()}{g.upper()}{r.upper()}" - - -def _hex_to_ass_bgr(hex_color: str) -> str: - """HEX → ASS BGR 部分(不含 alpha).""" - hex_color = hex_color.lstrip("#") - if len(hex_color) != 6: - return "FFFFFF" - r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] - return f"{b.upper()}{g.upper()}{r.upper()}" - - -def _opacity_to_ass_alpha(opacity: float) -> str: - """不透明度 → ASS alpha(00=不透明,FF=完全透明).""" - alpha = 255 - int(opacity * 255) - return f"{alpha:02X}" - - -def _escape_ass_text(text: str) -> str: - """转义 ASS 文本特殊字符.""" - text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N") - text = text.replace("{", "(").replace("}", ")") - return 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}" - - -def _wrap_text(text: str, max_chars: int) -> list[str]: - """按字数换行,优先标点断开.""" - if len(text) <= max_chars: - return [text] - - lines: list[str] = [] - remaining = text - - while len(remaining) > max_chars: - break_point = max_chars - punctuations = ",。!?、;:,.;:!?" - - for i in range(max_chars, max_chars // 2, -1): - if i < len(remaining) and remaining[i] in punctuations: - break_point = i + 1 - break - - lines.append(remaining[:break_point]) - remaining = remaining[break_point:] - - if remaining: - lines.append(remaining) - - return lines - - -# ── 字幕片段 ────────────────────────────────────────────────────────────────── - - -@dataclass -class SubtitleSegment: - """单个字幕片段.""" - - start: float # 开始时间(秒) - end: float # 结束时间(秒) - text: str # 字幕文本 - style_name: str = "Default" # 使用的样式名 - - # ── 字幕渲染引擎 ────────────────────────────────────────────────────────────── diff --git a/packages/domain/subtitle_style.py b/packages/domain/subtitle_style.py new file mode 100755 index 000000000..481e0d83c --- /dev/null +++ b/packages/domain/subtitle_style.py @@ -0,0 +1,272 @@ +"""字幕样式领域模型 — 纯逻辑,无外部依赖. + +抽离自 subtitle_render_engine.py 的数据类和工具函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +# 9宫格位置映射(ASS alignment 编号) +POSITION_ALIGNMENT: dict[str, int] = { + "top_left": 7, + "top_center": 8, + "top_right": 9, + "middle_left": 4, + "center": 5, + "middle_right": 6, + "bottom_left": 1, + "bottom_center": 2, + "bottom_right": 3, +} + +# 位置简称兼容 +POSITION_ALIASES: dict[str, str] = { + "top": "top_center", + "bottom": "bottom_center", + "middle": "center", + "left": "middle_left", + "right": "middle_right", +} + +DEFAULT_FONT = "思源黑体" +DEFAULT_FONT_SIZE = 24 +DEFAULT_COLOR = "#FFFFFF" +DEFAULT_STROKE_COLOR = "#000000" +DEFAULT_STROKE_WIDTH = 1.5 +DEFAULT_POSITION = "bottom_center" +DEFAULT_MAX_CHARS_PER_LINE = 20 + +ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"} + + +# ── 工具函数 ────────────────────────────────────────────────────────────────── + + +def hex_to_ass_color(hex_color: str) -> str: + """HEX → ASS 颜色 &HAABBGGRR(默认不透明).""" + hex_color = hex_color.lstrip("#") + if len(hex_color) != 6: + return "&H00FFFFFF" + r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] + return f"&H00{b.upper()}{g.upper()}{r.upper()}" + + +def hex_to_ass_bgr(hex_color: str) -> str: + """HEX → ASS BGR 部分(不含 alpha).""" + hex_color = hex_color.lstrip("#") + if len(hex_color) != 6: + return "FFFFFF" + r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] + return f"{b.upper()}{g.upper()}{r.upper()}" + + +def opacity_to_ass_alpha(opacity: float) -> str: + """不透明度 → ASS alpha(00=不透明,FF=完全透明).""" + alpha = 255 - int(max(0.0, min(1.0, opacity)) * 255) + return f"{alpha:02X}" + + +def escape_ass_text(text: str) -> str: + """转义 ASS 文本特殊字符.""" + text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N") + text = text.replace("{", "(").replace("}", ")") + return text + + +def format_ass_time(seconds: float) -> str: + """秒 → ASS 时间格式 H:MM:SS.cc.""" + if seconds < 0: + seconds = 0.0 + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = seconds % 60 + return f"{hours}:{minutes:02d}:{secs:05.2f}" + + +def wrap_text(text: str, max_chars: int) -> list[str]: + """按字数换行,优先标点断开.""" + if max_chars <= 0: + return [text] + if not text or len(text) <= max_chars: + return [text] + + lines: list[str] = [] + remaining = text + punctuations = ",。!?、;:,.;:!?" + + while len(remaining) > max_chars: + break_point = max_chars + # 在 max_chars 到 max_chars//2 之间寻找标点断点 + for i in range(max_chars, max_chars // 2, -1): + if i < len(remaining) and remaining[i] in punctuations: + break_point = i + 1 + break + + lines.append(remaining[:break_point]) + remaining = remaining[break_point:] + + if remaining: + lines.append(remaining) + + return lines + + +# ── 字幕样式配置 ──────────────────────────────────────────────────────────── + + +@dataclass +class SubtitleStyle: + """字幕样式配置.""" + + font_name: str = DEFAULT_FONT + font_size: int = DEFAULT_FONT_SIZE + font_color: str = DEFAULT_COLOR + bold: bool = False + italic: bool = False + + # 描边 + stroke_enabled: bool = True + stroke_color: str = DEFAULT_STROKE_COLOR + stroke_width: float = DEFAULT_STROKE_WIDTH + + # 阴影 + shadow_enabled: bool = False + shadow_color: str = "#000000" + shadow_offset_x: int = 2 + shadow_offset_y: int = 2 + shadow_blur: float = 0.0 + + # 背景框 + background_enabled: bool = False + background_color: str = "#000000" + background_opacity: float = 0.5 + background_padding: int = 8 + background_radius: int = 4 + + # 位置 + position: str = DEFAULT_POSITION + margin_v: int = 60 + margin_l: int = 40 + margin_r: int = 40 + + # 多行 + max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE + line_spacing: int = 0 + + # 动画 + fade_in: float = 0.0 + fade_out: float = 0.0 + animation_type: str = "none" + + @classmethod + def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle": + """从字典创建样式配置,带安全类型转换.""" + if not config or not isinstance(config, dict): + return cls() + + def safe_str(key: str, default: str) -> str: + val = config.get(key, default) + return str(val) if val is not None else default + + def safe_int(key: str, default: int) -> int: + try: + return int(config.get(key, default)) + except (TypeError, ValueError): + return default + + def safe_float(key: str, default: float) -> float: + try: + return float(config.get(key, default)) + except (TypeError, ValueError): + return default + + def safe_bool(key: str, default: bool) -> bool: + return bool(config.get(key, default)) + + position = safe_str("position", DEFAULT_POSITION) + position = POSITION_ALIASES.get(position, position) + if position not in POSITION_ALIGNMENT: + position = DEFAULT_POSITION + + return cls( + font_name=safe_str("font", DEFAULT_FONT), + font_size=safe_int("size", DEFAULT_FONT_SIZE), + font_color=safe_str("color", DEFAULT_COLOR), + bold=safe_bool("bold", False), + italic=safe_bool("italic", False), + stroke_enabled=safe_bool("stroke_enabled", True), + stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR), + stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH), + shadow_enabled=safe_bool("shadow_enabled", False), + shadow_color=safe_str("shadow_color", "#000000"), + shadow_offset_x=safe_int("shadow_offset_x", 2), + shadow_offset_y=safe_int("shadow_offset_y", 2), + shadow_blur=safe_float("shadow_blur", 0.0), + background_enabled=safe_bool("background_enabled", False), + background_color=safe_str("background_color", "#000000"), + background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))), + background_padding=safe_int("background_padding", 8), + background_radius=safe_int("background_radius", 4), + position=position, + margin_v=safe_int("margin_v", 60), + margin_l=safe_int("margin_l", 40), + margin_r=safe_int("margin_r", 40), + max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE), + line_spacing=safe_int("line_spacing", 0), + fade_in=max(0.0, safe_float("fade_in", 0.0)), + fade_out=max(0.0, safe_float("fade_out", 0.0)), + animation_type=safe_str("animation_type", "none"), + ) + + @property + def alignment(self) -> int: + """获取 ASS alignment 编号.""" + return POSITION_ALIGNMENT.get(self.position, 2) + + @property + def ass_font_color(self) -> str: + """ASS 格式颜色 &HAABBGGRR.""" + return hex_to_ass_color(self.font_color) + + @property + def ass_stroke_color(self) -> str: + return hex_to_ass_color(self.stroke_color) + + @property + def ass_shadow_color(self) -> str: + return hex_to_ass_color(self.shadow_color) + + @property + def ass_background_color(self) -> str: + """背景框颜色(ASS BackColour),带透明度.""" + alpha_hex = opacity_to_ass_alpha(self.background_opacity) + color_bgr = hex_to_ass_bgr(self.background_color) + return f"&H{alpha_hex}{color_bgr}" + + +# ── 字幕片段 ────────────────────────────────────────────────────────────────── + + +@dataclass +class SubtitleSegment: + """单个字幕片段.""" + + start: float # 开始时间(秒) + end: float # 结束时间(秒) + text: str # 字幕文本 + style_name: str = "Default" # 使用的样式名 + + @property + def duration(self) -> float: + """字幕时长.""" + return max(0.0, self.end - self.start) + + @property + def is_valid(self) -> bool: + """是否有效(有文本且时长>0).""" + return bool(self.text) and self.end > self.start diff --git a/tests/unit/test_subtitle_style.py b/tests/unit/test_subtitle_style.py new file mode 100755 index 000000000..94a0808e0 --- /dev/null +++ b/tests/unit/test_subtitle_style.py @@ -0,0 +1,401 @@ +"""subtitle_style 领域模型单测 — 纯逻辑.""" + +from __future__ import annotations + +import pytest + +from packages.domain.subtitle_style import ( + ALLOWED_SUBTITLE_EXTENSIONS, + DEFAULT_COLOR, + DEFAULT_FONT, + DEFAULT_FONT_SIZE, + DEFAULT_MAX_CHARS_PER_LINE, + DEFAULT_POSITION, + DEFAULT_STROKE_COLOR, + DEFAULT_STROKE_WIDTH, + POSITION_ALIGNMENT, + POSITION_ALIASES, + SubtitleSegment, + SubtitleStyle, + escape_ass_text, + format_ass_time, + hex_to_ass_bgr, + hex_to_ass_color, + opacity_to_ass_alpha, + wrap_text, +) + +# ── 常量测试 ────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_position_alignment_has_9_positions(self): + assert len(POSITION_ALIGNMENT) == 9 + assert POSITION_ALIGNMENT["bottom_center"] == 2 + assert POSITION_ALIGNMENT["top_center"] == 8 + assert POSITION_ALIGNMENT["center"] == 5 + + def test_position_aliases(self): + assert POSITION_ALIASES["top"] == "top_center" + assert POSITION_ALIASES["bottom"] == "bottom_center" + assert POSITION_ALIASES["middle"] == "center" + + def test_default_values(self): + assert DEFAULT_FONT == "思源黑体" + assert DEFAULT_FONT_SIZE == 24 + assert DEFAULT_COLOR == "#FFFFFF" + assert DEFAULT_POSITION == "bottom_center" + + def test_allowed_extensions(self): + assert ".srt" in ALLOWED_SUBTITLE_EXTENSIONS + assert ".ass" in ALLOWED_SUBTITLE_EXTENSIONS + assert ".vtt" in ALLOWED_SUBTITLE_EXTENSIONS + + +# ── 工具函数测试 ────────────────────────────────────────────────────────────── + + +class TestHexToAssColor: + def test_white(self): + assert hex_to_ass_color("#FFFFFF") == "&H00FFFFFF" + + def test_black(self): + assert hex_to_ass_color("#000000") == "&H00000000" + + def test_red(self): + assert hex_to_ass_color("#FF0000") == "&H000000FF" + + def test_blue(self): + assert hex_to_ass_color("#0000FF") == "&H00FF0000" + + def test_green(self): + assert hex_to_ass_color("#00FF00") == "&H0000FF00" + + def test_without_hash(self): + assert hex_to_ass_color("FF0000") == "&H000000FF" + + def test_lowercase(self): + assert hex_to_ass_color("#ff0000") == "&H000000FF" + + def test_invalid_length_returns_white(self): + assert hex_to_ass_color("#FFF") == "&H00FFFFFF" + assert hex_to_ass_color("#FF000000") == "&H00FFFFFF" + + def test_empty_string(self): + assert hex_to_ass_color("") == "&H00FFFFFF" + + +class TestHexToAssBgr: + def test_white(self): + assert hex_to_ass_bgr("#FFFFFF") == "FFFFFF" + + def test_red(self): + assert hex_to_ass_bgr("#FF0000") == "0000FF" + + def test_blue(self): + assert hex_to_ass_bgr("#0000FF") == "FF0000" + + def test_invalid_length(self): + assert hex_to_ass_bgr("#FFF") == "FFFFFF" + + +class TestOpacityToAssAlpha: + def test_fully_opaque(self): + assert opacity_to_ass_alpha(1.0) == "00" + + def test_fully_transparent(self): + assert opacity_to_ass_alpha(0.0) == "FF" + + def test_half(self): + assert opacity_to_ass_alpha(0.5) == "80" + + def test_above_1_clamped(self): + assert opacity_to_ass_alpha(1.5) == "00" + + def test_below_0_clamped(self): + assert opacity_to_ass_alpha(-0.5) == "FF" + + +class TestEscapeAssText: + def test_newline_unix(self): + assert escape_ass_text("hello\nworld") == "hello\\Nworld" + + def test_newline_windows(self): + assert escape_ass_text("hello\r\nworld") == "hello\\Nworld" + + def test_newline_mac(self): + assert escape_ass_text("hello\rworld") == "hello\\Nworld" + + def test_curly_braces(self): + assert escape_ass_text("{text}") == "(text)" + + def test_mixed(self): + assert escape_ass_text("hello\n{world}\r\nend") == "hello\\N(world)\\Nend" + + def test_empty(self): + assert escape_ass_text("") == "" + + +class TestFormatAssTime: + def test_zero(self): + assert format_ass_time(0.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(65.25) == "0:01:05.25" + + def test_hours(self): + assert format_ass_time(3661.5) == "1:01:01.50" + + def test_negative_returns_zero(self): + assert format_ass_time(-1.0) == "0:00:00.00" + + def test_centiseconds_precision(self): + assert format_ass_time(1.234) == "0:00:01.23" + + +class TestWrapText: + def test_short_text_no_wrap(self): + assert wrap_text("你好", 10) == ["你好"] + + def test_exact_length_no_wrap(self): + text = "你" * 10 + result = wrap_text(text, 10) + assert len(result) == 1 + assert len(result[0]) == 10 + + def test_long_text_breaks_at_max(self): + text = "你" * 25 + result = wrap_text(text, 10) + assert len(result) == 3 + assert len(result[0]) == 10 + assert len(result[1]) == 10 + assert len(result[2]) == 5 + + def test_breaks_at_punctuation(self): + # "一二三四五六。七八九十"共10字,"。"在索引6 + # max_chars=8 时,从 8 往回找到 4,会命中索引6的"。" + text = "一二三四五六。七八九十" + result = wrap_text(text, 8) + assert len(result) == 2 + assert result[0] == "一二三四五六。" + assert result[1] == "七八九十" + + def test_no_punctuation_breaks_at_max(self): + text = "一二三四五六七八九十一二三四五六七八九十" + result = wrap_text(text, 10) + assert len(result[0]) == 10 + + def test_empty_text(self): + assert wrap_text("", 10) == [""] + + def test_zero_max_chars(self): + assert wrap_text("hello", 0) == ["hello"] + + def test_negative_max_chars(self): + result = wrap_text("hello", -5) + assert isinstance(result, list) + assert len(result) == 1 + + +# ── SubtitleStyle 测试 ─────────────────────────────────────────────────────── + + +class TestSubtitleStyleDefaults: + def test_default_values(self): + style = SubtitleStyle() + assert style.font_name == DEFAULT_FONT + assert style.font_size == DEFAULT_FONT_SIZE + assert style.font_color == DEFAULT_COLOR + assert style.bold is False + assert style.italic is False + assert style.stroke_enabled is True + assert style.stroke_color == DEFAULT_STROKE_COLOR + assert style.stroke_width == DEFAULT_STROKE_WIDTH + assert style.position == DEFAULT_POSITION + assert style.max_chars_per_line == DEFAULT_MAX_CHARS_PER_LINE + + +class TestSubtitleStyleFromDict: + def test_none_returns_default(self): + style = SubtitleStyle.from_dict(None) + assert style.font_name == DEFAULT_FONT + + def test_empty_dict_returns_default(self): + style = SubtitleStyle.from_dict({}) + assert style.font_size == DEFAULT_FONT_SIZE + + def test_custom_font(self): + style = SubtitleStyle.from_dict({"font": "微软雅黑", "size": 32}) + assert style.font_name == "微软雅黑" + assert style.font_size == 32 + + def test_color(self): + style = SubtitleStyle.from_dict({"color": "#FF0000"}) + assert style.font_color == "#FF0000" + + def test_bold_italic(self): + style = SubtitleStyle.from_dict({"bold": True, "italic": True}) + assert style.bold is True + assert style.italic is True + + def test_stroke_config(self): + style = SubtitleStyle.from_dict( + { + "stroke_enabled": False, + "stroke_color": "#00FF00", + "stroke_width": 2.0, + } + ) + assert style.stroke_enabled is False + assert style.stroke_color == "#00FF00" + assert style.stroke_width == 2.0 + + def test_shadow_config(self): + style = SubtitleStyle.from_dict( + { + "shadow_enabled": True, + "shadow_color": "#111111", + "shadow_offset_x": 4, + "shadow_offset_y": 4, + "shadow_blur": 1.5, + } + ) + assert style.shadow_enabled is True + assert style.shadow_color == "#111111" + assert style.shadow_offset_x == 4 + assert style.shadow_offset_y == 4 + assert style.shadow_blur == 1.5 + + def test_background_config(self): + style = SubtitleStyle.from_dict( + { + "background_enabled": True, + "background_color": "#000000", + "background_opacity": 0.7, + "background_padding": 10, + "background_radius": 6, + } + ) + assert style.background_enabled is True + assert style.background_opacity == 0.7 + assert style.background_padding == 10 + + def test_background_opacity_clamped_0_to_1(self): + style = SubtitleStyle.from_dict({"background_opacity": -0.5}) + assert style.background_opacity == 0.0 + style2 = SubtitleStyle.from_dict({"background_opacity": 1.5}) + assert style2.background_opacity == 1.0 + + def test_position_valid(self): + style = SubtitleStyle.from_dict({"position": "top_center"}) + assert style.position == "top_center" + + def test_position_alias(self): + style = SubtitleStyle.from_dict({"position": "top"}) + assert style.position == "top_center" + + def test_position_invalid_falls_back(self): + style = SubtitleStyle.from_dict({"position": "invalid_pos"}) + assert style.position == DEFAULT_POSITION + + def test_margins(self): + style = SubtitleStyle.from_dict({"margin_v": 80, "margin_l": 50, "margin_r": 50}) + assert style.margin_v == 80 + assert style.margin_l == 50 + assert style.margin_r == 50 + + def test_max_chars_per_line(self): + style = SubtitleStyle.from_dict({"max_chars_per_line": 15}) + assert style.max_chars_per_line == 15 + + def test_line_spacing(self): + style = SubtitleStyle.from_dict({"line_spacing": 4}) + assert style.line_spacing == 4 + + def test_fade_in_out(self): + style = SubtitleStyle.from_dict({"fade_in": 0.5, "fade_out": 1.0}) + assert style.fade_in == 0.5 + assert style.fade_out == 1.0 + + def test_fade_negative_clamped(self): + style = SubtitleStyle.from_dict({"fade_in": -1, "fade_out": -2}) + assert style.fade_in == 0.0 + assert style.fade_out == 0.0 + + def test_animation_type(self): + style = SubtitleStyle.from_dict({"animation_type": "fade"}) + assert style.animation_type == "fade" + + def test_invalid_int_falls_back(self): + style = SubtitleStyle.from_dict({"size": "not_a_number"}) + assert style.font_size == DEFAULT_FONT_SIZE + + def test_invalid_float_falls_back(self): + style = SubtitleStyle.from_dict({"stroke_width": "abc"}) + assert style.stroke_width == DEFAULT_STROKE_WIDTH + + +class TestSubtitleStyleProperties: + def test_alignment_bottom_center(self): + style = SubtitleStyle(position="bottom_center") + assert style.alignment == 2 + + def test_alignment_top_center(self): + style = SubtitleStyle(position="top_center") + assert style.alignment == 8 + + def test_ass_font_color(self): + style = SubtitleStyle(font_color="#FF0000") + assert style.ass_font_color == "&H000000FF" + + def test_ass_stroke_color(self): + style = SubtitleStyle(stroke_color="#00FF00") + assert style.ass_stroke_color == "&H0000FF00" + + def test_ass_shadow_color(self): + style = SubtitleStyle(shadow_color="#0000FF") + assert style.ass_shadow_color == "&H00FF0000" + + def test_ass_background_color(self): + style = SubtitleStyle(background_color="#FF0000", background_opacity=0.5) + # alpha = 255 - 127 = 128 = 0x80, bgr of red = 0000FF + assert style.ass_background_color == "&H800000FF" + + +# ── SubtitleSegment 测试 ───────────────────────────────────────────────────── + + +class TestSubtitleSegment: + def test_basic(self): + seg = SubtitleSegment(start=1.0, end=3.0, text="你好") + assert seg.start == 1.0 + assert seg.end == 3.0 + assert seg.text == "你好" + assert seg.style_name == "Default" + + def test_custom_style(self): + seg = SubtitleSegment(start=0, end=2, text="hi", style_name="Title") + assert seg.style_name == "Title" + + def test_duration(self): + seg = SubtitleSegment(start=1.5, end=4.0, text="test") + assert seg.duration == 2.5 + + def test_duration_zero_when_end_before_start(self): + seg = SubtitleSegment(start=5.0, end=3.0, text="test") + assert seg.duration == 0.0 + + def test_is_valid_true(self): + seg = SubtitleSegment(start=0, end=2, text="hello") + assert seg.is_valid is True + + def test_is_valid_empty_text(self): + seg = SubtitleSegment(start=0, end=2, text="") + assert seg.is_valid is False + + def test_is_valid_zero_duration(self): + seg = SubtitleSegment(start=1, end=1, text="hello") + assert seg.is_valid is False -- 2.54.0