Files
xiaoxia-saas/apps/worker/video_processing/render_subtitles.py
T
CI Bot 6fc1abf2f5
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 12s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 2m36s
Phase 4 Backend P2 cleanup: 异常处理/类型注解/模块拆分/大函数拆分/配置项确认
- P2-1: 10处 except 审查改进(细化异常类型、添加日志)
- P2-2: 22个路由函数类型注解补全
- P2-3: edit_plans.py 拆分为 4 个模块(CRUD/generation/ai/timeline)
- P2-4: generate_video + generate_plan 大函数拆分
- P2-5: unified_render_service.py 拆分(1517→984行)
  - render_audio.py: 音频混音模块(RenderContext + mix/merge 函数)
  - render_subtitles.py: ASS 字幕生成模块
- P2-6: 6个未使用配置项删除确认

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:00:54 +08:00

256 lines
9.5 KiB
Python
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 字幕生成模块 — 从 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=1outline + 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