e86f137c3d
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
CI/CD Pipeline / Validate - Style (push) Successful in 2m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m20s
CI/CD Pipeline / Validate - Security (push) Successful in 6m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m59s
CI/CD Pipeline / Unit Tests (push) Failing after 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
475 lines
18 KiB
Python
Executable File
475 lines
18 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 = 120
|
||
TITLE_MARGIN_BOTTOM = 60
|
||
TITLE_MARGIN_SIDE = 40
|
||
|
||
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
|
||
FONT_NAME_MAP: dict[str, str] = {
|
||
"思源黑体": "Noto Sans SC",
|
||
"思源宋体": "Noto Serif CJK SC",
|
||
"苹方": "Noto Sans SC",
|
||
"PingFang": "Noto Sans SC",
|
||
"微软雅黑": "Noto Sans SC",
|
||
"楷体": "Noto Serif CJK SC",
|
||
"华康俪金黑": "Noto Sans SC",
|
||
}
|
||
|
||
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
|
||
# 中文字符实际只占声明字号的约 65%~75%;浏览器 CSS font-size 让中文字符占满声明高度。
|
||
# 为让成片中文字高与前端 CSS 预览一致,写入 ASS 时对字号乘以补偿系数。
|
||
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
|
||
ASS_FONTSIZE_COMPENSATION = 1.35
|
||
|
||
|
||
def _compensate_ass_fontsize(font_size: int) -> int:
|
||
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
|
||
return max(1, round(font_size * ASS_FONTSIZE_COMPENSATION))
|
||
|
||
|
||
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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 对齐编号,默认 2(底部居中,与前端 DEFAULT_TITLE_SETTINGS.position="bottom" 对齐)
|
||
"""
|
||
mapping = {
|
||
"top": 8,
|
||
"center": 5,
|
||
"bottom": 2,
|
||
}
|
||
return mapping.get(position, 2)
|
||
|
||
|
||
# ── 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
|
||
|
||
# 字体名称映射:前端中文字体名 → 服务器注册名,未命中则原样使用
|
||
actual_font = FONT_NAME_MAP.get(font_name, font_name)
|
||
|
||
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow)
|
||
back_color = primary_color
|
||
|
||
# Shadow 深度:shadow_offset[1] 作为纵向偏移
|
||
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
|
||
|
||
# 写入 ASS Style 时对字号做补偿,使成片中文字高与前端 CSS 预览一致
|
||
ass_font_size = _compensate_ass_fontsize(font_size)
|
||
|
||
return (
|
||
f"Style: {style_name},{actual_font},{ass_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("/", "\\N").replace("/", "\\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.
|
||
|
||
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 _wrap_title_text(
|
||
text: str,
|
||
video_width: int,
|
||
font_size: int,
|
||
margin_l: int = TITLE_MARGIN_SIDE,
|
||
margin_r: int = TITLE_MARGIN_SIDE,
|
||
) -> str:
|
||
"""根据视频宽度和字号自动换行标题文本。
|
||
|
||
中文字符按 font_size 像素宽度估算,英文/数字按半角估算。
|
||
超过可用宽度时插入 \\N (ASS 硬换行)。
|
||
"""
|
||
if not text or video_width <= 0 or font_size <= 0:
|
||
return text
|
||
|
||
available_width = video_width - margin_l - margin_r
|
||
if available_width <= 0:
|
||
return text
|
||
|
||
# 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染
|
||
|
||
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
|
||
segments = text.split("\\N")
|
||
wrapped_segments: list[str] = []
|
||
|
||
for seg in segments:
|
||
lines: list[str] = []
|
||
current_line = ""
|
||
current_width = 0.0
|
||
|
||
for ch in seg:
|
||
# CJK 字符按全角估算,其他按半角
|
||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||
|
||
if current_width + char_width > available_width and current_line:
|
||
lines.append(current_line)
|
||
current_line = ch
|
||
current_width = char_width
|
||
else:
|
||
current_line += ch
|
||
current_width += char_width
|
||
|
||
if current_line:
|
||
lines.append(current_line)
|
||
|
||
wrapped_segments.append("\\N".join(lines))
|
||
|
||
return "\\N".join(wrapped_segments)
|
||
|
||
|
||
def _parse_title_position(
|
||
title_config: dict[str, Any],
|
||
video_width: int,
|
||
video_height: int,
|
||
) -> tuple[int, int] | None:
|
||
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
|
||
|
||
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
|
||
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
|
||
|
||
Args:
|
||
title_config: 标题配置 dict
|
||
video_width: PlayResX(视频宽度像素)
|
||
video_height: PlayResY(视频高度像素)
|
||
|
||
Returns:
|
||
(x, y) 整数坐标,或 None 表示不使用自由位置
|
||
"""
|
||
if "pos_x" not in title_config or "pos_y" not in title_config:
|
||
return None
|
||
raw_x = title_config["pos_x"]
|
||
raw_y = title_config["pos_y"]
|
||
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
|
||
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
|
||
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
|
||
return None
|
||
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
|
||
return None
|
||
x, y = raw_x, raw_y
|
||
if video_width <= 0 or video_height <= 0:
|
||
return None
|
||
if not (0 <= x <= video_width and 0 <= y <= video_height):
|
||
return None
|
||
return (x, y)
|
||
|
||
|
||
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 {}
|
||
|
||
# ── 字段名归一化:前端传 font_size/font_color,内部用 size/color ──
|
||
if "font_size" in title_config and "size" not in title_config:
|
||
title_config["size"] = title_config["font_size"]
|
||
if "font_color" in title_config and "color" not in title_config:
|
||
title_config["color"] = title_config["font_color"]
|
||
|
||
# ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ──
|
||
# 前端 TitleSettings 发送 stroke=true/false, shadow=true/false
|
||
# 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y}
|
||
if title_config:
|
||
_stroke_val = title_config.get("stroke")
|
||
if isinstance(_stroke_val, bool):
|
||
title_config["stroke"] = (
|
||
{
|
||
"enabled": _stroke_val,
|
||
"color": "#000000",
|
||
"width": 2,
|
||
}
|
||
if _stroke_val
|
||
else {"enabled": False}
|
||
)
|
||
_shadow_val = title_config.get("shadow")
|
||
if isinstance(_shadow_val, bool):
|
||
title_config["shadow"] = (
|
||
{
|
||
"enabled": _shadow_val,
|
||
"color": "#000000",
|
||
"blur": 4,
|
||
"offset_x": 2,
|
||
"offset_y": 2,
|
||
}
|
||
if _shadow_val
|
||
else {"enabled": False}
|
||
)
|
||
|
||
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", 2)) 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,
|
||
)
|
||
|
||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
|
||
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
|
||
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
|
||
# position → alignment 三档逻辑,现有输出保持一字节不变。
|
||
title_pos = _parse_title_position(title_config, video_width, video_height)
|
||
|
||
title_alignment = (
|
||
5 if title_pos is not None else position_to_ass_alignment(title_config.get("position", "bottom"))
|
||
)
|
||
|
||
styles.append(
|
||
build_ass_style(
|
||
"TitleStyle",
|
||
font_name=title_config.get("font", "思源黑体"),
|
||
font_size=int(title_config.get("size", 36)),
|
||
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,
|
||
)
|
||
)
|
||
|
||
# 根据视频宽度和字号自动换行标题,防止超出画面
|
||
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
|
||
title_font_size = int(title_config.get("size", 36))
|
||
safe_title_text_raw = escape_ass_text(title_text)
|
||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||
|
||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||
if title_pos is not None:
|
||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_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)}
|
||
"""
|