diff --git a/.gitignore b/.gitignore index bccae66..387c051 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ src-tauri/resources/nodejs/ src-tauri/resources/resources-bundles/python-runtime/ src-tauri/resources/resources-bundles/python-runtime/Lib/site-packages/ src-tauri/resources/resources-bundles/models/ +src-tauri/resources/resources-bundles/python-runtimebackup/ +src-tauri/resources/resources-bundles/eSpeak/ diff --git a/src-tauri/resources/cover-python/advanced_cover_generator.py b/src-tauri/resources/cover-python/advanced_cover_generator.py new file mode 100644 index 0000000..7f0e179 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/cover_generator.py b/src-tauri/resources/cover-python/cover_generator.py new file mode 100644 index 0000000..4a06ee1 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/cover_preview_generator.py b/src-tauri/resources/cover-python/cover_preview_generator.py new file mode 100644 index 0000000..ada80d0 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/custom_template_cover_fix.py b/src-tauri/resources/cover-python/custom_template_cover_fix.py new file mode 100644 index 0000000..a281b1c --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/find_font.py b/src-tauri/resources/cover-python/find_font.py new file mode 100644 index 0000000..9892ce0 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/generate_cover_previews.py b/src-tauri/resources/cover-python/generate_cover_previews.py new file mode 100644 index 0000000..5f42926 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/generate_image_cover.py b/src-tauri/resources/cover-python/generate_image_cover.py new file mode 100644 index 0000000..83d2ab3 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/get_font_name.py b/src-tauri/resources/cover-python/get_font_name.py new file mode 100644 index 0000000..dd26cd2 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/get_fonts.py b/src-tauri/resources/cover-python/get_fonts.py new file mode 100644 index 0000000..4d3388e --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/modules/__init__.py b/src-tauri/resources/cover-python/modules/__init__.py new file mode 100644 index 0000000..f3158c0 --- /dev/null +++ b/src-tauri/resources/cover-python/modules/__init__.py @@ -0,0 +1,6 @@ +""" +modules 包初始化文件 +""" + +# 这个文件是必需的,让 Python 将 modules 目录识别为一个包 +# 从而支持相对导入(如 from .utils import ...) diff --git a/src-tauri/resources/cover-python/modules/cover.py b/src-tauri/resources/cover-python/modules/cover.py new file mode 100644 index 0000000..8c436eb --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/modules/cover_templates.py b/src-tauri/resources/cover-python/modules/cover_templates.py new file mode 100644 index 0000000..c6573a7 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/modules/font_manager.py b/src-tauri/resources/cover-python/modules/font_manager.py new file mode 100644 index 0000000..cca87e8 --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/modules/segment.py b/src-tauri/resources/cover-python/modules/segment.py new file mode 100644 index 0000000..4d5f9af --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/cover-python/modules/utils.py b/src-tauri/resources/cover-python/modules/utils.py new file mode 100644 index 0000000..b56577c --- /dev/null +++ b/src-tauri/resources/cover-python/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/src-tauri/resources/resources-bundles/InfiniteTalk b/src-tauri/resources/resources-bundles/InfiniteTalk deleted file mode 160000 index fd63149..0000000 --- a/src-tauri/resources/resources-bundles/InfiniteTalk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fd631497254e065777f2b2d0642de3600d674e24 diff --git a/src-tauri/resources/resources-bundles/fonts/NotoSerifCJK-VF.ttf.ttc b/src-tauri/resources/resources-bundles/fonts/NotoSerifCJK-VF.ttf.ttc deleted file mode 100644 index f2e98c6..0000000 Binary files a/src-tauri/resources/resources-bundles/fonts/NotoSerifCJK-VF.ttf.ttc and /dev/null differ diff --git a/src-tauri/resources/resources-bundles/fonts/ziti/LICENSE.txt b/src-tauri/resources/resources-bundles/fonts/ziti/LICENSE.txt deleted file mode 100644 index 26bedc9..0000000 --- a/src-tauri/resources/resources-bundles/fonts/ziti/LICENSE.txt +++ /dev/null @@ -1,96 +0,0 @@ -Copyright (c) 2022-11-02, ZERO子 (https://github.com/Skr-ZERO), -with Reserved Font Name “Assorted”“什锦”. - -Copyright (c) 2022-11-01, Umihotaru (https://umihotaru.work/), - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/src-tauri/resources/resources-bundles/fonts/ziti/NotoSerifCJK-VF.ttf.ttc b/src-tauri/resources/resources-bundles/fonts/ziti/NotoSerifCJK-VF.ttf.ttc deleted file mode 100644 index f2e98c6..0000000 Binary files a/src-tauri/resources/resources-bundles/fonts/ziti/NotoSerifCJK-VF.ttf.ttc and /dev/null differ diff --git a/src-tauri/resources/resources-bundles/fonts/ziti/OFL-FAQ.txt b/src-tauri/resources/resources-bundles/fonts/ziti/OFL-FAQ.txt deleted file mode 100644 index 8b03637..0000000 --- a/src-tauri/resources/resources-bundles/fonts/ziti/OFL-FAQ.txt +++ /dev/null @@ -1,435 +0,0 @@ -OFL FAQ - Frequently Asked Questions about the SIL Open Font License (OFL) -Version 1.1-update6 - December 2020 -The OFL FAQ is copyright (c) 2005-2020 SIL International. -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -(See http://scripts.sil.org/OFL for updates) - - -CONTENTS OF THIS FAQ -1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL -2 USING OFL FONTS FOR WEB PAGES AND ONLINE WEB FONT SERVICES -3 MODIFYING OFL-LICENSED FONTS -4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL -5 CHOOSING RESERVED FONT NAMES -6 ABOUT THE FONTLOG -7 MAKING CONTRIBUTIONS TO OFL PROJECTS -8 ABOUT THE LICENSE ITSELF -9 ABOUT SIL INTERNATIONAL -APPENDIX A - FONTLOG EXAMPLE - -1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL - -1.1 Can I use the fonts for a book or other print publication, to create logos or other graphics or even to manufacture objects based on their outlines? -Yes. You are very welcome to do so. Authors of fonts released under the OFL allow you to use their font software as such for any kind of design work. No additional license or permission is required, unlike with some other licenses. Some examples of these uses are: logos, posters, business cards, stationery, video titling, signage, t-shirts, personalised fabric, 3D-printed/laser-cut shapes, sculptures, rubber stamps, cookie cutters and lead type. - -1.1.1 Does that restrict the license or distribution of that artwork? -No. You remain the author and copyright holder of that newly derived graphic or object. You are simply using an open font in the design process. It is only when you redistribute, bundle or modify the font itself that other conditions of the license have to be respected (see below for more details). - -1.1.2 Is any kind of acknowledgement required? -No. Font authors may appreciate being mentioned in your artwork's acknowledgements alongside the name of the font, possibly with a link to their website, but that is not required. - -1.2 Can the fonts be included with Free/Libre and Open Source Software collections such as GNU/Linux and BSD distributions and repositories? -Yes! Fonts licensed under the OFL can be freely included alongside other software under FLOSS (Free/Libre and Open Source Software) licenses. Since fonts are typically aggregated with, not merged into, existing software, there is little need to be concerned about incompatibility with existing software licenses. You may also repackage the fonts and the accompanying components in a .rpm or .deb package (or other similar package formats or installers) and include them in distribution CD/DVDs and online repositories. (Also see section 5.9 about rebuilding from source.) - -1.3 I want to distribute the fonts with my program. Does this mean my program also has to be Free/Libre and Open Source Software? -No. Only the portions based on the Font Software are required to be released under the OFL. The intent of the license is to allow aggregation or bundling with software under restricted licensing as well. - -1.4 Can I sell a software package that includes these fonts? -Yes, you can do this with both the Original Version and a Modified Version of the fonts. Examples of bundling made possible by the OFL would include: word processors, design and publishing applications, training and educational software, games and entertainment software, mobile device applications, etc. - -1.5 Can I include the fonts on a CD of freeware or commercial fonts? -Yes, as long some other font or software is also on the disk, so the OFL font is not sold by itself. - -1.6 Why won't the OFL let me sell the fonts alone? -The intent is to keep people from making money by simply redistributing the fonts. The only people who ought to profit directly from the fonts should be the original authors, and those authors have kindly given up potential direct income to distribute their fonts under the OFL. Please honour and respect their contribution! - -1.7 What about sharing OFL fonts with friends on a CD, DVD or USB stick? -You are very welcome to share open fonts with friends, family and colleagues through removable media. Just remember to include the full font package, including any copyright notices and licensing information as available in OFL.txt. In the case where you sell the font, it has to come bundled with software. - -1.8 Can I host the fonts on a web site for others to use? -Yes, as long as you make the full font package available. In most cases it may be best to point users to the main site that distributes the Original Version so they always get the most recent stable and complete version. See also discussion of web fonts in Section 2. - -1.9 Can I host the fonts on a server for use over our internal network? -Yes. If the fonts are transferred from the server to the client computer by means that allow them to be used even if the computer is no longer attached to the network, the full package (copyright notices, licensing information, etc.) should be included. - -1.10 Does the full OFL license text always need to accompany the font? -The only situation in which an OFL font can be distributed without the text of the OFL (either in a separate file or in font metadata), is when a font is embedded in a document or bundled within a program. In the case of metadata included within a font, it is legally sufficient to include only a link to the text of the OFL on http://scripts.sil.org/OFL, but we strongly recommend against this. Most modern font formats include metadata fields that will accept the full OFL text, and full inclusion increases the likelihood that users will understand and properly apply the license. - -1.11 What do you mean by 'embedding'? How does that differ from other means of distribution? -By 'embedding' we mean inclusion of the font in a document or file in a way that makes extraction (and redistribution) difficult or clearly discouraged. In many cases the names of embedded fonts might also not be obvious to those reading the document, the font data format might be altered, and only a subset of the font - only the glyphs required for the text - might be included. Any other means of delivering a font to another person is considered 'distribution', and needs to be accompanied by any copyright notices and licensing information available in OFL.txt. - -1.12 So can I embed OFL fonts in my document? -Yes, either in full or a subset. The restrictions regarding font modification and redistribution do not apply, as the font is not intended for use outside the document. - -1.13 Does embedding alter the license of the document itself? -No. Referencing or embedding an OFL font in any document does not change the license of the document itself. The requirement for fonts to remain under the OFL does not apply to any document created using the fonts and their derivatives. Similarly, creating any kind of graphic using a font under OFL does not make the resulting artwork subject to the OFL. - -1.14 If OFL fonts are extracted from a document in which they are embedded (such as a PDF file), what can be done with them? Is this a risk to author(s)? -The few utilities that can extract fonts embedded in a PDF will typically output limited amounts of outlines - not a complete font. To create a working font from this method is much more difficult and time consuming than finding the source of the original OFL font. So there is little chance that an OFL font would be extracted and redistributed inappropriately through this method. Even so, copyright laws address any misrepresentation of authorship. All Font Software released under the OFL and marked as such by the author(s) is intended to remain under this license regardless of the distribution method, and cannot be redistributed under any other license. We strongly discourage any font extraction - we recommend directly using the font sources instead - but if you extract font outlines from a document, please be considerate: respect the work of the author(s) and the licensing model. - -1.15 What about distributing fonts with a document? Within a compressed folder structure? Is it distribution, bundling or embedding? -Certain document formats may allow the inclusion of an unmodified font within their file structure which may consist of a compressed folder containing the various resources forming the document (such as pictures and thumbnails). Including fonts within such a structure is understood as being different from embedding but rather similar to bundling (or mere aggregation) which the license explicitly allows. In this case the font is conveyed unchanged whereas embedding a font usually transforms it from the original format. The OFL does not allow anyone to extract the font from such a structure to then redistribute it under another license. The explicit permission to redistribute and embed does not cancel the requirement for the Font Software to remain under the license chosen by its author(s). Even if the font travels inside the document as one of its assets, it should not lose its authorship information and licensing. - -1.16 What about ebooks shipping with open fonts? -The requirements differ depending on whether the fonts are linked, embedded or distributed (bundled or aggregated). Some ebook formats use web technologies to do font linking via @font-face, others are designed for font embedding, some use fonts distributed with the document or reading software, and a few rely solely on the fonts already present on the target system. The license requirements depend on the type of inclusion as discussed in 1.15. - -1.17 Can Font Software released under the OFL be subject to URL-based access restrictions methods or DRM (Digital Rights Management) mechanisms? -Yes, but these issues are out-of-scope for the OFL. The license itself neither encourages their use nor prohibits them since such mechanisms are not implemented in the components of the Font Software but through external software. Such restrictions are put in place for many different purposes corresponding to various usage scenarios. One common example is to limit potentially dangerous cross-site scripting attacks. However, in the spirit of libre/open fonts and unrestricted writing systems, we strongly encourage open sharing and reuse of OFL fonts, and the establishment of an environment where such restrictions are unnecessary. Note that whether you wish to use such mechanisms or you prefer not to, you must still abide by the rules set forth by the OFL when using fonts released by their authors under this license. Derivative fonts must be licensed under the OFL, even if they are part of a service for which you charge fees and/or for which access to source code is restricted. You may not sell the fonts on their own - they must be part of a larger software package, bundle or subscription plan. For example, even if the OFL font is distributed in a software package or via an online service using a DRM mechanism, the user would still have the right to extract that font, use, study, modify and redistribute it under the OFL. - -1.18 I've come across a font released under the OFL. How can I easily get more information about the Original Version? How can I know where it stands compared to the Original Version or other Modified Versions? -Consult the copyright statement(s) in the license for ways to contact the original authors. Consult the FONTLOG (see section 6 for more details and examples) for information on how the font differs from the Original Version, and get in touch with the various contributors via the information in the acknowledgement section. Please consider using the Original Versions of the fonts whenever possible. - -1.19 What do you mean in condition 4 of the OFL's permissions and conditions? Can you provide examples of abusive promotion / endorsement / advertisement vs. normal acknowledgement? -The intent is that the goodwill and reputation of the author(s) should not be used in a way that makes it sound like the original author(s) endorse or approve of a specific Modified Version or software bundle. For example, it would not be right to advertise a word processor by naming the author(s) in a listing of software features, or to promote a Modified Version on a web site by saying "designed by ...". However, it would be appropriate to acknowledge the author(s) if your software package has a list of people who deserve thanks. We realize that this can seem to be a grey area, but the standard used to judge an acknowledgement is that if the acknowledgement benefits the author(s) it is allowed, but if it primarily benefits other parties, or could reflect poorly on the author(s), then it is not. - -1.20 I'm writing a small app for mobile platforms, do I need to include the whole package? -If you bundle a font under the OFL with your mobile app you must comply with the terms of the license. At a minimum you must include the copyright statement, the license notice and the license text. A mention of this information in your About box or Changelog, with a link to where the font package is from, is good practice, and the extra space needed to carry these items is very small. You do not, however, need to include the full contents of the font package - only the fonts you use and the copyright and license that apply to them. For example, if you only use the regular weight in your app, you do not need to include the italic and bold versions. - -1.21 What about including OFL fonts by default in my firmware or dedicated operating system? -Many such systems are restricted and turned into appliances so that users cannot study or modify them. Using open fonts to increase quality and language coverage is a great idea, but you need to be aware that if there is a way for users to extract fonts you cannot legally prevent them from doing that. The fonts themselves, including any changes you make to them, must be distributed under the OFL even if your firmware has a more restrictive license. If you do transform the fonts and change their formats when you include them in your firmware you must respect any names reserved by the font authors via the RFN mechanism and pick your own font name. Alternatively if you directly add a font under the OFL to the font folder of your firmware without modifying or optimizing it you are simply bundling the font like with any other software collection, and do not need to make any further changes. - -1.22 Can I make and publish CMS themes or templates that use OFL fonts? Can I include the fonts themselves in the themes or templates? Can I sell the whole package? -Yes, you are very welcome to integrate open fonts into themes and templates for your preferred CMS and make them more widely available. Remember that you can only sell the fonts and your CMS add-on as part of a software bundle. (See 1.4 for details and examples about selling bundles). - -1.23 Can OFL fonts be included in services that deliver fonts to the desktop from remote repositories? Even if they contain both OFL and non-OFL fonts? -Yes. Some foundries have set up services to deliver fonts to subscribers directly to desktops from their online repositories; similarly, plugins are available to preview and use fonts directly in your design tool or publishing suite. These services may mix open and restricted fonts in the same channel, however they should make a clear distinction between them to users. These services should also not hinder users (such as through DRM or obfuscation mechanisms) from extracting and using the OFL fonts in other environments, or continuing to use OFL fonts after subscription terms have ended, as those uses are specifically allowed by the OFL. - -1.24 Can services that provide or distribute OFL fonts restrict my use of them? -No. The terms of use of such services cannot replace or restrict the terms of the OFL, as that would be the same as distributing the fonts under a different license, which is not allowed. You are still entitled to use, modify and redistribute them as the original authors have intended outside of the sole control of that particular distribution channel. Note, however, that the fonts provided by these services may differ from the Original Versions. - - -2 USING OFL FONTS FOR WEBPAGES AND ONLINE WEB FONT SERVICES - -NOTE: This section often refers to a separate paper on 'Web Fonts & RFNs'. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.1 Can I make webpages using these fonts? -Yes! Go ahead! Using CSS (Cascading Style Sheets) is recommended. Your three best options are: -- referring directly in your stylesheet to open fonts which may be available on the user's system -- providing links to download the full package of the font - either from your own website or from elsewhere - so users can install it themselves -- using @font-face to distribute the font directly to browsers. This is recommended and explicitly allowed by the licensing model because it is distribution. The font file itself is distributed with other components of the webpage. It is not embedded in the webpage but referenced through a web address which will cause the browser to retrieve and use the corresponding font to render the webpage (see 1.11 and 1.15 for details related to embedding fonts into documents). As you take advantage of the @font-face cross-platform standard, be aware that web fonts are often tuned for a web environment and not intended for installation and use outside a browser. The reasons in favour of using web fonts are to allow design of dynamic text elements instead of static graphics, to make it easier for content to be localized and translated, indexed and searched, and all this with cross-platform open standards without depending on restricted extensions or plugins. You should check the CSS cascade (the order in which fonts are being called or delivered to your users) when testing. - -2.2 Can I make and use WOFF (Web Open Font Format) versions of OFL fonts? -Yes, but you need to be careful. A change in font format normally is considered modification, and Reserved Font Names (RFNs) cannot be used. Because of the design of the WOFF format, however, it is possible to create a WOFF version that is not considered modification, and so would not require a name change. You are allowed to create, use and distribute a WOFF version of an OFL font without changing the font name, but only if: - -- the original font data remains unchanged except for WOFF compression, and -- WOFF-specific metadata is either omitted altogether or present and includes, unaltered, the contents of all equivalent metadata in the original font. - -If the original font data or metadata is changed, or the WOFF-specific metadata is incomplete, the font must be considered a Modified Version, the OFL restrictions would apply and the name of the font must be changed: any RFNs cannot be used and copyright notices and licensing information must be included and cannot be deleted or modified. You must come up with a unique name - we recommend one corresponding to your domain or your particular web application. Be aware that only the original author(s) can use RFNs. This is to prevent collisions between a derivative tuned to your audience and the original upstream version and so to reduce confusion. - -Please note that most WOFF conversion tools and online services do not meet the two requirements listed above, and so their output must be considered a Modified Version. So be very careful and check to be sure that the tool or service you're using is compressing unchanged data and completely and accurately reflecting the original font metadata. - -2.3 What about other web font formats such as EOT/EOTLite/CWT/etc.? -In most cases these formats alter the original font data more than WOFF, and do not completely support appropriate metadata, so their use must be considered modification and RFNs may not be used. However, there may be certain formats or usage scenarios that may allow the use of RFNs. See http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.4 Can I make OFL fonts available through web font online services? -Yes, you are welcome to include OFL fonts in online web font services as long as you properly meet all the conditions of the license. The origin and open status of the font should be clear among the other fonts you are hosting. Authorship, copyright notices and license information must be sufficiently visible to your users or subscribers so they know where the font comes from and the rights granted by the author(s). Make sure the font file contains the needed copyright notice(s) and licensing information in its metadata. Please double-check the accuracy of every field to prevent contradictory information. Other font formats, including EOT/EOTLite/CWT and superior alternatives like WOFF, already provide fields for this information. Remember that if you modify the font within your library or convert it to another format for any reason the OFL restrictions apply and you need to change the names accordingly. Please respect the author's wishes as expressed in the OFL and do not misrepresent original designers and their work. Don't lump quality open fonts together with dubious freeware or public domain fonts. Consider how you can best work with the original designers and foundries, support their efforts and generate goodwill that will benefit your service. (See 1.17 for details related to URL-based access restrictions methods or DRM mechanisms). - -2.5 Some web font formats and services provide ways of "optimizing" the font for a particular website or web application; is that allowed? -Yes, it is permitted, but remember that these optimized versions are Modified Versions and so must follow OFL requirements like appropriate renaming. Also you need to bear in mind the other important parameters beyond compression, speed and responsiveness: you need to consider the audience of your particular website or web application, as choosing some optimization parameters may turn out to be less than ideal for them. Subsetting by removing certain glyphs or features may seriously limit functionality of the font in various languages that your users expect. It may also introduce degradation of quality in the rendering or specific bugs on the various target platforms compared to the original font from upstream. In other words, remember that one person's optimized font may be another person's missing feature. Various advanced typographic features (OpenType, Graphite or AAT) are also available through CSS and may provide the desired effects without the need to modify the font. - -2.6 Is subsetting a web font considered modification? -Yes. Removing any parts of the font when delivering a web font to a browser, including unused glyphs and smart font code, is considered modification. This is permitted by the OFL but would not normally allow the use of RFNs. Some newer subsetting technologies may be able to subset in a way that allows users to effectively have access to the complete font, including smart font behaviour. See 2.8 and http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.7 Are there any situations in which a modified web font could use RFNs? -Yes. If a web font is optimized only in ways that preserve Functional Equivalence (see 2.8), then it may use RFNs, as it reasonably represents the Original Version and respects the intentions of the author(s) and the main purposes of the RFN mechanism (avoids collisions, protects authors, minimizes support, encourages derivatives). However this is technically very difficult and often impractical, so a much better scenario is for the web font service or provider to sign a separate agreement with the author(s) that allows the use of RFNs for Modified Versions. - -2.8 How do you know if an optimization to a web font preserves Functional Equivalence? -Functional Equivalence is described in full in the 'Web fonts and RFNs' paper at http://scripts.sil.org/OFL_web_fonts_and_RFNs, in general, an optimized font is deemed to be Functionally Equivalent (FE) to the Original Version if it: - -- Supports the same full character inventory. If a character can be properly displayed using the Original Version, then that same character, encoded correctly on a web page, will display properly. -- Provides the same smart font behavior. Any dynamic shaping behavior that works with the Original Version should work when optimized, unless the browser or environment does not support it. There does not need to be guaranteed support in the client, but there should be no forced degradation of smart font or shaping behavior, such as the removal or obfuscation of OpenType, Graphite or AAT tables. -- Presents text with no obvious degradation in visual quality. The lettershapes should be equally (or more) readable, within limits of the rendering platform. -- Preserves original author, project and license metadata. At a minimum, this should include: Copyright and authorship; The license as stated in the Original Version, whether that is the full text of the OFL or a link to the web version; Any RFN declarations; Information already present in the font or documentation that points back to the Original Version, such as a link to the project or the author's website. - -If an optimized font meets these requirements, and so is considered to be FE, then it's very likely that the original author would feel that the optimized font is a good and reasonable equivalent. If it falls short of any of these requirements, the optimized font does not reasonably represent the Original Version, and so should be considered to be a Modified Version. Like other Modified Versions, it would not be allowed to use any RFNs and you simply need to pick your own font name. - -2.9 Isn't use of web fonts another form of embedding? -No. Unlike embedded fonts in a PDF, web fonts are not an integrated part of the document itself. They are not specific to a single document and are often applied to thousands of documents around the world. The font data is not stored alongside the document data and often originates from a different location. The ease by which the web fonts used by a document may be identified and downloaded for desktop use demonstrates that they are philosophically and technically separate from the web pages that specify them. See http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.10 So would it be better to not use RFNs at all if you want your font to be distributed by a web fonts service? -No. Although the OFL does not require authors to use RFNs, the RFN mechanism is an important part of the OFL model and completely compatible with web font services. If that web font service modifies the fonts, then the best solution is to sign a separate agreement for the use of any RFNs. It is perfectly valid for an author to not declare any RFNs, but before they do so they need to fully understand the benefits they are giving up, and the overall negative effect of allowing many different versions bearing the same name to be widely distributed. As a result, we don't generally recommend it. - -2.11 What should an agreement for the use of RFNs say? Are there any examples? -There is no prescribed format for this agreement, as legal systems vary, and no recommended examples. Authors may wish to add specific clauses to further restrict use, require author review of Modified Versions, establish user support mechanisms or provide terms for ending the agreement. Such agreements are usually not public, and apply only to the main parties. However, it would be very beneficial for web font services to clearly state when they have established such agreements, so that the public understands clearly that their service is operating appropriately. - -See the separate paper on 'Web Fonts & RFNs' for in-depth discussion of issues related to the use of RFNs for web fonts. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs - - -3 MODIFYING OFL-LICENSED FONTS - -3.1 Can I change the fonts? Are there any limitations to what things I can and cannot change? -You are allowed to change anything, as long as such changes do not violate the terms of the license. In other words, you are not allowed to remove the copyright statement(s) from the font, but you could put additional information into it that covers your contribution. See the placeholders in the OFL header template for recommendations on where to add your own statements. (Remember that, when authors have reserved names via the RFN mechanism, you need to change the internal names of the font to your own font name when making your modified version even if it is just a small change.) - -3.2 I have a font that needs a few extra glyphs - can I take them from an OFL licensed font and copy them into mine? -Yes, but if you distribute that font to others it must be under the OFL, and include the information mentioned in condition 2 of the license. - -3.3 Can I charge people for my additional work? In other words, if I add a bunch of special glyphs or OpenType/Graphite/AAT code, can I sell the enhanced font? -Not by itself. Derivative fonts must be released under the OFL and cannot be sold by themselves. It is permitted, however, to include them in a larger software package (such as text editors, office suites or operating systems), even if the larger package is sold. In that case, you are strongly encouraged, but not required, to also make that derived font easily and freely available outside of the larger package. - -3.4 Can I pay someone to enhance the fonts for my use and distribution? -Yes. This is a good way to fund the further development of the fonts. Keep in mind, however, that if the font is distributed to others it must be under the OFL. You won't be able to recover your investment by exclusively selling the font, but you will be making a valuable contribution to the community. Please remember how you have benefited from the contributions of others. - -3.5 I need to make substantial revisions to the font to make it work with my program. It will be a lot of work, and a big investment, and I want to be sure that it can only be distributed with my program. Can I restrict its use? -No. If you redistribute a Modified Version of the font it must be under the OFL. You may not restrict it in any way beyond what the OFL permits and requires. This is intended to ensure that all released improvements to the fonts become available to everyone. But you will likely get an edge over competitors by being the first to distribute a bundle with the enhancements. Again, please remember how you have benefited from the contributions of others. - -3.6 Do I have to make any derivative fonts (including extended source files, build scripts, documentation, etc.) publicly available? -No, but please consider sharing your improvements with others. You may find that you receive in return more than what you gave. - -3.7 If a trademark is claimed in the OFL font, does that trademark need to remain in modified fonts? -Yes. Any trademark notices must remain in any derivative fonts to respect trademark laws, but you may add any additional trademarks you claim, officially registered or not. For example if an OFL font called "Foo" contains a notice that "Foo is a trademark of Acme", then if you rename the font to "Bar" when creating a Modified Version, the new trademark notice could say "Foo is a trademark of Acme Inc. - Bar is a trademark of Roadrunner Technologies Ltd.". Trademarks work alongside the OFL and are not subject to the terms of the licensing agreement. The OFL does not grant any rights under trademark law. Bear in mind that trademark law varies from country to country and that there are no international trademark conventions as there are for copyright. You may need to significantly invest in registering and defending a trademark for it to remain valid in the countries you are interested in. This may be costly for an individual independent designer. - -3.8 If I commit changes to a font (or publish a branch in a DVCS) as part of a public open source software project, do I have to change the internal font names? -Only if there are declared RFNs. Making a public commit or publishing a public branch is effectively redistributing your modifications, so any change to the font will require that you do not use the RFNs. Even if there are no RFNs, it may be useful to change the name or add a suffix indicating that a particular version of the font is still in development and not released yet. This will clearly indicate to users and fellow designers that this particular font is not ready for release yet. See section 5 for more details. - - -4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL - -4.1 Can I use the SIL OFL for my own fonts? -Yes! We heartily encourage everyone to use the OFL to distribute their own original fonts. It is a carefully constructed license that allows great freedom along with enough artistic integrity protection for the work of the authors as well as clear rules for other contributors and those who redistribute the fonts. The licensing model is used successfully by various organisations, both for-profit and not-for-profit, to release fonts of varying levels of scope and complexity. - -4.2 What do I have to do to apply the OFL to my font? -If you want to release your fonts under the OFL, we recommend you do the following: - -4.2.1 Put your copyright and Reserved Font Names information at the beginning of the main OFL.txt file in place of the dedicated placeholders (marked with the <> characters). Include this file in your release package. - -4.2.2 Put your copyright and the OFL text with your chosen Reserved Font Name(s) into your font files (the copyright and license fields). A link to the OFL text on the OFL web site is an acceptable (but not recommended) alternative. Also add this information to any other components (build scripts, glyph databases, documentation, test files, etc). Accurate metadata in your font files is beneficial to you as an increasing number of applications are exposing this information to the user. For example, clickable links can bring users back to your website and let them know about other work you have done or services you provide. Depending on the format of your fonts and sources, you can use template human-readable headers or machine-readable metadata. You should also double-check that there is no conflicting metadata in the font itself contradicting the license, such as the fstype bits in the os2 table or fields in the name table. - -4.2.3 Write an initial FONTLOG.txt for your font and include it in the release package (see Section 6 and Appendix A for details including a template). - -4.2.4 Include the relevant practical documentation on the license by adding the current OFL-FAQ.txt file in your package. - -4.2.5 If you wish you can use the OFL graphics (http://scripts.sil.org/OFL_logo) on your website. - -4.3 Will you make my font OFL for me? -We won't do the work for you. We can, however, try to answer your questions, unfortunately we do not have the resources to review and check your font packages for correct use of the OFL. We recommend you turn to designers, foundries or consulting companies with experience in doing open font design to provide this service to you. - -4.4 Will you distribute my OFL font for me? -No, although if the font is of sufficient quality and general interest we may include a link to it on our partial list of OFL fonts on the OFL web site. You may wish to consider other open font catalogs or hosting services, such as the Unifont Font Guide (http://unifont.org/fontguide), The League of Movable Type (http://theleagueofmovabletype.com) or the Open Font Library (http://openfontlibrary.org/), which despite the name has no direct relationship to the OFL or SIL. We do not endorse any particular catalog or hosting service - it is your responsibility to determine if the service is right for you and if it treats authors with fairness. - -4.5 Why should I use the OFL for my fonts? -- to meet needs for fonts that can be modified to support lesser-known languages -- to provide a legal and clear way for people to respect your work but still use it (and reduce piracy) -- to involve others in your font project -- to enable your fonts to be expanded with new weights and improved writing system/language support -- to allow more technical font developers to add features to your design (such as OpenType, Graphite or AAT support) -- to renew the life of an old font lying on your hard drive with no business model -- to allow your font to be included in Libre Software operating systems like Ubuntu -- to give your font world status and wide, unrestricted distribution -- to educate students about quality typeface and font design -- to expand your test base and get more useful feedback -- to extend your reach to new markets when users see your metadata and go to your website -- to get your font more easily into one of the web font online services -- to attract attention for your commercial fonts -- to make money through web font services -- to make money by bundling fonts with applications -- to make money adjusting and extending existing open fonts -- to get a better chance that foundations/NGOs/charities/companies who commission fonts will pick you -- to be part of a sharing design and development community -- to give back and contribute to a growing body of font sources - - -5 CHOOSING RESERVED FONT NAMES - -5.1 What are Reserved Font Names? -These are font names, or portions of font names, that the author has chosen to reserve for use only with the Original Version of the font, or for Modified Version(s) created by the original author. - -5.2 Why can't I use the Reserved Font Names in my derivative font names? I'd like people to know where the design came from. -The best way to acknowledge the source of the design is to thank the original authors and any other contributors in the files that are distributed with your revised font (although no acknowledgement is required). The FONTLOG is a natural place to do this. Reserved Font Names ensure that the only fonts that have the original names are the unmodified Original Versions. This allows designers to maintain artistic integrity while allowing collaboration to happen. It eliminates potential confusion and name conflicts. When choosing a name, be creative and avoid names that reuse almost all the same letters in the same order or sound like the original. It will help everyone if Original Versions and Modified Versions can easily be distinguished from one another and from other derivatives. Any substitution and matching mechanism is outside the scope of the license. - -5.3 What do you mean by "primary name as presented to the user"? Are you referring to the font menu name? -Yes, this applies to the font menu name and other mechanisms that specify a font in a document. It would be fine, however, to keep a text reference to the original fonts in the description field, in your modified source file or in documentation provided alongside your derivative as long as no one could be confused that your modified source is the original. But you cannot use the Reserved Font Names in any way to identify the font to the user (unless the Copyright Holder(s) allow(s) it through a separate agreement). Users who install derivatives (Modified Versions) on their systems should not see any of the original Reserved Font Names in their font menus, for example. Again, this is to ensure that users are not confused and do not mistake one font for another and so expect features only another derivative or the Original Version can actually offer. - -5.4 Am I not allowed to use any part of the Reserved Font Names? -You may not use individual words from the Reserved Font Names, but you would be allowed to use parts of words, as long as you do not use any word from the Reserved Font Names entirely. We do not recommend using parts of words because of potential confusion, but it is allowed. For example, if "Foobar" was a Reserved Font Name, you would be allowed to use "Foo" or "bar", although we would not recommend it. Such an unfortunate choice would confuse the users of your fonts as well as make it harder for other designers to contribute. - -5.5 So what should I, as an author, identify as Reserved Font Names? -Original authors are encouraged to name their fonts using clear, distinct names, and only declare the unique parts of the name as Reserved Font Names. For example, the author of a font called "Foobar Sans" would declare "Foobar" as a Reserved Font Name, but not "Sans", as that is a common typographical term, and may be a useful word to use in a derivative font name. Reserved Font Names should also be single words for simplicity and legibility. A font called "Flowing River" should have Reserved Font Names "Flowing" and "River", not "Flowing River". You also need to be very careful about reserving font names which are already linked to trademarks (whether registered or not) which you do not own. - -5.6 Do I, as an author, have to identify any Reserved Font Names? -No. RFNs are optional and not required, but we encourage you to use them. This is primarily to avoid confusion between your work and Modified Versions. As an author you can release a font under the OFL and not declare any Reserved Font Names. There may be situations where you find that using no RFNs and letting your font be changed and modified - including any kind of modification - without having to change the original name is desirable. However you need to be fully aware of the consequences. There will be no direct way for end-users and other designers to distinguish your Original Version from many Modified Versions that may be created. You have to trust whoever is making the changes and the optimizations to not introduce problematic changes. The RFNs you choose for your own creation have value to you as an author because they allow you to maintain artistic integrity and keep some control over the distribution channel to your end-users. For discussion of RFNs and web fonts see section 2. - -5.7 Are any names (such as the main font name) reserved by default? -No. That is a change to the license as of version 1.1. If you want any names to be Reserved Font Names, they must be specified after the copyright statement(s). - -5.8 Is there any situation in which I can use Reserved Font Names for a Modified Version? -The Copyright Holder(s) can give certain trusted parties the right to use any of the Reserved Font Names through separate written agreements. For example, even if "Foobar" is a RFN, you could write up an agreement to give company "XYZ" the right to distribute a modified version with a name that includes "Foobar". This allows for freedom without confusion. The existence of such an agreement should be made as clear as possible to downstream users and designers in the distribution package and the relevant documentation. They need to know if they are a party to the agreement or not and what they are practically allowed to do or not even if all the details of the agreement are not public. - -5.9 Do font rebuilds require a name change? Do I have to change the name of the font when my packaging workflow includes a full rebuild from source? -Yes, all rebuilds which change the font data and the smart code are Modified Versions and the requirements of the OFL apply: you need to respect what the Author(s) have chosen in terms of Reserved Font Names. However if a package (or installer) is simply a wrapper or a compressed structure around the final font - leaving them intact on the inside - then no name change is required. Please get in touch with the author(s) and copyright holder(s) to inquire about the presence of font sources beyond the final font file(s) and the recommended build path. That build path may very well be non-trivial and hard to reproduce accurately by the maintainer. If a full font build path is made available by the upstream author(s) please be aware that any regressions and changes you may introduce when doing a rebuild for packaging purposes is your own responsibility as a package maintainer since you are effectively creating a separate branch. You should make it very clear to your users that your rebuilt version is not the canonical one from upstream. - -5.10 Can I add other Reserved Font Names when making a derivative font? -Yes. List your additional Reserved Font Names after your additional copyright statement, as indicated with example placeholders at the top of the OFL.txt file. Be sure you do not remove any existing RFNs but only add your own. RFN statements should be placed next to the copyright statement of the relevant author as indicated in the OFL.txt template to make them visible to designers wishing to make their separate version. - - -6 ABOUT THE FONTLOG - -6.1 What is this FONTLOG thing exactly? -It has three purposes: 1) to provide basic information on the font to users and other designers and developers, 2) to document changes that have been made to the font or accompanying files, either by the original authors or others, and 3) to provide a place to acknowledge authors and other contributors. Please use it! - -6.2 Is the FONTLOG required? -It is not a requirement of the license, but we strongly recommend you have one. - -6.3 Am I required to update the FONTLOG when making Modified Versions? -No, but users, designers and other developers might get very frustrated with you if you don't. People need to know how derivative fonts differ from the original, and how to take advantage of the changes, or build on them. There are utilities that can help create and maintain a FONTLOG, such as the FONTLOG support in FontForge. - -6.4 What should the FONTLOG look like? -It is typically a separate text file (FONTLOG.txt), but can take other formats. It commonly includes these four sections: - -- brief header describing the FONTLOG itself and name of the font family -- Basic Font Information - description of the font family, purpose and breadth -- ChangeLog - chronological listing of changes -- Acknowledgements - list of authors and contributors with contact information - -It could also include other sections, such as: where to find documentation, how to make contributions, information on contributing organizations, source code details, and a short design guide. See Appendix A for an example FONTLOG. - - -7 MAKING CONTRIBUTIONS TO OFL PROJECTS - -7.1 Can I contribute work to OFL projects? -In many cases, yes. It is common for OFL fonts to be developed by a team of people who welcome contributions from the wider community. Contact the original authors for specific information on how to participate in their projects. - -7.2 Why should I contribute my changes back to the original authors? -It would benefit many people if you contributed back in response to what you've received. Your contributions and improvements to the fonts and other components could be a tremendous help and would encourage others to contribute as well and 'give back'. You will then benefit from other people's contributions as well. Sometimes maintaining your own separate version takes more effort than merging back with the original. Be aware that any contributions, however, must be either your own original creation or work that you own, and you may be asked to affirm that clearly when you contribute. - -7.3 I've made some very nice improvements to the font. Will you consider adopting them and putting them into future Original Versions? -Most authors would be very happy to receive such contributions. Keep in mind that it is unlikely that they would want to incorporate major changes that would require additional work on their end. Any contributions would likely need to be made for all the fonts in a family and match the overall design and style. Authors are encouraged to include a guide to the design with the fonts. It would also help to have contributions submitted as patches or clearly marked changes - the use of smart source revision control systems like subversion, mercurial, git or bzr is a good idea. Please follow the recommendations given by the author(s) in terms of preferred source formats and configuration parameters for sending contributions. If this is not indicated in a FONTLOG or other documentation of the font, consider asking them directly. Examples of useful contributions are bug fixes, additional glyphs, stylistic alternates (and the smart font code to access them) or improved hinting. Keep in mind that some kinds of changes (esp. hinting) may be technically difficult to integrate. - -7.4 How can I financially support the development of OFL fonts? -It is likely that most authors of OFL fonts would accept financial contributions - contact them for instructions on how to do this. Such contributions would support future development. You can also pay for others to enhance the fonts and contribute the results back to the original authors for inclusion in the Original Version. - - -8 ABOUT THE LICENSE ITSELF - -8.1 I see that this is version 1.1 of the license. Will there be later changes? -Version 1.1 is the first minor revision of the OFL. We are confident that version 1.1 will meet most needs, but are open to future improvements. Any revisions would be for future font releases, and previously existing licenses would remain in effect. No retroactive changes are possible, although the Copyright Holder(s) can re-release the font under a revised OFL. All versions will be available on our web site: http://scripts.sil.org/OFL. - -8.2 Does this license restrict the rights of the Copyright Holder(s)? -No. The Copyright Holder(s) still retain(s) all the rights to their creation; they are only releasing a portion of it for use in a specific way. For example, the Copyright Holder(s) may choose to release a 'basic' version of their font under the OFL, but sell a restricted 'enhanced' version under a different license. They may also choose to release the same font under both the OFL and some other license. Only the Copyright Holder(s) can do this, and doing so does not change the terms of the OFL as it applies to that font. - -8.3 Is the OFL a contract or a license? -The OFL is a worldwide license based on international copyright agreements and conventions. It is not a contract and so does not require you to sign it to have legal validity. By using, modifying and redistributing components under the OFL you indicate that you accept the license. - -8.4 I really like the terms of the OFL, but want to change it a little. Am I allowed to take ideas and actual wording from the OFL and put them into my own custom license for distributing my fonts? -We strongly recommend against creating your very own unique open licensing model. Using a modified or derivative license will likely cut you off - along with the font(s) under that license - from the community of designers using the OFL, potentially expose you and your users to legal liabilities, and possibly put your work and rights at risk. The OFL went though a community and legal review process that took years of effort, and that review is only applicable to an unmodified OFL. The text of the OFL has been written by SIL (with review and consultation from the community) and is copyright (c) 2005-2017 SIL International. You may re-use the ideas and wording (in part, not in whole) in another non-proprietary license provided that you call your license by another unambiguous name, that you do not use the preamble, that you do not mention SIL and that you clearly present your license as different from the OFL so as not to cause confusion by being too similar to the original. If you feel the OFL does not meet your needs for an open license, please contact us. - -8.5 Can I quote from the OFL FAQ? -Yes, SIL gives permission to quote from the OFL FAQ (OFL-FAQ.txt), in whole or in part, provided that the quoted text is: - -- unmodified, -- used to help explain the intent of the OFL, rather than cause misunderstanding, and -- accompanied with the following attribution: "From the OFL FAQ (OFL-FAQ.txt), copyright (c) 2005-2020 SIL International. Used by permission. http://scripts.sil.org/OFL-FAQ_web". - -8.6 Can I translate the license and the FAQ into other languages? -SIL certainly recognises the need for people who are not familiar with English to be able to understand the OFL and its use. Making the license very clear and readable has been a key goal for the OFL, but we know that people understand their own language best. - -If you are an experienced translator, you are very welcome to translate the OFL and OFL-FAQ so that designers and users in your language community can understand the license better. But only the original English version of the license has legal value and has been approved by the community. Translations do not count as legal substitutes and should only serve as a way to explain the original license. SIL - as the author and steward of the license for the community at large - does not approve any translation of the OFL as legally valid because even small translation ambiguities could be abused and create problems. - -SIL gives permission to publish unofficial translations into other languages provided that they comply with the following guidelines: - -- Put the following disclaimer in both English and the target language stating clearly that the translation is unofficial: - -"This is an unofficial translation of the SIL Open Font License into . It was not published by SIL International, and does not legally state the distribution terms for fonts that use the OFL. A release under the OFL is only valid when using the original English text. However, we recognize that this unofficial translation will help users and designers not familiar with English to better understand and use the OFL. We encourage designers who consider releasing their creation under the OFL to read the OFL-FAQ in their own language if it is available. Please go to http://scripts.sil.org/OFL for the official version of the license and the accompanying OFL-FAQ." - -- Keep your unofficial translation current and update it at our request if needed, for example if there is any ambiguity which could lead to confusion. - -If you start such a unofficial translation effort of the OFL and OFL-FAQ please let us know. - -8.7 Does the OFL have an explicit expiration term? -No, the implicit intent of the OFL is that the permissions granted are perpetual and irrevocable. - - -9 ABOUT SIL INTERNATIONAL - -9.1 Who is SIL International and what do they do? -SIL serves language communities worldwide, building their capacity for sustainable language development, by means of research, translation, training and materials development. SIL makes its services available to all without regard to religious belief, political ideology, gender, race, or ethnic background. SIL's members and volunteers share a Christian commitment. - -9.2 What does this have to do with font licensing? -The ability to read, write, type and publish in one's own language is one of the most critical needs for millions of people around the world. This requires fonts that are widely available and support lesser-known languages. SIL develops - and encourages others to develop - a complete stack of writing systems implementation components available under open licenses. This open stack includes input methods, smart fonts, smart rendering libraries and smart applications. There has been a need for a common open license that is specifically applicable to fonts and related software (a crucial component of this stack), so SIL developed the SIL Open Font License with the help of the Free/Libre and Open Source Software community. - -9.3 How can I contact SIL? -Our main web site is: http://www.sil.org/ -Our site about complex scripts is: http://scripts.sil.org/ -Information about this license (and contact information) is at: http://scripts.sil.org/OFL - - -APPENDIX A - FONTLOG EXAMPLE - -Here is an example of the recommended format for a FONTLOG, although other formats are allowed. - ------ -FONTLOG for the GlobalFontFamily fonts - -This file provides detailed information on the GlobalFontFamily Font Software. This information should be distributed along with the GlobalFontFamily fonts and any derivative works. - -Basic Font Information - -GlobalFontFamily is a Unicode typeface family that supports all languages that use the Latin script and its variants, and could be expanded to support other scripts. - -NewWorldFontFamily is based on the GlobalFontFamily and also supports Greek, Hebrew, Cyrillic and Armenian. - -More specifically, this release supports the following Unicode ranges... -This release contains... -Documentation can be found at... -To contribute to the project... - -ChangeLog - -10 December 2010 (Fred Foobar) GlobalFontFamily-devel version 1.4 -- fix new build and testing system (bug #123456) - -1 August 2008 (Tom Parker) GlobalFontFamily version 1.2.1 -- Tweaked the smart font code (Branch merged with trunk version) -- Provided improved build and debugging environment for smart behaviours - -7 February 2007 (Pat Johnson) NewWorldFontFamily Version 1.3 -- Added Greek and Cyrillic glyphs - -7 March 2006 (Fred Foobar) NewWorldFontFamily Version 1.2 -- Tweaked contextual behaviours - -1 Feb 2005 (Jane Doe) NewWorldFontFamily Version 1.1 -- Improved build script performance and verbosity -- Extended the smart code documentation -- Corrected minor typos in the documentation -- Fixed position of combining inverted breve below (U+032F) -- Added OpenType/Graphite smart code for Armenian -- Added Armenian glyphs (U+0531 -> U+0587) -- Released as "NewWorldFontFamily" - -1 Jan 2005 (Joe Smith) GlobalFontFamily Version 1.0 -- Initial release - -Acknowledgements - -If you make modifications be sure to add your name (N), email (E), web-address (if you have one) (W) and description (D). This list is in alphabetical order. - -N: Jane Doe -E: jane@university.edu -W: http://art.university.edu/projects/fonts -D: Contributor - Armenian glyphs and code - -N: Fred Foobar -E: fred@foobar.org -W: http://foobar.org -D: Contributor - misc Graphite fixes - -N: Pat Johnson -E: pat@fontstudio.org -W: http://pat.fontstudio.org -D: Designer - Greek & Cyrillic glyphs based on Roman design - -N: Tom Parker -E: tom@company.com -W: http://www.company.com/tom/projects/fonts -D: Engineer - original smart font code - -N: Joe Smith -E: joe@fontstudio.org -W: http://joe.fontstudio.org -D: Designer - original Roman glyphs - -Fontstudio.org is an not-for-profit design group whose purpose is... -Foobar.org is a distributed community of developers... -Company.com is a small business who likes to support community designers... -University.edu is a renowned educational institution with a strong design department... ------ \ No newline at end of file diff --git a/src-tauri/resources/resources-bundles/ziti/LICENSE.txt b/src-tauri/resources/resources-bundles/ziti/LICENSE.txt deleted file mode 100644 index 26bedc9..0000000 --- a/src-tauri/resources/resources-bundles/ziti/LICENSE.txt +++ /dev/null @@ -1,96 +0,0 @@ -Copyright (c) 2022-11-02, ZERO子 (https://github.com/Skr-ZERO), -with Reserved Font Name “Assorted”“什锦”. - -Copyright (c) 2022-11-01, Umihotaru (https://umihotaru.work/), - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/src-tauri/resources/resources-bundles/ziti/NotoSerifCJK-VF.ttf.ttc b/src-tauri/resources/resources-bundles/ziti/NotoSerifCJK-VF.ttf.ttc deleted file mode 100644 index f2e98c6..0000000 Binary files a/src-tauri/resources/resources-bundles/ziti/NotoSerifCJK-VF.ttf.ttc and /dev/null differ diff --git a/src-tauri/resources/resources-bundles/ziti/OFL-FAQ.txt b/src-tauri/resources/resources-bundles/ziti/OFL-FAQ.txt deleted file mode 100644 index 8b03637..0000000 --- a/src-tauri/resources/resources-bundles/ziti/OFL-FAQ.txt +++ /dev/null @@ -1,435 +0,0 @@ -OFL FAQ - Frequently Asked Questions about the SIL Open Font License (OFL) -Version 1.1-update6 - December 2020 -The OFL FAQ is copyright (c) 2005-2020 SIL International. -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -(See http://scripts.sil.org/OFL for updates) - - -CONTENTS OF THIS FAQ -1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL -2 USING OFL FONTS FOR WEB PAGES AND ONLINE WEB FONT SERVICES -3 MODIFYING OFL-LICENSED FONTS -4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL -5 CHOOSING RESERVED FONT NAMES -6 ABOUT THE FONTLOG -7 MAKING CONTRIBUTIONS TO OFL PROJECTS -8 ABOUT THE LICENSE ITSELF -9 ABOUT SIL INTERNATIONAL -APPENDIX A - FONTLOG EXAMPLE - -1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL - -1.1 Can I use the fonts for a book or other print publication, to create logos or other graphics or even to manufacture objects based on their outlines? -Yes. You are very welcome to do so. Authors of fonts released under the OFL allow you to use their font software as such for any kind of design work. No additional license or permission is required, unlike with some other licenses. Some examples of these uses are: logos, posters, business cards, stationery, video titling, signage, t-shirts, personalised fabric, 3D-printed/laser-cut shapes, sculptures, rubber stamps, cookie cutters and lead type. - -1.1.1 Does that restrict the license or distribution of that artwork? -No. You remain the author and copyright holder of that newly derived graphic or object. You are simply using an open font in the design process. It is only when you redistribute, bundle or modify the font itself that other conditions of the license have to be respected (see below for more details). - -1.1.2 Is any kind of acknowledgement required? -No. Font authors may appreciate being mentioned in your artwork's acknowledgements alongside the name of the font, possibly with a link to their website, but that is not required. - -1.2 Can the fonts be included with Free/Libre and Open Source Software collections such as GNU/Linux and BSD distributions and repositories? -Yes! Fonts licensed under the OFL can be freely included alongside other software under FLOSS (Free/Libre and Open Source Software) licenses. Since fonts are typically aggregated with, not merged into, existing software, there is little need to be concerned about incompatibility with existing software licenses. You may also repackage the fonts and the accompanying components in a .rpm or .deb package (or other similar package formats or installers) and include them in distribution CD/DVDs and online repositories. (Also see section 5.9 about rebuilding from source.) - -1.3 I want to distribute the fonts with my program. Does this mean my program also has to be Free/Libre and Open Source Software? -No. Only the portions based on the Font Software are required to be released under the OFL. The intent of the license is to allow aggregation or bundling with software under restricted licensing as well. - -1.4 Can I sell a software package that includes these fonts? -Yes, you can do this with both the Original Version and a Modified Version of the fonts. Examples of bundling made possible by the OFL would include: word processors, design and publishing applications, training and educational software, games and entertainment software, mobile device applications, etc. - -1.5 Can I include the fonts on a CD of freeware or commercial fonts? -Yes, as long some other font or software is also on the disk, so the OFL font is not sold by itself. - -1.6 Why won't the OFL let me sell the fonts alone? -The intent is to keep people from making money by simply redistributing the fonts. The only people who ought to profit directly from the fonts should be the original authors, and those authors have kindly given up potential direct income to distribute their fonts under the OFL. Please honour and respect their contribution! - -1.7 What about sharing OFL fonts with friends on a CD, DVD or USB stick? -You are very welcome to share open fonts with friends, family and colleagues through removable media. Just remember to include the full font package, including any copyright notices and licensing information as available in OFL.txt. In the case where you sell the font, it has to come bundled with software. - -1.8 Can I host the fonts on a web site for others to use? -Yes, as long as you make the full font package available. In most cases it may be best to point users to the main site that distributes the Original Version so they always get the most recent stable and complete version. See also discussion of web fonts in Section 2. - -1.9 Can I host the fonts on a server for use over our internal network? -Yes. If the fonts are transferred from the server to the client computer by means that allow them to be used even if the computer is no longer attached to the network, the full package (copyright notices, licensing information, etc.) should be included. - -1.10 Does the full OFL license text always need to accompany the font? -The only situation in which an OFL font can be distributed without the text of the OFL (either in a separate file or in font metadata), is when a font is embedded in a document or bundled within a program. In the case of metadata included within a font, it is legally sufficient to include only a link to the text of the OFL on http://scripts.sil.org/OFL, but we strongly recommend against this. Most modern font formats include metadata fields that will accept the full OFL text, and full inclusion increases the likelihood that users will understand and properly apply the license. - -1.11 What do you mean by 'embedding'? How does that differ from other means of distribution? -By 'embedding' we mean inclusion of the font in a document or file in a way that makes extraction (and redistribution) difficult or clearly discouraged. In many cases the names of embedded fonts might also not be obvious to those reading the document, the font data format might be altered, and only a subset of the font - only the glyphs required for the text - might be included. Any other means of delivering a font to another person is considered 'distribution', and needs to be accompanied by any copyright notices and licensing information available in OFL.txt. - -1.12 So can I embed OFL fonts in my document? -Yes, either in full or a subset. The restrictions regarding font modification and redistribution do not apply, as the font is not intended for use outside the document. - -1.13 Does embedding alter the license of the document itself? -No. Referencing or embedding an OFL font in any document does not change the license of the document itself. The requirement for fonts to remain under the OFL does not apply to any document created using the fonts and their derivatives. Similarly, creating any kind of graphic using a font under OFL does not make the resulting artwork subject to the OFL. - -1.14 If OFL fonts are extracted from a document in which they are embedded (such as a PDF file), what can be done with them? Is this a risk to author(s)? -The few utilities that can extract fonts embedded in a PDF will typically output limited amounts of outlines - not a complete font. To create a working font from this method is much more difficult and time consuming than finding the source of the original OFL font. So there is little chance that an OFL font would be extracted and redistributed inappropriately through this method. Even so, copyright laws address any misrepresentation of authorship. All Font Software released under the OFL and marked as such by the author(s) is intended to remain under this license regardless of the distribution method, and cannot be redistributed under any other license. We strongly discourage any font extraction - we recommend directly using the font sources instead - but if you extract font outlines from a document, please be considerate: respect the work of the author(s) and the licensing model. - -1.15 What about distributing fonts with a document? Within a compressed folder structure? Is it distribution, bundling or embedding? -Certain document formats may allow the inclusion of an unmodified font within their file structure which may consist of a compressed folder containing the various resources forming the document (such as pictures and thumbnails). Including fonts within such a structure is understood as being different from embedding but rather similar to bundling (or mere aggregation) which the license explicitly allows. In this case the font is conveyed unchanged whereas embedding a font usually transforms it from the original format. The OFL does not allow anyone to extract the font from such a structure to then redistribute it under another license. The explicit permission to redistribute and embed does not cancel the requirement for the Font Software to remain under the license chosen by its author(s). Even if the font travels inside the document as one of its assets, it should not lose its authorship information and licensing. - -1.16 What about ebooks shipping with open fonts? -The requirements differ depending on whether the fonts are linked, embedded or distributed (bundled or aggregated). Some ebook formats use web technologies to do font linking via @font-face, others are designed for font embedding, some use fonts distributed with the document or reading software, and a few rely solely on the fonts already present on the target system. The license requirements depend on the type of inclusion as discussed in 1.15. - -1.17 Can Font Software released under the OFL be subject to URL-based access restrictions methods or DRM (Digital Rights Management) mechanisms? -Yes, but these issues are out-of-scope for the OFL. The license itself neither encourages their use nor prohibits them since such mechanisms are not implemented in the components of the Font Software but through external software. Such restrictions are put in place for many different purposes corresponding to various usage scenarios. One common example is to limit potentially dangerous cross-site scripting attacks. However, in the spirit of libre/open fonts and unrestricted writing systems, we strongly encourage open sharing and reuse of OFL fonts, and the establishment of an environment where such restrictions are unnecessary. Note that whether you wish to use such mechanisms or you prefer not to, you must still abide by the rules set forth by the OFL when using fonts released by their authors under this license. Derivative fonts must be licensed under the OFL, even if they are part of a service for which you charge fees and/or for which access to source code is restricted. You may not sell the fonts on their own - they must be part of a larger software package, bundle or subscription plan. For example, even if the OFL font is distributed in a software package or via an online service using a DRM mechanism, the user would still have the right to extract that font, use, study, modify and redistribute it under the OFL. - -1.18 I've come across a font released under the OFL. How can I easily get more information about the Original Version? How can I know where it stands compared to the Original Version or other Modified Versions? -Consult the copyright statement(s) in the license for ways to contact the original authors. Consult the FONTLOG (see section 6 for more details and examples) for information on how the font differs from the Original Version, and get in touch with the various contributors via the information in the acknowledgement section. Please consider using the Original Versions of the fonts whenever possible. - -1.19 What do you mean in condition 4 of the OFL's permissions and conditions? Can you provide examples of abusive promotion / endorsement / advertisement vs. normal acknowledgement? -The intent is that the goodwill and reputation of the author(s) should not be used in a way that makes it sound like the original author(s) endorse or approve of a specific Modified Version or software bundle. For example, it would not be right to advertise a word processor by naming the author(s) in a listing of software features, or to promote a Modified Version on a web site by saying "designed by ...". However, it would be appropriate to acknowledge the author(s) if your software package has a list of people who deserve thanks. We realize that this can seem to be a grey area, but the standard used to judge an acknowledgement is that if the acknowledgement benefits the author(s) it is allowed, but if it primarily benefits other parties, or could reflect poorly on the author(s), then it is not. - -1.20 I'm writing a small app for mobile platforms, do I need to include the whole package? -If you bundle a font under the OFL with your mobile app you must comply with the terms of the license. At a minimum you must include the copyright statement, the license notice and the license text. A mention of this information in your About box or Changelog, with a link to where the font package is from, is good practice, and the extra space needed to carry these items is very small. You do not, however, need to include the full contents of the font package - only the fonts you use and the copyright and license that apply to them. For example, if you only use the regular weight in your app, you do not need to include the italic and bold versions. - -1.21 What about including OFL fonts by default in my firmware or dedicated operating system? -Many such systems are restricted and turned into appliances so that users cannot study or modify them. Using open fonts to increase quality and language coverage is a great idea, but you need to be aware that if there is a way for users to extract fonts you cannot legally prevent them from doing that. The fonts themselves, including any changes you make to them, must be distributed under the OFL even if your firmware has a more restrictive license. If you do transform the fonts and change their formats when you include them in your firmware you must respect any names reserved by the font authors via the RFN mechanism and pick your own font name. Alternatively if you directly add a font under the OFL to the font folder of your firmware without modifying or optimizing it you are simply bundling the font like with any other software collection, and do not need to make any further changes. - -1.22 Can I make and publish CMS themes or templates that use OFL fonts? Can I include the fonts themselves in the themes or templates? Can I sell the whole package? -Yes, you are very welcome to integrate open fonts into themes and templates for your preferred CMS and make them more widely available. Remember that you can only sell the fonts and your CMS add-on as part of a software bundle. (See 1.4 for details and examples about selling bundles). - -1.23 Can OFL fonts be included in services that deliver fonts to the desktop from remote repositories? Even if they contain both OFL and non-OFL fonts? -Yes. Some foundries have set up services to deliver fonts to subscribers directly to desktops from their online repositories; similarly, plugins are available to preview and use fonts directly in your design tool or publishing suite. These services may mix open and restricted fonts in the same channel, however they should make a clear distinction between them to users. These services should also not hinder users (such as through DRM or obfuscation mechanisms) from extracting and using the OFL fonts in other environments, or continuing to use OFL fonts after subscription terms have ended, as those uses are specifically allowed by the OFL. - -1.24 Can services that provide or distribute OFL fonts restrict my use of them? -No. The terms of use of such services cannot replace or restrict the terms of the OFL, as that would be the same as distributing the fonts under a different license, which is not allowed. You are still entitled to use, modify and redistribute them as the original authors have intended outside of the sole control of that particular distribution channel. Note, however, that the fonts provided by these services may differ from the Original Versions. - - -2 USING OFL FONTS FOR WEBPAGES AND ONLINE WEB FONT SERVICES - -NOTE: This section often refers to a separate paper on 'Web Fonts & RFNs'. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.1 Can I make webpages using these fonts? -Yes! Go ahead! Using CSS (Cascading Style Sheets) is recommended. Your three best options are: -- referring directly in your stylesheet to open fonts which may be available on the user's system -- providing links to download the full package of the font - either from your own website or from elsewhere - so users can install it themselves -- using @font-face to distribute the font directly to browsers. This is recommended and explicitly allowed by the licensing model because it is distribution. The font file itself is distributed with other components of the webpage. It is not embedded in the webpage but referenced through a web address which will cause the browser to retrieve and use the corresponding font to render the webpage (see 1.11 and 1.15 for details related to embedding fonts into documents). As you take advantage of the @font-face cross-platform standard, be aware that web fonts are often tuned for a web environment and not intended for installation and use outside a browser. The reasons in favour of using web fonts are to allow design of dynamic text elements instead of static graphics, to make it easier for content to be localized and translated, indexed and searched, and all this with cross-platform open standards without depending on restricted extensions or plugins. You should check the CSS cascade (the order in which fonts are being called or delivered to your users) when testing. - -2.2 Can I make and use WOFF (Web Open Font Format) versions of OFL fonts? -Yes, but you need to be careful. A change in font format normally is considered modification, and Reserved Font Names (RFNs) cannot be used. Because of the design of the WOFF format, however, it is possible to create a WOFF version that is not considered modification, and so would not require a name change. You are allowed to create, use and distribute a WOFF version of an OFL font without changing the font name, but only if: - -- the original font data remains unchanged except for WOFF compression, and -- WOFF-specific metadata is either omitted altogether or present and includes, unaltered, the contents of all equivalent metadata in the original font. - -If the original font data or metadata is changed, or the WOFF-specific metadata is incomplete, the font must be considered a Modified Version, the OFL restrictions would apply and the name of the font must be changed: any RFNs cannot be used and copyright notices and licensing information must be included and cannot be deleted or modified. You must come up with a unique name - we recommend one corresponding to your domain or your particular web application. Be aware that only the original author(s) can use RFNs. This is to prevent collisions between a derivative tuned to your audience and the original upstream version and so to reduce confusion. - -Please note that most WOFF conversion tools and online services do not meet the two requirements listed above, and so their output must be considered a Modified Version. So be very careful and check to be sure that the tool or service you're using is compressing unchanged data and completely and accurately reflecting the original font metadata. - -2.3 What about other web font formats such as EOT/EOTLite/CWT/etc.? -In most cases these formats alter the original font data more than WOFF, and do not completely support appropriate metadata, so their use must be considered modification and RFNs may not be used. However, there may be certain formats or usage scenarios that may allow the use of RFNs. See http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.4 Can I make OFL fonts available through web font online services? -Yes, you are welcome to include OFL fonts in online web font services as long as you properly meet all the conditions of the license. The origin and open status of the font should be clear among the other fonts you are hosting. Authorship, copyright notices and license information must be sufficiently visible to your users or subscribers so they know where the font comes from and the rights granted by the author(s). Make sure the font file contains the needed copyright notice(s) and licensing information in its metadata. Please double-check the accuracy of every field to prevent contradictory information. Other font formats, including EOT/EOTLite/CWT and superior alternatives like WOFF, already provide fields for this information. Remember that if you modify the font within your library or convert it to another format for any reason the OFL restrictions apply and you need to change the names accordingly. Please respect the author's wishes as expressed in the OFL and do not misrepresent original designers and their work. Don't lump quality open fonts together with dubious freeware or public domain fonts. Consider how you can best work with the original designers and foundries, support their efforts and generate goodwill that will benefit your service. (See 1.17 for details related to URL-based access restrictions methods or DRM mechanisms). - -2.5 Some web font formats and services provide ways of "optimizing" the font for a particular website or web application; is that allowed? -Yes, it is permitted, but remember that these optimized versions are Modified Versions and so must follow OFL requirements like appropriate renaming. Also you need to bear in mind the other important parameters beyond compression, speed and responsiveness: you need to consider the audience of your particular website or web application, as choosing some optimization parameters may turn out to be less than ideal for them. Subsetting by removing certain glyphs or features may seriously limit functionality of the font in various languages that your users expect. It may also introduce degradation of quality in the rendering or specific bugs on the various target platforms compared to the original font from upstream. In other words, remember that one person's optimized font may be another person's missing feature. Various advanced typographic features (OpenType, Graphite or AAT) are also available through CSS and may provide the desired effects without the need to modify the font. - -2.6 Is subsetting a web font considered modification? -Yes. Removing any parts of the font when delivering a web font to a browser, including unused glyphs and smart font code, is considered modification. This is permitted by the OFL but would not normally allow the use of RFNs. Some newer subsetting technologies may be able to subset in a way that allows users to effectively have access to the complete font, including smart font behaviour. See 2.8 and http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.7 Are there any situations in which a modified web font could use RFNs? -Yes. If a web font is optimized only in ways that preserve Functional Equivalence (see 2.8), then it may use RFNs, as it reasonably represents the Original Version and respects the intentions of the author(s) and the main purposes of the RFN mechanism (avoids collisions, protects authors, minimizes support, encourages derivatives). However this is technically very difficult and often impractical, so a much better scenario is for the web font service or provider to sign a separate agreement with the author(s) that allows the use of RFNs for Modified Versions. - -2.8 How do you know if an optimization to a web font preserves Functional Equivalence? -Functional Equivalence is described in full in the 'Web fonts and RFNs' paper at http://scripts.sil.org/OFL_web_fonts_and_RFNs, in general, an optimized font is deemed to be Functionally Equivalent (FE) to the Original Version if it: - -- Supports the same full character inventory. If a character can be properly displayed using the Original Version, then that same character, encoded correctly on a web page, will display properly. -- Provides the same smart font behavior. Any dynamic shaping behavior that works with the Original Version should work when optimized, unless the browser or environment does not support it. There does not need to be guaranteed support in the client, but there should be no forced degradation of smart font or shaping behavior, such as the removal or obfuscation of OpenType, Graphite or AAT tables. -- Presents text with no obvious degradation in visual quality. The lettershapes should be equally (or more) readable, within limits of the rendering platform. -- Preserves original author, project and license metadata. At a minimum, this should include: Copyright and authorship; The license as stated in the Original Version, whether that is the full text of the OFL or a link to the web version; Any RFN declarations; Information already present in the font or documentation that points back to the Original Version, such as a link to the project or the author's website. - -If an optimized font meets these requirements, and so is considered to be FE, then it's very likely that the original author would feel that the optimized font is a good and reasonable equivalent. If it falls short of any of these requirements, the optimized font does not reasonably represent the Original Version, and so should be considered to be a Modified Version. Like other Modified Versions, it would not be allowed to use any RFNs and you simply need to pick your own font name. - -2.9 Isn't use of web fonts another form of embedding? -No. Unlike embedded fonts in a PDF, web fonts are not an integrated part of the document itself. They are not specific to a single document and are often applied to thousands of documents around the world. The font data is not stored alongside the document data and often originates from a different location. The ease by which the web fonts used by a document may be identified and downloaded for desktop use demonstrates that they are philosophically and technically separate from the web pages that specify them. See http://scripts.sil.org/OFL_web_fonts_and_RFNs - -2.10 So would it be better to not use RFNs at all if you want your font to be distributed by a web fonts service? -No. Although the OFL does not require authors to use RFNs, the RFN mechanism is an important part of the OFL model and completely compatible with web font services. If that web font service modifies the fonts, then the best solution is to sign a separate agreement for the use of any RFNs. It is perfectly valid for an author to not declare any RFNs, but before they do so they need to fully understand the benefits they are giving up, and the overall negative effect of allowing many different versions bearing the same name to be widely distributed. As a result, we don't generally recommend it. - -2.11 What should an agreement for the use of RFNs say? Are there any examples? -There is no prescribed format for this agreement, as legal systems vary, and no recommended examples. Authors may wish to add specific clauses to further restrict use, require author review of Modified Versions, establish user support mechanisms or provide terms for ending the agreement. Such agreements are usually not public, and apply only to the main parties. However, it would be very beneficial for web font services to clearly state when they have established such agreements, so that the public understands clearly that their service is operating appropriately. - -See the separate paper on 'Web Fonts & RFNs' for in-depth discussion of issues related to the use of RFNs for web fonts. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs - - -3 MODIFYING OFL-LICENSED FONTS - -3.1 Can I change the fonts? Are there any limitations to what things I can and cannot change? -You are allowed to change anything, as long as such changes do not violate the terms of the license. In other words, you are not allowed to remove the copyright statement(s) from the font, but you could put additional information into it that covers your contribution. See the placeholders in the OFL header template for recommendations on where to add your own statements. (Remember that, when authors have reserved names via the RFN mechanism, you need to change the internal names of the font to your own font name when making your modified version even if it is just a small change.) - -3.2 I have a font that needs a few extra glyphs - can I take them from an OFL licensed font and copy them into mine? -Yes, but if you distribute that font to others it must be under the OFL, and include the information mentioned in condition 2 of the license. - -3.3 Can I charge people for my additional work? In other words, if I add a bunch of special glyphs or OpenType/Graphite/AAT code, can I sell the enhanced font? -Not by itself. Derivative fonts must be released under the OFL and cannot be sold by themselves. It is permitted, however, to include them in a larger software package (such as text editors, office suites or operating systems), even if the larger package is sold. In that case, you are strongly encouraged, but not required, to also make that derived font easily and freely available outside of the larger package. - -3.4 Can I pay someone to enhance the fonts for my use and distribution? -Yes. This is a good way to fund the further development of the fonts. Keep in mind, however, that if the font is distributed to others it must be under the OFL. You won't be able to recover your investment by exclusively selling the font, but you will be making a valuable contribution to the community. Please remember how you have benefited from the contributions of others. - -3.5 I need to make substantial revisions to the font to make it work with my program. It will be a lot of work, and a big investment, and I want to be sure that it can only be distributed with my program. Can I restrict its use? -No. If you redistribute a Modified Version of the font it must be under the OFL. You may not restrict it in any way beyond what the OFL permits and requires. This is intended to ensure that all released improvements to the fonts become available to everyone. But you will likely get an edge over competitors by being the first to distribute a bundle with the enhancements. Again, please remember how you have benefited from the contributions of others. - -3.6 Do I have to make any derivative fonts (including extended source files, build scripts, documentation, etc.) publicly available? -No, but please consider sharing your improvements with others. You may find that you receive in return more than what you gave. - -3.7 If a trademark is claimed in the OFL font, does that trademark need to remain in modified fonts? -Yes. Any trademark notices must remain in any derivative fonts to respect trademark laws, but you may add any additional trademarks you claim, officially registered or not. For example if an OFL font called "Foo" contains a notice that "Foo is a trademark of Acme", then if you rename the font to "Bar" when creating a Modified Version, the new trademark notice could say "Foo is a trademark of Acme Inc. - Bar is a trademark of Roadrunner Technologies Ltd.". Trademarks work alongside the OFL and are not subject to the terms of the licensing agreement. The OFL does not grant any rights under trademark law. Bear in mind that trademark law varies from country to country and that there are no international trademark conventions as there are for copyright. You may need to significantly invest in registering and defending a trademark for it to remain valid in the countries you are interested in. This may be costly for an individual independent designer. - -3.8 If I commit changes to a font (or publish a branch in a DVCS) as part of a public open source software project, do I have to change the internal font names? -Only if there are declared RFNs. Making a public commit or publishing a public branch is effectively redistributing your modifications, so any change to the font will require that you do not use the RFNs. Even if there are no RFNs, it may be useful to change the name or add a suffix indicating that a particular version of the font is still in development and not released yet. This will clearly indicate to users and fellow designers that this particular font is not ready for release yet. See section 5 for more details. - - -4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL - -4.1 Can I use the SIL OFL for my own fonts? -Yes! We heartily encourage everyone to use the OFL to distribute their own original fonts. It is a carefully constructed license that allows great freedom along with enough artistic integrity protection for the work of the authors as well as clear rules for other contributors and those who redistribute the fonts. The licensing model is used successfully by various organisations, both for-profit and not-for-profit, to release fonts of varying levels of scope and complexity. - -4.2 What do I have to do to apply the OFL to my font? -If you want to release your fonts under the OFL, we recommend you do the following: - -4.2.1 Put your copyright and Reserved Font Names information at the beginning of the main OFL.txt file in place of the dedicated placeholders (marked with the <> characters). Include this file in your release package. - -4.2.2 Put your copyright and the OFL text with your chosen Reserved Font Name(s) into your font files (the copyright and license fields). A link to the OFL text on the OFL web site is an acceptable (but not recommended) alternative. Also add this information to any other components (build scripts, glyph databases, documentation, test files, etc). Accurate metadata in your font files is beneficial to you as an increasing number of applications are exposing this information to the user. For example, clickable links can bring users back to your website and let them know about other work you have done or services you provide. Depending on the format of your fonts and sources, you can use template human-readable headers or machine-readable metadata. You should also double-check that there is no conflicting metadata in the font itself contradicting the license, such as the fstype bits in the os2 table or fields in the name table. - -4.2.3 Write an initial FONTLOG.txt for your font and include it in the release package (see Section 6 and Appendix A for details including a template). - -4.2.4 Include the relevant practical documentation on the license by adding the current OFL-FAQ.txt file in your package. - -4.2.5 If you wish you can use the OFL graphics (http://scripts.sil.org/OFL_logo) on your website. - -4.3 Will you make my font OFL for me? -We won't do the work for you. We can, however, try to answer your questions, unfortunately we do not have the resources to review and check your font packages for correct use of the OFL. We recommend you turn to designers, foundries or consulting companies with experience in doing open font design to provide this service to you. - -4.4 Will you distribute my OFL font for me? -No, although if the font is of sufficient quality and general interest we may include a link to it on our partial list of OFL fonts on the OFL web site. You may wish to consider other open font catalogs or hosting services, such as the Unifont Font Guide (http://unifont.org/fontguide), The League of Movable Type (http://theleagueofmovabletype.com) or the Open Font Library (http://openfontlibrary.org/), which despite the name has no direct relationship to the OFL or SIL. We do not endorse any particular catalog or hosting service - it is your responsibility to determine if the service is right for you and if it treats authors with fairness. - -4.5 Why should I use the OFL for my fonts? -- to meet needs for fonts that can be modified to support lesser-known languages -- to provide a legal and clear way for people to respect your work but still use it (and reduce piracy) -- to involve others in your font project -- to enable your fonts to be expanded with new weights and improved writing system/language support -- to allow more technical font developers to add features to your design (such as OpenType, Graphite or AAT support) -- to renew the life of an old font lying on your hard drive with no business model -- to allow your font to be included in Libre Software operating systems like Ubuntu -- to give your font world status and wide, unrestricted distribution -- to educate students about quality typeface and font design -- to expand your test base and get more useful feedback -- to extend your reach to new markets when users see your metadata and go to your website -- to get your font more easily into one of the web font online services -- to attract attention for your commercial fonts -- to make money through web font services -- to make money by bundling fonts with applications -- to make money adjusting and extending existing open fonts -- to get a better chance that foundations/NGOs/charities/companies who commission fonts will pick you -- to be part of a sharing design and development community -- to give back and contribute to a growing body of font sources - - -5 CHOOSING RESERVED FONT NAMES - -5.1 What are Reserved Font Names? -These are font names, or portions of font names, that the author has chosen to reserve for use only with the Original Version of the font, or for Modified Version(s) created by the original author. - -5.2 Why can't I use the Reserved Font Names in my derivative font names? I'd like people to know where the design came from. -The best way to acknowledge the source of the design is to thank the original authors and any other contributors in the files that are distributed with your revised font (although no acknowledgement is required). The FONTLOG is a natural place to do this. Reserved Font Names ensure that the only fonts that have the original names are the unmodified Original Versions. This allows designers to maintain artistic integrity while allowing collaboration to happen. It eliminates potential confusion and name conflicts. When choosing a name, be creative and avoid names that reuse almost all the same letters in the same order or sound like the original. It will help everyone if Original Versions and Modified Versions can easily be distinguished from one another and from other derivatives. Any substitution and matching mechanism is outside the scope of the license. - -5.3 What do you mean by "primary name as presented to the user"? Are you referring to the font menu name? -Yes, this applies to the font menu name and other mechanisms that specify a font in a document. It would be fine, however, to keep a text reference to the original fonts in the description field, in your modified source file or in documentation provided alongside your derivative as long as no one could be confused that your modified source is the original. But you cannot use the Reserved Font Names in any way to identify the font to the user (unless the Copyright Holder(s) allow(s) it through a separate agreement). Users who install derivatives (Modified Versions) on their systems should not see any of the original Reserved Font Names in their font menus, for example. Again, this is to ensure that users are not confused and do not mistake one font for another and so expect features only another derivative or the Original Version can actually offer. - -5.4 Am I not allowed to use any part of the Reserved Font Names? -You may not use individual words from the Reserved Font Names, but you would be allowed to use parts of words, as long as you do not use any word from the Reserved Font Names entirely. We do not recommend using parts of words because of potential confusion, but it is allowed. For example, if "Foobar" was a Reserved Font Name, you would be allowed to use "Foo" or "bar", although we would not recommend it. Such an unfortunate choice would confuse the users of your fonts as well as make it harder for other designers to contribute. - -5.5 So what should I, as an author, identify as Reserved Font Names? -Original authors are encouraged to name their fonts using clear, distinct names, and only declare the unique parts of the name as Reserved Font Names. For example, the author of a font called "Foobar Sans" would declare "Foobar" as a Reserved Font Name, but not "Sans", as that is a common typographical term, and may be a useful word to use in a derivative font name. Reserved Font Names should also be single words for simplicity and legibility. A font called "Flowing River" should have Reserved Font Names "Flowing" and "River", not "Flowing River". You also need to be very careful about reserving font names which are already linked to trademarks (whether registered or not) which you do not own. - -5.6 Do I, as an author, have to identify any Reserved Font Names? -No. RFNs are optional and not required, but we encourage you to use them. This is primarily to avoid confusion between your work and Modified Versions. As an author you can release a font under the OFL and not declare any Reserved Font Names. There may be situations where you find that using no RFNs and letting your font be changed and modified - including any kind of modification - without having to change the original name is desirable. However you need to be fully aware of the consequences. There will be no direct way for end-users and other designers to distinguish your Original Version from many Modified Versions that may be created. You have to trust whoever is making the changes and the optimizations to not introduce problematic changes. The RFNs you choose for your own creation have value to you as an author because they allow you to maintain artistic integrity and keep some control over the distribution channel to your end-users. For discussion of RFNs and web fonts see section 2. - -5.7 Are any names (such as the main font name) reserved by default? -No. That is a change to the license as of version 1.1. If you want any names to be Reserved Font Names, they must be specified after the copyright statement(s). - -5.8 Is there any situation in which I can use Reserved Font Names for a Modified Version? -The Copyright Holder(s) can give certain trusted parties the right to use any of the Reserved Font Names through separate written agreements. For example, even if "Foobar" is a RFN, you could write up an agreement to give company "XYZ" the right to distribute a modified version with a name that includes "Foobar". This allows for freedom without confusion. The existence of such an agreement should be made as clear as possible to downstream users and designers in the distribution package and the relevant documentation. They need to know if they are a party to the agreement or not and what they are practically allowed to do or not even if all the details of the agreement are not public. - -5.9 Do font rebuilds require a name change? Do I have to change the name of the font when my packaging workflow includes a full rebuild from source? -Yes, all rebuilds which change the font data and the smart code are Modified Versions and the requirements of the OFL apply: you need to respect what the Author(s) have chosen in terms of Reserved Font Names. However if a package (or installer) is simply a wrapper or a compressed structure around the final font - leaving them intact on the inside - then no name change is required. Please get in touch with the author(s) and copyright holder(s) to inquire about the presence of font sources beyond the final font file(s) and the recommended build path. That build path may very well be non-trivial and hard to reproduce accurately by the maintainer. If a full font build path is made available by the upstream author(s) please be aware that any regressions and changes you may introduce when doing a rebuild for packaging purposes is your own responsibility as a package maintainer since you are effectively creating a separate branch. You should make it very clear to your users that your rebuilt version is not the canonical one from upstream. - -5.10 Can I add other Reserved Font Names when making a derivative font? -Yes. List your additional Reserved Font Names after your additional copyright statement, as indicated with example placeholders at the top of the OFL.txt file. Be sure you do not remove any existing RFNs but only add your own. RFN statements should be placed next to the copyright statement of the relevant author as indicated in the OFL.txt template to make them visible to designers wishing to make their separate version. - - -6 ABOUT THE FONTLOG - -6.1 What is this FONTLOG thing exactly? -It has three purposes: 1) to provide basic information on the font to users and other designers and developers, 2) to document changes that have been made to the font or accompanying files, either by the original authors or others, and 3) to provide a place to acknowledge authors and other contributors. Please use it! - -6.2 Is the FONTLOG required? -It is not a requirement of the license, but we strongly recommend you have one. - -6.3 Am I required to update the FONTLOG when making Modified Versions? -No, but users, designers and other developers might get very frustrated with you if you don't. People need to know how derivative fonts differ from the original, and how to take advantage of the changes, or build on them. There are utilities that can help create and maintain a FONTLOG, such as the FONTLOG support in FontForge. - -6.4 What should the FONTLOG look like? -It is typically a separate text file (FONTLOG.txt), but can take other formats. It commonly includes these four sections: - -- brief header describing the FONTLOG itself and name of the font family -- Basic Font Information - description of the font family, purpose and breadth -- ChangeLog - chronological listing of changes -- Acknowledgements - list of authors and contributors with contact information - -It could also include other sections, such as: where to find documentation, how to make contributions, information on contributing organizations, source code details, and a short design guide. See Appendix A for an example FONTLOG. - - -7 MAKING CONTRIBUTIONS TO OFL PROJECTS - -7.1 Can I contribute work to OFL projects? -In many cases, yes. It is common for OFL fonts to be developed by a team of people who welcome contributions from the wider community. Contact the original authors for specific information on how to participate in their projects. - -7.2 Why should I contribute my changes back to the original authors? -It would benefit many people if you contributed back in response to what you've received. Your contributions and improvements to the fonts and other components could be a tremendous help and would encourage others to contribute as well and 'give back'. You will then benefit from other people's contributions as well. Sometimes maintaining your own separate version takes more effort than merging back with the original. Be aware that any contributions, however, must be either your own original creation or work that you own, and you may be asked to affirm that clearly when you contribute. - -7.3 I've made some very nice improvements to the font. Will you consider adopting them and putting them into future Original Versions? -Most authors would be very happy to receive such contributions. Keep in mind that it is unlikely that they would want to incorporate major changes that would require additional work on their end. Any contributions would likely need to be made for all the fonts in a family and match the overall design and style. Authors are encouraged to include a guide to the design with the fonts. It would also help to have contributions submitted as patches or clearly marked changes - the use of smart source revision control systems like subversion, mercurial, git or bzr is a good idea. Please follow the recommendations given by the author(s) in terms of preferred source formats and configuration parameters for sending contributions. If this is not indicated in a FONTLOG or other documentation of the font, consider asking them directly. Examples of useful contributions are bug fixes, additional glyphs, stylistic alternates (and the smart font code to access them) or improved hinting. Keep in mind that some kinds of changes (esp. hinting) may be technically difficult to integrate. - -7.4 How can I financially support the development of OFL fonts? -It is likely that most authors of OFL fonts would accept financial contributions - contact them for instructions on how to do this. Such contributions would support future development. You can also pay for others to enhance the fonts and contribute the results back to the original authors for inclusion in the Original Version. - - -8 ABOUT THE LICENSE ITSELF - -8.1 I see that this is version 1.1 of the license. Will there be later changes? -Version 1.1 is the first minor revision of the OFL. We are confident that version 1.1 will meet most needs, but are open to future improvements. Any revisions would be for future font releases, and previously existing licenses would remain in effect. No retroactive changes are possible, although the Copyright Holder(s) can re-release the font under a revised OFL. All versions will be available on our web site: http://scripts.sil.org/OFL. - -8.2 Does this license restrict the rights of the Copyright Holder(s)? -No. The Copyright Holder(s) still retain(s) all the rights to their creation; they are only releasing a portion of it for use in a specific way. For example, the Copyright Holder(s) may choose to release a 'basic' version of their font under the OFL, but sell a restricted 'enhanced' version under a different license. They may also choose to release the same font under both the OFL and some other license. Only the Copyright Holder(s) can do this, and doing so does not change the terms of the OFL as it applies to that font. - -8.3 Is the OFL a contract or a license? -The OFL is a worldwide license based on international copyright agreements and conventions. It is not a contract and so does not require you to sign it to have legal validity. By using, modifying and redistributing components under the OFL you indicate that you accept the license. - -8.4 I really like the terms of the OFL, but want to change it a little. Am I allowed to take ideas and actual wording from the OFL and put them into my own custom license for distributing my fonts? -We strongly recommend against creating your very own unique open licensing model. Using a modified or derivative license will likely cut you off - along with the font(s) under that license - from the community of designers using the OFL, potentially expose you and your users to legal liabilities, and possibly put your work and rights at risk. The OFL went though a community and legal review process that took years of effort, and that review is only applicable to an unmodified OFL. The text of the OFL has been written by SIL (with review and consultation from the community) and is copyright (c) 2005-2017 SIL International. You may re-use the ideas and wording (in part, not in whole) in another non-proprietary license provided that you call your license by another unambiguous name, that you do not use the preamble, that you do not mention SIL and that you clearly present your license as different from the OFL so as not to cause confusion by being too similar to the original. If you feel the OFL does not meet your needs for an open license, please contact us. - -8.5 Can I quote from the OFL FAQ? -Yes, SIL gives permission to quote from the OFL FAQ (OFL-FAQ.txt), in whole or in part, provided that the quoted text is: - -- unmodified, -- used to help explain the intent of the OFL, rather than cause misunderstanding, and -- accompanied with the following attribution: "From the OFL FAQ (OFL-FAQ.txt), copyright (c) 2005-2020 SIL International. Used by permission. http://scripts.sil.org/OFL-FAQ_web". - -8.6 Can I translate the license and the FAQ into other languages? -SIL certainly recognises the need for people who are not familiar with English to be able to understand the OFL and its use. Making the license very clear and readable has been a key goal for the OFL, but we know that people understand their own language best. - -If you are an experienced translator, you are very welcome to translate the OFL and OFL-FAQ so that designers and users in your language community can understand the license better. But only the original English version of the license has legal value and has been approved by the community. Translations do not count as legal substitutes and should only serve as a way to explain the original license. SIL - as the author and steward of the license for the community at large - does not approve any translation of the OFL as legally valid because even small translation ambiguities could be abused and create problems. - -SIL gives permission to publish unofficial translations into other languages provided that they comply with the following guidelines: - -- Put the following disclaimer in both English and the target language stating clearly that the translation is unofficial: - -"This is an unofficial translation of the SIL Open Font License into . It was not published by SIL International, and does not legally state the distribution terms for fonts that use the OFL. A release under the OFL is only valid when using the original English text. However, we recognize that this unofficial translation will help users and designers not familiar with English to better understand and use the OFL. We encourage designers who consider releasing their creation under the OFL to read the OFL-FAQ in their own language if it is available. Please go to http://scripts.sil.org/OFL for the official version of the license and the accompanying OFL-FAQ." - -- Keep your unofficial translation current and update it at our request if needed, for example if there is any ambiguity which could lead to confusion. - -If you start such a unofficial translation effort of the OFL and OFL-FAQ please let us know. - -8.7 Does the OFL have an explicit expiration term? -No, the implicit intent of the OFL is that the permissions granted are perpetual and irrevocable. - - -9 ABOUT SIL INTERNATIONAL - -9.1 Who is SIL International and what do they do? -SIL serves language communities worldwide, building their capacity for sustainable language development, by means of research, translation, training and materials development. SIL makes its services available to all without regard to religious belief, political ideology, gender, race, or ethnic background. SIL's members and volunteers share a Christian commitment. - -9.2 What does this have to do with font licensing? -The ability to read, write, type and publish in one's own language is one of the most critical needs for millions of people around the world. This requires fonts that are widely available and support lesser-known languages. SIL develops - and encourages others to develop - a complete stack of writing systems implementation components available under open licenses. This open stack includes input methods, smart fonts, smart rendering libraries and smart applications. There has been a need for a common open license that is specifically applicable to fonts and related software (a crucial component of this stack), so SIL developed the SIL Open Font License with the help of the Free/Libre and Open Source Software community. - -9.3 How can I contact SIL? -Our main web site is: http://www.sil.org/ -Our site about complex scripts is: http://scripts.sil.org/ -Information about this license (and contact information) is at: http://scripts.sil.org/OFL - - -APPENDIX A - FONTLOG EXAMPLE - -Here is an example of the recommended format for a FONTLOG, although other formats are allowed. - ------ -FONTLOG for the GlobalFontFamily fonts - -This file provides detailed information on the GlobalFontFamily Font Software. This information should be distributed along with the GlobalFontFamily fonts and any derivative works. - -Basic Font Information - -GlobalFontFamily is a Unicode typeface family that supports all languages that use the Latin script and its variants, and could be expanded to support other scripts. - -NewWorldFontFamily is based on the GlobalFontFamily and also supports Greek, Hebrew, Cyrillic and Armenian. - -More specifically, this release supports the following Unicode ranges... -This release contains... -Documentation can be found at... -To contribute to the project... - -ChangeLog - -10 December 2010 (Fred Foobar) GlobalFontFamily-devel version 1.4 -- fix new build and testing system (bug #123456) - -1 August 2008 (Tom Parker) GlobalFontFamily version 1.2.1 -- Tweaked the smart font code (Branch merged with trunk version) -- Provided improved build and debugging environment for smart behaviours - -7 February 2007 (Pat Johnson) NewWorldFontFamily Version 1.3 -- Added Greek and Cyrillic glyphs - -7 March 2006 (Fred Foobar) NewWorldFontFamily Version 1.2 -- Tweaked contextual behaviours - -1 Feb 2005 (Jane Doe) NewWorldFontFamily Version 1.1 -- Improved build script performance and verbosity -- Extended the smart code documentation -- Corrected minor typos in the documentation -- Fixed position of combining inverted breve below (U+032F) -- Added OpenType/Graphite smart code for Armenian -- Added Armenian glyphs (U+0531 -> U+0587) -- Released as "NewWorldFontFamily" - -1 Jan 2005 (Joe Smith) GlobalFontFamily Version 1.0 -- Initial release - -Acknowledgements - -If you make modifications be sure to add your name (N), email (E), web-address (if you have one) (W) and description (D). This list is in alphabetical order. - -N: Jane Doe -E: jane@university.edu -W: http://art.university.edu/projects/fonts -D: Contributor - Armenian glyphs and code - -N: Fred Foobar -E: fred@foobar.org -W: http://foobar.org -D: Contributor - misc Graphite fixes - -N: Pat Johnson -E: pat@fontstudio.org -W: http://pat.fontstudio.org -D: Designer - Greek & Cyrillic glyphs based on Roman design - -N: Tom Parker -E: tom@company.com -W: http://www.company.com/tom/projects/fonts -D: Engineer - original smart font code - -N: Joe Smith -E: joe@fontstudio.org -W: http://joe.fontstudio.org -D: Designer - original Roman glyphs - -Fontstudio.org is an not-for-profit design group whose purpose is... -Foobar.org is a distributed community of developers... -Company.com is a small business who likes to support community designers... -University.edu is a renowned educational institution with a strong design department... ------ \ No newline at end of file diff --git a/src-tauri/src/commands/fs_util.rs b/src-tauri/src/commands/fs_util.rs index 2bf6a29..ec9be2d 100644 --- a/src-tauri/src/commands/fs_util.rs +++ b/src-tauri/src/commands/fs_util.rs @@ -3,6 +3,8 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::path::{Component, Path, PathBuf}; +use crate::ffmpeg; + fn normalize_path(path: &str) -> PathBuf { Path::new(path) .components() @@ -16,6 +18,15 @@ fn is_allowed_local_media(path: &Path) -> bool { if lower.contains("aiclient-voice-preview-cache") || lower.contains("aiclient-node-pipeline") || lower.contains("aiclient-generated-speech") + || lower.contains("aiclient-runninghub") + || lower.contains("aiclient-digital-human") + || lower.contains("generated_audios") + || lower.contains("generated_videos") + || lower.contains("aiclient-video-edit") + || lower.contains("aiclient-subtitle-bgm") + || lower.contains("aiclient-cover") + || lower.contains("aiclient-cover-previews") + || lower.contains("avatars") { return true; } @@ -45,3 +56,21 @@ pub async fn read_local_file_base64(path: String) -> Result { .map_err(|e| format!("读取文件失败: {e}"))?; Ok(STANDARD.encode(bytes)) } + +/// 获取本地音视频时长(秒),供口播视频云端任务对齐音频长度。 +#[tauri::command] +pub async fn get_media_duration_seconds(path: String) -> Result { + let normalized = normalize_path(path.trim()); + if normalized.as_os_str().is_empty() { + return Err("路径为空".into()); + } + if !normalized.is_file() { + return Err(format!("文件不存在: {}", normalized.display())); + } + if !is_allowed_local_media(&normalized) { + return Err("不允许读取该路径下的文件".into()); + } + ffmpeg::probe_media_duration(&normalized) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bea2016..cf4ad8f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,4 +6,5 @@ pub mod avatar; pub mod video; pub mod fs_util; pub mod nodejs; +pub mod publish; pub mod quickjs; diff --git a/src-tauri/src/commands/nodejs.rs b/src-tauri/src/commands/nodejs.rs index 10a204b..d735433 100644 --- a/src-tauri/src/commands/nodejs.rs +++ b/src-tauri/src/commands/nodejs.rs @@ -63,7 +63,7 @@ fn log_node_event(script_name: &str, level: &str, msg: &str, fields: &Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishCheckLoginParams { + pub platform: String, + pub account_id: Option, + #[serde(default)] + pub check_browser: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishPlatformItem { + pub platform: String, + pub account_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishExecuteParams { + pub platforms: Vec, + pub video_path: String, + pub cover_path: String, + pub title: String, + pub description: Option, + pub tags: Option>, + #[serde(default)] + pub auto_publish: bool, +} + +async fn run_publish_script( + app: AppHandle, + window: Window, + script_name: &str, + params: Value, +) -> Result { + let app_config = app.state::(); + let config_env = app_config.env_for_node().await; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let pump = crate::commands::nodejs::spawn_event_pump( + app.clone(), + window, + script_name.to_string(), + rx, + ); + let result = crate::nodejs::run_node_script( + script_name.to_string(), + params, + tx, + config_env, + ) + .await + .map_err(|e| e.to_string())?; + pump.await.ok(); + Ok(result) +} + +#[tauri::command] +pub async fn publish_list_accounts( + store: State<'_, PublishStore>, + platform: Option, +) -> Result, String> { + store.list(platform).await +} + +#[tauri::command] +pub async fn publish_delete_account( + store: State<'_, PublishStore>, + account_id: i64, +) -> Result<(), String> { + store.delete(account_id).await +} + +#[tauri::command] +pub async fn publish_login( + app: AppHandle, + window: Window, + store: State<'_, PublishStore>, + app_config: State<'_, AppConfig>, + params: PublishLoginParams, +) -> Result { + let _ = app_config; + let result = run_publish_script( + app, + window, + "publish_login.js", + json!({ + "platform": params.platform, + "accountId": params.account_id, + }), + ) + .await?; + + if result.get("success").and_then(|v| v.as_bool()) == Some(true) { + let nickname = result + .get("nickname") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let uid = result + .get("uid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let cookies = result + .get("cookies") + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())) + .unwrap_or_else(|| "[]".to_string()); + let rec = store + .upsert_login( + params.platform.clone(), + nickname, + uid, + cookies, + params.account_id, + ) + .await?; + return Ok(json!({ + "success": true, + "message": result.get("message").and_then(|v| v.as_str()).unwrap_or("登录成功"), + "account": rec, + })); + } + + Ok(result) +} + +#[tauri::command] +pub async fn publish_check_login( + app: AppHandle, + window: Window, + store: State<'_, PublishStore>, + params: PublishCheckLoginParams, +) -> Result { + if !params.check_browser { + let accounts = store.list(Some(params.platform.clone())).await?; + let account = if let Some(id) = params.account_id { + accounts.into_iter().find(|a| a.id == id) + } else { + accounts + .into_iter() + .find(|a| a.login_status == "active" && a.has_cookies) + }; + if let Some(acc) = account { + return Ok(json!({ + "success": true, + "isLoggedIn": acc.login_status == "active" && acc.has_cookies, + "userInfo": { "nickname": acc.nickname, "userId": acc.uid }, + })); + } + return Ok(json!({ + "success": true, + "isLoggedIn": false, + })); + } + + let mut cookies = json!([]); + if let Some(id) = params.account_id { + if let Some((_, c)) = store.get_with_cookies(id).await? { + cookies = serde_json::from_str(&c).unwrap_or(json!([])); + } + } else if let Ok(list) = store.list(Some(params.platform.clone())).await { + if let Some(active) = list + .iter() + .find(|a| a.login_status == "active" && a.has_cookies) + { + if let Some((_, c)) = store.get_with_cookies(active.id).await? { + cookies = serde_json::from_str(&c).unwrap_or(json!([])); + } + } + } + + let result = run_publish_script( + app, + window, + "publish_check_login.js", + json!({ + "platform": params.platform, + "accountId": params.account_id, + "cookies": cookies, + }), + ) + .await?; + + if result.get("isLoggedIn").and_then(|v| v.as_bool()) == Some(true) { + if let (Some(nickname), Some(account_id)) = ( + result + .get("userInfo") + .and_then(|u| u.get("nickname")) + .and_then(|v| v.as_str()), + params.account_id, + ) { + let _ = store.set_login_status(account_id, "active".to_string()).await; + if let Ok(Some((mut rec, _))) = store.get_with_cookies(account_id).await { + if !nickname.is_empty() { + rec.nickname = nickname.to_string(); + } + } + } + } else if let Some(account_id) = params.account_id { + let _ = store.set_login_status(account_id, "inactive".to_string()).await; + } + + Ok(result) +} + +#[tauri::command] +pub async fn publish_execute( + app: AppHandle, + window: Window, + store: State<'_, PublishStore>, + params: PublishExecuteParams, +) -> Result { + let mut platform_payloads = Vec::new(); + for item in ¶ms.platforms { + let account_id = item.account_id; + let (rec, cookies) = if let Some(id) = account_id { + store + .get_with_cookies(id) + .await? + .ok_or_else(|| format!("未找到账号 id={id}"))? + } else { + let list = store.list(Some(item.platform.clone())).await?; + let active = list + .into_iter() + .find(|a| a.login_status == "active" && a.has_cookies) + .ok_or_else(|| format!("平台 {} 无已登录账号", item.platform))?; + let full = store + .get_with_cookies(active.id) + .await? + .ok_or_else(|| "读取账号失败".to_string())?; + full + }; + if rec.login_status != "active" { + return Err(format!("账号 {} 未登录", rec.nickname)); + } + platform_payloads.push(json!({ + "platform": item.platform, + "accountId": rec.id, + "cookies": serde_json::from_str::(&cookies).unwrap_or(json!([])), + "nickname": rec.nickname, + })); + } + + run_publish_script( + app, + window, + "video_publish.js", + json!({ + "platforms": platform_payloads, + "videoPath": params.video_path, + "coverPath": params.cover_path, + "title": params.title, + "description": params.description.unwrap_or_default(), + "tags": params.tags.unwrap_or_default(), + "autoPublish": params.auto_publish, + }), + ) + .await +} diff --git a/src-tauri/src/cover_python.rs b/src-tauri/src/cover_python.rs new file mode 100644 index 0000000..05829c9 --- /dev/null +++ b/src-tauri/src/cover_python.rs @@ -0,0 +1,98 @@ +//! 封面 Python 运行时与脚本目录定位(对齐 Electron bundled python-runtime) + +use std::path::{Path, PathBuf}; + +fn exe_name() -> &'static str { + if cfg!(windows) { + "python.exe" + } else { + "python3" + } +} + +/// bundled `python-runtimebackup` 或系统 / 环境变量 Python。 +pub fn locate_python() -> Option { + if let Ok(p) = std::env::var("AICLIENT_PYTHON_PATH") { + let pb = PathBuf::from(&p); + if pb.is_file() { + return Some(pb); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let bundled = dir + .join("resources") + .join("resources-bundles") + .join("python-runtimebackup") + .join(exe_name()); + if bundled.exists() { + return Some(bundled); + } + let cover_only = dir.join("resources").join("cover-python").join(exe_name()); + if cover_only.exists() { + return Some(cover_only); + } + } + } + let dev_bundled = PathBuf::from("src-tauri/resources/resources-bundles/python-runtimebackup") + .join(exe_name()); + if dev_bundled.exists() { + return Some(dev_bundled); + } + let dev_cover = PathBuf::from("src-tauri/resources/cover-python").join(exe_name()); + if dev_cover.exists() { + return Some(dev_cover); + } + None +} + +/// 封面脚本目录(`advanced_cover_generator.py` 等)。 +pub fn locate_cover_scripts_dir() -> Option { + if let Ok(p) = std::env::var("AICLIENT_COVER_SCRIPTS_DIR") { + let pb = PathBuf::from(&p); + if pb.join("advanced_cover_generator.py").is_file() { + return Some(pb); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let bundled = dir.join("resources").join("cover-python"); + if bundled.join("advanced_cover_generator.py").is_file() { + return Some(bundled); + } + } + } + let dev = PathBuf::from("src-tauri/resources/cover-python"); + if dev.join("advanced_cover_generator.py").is_file() { + return Some(dev); + } + let backend = PathBuf::from("../pythonbackend/scripts/cover"); + if backend.join("advanced_cover_generator.py").is_file() { + return backend.canonicalize().ok(); + } + None +} + +/// 将 ffmpeg 所在目录 prepend 到 PATH(供 Python 子进程调用 ffmpeg)。 +pub fn prepend_ffmpeg_to_path(env_path: &str) -> String { + let sep = if cfg!(windows) { ";" } else { ":" }; + if let Some(ffmpeg) = crate::ffmpeg::locate_ffmpeg() { + if let Some(dir) = ffmpeg.parent() { + return format!("{}{}{}", dir.display(), sep, env_path); + } + } + env_path.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cover_scripts_dev_path_exists() { + let dev = Path::new("src-tauri/resources/cover-python/advanced_cover_generator.py"); + if dev.is_file() { + assert!(locate_cover_scripts_dir().is_some()); + } + } +} diff --git a/src-tauri/src/ffmpeg.rs b/src-tauri/src/ffmpeg.rs index 2fcb375..ee40a3c 100644 --- a/src-tauri/src/ffmpeg.rs +++ b/src-tauri/src/ffmpeg.rs @@ -26,6 +26,57 @@ fn exe_name() -> &'static str { if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" } } +fn ffprobe_exe_name() -> &'static str { + if cfg!(windows) { + "ffprobe.exe" + } else { + "ffprobe" + } +} + +/// 与 `locate_ffmpeg` 相同目录策略,优先 bundled `binaries/ffprobe`。 +pub fn locate_ffprobe() -> Option { + if let Ok(p) = std::env::var("AICLIENT_FFPROBE_PATH") { + let pb = PathBuf::from(p); + if pb.exists() { + return Some(pb); + } + } + if let Some(ffmpeg) = locate_ffmpeg() { + if let Some(dir) = ffmpeg.parent() { + let sibling = dir.join(ffprobe_exe_name()); + if sibling.exists() { + return Some(sibling); + } + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + for candidate in [ + dir.join("binaries").join(ffprobe_exe_name()), + dir.join(ffprobe_exe_name()), + dir.join("..") + .join("..") + .join("binaries") + .join(ffprobe_exe_name()), + ] { + if candidate.exists() { + return Some(candidate); + } + } + } + } + for dev in [ + PathBuf::from("src-tauri/binaries").join(ffprobe_exe_name()), + PathBuf::from("binaries").join(ffprobe_exe_name()), + ] { + if dev.exists() { + return Some(dev); + } + } + Some(PathBuf::from(ffprobe_exe_name())) +} + pub fn locate_ffmpeg() -> Option { if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") { let pb = PathBuf::from(p); @@ -86,3 +137,62 @@ pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), Ffm } Ok(()) } + +fn parse_duration_hms(text: &str) -> Option { + let marker = "Duration:"; + let idx = text.find(marker)?; + let rest = text[idx + marker.len()..].trim_start(); + let time_part = rest.split(',').next()?.trim(); + let mut parts = time_part.split(':'); + let hours: f64 = parts.next()?.parse().ok()?; + let minutes: f64 = parts.next()?.parse().ok()?; + let seconds: f64 = parts.next()?.parse().ok()?; + Some(hours * 3600.0 + minutes * 60.0 + seconds) +} + +/// 读取媒体时长(秒),优先 ffprobe,回退解析 `ffmpeg -i` 的 stderr。 +pub async fn probe_media_duration(path: &Path) -> Result { + if !path.is_file() { + return Err(FfmpegError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("文件不存在: {}", path.display()), + ))); + } + + if let Some(ffprobe) = locate_ffprobe() { + let output = Command::new(&ffprobe) + .arg("-v") + .arg("error") + .arg("-show_entries") + .arg("format=duration") + .arg("-of") + .arg("default=noprint_wrappers=1:nokey=1") + .arg(path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Ok(secs) = stdout.trim().parse::() { + if secs > 0.0 { + return Ok(secs); + } + } + } + } + + let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?; + let output = Command::new(&ffmpeg) + .arg("-i") + .arg(path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await?; + let stderr = String::from_utf8_lossy(&output.stderr); + parse_duration_hms(&stderr).ok_or_else(|| FfmpegError::Failed { + status: output.status.code(), + stderr: format!("无法解析媒体时长: {}", stderr.chars().take(400).collect::()), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8603c0e..25e8bb2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,11 +12,14 @@ pub mod asr; pub mod auth_session; pub mod chat; pub mod commands; +pub mod cover_python; pub mod ffmpeg; pub mod http; pub mod js_runtime; pub mod nodejs; pub mod oss; +pub mod publish_db; +pub mod publish_store; use tauri::Manager; @@ -30,6 +33,7 @@ pub fn run() { .manage(avatar_store::AvatarStore::new()) .manage(audio_store::AudioStore::new()) .manage(video_store::VideoStore::new()) + .manage(publish_store::PublishStore::new()) .setup(|app| { let handle = app.handle().clone(); tauri::async_runtime::block_on(async move { @@ -37,6 +41,7 @@ pub fn run() { let avatar_store = handle.state::(); let audio_store = handle.state::(); let video_store = handle.state::(); + let publish_store = handle.state::(); let dir = handle .path() .app_data_dir() @@ -46,6 +51,7 @@ pub fn run() { avatar_store.init(dir.join("avatars.db")).await?; audio_store.init(dir.join("generated_audios.db")).await?; video_store.init(dir.join("generated_videos.db")).await?; + publish_store.init(dir.join("publish_accounts.db")).await?; Ok::<(), String>(()) }) .map_err(|e| -> Box { e.into() })?; @@ -77,6 +83,7 @@ pub fn run() { commands::nodejs::run_nodejs_script_source, commands::nodejs::list_nodejs_scripts, commands::fs_util::read_local_file_base64, + commands::fs_util::get_media_duration_seconds, commands::avatar::list_avatars, commands::avatar::insert_avatar, commands::avatar::update_avatar_name, @@ -90,7 +97,19 @@ pub fn run() { commands::video::insert_generated_video, commands::video::import_generated_video, commands::video::delete_generated_video, + commands::publish::publish_list_accounts, + commands::publish::publish_login, + commands::publish::publish_check_login, + commands::publish::publish_execute, + commands::publish::publish_delete_account, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + if let tauri::RunEvent::Ready = event { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.maximize(); + } + } + }); } diff --git a/src-tauri/src/nodejs.rs b/src-tauri/src/nodejs.rs index 372f04e..cd03297 100644 --- a/src-tauri/src/nodejs.rs +++ b/src-tauri/src/nodejs.rs @@ -369,6 +369,12 @@ async fn run_script_bundle( cmd.env("AICLIENT_FFMPEG_PATH", ffmpeg_path); } } + if let Some(py) = crate::cover_python::locate_python() { + cmd.env("AICLIENT_PYTHON_PATH", py); + } + if let Some(dir) = crate::cover_python::locate_cover_scripts_dir() { + cmd.env("AICLIENT_COVER_SCRIPTS_DIR", dir); + } let mut child = cmd.spawn()?; // 把 params JSON 灌进 stdin diff --git a/src-tauri/src/publish_db.rs b/src-tauri/src/publish_db.rs new file mode 100644 index 0000000..8464d84 --- /dev/null +++ b/src-tauri/src/publish_db.rs @@ -0,0 +1,193 @@ +//! 平台发布账号 SQLite(对齐 Electron platform_accounts) + +use std::path::PathBuf; +use std::sync::Mutex; + +use rusqlite::{params, Connection}; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PublishDbError { + #[error("{0}")] + Db(#[from] rusqlite::Error), + #[error("{0}")] + Message(String), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformAccountRecord { + pub id: i64, + pub platform: String, + pub nickname: String, + pub uid: String, + pub avatar: String, + pub login_status: String, + pub has_cookies: bool, + pub updated_at: i64, +} + +#[derive(Debug)] +pub struct PublishDb { + conn: Mutex, +} + +impl PublishDb { + pub fn open(path: PathBuf) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| PublishDbError::Message(format!("创建目录失败: {e}")))?; + } + let conn = Connection::open(path)?; + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS platform_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + nickname TEXT NOT NULL DEFAULT '', + uid TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '', + cookies TEXT NOT NULL DEFAULT '[]', + tokens TEXT NOT NULL DEFAULT '', + login_status TEXT NOT NULL DEFAULT 'inactive', + expires_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_platform_accounts_platform + ON platform_accounts(platform); + "#, + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn list(&self, platform: Option<&str>) -> Result, PublishDbError> { + let conn = self.conn.lock().map_err(|_| { + PublishDbError::Message("数据库锁异常".into()) + })?; + let (sql, use_platform) = match platform { + Some(_) => ( + "SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at + FROM platform_accounts WHERE platform = ?1 ORDER BY updated_at DESC", + true, + ), + None => ( + "SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at + FROM platform_accounts ORDER BY platform ASC, updated_at DESC", + false, + ), + }; + let mut stmt = conn.prepare(sql)?; + let map_row = |row: &rusqlite::Row<'_>| { + let cookies: String = row.get(5)?; + Ok(PlatformAccountRecord { + id: row.get(0)?, + platform: row.get(1)?, + nickname: row.get(2)?, + uid: row.get(3)?, + avatar: row.get(4)?, + login_status: row.get(6)?, + has_cookies: cookies.len() > 4, + updated_at: row.get(7)?, + }) + }; + let rows = if use_platform { + stmt.query_map(params![platform.unwrap()], map_row)? + } else { + stmt.query_map([], map_row)? + }; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn get(&self, id: i64) -> Result, PublishDbError> { + let conn = self.conn.lock().map_err(|_| { + PublishDbError::Message("数据库锁异常".into()) + })?; + let mut stmt = conn.prepare( + "SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at + FROM platform_accounts WHERE id = ?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(row) = rows.next()? { + let cookies: String = row.get(5)?; + let rec = PlatformAccountRecord { + id: row.get(0)?, + platform: row.get(1)?, + nickname: row.get(2)?, + uid: row.get(3)?, + avatar: row.get(4)?, + login_status: row.get(6)?, + has_cookies: cookies.len() > 4, + updated_at: row.get(7)?, + }; + return Ok(Some((rec, cookies))); + } + Ok(None) + } + + pub fn upsert_login( + &self, + platform: &str, + nickname: &str, + uid: &str, + cookies_json: &str, + account_id: Option, + ) -> Result { + let conn = self.conn.lock().map_err(|_| { + PublishDbError::Message("数据库锁异常".into()) + })?; + let now = chrono::Utc::now().timestamp_millis(); + let expires = now + 30_i64 * 24 * 60 * 60 * 1000; + + if let Some(id) = account_id { + conn.execute( + "UPDATE platform_accounts SET nickname = ?1, uid = ?2, cookies = ?3, + login_status = 'active', expires_at = ?4, updated_at = ?5 WHERE id = ?6", + params![nickname, uid, cookies_json, expires, now, id], + )?; + return self + .get(id)? + .map(|(r, _)| r) + .ok_or_else(|| PublishDbError::Message("更新账号失败".into())); + } + + conn.execute( + "INSERT INTO platform_accounts (platform, nickname, uid, avatar, cookies, tokens, + login_status, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, '', ?4, '', 'active', ?5, ?6, ?6)", + params![platform, nickname, uid, cookies_json, expires, now], + )?; + let id = conn.last_insert_rowid(); + self.get(id)? + .map(|(r, _)| r) + .ok_or_else(|| PublishDbError::Message("插入账号失败".into())) + } + + pub fn set_login_status(&self, id: i64, status: &str) -> Result<(), PublishDbError> { + let conn = self.conn.lock().map_err(|_| { + PublishDbError::Message("数据库锁异常".into()) + })?; + let now = chrono::Utc::now().timestamp_millis(); + conn.execute( + "UPDATE platform_accounts SET login_status = ?1, updated_at = ?2 WHERE id = ?3", + params![status, now, id], + )?; + Ok(()) + } + + pub fn delete(&self, id: i64) -> Result<(), PublishDbError> { + let conn = self.conn.lock().map_err(|_| { + PublishDbError::Message("数据库锁异常".into()) + })?; + conn.execute("DELETE FROM platform_accounts WHERE id = ?1", params![id])?; + Ok(()) + } +} diff --git a/src-tauri/src/publish_store.rs b/src-tauri/src/publish_store.rs new file mode 100644 index 0000000..2087619 --- /dev/null +++ b/src-tauri/src/publish_store.rs @@ -0,0 +1,92 @@ +//! 发布账号 Tauri 状态 + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::publish_db::{PlatformAccountRecord, PublishDb}; + +#[derive(Debug, Default)] +pub struct PublishStore { + inner: Arc>>>, +} + +impl PublishStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn init(&self, db_path: PathBuf) -> Result<(), String> { + let db = tokio::task::spawn_blocking(move || { + PublishDb::open(db_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + *self.inner.write().await = Some(Arc::new(db)); + Ok(()) + } + + async fn with_db(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + Send + 'static, + T: Send + 'static, + { + let db = self + .inner + .read() + .await + .clone() + .ok_or_else(|| "发布账号数据库未初始化".to_string())?; + tokio::task::spawn_blocking(move || f(db)) + .await + .map_err(|e| e.to_string())? + } + + pub async fn list(&self, platform: Option) -> Result, String> { + self.with_db(move |db| { + db.list(platform.as_deref()) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn get_with_cookies( + &self, + id: i64, + ) -> Result, String> { + self.with_db(move |db| db.get(id).map_err(|e| e.to_string())) + .await + } + + pub async fn upsert_login( + &self, + platform: String, + nickname: String, + uid: String, + cookies_json: String, + account_id: Option, + ) -> Result { + self.with_db(move |db| { + db.upsert_login( + &platform, + &nickname, + &uid, + &cookies_json, + account_id, + ) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn set_login_status(&self, id: i64, status: String) -> Result<(), String> { + self.with_db(move |db| db.set_login_status(id, &status).map_err(|e| e.to_string())) + .await + } + + pub async fn delete(&self, id: i64) -> Result<(), String> { + self.with_db(move |db| db.delete(id).map_err(|e| e.to_string())) + .await + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a808e3d..aa5881a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,6 +17,7 @@ "title": "aiclient", "width": 800, "height": 600, + "maximized": true, "decorations": false, "resizable": true } diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 47136ee..72929c2 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -24,6 +24,9 @@ const menuItems = computed(() => { if (auth.isAdmin) { items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" }); } + if ( auth.isOEM) { + items.push({ name: "admin", label: "管理", to: "/oem", icon: "admin" }); + } if (auth.isAgent) { items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" }); } diff --git a/src/components/oem/OemSubNav.vue b/src/components/oem/OemSubNav.vue new file mode 100644 index 0000000..f4b096f --- /dev/null +++ b/src/components/oem/OemSubNav.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/workflow/Step03VideoEdit.vue b/src/components/workflow/Step03VideoEdit.vue index 3ca569f..dbc12e5 100644 --- a/src/components/workflow/Step03VideoEdit.vue +++ b/src/components/workflow/Step03VideoEdit.vue @@ -1,9 +1,57 @@ diff --git a/src/components/workflow/Step04TitleTags.vue b/src/components/workflow/Step04TitleTags.vue index 66a1e25..5ffded8 100644 --- a/src/components/workflow/Step04TitleTags.vue +++ b/src/components/workflow/Step04TitleTags.vue @@ -1,4 +1,5 @@