diff --git a/app/core/roles.py b/app/core/roles.py index c6016d3..eafee9b 100644 --- a/app/core/roles.py +++ b/app/core/roles.py @@ -1,8 +1,9 @@ ROLE_ADMIN = 1 ROLE_AGENT = 2 - +ROLE_OEM=3 ROLE_LABELS: dict[int, str] = { 0: "普通用户", ROLE_ADMIN: "管理员", ROLE_AGENT: "代理", + ROLE_OEM:"oem", } diff --git a/app/schemas/admin_user.py b/app/schemas/admin_user.py index eaf193f..14a9ca8 100644 --- a/app/schemas/admin_user.py +++ b/app/schemas/admin_user.py @@ -19,7 +19,7 @@ class AdminUserCreate(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=6, max_length=128) phone: str | None = Field(default=None, max_length=64) - role_id: int = Field(default=0, ge=0, le=2) + role_id: int = Field(default=0, ge=0, le=3) vip_end_time: datetime | None = None @field_validator("username") diff --git a/scripts/cover/README.md b/scripts/cover/README.md new file mode 100644 index 0000000..782bd83 --- /dev/null +++ b/scripts/cover/README.md @@ -0,0 +1,25 @@ +# 封面生成 Python 脚本 + +从 Electron `python-runtimebackup` 移植,供 Tauri 桌面端通过 Node `cover_generate.js` 调用。 + +## 目录 + +| 文件 | 用途 | +|------|------| +| `advanced_cover_generator.py` | 高级模板:虚化、抠图、描边、蒙版、多行标题等 | +| `cover_generator.py` | 中等复杂度:抽帧 + PIL 叠字 | +| `cover_preview_generator.py` | 多风格预览(CLI 输出 JSON) | +| `generate_cover_previews.py` | 批量生成模板预览图 | +| `generate_image_cover.py` | 静态图多风格封面 | +| `custom_template_cover_fix.py` | 自定义模板修复参考(补丁说明) | +| `modules/` | 人像分割、字体、封面模块 | + +## 运行时 + +- Python:`AICLIENT_PYTHON_PATH`(默认 `src-tauri/resources/resources-bundles/python-runtimebackup/python.exe`) +- 脚本目录:`AICLIENT_COVER_SCRIPTS_DIR`(默认 `src-tauri/resources/cover-python/`) +- FFmpeg:需在 PATH 或 `AICLIENT_FFMPEG_PATH` + +## 同步 + +开发时以本目录为源码;发布前请同步到 `aiclient/src-tauri/resources/cover-python/`(与 `resources/**/*` 一并打包)。 diff --git a/scripts/cover/advanced_cover_generator.py b/scripts/cover/advanced_cover_generator.py new file mode 100644 index 0000000..7f0e179 --- /dev/null +++ b/scripts/cover/advanced_cover_generator.py @@ -0,0 +1,2490 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +高级封面生成器 - 支持完整的模板功能 +Advanced Cover Generator - Full Template Support + +功能: +- 背景模糊 +- 人物抠图 +- 人物描边 +- 蒙版叠加 +- 自定义字体和文字样式 +- 步骤化生成,每步生成预览图 +""" + +import os +import sys +import json +import argparse +from typing import Dict, Any, Optional, Tuple +from pathlib import Path + +# ⚠️ 修复模块路径:确保 Python 能找到 modules 和其他本地模块 +# 将脚本所在目录(python 目录)添加到 sys.path +script_dir = os.path.dirname(os.path.abspath(__file__)) + +# 🔧 关键修复:在打包环境中,modules可能在当前目录或上级目录 +# 添加多个可能的路径 +possible_paths = [ + script_dir, # 脚本所在目录 + os.path.join(script_dir, '..'), # 上一级目录(如果python-scripts是子目录) + os.getcwd(), # 当前工作目录 +] + +for path in possible_paths: + abs_path = os.path.abspath(path) + if abs_path not in sys.path: + sys.path.insert(0, abs_path) + print(f"[PATH] Added to sys.path: {abs_path}", file=sys.stderr, flush=True) + +print(f"[PATH] Current sys.path (first 3): {sys.path[:3]}", file=sys.stderr, flush=True) # 显示前3个路径用于诊断 + +# ⚠️ 强制输出:验证Python脚本确实在运行 +print("=" * 60, file=sys.stderr, flush=True) +print("ADVANCED_COVER_GENERATOR.PY STARTED", file=sys.stderr, flush=True) +print("=" * 60, file=sys.stderr, flush=True) +sys.stderr.flush() + +# 导入基础库 +try: + import cv2 + import numpy as np + from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance + print("✅ Basic libraries loaded (cv2, numpy, PIL)", file=sys.stderr, flush=True) +except ImportError as e: + print(f"❌ Error: Missing required library - {e}", file=sys.stderr, flush=True) + sys.exit(1) + +# 导入人像分割模块(支持MODNet和rembg) +try: + from modules.segment import PersonSegmenter + SEGMENT_AVAILABLE = True + diag_segment = "✅ PersonSegmenter loaded successfully (supports MODNet + rembg)" + print(diag_segment, file=sys.stderr) + sys.stderr.flush() +except ImportError as e: + SEGMENT_AVAILABLE = False + PersonSegmenter = None + diag_segment = f"❌ PersonSegmenter failed to load: {e}" + print(diag_segment, file=sys.stderr) + print(f"[DIAGNOSTIC] Attempted module paths:", file=sys.stderr) + for p in sys.path[:5]: + modules_path = os.path.join(p, 'modules', 'segment.py') + exists = "EXISTS" if os.path.exists(modules_path) else "NOT FOUND" + print(f" {modules_path} - {exists}", file=sys.stderr) + sys.stderr.flush() + + # 尝试其他可能的导入方式 + try: + import modules.segment as seg_module + PersonSegmenter = seg_module.PersonSegmenter + SEGMENT_AVAILABLE = True + print("✅ PersonSegmenter loaded via alternative import path", file=sys.stderr) + except Exception as e2: + print(f"❌ Alternative import also failed: {e2}", file=sys.stderr) + sys.stderr.flush() + + +class AdvancedCoverGenerator: + """高级封面生成器""" + + def __init__(self, config: Dict[str, Any]): + """ + 初始化生成器 + + Args: + config: 配置字典 + """ + self.config = config + self.template = config + self.output_dir = os.path.dirname(config.get('output', './output/cover.png')) + self.output_path = config.get('output', './output/cover.png') + + # 创建输出目录 + os.makedirs(self.output_dir, exist_ok=True) + + # 步骤预览图列表 + self.preview_images = [] + + # 初始化人像分割器(支持MODNet优先级,与参考项目一致) + self.segmenter = None + self.current_model = "none" + if SEGMENT_AVAILABLE: + self._init_segmenter() + + def _init_segmenter(self): + """ + 初始化人像分割器,按优先级尝试加载模型 + + 模型优先级(修复:优先使用rembg模型,避免依赖MODNet本地模型): + 1. u2net - 通用高质量模型(rembg默认,会自动下载) + 2. u2net_human_seg - 人像专用模型 + 3. u2netp - 轻量版模型 + 4. silueta - 通用抠图模型 + 5. modnet - MODNet专业人像抠图(仅作为备选) + 6. simple - 简化方法(后备) + """ + model_priority = [ + "u2net", # 通用高质量(rembg默认,优先使用) + "u2net_human_seg", # 人像专用 + "u2netp", # 轻量版 + "silueta", # 备用 + "modnet", # MODNet专业人像(仅作为备选,避免本地模型依赖) + "simple" # 简化方法 + ] + + for model_name in model_priority: + try: + print(f"[COVER_GENERATOR] Trying to load segmentation model: {model_name}", file=sys.stderr) + self.segmenter = PersonSegmenter(model_name=model_name) + print(f"[COVER_GENERATOR] Successfully loaded segmentation model: {model_name}", file=sys.stderr) + self.current_model = model_name + return + except Exception as e: + print(f"[COVER_GENERATOR] Failed to load model {model_name}: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + continue + + # 如果所有模型都失败 + print("[COVER_GENERATOR] Warning: All segmentation models failed", file=sys.stderr) + self.segmenter = None + self.current_model = "none" + + def _segment_person_with_modnet(self, pil_image: Image.Image) -> Image.Image: + """ + 使用PersonSegmenter进行人像分割(支持MODNet优先级) + + Args: + pil_image: PIL Image对象(RGB或RGBA) + + Returns: + 分割后的PIL Image(RGBA格式,背景完全透明) + """ + if self.segmenter is None: + raise RuntimeError("PersonSegmenter not initialized") + + # 转换PIL Image到numpy数组(BGR格式,PersonSegmenter需要) + img_np = np.array(pil_image) + if img_np.shape[2] == 4: # RGBA + img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR) + elif img_np.shape[2] == 3: # RGB + img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) + + # 使用PersonSegmenter进行分割(获取mask,而不是预处理后的图像) + print(f"Using segmentation model: {self.current_model}", file=sys.stderr) + + # 关键修复:使用 return_mask=True 获取分割mask,而不是预处理后的图像 + # 这样可以确保背景被正确处理为透明 + _, mask = self.segmenter.segment_person(img_np, return_mask=True) + + # 确保mask是uint8格式 + if mask.dtype != np.uint8: + if mask.max() <= 1.0: + mask = (mask * 255).astype(np.uint8) + else: + mask = mask.astype(np.uint8) + + print(f"Segmentation mask: shape={mask.shape}, dtype={mask.dtype}, min={mask.min()}, max={mask.max()}", file=sys.stderr) + + # 将mask应用到原始图像,创建带透明背景的图像 + # 原始图像是RGB,需要转换为RGBA + img_rgb = cv2.cvtColor(img_np, cv2.COLOR_BGR2RGB) + + # 创建RGBA图像:RGB来自原始图像,A来自mask + segmented_rgba = np.dstack([img_rgb, mask]) + + print(f"Result image: shape={segmented_rgba.shape}, dtype={segmented_rgba.dtype}", file=sys.stderr) + + return Image.fromarray(segmented_rgba) + + def refine_mask(self, mask: np.ndarray) -> np.ndarray: + """ + 优化mask,提高人像分割精度,严格过滤背景物体 + + 改进策略: + 1. 高斯模糊平滑 + 2. OTSU 自适应阈值 + 3. 形态学操作(开运算去噪点,闭运算填充小洞) + 4. 智能轮廓过滤(基于位置、形状、面积多重条件) + 5. 边缘平滑 + + Args: + mask: 输入的 mask(numpy 数组) + + Returns: + 优化后的 mask + """ + try: + # 确保mask是uint8格式 + if mask.dtype != np.uint8: + if mask.max() <= 1.0: + mask = (mask * 255).astype(np.uint8) + else: + mask = mask.astype(np.uint8) + + h, w = mask.shape[:2] + print(f"Refining mask: shape={mask.shape}, dtype={mask.dtype}", file=sys.stderr) + + # 1. 第一次高斯模糊(sigma=0.3) + mask_blurred = cv2.GaussianBlur(mask, (3, 3), 0.3) + + # 2. 使用 OTSU 自适应阈值 + _, mask_binary = cv2.threshold(mask_blurred, 0, 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # 3. 形态学操作 + # 开运算(去除噪点)- 使用小kernel保持细节 + kernel_small = np.ones((3, 3), np.uint8) + mask_cleaned = cv2.morphologyEx(mask_binary, cv2.MORPH_OPEN, + kernel_small, iterations=1) + + # 闭运算(填充小洞)- 使用中等kernel + kernel_medium = np.ones((5, 5), np.uint8) + mask_filled = cv2.morphologyEx(mask_cleaned, cv2.MORPH_CLOSE, + kernel_medium, iterations=1) + + # 4. 智能轮廓过滤(严格过滤背景物体) + contours, _ = cv2.findContours(mask_filled, cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE) + + if len(contours) > 0: + # 找到最大轮廓(通常是人物主体) + largest_contour = max(contours, key=cv2.contourArea) + largest_area = cv2.contourArea(largest_contour) + + # 计算最大轮廓的中心点和边界框 + M = cv2.moments(largest_contour) + if M["m00"] != 0: + largest_cx = int(M["m10"] / M["m00"]) + largest_cy = int(M["m01"] / M["m00"]) + else: + largest_cx, largest_cy = w // 2, h // 2 + + largest_x, largest_y, largest_w, largest_h = cv2.boundingRect(largest_contour) + + print(f"Largest contour: area={largest_area}, center=({largest_cx},{largest_cy}), bbox=({largest_x},{largest_y},{largest_w},{largest_h})", file=sys.stderr) + + # 创建新的mask,只保留符合条件的轮廓 + mask_refined = np.zeros_like(mask_filled) + cv2.fillPoly(mask_refined, [largest_contour], 255) + + # 过滤其他轮廓(更严格的条件) + kept_contours = 1 + for contour in contours: + if contour is largest_contour: + continue + + area = cv2.contourArea(contour) + + # 条件1: 面积必须大于最大轮廓的5%(提高阈值从1.5%到5%) + if area < largest_area * 0.05: + continue + + # 计算轮廓的中心点和边界框 + M = cv2.moments(contour) + if M["m00"] != 0: + cx = int(M["m10"] / M["m00"]) + cy = int(M["m01"] / M["m00"]) + else: + continue + + x, y, cw, ch = cv2.boundingRect(contour) + + # 条件2: 必须与最大轮廓在垂直方向上有重叠(人物的手臂、腿等) + vertical_overlap = not (y + ch < largest_y or y > largest_y + largest_h) + if not vertical_overlap: + print(f"Filtered contour: no vertical overlap, area={area}, pos=({x},{y})", file=sys.stderr) + continue + + # 条件3: 水平距离不能太远(必须在最大轮廓宽度的1.5倍范围内) + horizontal_distance = min(abs(x - largest_x), abs((x + cw) - (largest_x + largest_w))) + max_horizontal_distance = largest_w * 1.5 + if horizontal_distance > max_horizontal_distance: + print(f"Filtered contour: too far horizontally, distance={horizontal_distance}, area={area}", file=sys.stderr) + continue + + # 条件4: 长宽比检查(避免保留细长的背景物体) + aspect_ratio = cw / ch if ch > 0 else 0 + # 人体部位的长宽比通常在0.2到5之间 + if aspect_ratio < 0.2 or aspect_ratio > 5: + print(f"Filtered contour: abnormal aspect ratio={aspect_ratio:.2f}, area={area}", file=sys.stderr) + continue + + # 条件5: 位置检查(避免保留图像边缘的物体) + # 如果轮廓紧贴图像边缘且不是最大轮廓,很可能是背景物体 + edge_margin = 10 # 边缘容差 + is_at_edge = (x < edge_margin or y < edge_margin or + x + cw > w - edge_margin or y + ch > h - edge_margin) + if is_at_edge and area < largest_area * 0.3: + print(f"Filtered contour: at edge with small area={area}, pos=({x},{y})", file=sys.stderr) + continue + + # 通过所有条件,保留此轮廓 + cv2.fillPoly(mask_refined, [contour], 255) + kept_contours += 1 + print(f"Kept contour: area={area}, center=({cx},{cy}), aspect_ratio={aspect_ratio:.2f}", file=sys.stderr) + + print(f"Contour filtering: kept {kept_contours}/{len(contours)} contours", file=sys.stderr) + mask_filled = mask_refined + + # 5. 第二次高斯模糊使边缘自然(sigma=0.2) + mask_smooth = cv2.GaussianBlur(mask_filled, (3, 3), 0.2) + + # 最终二值化(使用OTSU自动计算阈值,而不是固定值100) + # 这样可以避免背景被误认为是人物 + _, mask_final = cv2.threshold(mask_smooth, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + print(f"Mask refinement completed. Final mask: non-zero pixels={np.count_nonzero(mask_final)}", file=sys.stderr) + return mask_final + + except Exception as e: + print(f"Mask refinement failed: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return mask + + def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]: + """ + 将十六进制颜色转换为RGB元组 + + Args: + hex_color: 十六进制颜色字符串(如 "#FFFFFF") + + Returns: + RGB元组 + """ + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + def load_image(self, image_path: str) -> Image.Image: + """ + 加载图像 + + Args: + image_path: 图像路径 + + Returns: + PIL图像对象 + """ + print(f"Loading image: {image_path}", file=sys.stderr) + + if image_path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp')): + # 直接加载图像 + return Image.open(image_path).convert('RGB') + else: + # 从视频提取帧 + cap = cv2.VideoCapture(image_path) + ret, frame = cap.read() + cap.release() + + if not ret: + raise Exception("Failed to extract frame from video") + + # 转换BGR到RGB + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return Image.fromarray(frame_rgb) + + def save_preview(self, image: Image.Image, step_name: str) -> str: + """ + 保存步骤预览图 + + Args: + image: PIL图像对象 + step_name: 步骤名称 + + Returns: + 预览图路径 + """ + preview_path = os.path.join( + self.output_dir, + f"preview_{len(self.preview_images) + 1}_{step_name}.png" + ) + image.save(preview_path, quality=95) + self.preview_images.append({ + 'step': len(self.preview_images) + 1, + 'name': step_name, + 'path': preview_path + }) + print(f"Preview saved: {step_name} -> {preview_path}", file=sys.stderr) + return preview_path + + def apply_background_blur(self, image: Image.Image) -> Image.Image: + """ + 步骤1: 应用背景模糊 + + Args: + image: 输入图像 + + Returns: + 模糊后的图像 + """ + blur_enabled = self.template.get('backgroundBlurEnabled', False) + + if not blur_enabled: + print("Step 1: Background blur disabled, skipping", file=sys.stderr) + return image + + blur_intensity = self.template.get('backgroundBlurIntensity', 10) + print(f"Step 1: Applying background blur (intensity: {blur_intensity})", file=sys.stderr) + + # 应用高斯模糊 + blurred = image.filter(ImageFilter.GaussianBlur(radius=blur_intensity)) + + # 保存预览 + self.save_preview(blurred, "background_blur") + + return blurred + + def extract_and_composite_person(self, background: Image.Image) -> Image.Image: + """ + 步骤2: 人物抠图和合成 + + Args: + background: 背景图像 + + Returns: + 合成后的图像 + """ + person_border_enabled = self.template.get('personBorderEnabled', False) + + if not person_border_enabled or not SEGMENT_AVAILABLE: + print("Step 2: Person extraction disabled or PersonSegmenter not available, skipping", file=sys.stderr) + return background + + print("Step 2: Extracting person from original image", file=sys.stderr) + + # 从原始图像抠图(不是从模糊的背景) + original_image = self.load_image(self.config.get('video', '')) + + # 调整大小以匹配背景 + if original_image.size != background.size: + original_image = original_image.resize(background.size, Image.Resampling.LANCZOS) + + # 使用PersonSegmenter进行抠图(支持MODNet优先级) + try: + person_rgba = self._segment_person_with_modnet(original_image) + print("Person extraction completed with PersonSegmenter", file=sys.stderr) + except Exception as e: + print(f"PersonSegmenter extraction failed: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return background + + # 确保是 RGBA 模式 + if person_rgba.mode != 'RGBA': + person_rgba = person_rgba.convert('RGBA') + + # 提取 alpha 通道并优化 + alpha_channel = np.array(person_rgba.split()[3]) + refined_alpha = self.refine_mask(alpha_channel) + + # 将优化后的 alpha 通道应用回图像 + person_rgba.putalpha(Image.fromarray(refined_alpha)) + + print("Mask refinement applied to extracted person", file=sys.stderr) + + # 保存抠图预览(带透明背景) + self.save_preview(person_rgba, "person_extracted") + + # 调整人物大小 + person_size = self.template.get('personSize', 100) + person_rotation = self.template.get('personRotation', 0) + if person_size != 100: + print(f"Step 2: Resizing person to {person_size}%", file=sys.stderr) + new_width = int(person_rgba.width * person_size / 100) + new_height = int(person_rgba.height * person_size / 100) + person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 调整人物位置 + person_position = self.template.get('personPosition', {'x': 50, 'y': 50}) + + # ✅ 新增:人像旋转 + if person_rotation != 0: + print(f"Rotating person by {person_rotation}°", file=sys.stderr) + # 旋转人像(使用expand=True以保留完整旋转结果) + person_rgba = person_rgba.rotate(-person_rotation, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + print(f"Person rotated, new size: {person_rgba.size}", file=sys.stderr) + + person_x = int(background.width * person_position['x'] / 100) - person_rgba.width // 2 + person_y = int(background.height * person_position['y'] / 100) - person_rgba.height // 2 + + # 合成到背景(保持 RGBA 模式以保留透明度) + result = background.convert('RGBA') + result.paste(person_rgba, (person_x, person_y), person_rgba) + + # 保存预览(保持RGBA模式) + self.save_preview(result, "person_composited") + + return result + + def apply_person_outline(self, image: Image.Image) -> Image.Image: + """ + 步骤3: 应用人物描边 + + 注意:此方法已废弃,描边逻辑已移至 _apply_outline_to_person + 保留此方法仅为兼容性 + + Args: + image: 输入图像 + + Returns: + 添加描边后的图像 + """ + person_border_enabled = self.template.get('personBorderEnabled', False) + + if not person_border_enabled or not SEGMENT_AVAILABLE: + print("Step 3: Person outline disabled or PersonSegmenter not available, skipping", file=sys.stderr) + return image + + print("Step 3: Applying person outline (legacy method)", file=sys.stderr) + print("Warning: This method is deprecated, use the new generate() flow instead", file=sys.stderr) + + # 从原始图像抠图(使用PersonSegmenter) + original_image = self.load_image(self.config.get('video', '')) + if original_image.size != image.size: + original_image = original_image.resize(image.size, Image.Resampling.LANCZOS) + + # 使用PersonSegmenter进行抠图 + try: + person_rgba = self._segment_person_with_modnet(original_image) + except Exception as e: + print(f"PersonSegmenter extraction failed: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return image + + # 确保是 RGBA 模式 + if person_rgba.mode != 'RGBA': + person_rgba = person_rgba.convert('RGBA') + + # 提取 alpha 通道并优化 + alpha_channel = np.array(person_rgba.split()[3]) + refined_alpha = self.refine_mask(alpha_channel) + person_rgba.putalpha(Image.fromarray(refined_alpha)) + + # 应用描边 + person_rgba = self._apply_outline_to_person(person_rgba) + + # 调整人物大小 + person_size = self.template.get('personSize', 100) + person_rotation = self.template.get('personRotation', 0) + if person_size != 100: + new_width = int(person_rgba.width * person_size / 100) + new_height = int(person_rgba.height * person_size / 100) + person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 调整大小和位置 + person_position = self.template.get('personPosition', {'x': 50, 'y': 50}) + person_x = int(image.width * person_position['x'] / 100) - person_rgba.width // 2 + person_y = int(image.height * person_position['y'] / 100) - person_rgba.height // 2 + + # 合成 + result = image.convert('RGBA') + result.paste(person_rgba, (person_x, person_y), person_rgba) + + # 保存预览(保持RGBA模式) + self.save_preview(result, "person_outlined") + + return result + + def apply_mask(self, image: Image.Image) -> Image.Image: + """ + 步骤4: 应用蒙版 + + Args: + image: 输入图像 + + Returns: + 添加蒙版后的图像 + """ + mask_enabled = self.template.get('maskEnabled', False) + + if not mask_enabled: + print("Step 4: Mask disabled, skipping", file=sys.stderr) + return image + + mask_path = self.template.get('maskImagePath', '') + if not mask_path or not os.path.exists(mask_path): + print(f"Step 4: Mask image not found: {mask_path}, skipping", file=sys.stderr) + return image + + print(f"Step 4: Applying mask from {mask_path}", file=sys.stderr) + + # 加载蒙版 + mask_img = Image.open(mask_path).convert('RGBA') + + # 获取蒙版参数 + mask_size = self.template.get('maskSize', 100) + mask_position = self.template.get('maskPosition', {'x': 50, 'y': 50}) + mask_opacity = self.template.get('maskOpacity', 100) + + # 调整蒙版大小 + mask_width = int(image.width * mask_size / 100) + mask_height = int(image.height * mask_size / 100) + mask_img = mask_img.resize((mask_width, mask_height), Image.Resampling.LANCZOS) + + # 调整透明度 + if mask_opacity < 100: + alpha = mask_img.split()[3] + alpha = ImageEnhance.Brightness(alpha).enhance(mask_opacity / 100) + mask_img.putalpha(alpha) + + # 计算位置 + mask_x = int(image.width * mask_position['x'] / 100) - mask_width // 2 + mask_y = int(image.height * mask_position['y'] / 100) - mask_height // 2 + + # 合成 + result = image.convert('RGBA') + result.paste(mask_img, (mask_x, mask_y), mask_img) + + # 保存预览(保持RGBA模式) + self.save_preview(result, "mask_applied") + + return result + + def add_text(self, image: Image.Image) -> Image.Image: + """ + 步骤5: 添加文字(主标题和副标题) + + Args: + image: 输入图像 + + Returns: + 添加文字后的图像 + """ + title_text = self.config.get('title', self.template.get('titleText', '')) + subtitle_text = self.template.get('subtitleText', '') + + if not title_text and not subtitle_text: + print("Step 5: No title or subtitle text, skipping", file=sys.stderr) + return image + + print(f"Step 5: Adding text - Title: {title_text}, Subtitle: {subtitle_text}", file=sys.stderr) + + # 转换为RGBA以支持透明度 + result = image.convert('RGBA') + draw = ImageDraw.Draw(result) + + width, height = result.size + print(f"[add_text] Image size: {width}x{height}", file=sys.stderr) + + # 获取文字参数 + font_family = self.template.get('titleFontFamily', 'SimHei') + font_size = self.template.get('titleFontSize', 120) + font_weight = self.template.get('titleFontWeight', 700) + text_color = self.hex_to_rgb(self.template.get('titleColor', '#FFFFFF')) + stroke_color = self.hex_to_rgb(self.template.get('titleStrokeColor', '#000000')) + stroke_width = self.template.get('titleStrokeWidth', 2) + + # 修复:处理文本位置参数,确保是字典类型 + text_position = self.template.get('titlePosition', {'x': 50, 'y': 80}) + print(f"[add_text] Title position (percentage): x={text_position['x']}%, y={text_position['y']}%", file=sys.stderr) + + # 如果是字符串(旧格式),转换为字典 + if isinstance(text_position, str): + position_map = { + 'top': {'x': 50, 'y': 20}, + 'center': {'x': 50, 'y': 50}, + 'bottom': {'x': 50, 'y': 80} + } + text_position = position_map.get(text_position, {'x': 50, 'y': 80}) + print(f"Converted text position from string to dict: {text_position}", file=sys.stderr) + + # 加载字体 + font_path = self._get_font_path(font_family) + try: + font = ImageFont.truetype(font_path, font_size) + except: + print(f"Warning: Failed to load font {font_path}, using default", file=sys.stderr) + font = ImageFont.load_default() + + # ✅ 关键修复:支持多行文字,使用anchor='mm'实现中心对齐 + # 计算文字中心点位置(百分比转像素) + # 主标题不需要坐标反转 + text_x_from_config = text_position['x'] + text_y_from_config = text_position['y'] + text_x = int(width * text_x_from_config / 100) + text_y = int(height * text_y_from_config / 100) + print(f"[add_text] Title initial position (pixels): x={text_x}px, y={text_y}px (from {text_x_from_config}%, {text_y_from_config}%)", file=sys.stderr) + + if '\n' in title_text: + lines = title_text.split('\n') + print(f"Rendering multiline text: {len(lines)} lines, center=({text_x},{text_y}), text='{title_text}'", file=sys.stderr) + else: + print(f"Rendering single line text, center=({text_x},{text_y}), text='{title_text}'", file=sys.stderr) + + # 绘制装饰图层1(原文字背景,现为独立装饰层) + if self.template.get('titleBackgroundEnabled', False): + bg_color = self.hex_to_rgb(self.template.get('titleBackgroundColor', '#000000')) + bg_opacity = self.template.get('titleBackgroundOpacity', 70) + bg_shape = self.template.get('titleBackgroundShape', 'rectangle') + bg_size = self.template.get('titleBackgroundSize', {'width': 30, 'height': 10}) + bg_position = self.template.get('titleBackgroundPosition', {'x': 50, 'y': 30}) + bg_radius = self.template.get('titleBackgroundRadius', 10) + bg_points = self.template.get('titleBackgroundPoints', []) + title_background_rotation = self.template.get('titleBackgroundRotation', 0) + + bg_alpha = int(255 * bg_opacity / 100) + + print(f"Title background: color={bg_color}, opacity={bg_opacity}%, alpha={bg_alpha}, shape={bg_shape}", file=sys.stderr) + # 🔧 调试:输出多边形点数据 + print(f"[DEBUG] titleBackgroundPoints: {bg_points}, type: {type(bg_points)}, len: {len(bg_points) if isinstance(bg_points, (list, tuple)) else 'N/A'}", file=sys.stderr) + + # 创建单独的图层来绘制背景(确保透明度正确) + bg_layer = Image.new('RGBA', result.size, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg_layer) + + # 使用独立的位置和大小参数(百分比转像素) + bg_width = int(width * bg_size['width'] / 100) + bg_height = int(height * bg_size['height'] / 100) + bg_center_x = int(width * bg_position['x'] / 100) + bg_center_y = int(height * bg_position['y'] / 100) + + bg_left = bg_center_x - bg_width // 2 + bg_top = bg_center_y - bg_height // 2 + bg_right = bg_center_x + bg_width // 2 + bg_bottom = bg_center_y + bg_height // 2 + + # 🔧 调试:记录每个条件的结果 + print(f"[DEBUG] Shape check: bg_shape='{bg_shape}' (type: {type(bg_shape).__name__})", file=sys.stderr) + print(f"[DEBUG] Points check: bg_points={bg_points}, type={type(bg_points).__name__}, len={len(bg_points) if isinstance(bg_points, list) else 'N/A'}, bool(bg_points)={bool(bg_points)}", file=sys.stderr) + + # 🔧 详细输出:模板中的所有背景相关字段 + print(f"[DEBUG] Template background fields:", file=sys.stderr) + print(f" titleBackgroundEnabled: {self.template.get('titleBackgroundEnabled')}", file=sys.stderr) + print(f" titleBackgroundShape: {self.template.get('titleBackgroundShape')}", file=sys.stderr) + print(f" titleBackgroundPoints raw: {self.template.get('titleBackgroundPoints')}", file=sys.stderr) + print(f"[DEBUG] Polygon condition: bg_shape=='polygon'={bg_shape == 'polygon'}, bg_points truthy={bool(bg_points)}", file=sys.stderr) + + if bg_shape == 'rectangle': + # 矩形背景 + print(f"[DEBUG] Drawing RECTANGLE background", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + elif bg_shape == 'rounded': + # 圆角矩形背景 + print(f"[DEBUG] Drawing ROUNDED RECTANGLE background", file=sys.stderr) + bg_draw.rounded_rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + radius=bg_radius, + fill=(*bg_color, bg_alpha) + ) + elif bg_shape == 'polygon' and bg_points: + # 自定义多边形背景 + print(f"[DEBUG] Drawing POLYGON background with {len(bg_points)} points", file=sys.stderr) + polygon_points = [] + for i, point in enumerate(bg_points): + try: + px = bg_center_x + int(point['x'] * bg_width / 100) + py = bg_center_y + int(point['y'] * bg_height / 100) + polygon_points.append((px, py)) + print(f"[DEBUG] Point {i}: {point} -> ({px}, {py})", file=sys.stderr) + except Exception as e: + print(f"[DEBUG] Error processing point {i}: {point}, error: {e}", file=sys.stderr) + + if len(polygon_points) >= 3: + print(f"[DEBUG] Drawing polygon with {len(polygon_points)} points: {polygon_points}", file=sys.stderr) + bg_draw.polygon(polygon_points, fill=(*bg_color, bg_alpha)) + else: + print(f"[DEBUG] Not enough points for polygon (need >= 3, got {len(polygon_points)}). Falling back to rectangle.", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + else: + # 默认使用矩形 + print(f"[DEBUG] Drawing DEFAULT RECTANGLE (didn't match any shape type)", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + + # ✅ 如果需要旋转,旋转背景图层 + if title_background_rotation != 0: + print(f"Rotating title background by {title_background_rotation}°", file=sys.stderr) + bg_layer = bg_layer.rotate(-title_background_rotation, resample=Image.BICUBIC, expand=False, fillcolor=(0, 0, 0, 0)) + + # 使用alpha_composite合成背景层 + result = Image.alpha_composite(result, bg_layer) + draw = ImageDraw.Draw(result) # 重新创建draw对象 + + # 绘制装饰图层2(副标题背景,现为独立装饰层) + if self.template.get('subtitleBackgroundEnabled', False): + bg_color = self.hex_to_rgb(self.template.get('subtitleBackgroundColor', '#000000')) + bg_opacity = self.template.get('subtitleBackgroundOpacity', 70) + bg_shape = self.template.get('subtitleBackgroundShape', 'rectangle') + bg_size = self.template.get('subtitleBackgroundSize', {'width': 30, 'height': 10}) + bg_position = self.template.get('subtitleBackgroundPosition', {'x': 50, 'y': 60}) + bg_radius = self.template.get('subtitleBackgroundRadius', 10) + bg_points = self.template.get('subtitleBackgroundPoints', []) + subtitle_background_rotation = self.template.get('subtitleBackgroundRotation', 0) + + bg_alpha = int(255 * bg_opacity / 100) + + print(f"Subtitle background: color={bg_color}, opacity={bg_opacity}%, alpha={bg_alpha}, shape={bg_shape}", file=sys.stderr) + # 🔧 调试:输出副标题多边形点数据 + print(f"[DEBUG] subtitleBackgroundPoints: {bg_points}, type: {type(bg_points)}, len: {len(bg_points) if isinstance(bg_points, (list, tuple)) else 'N/A'}", file=sys.stderr) + + # 创建单独的图层来绘制背景(确保透明度正确) + bg_layer = Image.new('RGBA', result.size, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg_layer) + + # 使用独立的位置和大小参数(百分比转像素) + bg_width = int(width * bg_size['width'] / 100) + bg_height = int(height * bg_size['height'] / 100) + bg_center_x = int(width * bg_position['x'] / 100) + bg_center_y = int(height * bg_position['y'] / 100) + + bg_left = bg_center_x - bg_width // 2 + bg_top = bg_center_y - bg_height // 2 + bg_right = bg_center_x + bg_width // 2 + bg_bottom = bg_center_y + bg_height // 2 + + # 🔧 调试:记录每个条件的结果 + print(f"[DEBUG-SUB] Shape check: bg_shape='{bg_shape}' (type: {type(bg_shape).__name__})", file=sys.stderr) + print(f"[DEBUG-SUB] Points check: bg_points={bg_points}, bool(bg_points)={bool(bg_points)}", file=sys.stderr) + print(f"[DEBUG-SUB] Polygon condition: bg_shape=='polygon'={bg_shape == 'polygon'}, bg_points truthy={bool(bg_points)}", file=sys.stderr) + + if bg_shape == 'rectangle': + # 矩形背景 + print(f"[DEBUG-SUB] Drawing RECTANGLE background", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + elif bg_shape == 'rounded': + # 圆角矩形背景 + print(f"[DEBUG-SUB] Drawing ROUNDED RECTANGLE background", file=sys.stderr) + bg_draw.rounded_rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + radius=bg_radius, + fill=(*bg_color, bg_alpha) + ) + elif bg_shape == 'polygon' and bg_points: + # 自定义多边形背景 + print(f"[DEBUG-SUB] Drawing POLYGON background with {len(bg_points)} points", file=sys.stderr) + polygon_points = [] + for i, point in enumerate(bg_points): + try: + px = bg_center_x + int(point['x'] * bg_width / 100) + py = bg_center_y + int(point['y'] * bg_height / 100) + polygon_points.append((px, py)) + print(f"[DEBUG-SUB] Point {i}: {point} -> ({px}, {py})", file=sys.stderr) + except Exception as e: + print(f"[DEBUG-SUB] Error processing point {i}: {point}, error: {e}", file=sys.stderr) + + if len(polygon_points) >= 3: + print(f"[DEBUG-SUB] Drawing polygon with {len(polygon_points)} points: {polygon_points}", file=sys.stderr) + bg_draw.polygon(polygon_points, fill=(*bg_color, bg_alpha)) + else: + print(f"[DEBUG-SUB] Not enough points for polygon (need >= 3, got {len(polygon_points)}). Falling back to rectangle.", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + else: + # 默认使用矩形 + print(f"[DEBUG-SUB] Drawing DEFAULT RECTANGLE (didn't match any shape type)", file=sys.stderr) + bg_draw.rectangle( + [bg_left, bg_top, bg_right, bg_bottom], + fill=(*bg_color, bg_alpha) + ) + + # ✅ 如果需要旋转,旋转背景图层 + if subtitle_background_rotation != 0: + print(f"Rotating subtitle background by {subtitle_background_rotation}°", file=sys.stderr) + bg_layer = bg_layer.rotate(-subtitle_background_rotation, resample=Image.BICUBIC, expand=False, fillcolor=(0, 0, 0, 0)) + + # 使用alpha_composite合成背景层 + result = Image.alpha_composite(result, bg_layer) + draw = ImageDraw.Draw(result) # 重新创建draw对象 + + # ✅ 修复:直接在原始图层上绘制,不使用临时图层 + # 这样可以确保坐标计算和前端Canvas完全一致 + print(f"Drawing title text directly on original layer (no temp layer)", file=sys.stderr) + title_draw = draw + + # 绘制标题文字(带描边),使用anchor='mm'实现中心对齐 + # ✅ 新增:检查是否使用titles参数(支持direction、charSpacing、lineSpacing、maxLength) + titles_config = self.template.get('titles') + + # 📋 调试日志:显示 titles_config 的完整信息 + print(f"[DEBUG-TITLES] titles_config exists: {titles_config is not None}", file=sys.stderr) + if titles_config and 'main' in titles_config: + print(f"[DEBUG-TITLES] titles.main exists", file=sys.stderr) + print(f"[DEBUG-TITLES] titles.main keys: {list(titles_config['main'].keys())}", file=sys.stderr) + print(f"[DEBUG-TITLES] titles.main.maxLength: {titles_config['main'].get('maxLength')}", file=sys.stderr) + else: + print(f"[DEBUG-TITLES] titles.main does NOT exist, will use defaults or titleMaxCharsPerLine", file=sys.stderr) + print(f"[DEBUG-TITLES] template.titleMaxCharsPerLine: {self.template.get('titleMaxCharsPerLine')}", file=sys.stderr) + + # ✅ 修复:总是使用图层渲染方式(支持旋转),即使没有titles配置 + # 从顶级模板读取direction等参数,确保走新逻辑分支 + if not titles_config or 'main' not in titles_config: + # 创建默认的titles配置,从顶级模板读取参数 + titles_config = { + 'main': { + 'direction': self.template.get('titleDirection', 'horizontal'), + 'charSpacing': self.template.get('titleCharSpacing'), + 'lineSpacing': self.template.get('titleLineSpacing'), + 'maxLength': self.template.get('titleMaxCharsPerLine', 10), + 'rotation': self.template.get('titleRotation', 0), + 'backgroundRotation': self.template.get('titleBackgroundRotation', 0), + } + } + + if titles_config and 'main' in titles_config: + # 使用titles参数配置绘制主标题(支持横竖排、字符间距、行间距、自动折行) + print(f"[advanced_cover_generator] Using titles.main config for title rendering", file=sys.stderr) + main_config = titles_config['main'] + + # ✅ 修复:优先从 titles.main 读取字体大小,确保与前端一致 + font_size_from_config = main_config.get('fontSize', font_size) + if font_size_from_config != font_size: + print(f" [INFO] Using fontSize from titles.main: {font_size_from_config} (was {font_size})", file=sys.stderr) + font_size = font_size_from_config + # 重新加载字体 + try: + font = ImageFont.truetype(font_path, font_size) + except: + pass + + # 获取排版参数 - 优先使用 titles.main 中的值,否则使用顶级配置,最后使用默认值 + direction = main_config.get('direction', self.template.get('titleDirection', 'horizontal')) + + # ✅ 修复:优先使用 titles.main 中的间距值,然后是顶级配置,最后是默认值 + char_spacing = main_config.get('charSpacing') + if char_spacing is None: + char_spacing = self.template.get('titleCharSpacing') + if char_spacing is None: + char_spacing = int(font_size * 0.2) + + line_spacing = main_config.get('lineSpacing') + if line_spacing is None: + line_spacing = self.template.get('titleLineSpacing') + if line_spacing is None: + line_spacing = int(font_size * 1.2) + + max_chars_per_line = main_config.get('maxLength', self.template.get('titleMaxCharsPerLine', 10)) + + # 📋 调试日志:显示 maxLength 的实际值和来源 + print(f"[DEBUG-MAXLENGTH] main_config.get('maxLength'): {main_config.get('maxLength')}", file=sys.stderr) + print(f"[DEBUG-MAXLENGTH] self.template.get('titleMaxCharsPerLine'): {self.template.get('titleMaxCharsPerLine')}", file=sys.stderr) + print(f"[DEBUG-MAXLENGTH] Final max_chars_per_line: {max_chars_per_line}", file=sys.stderr) + + # ✅ 新增:获取阴影参数 + shadow_enabled = main_config.get('shadowEnabled', self.template.get('titleShadowEnabled', False)) + shadow_color = self.hex_to_rgb(main_config.get('shadowColor', self.template.get('titleShadowColor', '#000000'))) + shadow_offset_x = main_config.get('shadowOffsetX', self.template.get('titleShadowOffsetX', 0)) + shadow_offset_y = main_config.get('shadowOffsetY', self.template.get('titleShadowOffsetY', 0)) + shadow_blur = main_config.get('shadowBlur', self.template.get('titleShadowBlur', 0)) + + # ✅ 新增:获取多层阴影参数 + shadow_layers = main_config.get('shadowLayers', self.template.get('titleShadowLayers', [])) + print(f" 📦 Shadow layers: {len(shadow_layers)} layer(s)", file=sys.stderr) + if shadow_layers: + for i, layer in enumerate(shadow_layers): + if layer.get('enabled', True): + print(f" Layer {i}: offset=({layer.get('offsetX', 0)}, {layer.get('offsetY', 0)}), blur={layer.get('blur', 0)}, color={layer.get('color', '#000000')}, opacity={layer.get('opacity', 100)}", file=sys.stderr) + rotation_angle = main_config.get('rotation', self.template.get('titleRotation', 0)) + title_background_rotation = main_config.get('backgroundRotation', self.template.get('titleBackgroundRotation', 0)) + + # ✅ 修复旋转坐标问题:根据旋转角度使用不同的变换 + # 将角度归一化到0-360范围 + normalized_angle = rotation_angle % 360 + + if 45 <= normalized_angle < 135: + # ~90度旋转:X=Y, Y=100-X + text_x = int(width * text_y_from_config / 100) + text_y = int(height * (100 - text_x_from_config) / 100) + print(f"[ROTATION 90°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({text_y_from_config}%, {100-text_x_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr) + elif 135 <= normalized_angle < 225: + # ~180度旋转:X=100-X, Y=100-Y + text_x = int(width * (100 - text_x_from_config) / 100) + text_y = int(height * (100 - text_y_from_config) / 100) + print(f"[ROTATION 180°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({100-text_x_from_config}%, {100-text_y_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr) + elif 225 <= normalized_angle < 315: + # ~270度旋转:X=100-Y, Y=X + text_x = int(width * (100 - text_y_from_config) / 100) + text_y = int(height * text_x_from_config / 100) + print(f"[ROTATION 270°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({100-text_y_from_config}%, {text_x_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr) + else: + # ~0度或360度:不变换 + text_x = int(width * text_x_from_config / 100) + text_y = int(height * text_y_from_config / 100) + print(f"[NO ROTATION] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr) + + # ✅ 新增:计算文字实际高度并调整Y坐标,与前端getElementStyleWithSize保持一致 + # 不限制范围,允许文字超出边界 + try: + bbox = draw.textbbox((text_x, text_y), title_text, font=font, anchor='mm') + text_height = bbox[3] - bbox[1] + # 调整Y坐标:top = centerY - height/2(与前端一致) + text_y = text_y - text_height // 2 + print(f"[TITLE HEIGHT ADJUST] text_height={text_height}, adjusted text_y={text_y}", file=sys.stderr) + except Exception as e: + print(f"[TITLE HEIGHT ADJUST] Warning: {e}", file=sys.stderr) + + print(f"[DEBUG] Main title config:", file=sys.stderr) + print(f" Text: '{title_text}' (length: {len(title_text)})", file=sys.stderr) + print(f" maxLength: {max_chars_per_line}", file=sys.stderr) + print(f" Direction: {direction}", file=sys.stderr) + print(f" Char spacing: {char_spacing}px, Line spacing: {line_spacing}px", file=sys.stderr) + print(f" Position: ({text_x}, {text_y})", file=sys.stderr) + print(f" ⚠️ Shadow params: color={shadow_color}, offsetX={shadow_offset_x}, offsetY={shadow_offset_y}, blur={shadow_blur}", file=sys.stderr) + print(f" 🔄 Rotation angle: {rotation_angle}°", file=sys.stderr) + + # 将文字分行 + lines = [] + for i in range(0, len(title_text), max_chars_per_line): + lines.append(title_text[i:i + max_chars_per_line]) + + print(f" Split into {len(lines)} lines: {lines}", file=sys.stderr) + + if direction == 'vertical': + # ✅ 修复:与前端一致,竖排从左到右排列,每列从上到下 + total_width = (len(lines) - 1) * line_spacing + font_size + max_col_chars = max(len(line) for line in lines) if lines else 1 + total_height = (max_col_chars - 1) * (font_size + char_spacing) + font_size + + # ✅ 修复:计算起始位置(整体居中) + # 前端:left = centerX - totalWidth/2,第一列colX=0 + # 所以第一列中心 = centerX - totalWidth/2 + fontSize/2 + start_x = text_x - total_width // 2 + start_y = text_y - total_height // 2 + print(f" [Vertical] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr) + + # ✅ 计算padding:确保文字+阴影+模糊完全在图层内 + # 基础padding:考虑负坐标和超出边界 + base_padding = max( + abs(min(0, start_x)), + abs(min(0, start_y)), + max(0, start_x + total_width - width), + max(0, start_y + total_height - height), + shadow_blur * 3, + abs(shadow_offset_x), + abs(shadow_offset_y) + ) + # 旋转padding:旋转会扩大边界框,预留足够空间 + # 对角线长度作为旋转后的最大可能尺寸 + diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2) + rotation_padding = diagonal if rotation_angle != 0 else 0 + + padding = max(base_padding, rotation_padding) + 100 # 额外安全边距 + + expanded_width = width + padding * 2 + expanded_height = height + padding * 2 + print(f" [Vertical Title] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr) + + # ✅ 步骤1: 处理多层阴影或单层阴影 + has_shadow = shadow_enabled and (shadow_blur > 0 or shadow_offset_x != 0 or shadow_offset_y != 0) + has_multiple_shadows = len(shadow_layers) > 0 and any(layer.get('enabled', True) for layer in shadow_layers) + + if has_multiple_shadows or has_shadow: + print(f" [Vertical Title] Creating shadow layer(s): {len([l for l in shadow_layers if l.get('enabled', True)])} multi-layer + single={has_shadow}", file=sys.stderr) + shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + + # ✅ 首先绘制多层阴影(如果有) + if has_multiple_shadows: + for layer_idx, layer in enumerate(shadow_layers): + if layer.get('enabled', True): + layer_color = self.hex_to_rgb(layer.get('color', '#000000')) + layer_opacity = int(255 * (layer.get('opacity', 100) / 100)) + layer_offset_x = layer.get('offsetX', 0) + layer_offset_y = layer.get('offsetY', 0) + layer_blur = layer.get('blur', 0) + + shadow_draw = ImageDraw.Draw(shadow_layer) + + # 为这一层绘制文字 + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_x = col_x + shadow_x = char_x + layer_offset_x + shadow_y = char_y + layer_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*layer_color, layer_opacity), anchor='mm') + + # 对这一层应用模糊 + if layer_blur > 0: + print(f" [Vertical Title] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur)) + + print(f" [Vertical Title] Shadow layer {layer_idx} composited", file=sys.stderr) + + # ✅ 然后绘制传统单层阴影(如果有) + if has_shadow: + shadow_draw = ImageDraw.Draw(shadow_layer) + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_x = col_x + shadow_x = char_x + shadow_offset_x + shadow_y = char_y + shadow_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 160), anchor='mm') + + # 应用真正的高斯模糊(如果blur > 0) + if shadow_blur > 0: + print(f" [Vertical Title] Applying GaussianBlur with radius={shadow_blur}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur)) + + # ✅ 旋转扩展图层并裁剪回原始尺寸 + if rotation_angle != 0: + print(f" [Vertical Title] Rotating shadow layer by {rotation_angle}°", file=sys.stderr) + shadow_layer = shadow_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (shadow_layer.width - width) // 2 + crop_y = (shadow_layer.height - height) // 2 + shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, shadow_layer) + else: + shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, shadow_layer) + title_draw = ImageDraw.Draw(result) # 重新创建draw对象 + print(f" [Vertical Title] Shadow layer composited", file=sys.stderr) + + # ✅ 步骤2: 在扩展图层绘制描边和主文字 + print(f" [Vertical Title] Creating text layer", file=sys.stderr) + text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + text_draw = ImageDraw.Draw(text_layer) + + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_x = col_x + + # 绘制描边 + if stroke_width > 0: + for offset_x in range(-stroke_width, stroke_width + 1): + for offset_y in range(-stroke_width, stroke_width + 1): + if offset_x == 0 and offset_y == 0: + continue + text_draw.text((char_x + offset_x, char_y + offset_y), + char, font=font, fill=(*stroke_color, 255), anchor='mm') + + # 绘制主体 + text_draw.text((char_x, char_y), char, font=font, fill=(*text_color, 255), anchor='mm') + + # ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸 + if rotation_angle != 0: + print(f" [Vertical Title] Rotating text layer by {rotation_angle}°", file=sys.stderr) + text_layer = text_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (text_layer.width - width) // 2 + crop_y = (text_layer.height - height) // 2 + text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, text_layer) + else: + text_layer = text_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, text_layer) + title_draw = ImageDraw.Draw(result) # 重新创建draw对象 + print(f" [Vertical Title] Text layer composited", file=sys.stderr) + else: + # 横排:从左到右,从上到下(默认) + # ✅ 修复:与前端一致,行间距 = font_size + line_spacing + total_height = (len(lines) - 1) * (font_size + line_spacing) + font_size + + # ✅ 修复:计算最长行的宽度,所有行都基于此宽度左对齐(与前端一致) + max_line_chars = max(len(line) for line in lines) if lines else 1 + total_width = (max_line_chars - 1) * (font_size + char_spacing) + font_size + + # 计算整体起始位置(基于最长行居中,然后所有行左对齐) + start_x = text_x - total_width // 2 + start_y = text_y - total_height // 2 + print(f" [Horizontal] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr) + + # ✅ 计算padding:确保文字+阴影+模糊完全在图层内 + base_padding = max( + abs(min(0, start_x)), + abs(min(0, start_y)), + max(0, start_x + total_width - width), + max(0, start_y + total_height - height), + shadow_blur * 3, + abs(shadow_offset_x), + abs(shadow_offset_y) + ) + diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2) + rotation_padding = diagonal if rotation_angle != 0 else 0 + padding = max(base_padding, rotation_padding) + 100 + + expanded_width = width + padding * 2 + expanded_height = height + padding * 2 + print(f" [Horizontal Title] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr) + + # ✅ 步骤1: 处理多层阴影或单层阴影 + has_shadow = shadow_enabled and (shadow_blur > 0 or shadow_offset_x != 0 or shadow_offset_y != 0) + has_multiple_shadows = len(shadow_layers) > 0 and any(layer.get('enabled', True) for layer in shadow_layers) + + if has_multiple_shadows or has_shadow: + print(f" [Horizontal Title] Creating shadow layer(s): {len([l for l in shadow_layers if l.get('enabled', True)])} multi-layer + single={has_shadow}", file=sys.stderr) + shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + + # ✅ 首先绘制多层阴影(如果有) + if has_multiple_shadows: + for layer_idx, layer in enumerate(shadow_layers): + if layer.get('enabled', True): + layer_color = self.hex_to_rgb(layer.get('color', '#000000')) + layer_opacity = int(255 * (layer.get('opacity', 100) / 100)) + layer_offset_x = layer.get('offsetX', 0) + layer_offset_y = layer.get('offsetY', 0) + layer_blur = layer.get('blur', 0) + + shadow_draw = ImageDraw.Draw(shadow_layer) + + # 为这一层绘制文字 + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2 + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_y = line_y + shadow_x = char_x + layer_offset_x + shadow_y = char_y + layer_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*layer_color, layer_opacity), anchor='mm') + + # 对这一层应用模糊 + if layer_blur > 0: + print(f" [Horizontal Title] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur)) + + print(f" [Horizontal Title] Shadow layer {layer_idx} composited", file=sys.stderr) + + # ✅ 然后绘制传统单层阴影(如果有) + if has_shadow: + shadow_draw = ImageDraw.Draw(shadow_layer) + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2 + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_y = line_y + shadow_x = char_x + shadow_offset_x + shadow_y = char_y + shadow_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 160), anchor='mm') + + # 应用真正的高斯模糊(如果blur > 0) + if shadow_blur > 0: + print(f" [Horizontal Title] Applying GaussianBlur with radius={shadow_blur}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur)) + + # ✅ 旋转扩展图层并裁剪回原始尺寸 + if rotation_angle != 0: + print(f" [Horizontal Title] Rotating shadow layer by {rotation_angle}°", file=sys.stderr) + shadow_layer = shadow_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (shadow_layer.width - width) // 2 + crop_y = (shadow_layer.height - height) // 2 + shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, shadow_layer) + else: + shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, shadow_layer) + title_draw = ImageDraw.Draw(result) # 重新创建draw对象 + print(f" [Horizontal Title] Shadow layer composited", file=sys.stderr) + + # ✅ 步骤2: 在扩展图层绘制描边和主文字 + print(f" [Horizontal Title] Creating text layer", file=sys.stderr) + text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + text_draw = ImageDraw.Draw(text_layer) + + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2 + + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2 + char_y = line_y + + # 绘制描边 + if stroke_width > 0: + for offset_x in range(-stroke_width, stroke_width + 1): + for offset_y in range(-stroke_width, stroke_width + 1): + if offset_x == 0 and offset_y == 0: + continue + text_draw.text((char_x + offset_x, char_y + offset_y), + char, font=font, fill=(*stroke_color, 255), anchor='mm') + + # 绘制主体 + text_draw.text((char_x, char_y), char, font=font, fill=(*text_color, 255), anchor='mm') + + # ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸 + if rotation_angle != 0: + print(f" [Horizontal Title] Rotating text layer by {rotation_angle}°", file=sys.stderr) + text_layer = text_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (text_layer.width - width) // 2 + crop_y = (text_layer.height - height) // 2 + text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, text_layer) + else: + text_layer = text_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, text_layer) + title_draw = ImageDraw.Draw(result) # 重新创建draw对象 + print(f" [Horizontal Title] Text layer composited", file=sys.stderr) + elif '\n' in title_text: + # 多行文字渲染(原有逻辑) + if stroke_width > 0: + title_draw.multiline_text( + (text_x, text_y), # 直接使用text_x, text_y + title_text, + font=font, + fill=(*text_color, 255), + align='center', + anchor='mm', + stroke_width=stroke_width, + stroke_fill=(*stroke_color, 255) + ) + else: + title_draw.multiline_text( + (text_x, text_y), # 直接使用text_x, text_y + title_text, + font=font, + fill=(*text_color, 255), + align='center', + anchor='mm' + ) + else: + # 单行文字渲染(原有逻辑) + if stroke_width > 0: + title_draw.text( + (text_x, text_y), # 直接使用text_x, text_y + title_text, + font=font, + fill=(*text_color, 255), + anchor='mm', + stroke_width=stroke_width, + stroke_fill=(*stroke_color, 255) + ) + else: + title_draw.text( + (text_x, text_y), # 直接使用text_x, text_y + title_text, + font=font, + fill=(*text_color, 255), + anchor='mm' + ) + + # 绘制副标题(如果有) + if subtitle_text: + print(f"Adding subtitle text: {subtitle_text}", file=sys.stderr) + + # 获取副标题参数 + subtitle_font_family = self.template.get('subtitleFontFamily', 'SimHei') + subtitle_font_size = self.template.get('subtitleFontSize', 60) + subtitle_font_weight = self.template.get('subtitleFontWeight', 500) + subtitle_text_color = self.hex_to_rgb(self.template.get('subtitleColor', '#FFFFFF')) + subtitle_stroke_color = self.hex_to_rgb(self.template.get('subtitleStrokeColor', '#000000')) + subtitle_stroke_width = self.template.get('subtitleStrokeWidth', 1) + + # 修复:处理副标题位置参数 + subtitle_position = self.template.get('subtitlePosition', {'x': 50, 'y': 90}) + + # 如果是字符串(旧格式),转换为字典 + if isinstance(subtitle_position, str): + position_map = { + 'top': {'x': 50, 'y': 30}, + 'center': {'x': 50, 'y': 50}, + 'bottom': {'x': 50, 'y': 90} + } + subtitle_position = position_map.get(subtitle_position, {'x': 50, 'y': 90}) + print(f"Converted subtitle position from string to dict: {subtitle_position}", file=sys.stderr) + + # 加载副标题字体 + subtitle_font_path = self._get_font_path(subtitle_font_family) + try: + subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) + except: + print(f"Warning: Failed to load subtitle font {subtitle_font_path}, using default", file=sys.stderr) + subtitle_font = ImageFont.load_default() + + # 计算副标题中心点位置(百分比转像素) + # ✅ 修复旋转坐标问题:有旋转时Y轴需要反转,无旋转时不需要 + subtitle_x = int(width * subtitle_position['x'] / 100) + # 注意:这里还没有读取到rotation_angle,先使用正常计算,后面会根据rotation调整 + subtitle_y_from_config = subtitle_position['y'] + subtitle_y = int(height * subtitle_y_from_config / 100) + + print(f"Rendering subtitle, initial center=({subtitle_x},{subtitle_y}), text='{subtitle_text}' (from {subtitle_position['x']}%, {subtitle_y_from_config}%)", file=sys.stderr) + + # ✅ 直接在原图上绘制副标题(不使用临时图层) + print(f"Drawing subtitle directly on original image at ({subtitle_x},{subtitle_y})", file=sys.stderr) + + # 直接在原图上绘制,不使用临时图层 + subtitle_draw = draw + + # 绘制副标题(使用anchor='mm'实现中心对齐) + # ✅ 新增:检查是否使用titles.sub参数(支持direction、charSpacing、lineSpacing、maxLength) + + # ✅ 修复:总是使用图层渲染方式(支持旋转),即使没有titles配置 + if not titles_config or 'sub' not in titles_config: + # 创建默认的titles配置,从顶级模板读取参数 + if not titles_config: + titles_config = {} + titles_config['sub'] = { + 'direction': self.template.get('subtitleDirection', 'horizontal'), + 'charSpacing': self.template.get('subtitleCharSpacing'), + 'lineSpacing': self.template.get('subtitleLineSpacing'), + 'maxLength': self.template.get('subtitleMaxCharsPerLine', 15), + 'rotation': self.template.get('subtitleRotation', 0), + 'backgroundRotation': self.template.get('subtitleBackgroundRotation', 0), + } + + if titles_config and 'sub' in titles_config: + # 使用titles.sub参数配置绘制副标题(支持横竖排、字符间距、行间距、自动折行) + print(f"[advanced_cover_generator] Using titles.sub config for subtitle rendering", file=sys.stderr) + sub_config = titles_config['sub'] + + # ✅ 修复:优先从 titles.sub 读取字体大小,确保与前端一致 + subtitle_font_size_from_config = sub_config.get('fontSize', subtitle_font_size) + if subtitle_font_size_from_config != subtitle_font_size: + print(f" [INFO] Using fontSize from titles.sub: {subtitle_font_size_from_config} (was {subtitle_font_size})", file=sys.stderr) + subtitle_font_size = subtitle_font_size_from_config + # 重新加载字体 + try: + subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) + except: + pass + + # 获取排版参数 - 优先使用 titles.sub 中的值,否则使用顶级配置,最后使用默认值 + direction = sub_config.get('direction', self.template.get('subtitleDirection', 'horizontal')) + + # ✅ 修复:优先使用 titles.sub 中的间距值,然后是顶级配置,最后是默认值 + char_spacing = sub_config.get('charSpacing') + if char_spacing is None: + char_spacing = self.template.get('subtitleCharSpacing') + if char_spacing is None: + char_spacing = int(subtitle_font_size * 0.2) + + line_spacing = sub_config.get('lineSpacing') + if line_spacing is None: + line_spacing = self.template.get('subtitleLineSpacing') + if line_spacing is None: + line_spacing = int(subtitle_font_size * 1.2) + + max_chars_per_line = sub_config.get('maxLength', self.template.get('subtitleMaxCharsPerLine', 15)) + + # ✅ 新增:获取副标题阴影参数 + subtitle_shadow_enabled = sub_config.get('shadowEnabled', self.template.get('subtitleShadowEnabled', False)) + subtitle_shadow_color = self.hex_to_rgb(sub_config.get('shadowColor', self.template.get('subtitleShadowColor', '#000000'))) + subtitle_shadow_offset_x = sub_config.get('shadowOffsetX', self.template.get('subtitleShadowOffsetX', 0)) + subtitle_shadow_offset_y = sub_config.get('shadowOffsetY', self.template.get('subtitleShadowOffsetY', 0)) + subtitle_shadow_blur = sub_config.get('shadowBlur', self.template.get('subtitleShadowBlur', 0)) + subtitle_rotation_angle = sub_config.get('rotation', self.template.get('subtitleRotation', 0)) + subtitle_background_rotation = sub_config.get('backgroundRotation', self.template.get('subtitleBackgroundRotation', 0)) + + # ✅ 修复旋转坐标问题:根据旋转角度使用不同的变换(和主标题一致) + normalized_subtitle_angle = subtitle_rotation_angle % 360 + + if 45 <= normalized_subtitle_angle < 135: + # ~90度旋转 + subtitle_x = int(width * subtitle_y_from_config / 100) + subtitle_y = int(height * (100 - subtitle_position['x']) / 100) + print(f"[ROTATION 90°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({subtitle_y_from_config}%, {100-subtitle_position['x']}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr) + elif 135 <= normalized_subtitle_angle < 225: + # ~180度旋转 + subtitle_x = int(width * (100 - subtitle_position['x']) / 100) + subtitle_y = int(height * (100 - subtitle_y_from_config) / 100) + print(f"[ROTATION 180°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({100-subtitle_position['x']}%, {100-subtitle_y_from_config}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr) + elif 225 <= normalized_subtitle_angle < 315: + # ~270度旋转 + subtitle_x = int(width * (100 - subtitle_y_from_config) / 100) + subtitle_y = int(height * subtitle_position['x'] / 100) + print(f"[ROTATION 270°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({100-subtitle_y_from_config}%, {subtitle_position['x']}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr) + else: + # ~0度或360度 + print(f"[NO ROTATION] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr) + + # ✅ 新增:计算副标题实际高度并调整Y坐标,与前端getElementStyleWithSize保持一致 + # 不限制范围,允许文字超出边界 + try: + subtitle_bbox = draw.textbbox((subtitle_x, subtitle_y), subtitle_text, font=subtitle_font, anchor='mm') + subtitle_height = subtitle_bbox[3] - subtitle_bbox[1] + # 调整Y坐标:top = centerY - height/2(与前端一致) + subtitle_y = subtitle_y - subtitle_height // 2 + print(f"[SUBTITLE HEIGHT ADJUST] subtitle_height={subtitle_height}, adjusted subtitle_y={subtitle_y}", file=sys.stderr) + except Exception as e: + print(f"[SUBTITLE HEIGHT ADJUST] Warning: {e}", file=sys.stderr) + + print(f"[DEBUG] Sub title config:", file=sys.stderr) + print(f" Text: '{subtitle_text}' (length: {len(subtitle_text)})", file=sys.stderr) + print(f" maxLength: {max_chars_per_line}", file=sys.stderr) + print(f" Direction: {direction}", file=sys.stderr) + print(f" Char spacing: {char_spacing}px, Line spacing: {line_spacing}px", file=sys.stderr) + print(f" Position: ({subtitle_x}, {subtitle_y})", file=sys.stderr) + + # 将文字分行 + lines = [] + for i in range(0, len(subtitle_text), max_chars_per_line): + lines.append(subtitle_text[i:i + max_chars_per_line]) + + print(f" Split into {len(lines)} lines: {lines}", file=sys.stderr) + + if direction == 'vertical': + # ✅ 修复:与前端一致,竖排从左到右排列,每列从上到下 + total_width = (len(lines) - 1) * line_spacing + subtitle_font_size + max_col_chars = max(len(line) for line in lines) if lines else 1 + total_height = (max_col_chars - 1) * (subtitle_font_size + char_spacing) + subtitle_font_size + + # ✅ 修复:计算起始位置(整体居中),与前端一致 + start_x = subtitle_x - total_width // 2 + start_y = subtitle_y - total_height // 2 + + print(f" [Vertical] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr) + + # ✅ 计算padding:确保文字+阴影+模糊完全在图层内 + base_padding = max( + abs(min(0, start_x)), + abs(min(0, start_y)), + max(0, start_x + total_width - width), + max(0, start_y + total_height - height), + subtitle_shadow_blur * 3, + abs(subtitle_shadow_offset_x), + abs(subtitle_shadow_offset_y) + ) + diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2) + rotation_padding = diagonal if subtitle_rotation_angle != 0 else 0 + padding = max(base_padding, rotation_padding) + 100 + + expanded_width = width + padding * 2 + expanded_height = height + padding * 2 + print(f" [Vertical Subtitle] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr) + + # ✅ 步骤1: 处理多层阴影或单层阴影(与主标题逻辑一致) + subtitle_has_shadow = subtitle_shadow_enabled and (subtitle_shadow_blur > 0 or subtitle_shadow_offset_x != 0 or subtitle_shadow_offset_y != 0) + subtitle_shadow_layers = sub_config.get('shadowLayers', self.template.get('subtitleShadowLayers', [])) + subtitle_has_multiple_shadows = len(subtitle_shadow_layers) > 0 and any(layer.get('enabled', True) for layer in subtitle_shadow_layers) + + if subtitle_has_multiple_shadows or subtitle_has_shadow: + print(f" [Vertical Subtitle] Creating shadow layer(s): {len([l for l in subtitle_shadow_layers if l.get('enabled', True)])} multi-layer + single={subtitle_has_shadow}", file=sys.stderr) + shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + + # ✅ 首先绘制多层阴影(如果有) + if subtitle_has_multiple_shadows: + for layer_idx, layer in enumerate(subtitle_shadow_layers): + if layer.get('enabled', True): + layer_color = self.hex_to_rgb(layer.get('color', '#000000')) + layer_opacity = int(255 * (layer.get('opacity', 100) / 100)) + layer_offset_x = layer.get('offsetX', 0) + layer_offset_y = layer.get('offsetY', 0) + layer_blur = layer.get('blur', 0) + + shadow_draw = ImageDraw.Draw(shadow_layer) + + # 为这一层绘制文字 + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_x = col_x + shadow_x = char_x + layer_offset_x + shadow_y = char_y + layer_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*layer_color, layer_opacity), anchor='mm') + + # 对这一层应用模糊 + if layer_blur > 0: + print(f" [Vertical Subtitle] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur)) + + print(f" [Vertical Subtitle] Shadow layer {layer_idx} composited", file=sys.stderr) + + # ✅ 然后绘制传统单层阴影(如果有) + if subtitle_has_shadow: + shadow_draw_temp = ImageDraw.Draw(shadow_layer) + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_x = col_x + shadow_x = char_x + subtitle_shadow_offset_x + shadow_y = char_y + subtitle_shadow_offset_y + shadow_draw_temp.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*subtitle_shadow_color, 160), anchor='mm') + + if subtitle_shadow_blur > 0: + print(f" [Vertical Subtitle] Applying GaussianBlur with radius={subtitle_shadow_blur}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=subtitle_shadow_blur)) + + # ✅ 旋转扩展图层并裁剪回原始尺寸 + if subtitle_rotation_angle != 0: + print(f" [Vertical Subtitle] Rotating shadow layer by {subtitle_rotation_angle}°", file=sys.stderr) + shadow_layer = shadow_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (shadow_layer.width - width) // 2 + crop_y = (shadow_layer.height - height) // 2 + shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, shadow_layer) + else: + shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, shadow_layer) + subtitle_draw = ImageDraw.Draw(result) + print(f" [Vertical Subtitle] Shadow layer composited", file=sys.stderr) + + # ✅ 步骤2: 在扩展图层绘制描边和主文字 + print(f" [Vertical Subtitle] Creating text layer", file=sys.stderr) + text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + text_draw = ImageDraw.Draw(text_layer) + + for col_idx, line in enumerate(lines): + col_x = start_x + padding + col_idx * line_spacing + + for char_idx, char in enumerate(line): + char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_x = col_x + + # 绘制描边 + if subtitle_stroke_width > 0: + for offset_x in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + for offset_y in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + if offset_x == 0 and offset_y == 0: + continue + text_draw.text((char_x + offset_x, char_y + offset_y), + char, font=subtitle_font, fill=(*subtitle_stroke_color, 255), anchor='mm') + + # 绘制主体 + text_draw.text((char_x, char_y), char, font=subtitle_font, fill=(*subtitle_text_color, 255), anchor='mm') + + # ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸 + if subtitle_rotation_angle != 0: + print(f" [Vertical Subtitle] Rotating text layer by {subtitle_rotation_angle}°", file=sys.stderr) + text_layer = text_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (text_layer.width - width) // 2 + crop_y = (text_layer.height - height) // 2 + text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, text_layer) + else: + text_layer = text_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, text_layer) + subtitle_draw = ImageDraw.Draw(result) + print(f" [Vertical Subtitle] Text layer composited", file=sys.stderr) + else: + # 横排:从左到右,从上到下(默认) + # ✅ 修复:与前端一致,行间距 = subtitle_font_size + line_spacing + total_height = (len(lines) - 1) * (subtitle_font_size + line_spacing) + subtitle_font_size + + # ✅ 修复:计算最长行的宽度,所有行都基于此宽度左对齐(与前端一致) + max_line_chars = max(len(line) for line in lines) if lines else 1 + total_width = (max_line_chars - 1) * (subtitle_font_size + char_spacing) + subtitle_font_size + + # 计算整体起始位置(基于最长行居中,然后所有行左对齐) + start_x = subtitle_x - total_width // 2 + start_y = subtitle_y - total_height // 2 + + print(f" [Horizontal] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr) + + # ✅ 计算padding:确保文字+阴影+模糊完全在图层内 + base_padding = max( + abs(min(0, start_x)), + abs(min(0, start_y)), + max(0, start_x + total_width - width), + max(0, start_y + total_height - height), + subtitle_shadow_blur * 3, + abs(subtitle_shadow_offset_x), + abs(subtitle_shadow_offset_y) + ) + diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2) + rotation_padding = diagonal if subtitle_rotation_angle != 0 else 0 + padding = max(base_padding, rotation_padding) + 100 + + expanded_width = width + padding * 2 + expanded_height = height + padding * 2 + print(f" [Horizontal Subtitle] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr) + + # ✅ 步骤1: 处理多层阴影或单层阴影(与主标题逻辑一致) + subtitle_has_shadow = subtitle_shadow_enabled and (subtitle_shadow_blur > 0 or subtitle_shadow_offset_x != 0 or subtitle_shadow_offset_y != 0) + subtitle_shadow_layers = sub_config.get('shadowLayers', self.template.get('subtitleShadowLayers', [])) + subtitle_has_multiple_shadows = len(subtitle_shadow_layers) > 0 and any(layer.get('enabled', True) for layer in subtitle_shadow_layers) + + if subtitle_has_multiple_shadows or subtitle_has_shadow: + print(f" [Horizontal Subtitle] Creating shadow layer(s): {len([l for l in subtitle_shadow_layers if l.get('enabled', True)])} multi-layer + single={subtitle_has_shadow}", file=sys.stderr) + shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + + # ✅ 首先绘制多层阴影(如果有) + if subtitle_has_multiple_shadows: + for layer_idx, layer in enumerate(subtitle_shadow_layers): + if layer.get('enabled', True): + layer_color = self.hex_to_rgb(layer.get('color', '#000000')) + layer_opacity = int(255 * (layer.get('opacity', 100) / 100)) + layer_offset_x = layer.get('offsetX', 0) + layer_offset_y = layer.get('offsetY', 0) + layer_blur = layer.get('blur', 0) + + shadow_draw = ImageDraw.Draw(shadow_layer) + + # 为这一层绘制文字 + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2 + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_y = line_y + shadow_x = char_x + layer_offset_x + shadow_y = char_y + layer_offset_y + shadow_draw.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*layer_color, layer_opacity), anchor='mm') + + # 对这一层应用模糊 + if layer_blur > 0: + print(f" [Horizontal Subtitle] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur)) + + print(f" [Horizontal Subtitle] Shadow layer {layer_idx} composited", file=sys.stderr) + + # ✅ 然后绘制传统单层阴影(如果有) + if subtitle_has_shadow: + shadow_draw_temp = ImageDraw.Draw(shadow_layer) + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2 + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_y = line_y + shadow_x = char_x + subtitle_shadow_offset_x + shadow_y = char_y + subtitle_shadow_offset_y + shadow_draw_temp.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*subtitle_shadow_color, 160), anchor='mm') + + if subtitle_shadow_blur > 0: + print(f" [Horizontal Subtitle] Applying GaussianBlur with radius={subtitle_shadow_blur}", file=sys.stderr) + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=subtitle_shadow_blur)) + + # ✅ 旋转扩展图层并裁剪回原始尺寸 + if subtitle_rotation_angle != 0: + print(f" [Horizontal Subtitle] Rotating shadow layer by {subtitle_rotation_angle}°", file=sys.stderr) + shadow_layer = shadow_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + # 裁剪回原始尺寸:计算中心位置 + crop_x = (shadow_layer.width - width) // 2 + crop_y = (shadow_layer.height - height) // 2 + shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, shadow_layer) + else: + # 裁剪掉padding + shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, shadow_layer) + subtitle_draw = ImageDraw.Draw(result) + print(f" [Horizontal Subtitle] Shadow layer composited", file=sys.stderr) + + # ✅ 步骤2: 在扩展图层绘制描边和主文字 + print(f" [Horizontal Subtitle] Creating text layer", file=sys.stderr) + text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + text_draw = ImageDraw.Draw(text_layer) + + for row_idx, line in enumerate(lines): + line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2 + + for char_idx, char in enumerate(line): + char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2 + char_y = line_y + + # 绘制描边 + if subtitle_stroke_width > 0: + for offset_x in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + for offset_y in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + if offset_x == 0 and offset_y == 0: + continue + text_draw.text((char_x + offset_x, char_y + offset_y), + char, font=subtitle_font, fill=(*subtitle_stroke_color, 255), anchor='mm') + + # 绘制主体 + text_draw.text((char_x, char_y), char, font=subtitle_font, fill=(*subtitle_text_color, 255), anchor='mm') + + # ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸 + if subtitle_rotation_angle != 0: + print(f" [Horizontal Subtitle] Rotating text layer by {subtitle_rotation_angle}°", file=sys.stderr) + text_layer = text_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0)) + crop_x = (text_layer.width - width) // 2 + crop_y = (text_layer.height - height) // 2 + text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height)) + result = Image.alpha_composite(result, text_layer) + else: + # 裁剪掉padding + text_layer = text_layer.crop((padding, padding, padding + width, padding + height)) + result = Image.alpha_composite(result, text_layer) + subtitle_draw = ImageDraw.Draw(result) + print(f" [Horizontal Subtitle] Text layer composited", file=sys.stderr) + elif subtitle_stroke_width > 0: + # 原有逻辑:带描边(回退到简单multiline_text) + subtitle_draw.text( + (subtitle_x, subtitle_y), + subtitle_text, + font=subtitle_font, + fill=(*subtitle_text_color, 255), + anchor='mm', + stroke_width=subtitle_stroke_width, + stroke_fill=(*subtitle_stroke_color, 255) + ) + else: + # 原有逻辑:不带描边(回退到简单multiline_text) + subtitle_draw.text( + (subtitle_x, subtitle_y), + subtitle_text, + font=subtitle_font, + fill=(*subtitle_text_color, 255), + anchor='mm' + ) + + # 保存预览(保持RGBA模式) + self.save_preview(result, "text_added") + + return result + + + def _get_font_path(self, font_family: str) -> str: + """ + 获取字体文件路径(优先使用字体管理器,支持模糊匹配ziti字体,回退到系统字体) + + Args: + font_family: 字体家族名称 + + Returns: + 字体文件路径 + """ + if not font_family: + return 'C:\\Windows\\Fonts\\msyh.ttc' + + # 优先使用字体管理器(支持动态扫描ziti目录和系统字体) + try: + from modules.font_manager import get_font_manager + font_manager = get_font_manager() + font_path = font_manager.find_font(font_family, 400) # 默认weight为400 + if font_path and os.path.exists(font_path): + print(f"Found font via font_manager: {font_family} -> {font_path}", file=sys.stderr) + return font_path + except Exception as e: + print(f"Warning: Failed to use font_manager: {e}, trying ziti directory scan", file=sys.stderr) + + # 🔧 修复:支持模糊匹配ziti目录中的字体(就像subtitle_cover_generator_simple.py) + # 获取APP_ROOT环境变量以支持ASAR打包环境 + app_root = os.environ.get('APP_ROOT', None) + + possible_ziti_paths = [] + if app_root: + possible_ziti_paths.append(os.path.join(app_root, 'resources-bundles', 'ziti')) + possible_ziti_paths.append(os.path.join(app_root, 'app.asar.unpacked', 'ziti')) + + # 添加开发环境路径 + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + possible_ziti_paths.extend([ + os.path.join(project_root, 'ziti'), + os.path.join(os.getcwd(), 'ziti'), + ]) + + # 在ziti目录中查找字体(支持模糊匹配) + for ziti_dir in possible_ziti_paths: + if os.path.isdir(ziti_dir): + print(f"Scanning ziti directory: {ziti_dir}", file=sys.stderr) + try: + for file in os.listdir(ziti_dir): + if not file.startswith('.') and (file.endswith('.ttf') or file.endswith('.otf') or file.endswith('.ttc')): + file_base = os.path.splitext(file)[0] + # 检查是否匹配(精确或包含) + if file_base == font_family or font_family in file_base or file_base in font_family: + font_path = os.path.join(ziti_dir, file) + print(f"Found ziti font by fuzzy match: {font_family} -> {font_path}", file=sys.stderr) + return font_path + except Exception as e: + print(f"Error scanning ziti directory {ziti_dir}: {e}", file=sys.stderr) + + # 回退到硬编码映射表 + ziti_dir = os.path.join(project_root, 'ziti') + + font_map = { + # 系统字体 + 'SimHei': 'C:\\Windows\\Fonts\\simhei.ttf', + 'SimSun': 'C:\\Windows\\Fonts\\simsun.ttc', + 'Microsoft YaHei': 'C:\\Windows\\Fonts\\msyh.ttc', + 'Arial': 'C:\\Windows\\Fonts\\arial.ttf', + 'Times New Roman': 'C:\\Windows\\Fonts\\times.ttf', + + # 自定义字体 - 中文字体 + 'Maoken Assorted Sans': os.path.join(ziti_dir, 'MaokenAssortedSans.ttf'), + 'MaokenAssortedSans': os.path.join(ziti_dir, 'MaokenAssortedSans.ttf'), + 'Maoken Assorted Sans Lite': os.path.join(ziti_dir, 'MaokenAssortedSans-Lite.ttf'), + 'MaokenAssortedSans-Lite': os.path.join(ziti_dir, 'MaokenAssortedSans-Lite.ttf'), + 'Mo Qu Gu Feng Ti': os.path.join(ziti_dir, '墨趣古风体.ttf'), + '墨趣古风体': os.path.join(ziti_dir, '墨趣古风体.ttf'), + 'PingFang Zhang Yaling Hei': os.path.join(ziti_dir, '平方张亚玲黑方体.ttf'), + '平方张亚玲黑方体': os.path.join(ziti_dir, '平方张亚玲黑方体.ttf'), + 'Hu Xiaobo Sao Bao Ti': os.path.join(ziti_dir, '胡晓波骚包体2.0.otf'), + '胡晓波骚包体': os.path.join(ziti_dir, '胡晓波骚包体2.0.otf'), + '胡晓波骚包体2.0': os.path.join(ziti_dir, '胡晓波骚包体2.0.otf'), + + # 自定义字体 - 手写体 + 'Dymon ShouXieTi': os.path.join(ziti_dir, 'Dymon-ShouXieTi.otf'), + 'Dymon-ShouXieTi': os.path.join(ziti_dir, 'Dymon-ShouXieTi.otf'), + 'Dymon手写体': os.path.join(ziti_dir, 'Dymon-ShouXieTi.otf'), + + # 自定义字体 - 日文字体 + 'Murecho': os.path.join(ziti_dir, 'Murecho-Regular.ttf'), + 'Murecho-Regular': os.path.join(ziti_dir, 'Murecho-Regular.ttf'), + 'Murecho-Black': os.path.join(ziti_dir, 'Murecho-Black.ttf'), + 'Murecho-Bold': os.path.join(ziti_dir, 'Murecho-Bold.ttf'), + 'Keinan Maru Pop JP': os.path.join(ziti_dir, 'けいなん丸ポップ体JP.ttf'), + 'けいなん丸ポップ体': os.path.join(ziti_dir, 'けいなん丸ポップ体JP.ttf'), + } + + font_path = font_map.get(font_family) + + # 如果映射表中没有,尝试在ziti目录中查找(模糊匹配) + if not font_path and os.path.exists(ziti_dir): + import glob + font_extensions = ['.ttf', '.otf', '.ttc'] + for ext in font_extensions: + # 尝试精确匹配文件名 + possible_path = os.path.join(ziti_dir, f"{font_family}{ext}") + if os.path.exists(possible_path): + font_path = possible_path + break + # 尝试模糊匹配(忽略大小写和空格) + pattern = os.path.join(ziti_dir, f"*{font_family.replace(' ', '*')}*{ext}") + matches = glob.glob(pattern, recursive=False) + if matches: + font_path = matches[0] + break + + # 如果还是找不到,使用默认字体 + if not font_path or not os.path.exists(font_path): + print(f"Warning: Font file not found for '{font_family}', using fallback", file=sys.stderr) + if font_path: + print(f" Attempted path: {font_path}", file=sys.stderr) + return 'C:\\Windows\\Fonts\\msyh.ttc' + + return font_path + + # ✅ 标准封面尺寸 3:4 比例(与前端 CoverCustomEditor.vue 一致) + COVER_WIDTH = 1080 + COVER_HEIGHT = 1440 # 3:4 比例 + + def _resize_to_cover_size(self, image: Image.Image) -> Image.Image: + """ + 将图片调整到标准封面尺寸 (1080x1920),保持比例并居中裁剪 + + Args: + image: 输入图像 + + Returns: + 调整后的图像 + """ + target_width = self.COVER_WIDTH + target_height = self.COVER_HEIGHT + target_ratio = target_width / target_height # 9:16 = 0.5625 + + orig_width, orig_height = image.size + orig_ratio = orig_width / orig_height + + print(f"[resize_to_cover_size] Original: {orig_width}x{orig_height} (ratio: {orig_ratio:.4f})", file=sys.stderr) + print(f"[resize_to_cover_size] Target: {target_width}x{target_height} (ratio: {target_ratio:.4f})", file=sys.stderr) + + # 计算缩放和裁剪 + if orig_ratio > target_ratio: + # 原图更宽,以高度为基准缩放,然后裁剪宽度 + new_height = target_height + new_width = int(orig_width * (target_height / orig_height)) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + # 居中裁剪 + left = (new_width - target_width) // 2 + cropped = resized.crop((left, 0, left + target_width, target_height)) + else: + # 原图更高或相等,以宽度为基准缩放,然后裁剪高度 + new_width = target_width + new_height = int(orig_height * (target_width / orig_width)) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + # 居中裁剪 + top = (new_height - target_height) // 2 + cropped = resized.crop((0, top, target_width, top + target_height)) + + print(f"[resize_to_cover_size] Result: {cropped.size}", file=sys.stderr) + return cropped + + def generate(self) -> Dict[str, Any]: + """ + 生成封面(完整流程) + + 正确流程: + 1. 人物抠图(带透明背景) + 2. 人物描边(在抠图上添加描边) + 3. 模糊背景(原图模糊处理) + 4. 合成2和3(将描边后的人物合成到模糊背景上) + 5. 添加文字和其他信息(文字背景半透明) + + Returns: + 生成结果 + """ + try: + print("=" * 80, file=sys.stderr) + print("Starting advanced cover generation", file=sys.stderr) + print(f"Target cover size: {self.COVER_WIDTH}x{self.COVER_HEIGHT}", file=sys.stderr) + print("=" * 80, file=sys.stderr) + + # 加载原始图像 + original_image = self.load_image(self.config.get('video', '')) + + # ✅ 关键修复:将图片调整到标准封面尺寸,与前端画布一致 + original_image = self._resize_to_cover_size(original_image) + + self.save_preview(original_image, "original") + + # 步骤1: 人物抠图(如果启用) + person_rgba = None + if self.template.get('personBorderEnabled', False) and SEGMENT_AVAILABLE: + print("Step 1: Extracting person from original image using PersonSegmenter", file=sys.stderr) + try: + # 使用PersonSegmenter进行抠图(支持MODNet优先级) + person_rgba = self._segment_person_with_modnet(original_image) + + if person_rgba.mode != 'RGBA': + person_rgba = person_rgba.convert('RGBA') + + # 提取 alpha 通道并优化(确保只有人物轮廓,其他部分完全透明) + alpha_channel = np.array(person_rgba.split()[3]) + print(f"Alpha channel before refinement: min={alpha_channel.min()}, max={alpha_channel.max()}, unique_values={len(np.unique(alpha_channel))}", file=sys.stderr) + + refined_alpha = self.refine_mask(alpha_channel) + print(f"Alpha channel after refinement: min={refined_alpha.min()}, max={refined_alpha.max()}, unique_values={len(np.unique(refined_alpha))}", file=sys.stderr) + + # 关键修复:确保只有人物形状是不透明的,周围完全透明 + # 使用严格的二值化,避免灰度值导致的半透明边缘扩展 + _, refined_alpha_binary = cv2.threshold(refined_alpha, 127, 255, cv2.THRESH_BINARY) + + person_rgba.putalpha(Image.fromarray(refined_alpha_binary)) + + self.save_preview(person_rgba, "step1_person_extracted") + print("Step 1: Person extraction completed with PersonSegmenter and mask refinement", file=sys.stderr) + except Exception as e: + print(f"Step 1: Person extraction failed: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + person_rgba = None + + # 步骤2: 人物描边(如果有抠图) + if person_rgba is not None: + print("Step 2: Applying person outline", file=sys.stderr) + person_rgba = self._apply_outline_to_person(person_rgba) + self.save_preview(person_rgba, "step2_person_outlined") + + # 步骤3: 模糊背景 + print("Step 3: Applying background blur", file=sys.stderr) + blurred_background = self.apply_background_blur(original_image) + + # 步骤4: 合成人物到模糊背景 + if person_rgba is not None: + print("Step 4: Compositing person onto blurred background", file=sys.stderr) + result = self._composite_person_to_background(person_rgba, blurred_background) + self.save_preview(result, "step4_person_composited") + else: + result = blurred_background.convert('RGBA') + + # 步骤5: 蒙版叠加 + result = self.apply_mask(result) + + # 步骤6: 添加文字(文字背景半透明) + result = self.add_text(result) + + # 保存最终结果(PNG格式保留透明度) + print(f"Saving final cover to: {self.output_path}", file=sys.stderr) + + # 确保输出为PNG格式以保留透明度 + if not self.output_path.lower().endswith('.png'): + print("Warning: Output format should be PNG to preserve transparency", file=sys.stderr) + + # 保存为RGBA模式的PNG + if result.mode != 'RGBA': + result = result.convert('RGBA') + + result.save(self.output_path, 'PNG', quality=95) + + print("=" * 80, file=sys.stderr) + print("Cover generation completed successfully", file=sys.stderr) + print(f"Total preview images: {len(self.preview_images)}", file=sys.stderr) + print("=" * 80, file=sys.stderr) + + return { + 'success': True, + 'coverPath': self.output_path, + 'previewImages': self.preview_images, + 'message': 'Cover generated successfully' + } + + except Exception as e: + print(f"Error generating cover: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return { + 'success': False, + 'error': str(e), + 'message': f'Cover generation failed: {str(e)}' + } + + def _apply_outline_to_person(self, person_rgba: Image.Image) -> Image.Image: + """ + 对抠出的人物应用描边 + + 正确方案:扩大画布 → 绘制完整描边 → 保持扩大后的尺寸 + + Args: + person_rgba: 抠出的人物图像(RGBA模式) + + Returns: + 添加描边后的人物图像(尺寸会比原图大) + """ + # 获取描边参数 + border_color = self.hex_to_rgb(self.template.get('personBorderColor', '#FFFFFF')) + border_width = self.template.get('personBorderWidth', 6) + border_style = self.template.get('personBorderStyle', 'solid') + + # 使用文档规定的默认值 + dash_length = self.template.get('personBorderDashLength') + gap_length = self.template.get('personBorderGapLength') + + if dash_length is None: + dash_length = border_width * 3 + if gap_length is None: + gap_length = border_width * 2 + + print(f"Outline params: color={border_color}, width={border_width}, style={border_style}, dash={dash_length}, gap={gap_length}", file=sys.stderr) + + # 步骤1: 扩大画布(给描边留出空间) + padding = border_width + 2 # 描边宽度 + 2像素余量 + original_width, original_height = person_rgba.size + + # 创建扩大后的画布 + expanded_person = Image.new('RGBA', + (original_width + padding * 2, original_height + padding * 2), + (0, 0, 0, 0)) + # 将原始人物图像粘贴到中心 + expanded_person.paste(person_rgba, (padding, padding), person_rgba) + + print(f"Expanded person canvas: original={person_rgba.size}, expanded={expanded_person.size}, padding={padding}", file=sys.stderr) + + # 步骤2: 从扩大后的图像提取 alpha 通道 + alpha = expanded_person.split()[3] + alpha_np = np.array(alpha) + + # 确保是uint8格式 + if alpha_np.dtype != np.uint8: + if alpha_np.max() <= 1.0: + alpha_np = (alpha_np * 255).astype(np.uint8) + else: + alpha_np = alpha_np.astype(np.uint8) + + # 二值化mask(与参考项目一致 - cover.py:1221) + # 这一步很关键:确保mask只有0和255两个值,提高轮廓检测精度 + _, alpha_binary = cv2.threshold(alpha_np, 127, 255, cv2.THRESH_BINARY) + print(f"Alpha channel binarized: non-zero pixels = {np.count_nonzero(alpha_binary)}", file=sys.stderr) + + # 步骤3: 创建描边 mask - 使用轮廓检测(向外扩散,不覆盖人物) + border_mask = None + + # 方法1: 轮廓检测(在扩大的画布上,描边不会被截断) + try: + contours, _ = cv2.findContours(alpha_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + if len(contours) > 0: + print(f"Using contour detection method, found {len(contours)} contours", file=sys.stderr) + + # 创建空白 mask(与扩大后的画布同尺寸) + border_mask = np.zeros_like(alpha_np) + + # 🔧 优化:使用 border_width * 2 作为绘制宽度 + # 因为后面会减去人物本身,实际描边只会保留外部的一半 + # 所以需要加倍线宽才能达到用户期望的描边粗细 + draw_width = border_width * 2 + cv2.drawContours(border_mask, contours, -1, 255, draw_width) + + # 🔧 关键修复:从描边mask中减去人物本身,确保描边只在外部 + # 这样描边就是向外扩散的,不会覆盖人物边缘 + border_mask = cv2.subtract(border_mask, alpha_binary) + + print(f"Contour detection succeeded, border_pixels={np.count_nonzero(border_mask)} (外部描边, 实际宽度≈{border_width}px)", file=sys.stderr) + except Exception as e: + print(f"Contour detection failed: {e}", file=sys.stderr) + + # 方法2: 形态学操作(后备)- 这个方法本身就是向外扩散的 + if border_mask is None or np.sum(border_mask) == 0: + print("Using morphological operation method (fallback)", file=sys.stderr) + + # 计算 kernel 大小和迭代次数 + kernel_size = border_width * 2 + 1 + kernel = np.ones((kernel_size, kernel_size), np.uint8) + iterations = max(1, border_width // 2) + + # 膨胀操作(使用二值化的alpha_binary以获得更清晰的边缘) + dilated_mask = cv2.dilate(alpha_binary, kernel, iterations=iterations) + + # 边缘 = 膨胀后的mask - 原始mask(这就是向外扩散的描边) + border_mask = cv2.subtract(dilated_mask, alpha_binary) + + print(f"Morphological method: kernel_size={kernel_size}, iterations={iterations}", file=sys.stderr) + + # 根据描边样式处理 + if border_style == 'dashed': + print("Applying dashed border style", file=sys.stderr) + border_mask = self._create_dashed_border(border_mask, border_width, dash_length, gap_length) + + # 步骤4: 在扩大的画布上应用描边 + person_width, person_height = expanded_person.size + person_array = np.array(expanded_person) + border_mask_3d = np.stack([border_mask] * 3, axis=2) / 255.0 # 归一化到0-1 + + # 确保border_mask尺寸与人物图像匹配 + if border_mask.shape[:2] != (person_height, person_width): + border_mask = cv2.resize(border_mask, (person_width, person_height), interpolation=cv2.INTER_NEAREST) + border_mask_3d = np.stack([border_mask] * 3, axis=2) / 255.0 + print(f"Resized border_mask to match person image: {border_mask.shape}", file=sys.stderr) + + # 应用描边颜色到透明画布 + border_rgb_array = np.array(border_color, dtype=np.float32) + h, w = person_array.shape[:2] + border_rgb_3d = np.tile(border_rgb_array.reshape(1, 1, 3), (h, w, 1)) + + # 分离RGB和Alpha通道 + person_rgb = person_array[:, :, :3].astype(np.float32) + person_alpha = person_array[:, :, 3:4].astype(np.float32) / 255.0 # 归一化alpha到0-1 + + # 关键修复:处理透明区域的RGB值 + # 在完全透明的区域(alpha=0),RGB值应该被保留为原值(通常是0) + # 只在有描边和人物的地方应用颜色混合 + + # 对RGB通道应用描边:只在border_mask > 0的地方混合颜色 + person_rgb = (person_rgb * (1 - border_mask_3d) + + border_rgb_3d * border_mask_3d).astype(np.uint8) + + # 对Alpha通道:描边区域应该是不透明的(255),人物区域保持原有alpha + # border_mask是单通道的,值在0-255之间 + border_alpha_mask = border_mask[:, :, np.newaxis] / 255.0 # 归一化到0-1 + + # 关键修复:确保完全透明的区域保持透明 + # 只在border_mask有值或person_alpha > 0的地方设置alpha + # 这样可以避免扩大画布周围被填充成白色 + person_alpha_new = np.where( + (border_alpha_mask > 0) | (person_alpha > 0), + np.maximum(person_alpha, border_alpha_mask), + 0 # 透明区域保持完全透明 + ) * 255.0 + person_alpha_new = person_alpha_new.astype(np.uint8) + + # 合并RGB和Alpha通道 + person_array = np.concatenate([person_rgb, person_alpha_new], axis=2) + + person_with_border = Image.fromarray(person_array, 'RGBA') + print(f"Border applied on expanded canvas, final size={person_with_border.size}, color RGB: {border_color}", file=sys.stderr) + + # 步骤5: 返回扩大后的图像(不裁剪,保持描边完整) + return person_with_border + + def _create_dashed_border(self, border_mask: np.ndarray, border_width: int, + dash_length: int, gap_length: int) -> np.ndarray: + """ + 将实线描边转换为虚线描边 + + 使用轮廓路径追踪算法,沿着轮廓路径交替绘制虚线段和间隔 + 关键优化:扩展画布以避免边缘虚线被截断 + + Args: + border_mask: 实线描边 mask + border_width: 描边宽度 + dash_length: 虚线长度 + gap_length: 间隔长度 + + Returns: + 虚线描边 mask + """ + # 扩展画布以避免边缘虚线被截断 + padding = border_width * 2 + h, w = border_mask.shape + + # 创建扩展后的画布 + padded_border_mask = np.zeros((h + padding * 2, w + padding * 2), dtype=np.uint8) + padded_border_mask[padding:padding+h, padding:padding+w] = border_mask + + print(f"Padded border_mask for dashed border: original={border_mask.shape}, padded={padded_border_mask.shape}, padding={padding}", file=sys.stderr) + + # 在扩展后的画布上找到轮廓 + contours, _ = cv2.findContours(padded_border_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # 在扩展后的画布上创建虚线mask + padded_dashed_mask = np.zeros_like(padded_border_mask) + + print(f"Creating dashed border: {len(contours)} contours, dash={dash_length}px, gap={gap_length}px", file=sys.stderr) + + # 对每个轮廓绘制虚线 + for contour_idx, contour in enumerate(contours): + if len(contour) < 2: + continue + + # 将轮廓点连接成连续的路径 + contour_points = [tuple(pt[0]) for pt in contour] + + # 计算轮廓总长度 + total_length = 0.0 + for i in range(len(contour_points) - 1): + pt1 = contour_points[i] + pt2 = contour_points[i + 1] + total_length += np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2) + + print(f"Contour {contour_idx}: {len(contour_points)} points, length={total_length:.1f}px", file=sys.stderr) + + # 沿着轮廓路径绘制虚线 + is_dash = True # 当前是否在绘制虚线段 + dash_remaining = float(dash_length) # 当前虚线段剩余长度 + gap_remaining = float(gap_length) # 当前间隔剩余长度 + + for i in range(len(contour_points) - 1): + pt1 = contour_points[i] + pt2 = contour_points[i + 1] + + # 计算两点间距离 + segment_dist = np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2) + + if segment_dist < 0.1: # 跳过太短的点 + continue + + # 沿着这个线段绘制虚线 + remaining_in_segment = segment_dist + segment_offset = 0.0 # 在当前线段中的偏移 + + while remaining_in_segment > 0.01: # 还有剩余距离 + if is_dash: + # 绘制虚线段 + draw_length = min(dash_remaining, remaining_in_segment) + + # 计算起点和终点在 pt1-pt2 线段上的位置 + t1 = segment_offset / segment_dist + t2 = (segment_offset + draw_length) / segment_dist + + # 确保 t 在 [0, 1] 范围内 + t1 = max(0.0, min(1.0, t1)) + t2 = max(0.0, min(1.0, t2)) + + x1 = int(pt1[0] + (pt2[0] - pt1[0]) * t1) + y1 = int(pt1[1] + (pt2[1] - pt1[1]) * t1) + x2 = int(pt1[0] + (pt2[0] - pt1[0]) * t2) + y2 = int(pt1[1] + (pt2[1] - pt1[1]) * t2) + + # 绘制虚线段(在扩展画布上) + if abs(x2 - x1) > 0 or abs(y2 - y1) > 0: # 确保不是同一个点 + cv2.line(padded_dashed_mask, (x1, y1), (x2, y2), 255, border_width) + + segment_offset += draw_length + remaining_in_segment -= draw_length + dash_remaining -= draw_length + + # 如果虚线段绘制完成,切换到间隔 + if dash_remaining <= 0.01: + is_dash = False + gap_remaining = float(gap_length) # 重置间隔长度 + else: + # 跳过间隔段 + skip_length = min(gap_remaining, remaining_in_segment) + segment_offset += skip_length + remaining_in_segment -= skip_length + gap_remaining -= skip_length + + # 如果间隔段完成,切换到虚线 + if gap_remaining <= 0.01: + is_dash = True + dash_remaining = float(dash_length) # 重置虚线长度 + + # 裁剪回原始尺寸 + dashed_mask = padded_dashed_mask[padding:padding+h, padding:padding+w] + print(f"Dashed border created and cropped back to original size", file=sys.stderr) + + return dashed_mask + + def _composite_person_to_background(self, person_rgba: Image.Image, background: Image.Image) -> Image.Image: + """ + 将人物合成到背景上 + + Args: + person_rgba: 人物图像(RGBA模式,可能带描边) + background: 背景图像(RGB模式) + + Returns: + 合成后的图像(RGBA模式) + """ + # 调整人物大小 + person_size = self.template.get('personSize', 100) + person_rotation = self.template.get('personRotation', 0) + if person_size != 100: + new_width = int(person_rgba.width * person_size / 100) + new_height = int(person_rgba.height * person_size / 100) + person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 调整人物位置 + person_position = self.template.get('personPosition', {'x': 50, 'y': 50}) + person_x = int(background.width * person_position['x'] / 100) - person_rgba.width // 2 + person_y = int(background.height * person_position['y'] / 100) - person_rgba.height // 2 + + # 合成 + result = background.convert('RGBA') + result.paste(person_rgba, (person_x, person_y), person_rgba) + + return result + + def run_and_output_json(self): + """ + 运行生成器并输出JSON结果(只输出一行JSON) + """ + result = self.generate() + # 确保只输出一行JSON + sys.stdout.write(json.dumps(result, ensure_ascii=False)) + sys.stdout.flush() + + +def main(): + parser = argparse.ArgumentParser(description='Advanced Cover Generator') + parser.add_argument('--video', required=True, help='Path to video or image file') + parser.add_argument('--title', required=True, help='Title text') + parser.add_argument('--output', required=True, help='Output cover path') + parser.add_argument('--config', default='{}', help='JSON configuration') + + args = parser.parse_args() + + try: + # 确保系统编码支持中文 + import sys + import locale + if sys.platform == 'win32': + # Windows 设置控制台编码 + import ctypes + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleOutputCP(65001) # UTF-8 + + # 解析配置 + config = json.loads(args.config) + + # ⚠️ 诊断日志:同时输出到 stdout 和 stderr,确保能看到 + import sys + diag_msg = f""" +=== ADVANCED COVER GENERATOR DIAGNOSTIC === +[CONFIG] Received config keys: {list(config.keys())[:20]}... (showing first 20) +[CONFIG] titleStrokeWidth: {config.get('titleStrokeWidth', 'NOT SET')} +[CONFIG] titleStrokeColor: {config.get('titleStrokeColor', 'NOT SET')} +[CONFIG] titleFontFamily: {config.get('titleFontFamily', 'NOT SET')} +[CONFIG] personBorderEnabled: {config.get('personBorderEnabled', 'NOT SET')} +[CONFIG] personBorderWidth: {config.get('personBorderWidth', 'NOT SET')} +[CONFIG] personBorderColor: {config.get('personBorderColor', 'NOT SET')} +[CONFIG] backgroundBlurEnabled: {config.get('backgroundBlurEnabled', 'NOT SET')} +=========================================== +""" + print(diag_msg, file=sys.stderr, flush=True) + sys.stderr.flush() + + # 添加命令行参数到配置 + config['video'] = args.video + config['title'] = args.title + config['output'] = args.output + + # 创建生成器 + generator = AdvancedCoverGenerator(config) + + # 运行并输出结果(只输出一行JSON) + generator.run_and_output_json() + + except Exception as e: + error_result = { + 'success': False, + 'error': str(e), + 'message': f'Error: {str(e)}' + } + sys.stdout.write(json.dumps(error_result, ensure_ascii=False)) + sys.stdout.flush() + sys.exit(1) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/scripts/cover/cover_generator.py b/scripts/cover/cover_generator.py new file mode 100644 index 0000000..4a06ee1 --- /dev/null +++ b/scripts/cover/cover_generator.py @@ -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() \ No newline at end of file diff --git a/scripts/cover/cover_preview_generator.py b/scripts/cover/cover_preview_generator.py new file mode 100644 index 0000000..ada80d0 --- /dev/null +++ b/scripts/cover/cover_preview_generator.py @@ -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() diff --git a/scripts/cover/custom_template_cover_fix.py b/scripts/cover/custom_template_cover_fix.py new file mode 100644 index 0000000..a281b1c --- /dev/null +++ b/scripts/cover/custom_template_cover_fix.py @@ -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)}") diff --git a/scripts/cover/find_font.py b/scripts/cover/find_font.py new file mode 100644 index 0000000..9892ce0 --- /dev/null +++ b/scripts/cover/find_font.py @@ -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_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() \ No newline at end of file diff --git a/scripts/cover/generate_cover_previews.py b/scripts/cover/generate_cover_previews.py new file mode 100644 index 0000000..5f42926 --- /dev/null +++ b/scripts/cover/generate_cover_previews.py @@ -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() diff --git a/scripts/cover/generate_image_cover.py b/scripts/cover/generate_image_cover.py new file mode 100644 index 0000000..83d2ab3 --- /dev/null +++ b/scripts/cover/generate_image_cover.py @@ -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() \ No newline at end of file diff --git a/scripts/cover/get_font_name.py b/scripts/cover/get_font_name.py new file mode 100644 index 0000000..dd26cd2 --- /dev/null +++ b/scripts/cover/get_font_name.py @@ -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() + + + + + + + + + + + + + + + + + + + diff --git a/scripts/cover/get_fonts.py b/scripts/cover/get_fonts.py new file mode 100644 index 0000000..4d3388e --- /dev/null +++ b/scripts/cover/get_fonts.py @@ -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() \ No newline at end of file diff --git a/scripts/cover/modules/__init__.py b/scripts/cover/modules/__init__.py new file mode 100644 index 0000000..f3158c0 --- /dev/null +++ b/scripts/cover/modules/__init__.py @@ -0,0 +1,6 @@ +""" +modules 包初始化文件 +""" + +# 这个文件是必需的,让 Python 将 modules 目录识别为一个包 +# 从而支持相对导入(如 from .utils import ...) diff --git a/scripts/cover/modules/cover.py b/scripts/cover/modules/cover.py new file mode 100644 index 0000000..8c436eb --- /dev/null +++ b/scripts/cover/modules/cover.py @@ -0,0 +1,2654 @@ +""" +封面生成模块 +根据模板生成封面图,包括背景虚化、人物描边和文本排版 +""" + +import cv2 +import numpy as np +from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, List, Union +import os +import sys +import time +import shutil + +# 尝试导入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) + +from .utils import ensure_dir, save_image, apply_blur, apply_gradient_overlay, blend_images + +# 检查cv2是否可用 +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + CV2_AVAILABLE = False + logger.warning("opencv-python不可用,某些功能可能受限") + + +class CoverGenerator: + """封面生成器""" + + def __init__(self, template_config: Optional[Dict[str, Any]] = None): + self.template_config = template_config or self._get_default_config() + self.templates = self._load_templates() + + def _get_default_config(self) -> Dict[str, Any]: + """获取默认配置""" + return { + 'canvas_size': (1080, 1920), # 9:16 比例 + 'background_color': (255, 255, 255), + 'font_family': 'simhei.ttf', # 默认字体 + 'font_size_title': 120, + 'font_size_subtitle': 60, + 'text_color': (255, 255, 255), + 'text_shadow': True, + 'text_shadow_color': (0, 0, 0), + 'text_shadow_offset': (3, 3), + } + + def _load_templates(self) -> Dict[str, Dict[str, Any]]: + """加载封面模板""" + return { + 'big_text': { + 'name': '大字封面', + 'description': '大标题文字,适合宣传类内容', + 'background_effect': 'blur', + 'text_position': 'center', + 'text_size': 'large', + 'person_position': 'bottom', + 'person_size': 0.8, + }, + 'search_card': { + 'name': '搜索卡片封面', + 'description': '卡片式布局,适合教程类内容', + 'background_effect': 'gradient', + 'text_position': 'top-left', + 'text_size': 'medium', + 'person_position': 'right', + 'person_size': 0.6, + }, + 'cut_angle': { + 'name': '斜切色块封面', + 'description': '斜切设计,现代感强', + 'background_effect': 'solid', + 'text_position': 'top-right', + 'text_size': 'medium', + 'person_position': 'left', + 'person_size': 0.7, + }, + 'blur_person': { + 'name': '背景虚化人物封面', + 'description': '人物突出,背景虚化', + 'background_effect': 'blur-person', + 'blur_intensity': 15, # 增强背景模糊(从8增加到15) + 'text_position': 'bottom', + 'text_size': 'large', + 'person_position': 'center', + 'person_size': 0.9, + 'person_border_color': '#FFFFFF', + 'person_border_width': 6, + 'person_border_style': 'solid', # solid 或 dashed + }, + 'red_banner': { + 'name': '红色横幅封面', + 'description': '红色横幅,白色文字,简洁醒目', + 'background_effect': 'none', + 'text_position': 'bottom-center', + 'text_size': 'large', + 'text_background_color': '#DC143C', # 深红色 + 'text_background_opacity': 255, + 'person_position': 'center', + 'person_size': 0.7, + }, + 'blue_banner': { + 'name': '蓝色横幅封面', + 'description': '蓝色横幅,不规则边缘,现代感', + 'background_effect': 'none', + 'text_position': 'bottom-center', + 'text_size': 'large', + 'text_background_color': '#1E90FF', # 蓝色 + 'text_background_opacity': 255, + 'text_background_shape': 'irregular', # 不规则边缘 + 'person_position': 'center', + 'person_size': 0.8, + }, + 'yellow_outline': { + 'name': '黄色描边文字封面', + 'description': '黄色文字,白色描边,无背景框', + 'background_effect': 'blur', + 'text_position': 'center', + 'text_size': 'large', + 'text_color': '#FFD700', # 黄色 + 'text_outline': True, + 'text_outline_color': '#FFFFFF', + 'text_outline_width': 3, + 'person_position': 'center', + 'person_size': 0.8, + }, + 'dual_color': { + 'name': '双色文字封面', + 'description': '黄色和橙色文字,无背景框', + 'background_effect': 'none', + 'text_position': 'center', + 'text_size': 'large', + 'text_color': '#FFD700', # 主文字黄色 + 'secondary_text_color': '#FF8C00', # 副文字橙色 + 'person_position': 'center', + 'person_size': 0.8, + }, + 'multi_text': { + 'name': '多位置文字封面', + 'description': '左上和右侧都有文字,适合信息展示', + 'background_effect': 'blur-person', + 'blur_intensity': 8, + 'text_position': 'top-left', + 'secondary_text_position': 'right', + 'text_size': 'medium', + 'person_position': 'center', + 'person_size': 0.9, + 'person_border_color': '#FFFFFF', + 'person_border_width': 3, + }, + } + + def generate_cover(self, background_path: str, person_path: Optional[str] = None, + text_info: Dict[str, Any] = None, template_id: str = "big_text", + output_path: Optional[str] = None, person_mask: Optional[np.ndarray] = None, + cover_style: Optional[Dict[str, Any]] = None, save_steps: bool = False, + step_callback: Optional[callable] = None) -> Union[str, Dict[str, Any]]: + """生成封面 + + Args: + background_path: 背景图路径 + person_path: 人物图路径(可选) + text_info: 文本信息 + template_id: 模板ID + output_path: 输出路径 + person_mask: 人物mask(用于描边和背景模糊) + cover_style: 封面样式配置 + save_steps: 是否保存中间步骤(用于预览) + step_callback: 步骤回调函数,每完成一个步骤时调用,参数为(step_num, step_name, step_path) + + Returns: + 如果save_steps=False,返回生成的封面路径(str) + 如果save_steps=True,返回包含最终路径和中间步骤路径的字典 + """ + try: + # 检测自定义模板(格式:custom:${templateId}) + is_custom_template = template_id.startswith('custom:') + if is_custom_template: + # 自定义模板:使用 cover_style 中的所有参数 + # 创建一个基础模板配置 + template = self.templates.get('big_text', {}).copy() + # 标记为自定义模板 + template['is_custom'] = True + # 设置 normalized ID(用于日志) + template_id_normalized = template_id + logger.info(f"检测到自定义模板: {template_id}") + else: + # 处理模板ID映射(前端可能使用连字符,后端使用下划线) + template_id_normalized = template_id.replace('-', '_') + # 获取模板配置 + template = self.templates.get(template_id_normalized, self.templates['big_text']).copy() + + # 合并cover_style到template,以便统一处理 + if cover_style: + # 保存原始的background_effect(模板定义的背景效果不应该被cover_style覆盖) + original_background_effect = template.get('background_effect') + + # 保存cover_style到template中,供描边函数使用 + template['cover_style'] = cover_style + + # 合并cover_style(但不覆盖background_effect) + for key, value in cover_style.items(): + # 跳过effectType,因为它不应该覆盖模板的background_effect + if key != 'effectType': + # 将前端命名转换为后端命名 + if key == 'personBorderEnabled': + template['person_border_enabled'] = value + elif key == 'personBorderWidth': + template['person_border_width'] = value + elif key == 'personBorderColor': + template['person_border_color'] = value + elif key == 'personBorderStyle': + template['person_border_style'] = value + elif key == 'personBorderDashLength': + template['person_border_dash_length'] = value + elif key == 'personBorderGapLength': + template['person_border_gap_length'] = value + elif key == 'backgroundBlurEnabled': + template['background_blur_enabled'] = value + elif key == 'backgroundBlurIntensity': + template['background_blur_intensity'] = value + elif key == 'blurIntensity': + # 背景模糊度(用于blur-person模板,兼容旧字段) + template['blur_intensity'] = value + elif key == 'personSize': + # 人像大小(百分比,转换为0-1的小数) + template['person_size'] = value / 100.0 if value else 0.9 + elif key == 'personPosition': + # 人像位置(百分比坐标) + template['person_position_custom'] = value + elif key == 'backgroundSize': + # 背景大小(百分比) + template['background_size'] = value / 100.0 if value else 1.0 + elif key == 'backgroundPosition': + # 背景位置(百分比坐标) + template['background_position_custom'] = value + elif key == 'titlePosition': + # 主标题位置(百分比坐标) + template['title_position_custom'] = value + elif key == 'subtitlePosition': + # 副标题位置(百分比坐标) + template['subtitle_position_custom'] = value + elif key == 'maskEnabled': + # 蒙版启用 + template['mask_enabled'] = value + elif key == 'maskImagePath': + # 蒙版图片路径 + template['mask_image_path'] = value + elif key == 'maskSize': + # 蒙版大小(百分比) + template['mask_size'] = value / 100.0 if value else 1.0 + elif key == 'maskPosition': + # 蒙版位置(百分比坐标) + template['mask_position_custom'] = value + elif key == 'maskColor': + # 蒙版颜色 + template['mask_color'] = value + elif key == 'maskOpacity': + # 蒙版透明度(0-100转换为0-1) + template['mask_opacity'] = value / 100.0 if value else 1.0 + elif key == 'maskShape': + # 蒙版形状 + template['mask_shape'] = value + else: + template[key] = value + + # 确保background_effect不被覆盖 + template['background_effect'] = original_background_effect + + logger.info(f"合并cover_style后 - background_effect: {template.get('background_effect')}") + logger.info(f"描边配置 - width: {template.get('person_border_width')}, color: {template.get('person_border_color')}, style: {template.get('person_border_style')}") + + # 调试日志 + logger.info(f"生成封面 - template_id: {template_id}, normalized: {template_id_normalized}") + logger.info(f"生成封面 - template配置: {template}") + logger.info(f"生成封面 - background_effect: {template.get('background_effect')}") + if text_info: + logger.info(f"生成封面 - text_info.title: {text_info.get('title')}") + logger.info(f"生成封面 - text_info.cover_style: {text_info.get('cover_style')}") + logger.info(f"生成封面 - person_path: {person_path}") + + # 确定输出目录(用于保存中间步骤) + if output_path is None: + output_dir = ensure_dir("output") + # 对于自定义模板,使用简化的文件名(移除 custom: 前缀和特殊字符) + safe_template_id = template_id.replace('custom:', '').replace(':', '-').replace('/', '-') + output_path = str(output_dir / f"cover_{safe_template_id}.png") + else: + output_dir = os.path.dirname(output_path) + if output_dir: + ensure_dir(output_dir) + else: + output_dir = "output" + ensure_dir(output_dir) + + # 中间步骤路径列表 + step_paths = [] + + # 生成安全的模板ID(用于文件名) + safe_template_id = template_id.replace('custom:', '').replace(':', '-').replace('/', '-') + + # 创建画布 + canvas = self._create_canvas(template) + + # 步骤0: 空白画布 + if save_steps: + step_path_0 = os.path.join(output_dir, f"step_0_canvas_{safe_template_id}.png") + # 替换所有使用 template_id 作为文件名的地方 + # 复制画布以确保保存当前状态 + canvas_copy_0 = canvas.copy() + canvas_bgr_0 = cv2.cvtColor(np.array(canvas_copy_0), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_0, step_path_0) + # 确保路径是绝对路径 + step_path_0 = os.path.abspath(step_path_0) + step_paths.append({"step": 0, "name": "创建画布", "path": step_path_0}) + logger.info(f"步骤0已保存: {step_path_0}") + print(f"步骤0 (创建画布) 图片路径: {step_path_0}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(0, "创建画布", step_path_0) + # 等待3秒 + time.sleep(3) + logger.info("步骤0等待完成,继续下一步") + + # 步骤0.5: 显示人像分割结果(透明背景人像图) + if save_steps and person_path and os.path.exists(person_path): + # 直接使用人像分割的结果(已经是透明背景的PNG) + step_path_person_seg = os.path.join(output_dir, f"step_0.5_person_seg_{safe_template_id}.png") + # 复制人像分割结果到步骤目录 + shutil.copy2(person_path, step_path_person_seg) + # 确保路径是绝对路径 + step_path_person_seg = os.path.abspath(step_path_person_seg) + step_paths.append({"step": 0.5, "name": "人像分割(透明背景)", "path": step_path_person_seg}) + logger.info(f"步骤0.5已保存(人像分割结果): {step_path_person_seg}") + print(f"步骤0.5 (人像分割(透明背景)) 图片路径: {step_path_person_seg}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(0.5, "人像分割(透明背景)", step_path_person_seg) + # 等待3秒 + time.sleep(3) + logger.info("步骤0.5等待完成,继续下一步") + + # 处理背景(如果是blur-person模板或自定义模板启用了描边,需要先添加人物再模糊背景) + background_effect = template.get('background_effect') + is_custom_template = template.get('is_custom', False) + cover_style = template.get('cover_style', {}) + person_border_enabled = cover_style.get('personBorderEnabled') or template.get('person_border_enabled', False) + + # 判断是否需要走blur-person流程(先添加背景,再添加人物和描边) + # 对于自定义模板,如果启用了描边或背景模糊,都需要走这个流程 + background_blur_enabled = cover_style.get('backgroundBlurEnabled') or template.get('background_blur_enabled', False) + needs_blur_person_flow = ( + (background_effect == 'blur-person' or background_effect == 'blur_person') or + (is_custom_template and (person_border_enabled or background_blur_enabled)) + ) + + logger.info(f"模板背景效果: {background_effect}, is_custom: {is_custom_template}, personBorderEnabled: {person_border_enabled}, needs_blur_person_flow: {needs_blur_person_flow}") + logger.info(f"person_path: {person_path}, person_mask: {person_mask is not None}") + + if needs_blur_person_flow and person_path: + logger.info("blur-person模板:开始处理背景和人物") + + # 步骤1: 对抠图进行描边(在透明画布上) + logger.info("步骤1开始:对抠图进行描边") + person_with_border = None + person_position_info = None + + if person_border_enabled: + # 创建带描边的人物图(在透明画布上) + person_with_border, person_position_info = self._create_person_with_border( + person_path, template, person_mask + ) + logger.info("步骤1完成:人物描边已添加") + + # 保存步骤1(描边后的人物图) + if save_steps and person_with_border: + step_path_1 = os.path.join(output_dir, f"step_1_person_border_{safe_template_id}.png") + # 保存带描边的人物图(RGBA格式) + person_with_border_bgr = cv2.cvtColor(np.array(person_with_border.convert('RGB')), cv2.COLOR_RGB2BGR) + save_image(person_with_border_bgr, step_path_1) + # 确保路径是绝对路径 + step_path_1 = os.path.abspath(step_path_1) + step_paths.append({"step": 1, "name": "人物描边", "path": step_path_1}) + logger.info(f"步骤1已保存: {step_path_1}") + print(f"步骤1 (人物描边) 图片路径: {step_path_1}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(1, "人物描边", step_path_1) + # 等待3秒 + time.sleep(3) + logger.info("步骤1等待完成,继续下一步") + else: + # 如果没有描边,直接读取人物图并计算位置信息 + person_img = Image.open(person_path).convert('RGBA') + canvas_width, canvas_height = canvas.size + person_position_custom = template.get('person_position_custom') + person_size_custom = template.get('person_size') + + if person_position_custom and person_size_custom is not None: + person_size_ratio = person_size_custom + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_img.height / person_img.width) + if person_height > canvas_height * 0.95: + person_height = int(canvas_height * 0.95) + person_width = int(person_height * person_img.width / person_img.height) + x = int((person_position_custom.get('x', 50) / 100.0) * canvas_width) - person_width // 2 + y = int((person_position_custom.get('y', 50) / 100.0) * canvas_height) - person_height // 2 + x = max(0, min(x, canvas_width - person_width)) + y = max(0, min(y, canvas_height - person_height)) + person_with_border = person_img.resize((person_width, person_height), Image.LANCZOS) + else: + person_size_ratio = template.get('person_size', 0.8) + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_img.height / person_img.width) + if person_height > canvas_height * 0.9: + person_height = int(canvas_height * 0.9) + person_width = int(person_height * person_img.width / person_img.height) + person_position = template.get('person_position', 'center') + x, y = self._calculate_position(person_position, (person_width, person_height), (canvas_width, canvas_height)) + person_with_border = person_img.resize((person_width, person_height), Image.LANCZOS) + + person_position_info = {'x': x, 'y': y, 'width': person_width, 'height': person_height} + logger.info("步骤1跳过:描边已禁用") + + # 步骤2: 模糊背景(在单独的背景图上) + logger.info("步骤2开始:模糊背景") + # 检查是否启用背景模糊 + background_blur_enabled = cover_style.get('backgroundBlurEnabled') + if background_blur_enabled is None: + background_blur_enabled = template.get('background_blur_enabled') + if background_blur_enabled is None: + background_blur_enabled = (background_effect == 'blur-person' or background_effect == 'blur_person') + + logger.info(f"背景模糊启用状态 - backgroundBlurEnabled: {background_blur_enabled}") + + # 读取背景图 + background = Image.open(background_path).convert('RGB') + canvas_width, canvas_height = canvas.size + background = self._resize_image(background, (canvas_width, canvas_height)) + + if background_blur_enabled: + # 对背景进行模糊 + cover_style = template.get('cover_style', {}) + blur_radius = cover_style.get('backgroundBlurIntensity') + if blur_radius is None: + blur_radius = template.get('background_blur_intensity') + if blur_radius is None: + blur_radius = template.get('blur_intensity', 8) + background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + logger.info(f"步骤2完成:背景模糊已应用(模糊度: {blur_radius})") + else: + logger.info("步骤2完成:背景未模糊") + + # 保存步骤2(模糊后的背景图) + if save_steps: + step_path_2 = os.path.join(output_dir, f"step_2_background_blur_{safe_template_id}.png") + background_bgr = cv2.cvtColor(np.array(background), cv2.COLOR_RGB2BGR) + save_image(background_bgr, step_path_2) + step_path_2 = os.path.abspath(step_path_2) + step_paths.append({"step": 2, "name": "模糊背景", "path": step_path_2}) + logger.info(f"步骤2已保存: {step_path_2}") + print(f"步骤2 (模糊背景) 图片路径: {step_path_2}", file=sys.stderr, flush=True) + if step_callback: + step_callback(2, "模糊背景", step_path_2) + time.sleep(3) + logger.info("步骤2等待完成,继续下一步") + + # 步骤3: 合成(将描边的人物图合成到模糊的背景上) + logger.info("步骤3开始:合成描边人物和模糊背景") + # 将模糊的背景粘贴到画布 + canvas.paste(background, (0, 0)) + # 将带描边的人物图合成到画布上 + if person_with_border and person_position_info: + x = person_position_info['x'] + y = person_position_info['y'] + if person_with_border.mode == 'RGBA': + canvas.paste(person_with_border, (x, y), mask=person_with_border.split()[-1]) + else: + canvas.paste(person_with_border, (x, y)) + logger.info(f"步骤3完成:已合成到位置 ({x}, {y})") + + # 保存步骤3(合成后的最终结果) + if save_steps: + step_path_3 = os.path.join(output_dir, f"step_3_composite_{safe_template_id}.png") + canvas_copy_3 = canvas.copy() + canvas_bgr_3 = cv2.cvtColor(np.array(canvas_copy_3), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_3, step_path_3) + step_path_3 = os.path.abspath(step_path_3) + step_paths.append({"step": 3, "name": "合成", "path": step_path_3}) + logger.info(f"步骤3已保存: {step_path_3}") + print(f"步骤3 (合成) 图片路径: {step_path_3}", file=sys.stderr, flush=True) + if step_callback: + step_callback(3, "合成", step_path_3) + time.sleep(3) + logger.info("步骤3等待完成,继续下一步") + else: + # 普通背景处理 + logger.info("普通模板:处理背景效果") + canvas = self._apply_background_effect(canvas, background_path, template) + logger.info("背景效果已应用") + + # 保存背景步骤 + if save_steps: + step_path_bg = os.path.join(output_dir, f"step_1_background_{safe_template_id}.png") + # 复制画布以确保保存当前状态 + canvas_copy_bg = canvas.copy() + canvas_bgr_bg = cv2.cvtColor(np.array(canvas_copy_bg), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_bg, step_path_bg) + # 确保路径是绝对路径 + step_path_bg = os.path.abspath(step_path_bg) + step_paths.append({"step": 1, "name": "背景效果", "path": step_path_bg}) + logger.info(f"背景步骤已保存: {step_path_bg}") + # 调用步骤回调 + if step_callback: + step_callback(1, "背景效果", step_path_bg) + # 等待3秒 + time.sleep(3) + logger.info("背景步骤等待完成,继续下一步") + + # 添加人物 + if person_path: + logger.info("添加人物") + canvas = self._add_person(canvas, person_path, template, person_mask) + logger.info("人物已添加") + + # 保存人物步骤 + if save_steps: + step_path_person = os.path.join(output_dir, f"step_2_person_{safe_template_id}.png") + # 复制画布以确保保存当前状态 + canvas_copy_person = canvas.copy() + canvas_bgr_person = cv2.cvtColor(np.array(canvas_copy_person), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_person, step_path_person) + # 确保路径是绝对路径 + step_path_person = os.path.abspath(step_path_person) + step_paths.append({"step": 2, "name": "添加人物", "path": step_path_person}) + logger.info(f"人物步骤已保存: {step_path_person}") + print(f"步骤2 (添加人物) 图片路径: {step_path_person}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(2, "添加人物", step_path_person) + # 等待3秒 + time.sleep(3) + logger.info("人物步骤等待完成,继续下一步") + + # 添加文字 + if text_info: + logger.info(f"准备添加文字 - text_info: {text_info}") + print(f"准备添加文字 - title: '{text_info.get('title', '')}'", file=sys.stderr, flush=True) + # _add_text 直接修改画布,但也会返回画布 + canvas = self._add_text(canvas, text_info, template) + logger.info(f"文字添加完成,画布状态已更新") + print(f"文字添加完成", file=sys.stderr, flush=True) + + # 保存文字步骤(在添加文字后立即保存) + if save_steps: + step_path_text = os.path.join(output_dir, f"step_4_text_{safe_template_id}.png") + # 复制画布以确保保存当前状态(添加文字后的状态) + canvas_copy_text = canvas.copy() + canvas_bgr_text = cv2.cvtColor(np.array(canvas_copy_text), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_text, step_path_text) + # 确保路径是绝对路径 + step_path_text = os.path.abspath(step_path_text) + step_paths.append({"step": 4, "name": "添加文字", "path": step_path_text}) + logger.info(f"文字步骤已保存: {step_path_text}") + print(f"步骤4 (添加文字) 图片路径: {step_path_text}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(4, "添加文字", step_path_text) + # 等待3秒 + time.sleep(3) + logger.info("文字步骤等待完成,继续下一步") + + # 添加蒙版(如果启用) + if template.get('mask_enabled') and template.get('mask_image_path'): + logger.info("添加蒙版") + canvas = self._add_mask(canvas, template) + logger.info("蒙版已添加") + + # 保存蒙版步骤 + if save_steps: + step_path_mask = os.path.join(output_dir, f"step_5_mask_{safe_template_id}.png") + canvas_copy_mask = canvas.copy() + canvas_bgr_mask = cv2.cvtColor(np.array(canvas_copy_mask), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_mask, step_path_mask) + step_path_mask = os.path.abspath(step_path_mask) + step_paths.append({"step": 5, "name": "添加蒙版", "path": step_path_mask}) + logger.info(f"蒙版步骤已保存: {step_path_mask}") + if step_callback: + step_callback(5, "添加蒙版", step_path_mask) + time.sleep(3) + + # 应用最终效果 + canvas = self._apply_final_effects(canvas, template) + + # 保存最终效果步骤(如果有特殊效果) + if save_steps and template.get('final_effects'): + step_path_final = os.path.join(output_dir, f"step_5_final_{safe_template_id}.png") + # 复制画布以确保保存当前状态 + canvas_copy_final = canvas.copy() + canvas_bgr_final = cv2.cvtColor(np.array(canvas_copy_final), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr_final, step_path_final) + # 确保路径是绝对路径 + step_path_final = os.path.abspath(step_path_final) + step_paths.append({"step": 5, "name": "最终效果", "path": step_path_final}) + logger.info(f"最终效果步骤已保存: {step_path_final}") + print(f"步骤5 (最终效果) 图片路径: {step_path_final}", file=sys.stderr, flush=True) + # 调用步骤回调 + if step_callback: + step_callback(5, "最终效果", step_path_final) + # 等待3秒 + time.sleep(3) + logger.info("最终效果步骤等待完成") + + # 转换为OpenCV格式并保存最终结果 + canvas_bgr = cv2.cvtColor(np.array(canvas), cv2.COLOR_RGB2BGR) + save_image(canvas_bgr, output_path) + + logger.info(f"封面生成成功: {output_path}") + + # 如果保存了中间步骤,返回包含步骤信息的字典 + if save_steps: + return { + "cover_path": output_path, + "steps": step_paths + } + else: + return output_path + + except Exception as e: + logger.error(f"封面生成失败: {e}") + raise + + def _apply_person_background_blur(self, canvas: Image.Image, person_path: Optional[str], + person_mask: Optional[np.ndarray], template: Dict[str, Any]) -> Image.Image: + """对背景区域应用模糊,保持人物清晰 + + Args: + canvas: 画布(已经添加了人物) + person_path: 人物图路径 + person_mask: 人物mask + template: 模板配置 + + Returns: + 处理后的画布 + """ + try: + if not CV2_AVAILABLE: + # 如果cv2不可用,使用PIL的模糊(会模糊整张图,包括人物) + cover_style = template.get('cover_style', {}) + blur_radius = cover_style.get('backgroundBlurIntensity') + if blur_radius is None: + blur_radius = template.get('background_blur_intensity') + if blur_radius is None: + blur_radius = template.get('blur_intensity', 8) # 兼容旧字段 + logger.warning("OpenCV不可用,将模糊整张图(包括人物)") + return canvas.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + if not person_path: + logger.warning("人物路径为空,跳过背景模糊") + return canvas + + # 转换为numpy数组 + canvas_array = np.array(canvas) + canvas_h, canvas_w = canvas_array.shape[:2] + + # 获取模糊半径(优先使用cover_style中的配置) + cover_style = template.get('cover_style', {}) + blur_radius = cover_style.get('backgroundBlurIntensity') + if blur_radius is None: + blur_radius = template.get('background_blur_intensity') + if blur_radius is None: + blur_radius = template.get('blur_intensity', 8) # 兼容旧字段 + + logger.info(f"背景模糊半径: {blur_radius} (来源: cover_style={cover_style.get('backgroundBlurIntensity')}, template={template.get('background_blur_intensity')})") + + # 读取人物图以获取mask和位置信息 + try: + # 确保使用绝对路径 + if not os.path.isabs(person_path): + # 如果是相对路径,尝试从当前工作目录解析 + person_path_abs = os.path.abspath(person_path) + else: + person_path_abs = person_path + + logger.info(f"尝试读取人物图像: {person_path_abs}") + if not os.path.exists(person_path_abs): + logger.warning(f"人物图像文件不存在: {person_path_abs}") + # 如果文件不存在,尝试使用person_mask + if person_mask is not None: + logger.info("使用提供的person_mask进行背景模糊") + else: + return canvas + + person_img = Image.open(person_path_abs) + # 强制加载图像数据 + person_img.load() + logger.info(f"人物图像读取成功: {person_img.size}, mode: {person_img.mode}") + except Exception as e: + logger.warning(f"读取人物图像失败: {e},尝试使用person_mask") + person_img = None + + person_position = template.get('person_position', 'center') + + # 创建人物区域的mask + if person_mask is not None: + # 优先使用提供的person_mask + mask_h, mask_w = person_mask.shape[:2] + person_width = int(canvas_w * template.get('person_size', 0.9)) + person_height = int(person_width * mask_h / mask_w) + if person_height > canvas_h * 0.9: + person_height = int(canvas_h * 0.9) + person_width = int(person_height * mask_w / mask_h) + + x, y = self._calculate_position(person_position, (person_width, person_height), (canvas_w, canvas_h)) + alpha_resized = cv2.resize(person_mask, (person_width, person_height)) + logger.info(f"使用person_mask,尺寸: {alpha_resized.shape}, 位置: ({x}, {y})") + elif person_img is not None: + # 从人物图获取尺寸和位置 + person_width = int(canvas_w * template.get('person_size', 0.9)) + person_height = int(person_width * person_img.height / person_img.width) + if person_height > canvas_h * 0.9: + person_height = int(canvas_h * 0.9) + person_width = int(person_height * person_img.width / person_img.height) + + x, y = self._calculate_position(person_position, (person_width, person_height), (canvas_w, canvas_h)) + + if person_img.mode == 'RGBA': + # 从人物图提取alpha通道作为mask + try: + alpha = np.array(person_img.split()[-1]) + # 调整mask大小 + alpha_resized = cv2.resize(alpha, (person_width, person_height)) + logger.info(f"从RGBA图像提取alpha通道,尺寸: {alpha_resized.shape}, 位置: ({x}, {y})") + except Exception as e: + logger.warning(f"提取alpha通道失败: {e},创建矩形mask") + alpha_resized = np.ones((person_height, person_width), dtype=np.uint8) * 255 + else: + # 创建简单的矩形mask(基于人物位置和大小) + alpha_resized = np.ones((person_height, person_width), dtype=np.uint8) * 255 + logger.info(f"创建矩形mask,尺寸: {alpha_resized.shape}, 位置: ({x}, {y})") + else: + logger.warning("无法获取人物图像或mask,跳过背景模糊") + return canvas + + # 创建画布大小的mask + canvas_mask = np.zeros((canvas_h, canvas_w), dtype=np.uint8) + + # 将人物mask放置到正确位置 + start_y = max(0, y) + end_y = min(canvas_h, y + person_height) + start_x = max(0, x) + end_x = min(canvas_w, x + person_width) + + mask_start_y = max(0, -y) + mask_end_y = mask_start_y + (end_y - start_y) + mask_start_x = max(0, -x) + mask_end_x = mask_start_x + (end_x - start_x) + + if mask_end_y > mask_start_y and mask_end_x > mask_start_x: + canvas_mask[start_y:end_y, start_x:end_x] = alpha_resized[mask_start_y:mask_end_y, mask_start_x:mask_end_x] + + # 对整张图应用模糊 + blurred = cv2.GaussianBlur(canvas_array, (blur_radius * 2 + 1, blur_radius * 2 + 1), blur_radius) + + # 归一化mask(人物区域=1保持清晰,背景区域=0使用模糊) + mask_normalized = canvas_mask.astype(float) / 255.0 + + # 使用mask混合:人物区域保持清晰,背景区域模糊 + mask_3d = np.stack([mask_normalized] * 3, axis=2) + result = (canvas_array * mask_3d + blurred * (1 - mask_3d)).astype(np.uint8) + + return Image.fromarray(result) + + except Exception as e: + logger.warning(f"应用人物背景模糊失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + return canvas + + def _create_canvas(self, template: Dict[str, Any]) -> Image.Image: + """创建画布""" + width, height = self.template_config['canvas_size'] + + # 创建背景 + if template.get('background_color'): + canvas = Image.new('RGB', (width, height), template['background_color']) + else: + canvas = Image.new('RGB', (width, height), self.template_config['background_color']) + + return canvas + + def _apply_background_effect(self, canvas: Image.Image, background_path: str, + template: Dict[str, Any]) -> Image.Image: + """应用背景效果""" + try: + # 读取背景图 + background = Image.open(background_path).convert('RGB') + + # 调整背景图大小 + canvas_width, canvas_height = canvas.size + background = self._resize_image(background, (canvas_width, canvas_height)) + + effect_type = template.get('background_effect', 'blur') + + if effect_type == 'blur': + # 高斯模糊 + background = background.filter(ImageFilter.GaussianBlur(radius=10)) + + elif effect_type == 'gradient': + # 渐变覆盖(从下到上,从透明到半透明黑色) + overlay = Image.new('RGBA', background.size, (0, 0, 0, 0)) + for y in range(background.height): + alpha = int(180 * (1 - y / background.height)) # 从下到上逐渐变透明 + for x in range(background.width): + overlay.putpixel((x, y), (0, 0, 0, alpha)) + background = Image.alpha_composite(background.convert('RGBA'), overlay).convert('RGB') + + elif effect_type == 'solid': + # 斜切色块背景(cut_angle模板使用) + logger.info("应用斜切色块效果(solid)") + # 先添加半透明的深色覆盖层 + overlay = Image.new('RGBA', background.size, (20, 20, 30, 200)) + background = Image.alpha_composite(background.convert('RGBA'), overlay).convert('RGB') + + # 添加斜切色块效果 + if CV2_AVAILABLE: + logger.info("使用OpenCV绘制斜切多边形") + # 使用OpenCV绘制斜切多边形 + bg_array = np.array(background) + h, w = bg_array.shape[:2] + + # 创建一个斜切的多边形(从左上角到右下角的斜切) + # 定义多边形的顶点(斜切形状) + points = np.array([ + [0, 0], # 左上 + [int(w * 0.3), 0], # 右上(斜切点1) + [int(w * 0.7), h], # 右下(斜切点2) + [0, h] # 左下 + ], np.int32) + + # 创建mask + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [points], 255) + + # 创建色块(使用亮色,如黄色或橙色) + color_block = np.zeros_like(bg_array) + color_block[:] = (255, 200, 50) # 金黄色 + + # 将色块应用到背景(使用mask,60%透明度) + mask_3d = np.stack([mask] * 3, axis=2) / 255.0 + bg_array = (bg_array * (1 - mask_3d * 0.6) + color_block * (mask_3d * 0.6)).astype(np.uint8) + + background = Image.fromarray(bg_array) + logger.info("斜切色块效果已应用(OpenCV)") + else: + # 如果没有OpenCV,使用PIL绘制简单的斜切矩形 + draw = ImageDraw.Draw(background, 'RGBA') + w, h = background.size + # 绘制一个斜切的矩形(使用多边形) + points = [ + (0, 0), + (int(w * 0.3), 0), + (int(w * 0.7), h), + (0, h) + ] + draw.polygon(points, fill=(255, 200, 50, 150)) + logger.info("斜切色块效果已应用(PIL)") + + elif effect_type == 'blur-person': + # 人物突出背景虚化(需要在添加人物后处理) + blur_radius = template.get('blur_intensity', 8) + background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + # 将背景合成到画布 + canvas.paste(background, (0, 0)) + + return canvas + + except Exception as e: + logger.warning(f"应用背景效果失败: {e}") + return canvas + + def _add_person(self, canvas: Image.Image, person_path: str, + template: Dict[str, Any], person_mask: Optional[np.ndarray] = None) -> Image.Image: + """添加人物(支持描边效果) + + Args: + canvas: 画布 + person_path: 人物图路径 + template: 模板配置 + person_mask: 人物mask(用于描边) + """ + try: + if not person_path: + logger.warning("人物路径为空,跳过添加人物") + return canvas + + # 确保使用绝对路径 + if not os.path.isabs(person_path): + person_path_abs = os.path.abspath(person_path) + else: + person_path_abs = person_path + + logger.info(f"添加人物 - 读取图像: {person_path_abs}") + if not os.path.exists(person_path_abs): + logger.warning(f"人物图像文件不存在: {person_path_abs}") + return canvas + + # 读取人物图(应该是带透明背景的PNG) + person = Image.open(person_path_abs) + logger.info(f"人物图像读取成功: {person.size}, mode: {person.mode}") + original_person = person.copy() + + # 保存alpha通道(如果有) + alpha_channel = None + if person.mode == 'RGBA': + # 保留alpha通道用于透明合成 + alpha_channel = person.split()[-1] + # 保持RGBA格式,不要转换为RGB + person_rgba = person + elif person.mode == 'RGB': + # 没有alpha通道,创建不透明的人物图 + person_rgba = person + alpha_channel = None + else: + person_rgba = person.convert('RGBA') + alpha_channel = person_rgba.split()[-1] if person_rgba.mode == 'RGBA' else None + + # 计算人物大小和位置 + canvas_width, canvas_height = canvas.size + + # 检查是否有自定义位置和大小 + person_position_custom = template.get('person_position_custom') + person_size_custom = template.get('person_size') + + if person_position_custom and person_size_custom is not None: + # 使用自定义位置和大小(百分比坐标) + person_size_ratio = person_size_custom # 已经是0-1的小数 + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_rgba.height / person_rgba.width) + + # 确保不超过画布高度 + if person_height > canvas_height * 0.95: + person_height = int(canvas_height * 0.95) + person_width = int(person_height * person_rgba.width / person_rgba.height) + + # 计算位置(百分比坐标转换为像素) + x = int((person_position_custom.get('x', 50) / 100.0) * canvas_width) + y = int((person_position_custom.get('y', 50) / 100.0) * canvas_height) + # 调整为中心对齐(因为位置是中心点) + x = x - person_width // 2 + y = y - person_height // 2 + # 确保在画布范围内 + x = max(0, min(x, canvas_width - person_width)) + y = max(0, min(y, canvas_height - person_height)) + logger.info(f"使用自定义位置和大小 - 大小: {person_size_ratio}, 位置: ({x}, {y})") + else: + # 使用预设位置和大小 + person_size_ratio = template.get('person_size', 0.8) + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_rgba.height / person_rgba.width) + + # 确保不超过画布高度 + if person_height > canvas_height * 0.9: + person_height = int(canvas_height * 0.9) + person_width = int(person_height * person_rgba.width / person_rgba.height) + + # 计算位置 + person_position = template.get('person_position', 'center') + x, y = self._calculate_position(person_position, (person_width, person_height), canvas.size) + + person_resized = person_rgba.resize((person_width, person_height), Image.LANCZOS) + + # 提取alpha通道 + if person_resized.mode == 'RGBA': + alpha_resized = person_resized.split()[-1] + else: + alpha_resized = None + + # 检查是否启用描边(对于blur-person模板或自定义模板) + background_effect = template.get('background_effect') + is_custom_template = template.get('is_custom', False) + cover_style = template.get('cover_style', {}) + person_border_enabled = cover_style.get('personBorderEnabled') + if person_border_enabled is None: + # 如果没有设置,从template中读取 + person_border_enabled = template.get('person_border_enabled') + if person_border_enabled is None: + # 如果还是没有设置,对于blur-person模板默认启用,其他模板默认禁用 + person_border_enabled = (background_effect == 'blur-person' or background_effect == 'blur_person') + + logger.info(f"添加人物 - background_effect: {background_effect}, is_custom: {is_custom_template}, personBorderEnabled: {person_border_enabled}") + logger.info(f"描边启用状态 - personBorderEnabled: {person_border_enabled}, 来源: cover_style={cover_style.get('personBorderEnabled')}, template={template.get('person_border_enabled')}") + + # 如果是blur-person模板或启用了描边,需要先合成人物,然后添加描边 + needs_border = (background_effect == 'blur-person' or background_effect == 'blur_person' or person_border_enabled) + + if needs_border: + logger.info("需要添加描边:合成人物并添加描边") + # 先合成人物(使用透明背景) + if alpha_resized is not None: + # 将alpha_resized转换为PIL Image mask + if isinstance(alpha_resized, np.ndarray): + alpha_mask = Image.fromarray(alpha_resized) + else: + alpha_mask = alpha_resized + canvas.paste(person_resized, (x, y), mask=alpha_mask) + logger.info(f"人物已合成到位置 ({x}, {y}),使用alpha通道") + else: + canvas.paste(person_resized, (x, y)) + logger.info(f"人物已合成到位置 ({x}, {y}),无alpha通道") + + if person_border_enabled: + # 然后添加描边(描边是直接绘制在画布上的) + logger.info("开始添加人物描边") + try: + # 如果有person_mask(numpy array),优先使用它,否则使用alpha_resized + border_mask = None + if person_mask is not None and isinstance(person_mask, np.ndarray): + # 调整person_mask大小以匹配person_resized + mask_h, mask_w = person_mask.shape[:2] + if mask_h != person_height or mask_w != person_width: + border_mask = cv2.resize(person_mask, (person_width, person_height)) + logger.info(f"使用person_mask进行描边,已调整大小: {border_mask.shape}") + else: + border_mask = person_mask + logger.info(f"使用person_mask进行描边,原始大小: {border_mask.shape}") + elif alpha_resized is not None: + # 使用alpha_resized作为mask + if isinstance(alpha_resized, np.ndarray): + border_mask = alpha_resized + else: + border_mask = np.array(alpha_resized) + logger.info(f"使用alpha_resized进行描边: {border_mask.shape}") + + if border_mask is not None: + canvas = self._add_person_border_to_canvas(canvas, person_resized, border_mask, (x, y), template) + logger.info("人物描边添加完成") + else: + logger.warning("无法获取mask,跳过描边") + except Exception as e: + logger.warning(f"添加人物描边失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + else: + logger.info("描边已禁用,跳过描边处理") + else: + # 普通模板,直接合成人物(使用透明背景) + logger.info(f"普通模板:合成人物到位置 ({x}, {y})") + if alpha_resized is not None: + if isinstance(alpha_resized, np.ndarray): + alpha_mask = Image.fromarray(alpha_resized) + else: + alpha_mask = alpha_resized + canvas.paste(person_resized, (x, y), mask=alpha_mask) + else: + canvas.paste(person_resized, (x, y)) + + return canvas + + except Exception as e: + logger.warning(f"添加人物失败: {e}") + return canvas + + def _create_person_with_border(self, person_path: str, template: Dict[str, Any], + person_mask: Optional[np.ndarray] = None) -> Tuple[Image.Image, Dict[str, int]]: + """在透明画布上创建带描边的人物图 + + Args: + person_path: 人物图路径 + template: 模板配置 + person_mask: 人物mask(用于描边) + + Returns: + (带描边的人物图, 位置信息字典 {'x': int, 'y': int, 'width': int, 'height': int}) + """ + try: + # 读取人物图 + if not os.path.isabs(person_path): + person_path_abs = os.path.abspath(person_path) + else: + person_path_abs = person_path + + person_img = Image.open(person_path_abs) + if person_img.mode != 'RGBA': + person_img = person_img.convert('RGBA') + + # 计算人物大小和位置(基于画布尺寸) + canvas_width, canvas_height = 1080, 1920 # 标准画布尺寸 + person_position_custom = template.get('person_position_custom') + person_size_custom = template.get('person_size') + + if person_position_custom and person_size_custom is not None: + person_size_ratio = person_size_custom + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_img.height / person_img.width) + if person_height > canvas_height * 0.95: + person_height = int(canvas_height * 0.95) + person_width = int(person_height * person_img.width / person_img.height) + x = int((person_position_custom.get('x', 50) / 100.0) * canvas_width) - person_width // 2 + y = int((person_position_custom.get('y', 50) / 100.0) * canvas_height) - person_height // 2 + x = max(0, min(x, canvas_width - person_width)) + y = max(0, min(y, canvas_height - person_height)) + else: + person_size_ratio = template.get('person_size', 0.8) + person_width = int(canvas_width * person_size_ratio) + person_height = int(person_width * person_img.height / person_img.width) + if person_height > canvas_height * 0.9: + person_height = int(canvas_height * 0.9) + person_width = int(person_height * person_img.width / person_img.height) + person_position = template.get('person_position', 'center') + x, y = self._calculate_position(person_position, (person_width, person_height), (canvas_width, canvas_height)) + + # 调整人物图大小 + person_resized = person_img.resize((person_width, person_height), Image.LANCZOS) + + # 提取alpha通道 + if person_resized.mode == 'RGBA': + alpha_resized = person_resized.split()[-1] + # 转换为numpy array用于描边 + if isinstance(alpha_resized, Image.Image): + alpha_array = np.array(alpha_resized) + else: + alpha_array = alpha_resized + else: + alpha_array = None + + # 如果有person_mask,调整大小 + border_mask = None + if person_mask is not None and isinstance(person_mask, np.ndarray): + mask_h, mask_w = person_mask.shape[:2] + if mask_h != person_height or mask_w != person_width: + border_mask = cv2.resize(person_mask, (person_width, person_height)) + else: + border_mask = person_mask + elif alpha_array is not None: + border_mask = alpha_array + + # 创建带描边的人物图(在透明画布上) + # 这个方法会返回扩展后的图像(包含padding) + person_with_border = self._add_person_border_to_canvas(None, person_resized, + border_mask if border_mask is not None else alpha_array, + (0, 0), template) + + # 获取描边宽度以计算padding + cover_style = template.get('cover_style', {}) + border_width = cover_style.get('personBorderWidth') + if border_width is None: + border_width = template.get('person_border_width', 6) + padding = border_width * 2 + + # 调整位置信息以考虑padding(图像扩大了,所以位置需要向左上偏移) + adjusted_x = x - padding + adjusted_y = y - padding + adjusted_width = person_width + padding * 2 + adjusted_height = person_height + padding * 2 + + position_info = { + 'x': adjusted_x, + 'y': adjusted_y, + 'width': adjusted_width, + 'height': adjusted_height + } + logger.info(f"位置信息已调整 - 原始: ({x}, {y}, {person_width}x{person_height}), 调整后: ({adjusted_x}, {adjusted_y}, {adjusted_width}x{adjusted_height}), padding: {padding}") + return person_with_border, position_info + + except Exception as e: + logger.warning(f"创建带描边的人物图失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + # 返回原始人物图和默认位置 + person_img = Image.open(person_path).convert('RGBA') + canvas_width, canvas_height = 1080, 1920 + person_width = int(canvas_width * 0.8) + person_height = int(person_width * person_img.height / person_img.width) + person_resized = person_img.resize((person_width, person_height), Image.LANCZOS) + x, y = self._calculate_position('center', (person_width, person_height), (canvas_width, canvas_height)) + return person_resized, {'x': x, 'y': y, 'width': person_width, 'height': person_height} + + def _add_person_border_to_canvas(self, canvas: Optional[Image.Image], person_img: Image.Image, + mask: Optional[Union[Image.Image, np.ndarray]], position: Tuple[int, int], + template: Dict[str, Any]) -> Image.Image: + """在画布上为人物添加描边效果(如果canvas为None,则在透明画布上创建带描边的人物图) + + Args: + canvas: 画布(如果为None,则创建新的透明画布) + person_img: 人物图像 + mask: 人物mask(alpha通道) + position: 人物在画布上的位置 (x, y)(如果canvas为None,则忽略) + template: 模板配置 + + Returns: + 如果canvas不为None,返回添加描边后的画布;如果canvas为None,返回带描边的人物图 + """ + try: + if not CV2_AVAILABLE: + logger.warning("opencv不可用,跳过描边效果") + return canvas if canvas is not None else person_img + + x, y = position + + # 获取mask + logger.info(f"描边 - person_img.mode: {person_img.mode}, mask类型: {type(mask)}") + if mask is None: + # 从人物图像提取alpha通道作为mask + if person_img.mode == 'RGBA': + # 直接从RGBA图像提取alpha通道 + try: + mask_array = np.array(person_img.split()[-1]) + logger.info(f"从RGBA图像提取alpha通道成功,尺寸: {mask_array.shape}, 值范围: {mask_array.min()}-{mask_array.max()}") + except Exception as e: + logger.warning(f"提取alpha通道失败: {e},尝试从图像创建mask") + person_array = np.array(person_img.convert('RGB')) + gray = cv2.cvtColor(person_array, cv2.COLOR_RGB2GRAY) + _, mask_array = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY_INV) + else: + # 如果没有alpha通道,尝试从图像创建mask + person_array = np.array(person_img.convert('RGB')) + # 转换为灰度图 + gray = cv2.cvtColor(person_array, cv2.COLOR_RGB2GRAY) + # 使用阈值创建mask(假设背景较亮) + _, mask_array = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY_INV) + logger.info(f"从RGB图像创建mask,尺寸: {mask_array.shape}") + else: + # 使用提供的mask + if isinstance(mask, Image.Image): + mask_array = np.array(mask) + else: + mask_array = mask + if len(mask_array.shape) == 3: + mask_array = cv2.cvtColor(mask_array, cv2.COLOR_RGB2GRAY) + elif len(mask_array.shape) == 2: + pass # 已经是单通道 + else: + logger.warning("mask格式不正确") + return canvas + + # 使用适中的阈值来二值化mask + _, mask_array = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY) + logger.info(f"mask二值化完成 - 非零像素: {np.count_nonzero(mask_array)}, 总像素: {mask_array.size}") + + # 中等强度的边缘平滑:平衡均匀性和自然曲线 + smooth_kernel_size = max(7, border_width) + if smooth_kernel_size % 2 == 0: + smooth_kernel_size += 1 + smooth_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (smooth_kernel_size, smooth_kernel_size)) + + # 闭运算:填充小孔和平滑凹陷 + mask_array = cv2.morphologyEx(mask_array, cv2.MORPH_CLOSE, smooth_kernel) + # 开运算:去除小突起和平滑凸出 + mask_array = cv2.morphologyEx(mask_array, cv2.MORPH_OPEN, smooth_kernel) + + logger.info(f"mask边缘中等平滑完成 - smooth_kernel_size: {smooth_kernel_size}") + + # 描边参数(优先使用cover_style中的配置) + cover_style = template.get('cover_style', {}) + logger.info(f"描边函数 - cover_style内容: {cover_style}") + logger.info(f"描边函数 - template中的person_border_color: {template.get('person_border_color')}") + logger.info(f"描边函数 - template中的person_border_width: {template.get('person_border_width')}") + + # 优先使用cover_style中的配置,然后使用template中的配置,最后使用默认值 + border_width = cover_style.get('personBorderWidth') + if border_width is None: + border_width = template.get('person_border_width', 6) + + border_color = cover_style.get('personBorderColor') + if border_color is None: + border_color = template.get('person_border_color', '#FFFFFF') + + border_style = cover_style.get('personBorderStyle') + if border_style is None: + border_style = template.get('person_border_style', 'solid') + + logger.info(f"描边参数 - width: {border_width}, color: {border_color}, style: {border_style}") + logger.info(f"描边参数来源 - cover_style.personBorderWidth: {cover_style.get('personBorderWidth')}, template.person_border_width: {template.get('person_border_width')}") + logger.info(f"描边参数来源 - cover_style.personBorderColor: {cover_style.get('personBorderColor')}, template.person_border_color: {template.get('person_border_color')}") + logger.info(f"描边参数来源 - cover_style.personBorderStyle: {cover_style.get('personBorderStyle')}, template.person_border_style: {template.get('person_border_style')}") + if border_style == 'dashed' or str(border_style).lower() == 'dashed': + logger.info(f"虚线参数来源 - cover_style.personBorderDashLength: {cover_style.get('personBorderDashLength')}, template.person_border_dash_length: {template.get('person_border_dash_length')}") + logger.info(f"虚线参数来源 - cover_style.personBorderGapLength: {cover_style.get('personBorderGapLength')}, template.person_border_gap_length: {template.get('person_border_gap_length')}") + + # 转换颜色 + if isinstance(border_color, str): + if border_color.startswith('#'): + try: + # 确保颜色字符串格式正确(6位十六进制) + color_hex = border_color[1:] if border_color.startswith('#') else border_color + if len(color_hex) == 6: + border_rgb = tuple(int(color_hex[i:i+2], 16) for i in (0, 2, 4)) + elif len(color_hex) == 3: + # 支持3位十六进制(如 #FFF) + border_rgb = tuple(int(c*2, 16) for c in color_hex) + else: + border_rgb = self._parse_color(border_color) + except (ValueError, IndexError) as e: + logger.warning(f"颜色解析失败: {border_color}, 错误: {e},使用默认白色") + border_rgb = (255, 255, 255) + else: + # 使用颜色名称(如 'white', 'red' 等) + border_rgb = self._parse_color(border_color) + else: + border_rgb = (255, 255, 255) # 默认白色 + + logger.info(f"描边颜色RGB: {border_rgb} (来源: {border_color})") + + # 使用多次小幅度膨胀生成完全均匀的描边 + # 这种方法比一次大膨胀更均匀 + + # 使用小的椭圆kernel进行多次膨胀 + small_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + + # 计算需要膨胀的次数(基于border_width) + iterations = max(1, border_width) + + # 执行膨胀 + dilated_mask = mask_array.copy() + for i in range(iterations): + dilated_mask = cv2.dilate(dilated_mask, small_kernel, iterations=1) + + # 边缘 = 膨胀后的mask - 原始mask + border_mask = cv2.subtract(dilated_mask, mask_array) + + logger.info(f"使用多次膨胀生成描边 - iterations: {iterations}, border_mask非零像素: {np.count_nonzero(border_mask)}") + + # 如果是虚线样式,需要进一步处理 + if border_style == 'dashed': + # 虚线处理会在后面的_create_dashed_border中进行 + pass + else: + # 如果轮廓检测失败,回退到形态学方法 + logger.warning("轮廓检测失败,使用形态学方法生成描边") + kernel_size = border_width * 2 + 1 + kernel = np.ones((kernel_size, kernel_size), np.uint8) + iterations = max(1, border_width // 2) + dilated_mask = cv2.dilate(mask_array, kernel, iterations=iterations) + # 确保只得到边缘:膨胀后的mask减去原始mask + border_mask = cv2.subtract(dilated_mask, mask_array) + logger.info(f"使用形态学方法生成描边 - border_width: {border_width}, iterations: {iterations}, border_mask非零像素: {np.count_nonzero(border_mask)}") + + # 如果是虚线样式,需要处理border_mask(必须在应用描边之前处理) + logger.info(f"检查描边样式 - border_style: {border_style}, type: {type(border_style)}") + if border_style == 'dashed' or str(border_style).lower() == 'dashed': + # 获取虚线参数(优先使用cover_style中的配置) + # 注意:前端可能传递 null/None,需要正确处理 + dash_length_raw = cover_style.get('personBorderDashLength') + logger.info(f"虚线长度读取 - cover_style.personBorderDashLength: {dash_length_raw}, type: {type(dash_length_raw)}") + if dash_length_raw is None or dash_length_raw == 0 or dash_length_raw == '': + dash_length_raw = template.get('person_border_dash_length') + logger.info(f"虚线长度读取 - template.person_border_dash_length: {dash_length_raw}, type: {type(dash_length_raw)}") + + # 转换为整数(如果有效),否则为None + if dash_length_raw is not None and dash_length_raw != 0 and dash_length_raw != '': + try: + dash_length = int(float(dash_length_raw)) # 支持字符串数字 + logger.info(f"虚线长度已转换: {dash_length}") + except (ValueError, TypeError): + dash_length = None + logger.warning(f"虚线长度转换失败: {dash_length_raw}") + else: + dash_length = None + logger.info(f"虚线长度未设置,将使用默认值") + + gap_length_raw = cover_style.get('personBorderGapLength') + logger.info(f"虚线间隔读取 - cover_style.personBorderGapLength: {gap_length_raw}, type: {type(gap_length_raw)}") + if gap_length_raw is None or gap_length_raw == 0 or gap_length_raw == '': + gap_length_raw = template.get('person_border_gap_length') + logger.info(f"虚线间隔读取 - template.person_border_gap_length: {gap_length_raw}, type: {type(gap_length_raw)}") + + # 转换为整数(如果有效),否则为None + if gap_length_raw is not None and gap_length_raw != 0 and gap_length_raw != '': + try: + gap_length = int(float(gap_length_raw)) # 支持字符串数字 + logger.info(f"虚线间隔已转换: {gap_length}") + except (ValueError, TypeError): + gap_length = None + logger.warning(f"虚线间隔转换失败: {gap_length_raw}") + else: + gap_length = None + logger.info(f"虚线间隔未设置,将使用默认值") + + logger.info(f"虚线参数最终值 - border_width: {border_width}, dash_length: {dash_length}, gap_length: {gap_length}") + logger.info(f"虚线参数来源 - cover_style.personBorderDashLength: {cover_style.get('personBorderDashLength')}, template.person_border_dash_length: {template.get('person_border_dash_length')}") + logger.info(f"虚线参数来源 - cover_style.personBorderGapLength: {cover_style.get('personBorderGapLength')}, template.person_border_gap_length: {template.get('person_border_gap_length')}") + border_mask = self._create_dashed_border(border_mask, border_width, dash_length, gap_length) + logger.info(f"虚线描边已应用 - border_width: {border_width}, dash_length: {dash_length}, gap_length: {gap_length}") + + # 新的逻辑:扩大画布以容纳描边,避免描边被裁剪 + # 1. 计算需要扩展的边距(描边宽度的3倍,确保足够空间) + padding = border_width * 3 + person_width, person_height = person_img.size + expanded_width = person_width + padding * 2 + expanded_height = person_height + padding * 2 + + # 2. 创建扩展后的透明画布 + person_with_border = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0)) + + # 3. 将人物图像粘贴到扩展画布的中心 + if person_img.mode == 'RGBA': + person_with_border.paste(person_img, (padding, padding)) + else: + person_with_border.paste(person_img.convert('RGBA'), (padding, padding)) + + logger.info(f"创建扩展画布用于描边: {expanded_width}x{expanded_height} (原始: {person_width}x{person_height}, padding: {padding})") + + # 4. 将已经生成好的均匀描边mask放到扩展画布上 + # 不要重新生成,直接使用之前通过多次膨胀生成的均匀border_mask + expanded_mask = np.zeros((expanded_height, expanded_width), dtype=np.uint8) + expanded_mask[padding:padding+person_height, padding:padding+person_width] = border_mask + + logger.info(f"将均匀描边mask放到扩展画布 - 非零像素: {np.count_nonzero(expanded_mask)}") + + # 5. 在扩展画布上绘制描边 + person_array = np.array(person_with_border) + border_mask_3d = np.stack([expanded_mask] * 3, axis=2) / 255.0 # 归一化到0-1 + + # 应用描边颜色 + border_rgb_array = np.array(border_rgb, dtype=np.float32) + h, w = person_array.shape[:2] + border_rgb_3d = np.tile(border_rgb_array.reshape(1, 1, 3), (h, w, 1)) + + # 分离RGB和Alpha通道 + person_rgb = person_array[:, :, :3].astype(np.float32) + person_alpha = person_array[:, :, 3:4].astype(np.float32) / 255.0 + + # 对RGB通道应用描边 + person_rgb = (person_rgb * (1 - border_mask_3d) + + border_rgb_3d * border_mask_3d).astype(np.uint8) + + # 对Alpha通道:描边区域不透明 + border_alpha_mask = expanded_mask[:, :, np.newaxis] / 255.0 + person_alpha_new = np.maximum(person_alpha, border_alpha_mask) * 255.0 + person_alpha_new = person_alpha_new.astype(np.uint8) + + # 合并RGB和Alpha通道 + person_array = np.concatenate([person_rgb, person_alpha_new], axis=2) + + person_with_border = Image.fromarray(person_array, 'RGBA') + logger.info(f"描边已应用到扩展画布,颜色RGB: {border_rgb}") + + # 6. 返回带描边的人物图像(包含padding) + logger.info(f"带描边的人物图已创建,尺寸: {expanded_width}x{expanded_height}") + return person_with_border + + except Exception as e: + logger.warning(f"创建带描边的人物图失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + return person_img # 返回原始人物图 + + def _add_person_border(self, person_img: Image.Image, mask: Optional[Image.Image], + template: Dict[str, Any]) -> Image.Image: + """为人物添加描边效果 + + Args: + person_img: 人物图像 + mask: 人物mask(alpha通道) + template: 模板配置 + + Returns: + 带描边的人物图像 + """ + try: + if not CV2_AVAILABLE: + logger.warning("opencv不可用,跳过描边效果") + return person_img + + # 如果没有mask,从图像创建 + if mask is None: + # 转换为numpy数组处理 + person_array = np.array(person_img) + # 创建简单的mask(基于非白色区域) + gray = cv2.cvtColor(person_array, cv2.COLOR_RGB2GRAY) if len(person_array.shape) == 3 else person_array + _, mask_array = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY_INV) + mask = Image.fromarray(mask_array) + else: + mask_array = np.array(mask) + + # 描边参数 + border_width = template.get('person_border_width', 3) + border_color = template.get('person_border_color', '#FFFFFF') + + # 转换颜色 + if isinstance(border_color, str) and border_color.startswith('#'): + border_rgb = tuple(int(border_color[i:i+2], 16) for i in (1, 3, 5)) + else: + border_rgb = (255, 255, 255) # 默认白色 + + # 使用形态学操作生成描边 + kernel_size = border_width * 2 + 1 + kernel = np.ones((kernel_size, kernel_size), np.uint8) + dilated_mask = cv2.dilate(mask_array, kernel, iterations=1) + border_mask = dilated_mask - mask_array + + # 创建描边图像 + person_array = np.array(person_img) + if len(person_array.shape) == 2: + person_array = cv2.cvtColor(person_array, cv2.COLOR_GRAY2RGB) + + # 应用描边 + border_mask_3d = np.stack([border_mask] * 3, axis=2) / 255.0 + person_with_border = person_array.copy() + person_with_border = (person_with_border * (1 - border_mask_3d) + + np.array(border_rgb) * border_mask_3d).astype(np.uint8) + + return Image.fromarray(person_with_border) + + except Exception as e: + logger.warning(f"添加人物描边失败: {e}") + return person_img + + def _add_text(self, canvas: Image.Image, text_info: Dict[str, Any], + template: Dict[str, Any]) -> Image.Image: + """添加文字""" + try: + draw = ImageDraw.Draw(canvas) + + # 获取封面样式配置(如果提供) + cover_style = text_info.get('cover_style', {}) + + # 调试日志 + logger.info(f"添加文字 - cover_style: {cover_style}") + logger.info(f"添加文字 - template: {template}") + + # 获取字体 + font_family = self.template_config.get('font_family') + font_path = self._find_font(font_family) + + # 标题 + title = text_info.get('title', '') or cover_style.get('title', '') or cover_style.get('titleText', '') + logger.info(f"添加文字 - title: '{title}', title长度: {len(title) if title else 0}") + if title: + # 获取字体族(优先使用自定义) + title_font_family = cover_style.get('titleFontFamily') or template.get('title_font_family') or font_family + logger.info(f"查找标题字体 - titleFontFamily: '{title_font_family}', 原始值: {cover_style.get('titleFontFamily')}, 模板值: {template.get('title_font_family')}, 默认值: {font_family}") + + # 获取字重(如果支持) + font_weight = cover_style.get('titleFontWeight', 700) if cover_style else 700 + + # 查找字体路径 + title_font_path = self._find_font(title_font_family, font_weight) + if not title_font_path: + logger.warning(f"未找到字体 '{title_font_family}',使用默认字体路径") + title_font_path = font_path + else: + logger.info(f"找到标题字体路径: {title_font_path}") + + # 使用cover_style中的字体大小,否则使用模板默认大小 + if cover_style and cover_style.get('titleFontSize'): + font_size = int(cover_style.get('titleFontSize')) + logger.info(f"使用cover_style标题字体大小: {font_size}") + elif cover_style and cover_style.get('fontSize'): + font_size = int(cover_style.get('fontSize')) + logger.info(f"使用cover_style字体大小: {font_size}") + else: + font_size = self._get_font_size(template, 'title') + logger.info(f"使用模板默认字体大小: {font_size}") + + font = self._load_font(title_font_path, font_size, font_weight) + + # 使用cover_style中的字体颜色,否则使用默认颜色 + if cover_style and cover_style.get('titleColor'): + text_color_str = cover_style.get('titleColor', 'white') + # 转换颜色字符串为RGB元组 + text_color = self._parse_color(text_color_str) + logger.info(f"使用cover_style标题颜色: {text_color_str} -> {text_color}") + elif cover_style and cover_style.get('fontColor'): + text_color_str = cover_style.get('fontColor', 'white') + text_color = self._parse_color(text_color_str) + logger.info(f"使用cover_style字体颜色: {text_color_str} -> {text_color}") + else: + text_color = text_info.get('color', self.template_config['text_color']) + logger.info(f"使用默认字体颜色: {text_color}") + + # 获取描边设置 + title_stroke_color = cover_style.get('titleStrokeColor', '#000000') if cover_style else '#000000' + title_stroke_width = cover_style.get('titleStrokeWidth', 0) if cover_style else 0 + + bbox = draw.textbbox((0, 0), title, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 计算位置 - 优先使用自定义位置(百分比坐标) + title_position_custom = template.get('title_position_custom') + if title_position_custom: + # 使用自定义位置(百分比坐标) + canvas_width, canvas_height = canvas.size + x = int((title_position_custom.get('x', 50) / 100.0) * canvas_width) + y = int((title_position_custom.get('y', 50) / 100.0) * canvas_height) + # 调整为中心对齐(因为位置是中心点) + x = x - text_width // 2 + y = y - text_height // 2 + # 确保在画布范围内 + x = max(0, min(x, canvas_width - text_width)) + y = max(0, min(y, canvas_height - text_height)) + logger.info(f"使用自定义标题位置: ({x}, {y})") + elif cover_style and cover_style.get('position'): + text_position = cover_style.get('position', 'center') + logger.info(f"使用cover_style位置: {text_position}") + x, y = self._calculate_text_position(text_position, (text_width, text_height), canvas.size) + else: + text_position = template.get('text_position', 'center') + logger.info(f"使用模板位置: {text_position}") + x, y = self._calculate_text_position(text_position, (text_width, text_height), canvas.size) + + # 检查是否需要文字背景框(新的独立文字背景功能) + # 优先使用新的 titleBackgroundEnabled 配置 + title_bg_enabled = cover_style.get('titleBackgroundEnabled') if cover_style else False + + # 兼容旧配置 + if not title_bg_enabled: + template_bg_color = template.get('text_background_color') + has_background = cover_style.get('backgroundColor', False) if cover_style else False + if template_bg_color: + has_background = True + else: + has_background = True + + # 如果有背景框,先绘制背景 + if has_background: + if title_bg_enabled: + # 使用新的独立文字背景配置 + title_bg_color = cover_style.get('titleBackgroundColor', '#000000') + title_bg_opacity = cover_style.get('titleBackgroundOpacity', 70) + title_bg_shape = cover_style.get('titleBackgroundShape', 'rectangle') + title_bg_size = cover_style.get('titleBackgroundSize', {'width': 100, 'height': 50}) + title_bg_position = cover_style.get('titleBackgroundPosition', {'x': 0, 'y': 0}) + title_bg_radius = cover_style.get('titleBackgroundRadius', 10) + title_bg_points = cover_style.get('titleBackgroundPoints', []) + + logger.info(f"绘制主标题背景 - 颜色: {title_bg_color}, 透明度: {title_bg_opacity}%, 形状: {title_bg_shape}") + + # 绘制主标题背景(保持RGBA模式以支持透明度) + canvas_rgba = canvas.convert('RGBA') + canvas_rgba = self._draw_text_background( + canvas_rgba, x, y, text_width, text_height, + title_bg_color, title_bg_opacity, title_bg_shape, + title_bg_size, title_bg_position, title_bg_radius, title_bg_points + ) + # 保持RGBA模式用于绘制文字(确保透明度正确) + canvas = canvas_rgba + logger.info(f"主标题背景绘制完成,画布模式: {canvas.mode}") + draw = ImageDraw.Draw(canvas) # 重新创建draw对象 + else: + # 兼容旧配置 + logger.info("绘制文字背景框") + # 计算背景框的宽度(基于maxWidth百分比) + max_width_percent = cover_style.get('maxWidth', 80) if cover_style else 80 + canvas_width = canvas.width + max_text_width = int(canvas_width * max_width_percent / 100) + + # 如果文字宽度超过最大宽度,需要换行(简化处理:缩小字体或截断) + actual_text_width = min(text_width, max_text_width) + + # 背景框的padding + padding_x = 20 + padding_y = 15 + + # 计算背景框位置和大小 + bg_x = max(0, x - padding_x) + bg_y = max(0, y - padding_y) + bg_width = min(actual_text_width + padding_x * 2, canvas_width - bg_x) + bg_height = text_height + padding_y * 2 + + # 确定背景颜色 + background_opacity = cover_style.get('backgroundOpacity', 70) if cover_style else 70 + if template_bg_color: + bg_rgb = self._parse_color(template_bg_color) + bg_color = (*bg_rgb, background_opacity) + else: + bg_color = (0, 0, 0, int(255 * background_opacity / 100)) + + bg_overlay = Image.new('RGBA', canvas.size, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg_overlay) + template_bg_shape = template.get('text_background_shape', 'rectangle') + + # 根据形状绘制背景 + if template_bg_shape == 'irregular' and CV2_AVAILABLE: + # 不规则边缘(使用波浪边缘) + try: + mask = np.zeros((bg_height, bg_width), dtype=np.uint8) + + # 创建波浪边缘效果 + points = [] + wave_amplitude = 8 + wave_frequency = 0.1 + + # 上边缘(波浪) + for i in range(bg_width): + wave_y = int(wave_amplitude * np.sin(i * wave_frequency)) + points.append((i, max(0, wave_y))) + + # 右边缘 + for i in range(bg_height): + points.append((bg_width - 1, i)) + + # 下边缘(波浪) + for i in range(bg_width - 1, -1, -1): + wave_y = bg_height - 1 - int(wave_amplitude * np.sin(i * wave_frequency)) + points.append((i, min(bg_height - 1, wave_y))) + + # 左边缘 + for i in range(bg_height - 1, -1, -1): + points.append((0, i)) + + # 填充多边形 + points_array = np.array(points, np.int32) + cv2.fillPoly(mask, [points_array], 255) + + # 转换为PIL Image并应用颜色 + mask_img = Image.fromarray(mask, mode='L') + bg_img = Image.new('RGBA', (bg_width, bg_height), bg_color) + bg_img.putalpha(mask_img) + + # 粘贴到overlay + bg_overlay.paste(bg_img, (bg_x, bg_y), mask_img) + logger.info("不规则背景已绘制") + except Exception as e: + logger.warning(f"绘制不规则背景失败: {e},使用矩形背景") + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_color + ) + else: + # 矩形背景 + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_color + ) + + canvas = Image.alpha_composite(canvas.convert('RGBA'), bg_overlay).convert('RGB') + draw = ImageDraw.Draw(canvas) # 重新创建draw对象 + + # 检查是否需要文字描边(优先使用自定义描边设置) + if title_stroke_width > 0: + # 绘制文字描边(使用多次偏移绘制实现描边效果) + logger.info(f"绘制标题描边 - 颜色: {title_stroke_color}, 宽度: {title_stroke_width}") + outline_color = self._parse_color(title_stroke_color) + # 在多个方向绘制描边 + for dx in range(-title_stroke_width, title_stroke_width + 1): + for dy in range(-title_stroke_width, title_stroke_width + 1): + if dx*dx + dy*dy <= title_stroke_width*title_stroke_width: + draw.text((x + dx, y + dy), title, fill=outline_color, font=font) + else: + # 检查模板定义的描边 + text_outline = template.get('text_outline', False) + text_outline_color = template.get('text_outline_color', '#FFFFFF') + text_outline_width = template.get('text_outline_width', 3) + + # 添加文字阴影或描边 + effect_type = cover_style.get('effectType', 'none') if cover_style else 'none' + + if text_outline: + # 绘制文字描边(使用多次偏移绘制实现描边效果) + logger.info(f"绘制文字描边 - 颜色: {text_outline_color}, 宽度: {text_outline_width}") + outline_color = self._parse_color(text_outline_color) + # 在多个方向绘制描边 + for dx in range(-text_outline_width, text_outline_width + 1): + for dy in range(-text_outline_width, text_outline_width + 1): + if dx*dx + dy*dy <= text_outline_width*text_outline_width: + draw.text((x + dx, y + dy), title, fill=outline_color, font=font) + elif effect_type == 'shadow' or (effect_type == 'none' and self.template_config.get('text_shadow', True)): + shadow_color = self.template_config.get('text_shadow_color', (0, 0, 0)) + shadow_offset = self.template_config.get('text_shadow_offset', (2, 2)) + draw.text((x + shadow_offset[0], y + shadow_offset[1]), title, + fill=shadow_color, font=font) + + # 绘制主要文字(如果fontWeight >= 800,使用粗体效果) + # 如果canvas是RGBA模式,确保颜色包含alpha通道 + if canvas.mode == 'RGBA': + if isinstance(text_color, tuple) and len(text_color) == 3: + text_color_rgba = (*text_color, 255) # 添加完全不透明的alpha + else: + text_color_rgba = text_color + if font_weight >= 800: + self._apply_bold_effect(draw, title, (x, y), font, text_color_rgba, font_weight) + else: + draw.text((x, y), title, fill=text_color_rgba, font=font) + else: + if font_weight >= 800: + self._apply_bold_effect(draw, title, (x, y), font, text_color, font_weight) + else: + draw.text((x, y), title, fill=text_color, font=font) + logger.info(f"文字已绘制到位置 ({x}, {y}),颜色: {text_color}, 字体大小: {font_size}, 字重: {font_weight}") + print(f"文字已绘制: '{title}' 到位置 ({x}, {y})", file=sys.stderr, flush=True) + + # 副标题 + subtitle = text_info.get('subtitle', '') or cover_style.get('subtitle', '') or cover_style.get('subtitleText', '') + if subtitle: + # 获取字体族(优先使用自定义) + subtitle_font_family = cover_style.get('subtitleFontFamily') or template.get('subtitle_font_family') or font_family + subtitle_font_path = self._find_font(subtitle_font_family) or font_path + + # 使用cover_style中的字体大小 + if cover_style and cover_style.get('subtitleFontSize'): + font_size = int(cover_style.get('subtitleFontSize')) + logger.info(f"使用cover_style副标题字体大小: {font_size}") + else: + font_size = self._get_font_size(template, 'subtitle') + logger.info(f"使用模板默认副标题字体大小: {font_size}") + + # 获取字重 + subtitle_font_weight = cover_style.get('subtitleFontWeight', 400) if cover_style else 400 + # 尝试查找粗体字体 + if subtitle_font_weight >= 700: + bold_font_path = self._find_font(subtitle_font_family, subtitle_font_weight) + if bold_font_path: + subtitle_font_path = bold_font_path + font = self._load_font(subtitle_font_path, font_size, subtitle_font_weight) + + # 获取副标题颜色 + if cover_style and cover_style.get('subtitleColor'): + subtitle_color_str = cover_style.get('subtitleColor', 'white') + subtitle_color = self._parse_color(subtitle_color_str) + logger.info(f"使用cover_style副标题颜色: {subtitle_color_str} -> {subtitle_color}") + else: + subtitle_color = text_info.get('color', self.template_config['text_color']) + logger.info(f"使用默认副标题颜色: {subtitle_color}") + + # 获取副标题描边设置 + subtitle_stroke_color = cover_style.get('subtitleStrokeColor', '#000000') if cover_style else '#000000' + subtitle_stroke_width = cover_style.get('subtitleStrokeWidth', 0) if cover_style else 0 + + bbox = draw.textbbox((0, 0), subtitle, font=font) + text_width = bbox[2] - bbox[0] + text_height_sub = bbox[3] - bbox[1] + + # 检查是否需要副标题背景 + subtitle_bg_enabled = cover_style.get('subtitleBackgroundEnabled') if cover_style else False + + # 计算副标题位置 - 优先使用自定义位置(百分比坐标) + subtitle_position_custom = template.get('subtitle_position_custom') + if subtitle_position_custom: + # 使用自定义位置(百分比坐标) + canvas_width, canvas_height = canvas.size + x_sub = int((subtitle_position_custom.get('x', 50) / 100.0) * canvas_width) + y_sub = int((subtitle_position_custom.get('y', 50) / 100.0) * canvas_height) + # 调整为中心对齐 + x_sub = x_sub - text_width // 2 + y_sub = y_sub - text_height_sub // 2 + # 确保在画布范围内 + x_sub = max(0, min(x_sub, canvas_width - text_width)) + y_sub = max(0, min(y_sub, canvas_height - text_height_sub)) + logger.info(f"使用自定义副标题位置: ({x_sub}, {y_sub})") + elif title: + # 副标题位置在标题下方 + x_sub = x + y_sub = y + text_height + 20 + else: + # 使用模板位置 + x_sub, y_sub = self._calculate_text_position(template.get('text_position', 'center'), + (text_width, text_height_sub), canvas.size) + + # 如果有副标题背景,先绘制背景 + if subtitle_bg_enabled: + subtitle_bg_color = cover_style.get('subtitleBackgroundColor', '#000000') + subtitle_bg_opacity = cover_style.get('subtitleBackgroundOpacity', 70) + subtitle_bg_shape = cover_style.get('subtitleBackgroundShape', 'rectangle') + subtitle_bg_size = cover_style.get('subtitleBackgroundSize', {'width': 100, 'height': 50}) + subtitle_bg_position = cover_style.get('subtitleBackgroundPosition', {'x': 0, 'y': 0}) + subtitle_bg_radius = cover_style.get('subtitleBackgroundRadius', 10) + subtitle_bg_points = cover_style.get('subtitleBackgroundPoints', []) + + # 绘制副标题背景(保持RGBA模式以支持透明度) + canvas_rgba = canvas.convert('RGBA') + canvas_rgba = self._draw_text_background( + canvas_rgba, x_sub, y_sub, text_width, text_height_sub, + subtitle_bg_color, subtitle_bg_opacity, subtitle_bg_shape, + subtitle_bg_size, subtitle_bg_position, subtitle_bg_radius, subtitle_bg_points + ) + # 保持RGBA模式用于绘制文字(确保透明度正确) + canvas = canvas_rgba + draw = ImageDraw.Draw(canvas) # 重新创建draw对象 + + # 绘制副标题描边 + if subtitle_stroke_width > 0: + logger.info(f"绘制副标题描边 - 颜色: {subtitle_stroke_color}, 宽度: {subtitle_stroke_width}") + outline_color = self._parse_color(subtitle_stroke_color) + # 在多个方向绘制描边 + for dx in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + for dy in range(-subtitle_stroke_width, subtitle_stroke_width + 1): + if dx*dx + dy*dy <= subtitle_stroke_width*subtitle_stroke_width: + draw.text((x_sub + dx, y_sub + dy), subtitle, fill=outline_color, font=font) + + # 绘制副标题(如果fontWeight >= 800,使用粗体效果) + # 如果canvas是RGBA模式,确保颜色包含alpha通道 + if canvas.mode == 'RGBA': + if isinstance(subtitle_color, tuple) and len(subtitle_color) == 3: + subtitle_color_rgba = (*subtitle_color, 255) # 添加完全不透明的alpha + else: + subtitle_color_rgba = subtitle_color + if subtitle_font_weight >= 800: + self._apply_bold_effect(draw, subtitle, (x_sub, y_sub), font, subtitle_color_rgba, subtitle_font_weight) + else: + draw.text((x_sub, y_sub), subtitle, fill=subtitle_color_rgba, font=font) + else: + if subtitle_font_weight >= 800: + self._apply_bold_effect(draw, subtitle, (x_sub, y_sub), font, subtitle_color, subtitle_font_weight) + else: + draw.text((x_sub, y_sub), subtitle, fill=subtitle_color, font=font) + logger.info(f"副标题已绘制到位置 ({x_sub}, {y_sub}),颜色: {subtitle_color}, 字体大小: {font_size}, 字重: {subtitle_font_weight}") + + # 如果画布是RGBA模式,转换为RGB(最终输出需要RGB) + if canvas.mode == 'RGBA': + canvas = canvas.convert('RGB') + logger.info(f"添加文字完成,画布模式: {canvas.mode}, 尺寸: {canvas.size}") + print(f"添加文字完成,画布尺寸: {canvas.size}", file=sys.stderr, flush=True) + return canvas + + except Exception as e: + logger.error(f"添加文字失败: {e}") + import traceback + logger.error(traceback.format_exc()) + print(f"添加文字失败: {e}", file=sys.stderr, flush=True) + return canvas + + def _draw_text_background(self, canvas: Image.Image, text_x: int, text_y: int, + text_width: int, text_height: int, + bg_color: str, bg_opacity: int, bg_shape: str, + bg_size: Dict[str, int], bg_position: Dict[str, int], + bg_radius: int = 10, bg_points: List[Dict[str, Any]] = None) -> Image.Image: + """绘制文字背景(独立图层,不依赖文字位置和大小) + + Args: + canvas: 画布 + text_x: 文字X坐标(左上角)- 仅用于兼容,实际不使用 + text_y: 文字Y坐标(左上角)- 仅用于兼容,实际不使用 + text_width: 文字宽度 - 仅用于兼容,实际不使用 + text_height: 文字高度 - 仅用于兼容,实际不使用 + bg_color: 背景颜色 + bg_opacity: 背景透明度 (0-100) + bg_shape: 背景形状 ('rectangle', 'rounded', 'polygon', 'bezier') + bg_size: 背景大小 {'width': int, 'height': int} (百分比,相对于画布) + bg_position: 背景位置 {'x': int, 'y': int} (百分比,相对于画布中心) + bg_radius: 圆角半径(仅用于rounded) + bg_points: 控制点列表(用于polygon和bezier,相对于背景中心) + + Returns: + 绘制背景后的画布 + """ + try: + canvas_width = canvas.width + canvas_height = canvas.height + + # 计算背景大小(百分比转换为像素,相对于画布) + bg_width = int((bg_size.get('width', 30) / 100.0) * canvas_width) + bg_height = int((bg_size.get('height', 10) / 100.0) * canvas_height) + + # 计算背景中心位置(百分比转换为像素,相对于画布) + bg_center_x = int((bg_position.get('x', 50) / 100.0) * canvas_width) + bg_center_y = int((bg_position.get('y', 50) / 100.0) * canvas_height) + + # 计算背景左上角位置 + bg_x = bg_center_x - bg_width // 2 + bg_y = bg_center_y - bg_height // 2 + + # 解析背景颜色 + bg_rgb = self._parse_color(bg_color) + bg_alpha = int(255 * bg_opacity / 100) + bg_rgba = (*bg_rgb, bg_alpha) + + logger.info(f"_draw_text_background - 颜色: {bg_color} -> RGB: {bg_rgb}, 透明度: {bg_opacity}% -> Alpha: {bg_alpha}, RGBA: {bg_rgba}") + + # 创建背景overlay + bg_overlay = Image.new('RGBA', canvas.size, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg_overlay) + + # 根据形状绘制背景 + if bg_shape == 'rectangle': + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_rgba + ) + elif bg_shape == 'rounded': + # 圆角矩形 + if bg_radius > 0: + # 使用圆角矩形绘制 + bg_draw.rounded_rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + radius=bg_radius, + fill=bg_rgba + ) + else: + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_rgba + ) + elif bg_shape == 'polygon' and bg_points: + # 多边形 + if len(bg_points) >= 3: + # 将控制点转换为绝对坐标(控制点相对于背景中心) + points = [] + for pt in bg_points: + # pt.x 和 pt.y 是相对于背景中心的百分比 + px = bg_center_x + int((pt.get('x', 0) / 100.0) * bg_width) + py = bg_center_y + int((pt.get('y', 0) / 100.0) * bg_height) + points.append((px, py)) + + if len(points) >= 3: + bg_draw.polygon(points, fill=bg_rgba) + elif bg_shape == 'bezier' and bg_points and CV2_AVAILABLE: + # 贝塞尔曲线 + try: + # 将控制点转换为绝对坐标(控制点相对于背景中心) + control_points = [] + for pt in bg_points: + # pt.x 和 pt.y 是相对于背景中心的百分比 + px = bg_center_x + int((pt.get('x', 0) / 100.0) * bg_width) + py = bg_center_y + int((pt.get('y', 0) / 100.0) * bg_height) + control_points.append((px, py)) + + if len(control_points) >= 2: + # 使用cv2绘制贝塞尔曲线路径 + mask = np.zeros((canvas.height, canvas.width), dtype=np.uint8) + + # 将控制点转换为numpy数组 + points_array = np.array(control_points, dtype=np.int32) + + # 使用cv2.fillPoly填充(简化处理,实际应该使用贝塞尔曲线) + if len(control_points) >= 3: + cv2.fillPoly(mask, [points_array], 255) + else: + # 如果只有2个点,绘制直线 + cv2.line(mask, tuple(control_points[0]), tuple(control_points[1]), 255, 2) + + # 转换为PIL Image并应用颜色和透明度 + mask_img = Image.fromarray(mask, mode='L') + # 将mask与透明度相乘,确保透明度正确应用 + mask_array = np.array(mask, dtype=np.float32) + mask_alpha = (mask_array * (bg_alpha / 255.0)).astype(np.uint8) + # 创建带透明度的背景图像 + bg_img = Image.new('RGBA', canvas.size, (0, 0, 0, 0)) + bg_rgba_array = np.array(bg_img) + # 在mask区域内填充颜色,alpha通道使用计算后的透明度 + mask_bool = mask > 0 + bg_rgba_array[:, :, 0][mask_bool] = bg_rgb[0] + bg_rgba_array[:, :, 1][mask_bool] = bg_rgb[1] + bg_rgba_array[:, :, 2][mask_bool] = bg_rgb[2] + bg_rgba_array[:, :, 3] = mask_alpha + bg_img = Image.fromarray(bg_rgba_array, mode='RGBA') + bg_overlay = Image.alpha_composite(bg_overlay, bg_img) + except Exception as e: + logger.warning(f"绘制贝塞尔曲线背景失败: {e},使用矩形背景") + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_rgba + ) + else: + # 默认矩形 + bg_draw.rectangle( + [(bg_x, bg_y), (bg_x + bg_width, bg_y + bg_height)], + fill=bg_rgba + ) + + # 合成背景到画布(保持RGBA模式以支持透明度) + canvas_rgba = canvas.convert('RGBA') + logger.info(f"_draw_text_background - 合成前画布模式: {canvas.mode}, overlay模式: {bg_overlay.mode}") + canvas_rgba = Image.alpha_composite(canvas_rgba, bg_overlay) + logger.info(f"_draw_text_background - 合成后画布模式: {canvas_rgba.mode}") + # 检查合成后的alpha通道(采样检查) + if canvas_rgba.mode == 'RGBA': + sample_alpha = canvas_rgba.getpixel((bg_center_x, bg_center_y))[3] if 0 <= bg_center_x < canvas_rgba.width and 0 <= bg_center_y < canvas_rgba.height else None + logger.info(f"_draw_text_background - 合成后中心点alpha值: {sample_alpha}") + return canvas_rgba + + except Exception as e: + logger.warning(f"绘制文字背景失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + return canvas + + def _resample_contour(self, contour: np.ndarray, spacing: float = 1.0) -> List[Tuple[int, int]]: + """重采样轮廓,使点均匀分布 + + Args: + contour: 原始轮廓点 + spacing: 采样间隔(像素) + + Returns: + 重采样后的轮廓点列表 + """ + if len(contour) < 2: + return [tuple(pt[0]) for pt in contour] + + # 将轮廓点转换为列表 + points = [tuple(pt[0]) for pt in contour] + + # 计算累积距离 + cumulative_dist = [0.0] + for i in range(len(points) - 1): + pt1 = points[i] + pt2 = points[i + 1] + dist = np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2) + cumulative_dist.append(cumulative_dist[-1] + dist) + + total_length = cumulative_dist[-1] + if total_length < spacing: + return points + + # 重采样 + resampled_points = [] + target_dist = 0.0 + + while target_dist <= total_length: + # 找到目标距离所在的线段 + for i in range(len(cumulative_dist) - 1): + if cumulative_dist[i] <= target_dist <= cumulative_dist[i + 1]: + # 在这个线段上插值 + segment_start_dist = cumulative_dist[i] + segment_end_dist = cumulative_dist[i + 1] + segment_length = segment_end_dist - segment_start_dist + + if segment_length > 0: + t = (target_dist - segment_start_dist) / segment_length + pt1 = points[i] + pt2 = points[i + 1] + x = int(pt1[0] + (pt2[0] - pt1[0]) * t) + y = int(pt1[1] + (pt2[1] - pt1[1]) * t) + resampled_points.append((x, y)) + break + + target_dist += spacing + + return resampled_points + + def _get_point_at_distance(self, points: List[Tuple[int, int]], + cumulative_dist: List[float], + target_dist: float) -> Optional[Tuple[int, int]]: + """在轮廓上找到指定距离处的点 + + Args: + points: 轮廓点列表 + cumulative_dist: 累积距离列表 + target_dist: 目标距离 + + Returns: + 目标距离处的点坐标,如果找不到则返回None + """ + if target_dist < 0 or target_dist > cumulative_dist[-1]: + return None + + # 找到目标距离所在的线段 + for i in range(len(cumulative_dist) - 1): + if cumulative_dist[i] <= target_dist <= cumulative_dist[i + 1]: + # 在这个线段上插值 + segment_start_dist = cumulative_dist[i] + segment_end_dist = cumulative_dist[i + 1] + segment_length = segment_end_dist - segment_start_dist + + if segment_length > 0: + t = (target_dist - segment_start_dist) / segment_length + pt1 = points[i] + pt2 = points[i + 1] + x = int(pt1[0] + (pt2[0] - pt1[0]) * t) + y = int(pt1[1] + (pt2[1] - pt1[1]) * t) + return (x, y) + else: + return points[i] + + return None + + def _create_dashed_border(self, border_mask: np.ndarray, border_width: int, + dash_length: Optional[int] = None, gap_length: Optional[int] = None) -> np.ndarray: + """创建虚线描边效果 + + Args: + border_mask: 原始描边mask + border_width: 描边宽度 + dash_length: 每段虚线长度(像素),如果为None则自动计算 + gap_length: 每段间隔长度(像素),如果为None则自动计算 + + Returns: + 虚线描边mask + """ + try: + if not CV2_AVAILABLE: + return border_mask + + # 虚线参数(如果未提供或为0,使用默认值) + if dash_length is None or dash_length == 0: + dash_length = border_width * 3 # 每段虚线长度 + logger.info(f"虚线长度未提供,使用默认值: {dash_length} (border_width * 3)") + if gap_length is None or gap_length == 0: + gap_length = border_width * 2 # 每段间隔长度 + logger.info(f"虚线间隔未提供,使用默认值: {gap_length} (border_width * 2)") + + logger.info(f"_create_dashed_border 接收参数 - border_width: {border_width}, dash_length: {dash_length}, gap_length: {gap_length}") + + # 创建虚线mask + dashed_mask = np.zeros_like(border_mask) + + # 找到所有描边像素的位置 + if np.count_nonzero(border_mask) == 0: + return border_mask + + # 使用轮廓来生成虚线 + contours, _ = cv2.findContours(border_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) + + if len(contours) == 0: + return border_mask + + # 新方法:基于已有的均匀实线border_mask创建虚线 + # 不重新绘制,而是对实线mask进行选择性保留 + # 这样可以保持与实线相同的均匀性 + + # 对border_mask中的每个像素,计算它到轮廓起点的距离 + # 然后根据距离决定是否保留(虚线模式) + + for contour in contours: + if len(contour) < 2: + continue + + # 将轮廓点展平为列表 + points = [tuple(pt[0]) for pt in contour] + + # 计算累积距离 + cumulative_dist = [0.0] + for i in range(len(points) - 1): + pt1 = points[i] + pt2 = points[i + 1] + dist = np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2) + cumulative_dist.append(cumulative_dist[-1] + dist) + + total_length = cumulative_dist[-1] + if total_length < 1: + continue + + logger.info(f"轮廓总长度: {total_length}, 点数: {len(points)}") + + # 为轮廓上的每个点分配距离值 + point_distances = {} + for i, pt in enumerate(points): + point_distances[pt] = cumulative_dist[i] + + # 遍历border_mask中的所有描边像素 + # 找到每个像素最近的轮廓点,获取其距离值 + # 根据距离值决定是否保留(虚线模式) + y_coords, x_coords = np.where(border_mask > 0) + + cycle_length = dash_length + gap_length + + for y, x in zip(y_coords, x_coords): + # 找到最近的轮廓点 + min_dist = float('inf') + nearest_contour_dist = 0 + + for pt, contour_dist in point_distances.items(): + dist = np.sqrt((x - pt[0])**2 + (y - pt[1])**2) + if dist < min_dist: + min_dist = dist + nearest_contour_dist = contour_dist + + # 根据轮廓距离决定是否保留这个像素 + position_in_cycle = nearest_contour_dist % cycle_length + if position_in_cycle < dash_length: + dashed_mask[y, x] = 255 + + logger.info(f"虚线生成完成,共保留了 {np.count_nonzero(dashed_mask)} 个像素") + + return dashed_mask + + except Exception as e: + logger.warning(f"创建虚线描边失败: {e},使用实线描边") + import traceback + logger.warning(traceback.format_exc()) + return border_mask + + def _apply_final_effects(self, canvas: Image.Image, template: Dict[str, Any]) -> Image.Image: + """应用最终效果""" + try: + # 可以在这里添加边框、水印等效果 + return canvas + except Exception as e: + logger.warning(f"应用最终效果失败: {e}") + return canvas + + def _resize_image(self, image: Image.Image, target_size: Tuple[int, int]) -> Image.Image: + """调整图像大小,保持比例""" + target_width, target_height = target_size + img_width, img_height = image.size + + # 计算缩放比例 + scale = min(target_width / img_width, target_height / img_height) + new_width = int(img_width * scale) + new_height = int(img_height * scale) + + return image.resize((new_width, new_height), Image.LANCZOS) + + def _calculate_position(self, position: str, element_size: Tuple[int, int], + canvas_size: Tuple[int, int]) -> Tuple[int, int]: + """计算元素位置""" + element_width, element_height = element_size + canvas_width, canvas_height = canvas_size + + if position == 'center': + x = (canvas_width - element_width) // 2 + y = (canvas_height - element_height) // 2 + elif position == 'top': + x = (canvas_width - element_width) // 2 + y = canvas_height // 10 + elif position == 'bottom': + x = (canvas_width - element_width) // 2 + y = canvas_height - element_height - canvas_height // 10 + elif position == 'left': + x = canvas_width // 10 + y = (canvas_height - element_height) // 2 + elif position == 'right': + x = canvas_width - element_width - canvas_width // 10 + y = (canvas_height - element_height) // 2 + elif position == 'top-left': + x = canvas_width // 10 + y = canvas_height // 10 + elif position == 'top-right': + x = canvas_width - element_width - canvas_width // 10 + y = canvas_height // 10 + elif position == 'bottom-left': + x = canvas_width // 10 + y = canvas_height - element_height - canvas_height // 10 + elif position == 'bottom-right': + x = canvas_width - element_width - canvas_width // 10 + y = canvas_height - element_height - canvas_height // 10 + elif position == 'bottom-center': + x = (canvas_width - element_width) // 2 + y = canvas_height - element_height - canvas_height // 10 + else: + # 默认居中 + x = (canvas_width - element_width) // 2 + y = (canvas_height - element_height) // 2 + + return x, y + + def _calculate_text_position(self, position: str, text_size: Tuple[int, int], + canvas_size: Tuple[int, int]) -> Tuple[int, int]: + """计算文字位置""" + return self._calculate_position(position, text_size, canvas_size) + + def _add_mask(self, canvas: Image.Image, template: Dict[str, Any]) -> Image.Image: + """添加蒙版 + + Args: + canvas: 画布 + template: 模板配置 + + Returns: + 添加蒙版后的画布 + """ + try: + mask_image_path = template.get('mask_image_path') + if not mask_image_path or not os.path.exists(mask_image_path): + logger.warning(f"蒙版图片不存在: {mask_image_path}") + return canvas + + # 读取蒙版图片 + mask_img = Image.open(mask_image_path).convert('RGBA') + canvas_width, canvas_height = canvas.size + + # 获取蒙版大小和位置 + mask_size = template.get('mask_size', 1.0) # 0-1的小数 + mask_position_custom = template.get('mask_position_custom', {'x': 50, 'y': 50}) + mask_opacity = template.get('mask_opacity', 1.0) # 0-1 + mask_color = template.get('mask_color') + mask_shape = template.get('mask_shape', 'rectangle') + + # 调整蒙版大小 + mask_width = int(canvas_width * mask_size) + mask_height = int(mask_width * mask_img.height / mask_img.width) + if mask_height > canvas_height * 0.95: + mask_height = int(canvas_height * 0.95) + mask_width = int(mask_height * mask_img.width / mask_img.height) + + mask_resized = mask_img.resize((mask_width, mask_height), Image.LANCZOS) + + # 应用颜色(如果指定) + if mask_color: + mask_rgb = self._parse_color(mask_color) + # 创建颜色层 + color_layer = Image.new('RGB', mask_resized.size, mask_rgb) + # 使用alpha通道合成 + mask_resized = Image.composite( + color_layer.convert('RGBA'), + mask_resized, + mask_resized.split()[-1] # 使用原图的alpha通道 + ) + + # 应用透明度 + if mask_opacity < 1.0: + alpha = mask_resized.split()[-1] + alpha = alpha.point(lambda p: int(p * mask_opacity)) + mask_resized.putalpha(alpha) + + # 应用形状(如果需要) + if mask_shape == 'circle': + # 创建圆形蒙版 + mask_alpha = Image.new('L', mask_resized.size, 0) + draw = ImageDraw.Draw(mask_alpha) + radius = min(mask_width, mask_height) // 2 + center = (mask_width // 2, mask_height // 2) + draw.ellipse([center[0] - radius, center[1] - radius, + center[0] + radius, center[1] + radius], fill=255) + mask_resized.putalpha(mask_alpha) + elif mask_shape == 'rounded': + # 创建圆角矩形蒙版 + mask_alpha = Image.new('L', mask_resized.size, 0) + draw = ImageDraw.Draw(mask_alpha) + corner_radius = min(mask_width, mask_height) // 10 + draw.rounded_rectangle([0, 0, mask_width, mask_height], + radius=corner_radius, fill=255) + mask_resized.putalpha(mask_alpha) + + # 计算位置(百分比坐标转换为像素) + x = int((mask_position_custom.get('x', 50) / 100.0) * canvas_width) + y = int((mask_position_custom.get('y', 50) / 100.0) * canvas_height) + # 调整为中心对齐 + x = x - mask_width // 2 + y = y - mask_height // 2 + # 确保在画布范围内 + x = max(0, min(x, canvas_width - mask_width)) + y = max(0, min(y, canvas_height - mask_height)) + + # 合成蒙版到画布 + canvas = canvas.convert('RGBA') + canvas.paste(mask_resized, (x, y), mask_resized.split()[-1]) + canvas = canvas.convert('RGB') + + logger.info(f"蒙版已添加到位置 ({x}, {y}),大小: {mask_width}x{mask_height}") + return canvas + + except Exception as e: + logger.warning(f"添加蒙版失败: {e}") + return canvas + + def _find_font(self, font_family: str, font_weight: int = 400) -> Optional[str]: + """查找字体文件,支持预置字体、系统字体和粗体字体查找""" + try: + # 尝试使用字体管理器(优先查找预置字体) + from .font_manager import get_font_manager + font_manager = get_font_manager() + font_path = font_manager.find_font(font_family, font_weight) + if font_path: + return font_path + except Exception as e: + logger.warning(f"使用字体管理器查找字体失败: {e},回退到系统字体查找") + + # 回退到原有的系统字体查找逻辑 + import platform + + # 粗体字体名称映射 + bold_font_map = { + 'SimHei': 'simhei.ttf', # 黑体 + 'Microsoft YaHei': 'msyhbd.ttf', # 微软雅黑粗体 + 'SimSun': 'simsun.ttc', # 宋体(不支持粗体,使用描边模拟) + 'Arial': 'arialbd.ttf', # Arial Bold + 'Times New Roman': 'timesbd.ttf', # Times New Roman Bold + } + + system = platform.system() + font_paths = [] + + if system == 'Windows': + windows_font_dir = Path("C:/Windows/Fonts") + # 如果指定了粗体且font_weight >= 700,尝试查找粗体字体 + if font_weight >= 700 and font_family in bold_font_map: + bold_font_name = bold_font_map[font_family] + bold_path = windows_font_dir / bold_font_name + if bold_path.exists(): + font_paths.append(str(bold_path)) + + # 普通字体查找 + font_name_map = { + 'SimHei': 'simhei.ttf', + 'Microsoft YaHei': 'msyh.ttc', + 'SimSun': 'simsun.ttc', + 'Arial': 'arial.ttf', + 'Times New Roman': 'times.ttf', + } + if font_family in font_name_map: + font_path = windows_font_dir / font_name_map[font_family] + if font_path.exists(): + font_paths.append(str(font_path)) + + # 通用字体路径 + font_paths.extend([ + str(windows_font_dir / "simhei.ttf"), + str(windows_font_dir / "msyh.ttc"), + str(windows_font_dir / "simsun.ttc"), + ]) + elif system == 'Darwin': # macOS + font_paths = [ + "/System/Library/Fonts/PingFang.ttc", + "/Library/Fonts/Arial.ttf", + ] + else: # Linux + font_paths = [ + "/usr/share/fonts/truetype/droid/DroidSansFallback.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + ] + + # 检查字体文件是否存在 + for path in font_paths: + if Path(path).exists(): + return path + + # 如果找不到,使用PIL默认字体 + return None + + def _load_font(self, font_path: Optional[str], size: int, font_weight: int = 400) -> ImageFont.FreeTypeFont: + """加载字体,支持fontWeight参数""" + try: + if font_path: + # 验证字体文件是否存在 + from pathlib import Path + if not Path(font_path).exists(): + logger.warning(f"❌ 字体文件不存在: {font_path}") + logger.warning(f" 使用默认字体作为回退") + return ImageFont.load_default() + + logger.debug(f"✅ 加载字体: {font_path} (大小: {size})") + font = ImageFont.truetype(font_path, size) + # 注意:PIL的ImageFont.truetype不支持直接设置fontWeight + # 如果需要更粗的字体,需要在_find_font中查找粗体字体文件 + return font + else: + logger.debug(f"⚠️ 未提供字体路径,使用默认字体") + return ImageFont.load_default() + except Exception as e: + logger.warning(f"❌ 加载字体失败: {e}") + logger.warning(f" 字体路径: {font_path}") + logger.warning(f" 使用默认字体作为回退") + import traceback + logger.debug(f" 错误追踪: {traceback.format_exc()}") + return ImageFont.load_default() + + def _apply_bold_effect(self, draw: ImageDraw.Draw, text: str, position: Tuple[int, int], + font: ImageFont.FreeTypeFont, color: Tuple[int, int, int], + font_weight: int) -> None: + """应用粗体效果(当字体本身不够粗时,使用描边模拟)""" + if font_weight >= 800: + # 对于非常粗的字体(800+),使用多次偏移绘制来增加视觉粗细 + offsets = [ + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 0), (0, 1), + (1, -1), (1, 0), (1, 1), + ] + for dx, dy in offsets: + draw.text((position[0] + dx, position[1] + dy), text, fill=color, font=font) + elif font_weight >= 700: + # 对于粗体(700-799),使用轻微偏移 + offsets = [ + (-0.5, -0.5), (-0.5, 0.5), + (0.5, -0.5), (0.5, 0.5), + ] + for dx, dy in offsets: + draw.text((int(position[0] + dx), int(position[1] + dy)), text, fill=color, font=font) + # 最后绘制主文字 + draw.text(position, text, fill=color, font=font) + + def _get_font_size(self, template: Dict[str, Any], text_type: str) -> int: + """获取字体大小""" + if text_type == 'title': + base_size = self.template_config.get('font_size_title', 120) + else: + base_size = self.template_config.get('font_size_subtitle', 60) + + # 根据模板调整大小 + text_size = template.get('text_size', 'medium') + if text_size == 'small': + base_size = int(base_size * 0.8) + elif text_size == 'large': + base_size = int(base_size * 1.2) + + return base_size + + def _parse_color(self, color_str: str) -> Tuple[int, int, int]: + """解析颜色字符串为RGB元组 + + Args: + color_str: 颜色字符串,支持 'white', 'black', 'red', 'yellow', '#RRGGBB' 等格式 + + Returns: + RGB元组 (r, g, b) + """ + color_str = color_str.lower().strip() + + # 预定义颜色 + color_map = { + 'white': (255, 255, 255), + 'black': (0, 0, 0), + 'red': (255, 0, 0), + 'green': (0, 255, 0), + 'blue': (0, 0, 255), + 'yellow': (255, 255, 0), + 'orange': (255, 165, 0), + 'purple': (128, 0, 128), + 'pink': (255, 192, 203), + } + + if color_str in color_map: + return color_map[color_str] + + # 十六进制颜色 #RRGGBB + if color_str.startswith('#'): + try: + hex_color = color_str[1:] + if len(hex_color) == 6: + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + return (r, g, b) + except ValueError: + pass + + # 默认返回白色 + return (255, 255, 255) + + +def generate_cover(background_path: str, people_path: Optional[str] = None, + text: Dict[str, Any] = None, template_id: str = "big_text", + output_path: Optional[str] = None) -> str: + """便捷的封面生成函数 + + Args: + background_path: 背景图路径 + people_path: 人物图路径 + text: 文本信息 + template_id: 模板ID + output_path: 输出路径 + + Returns: + 生成的封面路径 + """ + generator = CoverGenerator() + return generator.generate_cover(background_path, people_path, text, template_id, output_path) + + """ + generator = CoverGenerator() + return generator.generate_cover(background_path, people_path, text, template_id, output_path) diff --git a/scripts/cover/modules/cover_templates.py b/scripts/cover/modules/cover_templates.py new file mode 100644 index 0000000..c6573a7 --- /dev/null +++ b/scripts/cover/modules/cover_templates.py @@ -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} +} \ No newline at end of file diff --git a/scripts/cover/modules/font_manager.py b/scripts/cover/modules/font_manager.py new file mode 100644 index 0000000..cca87e8 --- /dev/null +++ b/scripts/cover/modules/font_manager.py @@ -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 + diff --git a/scripts/cover/modules/segment.py b/scripts/cover/modules/segment.py new file mode 100644 index 0000000..4d5f9af --- /dev/null +++ b/scripts/cover/modules/segment.py @@ -0,0 +1,1050 @@ +""" +人像分割模块 +使用 MODNet/RMBG 进行人物抠图 +""" + +import os +import sys +import cv2 +import numpy as np +from pathlib import Path +from typing import Optional, Tuple, Dict, Any + +# ⚠️ 诊断输出:验证 segment.py 导入开始 +print("[SEGMENT.PY] Starting import...", file=sys.stderr, flush=True) + +try: + from loguru import logger + print("[SEGMENT.PY] ✅ loguru imported successfully", file=sys.stderr, flush=True) +except ImportError as e: + print(f"[SEGMENT.PY] ⚠️ loguru import failed: {e}, using print instead", file=sys.stderr, flush=True) + # 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) + +# 检查cv2是否可用 +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + CV2_AVAILABLE = False + logger.warning("OpenCV不可用,某些功能可能受限") + +# 尝试导入AI库,如果不可用则使用简化实现 +try: + from rembg import remove, new_session + REMBG_AVAILABLE = True +except ImportError: + REMBG_AVAILABLE = False + logger.warning("rembg不可用,使用简化的抠图方法") + +try: + import torch + import torch.nn as nn + from torchvision import transforms + PYTORCH_AVAILABLE = True +except ImportError: + PYTORCH_AVAILABLE = False + logger.warning("PyTorch不可用,高级AI功能受限") + +# 尝试导入MODNet(如果可用) +MODNET_AVAILABLE = False +try: + # 尝试导入MODNet(可能有多种实现方式) + try: + from modnet import MODNet as MODNetLib + MODNET_AVAILABLE = True + logger.info("MODNet库可用(modnet包)") + except ImportError: + try: + # 尝试从本地MODNet实现导入 + import sys + # 尝试多个可能的路径 + possible_paths = [ + Path(__file__).parent.parent / "MODNet", + Path(__file__).parent.parent / "MODNet-master", + ] + for modnet_path in possible_paths: + if modnet_path.exists(): + sys.path.insert(0, str(modnet_path)) + try: + from src.models.modnet import MODNet as MODNetLib + MODNET_AVAILABLE = True + logger.info(f"MODNet库可用(本地实现: {modnet_path.name})") + break + except ImportError: + # 尝试其他可能的导入路径 + try: + from models.modnet import MODNet as MODNetLib + MODNET_AVAILABLE = True + logger.info(f"MODNet库可用(本地实现: {modnet_path.name},models路径)") + break + except ImportError: + continue + except Exception as e: + logger.debug(f"MODNet本地导入失败: {e}") +except Exception as e: + logger.debug(f"MODNet不可用: {e}") + +try: + from .utils import ensure_dir, save_image + print("[SEGMENT.PY] ✅ utils module imported successfully", file=sys.stderr, flush=True) +except ImportError as e: + print(f"[SEGMENT.PY] ❌ Failed to import utils: {e}", file=sys.stderr, flush=True) + raise + +print("[SEGMENT.PY] ✅ Module import completed successfully", file=sys.stderr, flush=True) + + +class PersonSegmenter: + """人像分割器""" + + def __init__(self, model_name: str = "u2net"): + """ + 初始化分割器 + + Args: + model_name: 使用的模型名称 + - "u2net": U2Net模型(默认,效果较好) + - "u2netp": U2Net轻量版 + - "silueta": Silueta模型 + - "isnet": ISNet模型 + - "modnet": MODNet模型(专门用于人像,需要PyTorch和MODNet库) + - "simple": 简化的抠图方法(当AI库不可用时) + """ + self.model_name = model_name + self.session = None + self.modnet_model = None + self.use_modnet = False + + # 检查是否使用MODNet + if model_name == "modnet": + if MODNET_AVAILABLE and PYTORCH_AVAILABLE: + self.use_modnet = True + self._load_modnet() + else: + logger.warning("MODNet不可用(需要PyTorch和MODNet库),使用rembg作为后备") + self.use_simple_method = not REMBG_AVAILABLE or model_name == "simple" + if not self.use_simple_method: + self._load_model() + else: + self.use_simple_method = not REMBG_AVAILABLE or model_name == "simple" + if not self.use_simple_method: + self._load_model() + + def _load_model(self): + """加载rembg模型 - 支持本地模型路径""" + try: + logger.info(f"加载人像分割模型: {self.model_name}") + + # 关键修复:优先使用本地打包的模型 + local_model_path = self._get_local_model_path() + + if local_model_path and os.path.exists(local_model_path): + logger.info(f"✅ 使用本地模型文件: {local_model_path}") + # 设置环境变量,让 rembg 使用本地模型目录 + cache_dir = os.path.dirname(local_model_path) + os.environ['U2NET_HOME'] = cache_dir + logger.info(f"设置 U2NET_HOME = {cache_dir}") + + # 使用 rembg 加载模型(会从 U2NET_HOME 读取) + self.session = new_session(self.model_name) + else: + # 回退到 rembg 的默认行为(尝试从网络下载) + logger.warning(f"⚠️ 未找到本地模型,尝试使用默认下载方式") + cache_dir = self._get_cache_dir() + os.environ['U2NET_HOME'] = cache_dir + logger.info(f"设置缓存目录: {cache_dir}") + self.session = new_session(self.model_name) + + logger.info(f"人像分割模型加载成功: {self.model_name}") + logger.info(f"Session类型: {type(self.session)}") + except Exception as e: + logger.error(f"❌ 加载人像分割模型失败: {e}") + logger.error(f"尝试加载的模型: {self.model_name}") + import traceback + logger.error(traceback.format_exc()) + raise + + def _get_local_model_path(self) -> Optional[str]: + """获取本地模型文件路径 - 自动识别开发/打包环境""" + model_filename = f"{self.model_name}.onnx" if self.model_name in ["u2net", "u2netp", "u2net_human_seg", "silueta", "isnet"] else "u2net.onnx" + + # 🔧 修复:支持ASAR打包环境,优先使用APP_ROOT环境变量 + app_root = os.environ.get('APP_ROOT', None) + + possible_paths = [] + + if app_root: + # 打包环境:优先使用 resources-bundles/models + possible_paths.append(Path(app_root) / "resources-bundles" / "models" / model_filename) + # 打包环境:使用APP_ROOT定位资源(回退) + possible_paths.append(Path(app_root) / "extra" / "common" / "models" / model_filename) + logger.info(f"🔧 打包环境检测: APP_ROOT={app_root}") + + # 添加其他可能的路径(开发环境和回退) + possible_paths.extend([ + # 打包后的路径(生产环境 - 回退) + Path(__file__).parent.parent.parent / "extra" / "common" / "models" / model_filename, + # 开发环境路径 1 + Path(__file__).parent.parent / "models" / model_filename, + # 开发环境路径 2(electron/resources/extra/common/models) + Path(__file__).parent.parent.parent / "electron" / "resources" / "extra" / "common" / "models" / model_filename, + # 备用路径 + Path(__file__).parent.parent.parent / "models" / model_filename, + ]) + + logger.info(f"🔍 搜索本地模型: {model_filename}") + for i, path in enumerate(possible_paths, 1): + exists = path.exists() + logger.debug(f" 尝试路径 {i}: {path} (存在: {exists})") + if exists: + size_mb = path.stat().st_size / (1024 * 1024) + logger.info(f"✅ 找到本地模型: {path} ({size_mb:.1f}MB)") + return str(path) + else: + logger.debug(f" ❌ 路径不存在: {path}") + + logger.warning(f"⚠️ 未找到本地模型文件: {model_filename}") + logger.warning(f" 已检查{len(possible_paths)}个路径,全部不存在") + logger.warning(f" APP_ROOT环境变量: {app_root}") + logger.warning(f" __file__路径: {__file__}") + return None + + def _get_cache_dir(self) -> str: + """获取模型缓存目录""" + # 优先使用应用资源目录 + possible_cache_dirs = [ + Path(__file__).parent.parent.parent / "extra" / "common" / "models", + Path(__file__).parent.parent / "models", + ] + + for cache_dir in possible_cache_dirs: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"使用缓存目录: {cache_dir}") + return str(cache_dir) + except Exception as e: + logger.warning(f"无法创建缓存目录 {cache_dir}: {e}") + continue + + # 最后回退到用户主目录 + from pathlib import Path + fallback_dir = Path.home() / ".u2net" + fallback_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"使用回退缓存目录: {fallback_dir}") + return str(fallback_dir) + + def _load_modnet(self): + """加载MODNet模型""" + try: + logger.info("加载MODNet人像分割模型...") + if not MODNET_AVAILABLE or not PYTORCH_AVAILABLE: + logger.warning("MODNet不可用(需要PyTorch和MODNet库),使用rembg作为后备") + self.use_modnet = False + self.use_simple_method = not REMBG_AVAILABLE + if not self.use_simple_method: + self._load_model() + return + + # 查找预训练模型文件(支持多个可能的位置) + possible_paths = [ + # pretrained目录下 + Path(__file__).parent.parent / "MODNet-master" / "pretrained" / "modnet_webcam_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet-master" / "pretrained" / "modnet_photographic_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet" / "pretrained" / "modnet_webcam_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet" / "pretrained" / "modnet_photographic_portrait_matting.ckpt", + # MODNet-master根目录下(用户可能直接放在这里) + Path(__file__).parent.parent / "MODNet-master" / "modnet_webcam_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet-master" / "modnet_photographic_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet" / "modnet_webcam_portrait_matting.ckpt", + Path(__file__).parent.parent / "MODNet" / "modnet_photographic_portrait_matting.ckpt", + ] + + ckpt_path = None + for path in possible_paths: + if path.exists(): + ckpt_path = path + logger.info(f"找到MODNet预训练模型: {ckpt_path}") + break + + if ckpt_path is None: + logger.warning("未找到MODNet预训练模型,使用rembg作为后备") + logger.info("请下载预训练模型到 MODNet-master/pretrained/ 目录") + logger.info("下载地址: https://github.com/ZHKKKe/MODNet/releases") + self.use_modnet = False + self.use_simple_method = not REMBG_AVAILABLE + if not self.use_simple_method: + self._load_model() + return + + # 创建MODNet模型 + self.modnet_model = MODNetLib(backbone_pretrained=False) + + # 加载预训练权重 + device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + if device.type == 'cpu': + weights = torch.load(str(ckpt_path), map_location=device) + else: + weights = torch.load(str(ckpt_path)) + + # 处理权重(可能包含 'module.' 前缀) + if any(k.startswith('module.') for k in weights.keys()): + weights = {k.replace('module.', ''): v for k, v in weights.items()} + + self.modnet_model.load_state_dict(weights, strict=False) + self.modnet_model.eval() + self.modnet_model = self.modnet_model.to(device) + self.modnet_device = device + self.modnet_ref_size = 512 # MODNet的标准输入尺寸 + self.use_modnet = True # 确认启用MODNet + + logger.info(f"MODNet模型加载成功,设备: {device}") + + except Exception as e: + logger.error(f"加载MODNet模型失败: {e}") + import traceback + logger.error(traceback.format_exc()) + logger.info("使用rembg作为后备方案") + self.use_modnet = False + self.use_simple_method = not REMBG_AVAILABLE + if not self.use_simple_method: + self._load_model() + + def segment_person(self, image: np.ndarray, + return_mask: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """ + 进行人像分割 + + Args: + image: 输入图像 (BGR格式) + return_mask: 是否返回mask + + Returns: + (分割后的图像, mask) 或 (分割后的图像, None) + """ + try: + if self.use_modnet: + return self._segment_with_modnet(image, return_mask) + elif self.use_simple_method: + return self._simple_segment_person(image, return_mask) + else: + # 转换颜色空间 BGR -> RGB + if image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + rgb_image = image + + # 使用rembg进行分割,启用后处理以提高精度 + # post_process_mask=True 会自动优化mask边缘 + # alpha_matting=True 可以改善边缘质量(提高精度,但速度较慢) + # 针对低对比度图像(如浅色衣服+浅色背景),调整alpha_matting参数: + # - foreground_threshold: 200(从240降低,更宽松地识别前景) + # - background_threshold: 20(从10提高,更严格地排除背景) + # - erode_size: 8(从10减小,保留更多边缘细节) + logger.info(f"使用模型 {self.model_name} 进行人像分割,启用后处理以提高精度(优化低对比度场景)") + output = remove( + rgb_image, + session=self.session, + post_process_mask=True, + alpha_matting=True, # 启用alpha_matting以提高边缘精度 + alpha_matting_foreground_threshold=200, # 降低阈值,更宽松地识别前景(适合低对比度) + alpha_matting_background_threshold=20, # 提高阈值,更严格地排除背景 + alpha_matting_erode_size=8 # 减小腐蚀尺寸,保留更多边缘细节 + ) + logger.info(f"rembg分割完成,输出形状: {output.shape}, 模型: {self.model_name}") + + # 如果需要返回mask,计算mask + mask = None + if return_mask: + # 从alpha通道提取mask + if output.shape[2] == 4: + mask = output[:, :, 3] + # 优化mask:形态学操作去除噪点 + mask = self.refine_mask(mask) + else: + # 如果没有alpha通道,转换为灰度图作为mask + gray = cv2.cvtColor(output, cv2.COLOR_RGB2GRAY) + # 使用更低的阈值,确保捕获更多细节 + _, mask = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY) + mask = self.refine_mask(mask) + + # 转换回BGR格式 + if output.shape[2] == 4: + bgr_output = cv2.cvtColor(output[:, :, :3], cv2.COLOR_RGB2BGR) + else: + bgr_output = cv2.cvtColor(output, cv2.COLOR_RGB2BGR) + + return bgr_output, mask + + except Exception as e: + logger.error(f"人像分割失败: {e}") + raise + + def _simple_segment_person(self, image: np.ndarray, + return_mask: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """ + 简化的背景去除方法(当AI库不可用时使用) + 使用颜色距离和边缘检测的简单方法 + """ + try: + # 确保numpy已导入(虽然文件顶部已导入,但这里再次确认) + import numpy as np + + # 转换为HSV色彩空间,更容易处理颜色 + if not CV2_AVAILABLE: + logger.warning("OpenCV不可用,无法进行简化抠图,返回原始图像") + return image, None + + h, w = image.shape[:2] + + # 方法1: 尝试使用GrabCut算法(更准确) + try: + # 创建初始mask:假设中心区域是前景(人物) + mask_gc = np.zeros((h, w), np.uint8) + + # 改进的初始区域设置: + # 1. 中心区域标记为确定的前景(更大范围) + center_x, center_y = w // 2, h // 2 + # 使用更大的初始区域(50% 而不是 33%) + rect_size_w = int(w * 0.4) + rect_size_h = int(h * 0.4) + mask_gc[center_y - rect_size_h:center_y + rect_size_h, + center_x - rect_size_w:center_x + rect_size_w] = cv2.GC_FGD + + # 2. 中心区域周围标记为可能的前景 + rect_size_w2 = int(w * 0.45) + rect_size_h2 = int(h * 0.45) + outer_mask = np.zeros((h, w), np.uint8) + outer_mask[center_y - rect_size_h2:center_y + rect_size_h2, + center_x - rect_size_w2:center_x + rect_size_w2] = 255 + # 在可能前景区域中,排除已标记为确定前景的部分 + mask_gc[(outer_mask == 255) & (mask_gc == 0)] = cv2.GC_PR_FGD + + # 3. 边缘区域标记为确定的背景(更宽的边缘) + border = max(30, min(w, h) // 15) # 至少30像素,或图像尺寸的1/15 + mask_gc[:border, :] = cv2.GC_BGD + mask_gc[-border:, :] = cv2.GC_BGD + mask_gc[:, :border] = cv2.GC_BGD + mask_gc[:, -border:] = cv2.GC_BGD + + # 使用GrabCut算法,增加迭代次数以提高精度 + bgd_model = np.zeros((1, 65), np.float64) + fgd_model = np.zeros((1, 65), np.float64) + + # 第一次迭代:使用初始mask + cv2.grabCut(image, mask_gc, None, bgd_model, fgd_model, 10, cv2.GC_INIT_WITH_MASK) + + # 第二次迭代:基于第一次结果继续优化 + cv2.grabCut(image, mask_gc, None, bgd_model, fgd_model, 5, cv2.GC_EVAL) + + # 提取前景mask(包括确定前景和可能前景) + mask = np.where((mask_gc == cv2.GC_FGD) | (mask_gc == cv2.GC_PR_FGD), 255, 0).astype(np.uint8) + + # 检查mask的有效性 + mask_area = np.count_nonzero(mask) + total_area = h * w + mask_ratio = mask_area / total_area + + logger.info(f"使用GrabCut算法完成简化抠图 - mask占比: {mask_ratio:.2%} ({mask_area}/{total_area})") + + # 如果mask太小(小于5%),可能是过度分割,尝试更宽松的设置 + if mask_ratio < 0.05: + logger.warning(f"GrabCut结果mask过小 ({mask_ratio:.2%}),尝试更宽松的设置") + # 重新初始化,使用更宽松的前景区域 + mask_gc = np.zeros((h, w), np.uint8) + rect_size_w = int(w * 0.5) + rect_size_h = int(h * 0.5) + mask_gc[center_y - rect_size_h:center_y + rect_size_h, + center_x - rect_size_w:center_x + rect_size_w] = cv2.GC_PR_FGD + border = max(20, min(w, h) // 20) + mask_gc[:border, :] = cv2.GC_BGD + mask_gc[-border:, :] = cv2.GC_BGD + mask_gc[:, :border] = cv2.GC_BGD + mask_gc[:, -border:] = cv2.GC_BGD + + bgd_model = np.zeros((1, 65), np.float64) + fgd_model = np.zeros((1, 65), np.float64) + cv2.grabCut(image, mask_gc, None, bgd_model, fgd_model, 15, cv2.GC_INIT_WITH_MASK) + mask = np.where((mask_gc == cv2.GC_FGD) | (mask_gc == cv2.GC_PR_FGD), 255, 0).astype(np.uint8) + mask_ratio = np.count_nonzero(mask) / total_area + logger.info(f"宽松设置后mask占比: {mask_ratio:.2%}") + except Exception as e: + logger.warning(f"GrabCut算法失败,使用备用方法: {e}") + # 方法2: 改进的颜色距离方法 + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + + # 使用中心区域的颜色作为前景参考(假设人物在中心) + center_region = hsv[h//4:3*h//4, w//4:3*w//4] + if center_region.size > 0: + fg_color = np.mean(center_region.reshape(-1, 3), axis=0) + else: + fg_color = np.mean(hsv.reshape(-1, 3), axis=0) + + # 计算边缘区域的平均颜色(作为背景参考) + border_region = np.concatenate([ + hsv[:10, :].reshape(-1, 3), + hsv[-10:, :].reshape(-1, 3), + hsv[:, :10].reshape(-1, 3), + hsv[:, -10:].reshape(-1, 3) + ], axis=0) + bg_color = np.mean(border_region, axis=0) if border_region.size > 0 else np.mean(hsv.reshape(-1, 3), axis=0) + + # 计算每个像素到前景和背景的距离 + hsv_float = hsv.astype(np.float32) + fg_dist = np.sqrt(np.sum((hsv_float - fg_color) ** 2, axis=2)) + bg_dist = np.sqrt(np.sum((hsv_float - bg_color) ** 2, axis=2)) + + # 如果到前景的距离小于到背景的距离,则认为是前景 + mask = np.where(fg_dist < bg_dist, 255, 0).astype(np.uint8) + + logger.info("使用改进的颜色距离方法完成简化抠图") + + # 形态学操作优化mask + kernel = np.ones((5, 5), np.uint8) + # 先闭运算填充小洞 + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) + # 再开运算去除小噪点 + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) + + # 找到最大连通区域(通常是人物主体) + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) + if num_labels > 1: + # 找到面积最大的连通区域(排除背景,索引0) + largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) + mask = np.where(labels == largest_label, 255, 0).astype(np.uint8) + + # 轻微高斯模糊使边缘更自然 + mask = cv2.GaussianBlur(mask, (5, 5), 1.0) + _, mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY) + + # 创建输出图像 + output = image.copy() + if output.shape[2] == 3: + # 添加alpha通道 + alpha = mask + output = np.dstack([output, alpha]) + + logger.info("使用简化的抠图方法完成") + return output, mask if return_mask else None + + except Exception as e: + logger.error(f"简化抠图失败: {e}") + import traceback + logger.error(traceback.format_exc()) + # 返回原始图像 + return image, None + + def segment_and_composite(self, image: np.ndarray, + background_color: Tuple[int, int, int] = (255, 255, 255), + background_image: Optional[np.ndarray] = None) -> np.ndarray: + """ + 分割并合成到新背景 + + Args: + image: 输入图像 + background_color: 背景颜色 (BGR格式) + background_image: 背景图像,如果提供则使用图片背景 + + Returns: + 合成后的图像 + """ + segmented, mask = self.segment_person(image, return_mask=True) + + if background_image is not None: + # 使用背景图片 + result = background_image.copy() + # 调整背景图片大小以匹配segmented + if result.shape[:2] != segmented.shape[:2]: + result = cv2.resize(result, (segmented.shape[1], segmented.shape[0])) + else: + # 使用纯色背景 + result = np.full_like(segmented, background_color) + + # 如果有mask,进行透明合成 + if mask is not None: + mask_normalized = mask.astype(float) / 255.0 + mask_3ch = np.stack([mask_normalized] * 3, axis=2) + + result = (segmented * mask_3ch + result * (1 - mask_3ch)).astype(np.uint8) + + return result + + def refine_mask(self, mask: np.ndarray) -> np.ndarray: + """优化mask,提高人像分割精度""" + try: + if not CV2_AVAILABLE: + return mask + + # 确保mask是uint8格式 + if mask.dtype != np.uint8: + if mask.max() <= 1.0: + mask = (mask * 255).astype(np.uint8) + else: + mask = mask.astype(np.uint8) + + # 使用自适应阈值而不是固定阈值,提高边缘精度 + # 进一步减小高斯模糊强度以保留更多边缘细节(从(3,3),0.5降到(3,3),0.3) + mask_blurred = cv2.GaussianBlur(mask, (3, 3), 0.3) + + # 使用OTSU自适应阈值,自动找到最佳阈值 + _, mask_binary = cv2.threshold(mask_blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # 形态学操作优化mask + # 使用较小的kernel,保持边缘细节 + kernel_small = np.ones((3, 3), np.uint8) + kernel_medium = np.ones((5, 5), np.uint8) + + # 先进行开运算去除小噪点(使用小kernel保持细节,减少迭代次数) + mask_cleaned = cv2.morphologyEx(mask_binary, cv2.MORPH_OPEN, kernel_small, iterations=1) + + # 闭运算填充小洞(使用中等kernel,减少迭代次数以保留更多细节) + # 进一步减少迭代次数(从1降到0,或者完全移除闭运算) + mask_filled = cv2.morphologyEx(mask_cleaned, cv2.MORPH_CLOSE, kernel_medium, iterations=1) + + # 使用轮廓检测找到最大连通区域(通常是人物) + # 这样可以去除背景中的小噪点 + contours, _ = cv2.findContours(mask_filled, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + if len(contours) > 0: + # 找到最大的轮廓(通常是人物主体) + largest_contour = max(contours, key=cv2.contourArea) + + # 创建新的mask,只保留最大轮廓 + mask_refined = np.zeros_like(mask_filled) + cv2.fillPoly(mask_refined, [largest_contour], 255) + + # 如果还有其他较大的轮廓(可能是手臂等),也保留 + # 进一步降低阈值以保留更多细节(从2%降到1.5%,特别适合低对比度场景) + for contour in contours: + area = cv2.contourArea(contour) + # 保留面积大于最大轮廓1.5%的轮廓(进一步降低阈值以保留更多细节,适合低对比度场景) + if area > cv2.contourArea(largest_contour) * 0.015: + cv2.fillPoly(mask_refined, [contour], 255) + logger.debug(f"保留轮廓,面积: {area}, 占比: {area/cv2.contourArea(largest_contour)*100:.1f}%") + + mask_filled = mask_refined + + # 轻微的高斯模糊使边缘更自然(但保持清晰) + # 进一步减小模糊强度以保留更多边缘细节(从0.3降到0.2) + mask_smooth = cv2.GaussianBlur(mask_filled, (3, 3), 0.2) + + # 最终二值化,使用更低的阈值以保留更多边缘细节(从127降到100) + _, mask_final = cv2.threshold(mask_smooth, 100, 255, cv2.THRESH_BINARY) + + logger.info(f"mask优化完成 - 原始非零像素: {np.count_nonzero(mask)}, 优化后: {np.count_nonzero(mask_final)}") + + return mask_final + + except Exception as e: + logger.warning(f"mask优化失败: {e}") + import traceback + logger.warning(traceback.format_exc()) + return mask + + def generate_border_mask(self, mask: np.ndarray, border_width: int = 3) -> np.ndarray: + """生成描边mask + + Args: + mask: 原始mask + border_width: 描边宽度 + + Returns: + 描边mask(只包含边缘区域) + """ + try: + if not CV2_AVAILABLE: + return mask + + # 使用形态学操作生成描边 + kernel_size = border_width * 2 + 1 + kernel = np.ones((kernel_size, kernel_size), np.uint8) + + # 膨胀操作 + dilated = cv2.dilate(mask, kernel, iterations=1) + + # 描边 = 膨胀后的mask - 原始mask + border_mask = cv2.subtract(dilated, mask) + + return border_mask + + except Exception as e: + logger.warning(f"生成描边mask失败: {e}") + return mask + + def _segment_with_modnet(self, image: np.ndarray, return_mask: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """使用MODNet进行人像分割""" + try: + if not PYTORCH_AVAILABLE or not MODNET_AVAILABLE or self.modnet_model is None: + logger.warning("MODNet不可用,使用rembg作为后备") + if not self.use_simple_method: + return self._segment_with_rembg(image, return_mask) + else: + return self._simple_segment_person(image, return_mask) + + # 转换BGR到RGB + if image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + rgb_image = image + + # 预处理图像 + # MODNet通常需要输入尺寸为512x512 + h, w = rgb_image.shape[:2] + ref_size = self.modnet_ref_size + + # 计算缩放比例,保持宽高比 + if max(h, w) > ref_size: + scale = ref_size / max(h, w) + new_h, new_w = int(h * scale), int(w * scale) + else: + new_h, new_w = h, w + + # 调整大小(必须是32的倍数,MODNet的要求) + new_h = (new_h // 32) * 32 + new_w = (new_w // 32) * 32 + if new_h < 32: + new_h = 32 + if new_w < 32: + new_w = 32 + + rgb_resized = cv2.resize(rgb_image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + + # 转换为PIL Image并归一化 + from PIL import Image + pil_image = Image.fromarray(rgb_resized) + + # MODNet的预处理(根据官方实现) + transform = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) + ]) + + input_tensor = transform(pil_image).unsqueeze(0).to(self.modnet_device) + + # 推理 + with torch.no_grad(): + # MODNet的前向传播返回 (pred_semantic, pred_detail, pred_matte) + # 我们只需要 pred_matte(alpha matte) + _, _, pred_matte = self.modnet_model(input_tensor, inference=True) + + # 处理输出 + alpha = pred_matte.squeeze().cpu().numpy() + if len(alpha.shape) == 3: + alpha = alpha[0] # 取第一个通道 + + # 归一化到0-255 + if alpha.max() <= 1.0: + alpha = (alpha * 255).astype(np.uint8) + else: + alpha = alpha.astype(np.uint8) + + # 调整alpha大小回原始尺寸 + if new_h != h or new_w != w: + alpha = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_LINEAR) + + # 创建RGBA图像 + bgr_image = image.copy() + if bgr_image.shape[2] == 4: + bgr_image = bgr_image[:, :, :3] + + # 应用alpha通道 + rgba_output = np.dstack([bgr_image, alpha]) + + # 如果需要mask + mask = None + if return_mask: + mask = alpha.copy() + mask = self.refine_mask(mask) + + logger.info(f"MODNet分割完成,输出形状: {rgba_output.shape}") + return rgba_output, mask + + except Exception as e: + logger.error(f"MODNet分割失败: {e}") + import traceback + logger.error(traceback.format_exc()) + # 回退到rembg + logger.info("MODNet失败,使用rembg作为后备") + if not self.use_simple_method: + return self._segment_with_rembg(image, return_mask) + else: + return self._simple_segment_person(image, return_mask) + + def _segment_with_rembg(self, image: np.ndarray, return_mask: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """使用rembg进行人像分割(内部方法)""" + # 转换颜色空间 BGR -> RGB + if image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + rgb_image = image + + # 使用rembg进行分割 + logger.info(f"使用模型 {self.model_name} 进行人像分割,启用后处理以提高精度(优化低对比度场景)") + output = remove( + rgb_image, + session=self.session, + post_process_mask=True, + alpha_matting=True, + alpha_matting_foreground_threshold=200, + alpha_matting_background_threshold=20, + alpha_matting_erode_size=8 + ) + logger.info(f"rembg分割完成,输出形状: {output.shape}, 模型: {self.model_name}") + + # 如果需要返回mask,计算mask + mask = None + if return_mask: + # 从alpha通道提取mask + if output.shape[2] == 4: + mask = output[:, :, 3] + # 优化mask:形态学操作去除噪点 + mask = self.refine_mask(mask) + else: + # 如果没有alpha通道,转换为灰度图作为mask + gray = cv2.cvtColor(output, cv2.COLOR_RGB2GRAY) + # 使用更低的阈值,确保捕获更多细节 + _, mask = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY) + mask = self.refine_mask(mask) + + # 转换回BGR格式 + if output.shape[2] == 4: + bgr_output = cv2.cvtColor(output[:, :, :3], cv2.COLOR_RGB2BGR) + # 合并alpha通道 + alpha = output[:, :, 3:4] + bgr_output = np.dstack([bgr_output, alpha]) + else: + bgr_output = cv2.cvtColor(output, cv2.COLOR_RGB2BGR) + + return bgr_output, mask + + +class MODNetSegmenter: + """基于MODNet的专业人像分割器""" + + def __init__(self, model_path: Optional[str] = None): + self.model_path = model_path or "models/modnet_photographic_portrait_matting.ckpt" + self.model = None + self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self._load_model() + + def _load_model(self): + """加载MODNet模型""" + try: + logger.info("加载MODNet人像分割模型") + + # 这里需要预训练的MODNet模型 + # 由于模型较大,这里提供接口,实际使用时需要下载模型 + logger.warning("MODNet模型需要单独下载,请确保模型文件存在") + + # 简化的实现,使用rembg作为后备 + self.fallback_segmenter = PersonSegmenter("u2net") + + except Exception as e: + logger.error(f"加载MODNet模型失败: {e}") + logger.info("使用U2Net作为后备方案") + self.fallback_segmenter = PersonSegmenter("u2net") + + def segment_person(self, image: np.ndarray) -> np.ndarray: + """使用MODNet进行人像分割""" + # 如果MODNet可用,使用MODNet + if self.model is not None: + try: + return self._segment_with_modnet(image) + except Exception as e: + logger.warning(f"MODNet分割失败,使用后备方案: {e}") + + # 使用后备方案 + return self.fallback_segmenter.segment_person(image, return_mask=False)[0] + + def _segment_with_modnet(self, image: np.ndarray, return_mask: bool = False) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """使用MODNet进行人像分割""" + try: + if not PYTORCH_AVAILABLE or not MODNET_AVAILABLE or self.modnet_model is None: + logger.warning("MODNet不可用,使用rembg作为后备") + if not self.use_simple_method: + return self._segment_with_rembg(image, return_mask) + else: + return self._simple_segment_person(image, return_mask) + + # 转换BGR到RGB + if image.shape[2] == 3: + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + rgb_image = image + + # 预处理图像 + # MODNet通常需要输入尺寸为512x512 + h, w = rgb_image.shape[:2] + ref_size = self.modnet_ref_size + + # 计算缩放比例,保持宽高比 + if max(h, w) > ref_size: + scale = ref_size / max(h, w) + new_h, new_w = int(h * scale), int(w * scale) + else: + new_h, new_w = h, w + + # 调整大小(必须是32的倍数,MODNet的要求) + new_h = (new_h // 32) * 32 + new_w = (new_w // 32) * 32 + if new_h < 32: + new_h = 32 + if new_w < 32: + new_w = 32 + + rgb_resized = cv2.resize(rgb_image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + + # 转换为PIL Image并归一化 + from PIL import Image + pil_image = Image.fromarray(rgb_resized) + + # MODNet的预处理(根据官方实现) + transform = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) + ]) + + input_tensor = transform(pil_image).unsqueeze(0).to(self.modnet_device) + + # 推理 + with torch.no_grad(): + # MODNet的前向传播返回 (pred_semantic, pred_detail, pred_matte) + # 我们只需要 pred_matte(alpha matte) + _, _, pred_matte = self.modnet_model(input_tensor, inference=True) + + # 处理输出 + alpha = pred_matte.squeeze().cpu().numpy() + if len(alpha.shape) == 3: + alpha = alpha[0] # 取第一个通道 + + # 归一化到0-255 + if alpha.max() <= 1.0: + alpha = (alpha * 255).astype(np.uint8) + else: + alpha = alpha.astype(np.uint8) + + # 调整alpha大小回原始尺寸 + if new_h != h or new_w != w: + alpha = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_LINEAR) + + # 创建RGBA图像 + bgr_image = image.copy() + if bgr_image.shape[2] == 4: + bgr_image = bgr_image[:, :, :3] + + # 应用alpha通道 + rgba_output = np.dstack([bgr_image, alpha]) + + # 如果需要mask + mask = None + if return_mask: + mask = alpha.copy() + mask = self.refine_mask(mask) + + logger.info(f"MODNet分割完成,输出形状: {rgba_output.shape}") + return rgba_output, mask + + except Exception as e: + logger.error(f"MODNet分割失败: {e}") + import traceback + logger.error(traceback.format_exc()) + # 回退到rembg + logger.info("MODNet失败,使用rembg作为后备") + if not self.use_simple_method: + return self._segment_with_rembg(image, return_mask) + else: + return self._simple_segment_person(image, return_mask) + + +def segment_image(image_path: str, output_path: Optional[str] = None, + model_name: str = "u2net") -> str: + """便捷的图像分割函数 + + Args: + image_path: 输入图像路径 + output_path: 输出路径,如果为None则自动生成 + model_name: 使用的模型名称 + + Returns: + 输出图像路径 + """ + try: + # 读取图像 + image = cv2.imread(image_path) + if image is None: + raise ValueError(f"无法读取图像: {image_path}") + + # 创建分割器 + segmenter = PersonSegmenter(model_name) + + # 进行分割 + segmented, _ = segmenter.segment_person(image) + + # 确定输出路径 + if output_path is None: + image_name = Path(image_path).stem + output_dir = ensure_dir("output") + output_path = str(output_dir / f"{image_name}_segmented.png") + + # 保存结果 + save_image(segmented, output_path) + + logger.info(f"人像分割完成: {output_path}") + return output_path + + except Exception as e: + logger.error(f"人像分割失败: {e}") + raise + + +def segment_person_from_video_frame(video_path: str, time_seconds: float = 1.0, + output_path: Optional[str] = None, + model_name: str = "u2net") -> str: + """从视频帧中分割人物 + + Args: + video_path: 视频路径 + time_seconds: 抽帧时间 + output_path: 输出路径 + model_name: 模型名称 + + Returns: + 分割后的图像路径 + """ + from .extractor import VideoExtractor + + try: + # 抽取帧 + extractor = VideoExtractor(video_path) + frame = extractor.extract_frame_at_time(time_seconds) + + # 保存临时帧 + temp_frame_path = f"temp_frame_{time_seconds}.jpg" + save_image(frame, temp_frame_path) + + # 分割人物 + result_path = segment_image(temp_frame_path, output_path, model_name) + + # 清理临时文件 + try: + os.remove(temp_frame_path) + except: + pass + + return result_path + + except Exception as e: + logger.error(f"视频帧人物分割失败: {e}") + raise diff --git a/scripts/cover/modules/utils.py b/scripts/cover/modules/utils.py new file mode 100644 index 0000000..b56577c --- /dev/null +++ b/scripts/cover/modules/utils.py @@ -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通道,保存为BGRA(OpenCV使用BGR格式) + # 确保图像是BGRA格式(B, G, R, A) + # 如果输入是RGBA(R, 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) diff --git a/scripts/nodejs/cover_generate.js b/scripts/nodejs/cover_generate.js new file mode 100644 index 0000000..2a13cb6 --- /dev/null +++ b/scripts/nodejs/cover_generate.js @@ -0,0 +1,186 @@ +"use strict"; + +/** + * 视频封面生成:优先高级 Python → PIL Python → ffmpeg drawtext 回退 + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawnSync } = require("child_process"); + +const pipelineNative = require("./pipeline_native.js"); +const coverPython = require("./cover_python_runner.js"); + +const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-cover"); + +function ensureDir() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + return OUTPUT_DIR; +} + +function execFfmpeg(args) { + const ffmpeg = pipelineNative.locateFfmpeg(); + if (!ffmpeg) { + throw new Error("未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe"); + } + const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true }); + if (r.status !== 0) { + throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`); + } +} + +function escapeDrawtext(text) { + return String(text) + .replace(/\\/g, "\\\\") + .replace(/'/g, "'\\''") + .replace(/:/g, "\\:") + .replace(/%/g, "\\%"); +} + +function buildTextPosition(titlePosition) { + if (titlePosition === "top") return "y=50"; + if (titlePosition === "center") return "y=(h-text_h)/2"; + return "y=h-text_h-50"; +} + +function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFramePath, emit }) { + emit?.("正在提取视频帧…"); + execFfmpeg([ + "-ss", + "00:00:03", + "-i", + sourceVideo, + "-vframes", + "1", + "-pix_fmt", + "yuvj420p", + "-c:v", + "mjpeg", + "-strict:v", + "unofficial", + "-f", + "image2", + "-y", + tempFramePath, + ]); + + const titleFontSize = effectStyle.titleFontSize ?? 90; + const titleFontColor = effectStyle.titleFontColor || "#FFFFFF"; + const titlePosition = effectStyle.titlePosition || "bottom"; + const textPos = buildTextPosition(titlePosition); + const escaped = escapeDrawtext(titleText); + const vf = `drawtext=text='${escaped}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPos}:shadowcolor=black:shadowx=2:shadowy=2`; + + emit?.("正在叠加标题文字(ffmpeg)…"); + execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]); +} + +function shouldUsePilCover(effectStyle) { + if (coverPython.isAdvancedCoverConfig(effectStyle)) return false; + return ( + (effectStyle.titleStrokeWidth && effectStyle.titleStrokeWidth > 0) || + Boolean(effectStyle.titleFontFamily) || + Boolean(effectStyle.titleColor) || + effectStyle.titleShadowBlur > 0 + ); +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const videoPath = String(p.videoPath || "").trim(); + const titleText = String(p.titleText || "").trim(); + const effectStyle = p.effectStyle || {}; + + if (!videoPath || !fs.existsSync(videoPath)) { + return { success: false, error: "视频文件不存在,请先在步骤 02/03/05 生成视频" }; + } + if (!titleText) { + return { success: false, error: "标题文本不能为空,请先在步骤 04 生成标题" }; + } + + ensureDir(); + const ts = Date.now(); + const tempFramePath = path.join(OUTPUT_DIR, `temp_frame_${ts}.jpg`); + const coverPath = path.join(OUTPUT_DIR, `cover_${ts}.jpg`); + + let sourceVideo = videoPath; + const custom = String(effectStyle.customVideoPath || "").trim(); + if (custom && fs.existsSync(custom)) { + sourceVideo = custom; + } + + const emit = (status) => globalThis.__native?.emitProgress?.(status); + + try { + if (coverPython.isAdvancedCoverConfig(effectStyle)) { + const adv = coverPython.runAdvancedCoverGenerator({ + videoPath: sourceVideo, + titleText, + outputPath: coverPath, + config: effectStyle, + onProgress: emit, + }); + if (adv.success) { + return { + success: true, + coverPath: adv.coverPath, + previewImages: adv.previewImages, + message: adv.message || "高级封面生成完成", + mode: "advanced_python", + }; + } + emit(`高级生成失败,尝试回退:${(adv.error || "").slice(0, 80)}`); + } else if (shouldUsePilCover(effectStyle) && coverPython.locatePython()) { + const pil = coverPython.runBasicCoverGenerator({ + videoPath: sourceVideo, + titleText, + outputPath: coverPath, + config: effectStyle, + timestamp: 3, + onProgress: emit, + }); + if (pil.success) { + return { + success: true, + coverPath: pil.coverPath, + message: pil.message || "封面生成完成", + mode: "pil_python", + }; + } + emit(`PIL 生成失败,尝试 ffmpeg 回退…`); + } + + runFfmpegCover({ + sourceVideo, + titleText, + effectStyle, + coverPath, + tempFramePath, + emit, + }); + + if (fs.existsSync(tempFramePath)) { + try { + fs.unlinkSync(tempFramePath); + } catch { + /* ignore */ + } + } + + if (!fs.existsSync(coverPath)) { + return { success: false, error: "封面文件未生成" }; + } + + return { + success: true, + coverPath, + message: "封面生成完成", + mode: "ffmpeg", + }; + } catch (e) { + return { + success: false, + error: e.message || String(e), + }; + } +}; diff --git a/scripts/nodejs/cover_python_runner.js b/scripts/nodejs/cover_python_runner.js new file mode 100644 index 0000000..131eae9 --- /dev/null +++ b/scripts/nodejs/cover_python_runner.js @@ -0,0 +1,203 @@ +"use strict"; + +/** + * 调用 bundled Python 封面脚本(对齐 Electron ipAgent:generateVideoCover) + */ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const pipelineNative = require("./pipeline_native.js"); + +function locatePython() { + const env = process.env.AICLIENT_PYTHON_PATH; + if (env && fs.existsSync(env)) return env; + return null; +} + +function locateCoverScriptsDir() { + const env = process.env.AICLIENT_COVER_SCRIPTS_DIR; + if (env && fs.existsSync(path.join(env, "advanced_cover_generator.py"))) { + return env; + } + return null; +} + +function normalizePathForPython(filePath) { + if (process.platform === "win32") { + return String(filePath).replace(/\\/g, "/"); + } + return String(filePath); +} + +function buildPythonEnv() { + const env = { ...process.env, PYTHONIOENCODING: "utf-8" }; + const ffmpeg = pipelineNative.locateFfmpeg(); + if (ffmpeg && fs.existsSync(ffmpeg)) { + const ffmpegDir = path.dirname(ffmpeg); + const sep = process.platform === "win32" ? ";" : ":"; + env.PATH = `${ffmpegDir}${sep}${env.PATH || ""}`; + env.AICLIENT_FFMPEG_PATH = ffmpeg; + } + return env; +} + +/** 与 Electron main 中 isNewTemplate 判定一致 */ +function isAdvancedCoverConfig(config) { + if (!config || typeof config !== "object") return false; + return ( + config.blurBackground !== undefined || + config.extractPerson !== undefined || + config.personOutlineColor !== undefined || + config.personOutlineWidth !== undefined || + config.maskImagePath !== undefined || + Boolean(config.titleFontFamily) || + config.titleBackgroundEnabled !== undefined || + config.personSize !== undefined || + config.backgroundBlurEnabled !== undefined + ); +} + +/** + * @param {{ videoPath: string, titleText: string, outputPath: string, config: object, onProgress?: (s: string) => void }} opts + */ +function runAdvancedCoverGenerator(opts) { + const python = locatePython(); + const scriptsDir = locateCoverScriptsDir(); + if (!python) { + return { success: false, error: "未找到 Python 运行时,请检查 resources-bundles/python-runtimebackup" }; + } + if (!scriptsDir) { + return { success: false, error: "未找到封面脚本目录(cover-python)" }; + } + + const script = path.join(scriptsDir, "advanced_cover_generator.py"); + if (!fs.existsSync(script)) { + return { success: false, error: `缺少脚本: ${script}` }; + } + + const configJson = JSON.stringify({ + ...opts.config, + output: opts.outputPath, + customVideoPath: opts.config.customVideoPath || undefined, + }); + + opts.onProgress?.("正在使用高级封面生成器…"); + + const args = [ + script, + "--video", + normalizePathForPython(opts.videoPath), + "--title", + opts.titleText, + "--output", + normalizePathForPython(opts.outputPath), + "--config", + configJson, + ]; + + const r = spawnSync(python, args, { + encoding: "utf8", + windowsHide: true, + cwd: scriptsDir, + env: buildPythonEnv(), + maxBuffer: 20 * 1024 * 1024, + }); + + if (r.error) { + return { success: false, error: r.error.message || String(r.error) }; + } + + let previewImages = []; + const stdout = (r.stdout || "").trim(); + if (stdout) { + try { + const lastLine = stdout.split("\n").filter(Boolean).pop(); + const parsed = JSON.parse(lastLine || stdout); + if (parsed.previewImages) previewImages = parsed.previewImages; + if (parsed.success === false) { + return { + success: false, + error: parsed.error || parsed.message || "高级封面生成失败", + stderr: r.stderr, + }; + } + } catch { + /* 非 JSON 时仅检查文件 */ + } + } + + if (r.status !== 0 || !fs.existsSync(opts.outputPath)) { + const errMsg = + (r.stderr || "").trim() || + stdout || + `Python 退出码 ${r.status ?? "unknown"}`; + return { success: false, error: errMsg }; + } + + return { + success: true, + coverPath: opts.outputPath, + previewImages, + message: "高级封面生成完成", + }; +} + +/** + * PIL 中等复杂度封面(cover_generator.py) + * @param {{ videoPath: string, titleText: string, outputPath: string, config: object, timestamp?: number, onProgress?: (s: string) => void }} opts + */ +function runBasicCoverGenerator(opts) { + const python = locatePython(); + const scriptsDir = locateCoverScriptsDir(); + if (!python || !scriptsDir) { + return { success: false, error: "未找到 Python 或封面脚本" }; + } + + const script = path.join(scriptsDir, "cover_generator.py"); + if (!fs.existsSync(script)) { + return { success: false, error: `缺少脚本: ${script}` }; + } + + opts.onProgress?.("正在使用 PIL 封面生成器…"); + + const configJson = JSON.stringify(opts.config || {}); + const args = [ + script, + "--video", + normalizePathForPython(opts.videoPath), + "--title", + opts.titleText, + "--output", + normalizePathForPython(opts.outputPath), + "--config", + configJson, + "--timestamp", + String(opts.timestamp ?? 3), + ]; + + const r = spawnSync(python, args, { + encoding: "utf8", + windowsHide: true, + cwd: scriptsDir, + env: buildPythonEnv(), + maxBuffer: 10 * 1024 * 1024, + }); + + if (r.status === 0 && fs.existsSync(opts.outputPath)) { + return { success: true, coverPath: opts.outputPath, message: "封面生成完成" }; + } + + return { + success: false, + error: (r.stderr || r.stdout || "").trim() || `Python 退出码 ${r.status}`, + }; +} + +module.exports = { + isAdvancedCoverConfig, + runAdvancedCoverGenerator, + runBasicCoverGenerator, + locatePython, + locateCoverScriptsDir, +}; diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js index 80c722f..cb92fea 100644 --- a/scripts/nodejs/pipeline_native.js +++ b/scripts/nodejs/pipeline_native.js @@ -259,6 +259,25 @@ function parseTranscriptionText(trans) { return fullText; } +/** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */ +function parseTranscriptionSentences(trans) { + const records = []; + if (trans?.transcripts?.length) { + for (const t of trans.transcripts) { + if (t.sentences?.length) { + for (const s of t.sentences) { + const text = String(s.text || "").trim(); + if (!text) continue; + const start = Number(s.begin_time ?? s.start_time ?? 0); + const end = Number(s.end_time ?? s.end_time ?? start + 2000); + records.push({ text, start, end }); + } + } + } + } + return records; +} + async function fetchJson(url, options = {}) { const res = await fetch(url, options); const text = await res.text(); @@ -537,7 +556,8 @@ async function aliyunAsrFiletrans(audioUrl, opts) { } const trans = await fetchJson(transUrl); const fullText = parseTranscriptionText(trans); - return { success: true, text: fullText }; + const sentences = parseTranscriptionSentences(trans); + return { success: true, text: fullText, sentences }; } if (status === "FAILED" || status === "UNKNOWN") { const detail = @@ -597,10 +617,21 @@ async function localAsr(audioPath, info) { data.data?.records || data.records || []; - const text = Array.isArray(records) - ? records.map((r) => r.text || "").join("") + const sentences = Array.isArray(records) + ? records + .map((r) => { + const text = String(r.text || "").trim(); + if (!text) return null; + const start = Number(r.start ?? r.begin_time ?? 0); + const end = Number(r.end ?? r.end_time ?? start + 2000); + return { text, start, end }; + }) + .filter(Boolean) + : []; + const text = sentences.length + ? sentences.map((r) => r.text).join("") : data.text || data.data?.text || ""; - resolve({ success: true, text: String(text) }); + resolve({ success: true, text: String(text), sentences }); } else { resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) }); } @@ -656,8 +687,10 @@ module.exports = { aliyunOssUpload, aliyunAsrFiletrans, localAsr, + parseTranscriptionSentences, openaiChat, resolveTtsModel, splitTextForTts, qwenTtsSynthesize, + locateFfmpeg, }; diff --git a/scripts/nodejs/publish_browser.js b/scripts/nodejs/publish_browser.js new file mode 100644 index 0000000..2ca49b4 --- /dev/null +++ b/scripts/nodejs/publish_browser.js @@ -0,0 +1,121 @@ +"use strict"; + +/** + * 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir) + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const { + COMMON_BROWSER_ARGS, + BROWSER_IGNORE_DEFAULT_ARGS, + STEALTH_INIT_SCRIPT, + DEFAULT_USER_AGENT, +} = require("./douyin_browser_constants.js"); + +const browsers = new Map(); + +function getRuntimeDataPath(...parts) { + const base = process.env.AICLIENT_DATA_DIR || path.join(os.homedir(), ".aiclient"); + return path.join(base, ...parts); +} + +function getChromiumExecutablePath() { + const fromEnv = + process.env.AICLIENT_CHROMIUM_PATH || process.env.PUPPETEER_EXECUTABLE_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + const candidates = []; + if (process.platform === "win32") { + const pf = process.env.ProgramFiles || "C:\\Program Files"; + const local = process.env.LOCALAPPDATA || ""; + candidates.push( + path.join(pf, "Google", "Chrome", "Application", "chrome.exe"), + path.join(local, "Google", "Chrome", "Application", "chrome.exe"), + path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), + ); + } + return candidates.find((p) => p && fs.existsSync(p)); +} + +function loadPuppeteer() { + try { + return require("puppeteer-core"); + } catch { + return require("puppeteer"); + } +} + +function contextKey(platform, accountId) { + return accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`; +} + +function userDataDir(platform, accountId) { + const sub = accountId ? `${platform}_${accountId}` : platform; + return getRuntimeDataPath("browser-data", "publish", sub); +} + +function delay(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function getPublishBrowser(platform, accountId) { + const key = contextKey(platform, accountId); + const existing = browsers.get(key); + if (existing && existing.isConnected()) { + return existing; + } + const puppeteer = loadPuppeteer(); + const dir = userDataDir(platform, accountId); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const executablePath = getChromiumExecutablePath(); + if (!executablePath) { + throw new Error( + "未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH", + ); + } + const browser = await puppeteer.launch({ + executablePath, + headless: false, + userDataDir: dir, + args: COMMON_BROWSER_ARGS, + ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS, + defaultViewport: { width: 1280, height: 900 }, + }); + browser.on("disconnected", () => browsers.delete(key)); + browsers.set(key, browser); + return browser; +} + +async function newPublishPage(platform, accountId, cookies) { + const browser = await getPublishBrowser(platform, accountId); + const page = await browser.newPage(); + await page.setUserAgent(DEFAULT_USER_AGENT); + await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT); + page.setDefaultNavigationTimeout(60000); + page.setDefaultTimeout(60000); + if (Array.isArray(cookies) && cookies.length) { + try { + await page.setCookie(...cookies); + } catch (e) { + console.warn("setCookie 失败:", e.message); + } + } + return page; +} + +const LOGIN_URLS = { + douyin: "https://creator.douyin.com/", + kuaishou: "https://cp.kuaishou.com/", + shipin: "https://channels.weixin.qq.com/", + xiaohongshu: "https://creator.xiaohongshu.com/", +}; + +module.exports = { + delay, + getPublishBrowser, + newPublishPage, + userDataDir, + LOGIN_URLS, + getChromiumExecutablePath, +}; diff --git a/scripts/nodejs/publish_check_login.js b/scripts/nodejs/publish_check_login.js new file mode 100644 index 0000000..e8d57e1 --- /dev/null +++ b/scripts/nodejs/publish_check_login.js @@ -0,0 +1,71 @@ +"use strict"; + +const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js"); +async function checkDouyin(page) { + await page.goto("https://creator.douyin.com/creator-micro/content/upload", { + waitUntil: "domcontentloaded", + }); + await delay(3000); + const url = page.url(); + if (url.includes("login") || url.includes("passport")) { + return { isLoggedIn: false }; + } + const avatar = await page.$("#header-avatar"); + if (!avatar) return { isLoggedIn: false }; + let nickname = ""; + try { + nickname = await page.$eval("#header-avatar", (el) => el.getAttribute("alt") || ""); + } catch { + /* ignore */ + } + return { + isLoggedIn: true, + userInfo: { nickname: nickname || "抖音用户", userId: "" }, + }; +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const platform = String(p.platform || "").trim(); + const accountId = p.accountId || null; + const cookies = Array.isArray(p.cookies) ? p.cookies : []; + + if (!LOGIN_URLS[platform]) { + return { success: false, isLoggedIn: false, error: `不支持的平台: ${platform}` }; + } + + globalThis.__native?.emitProgress?.("正在检测登录状态…"); + const page = await newPublishPage(platform, accountId, cookies); + try { + let result; + if (platform === "douyin") { + result = await checkDouyin(page); + } else { + await page.goto(LOGIN_URLS[platform], { waitUntil: "domcontentloaded" }); + await delay(3000); + const url = page.url(); + const loggedIn = !["login", "passport", "auth"].some((k) => url.includes(k)); + result = { + isLoggedIn: loggedIn, + userInfo: loggedIn ? { nickname: "已登录", userId: "" } : undefined, + }; + } + return { + success: true, + isLoggedIn: Boolean(result.isLoggedIn), + userInfo: result.userInfo, + }; + } catch (e) { + return { + success: false, + isLoggedIn: false, + message: e.message || String(e), + }; + } finally { + try { + await page.close(); + } catch { + /* ignore */ + } + } +}; diff --git a/scripts/nodejs/publish_login.js b/scripts/nodejs/publish_login.js new file mode 100644 index 0000000..0d4c6e5 --- /dev/null +++ b/scripts/nodejs/publish_login.js @@ -0,0 +1,82 @@ +"use strict"; + +const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js"); + +const LOGIN_SELECTORS = { + douyin: ["#header-avatar", ".creator-avatar", 'input[type="file"]'], + kuaishou: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'], + shipin: ['[class*="avatar"]', ".avatar"], + xiaohongshu: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'], +}; + +const URL_EXCLUDE = { + douyin: ["login", "passport"], + kuaishou: ["login", "passport"], + shipin: ["login", "auth", "qr"], + xiaohongshu: ["login", "passport", "account"], +}; + +async function checkLoggedIn(page, platform) { + const url = page.url(); + const excludes = URL_EXCLUDE[platform] || URL_EXCLUDE.douyin; + if (excludes.some((k) => url.includes(k))) return false; + const sels = LOGIN_SELECTORS[platform] || LOGIN_SELECTORS.douyin; + for (const sel of sels) { + const el = await page.$(sel); + if (el) return true; + } + return false; +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const platform = String(p.platform || "").trim(); + const accountId = p.accountId || null; + const loginUrl = LOGIN_URLS[platform]; + if (!loginUrl) { + return { success: false, error: `不支持的平台: ${platform}` }; + } + + globalThis.__native?.emitProgress?.("正在打开登录页,请在浏览器中完成登录…"); + const page = await newPublishPage(platform, accountId, []); + try { + await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + await delay(3000); + + const maxMs = 300000; + const start = Date.now(); + while (Date.now() - start < maxMs) { + if (await checkLoggedIn(page, platform)) { + const cookies = await page.cookies(); + let nickname = ""; + try { + nickname = await page.$eval( + ".username, .account-name, .user-name, .nickname", + (el) => (el.textContent || "").trim(), + ); + } catch { + nickname = "已登录用户"; + } + return { + success: true, + message: "登录成功", + nickname: nickname || "已登录用户", + uid: `user_${Date.now()}`, + cookies, + }; + } + await delay(2000); + globalThis.__native?.emitProgress?.("等待登录完成…"); + } + return { + success: false, + error: "登录超时(5 分钟),请重试", + }; + } finally { + try { + await page.close(); + } catch { + /* keep browser for user */ + } + } +}; diff --git a/scripts/nodejs/publish_platform.js b/scripts/nodejs/publish_platform.js new file mode 100644 index 0000000..8b73d3d --- /dev/null +++ b/scripts/nodejs/publish_platform.js @@ -0,0 +1,36 @@ +"use strict"; + +const { newPublishPage } = require("./publish_browser.js"); +const { publishToDouyin } = require("./publish_to_douyin.js"); + +const PLATFORM_LABELS = { + douyin: "抖音", + kuaishou: "快手", + shipin: "视频号", + xiaohongshu: "小红书", +}; + +async function publishToPlatform(platform, accountId, cookies, params) { + const label = PLATFORM_LABELS[platform] || platform; + if (platform === "douyin") { + const page = await newPublishPage(platform, accountId, cookies); + try { + return await publishToDouyin(page, params); + } finally { + if (!params.keepPageOpen) { + try { + await page.close(); + } catch { + /* ignore */ + } + } + } + } + return { + success: false, + status: "failed", + message: `${label}自动发布即将支持,请先使用抖音`, + }; +} + +module.exports = { publishToPlatform, PLATFORM_LABELS }; diff --git a/scripts/nodejs/publish_to_douyin.js b/scripts/nodejs/publish_to_douyin.js new file mode 100644 index 0000000..749f5d4 --- /dev/null +++ b/scripts/nodejs/publish_to_douyin.js @@ -0,0 +1,197 @@ +"use strict"; + +/** + * 抖音创作者中心发布(对齐 Electron publishToDouyin,Puppeteer 版) + */ +const fs = require("fs"); +const { delay } = require("./publish_browser.js"); + +async function clickIfVisible(page, selector) { + const el = await page.$(selector); + if (!el) return false; + try { + await el.click(); + return true; + } catch { + return false; + } +} + +async function typeIntoFirst(page, selectors, text) { + for (const sel of selectors) { + const el = await page.$(sel); + if (!el) continue; + try { + await el.click({ clickCount: 3 }); + await page.keyboard.down("Control"); + await page.keyboard.press("a"); + await page.keyboard.up("Control"); + await page.keyboard.press("Backspace"); + await el.type(text, { delay: 30 }); + return true; + } catch { + /* try next */ + } + } + return false; +} + +async function publishToDouyin(page, params) { + const { videoPath, coverPath, title, tags = [], autoPublish } = params; + if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在"); + if (!fs.existsSync(coverPath)) throw new Error("封面文件不存在"); + + globalThis.__native?.emitProgress?.("正在打开抖音上传页…"); + const uploadUrl = "https://creator.douyin.com/creator-micro/content/upload"; + await page.goto(uploadUrl, { waitUntil: "domcontentloaded" }); + await delay(3000); + + let currentUrl = page.url(); + if (currentUrl.includes("login") || currentUrl.includes("passport")) { + throw new Error("未登录抖音,请先在步骤 07 点击「登录平台」"); + } + + let avatar = await page.$("#header-avatar"); + if (!avatar) { + await delay(5000); + avatar = await page.$("#header-avatar"); + } + if (!avatar) { + throw new Error("抖音登录已过期,请重新登录"); + } + + if (!page.url().includes("content/upload")) { + await page.goto(uploadUrl, { waitUntil: "domcontentloaded" }); + await delay(3000); + } + + globalThis.__native?.emitProgress?.("正在上传视频…"); + await page.waitForSelector('input[type="file"]', { timeout: 60000 }); + const fileInputs = await page.$$('input[type="file"]'); + if (!fileInputs.length) throw new Error("未找到视频上传控件"); + await fileInputs[0].uploadFile(videoPath); + await delay(5000); + + globalThis.__native?.emitProgress?.("等待视频处理…"); + const start = Date.now(); + let ready = false; + while (Date.now() - start < 300000 && !ready) { + const titleBox = await page.$('textarea[placeholder*="标题"], input[placeholder*="标题"]'); + if (titleBox) { + try { + const disabled = await page.evaluate((el) => el.disabled, titleBox); + if (!disabled) ready = true; + } catch { + ready = true; + } + } + if (!ready) await delay(2000); + } + + globalThis.__native?.emitProgress?.("正在上传封面…"); + try { + await clickIfVisible(page, 'button'); + const buttons = await page.$$("button"); + for (const btn of buttons) { + const text = await page.evaluate((el) => el.textContent || "", btn); + if (String(text).includes("选择封面")) { + await btn.click(); + await delay(2000); + break; + } + } + const imgInput = await page.$('input[type="file"][accept*="image"]'); + if (imgInput) { + await imgInput.uploadFile(coverPath); + await delay(3000); + } + const doneBtns = await page.$$("button"); + for (const btn of doneBtns) { + const text = await page.evaluate((el) => el.textContent || "", btn); + if (String(text).trim() === "完成") { + await btn.click(); + await delay(2000); + break; + } + } + } catch (e) { + console.warn("封面上传跳过:", e.message); + } + + globalThis.__native?.emitProgress?.("正在填写标题与话题…"); + const titleText = String(title || "").slice(0, 30); + await typeIntoFirst( + page, + [ + 'input[placeholder*="填写作品标题"]', + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + ], + titleText, + ); + + const tagList = Array.isArray(tags) ? tags : []; + if (tagList.length) { + const descOk = await typeIntoFirst( + page, + [ + 'textarea[placeholder*="描述"]', + 'textarea[placeholder*="简介"]', + 'motion[contenteditable="true"]', + 'div[contenteditable="true"]', + ], + "", + ); + if (descOk) { + for (const tag of tagList) { + const t = String(tag).trim(); + if (!t) continue; + const clean = t.startsWith("#") ? t : `#${t}`; + await page.keyboard.type(clean, { delay: 40 }); + await page.keyboard.press("Space"); + await delay(400); + } + } + } + + if (!autoPublish) { + return { + success: false, + status: "manual_pending", + message: "内容已填写,请在浏览器中检查并手动点击发布", + videoUrl: page.url(), + keepPageOpen: true, + }; + } + + globalThis.__native?.emitProgress?.("正在点击发布…"); + const publishBtns = await page.$$("button"); + let clicked = false; + for (const btn of publishBtns) { + const text = (await page.evaluate((el) => el.textContent || "", btn)).trim(); + if (text === "发布" || text === "确定发布") { + await btn.click(); + clicked = true; + break; + } + } + + if (!clicked) { + return { + success: false, + status: "manual_pending", + message: "未找到发布按钮,请手动发布", + videoUrl: page.url(), + }; + } + + await delay(5000); + return { + success: true, + status: "success", + message: "已提交发布", + videoUrl: page.url(), + }; +} + +module.exports = { publishToDouyin }; diff --git a/scripts/nodejs/runninghub_generate.js b/scripts/nodejs/runninghub_generate.js index b685ec7..56a1898 100644 --- a/scripts/nodejs/runninghub_generate.js +++ b/scripts/nodejs/runninghub_generate.js @@ -169,8 +169,36 @@ async function uploadFile(baseUrl, apiKey, filePath) { return fileName; } +function locateFfmpegBinary() { + const fromEnv = process.env.AICLIENT_FFMPEG_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; +} + +function locateFfprobeBinary() { + const fromEnv = process.env.AICLIENT_FFPROBE_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + const ffmpeg = locateFfmpegBinary(); + if (ffmpeg && ffmpeg !== "ffmpeg" && ffmpeg !== "ffmpeg.exe") { + const dir = path.dirname(ffmpeg); + const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe"); + if (fs.existsSync(sibling)) return sibling; + } + return process.platform === "win32" ? "ffprobe.exe" : "ffprobe"; +} + +function parseDurationFromFfmpegStderr(text) { + const m = String(text || "").match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); + if (!m) return 0; + const h = parseFloat(m[1]); + const min = parseFloat(m[2]); + const s = parseFloat(m[3]); + if (!Number.isFinite(h + min + s)) return 0; + return h * 3600 + min * 60 + s; +} + function probeAudioDuration(audioPath) { - const ffprobe = process.env.AICLIENT_FFPROBE_PATH || "ffprobe"; + const ffprobe = locateFfprobeBinary(); const r = spawnSync( ffprobe, [ @@ -184,9 +212,19 @@ function probeAudioDuration(audioPath) { ], { encoding: "utf8", windowsHide: true }, ); - if (r.status !== 0) return 0; - const n = parseFloat(String(r.stdout || "").trim()); - return Number.isFinite(n) && n > 0 ? n : 0; + if (r.status === 0) { + const n = parseFloat(String(r.stdout || "").trim()); + if (Number.isFinite(n) && n > 0) return n; + } + + const ffmpeg = locateFfmpegBinary(); + const r2 = spawnSync(ffmpeg, ["-i", audioPath], { + encoding: "utf8", + windowsHide: true, + }); + const fromStderr = parseDurationFromFfmpegStderr(r2.stderr || ""); + if (fromStderr > 0) return fromStderr; + return 0; } async function createTask(config, videoFileName, audioFileName, audioDuration) { @@ -379,6 +417,14 @@ globalThis.__nodejsMain = async function (params) { if (!Number.isFinite(audioDuration) || audioDuration <= 0) { audioDuration = probeAudioDuration(audioPath); } + if (audioDuration <= 0) { + return { + success: false, + error: + "无法获取音频时长。云端口播需将时长传给 RunningHub(请安装 ffmpeg/ffprobe,或升级客户端后重试)", + }; + } + const durationSec = Math.ceil(audioDuration); try { const videoFileName = await uploadFile(config.baseUrl, config.apiKey, videoPath); @@ -387,7 +433,7 @@ globalThis.__nodejsMain = async function (params) { config, videoFileName, audioFileName, - audioDuration, + durationSec, ); const done = await waitForTask(config, taskId); const remoteUrl = pickResultUrl(done.results); @@ -398,7 +444,12 @@ globalThis.__nodejsMain = async function (params) { }; } const localPath = await ensureLocalVideo(remoteUrl); - return { success: true, videoPath: localPath, taskId }; + return { + success: true, + videoPath: localPath, + taskId, + audioDuration: durationSec, + }; } catch (e) { return { success: false, error: e.message || String(e) }; } diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js new file mode 100644 index 0000000..f0f5043 --- /dev/null +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -0,0 +1,288 @@ +"use strict"; + +/** + * 自动生成字幕并混 BGM(对齐 Electron autoGenerateSubtitleAndBGM) + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawnSync } = require("child_process"); + +const pipelineNative = require("./pipeline_native.js"); +const desktopConfig = require("./desktop_config.js"); + +const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm"); + +function ensureDir() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + return OUTPUT_DIR; +} + +function makeOutputPath(prefix, ext = "mp4") { + return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`); +} + +function execFfmpeg(args) { + const ffmpeg = pipelineNative.locateFfmpeg(); + if (!ffmpeg) { + throw new Error("未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe"); + } + const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true }); + if (r.status !== 0) { + throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`); + } +} + +function probeVideoDuration(videoPath) { + const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1"); + if (ffprobe && fs.existsSync(ffprobe)) { + const r = spawnSync( + ffprobe, + [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + videoPath, + ], + { encoding: "utf8", windowsHide: true }, + ); + if (r.status === 0) { + const n = parseFloat(String(r.stdout || "").trim()); + if (Number.isFinite(n) && n > 0) return n; + } + } + const ffmpeg = pipelineNative.locateFfmpeg(); + const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true }); + const stderr = r2.stderr || ""; + const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); + if (m) { + return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]); + } + return 0; +} + +function splitScriptLines(text) { + return String(text || "") + .split(/(?<=[。!?.!?])\s*|\n+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function alignScriptToRecords(script, records) { + const lines = splitScriptLines(script); + if (!lines.length || !records.length) return records; + + if (lines.length === records.length) { + return records.map((r, i) => ({ ...r, text: lines[i] })); + } + + const startMs = records[0].start; + const endMs = records[records.length - 1].end; + const total = Math.max(endMs - startMs, lines.length * 2000); + const step = total / lines.length; + return lines.map((text, i) => ({ + text, + start: Math.round(startMs + i * step), + end: Math.round(startMs + (i + 1) * step), + })); +} + +function recordsFromTextEvenly(text, durationSec) { + const lines = splitScriptLines(text); + if (!lines.length) return []; + const totalMs = Math.max(Math.round(durationSec * 1000), lines.length * 1500); + const step = totalMs / lines.length; + return lines.map((line, i) => ({ + text: line, + start: Math.round(i * step), + end: Math.round((i + 1) * step), + })); +} + +function formatSrtTime(ms) { + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + const msPart = Math.floor(ms % 1000); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`; +} + +function buildSrtContent(records) { + return records + .map((r, i) => { + const start = formatSrtTime(r.start); + const end = formatSrtTime(Math.max(r.end, r.start + 200)); + return `${i + 1}\n${start} --> ${end}\n${r.text}\n`; + }) + .join("\n"); +} + +function escapeSubPath(filePath) { + return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'"); +} + +function addSubtitleToVideo(inputVideo, srtPath, forceStyle) { + const outputVideo = makeOutputPath("with_subtitle"); + const style = forceStyle || "FontName=Microsoft YaHei,FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Alignment=2,MarginV=40"; + const sub = escapeSubPath(srtPath); + const vf = `subtitles='${sub}':force_style='${style}'`; + execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]); + return outputVideo; +} + +function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) { + const outputVideo = makeOutputPath("with_bgm"); + const ratio = Number(bgmVolume) / 100; + const filter = `[0:a]volume=1.0[a0];[1:a]volume=${ratio}[a1];[a0][a1]amix=inputs=2:duration=first`; + execFfmpeg([ + "-y", + "-i", + inputVideo, + "-stream_loop", + "-1", + "-i", + bgmPath, + "-filter_complex", + filter, + "-c:v", + "copy", + "-shortest", + outputVideo, + ]); + return outputVideo; +} + +async function runAsr(audioPath, modelConfig, scriptContent) { + const asrMode = modelConfig.asrMode || "online"; + if (asrMode === "online") { + if (!modelConfig.aliyunApiKey) { + throw new Error("在线 ASR 需配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY"); + } + if (!modelConfig.ossConfig?.bucket) { + throw new Error("在线 ASR 需配置 OSS(上传音频供识别)"); + } + globalThis.__native?.emitProgress?.("正在上传音频到 OSS…"); + const upload = await pipelineNative.aliyunOssUpload(audioPath, modelConfig.ossConfig); + if (!upload?.success || !upload.url) { + throw new Error(upload?.error || "OSS 上传失败"); + } + globalThis.__native?.emitProgress?.("正在进行语音识别…"); + const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, { + apiKey: modelConfig.aliyunApiKey, + model: "qwen3-asr-flash-filetrans", + }); + if (!asr?.success) { + throw new Error(asr?.error || "语音识别失败"); + } + let records = Array.isArray(asr.sentences) ? asr.sentences : []; + if (!records.length && asr.text) { + records = recordsFromTextEvenly(asr.text, 60); + } + return { text: asr.text || "", records }; + } + + const info = modelConfig.asrServerInfo; + if (!info) { + throw new Error("本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO"); + } + globalThis.__native?.emitProgress?.("正在调用本地语音识别…"); + const asr = await pipelineNative.localAsr(audioPath, info); + if (!asr?.success) { + throw new Error(asr?.error || "本地语音识别失败"); + } + let records = Array.isArray(asr.sentences) ? asr.sentences : []; + if (!records.length && asr.text) { + records = recordsFromTextEvenly(asr.text, 60); + } + return { text: asr.text || "", records }; +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const inputVideo = String(p.inputVideo || "").trim(); + if (!inputVideo || !fs.existsSync(inputVideo)) { + return { success: false, error: "缺少或找不到输入视频,请先在步骤 02/03 生成并处理视频" }; + } + + const autoSubtitle = Boolean(p.autoSubtitle); + const bgmEnabled = Boolean(p.bgmEnabled); + if (!autoSubtitle && !bgmEnabled) { + return { success: false, error: "请至少勾选「自动生成字幕」或「添加背景音乐」" }; + } + if (bgmEnabled) { + const bgm = String(p.bgmPath || "").trim(); + if (!bgm || !fs.existsSync(bgm)) { + return { success: false, error: "已启用背景音乐,请先选择音乐文件" }; + } + } + + const modelConfig = desktopConfig.buildModelConfig(); + const check = desktopConfig.validateModelConfig(modelConfig); + if (autoSubtitle && !check.ok) { + return { success: false, error: check.error }; + } + if (autoSubtitle && (modelConfig.asrMode === "online" || !modelConfig.asrMode)) { + const appCheck = desktopConfig.validateModelConfig(modelConfig); + if (!appCheck.ok && modelConfig.asrMode === "online") { + return { success: false, error: appCheck.error }; + } + } + + let current = inputVideo; + let srtPath = ""; + + try { + if (autoSubtitle) { + globalThis.__native?.emitProgress?.("正在提取音频…"); + const audioPath = await pipelineNative.ffmpegExtractAudio(current); + const scriptContent = String(p.scriptContent || "").trim(); + const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent); + + let records = rawRecords; + if (p.smartSubtitle !== false && scriptContent) { + records = alignScriptToRecords(scriptContent, records); + } + if (!records.length) { + const duration = probeVideoDuration(current); + const fallbackText = scriptContent || " "; + records = recordsFromTextEvenly(fallbackText, duration || 30); + } + if (!records.length) { + return { success: false, error: "未识别到语音内容,无法生成字幕" }; + } + + srtPath = makeOutputPath("subtitle", "srt"); + fs.writeFileSync(srtPath, buildSrtContent(records), "utf8"); + + globalThis.__native?.emitProgress?.("正在烧录字幕到视频…"); + const forceStyle = String(p.subtitleForceStyle || "").trim(); + current = addSubtitleToVideo(current, srtPath, forceStyle || undefined); + pipelineNative.deleteFile(audioPath); + } + + if (bgmEnabled) { + globalThis.__native?.emitProgress?.("正在混合背景音乐…"); + current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30); + } + + return { + success: true, + videoPath: current, + srtPath: srtPath || null, + message: autoSubtitle && bgmEnabled + ? "字幕与背景音乐处理完成" + : autoSubtitle + ? "字幕生成完成" + : "背景音乐添加完成", + }; + } catch (e) { + return { + success: false, + error: e.message || String(e), + partialVideo: current !== inputVideo ? current : null, + }; + } +}; diff --git a/scripts/nodejs/title_tags_generate.js b/scripts/nodejs/title_tags_generate.js new file mode 100644 index 0000000..e2ab3f3 --- /dev/null +++ b/scripts/nodejs/title_tags_generate.js @@ -0,0 +1,174 @@ +"use strict"; + +/** + * 生成标题、标签、分组关键词(对齐 Electron ipAgent:generateTitleTags) + */ +const pipelineNative = require("./pipeline_native.js"); +const desktopConfig = require("./desktop_config.js"); + +const KEYWORD_GROUPS = ["重点词/成语词", "描述词", "行动词", "情感词"]; + +const DEFAULT_TITLE_TAG_PROMPT = `你是短视频标题与标签助手。分析以下文案内容,生成: +1. 一个最吸引人的标题(不超过30字,若需多个建议可写在 title 字段内用换行分隔) +2. 8-10 个相关话题标签(带 # 号) +3. 四个分组关键词,每组 0-2 个词:重点词/成语词、描述词、行动词、情感词 + +文案内容: +{{content}} + +请只返回 JSON,不要 markdown,格式如下: +{ + "title": "标题", + "tags": ["#标签1", "#标签2"], + "keywords": { + "重点词/成语词": ["词1"], + "描述词": [], + "行动词": [], + "情感词": [] + } +}`; + +function buildPrompt(template, content) { + let prompt = String(template || "").trim(); + if (!prompt) prompt = DEFAULT_TITLE_TAG_PROMPT; + return prompt + .replace(/\{\{content\}\}/g, content) + .replace(/\{content\}/g, content); +} + +function cleanJsonText(raw) { + let text = String(raw || "").trim(); + text = text.replace(/```json\s*/gi, ""); + text = text.replace(/```\s*/g, ""); + text = text.trim(); + text = text.replace(/"/g, '"').replace(/"/g, '"'); + text = text.replace(/'/g, "'").replace(/'/g, "'"); + text = text.replace(/,/g, ","); + const m = text.match(/\{[\s\S]*\}/); + if (m) text = m[0]; + return text; +} + +function normalizeTags(tags) { + if (Array.isArray(tags)) { + return tags + .map((t) => String(t || "").trim()) + .filter(Boolean) + .join(", "); + } + return String(tags || "").trim(); +} + +function normalizeKeywordGroup(val) { + if (Array.isArray(val)) { + return val + .map((w) => String(w || "").trim()) + .filter(Boolean) + .join("\n"); + } + if (typeof val === "string") { + return val + .split(/[,,\n]/) + .map((w) => w.trim()) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +function parseTitleTagsContent(content) { + let title = ""; + let tags = ""; + const keywords = {}; + + try { + const parsed = JSON.parse(cleanJsonText(content)); + title = String(parsed.title || "").trim(); + tags = normalizeTags(parsed.tags); + const kw = parsed.keywords; + if (kw && typeof kw === "object" && !Array.isArray(kw)) { + for (const group of KEYWORD_GROUPS) { + keywords[group] = normalizeKeywordGroup(kw[group]); + } + } else if (Array.isArray(kw)) { + keywords["重点词/成语词"] = normalizeKeywordGroup(kw); + } else if (typeof kw === "string") { + keywords["重点词/成语词"] = normalizeKeywordGroup(kw); + } + } catch { + const lines = String(content).split("\n"); + for (const line of lines) { + if (/标题|Title/i.test(line)) { + title = (line.split(/[::]/)[1] || "").trim() || line.trim(); + } else if (/标签|Tags|#/i.test(line)) { + tags = (line.split(/[::]/)[1] || "").trim() || line.trim(); + } else if (/关键词|Keywords/i.test(line)) { + keywords["重点词/成语词"] = normalizeKeywordGroup( + (line.split(/[::]/)[1] || "").trim(), + ); + } + } + } + + for (const group of KEYWORD_GROUPS) { + if (!keywords[group]) keywords[group] = ""; + } + + const hasKeywords = KEYWORD_GROUPS.some((g) => keywords[g]); + if (!title && !tags && !hasKeywords) { + return null; + } + return { title, tags, keywords }; +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const content = String(p.content || p.script || "").trim(); + if (!content) { + return { success: false, error: "请先填写视频文案" }; + } + + const modelConfig = desktopConfig.buildModelConfig(); + const check = desktopConfig.validateModelConfig(modelConfig); + if (!check.ok) { + return { success: false, error: check.error }; + } + + const prompt = buildPrompt(p.titleTagPrompt, content); + + globalThis.__native?.emitProgress?.("正在生成标题、标签和关键词…"); + + const chat = await pipelineNative.openaiChat({ + apiUrl: modelConfig.apiUrl, + apiKey: modelConfig.apiKey, + model: modelConfig.apiModelId, + messages: [{ role: "user", content: prompt }], + temperature: typeof p.temperature === "number" ? p.temperature : 0.7, + max_tokens: p.max_tokens || 2000, + }); + + if (!chat.success || !chat.content) { + return { success: false, error: chat.error || "模型返回为空" }; + } + + const parsed = parseTitleTagsContent(chat.content); + if (!parsed) { + return { + success: false, + error: "解析结果为空,请检查模型返回格式", + rawContent: chat.content, + }; + } + + return { + success: true, + title: parsed.title, + tags: parsed.tags, + keywords: parsed.keywords, + titleTags: JSON.stringify({ + title: parsed.title, + tags: parsed.tags, + keywords: parsed.keywords, + }), + }; +}; diff --git a/scripts/nodejs/video_edit_process.js b/scripts/nodejs/video_edit_process.js new file mode 100644 index 0000000..17e4815 --- /dev/null +++ b/scripts/nodejs/video_edit_process.js @@ -0,0 +1,539 @@ +/** + * 视频编辑后处理(对齐 Electron ipAgent:autoProcessVideo) + * - processSilenceDetection:自动剪气口 + * - processVideoMixCut:画中画 / 混剪 + * - processGreenScreen:绿幕替换 + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawnSync } = require("child_process"); + +const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); + +function locateFfmpegBinary() { + const fromEnv = process.env.AICLIENT_FFMPEG_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; +} + +function locateFfprobeBinary() { + const fromEnv = process.env.AICLIENT_FFPROBE_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + const ffmpeg = locateFfmpegBinary(); + const dir = path.dirname(ffmpeg); + const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe"); + if (fs.existsSync(sibling)) return sibling; + return process.platform === "win32" ? "ffprobe.exe" : "ffprobe"; +} + +function execFfmpeg(args) { + const ffmpeg = locateFfmpegBinary(); + const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true }); + if (r.status !== 0) { + const err = (r.stderr || r.stdout || "").trim(); + throw new Error(err || `ffmpeg 退出码 ${r.status}`); + } +} + +function ensureOutputDir() { + const dir = path.join(os.tmpdir(), "aiclient-video-edit"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeOutputPath(prefix, ext = "mp4") { + return path.join(ensureOutputDir(), `${prefix}_${Date.now()}.${ext}`); +} + +function parseDurationHms(text) { + const marker = "Duration:"; + const idx = text.indexOf(marker); + if (idx < 0) return 0; + const rest = text.slice(idx + marker.length).trim(); + const timePart = rest.split(",")[0].trim(); + const parts = timePart.split(":").map(Number); + if (parts.length < 3) return 0; + const [h, m, s] = parts; + if (![h, m, s].every((n) => Number.isFinite(n))) return 0; + return h * 3600 + m * 60 + s; +} + +function probeVideoDuration(videoPath) { + const ffprobe = locateFfprobeBinary(); + const r = spawnSync( + ffprobe, + [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + videoPath, + ], + { encoding: "utf8", windowsHide: true }, + ); + if (r.status === 0) { + const n = parseFloat(String(r.stdout || "").trim()); + if (Number.isFinite(n) && n > 0) return n; + } + const ffmpeg = locateFfmpegBinary(); + const r2 = spawnSync(ffmpeg, ["-i", videoPath], { + encoding: "utf8", + windowsHide: true, + }); + return parseDurationHms(r2.stderr || ""); +} + +function probeVideoResolution(videoPath) { + const ffprobe = locateFfprobeBinary(); + const r = spawnSync( + ffprobe, + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + videoPath, + ], + { encoding: "utf8", windowsHide: true }, + ); + if (r.status === 0) { + const line = String(r.stdout || "").trim(); + const m = line.match(/^(\d+)x(\d+)$/); + if (m) { + return { width: Number(m[1]), height: Number(m[2]) }; + } + } + return { width: 1920, height: 1080 }; +} + +function isImageFile(filePath) { + return IMAGE_EXT.has(path.extname(filePath).toLowerCase()); +} + +function processSilenceDetection(inputVideo, opts = {}) { + const silenceThreshold = Number(opts.silenceThreshold ?? -40); + const silenceDuration = Number(opts.silenceDuration ?? 1); + const minPauseDuration = Number(opts.minPauseDuration ?? 0.15); + const minPauseDurationMs = Math.round(minPauseDuration * 1000); + const outputVideo = makeOutputPath("silence"); + const audioFilter = `silenceremove=stop_periods=-1:stop_duration=${silenceDuration}:stop_threshold=${silenceThreshold}dB,adelay=${minPauseDurationMs}ms|${minPauseDurationMs}ms`; + execFfmpeg([ + "-i", + inputVideo, + "-af", + audioFilter, + "-c:v", + "copy", + "-y", + outputVideo, + ]); + return { + outputVideo, + message: `已删除超过 ${silenceDuration} 秒且低于 ${silenceThreshold}dB 的停顿`, + }; +} + +function processGreenScreen(inputVideo, backgroundImage) { + const outputVideo = makeOutputPath("greenscreen"); + const filterComplex = [ + "[1:v][0:v]scale2ref=flags=lanczos[bg][vid]", + "[vid]format=yuva444p,chromakey=0x00FF00:0.28:0.05,chromakey=0x40FF40:0.12:0.15[fg]", + "[bg][fg]overlay=0:0:shortest=1,unsharp=3:3:0.5:3:3:0.5,format=yuv420p[out]", + ].join(";"); + execFfmpeg([ + "-i", + inputVideo, + "-loop", + "1", + "-i", + backgroundImage, + "-filter_complex", + filterComplex, + "-map", + "[out]", + "-map", + "0:a?", + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "18", + "-c:a", + "aac", + "-b:a", + "128k", + "-shortest", + "-y", + outputVideo, + ]); + return { outputVideo, message: "绿幕替换完成" }; +} + +/** 对齐 Electron ipAgent:processVideoMixCut */ +function processVideoMixCut({ + inputVideo, + replacements, + outputPath, + videoResolution, + videoDuration, + displayMode = "pip", +}) { + if (!replacements?.length) { + throw new Error("混剪替换点为空"); + } + for (const r of replacements) { + if (!r.materialPath || !fs.existsSync(r.materialPath)) { + throw new Error(`素材文件不存在: ${r.materialPath || "(空)"}`); + } + } + + let filterComplex = ""; + let inputFiles = [inputVideo]; + let currentStream = "[0:v]"; + + const adjustedReplacements = replacements.map((r, i) => { + let actualEndTime = r.startTime + Math.min(r.duration, r.materialDuration); + for (let j = i + 1; j < replacements.length; j++) { + const nextR = replacements[j]; + if (nextR.startTime < actualEndTime) { + actualEndTime = nextR.startTime; + } + } + return { ...r, actualEndTime }; + }); + + for (let i = 0; i < adjustedReplacements.length; i++) { + const r = adjustedReplacements[i]; + const inputIndex = i + 1; + const materialStream = `[${inputIndex}:v]`; + const trimmedMaterialStream = `[m_trim${i}]`; + const processedMaterialStream = `[m${i}]`; + const mode = r.displayMode || displayMode || "pip"; + let scaleFilter = ""; + let overlayParam = ""; + + if (mode === "fullscreen") { + scaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height},boxblur=25:2`; + overlayParam = "0:0"; + } else { + const pipSizePercent = r.pipSizePercent || 30; + const pipWidth = Math.round((videoResolution.width * pipSizePercent) / 100); + const pipHeight = Math.round((videoResolution.height * pipSizePercent) / 100); + const pipScaleMode = r.pipScaleMode || "fit"; + let pipScaleFilter = ""; + if (pipScaleMode === "fit") { + pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=decrease`; + } else if (pipScaleMode === "fill") { + pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=increase,crop=${pipWidth}:${pipHeight}`; + } else { + pipScaleFilter = `scale=${pipWidth}:${pipHeight}`; + } + const pipPos = r.pipPosition || "bottom-right"; + let pipX = 0; + let pipY = 0; + switch (pipPos) { + case "top-left": + pipX = 10; + pipY = 10; + break; + case "top-center": + pipX = (videoResolution.width - pipWidth) / 2; + pipY = 10; + break; + case "top-right": + pipX = videoResolution.width - pipWidth - 10; + pipY = 10; + break; + case "center-left": + pipX = 10; + pipY = (videoResolution.height - pipHeight) / 2; + break; + case "center": + pipX = (videoResolution.width - pipWidth) / 2; + pipY = (videoResolution.height - pipHeight) / 2; + break; + case "center-right": + pipX = videoResolution.width - pipWidth - 10; + pipY = (videoResolution.height - pipHeight) / 2; + break; + case "bottom-left": + pipX = 10; + pipY = videoResolution.height - pipHeight - 10; + break; + case "bottom-center": + pipX = (videoResolution.width - pipWidth) / 2; + pipY = videoResolution.height - pipHeight - 10; + break; + case "bottom-right": + default: + pipX = videoResolution.width - pipWidth - 10; + pipY = videoResolution.height - pipHeight - 10; + break; + } + scaleFilter = pipScaleFilter; + overlayParam = `${Math.round(pipX)}:${Math.round(pipY)}`; + } + + const actualDuration = Math.min(r.duration, r.materialDuration); + const isImage = r.isImage ?? isImageFile(r.materialPath); + + if (isImage) { + const fpsFilter = "fps=25"; + const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; + filterComplex += `${materialStream}${fpsFilter},${setptsFilter}${trimmedMaterialStream};`; + if (mode === "fullscreen") { + const originalMinimized = r.originalVideoMinimized; + if (originalMinimized?.enabled) { + const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; + filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; + } else { + const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; + filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; + } + } else { + filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; + } + } else { + const trimFilter = `trim=start=${(r.materialStartOffset || 0).toFixed(3)}:duration=${actualDuration.toFixed(3)}`; + const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; + filterComplex += `${materialStream}${trimFilter},${setptsFilter}${trimmedMaterialStream};`; + if (mode === "fullscreen") { + const originalMinimized = r.originalVideoMinimized; + if (originalMinimized?.enabled) { + const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; + filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; + } else { + const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; + filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; + } + } else { + filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; + } + } + + const outputStream = `[v${i}]`; + const enableExpr = `gte(t,${r.startTime.toFixed(3)})*lt(t,${r.actualEndTime.toFixed(3)})`; + + if (mode === "fullscreen") { + const originalMinimized = r.originalVideoMinimized; + if (originalMinimized?.enabled) { + const { shape, position, sizePercent } = originalMinimized; + const originalSize = Math.floor(videoResolution.width * (sizePercent / 100)); + const splitStream1 = `[split${i}_1]`; + const splitStream2 = `[split${i}_2]`; + filterComplex += `${currentStream}split=2${splitStream1}${splitStream2};`; + const origStream = `[orig${i}]`; + let originalScaled = `${splitStream1}scale=${originalSize}:${originalSize}:force_original_aspect_ratio=increase,crop=${originalSize}:${originalSize}`; + if (shape === "circle") { + const radius = originalSize / 2; + originalScaled += `,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lte(hypot(X-${radius},Y-${radius}),${radius}),255,0)'`; + } + originalScaled += origStream; + filterComplex += `${originalScaled};`; + const tmpStream = `[tmp${i}]`; + filterComplex += `${splitStream2}${processedMaterialStream}overlay=0:0:enable='${enableExpr}'${tmpStream};`; + const padding = 50; + let x = 0; + let y = 0; + switch (position) { + case "top-left": + x = padding; + y = padding; + break; + case "top-right": + x = videoResolution.width - originalSize - padding; + y = padding; + break; + case "bottom-left": + x = padding; + y = videoResolution.height - originalSize - padding; + break; + case "bottom-right": + x = videoResolution.width - originalSize - padding; + y = videoResolution.height - originalSize - padding; + break; + case "center": + x = (videoResolution.width - originalSize) / 2; + y = (videoResolution.height - originalSize) / 2; + break; + default: + break; + } + filterComplex += `${tmpStream}${origStream}overlay=${Math.round(x)}:${Math.round(y)}:enable='${enableExpr}'${outputStream};`; + } else { + const blurredBgStream = `[blurred_bg${i}]`; + filterComplex += `${currentStream}boxblur=25:2:enable='${enableExpr}'${blurredBgStream};`; + filterComplex += `${blurredBgStream}${processedMaterialStream}overlay=(W-w)/2:(H-h)/2:enable='${enableExpr}'${outputStream};`; + } + } else { + filterComplex += `${currentStream}${processedMaterialStream}overlay=${overlayParam}:enable='${enableExpr}'${outputStream};`; + } + inputFiles.push(r.materialPath); + currentStream = outputStream; + } + + filterComplex = filterComplex.slice(0, -1); + filterComplex += `; ${currentStream}trim=start=0:duration=${videoDuration}[final_video]`; + + const ffmpegArgs = [ + ...inputFiles.flatMap((f) => ["-i", f]), + "-filter_complex", + filterComplex, + "-map", + "[final_video]", + "-map", + "0:a?", + "-c:a", + "copy", + "-c:v", + "libx264", + "-preset", + "medium", + "-shortest", + "-y", + outputPath, + ]; + execFfmpeg(ffmpegArgs); + return { outputVideo: outputPath, message: "混剪处理完成" }; +} + +function normalizeReplacements(raw) { + if (!Array.isArray(raw)) return []; + return raw + .map((r) => ({ + startTime: Number(r.startTime), + duration: Number(r.duration), + materialPath: String(r.materialPath || "").trim(), + materialDuration: Number(r.materialDuration), + materialStartOffset: Number(r.materialStartOffset || 0), + displayMode: r.displayMode, + pipPosition: r.pipPosition, + pipSizePercent: r.pipSizePercent, + pipScaleMode: r.pipScaleMode, + isImage: r.isImage, + originalVideoMinimized: r.originalVideoMinimized, + })) + .filter( + (r) => + Number.isFinite(r.startTime) && + Number.isFinite(r.duration) && + r.duration > 0 && + r.materialPath && + Number.isFinite(r.materialDuration) && + r.materialDuration > 0, + ); +} + +function pickReplacementsFromSettings(settings, videoDuration) { + if (!settings || typeof settings !== "object") return []; + if (Array.isArray(settings.replacements) && settings.replacements.length) { + return normalizeReplacements(settings.replacements); + } + const simple = settings.simplePip; + if (simple?.materialPath && fs.existsSync(simple.materialPath)) { + const matDur = + Number(simple.materialDuration) > 0 + ? Number(simple.materialDuration) + : isImageFile(simple.materialPath) + ? videoDuration + : probeVideoDuration(simple.materialPath); + const startTime = Number(simple.startTime || 0); + const duration = Number(simple.duration || videoDuration); + return normalizeReplacements([ + { + startTime, + duration, + materialPath: simple.materialPath, + materialDuration: matDur, + materialStartOffset: Number(simple.materialStartOffset || 0), + displayMode: simple.displayMode || settings.displayMode || "pip", + pipPosition: simple.pipPosition || "bottom-right", + pipSizePercent: simple.pipSizePercent ?? 30, + pipScaleMode: simple.pipScaleMode || "fit", + isImage: simple.isImage ?? isImageFile(simple.materialPath), + }, + ]); + } + return []; +} + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const inputVideo = String(p.inputVideo || "").trim(); + if (!inputVideo || !fs.existsSync(inputVideo)) { + return { success: false, error: "缺少或找不到输入视频" }; + } + + const steps = []; + let current = inputVideo; + + try { + if (p.autoCutBreath) { + globalThis.__native?.emitProgress?.("正在剪气口…"); + const r = processSilenceDetection(current, { + silenceThreshold: p.silenceThreshold, + silenceDuration: p.silenceDuration, + minPauseDuration: p.minPauseDuration, + }); + current = r.outputVideo; + steps.push({ step: "silence", ...r }); + } + + if (p.pipInPicture) { + const settings = p.mixCutSettings || null; + const replacements = pickReplacementsFromSettings( + settings, + probeVideoDuration(current), + ); + if (!replacements.length) { + return { + success: false, + error: + "已启用画中画,但未配置混剪素材。请在本地配置 videoMixCutSettings(含 replacements 或 simplePip),或使用「选择画中画素材」", + }; + } + globalThis.__native?.emitProgress?.("正在混剪/画中画…"); + const videoDuration = probeVideoDuration(current); + const videoResolution = probeVideoResolution(current); + const outputPath = makeOutputPath("mixcut"); + const r = processVideoMixCut({ + inputVideo: current, + replacements, + outputPath, + videoResolution, + videoDuration, + displayMode: settings?.displayMode || "pip", + }); + current = r.outputVideo; + steps.push({ step: "mixcut", ...r }); + } + + if (p.greenScreen) { + const bg = String(p.backgroundImage || "").trim(); + if (!bg || !fs.existsSync(bg)) { + return { success: false, error: "已启用绿幕切换,请先上传替换背景图" }; + } + globalThis.__native?.emitProgress?.("正在绿幕替换…"); + const r = processGreenScreen(current, bg); + current = r.outputVideo; + steps.push({ step: "greenscreen", ...r }); + } + + return { + success: true, + videoPath: current, + steps, + message: steps.length ? steps[steps.length - 1].message : "未执行任何处理", + }; + } catch (e) { + return { success: false, error: e.message || String(e), partialVideo: current }; + } +}; diff --git a/scripts/nodejs/video_publish.js b/scripts/nodejs/video_publish.js new file mode 100644 index 0000000..54d2157 --- /dev/null +++ b/scripts/nodejs/video_publish.js @@ -0,0 +1,84 @@ +"use strict"; + +/** + * 多平台视频发布(工作流步骤 07 / 一键自动) + */ +const { publishToPlatform, PLATFORM_LABELS } = require("./publish_platform.js"); + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const platforms = Array.isArray(p.platforms) ? p.platforms : []; + const videoPath = String(p.videoPath || "").trim(); + const coverPath = String(p.coverPath || "").trim(); + const title = String(p.title || "").trim(); + const description = String(p.description || title || "").trim(); + const tags = Array.isArray(p.tags) ? p.tags : []; + const autoPublish = Boolean(p.autoPublish); + + if (!platforms.length) { + return { success: false, error: "请至少选择一个发布平台" }; + } + if (!videoPath) return { success: false, error: "缺少视频路径" }; + if (!coverPath) return { success: false, error: "缺少封面路径,请先在步骤 06 生成封面" }; + if (!title) return { success: false, error: "缺少标题,请先在步骤 04 生成标题" }; + + const results = []; + for (let i = 0; i < platforms.length; i++) { + const item = platforms[i]; + const platform = item.platform; + const accountId = item.accountId; + const cookies = item.cookies || []; + const label = PLATFORM_LABELS[platform] || platform; + globalThis.__native?.emitProgress?.( + `正在发布到${label}… (${i + 1}/${platforms.length})`, + ); + try { + const r = await publishToPlatform(platform, accountId, cookies, { + videoPath, + coverPath, + title, + description, + tags, + autoPublish, + }); + results.push({ + platform: label, + platformKey: platform, + success: Boolean(r.success), + pending: + r.status === "manual_pending" || r.status === "verification_required", + status: r.status || (r.success ? "success" : "failed"), + message: r.message || "", + videoUrl: r.videoUrl || "", + }); + } catch (e) { + results.push({ + platform: label, + platformKey: platform, + success: false, + pending: false, + status: "failed", + message: e.message || String(e), + }); + } + if (i < platforms.length - 1) { + await new Promise((r) => setTimeout(r, 2000)); + } + } + + const ok = results.filter((r) => r.success).length; + const pending = results.filter((r) => r.pending).length; + const failed = results.length - ok - pending; + + return { + success: ok > 0 || pending > 0, + results, + summary: { ok, pending, failed, total: results.length }, + message: + ok === results.length + ? "全部发布成功" + : pending > 0 + ? `成功 ${ok},待确认 ${pending},失败 ${failed}` + : `成功 ${ok},失败 ${failed}`, + }; +};