"""字幕样式领域模型 — 纯逻辑,无外部依赖. 抽离自 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