#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 字幕封面生成器 - 简化版本(不依赖spacy和rembg) Subtitle & Cover Generator - Simplified Version """ import os import sys import json import argparse import subprocess from typing import Dict, List, Any, Optional from pathlib import Path # ⚠️ 修复模块路径:确保 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) try: from modules.font_manager import get_font_manager FONT_MANAGER_AVAILABLE = True print("[PATH] font_manager loaded successfully", file=sys.stderr) except ImportError as e: FONT_MANAGER_AVAILABLE = False print(f"[PATH] Warning: font_manager module not available: {e}", file=sys.stderr) # 导入封面模板 try: from modules.cover_templates import get_template, get_all_templates COVER_TEMPLATES_AVAILABLE = True except ImportError: COVER_TEMPLATES_AVAILABLE = False print("Warning: cover_templates module not available", file=sys.stderr) # 导入基础库 try: import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance, ImageFont, ImageOps except ImportError as e: print(f"Error: Missing required library - {e}", file=sys.stderr) sys.exit(1) # 尝试导入 RMBG(人像抠图) try: from rembg import remove as rembg_remove REMBG_AVAILABLE = True print("RMBG library loaded successfully", file=sys.stderr) except ImportError: REMBG_AVAILABLE = False print("Warning: RMBG library not available, portrait extraction will be disabled", file=sys.stderr) # 尝试导入 Jieba(中文NLP - 轻量级) try: import jieba import jieba.analyse NLP_AVAILABLE = True print("Jieba NLP library loaded successfully", file=sys.stderr) except ImportError: NLP_AVAILABLE = False print("Warning: Jieba library not available, NLP features will be disabled", file=sys.stderr) # 尝试导入 Transformers(深度学习情感分析) try: from transformers import pipeline import torch TRANSFORMERS_AVAILABLE = True print("Transformers library loaded successfully", file=sys.stderr) # 检查是否支持FP16 FP16_AVAILABLE = torch.cuda.is_available() if FP16_AVAILABLE: print("CUDA available, FP16 optimization enabled", file=sys.stderr) else: print("CUDA not available, using CPU mode", file=sys.stderr) except ImportError: TRANSFORMERS_AVAILABLE = False FP16_AVAILABLE = False print("Warning: Transformers library not available, advanced sentiment analysis will be disabled", file=sys.stderr) # 尝试导入 FUNASR(语音识别) try: from funasr import AutoModel FUNASR_AVAILABLE = True print("FUNASR library loaded successfully", file=sys.stderr) except ImportError: FUNASR_AVAILABLE = False print("Warning: FUNASR library not available, speech recognition will be disabled", file=sys.stderr) class SubtitleCoverGenerator: """字幕封面生成器主类(简化版)""" def __init__(self, config: Dict[str, Any]): """ 初始化生成器 Args: config: 配置字典,包含视频路径、文案、样式等 """ self.config = config self.video_path = config.get('videoPath') self.script_text = config.get('scriptText', '') self.subtitle_style = config.get('subtitleStyle', 'default') self.cover_style = config.get('coverStyle', 'default') self.output_dir = config.get('outputDir', './output') # 新增:关键词分组和效果配置 self.keyword_groups = config.get('keywordGroups', []) # 关键词分组列表 self.keyword_markers = config.get('keywordMarkers', []) # 关键词标记 print(f"[SubtitleGenerator] Loaded {len(self.keyword_groups)} keyword groups and {len(self.keyword_markers)} keyword markers", file=sys.stderr) # ✅ 详细日志:显示关键词分组信息 if self.keyword_groups: print(f"[SubtitleGenerator] === Keyword Groups Details ===", file=sys.stderr) for idx, group in enumerate(self.keyword_groups): style_override = group.get('styleOverride', {}) print(f"[SubtitleGenerator] Group {idx}: id={group.get('id')}", file=sys.stderr) print(f" name={group.get('name')}", file=sys.stderr) print(f" effectId={group.get('effectId')}", file=sys.stderr) print(f" styleOverride={style_override}", file=sys.stderr) print(f" fontColor={style_override.get('fontColor')}", file=sys.stderr) print(f" outlineColor={style_override.get('outlineColor')}", file=sys.stderr) print(f" fontSize={style_override.get('fontSize')}", file=sys.stderr) else: print("[SubtitleGenerator] WARNING: No keyword groups received!", file=sys.stderr) # ✅ 详细日志:显示关键词标记信息 if self.keyword_markers: for marker in self.keyword_markers: print(f"[SubtitleGenerator] Keyword Marker: text={marker.get('text')}, groupId={marker.get('groupId')}", file=sys.stderr) # 新增:字体配置(从subtitleStyle中提取) subtitle_style = config.get('subtitleStyle', {}) self.subtitle_font_name = subtitle_style.get('fontName', 'NotoSerifCJK-VF') # 字体名称 self.subtitle_font_size = subtitle_style.get('fontSize', 24) # 字体大小 self.subtitle_font_color = subtitle_style.get('fontColor', '#FFFFFF') # 字体颜色 print(f"Subtitle font config: {self.subtitle_font_name}, {self.subtitle_font_size}px, {self.subtitle_font_color}", file=sys.stderr) # 获取用户配置的文件存储路径,如果没有则使用默认输出目录 self.hub_root = config.get('hubRoot', None) if self.hub_root: # 确保hub_root路径存在 os.makedirs(self.hub_root, exist_ok=True) # 使用hub_root作为输出目录 self.output_dir = self.hub_root else: # 没有hub_root配置,使用提供的outputDir或默认值 self.output_dir = config.get('outputDir', './output') # 确保输出目录存在 os.makedirs(self.output_dir, exist_ok=True) # 初始化 ziti 字体目录路径(用于 FFmpeg fontconfig) self.ziti_dir = self._find_ziti_directory() print(f"[SubtitleGenerator] Ziti font directory: {self.ziti_dir}", file=sys.stderr) self.use_funasr = config.get('useFunasr', False) # 是否使用FUNASR self.funasr_result = config.get('funasrResult', None) # FUNASR识别结果(可选) self.cover_only = config.get('coverOnly', False) # 是否仅生成封面 self.use_extracted_frame = config.get('useExtractedFrame', False) # 是否使用提取的帧 self.extracted_frame_path = config.get('extractedFramePath', None) # 提取的帧路径 # 封面参数配置 self.cover_text = config.get('coverText', '') # 封面文字 self.cover_text_position = config.get('coverTextPosition', 'bottom') # 文字位置 self.cover_text_color = config.get('coverTextColor', '#FFFFFF') # 文字颜色 self.cover_text_size = config.get('coverTextSize', 50) # 文字大小 self.outline_color = config.get('outlineColor', '#FFD700') # 描边颜色 self.outline_width = config.get('outlineWidth', 3) # 描边宽度 self.blur_radius = config.get('blurRadius', 15) # 背景模糊度 self.zoom_level = config.get('zoomLevel', 1.0) # 封面放大倍数 # 自定义模板配置 self.custom_template = config.get('customTemplate', False) # 是否使用自定义模板 self.template = config.get('template', None) # 自定义模板对象 # 初始化情感分析模型(延迟加载) self.sentiment_analyzer = None if TRANSFORMERS_AVAILABLE: try: print("Loading sentiment analysis model...", file=sys.stderr) # 使用中文情感分析模型,支持FP16优化 device = 0 if FP16_AVAILABLE else -1 # 0 = GPU, -1 = CPU torch_dtype = torch.float16 if FP16_AVAILABLE else torch.float32 self.sentiment_analyzer = pipeline( 'sentiment-analysis', model='uer/roberta-base-finetuned-jd-binary-chinese', # 中文情感分析模型 device=device, torch_dtype=torch_dtype ) print(f"Sentiment analysis model loaded (device={device}, dtype={torch_dtype})", file=sys.stderr) except Exception as e: print(f"Failed to load sentiment analyzer: {e}", file=sys.stderr) self.sentiment_analyzer = None # 初始化FUNASR模型(延迟加载) self.funasr_model = None if FUNASR_AVAILABLE and self.use_funasr: try: print("Loading FUNASR model...", file=sys.stderr) self.funasr_model = AutoModel( model="paraformer-zh", # 中文语音识别模型 model_revision="v2.0.4", vad_model="fsmn-vad", vad_model_revision="v2.0.4", punc_model="ct-punc-c", punc_model_revision="v2.0.4", ) print("FUNASR model loaded successfully", file=sys.stderr) except Exception as e: print(f"Failed to load FUNASR model: {e}", file=sys.stderr) self.funasr_model = None def _find_ziti_directory(self) -> Optional[str]: """ 查找 ziti 字体目录的绝对路径(支持开发环境和打包环境) Returns: ziti 目录路径,如果未找到返回 None """ # 可能的 ziti 目录路径(按优先级排列) possible_paths = [] # 1. 从 APP_ROOT 环境变量查找(打包环境) app_root = os.environ.get('APP_ROOT', None) if app_root: # 打包后的结构: resources/app.asar.unpacked/ziti possible_paths.append(os.path.join(app_root, 'app.asar.unpacked', 'ziti')) # 备选结构: resources/extra/common/fonts/ziti possible_paths.append(os.path.join(app_root, 'extra', 'common', 'fonts', 'ziti')) # 直接在 app_root 下 possible_paths.append(os.path.join(app_root, 'ziti')) # 2. 相对于脚本位置查找(开发环境) script_dir = os.path.dirname(os.path.abspath(__file__)) possible_paths.append(os.path.join(script_dir, '..', 'ziti')) # python/../ziti possible_paths.append(os.path.join(script_dir, '..', '..', 'ziti')) # python/../../ziti # 3. 当前工作目录 possible_paths.append(os.path.join(os.getcwd(), 'ziti')) # 4. 用户主目录 possible_paths.append(os.path.expanduser('~/ziti')) # 遍历查找存在的目录 for path in possible_paths: normalized_path = os.path.normpath(os.path.abspath(path)) if os.path.isdir(normalized_path): # 检查目录中是否有字体文件 try: fonts = [f for f in os.listdir(normalized_path) if f.endswith(('.ttf', '.otf', '.ttc'))] if fonts: print(f"[_find_ziti_directory] ✅ Found ziti directory with {len(fonts)} fonts: {normalized_path}", file=sys.stderr) return normalized_path except Exception as e: print(f"[_find_ziti_directory] ⚠ Error scanning {normalized_path}: {e}", file=sys.stderr) print(f"[_find_ziti_directory] ❌ ziti directory not found, searched: {possible_paths[:3]}...", file=sys.stderr) return None def _get_ffmpeg_fontconfig(self) -> str: """ 生成 FFmpeg fontconfig 参数字符串 Returns: FFmpeg fontconfig 参数,如 ":fontconfig='fontdir=/path/to/ziti:'" """ if self.ziti_dir and os.path.isdir(self.ziti_dir): # 将 Windows 路径转换为正斜杠格式(FFmpeg 需要) ziti_path = self.ziti_dir.replace('\\', '/') fontconfig = f":fontconfig='fontdir={ziti_path}:'" print(f"[_get_ffmpeg_fontconfig] Using ziti path: {ziti_path}", file=sys.stderr) return fontconfig else: # 降级:使用当前工作目录 print(f"[_get_ffmpeg_fontconfig] ⚠ Ziti directory not available, using fallback '.'", file=sys.stderr) return ":fontconfig='fontdir=.'" def extract_audio_for_asr(self) -> str: """ 从视频中提取音频为WAV格式,用于语音识别 Returns: 音频文件路径 """ try: audio_path = os.path.join(self.output_dir, 'extracted_audio.wav') # 使用FFmpeg提取音频 cmd = [ 'ffmpeg', '-y', '-i', self.video_path, '-vn', # 不要视频 '-acodec', 'pcm_s16le', # WAV格式 '-ar', '16000', # 采样率16kHz (FUNASR推荐) '-ac', '1', # 单声道 audio_path ] print(f"Extracting audio from video: {' '.join(cmd)}", file=sys.stderr) result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', timeout=300) if result.returncode == 0 and os.path.exists(audio_path): print(f"Audio extracted successfully: {audio_path}", file=sys.stderr) return audio_path else: raise Exception(f"FFmpeg failed: {result.stderr}") except subprocess.TimeoutExpired: raise Exception("Audio extraction timeout (exceeded 5 minutes)") except Exception as e: raise Exception(f"Error extracting audio: {str(e)}") def transcribe_audio_with_funasr(self, audio_path: str) -> Dict: """ 使用FUNASR进行语音识别 Args: audio_path: 音频文件路径 Returns: 识别结果字典,包含segments列表 """ if not FUNASR_AVAILABLE or self.funasr_model is None: raise Exception("FUNASR not available or model not loaded") try: print(f"Transcribing audio with FUNASR: {audio_path}", file=sys.stderr) # 调用FUNASR模型进行识别 result = self.funasr_model.generate( input=audio_path, batch_size_s=300, # 批处理大小 hotword='', # 热词 ) print(f"FUNASR transcription completed", file=sys.stderr) # 解析FUNASR结果 segments = [] if result and len(result) > 0: for i, item in enumerate(result): # FUNASR返回格式: {'text': '...', 'timestamp': [[start_ms, end_ms], ...]} text = item.get('text', '') timestamp = item.get('timestamp', []) # 如果有时间戳,使用时间戳 if timestamp and len(timestamp) > 0: for j, (start_ms, end_ms) in enumerate(timestamp): # 提取对应的文本片段 words = text.split() if j < len(words): segment_text = words[j] else: segment_text = text segments.append({ 'text': segment_text, 'start': start_ms / 1000.0, # 转换为秒 'end': end_ms / 1000.0 }) else: # 没有时间戳,按句子分割,平均分配时间 sentences = text.split('。') for k, sentence in enumerate(sentences): if sentence.strip(): segments.append({ 'text': sentence.strip() + '。', 'start': k * 3.0, 'end': (k + 1) * 3.0 }) print(f"Generated {len(segments)} segments from FUNASR", file=sys.stderr) return { 'success': True, 'segments': segments } except Exception as e: print(f"FUNASR transcription failed: {e}", file=sys.stderr) return { 'success': False, 'error': str(e), 'segments': [] } def extract_frame(self, timestamp: float = 1.0) -> tuple[np.ndarray, tuple[int, int]]: """ 从视频中提取关键帧 Args: timestamp: 提取帧的时间戳(秒) Returns: 提取的帧(numpy数组)和视频尺寸(宽度, 高度) """ try: cap = cv2.VideoCapture(self.video_path) # 获取视频尺寸 width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) video_size = (width, height) # 设置到指定时间戳 fps = cap.get(cv2.CAP_PROP_FPS) frame_number = int(timestamp * fps) cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) ret, frame = cap.read() cap.release() if not ret: raise Exception("Failed to extract frame from video") return frame, video_size except Exception as e: raise Exception(f"Error extracting frame: {str(e)}") def generate_cover(self) -> str: """ 生成视频封面 Returns: 生成的封面图片路径 """ try: # 如果使用提取的帧,直接使用已提取的帧 if self.use_extracted_frame and self.extracted_frame_path and os.path.exists(self.extracted_frame_path): print(f"Using extracted frame: {self.extracted_frame_path}", file=sys.stderr) frame = cv2.imread(self.extracted_frame_path) self.video_width, self.video_height = frame.shape[1], frame.shape[0] else: # 提取关键帧和视频尺寸 frame, video_size = self.extract_frame(1.0) self.video_width, self.video_height = video_size # 检查是否使用自定义模板 if self.custom_template and self.template: print("Using custom template for cover generation", file=sys.stderr) cover_path = self._generate_custom_template_cover(frame) return cover_path if self.cover_style == 'default': # 默认封面:直接使用提取的帧,保持原始尺寸 cover_path = os.path.join(self.output_dir, 'cover.png') cv2.imwrite(cover_path, frame) # 应用缩放和文字叠加 image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) image = self._apply_zoom(image) image = self._add_text_overlay(image) image.save(cover_path, quality=95) elif self.cover_style == 'blur-bg': # 背景虚化效果(简化版) cover_path = self._generate_blur_cover(frame) elif self.cover_style == 'outline': # 人物描边效果(简化版) cover_path = self._generate_outline_cover(frame) elif self.cover_style == 'gradient': # 渐变背景效果 cover_path = self._generate_gradient_cover(frame) elif self.cover_style == 'split': # 斜切设计效果 cover_path = self._generate_split_cover(frame) else: cover_path = os.path.join(self.output_dir, 'cover.png') cv2.imwrite(cover_path, frame) return cover_path except Exception as e: raise Exception(f"Error generating cover: {str(e)}") def _generate_blur_cover(self, frame: np.ndarray) -> str: """生成背景虚化封面(人物突出,背景虚化)""" try: print("Starting blur cover generation...", file=sys.stderr) # 转换为PIL image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) width, height = image.size # 确保图像格式正确 print(f"Image size: {width}x{height}, mode: {image.mode}", file=sys.stderr) # 使用视频的原始尺寸 video_width = getattr(self, 'video_width', width) video_height = getattr(self, 'video_height', height) if REMBG_AVAILABLE: try: print("Creating background blur effect with portrait segmentation...", file=sys.stderr) # 使用RMBG提取人像 portrait = rmbg_remove(image) print(f"Portrait extracted, mode: {portrait.mode if hasattr(portrait, 'mode') else 'unknown'}", file=sys.stderr) # 创建强力背景虚化 print("Creating blurred background...", file=sys.stderr) blurred_bg = image.filter(ImageFilter.GaussianBlur(radius=self.blur_radius)) # 创建结果:虚化背景 + 清晰人物 print(f"Creating result image with size: {video_width}x{video_height}", file=sys.stderr) result = Image.new('RGB', (video_width, video_height)) result.paste(blurred_bg, (0, 0)) # 将清晰的人物合成到虚化背景上 if portrait.mode == 'RGBA': # 计算居中位置 x_offset = (video_width - width) // 2 y_offset = (video_height - height) // 2 print(f"Pasting portrait at offset: ({x_offset}, {y_offset})", file=sys.stderr) result.paste(portrait, (x_offset, y_offset), portrait) print("Portrait background blur effect successful", file=sys.stderr) else: # 如果抠图失败,使用降低透明度的原始图像 print(f"Warning: Portrait mode is {portrait.mode}, expected RGBA", file=sys.stderr) overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) overlay.paste(image, (0, 0)) result.paste(overlay, (0, 0), overlay) # 应用缩放和文字叠加 print("Applying zoom and text overlay...", file=sys.stderr) result = self._apply_zoom(result) result = self._add_text_overlay(result) except Exception as e: print(f"RMBG failed with error: {str(e)}", file=sys.stderr) import traceback traceback.print_exc(file=sys.stderr) print("Falling back to software blur method", file=sys.stderr) # 软件方法:创建人物区域mask # 这里简化实现:假设人物在中心区域 result = image.copy() # 降低背景亮度并模糊 bg_overlay = Image.new('RGB', (video_width, video_height), (0, 0, 0)) blurred = image.filter(ImageFilter.GaussianBlur(radius=self.blur_radius)) bg_overlay.paste(blurred, (0, 0)) # 中心人物区域保持清晰 center_mask = Image.new('L', (width, height), 0) draw = ImageDraw.Draw(center_mask) draw.ellipse([width//4, height//4, 3*width//4, 3*height//4], fill=255) result = Image.composite(image, bg_overlay, center_mask) # 应用缩放和文字叠加 result = self._apply_zoom(result) result = self._add_text_overlay(result) else: print("RMBG not available, using simple blur effect", file=sys.stderr) # 简单方法:整体轻微模糊 + 中心清晰区域 blurred = image.filter(ImageFilter.GaussianBlur(radius=self.blur_radius)) center_region = image.crop((width//4, height//4, 3*width//4, 3*width//4)) blurred.paste(center_region, (width//4, height//4)) result = blurred.resize((video_width, video_height)) # 应用缩放和文字叠加 result = self._apply_zoom(result) result = self._add_text_overlay(result) # 保存 print("Saving cover image...", file=sys.stderr) cover_path = os.path.join(self.output_dir, 'cover_blur.png') result.save(cover_path, quality=95) print(f"Cover saved successfully: {cover_path}", file=sys.stderr) return cover_path except Exception as e: print(f"Error in _generate_blur_cover: {str(e)}", file=sys.stderr) import traceback traceback.print_exc(file=sys.stderr) raise def _generate_outline_cover(self, frame: np.ndarray) -> str: """生成人物描边封面(使用RMBG人像抠图 + 描边效果)""" # 转换为PIL image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) width, height = image.size if REMBG_AVAILABLE: try: # 使用更精确的人像分割模型 print("Extracting portrait using RMBG for outline effect...", file=sys.stderr) # 尝试使用专门的人像分割模型 try: from rembg import new_session session = new_session("u2net_human_seg") portrait = rmbg_remove(image, session=session, post_process_mask=True) print("Used specialized human segmentation model", file=sys.stderr) except: # 降级到默认模型 portrait = rmbg_remove(image) print("Used default rembg model", file=sys.stderr) # 获取alpha通道作为mask if portrait.mode == 'RGBA': alpha = portrait.split()[3] alpha_np = np.array(alpha) # 改进的描边方法:使用固定迭代次数和可变 kernel 大小 # 这样描边宽度可控,但紧贴轮廓 kernel_size = max(3, self.outline_width * 2 + 1) # 确保是奇数 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 只膨胀1次,避免离轮廓太远 alpha_dilated = cv2.dilate(alpha_np, kernel, iterations=1) # 描边 = 膨胀后的轮廓 - 原始轮廓(这样得到紧贴的描边) outline_mask = cv2.subtract(alpha_dilated, alpha_np) # 转换描边颜色 outline_rgb = self._hex_to_rgb(self.outline_color) outline_layer = Image.new('RGBA', (width, height), (*outline_rgb, 0)) # 应用描边 outline_pil = Image.fromarray(outline_mask) outline_colored = Image.new('RGBA', (width, height), (*outline_rgb, 255)) outline_colored.putalpha(outline_pil) outline_layer = Image.alpha_composite(outline_layer, outline_colored) # 创建结果:背景 + 描边 + 人像 result = Image.new('RGB', (width, height), (0, 0, 0)) # 先放原图作为背景 result.paste(image, (0, 0)) # 应用缩放到人像和描边 if self.zoom_level != 1.0: # 只缩放人像和描边,保持背景不变 portrait_zoomed = self._apply_zoom(portrait) outline_zoomed = self._apply_zoom(outline_layer) # 计算居中位置 p_width, p_height = portrait_zoomed.size if portrait_zoomed.mode == 'RGBA': result.paste(portrait_zoomed, ((width - p_width) // 2, (height - p_height) // 2), portrait_zoomed) result.paste(outline_zoomed, ((width - p_width) // 2, (height - p_height) // 2), outline_zoomed) else: result.paste(outline_layer, (0, 0), outline_layer) result.paste(portrait, (0, 0), portrait) print("Enhanced portrait outline extraction successful", file=sys.stderr) else: raise Exception("Portrait extraction did not return RGBA image") except Exception as e: print(f"Enhanced RMBG portrait outline failed: {e}, using fallback method", file=sys.stderr) # 降级方案:简单的边缘检测 image_np = np.array(image.convert('L')) edges = cv2.Canny(image_np, 50, 150) outline_rgb = self._hex_to_rgb(self.outline_color) edges_colored = np.zeros((*edges.shape, 3), dtype=np.uint8) edges_colored[edges > 0] = outline_rgb edges_pil = Image.fromarray(edges_colored) result = Image.blend(image, edges_pil, 0.3) result = self._apply_zoom(result) else: # 降级方案:简单的边缘检测 image_np = np.array(image.convert('L')) edges = cv2.Canny(image_np, 50, 150) outline_rgb = self._hex_to_rgb(self.outline_color) edges_colored = np.zeros((*edges.shape, 3), dtype=np.uint8) edges_colored[edges > 0] = outline_rgb edges_pil = Image.fromarray(edges_colored) result = Image.blend(image, edges_pil, 0.3) result = self._apply_zoom(result) # 应用文字叠加 result = self._add_text_overlay(result) # 保存 cover_path = os.path.join(self.output_dir, 'cover_outline.png') result.save(cover_path, quality=95) return cover_path def _generate_gradient_cover(self, frame: np.ndarray) -> str: """生成渐变背景封面""" # 转换为PIL image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) width, height = image.size # 创建渐变背景 gradient = Image.new('RGB', (width, height), color=(0, 0, 0)) draw = ImageDraw.Draw(gradient) # 绘制渐变 for y in range(height): r = int(103 + (118 - 103) * y / height) g = int(126 + (74 - 126) * y / height) b = int(234 + (162 - 234) * y / height) draw.line([(0, y), (width, y)], fill=(r, g, b)) # 应用半透明图片 image_enhanced = ImageEnhance.Brightness(image).enhance(0.8) gradient.paste(image_enhanced, (0, 0)) # 应用缩放和文字叠加 gradient = self._apply_zoom(gradient) gradient = self._add_text_overlay(gradient) # 保存 cover_path = os.path.join(self.output_dir, 'cover_gradient.png') gradient.save(cover_path, quality=95) return cover_path def _generate_split_cover(self, frame: np.ndarray) -> str: """生成斜切设计封面""" # 转换为PIL image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) width, height = image.size # 创建斜切背景 background = Image.new('RGB', (width, height), color=(255, 255, 255)) draw = ImageDraw.Draw(background) # 绘制斜切色块 draw.polygon([(0, 0), (width * 0.6, 0), (width * 0.4, height), (0, height)], fill=(103, 126, 234)) draw.polygon([(width * 0.6, 0), (width, 0), (width, height), (width * 0.4, height)], fill=(118, 74, 162)) # 应用半透明图片 image_enhanced = ImageEnhance.Brightness(image).enhance(0.8) background.paste(image_enhanced, (0, 0)) # 应用缩放和文字叠加 background = self._apply_zoom(background) background = self._add_text_overlay(background) # 保存 cover_path = os.path.join(self.output_dir, 'cover_split.png') background.save(cover_path, quality=95) return cover_path def _apply_zoom(self, image: Image.Image, zoom_level: float = None) -> Image.Image: """ 对图像应用缩放效果 Args: image: 输入图像 zoom_level: 缩放倍数,如果为None则使用self.zoom_level Returns: 缩放后的图像 """ if zoom_level is None: zoom_level = self.zoom_level if zoom_level == 1.0: return image width, height = image.size new_width = int(width * zoom_level) new_height = int(height * zoom_level) print(f"Applying zoom level {zoom_level}x: {width}x{height} -> {new_width}x{new_height}", file=sys.stderr) # 使用高质量重采样 zoomed = image.resize((new_width, new_height), Image.LANCZOS) # 如果放大,需要裁剪中心区域 if zoom_level > 1.0: left = (new_width - width) // 2 top = (new_height - height) // 2 zoomed = zoomed.crop((left, top, left + width, top + height)) print(f"Cropped zoomed image to center: {width}x{height}", file=sys.stderr) # 如果缩小,需要填充边缘 elif zoom_level < 1.0: result = Image.new('RGB', (width, height), (0, 0, 0)) left = (width - new_width) // 2 top = (height - new_height) // 2 result.paste(zoomed, (left, top)) print(f"Padded zoomed image to original size: {width}x{height}", file=sys.stderr) return result return zoomed def _hex_to_rgb(self, hex_color: str) -> tuple: """ 将十六进制颜色转换为RGB元组 Args: hex_color: 十六进制颜色字符串,如 '#FFFFFF' 或 'FFFFFF' Returns: RGB元组,如 (255, 255, 255) """ hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def _draw_char_with_effects(self, image: Image.Image, char: str, x: int, y: int, font, color: tuple, title_config: dict) -> Image.Image: """ 绘制带阴影和描边效果的单个字符 Args: image: PIL图像(RGBA模式) char: 要绘制的字符 x, y: 字符位置 font: 字体对象 color: 文字颜色(RGB元组) title_config: 标题配置(包含阴影和描边参数) Returns: 绘制后的图像 """ # 获取阴影参数 shadow_color = self._hex_to_rgb(title_config.get('shadowColor', '#000000')) shadow_offset_x = title_config.get('shadowOffsetX', 0) shadow_offset_y = title_config.get('shadowOffsetY', 0) shadow_blur = title_config.get('shadowBlur', 0) # 获取描边参数 stroke_width = title_config.get('strokeWidth', 0) stroke_color = self._hex_to_rgb(title_config.get('strokeColor', '#000000')) # 1. 绘制阴影(如果有偏移) if shadow_offset_x != 0 or shadow_offset_y != 0: shadow_x = x + shadow_offset_x shadow_y = y + shadow_offset_y # 调试:打印阴影位置计算 print(f"[DEBUG] Shadow for char '{char}': text_pos=({x}, {y}), offset=({shadow_offset_x}, {shadow_offset_y}), shadow_pos=({shadow_x}, {shadow_y})", file=sys.stderr) if shadow_blur > 0: # 创建阴影图层并模糊 shadow_layer = Image.new('RGBA', image.size, (0, 0, 0, 0)) shadow_draw = ImageDraw.Draw(shadow_layer) shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 180)) shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur)) image = Image.alpha_composite(image, shadow_layer) else: draw = ImageDraw.Draw(image) draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 180)) # 2. 绘制描边 if stroke_width > 0: draw = ImageDraw.Draw(image) 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 draw.text((x + offset_x, y + offset_y), char, font=font, fill=(*stroke_color, 255)) # 3. 绘制主体文字 draw = ImageDraw.Draw(image) draw.text((x, y), char, font=font, fill=(*color, 255)) return image def _load_font(self, font_name: str = None, font_size: int = 24, font_weight: int = 400) -> ImageFont.FreeTypeFont: """ 加载字体,支持自定义字体、ziti目录、系统字体 优先顺序:ziti目录 -> FontManager -> 系统字体 -> 默认字体 Args: font_name: 字体名称(如 "微软雅黑"、"墨趣古风体" 等) font_size: 字体大小 font_weight: 字体粗细 (100-1000) Returns: 字体对象 """ import platform # 1. 首先直接尝试在 ziti 目录查找字体文件 if font_name: # 多个可能的 ziti 目录路径(相对和绝对) possible_ziti_paths = [ os.path.join(os.getcwd(), 'ziti'), # 当前工作目录 os.path.join(os.path.dirname(__file__), '..', 'ziti'), # 相对于脚本 os.path.join(os.path.dirname(__file__), '..', '..', 'ziti'), # 项目根目录 'ziti', # 相对路径 os.path.expanduser('~/ziti'), # 用户主目录 ] # 尝试找到 ziti 目录 for ziti_dir in possible_ziti_paths: if os.path.isdir(ziti_dir): print(f"Found ziti directory: {ziti_dir}", file=sys.stderr) # 在 ziti 目录中查找字体文件 for ext in ['.ttf', '.otf', '.ttc']: # 直接按字体名称查找 font_path = os.path.join(ziti_dir, font_name + ext) if os.path.exists(font_path): try: font = ImageFont.truetype(font_path, font_size) print(f"✓ Successfully loaded ziti font '{font_name}': {font_path}", file=sys.stderr) return font except Exception as e: print(f"✗ Failed to load ziti font from {font_path}: {e}", 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_name or font_name in file_base: font_path = os.path.join(ziti_dir, file) try: font = ImageFont.truetype(font_path, font_size) print(f"✓ Successfully loaded ziti font '{font_name}' (matched as {file}): {font_path}", file=sys.stderr) return font except Exception as e: print(f"✗ Failed to load matched font {font_path}: {e}", file=sys.stderr) except Exception as e: print(f"✗ Error scanning ziti directory: {e}", file=sys.stderr) # 2. 如果 ziti 目录查找失败,使用 FontManager if font_name and FONT_MANAGER_AVAILABLE: try: font_manager = get_font_manager() font_path = font_manager.find_font(font_name, font_weight) if font_path and os.path.exists(font_path): try: font = ImageFont.truetype(font_path, font_size) print(f"✓ Successfully loaded font via FontManager '{font_name}': {font_path}", file=sys.stderr) return font except Exception as e: print(f"✗ Failed to load font from {font_path}: {e}", file=sys.stderr) except Exception as e: print(f"✗ FontManager error: {e}", file=sys.stderr) # 3. 降级:根据操作系统加载默认系统字体 print(f"⚠ Font '{font_name}' not found, trying system fonts...", file=sys.stderr) system = platform.system() font_paths = [] if system == 'Windows': font_dir = None font_paths = [ os.path.join(font_dir, 'NotoSerifCJK-VF'), # 微软雅黑 '', # 微软雅黑 Bold '', # 黑体 '', # 宋体 os.path.join(font_dir, 'arial.ttf'), # Arial ] elif system == 'Darwin': # macOS font_paths = [ '/Library/Fonts/PingFang.ttc', '/System/Library/Fonts/PingFang.ttc', '/Library/Fonts/Arial.ttf', ] else: # Linux font_paths = [ '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', ] # 尝试加载每个系统字体 for font_path in font_paths: if os.path.exists(font_path): try: font = ImageFont.truetype(font_path, font_size) print(f"✓ Successfully loaded system font: {font_path}", file=sys.stderr) return font except Exception as e: print(f"✗ Failed to load system font {font_path}: {e}", file=sys.stderr) continue # 4. 最后的降级方案:使用 PIL 默认字体 print("⚠ All font loading attempts failed, using PIL default font (text may not render correctly for CJK)", file=sys.stderr) return ImageFont.load_default() def _add_text_overlay(self, image: Image.Image) -> Image.Image: """ 在封面上添加文字叠加 Args: image: 输入图像 Returns: 添加了文字的图像 """ if not self.cover_text or not self.cover_text.strip(): return image print(f"Adding text overlay: '{self.cover_text}'", file=sys.stderr) # 创建绘图对象 draw = ImageDraw.Draw(image) # 获取字体名称(从配置中优先获取,否则使用微软雅黑作为默认) font_name = self.config.get('coverTextFont', 'NotoSerifCJK-VF') # 加载字体 - 改进的字体加载逻辑,支持自定义字体 font = self._load_font(font_name, self.cover_text_size) # 计算文字边界框 text_bbox = draw.textbbox((0, 0), self.cover_text, font=font) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] # 根据位置参数定位文字 x = (image.width - text_width) // 2 if self.cover_text_position == 'top': y = 50 elif self.cover_text_position == 'center': y = (image.height - text_height) // 2 else: # bottom y = image.height - text_height - 50 print(f"Text position: ({x}, {y}), size: {text_width}x{text_height}", file=sys.stderr) # 转换颜色 text_color = self._hex_to_rgb(self.cover_text_color) outline_rgb = self._hex_to_rgb(self.outline_color) # 绘制文字描边 if self.outline_width > 0: for offset_x in range(-self.outline_width, self.outline_width + 1): for offset_y in range(-self.outline_width, self.outline_width + 1): if offset_x == 0 and offset_y == 0: continue draw.text((x + offset_x, y + offset_y), self.cover_text, font=font, fill=outline_rgb) # 绘制文字主体 draw.text((x, y), self.cover_text, font=font, fill=text_color) print("Text overlay added successfully", file=sys.stderr) return image def _generate_custom_template_cover(self, frame: np.ndarray) -> str: """ 使用自定义模板生成封面 Args: frame: 视频帧 Returns: 生成的封面路径 """ try: print("Generating custom template cover...", file=sys.stderr) template = self.template # 转换为PIL图像 image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) width, height = image.size # 1. 应用背景模糊 # 支持两种格式:旧格式 template['background']['blurEnabled'] 和新格式 template['backgroundBlurEnabled'] blur_enabled = template.get('backgroundBlurEnabled', template.get('background', {}).get('blurEnabled', False)) if blur_enabled: blur_radius = template.get('backgroundBlurIntensity', template.get('background', {}).get('blurRadius', 15)) print(f"Applying background blur: {blur_radius}", file=sys.stderr) image = image.filter(ImageFilter.GaussianBlur(radius=blur_radius)) # 2. 处理人像描边(如果启用RMBG) # 支持两种格式:旧格式 template['portrait']['strokeEnabled'] 和新格式 template['personBorderEnabled'] stroke_enabled = template.get('personBorderEnabled', template.get('portrait', {}).get('strokeEnabled', False)) if stroke_enabled and REMBG_AVAILABLE: try: print("Extracting portrait for stroke effect...", file=sys.stderr) portrait = rmbg_remove(image) # 创建描边效果 # 支持两种格式 stroke_color = template.get('personBorderColor', template.get('portrait', {}).get('strokeColor', '#FFD700')) stroke_width = template.get('personBorderWidth', template.get('portrait', {}).get('strokeWidth', 3)) stroke_type = template.get('personBorderStyle', template.get('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] alpha_np = np.array(alpha) # 改进的描边方法:使用固定迭代次数和可变 kernel 大小 # 这样描边宽度可控,但紧贴轮廓 kernel_size = max(3, stroke_width * 2 + 1) # 确保是奇数 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 只膨胀1次,避免离轮廓太远 alpha_dilated = cv2.dilate(alpha_np, kernel, iterations=1) # 描边 = 膨胀后的轮廓 - 原始轮廓(这样得到紧贴的描边) outline_mask = cv2.subtract(alpha_dilated, alpha_np) # 转换描边颜色 stroke_rgb = self._hex_to_rgb(stroke_color) outline_layer = Image.new('RGBA', (width, height), (*stroke_rgb, 0)) # 应用描边 outline_pil = Image.fromarray(outline_mask) outline_colored = Image.new('RGBA', (width, height), (*stroke_rgb, 255)) outline_colored.putalpha(outline_pil) outline_layer = Image.alpha_composite(outline_layer, outline_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) # 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. 添加标题 # 转换为RGBA以支持透明图层 if image.mode != 'RGBA': image = image.convert('RGBA') # 加载字体 - 使用模板中的字体设置 main_font_family = template['titles']['main'].get('fontFamily', 'NotoSerifCJK-VF') main_font_size = template['titles']['main'].get('fontSize', 60) main_font_weight = template['titles']['main'].get('fontWeight', 400) sub_font_family = template['titles']['sub'].get('fontFamily', 'NotoSerifCJK-VF') sub_font_size = template['titles']['sub'].get('fontSize', 40) sub_font_weight = template['titles']['sub'].get('fontWeight', 400) print(f"Loading main font: {main_font_family}, size={main_font_size}, weight={main_font_weight}", file=sys.stderr) print(f"Loading sub font: {sub_font_family}, size={sub_font_size}, weight={sub_font_weight}", file=sys.stderr) font_main = self._load_font(main_font_family, main_font_size, main_font_weight) font_sub = self._load_font(sub_font_family, sub_font_size, sub_font_weight) draw = ImageDraw.Draw(image) # 绘制主标题(支持自动换行) 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) # 调试:打印接收到的位置参数 print(f"[DEBUG] Main title position - Received from frontend: x={main_title['position']['x']}%, y={main_title['position']['y']}%", file=sys.stderr) print(f"[DEBUG] Main title position - Calculated pixels: x={main_pos_x}px, y={main_pos_y}px", file=sys.stderr) print(f"[DEBUG] Canvas size: {width}x{height}", file=sys.stderr) max_chars_per_line = main_title.get('maxLength', 4) # 每行最多字符数 # 获取文字方向和间距参数 direction = main_title.get('direction', 'horizontal') font_size = main_title.get('fontSize', 60) char_spacing = main_title.get('charSpacing', int(font_size * 0.2)) # 字符间距 line_spacing = main_title.get('lineSpacing', int(font_size * 1.2)) # 行间距 # 调试日志 print(f"[DEBUG] Main title config:", file=sys.stderr) print(f" Text: '{main_text}' (length: {len(main_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: ({main_pos_x}, {main_pos_y})", file=sys.stderr) # 将文字分行 lines = [] for i in range(0, len(main_text), max_chars_per_line): lines.append(main_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 # 计算起始位置(整体居中) start_x = main_pos_x + total_width // 2 - font_size // 2 start_y = main_pos_y - total_height // 2 # 逐列绘制(从右到左) for col_idx, line in enumerate(lines): col_x = start_x - col_idx * line_spacing # 逐字符绘制(从上到下) for char_idx, char in enumerate(line): char_y = start_y + char_idx * (font_size + char_spacing) # 计算字符实际宽度以水平居中 try: bbox = draw.textbbox((0, 0), char, font=font_main) char_w = bbox[2] - bbox[0] char_x = col_x - char_w // 2 + font_size // 2 except: char_x = col_x # 使用带阴影和描边效果的绘制函数 image = self._draw_char_with_effects( image, char, char_x, char_y, font_main, main_color, main_title ) draw = ImageDraw.Draw(image) print(f" Char '{char}' at ({char_x}, {char_y})", file=sys.stderr) else: # 横排:从左到右,从上到下(默认) # 计算总高度 total_height = (len(lines) - 1) * line_spacing + font_size start_y = main_pos_y - total_height // 2 # 逐行绘制 for row_idx, line in enumerate(lines): line_y = start_y + row_idx * line_spacing # 计算该行的总宽度 line_width = (len(line) - 1) * (font_size + char_spacing) + font_size start_x = main_pos_x - line_width // 2 # 逐字符绘制(从左到右) for char_idx, char in enumerate(line): char_x = start_x + char_idx * (font_size + char_spacing) # 使用带阴影和描边效果的绘制函数 image = self._draw_char_with_effects( image, char, char_x, line_y, font_main, main_color, main_title ) draw = ImageDraw.Draw(image) print(f" Char '{char}' at ({char_x}, {line_y})", file=sys.stderr) 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) # 调试:打印接收到的位置参数 print(f"[DEBUG] Subtitle position - Received from frontend: x={sub_title['position']['x']}%, y={sub_title['position']['y']}%", file=sys.stderr) print(f"[DEBUG] Subtitle position - Calculated pixels: x={sub_pos_x}px, y={sub_pos_y}px", file=sys.stderr) max_chars_per_line = sub_title.get('maxLength', 3) # 每行最多字符数 # 获取文字方向和间距参数 direction = sub_title.get('direction', 'horizontal') font_size = sub_title.get('fontSize', 40) char_spacing = sub_title.get('charSpacing', int(font_size * 0.2)) # 字符间距 line_spacing = sub_title.get('lineSpacing', int(font_size * 1.2)) # 行间距 # 调试日志 print(f"[DEBUG] Sub title config:", file=sys.stderr) print(f" Text: '{sub_text}' (length: {len(sub_text)})", file=sys.stderr) print(f" maxLength: {max_chars_per_line}", file=sys.stderr) print(f" Direction: '{direction}' (type: {type(direction).__name__}, equals 'vertical': {direction == 'vertical'})", file=sys.stderr) print(f" Char spacing: {char_spacing}px, Line spacing: {line_spacing}px", file=sys.stderr) print(f" Position: ({sub_pos_x}, {sub_pos_y})", file=sys.stderr) print(f"[DEBUG] sub_title dict keys: {list(sub_title.keys())}", file=sys.stderr) # 将文字分行 lines = [] for i in range(0, len(sub_text), max_chars_per_line): lines.append(sub_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 # 计算起始位置(整体居中) start_x = sub_pos_x + total_width // 2 - font_size // 2 start_y = sub_pos_y - total_height // 2 # 逐列绘制(从右到左) for col_idx, line in enumerate(lines): col_x = start_x - col_idx * line_spacing # 逐字符绘制(从上到下) for char_idx, char in enumerate(line): char_y = start_y + char_idx * (font_size + char_spacing) # 计算字符实际宽度以水平居中 try: bbox = draw.textbbox((0, 0), char, font=font_sub) char_w = bbox[2] - bbox[0] char_x = col_x - char_w // 2 + font_size // 2 except: char_x = col_x # 使用带阴影和描边效果的绘制函数 image = self._draw_char_with_effects( image, char, char_x, char_y, font_sub, sub_color, sub_title ) draw = ImageDraw.Draw(image) print(f" Char '{char}' at ({char_x}, {char_y})", file=sys.stderr) else: # 横排:从左到右,从上到下(默认) # 计算总高度 total_height = (len(lines) - 1) * line_spacing + font_size start_y = sub_pos_y - total_height // 2 # 逐行绘制 for row_idx, line in enumerate(lines): line_y = start_y + row_idx * line_spacing # 计算该行的总宽度 line_width = (len(line) - 1) * (font_size + char_spacing) + font_size start_x = sub_pos_x - line_width // 2 # 逐字符绘制(从左到右) for char_idx, char in enumerate(line): char_x = start_x + char_idx * (font_size + char_spacing) char_y = line_y # ✅ 添加char_y变量以保持一致性 # 使用带阴影和描边效果的绘制函数 image = self._draw_char_with_effects( image, char, char_x, char_y, font_sub, sub_color, sub_title ) draw = ImageDraw.Draw(image) print(f" Char '{char}' at ({char_x}, {line_y})", file=sys.stderr) print(f"Drew sub title: '{sub_text}' ({len(lines)} lines) at ({sub_pos_x}, {sub_pos_y})", file=sys.stderr) # 转换回RGB并保存封面 if image.mode == 'RGBA': image = image.convert('RGB') cover_path = os.path.join(self.output_dir, 'cover_custom_template.png') image.save(cover_path, quality=95) print(f"Custom template cover generated: {cover_path}", file=sys.stderr) return cover_path except Exception as e: print(f"Error generating custom template cover: {e}", file=sys.stderr) raise Exception(f"Custom template cover generation failed: {str(e)}") def generate_subtitles(self) -> List[Dict[str, Any]]: """ 生成字幕列表 支持两种模式: 1. FUNASR模式:使用语音识别结果的时间戳 2. 文本模式:根据文案按行分割,平均分配时间 现在支持:逐句情感分析 + 动态字幕样式 Returns: 字幕列表,每个字幕包含文本、开始时间、结束时间、情感、样式等 """ try: subtitles = [] # 模式1: 使用FUNASR识别结果 if self.use_funasr and self.funasr_result: print("Generating subtitles from FUNASR result with sentiment analysis...", file=sys.stderr) segments = self.funasr_result.get('segments', []) for i, segment in enumerate(segments): text = segment.get('text', '') # 对每句话进行情感分析 sentiment_analysis = self.analyze_sentence_sentiment(text) sentiment = sentiment_analysis['sentiment'] score = sentiment_analysis['score'] # 根据情感选择样式 dynamic_style = self.get_style_by_sentiment(sentiment, score) subtitle = { 'index': i + 1, 'text': text, 'start_time': segment.get('start', i * 3.0), 'end_time': segment.get('end', (i + 1) * 3.0), 'style': dynamic_style, # 动态样式 'sentiment': sentiment, # 情感类型 'sentiment_score': score, # 情感分数 'fontName': self.subtitle_font_name, # 加入字体名称 'fontSize': self.subtitle_font_size, # 加入字体大小 } subtitles.append(subtitle) print(f"Generated {len(subtitles)} subtitles from FUNASR with sentiment-based styles", file=sys.stderr) # 模式2: 基于文本的简单分割(支持情感分析) else: print("Generating subtitles from script text with sentiment analysis...", file=sys.stderr) # 先按行分割 lines = [line.strip() for line in self.script_text.split('\n') if line.strip()] # 如果只有一行,则按句子分割(使用句号、问号、感叹号等标点) if len(lines) == 1: import re # 使用正则表达式按中英文标点分割句子 sentences = re.split(r'[。!?\.\!\?]+', lines[0]) lines = [s.strip() for s in sentences if s.strip()] print(f"Single line detected, split into {len(lines)} sentences", file=sys.stderr) # 生成字幕列表(简单示例,每句3秒) for i, line in enumerate(lines): # 对每句话进行情感分析 sentiment_analysis = self.analyze_sentence_sentiment(line) sentiment = sentiment_analysis['sentiment'] score = sentiment_analysis['score'] # 根据情感选择样式 dynamic_style = self.get_style_by_sentiment(sentiment, score) subtitle = { 'index': i + 1, 'text': line, 'start_time': i * 3.0, 'end_time': (i + 1) * 3.0, 'style': dynamic_style, # 动态样式 'sentiment': sentiment, # 情感类型 'sentiment_score': score, # 情感分数 'fontName': self.subtitle_font_name, # 加入字体名称 'fontSize': self.subtitle_font_size, # 加入字体大小 } subtitles.append(subtitle) print(f"Generated {len(subtitles)} subtitles from text with sentiment-based styles", file=sys.stderr) return self.normalize_subtitles(subtitles) except Exception as e: raise Exception(f"Error generating subtitles: {str(e)}") def normalize_subtitles(self, subtitles: List[Dict[str, Any]]) -> List[Dict[str, Any]]: min_duration = 0.18 overlap_gap = 0.03 duplicate_time_threshold = 0.2 normalized: List[Dict[str, Any]] = [] for original in subtitles: text = str(original.get('text', '')).replace('\r', ' ').replace('\n', ' ').strip() if not text: continue try: start_time = float(original.get('start_time', 0.0) or 0.0) except (TypeError, ValueError): start_time = 0.0 try: end_time = float(original.get('end_time', start_time + min_duration) or (start_time + min_duration)) except (TypeError, ValueError): end_time = start_time + min_duration start_time = max(0.0, start_time) end_time = max(start_time + min_duration, end_time) subtitle = dict(original) subtitle['text'] = text subtitle['start_time'] = start_time subtitle['end_time'] = end_time normalized.append(subtitle) normalized.sort(key=lambda item: (item['start_time'], item['end_time'], item.get('index', 0))) cleaned: List[Dict[str, Any]] = [] adjusted_count = 0 for subtitle in normalized: if cleaned: previous = cleaned[-1] is_duplicate = ( subtitle['text'] == previous['text'] and abs(subtitle['start_time'] - previous['start_time']) <= duplicate_time_threshold ) if is_duplicate: previous['end_time'] = max(previous['end_time'], subtitle['end_time']) adjusted_count += 1 continue if subtitle['start_time'] < previous['end_time']: preferred_previous_end = max( previous['start_time'] + min_duration, subtitle['start_time'] - overlap_gap ) if preferred_previous_end < previous['end_time']: previous['end_time'] = preferred_previous_end adjusted_count += 1 if subtitle['start_time'] < previous['end_time']: subtitle['start_time'] = previous['end_time'] + overlap_gap subtitle['end_time'] = max(subtitle['start_time'] + min_duration, subtitle['end_time']) adjusted_count += 1 cleaned.append(subtitle) for index, subtitle in enumerate(cleaned, start=1): subtitle['index'] = index if adjusted_count > 0: print( f"Normalized subtitles: {len(subtitles)} -> {len(cleaned)}, adjusted {adjusted_count} entries", file=sys.stderr ) return cleaned def save_srt(self, subtitles: List[Dict[str, Any]]) -> str: """ 保存SRT字幕文件 Args: subtitles: 字幕列表 Returns: SRT文件路径 """ try: srt_path = os.path.join(self.output_dir, 'subtitles.srt') with open(srt_path, 'w', encoding='utf-8') as f: for sub in subtitles: # SRT格式 f.write(f"{sub['index']}\n") f.write(f"{self._format_time(sub['start_time'])} --> {self._format_time(sub['end_time'])}\n") f.write(f"{sub['text']}\n\n") return srt_path except Exception as e: raise Exception(f"Error saving SRT file: {str(e)}") def _format_time(self, seconds: float) -> str: """格式化时间为SRT格式""" hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) millis = int((seconds % 1) * 1000) return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" def _apply_keyword_effects_to_subtitle(self, subtitle: Dict[str, Any]) -> Dict[str, Any]: """ 对字幕应用关键词分组效果 Args: subtitle: 字幕对象 Returns: 应用了效果的字幕对象 """ # 查找该字幕中的关键词标记 text = subtitle.get('text', '') # ✅ 修复:正确处理关键词标记的结构 # 前端发送的关键词标记结构:{ text: '关键词', groupId: 'xxx-xxx-xxx', positions: [...], count: 1 } if not self.keyword_markers: print(f"[_apply_keyword_effects_to_subtitle] No keyword markers found", file=sys.stderr) return subtitle # 检查是否有相关的关键词标记 for marker in self.keyword_markers: # ✅ 尝试多种方式获取标记文本 marker_text = marker.get('text') or marker.get('keyword') or '' if not marker_text: continue # 检查字幕中是否包含此关键词 if marker_text in text: group_id = marker.get('groupId') # 查找对应的分组 if group_id: matched_group = None for group in self.keyword_groups: if group.get('id') == group_id: matched_group = group break if matched_group: # 应用分组的样式覆盖 style_override = matched_group.get('styleOverride', {}) # ✅ 即使没有 styleOverride,也要应用 effectId effect_id = matched_group.get('effectId', 'none') # 更新字幕的样式信息 subtitle['keywordEffect'] = { 'groupId': group_id, 'groupName': matched_group.get('name', ''), 'fontColor': style_override.get('fontColor', '#FFFFFF'), 'outlineColor': style_override.get('outlineColor', '#000000'), 'fontSize': style_override.get('fontSize', 24), 'outlineWidth': style_override.get('outlineWidth', 2), 'effectId': effect_id, 'effectDuration': style_override.get('effectDuration', 2.0), } print(f"[_apply_keyword_effects] Matched keyword '{marker_text}' -> group '{matched_group.get('name')}': effect={effect_id}, color={style_override.get('fontColor')}", file=sys.stderr) # ✅ 找到匹配后,只返回,不继续搜索(一个字幕一个特效) return subtitle else: print(f"[_apply_keyword_effects] Keyword '{marker_text}' found but no groupId", file=sys.stderr) return subtitle def save_ass(self, subtitles: List[Dict[str, Any]]) -> str: """ 保存ASS字幕文件(Advanced Substation Alpha) 支持每个字幕不同的样式、字体和关键词分组效果 Args: subtitles: 字幕列表 Returns: ASS文件路径 """ try: ass_path = os.path.join(self.output_dir, 'subtitles.ass') with open(ass_path, 'w', encoding='utf-8-sig') as f: # ASS头部信息 f.write("""[Script Info] ScriptType: v4.00+ Collisions: Normal PlayResX: 1920 PlayResY: 1080 Timer: 100.0000 WrapStyle: 0 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding """) # 收集所有唯一的字体名称和关键词分组样式(从字幕中提取) unique_fonts = set() keyword_group_styles = {} # 存储关键词分组的样式信息 for sub in subtitles: font_name = sub.get('fontName', 'NotoSerifCJK-VF') unique_fonts.add(font_name) # 如果有关键词效果,记录其样式信息 if sub.get('keywordEffect'): keyword_effect = sub.get('keywordEffect') group_id = keyword_effect.get('groupId') group_name = keyword_effect.get('groupName', '') # 为关键词分组创建唯一的样式 style_key = f"group_{group_id}" if group_id else None if style_key and style_key not in keyword_group_styles: # 转换颜色格式:#RRGGBB → &H00BBGGRR& font_color = keyword_effect.get('fontColor', '#FFFFFF') outline_color = keyword_effect.get('outlineColor', '#000000') outline_width = keyword_effect.get('outlineWidth', 2) font_size = keyword_effect.get('fontSize', 72) # 转换十六进制颜色 if font_color.startswith('#'): hex_c = font_color.lstrip('#') primary_color = f"&H00{hex_c[4:6]}{hex_c[2:4]}{hex_c[0:2]}&" else: primary_color = "&H00FFFFFF&" if outline_color.startswith('#'): hex_c = outline_color.lstrip('#') outline_color_ass = f"&H00{hex_c[4:6]}{hex_c[2:4]}{hex_c[0:2]}&" else: outline_color_ass = "&H00000000&" keyword_group_styles[style_key] = { 'font_size': font_size, 'primary_color': primary_color, 'outline_color': outline_color_ass, 'outline_width': outline_width, 'style_def': f'Style: {style_key},{font_name.replace(" ", "_")},{font_size},{primary_color},&H000000FF,{outline_color_ass},&H80000000,-1,0,0,0,100,100,0,0,1,{int(outline_width)},1,2,10,10,20,1' } print(f"Found {len(unique_fonts)} unique fonts in subtitles: {unique_fonts}", file=sys.stderr) print(f"Found {len(keyword_group_styles)} keyword group styles", file=sys.stderr) # 为每个唯一字体创建样式 styles = {} default_font = 'NotoSerifCJK-VF' # 先添加默认样式(使用默认字体) default_font_to_use = list(unique_fonts)[0] if unique_fonts else default_font styles['default'] = f'Style: default,{default_font_to_use},24,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,10,10,20,1' # 为其他唯一字体创建额外样式 for idx, font_name in enumerate(unique_fonts, 1): # 清理字体名称用作样式名(只允许字母和数字) style_name = f'font_{idx}' if font_name == default_font_to_use or font_name not in ['NotoSerifCJK-VF', 'NotoSerifCJK-VF', 'NotoSerifCJK-VF'] else font_name.replace(' ', '_') styles[style_name] = f'Style: {style_name},{font_name},24,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,10,10,20,1' # 添加关键词分组的样式 for style_key, style_info in keyword_group_styles.items(): styles[style_key] = style_info['style_def'] # 输出所有样式定义 for style_def in styles.values(): f.write(f"{style_def}\n") f.write(""" [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """) # 添加字幕事件 for sub in subtitles: # 应用关键词分组效果 sub = self._apply_keyword_effects_to_subtitle(sub) start_time = self._format_ass_time(sub['start_time']) end_time = self._format_ass_time(sub['end_time']) style = sub.get('style', 'default') text = sub['text'] font_name = sub.get('fontName') # 从字幕对象获取字体名称 # ✅ 如果有关键词效果,优先使用关键词分组的样式 keyword_effect = sub.get('keywordEffect', None) if keyword_effect: group_id = keyword_effect.get('groupId') style_key = f"group_{group_id}" if group_id else None if style_key and style_key in styles: style = style_key print(f"Subtitle '{text[:20]}...' using keyword group style: {style}", file=sys.stderr) else: print(f"Subtitle '{text[:20]}...' keyword group style '{style_key}' not found, using default", file=sys.stderr) # 否则,根据字体名称选择对应的样式 elif font_name: # 尝试使用字体名称作为样式名 style_for_font = font_name.replace(' ', '_') # 如果样式不存在,使用default if style_for_font not in styles: style = 'default' else: style = style_for_font print(f"Subtitle '{text[:20]}...' using font: {font_name}, style: {style}", file=sys.stderr) else: print(f"Subtitle '{text[:20]}...' using default font and style", file=sys.stderr) # 如果有关键词效果,添加ASS特效标签和颜色 if keyword_effect: effect_id = keyword_effect.get('effectId', 'none') effect_duration = keyword_effect.get('effectDuration', 2.0) font_color = keyword_effect.get('fontColor', '#FFFFFF') # 转换十六进制颜色为ASS格式(&HAABBGGRR) if font_color.startswith('#'): hex_color = font_color.lstrip('#') # 反转RGB为BGR ass_color = f"&H00{hex_color[4:6]}{hex_color[2:4]}{hex_color[0:2]}&" else: ass_color = "&H00FFFFFF&" # ✅ 根据前端支持的所有特效类型添加ASS标签 # 前端支持的特效ID: zoom-in, pulse, bounce, glow, shake, slide-up, fade, spin, wave, flip if effect_id in ['bounce', 'pulse', 'pulse_scale']: # 弹跳/脉冲:使用缩放变换 text = f"{{\\t(0,{int(effect_duration*1000)},\\t(0,{int(effect_duration*500)},\\fscx110)\\t({int(effect_duration*500)},{int(effect_duration*1000)},\\fscx100))}}{text}" elif effect_id in ['zoom-in']: # 缩放弹出 text = f"{{\\t(0,{int(effect_duration*1000)},\\fscx130\\fscy130)}}{text}" elif effect_id in ['glow', 'glow_effect']: # 发光效果 text = f"{{\\c{ass_color}\\blur2\\bord2}}{text}" elif effect_id in ['shake']: # 抖动效果(使用多个 pos 标签模拟) text = f"{{\\fad(100,{int(effect_duration*500)})}}{text}" elif effect_id in ['slide-up', 'slide_in_up']: # 从下向上滑入 text = f"{{\\move(0,{int(1080 * 0.3)},0,{int(1080 * 0.2)},0,{int(effect_duration*1000)})}}{text}" elif effect_id in ['fade']: # 渐变高亮 text = f"{{\\t(0,{int(effect_duration*500)},\\c{ass_color})\\t({int(effect_duration*500)},{int(effect_duration*1000)},\\c&H00FFFFFF&)}}{text}" elif effect_id in ['spin']: # 旋转效果 text = f"{{\\t(0,{int(effect_duration*1000)},\\frz360)}}{text}" elif effect_id in ['wave']: # 彩虹波浪(使用多个颜色变换) text = f"{{\\t(0,{int(effect_duration*1000)},\\c&H0000FF&)\\t({int(effect_duration*250)},{int(effect_duration*500)},\\c&H00FF00&)\\t({int(effect_duration*500)},{int(effect_duration*750)},\\c&HFF0000&)}}{text}" elif effect_id in ['flip']: # 3D翻转效果 text = f"{{\\t(0,{int(effect_duration*1000)},\\frx360)}}{text}" # 其他特效或 'none' 不添加标签 f.write(f"Dialogue: 0,{start_time},{end_time},{style},,0,0,0,,{text}\n") print(f"ASS subtitle file saved: {ass_path}", file=sys.stderr) return ass_path except Exception as e: raise Exception(f"Error saving ASS file: {str(e)}") def _format_ass_time(self, seconds: float) -> str: """格式化时间为ASS格式 (H:MM:SS.cc)""" hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) centisecs = int((seconds % 1) * 100) return f"{hours}:{minutes:02d}:{secs:02d}.{centisecs:02d}" def analyze_script(self) -> Dict[str, Any]: """ 使用轻量级NLP分析文案 提取标题、关键词、列表项、情感等信息 Returns: 分析结果字典 """ if not NLP_AVAILABLE: print("NLP not available, returning basic analysis", file=sys.stderr) return { 'title': None, 'keywords': [], 'list_items': [], 'sentiment': 'neutral' } try: result = {} # 分割文本为行 lines = [line.strip() for line in self.script_text.split('\n') if line.strip()] # 1. 提取标题(取第一行,如果长度适中) if lines and len(lines[0]) <= 30: result['title'] = lines[0] else: result['title'] = None # 2. 提取列表项(包含"第一"、"第二"、"首先"、"其次"等序号词的行) list_markers = ['第一', '第二', '第三', '第四', '第五', '首先', '其次', '再次', '最后', '1.', '2.', '3.', '4.', '5.'] list_items = [] for line in lines: if any(marker in line for marker in list_markers): list_items.append(line) result['list_items'] = list_items # 3. 提取关键词(使用TF-IDF) full_text = ' '.join(lines) try: # 使用 jieba 提取关键词(基于 TF-IDF) keywords = jieba.analyse.extract_tags(full_text, topK=10, withWeight=False) result['keywords'] = keywords print(f"Extracted keywords: {keywords}", file=sys.stderr) except Exception as e: print(f"Keyword extraction failed: {e}", file=sys.stderr) result['keywords'] = [] # 4. 情感分析(优先使用Transformer,降级到关键词匹配) if self.sentiment_analyzer is not None: try: # 使用Transformer模型进行深度情感分析 print("Using Transformer for sentiment analysis...", file=sys.stderr) # 截取前512个字符(模型限制) text_for_analysis = full_text[:512] if len(full_text) > 512 else full_text sentiment_result = self.sentiment_analyzer(text_for_analysis)[0] # 转换标签为统一格式 label = sentiment_result['label'].lower() score = sentiment_result['score'] if 'pos' in label or 'positive' in label: result['sentiment'] = 'positive' elif 'neg' in label or 'negative' in label: result['sentiment'] = 'negative' else: result['sentiment'] = 'neutral' result['sentiment_score'] = score print(f"Transformer sentiment: {result['sentiment']} (score: {score:.4f})", file=sys.stderr) except Exception as e: print(f"Transformer sentiment analysis failed: {e}, using fallback", file=sys.stderr) # 降级到关键词匹配 result['sentiment'] = self._fallback_sentiment_analysis(full_text) result['sentiment_score'] = 0.5 else: # 降级到关键词匹配 result['sentiment'] = self._fallback_sentiment_analysis(full_text) result['sentiment_score'] = 0.5 print(f"Script analysis result: {result}", file=sys.stderr) return result except Exception as e: print(f"Error analyzing script: {e}", file=sys.stderr) return { 'title': None, 'keywords': [], 'list_items': [], 'sentiment': 'neutral', 'sentiment_score': 0.5 } def _fallback_sentiment_analysis(self, text: str) -> str: """基于关键词的简单情感分析(降级方案)""" positive_words = ['好', '棒', '优秀', '成功', '喜欢', '开心', '快乐', '美好', '精彩', '完美'] negative_words = ['差', '糟', '失败', '讨厌', '难过', '痛苦', '糟糕', '失望', '困难'] positive_count = sum(1 for word in positive_words if word in text) negative_count = sum(1 for word in negative_words if word in text) if positive_count > negative_count: return 'positive' elif negative_count > positive_count: return 'negative' else: return 'neutral' def analyze_sentence_sentiment(self, text: str) -> Dict[str, Any]: """ 分析单句话的情感 Args: text: 待分析的句子 Returns: 包含情感和分数的字典 """ # 如果句子太短(少于3个字),返回中性 if len(text.strip()) < 3: return { 'sentiment': 'neutral', 'score': 0.5 } # 优先使用Transformer模型 if self.sentiment_analyzer is not None: try: # 截取前512个字符(模型限制) text_for_analysis = text[:512] if len(text) > 512 else text sentiment_result = self.sentiment_analyzer(text_for_analysis)[0] # 转换标签为统一格式 label = sentiment_result['label'].lower() score = sentiment_result['score'] if 'pos' in label or 'positive' in label: sentiment = 'positive' elif 'neg' in label or 'negative' in label: sentiment = 'negative' else: sentiment = 'neutral' return { 'sentiment': sentiment, 'score': score } except Exception as e: print(f"Transformer sentiment analysis failed for sentence: {e}", file=sys.stderr) # 降级到关键词匹配 sentiment = self._fallback_sentiment_analysis(text) return { 'sentiment': sentiment, 'score': 0.5 } else: # 降级到关键词匹配 sentiment = self._fallback_sentiment_analysis(text) return { 'sentiment': sentiment, 'score': 0.5 } def get_style_by_sentiment(self, sentiment: str, score: float) -> str: """ 根据情感选择字幕样式 Args: sentiment: 情感类型 (positive/negative/neutral) score: 情感分数 (0-1) Returns: 字幕样式名称 """ # 如果用户强制指定了样式,优先使用用户指定的 if hasattr(self, 'force_style') and self.force_style: return self.subtitle_style # 处理emotion-mixed样式:使用情感分析自动选择 if self.subtitle_style == 'emotion-mixed': # 根据情感自动选择样式 if sentiment == 'positive': if score > 0.8: # 强烈积极 return 'neon' # 霓虹样式 - 明亮彩色 else: return 'highlight' # 高亮样式 - 黄色背景 elif sentiment == 'negative': if score > 0.8: # 强烈消极 return 'minimal' # 极简样式 - 低调 else: return 'card' # 卡片样式 - 半透明背景 else: return 'default' # 中性 - 默认样式 # 普通样式映射 if sentiment == 'positive': if score > 0.8: # 强烈积极 return 'neon' # 霓虹样式 - 明亮彩色 else: return 'highlight' # 高亮样式 - 黄色背景 elif sentiment == 'negative': if score > 0.8: # 强烈消极 return 'minimal' # 极简样式 - 低调 else: return 'card' # 卡片样式 - 半透明背景 else: return 'default' # 中性 - 默认样式 def burn_subtitles_to_video(self, subtitles: List[Dict[str, Any]]) -> str: """ 使用 FFmpeg 将字幕烧录到视频 现在支持每个字幕不同的样式(使用ASS格式) Args: subtitles: 字幕列表(包含样式信息) Returns: 输出视频路径 """ try: output_video = os.path.join(self.output_dir, 'video_with_subtitles.mp4') # 保存ASS字幕文件(支持多样式) ass_path = self.save_ass(subtitles) # Windows上FFmpeg无法正确解析绝对路径中的ASS文件 # 使用相对路径执行FFmpeg ass_filename = os.path.basename(ass_path) working_dir = os.path.dirname(ass_path) # 构建 FFmpeg 命令 # 🔧 修复:使用 ziti 目录的绝对路径,而不是相对路径 '.' fontconfig = self._get_ffmpeg_fontconfig() print(f"Adding fontconfig to FFmpeg: {fontconfig}", file=sys.stderr) # 构建字幕过滤器参数 subtitle_filter = f"subtitles={ass_filename}{fontconfig}" cmd = [ 'ffmpeg', '-y', '-i', self.video_path, '-vf', subtitle_filter, '-c:v', 'libx264', '-preset', 'medium', '-crf', '23', '-c:a', 'copy', output_video ] print(f"Running FFmpeg command with ASS subtitles: {' '.join(cmd)}", file=sys.stderr) print(f"Working directory: {working_dir}", file=sys.stderr) # 执行 FFmpeg 命令 (使用UTF-8编码避免GBK解码错误,从工作目录执行) result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', timeout=300, cwd=working_dir) if result.returncode == 0 and os.path.exists(output_video): print(f"Video with dynamic sentiment-based subtitles generated successfully: {output_video}", file=sys.stderr) return output_video else: print(f"FFmpeg error: {result.stderr}", file=sys.stderr) # 尝试使用subtitles过滤器作为fallback print("Trying fallback with subtitles filter...", file=sys.stderr) return self._burn_with_subtitles_filter(ass_path, output_video) except subprocess.TimeoutExpired: print("FFmpeg timeout, trying fallback method...", file=sys.stderr) return self._burn_with_srt_fallback(subtitles, output_video) except Exception as e: print(f"FFmpeg failed: {e}, trying fallback method...", file=sys.stderr) return self._burn_with_srt_fallback(subtitles, output_video) def _burn_with_subtitles_filter(self, ass_path: str, output_video: str) -> str: """使用subtitles过滤器的fallback方法""" try: # 使用相对路径执行FFmpeg ass_filename = os.path.basename(ass_path) working_dir = os.path.dirname(ass_path) # 构建 FFmpeg 命令(添加字体路径支持) # 🔧 修复:使用 ziti 目录的绝对路径,而不是相对路径 '.' fontconfig = self._get_ffmpeg_fontconfig() print(f"Fallback: Adding fontconfig to FFmpeg: {fontconfig}", file=sys.stderr) # 使用subtitles过滤器而不是ass过滤器 subtitle_filter = f"subtitles={ass_filename}{fontconfig}" cmd = [ 'ffmpeg', '-y', '-i', self.video_path, '-vf', subtitle_filter, '-c:v', 'libx264', '-preset', 'medium', '-crf', '23', '-c:a', 'copy', output_video ] print(f"Running fallback FFmpeg command: {' '.join(cmd)}", file=sys.stderr) result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', timeout=300, cwd=working_dir) if result.returncode == 0 and os.path.exists(output_video): print(f"Fallback method successful: {output_video}", file=sys.stderr) return output_video else: print(f"Fallback method failed: {result.stderr}", file=sys.stderr) raise Exception(f"Both FFmpeg methods failed") except Exception as e: raise Exception(f"FFmpeg fallback also failed: {str(e)}") def _burn_with_srt_fallback(self, subtitles: List[Dict[str, Any]], output_video: str) -> str: """最终fallback:使用SRT格式""" try: # 保存SRT文件(所有字幕用default样式) srt_path = self.save_srt(subtitles) # 使用相对路径执行FFmpeg srt_filename = os.path.basename(srt_path) working_dir = os.path.dirname(srt_path) # 构建 FFmpeg 命令(添加字体路径支持) # 🔧 修复:使用 ziti 目录的绝对路径,而不是相对路径 '.' fontconfig = self._get_ffmpeg_fontconfig() print(f"SRT Fallback: Adding fontconfig to FFmpeg: {fontconfig}", file=sys.stderr) # 使用subtitles过滤器 subtitle_filter = f"subtitles={srt_filename}{fontconfig}" cmd = [ 'ffmpeg', '-y', '-i', self.video_path, '-vf', subtitle_filter, '-c:v', 'libx264', '-preset', 'medium', '-crf', '23', '-c:a', 'copy', output_video ] print(f"Running SRT fallback command: {' '.join(cmd)}", file=sys.stderr) result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', timeout=300, cwd=working_dir) if result.returncode == 0 and os.path.exists(output_video): print(f"SRT fallback successful: {output_video}", file=sys.stderr) return output_video else: raise Exception(f"All methods failed: {result.stderr}") except Exception as e: raise Exception(f"SRT fallback failed: {str(e)}") def _get_subtitle_filter(self, srt_path: str) -> str: """ 根据样式生成 FFmpeg 字幕过滤器 Args: srt_path: SRT文件路径 Returns: FFmpeg 过滤器字符串 """ # 使用双引号包围 Windows 路径 safe_path = f'"{srt_path}"' if self.subtitle_style == 'highlight': # 高亮样式:黄色背景 return f"subtitles={safe_path}:force_style='FontName=NotoSerifCJK-VF,FontSize=24,PrimaryColour=&H000000&,BackColour=&H00FFFF&,Bold=1,Outline=0,Shadow=0,MarginV=20'" elif self.subtitle_style == 'card': # 卡片样式:带背景框 return f"subtitles={safe_path}:force_style='FontName=NotoSerifCJK-VF,FontSize=24,PrimaryColour=&HFFFFFF&,BackColour=&H80000000&,Bold=1,Outline=2,Shadow=1,MarginV=20'" elif self.subtitle_style == 'neon': # 霓虹样式:彩色描边 return f"subtitles={safe_path}:force_style='FontName=NotoSerifCJK-VF,FontSize=26,PrimaryColour=&H00FFFF&,OutlineColour=&HFF00FF&,Bold=1,Outline=3,Shadow=0,MarginV=20'" elif self.subtitle_style == 'minimal': # 极简样式:细描边 return f"subtitles={safe_path}:force_style='FontName=NotoSerifCJK-VF,FontSize=22,PrimaryColour=&HFFFFFF&,OutlineColour=&H000000&,Bold=0,Outline=1,Shadow=0,MarginV=20'" else: # 默认样式:白色文字,黑色描边 return f"subtitles={safe_path}:force_style='FontName=NotoSerifCJK-VF,FontSize=24,PrimaryColour=&HFFFFFF&,OutlineColour=&H000000&,Bold=1,Outline=2,Shadow=1,MarginV=20'" def generate_all(self) -> Dict[str, Any]: """ 一键生成所有内容 Returns: 包含生成结果的字典 """ try: # 如果仅生成封面模式 if self.cover_only: print("Cover-only mode: Generating cover only...", file=sys.stderr) # 生成封面 print("Generating cover...", file=sys.stderr) cover_path = self.generate_cover() result = { 'success': True, 'coverPath': cover_path, 'message': 'Cover generated successfully' } return result # 完整生成模式 # 分析文案(NLP) print("Analyzing script with NLP...", file=sys.stderr) nlp_analysis = self.analyze_script() # 生成封面 print("Generating cover...", file=sys.stderr) cover_path = self.generate_cover() # 生成字幕 print("Generating subtitles...", file=sys.stderr) subtitles = self.generate_subtitles() # 烧录字幕到视频(传递字幕列表而不是SRT路径) print("Burning subtitles to video...", file=sys.stderr) video_path = self.burn_subtitles_to_video(subtitles) result = { 'success': True, 'coverPath': cover_path, 'videoPath': video_path, 'subtitles': subtitles, 'nlpAnalysis': nlp_analysis, # 添加NLP分析结果 'message': 'Generated successfully with sentiment-based subtitles' } return result except Exception as e: return { 'success': False, 'error': str(e), 'message': f'Generation failed: {str(e)}' } def main(): """主函数""" parser = argparse.ArgumentParser(description='Subtitle & Cover Generator (Simplified)') parser.add_argument('config_file', help='Configuration JSON file path') args = parser.parse_args() try: # 读取配置文件 with open(args.config_file, 'r', encoding='utf-8') as f: config = json.load(f) # 创建生成器 generator = SubtitleCoverGenerator(config) # 执行生成 result = generator.generate_all() # 输出结果 print(json.dumps(result, ensure_ascii=False)) sys.exit(0 if result['success'] else 1) except Exception as e: error_result = { 'success': False, 'error': str(e), 'message': f'Error: {str(e)}' } print(json.dumps(error_result, ensure_ascii=False)) sys.exit(1) if __name__ == '__main__': main()