Initial commit: lesson-highlights generator
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Piano Highlight Generator App
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CLI - 命令行入口
|
||||
|
||||
用法:
|
||||
python cli.py --video video.mp4 --clips clips.yaml --output ./output
|
||||
python cli.py --config config.yaml
|
||||
python cli.py --video video.mp4 --ppt presentation.pptx --output ./output
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# Add src directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Windows控制台UTF-8编码设置(必须在logging.basicConfig之前,否则handler绑定旧stderr)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Piano Highlight Generator - CLI',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
# 主要输入模式
|
||||
parser.add_argument('--video', '-v', type=str,
|
||||
help='视频文件路径')
|
||||
parser.add_argument('--clips', '-c', type=str,
|
||||
help='clips配置文件(YAML格式)')
|
||||
parser.add_argument('--ppt', '-p', type=str,
|
||||
help='PPT/PDF文件路径 (用于自动生成clips)')
|
||||
parser.add_argument('--output', '-o', type=str, default='./output',
|
||||
help='输出目录 (默认: ./output)')
|
||||
|
||||
# 完整配置模式
|
||||
parser.add_argument('--config', '-f', type=str,
|
||||
help='完整配置文件路径')
|
||||
|
||||
# 可选参数
|
||||
parser.add_argument('--api-key', type=str,
|
||||
help='LLM API密钥')
|
||||
parser.add_argument('--api-host', type=str,
|
||||
help='LLM API地址')
|
||||
parser.add_argument('--whisper-model', type=str, default='large',
|
||||
help='Whisper模型 (默认: large)')
|
||||
parser.add_argument('--verbose', '-V', action='store_true',
|
||||
help='详细输出')
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config_from_args(args) -> dict:
|
||||
"""从命令行参数构建配置"""
|
||||
config = {
|
||||
'output_dir': args.output,
|
||||
'video_src': args.video,
|
||||
'clips': [],
|
||||
'api_key': args.api_key,
|
||||
'api_host': args.api_host,
|
||||
'whisper_model': args.whisper_model,
|
||||
'video_params': {
|
||||
'fade_duration': 1,
|
||||
'title_fontsize': 90,
|
||||
'title_color': 'FFFF00',
|
||||
'subtitle_fontsize': 24,
|
||||
'subtitle_color': 'FFFFFF',
|
||||
}
|
||||
}
|
||||
|
||||
# 从YAML加载clips
|
||||
if args.clips and os.path.exists(args.clips):
|
||||
import yaml
|
||||
with open(args.clips, 'r', encoding='utf-8') as f:
|
||||
clips_config = yaml.safe_load(f)
|
||||
if 'clips' in clips_config:
|
||||
config['clips'] = clips_config['clips']
|
||||
if 'term_corrections' in clips_config:
|
||||
config['term_corrections'] = clips_config['term_corrections']
|
||||
if 'video_params' in clips_config:
|
||||
config['video_params'].update(clips_config['video_params'])
|
||||
|
||||
# 从完整配置加载
|
||||
if args.config and os.path.exists(args.config):
|
||||
import yaml
|
||||
with open(args.config, 'r', encoding='utf-8') as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
config.update(file_config)
|
||||
|
||||
# 优先级: 命令行参数 > 配置文件
|
||||
if args.api_key:
|
||||
config['api_key'] = args.api_key
|
||||
if args.api_host:
|
||||
config['api_host'] = args.api_host
|
||||
if args.video:
|
||||
config['video_src'] = args.video
|
||||
if args.output:
|
||||
config['output_dir'] = args.output
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def generate_config_from_ppt(args) -> dict:
|
||||
"""从PPT自动生成配置"""
|
||||
from core import parse_ppt_to_config
|
||||
|
||||
def progress_callback(step, percent, message):
|
||||
logger.info(f"[{step}] {percent}%: {message}")
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("从PPT自动生成clips配置...")
|
||||
logger.info(f"视频: {args.video}")
|
||||
logger.info(f"PPT: {args.ppt}")
|
||||
logger.info(f"输出: {args.output}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
config = parse_ppt_to_config(
|
||||
video_path=args.video,
|
||||
ppt_path=args.ppt,
|
||||
output_dir=args.output,
|
||||
progress_callback=progress_callback,
|
||||
api_key=args.api_key,
|
||||
api_host=args.api_host,
|
||||
)
|
||||
|
||||
# 保存生成的配置
|
||||
config_path = os.path.join(args.output, 'generated_config.yaml')
|
||||
import yaml
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
|
||||
logger.info(f"配置已保存: {config_path}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
"""主入口"""
|
||||
# 设置FFmpeg PATH(不调用init_environment以避免编码问题)
|
||||
import shutil
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path:
|
||||
ffmpeg_bin = os.path.dirname(ffmpeg_path)
|
||||
if ffmpeg_bin not in os.environ.get('PATH', ''):
|
||||
os.environ['PATH'] = ffmpeg_bin + os.pathsep + os.environ.get('PATH', '')
|
||||
|
||||
args = parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# 没有参数时显示帮助
|
||||
if len(sys.argv) == 1:
|
||||
print("Piano Highlight Generator - CLI")
|
||||
print("=" * 50)
|
||||
print("用法:")
|
||||
print(" python cli.py --video video.mp4 --clips clips.yaml --output ./output")
|
||||
print(" python cli.py --config config.yaml")
|
||||
print(" python cli.py --video video.mp4 --ppt presentation.pptx --output ./output")
|
||||
print()
|
||||
print("完整帮助: python cli.py --help")
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 如果指定了--ppt,自动从PPT生成clips配置
|
||||
if args.ppt and args.video:
|
||||
config = generate_config_from_ppt(args)
|
||||
else:
|
||||
# 构建配置
|
||||
config = load_config_from_args(args)
|
||||
|
||||
# 验证必要参数
|
||||
if not config.get('video_src'):
|
||||
logger.error("错误: 必须指定视频文件 (--video)")
|
||||
return 1
|
||||
|
||||
if not config.get('clips'):
|
||||
logger.error("错误: 必须指定clips配置 (--clips 或 --config)")
|
||||
logger.error("或使用 --ppt 自动从PPT生成clips")
|
||||
return 1
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("Piano Highlight Generator - CLI")
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"视频: {config.get('video_src')}")
|
||||
logger.info(f"片段数: {len(config.get('clips', []))}")
|
||||
logger.info(f"输出: {config.get('output_dir')}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 创建并运行Pipeline
|
||||
from core import Pipeline
|
||||
|
||||
pipeline = Pipeline(config)
|
||||
|
||||
logger.info("开始处理...")
|
||||
final_path = pipeline.run()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"完成! 最终视频: {final_path}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("用户中断")
|
||||
return 130
|
||||
except Exception as e:
|
||||
logger.exception(f"处理失败: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Core modules for Piano Highlight Generator App
|
||||
|
||||
Exports video processing, subtitle processing, LLM client, and utilities
|
||||
"""
|
||||
|
||||
from .video import extract_clip, merge_clips, burn_subtitles, burn_dual_subtitles, VideoPipeline
|
||||
from .subtitle import SubtitleTrack, SubtitlePipeline
|
||||
from .llm import LLMClient
|
||||
from .corrections import apply_all_corrections, load_term_corrections_from_config
|
||||
from .pipeline import Pipeline, create_pipeline_from_yaml
|
||||
from .ppt_parser import PPTParser, parse_ppt_to_config
|
||||
from .utils import run_cmd, to_srt_time, ensure_dir
|
||||
from .constants import FFMPEG_CMD, FFPROBE_CMD, DEFAULT_API_HOST, DEFAULT_OUTPUT_DIR, WHISPER_MODEL_PATH
|
||||
from .errors import (
|
||||
PianoAppError, APIError, APIKeyError, APIRateLimitError, APITimeoutError,
|
||||
FileError, FileNotFoundError, FilePermissionError, DiskSpaceError,
|
||||
VideoError, VideoNotFoundError, VideoCodecError, FFmpegNotFoundError, SubtitleBurnError,
|
||||
ErrorHandler, get_error_handler, handle_error, get_user_message, USER_MESSAGES
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
环境配置和常量定义
|
||||
|
||||
所有硬编码路径和配置集中管理,确保可维护性
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
# ============== 项目根目录 ==============
|
||||
def _get_project_root():
|
||||
"""获取项目根目录"""
|
||||
# Assume this file is in src/core/, so project root is 2 levels up
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
PROJECT_ROOT = _get_project_root()
|
||||
|
||||
# ============== FFmpeg路径(动态检测)==============
|
||||
def _find_ffmpeg():
|
||||
"""动态查找FFmpeg路径"""
|
||||
# 1. Check local ffmpeg folder (for portable distribution)
|
||||
# Direct: ffmpeg/bin/ffmpeg.exe
|
||||
local_ffmpeg = os.path.join(PROJECT_ROOT, "ffmpeg", "bin")
|
||||
if os.path.exists(os.path.join(local_ffmpeg, "ffmpeg.exe")):
|
||||
return local_ffmpeg
|
||||
|
||||
# Nested: ffmpeg/ffmpeg-8.1-full_build/bin/ffmpeg.exe
|
||||
ffmpeg_base = os.path.join(PROJECT_ROOT, "ffmpeg")
|
||||
if os.path.isdir(ffmpeg_base):
|
||||
for subdir in os.listdir(ffmpeg_base):
|
||||
nested_path = os.path.join(ffmpeg_base, subdir, "bin")
|
||||
if os.path.exists(os.path.join(nested_path, "ffmpeg.exe")):
|
||||
return nested_path
|
||||
|
||||
# 2. Check system PATH
|
||||
system_ffmpeg = shutil.which("ffmpeg")
|
||||
if system_ffmpeg:
|
||||
return os.path.dirname(system_ffmpeg)
|
||||
|
||||
# 3. Return empty - will fail later with clear error
|
||||
return ""
|
||||
|
||||
FFMPEG_BIN = _find_ffmpeg()
|
||||
FFMPEG_CMD = os.path.join(FFMPEG_BIN, "ffmpeg.exe") if FFMPEG_BIN else "ffmpeg"
|
||||
FFPROBE_CMD = os.path.join(FFMPEG_BIN, "ffprobe.exe") if FFMPEG_BIN else "ffprobe"
|
||||
|
||||
# ============== Whisper模型(动态检测)==============
|
||||
def _find_whisper_model():
|
||||
"""查找Whisper模型路径"""
|
||||
# 1. Check local whisper_models folder
|
||||
local_models = os.path.join(PROJECT_ROOT, "whisper_models")
|
||||
if os.path.exists(local_models):
|
||||
# Find any model folder
|
||||
for name in os.listdir(local_models):
|
||||
model_path = os.path.join(local_models, name)
|
||||
if os.path.isdir(model_path):
|
||||
return model_path.replace('\\', '/')
|
||||
|
||||
# 2. Check environment variable
|
||||
env_path = os.environ.get("WHISPER_MODEL_PATH")
|
||||
if env_path and os.path.exists(env_path):
|
||||
return env_path.replace('\\', '/')
|
||||
|
||||
# 3. Default path (for this user's environment)
|
||||
default_path = r"D:/AI/LM-Models/faster-whisper/large-v3"
|
||||
if os.path.exists(default_path):
|
||||
return default_path.replace('\\', '/')
|
||||
|
||||
return ""
|
||||
|
||||
WHISPER_MODEL_PATH = _find_whisper_model()
|
||||
|
||||
# ============== 输出目录默认值 ==============
|
||||
DEFAULT_OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
|
||||
|
||||
# ============== 视频参数默认值 ==============
|
||||
DEFAULT_VIDEO_PARAMS = {
|
||||
"fade_duration": 1,
|
||||
"title_duration": 3,
|
||||
"title_fontsize": 90,
|
||||
"title_color": "FFFF00",
|
||||
"subtitle_fontsize": 24,
|
||||
"subtitle_color": "FFFFFF",
|
||||
"use_fast_whisper": True,
|
||||
"whisper_model": "large",
|
||||
}
|
||||
|
||||
# ============== API配置 ==============
|
||||
DEFAULT_API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
API_KEY_ENVS = ["VOLCENGINE_API_KEY", "MINIMAX_API_KEY"]
|
||||
|
||||
# ============== LLM配置 ==============
|
||||
LLM_MODEL = "doubao-seed-2.0-lite"
|
||||
LLM_TIMEOUT = 60
|
||||
LLM_MAX_RETRIES = 3
|
||||
LLM_TITLE_TIMEOUT = 60
|
||||
LLM_VALIDATE_TIMEOUT = 30
|
||||
|
||||
# ============== 字幕样式 ==============
|
||||
SUBTITLE_STYLE = "FontName=微软雅黑,FontSize=24,PrimaryColour=&H00FFFFFF"
|
||||
|
||||
# ============== Windows控制台编码 ==============
|
||||
def setup_windows_encoding():
|
||||
"""Windows控制台UTF-8编码设置"""
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
# ============== FFmpeg PATH设置 ==============
|
||||
def setup_ffmpeg_path():
|
||||
"""将FFmpeg添加到PATH(仅当使用本地FFmpeg时)"""
|
||||
if FFMPEG_BIN and FFMPEG_BIN not in os.environ.get('PATH', ''):
|
||||
os.environ['PATH'] = FFMPEG_BIN + os.pathsep + os.environ.get('PATH', '')
|
||||
|
||||
# ============== 环境初始化 ==============
|
||||
def init_environment():
|
||||
"""初始化环境设置"""
|
||||
setup_windows_encoding()
|
||||
setup_ffmpeg_path()
|
||||
|
||||
def get_api_key():
|
||||
"""获取API密钥"""
|
||||
for env_name in API_KEY_ENVS:
|
||||
key = os.environ.get(env_name)
|
||||
if key:
|
||||
return key
|
||||
return None
|
||||
@@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
字幕纠错模块
|
||||
|
||||
包含术语纠正、异常检测、上下文纠错等功能
|
||||
"""
|
||||
|
||||
import re
|
||||
from pypinyin import pinyin, Style
|
||||
|
||||
# ============== 直接替换词典 ==============
|
||||
# 格式: "错误词": "正确词"
|
||||
DIRECT_FIXES = {
|
||||
"副点": "附点",
|
||||
"拍苻": "拍符",
|
||||
"演音": "延音",
|
||||
"调苻": "调号",
|
||||
"谱苻": "谱号",
|
||||
"负点": "附点",
|
||||
"阅历": "乐理",
|
||||
"音苻": "音符",
|
||||
"首位": "手位",
|
||||
"黑剑": "黑键",
|
||||
# 新增(从lesson1经验)
|
||||
"非联奏": "非连奏",
|
||||
"任谱": "认谱",
|
||||
"实谱": "识谱",
|
||||
"任音": "认音",
|
||||
"传人": "唱人",
|
||||
"修纸符": "休止符",
|
||||
"修纸": "休止",
|
||||
"修纸整小节": "休止整小节",
|
||||
}
|
||||
|
||||
# ============== 音符名称纠错 ==============
|
||||
SONG_NAME_FIXES = {
|
||||
"Doramifasalasi": "Do Re Mi Fa So La Si",
|
||||
"刀": "do",
|
||||
"锐": "re",
|
||||
"咪": "mi",
|
||||
"发": "fa",
|
||||
"嗦": "so",
|
||||
"啦": "la",
|
||||
"西": "si",
|
||||
}
|
||||
|
||||
# ============== 异常词检测 ==============
|
||||
ANOMALY_WORDS = [
|
||||
"羞耻", "休息", # 可能是"休止"
|
||||
"实质", "时值", # 时值相关
|
||||
]
|
||||
|
||||
# ============== 音乐术语 ==============
|
||||
MUSIC_TERMS = [
|
||||
"音符", "休止符", "拍子", "节拍", "节奏",
|
||||
"全分", "二分", "四分", "八分", "十六分", "三十二分",
|
||||
"附点", "调号", "谱号", "音名", "唱名",
|
||||
"手型", "手位", "支撑", "放松",
|
||||
"弹奏", "非连奏", "跳奏", "连奏",
|
||||
]
|
||||
|
||||
# ============== 异常模式 ==============
|
||||
ANOMALY_PATTERNS = [
|
||||
(r'羞耻', '休止'),
|
||||
(r'实质音符', '时值音符'),
|
||||
(r'实质', '时值'),
|
||||
]
|
||||
|
||||
|
||||
def apply_term_corrections(text, corrections=None):
|
||||
"""
|
||||
应用术语纠正
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
corrections: 额外的纠正词典
|
||||
|
||||
Returns:
|
||||
纠正后的文本
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# 合并纠正词典
|
||||
all_fixes = dict(DIRECT_FIXES)
|
||||
if corrections:
|
||||
all_fixes.update(corrections)
|
||||
|
||||
# 先处理长词,再处理短词(避免部分替换)
|
||||
sorted_fixes = sorted(all_fixes.items(), key=lambda x: len(x[0]), reverse=True)
|
||||
|
||||
for wrong, correct in sorted_fixes:
|
||||
if wrong in text:
|
||||
text = text.replace(wrong, correct)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def apply_song_name_fixes(text):
|
||||
"""应用音符名称纠错"""
|
||||
for wrong, correct in SONG_NAME_FIXES.items():
|
||||
if wrong in text:
|
||||
text = text.replace(wrong, correct)
|
||||
return text
|
||||
|
||||
|
||||
def apply_anomaly_fixes(text):
|
||||
"""应用异常模式纠错"""
|
||||
for pattern, replacement in ANOMALY_PATTERNS:
|
||||
text = re.sub(pattern, replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
def apply_all_corrections(text, extra_corrections=None):
|
||||
"""
|
||||
应用所有纠错规则
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
extra_corrections: 额外的纠正词典(来自config)
|
||||
|
||||
Returns:
|
||||
纠错后的文本
|
||||
"""
|
||||
text = apply_term_corrections(text, extra_corrections)
|
||||
text = apply_song_name_fixes(text)
|
||||
text = apply_anomaly_fixes(text)
|
||||
return text
|
||||
|
||||
|
||||
def detect_anomalies(text, knowledge_terms=None):
|
||||
"""
|
||||
检测文本中的异常
|
||||
|
||||
Args:
|
||||
text: 文本
|
||||
knowledge_terms: 知识点列表
|
||||
|
||||
Returns:
|
||||
异常词列表
|
||||
"""
|
||||
anomalies = []
|
||||
|
||||
# 检查异常词
|
||||
for word in ANOMALY_WORDS:
|
||||
if word in text:
|
||||
anomalies.append(word)
|
||||
|
||||
# 检查是否包含知识术语
|
||||
if knowledge_terms:
|
||||
text_lower = text.lower()
|
||||
has_knowledge = any(term.lower() in text_lower for term in knowledge_terms)
|
||||
if not has_knowledge and len(text) > 10:
|
||||
anomalies.append("NO_KNOWLEDGE_TERM")
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def get_pinyin(text):
|
||||
"""获取文本的拼音"""
|
||||
try:
|
||||
return ' '.join([p[0] for p in pinyin(text, style=Style.TONE3)])
|
||||
except:
|
||||
return text
|
||||
|
||||
|
||||
def pinyin_similarity(word1, word2):
|
||||
"""
|
||||
计算两个词的拼音相似度
|
||||
|
||||
Args:
|
||||
word1: 词1
|
||||
word2: 词2
|
||||
|
||||
Returns:
|
||||
相似度分数 (0-1)
|
||||
"""
|
||||
p1 = get_pinyin(word1)
|
||||
p2 = get_pinyin(word2)
|
||||
|
||||
if p1 == p2:
|
||||
return 1.0
|
||||
|
||||
# 简单相似度计算
|
||||
common = sum(1 for c1, c2 in zip(p1, p2) if c1 == c2)
|
||||
max_len = max(len(p1), len(p2))
|
||||
|
||||
return common / max_len if max_len > 0 else 0
|
||||
|
||||
|
||||
def ai_context_correct(text, clip_title="", all_clips=None):
|
||||
"""
|
||||
上下文感知纠错(基于拼音相似度)
|
||||
|
||||
Args:
|
||||
text: 文本
|
||||
clip_title: 片段标题
|
||||
all_clips: 所有片段信息
|
||||
|
||||
Returns:
|
||||
纠错后的文本
|
||||
"""
|
||||
# 如果文本包含异常词,尝试修复
|
||||
for wrong, correct in DIRECT_FIXES.items():
|
||||
if wrong in text:
|
||||
# 检查拼音相似度
|
||||
similarity = pinyin_similarity(wrong, correct)
|
||||
if similarity > 0.5:
|
||||
text = text.replace(wrong, correct)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def load_term_corrections_from_config(config):
|
||||
"""
|
||||
从配置加载术语纠正词典
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
术语纠正词典
|
||||
"""
|
||||
term_corrections = config.get('term_corrections', {})
|
||||
|
||||
# 确保基本纠正规则存在
|
||||
defaults = {
|
||||
"副点": "附点",
|
||||
"实质": "时值",
|
||||
}
|
||||
|
||||
defaults.update(term_corrections)
|
||||
return defaults
|
||||
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Comprehensive error handling system for the piano highlight pipeline."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exception Hierarchy
|
||||
# =============================================================================
|
||||
|
||||
class PianoAppError(Exception):
|
||||
"""Base exception for all app errors"""
|
||||
|
||||
def __init__(self, message: str, recoverable: bool = True):
|
||||
self.message = message
|
||||
self.recoverable = recoverable # If True, pipeline can continue after handling
|
||||
super().__init__(self.message)
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API Errors (recoverable - can retry)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class APIError(PianoAppError):
|
||||
"""API related errors"""
|
||||
|
||||
def __init__(self, message: str, recoverable: bool = True):
|
||||
super().__init__(message, recoverable=recoverable)
|
||||
|
||||
|
||||
class APIKeyError(APIError):
|
||||
"""Invalid or expired API key - Non-recoverable"""
|
||||
|
||||
def __init__(self, message: str = "API密钥无效或已过期"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class APIRateLimitError(APIError):
|
||||
"""API rate limit exceeded - Recoverable with delay"""
|
||||
|
||||
def __init__(self, message: str = "API调用频率超限", retry_after: float = 60.0):
|
||||
super().__init__(message, recoverable=True)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class APITimeoutError(APIError):
|
||||
"""API request timeout - Recoverable"""
|
||||
|
||||
def __init__(self, message: str = "API请求超时"):
|
||||
super().__init__(message, recoverable=True)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# File Errors (recoverable depending on type)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class FileError(PianoAppError):
|
||||
"""File related errors"""
|
||||
|
||||
def __init__(self, message: str, recoverable: bool = True):
|
||||
super().__init__(message, recoverable=recoverable)
|
||||
|
||||
|
||||
class FileNotFoundError(FileError):
|
||||
"""File not found - Non-recoverable"""
|
||||
|
||||
def __init__(self, message: str = "文件不存在"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class FilePermissionError(FileError):
|
||||
"""Permission denied - Non-recoverable"""
|
||||
|
||||
def __init__(self, message: str = "文件权限不足"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class DiskSpaceError(FileError):
|
||||
"""Disk space insufficient - Recoverable after cleanup"""
|
||||
|
||||
def __init__(self, message: str = "磁盘空间不足"):
|
||||
super().__init__(message, recoverable=True)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Video Processing Errors
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class VideoError(PianoAppError):
|
||||
"""Video processing errors"""
|
||||
|
||||
def __init__(self, message: str, recoverable: bool = True):
|
||||
super().__init__(message, recoverable=recoverable)
|
||||
|
||||
|
||||
class VideoNotFoundError(VideoError):
|
||||
"""Video file not found - Non-recoverable"""
|
||||
|
||||
def __init__(self, message: str = "视频文件不存在"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class VideoCodecError(VideoError):
|
||||
"""Video codec error - May be recoverable"""
|
||||
|
||||
def __init__(self, message: str = "视频编码错误"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class FFmpegNotFoundError(VideoError):
|
||||
"""FFmpeg not found - Non-recoverable"""
|
||||
|
||||
def __init__(self, message: str = "未找到FFmpeg,请确保已正确安装并配置PATH环境变量"):
|
||||
super().__init__(message, recoverable=False)
|
||||
|
||||
|
||||
class SubtitleBurnError(VideoError):
|
||||
"""Subtitle burn failed - May be recoverable with different settings"""
|
||||
|
||||
def __init__(self, message: str = "字幕烧录失败"):
|
||||
super().__init__(message, recoverable=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User-Facing Error Messages
|
||||
# =============================================================================
|
||||
|
||||
USER_MESSAGES = {
|
||||
"api_key_invalid": "API密钥无效或已过期,请在设置中更新API密钥。",
|
||||
"api_rate_limit": "API调用频率超限,请在{}秒后重试。",
|
||||
"api_timeout": "API请求超时,请检查网络连接后重试。",
|
||||
"api_error": "API调用失败:{}",
|
||||
"ffmpeg_not_found": "未找到FFmpeg,请确保已正确安装并配置PATH环境变量。",
|
||||
"ffmpeg_error": "FFmpeg执行错误:{}",
|
||||
"video_not_found": "视频文件不存在:{}",
|
||||
"video_corrupt": "视频文件损坏或格式不支持。",
|
||||
"video_codec": "视频编码错误,不支持当前格式。",
|
||||
"file_not_found": "文件不存在:{}",
|
||||
"file_permission": "文件权限不足,无法访问:{}",
|
||||
"disk_space": "磁盘空间不足,请清理后重试。",
|
||||
"subtitle_burn": "字幕烧录失败:{}",
|
||||
"transcription_failed": "语音转录失败:{}",
|
||||
"title_correction_failed": "标题纠正失败:{}",
|
||||
"merge_failed": "视频合并失败:{}",
|
||||
"clip_extract_failed": "片段提取失败:{}",
|
||||
"unknown_error": "发生未知错误:{}",
|
||||
}
|
||||
|
||||
|
||||
def get_user_message(error_key: str, *args) -> str:
|
||||
"""
|
||||
Get user-friendly error message.
|
||||
|
||||
Args:
|
||||
error_key: Key in USER_MESSAGES dict
|
||||
*args: Format arguments for the message
|
||||
|
||||
Returns:
|
||||
Formatted user message in Chinese
|
||||
"""
|
||||
if error_key in USER_MESSAGES:
|
||||
msg = USER_MESSAGES[error_key]
|
||||
if args:
|
||||
return msg.format(*args)
|
||||
return msg
|
||||
return USER_MESSAGES["unknown_error"].format(error_key)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Error Handler Class
|
||||
# =============================================================================
|
||||
|
||||
class ErrorHandler:
|
||||
"""
|
||||
Centralized error handling with retry logic.
|
||||
|
||||
Provides consistent error handling across the pipeline with:
|
||||
- Retry logic for recoverable errors
|
||||
- User-friendly error messages
|
||||
- Recovery action callbacks
|
||||
"""
|
||||
|
||||
def __init__(self, max_retries: int = 3, base_delay: float = 1.0):
|
||||
"""
|
||||
Initialize ErrorHandler.
|
||||
|
||||
Args:
|
||||
max_retries: Maximum number of retry attempts for recoverable errors
|
||||
base_delay: Base delay in seconds between retries (doubles each retry)
|
||||
"""
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self._retry_counts = {} # Track retries per context
|
||||
|
||||
def handle(
|
||||
self,
|
||||
error: Exception,
|
||||
context: str = "",
|
||||
show_dialog_callback: Optional[Callable[[str, str, bool], str]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Handle an error with appropriate recovery strategy.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
context: Description of where the error occurred
|
||||
show_dialog_callback: Optional callback to show dialog to user.
|
||||
Signature: callback(title, message, recoverable) -> action ('retry', 'skip', 'cancel')
|
||||
|
||||
Returns:
|
||||
True if error was handled and recovery is possible
|
||||
False if error is non-recoverable or user chose to cancel
|
||||
"""
|
||||
# Log the error
|
||||
error_type = type(error).__name__
|
||||
error_msg = str(error)
|
||||
full_context = f"{context}: {error_msg}" if context else error_msg
|
||||
logger.error(f"[{error_type}] {full_context}")
|
||||
|
||||
# Determine if error is recoverable
|
||||
if isinstance(error, PianoAppError):
|
||||
recoverable = error.recoverable
|
||||
else:
|
||||
# Default for unknown exceptions - assume recoverable if it's a common transient error
|
||||
recoverable = True
|
||||
|
||||
# Get user-friendly message
|
||||
user_msg = self._get_error_key(error)
|
||||
if context:
|
||||
user_msg = f"{context}: {user_msg}"
|
||||
|
||||
# If we have a dialog callback, let user choose the action
|
||||
if show_dialog_callback:
|
||||
action = show_dialog_callback(error_type, user_msg, recoverable)
|
||||
if action == 'retry':
|
||||
return True
|
||||
elif action == 'skip':
|
||||
return True
|
||||
else: # cancel
|
||||
return False
|
||||
|
||||
# Default behavior: return whether error is recoverable
|
||||
return recoverable
|
||||
|
||||
def _get_error_key(self, error: Exception) -> str:
|
||||
"""Map exception to user message key."""
|
||||
error_type = type(error).__name__
|
||||
|
||||
key_mapping = {
|
||||
APIKeyError: "api_key_invalid",
|
||||
APIRateLimitError: "api_rate_limit",
|
||||
APITimeoutError: "api_timeout",
|
||||
FFmpegNotFoundError: "ffmpeg_not_found",
|
||||
FileNotFoundError: "file_not_found",
|
||||
FilePermissionError: "file_permission",
|
||||
DiskSpaceError: "disk_space",
|
||||
VideoNotFoundError: "video_not_found",
|
||||
VideoCodecError: "video_codec",
|
||||
SubtitleBurnError: "subtitle_burn",
|
||||
}
|
||||
|
||||
if type(error) in key_mapping:
|
||||
return get_user_message(key_mapping[type(error)])
|
||||
elif isinstance(error, APIError):
|
||||
return get_user_message("api_error", str(error))
|
||||
elif isinstance(error, FileError):
|
||||
return get_user_message("file_not_found", str(error))
|
||||
elif isinstance(error, VideoError):
|
||||
return get_user_message("video_corrupt")
|
||||
else:
|
||||
return get_user_message("unknown_error", str(error))
|
||||
|
||||
def with_retry(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
context: str = "",
|
||||
show_dialog_callback: Optional[Callable[[str, str, bool], str]] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Execute function with retry logic for recoverable errors.
|
||||
|
||||
Args:
|
||||
func: Function to execute
|
||||
*args: Positional arguments for func
|
||||
context: Description for error messages
|
||||
show_dialog_callback: Optional dialog callback
|
||||
**kwargs: Keyword arguments for func
|
||||
|
||||
Returns:
|
||||
Return value of func if successful
|
||||
|
||||
Raises:
|
||||
The original exception if all retries fail
|
||||
"""
|
||||
retry_key = context or str(func)
|
||||
self._retry_counts[retry_key] = 0
|
||||
|
||||
last_error = None
|
||||
delay = self.base_delay
|
||||
|
||||
while self._retry_counts[retry_key] <= self.max_retries:
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
# Success - reset retry count
|
||||
if retry_key in self._retry_counts:
|
||||
del self._retry_counts[retry_key]
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
self._retry_counts[retry_key] += 1
|
||||
|
||||
# Check if error is recoverable
|
||||
if isinstance(e, PianoAppError) and not e.recoverable:
|
||||
logger.error(f"Non-recoverable error in {context}: {e}")
|
||||
raise
|
||||
|
||||
# Check if we have retries left
|
||||
if self._retry_counts[retry_key] <= self.max_retries:
|
||||
retry_msg = f"Retry {self._retry_counts[retry_key]}/{self.max_retries} after {delay}s"
|
||||
logger.warning(f"{context}: {e}. {retry_msg}")
|
||||
|
||||
# Handle rate limiting specially
|
||||
if isinstance(e, APIRateLimitError) and e.retry_after:
|
||||
delay = max(delay, e.retry_after)
|
||||
|
||||
# Wait before retry
|
||||
time.sleep(delay)
|
||||
delay *= 2 # Exponential backoff
|
||||
else:
|
||||
logger.error(f"Max retries exceeded for {context}")
|
||||
break
|
||||
|
||||
# All retries exhausted
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def reset_retry_count(self, context: str = ""):
|
||||
"""Reset retry count for a specific context."""
|
||||
if context in self._retry_counts:
|
||||
del self._retry_counts[context]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global Error Handler Instance
|
||||
# =============================================================================
|
||||
|
||||
_default_error_handler: Optional[ErrorHandler] = None
|
||||
|
||||
|
||||
def get_error_handler() -> ErrorHandler:
|
||||
"""Get or create the global error handler instance."""
|
||||
global _default_error_handler
|
||||
if _default_error_handler is None:
|
||||
_default_error_handler = ErrorHandler()
|
||||
return _default_error_handler
|
||||
|
||||
|
||||
def handle_error(
|
||||
error: Exception,
|
||||
context: str = "",
|
||||
show_dialog_callback: Optional[Callable[[str, str, bool], str]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Convenience function to handle an error using the global error handler.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
context: Description of where the error occurred
|
||||
show_dialog_callback: Optional callback to show dialog to user
|
||||
|
||||
Returns:
|
||||
True if error was handled and recovery is possible
|
||||
"""
|
||||
return get_error_handler().handle(error, context, show_dialog_callback)
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LLM调用封装
|
||||
|
||||
统一管理火山方舟API调用,包含重试和错误处理
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from .constants import (
|
||||
DEFAULT_API_HOST, LLM_MODEL, LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES, LLM_TITLE_TIMEOUT, LLM_VALIDATE_TIMEOUT,
|
||||
get_api_key
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""LLM客户端封装"""
|
||||
|
||||
def __init__(self, api_key=None, api_host=None):
|
||||
# 优先使用传入的参数,其次使用环境变量
|
||||
self.api_key = api_key or get_api_key()
|
||||
self.api_host = api_host or DEFAULT_API_HOST
|
||||
if not self.api_key:
|
||||
logger.warning("No API key configured - LLM calls will be skipped")
|
||||
|
||||
def chat(self, prompt, max_tokens=500, timeout=LLM_TIMEOUT):
|
||||
"""
|
||||
发送聊天请求到LLM
|
||||
|
||||
Args:
|
||||
prompt: 提示词
|
||||
max_tokens: 最大token数
|
||||
timeout: 超时时间
|
||||
|
||||
Returns:
|
||||
LLM回复文本,失败返回None
|
||||
"""
|
||||
if not self.api_key:
|
||||
logger.info("LLM: No API key, skipping")
|
||||
return None
|
||||
|
||||
url = f"{self.api_host}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": LLM_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
for attempt in range(LLM_MAX_RETRIES):
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
# 401错误立即停止,不重试
|
||||
if response.status_code == 401:
|
||||
logger.error(f"LLM: 401 Unauthorized - API key invalid, stopping immediately")
|
||||
return None
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
choices = result.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning(f"LLM: No choices in response (attempt {attempt+1})")
|
||||
continue
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "").strip()
|
||||
if content:
|
||||
return content
|
||||
|
||||
logger.warning(f"LLM: Empty content (attempt {attempt+1})")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"LLM: Timeout (attempt {attempt+1}/{LLM_MAX_RETRIES})")
|
||||
if attempt < LLM_MAX_RETRIES - 1:
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM: Error - {e}")
|
||||
if attempt < LLM_MAX_RETRIES - 1:
|
||||
time.sleep(1)
|
||||
|
||||
return None
|
||||
|
||||
def correct_title(self, transcript_text, original_title, all_titles=None):
|
||||
"""
|
||||
使用LLM纠正标题
|
||||
|
||||
Args:
|
||||
transcript_text: 字幕文本
|
||||
original_title: 原始标题
|
||||
all_titles: 所有标题列表
|
||||
|
||||
Returns:
|
||||
纠正后的标题
|
||||
"""
|
||||
titles_str = ", ".join(all_titles[:20]) if all_titles else "无"
|
||||
|
||||
prompt = f"""你是一个钢琴教学视频的标题验证专家。
|
||||
|
||||
PPT提取的标题:{original_title}
|
||||
|
||||
视频字幕内容:{transcript_text[:500] if transcript_text else "无"}
|
||||
|
||||
本节课所有标题:{titles_str}
|
||||
|
||||
【重要规则】
|
||||
- 只有当你有90%以上把握认为原标题错误时,才输出纠正后的标题
|
||||
- 如果原标题基本正确,即使不完美,也必须输出原标题
|
||||
- 绝对不能输出与原标题完全不同概念的词
|
||||
- 如果不确定,输出原标题
|
||||
|
||||
请直接输出标题,不要添加任何解释。"""
|
||||
|
||||
result = self.chat(prompt, max_tokens=50, timeout=LLM_TITLE_TIMEOUT)
|
||||
return result if result else original_title
|
||||
|
||||
def validate_content(self, transcript_text, title):
|
||||
"""
|
||||
使用LLM验证内容是否与标题相关
|
||||
|
||||
Args:
|
||||
transcript_text: 字幕文本
|
||||
title: 标题
|
||||
|
||||
Returns:
|
||||
(is_valid: bool, reason: str)
|
||||
"""
|
||||
prompt = f"""判断视频字幕内容是否与标题相关。
|
||||
|
||||
标题:{title}
|
||||
|
||||
字幕内容:{transcript_text[:300] if transcript_text else "无"}
|
||||
|
||||
判断标准:
|
||||
- 内容讨论的主题与标题概念相关 = 相关
|
||||
- 内容与标题无关(如广告、闲聊、无关话题)= 无关
|
||||
- 无法判断 = 不确定
|
||||
|
||||
请直接输出:相关/无关/不确定"""
|
||||
|
||||
result = self.chat(prompt, max_tokens=20, timeout=LLM_VALIDATE_TIMEOUT)
|
||||
if not result:
|
||||
return True, "error"
|
||||
|
||||
if "无关" in result:
|
||||
return False, result
|
||||
elif "不确定" in result:
|
||||
return True, "uncertain"
|
||||
return True, result
|
||||
|
||||
def full_text_correction(self, text, clip_title, knowledge_terms=None):
|
||||
"""
|
||||
使用LLM进行全文字幕纠错
|
||||
|
||||
Args:
|
||||
text: 原始字幕
|
||||
clip_title: 片段标题
|
||||
knowledge_terms: 知识点列表
|
||||
|
||||
Returns:
|
||||
纠错后的字幕
|
||||
"""
|
||||
knowledge_str = ", ".join(knowledge_terms[:20]) if knowledge_terms else "无"
|
||||
|
||||
prompt = f"""你是一个钢琴教学视频的字幕纠错专家。
|
||||
|
||||
原始字幕:{text}
|
||||
|
||||
本节课片段标题:{clip_title}
|
||||
本节课知识点:{knowledge_str}
|
||||
|
||||
请进行字幕纠错:
|
||||
1. 修复语音识别错误(如"羞耻"→"休止","副点"→"附点","负点"→"附点")
|
||||
2. 修复同音字错误
|
||||
3. 保留原文的专业术语和表达方式
|
||||
4. 不要改变原文的语气和意思
|
||||
|
||||
请直接输出纠错后的字幕,不要添加任何解释。"""
|
||||
|
||||
result = self.chat(prompt, max_tokens=500, timeout=LLM_TIMEOUT)
|
||||
return result if result else text
|
||||
|
||||
|
||||
# 全局LLM客户端实例
|
||||
_llm_client = None
|
||||
|
||||
|
||||
def get_llm_client():
|
||||
"""获取LLM客户端单例"""
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
_llm_client = LLMClient()
|
||||
return _llm_client
|
||||
@@ -0,0 +1,518 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pipeline - 核心业务逻辑
|
||||
|
||||
统一管理从视频提取到最终输出的完整流程
|
||||
UI和CLI共用同一套逻辑
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Optional, List, Dict, Any
|
||||
|
||||
from .video import extract_clip, merge_clips, burn_dual_subtitles
|
||||
from .subtitle import SubtitlePipeline
|
||||
from .llm import LLMClient
|
||||
from .corrections import apply_all_corrections, load_term_corrections_from_config
|
||||
from .utils import ensure_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""
|
||||
精华视频生成流水线
|
||||
|
||||
使用方法:
|
||||
# CLI模式
|
||||
pipeline = Pipeline(config)
|
||||
pipeline.run()
|
||||
|
||||
# UI模式 (带回调)
|
||||
pipeline = Pipeline(config, progress_callback=my_callback)
|
||||
pipeline.run()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict,
|
||||
progress_callback: Optional[Callable[[str, int, str], None]] = None,
|
||||
step_callback: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
"""
|
||||
初始化流水线
|
||||
|
||||
Args:
|
||||
config: 配置字典,包含:
|
||||
- video_src: 视频路径
|
||||
- clips: [{title, start, end}, ...]
|
||||
- output_dir: 输出目录
|
||||
- api_key: LLM API密钥
|
||||
- api_host: LLM API地址
|
||||
- whisper_model_path: Whisper模型路径
|
||||
- term_corrections: 术语纠正字典
|
||||
- video_params: 视频参数
|
||||
progress_callback: 进度回调 (step, percent, message)
|
||||
step_callback: 步骤开始/完成回调 (step_name)
|
||||
"""
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback if progress_callback else (lambda s, p, m: logger.info(f"[{s}] {p}%: {m}"))
|
||||
self.step_callback = step_callback if step_callback else (lambda s: None)
|
||||
|
||||
# 路径
|
||||
self.output_dir = config.get('output_dir', './output')
|
||||
self.inter_dir = ensure_dir(os.path.join(self.output_dir, 'intermediates'))
|
||||
self.subs_dir = ensure_dir(os.path.join(self.output_dir, 'subs'))
|
||||
|
||||
# 配置
|
||||
self.clips = config.get('clips', [])
|
||||
self.video_src = config.get('video_src')
|
||||
self.video_params = config.get('video_params', {})
|
||||
self.fade_duration = self.video_params.get('fade_duration', 1)
|
||||
|
||||
# LLM客户端 (延迟初始化)
|
||||
self._llm_client = None
|
||||
|
||||
# 字幕处理
|
||||
self._subtitle_pipeline = None
|
||||
|
||||
# 术语纠正
|
||||
self.term_corrections = load_term_corrections_from_config(config)
|
||||
|
||||
@property
|
||||
def llm_client(self) -> LLMClient:
|
||||
if self._llm_client is None:
|
||||
self._llm_client = LLMClient(
|
||||
api_key=self.config.get('api_key'),
|
||||
api_host=self.config.get('api_host')
|
||||
)
|
||||
return self._llm_client
|
||||
|
||||
@property
|
||||
def subtitle_pipeline(self) -> SubtitlePipeline:
|
||||
if self._subtitle_pipeline is None:
|
||||
self._subtitle_pipeline = SubtitlePipeline(self.config, self.output_dir)
|
||||
return self._subtitle_pipeline
|
||||
|
||||
# ==================== 步骤方法 ====================
|
||||
|
||||
def step_extract(self) -> List[str]:
|
||||
"""
|
||||
Step 1: 提取视频片段
|
||||
|
||||
Returns:
|
||||
clip_paths: 提取的片段路径列表
|
||||
"""
|
||||
self.step_callback('extracting')
|
||||
self.progress_callback('extracting', 0, "开始提取片段...")
|
||||
|
||||
if not self.clips:
|
||||
raise ValueError("No clips configured")
|
||||
if not self.video_src or not os.path.exists(self.video_src):
|
||||
raise ValueError(f"Video file not found: {self.video_src}")
|
||||
|
||||
clip_paths = []
|
||||
total = len(self.clips)
|
||||
|
||||
for i, clip in enumerate(self.clips, 1):
|
||||
clip_path = os.path.join(self.inter_dir, f"clip{i}.mp4")
|
||||
fade_path = os.path.join(self.inter_dir, f"clip{i}_fade.mp4")
|
||||
|
||||
# 提取片段
|
||||
success = extract_clip(
|
||||
self.video_src,
|
||||
clip['start'],
|
||||
clip['end'],
|
||||
clip_path,
|
||||
fade_duration=0 # 先不添加淡出
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed to extract clip {i}")
|
||||
continue
|
||||
|
||||
# 如果需要淡入淡出
|
||||
if self.fade_duration > 0:
|
||||
duration = clip['end'] - clip['start']
|
||||
fade_out_start = max(0, duration - self.fade_duration)
|
||||
|
||||
from .constants import FFMPEG_CMD
|
||||
from .utils import run_cmd
|
||||
|
||||
cmd = f'"{FFMPEG_CMD}" -y -i "{clip_path}" '
|
||||
cmd += f'-vf "fade=t=in:st=0:d={self.fade_duration},fade=t=out:st={fade_out_start}:d={self.fade_duration}" '
|
||||
cmd += f'-c:v libx264 -crf 20 -c:a aac -y "{fade_path}"'
|
||||
|
||||
if run_cmd(cmd):
|
||||
clip_paths.append(fade_path)
|
||||
else:
|
||||
clip_paths.append(clip_path)
|
||||
else:
|
||||
clip_paths.append(clip_path)
|
||||
|
||||
percent = int((i / total) * 100)
|
||||
self.progress_callback('extracting', percent, f"提取片段 {i}/{total}")
|
||||
|
||||
self.progress_callback('extracting', 100, f"提取完成,共 {len(clip_paths)} 个片段")
|
||||
self.step_callback('extracting')
|
||||
return clip_paths
|
||||
|
||||
def step_transcribe(self, clip_paths: List[str]) -> List[str]:
|
||||
"""
|
||||
Step 2: 转录片段
|
||||
|
||||
Args:
|
||||
clip_paths: 片段路径列表
|
||||
|
||||
Returns:
|
||||
json_paths: JSON转录文件路径列表
|
||||
"""
|
||||
self.step_callback('transcribing')
|
||||
self.progress_callback('transcribing', 0, "开始转录...")
|
||||
|
||||
# 延迟导入,避免没有faster-whisper时无法import
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError:
|
||||
logger.warning("faster-whisper not available, skipping transcription")
|
||||
self.progress_callback('transcribing', 100, "faster-whisper未安装,跳过转录")
|
||||
self.step_callback('transcribing')
|
||||
return []
|
||||
|
||||
model_path = self.config.get('whisper_model_path')
|
||||
model_name = self.config.get('whisper_model', 'large')
|
||||
|
||||
# 加载模型
|
||||
self.progress_callback('transcribing', 5, "加载Whisper模型...")
|
||||
model = WhisperModel(model_path or model_name, compute_type="float16")
|
||||
|
||||
# 通过YAML配置hash检测配置是否改变,如果改变则删除所有旧JSON
|
||||
import hashlib
|
||||
config_str = str([(c['start'], c['end'], c.get('title', '')) for c in self.clips])
|
||||
config_hash = hashlib.md5(config_str.encode()).hexdigest()
|
||||
hash_file = os.path.join(self.inter_dir, '.config_hash')
|
||||
old_hash = None
|
||||
if os.path.exists(hash_file):
|
||||
with open(hash_file, 'r') as f:
|
||||
old_hash = f.read().strip()
|
||||
if old_hash != config_hash:
|
||||
# 配置变了,删除所有旧JSON
|
||||
for f in os.listdir(self.inter_dir):
|
||||
if f.startswith('clip') and f.endswith('.json'):
|
||||
os.remove(os.path.join(self.inter_dir, f))
|
||||
logger.info(f"清理旧JSON: {f} (配置已改变)")
|
||||
with open(hash_file, 'w') as f:
|
||||
f.write(config_hash)
|
||||
logger.info("配置已更新,清除所有旧JSON,重新转录")
|
||||
|
||||
json_paths = []
|
||||
total = len(clip_paths)
|
||||
|
||||
for i, clip_path in enumerate(clip_paths, 1):
|
||||
json_path = os.path.join(self.inter_dir, f"clip{i}.json")
|
||||
json_paths.append(json_path)
|
||||
|
||||
# 如果JSON已存在,跳过
|
||||
if os.path.exists(json_path):
|
||||
logger.info(f"Clip {i}: JSON exists, skipping")
|
||||
self.progress_callback('transcribing', int((i/total)*100), f"跳过片段 {i} (已存在)")
|
||||
continue
|
||||
|
||||
# 转录
|
||||
self.progress_callback('transcribing', int((i/total)*90), f"转录片段 {i}/{total}")
|
||||
|
||||
try:
|
||||
segments, _ = model.transcribe(clip_path, language='zh', beam_size=5)
|
||||
|
||||
# 保存转录结果
|
||||
segments_data = []
|
||||
for seg in segments:
|
||||
segments_data.append({
|
||||
'start': seg.start,
|
||||
'end': seg.end,
|
||||
'text': seg.text.strip()
|
||||
})
|
||||
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({'segments': segments_data}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Transcribed clip {i}: {json_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to transcribe clip {i}: {e}")
|
||||
|
||||
# 不手动 del model —— CUDA 上下文在 Windows 下销毁时容易触发
|
||||
# Access Violation (0xC0000005),让进程自然释放即可。
|
||||
|
||||
self.progress_callback('transcribing', 100, "转录完成")
|
||||
self.step_callback('transcribing')
|
||||
return json_paths
|
||||
|
||||
def step_correct_titles(self, json_paths: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Step 3: LLM标题纠正
|
||||
|
||||
Args:
|
||||
json_paths: JSON文件路径列表
|
||||
|
||||
Returns:
|
||||
corrected_clips: 纠正后的片段配置列表
|
||||
"""
|
||||
self.step_callback('title_correcting')
|
||||
self.progress_callback('title_correcting', 0, "开始标题纠正...")
|
||||
|
||||
corrected_clips = []
|
||||
total = len(self.clips)
|
||||
|
||||
for i, (clip, json_path) in enumerate(zip(self.clips, json_paths), 1):
|
||||
original_title = clip.get('title', f'Clip {i}')
|
||||
|
||||
# 读取转录文本
|
||||
transcript_text = ''
|
||||
if json_path and os.path.exists(json_path):
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
transcript_text = ' '.join(seg.get('text', '') for seg in data.get('segments', []))
|
||||
|
||||
# LLM纠正标题
|
||||
corrected_title = original_title
|
||||
if transcript_text and self.config.get('api_key'):
|
||||
try:
|
||||
corrected_title = self.llm_client.correct_title(
|
||||
transcript_text,
|
||||
original_title,
|
||||
[c.get('title', '') for c in self.clips]
|
||||
) or original_title
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM title correction failed for clip {i}: {e}")
|
||||
|
||||
corrected_clip = {
|
||||
'index': i - 1,
|
||||
'title': corrected_title,
|
||||
'original_title': original_title,
|
||||
'start': clip['start'],
|
||||
'end': clip['end'],
|
||||
}
|
||||
corrected_clips.append(corrected_clip)
|
||||
|
||||
percent = int((i / total) * 100)
|
||||
self.progress_callback('title_correcting', percent, f"纠正标题 {i}/{total}")
|
||||
|
||||
self.progress_callback('title_correcting', 100, "标题纠正完成")
|
||||
self.step_callback('title_correcting')
|
||||
return corrected_clips
|
||||
|
||||
def step_generate_subtitles(self, corrected_clips: List[Dict], json_paths: List[str]) -> tuple:
|
||||
"""
|
||||
Step 4: 生成字幕
|
||||
|
||||
Args:
|
||||
corrected_clips: 纠正后的片段配置
|
||||
json_paths: JSON文件路径列表
|
||||
|
||||
Returns:
|
||||
(title_path, content_path): 字幕文件路径
|
||||
"""
|
||||
self.step_callback('generating_subtitles')
|
||||
self.progress_callback('generating_subtitles', 0, "开始生成字幕...")
|
||||
|
||||
# 准备clip配置
|
||||
clip_configs = []
|
||||
valid_json_paths = []
|
||||
|
||||
for i, (clip, json_path) in enumerate(zip(corrected_clips, json_paths), 1):
|
||||
clip_config = {
|
||||
'index': i - 1,
|
||||
'start': clip['start'],
|
||||
'end': clip['end'],
|
||||
'title': clip.get('title', clip.get('original_title', '')),
|
||||
}
|
||||
clip_configs.append(clip_config)
|
||||
|
||||
if json_path and os.path.exists(json_path):
|
||||
valid_json_paths.append(json_path)
|
||||
else:
|
||||
valid_json_path = os.path.join(self.inter_dir, f"clip{i}.json")
|
||||
if os.path.exists(valid_json_path):
|
||||
valid_json_paths.append(valid_json_path)
|
||||
|
||||
if not valid_json_paths:
|
||||
raise ValueError("No valid JSON files for subtitle generation")
|
||||
|
||||
# 纠错函数
|
||||
def correct(text):
|
||||
return apply_all_corrections(text, self.term_corrections)
|
||||
|
||||
self.progress_callback('generating_subtitles', 50, "生成字幕轨道...")
|
||||
|
||||
# 生成字幕
|
||||
_, _, title_path, content_path = self.subtitle_pipeline.generate_from_clips(
|
||||
clip_configs,
|
||||
valid_json_paths,
|
||||
apply_corrections=correct
|
||||
)
|
||||
|
||||
self.progress_callback('generating_subtitles', 100, "字幕生成完成")
|
||||
self.step_callback('generating_subtitles')
|
||||
return title_path, content_path
|
||||
|
||||
def step_merge(self, clip_paths: List[str]) -> str:
|
||||
"""
|
||||
Step 5: 合并视频
|
||||
|
||||
Args:
|
||||
clip_paths: 片段路径列表
|
||||
|
||||
Returns:
|
||||
merged_path: 合并后的视频路径
|
||||
"""
|
||||
self.step_callback('merging')
|
||||
self.progress_callback('merging', 0, "开始合并视频...")
|
||||
|
||||
if not clip_paths:
|
||||
raise ValueError("No clips to merge")
|
||||
|
||||
merged_path = os.path.join(self.output_dir, "concat_merged.mp4")
|
||||
|
||||
success = merge_clips(clip_paths, merged_path, self.inter_dir)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError("Failed to merge clips")
|
||||
|
||||
self.progress_callback('merging', 100, f"合并完成: {merged_path}")
|
||||
self.step_callback('merging')
|
||||
return merged_path
|
||||
|
||||
def step_burn(self, merged_path: str, title_path: str, content_path: str) -> str:
|
||||
"""
|
||||
Step 6: 烧录字幕
|
||||
|
||||
Args:
|
||||
merged_path: 合并后的视频路径
|
||||
title_path: 标题字幕路径
|
||||
content_path: 正文字幕路径
|
||||
|
||||
Returns:
|
||||
final_path: 最终视频路径
|
||||
"""
|
||||
self.step_callback('burning')
|
||||
self.progress_callback('burning', 0, "开始烧录字幕...")
|
||||
|
||||
if not os.path.exists(merged_path):
|
||||
raise ValueError(f"Merged video not found: {merged_path}")
|
||||
|
||||
final_path = os.path.join(self.output_dir, "final.mp4")
|
||||
|
||||
video_params = self.config.get('video_params', {})
|
||||
|
||||
success = burn_dual_subtitles(
|
||||
merged_path,
|
||||
title_path,
|
||||
content_path,
|
||||
final_path,
|
||||
title_fontsize=video_params.get('title_fontsize', 90),
|
||||
title_color=video_params.get('title_color', 'FFFF00'),
|
||||
subtitle_fontsize=video_params.get('subtitle_fontsize', 24),
|
||||
subtitle_color=video_params.get('subtitle_color', 'FFFFFF')
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError("Failed to burn subtitles")
|
||||
|
||||
self.progress_callback('burning', 100, f"完成: {final_path}")
|
||||
self.step_callback('burning')
|
||||
return final_path
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def run(self) -> str:
|
||||
"""
|
||||
运行完整流水线
|
||||
|
||||
Returns:
|
||||
final_path: 最终视频路径
|
||||
|
||||
Raises:
|
||||
ValueError: 配置错误
|
||||
RuntimeError: 处理失败
|
||||
"""
|
||||
logger.info(f"Pipeline starting: {len(self.clips)} clips, output: {self.output_dir}")
|
||||
|
||||
# Step 1: 提取
|
||||
clip_paths = self.step_extract()
|
||||
if not clip_paths:
|
||||
raise RuntimeError("No clips extracted")
|
||||
|
||||
# Step 2: 转录
|
||||
json_paths = self.step_transcribe(clip_paths)
|
||||
|
||||
# Step 3: 标题纠正
|
||||
corrected_clips = self.step_correct_titles(json_paths)
|
||||
|
||||
# Step 4: 生成字幕
|
||||
title_path, content_path = self.step_generate_subtitles(corrected_clips, json_paths)
|
||||
|
||||
# Step 5: 合并
|
||||
merged_path = self.step_merge(clip_paths)
|
||||
|
||||
# Step 6: 烧录
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
|
||||
logger.info(f"Pipeline completed: {final_path}")
|
||||
return final_path
|
||||
|
||||
def run_with_user_confirm(self, confirmed_titles: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
运行流水线,在标题纠正后等待用户确认
|
||||
|
||||
Args:
|
||||
confirmed_titles: 用户确认后的标题列表
|
||||
|
||||
Returns:
|
||||
final_path: 最终视频路径
|
||||
"""
|
||||
logger.info(f"Pipeline starting with user confirmation: {len(self.clips)} clips")
|
||||
|
||||
# Step 1-3: 同上
|
||||
clip_paths = self.step_extract()
|
||||
if not clip_paths:
|
||||
raise RuntimeError("No clips extracted")
|
||||
|
||||
json_paths = self.step_transcribe(clip_paths)
|
||||
corrected_clips = self.step_correct_titles(json_paths)
|
||||
|
||||
# 应用用户确认的标题
|
||||
for i, confirmed in enumerate(confirmed_titles):
|
||||
if i < len(corrected_clips):
|
||||
corrected_clips[i]['title'] = confirmed.get('title', corrected_clips[i]['title'])
|
||||
|
||||
# Step 4-6: 同上
|
||||
title_path, content_path = self.step_generate_subtitles(corrected_clips, json_paths)
|
||||
merged_path = self.step_merge(clip_paths)
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
|
||||
logger.info(f"Pipeline completed: {final_path}")
|
||||
return final_path
|
||||
|
||||
|
||||
def create_pipeline_from_yaml(config_path: str, **kwargs) -> Pipeline:
|
||||
"""
|
||||
从YAML配置文件创建Pipeline
|
||||
|
||||
Args:
|
||||
config_path: 配置文件路径
|
||||
**kwargs: 额外配置参数
|
||||
|
||||
Returns:
|
||||
Pipeline实例
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# 合并额外参数
|
||||
config.update(kwargs)
|
||||
|
||||
return Pipeline(config, **kwargs)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
字幕处理模块
|
||||
|
||||
包含字幕生成、SRT格式转换、纠错等功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from .utils import to_srt_time, to_ass_time, ensure_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubtitleSegment:
|
||||
"""字幕片段"""
|
||||
|
||||
def __init__(self, start, end, text, style=None):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.text = text
|
||||
self.style = style # 'title' or 'content'
|
||||
|
||||
def to_srt_line(self):
|
||||
"""转换为SRT格式"""
|
||||
return f"{to_srt_time(self.start)} --> {to_srt_time(self.end)}"
|
||||
|
||||
def to_ass_line(self):
|
||||
"""转换为ASS格式"""
|
||||
# ASS format: Start --> End
|
||||
return f"{to_ass_time(self.start)} --> {to_ass_time(self.end)}"
|
||||
|
||||
|
||||
class SubtitleTrack:
|
||||
"""字幕轨道"""
|
||||
|
||||
def __init__(self, style=None):
|
||||
self.segments = []
|
||||
self.style = style # 可以是 'title' 或 'content'
|
||||
|
||||
def add(self, start, end, text, style=None):
|
||||
"""添加字幕段"""
|
||||
seg = SubtitleSegment(start, end, text)
|
||||
seg.style = style or self.style
|
||||
self.segments.append(seg)
|
||||
|
||||
def to_srt(self, with_index=True):
|
||||
"""
|
||||
转换为SRT格式
|
||||
|
||||
Args:
|
||||
with_index: 是否包含序号
|
||||
|
||||
Returns:
|
||||
SRT格式字符串
|
||||
"""
|
||||
lines = []
|
||||
for i, seg in enumerate(self.segments, 1):
|
||||
if with_index:
|
||||
lines.append(str(i))
|
||||
lines.append(seg.to_srt_line())
|
||||
lines.append(seg.text)
|
||||
lines.append('')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def to_ass(self, style_name="Default", font_size=24, primary_color="FFFFFF", alignment=2):
|
||||
"""
|
||||
转换为ASS格式
|
||||
|
||||
Args:
|
||||
style_name: 样式名称
|
||||
font_size: 字体大小
|
||||
primary_color: 颜色(HTML格式)
|
||||
alignment: 对齐方式 (5=正中, 2=底部居中)
|
||||
|
||||
Returns:
|
||||
ASS格式字符串
|
||||
"""
|
||||
# ASS header
|
||||
ass_lines = [
|
||||
"[Script Info]",
|
||||
"Title: Generated by piano-lesson-highlight-generator",
|
||||
"ScriptType: v4.00+",
|
||||
"PlayResX: 1920",
|
||||
"PlayResY: 1080",
|
||||
"WrapStyle: 0",
|
||||
"",
|
||||
"[V4+ Styles]",
|
||||
f"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
]
|
||||
|
||||
# 转换HTML颜色到ASS格式 (BGR with &H prefix)
|
||||
def html_to_ass_bgr(color):
|
||||
if color.startswith('&H'):
|
||||
return color
|
||||
r = int(color[0:2], 16)
|
||||
g = int(color[2:4], 16)
|
||||
b = int(color[4:6], 16)
|
||||
return f"&H{b:02X}{g:02X}{r:02X}"
|
||||
|
||||
primary_bgr = html_to_ass_bgr(primary_color)
|
||||
# Outline颜色为黑色
|
||||
outline_bgr = "&H000000"
|
||||
# BackColour (阴影)为半透明黑色
|
||||
shadow_bgr = "&H80000000"
|
||||
|
||||
# Style行
|
||||
style_line = (
|
||||
f"Style: {style_name},微软雅黑,{font_size},{primary_bgr},"
|
||||
f"{primary_bgr},{outline_bgr},{shadow_bgr},0,0,0,0,100,100,0,0,1,2,2,"
|
||||
f"{alignment},10,10,30,1"
|
||||
)
|
||||
ass_lines.append(style_line)
|
||||
ass_lines.append("")
|
||||
ass_lines.append("[Events]")
|
||||
ass_lines.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text")
|
||||
|
||||
# 添加字幕段
|
||||
for seg in self.segments:
|
||||
# Layer=0, Style=name, Name=, Margins=0, Effect=
|
||||
line = (
|
||||
f"Dialogue: 0,{seg.to_ass_line()},{style_name},"
|
||||
f"0,0,0,0,," # Margins and Effect
|
||||
f"{seg.text.replace(chr(10), '\\N')}"
|
||||
)
|
||||
ass_lines.append(line)
|
||||
|
||||
return '\n'.join(ass_lines)
|
||||
|
||||
def save(self, path):
|
||||
"""保存到文件"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(self.to_srt())
|
||||
logger.info(f"Saved subtitles: {path}")
|
||||
|
||||
def save_ass(self, path, style_name="Default", font_size=24, primary_color="FFFFFF", alignment=2):
|
||||
"""保存为ASS格式"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(self.to_ass(style_name, font_size, primary_color, alignment))
|
||||
logger.info(f"Saved ASS subtitles: {path}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_path, title=None):
|
||||
"""
|
||||
从JSON文件加载字幕
|
||||
|
||||
Args:
|
||||
json_path: JSON文件路径
|
||||
title: 可选的标题
|
||||
|
||||
Returns:
|
||||
SubtitleTrack对象
|
||||
"""
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
track = cls()
|
||||
|
||||
# 添加标题
|
||||
if title:
|
||||
track.add(0, 3, title)
|
||||
|
||||
# 添加字幕段
|
||||
for seg in data.get('segments', []):
|
||||
track.add(
|
||||
seg.get('start', 0),
|
||||
seg.get('end', 0),
|
||||
seg.get('text', '')
|
||||
)
|
||||
|
||||
return track
|
||||
|
||||
|
||||
class SubtitlePipeline:
|
||||
"""字幕处理流水线"""
|
||||
|
||||
def __init__(self, config, output_dir):
|
||||
self.config = config
|
||||
self.output_dir = output_dir
|
||||
self.subs_dir = ensure_dir(os.path.join(output_dir, 'subs'))
|
||||
|
||||
def load_clip_json(self, clip_num, inter_dir):
|
||||
"""
|
||||
加载clip的JSON
|
||||
|
||||
Args:
|
||||
clip_num: clip编号
|
||||
inter_dir: 中间目录
|
||||
|
||||
Returns:
|
||||
JSON数据
|
||||
"""
|
||||
json_path = os.path.join(inter_dir, f"clip{clip_num}.json")
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def generate_from_clips(self, clip_configs, json_paths, apply_corrections=None):
|
||||
"""
|
||||
从clips生成字幕(分离标题和正文轨道)
|
||||
|
||||
Args:
|
||||
clip_configs: clip配置列表
|
||||
json_paths: JSON文件路径列表
|
||||
apply_corrections: 纠错函数
|
||||
|
||||
Returns:
|
||||
(title_track, content_track, title_path, content_path)
|
||||
"""
|
||||
title_track = SubtitleTrack(style='title')
|
||||
content_track = SubtitleTrack(style='content')
|
||||
current_time = 0
|
||||
|
||||
# 计算每个clip的偏移
|
||||
# 必须用 clip_configs 里的实际时长,而不是 Whisper 检测的语音结束时间
|
||||
# 因为 Whisper 只检测有语音的部分,无语音的间隙会被忽略,导致偏移累积偏差
|
||||
offsets = []
|
||||
for i, json_path in enumerate(json_paths):
|
||||
offsets.append(current_time)
|
||||
clip = clip_configs[i]
|
||||
clip_duration = clip['end'] - clip['start']
|
||||
current_time += clip_duration
|
||||
|
||||
# 重新遍历生成字幕
|
||||
current_time = 0
|
||||
for i, (clip, json_path) in enumerate(zip(clip_configs, json_paths)):
|
||||
offset = offsets[i]
|
||||
clip_duration = offsets[i+1] - offsets[i] if i+1 < len(offsets) else 3
|
||||
|
||||
# 添加标题(使用title样式)- 标题显示3秒后正文才显示,避免重叠
|
||||
title_duration = min(3, clip_duration)
|
||||
title_track.add(offset, offset + title_duration, clip['title'], style='title')
|
||||
|
||||
# 添加正文字幕 - 从标题结束后开始,避免重叠
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
content_start = offset + title_duration # 正文从标题结束后开始
|
||||
for seg in data.get('segments', []):
|
||||
text = seg.get('text', '').strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# 应用纠错
|
||||
if apply_corrections:
|
||||
text = apply_corrections(text)
|
||||
|
||||
# 计算相对偏移(正文时间从标题结束后开始)
|
||||
seg_start = offset + seg['start']
|
||||
seg_end = offset + seg['end']
|
||||
|
||||
# 只添加在clip时间范围内的字幕
|
||||
clip_end = clip['end'] - clip['start'] + offset
|
||||
if seg_start < clip_end and seg_end <= clip_end:
|
||||
content_track.add(
|
||||
seg_start,
|
||||
seg_end,
|
||||
text,
|
||||
style='content'
|
||||
)
|
||||
|
||||
# 保存两个轨道 - 标题使用SRT格式
|
||||
version = self._get_next_version()
|
||||
title_path = os.path.join(self.subs_dir, f"v{version}_title.srt")
|
||||
content_path = os.path.join(self.subs_dir, f"v{version}_content.srt")
|
||||
|
||||
title_track.save(title_path)
|
||||
content_track.save(content_path)
|
||||
|
||||
return title_track, content_track, title_path, content_path
|
||||
|
||||
def _get_next_version(self):
|
||||
"""获取下一个版本号"""
|
||||
existing = [f for f in os.listdir(self.subs_dir) if f.startswith('v') and f.endswith('_terms.srt')]
|
||||
|
||||
if not existing:
|
||||
return 1
|
||||
|
||||
# 提取版本号
|
||||
versions = []
|
||||
for f in existing:
|
||||
try:
|
||||
v = int(f.split('_')[0][1:])
|
||||
versions.append(v)
|
||||
except:
|
||||
pass
|
||||
|
||||
return max(versions) + 1 if versions else 1
|
||||
|
||||
def generate_v1(self, clip_configs, json_paths, apply_corrections=None):
|
||||
"""
|
||||
生成V1版本字幕(原版+纠错)
|
||||
|
||||
Args:
|
||||
clip_configs: clip配置
|
||||
json_paths: JSON路径
|
||||
apply_corrections: 纠错函数
|
||||
|
||||
Returns:
|
||||
字幕路径
|
||||
"""
|
||||
return self.generate_from_clips(clip_configs, json_paths, apply_corrections)[1]
|
||||
|
||||
|
||||
def load_clip_subtitles(inter_dir, clip_nums):
|
||||
"""
|
||||
批量加载多个clip的字幕
|
||||
|
||||
Args:
|
||||
inter_dir: 中间目录
|
||||
clip_nums: clip编号列表
|
||||
|
||||
Returns:
|
||||
{clip_num: json_data}
|
||||
"""
|
||||
clips = {}
|
||||
for num in clip_nums:
|
||||
json_path = os.path.join(inter_dir, f"clip{num}.json")
|
||||
if os.path.exists(json_path):
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
clips[num] = json.load(f)
|
||||
return clips
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用工具函数
|
||||
|
||||
提供跨模块使用的通用功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_dir(path):
|
||||
"""确保目录存在"""
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_clip_num(path):
|
||||
"""从路径中提取clip编号"""
|
||||
match = re.search(r'clip(\d+)', path)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def run_cmd(cmd, capture=True, timeout=300):
|
||||
"""
|
||||
执行shell命令
|
||||
|
||||
Args:
|
||||
cmd: 命令字符串
|
||||
capture: 是否捕获输出
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
True if success, False otherwise
|
||||
"""
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=timeout
|
||||
)
|
||||
return result.returncode == 0
|
||||
else:
|
||||
result = subprocess.run(cmd, shell=True, timeout=timeout)
|
||||
return result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"Command timeout: {cmd[:100]}...")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Command failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def to_srt_time(t):
|
||||
"""将秒数转换为SRT时间格式 (HH:MM:SS,mmm)"""
|
||||
hours = int(t // 3600)
|
||||
minutes = int((t % 3600) // 60)
|
||||
seconds = int(t % 60)
|
||||
millis = int((t % 1) * 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
||||
|
||||
|
||||
def to_ass_time(t):
|
||||
"""将秒数转换为ASS时间格式 (HH:MM:SS.cc)"""
|
||||
hours = int(t // 3600)
|
||||
minutes = int((t % 3600) // 60)
|
||||
seconds = int(t % 60)
|
||||
centis = int((t % 1) * 100)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{centis:02d}"
|
||||
|
||||
|
||||
def format_duration(seconds):
|
||||
"""格式化时长为可读字符串"""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}秒"
|
||||
elif seconds < 3600:
|
||||
return f"{seconds/60:.1f}分钟"
|
||||
else:
|
||||
return f"{seconds/3600:.1f}小时"
|
||||
|
||||
|
||||
def format_filesize(bytes_size):
|
||||
"""格式化文件大小为可读字符串"""
|
||||
mb = bytes_size / (1024 * 1024)
|
||||
if mb < 1024:
|
||||
return f"{mb:.1f} MB"
|
||||
else:
|
||||
return f"{mb/1024:.1f} GB"
|
||||
|
||||
|
||||
class SubtitleError(Exception):
|
||||
"""字幕处理异常"""
|
||||
pass
|
||||
|
||||
|
||||
class VideoError(Exception):
|
||||
"""视频处理异常"""
|
||||
pass
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""LLM调用异常"""
|
||||
pass
|
||||
@@ -0,0 +1,308 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
视频处理模块
|
||||
|
||||
包含视频剪辑、合并、淡入淡出等操作
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from .constants import FFMPEG_CMD, FFPROBE_CMD, DEFAULT_VIDEO_PARAMS, SUBTITLE_STYLE
|
||||
from .utils import run_cmd, ensure_dir, get_clip_num
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_video_info(video_path):
|
||||
"""
|
||||
获取视频信息
|
||||
|
||||
Args:
|
||||
video_path: 视频路径
|
||||
|
||||
Returns:
|
||||
视频信息字典
|
||||
"""
|
||||
cmd = f'"{FFPROBE_CMD}" -v error -show_entries format=duration,size -of json "{video_path}"'
|
||||
import subprocess
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding='utf-8', errors='ignore')
|
||||
|
||||
try:
|
||||
import json as json_mod
|
||||
return json_mod.loads(result.stdout)
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def extract_clip(video_src, start, end, output_path, fade_duration=1):
|
||||
"""
|
||||
从视频提取片段
|
||||
|
||||
Args:
|
||||
video_src: 源视频路径
|
||||
start: 开始时间(秒)
|
||||
end: 结束时间(秒)
|
||||
output_path: 输出路径
|
||||
fade_duration: 淡入淡出时长
|
||||
|
||||
Returns:
|
||||
True if success
|
||||
"""
|
||||
duration = end - start
|
||||
|
||||
# 使用掐头去尾精确控制
|
||||
cmd = f'"{FFMPEG_CMD}" -y -ss {start} -i "{video_src}" -t {duration} '
|
||||
|
||||
if fade_duration > 0:
|
||||
# 添加淡入淡出
|
||||
cmd += f'-vf "fade=t=in:st=0:d={fade_duration},fade=t=out:st={duration-fade_duration}:d={fade_duration}" '
|
||||
|
||||
cmd += f'-c:v libx264 -crf 20 -c:a aac -y "{output_path}"'
|
||||
|
||||
success = run_cmd(cmd)
|
||||
if success:
|
||||
logger.info(f"Extracted clip: {output_path}")
|
||||
else:
|
||||
logger.error(f"Failed to extract clip: {output_path}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def merge_clips(clip_paths, output_path, inter_dir=None):
|
||||
"""
|
||||
合并多个视频片段
|
||||
|
||||
Args:
|
||||
clip_paths: 片段路径列表
|
||||
output_path: 输出路径
|
||||
inter_dir: 中间目录(用于保存concat list)
|
||||
|
||||
Returns:
|
||||
True if success
|
||||
"""
|
||||
if not clip_paths:
|
||||
logger.error("No clips to merge")
|
||||
return False
|
||||
|
||||
# 按编号排序
|
||||
sorted_clips = sorted(clip_paths, key=lambda p: get_clip_num(p))
|
||||
|
||||
# 创建concat list
|
||||
if inter_dir:
|
||||
list_path = os.path.join(inter_dir, "concat_list.txt")
|
||||
else:
|
||||
list_path = os.path.join(os.path.dirname(output_path), "concat_list.txt")
|
||||
|
||||
with open(list_path, 'w', encoding='utf-8') as f:
|
||||
for p in sorted_clips:
|
||||
clip_num = get_clip_num(p)
|
||||
# 验证clip对应的json存在
|
||||
if inter_dir:
|
||||
json_path = os.path.join(inter_dir, f"clip{clip_num}.json")
|
||||
if not os.path.exists(json_path):
|
||||
logger.warning(f"Skipping clip{clip_num} - no JSON found")
|
||||
continue
|
||||
f.write(f"file '{p}'\n")
|
||||
|
||||
# 合并
|
||||
cmd = f'"{FFMPEG_CMD}" -y -f concat -safe 0 -i "{list_path}" -c copy -y "{output_path}"'
|
||||
success = run_cmd(cmd)
|
||||
|
||||
if success:
|
||||
logger.info(f"Merged {len(sorted_clips)} clips -> {output_path}")
|
||||
else:
|
||||
logger.error(f"Failed to merge clips")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def burn_subtitles(video_path, srt_path, output_path):
|
||||
"""
|
||||
烧录字幕到视频
|
||||
|
||||
Args:
|
||||
video_path: 输入视频路径
|
||||
srt_path: 字幕文件路径
|
||||
output_path: 输出路径
|
||||
|
||||
Returns:
|
||||
True if success
|
||||
"""
|
||||
# Windows路径转义
|
||||
srt_escaped = srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||||
|
||||
filter_str = f"subtitles='{srt_escaped}':force_style='{SUBTITLE_STYLE}'"
|
||||
|
||||
cmd = f'"{FFMPEG_CMD}" -y -i "{video_path}" -vf "{filter_str}" -c:a copy -y "{output_path}"'
|
||||
|
||||
success = run_cmd(cmd)
|
||||
|
||||
if success:
|
||||
logger.info(f"Burned subtitles -> {output_path}")
|
||||
else:
|
||||
logger.error(f"Failed to burn subtitles")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def burn_dual_subtitles(video_path, title_srt_path, content_srt_path, output_path, title_fontsize=90, title_color="FFFF00", subtitle_fontsize=24, subtitle_color="FFFFFF"):
|
||||
"""
|
||||
烧录两层字幕到视频(标题在屏幕正中,正文在下方)
|
||||
|
||||
Args:
|
||||
video_path: 输入视频路径
|
||||
title_srt_path: 标题字幕文件路径
|
||||
content_srt_path: 正文字幕文件路径
|
||||
output_path: 输出路径
|
||||
title_fontsize: 标题字号
|
||||
title_color: 标题颜色(HTML格式如FFFF00)
|
||||
subtitle_fontsize: 正文字号
|
||||
subtitle_color: 正文颜色
|
||||
|
||||
Returns:
|
||||
True if success
|
||||
"""
|
||||
# Windows路径转义
|
||||
title_escaped = title_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||||
content_escaped = content_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||||
|
||||
# 转换颜色格式:HTML (FFFF00) -> FFmpeg BGR (&H00FFFF)
|
||||
def html_to_bgr(color):
|
||||
if color.startswith('&H') or color.startswith('0x'):
|
||||
return color
|
||||
# HTML format like FFFF00 -> FFmpeg BGR format
|
||||
r = int(color[0:2], 16)
|
||||
g = int(color[2:4], 16)
|
||||
b = int(color[4:6], 16)
|
||||
return f"&H00{b:02X}{g:02X}{r:02X}"
|
||||
|
||||
title_bgr = html_to_bgr(title_color)
|
||||
subtitle_bgr = html_to_bgr(subtitle_color)
|
||||
|
||||
# 标题样式:使用SRT+force_style,Alignment=5水平居中,垂直位置由MarginV控制
|
||||
# 正文字样式:底部居中,24字号,白色,带描边
|
||||
content_style = f"FontName=微软雅黑,FontSize={subtitle_fontsize},PrimaryColour={subtitle_bgr},Alignment=2,MarginV=20,Outline=1,Shadow=1"
|
||||
|
||||
# 使用两个独立字幕滤镜分别渲染,然后叠加
|
||||
# 标题使用Alignment=5,MarginV=0(正中)
|
||||
title_style = f"FontName=微软雅黑,FontSize={title_fontsize},PrimaryColour={title_bgr},Alignment=5,MarginV=0,Outline=3,Shadow=2"
|
||||
|
||||
# 使用两个字幕滤镜叠加,然后映射视频+原始音频
|
||||
# 标题使用Alignment=5,MarginV=0(正中)
|
||||
title_style = f"FontName=微软雅黑,FontSize={title_fontsize},PrimaryColour={title_bgr},Alignment=5,MarginV=0,Outline=3,Shadow=2"
|
||||
|
||||
# 使用两个字幕滤镜叠加
|
||||
filter_str = f"[0:v]subtitles='{title_escaped}':force_style='{title_style}',subtitles='{content_escaped}':force_style='{content_style}'[out]"
|
||||
|
||||
# 保留原始音频 - 映射视频输出和原始音频
|
||||
cmd = f'"{FFMPEG_CMD}" -y -i "{video_path}" -filter_complex "{filter_str}" -map "[out]" -map 0:a? -c:a copy -c:v libx264 -crf 18 -y "{output_path}"'
|
||||
|
||||
success = run_cmd(cmd)
|
||||
|
||||
if success:
|
||||
logger.info(f"Burned dual subtitles (title+content) -> {output_path}")
|
||||
else:
|
||||
logger.error(f"Failed to burn dual subtitles")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def extract_key_frames(video_path, timestamps, output_dir, prefix="frame"):
|
||||
"""
|
||||
从视频提取关键帧
|
||||
|
||||
Args:
|
||||
video_path: 视频路径
|
||||
timestamps: 时间戳列表
|
||||
output_dir: 输出目录
|
||||
prefix: 文件名前缀
|
||||
|
||||
Returns:
|
||||
帧文件路径列表
|
||||
"""
|
||||
ensure_dir(output_dir)
|
||||
frames = []
|
||||
|
||||
for i, ts in enumerate(timestamps):
|
||||
output_path = os.path.join(output_dir, f"{prefix}_{i:03d}.jpg")
|
||||
|
||||
cmd = f'"{FFMPEG_CMD}" -y -ss {ts} -i "{video_path}" -vframes 1 -q:v 2 "{output_path}"'
|
||||
|
||||
if run_cmd(cmd) and os.path.exists(output_path):
|
||||
frames.append(output_path)
|
||||
else:
|
||||
logger.warning(f"Failed to extract frame at {ts}s")
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
class VideoPipeline:
|
||||
"""视频流水线封装"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.video_params = config.get('video_params', DEFAULT_VIDEO_PARAMS)
|
||||
self.video_src = config.get('video_src')
|
||||
self.output_dir = config.get('output_dir')
|
||||
self.inter_dir = os.path.join(self.output_dir, 'intermediates')
|
||||
|
||||
ensure_dir(self.output_dir)
|
||||
ensure_dir(self.inter_dir)
|
||||
|
||||
def extract_clips(self, clips):
|
||||
"""
|
||||
提取所有视频片段
|
||||
|
||||
Args:
|
||||
clips: 片段配置列表
|
||||
|
||||
Returns:
|
||||
片段路径列表
|
||||
"""
|
||||
clip_paths = []
|
||||
fade_dur = self.video_params.get('fade_duration', 1)
|
||||
|
||||
for i, clip in enumerate(clips, 1):
|
||||
clip_path = os.path.join(self.inter_dir, f"clip{i}.mp4")
|
||||
fade_path = os.path.join(self.inter_dir, f"clip{i}_fade.mp4")
|
||||
|
||||
# 提取片段
|
||||
success = extract_clip(
|
||||
self.video_src,
|
||||
clip['start'],
|
||||
clip['end'],
|
||||
clip_path,
|
||||
fade_duration=0 # 先不添加淡出
|
||||
)
|
||||
|
||||
if success and fade_dur > 0:
|
||||
# 添加淡入淡出
|
||||
from .utils import to_srt_time
|
||||
duration = clip['end'] - clip['start']
|
||||
fade_in_end = fade_dur
|
||||
fade_out_start = max(0, duration - fade_dur)
|
||||
|
||||
cmd = f'"{FFMPEG_CMD}" -y -i "{clip_path}" '
|
||||
cmd += f'-vf "fade=t=in:st=0:d={fade_dur},fade=t=out:st={fade_out_start}:d={fade_dur}" '
|
||||
cmd += f'-c:v libx264 -crf 20 -c:a aac -y "{fade_path}"'
|
||||
|
||||
if run_cmd(cmd):
|
||||
clip_paths.append(fade_path)
|
||||
else:
|
||||
# 淡出失败,使用原始片段
|
||||
clip_paths.append(clip_path)
|
||||
else:
|
||||
clip_paths.append(clip_path)
|
||||
|
||||
return clip_paths
|
||||
|
||||
def merge(self, clip_paths, output_name="concat_merged.mp4"):
|
||||
"""合并片段"""
|
||||
output_path = os.path.join(self.output_dir, output_name)
|
||||
return merge_clips(clip_paths, output_path, self.inter_dir)
|
||||
|
||||
def burn(self, video_path, srt_path, output_name="final.mp4"):
|
||||
"""烧录字幕"""
|
||||
output_path = os.path.join(self.output_dir, output_name)
|
||||
return burn_subtitles(video_path, srt_path, output_path)
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Piano Highlight Generator - GUI
|
||||
简单的 GUI 包装,调用与 CLI 完全相同的底层函数
|
||||
API 配置在 config.ini 中
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
|
||||
# 设置环境
|
||||
import shutil
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path:
|
||||
ffmpeg_bin = os.path.dirname(ffmpeg_path)
|
||||
if ffmpeg_bin not in os.environ.get('PATH', ''):
|
||||
os.environ['PATH'] = ffmpeg_bin + os.pathsep + os.environ.get('PATH', '')
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLineEdit, QPushButton, QTextEdit, QLabel, QFileDialog,
|
||||
QMessageBox, QGroupBox, QFormLayout, QProgressBar
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
|
||||
# 底层函数(与 CLI 共用)
|
||||
from core import parse_ppt_to_config, Pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_config():
|
||||
"""从 config.ini 加载配置"""
|
||||
config = configparser.ConfigParser()
|
||||
# 配置文件位于项目根目录
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config.ini')
|
||||
if os.path.exists(config_path):
|
||||
config.read(config_path, encoding='utf-8')
|
||||
return config
|
||||
return None
|
||||
|
||||
|
||||
class Signaller(QObject):
|
||||
"""线程安全的信号发射器"""
|
||||
log_signal = Signal(str)
|
||||
progress_signal = Signal(str, int, str) # step, percent, message
|
||||
finished_signal = Signal(bool, str)
|
||||
|
||||
|
||||
class GUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# 加载配置文件
|
||||
self.config = load_config()
|
||||
if not self.config:
|
||||
QMessageBox.critical(None, "错误", "找不到 config.ini 配置文件")
|
||||
sys.exit(1)
|
||||
|
||||
self.signaller = Signaller()
|
||||
self.worker_thread = None
|
||||
self._setup_ui()
|
||||
|
||||
# 连接信号
|
||||
self.signaller.log_signal.connect(self._append_log)
|
||||
self.signaller.finished_signal.connect(self._on_finished)
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle("Piano Highlight Generator - GUI")
|
||||
self.setGeometry(100, 100, 700, 500)
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
# === 文件选择区 ===
|
||||
file_group = QGroupBox("文件配置")
|
||||
file_layout = QFormLayout()
|
||||
|
||||
self.video_edit = QLineEdit()
|
||||
self.video_btn = QPushButton("选择视频")
|
||||
self.video_btn.clicked.connect(lambda: self._select_file(self.video_edit, "视频文件 (*.mp4 *.avi *.mov)"))
|
||||
video_row = QHBoxLayout()
|
||||
video_row.addWidget(self.video_edit)
|
||||
video_row.addWidget(self.video_btn)
|
||||
file_layout.addRow("视频:", video_row)
|
||||
|
||||
self.ppt_edit = QLineEdit()
|
||||
self.ppt_btn = QPushButton("选择PPT")
|
||||
self.ppt_btn.clicked.connect(lambda: self._select_file(self.ppt_edit, "PPT文件 (*.pptx)"))
|
||||
ppt_row = QHBoxLayout()
|
||||
ppt_row.addWidget(self.ppt_edit)
|
||||
ppt_row.addWidget(self.ppt_btn)
|
||||
file_layout.addRow("PPT:", ppt_row)
|
||||
|
||||
self.output_edit = QLineEdit(os.path.expanduser("~/piano_output"))
|
||||
self.output_btn = QPushButton("选择输出目录")
|
||||
self.output_btn.clicked.connect(lambda: self._select_dir(self.output_edit))
|
||||
output_row = QHBoxLayout()
|
||||
output_row.addWidget(self.output_edit)
|
||||
output_row.addWidget(self.output_btn)
|
||||
file_layout.addRow("输出目录:", output_row)
|
||||
|
||||
file_group.setLayout(file_layout)
|
||||
layout.addWidget(file_group)
|
||||
|
||||
# === 进度条 ===
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setValue(0)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# === 日志区 ===
|
||||
log_label = QLabel("日志:")
|
||||
layout.addWidget(log_label)
|
||||
self.log_area = QTextEdit()
|
||||
self.log_area.setReadOnly(True)
|
||||
self.log_area.setMaximumHeight(250)
|
||||
layout.addWidget(self.log_area)
|
||||
|
||||
# === 按钮区 ===
|
||||
btn_layout = QHBoxLayout()
|
||||
self.start_btn = QPushButton("开始处理")
|
||||
self.start_btn.clicked.connect(self._on_start)
|
||||
self.clear_btn = QPushButton("清空日志")
|
||||
self.clear_btn.clicked.connect(lambda: self.log_area.clear())
|
||||
btn_layout.addWidget(self.start_btn)
|
||||
btn_layout.addWidget(self.clear_btn)
|
||||
btn_layout.addStretch()
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _select_file(self, edit, filter_str):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "选择文件", "", filter_str)
|
||||
if path:
|
||||
edit.setText(path)
|
||||
|
||||
def _select_dir(self, edit):
|
||||
path = QFileDialog.getExistingDirectory(self, "选择目录")
|
||||
if path:
|
||||
edit.setText(path)
|
||||
|
||||
def _append_log(self, text):
|
||||
self.log_area.append(text)
|
||||
# 滚动到底部
|
||||
self.log_area.verticalScrollBar().setValue(
|
||||
self.log_area.verticalScrollBar().maximum()
|
||||
)
|
||||
|
||||
def _on_finished(self, success, message):
|
||||
self.start_btn.setEnabled(True)
|
||||
if success:
|
||||
QMessageBox.information(self, "完成", message)
|
||||
else:
|
||||
QMessageBox.critical(self, "错误", message)
|
||||
|
||||
def _on_start(self):
|
||||
# 收集参数
|
||||
video_path = self.video_edit.text().strip()
|
||||
ppt_path = self.ppt_edit.text().strip()
|
||||
output_dir = self.output_edit.text().strip()
|
||||
|
||||
# 从配置文件读取 API 配置
|
||||
api_key = self.config.get('api', 'api_key', fallback='').strip()
|
||||
api_host = self.config.get('api', 'api_host', fallback='').strip()
|
||||
|
||||
# 验证
|
||||
if not video_path:
|
||||
QMessageBox.warning(self, "警告", "请选择视频文件")
|
||||
return
|
||||
if not ppt_path:
|
||||
QMessageBox.warning(self, "警告", "请选择PPT文件")
|
||||
return
|
||||
if not output_dir:
|
||||
QMessageBox.warning(self, "警告", "请选择输出目录")
|
||||
return
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.log_area.clear()
|
||||
|
||||
# 后台线程执行
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._worker,
|
||||
args=(video_path, ppt_path, output_dir, api_key, api_host)
|
||||
)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _worker(self, video_path, ppt_path, output_dir, api_key, api_host):
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Step 1: 从 PPT 生成配置
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
self.signaller.log_signal.emit("从PPT生成clips配置...")
|
||||
self.signaller.log_signal.emit(f"视频: {video_path}")
|
||||
self.signaller.log_signal.emit(f"PPT: {ppt_path}")
|
||||
self.signaller.log_signal.emit(f"输出: {output_dir}")
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
|
||||
def progress_callback(step, percent, message):
|
||||
self.signaller.progress_signal.emit(step, percent, message)
|
||||
self.signaller.log_signal.emit(f"[{step}] {percent}%: {message}")
|
||||
|
||||
config = parse_ppt_to_config(
|
||||
video_path=video_path,
|
||||
ppt_path=ppt_path,
|
||||
output_dir=output_dir,
|
||||
api_key=api_key,
|
||||
api_host=api_host,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
if not config.get('clips'):
|
||||
raise ValueError("LLM未能提取到任何片段")
|
||||
|
||||
# 保存配置
|
||||
config_path = os.path.join(output_dir, 'generated_config.yaml')
|
||||
import yaml
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
|
||||
self.signaller.log_signal.emit(f"配置已保存: {config_path}")
|
||||
|
||||
# Step 2: 运行 Pipeline
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
self.signaller.log_signal.emit("开始处理视频...")
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
|
||||
pipeline = Pipeline(config)
|
||||
final_path = pipeline.run()
|
||||
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
self.signaller.log_signal.emit(f"完成! 最终视频: {final_path}")
|
||||
self.signaller.log_signal.emit("=" * 50)
|
||||
|
||||
self.signaller.finished_signal.emit(True, f"处理完成!\n最终视频: {final_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.signaller.log_signal.emit(f"错误: {e}")
|
||||
import traceback
|
||||
self.signaller.log_signal.emit(traceback.format_exc())
|
||||
self.signaller.finished_signal.emit(False, str(e))
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
window = GUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Piano Highlight Generator - GUI Entry Point
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加 src 目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from gui import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user