Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 3d9a337d2a refactor(wave107): 抽离subtitle_style领域模型 + 76单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
- 从subtitle_render_engine.py抽离SubtitleStyle/SubtitleSegment/常量/ASS工具函数
- subtitle_render_engine保留全部导出,完全向后兼容
- 新增76个纯逻辑单测,覆盖颜色转换、时间格式化、文本换行、样式解析
- subtitle_render_engine.py: 685→454行 (-231行, -34%)
2026-07-26 22:46:18 +08:00
3 changed files with 693 additions and 251 deletions
@@ -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" # 使用的样式名
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
+272
View File
@@ -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
+401
View File
@@ -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