"""字幕样式领域模型 — 纯逻辑,无外部依赖. 抽离自 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" # 逐行独立样式覆盖(#2001 爆款标题样式面板) # list[dict],每项可选字段: line_index(0-based,支持负数从末尾倒数), # color/font/size/bold/italic/stroke_color/stroke_width/shadow_color/shadow_offset_x/shadow_offset_y # 渲染时按行索引匹配,用 ASS 内联 override 标签包裹该行。缺省字段继承主样式。 line_overrides: list[dict[str, Any]] = field(default_factory=list) @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 # 逐行覆盖:仅保留 dict 类型项;非 dict 项过滤掉避免渲染崩溃 raw_overrides = config.get("line_overrides") or [] line_overrides: list[dict[str, Any]] = [] if isinstance(raw_overrides, list): for item in raw_overrides: if isinstance(item, dict): line_overrides.append(dict(item)) 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"), line_overrides=line_overrides, ) @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 build_line_override_tag(self, line_index: int, total_lines: int) -> str: r"""按 line_overrides 配置为指定行构造 ASS 内联 override 标签 {\c&HBBGGRR&...}. 仅返回大括号包裹的 override 标签串;调用方拼到该行文本前即可。 未配置该覆盖项时返回空串。缺省字段继承主样式,不生成对应 tag。 Args: line_index: 行号(0-based);支持负数(-1 为最后一行)。 total_lines: 总行数(用于解析负数索引)。 """ if not self.line_overrides or total_lines <= 0: return "" # 解析负数索引 resolved = line_index if line_index >= 0 else total_lines + line_index if resolved < 0 or resolved >= total_lines: return "" override: dict[str, Any] | None = None for item in self.line_overrides: if not isinstance(item, dict): continue idx = item.get("line_index") try: idx_int = int(idx) if idx is not None else None except (TypeError, ValueError): continue if idx_int is None: continue if idx_int < 0: idx_int = total_lines + idx_int if idx_int == resolved: override = item break if not override: return "" tags: list[str] = [] # 主色(字体颜色):\c&HBBGGRR& color_val = override.get("color") if isinstance(color_val, str) and color_val: tags.append(f"\\c{hex_to_ass_color(color_val)}") # 字号:\fsN size_val = override.get("size") or override.get("font_size") try: size_int = int(size_val) if size_val is not None else None if size_int and size_int > 0: tags.append(f"\\fs{size_int}") except (TypeError, ValueError): pass # 字体:\fnFontName font_val = override.get("font") or override.get("font_name") if isinstance(font_val, str) and font_val: tags.append(f"\\fn{font_val}") # 粗体:\b1 / \b0 bold_val = override.get("bold") if isinstance(bold_val, bool): tags.append("\\b1" if bold_val else "\\b0") # 斜体:\i1 / \i0 italic_val = override.get("italic") if isinstance(italic_val, bool): tags.append("\\i1" if italic_val else "\\i0") # 描边色:\3c&HBBGGRR& stroke_c = override.get("stroke_color") if isinstance(stroke_c, str) and stroke_c: tags.append(f"\\3c{hex_to_ass_color(stroke_c)}") # 描边宽:\bordN stroke_w = override.get("stroke_width") try: sw = float(stroke_w) if stroke_w is not None else None if sw is not None and sw >= 0: tags.append(f"\\bord{sw:g}") except (TypeError, ValueError): pass # 阴影色:\4c&HBBGGRR& shadow_c = override.get("shadow_color") if isinstance(shadow_c, str) and shadow_c: tags.append(f"\\4c{hex_to_ass_color(shadow_c)}") # 阴影偏移:\shadN(单值,同时设置 x/y;精细控制用 \xshad/\yshad) sx = override.get("shadow_offset_x") sy = override.get("shadow_offset_y") try: sx_i = int(sx) if sx is not None else None sy_i = int(sy) if sy is not None else None if sx_i is not None: tags.append(f"\\xshad{sx_i}") if sy_i is not None: tags.append(f"\\yshad{sy_i}") except (TypeError, ValueError): pass if not tags: return "" return "{" + "".join(tags) + "}" def apply_line_overrides(self, text: str) -> str: """按 line_overrides 对 ASS 文本(已 escape、换行用 \\N 分隔)逐行套 override 标签. 仅对换行后的每行首加对应 override 标签;无 override 的行保持原样。 """ if not self.line_overrides or not text: return text if "\\N" not in text: # 单行 tag = self.build_line_override_tag(0, 1) return tag + text if tag else text lines = text.split("\\N") total = len(lines) out: list[str] = [] for i, ln in enumerate(lines): tag = self.build_line_override_tag(i, total) out.append(tag + ln if tag else ln) return "\\N".join(out) # ── 字幕片段 ────────────────────────────────────────────────────────────────── @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