fix: overlay title text on auto-generated cover frames #1365
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user