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
- 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>
256 lines
9.5 KiB
Python
256 lines
9.5 KiB
Python
"""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
|