From 7914be887a46cac2a1d563cec1b2be38795ceaa1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 14 Aug 2026 11:30:46 +0800 Subject: [PATCH 1/4] fix: overlay title text on auto-generated cover frames When auto-generating cover images from video keyframes, the title text from plan config was not being overlaid on the cover images. Users saw raw video frames without any title text. Changes: - thumbnail_generator.py: Add _overlay_title_on_image() using FFmpeg drawtext filter with Chinese font (Noto Sans CJK), white text with shadow, centered at bottom area of the image - extract_cover_candidates(): Accept optional title_text parameter, overlay title on each extracted frame - extract_and_upload_cover_frames(): Accept and pass title_text through - render_adapter.py: Extract title text from plan.config['title']['text'] and pass to cover generation The title overlay gracefully falls back to the original image if FFmpeg drawtext fails (e.g., font not found). --- .../worker/video_processing/render_adapter.py | 8 +- .../video_processing/thumbnail_generator.py | 86 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 49930092e..770501038 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.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..c8d70e3e7 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -120,6 +120,84 @@ 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("%", "%%") + # 截断过长标题 + 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 +241,7 @@ def extract_cover_candidates( *, width: int = 640, timeout: int = 30, + title_text: str = "", ) -> list[dict]: """在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。 @@ -218,6 +297,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 +319,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 +332,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 [] -- 2.54.0 From 8a447862426fb88b2cd4c953d5529e70e8ce9e95 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 14 Aug 2026 03:34:10 +0000 Subject: [PATCH 2/4] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/video_processing/thumbnail_generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index c8d70e3e7..dab1bec2a 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -186,6 +186,7 @@ def _overlay_title_on_image( 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: -- 2.54.0 From 48a14f8fea2b7943a75531d2e447ac9e0863e695 Mon Sep 17 00:00:00 2001 From: auto-approve-bot Date: Fri, 14 Aug 2026 11:58:24 +0800 Subject: [PATCH 3/4] fix: add [ ] escaping for FFmpeg drawtext & guard plan_config None - thumbnail_generator: _overlay_title_on_image now escapes [ and ] characters to prevent FFmpeg filter syntax errors when title contains brackets (e.g. '[Tag]') - render_adapter: use (plan_config or {}).get(...) to guard against None plan_config in cover candidate generation block Addresses AI Code Review findings on PR #1365. --- apps/worker/video_processing/render_adapter.py | 2 +- apps/worker/video_processing/thumbnail_generator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 770501038..bd14d13a0 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -584,7 +584,7 @@ class RenderAdapter: from video_processing.thumbnail_generator import extract_and_upload_cover_frames # 从 plan config 提取标题文字,叠加到封面候选帧上 - _title_cfg = plan_config.get("title", {}) or {} + _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( diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index dab1bec2a..04e2d44f7 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -145,7 +145,7 @@ def _overlay_title_on_image( # 转义 drawtext 特殊字符 # FFmpeg drawtext 需要转义: ' : % \ [ ] - escaped = title_text.replace("\\", "\\\\").replace("'", "’").replace(":", "\\:").replace("%", "%%") + escaped = title_text.replace("\\", "\\\\").replace("'", "’").replace(":", "\\:").replace("%", "%%").replace("[", "\\[").replace("]", "\\]") # 截断过长标题 if len(escaped) > 60: escaped = escaped[:57] + "..." -- 2.54.0 From 65fff01db0b730cf47ea0f87e586e1635ded10d5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 14 Aug 2026 04:00:52 +0000 Subject: [PATCH 4/4] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/video_processing/thumbnail_generator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 04e2d44f7..55cdc28c9 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -145,7 +145,14 @@ def _overlay_title_on_image( # 转义 drawtext 特殊字符 # FFmpeg drawtext 需要转义: ' : % \ [ ] - escaped = title_text.replace("\\", "\\\\").replace("'", "’").replace(":", "\\:").replace("%", "%%").replace("[", "\\[").replace("]", "\\]") + escaped = ( + title_text.replace("\\", "\\\\") + .replace("'", "’") + .replace(":", "\\:") + .replace("%", "%%") + .replace("[", "\\[") + .replace("]", "\\]") + ) # 截断过长标题 if len(escaped) > 60: escaped = escaped[:57] + "..." -- 2.54.0