fix: overlay title text on auto-generated cover frames #1365

Merged
auto-approve-bot merged 4 commits from fix/cover-title-overlay into develop 2026-08-14 12:11:00 +08:00
2 changed files with 100 additions and 2 deletions
@@ -583,7 +583,13 @@ class RenderAdapter:
try:
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
cover_candidates = extract_and_upload_cover_frames(str(result.output_path), plan_id, num_frames=3)
# 从 plan config 提取标题文字,叠加到封面候选帧上
_title_cfg = (plan_config or {}).get("title", {}) or {}
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
)
if cover_candidates:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
@@ -120,6 +120,92 @@ def _format_seek_time(seconds: float) -> str:
return f"{h:02d}:{m:02d}:{s:05.2f}"
def _overlay_title_on_image(
image_path: str,
title_text: str,
*,
timeout: int = 15,
) -> str:
"""在封面图上叠加标题文字(居中、白色、带阴影)。
使用 FFmpeg drawtext 滤镜,原地覆盖 image_path。
Args:
image_path: 输入图片路径(覆盖写入)
title_text: 要叠加的标题文字
timeout: 超时时间(秒)
Returns:
处理后的图片路径(与输入相同)
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
if not title_text or not title_text.strip():
return image_path
# 转义 drawtext 特殊字符
# FFmpeg drawtext 需要转义: ' : % \ [ ]
escaped = (
title_text.replace("\\", "\\\\")
.replace("'", "")
.replace(":", "\\:")
.replace("%", "%%")
.replace("[", "\\[")
.replace("]", "\\]")
)
# 截断过长标题
if len(escaped) > 60:
escaped = escaped[:57] + "..."
# 使用中文字体
font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
# drawtext 滤镜参数:
# - 白色文字,字号按画面宽度自适应(约 1/18 宽度)
# - 黑色阴影偏移 2px
# - 水平居中,垂直偏下(距底部约 15%)
drawtext_filter = (
f"drawtext=fontfile='{font_path}'"
f":text='{escaped}'"
f":fontsize=h/14"
f":fontcolor=white"
f":shadowcolor=black@0.7"
f":shadowx=2:shadowy=2"
f":x=(w-text_w)/2"
f":y=h*0.82-text_h/2"
f":borderw=0"
)
tmp_out = image_path + ".tmp.jpg"
cmd = [
FFMPEG_BIN,
"-y",
"-i",
image_path,
"-vf",
drawtext_filter,
"-q:v",
"2",
tmp_out,
]
try:
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
# 覆盖原文件
import shutil
shutil.move(tmp_out, image_path)
logger.info("封面标题叠加成功: text=%s", title_text[:30])
except Exception as e:
logger.warning("封面标题叠加失败(使用原图): %s", e)
try:
Path(tmp_out).unlink(missing_ok=True)
except Exception:
pass
return image_path
def generate_and_upload_thumbnail(
video_path: str,
storage_key: str,
@@ -163,6 +249,7 @@ def extract_cover_candidates(
*,
width: int = 640,
timeout: int = 30,
title_text: str = "",
) -> list[dict]:
"""在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。
@@ -218,6 +305,9 @@ def extract_cover_candidates(
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
if Path(output_path).exists() and Path(output_path).stat().st_size > 0:
# 叠加标题文字
if title_text and title_text.strip():
_overlay_title_on_image(output_path, title_text, timeout=timeout)
results.append(
{
"local_path": output_path,
@@ -237,6 +327,8 @@ def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
num_frames: int = 3,
*,
title_text: str = "",
) -> list[dict]:
"""抽取封面候选帧并上传到 OSS。
@@ -248,7 +340,7 @@ def extract_and_upload_cover_frames(
Returns:
[{"image_url": "https://...", "frame_time": 5.0, "storage_key": "covers/xxx/frame_0.jpg"}, ...]
"""
candidates = extract_cover_candidates(video_path, num_frames=num_frames)
candidates = extract_cover_candidates(video_path, num_frames=num_frames, title_text=title_text)
if not candidates:
logger.warning("封面候选帧抽取为空: plan_id=%s", plan_id)
return []