"""ASS 字幕构建领域模型 — 纯逻辑,无文件IO依赖. 抽离自 render_subtitles.py,包含: - 颜色转换(hex → ASS &HBBGGRR) - 位置对齐映射 - ASS Style 行构建 - 文本转义 - 时间格式化 - 完整 ASS 内容生成(返回字符串,不写文件) """ from __future__ import annotations import logging from typing import Any logger = logging.getLogger(__name__) # ── 常量 ────────────────────────────────────────────────────────────────────── # Title/Subtitle 默认边距(像素) TITLE_MARGIN_TOP = 60 TITLE_MARGIN_BOTTOM = 60 TITLE_MARGIN_SIDE = 40 # ── 颜色转换 ────────────────────────────────────────────────────────────────── def hex_to_ass_color(hex_color: str) -> str: """将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式. Args: hex_color: HEX 颜色字符串,支持 #RRGGBB 或 RRGGBB 格式 Returns: ASS 格式颜色,如 &H0000FF(红色) """ hex_color = hex_color.lstrip("#") if len(hex_color) != 6: return "&H000000" r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] return f"&H{b.upper()}{g.upper()}{r.upper()}" # ── 位置对齐 ────────────────────────────────────────────────────────────────── def position_to_ass_alignment(position: str) -> int: """将文字位置映射为 ASS \\an 对齐编号. ASS 对齐编号(数字小键盘布局): 7 8 9 4 5 6 1 2 3 Args: position: 位置字符串 top/center/bottom Returns: ASS 对齐编号,默认 8(顶部居中) """ mapping = { "top": 8, "center": 5, "bottom": 2, } return mapping.get(position, 8) # ── Style 行构建 ────────────────────────────────────────────────────────────── def build_ass_style( style_name: str, *, font_name: str = "思源黑体", font_size: int = 48, primary_color: str = "&H00FFFFFF", outline_color: str = "&H00000000", outline_width: float = 1.0, shadow_blur: float = 0.0, shadow_offset: tuple[int, int] = (0, 0), bold: bool = False, italic: bool = False, alignment: int = 8, margin_v: int = 60, margin_l: int = 40, margin_r: int = 40, ) -> str: """构建 ASS Style 行. Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Args: style_name: 样式名称 font_name: 字体名称 font_size: 字体大小 primary_color: 主色(文字颜色) outline_color: 描边颜色 outline_width: 描边宽度 shadow_blur: 阴影模糊度(>0 时启用阴影) shadow_offset: 阴影偏移 (x, y) bold: 是否粗体 italic: 是否斜体 alignment: 对齐方式(ASS \an 编号) margin_v: 垂直边距 margin_l: 左边距 margin_r: 右边距 Returns: 完整的 Style: 行字符串 """ bold_val = -1 if bold else 0 italic_val = -1 if italic else 0 # BackColour 用于阴影(BorderStyle=1 时 outline + shadow) back_color = primary_color # Shadow 深度:shadow_offset[1] 作为纵向偏移 shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0 return ( f"Style: {style_name},{font_name},{font_size},{primary_color}," f"&H000000FF,{outline_color},{back_color}," f"{bold_val},{italic_val},0,0,100,100,0,0," f"1,{outline_width},{shadow_depth},{alignment}," f"{margin_l},{margin_r},{margin_v},1" ) # ── 文本转义 ────────────────────────────────────────────────────────────────── def escape_ass_text(text: str) -> str: r"""转义 ASS 文本中的特殊字符. ASS 中换行用 \N(硬换行)或 \n(软换行), 大括号 {} 用于覆盖样式,需要转义. Args: text: 原始文本 Returns: 转义后的 ASS 文本 """ # 将实际换行转为 ASS 硬换行 text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N") # 转义大括号(ASS 用它做样式覆盖标签) text = text.replace("{", "(").replace("}", ")") return text # ── 时间格式化 ──────────────────────────────────────────────────────────────── def format_ass_time(seconds: float) -> str: """将秒数格式化为 ASS 时间格式 H:MM:SS.cc. Args: seconds: 秒数 Returns: ASS 格式时间,如 "1:23:45.67" """ hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = seconds % 60 return f"{hours}:{minutes:02d}:{secs:05.2f}" # ── 完整 ASS 内容生成 ───────────────────────────────────────────────────────── def build_ass_content( *, video_width: int, video_height: int, video_duration: float, title_text: str = "", title_config: dict[str, Any] | None = None, subtitle_text: str = "", subtitle_config: dict[str, Any] | None = None, ) -> str: """生成 ASS 字幕文件内容(纯字符串,不写文件). 支持 Title(标题)和 Subtitle(字幕)两种字幕类型, 各自可独立配置样式、位置和内容. Args: video_width: 视频宽度(用于 ASS PlayResX) video_height: 视频高度(用于 ASS PlayResY) video_duration: 视频总时长(秒),字幕显示整个时长 title_text: 标题文本 title_config: 标题样式配置 subtitle_text: 字幕文本 subtitle_config: 字幕样式配置 Returns: 完整的 ASS 文件内容字符串;无字幕时返回空字符串 """ title_config = title_config or {} subtitle_config = subtitle_config or {} title_enabled = title_config.get("enabled", True) and bool(title_text.strip()) subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip()) if not title_enabled and not subtitle_enabled: return "" styles: list[str] = [] events: list[str] = [] # ── Title 样式与事件 ────────────────────────────────────────────────── if title_enabled: title_color = hex_to_ass_color(title_config.get("color", "#ffffff")) title_stroke = title_config.get("stroke", {}) or {} title_shadow = title_config.get("shadow", {}) or {} stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000")) stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0 shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0 shadow_offset = ( title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0, title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0, ) title_alignment = position_to_ass_alignment(title_config.get("position", "top")) styles.append( build_ass_style( "TitleStyle", font_name=title_config.get("font", "思源黑体"), font_size=int(title_config.get("size", 48)), primary_color=title_color, outline_color=stroke_color, outline_width=stroke_width, shadow_blur=shadow_blur, shadow_offset=shadow_offset, bold=bool(title_config.get("bold", True)), italic=bool(title_config.get("italic", False)), alignment=title_alignment, margin_v=TITLE_MARGIN_TOP, margin_l=TITLE_MARGIN_SIDE, margin_r=TITLE_MARGIN_SIDE, ) ) safe_title_text = escape_ass_text(title_text) events.append( "Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}" ) # ── Subtitle 样式与事件 ─────────────────────────────────────────────── if subtitle_enabled: sub_color = hex_to_ass_color(subtitle_config.get("color", "#ffffff")) sub_alignment = position_to_ass_alignment(subtitle_config.get("position", "bottom")) styles.append( build_ass_style( "SubtitleStyle", font_name=subtitle_config.get("font", "思源黑体"), font_size=int(subtitle_config.get("size", 24)), primary_color=sub_color, outline_color="&H00000000", outline_width=1.0, shadow_blur=0.0, shadow_offset=(0, 0), bold=False, italic=False, alignment=sub_alignment, margin_v=TITLE_MARGIN_BOTTOM, margin_l=TITLE_MARGIN_SIDE, margin_r=TITLE_MARGIN_SIDE, ) ) safe_subtitle_text = escape_ass_text(subtitle_text) events.append( "Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "SubtitleStyle,,0,0,0,," f"{safe_subtitle_text}" ) # ── 组装 ASS 文件 ───────────────────────────────────────────────────── return f"""[Script Info] ScriptType: v4.00+ PlayResX: {video_width} PlayResY: {video_height} ScaledBorderAndShadow: yes WrapStyle: 2 Encoding: UTF-8 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501 {chr(10).join(styles)} [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text {chr(10).join(events)} """