307 lines
11 KiB
Python
Executable File
307 lines
11 KiB
Python
Executable File
"""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)}
|
||
"""
|