Files
xiaoxia-saas/packages/domain/ass_subtitle_builder.py
T
saas-backend-agent dc5165bd5f
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 11s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 12m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m23s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 33m11s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 41h52m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 42h23m34s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 42h23m50s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 42h26m4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 42h26m11s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 42h25m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 42h25m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 42h25m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 42h25m42s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 42h25m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 41h52m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 41h52m23s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 41h52m24s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 41h52m17s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 42h23m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 42h23m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 42h23m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 42h23m31s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 42h23m31s
fix(backend): #1896 修复字体映射补充开源字体文件
问题:DRAWTEXT_FONT_MAP把所有前端字体(思源宋体/苹方/微软雅黑/楷体/华康俪金黑)全部映射到 NotoSansSC,导致无论选什么字体成片都渲染为思源黑体;FONT_NAME_MAP 将楷体错误映射到 Noto Serif CJK SC(宋体)。

修复:
- packages/domain/video_filter_builder.py:
  * DRAWTEXT_FONT_MAP 每个字体映射到独立关键字:思源宋体→NotoSerifCJKsc,楷体→LXGWWenKai,苹方/微软雅黑/PingFang 明确标注fallback NotoSansSC(macOS/Windows 系统字体服务器不存在),华康俪金黑→NotoSansSC(商业字体版权风险,前端已移除,兼容老数据)
  * DRAWTEXT_FONT_SEARCH_PATHS 新增 NotoSerifCJKsc-VF.otf 和 LXGWWenKai-Regular.ttf 路径
- packages/domain/ass_subtitle_builder.py:
  * FONT_NAME_MAP 楷体映射到 'LXGW WenKai'(fc-list 验证注册名),思源宋体已正确映射 'Noto Serif CJK SC'(fonts-noto-cjk 包自带);新增 Microsoft YaHei/霞鹜文楷/华康俪金黑别名
- infra/fonts/ 新增两个开源字体文件(SIL Open Font License/Apache 2.0 免费可商用):
  * NotoSerifCJKsc-VF.otf(53MB,思源宋体可变字体,含多字重)
  * LXGWWenKai-Regular.ttf(25MB,霞鹜文楷开源楷体,v1.522)
- infra/docker/worker-base.Dockerfile + api-base.Dockerfile:
  * 保留 Serif .ttc(宋体fallback),仅删除 Sans .ttc(Mono变体问题)
  * COPY 新字体到镜像对应目录,fc-cache -fv 重建字体缓存
  * mkdir -p /usr/share/fonts/truetype/lxgw 确保楷体目录存在
- 字体大小总增量约 78MB(17M→95M),Docker 镜像分层缓存不会显著拉长构建时间

验证:
- 视频合成/ASS字幕/字幕滤镜三条渲染路径字体映射均独立正确
- fc-list 在本地实测可正确识别 'LXGW WenKai' / 'Noto Serif CJK SC' 注册名
- 苹方/微软雅黑等系统字体通过 NotoSansSC fallback 正常显示中文
- 存量数据(老数据选华康俪金黑/苹方等)继续渲染不中断
- 899 个字幕/滤镜/合成相关单测全通过,190 个writeback/filter/compose单测全通过
2026-09-14 20:54:26 +08:00

482 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 = 180
TITLE_MARGIN_BOTTOM = 100
TITLE_MARGIN_SIDE = 40
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
# #1896 字体映射修复:每个字体映射到独立的注册名,而非全部回退到 Noto Sans SC
# - 思源宋体 → Noto Serif CJK SC(fonts-noto-cjk 包预装 + VF.otf)
# - 楷体 → LXGW WenKai(霞鹜文楷,#1896 新增 SIL OFL 开源楷体)
# - 苹方/PingFang/微软雅黑:服务器 Linux 无对应字体,fallback 思源黑体
# - 华康俪金黑:商业字体有版权风险,前端已移除,后端保留映射 fallback 思源黑体(兼容老数据)
FONT_NAME_MAP: dict[str, str] = {
"思源黑体": "Noto Sans SC",
"思源宋体": "Noto Serif CJK SC",
"苹方": "Noto Sans SC",
"PingFang": "Noto Sans SC",
"微软雅黑": "Noto Sans SC",
"Microsoft YaHei": "Noto Sans SC",
"楷体": "LXGW WenKai",
"霞鹜文楷": "LXGW WenKai",
"华康俪金黑": "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)}
"""