"""封面标题文字叠加(Pillow)— API / Worker 共用。 在封面帧上绘制白色标题文字 + 黑色描边/阴影,支持 CJK 字体和自动换行。 从已渲染视频抽帧时通常不需要调用(标题已烧录); 从源素材抽帧(API E2 兜底)时调用,保证封面带标题。 """ from __future__ import annotations import logging from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) # 按优先级查找 CJK 字体(Debian/Ubuntu fonts-noto-cjk 安装路径) _FONT_CANDIDATES = ( "/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", ) def find_title_font(size: int): """查找可用的 CJK 字体并返回 PIL ImageFont,找不到返回 None。""" try: from PIL import ImageFont except ImportError: return None for fp in _FONT_CANDIDATES: if Path(fp).exists(): try: return ImageFont.truetype(fp, size=size) except Exception: continue logger.warning("未找到 CJK 字体,标题叠加将使用 PIL 默认字体(中文可能显示为方块)") return ImageFont.load_default() def _parse_hex_color(color: str, fallback=(255, 255, 255)) -> tuple[int, int, int]: "将 #RRGGBB / #RGB 解析为 RGB 元组,失败返回 fallback。" if not color or not isinstance(color, str): return fallback c = color.strip().lstrip("#") try: if len(c) == 6: return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) if len(c) == 3: return (int(c[0] * 2, 16), int(c[1] * 2, 16), int(c[2] * 2, 16)) except (ValueError, IndexError): pass return fallback def wrap_title_text(text: str, font, max_width: int) -> list[str]: """按像素宽度对中英文混合文本自动换行,支持显式 \\n。""" lines: list[str] = [] current = "" for ch in text: if ch == "\n": if current: lines.append(current) current = "" continue trial = current + ch try: bbox = font.getbbox(trial) width = bbox[2] - bbox[0] except Exception: width = len(trial) * (font.size // 2) if width <= max_width: current = trial else: if current: lines.append(current) current = ch if current: lines.append(current) return lines def apply_title_to_image( image_path: str, title_text: str, *, color: str = "#ffffff", position: str = "bottom", font_size: Optional[int] = None, margin_ratio: float = 0.06, stroke_width_ratio: float = 0.04, ) -> Optional[str]: """在图片上绘制标题文字并覆盖保存。 Args: image_path: 图片路径(处理结果覆盖写回) title_text: 标题文字;为空直接返回 None 表示跳过 color: 字体颜色(#RRGGBB),默认白色 position: top / center / bottom font_size: 字号,None 时按图片宽度自动计算 margin_ratio: 边缘留白占短边比例 stroke_width_ratio: 描边宽度占字号比例 Returns: 成功返回 image_path;标题为空或 PIL 不可用返回 None。 """ if not title_text or not title_text.strip(): return None try: from PIL import Image, ImageDraw except ImportError: logger.warning("Pillow 未安装,跳过标题叠加: image=%s", image_path) return None img = Image.open(image_path).convert("RGB") draw = ImageDraw.Draw(img) img_w, img_h = img.size if font_size is None: font_size = max(28, min(72, img_w // 16)) font = find_title_font(font_size) if font is None: return None text_rgb = _parse_hex_color(color) stroke_width = max(2, int(font_size * stroke_width_ratio)) margin = int(min(img_w, img_h) * margin_ratio) max_text_width = img_w - 2 * margin lines = wrap_title_text(title_text.strip(), font, max_text_width) if not lines: return None line_heights = [] for ln in lines: bbox = font.getbbox(ln) line_heights.append(bbox[3] - bbox[1]) line_height = max(line_heights) if line_heights else font_size line_gap = int(line_height * 0.3) total_height = len(lines) * line_height + (len(lines) - 1) * line_gap if position == "top": y_start = margin elif position == "center": y_start = (img_h - total_height) // 2 else: y_start = img_h - total_height - margin for i, ln in enumerate(lines): bbox = font.getbbox(ln) line_w = bbox[2] - bbox[0] x = (img_w - line_w) // 2 y = y_start + i * (line_height + line_gap) # 阴影 draw.text((x + 2, y + 2), ln, font=font, fill=(0, 0, 0)) # 文字(颜色由 color 参数控制)+ 黑色描边 draw.text( (x, y), ln, font=font, fill=text_rgb, stroke_width=stroke_width, stroke_fill=(0, 0, 0), ) img.save(image_path, "JPEG", quality=92) return image_path