d4c8064cdc
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m50s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m35s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m24s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m21s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m22s
CI/CD Pipeline / Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m17s
CI/CD Pipeline / Unit Tests (push) Successful in 13m18s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
173 lines
5.4 KiB
Python
173 lines
5.4 KiB
Python
"""封面标题文字叠加(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
|