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