Files
WYF-koubo/python/cover_generator.py
T
2026-06-19 18:45:55 +08:00

362 lines
15 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
视频封面生成器
支持从视频提取帧并添加标题文字
"""
import argparse
import json
import os
import sys
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import cv2
# ⚠️ 修复模块路径:确保 Python 能找到 modules 和其他本地模块
# 将脚本所在目录(python 目录)添加到 sys.path
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
print(f"[PATH] Added to sys.path: {script_dir}", file=sys.stderr)
def extract_frame_from_video(video_path, timestamp=0):
"""从视频中提取指定时间的帧"""
try:
cap = cv2.VideoCapture(video_path)
# 设置到指定时间(秒)
cap.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000)
ret, frame = cap.read()
cap.release()
if not ret:
return None
# 转换BGR到RGB
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
return Image.fromarray(frame_rgb)
except Exception as e:
print(f"Error extracting frame: {e}", file=sys.stderr)
return None
def hex_to_rgb(hex_color):
"""将十六进制颜色转换为RGB元组"""
if isinstance(hex_color, str):
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return tuple(hex_color)
def add_text_to_image(img, title, config):
"""在图像上添加标题文字"""
pil_img = img.copy().convert('RGBA')
width, height = pil_img.size
# 获取配置参数 - 支持新格式(来自前端的配置)
# 新格式:titleFontSize, titleColor, titleFontFamily, titlePosition, titleStrokeWidth, titleStrokeColor
# 旧格式:fontSize, fontColor, fontPath, position, style
font_size = config.get('titleFontSize', config.get('fontSize', int(height * 0.08)))
# 处理颜色格式:新格式是十六进制字符串(如 "#eb1414"),旧格式是RGB元组
title_color = config.get('titleColor', config.get('fontColor', '#FFFFFF'))
font_color = hex_to_rgb(title_color)
# 阴影参数
shadow_color = hex_to_rgb(config.get('titleShadowColor', '#000000'))
shadow_offset_x = config.get('titleShadowOffsetX', 0)
shadow_offset_y = config.get('titleShadowOffsetY', 0)
shadow_blur_raw = config.get('titleShadowBlur', 0)
# 修复:PIL的GaussianBlur效果比CSS重,需要缩小系数以匹配前端预览
# 经验值:CSS box-shadow blur 与 PIL GaussianBlur 的比例约为 2.5:1
shadow_blur = int(shadow_blur_raw * 0.4) if shadow_blur_raw > 0 else 0
# 描边参数
stroke_width = config.get('titleStrokeWidth', 0)
stroke_color = hex_to_rgb(config.get('titleStrokeColor', '#000000'))
print(f"[DEBUG] Text rendering params: stroke_width={stroke_width}, stroke_color={stroke_color}", file=sys.stderr)
font_family = config.get('titleFontFamily', config.get('fontPath', 'NotoSerifCJK-VF'))
# 使用 font_manager 查找字体(支持系统字体和ziti目录字体)
font_path = None
try:
from modules.font_manager import get_font_manager
font_manager = get_font_manager()
font_path = font_manager.find_font(font_family, 400)
if font_path:
print(f"[DEBUG] Font found via font_manager: {font_family} -> {font_path}", file=sys.stderr)
else:
print(f"[DEBUG] Font not found via font_manager: {font_family}, using fallback", file=sys.stderr)
except Exception as e:
print(f"[DEBUG] font_manager not available: {e}, using hardcoded fonts", file=sys.stderr)
if not font_path:
raise RuntimeError(f"Bundled font not found: {font_family}")
# 位置:新格式是百分比坐标 {x, y},旧格式是 'top', 'center', 'bottom'
title_position = config.get('titlePosition', {})
if isinstance(title_position, dict):
position = 'custom'
else:
position = config.get('position', 'top') # top, center, bottom
style = config.get('style', 'default') # default, outline, blur_bg, gradient, split
# 加载字体
try:
font = ImageFont.truetype(font_path, font_size)
except:
font = ImageFont.load_default()
# 创建绘图对象
draw = ImageDraw.Draw(pil_img)
# 计算文字尺寸
bbox = draw.textbbox((0, 0), title, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 计算文字位置
if position == 'custom' and isinstance(title_position, dict):
# 使用自定义位置(百分比)
x = int(width * title_position.get('x', 50) / 100) - text_width // 2
y = int(height * title_position.get('y', 80) / 100) - text_height // 2
else:
x = (width - text_width) // 2
if position == 'top':
y = int(height * 0.08)
elif position == 'center':
y = (height - text_height) // 2
elif position == 'bottom':
y = int(height * 0.85) - text_height
else:
y = int(height * 0.08)
# 辅助函数:绘制带阴影和描边的文字
def draw_text_with_effects(draw_obj, pos_x, pos_y, text, font, color, stroke_w, stroke_c, shadow_ox, shadow_oy, shadow_b, shadow_c):
"""绘制带阴影和描边的文字"""
# 1. 先绘制阴影(如果有偏移)
if shadow_ox != 0 or shadow_oy != 0:
if shadow_b > 0:
# 创建阴影图层并模糊
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow_layer)
shadow_draw.text((pos_x + shadow_ox, pos_y + shadow_oy), text, font=font, fill=shadow_c + (180,))
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_b))
return shadow_layer
else:
draw_obj.text((pos_x + shadow_ox, pos_y + shadow_oy), text, font=font, fill=shadow_c + (180,))
# 2. 绘制描边
if stroke_w > 0:
for adj_x in range(-stroke_w, stroke_w + 1):
for adj_y in range(-stroke_w, stroke_w + 1):
if adj_x != 0 or adj_y != 0:
draw_obj.text((pos_x + adj_x, pos_y + adj_y), text, font=font, fill=stroke_c + (255,))
# 3. 绘制主文字
draw_obj.text((pos_x, pos_y), text, font=font, fill=color + (255,))
return None
# 应用样式
if style == 'default':
# 默认风格:黑色半透明背景条
padding = 30
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
overlay_draw.rectangle(
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
fill=(0, 0, 0, 180)
)
pil_img = Image.alpha_composite(pil_img, overlay)
draw = ImageDraw.Draw(pil_img)
# 绘制阴影
if shadow_offset_x != 0 or shadow_offset_y != 0:
if shadow_blur > 0:
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow_layer)
shadow_draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
pil_img = Image.alpha_composite(pil_img, shadow_layer)
draw = ImageDraw.Draw(pil_img)
else:
draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
# 绘制描边
if stroke_width > 0:
print(f"[DEBUG] Drawing text stroke: width={stroke_width}, color={stroke_color}", file=sys.stderr)
for adj_x in range(-stroke_width, stroke_width + 1):
for adj_y in range(-stroke_width, stroke_width + 1):
if adj_x != 0 or adj_y != 0:
draw.text((x + adj_x, y + adj_y), title, font=font, fill=stroke_color + (255,))
else:
print(f"[DEBUG] Stroke disabled: stroke_width={stroke_width}", file=sys.stderr)
draw.text((x, y), title, font=font, fill=font_color + (255,))
elif style == 'outline':
# 描边风格
# 绘制阴影
if shadow_offset_x != 0 or shadow_offset_y != 0:
if shadow_blur > 0:
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow_layer)
shadow_draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
pil_img = Image.alpha_composite(pil_img, shadow_layer)
draw = ImageDraw.Draw(pil_img)
else:
draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
outline_width = stroke_width if stroke_width > 0 else 3
outline_color = stroke_color
for adj_x in range(-outline_width, outline_width + 1):
for adj_y in range(-outline_width, outline_width + 1):
draw.text((x + adj_x, y + adj_y), title, font=font, fill=outline_color + (255,))
draw.text((x, y), title, font=font, fill=font_color + (255,))
elif style == 'blur_bg':
# 模糊背景风格
padding = 50
bg_region = pil_img.crop((
max(0, x - padding),
max(0, y - padding),
min(width, x + text_width + padding),
min(height, y + text_height + padding)
))
bg_region = bg_region.filter(ImageFilter.GaussianBlur(radius=15))
pil_img.paste(bg_region, (max(0, x - padding), max(0, y - padding)))
# 添加半透明遮罩
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
overlay_draw.rectangle(
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
fill=(0, 0, 0, 120)
)
pil_img = Image.alpha_composite(pil_img, overlay)
draw = ImageDraw.Draw(pil_img)
draw.text((x, y), title, font=font, fill=font_color + (255,))
elif style == 'gradient':
# 渐变背景风格
gradient_height = text_height + 100
gradient = Image.new('RGBA', (width, gradient_height), (0, 0, 0, 0))
gradient_draw = ImageDraw.Draw(gradient)
for i in range(gradient_height):
alpha = int(200 * (i / gradient_height))
gradient_draw.rectangle([0, i, width, i + 1], fill=(0, 0, 0, alpha))
pil_img.paste(gradient, (0, max(0, y - 50)), gradient)
draw = ImageDraw.Draw(pil_img)
draw.text((x, y), title, font=font, fill=font_color + (255,))
elif style == 'split':
# 分栏风格
padding = 40
left_x = padding
# 左侧标题背景
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
overlay_draw.rectangle(
[left_x - 20, y - 20, left_x + text_width + 20, y + text_height + 20],
fill=(0, 0, 0, 180)
)
pil_img = Image.alpha_composite(pil_img, overlay)
draw = ImageDraw.Draw(pil_img)
draw.text((left_x, y), title, font=font, fill=font_color + (255,))
# 右侧装饰
accent_text = config.get('accentText', '')
accent_bbox = draw.textbbox((0, 0), accent_text, font=font)
accent_width = accent_bbox[2] - accent_bbox[0]
right_x = width - accent_width - padding
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
overlay_draw.rectangle(
[right_x - 25, y - 25, right_x + accent_width + 25, y + text_height + 25],
fill=(255, 193, 7, 200)
)
pil_img = Image.alpha_composite(pil_img, overlay)
draw = ImageDraw.Draw(pil_img)
draw.text((right_x, y), accent_text, font=font, fill=(0, 0, 0, 255))
return pil_img.convert('RGB')
def main():
parser = argparse.ArgumentParser(description='Generate video cover with title')
parser.add_argument('--video', required=True, help='Path to video file')
parser.add_argument('--title', required=True, help='Title text')
parser.add_argument('--output', required=True, help='Output image path')
parser.add_argument('--config', default='{}', help='JSON configuration')
parser.add_argument('--preview', action='store_true', help='Preview mode')
parser.add_argument('--timestamp', type=float, default=0, help='Frame timestamp in seconds')
args = parser.parse_args()
try:
# 解析配置
config = json.loads(args.config)
# 诊断日志:输出接收到的配置参数
print(f"[DEBUG] Received config: {json.dumps(config, indent=2, ensure_ascii=False)}", file=sys.stderr)
print(f"[DEBUG] titleStrokeWidth: {config.get('titleStrokeWidth', 'NOT SET')}", file=sys.stderr)
print(f"[DEBUG] titleStrokeColor: {config.get('titleStrokeColor', 'NOT SET')}", file=sys.stderr)
print(f"[DEBUG] titleFontFamily: {config.get('titleFontFamily', 'NOT SET')}", file=sys.stderr)
# 检查输入是图像还是视频
video_path = args.video
if video_path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp')):
# 直接加载图像
print(f"Loading image: {video_path}")
try:
frame = Image.open(video_path)
except Exception as e:
print(f"Error: Failed to load image: {e}", file=sys.stderr)
sys.exit(1)
else:
# 提取视频帧
print(f"Extracting frame from video: {video_path}")
frame = extract_frame_from_video(video_path, args.timestamp)
if frame is None:
print("Error: Failed to extract frame from video", file=sys.stderr)
sys.exit(1)
# 调整图片大小
width, height = frame.size
max_width = config.get('maxWidth', 1920)
if width > max_width:
scale = max_width / width
new_width = max_width
new_height = int(height * scale)
frame = frame.resize((new_width, new_height), Image.Resampling.LANCZOS)
# 添加标题
print(f"Adding title: {args.title}")
result = add_text_to_image(frame, args.title, config)
# 保存结果
os.makedirs(os.path.dirname(args.output), exist_ok=True)
result.save(args.output, quality=95)
print(f"Cover saved to: {args.output}")
sys.exit(0)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()