This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:08 +08:00
parent 3215d39a32
commit 3b0a8d777c
63 changed files with 12483 additions and 1114 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
#!/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', 'SimHei'))
# 使用 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:
if font_family == 'SimHei':
font_path = 'C:\\Windows\\Fonts\\simhei.ttf'
elif font_family == 'SimSun':
font_path = 'C:\\Windows\\Fonts\\simsun.ttc'
elif font_family == 'Microsoft YaHei':
font_path = 'C:\\Windows\\Fonts\\msyh.ttc'
else:
font_path = config.get('fontPath', 'C:\\Windows\\Fonts\\msyh.ttc')
print(f"[DEBUG] Using hardcoded font path: {font_path}", file=sys.stderr)
# 位置:新格式是百分比坐标 {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()

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
实时封面预览生成器(移植版:调用 cover_generator.py不依赖 subtitle_cover_generator_simple
"""
import os
import sys
import json
import hashlib
import tempfile
import subprocess
from typing import Dict, Any
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
COVER_GENERATOR = os.path.join(SCRIPT_DIR, "cover_generator.py")
STYLE_CONFIGS = {
"default": {
"titlePosition": "top",
"titleColor": "#FFFFFF",
"titleFontSize": 72,
"titleStrokeWidth": 0,
},
"blur-bg": {
"titlePosition": "top",
"titleColor": "#FFFFFF",
"titleFontSize": 72,
"backgroundBlurEnabled": True,
},
"outline": {
"titlePosition": "top",
"titleColor": "#FFFFFF",
"titleFontSize": 72,
"titleStrokeWidth": 4,
"titleStrokeColor": "#000000",
},
"gradient": {
"titlePosition": "top",
"titleColor": "#FFFFFF",
"titleFontSize": 72,
},
"split": {
"titlePosition": "center",
"titleColor": "#FFFFFF",
"titleFontSize": 64,
},
}
class CoverPreviewGenerator:
def __init__(self):
self.temp_dir = os.path.join(tempfile.gettempdir(), "aiclient-cover-previews")
os.makedirs(self.temp_dir, exist_ok=True)
self.preview_cache = {}
self.COVER_STYLES = list(STYLE_CONFIGS.keys())
def get_video_hash(self, video_path: str) -> str:
h = hashlib.md5()
with open(video_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
h.update(chunk)
return h.hexdigest()[:16]
def _run_cover_generator(self, video_path: str, title: str, output_path: str, style: str) -> bool:
if not os.path.isfile(COVER_GENERATOR):
print(f"Missing {COVER_GENERATOR}", file=sys.stderr)
return False
config = dict(STYLE_CONFIGS.get(style, STYLE_CONFIGS["default"]))
python_exe = os.environ.get("AICLIENT_PYTHON_PATH") or sys.executable
cmd = [
python_exe,
COVER_GENERATOR,
"--video",
video_path,
"--title",
title,
"--output",
output_path,
"--config",
json.dumps(config, ensure_ascii=False),
"--timestamp",
"1",
]
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
try:
r = subprocess.run(
cmd,
cwd=SCRIPT_DIR,
capture_output=True,
text=True,
timeout=120,
env=env,
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
return r.returncode == 0 and os.path.isfile(output_path)
except Exception as e:
print(f"cover_generator failed: {e}", file=sys.stderr)
return False
def generate_preview(self, video_path: str, style: str, output_path: str) -> bool:
return self._run_cover_generator(
video_path,
"封面预览",
output_path,
style,
)
def generate_all_previews(self, video_path: str) -> Dict[str, Any]:
if not os.path.exists(video_path):
return {"error": f"Video file not found: {video_path}"}
video_hash = self.get_video_hash(video_path)
if video_hash in self.preview_cache:
return self.preview_cache[video_hash]
previews = []
errors = []
for style in self.COVER_STYLES:
output_path = os.path.join(self.temp_dir, f"preview_{video_hash}_{style}.jpg")
ok = self.generate_preview(video_path, style, output_path)
if ok:
previews.append({"style": style, "path": output_path})
else:
errors.append(style)
response = {
"previews": previews,
"videoHash": video_hash,
"tempDir": self.temp_dir,
}
if errors:
response["errors"] = errors
self.preview_cache[video_hash] = response
return response
def main():
import argparse
parser = argparse.ArgumentParser(description="Generate cover style previews")
parser.add_argument("video_path", help="Path to the video file")
parser.add_argument("--output-dir", help="Output directory for previews")
args = parser.parse_args()
if not os.path.exists(args.video_path):
print(json.dumps({"error": f"not found: {args.video_path}"}, ensure_ascii=False))
sys.exit(1)
gen = CoverPreviewGenerator()
if args.output_dir:
gen.temp_dir = args.output_dir
os.makedirs(gen.temp_dir, exist_ok=True)
result = gen.generate_all_previews(args.video_path)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自定义模板封面修复补丁
将这段代码复制到 subtitle_cover_generator_simple.py 替换 _generate_custom_template_cover 方法
"""
def _generate_custom_template_cover(self, frame):
"""
使用自定义模板生成封面 (修复版)
Args:
frame: 视频帧
Returns:
生成的封面路径
"""
try:
print("Generating custom template cover (fixed version)...", file=sys.stderr)
template = self.template
# 转换为PIL图像
image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
width, height = image.size
# 1. 应用背景模糊
if template.get('background', {}).get('blurEnabled', False):
blur_radius = template['background'].get('blurRadius', 15)
print(f"Applying background blur: {blur_radius}", file=sys.stderr)
image = image.filter(ImageFilter.GaussianBlur(radius=blur_radius))
# 2. 处理人像描边如果启用RMBG
if template.get('portrait', {}).get('strokeEnabled', False):
if RMBG_AVAILABLE:
try:
print("Extracting portrait for stroke effect...", file=sys.stderr)
portrait = rembg_remove(image)
# 创建描边效果
stroke_color = template['portrait'].get('strokeColor', '#FFD700')
stroke_width = template['portrait'].get('strokeWidth', 3)
stroke_type = template['portrait'].get('strokeType', 'solid')
print(f"Stroke: color={stroke_color}, width={stroke_width}, type={stroke_type}", file=sys.stderr)
# 合成带描边的人像
if portrait.mode == 'RGBA':
# 获取alpha通道作为mask
alpha = portrait.split()[3]
# 对mask进行边缘检测
alpha_np = np.array(alpha)
edges = cv2.Canny(alpha_np, 30, 100)
# 转换描边颜色
stroke_rgb = self._hex_to_rgb(stroke_color)
outline_layer = Image.new('RGBA', (width, height), (*stroke_rgb, 0))
# 创建多层描边
for i in range(stroke_width, 0, -1):
# 外层更粗的描边
kernel_size = i * 3
kernel = np.ones((kernel_size, kernel_size), np.uint8)
layer_edges = cv2.dilate(edges, kernel, iterations=1)
# 转换为PIL图像
layer_pil = Image.fromarray(layer_edges)
layer_colored = Image.new('RGBA', (width, height), (*stroke_rgb, 255))
layer_colored.putalpha(layer_pil)
# 合并到描边层
outline_layer = Image.alpha_composite(outline_layer, layer_colored)
# 创建结果:背景 + 描边 + 人像
result = Image.new('RGB', (width, height))
result.paste(image, (0, 0))
# 将描边和人像合成到背景上
result_rgba = result.convert('RGBA')
result_rgba = Image.alpha_composite(result_rgba, outline_layer)
result_rgba = Image.alpha_composite(result_rgba, portrait)
image = result_rgba.convert('RGB')
print("Portrait stroke effect applied successfully", file=sys.stderr)
except Exception as e:
print(f"Portrait stroke failed: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
else:
print("RMBG not available, skipping portrait stroke", file=sys.stderr)
# 3. 添加蒙版(如果有)
mask_config = template.get('mask', {})
mask_path = mask_config.get('imagePath', '')
if mask_path and os.path.exists(mask_path):
try:
print(f"Adding mask from: {mask_path}", file=sys.stderr)
mask_img = Image.open(mask_path).convert('RGBA')
# 获取蒙版位置和大小
mask_pos = mask_config.get('position', {'x': 10, 'y': 10})
mask_size = mask_config.get('size', {'width': 30, 'height': 20})
mask_opacity = mask_config.get('opacity', 0.8)
mask_width = int(mask_size['width'] / 100 * width)
mask_height = int(mask_size['height'] / 100 * height)
mask_x = int(mask_pos['x'] / 100 * width)
mask_y = int(mask_pos['y'] / 100 * height)
# 调整蒙版大小和透明度
mask_img = mask_img.resize((mask_width, mask_height), Image.LANCZOS)
if mask_opacity < 1.0:
alpha = mask_img.split()[3]
alpha = alpha.point(lambda p: int(p * mask_opacity))
mask_img.putalpha(alpha)
# 将蒙版粘贴到图像上
image_rgba = image.convert('RGBA')
image_rgba.paste(mask_img, (mask_x - mask_width // 2, mask_y - mask_height // 2), mask_img)
image = image_rgba.convert('RGB')
print("Mask applied successfully", file=sys.stderr)
except Exception as e:
print(f"Failed to add mask: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# 4. 添加标题
draw = ImageDraw.Draw(image)
# 加载字体
try:
font_main = ImageFont.truetype("msyh.ttc", template['titles']['main'].get('fontSize', 60))
font_sub = ImageFont.truetype("msyh.ttc", template['titles']['sub'].get('fontSize', 40))
except:
try:
font_main = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", template['titles']['main'].get('fontSize', 60))
font_sub = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", template['titles']['sub'].get('fontSize', 40))
except:
font_main = ImageFont.load_default()
font_sub = ImageFont.load_default()
print("Warning: Failed to load custom font, using default", file=sys.stderr)
# 绘制主标题(支持自动换行)
main_title = template['titles']['main']
if main_title.get('text'):
main_text = main_title['text']
main_color = self._hex_to_rgb(main_title.get('color', '#FFFFFF'))
main_pos_x = int(width * main_title['position']['x'] / 100)
main_pos_y = int(height * main_title['position']['y'] / 100)
max_chars_per_line = main_title.get('maxLength', 4) # 每行最多字符数
# 将文字分行
lines = []
for i in range(0, len(main_text), max_chars_per_line):
lines.append(main_text[i:i + max_chars_per_line])
# 计算行高和总高度
line_height = int(font_main.size * 1.2) # 行高为字体大小的1.2倍
total_height = len(lines) * line_height
# 计算起始Y坐标使多行文字垂直居中
start_y = main_pos_y - total_height // 2 + line_height // 2
# 逐行绘制
for i, line in enumerate(lines):
line_y = start_y + i * line_height
# 绘制描边
if main_title.get('strokeWidth', 0) > 0:
stroke_color = self._hex_to_rgb(main_title.get('strokeColor', '#000000'))
for offset_x in range(-main_title['strokeWidth'], main_title['strokeWidth'] + 1):
for offset_y in range(-main_title['strokeWidth'], main_title['strokeWidth'] + 1):
if offset_x == 0 and offset_y == 0:
continue
draw.text((main_pos_x + offset_x, line_y + offset_y),
line, font=font_main, fill=stroke_color, anchor='mm')
# 绘制主体
draw.text((main_pos_x, line_y), line, font=font_main, fill=main_color, anchor='mm')
print(f"Drew main title: '{main_text}' ({len(lines)} lines) at ({main_pos_x}, {main_pos_y})", file=sys.stderr)
# 绘制副标题(支持自动换行)
sub_title = template['titles']['sub']
if sub_title.get('text'):
sub_text = sub_title['text']
sub_color = self._hex_to_rgb(sub_title.get('color', '#FFFFFF'))
sub_pos_x = int(width * sub_title['position']['x'] / 100)
sub_pos_y = int(height * sub_title['position']['y'] / 100)
max_chars_per_line = sub_title.get('maxLength', 3) # 每行最多字符数
# 将文字分行
lines = []
for i in range(0, len(sub_text), max_chars_per_line):
lines.append(sub_text[i:i + max_chars_per_line])
# 计算行高和总高度
line_height = int(font_sub.size * 1.2) # 行高为字体大小的1.2倍
total_height = len(lines) * line_height
# 计算起始Y坐标使多行文字垂直居中
start_y = sub_pos_y - total_height // 2 + line_height // 2
# 逐行绘制
for i, line in enumerate(lines):
line_y = start_y + i * line_height
# 绘制描边
if sub_title.get('strokeWidth', 0) > 0:
stroke_color = self._hex_to_rgb(sub_title.get('strokeColor', '#000000'))
for offset_x in range(-sub_title['strokeWidth'], sub_title['strokeWidth'] + 1):
for offset_y in range(-sub_title['strokeWidth'], sub_title['strokeWidth'] + 1):
if offset_x == 0 and offset_y == 0:
continue
draw.text((sub_pos_x + offset_x, line_y + offset_y),
line, font=font_sub, fill=stroke_color, anchor='mm')
# 绘制主体
draw.text((sub_pos_x, line_y), line, font=font_sub, fill=sub_color, anchor='mm')
print(f"Drew sub title: '{sub_text}' ({len(lines)} lines) at ({sub_pos_x}, {sub_pos_y})", file=sys.stderr)
# 保存封面
cover_path = os.path.join(self.output_dir, 'cover_custom_template.png')
image.save(cover_path, quality=95)
print(f"Custom template cover generated successfully: {cover_path}", file=sys.stderr)
return cover_path
except Exception as e:
print(f"Error generating custom template cover: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
raise Exception(f"Custom template cover generation failed: {str(e)}")

View File

@@ -0,0 +1,35 @@
"""
查找指定字体文件路径
"""
import sys
from pathlib import Path
# 添加模块路径
sys.path.insert(0, str(Path(__file__).parent))
from modules.font_manager import get_font_manager
def main():
if len(sys.argv) < 2:
print("Usage: python find_font.py <font_family> [font_weight]", file=sys.stderr)
sys.exit(1)
font_family = sys.argv[1]
font_weight = int(sys.argv[2]) if len(sys.argv) > 2 else 400
try:
font_manager = get_font_manager()
font_path = font_manager.find_font(font_family, font_weight)
if font_path:
print(font_path)
else:
print("") # 返回空字符串表示未找到
sys.exit(0)
except Exception as e:
print(f"Error: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量生成封面模板预览图CLI对齐 Electron 资源目录生成)
"""
import os
import sys
import json
import argparse
import subprocess
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COVER_GENERATOR = os.path.join(SCRIPT_DIR, "cover_generator.py")
COVER_STYLES = ["default", "blur-bg", "outline", "gradient", "split"]
STYLE_CONFIGS = {
"default": {"titlePosition": "top", "titleColor": "#FFFFFF", "titleFontSize": 72},
"blur-bg": {"titlePosition": "top", "titleColor": "#FFFFFF", "backgroundBlurEnabled": True},
"outline": {
"titlePosition": "top",
"titleColor": "#FFFFFF",
"titleStrokeWidth": 4,
"titleStrokeColor": "#000000",
},
"gradient": {"titlePosition": "top", "titleColor": "#FFFFFF"},
"split": {"titlePosition": "center", "titleColor": "#FFFFFF"},
}
def generate_all_cover_previews(video_path: str, preview_dir: str, demo_title: str) -> bool:
os.makedirs(preview_dir, exist_ok=True)
python_exe = os.environ.get("AICLIENT_PYTHON_PATH") or sys.executable
ok_count = 0
for style in COVER_STYLES:
out_path = os.path.join(preview_dir, f"cover-preview-{style}.jpg")
config = dict(STYLE_CONFIGS.get(style, STYLE_CONFIGS["default"]))
cmd = [
python_exe,
COVER_GENERATOR,
"--video",
video_path,
"--title",
demo_title,
"--output",
out_path,
"--config",
json.dumps(config, ensure_ascii=False),
"--timestamp",
"1",
]
try:
r = subprocess.run(cmd, cwd=SCRIPT_DIR, capture_output=True, text=True, timeout=120)
if r.returncode == 0 and os.path.isfile(out_path):
print(f"{out_path}", file=sys.stderr)
ok_count += 1
else:
print(f"{style}: {r.stderr or r.stdout}", file=sys.stderr)
except Exception as e:
print(f"{style}: {e}", file=sys.stderr)
return ok_count > 0
def main():
parser = argparse.ArgumentParser(description="Generate cover template preview images")
parser.add_argument("--video", required=True, help="Source video path")
parser.add_argument("--output-dir", required=True, help="Output directory")
parser.add_argument("--title", default="封面预览标题示例", help="Demo title text")
args = parser.parse_args()
if not os.path.isfile(args.video):
print(f"Error: video not found: {args.video}", file=sys.stderr)
sys.exit(1)
success = generate_all_cover_previews(args.video, args.output_dir, args.title)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
为图片生成5种字幕封面风格
"""
from PIL import Image, ImageDraw, ImageFont
import os
def add_text_with_style(img, title, style_name, font_path):
"""在图像上添加指定风格的标题文字"""
pil_img = img.copy()
draw = ImageDraw.Draw(pil_img)
width, height = pil_img.size
# 设置字体大小
font_size = int(height * 0.12)
try:
font = ImageFont.truetype(font_path, font_size)
except:
font = ImageFont.load_default()
# 计算标题位置(顶部居中)
bbox = draw.textbbox((0, 0), title, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (width - text_width) // 2
y = int(height * 0.08) # 距离顶部8%的位置
if style_name == "default":
# 默认风格:黑色背景条 + 白色文字
padding = 30
draw.rectangle(
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
fill=(0, 0, 0, 200)
)
draw.text((x, y), title, font=font, fill=(255, 255, 255))
elif style_name == "blur_bg":
# 模糊背景风格
bg_region = pil_img.crop((x - 50, y - 50, x + text_width + 50, y + text_height + 50))
bg_region = bg_region.filter(Image.BLUR)
pil_img.paste(bg_region, (x - 50, y - 50))
draw = ImageDraw.Draw(pil_img)
# 添加半透明黑色遮罩
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay)
overlay_draw.rectangle([x - 50, y - 50, x + text_width + 50, y + text_height + 50], fill=(0, 0, 0, 150))
pil_img = Image.alpha_composite(pil_img.convert('RGBA'), overlay).convert('RGB')
draw = ImageDraw.Draw(pil_img)
draw.text((x, y), title, font=font, fill=(255, 255, 255))
elif style_name == "outline":
# 描边风格:白色文字 + 黑色描边
outline_width = 4
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=(0, 0, 0))
draw.text((x, y), title, font=font, fill=(255, 255, 255))
elif style_name == "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(250 * (i / gradient_height))
gradient_draw.rectangle([0, i, width, i + 1], fill=(0, 0, 0, alpha))
pil_img_rgba = pil_img.convert('RGBA')
pil_img_rgba.paste(gradient, (0, y - 50), gradient)
pil_img = pil_img_rgba.convert('RGB')
draw = ImageDraw.Draw(pil_img)
draw.text((x, y), title, font=font, fill=(255, 255, 255))
elif style_name == "split":
# 分栏风格:左侧标题 + 右侧装饰
padding = 40
# 左侧标题
left_x = padding
draw.rectangle(
[left_x - 20, y - 20, left_x + text_width + 20, y + text_height + 20],
fill=(0, 0, 0, 180)
)
draw.text((left_x, y), title, font=font, fill=(255, 255, 255))
# 右侧装饰元素
right_text = "✨ 精彩"
right_bbox = draw.textbbox((0, 0), right_text, font=font)
right_width = right_bbox[2] - right_bbox[0]
right_x = width - right_width - padding
draw.rectangle(
[right_x - 25, y - 25, right_x + right_width + 25, y + text_height + 25],
fill=(255, 193, 7, 200) # 金黄色背景
)
draw.text((right_x, y), right_text, font=font, fill=(0, 0, 0))
return pil_img
def main():
# 输入图片路径
image_path = r"C:\aigcpanel-main\微信图片_20251125215233_633_235.jpg"
title = "我是个美女啊啊啊"
# 输出目录
output_dir = r"C:\aigcpanel-main\public\cover-previews"
os.makedirs(output_dir, exist_ok=True)
# 字体路径
font_path = r"C:\Windows\Fonts\msyh.ttc" # 微软雅黑
# 读取图片
print("正在读取图片...")
try:
img = Image.open(image_path)
print(f"图片尺寸:{img.size}")
except Exception as e:
print(f"❌ 无法读取图片: {e}")
return
# 调整图片大小以便预览
width, height = img.size
if width > 1280:
scale = 1280 / width
new_width = 1280
new_height = int(height * scale)
img = img.resize((new_width, new_height))
print(f"调整图片尺寸为:{img.size}")
# 定义5种风格
styles = [
("default", "默认风格:黑色半透明背景条"),
("blur_bg", "模糊背景风格:背景模糊效果"),
("outline", "描边风格:文字带黑色描边"),
("gradient", "渐变背景风格:渐变半透明遮罩"),
("split", "分栏风格:标题+装饰元素")
]
# 生成每种风格的封面
for style_name, description in styles:
print(f"生成 {style_name} 风格封面:{description}")
# 复制原始图片
styled_img = img.copy()
# 应用风格
styled_img = add_text_with_style(styled_img, title, style_name, font_path)
# 保存封面
output_path = os.path.join(output_dir, f"cover-title-{style_name}.png")
styled_img.save(output_path)
print(f"✅ 已保存:{output_path}")
print("\n" + "="*60)
print("🎉 所有封面生成完成!")
print(f"📁 保存位置:{output_dir}")
print("="*60)
print(f"\n标题:{title}")
print("\n封面列表:")
for style_name, description in styles:
print(f" • cover-title-{style_name}.png - {description}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,107 @@
"""
读取字体文件的内部Family名称用于ASS字幕
"""
import sys
from pathlib import Path
# 添加模块路径
sys.path.insert(0, str(Path(__file__).parent))
try:
from fontTools.ttLib import TTFont
except ImportError:
try:
import fontTools
except ImportError:
print("", file=sys.stderr) # 返回空字符串,表示无法读取
sys.exit(1)
def get_font_family_name(font_path: str) -> str:
"""
获取字体名称。
优先使用文件名不含扩展名这样可以保证与ziti目录中的文件名匹配。
例如:墨趣古风体.ttf → 返回 "墨趣古风体"
"""
try:
# 优先返回文件名(不含扩展名)
# 这样可以确保与ziti目录中的文件名匹配避免使用字体内部的元数据
# 字体文件内部的Family Name有时是拼音如"MoQuGuFengTi"导致libass找不到字体
from pathlib import Path
filename = Path(font_path).stem # 获取文件名,不含扩展名
if filename:
# 确保返回的是正确编码的字符串(对于中文字体名)
return str(filename)
# 如果文件名为空尝试读取字体内部的Family名称作为备选
font = TTFont(font_path)
# 读取name表中的Family名称通常在第1或第16条记录中
for record in font['name'].names:
# nameID 1 = Font Family Name
if record.nameID == 1:
# 尝试解码优先使用Unicode编码
try:
if hasattr(record, 'string'):
if isinstance(record.string, bytes):
return record.string.decode('utf-16-be') if record.isUnicode() else record.string.decode('latin-1')
return str(record.string)
except:
pass
# 如果找不到nameID 1尝试其他nameID
for record in font['name'].names:
if record.nameID == 16: # Typographic Family Name
try:
if hasattr(record, 'string'):
if isinstance(record.string, bytes):
return record.string.decode('utf-16-be') if record.isUnicode() else record.string.decode('latin-1')
return str(record.string)
except:
pass
font.close()
except Exception as e:
pass
return ""
def main():
if len(sys.argv) < 2:
print("", file=sys.stderr)
sys.exit(1)
font_path = sys.argv[1]
if not Path(font_path).exists():
print("", file=sys.stderr)
sys.exit(1)
try:
family_name = get_font_family_name(font_path)
if family_name:
print(family_name)
else:
print("") # 返回空字符串表示无法读取
sys.exit(0)
except Exception as e:
print("", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,26 @@
"""
获取所有可用字体列表
"""
import json
import sys
from pathlib import Path
# 添加模块路径
sys.path.insert(0, str(Path(__file__).parent))
from modules.font_manager import get_font_manager
def main():
try:
font_manager = get_font_manager()
fonts = font_manager.get_all_available_fonts()
# 输出JSON格式
print(json.dumps(fonts, ensure_ascii=False))
sys.exit(0)
except Exception as e:
print(f"Error: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,6 @@
"""
modules 包初始化文件
"""
# 这个文件是必需的,让 Python 将 modules 目录识别为一个包
# 从而支持相对导入(如 from .utils import ...

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""
封面模板定义文件
Cover Template Definitions
"""
from typing import Dict, Any, Tuple
# 封面模板配置
COVER_TEMPLATES: Dict[str, Dict[str, Any]] = {
'professional': {
'name': '专业商务风',
'description': '金色描边 + 中度虚化 + 人物清晰',
'blur_radius': 25, # 背景模糊半径
'outline_color': (255, 215, 0), # 金色 (R, G, B)
'outline_width': 8, # 描边宽度
'brightness': 0.9, # 亮度调整 (0.0-1.0)
'contrast': 1.0, # 对比度
'preview_path': '/assets/templates/professional.jpg'
},
'vibrant': {
'name': '活力青春风',
'description': '粉色描边 + 轻度虚化 + 明亮色调',
'blur_radius': 20,
'outline_color': (255, 105, 180), # 粉色 (R, G, B)
'outline_width': 6,
'brightness': 1.1,
'contrast': 1.05,
'preview_path': '/assets/templates/vibrant.jpg'
},
'elegant': {
'name': '优雅高级风',
'description': '银色描边 + 重度虚化 + 柔和光效',
'blur_radius': 30,
'outline_color': (192, 192, 192), # 银色 (R, G, B)
'outline_width': 10,
'brightness': 0.85,
'contrast': 0.95,
'preview_path': '/assets/templates/elegant.jpg'
},
'classic': {
'name': '经典商务风',
'description': '白色描边 + 轻度虚化 + 简洁风格',
'blur_radius': 15,
'outline_color': (255, 255, 255), # 白色 (R, G, B)
'outline_width': 5,
'brightness': 0.95,
'contrast': 1.0,
'preview_path': '/assets/templates/classic.jpg'
},
'dramatic': {
'name': '戏剧艺术风',
'description': '红色描边 + 重度虚化 + 强烈对比',
'blur_radius': 35,
'outline_color': (220, 20, 60), # 红色 (R, G, B)
'outline_width': 12,
'brightness': 0.8,
'contrast': 1.2,
'preview_path': '/assets/templates/dramatic.jpg'
}
}
def get_template(template_id: str) -> Dict[str, Any]:
"""
获取模板配置
Args:
template_id: 模板ID
Returns:
模板配置字典
"""
return COVER_TEMPLATES.get(template_id, COVER_TEMPLATES['professional'])
def get_all_templates() -> Dict[str, Dict[str, Any]]:
"""
获取所有模板配置
Returns:
所有模板配置字典
"""
return COVER_TEMPLATES.copy()
def get_template_names() -> Dict[str, str]:
"""
获取模板ID和名称映射
Returns:
{template_id: template_name}
"""
return {k: v['name'] for k, v in COVER_TEMPLATES.items()}
def get_template_descriptions() -> Dict[str, str]:
"""
获取模板ID和描述映射
Returns:
{template_id: template_description}
"""
return {k: v['description'] for k, v in COVER_TEMPLATES.items()}
def get_template_preview_paths() -> Dict[str, str]:
"""
获取模板ID和预览图路径映射
Returns:
{template_id: preview_path}
"""
return {k: v['preview_path'] for k, v in COVER_TEMPLATES.items()}
# 模板效果参数范围定义用于UI验证
TEMPLATE_PARAM_RANGES = {
'blur_radius': {'min': 0, 'max': 50, 'default': 25},
'outline_width': {'min': 0, 'max': 20, 'default': 8},
'brightness': {'min': 0.5, 'max': 1.5, 'default': 1.0},
'contrast': {'min': 0.5, 'max': 1.5, 'default': 1.0}
}

View File

@@ -0,0 +1,517 @@
"""
字体管理模块
负责查找和管理字体文件支持预置字体、系统字体、ziti目录字体和Google Fonts
"""
import os
import sys
import platform
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import json
# 尝试导入loguru如果不可用则使用print作为替代
try:
from loguru import logger
except ImportError:
# Fallback logger that uses print
class logger:
@staticmethod
def info(msg, *args, **kwargs):
print(f"[INFO] {msg}", file=sys.stderr)
@staticmethod
def warning(msg, *args, **kwargs):
print(f"[WARN] {msg}", file=sys.stderr)
@staticmethod
def error(msg, *args, **kwargs):
print(f"[ERROR] {msg}", file=sys.stderr)
@staticmethod
def debug(msg, *args, **kwargs):
print(f"[DEBUG] {msg}", file=sys.stderr)
class FontManager:
"""字体管理器"""
def __init__(self):
# 计算项目根目录(从 python/modules 向上两级)
# __file__ 应该是 .../python/modules/font_manager.py
current_file = Path(__file__).resolve()
python_dir = current_file.parent.parent # python 目录
project_root = python_dir.parent # 项目根目录
self.bundled_fonts_dir = Path("fonts/bundled")
self.fonts_metadata_file = self.bundled_fonts_dir / "fonts_metadata.json"
# 🔧 修复ziti字体路径支持ASAR打包环境
# 检查是否在打包环境中运行
app_root = os.environ.get('APP_ROOT', None)
if app_root:
ziti_path_bundle = Path(app_root) / "resources-bundles" / "ziti"
if ziti_path_bundle.exists():
self.ziti_fonts_dir = ziti_path_bundle
print(f"✅ [font_manager] 找到ziti字体目录(bundle): {ziti_path_bundle}")
else:
ziti_path = Path(app_root) / "extra" / "common" / "fonts" / "ziti"
if ziti_path.exists():
self.ziti_fonts_dir = ziti_path
print(f"✅ [font_manager] 找到ziti字体目录: {ziti_path}")
else:
ziti_path_fallback1 = Path(app_root) / "app.asar.unpacked" / "ziti"
if ziti_path_fallback1.exists():
self.ziti_fonts_dir = ziti_path_fallback1
print(f"✅ [font_manager] 找到ziti字体目录(回退1): {ziti_path_fallback1}")
else:
ziti_path_fallback2 = Path(app_root) / "ziti"
self.ziti_fonts_dir = ziti_path_fallback2
print(f"⚠️ [font_manager] 使用回退路径: {ziti_path_fallback2} (存在: {ziti_path_fallback2.exists()})")
else:
self.ziti_fonts_dir = project_root / "ziti"
print(f"🔧 [font_manager] 开发环境ziti路径: {self.ziti_fonts_dir}")
self._bundled_fonts_cache: Optional[Dict] = None
self._system_fonts_cache: Optional[List[Dict]] = None
self._ziti_fonts_cache: Optional[List[Dict]] = None # 新增ziti字体缓存
# 诊断日志
logger.debug(f"FontManager 初始化: current_file={current_file}, python_dir={python_dir}, project_root={project_root}")
logger.debug(f"ziti_fonts_dir={self.ziti_fonts_dir.absolute()}, exists={self.ziti_fonts_dir.exists()}")
def get_bundled_fonts(self) -> List[Dict]:
"""获取预置字体列表"""
if self._bundled_fonts_cache is not None:
return self._bundled_fonts_cache.get("fonts", [])
if not self.fonts_metadata_file.exists():
logger.warning(f"字体元数据文件不存在: {self.fonts_metadata_file}")
return []
try:
with open(self.fonts_metadata_file, "r", encoding="utf-8") as f:
metadata = json.load(f)
self._bundled_fonts_cache = metadata
return metadata.get("fonts", [])
except Exception as e:
logger.error(f"读取字体元数据失败: {e}")
return []
def scan_ziti_fonts(self) -> List[Dict]:
"""扫描ziti目录中的字体文件"""
if self._ziti_fonts_cache is not None:
return self._ziti_fonts_cache
ziti_fonts = []
logger.info(f"正在扫描 ziti 目录: {self.ziti_fonts_dir.absolute()}")
logger.info(f" 当前工作目录: {os.getcwd()}")
logger.info(f" APP_ROOT环境变量: {os.environ.get('APP_ROOT', 'None')}")
if not self.ziti_fonts_dir.exists():
logger.warning(f"❌ ziti目录不存在: {self.ziti_fonts_dir.absolute()}")
logger.warning(f" 请检查路径配置和资源文件是否正确打包")
return []
else:
logger.info(f"✅ ziti目录存在开始扫描字体文件...")
try:
# 支持的字体格式
font_extensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2']
# 扫描ziti目录中的所有字体文件
for font_file in self.ziti_fonts_dir.iterdir():
if font_file.is_file() and font_file.suffix.lower() in font_extensions:
# 从文件名提取字体信息
font_name = font_file.stem
# 尝试解析字体名称和变体
display_name = font_name
family = font_name
weight = 400
# 检测常见的字体变体
name_lower = font_name.lower()
if 'bold' in name_lower:
weight = 700
elif 'semibold' in name_lower:
weight = 600
elif 'medium' in name_lower:
weight = 500
elif 'light' in name_lower:
weight = 300
elif 'thin' in name_lower:
weight = 100
elif 'black' in name_lower or 'extrabold' in name_lower:
weight = 900
# 清理family名称移除变体后缀
for variant in ['-Bold', '-SemiBold', '-Medium', '-Light', '-Thin', '-Black', '-ExtraBold', '-Regular']:
if family.endswith(variant):
family = family[:-len(variant)]
break
ziti_fonts.append({
'family': family,
'display_name': display_name,
'path': str(font_file.absolute()),
'source': 'ziti',
'weight': weight,
'category': 'sans-serif', # 默认分类
'languages': ['zh-CN', 'ja', 'en'] # 假设支持中日英
})
logger.info(f"✅ 从ziti目录扫描到 {len(ziti_fonts)} 个字体")
if len(ziti_fonts) > 0:
logger.debug(f" 扫描到的字体: {', '.join([f['family'] for f in ziti_fonts[:5]])}" +
(f" 等({len(ziti_fonts)}个)" if len(ziti_fonts) > 5 else ""))
except Exception as e:
logger.error(f"❌ 扫描ziti目录字体失败: {e}")
import traceback
logger.error(f" 追踪: {traceback.format_exc()}")
# 去重按family和weight
seen = set()
unique_fonts = []
for font in ziti_fonts:
key = (font['family'], font.get('weight', 400))
if key not in seen:
seen.add(key)
unique_fonts.append(font)
self._ziti_fonts_cache = unique_fonts
return unique_fonts
def scan_system_fonts(self) -> List[Dict]:
"""扫描系统已安装的字体"""
if self._system_fonts_cache is not None:
return self._system_fonts_cache
system_fonts = []
system = platform.system()
try:
if system == 'Windows':
font_dir = Path("C:/Windows/Fonts")
if font_dir.exists():
# 常见Windows字体
common_fonts = {
'simhei.ttf': {'family': 'SimHei', 'display_name': '黑体'},
'simsun.ttc': {'family': 'SimSun', 'display_name': '宋体'},
'msyh.ttc': {'family': 'Microsoft YaHei', 'display_name': '微软雅黑'},
'msyhbd.ttc': {'family': 'Microsoft YaHei', 'display_name': '微软雅黑 Bold', 'weight': 700},
'arial.ttf': {'family': 'Arial', 'display_name': 'Arial'},
'arialbd.ttf': {'family': 'Arial', 'display_name': 'Arial Bold', 'weight': 700},
'times.ttf': {'family': 'Times New Roman', 'display_name': 'Times New Roman'},
'timesbd.ttf': {'family': 'Times New Roman', 'display_name': 'Times New Roman Bold', 'weight': 700},
}
for font_file, font_info in common_fonts.items():
font_path = font_dir / font_file
if font_path.exists():
system_fonts.append({
'family': font_info['family'],
'display_name': font_info['display_name'],
'path': str(font_path),
'source': 'system',
'weight': font_info.get('weight', 400)
})
elif system == 'Darwin': # macOS
font_dirs = [
Path("/System/Library/Fonts"),
Path("/Library/Fonts"),
Path.home() / "Library/Fonts"
]
common_fonts = {
'PingFang.ttc': {'family': 'PingFang SC', 'display_name': '苹方'},
'Arial.ttf': {'family': 'Arial', 'display_name': 'Arial'},
}
for font_dir in font_dirs:
if font_dir.exists():
for font_file, font_info in common_fonts.items():
font_path = font_dir / font_file
if font_path.exists():
system_fonts.append({
'family': font_info['family'],
'display_name': font_info['display_name'],
'path': str(font_path),
'source': 'system',
'weight': 400
})
elif system == 'Linux':
font_dirs = [
Path("/usr/share/fonts/truetype"),
Path("/usr/share/fonts/TTF"),
Path.home() / ".fonts"
]
for font_dir in font_dirs:
if font_dir.exists():
# 查找常见字体
for font_file in font_dir.rglob("*.ttf"):
font_name = font_file.stem.lower()
if 'dejavu' in font_name or 'liberation' in font_name:
system_fonts.append({
'family': font_file.stem,
'display_name': font_file.stem,
'path': str(font_file),
'source': 'system',
'weight': 400
})
except Exception as e:
logger.error(f"扫描系统字体失败: {e}")
# 去重按family和weight
seen = set()
unique_fonts = []
for font in system_fonts:
key = (font['family'], font.get('weight', 400))
if key not in seen:
seen.add(key)
unique_fonts.append(font)
self._system_fonts_cache = unique_fonts
return unique_fonts
def find_font(self, font_family: str, font_weight: int = 400) -> Optional[str]:
"""查找字体文件(按优先级:预置 > ziti > 系统 > 默认)
Args:
font_family: 字体名称
font_weight: 字体粗细 (100-1000)
Returns:
字体文件路径如果找不到返回None
"""
# 1. 优先查找预置字体
bundled_font = self._find_bundled_font(font_family, font_weight)
if bundled_font:
return bundled_font
# 2. 查找ziti目录字体
ziti_font = self._find_ziti_font(font_family, font_weight)
if ziti_font:
return ziti_font
# 3. 查找系统字体
system_font = self._find_system_font(font_family, font_weight)
if system_font:
return system_font
# 4. 返回None使用默认字体
return None
def _find_bundled_font(self, font_family: str, font_weight: int) -> Optional[str]:
"""查找预置字体"""
if not self.bundled_fonts_dir.exists():
return None
bundled_fonts = self.get_bundled_fonts()
# 查找匹配的字体
for font_info in bundled_fonts:
if font_info.get('family') == font_family:
font_dir = self.bundled_fonts_dir / font_info.get('path', '')
# 根据font_weight选择变体
variant = self._get_variant_for_weight(font_weight)
# 查找字体文件支持TTF和OTF格式
for ext in [".ttf", ".otf"]:
variant_file = font_dir / f"{variant}{ext}"
if variant_file.exists():
return str(variant_file)
# 如果找不到指定变体尝试查找Regular
for ext in [".ttf", ".otf"]:
regular_file = font_dir / f"Regular{ext}"
if regular_file.exists():
return str(regular_file)
return None
def _find_ziti_font(self, font_family: str, font_weight: int) -> Optional[str]:
"""查找ziti目录字体支持模糊匹配和名称映射"""
if not font_family:
return None
ziti_fonts = self.scan_ziti_fonts()
if not ziti_fonts:
logger.debug(f"ziti目录中没有字体文件无法查找: {font_family}")
return None
# 字体名称映射表(与前端保持一致)
# 映射:显示名称/别名 -> 文件名(不包含扩展名)
font_name_map = {
'Dymon手写体': 'Dymon-ShouXieTi',
'Dymon-ShouXieTi': 'Dymon-ShouXieTi',
'猫啃杂糅体': 'MaokenAssortedSans',
'MaokenAssortedSans': 'MaokenAssortedSans',
'猫啃杂糅体 Lite': 'MaokenAssortedSans-Lite',
'MaokenAssortedSans-Lite': 'MaokenAssortedSans-Lite',
'Murecho 黑体': 'Murecho-Black',
'Murecho-Black': 'Murecho-Black',
'Murecho 粗体': 'Murecho-Bold',
'Murecho-Bold': 'Murecho-Bold',
'墨趣古风体': '墨趣古风体',
'平方张亚玲黑方体': '平方张亚玲黑方体',
'胡晓波骚包体': '胡晓波骚包体2.0',
'胡晓波骚包体2.0': '胡晓波骚包体2.0',
}
# 标准化字体名称(移除空格、统一大小写)
def normalize_name(name: str) -> str:
return name.replace(' ', '').replace('-', '').replace('_', '').lower()
# 尝试通过映射表转换字体名称
mapped_font_family = font_name_map.get(font_family, font_family)
normalized_target = normalize_name(mapped_font_family)
logger.debug(f"查找ziti字体: '{font_family}' -> 映射: '{mapped_font_family}' -> 标准化: '{normalized_target}', 权重: {font_weight}")
# 查找匹配的字体
best_match = None
min_weight_diff = float('inf')
for font_info in ziti_fonts:
family = font_info.get('family', '')
display_name = font_info.get('display_name', '')
path = font_info.get('path', '')
# 获取文件名(不含扩展名)用于匹配
file_stem = None
if path:
from pathlib import Path
file_stem = Path(path).stem
# 多种匹配方式:精确匹配、模糊匹配、文件名匹配
is_match = False
match_type = None
# 1. 精确匹配
if family == font_family or family == mapped_font_family:
is_match = True
match_type = f"精确匹配(family={family})"
elif display_name == font_family or display_name == mapped_font_family:
is_match = True
match_type = f"精确匹配(display_name={display_name})"
# 2. 模糊匹配(忽略大小写和空格)
elif normalize_name(family) == normalized_target:
is_match = True
match_type = f"模糊匹配(family={family})"
elif normalize_name(display_name) == normalized_target:
is_match = True
match_type = f"模糊匹配(display_name={display_name})"
# 3. 文件名匹配
elif file_stem and normalize_name(file_stem) == normalized_target:
is_match = True
match_type = f"文件名匹配(file_stem={file_stem})"
if is_match:
font_weight_info = font_info.get('weight', 400)
weight_diff = abs(font_weight_info - font_weight)
logger.debug(f"找到匹配字体: {match_type}, 路径: {path}, 权重: {font_weight_info}, 权重差: {weight_diff}")
# 找到权重最接近的字体
if weight_diff < min_weight_diff:
min_weight_diff = weight_diff
best_match = path
if best_match:
logger.info(f"找到ziti字体 '{font_family}': {best_match}")
else:
logger.warning(f"未找到ziti字体 '{font_family}',已扫描{len(ziti_fonts)}个字体文件")
return best_match
def _find_system_font(self, font_family: str, font_weight: int) -> Optional[str]:
"""查找系统字体"""
system_fonts = self.scan_system_fonts()
# 查找匹配的字体
for font_info in system_fonts:
if font_info.get('family') == font_family:
font_weight_info = font_info.get('weight', 400)
# 如果权重匹配(或接近),返回字体路径
if abs(font_weight_info - font_weight) <= 100:
return font_info.get('path')
return None
def _get_variant_for_weight(self, font_weight: int) -> str:
"""根据字体权重获取变体名称"""
if font_weight >= 700:
return "Bold"
elif font_weight >= 600:
return "SemiBold"
elif font_weight >= 500:
return "Medium"
else:
return "Regular"
def get_all_available_fonts(self) -> List[Dict]:
"""获取所有可用字体合并预置、ziti和系统字体"""
all_fonts = []
# 添加预置字体
bundled_fonts = self.get_bundled_fonts()
for font_info in bundled_fonts:
all_fonts.append({
'value': font_info.get('family'),
'label': font_info.get('display_name', font_info.get('family')),
'source': 'bundled',
'category': font_info.get('category', 'sans-serif'),
'languages': font_info.get('languages', [])
})
# 添加ziti目录字体
ziti_fonts = self.scan_ziti_fonts()
seen_families = {f.get('family') for f in bundled_fonts}
for font_info in ziti_fonts:
family = font_info.get('family')
if family not in seen_families:
all_fonts.append({
'value': family,
'label': font_info.get('display_name', family),
'source': 'ziti',
'category': font_info.get('category', 'sans-serif'),
'languages': font_info.get('languages', []),
'path': font_info.get('path')
})
seen_families.add(family)
# 添加系统字体(去重)
system_fonts = self.scan_system_fonts()
for font_info in system_fonts:
family = font_info.get('family')
if family not in seen_families:
all_fonts.append({
'value': family,
'label': font_info.get('display_name', family),
'source': 'system',
'category': 'sans-serif',
'languages': []
})
seen_families.add(family)
return all_fonts
# 全局字体管理器实例
_font_manager: Optional[FontManager] = None
def get_font_manager() -> FontManager:
"""获取全局字体管理器实例"""
global _font_manager
if _font_manager is None:
_font_manager = FontManager()
return _font_manager

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,312 @@
"""
工具函数模块
提供字幕封面生成过程中需要的通用工具函数
"""
import os
import sys
from pathlib import Path
from typing import Tuple, Optional, List, Dict, Any
# 尝试导入loguru如果不可用则使用print作为替代
try:
from loguru import logger
except ImportError:
# Fallback logger that uses print
class logger:
@staticmethod
def info(msg, *args, **kwargs):
print(f"[INFO] {msg}", file=sys.stderr)
@staticmethod
def warning(msg, *args, **kwargs):
print(f"[WARN] {msg}", file=sys.stderr)
@staticmethod
def error(msg, *args, **kwargs):
print(f"[ERROR] {msg}", file=sys.stderr)
@staticmethod
def debug(msg, *args, **kwargs):
print(f"[DEBUG] {msg}", file=sys.stderr)
# 尝试导入图像处理库
try:
import cv2
import numpy as np
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
logger.warning("opencv-python不可用图像处理功能受限")
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
logger.warning("PIL不可用图像处理功能受限")
def ensure_dir(path: str) -> Path:
"""确保目录存在"""
path_obj = Path(path)
path_obj.mkdir(parents=True, exist_ok=True)
return path_obj
def get_video_info(video_path: str) -> Dict[str, Any]:
"""获取视频信息"""
if not CV2_AVAILABLE:
# 如果opencv不可用返回基本信息
try:
import os
file_size = os.path.getsize(video_path)
return {
'fps': 30, # 默认值
'frame_count': 0,
'width': 1920, # 默认值
'height': 1080, # 默认值
'duration': 0, # 未知
'path': video_path,
'file_size': file_size,
'note': '视频信息不完整opencv不可用'
}
except Exception as e:
logger.error(f"获取基本视频信息失败: {e}")
raise
try:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"无法打开视频文件: {video_path}")
# 获取视频属性
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = frame_count / fps if fps > 0 else 0
cap.release()
return {
'fps': fps,
'frame_count': frame_count,
'width': width,
'height': height,
'duration': duration,
'path': video_path
}
except Exception as e:
logger.error(f"获取视频信息失败: {e}")
raise
def extract_frame_at_time(video_path: str, time_seconds: float):
"""在指定时间抽取视频帧"""
if not CV2_AVAILABLE:
logger.warning("opencv不可用无法抽取视频帧")
# 返回一个占位符
return None
try:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"无法打开视频文件: {video_path}")
# 设置帧位置
fps = cap.get(cv2.CAP_PROP_FPS)
frame_number = int(time_seconds * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = cap.read()
cap.release()
if not ret:
raise ValueError(f"无法读取帧: {frame_number}")
return frame
except Exception as e:
logger.error(f"抽帧失败: {e}")
raise
def save_image(image, output_path: str, quality: int = 95) -> None:
"""保存图像
如果输出路径是PNG格式会保留alpha通道透明背景
如果是JPEG格式会转换为RGB格式
"""
try:
ensure_dir(os.path.dirname(output_path))
is_png = output_path.lower().endswith('.png')
if CV2_AVAILABLE and isinstance(image, np.ndarray):
# 使用OpenCV保存
if is_png and image.shape[2] == 4:
# PNG格式且有alpha通道保存为BGRAOpenCV使用BGR格式
# 确保图像是BGRA格式B, G, R, A
# 如果输入是RGBAR, G, B, A需要转换为BGRA
if image.dtype != np.uint8:
image = image.astype(np.uint8)
# OpenCV的imwrite会自动处理BGRA格式
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
elif is_png:
# PNG格式但没有alpha通道转换为RGB
if image.shape[2] == 3:
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
else:
# 如果是单通道转换为3通道
if len(image.shape) == 2:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
else:
# JPEG格式确保是3通道BGR
if image.shape[2] == 4:
# 有alpha通道先合成到白色背景
bgr = image[:, :, :3]
alpha = image[:, :, 3:4] / 255.0
white_bg = np.ones_like(bgr) * 255
image = (bgr * alpha + white_bg * (1 - alpha)).astype(np.uint8)
elif len(image.shape) == 2:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_JPEG_QUALITY, quality])
if not success:
raise ValueError(f"保存图像失败: {output_path}")
elif PIL_AVAILABLE and hasattr(image, 'save'):
# 使用PIL保存
if is_png:
image.save(output_path, 'PNG', compress_level=3)
else:
# JPEG格式确保是RGB
if image.mode == 'RGBA':
# 合成到白色背景
rgb = Image.new('RGB', image.size, (255, 255, 255))
rgb.paste(image, mask=image.split()[3])
image = rgb
image.save(output_path, 'JPEG', quality=quality)
else:
raise ValueError("没有可用的图像保存方法")
logger.info(f"图像已保存: {output_path}")
except Exception as e:
logger.error(f"保存图像失败: {e}")
raise
def format_time(seconds: float) -> str:
"""格式化时间为 HH:MM:SS.mmm"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
milliseconds = int((seconds % 1) * 1000)
return "02d"
def create_temp_dir(prefix: str = "subtitle_cover_") -> str:
"""创建临时目录"""
import tempfile
temp_dir = tempfile.mkdtemp(prefix=prefix)
logger.info(f"创建临时目录: {temp_dir}")
return temp_dir
def cleanup_temp_dir(temp_dir: str) -> None:
"""清理临时目录"""
try:
import shutil
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
logger.info(f"清理临时目录: {temp_dir}")
except Exception as e:
logger.warning(f"清理临时目录失败: {e}")
def validate_file_exists(file_path: str, file_type: str = "文件") -> None:
"""验证文件是否存在"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"{file_type}不存在: {file_path}")
if not os.path.isfile(file_path):
raise ValueError(f"{file_type}不是文件: {file_path}")
def get_file_size_mb(file_path: str) -> float:
"""获取文件大小(MB)"""
size_bytes = os.path.getsize(file_path)
return size_bytes / (1024 * 1024)
def calculate_aspect_ratio(width: int, height: int) -> float:
"""计算宽高比"""
return width / height if height > 0 else 0
def resize_image(image: np.ndarray, target_width: int, target_height: int,
keep_aspect_ratio: bool = True) -> np.ndarray:
"""调整图像大小"""
if keep_aspect_ratio:
# 保持宽高比
h, w = image.shape[:2]
aspect_ratio = w / h
if target_width / target_height > aspect_ratio:
# 目标更宽,以高度为准
new_width = int(target_height * aspect_ratio)
new_height = target_height
else:
# 目标更高,以宽度为准
new_width = target_width
new_height = int(target_width / aspect_ratio)
resized = cv2.resize(image, (new_width, new_height))
else:
# 不保持宽高比,直接缩放
resized = cv2.resize(image, (target_width, target_height))
return resized
def blend_images(background: np.ndarray, foreground: np.ndarray,
position: Tuple[int, int] = (0, 0)) -> np.ndarray:
"""将前景图像合成到背景图像上"""
x, y = position
h, w = foreground.shape[:2]
# 确保位置不超出边界
bg_h, bg_w = background.shape[:2]
x = max(0, min(x, bg_w - w))
y = max(0, min(y, bg_h - h))
# 创建ROI
roi = background[y:y+h, x:x+w]
# 如果前景有alpha通道进行透明合成
if foreground.shape[2] == 4:
# 分离颜色和alpha通道
foreground_rgb = foreground[:, :, :3]
alpha = foreground[:, :, 3] / 255.0
# 扩展alpha到3通道
alpha = np.stack([alpha] * 3, axis=2)
# 透明合成
blended = foreground_rgb * alpha + roi * (1 - alpha)
background[y:y+h, x:x+w] = blended.astype(np.uint8)
else:
# 直接覆盖
background[y:y+h, x:x+w] = foreground
return background
def apply_blur(image: np.ndarray, kernel_size: int = 15) -> np.ndarray:
"""应用模糊效果"""
return cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
def apply_gradient_overlay(image: np.ndarray, color: Tuple[int, int, int],
opacity: float = 0.5) -> np.ndarray:
"""应用渐变覆盖"""
overlay = np.full_like(image, color, dtype=np.uint8)
return cv2.addWeighted(image, 1 - opacity, overlay, opacity, 0)