f6b50b49ec
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
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
AI Code Review / AI Code Review (pull_request) Successful in 4m45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 52s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m31s
问题:
1. 之前 Worker 用 MediaKit 网络抽帧写 cover_url,因状态字符串 bug(#1461 已修)
一直失败;且在 API 请求内同步抽帧易超时。
2. 封面从源素材抽取时无标题(源素材未烧录标题)。
修复:
1. Worker 渲染完成后直接复用 RenderAdapter 已用本地 ffmpeg 抽出的 cover_candidates:
- _render_video 返回值改为 (output_path, render_duration, cover_candidates)
- 第一帧写入 gen_task.cover_url,完整列表写入 metadata.cover_candidates
- 删除步骤 4.5 的 MediaKit 抽帧代码(不依赖网络,不阻塞)
2. 标题叠加:
- 新增 packages/shared/title_overlay.py,Pillow 绘制白色文字+黑色描边/阴影,
支持 CJK 字体和自动换行(API/Worker 共用)
- thumbnail_generator.extract_and_upload_cover_frames 的 title_text 参数真正生效
- render_adapter 从已渲染视频抽帧时传空 title_text(标题已 ASS 烧录,避免重影)
- 封面 API E2 从源素材抽帧后,从 plan.config.title.text 读取标题并叠加
3. API 基础镜像增加 Pillow 和 fonts-noto-cjk(E2 标题叠加依赖)
测试:新增 test_title_overlay.py(5)、E2 标题透传测试,更新 1294 解包;
相关 51 passed。
154 lines
4.6 KiB
Python
154 lines
4.6 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/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 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,
|
|
*,
|
|
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 表示跳过
|
|
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
|
|
|
|
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))
|
|
# 白色文字 + 黑色描边
|
|
draw.text(
|
|
(x, y),
|
|
ln,
|
|
font=font,
|
|
fill=(255, 255, 255),
|
|
stroke_width=stroke_width,
|
|
stroke_fill=(0, 0, 0),
|
|
)
|
|
|
|
img.save(image_path, "JPEG", quality=92)
|
|
return image_path
|