fix: ASR字幕路径叠加标题,修复封面没有标题的问题 (#1374)
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m31s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Unit Tests (push) Successful in 8m51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 768h40m57s
CI/CD Pipeline / Deploy Production (push) Failing after 768h56m40s
CI/CD Pipeline / CI Gate (push) Failing after 768h56m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 768h56m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 768h56m41s
CI/CD Pipeline / PR Build Web Image (push) Failing after 769h5m29s
CI/CD Pipeline / PR Build API Image (push) Failing after 769h5m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 769h5m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 769h30m10s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 769h30m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 769h38m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 769h39m2s

This commit was merged in pull request #1374.
This commit is contained in:
2026-08-15 14:21:09 +08:00
parent 59faf37fc9
commit d7bc908ed3
3 changed files with 381 additions and 3 deletions
@@ -52,6 +52,7 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.ass_subtitle_builder import build_ass_content
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
@@ -131,6 +132,83 @@ _PIP_SCALE = 0.25 # PiP 占主画面的比例
# ── 统一渲染引擎 ─────────────────────────────────────────────────────────────
def _overlay_title_on_ass(
ass_path: Path,
*,
title_text: str,
title_config: dict,
video_width: int,
video_height: int,
video_duration: float,
) -> None:
"""在已有的 ASS 文件上叠加标题事件。
用于 ASR 字幕路径:ASR 生成的 ASS 只含字幕事件,此函数将标题
作为独立的 TitleStyle + Dialogue 事件追加进去,使标题显示在
ASR 字幕之上(封面抽帧时也能看到标题)。
Args:
ass_path: 已有的 ASS 文件路径(由 generate_ass_from_timeline 生成)
title_text: 标题文本
title_config: 标题样式配置
video_width: 视频宽度
video_height: 视频高度
video_duration: 视频时长
"""
if not title_text or not title_text.strip():
return
# 生成仅包含标题的 ASS 内容
title_only_content = build_ass_content(
video_width=video_width,
video_height=video_height,
video_duration=video_duration,
title_text=title_text,
title_config=title_config,
)
if not title_only_content:
return
# 从 title_only_content 中提取 TitleStyle 行和标题 Dialogue 行
title_style_line = None
title_dialogue_line = None
for line in title_only_content.splitlines():
if line.startswith("Style: TitleStyle"):
title_style_line = line
elif "TitleStyle" in line and line.startswith("Dialogue:"):
title_dialogue_line = line
if not title_style_line or not title_dialogue_line:
logger.warning("标题 ASS 内容解析失败,跳过叠加")
return
# 读取现有 ASS 文件
existing_content = ass_path.read_text(encoding="utf-8")
# 插入 TitleStyle 到 [V4+ Styles] 段(最后一个 Style: 行之后)
# 插入标题 Dialogue 到 [Events] 段(Format 行之后)
lines = existing_content.splitlines()
last_style_idx = -1
events_format_idx = -1
for i, line in enumerate(lines):
if line.startswith("Style:"):
last_style_idx = i
if line.startswith("Format: Layer,"):
events_format_idx = i
if last_style_idx >= 0:
lines.insert(last_style_idx + 1, title_style_line)
# events_format_idx 需要 +1 因为插入了一行
events_format_idx += 1
# 2. 在 Events Format 行之后、第一个 Dialogue 之前插入标题 Dialogue
# 标题应该显示在整个视频时长,放在最前面(最先渲染,在底层)
if events_format_idx >= 0:
lines.insert(events_format_idx + 1, title_dialogue_line)
ass_path.write_text("\n".join(lines), encoding="utf-8")
class UnifiedRenderService:
"""统一渲染引擎。
@@ -515,14 +593,71 @@ class UnifiedRenderService:
timeline.segment_count,
video_duration,
)
# ASR 路径也需要叠加标题(标题作为独立 ASS Event 追加到 ASR 字幕之上)
# 用独立 try-except 包裹,避免叠加失败时覆盖已生成的 ASR 数据
if has_title:
try:
_overlay_title_on_ass(
ass_path,
title_text=title_text,
title_config=title_cfg,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
)
logger.info(
"ASR字幕叠加标题: plan_id=%s title=%s",
self.plan.id,
title_text[:30],
)
except Exception:
logger.warning(
"ASR字幕叠加标题失败,保留纯ASR字幕: plan_id=%s",
self.plan.id,
exc_info=True,
)
return ass_path
else:
# ASR 无结果,不生成字幕
# ASR 无结果:如果有标题,仍然生成标题 ASS
if has_title:
generate_ass_subtitles(
ass_path,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
title_text=title_text,
title_config=title_cfg,
)
logger.info(
"ASR无结果但生成标题: plan_id=%s title=%s",
self.plan.id,
title_text[:30],
)
return ass_path
logger.info("ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id)
return None
except Exception:
# ASR 失败降级:不生成字幕,不阻断主流程
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
# ASR 失败降级:如果有标题,仍然生成标题 ASS
if has_title:
try:
generate_ass_subtitles(
ass_path,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
title_text=title_text,
title_config=title_cfg,
)
logger.info(
"ASR失败但生成标题: plan_id=%s title=%s",
self.plan.id,
title_text[:30],
)
return ass_path
except Exception:
logger.warning("ASR失败后标题生成也失败", exc_info=True)
else:
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
return None
# 静态字幕模式(原有逻辑)