diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 49930092e..bd14d13a0 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -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", diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index dec1481bd..55cdc28c9 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -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 []