Files
WYF-koubo/python/subtitle_cover_generator_simple.py
T
2026-06-19 18:45:55 +08:00

2338 lines
105 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()