Initial clean project import
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user