Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d9a337d2a |
@@ -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" # 使用的样式名
|
||||
|
||||
|
||||
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Executable
+272
@@ -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
|
||||
Executable
+401
@@ -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
|
||||
Reference in New Issue
Block a user