273 lines
8.9 KiB
Python
Executable File
273 lines
8.9 KiB
Python
Executable File
"""字幕样式领域模型 — 纯逻辑,无外部依赖.
|
||
|
||
抽离自 subtitle_render_engine.py 的数据类和工具函数,
|
||
方便单测覆盖,同时保持向后兼容。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
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
|