4308621845
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (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 / PR Build 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m44s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
## 问题
用户反馈最终生成视频中标题字体比模板预览/选择时看到的小很多。
前端 titleCanvas.ts 已明确 titleConfig.size 的语义是「720p 基准宽度下的 px 字号」,
按 scale = videoWidth/720 缩放渲染到预览 Canvas;但后端三个渲染路径
(ass_subtitle_builder / drawtext 降级 / subtitle_generator ASR)直接使用原始 size 值,
在 1080×1920 竖屏/1920×1080 横屏等非 720p 输出下,标题字号未随分辨率等比放大,
导致成片标题明显小于前端预览。
此外 apps/worker/video_processing/subtitle_generator.py 中存在
`min(int(title_cfg.get("size", 36)), 36)` 的上限钳位,任何分辨率下标题字号
都会被强行压到 ≤36,进一步放大了问题。
## 修复
三个渲染路径统一以 720p 为基准,按 video_width/720 等比缩放所有长度类参数:
- packages/domain/ass_subtitle_builder.py
- 新增 _scale_len(value, video_width) 工具:整数输入返回 int,浮点输入保留 float
- build_ass_content 内新增 _scale_cfg(cfg, defaults) 内部函数:
先 setdefault 填充 size 默认值(title=36、subtitle=24),再统一缩放
size/font_size、margin_top/bg_padding/bg_radius、line_overrides[*].size;
pos_x/pos_y 是百分比(0-100)不缩放
- 描边宽(默认 2)、阴影 blur(默认 4)、阴影 offset_x/y(默认 2)在字段提取
处经 _scale_len 缩放,避免双重缩放
- TITLE_MARGIN_TOP/BOTTOM/SIDE 常量经 _scale_len 成局部 _margin_top/bottom/side
- subtitle outline_width(1.0)经 _scale_len 缩放(保持 float 以支持 1.5 等小数)
- packages/domain/video_filter_builder.py (build_title_drawtext_filter 降级路径)
- 新增 _scale_title_len(value, output_width) 工具
- font_size、border_width(描边)、shadowx/shadowy(阴影偏移)、top/bottom 位置
y 偏移(50px)统一按 output_width/720 缩放
- apps/worker/video_processing/subtitle_generator.py (ASR 时间轴字幕路径)
- 复用 ass_subtitle_builder._scale_len
- subtitle: font_size 默认 24、outline_width 默认 1.5、margin_v/l/r 默认 60/40/40
全部按 video_width/720 缩放
- title: 移除 min(..., 36) 上限钳位;s_width/sh_blur/sh_offset、margin_top/side
统一缩放;_wrap_title_text 传入缩放后的 margin_l/r
## 不缩放的字段
颜色/字体/对齐/粗体/斜体等枚举/布尔值不缩放;
pos_x/pos_y 为百分比(0-100)不缩放;
AI 数字人 WYSIWYG PNG overlay 路径(build_title_overlay_filter)由前端 Canvas 按
videoWidth 直接绘制,后端只 overlay=0:0 叠加,无需后端缩放。
## 测试
- 既有断言固定值的单测:将 video_width 改为 720(720p 基准下缩放比=1,断言值不变)
- 新增 TestTitleFontsizeScaling / TestDrawtextFontsizeScaling 共 16 个测试用例:
720p 不变、1080p 1.5×、1920p 8/3×、描边宽/阴影偏移/边距/位置边距/逐行覆盖 size
等场景
- 修正 test_subtitle_generator.py::test_720p_resolution 中 PlayResX/Y 与
video_width/height 互换的断言错误
- 全量单测:15978 passed, 2 failed(均为 test_1970_musetalk_server.py 中
ffmpeg 超时/FileNotFound 的 flaky,与本次改动无关,在 develop HEAD 上复现)
28 skipped
274 lines
9.9 KiB
Python
Executable File
274 lines
9.9 KiB
Python
Executable File
"""字幕生成器 — 将字幕时间轴转换为 ASS 字幕文件。
|
||
|
||
与 render_subtitles.py 的区别:
|
||
- render_subtitles.py 处理静态整段标题/字幕
|
||
- 本模块处理带时间轴的多段 ASR 字幕
|
||
|
||
两者最终都输出 ASS 文件,供 FFmpeg 烧录。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from packages.domain.ass_subtitle_builder import (
|
||
TITLE_MARGIN_SIDE,
|
||
TITLE_MARGIN_TOP,
|
||
_scale_len,
|
||
_wrap_title_text,
|
||
build_ass_style,
|
||
escape_ass_text,
|
||
format_ass_time,
|
||
hex_to_ass_color,
|
||
position_to_ass_alignment,
|
||
)
|
||
from packages.domain.subtitle import SubtitleTimeline
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||
|
||
DEFAULT_MAX_CHARS_PER_LINE = 20 # 每行最多字符数
|
||
DEFAULT_MIN_CHARS_PER_SEGMENT = 8 # 每段最少字符数
|
||
|
||
|
||
# ── 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 "&H00FFFFFF"
|
||
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 对齐编号。"""
|
||
mapping = {
|
||
"top": 8,
|
||
"center": 5,
|
||
"bottom": 2,
|
||
}
|
||
return mapping.get(position, 2)
|
||
|
||
|
||
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 _escape_ass_text(text: str) -> str:
|
||
"""转义 ASS 文本中的特殊字符。"""
|
||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||
text = text.replace("{", "(").replace("}", ")")
|
||
return text
|
||
|
||
|
||
def _wrap_text(text: str, max_chars: int) -> list[str]:
|
||
"""将长文本按字数换行。
|
||
|
||
优先在标点处换行,没有合适标点时硬切。
|
||
"""
|
||
if len(text) <= max_chars:
|
||
return [text]
|
||
|
||
lines: list[str] = []
|
||
remaining = text
|
||
|
||
while len(remaining) > max_chars:
|
||
# 在前 max_chars 个字符中找标点断开
|
||
break_point = max_chars
|
||
punctuations = ",。!?、;:,.;:!?"
|
||
|
||
for i in range(max_chars, max_chars // 2, -1):
|
||
if i < len(remaining) and remaining[i] in punctuations:
|
||
break_point = i + 1
|
||
break
|
||
|
||
lines.append(remaining[:break_point])
|
||
remaining = remaining[break_point:]
|
||
|
||
if remaining:
|
||
lines.append(remaining)
|
||
|
||
return lines
|
||
|
||
|
||
# ── 主生成器 ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def generate_ass_from_timeline(
|
||
output_path: Path,
|
||
timeline: SubtitleTimeline,
|
||
*,
|
||
video_width: int,
|
||
video_height: int,
|
||
video_duration: float = 0.0,
|
||
subtitle_config: dict[str, Any] | None = None,
|
||
title_text: str = "",
|
||
title_config: dict[str, Any] | None = None,
|
||
) -> Path:
|
||
"""从字幕时间轴生成 ASS 字幕文件。
|
||
|
||
Args:
|
||
output_path: 输出 ASS 文件路径
|
||
timeline: 字幕时间轴
|
||
video_width: 视频宽度
|
||
video_height: 视频高度
|
||
subtitle_config: 字幕样式配置(同 SubtitleConfig dict)
|
||
|
||
Returns:
|
||
生成的 ASS 文件路径
|
||
"""
|
||
subtitle_config = subtitle_config or {}
|
||
|
||
if not timeline.segments:
|
||
output_path.write_text("", encoding="utf-8")
|
||
return output_path
|
||
|
||
# 样式参数(字号/描边/边距按视频宽度缩放,基准 720p,与前端预览一致)
|
||
_sw = video_width
|
||
font_name = subtitle_config.get("font", "思源黑体")
|
||
font_size = _scale_len(int(subtitle_config.get("size", 24)), _sw)
|
||
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
|
||
position = subtitle_config.get("position", "bottom")
|
||
alignment = _position_to_ass_alignment(position)
|
||
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
|
||
|
||
# 描边(默认黑色描边,保证可读性)—— 720p 基准 1.5
|
||
outline_color = "&H00000000"
|
||
outline_width = _scale_len(1.5, _sw)
|
||
|
||
# 边距(720p 基准 60/40)
|
||
margin_v = _scale_len(60 if position == "bottom" else 60, _sw)
|
||
margin_l = _scale_len(40, _sw)
|
||
margin_r = _scale_len(40, _sw)
|
||
|
||
# 生成样式行
|
||
style_line = (
|
||
f"Style: Default,{font_name},{font_size},{color},"
|
||
f"&H000000FF,{outline_color},&H00000000,"
|
||
f"-1,0,0,0,100,100,0,0,"
|
||
f"1,{outline_width},0,{alignment},"
|
||
f"{margin_l},{margin_r},{margin_v},1"
|
||
)
|
||
|
||
# 生成事件行
|
||
events: list[str] = []
|
||
for seg in timeline.segments:
|
||
start_time = _format_ass_time(seg.start)
|
||
end_time = _format_ass_time(seg.end)
|
||
|
||
# 自动换行
|
||
lines = _wrap_text(seg.text, max_chars_per_line)
|
||
display_text = "\\N".join(lines)
|
||
|
||
safe_text = _escape_ass_text(display_text)
|
||
|
||
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
|
||
|
||
# ── 标题样式与事件(叠加在 ASR 字幕之上)───────────────────────────
|
||
title_cfg = title_config or {}
|
||
if not isinstance(title_cfg, dict):
|
||
title_cfg = {}
|
||
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
|
||
|
||
title_style_line = ""
|
||
title_event_line = ""
|
||
|
||
if title_enabled:
|
||
# 兼容 boolean stroke/shadow → dict
|
||
_stroke_val = title_cfg.get("stroke")
|
||
if isinstance(_stroke_val, bool):
|
||
title_cfg["stroke"] = (
|
||
{"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False}
|
||
)
|
||
_shadow_val = title_cfg.get("shadow")
|
||
if isinstance(_shadow_val, bool):
|
||
title_cfg["shadow"] = (
|
||
{"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2}
|
||
if _shadow_val
|
||
else {"enabled": False}
|
||
)
|
||
|
||
# 字段名归一化: font_size→size, font_color→color
|
||
if "font_size" in title_cfg and "size" not in title_cfg:
|
||
title_cfg["size"] = title_cfg["font_size"]
|
||
if "font_color" in title_cfg and "color" not in title_cfg:
|
||
title_cfg["color"] = title_cfg["font_color"]
|
||
|
||
_tsw = video_width
|
||
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
|
||
t_stroke = title_cfg.get("stroke", {}) or {}
|
||
t_shadow = title_cfg.get("shadow", {}) or {}
|
||
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
|
||
s_width = _scale_len(float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0, _tsw)
|
||
sh_blur = _scale_len(float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0, _tsw)
|
||
sh_offset = (
|
||
_scale_len(t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, _tsw),
|
||
_scale_len(t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, _tsw),
|
||
)
|
||
t_alignment = position_to_ass_alignment(title_cfg.get("position", "bottom"))
|
||
|
||
# 标题字号/边距按视频宽度缩放(基准 720p),移除旧的 min(...,36) 上限避免 1080p 被钳位过小
|
||
_base_title_size = int(title_cfg.get("size", 36))
|
||
t_font_size = _scale_len(_base_title_size, _tsw)
|
||
_t_margin_top = _scale_len(TITLE_MARGIN_TOP, _tsw)
|
||
_t_margin_side = _scale_len(TITLE_MARGIN_SIDE, _tsw)
|
||
|
||
title_style_line = build_ass_style(
|
||
"TitleStyle",
|
||
font_name=title_cfg.get("font", "思源黑体"),
|
||
font_size=t_font_size,
|
||
primary_color=t_color,
|
||
outline_color=s_color,
|
||
outline_width=s_width,
|
||
shadow_blur=sh_blur,
|
||
shadow_offset=sh_offset,
|
||
bold=bool(title_cfg.get("bold", True)),
|
||
italic=bool(title_cfg.get("italic", False)),
|
||
alignment=t_alignment,
|
||
margin_v=_t_margin_top,
|
||
margin_l=_t_margin_side,
|
||
margin_r=_t_margin_side,
|
||
)
|
||
|
||
safe_raw = escape_ass_text(title_text.strip())
|
||
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size, margin_l=_t_margin_side, margin_r=_t_margin_side)
|
||
|
||
if video_duration > 0:
|
||
t_end_time = format_ass_time(video_duration)
|
||
else:
|
||
t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0)
|
||
title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}"
|
||
|
||
# 组装 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(filter(None, [title_style_line, style_line]))}
|
||
|
||
[Events]
|
||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||
{chr(10).join(filter(None, [title_event_line] + events))}
|
||
"""
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
output_path.write_text(ass_content, encoding="utf-8")
|
||
return output_path
|