This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:11 +08:00
parent 1325ff92e5
commit 7f57dfa9ed
31 changed files with 10437 additions and 12 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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