"""ASS 字幕生成模块 — 从 unified_render_service.py 拆分. 职责: - 将 title / subtitle 配置转换为 ASS 字幕文件 - 提供样式计算(颜色、对齐、描边/阴影) - 供 UnifiedRenderService._maybe_generate_ass 调用 """ from __future__ import annotations import logging from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # ── 常量 ────────────────────────────────────────────────────────────────────── # Title/Subtitle 默认边距(像素) TITLE_MARGIN_TOP = 60 TITLE_MARGIN_BOTTOM = 60 TITLE_MARGIN_SIDE = 40 # ── ASS 字幕工具 ───────────────────────────────────────────────────────────── def _hex_to_ass_color(hex_color: str) -> str: """将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。""" 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 """ mapping = { "top": 8, # 顶部居中 "center": 5, # 居中 "bottom": 2, # 底部居中 } return mapping.get(position, 8) 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 """ bold_val = -1 if bold else 0 italic_val = -1 if italic else 0 # BackColour 用于阴影(BorderStyle=1 时 outline + shadow) back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制) # Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素), # 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现 # 简化:BorderStyle=1(outline + drop shadow),Shadow 字段表示阴影深度 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(软换行), 大括号 {} 用于覆盖样式,需要转义。 """ # 将实际换行转为 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。""" hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = seconds % 60 return f"{hours}:{minutes:02d}:{secs:05.2f}" def generate_ass_subtitles( output_path: Path, *, 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, ) -> Path: """生成 ASS 字幕文件。 支持 Title(标题)和 Subtitle(字幕)两种字幕类型, 各自可独立配置样式、位置和内容。 Args: output_path: 输出 ASS 文件路径 video_width: 视频宽度(用于 ASS PlayResX) video_height: 视频高度(用于 ASS PlayResY) video_duration: 视频总时长(秒),字幕显示整个时长 title_text: 标题文本 title_config: 标题样式配置(TitleConfig dict) subtitle_text: 字幕文本 subtitle_config: 字幕样式配置(SubtitleConfig dict) 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: # 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用) output_path.write_text("", encoding="utf-8") return output_path 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, ) ) # 转义 ASS 特殊字符 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 文件 ───────────────────────────────────────────────────── ass_content = 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)} """ output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(ass_content, encoding="utf-8") return output_path