Initial commit: lesson-highlights generator
This commit is contained in:
+54
@@ -0,0 +1,54 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
*.exe
|
||||
*.msi
|
||||
*.dmg
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# State files
|
||||
state.json
|
||||
*.state.json
|
||||
output/
|
||||
intermediates/
|
||||
subs/
|
||||
concat_*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Model files (large)
|
||||
*.pt
|
||||
*.pth
|
||||
*.onnx
|
||||
*.bin
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
|
||||
# Local config (contains API keys)
|
||||
config.ini
|
||||
@@ -0,0 +1,10 @@
|
||||
# Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
## Development Setup
|
||||
|
||||
See README.md for setup instructions.
|
||||
@@ -0,0 +1,105 @@
|
||||
# 🎹 Piano Highlight Generator
|
||||
|
||||
钢琴课精华视频生成工具。自动从完整课程视频中提取精华片段,转录、纠错、生成字幕,批量烧录到视频中。
|
||||
|
||||
## ✨ 功能特点
|
||||
|
||||
- **智能提取**: 自动检测视频中的精彩片段
|
||||
- **语音转录**: 支持 Whisper 多模型(tiny/base/small/medium/large)
|
||||
- **AI 纠错**: LLM 自动纠正转录错误,优化标题
|
||||
- **双语字幕**: 支持双轨字幕(标题轨 + 内容轨)
|
||||
- **状态持久化**: 支持暂停/恢复,可中断继续
|
||||
- **手动编辑**: 生成前可人工审核编辑标题和字幕内容
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
- Windows 10/11 或 macOS 10.15+
|
||||
- Python 3.10+
|
||||
- FFmpeg(必须,添加到 PATH)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repo-url>
|
||||
cd piano-highlight-app
|
||||
|
||||
# 创建虚拟环境(推荐)
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate # Windows
|
||||
source venv/bin/activate # Linux/macOS
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装 FFmpeg(Windows - 使用 winget)
|
||||
winget install Gyan.FFmpeg
|
||||
|
||||
# 或 macOS
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
### 2. 运行
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
### 3. 配置
|
||||
|
||||
首次运行需要配置:
|
||||
1. **API 设置**: 选择 API 提供商(DeepSeek/硅基流动),输入 API Key
|
||||
2. **视频设置**: 选择输入视频、输出目录
|
||||
3. **转录设置**: 选择 Whisper 模型(推荐 medium)
|
||||
|
||||
### 4. 生成
|
||||
|
||||
1. 点击「开始处理」
|
||||
2. 等待各步骤完成
|
||||
3. **标题确认**: LLM 生成标题后,审核并编辑
|
||||
4. **字幕确认**: 查看字幕内容,可进一步编辑
|
||||
5. 等待烧录完成
|
||||
|
||||
## 📁 输出文件
|
||||
|
||||
```
|
||||
output/
|
||||
├── state.json # 处理状态
|
||||
├── clips/ # 提取的片段
|
||||
│ └── clip_001.mp4
|
||||
├── subtitles/ # 字幕文件
|
||||
│ ├── clip_001_title.srt # 标题轨
|
||||
│ └── clip_001_content.srt # 内容轨
|
||||
└── final/ # 最终输出
|
||||
└── clip_001_final.mp4
|
||||
```
|
||||
|
||||
## 🔧 流水线步骤
|
||||
|
||||
1. **extract** - 片段提取
|
||||
2. **transcribe** - 语音转录
|
||||
3. **title_correct** - 标题生成与纠错
|
||||
4. **generate_subtitles** - 字幕生成
|
||||
5. **merge** - 片段合并
|
||||
6. **burn** - 字幕烧录
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### Q: 提示 "FFmpeg not found"
|
||||
A: 确保 FFmpeg 已安装并添加到系统 PATH。重启终端后重试。
|
||||
|
||||
### Q: API 调用失败
|
||||
A: 检查 API Key 是否正确,网络是否正常,或切换 API 提供商。
|
||||
|
||||
### Q: 磁盘空间不足
|
||||
A: 清理输出目录或更换到空间更大的磁盘。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# Piano Highlight Generator - Build Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Python
|
||||
- **Version**: Python 3.10 or higher (3.12 recommended)
|
||||
- **Download**: https://www.python.org/downloads/
|
||||
- **Note**: Ensure Python is added to PATH
|
||||
|
||||
### 2. FFmpeg (Runtime Requirement)
|
||||
FFmpeg is required for video processing at runtime, NOT for building.
|
||||
|
||||
**Windows:**
|
||||
- Download from https://ffmpeg.org/download.html
|
||||
- Or use: `winget install ffmpeg`
|
||||
- Add FFmpeg binary location to system PATH
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
### 3. Additional Build Tools (Windows)
|
||||
- **Visual Studio Build Tools** or **MinGW** may be required for Nuitka compilation
|
||||
- Download: https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
|
||||
---
|
||||
|
||||
## Build Steps
|
||||
|
||||
### Windows
|
||||
|
||||
1. Open Command Prompt or PowerShell in project directory
|
||||
|
||||
2. Run the build script:
|
||||
```cmd
|
||||
build.bat
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```cmd
|
||||
pip install nuitka pandas
|
||||
python -m nuitka --standalone --onefile --windows-console-mode=disable --output-dir=dist --output-name=PianoHighlightGenerator --enable-plugin=pyside6 src/main.py
|
||||
```
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
1. Make build script executable:
|
||||
```bash
|
||||
chmod +x build.sh
|
||||
```
|
||||
|
||||
2. Run the build script:
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
pip3 install nuitka pandas
|
||||
python3 -m nuitka --standalone --onefile --output-dir=dist --output-name=PianoHighlightGenerator --enable-plugin=pyside6 src/main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
| Platform | Output Location | Filename |
|
||||
|----------|----------------|----------|
|
||||
| Windows | `dist/` | `PianoHighlightGenerator.exe` |
|
||||
| Linux | `dist/` | `PianoHighlightGenerator.bin` |
|
||||
| macOS | `dist/` | `PianoHighlightGenerator.bin` |
|
||||
|
||||
---
|
||||
|
||||
## Expected Size
|
||||
|
||||
- **Standalone executable**: ~150-250 MB
|
||||
- This includes Python interpreter, PySide6, and all dependencies
|
||||
|
||||
---
|
||||
|
||||
## Testing the Built Executable
|
||||
|
||||
1. Copy FFmpeg to the same directory as the executable OR ensure FFmpeg is in PATH
|
||||
|
||||
2. Run the executable:
|
||||
- **Windows**: Double-click `PianoHighlightGenerator.exe` or run from cmd
|
||||
- **Linux/macOS**: Run `./PianoHighlightGenerator.bin` in terminal
|
||||
|
||||
3. Test basic functionality:
|
||||
- App should launch with GUI
|
||||
- Video selection should work
|
||||
- Processing pipeline should execute
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ffmpeg not found" error
|
||||
- Ensure FFmpeg is installed and in system PATH
|
||||
- Test by running `ffmpeg -version` in terminal
|
||||
|
||||
### "Missing DLL" errors on Windows
|
||||
- Install Visual C++ Redistributable: https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
|
||||
### Build fails with memory error
|
||||
- Reduce parallelism: Add `--jobs=2` to build command
|
||||
- Close other applications
|
||||
|
||||
### PySide6 plugin issues
|
||||
- Ensure `--enable-plugin=pyside6` is included
|
||||
- For special PySide6 handling, add `--pyside6-option=--no-sandbox`
|
||||
|
||||
---
|
||||
|
||||
## Data Files
|
||||
|
||||
If you have a `prompts/` directory with template files, ensure:
|
||||
- Path: `src/core/prompts/`
|
||||
- Files are copied with `--include-data-files=src/core/prompts=prompts`
|
||||
|
||||
Currently, no prompts directory exists in the project. Create `src/core/prompts/` if needed for custom prompt templates.
|
||||
|
||||
---
|
||||
|
||||
## Nuitka Configuration (pyproject.toml)
|
||||
|
||||
The project includes Nuitka settings in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.nuitka]
|
||||
assume_yes_for_downloads = true
|
||||
show_progress = true
|
||||
output_dir = "dist"
|
||||
output_name = "PianoHighlightGenerator"
|
||||
python_version = "3.10"
|
||||
standalone = true
|
||||
onefile = true
|
||||
```
|
||||
|
||||
You can also use the command line options documented above for more control.
|
||||
@@ -0,0 +1,71 @@
|
||||
@echo off
|
||||
REM Piano Highlight Generator - Build Script for Windows
|
||||
REM Prerequisites: Python 3.10+, FFmpeg (in PATH)
|
||||
|
||||
echo ================================================
|
||||
echo Piano Highlight Generator - Nuitka Build
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
REM Check Python version
|
||||
echo Checking Python version...
|
||||
python --version
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python not found. Please install Python 3.10 or higher.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check FFmpeg
|
||||
echo.
|
||||
echo Checking FFmpeg...
|
||||
where ffmpeg >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo WARNING: FFmpeg not found in PATH. The built executable will require FFmpeg to be installed.
|
||||
echo Please install FFmpeg from: https://ffmpeg.org/download.html
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Create dist directory if not exists
|
||||
if not exist "dist" mkdir dist
|
||||
|
||||
REM Install build dependencies
|
||||
echo.
|
||||
echo Installing build dependencies...
|
||||
pip install nuitka pandas
|
||||
|
||||
REM Build command
|
||||
echo.
|
||||
echo Starting Nuitka compilation...
|
||||
echo This may take several minutes on first run...
|
||||
echo.
|
||||
|
||||
python -m nuitka ^
|
||||
--standalone ^
|
||||
--onefile ^
|
||||
--windows-console-mode=disable ^
|
||||
--output-dir=dist ^
|
||||
--output-name=PianoHighlightGenerator ^
|
||||
--enable-plugin=pyside6 ^
|
||||
--include-data-files=src/core/prompts=prompts ^
|
||||
--python-version=3.10 ^
|
||||
src/main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo BUILD FAILED!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo Build complete!
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Output: dist\PianoHighlightGenerator.exe
|
||||
echo.
|
||||
echo NOTE: FFmpeg must be in PATH for the executable to work.
|
||||
echo If FFmpeg is not installed, download from https://ffmpeg.org
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Piano Highlight Generator - Build Script for Linux/macOS
|
||||
# Prerequisites: Python 3.10+, FFmpeg (in PATH)
|
||||
|
||||
set -e
|
||||
|
||||
echo "================================================"
|
||||
echo " Piano Highlight Generator - Nuitka Build"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Check Python version
|
||||
echo "Checking Python version..."
|
||||
python3 --version || { echo "ERROR: Python not found. Please install Python 3.10 or higher."; exit 1; }
|
||||
|
||||
# Check FFmpeg
|
||||
echo ""
|
||||
echo "Checking FFmpeg..."
|
||||
if ! command -v ffmpeg &> /dev/null; then
|
||||
echo "WARNING: FFmpeg not found in PATH. The built executable will require FFmpeg."
|
||||
echo "Please install FFmpeg: sudo apt install ffmpeg (Ubuntu/Debian) or brew install ffmpeg (macOS)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Create dist directory if not exists
|
||||
mkdir -p dist
|
||||
|
||||
# Install build dependencies
|
||||
echo ""
|
||||
echo "Installing build dependencies..."
|
||||
pip3 install nuitka pandas
|
||||
|
||||
# Build
|
||||
echo ""
|
||||
echo "Starting Nuitka compilation..."
|
||||
echo "This may take several minutes on first run..."
|
||||
echo ""
|
||||
|
||||
python3 -m nuitka \
|
||||
--standalone \
|
||||
--onefile \
|
||||
--output-dir=dist \
|
||||
--output-name=PianoHighlightGenerator \
|
||||
--enable-plugin=pyside6 \
|
||||
--include-data-files=src/core/prompts=prompts \
|
||||
--python-version=3.10 \
|
||||
src/main.py
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " Build complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Output: dist/PianoHighlightGenerator.bin"
|
||||
echo ""
|
||||
echo "NOTE: FFmpeg must be in PATH for the executable to work."
|
||||
echo ""
|
||||
echo "To run FFmpeg from a specific location, either:"
|
||||
echo " 1. Add FFmpeg to your PATH"
|
||||
echo " 2. Place FFmpeg binary in the same directory as the executable"
|
||||
echo ""
|
||||
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
REM Piano Highlight Generator - CLI Build Script for Windows
|
||||
REM Builds a standalone CLI executable from cli.py
|
||||
|
||||
set "PYTHON=D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
set "PYINSTALLER=D:\ProgramData\anaconda3\envs\py312_cuda\Scripts\pyinstaller.exe"
|
||||
|
||||
echo ================================================
|
||||
echo Piano Highlight Generator - CLI Build
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
REM Check Python
|
||||
"%PYTHON%" --version
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Install pyinstaller if needed
|
||||
"%PYTHON%" -c "import PyInstaller" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing PyInstaller...
|
||||
"%PYTHON%" -m pip install pyinstaller -q
|
||||
)
|
||||
|
||||
REM Build
|
||||
echo.
|
||||
echo Starting PyInstaller compilation...
|
||||
echo This may take several minutes...
|
||||
echo.
|
||||
|
||||
cd /d "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app"
|
||||
|
||||
"%PYTHON%" -m PyInstaller ^
|
||||
--name=PianoHighlightCLI ^
|
||||
--console ^
|
||||
--onefile ^
|
||||
--clean ^
|
||||
--distpath=dist_cli ^
|
||||
--workpath=build_cli ^
|
||||
--specpath=build_cli ^
|
||||
--additional-hooks-dir= ^
|
||||
--hidden-import=pkg_resources ^
|
||||
--hidden-import=faster_whisper ^
|
||||
--hidden-import=cv2 ^
|
||||
--hidden-import= yaml ^
|
||||
--hidden-import=requests ^
|
||||
--hidden-import=PIL ^
|
||||
--collect-all=faster_whisper ^
|
||||
--collect-all=transformers ^
|
||||
src/cli.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo BUILD FAILED!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo Build complete!
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Output: dist_cli\PianoHighlightCLI.exe
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,3 @@
|
||||
[api]
|
||||
api_host = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
api_key = "YOUR_API_KEY_HERE"
|
||||
@@ -0,0 +1,521 @@
|
||||
# Piano Highlight Generator App - 技术设计
|
||||
|
||||
> 设计日期:2026-05-02
|
||||
> 版本:1.0
|
||||
> 状态:Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. 技术栈
|
||||
|
||||
| 层级 | 技术 | 选型理由 |
|
||||
|------|------|----------|
|
||||
| GUI 框架 | PySide6 (Qt for Python) | LGPL 许可,功能完备,信号槽机制适合异步更新 |
|
||||
| 打包工具 | Nuitka | 编译为 C,性能好,体积小 |
|
||||
| 状态持久化 | JSON 文件 | 简单,无需数据库依赖 |
|
||||
| 核心模块 | 复用现有脚本 | video.py, subtitle.py, llm.py, corrections.py |
|
||||
| 配置格式 | YAML/JSON | 用户友好,可读性好 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
piano-highlight-app/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── app.py # QMainWindow 主窗口
|
||||
│ ├── gui/ # GUI 组件
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config_panel.py # 配置面板
|
||||
│ │ ├── progress_view.py # 进度监控
|
||||
│ │ ├── title_editor.py # 标题编辑器
|
||||
│ │ └── log_view.py # 日志窗口
|
||||
│ ├── logic/ # 业务逻辑
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config_manager.py # 配置管理
|
||||
│ │ ├── pipeline_controller.py # 流水线控制
|
||||
│ │ ├── state_manager.py # 状态管理
|
||||
│ │ └── worker.py # 后台工作线程
|
||||
│ └── core/ # 核心模块(复用)
|
||||
│ ├── __init__.py
|
||||
│ ├── constants.py # 常量
|
||||
│ ├── utils.py # 工具函数
|
||||
│ ├── video.py # 视频处理
|
||||
│ ├── subtitle.py # 字幕处理
|
||||
│ ├── llm.py # LLM 调用
|
||||
│ └── corrections.py # 纠错规则
|
||||
├── assets/ # 资源文件
|
||||
│ └── icons/
|
||||
├── requirements.txt # 依赖
|
||||
├── pyproject.toml # 项目配置
|
||||
├── nuitka_options.py # Nuitka 打包配置
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心类设计
|
||||
|
||||
### 3.1 StateManager(状态管理)
|
||||
|
||||
```python
|
||||
class StateManager:
|
||||
"""状态管理器 - 负责状态持久化"""
|
||||
|
||||
def __init__(self, state_file: str):
|
||||
self.state_file = state_file
|
||||
self.state = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
"""从文件加载状态"""
|
||||
|
||||
def save(self):
|
||||
"""保存状态到文件"""
|
||||
|
||||
def get_current_step(self) -> int:
|
||||
"""获取当前步骤"""
|
||||
|
||||
def set_step_status(self, step: str, status: str):
|
||||
"""设置步骤状态 (pending/in_progress/completed/failed)"""
|
||||
|
||||
def update_clip_status(self, clip_index: int, **kwargs):
|
||||
"""更新 clip 状态"""
|
||||
|
||||
def get_clip_titles(self) -> list:
|
||||
"""获取所有 clip 的标题(含用户修改)"""
|
||||
```
|
||||
|
||||
### 3.2 PipelineController(流水线控制)
|
||||
|
||||
```python
|
||||
class PipelineController:
|
||||
"""流水线控制器 - 管理处理流程"""
|
||||
|
||||
# 步骤定义
|
||||
STEPS = [
|
||||
'ready',
|
||||
'extracting',
|
||||
'transcribing',
|
||||
'title_correcting',
|
||||
'generating_subtitles',
|
||||
'merging',
|
||||
'burning',
|
||||
'completed'
|
||||
]
|
||||
|
||||
def __init__(self, config: dict, state_manager: StateManager):
|
||||
self.config = config
|
||||
self.state = state_manager
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
|
||||
def run(self, worker: Worker):
|
||||
"""运行流水线"""
|
||||
|
||||
def pause(self):
|
||||
"""暂停流水线"""
|
||||
|
||||
def resume(self):
|
||||
"""恢复流水线"""
|
||||
|
||||
def stop(self):
|
||||
"""停止流水线"""
|
||||
|
||||
def step_extracting(self):
|
||||
"""Step 1: 提取片段"""
|
||||
|
||||
def step_transcribing(self):
|
||||
"""Step 2: 转录"""
|
||||
|
||||
def step_title_correcting(self) -> list:
|
||||
"""Step 3: 标题纠正 - 返回需要用户确认的标题"""
|
||||
# 返回标题列表,用户可以在此介入修改
|
||||
|
||||
def step_generating_subtitles(self):
|
||||
"""Step 4: 生成字幕"""
|
||||
|
||||
def step_merging(self):
|
||||
"""Step 5: 合并视频"""
|
||||
|
||||
def step_burning(self):
|
||||
"""Step 6: 烧录字幕"""
|
||||
```
|
||||
|
||||
### 3.3 Worker(后台工作线程)
|
||||
|
||||
```python
|
||||
class Worker(QThread):
|
||||
"""后台工作线程 - 在独立线程中执行流水线"""
|
||||
|
||||
progress_signal = pyqtSignal(str, int, str) # step, percent, message
|
||||
clip_completed_signal = pyqtSignal(int) # clip_index
|
||||
step_completed_signal = pyqtSignal(str) # step_name
|
||||
titles_ready_signal = pyqtSignal(list) # 标题列表,等待用户确认
|
||||
finished_signal = pyqtSignal(bool, str) # success, message
|
||||
log_signal = pyqtSignal(str) # 日志消息
|
||||
|
||||
def __init__(self, controller: PipelineController):
|
||||
super().__init__()
|
||||
self.controller = controller
|
||||
|
||||
def run(self):
|
||||
"""执行流水线(可暂停)"""
|
||||
|
||||
def request_pause(self):
|
||||
"""请求暂停(由 UI 调用)"""
|
||||
```
|
||||
|
||||
### 3.4 ConfigPanel(配置面板)
|
||||
|
||||
```python
|
||||
class ConfigPanel(QWidget):
|
||||
"""配置面板"""
|
||||
|
||||
config_changed_signal = pyqtSignal(dict)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化 UI"""
|
||||
# API 配置组
|
||||
# - API Host (QLineEdit)
|
||||
# - API Key (QLineEdit, 密码模式)
|
||||
# - 模型选择 (QComboBox)
|
||||
|
||||
# 视频配置组
|
||||
# - 视频文件选择 (QLineEdit + QPushButton)
|
||||
# - 输出目录选择 (QLineEdit + QPushButton)
|
||||
|
||||
# Whisper 配置组
|
||||
# - 模型选择 (QComboBox: base/small/medium/large)
|
||||
# - 模型路径 (QLineEdit)
|
||||
|
||||
def load_config(self, config: dict):
|
||||
"""加载配置到 UI"""
|
||||
|
||||
def get_config(self) -> dict:
|
||||
"""从 UI 获取配置"""
|
||||
|
||||
def validate(self) -> tuple:
|
||||
"""验证配置有效性"""
|
||||
# 返回 (is_valid, error_message)
|
||||
```
|
||||
|
||||
### 3.5 ProgressView(进度视图)
|
||||
|
||||
```python
|
||||
class ProgressView(QWidget):
|
||||
"""进度监控视图"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化 UI"""
|
||||
# 当前步骤标签 (QLabel)
|
||||
# 整体进度条 (QProgressBar)
|
||||
# Clip 进度 (QLabel: "Clip 3/14")
|
||||
# 日志文本框 (QTextEdit, 只读)
|
||||
# 控制按钮 (开始/暂停/停止/继续)
|
||||
|
||||
def update_progress(self, step: str, percent: int, message: str):
|
||||
"""更新进度显示"""
|
||||
|
||||
def append_log(self, message: str):
|
||||
"""追加日志"""
|
||||
|
||||
def set_clip_progress(self, current: int, total: int):
|
||||
"""设置 Clip 进度"""
|
||||
|
||||
def enable_controls(self, can_start: bool, can_pause: bool, can_stop: bool, can_resume: bool):
|
||||
"""设置控制按钮状态"""
|
||||
```
|
||||
|
||||
### 3.6 ContentEditor(内容编辑器 - 人工介入点)
|
||||
|
||||
```python
|
||||
class ContentEditor(QWidget):
|
||||
"""内容编辑器 - 用于人工介入修改标题和字幕内容"""
|
||||
|
||||
content_confirmed_signal = pyqtSignal(dict) # 用户确认的内容 {clip_index: {title, subtitles}}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化 UI"""
|
||||
# 标签页:标题编辑 / 字幕编辑
|
||||
# 标题编辑:
|
||||
# - Clip # | 原始标题 | LLM建议 | 用户修改 | 操作
|
||||
# - 编辑按钮 (QPushButton)
|
||||
# 字幕编辑:
|
||||
# - 按Clip分页,每个Clip显示其字幕内容
|
||||
# - 每个字幕段可编辑(原始文本 → 纠正后文本 → 用户修改)
|
||||
# 确认按钮 (QPushButton)
|
||||
|
||||
def set_content(self, clips_data: dict):
|
||||
"""设置内容供用户编辑
|
||||
clips_data: {
|
||||
clip_index: {
|
||||
'title': {...},
|
||||
'subtitles': [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def get_user_content(self) -> dict:
|
||||
"""获取用户修改后的内容"""
|
||||
|
||||
def edit_clip_title(self, clip_index: int):
|
||||
"""编辑单个Clip的标题 - 弹出对话框"""
|
||||
|
||||
def edit_clip_subtitle(self, clip_index: int, subtitle_index: int):
|
||||
"""编辑单个字幕段 - 弹出对话框"""
|
||||
```
|
||||
|
||||
### 3.7 SubtitleSegmentEditor(字幕段编辑器)
|
||||
|
||||
```python
|
||||
class SubtitleSegmentEditor(QWidget):
|
||||
"""单个字幕片段的编辑器"""
|
||||
|
||||
def __init__(self, segment_data: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
# 显示:时间范围、原始文本、规则纠正后、LLM纠正后、用户可编辑
|
||||
|
||||
def get_corrected_text(self) -> str:
|
||||
"""获取用户修改后的文本"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 流水线状态机
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
┌─────────►│ Ready │◄────────┐
|
||||
│ └────┬────┘ │
|
||||
│ │ start() │ reset()
|
||||
│ ▼ │
|
||||
│ ┌─────────┐ │
|
||||
│ ┌─────│Extracting│─────┐ │
|
||||
│ │ └────┬────┘ │ │
|
||||
│ │ pause │ completed │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌───────────┐ │ │
|
||||
│ └──►│Transcribing│◄────┘ │
|
||||
│ └─────┬─────┘ │
|
||||
│ pause │ │ completed │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │Title Correcting │◄──┐ │ 人工介入点
|
||||
│ └────────┬────────┘ │ │ 用户可暂停
|
||||
│ │ completed │ │ 修改标题
|
||||
│ ▼ │ │
|
||||
│ ┌──────────────────┐ │ │
|
||||
│ │Generating Subtitles│───┘ │
|
||||
│ └─────────┬────────┘ │
|
||||
│ pause │ │ completed │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Merging │◄──┘ │
|
||||
│ └──────┬───────┘ │
|
||||
│ pause│ │ completed │
|
||||
│ ▼ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ Burning │◄─────────────┘
|
||||
│ └─────┬─────┘
|
||||
│ pause│ │ completed
|
||||
│ ▼
|
||||
│ ┌───────────┐
|
||||
└────│ Completed │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 信号流设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ ConfigPanel │ │ ProgressView │ │ TitleEditor │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ config_changed │ titles_ready │ titles_confirmed │
|
||||
└─────────┼──────────────────┼──────────────────┼──────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Controller Layer │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ PipelineController │ │
|
||||
│ │ - manage_workflow() │ │
|
||||
│ │ - handle_pause() / handle_resume() │ │
|
||||
│ │ - collect_user_titles() │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Worker Thread │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Worker (QThread) │ │
|
||||
│ │ - runs pipeline steps │ │
|
||||
│ │ - emits progress/titles signals │ │
|
||||
│ │ - respects pause/stop flags │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 信号定义
|
||||
|
||||
| 信号 | 方向 | 参数 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `config_changed` | UI → Controller | dict | 配置变更 |
|
||||
| `start_pipeline` | Controller → Worker | dict, StateManager | 启动流水线 |
|
||||
| `progress_signal` | Worker → UI | step, percent, message | 进度更新 |
|
||||
| `titles_ready_signal` | Worker → UI | list | 标题列表准备好,等待确认 |
|
||||
| `titles_confirmed_signal` | UI → Controller | list | 用户确认的标题 |
|
||||
| `pause_pipeline` | UI → Worker | - | 请求暂停 |
|
||||
| `resume_pipeline` | UI → Worker | - | 请求恢复 |
|
||||
| `stop_pipeline` | UI → Worker | - | 请求停止 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"app_version": "1.0.0",
|
||||
"project_name": "福田夜校-03月18日",
|
||||
"created_at": "2026-05-02T10:00:00",
|
||||
"updated_at": "2026-05-02T10:30:00",
|
||||
|
||||
"config": {
|
||||
"video_src": "D:/path/to/video.mp4",
|
||||
"output_dir": "D:/path/to/output",
|
||||
"api_key": "xxx",
|
||||
"api_host": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"whisper_model": "large",
|
||||
"whisper_model_path": "D:/AI/LM-Models/faster-whisper/large-v3",
|
||||
"video_params": {
|
||||
"fade_duration": 1,
|
||||
"title_fontsize": 90,
|
||||
"title_color": "FFFF00",
|
||||
"subtitle_fontsize": 24,
|
||||
"subtitle_color": "FFFFFF"
|
||||
}
|
||||
},
|
||||
|
||||
"pipeline": {
|
||||
"current_step": 3,
|
||||
"steps": {
|
||||
"extracting": {"status": "completed", "started_at": "...", "completed_at": "..."},
|
||||
"transcribing": {"status": "completed", "started_at": "...", "completed_at": "..."},
|
||||
"title_correcting": {"status": "in_progress", "started_at": "...", "completed_at": null},
|
||||
"generating_subtitles": {"status": "pending", "started_at": null, "completed_at": null},
|
||||
"merging": {"status": "pending", "started_at": null, "completed_at": null},
|
||||
"burning": {"status": "pending", "started_at": null, "completed_at": null}
|
||||
}
|
||||
},
|
||||
|
||||
"clips": [
|
||||
{
|
||||
"index": 1,
|
||||
"title_original": "弹奏",
|
||||
"title_llm": "弹奏",
|
||||
"title_user": null,
|
||||
"title_final": "弹奏",
|
||||
"start": 412,
|
||||
"end": 442,
|
||||
"status": "completed",
|
||||
"clip_path": "intermediates/clip1_fade.mp4",
|
||||
"json_path": "intermediates/clip1.json",
|
||||
"transcription_completed_at": "..."
|
||||
}
|
||||
],
|
||||
|
||||
"outputs": {
|
||||
"subtitle_title_path": "subs/v1_title.srt",
|
||||
"subtitle_content_path": "subs/v1_content.srt",
|
||||
"merged_video_path": "concat_merged.mp4",
|
||||
"final_video_path": "v1_final.mp4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误处理策略
|
||||
|
||||
| 错误类型 | 处理方式 |
|
||||
|----------|----------|
|
||||
| 配置无效 | 阻止开始,提示用户修正 |
|
||||
| API 调用失败 | 重试 3 次,仍失败则暂停流水线,等待用户处理 |
|
||||
| 视频文件不存在 | 暂停,提示用户选择其他文件 |
|
||||
| 磁盘空间不足 | 暂停,提示用户清理空间 |
|
||||
| 处理异常崩溃 | 状态已持久化,重启后可恢复 |
|
||||
| 用户取消 | 保存当前进度,清理临时文件(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 打包配置 (Nuitka)
|
||||
|
||||
```python
|
||||
# nuitka_options.py
|
||||
import nuitka
|
||||
|
||||
nuitka.compile(
|
||||
script="src/main.py",
|
||||
mode="standalone",
|
||||
output_dir="dist",
|
||||
windows_icon="assets/icon.ico",
|
||||
include_qt_plugins=["qt_plugins/styles", "qt_plugins/imageformats"],
|
||||
data_files=[
|
||||
("assets/icons", "assets/icons"),
|
||||
],
|
||||
remove_output_dir=True,
|
||||
onefile=True, # 打包成单个 exe
|
||||
company_name="Piano Tools",
|
||||
product_name="Piano Highlight Generator",
|
||||
product_version="1.0.0",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 依赖清单
|
||||
|
||||
```
|
||||
PySide6>=6.6.0
|
||||
pyyaml>=6.0
|
||||
requests>=2.31.0
|
||||
pypinyin>=0.50.0
|
||||
faster-whisper>=1.0.0 # 可选,如需本地转录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 开发优先级
|
||||
|
||||
| 优先级 | 模块 | 工期估计 |
|
||||
|--------|------|----------|
|
||||
| P0 | 项目骨架 + ConfigPanel + StateManager | 2h |
|
||||
| P0 | ProgressView + Worker 集成 | 2h |
|
||||
| P0 | PipelineController 核心逻辑 | 2h |
|
||||
| P1 | TitleEditor 标题编辑器 | 1.5h |
|
||||
| P2 | 完善错误处理和边界情况 | 1h |
|
||||
| P2 | 打包配置 + 测试 | 1.5h |
|
||||
| P3 | README 和用户文档 | 0.5h |
|
||||
|
||||
**总工期估计:约 10 小时**
|
||||
@@ -0,0 +1,90 @@
|
||||
# 架构设计
|
||||
|
||||
## 1. 技术栈
|
||||
|
||||
| 层级 | 技术 | 选型理由 |
|
||||
|------|------|----------|
|
||||
| GUI 框架 | PySide6 (Qt for Python) | LGPL 许可,功能完备,信号槽机制适合异步更新 |
|
||||
| 打包工具 | Nuitka | 编译为 C,性能好,体积小 |
|
||||
| 状态持久化 | JSON 文件 | 简单,无需数据库依赖 |
|
||||
| 核心模块 | 复用现有脚本 | video.py, subtitle.py, llm.py, corrections.py |
|
||||
| 配置格式 | YAML/JSON | 用户友好,可读性好 |
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
piano-highlight-app/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── app.py # QMainWindow 主窗口
|
||||
│ ├── gui/ # GUI 组件
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config_panel.py # 配置面板
|
||||
│ │ ├── progress_view.py # 进度监控
|
||||
│ │ ├── title_editor.py # 标题编辑器
|
||||
│ │ └── log_view.py # 日志窗口
|
||||
│ ├── logic/ # 业务逻辑
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config_manager.py # 配置管理
|
||||
│ │ ├── pipeline_controller.py # 流水线控制
|
||||
│ │ ├── state_manager.py # 状态管理
|
||||
│ │ └── worker.py # 后台工作线程
|
||||
│ └── core/ # 核心模块(复用)
|
||||
│ ├── __init__.py
|
||||
│ ├── constants.py # 常量
|
||||
│ ├── utils.py # 工具函数
|
||||
│ ├── video.py # 视频处理
|
||||
│ ├── subtitle.py # 字幕处理
|
||||
│ ├── llm.py # LLM 调用
|
||||
│ └── corrections.py # 纠错规则
|
||||
├── assets/ # 资源文件
|
||||
│ └── icons/
|
||||
├── requirements.txt # 依赖
|
||||
├── pyproject.toml # 项目配置
|
||||
├── nuitka_options.py # Nuitka 打包配置
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 3. 核心类设计
|
||||
|
||||
### StateManager(状态管理)
|
||||
|
||||
负责状态持久化,支持暂停/恢复。
|
||||
|
||||
### PipelineController(流水线控制)
|
||||
|
||||
管理处理流程的 6 个步骤:
|
||||
1. extract - 片段提取
|
||||
2. transcribe - 语音转录
|
||||
3. title_correct - 标题生成与纠错
|
||||
4. generate_subtitles - 字幕生成
|
||||
5. merge - 片段合并
|
||||
6. burn - 字幕烧录
|
||||
|
||||
### Worker(后台工作线程)
|
||||
|
||||
在独立线程中执行流水线,通过信号与 UI 通信。
|
||||
|
||||
## 4. 流水线状态机
|
||||
|
||||
```
|
||||
Ready → Extracting → Transcribing → Title Correcting → Generating Subtitles → Merging → Burning → Completed
|
||||
↑ ↓
|
||||
└───────────── 用户可暂停并编辑标题 ─────────────┘
|
||||
```
|
||||
|
||||
## 5. 信号流
|
||||
|
||||
| 信号 | 方向 | 说明 |
|
||||
|------|------|------|
|
||||
| config_changed | UI → Controller | 配置变更 |
|
||||
| progress_signal | Worker → UI | 进度更新 |
|
||||
| titles_ready_signal | Worker → UI | 标题列表准备好 |
|
||||
| titles_confirmed_signal | UI → Controller | 用户确认的标题 |
|
||||
|
||||
## 6. 状态文件格式
|
||||
|
||||
JSON 格式,包含配置、流水线状态、clips 列表等。
|
||||
|
||||
详见 design.md。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 更新日志
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [版本号] - 日期
|
||||
|
||||
### Added
|
||||
- 新功能
|
||||
|
||||
### Changed
|
||||
- 功能变更
|
||||
|
||||
### Fixed
|
||||
- 问题修复
|
||||
|
||||
### Deprecated
|
||||
- 弃用功能
|
||||
|
||||
### Removed
|
||||
- 移除的功能
|
||||
|
||||
### Security
|
||||
- 安全相关
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
### [1.0.0] - 2026-05-02
|
||||
|
||||
### Added
|
||||
- 初始版本发布
|
||||
- 片段提取功能
|
||||
- Whisper 语音转录
|
||||
- LLM 标题纠错
|
||||
- 双轨字幕生成
|
||||
- 字幕烧录功能
|
||||
- 状态持久化支持
|
||||
- 暂停/恢复功能
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
# Piano Highlight Generator App - 需求提案
|
||||
|
||||
> 提案日期:2026-05-02
|
||||
> 提案人:AI Dev Team
|
||||
> 状态:Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题描述
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
当前钢琴课精华视频生成器是 Python CLI 脚本集合:
|
||||
- `generate_highlights.py` - 命令行程序,一次性执行完整流程
|
||||
- 无图形界面,需要手动编辑 YAML 配置文件
|
||||
- 无状态保持,程序中断只能从头开始
|
||||
- 无人工介入点,无法在处理过程中修改标题
|
||||
|
||||
### 1.2 用户需求
|
||||
|
||||
用户希望将其重构为**可分发的桌面应用**,具备:
|
||||
|
||||
| 需求 | 说明 |
|
||||
|------|------|
|
||||
| **配置界面** | 图形化配置 API 大模型参数,无需编辑 YAML |
|
||||
| **全过程监控** | 实时显示每个处理步骤的状态 |
|
||||
| **状态保持** | 可在任意步骤暂停,然后继续往下进行 |
|
||||
| **人工介入** | 暂停时可手动修改标题,然后再最后烧制 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户故事
|
||||
|
||||
### 2.1 作为用户,我想要...
|
||||
|
||||
**Story 1: 配置管理**
|
||||
- 打开应用后,能看到清晰的配置界面
|
||||
- 可以选择/切换不同的 LLM API(当前是火山方舟,可扩展)
|
||||
- 可以选择 Whisper 模型(用于音频转录)
|
||||
- 配置可以保存和加载
|
||||
|
||||
**Story 2: 全过程监控**
|
||||
- 启动处理后,能看到每个步骤的实时状态
|
||||
- 看到当前正在处理哪个片段(Clip 3/14)
|
||||
- 看到预估剩余时间
|
||||
- 能看到每个步骤的日志输出
|
||||
|
||||
**Story 3: 暂停与恢复**
|
||||
- 点击"暂停"按钮,处理立即停止
|
||||
- 关闭应用后,下次打开能从暂停点继续
|
||||
- 恢复后从最近的检查点继续,不丢失进度
|
||||
|
||||
**Story 4: 人工介入**
|
||||
- 在标题纠正步骤,可以预览每个片段的标题
|
||||
- 可以手动修改标题
|
||||
- 可以跳过某些片段
|
||||
- 所有修改会被保存
|
||||
|
||||
**Story 5: 打包分发**
|
||||
- 生成一个 .exe 文件,双击即可运行
|
||||
- 无需安装 Python 环境
|
||||
- 可以在其他电脑上使用
|
||||
|
||||
---
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
### 3.1 配置界面
|
||||
- [ ] 应用启动后显示配置面板
|
||||
- [ ] 可以输入/编辑 API Key 和 API Host
|
||||
- [ ] 可以选择 Whisper 模型(base/small/medium/large)
|
||||
- [ ] 可以选择输入视频文件(文件选择对话框)
|
||||
- [ ] 可以选择输出目录
|
||||
- [ ] 配置可以保存到文件(JSON/YAML)
|
||||
- [ ] 配置可以从文件加载
|
||||
|
||||
### 3.2 全过程监控
|
||||
- [ ] 显示当前步骤:准备 → 提取片段 → 转录 → 标题纠正 → 合并 → 烧录
|
||||
- [ ] 每一步显示进度条(百分比)
|
||||
- [ ] 显示当前片段序号(如 "Clip 3/14")
|
||||
- [ ] 实时显示处理日志
|
||||
- [ ] 处理完成后显示最终视频路径
|
||||
|
||||
### 3.3 暂停与恢复
|
||||
- [ ] 有"暂停"按钮,点击后处理停止
|
||||
- [ ] 暂停时状态持久化到文件
|
||||
- [ ] 重新打开应用后,自动检测到未完成的任务
|
||||
- [ ] 可以选择"继续"从暂停点恢复
|
||||
- [ ] 也可以选择"重新开始"
|
||||
|
||||
### 3.4 人工介入
|
||||
- [ ] 在标题纠正步骤,显示所有片段的标题列表
|
||||
- [ ] 每个标题可以点击编辑
|
||||
- [ ] 编辑后自动保存
|
||||
- [ ] 可以预览修改后的字幕效果
|
||||
- [ ] 确认后继续后续步骤
|
||||
|
||||
### 3.5 打包分发
|
||||
- [ ] 生成 Windows 可执行文件(.exe)
|
||||
- [ ] 双击运行,无须安装 Python
|
||||
- [ ] 界面美观,与现代桌面应用一致
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术选型
|
||||
|
||||
### 4.1 GUI 框架
|
||||
|
||||
**选择:PySide6 (Qt for Python)**
|
||||
|
||||
| 因素 | 结论 |
|
||||
|------|------|
|
||||
| 许可证 | LGPL - 可闭源商用,无需购买 |
|
||||
| 功能 | 成熟完备,适合复杂桌面应用 |
|
||||
| 状态管理 | 传统保留模式,天然支持暂停/恢复 |
|
||||
| 多线程 | 信号槽机制完善,支持多线程安全更新 UI |
|
||||
| 社区 | 文档丰富,社区活跃 |
|
||||
| 打包 | Nuitka 打包体积小(~10MB),启动快 |
|
||||
|
||||
### 4.2 打包方案
|
||||
|
||||
**开发阶段**:PyInstaller(调试方便)
|
||||
**正式发布**:Nuitka(体积小,性能好)
|
||||
|
||||
### 4.3 状态持久化
|
||||
|
||||
- 使用 JSON 文件存储处理进度
|
||||
- 每个项目独立的状态文件
|
||||
- 包含:当前步骤、已完成片段、用户修改的标题等
|
||||
|
||||
---
|
||||
|
||||
## 5. 架构设计(预览)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ GUI Layer (PySide6) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ ConfigPanel │ │ ProgressView│ │ TitleEditor │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Business Logic Layer │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ ConfigManager│ │PipelineCtrl │ │ StateManager │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Core Modules (Legacy) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ video.py│ │subtitle │ │ llm.py │ │correction│ │
|
||||
│ │ │ │ .py │ │ │ │s.py │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| ConfigPanel | 配置界面(API、视频路径、模型选择) |
|
||||
| ProgressView | 进度监控(步骤、百分比、日志) |
|
||||
| TitleEditor | 标题编辑器(预览、编辑用户修改) |
|
||||
| ConfigManager | 配置加载/保存 |
|
||||
| PipelineController | 流水线控制(启动/暂停/恢复/停止) |
|
||||
| StateManager | 状态持久化(保存/恢复进度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 流水线步骤
|
||||
|
||||
与应用UI对应的处理步骤:
|
||||
|
||||
```
|
||||
Step 0: 准备 (Ready)
|
||||
└─ 检查配置、初始化环境
|
||||
|
||||
Step 1: 提取片段 (Extracting)
|
||||
└─ 从视频提取 clips -> intermediates/clip{N}_fade.mp4
|
||||
|
||||
Step 2: 转录 (Transcribing)
|
||||
└─ Whisper 转录 -> intermediates/clip{N}.json
|
||||
|
||||
Step 3: 标题纠正 (Title Correcting)
|
||||
└─ LLM 分析 + 用户修改 -> intermediates/corrected_titles.json
|
||||
[人工介入点] 用户可在此步骤暂停并修改标题
|
||||
|
||||
Step 4: 生成字幕 (Generating Subtitles)
|
||||
└─ 生成双轨字幕 -> subs/v{N}_title.srt, v{N}_content.srt
|
||||
|
||||
Step 5: 合并视频 (Merging)
|
||||
└─ FFmpeg concat -> concat_merged.mp4
|
||||
|
||||
Step 6: 烧录字幕 (Burning)
|
||||
└─ FFmpeg burn -> v{N}_final.mp4
|
||||
|
||||
Step 7: 完成 (Completed)
|
||||
└─ 显示结果,清理临时文件(可选)
|
||||
```
|
||||
|
||||
### 暂停点
|
||||
|
||||
用户可以在以下步骤暂停:
|
||||
- Step 1 完成后(片段已提取)
|
||||
- Step 2 完成后(转录已完成)
|
||||
- Step 3 完成后(标题已确认)- **推荐暂停点**
|
||||
- Step 4 完成后(字幕已生成)
|
||||
- Step 5 完成后(视频已合并)
|
||||
|
||||
---
|
||||
|
||||
## 7. 状态数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"project_name": "福田夜校-03月18日",
|
||||
"config": {
|
||||
"video_src": "D:/path/to/video.mp4",
|
||||
"output_dir": "D:/path/to/output",
|
||||
"api_key": "xxx",
|
||||
"api_host": "https://...",
|
||||
"whisper_model": "large"
|
||||
},
|
||||
"current_step": 3,
|
||||
"clips": [
|
||||
{
|
||||
"index": 1,
|
||||
"title_original": "弹奏",
|
||||
"title_corrected": "弹奏",
|
||||
"title_user_modified": null,
|
||||
"start": 412,
|
||||
"end": 442,
|
||||
"status": "completed",
|
||||
"clip_path": "intermediates/clip1_fade.mp4",
|
||||
"json_path": "intermediates/clip1.json"
|
||||
}
|
||||
],
|
||||
"step_status": {
|
||||
"extracting": "completed",
|
||||
"transcribing": "completed",
|
||||
"title_correcting": "in_progress",
|
||||
"generating_subtitles": "pending",
|
||||
"merging": "pending",
|
||||
"burning": "pending"
|
||||
},
|
||||
"created_at": "2026-05-02T10:00:00",
|
||||
"updated_at": "2026-05-02T10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 边界情况
|
||||
|
||||
| 情况 | 处理方式 |
|
||||
|------|----------|
|
||||
| API Key 无效 | 显示错误提示,不开始处理 |
|
||||
| 视频文件不存在 | 配置检查时发现,提示用户 |
|
||||
| 处理到一半崩溃 | 状态已持久化,重启后可恢复 |
|
||||
| 用户取消处理 | 保存当前进度,可选择重新开始或继续 |
|
||||
| Whisper 模型未下载 | 显示下载链接,或使用默认模型 |
|
||||
| 输出目录磁盘空间不足 | 处理前检查,提示用户 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 非功能性需求
|
||||
|
||||
| 需求 | 标准 |
|
||||
|------|------|
|
||||
| 启动时间 | < 3 秒(使用 Nuitka 打包后) |
|
||||
| UI 响应性 | 所有操作在 100ms 内响应 |
|
||||
| 内存占用 | < 500MB(不含视频处理) |
|
||||
| 打包后体积 | < 50MB |
|
||||
| 兼容性 | Windows 10/11 64-bit |
|
||||
|
||||
---
|
||||
|
||||
## 10. 后续步骤
|
||||
|
||||
1. **Phase 2**: 技术设计 - 详细架构设计、数据库选型、接口定义
|
||||
2. **Phase 3**: 任务编排 - 拆分并行任务、创建 git worktree
|
||||
3. **Phase 4**: 并行开发 - 各模块实现
|
||||
4. **Phase 5**: 质量交付 - 审查、测试、打包
|
||||
@@ -0,0 +1,71 @@
|
||||
[project]
|
||||
name = "piano-highlight-app"
|
||||
version = "1.0.0"
|
||||
description = "Piano Highlight Generator App - 自动生成钢琴课精华宣传视频"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Piano Tools Team"}
|
||||
]
|
||||
dependencies = [
|
||||
"PySide6>=6.6.0",
|
||||
"pyyaml>=6.0",
|
||||
"requests>=2.31.0",
|
||||
"pypinyin>=0.50.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-qt>=4.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
|
||||
[tool.nuitka]
|
||||
# Basic options
|
||||
assume_yes_for_downloads = true
|
||||
show_progress = true
|
||||
verbose = false
|
||||
|
||||
# Output configuration
|
||||
output_dir = "dist"
|
||||
output_name = "PianoHighlightGenerator"
|
||||
|
||||
# Python version
|
||||
python_version = "3.10"
|
||||
|
||||
# Compilation mode
|
||||
standalone = true
|
||||
onefile = true
|
||||
|
||||
# Plugin enablement
|
||||
enable_plugin.pyside6 = true
|
||||
|
||||
# Windows specific
|
||||
[tool.nuitka.windows]
|
||||
console = false # GUI app, no console window
|
||||
|
||||
# Data files to include (relative to project root)
|
||||
# Uncomment when prompts directory exists:
|
||||
# include_data_files = [
|
||||
# { from = "src/core/prompts", to = "prompts", relative_to = "." }
|
||||
# ]
|
||||
|
||||
# Advanced options
|
||||
[tool.nuitka.advanced]
|
||||
# Treat sqlite as required (for state storage)
|
||||
no_sqlite = false
|
||||
|
||||
# Include optional dependencies for better compatibility
|
||||
include_optional = true
|
||||
@@ -0,0 +1,5 @@
|
||||
# Build dependencies for Nuitka compilation
|
||||
# Install with: pip install -r requirements-build.txt
|
||||
|
||||
nuitka>=1.7.0
|
||||
pandas>=1.5.0
|
||||
@@ -0,0 +1,8 @@
|
||||
# Core dependencies
|
||||
PySide6>=6.6.0
|
||||
pyyaml>=6.0
|
||||
requests>=2.31.0
|
||||
pypinyin>=0.50.0
|
||||
|
||||
# Optional: Transcription (required for full functionality)
|
||||
# faster-whisper>=0.10.0
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo Cleaning pycache...
|
||||
rmdir /s /q "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\src\__pycache__" 2>nul
|
||||
rmdir /s /q "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\src\core\__pycache__" 2>nul
|
||||
echo Cache cleaned.
|
||||
echo.
|
||||
echo Running CLI...
|
||||
del "D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt" 2>nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\run_lesson1.py"
|
||||
echo.
|
||||
echo Exit: %errorlevel%
|
||||
pause
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
VIDEO = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\直播回放-03月18日.mp4"
|
||||
PPT = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
|
||||
OUTPUT = r"D:\F\NewI\opencode\daily-workspace\projects\piano-lesson-highlights\cases\lesson1\output_cli_full"
|
||||
PYTHON = r"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
CLI_DIR = r"D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\src"
|
||||
API_KEY = "b0359bed-09f2-49e2-a53c-32ba057412e3"
|
||||
API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
LOG_FILE = r"D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = r"D:\ProgramData\anaconda3\envs\py312_cuda;" + env.get("PATH", "")
|
||||
|
||||
cmd = [
|
||||
PYTHON,
|
||||
os.path.join(CLI_DIR, "cli.py"),
|
||||
"--video", VIDEO,
|
||||
"--ppt", PPT,
|
||||
"--output", OUTPUT,
|
||||
"--api-key", API_KEY,
|
||||
"--api-host", API_HOST,
|
||||
"--verbose"
|
||||
]
|
||||
|
||||
print("Starting CLI...")
|
||||
print(f"Video: {VIDEO}")
|
||||
print(f"PPT: {PPT}")
|
||||
print(f"Log: {LOG_FILE}")
|
||||
|
||||
proc = subprocess.Popen(cmd, cwd=CLI_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8', errors='replace')
|
||||
|
||||
with open(LOG_FILE, 'w', encoding='utf-8') as log:
|
||||
for line in proc.stdout:
|
||||
log.write(line)
|
||||
log.flush()
|
||||
print(line, end='')
|
||||
|
||||
proc.wait()
|
||||
print(f"\nExit code: {proc.returncode}")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,112 @@
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
:: ============================================
|
||||
:: Piano Highlight Generator - Startup Script
|
||||
:: ============================================
|
||||
|
||||
set "PROJECT_DIR=%~dp0"
|
||||
set "SRC_DIR=%PROJECT_DIR%src"
|
||||
set "VENV_DIR=%PROJECT_DIR%venv"
|
||||
set "PYTHON_SRC=D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
set "STATE_FILE=%PROJECT_DIR%piano_highlight_state.json"
|
||||
|
||||
echo ============================================
|
||||
echo Piano Highlight Generator - Launcher
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
:: --------------------------------------------
|
||||
:: 1. Create venv using working Python
|
||||
:: --------------------------------------------
|
||||
echo [1/4] Setting up Python environment...
|
||||
|
||||
if exist "%VENV_DIR%\Scripts\python.exe" (
|
||||
echo venv exists, skipping creation
|
||||
) else (
|
||||
echo Creating venv using py312_cuda Python...
|
||||
"%PYTHON_SRC%" -m venv "%VENV_DIR%"
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to create venv
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo venv created
|
||||
)
|
||||
|
||||
:: --------------------------------------------
|
||||
:: 2. Install dependencies
|
||||
:: --------------------------------------------
|
||||
echo.
|
||||
echo [2/4] Installing dependencies...
|
||||
|
||||
set "VENV_PIP=%VENV_DIR%\Scripts\pip.exe"
|
||||
|
||||
:: Check if PySide6 installed
|
||||
"%VENV_DIR%\python.exe" -c "import PySide6" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing PySide6 and dependencies...
|
||||
"%VENV_PIP%" install PySide6 pyyaml requests pypinyin
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to install dependencies
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo Dependencies already installed
|
||||
)
|
||||
|
||||
:: --------------------------------------------
|
||||
:: 3. Setup FFmpeg
|
||||
:: --------------------------------------------
|
||||
echo.
|
||||
echo [3/4] Setting up FFmpeg...
|
||||
|
||||
set "FFMPEG_SOURCE=D:\ProgramData\anaconda3\envs\py312_cuda\Scripts"
|
||||
set "FFMPEG_LOCAL=%PROJECT_DIR%ffmpeg"
|
||||
|
||||
if exist "%FFMPEG_LOCAL%\ffmpeg-8.1-full_build\bin\ffmpeg.exe" (
|
||||
echo Local FFmpeg found
|
||||
) else (
|
||||
echo Copying FFmpeg from conda env...
|
||||
if not exist "%FFMPEG_LOCAL%\ffmpeg-8.1-full_build\bin" mkdir "%FFMPEG_LOCAL%\ffmpeg-8.1-full_build\bin"
|
||||
for %%f in (ffmpeg.exe ffprobe.exe ffplay.exe) do (
|
||||
if exist "%FFMPEG_SOURCE%\%%f" copy /Y "%FFMPEG_SOURCE%\%%f" "%FFMPEG_LOCAL%\ffmpeg-8.1-full_build\bin\%%f" >nul
|
||||
)
|
||||
)
|
||||
set "FFMPEG_BIN=%FFMPEG_LOCAL%\ffmpeg-8.1-full_build\bin"
|
||||
echo FFmpeg ready
|
||||
|
||||
:: --------------------------------------------
|
||||
:: 4. Launch Application
|
||||
:: --------------------------------------------
|
||||
echo.
|
||||
echo [4/4] Launching...
|
||||
|
||||
set "PATH=%FFMPEG_BIN%;%PATH%"
|
||||
|
||||
if exist "%STATE_FILE%" (
|
||||
echo.
|
||||
echo Found incomplete work. Resume?
|
||||
set /p RESTORE=" [Y/N]: "
|
||||
if /i "!RESTORE!"=="N" (
|
||||
del /f "%STATE_FILE%" >nul 2>&1
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo Starting...
|
||||
echo ============================================
|
||||
|
||||
cd /d "%SRC_DIR%"
|
||||
"%VENV_DIR%\Scripts\python.exe" main.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] Exit code: !errorlevel!
|
||||
) else (
|
||||
echo.
|
||||
echo Done
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,477 @@
|
||||
# Piano Highlight Generator App - 任务拆解
|
||||
|
||||
> 创建日期:2026-05-02
|
||||
> 基于:design.md
|
||||
|
||||
---
|
||||
|
||||
## 任务总览
|
||||
|
||||
| # | 任务 | 依赖 | 优先级 | 工期 |
|
||||
|---|------|------|--------|------|
|
||||
| 1 | 项目骨架搭建 | - | P0 | 1h |
|
||||
| 2 | ConfigPanel 配置面板 | 1 | P0 | 1h |
|
||||
| 3 | StateManager 状态管理 | 1 | P0 | 0.5h |
|
||||
| 4 | ProgressView 进度视图 | 1 | P0 | 1h |
|
||||
| 5 | Worker 后台线程 | 1 | P0 | 1h |
|
||||
| 6 | PipelineController 流水线控制 | 3, 5 | P0 | 1.5h |
|
||||
| 7 | TitleEditor 标题编辑器 | 6 | P1 | 1.5h |
|
||||
| 8 | 主窗口集成 | 2, 4, 7 | P0 | 1h |
|
||||
| 9 | 核心模块适配 | 1 | P0 | 0.5h |
|
||||
| 10 | 错误处理完善 | 8 | P2 | 1h |
|
||||
| 11 | 打包配置 + 测试 | 10 | P2 | 1.5h |
|
||||
| 12 | 文档和 README | 11 | P3 | 0.5h |
|
||||
|
||||
---
|
||||
|
||||
## 任务 1: 项目骨架搭建
|
||||
|
||||
**文件**:
|
||||
- `src/__init__.py`
|
||||
- `src/main.py` - 应用入口
|
||||
- `src/app.py` - QMainWindow 主窗口
|
||||
- `src/gui/__init__.py`
|
||||
- `src/logic/__init__.py`
|
||||
- `src/core/__init__.py`
|
||||
- `requirements.txt`
|
||||
- `pyproject.toml`
|
||||
|
||||
**内容**:
|
||||
- 创建目录结构
|
||||
- 创建空的 `__init__.py`
|
||||
- `main.py` 使用 QApplication 启动
|
||||
- `app.py` 创建空的主窗口框架
|
||||
- `requirements.txt` 包含所有依赖
|
||||
- 配置 pyproject.toml
|
||||
|
||||
**验收标准**:
|
||||
- 运行 `python src/main.py` 能启动空窗口
|
||||
- 窗口标题为 "Piano Highlight Generator"
|
||||
|
||||
---
|
||||
|
||||
## 任务 2: ConfigPanel 配置面板
|
||||
|
||||
**文件**:`src/gui/config_panel.py`
|
||||
|
||||
**UI 组件**:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ API 配置 │
|
||||
│ API Host: [________________________] │
|
||||
│ API Key: [________________________] │
|
||||
│ 模型: [火山方舟 Ark v] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 视频配置 │
|
||||
│ 视频文件: [________________] [浏览...] │
|
||||
│ 输出目录: [________________] [浏览...] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Whisper 配置 │
|
||||
│ 模型: [large v] │
|
||||
│ 模型路径: [________________] [浏览...] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [开始处理] [保存配置] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**信号**:
|
||||
- `config_changed_signal(dict)` - 配置变更时发出
|
||||
|
||||
**方法**:
|
||||
- `load_config(config: dict)` - 加载配置到 UI
|
||||
- `get_config() -> dict` - 从 UI 获取配置
|
||||
- `validate() -> (bool, str)` - 验证配置有效性
|
||||
|
||||
**验收标准**:
|
||||
- 所有输入框能正常输入
|
||||
- 文件选择对话框能选择文件和目录
|
||||
- 配置变更时发出信号
|
||||
|
||||
---
|
||||
|
||||
## 任务 3: StateManager 状态管理
|
||||
|
||||
**文件**:`src/logic/state_manager.py`
|
||||
|
||||
**类**:`StateManager`
|
||||
|
||||
**方法**:
|
||||
```python
|
||||
def __init__(self, state_file: str):
|
||||
"""初始化,加载或创建状态文件"""
|
||||
|
||||
def save(self):
|
||||
"""保存状态到 JSON 文件"""
|
||||
|
||||
def get_current_step(self) -> int:
|
||||
"""获取当前步骤索引 (0-6)"""
|
||||
|
||||
def get_step_name(self) -> str:
|
||||
"""获取当前步骤名称"""
|
||||
|
||||
def set_step_status(self, step_name: str, status: str):
|
||||
"""设置步骤状态 (pending/in_progress/completed/failed)"""
|
||||
|
||||
def update_clip(self, clip_index: int, **kwargs):
|
||||
"""更新 clip 信息"""
|
||||
|
||||
def get_clips(self) -> list:
|
||||
"""获取所有 clips"""
|
||||
|
||||
def get_user_modified_titles(self) -> dict:
|
||||
"""获取用户修改过的标题 {clip_index: title}"""
|
||||
|
||||
def reset(self):
|
||||
"""重置状态,开始新项目"""
|
||||
```
|
||||
|
||||
**状态文件**:`{output_dir}/state.json`
|
||||
|
||||
**验收标准**:
|
||||
- 创建新状态时生成默认结构
|
||||
- 加载已有状态时恢复完整信息
|
||||
- 保存后 JSON 文件格式正确
|
||||
|
||||
---
|
||||
|
||||
## 任务 4: ProgressView 进度视图
|
||||
|
||||
**文件**:`src/gui/progress_view.py`
|
||||
|
||||
**UI 组件**:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 当前步骤: 提取视频片段 [Clip 3/14] │
|
||||
│ ████████████░░░░░░░░░░░░ 50% │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [准备] → [提取] → [转录] → [纠正] → [字幕] │
|
||||
│ → [合并] → [烧录] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 日志: │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 10:30:01 开始提取片段 3/14 │ │
|
||||
│ │ 10:30:02 片段 3 提取完成 │ │
|
||||
│ │ 10:30:03 开始提取片段 4/14 │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [暂停] [停止] [继续] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**信号**:
|
||||
- `start_signal` - 开始处理
|
||||
- `pause_signal` - 暂停
|
||||
- `resume_signal` - 继续
|
||||
- `stop_signal` - 停止
|
||||
|
||||
**方法**:
|
||||
- `update_step(step_name: str, percent: int)` - 更新步骤进度
|
||||
- `update_clip_progress(current: int, total: int)` - 更新 Clip 进度
|
||||
- `append_log(message: str)` - 追加日志
|
||||
- `show_titles_for_review(titles: list)` - 显示标题待审核(触发 TitleEditor)
|
||||
|
||||
**验收标准**:
|
||||
- 进度条能实时更新
|
||||
- 日志能自动滚动到最新
|
||||
- 按钮状态能根据流水线状态变化
|
||||
|
||||
---
|
||||
|
||||
## 任务 5: Worker 后台线程
|
||||
|
||||
**文件**:`src/logic/worker.py`
|
||||
|
||||
**类**:`Worker(QThread)`
|
||||
|
||||
**信号**:
|
||||
```python
|
||||
progress_signal = pyqtSignal(str, int, str) # step_name, percent, message
|
||||
clip_completed_signal = pyqtSignal(int) # clip_index
|
||||
step_started_signal = pyqtSignal(str) # step_name
|
||||
step_completed_signal = pyqtSignal(str) # step_name
|
||||
titles_ready_signal = pyqtSignal(list) # [{clip_index, original, llm_suggested}]
|
||||
error_signal = pyqtSignal(str) # error_message
|
||||
finished_signal = pyqtSignal(bool, str) # success, message
|
||||
log_signal = pyqtSignal(str) # log message
|
||||
```
|
||||
|
||||
**方法**:
|
||||
- `__init__(config, state_manager, controller)`
|
||||
- `run()` - 执行流水线
|
||||
- `request_pause()` - 请求暂停
|
||||
- `request_stop()` - 请求停止
|
||||
|
||||
**暂停实现**:
|
||||
```python
|
||||
def run(self):
|
||||
for step in self.steps:
|
||||
if self.is_stopped:
|
||||
break
|
||||
if self.is_paused:
|
||||
self.wait_for_resume() # 等待用户resume信号
|
||||
# 执行步骤...
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- UI 在处理过程中不卡顿
|
||||
- 暂停信号能在 1 秒内响应
|
||||
- 所有信号能正确传递到 UI
|
||||
|
||||
---
|
||||
|
||||
## 任务 6: PipelineController 流水线控制
|
||||
|
||||
**文件**:`src/logic/pipeline_controller.py`
|
||||
|
||||
**类**:`PipelineController`
|
||||
|
||||
**步骤定义**:
|
||||
```python
|
||||
STEPS = [
|
||||
'ready',
|
||||
'extracting', # 提取片段
|
||||
'transcribing', # 转录
|
||||
'title_correcting', # 标题纠正(人工介入点)
|
||||
'generating_subtitles', # 生成字幕
|
||||
'merging', # 合并
|
||||
'burning', # 烧录
|
||||
'completed'
|
||||
]
|
||||
```
|
||||
|
||||
**方法**:
|
||||
```python
|
||||
def __init__(self, config: dict, state: StateManager):
|
||||
self.config = config
|
||||
self.state = state
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
|
||||
def run(self, worker: Worker):
|
||||
"""运行流水线"""
|
||||
|
||||
def pause(self):
|
||||
"""暂停"""
|
||||
|
||||
def resume(self):
|
||||
"""恢复"""
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
|
||||
def step_extracting(self, worker: Worker):
|
||||
"""提取片段"""
|
||||
|
||||
def step_transcribing(self, worker: Worker):
|
||||
"""转录(调用 Whisper)"""
|
||||
|
||||
def step_title_correcting(self, worker: Worker) -> list:
|
||||
"""标题纠正 - 调用 LLM,返回需要用户确认的标题"""
|
||||
|
||||
def step_generating_subtitles(self, worker: Worker):
|
||||
"""生成字幕"""
|
||||
|
||||
def step_merging(self, worker: Worker):
|
||||
"""合并视频"""
|
||||
|
||||
def step_burning(self, worker: Worker):
|
||||
"""烧录字幕"""
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- 每个步骤能正确执行
|
||||
- 暂停/恢复能正确工作
|
||||
- 状态能正确保存
|
||||
|
||||
---
|
||||
|
||||
## 任务 7: TitleEditor 标题编辑器
|
||||
|
||||
**文件**:`src/gui/title_editor.py`
|
||||
|
||||
**UI 组件**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 标题审核 - 请确认以下标题是否正确 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ # │ 原始标题 │ LLM建议 │ 修改后 │ 操作 │
|
||||
├───┼──────────────┼─────────────┼───────────────┼──────────┤
|
||||
│ 1 │ 弹奏 │ 弹奏 │ [弹奏 ] │ [编辑] │
|
||||
│ 2 │ 非连奏弹奏法 │ 非连奏弹奏法 │ [非连奏弹奏法] │ [编辑] │
|
||||
│ 3 │ 时值 │ 休止符 ✗ │ [休止符 ] │ [编辑] │
|
||||
│ 4 │ 休止符 │ 休止符 │ [休止符 ] │ [编辑] │
|
||||
│ 5 │ 节奏 │ 节奏 │ [节奏 ] │ [编辑] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [全部确认] [取消] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**编辑弹窗**:
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 编辑标题 - Clip #3 │
|
||||
│ │
|
||||
│ 原始标题: 时值 │
|
||||
│ LLM建议: 休止符 │
|
||||
│ │
|
||||
│ 修改后: [_______________] │
|
||||
│ │
|
||||
│ [确定] [取消] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**信号**:
|
||||
- `titles_confirmed_signal(list)` - 用户确认的标题
|
||||
|
||||
**验收标准**:
|
||||
- 能显示所有标题
|
||||
- 能编辑单个标题
|
||||
- 能批量确认
|
||||
- 确认后返回列表
|
||||
|
||||
---
|
||||
|
||||
## 任务 8: 主窗口集成
|
||||
|
||||
**文件**:`src/app.py`
|
||||
|
||||
**布局**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Piano Highlight Generator [_][□][X] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ │ │ │ │
|
||||
│ │ 配置面板 │ │ 进度视图 │ │
|
||||
│ │ ConfigPanel │ │ ProgressView │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ├─────────────────────────────────┤ │
|
||||
│ │ │ │ 标题编辑器 (折叠) │ │
|
||||
│ │ │ │ TitleEditor │ │
|
||||
│ └─────────────────┘ └─────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 状态: 就绪 v1.0 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**集成逻辑**:
|
||||
1. ConfigPanel 发出 `config_changed_signal` → Controller 接收
|
||||
2. 用户点击"开始" → Controller 启动 Worker
|
||||
3. Worker 发出 `titles_ready_signal` → TitleEditor 显示
|
||||
4. 用户确认标题 → TitleEditor 发出 `titles_confirmed_signal` → Worker 继续
|
||||
5. Worker 发出 `progress_signal` → ProgressView 更新
|
||||
|
||||
**验收标准**:
|
||||
- 能启动处理
|
||||
- 各组件能正确通信
|
||||
- 标题编辑器在正确时机显示
|
||||
|
||||
---
|
||||
|
||||
## 任务 9: 核心模块适配
|
||||
|
||||
**文件**:`src/core/` (复用现有模块)
|
||||
|
||||
**适配工作**:
|
||||
1. 复制 `scripts/` 下的核心模块到 `src/core/`
|
||||
2. 修改 import 路径
|
||||
3. 确保 `constants.py` 中的路径配置可从外部传入
|
||||
4. 适配视频处理函数返回成功/失败状态
|
||||
|
||||
**验收标准**:
|
||||
- 核心模块能在 GUI 中正常调用
|
||||
- 错误能被捕获并传递到 UI
|
||||
|
||||
---
|
||||
|
||||
## 任务 10: 错误处理完善
|
||||
|
||||
**处理场景**:
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| API Key 无效 | 401 错误,提示用户检查配置 |
|
||||
| 视频文件不存在 | 暂停,弹窗提示 |
|
||||
| 磁盘空间不足 | 暂停,弹窗提示 |
|
||||
| Whisper 模型未找到 | 提示下载或选择其他模型 |
|
||||
| 处理异常 | 保存状态,显示错误日志 |
|
||||
|
||||
**验收标准**:
|
||||
- 错误不导致程序崩溃
|
||||
- 错误信息清晰用户友好
|
||||
- 状态正确保存
|
||||
|
||||
---
|
||||
|
||||
## 任务 11: 打包配置 + 测试
|
||||
|
||||
**文件**:
|
||||
- `nuitka_options.py`
|
||||
- `build.bat` (Windows 打包脚本)
|
||||
- `build.sh` (Linux/Mac 打包脚本)
|
||||
|
||||
**打包步骤**:
|
||||
1. 安装依赖:`pip install -r requirements.txt`
|
||||
2. 开发测试:`python src/main.py`
|
||||
3. 打包:`python -m nuitka nuitka_options.py`
|
||||
4. 测试 exe:`dist/PianoHighlightGenerator.exe`
|
||||
|
||||
**验收标准**:
|
||||
- 打包后体积 < 50MB
|
||||
- 双击能正常运行
|
||||
- 所有功能在打包后正常工作
|
||||
|
||||
---
|
||||
|
||||
## 任务 12: 文档和 README
|
||||
|
||||
**文件**:`README.md`
|
||||
|
||||
**内容**:
|
||||
- 应用介绍和截图
|
||||
- 系统要求
|
||||
- 安装指南(开发安装、打包安装)
|
||||
- 使用说明
|
||||
- 常见问题
|
||||
- 许可证
|
||||
|
||||
**验收标准**:
|
||||
- 用户能根据 README 运行应用
|
||||
- 常见问题有解决方案
|
||||
|
||||
---
|
||||
|
||||
## 并行化分析
|
||||
|
||||
**可并行任务**:
|
||||
|
||||
| 任务组 | 可并行任务 | 原因 |
|
||||
|--------|-----------|------|
|
||||
| UI 组件组 | 2, 4, 7 | 独立开发,独立 UI 组件 |
|
||||
| 核心逻辑组 | 3, 5, 6 | 有依赖关系,需顺序开发 |
|
||||
| 集成测试组 | 8, 10 | 依赖前面所有任务 |
|
||||
|
||||
**推荐开发顺序**:
|
||||
1. **第一波(可并行)**:1, 2, 4, 7, 9
|
||||
2. **第二波(依赖第一波)**:3, 5, 6
|
||||
3. **第三波(集成)**:8, 10
|
||||
4. **最后**:11, 12
|
||||
|
||||
---
|
||||
|
||||
## Git 分支规划
|
||||
|
||||
**建议分支**:
|
||||
- `main` - 主分支,稳定代码
|
||||
- `feat/ui` - UI 组件开发 (任务 2, 4, 7)
|
||||
- `feat/core` - 核心逻辑开发 (任务 3, 5, 6)
|
||||
- `feat/integration` - 集成和打包 (任务 8, 10, 11, 12)
|
||||
|
||||
**合并顺序**:
|
||||
```
|
||||
feat/ui ─────────┐
|
||||
feat/core ────────┼──► main
|
||||
feat/integration ─┘
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
f = open(r'D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt', 'rb')
|
||||
data = f.read()
|
||||
f.close()
|
||||
|
||||
print('Total bytes:', len(data))
|
||||
print('First 300 hex:', data[:300].hex())
|
||||
print()
|
||||
print('UTF-8 decode of first 300:')
|
||||
print(data[:300].decode('utf-8', 'replace'))
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -c "import pptx; print('pptx available')"
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\check_pptx2.py"
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys
|
||||
out = r"D:\F\NewI\opencode\daily-workspace\temp\check_pptx_out.txt"
|
||||
try:
|
||||
import pptx
|
||||
result = "pptx available: " + pptx.__version__
|
||||
except ImportError as e:
|
||||
result = "pptx NOT available: " + str(e)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(result)
|
||||
print(result)
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\check_transcript.py"
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
inter_dir = r"D:\F\NewI\opencode\daily-workspace\projects\piano-lesson-highlights\cases\lesson1\output_cli_full\intermediates"
|
||||
transcript_file = os.path.join(inter_dir, "full_transcript.json")
|
||||
|
||||
if os.path.exists(transcript_file):
|
||||
size = os.path.getsize(transcript_file)
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
print(f"Transcript exists: {size} bytes")
|
||||
print(f"Segments: {len(data)}")
|
||||
if data:
|
||||
print(f"First segment: {data[0]}")
|
||||
print(f"Last segment: {data[-1]}")
|
||||
else:
|
||||
print("Transcript file NOT found")
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\debug_ppt.py"
|
||||
pause
|
||||
@@ -0,0 +1,30 @@
|
||||
import zipfile
|
||||
import re
|
||||
|
||||
ppt = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
|
||||
|
||||
with zipfile.ZipFile(ppt, "r") as z:
|
||||
names = z.namelist()
|
||||
slide_files = [f for f in names if f.startswith("ppt/slides/slide") and f.endswith(".xml")]
|
||||
print(f"Total files in zip: {len(names)}")
|
||||
print(f"Slide files found: {len(slide_files)}")
|
||||
print(f"First 5 slide files: {slide_files[:5]}")
|
||||
|
||||
# Test presentation.xml
|
||||
try:
|
||||
pres_xml = z.read("ppt/presentation.xml").decode("utf-8", errors="replace")
|
||||
sld_ids = re.findall(r'<p:sldId\b[^>]*r:id="([^"]+)"', pres_xml)
|
||||
print(f"\nsldIdList rIds: {sld_ids[:5]}")
|
||||
except Exception as e:
|
||||
print(f"\npresentation.xml error: {e}")
|
||||
|
||||
# Test rels
|
||||
try:
|
||||
rels_xml = z.read("ppt/_rels/presentation.xml.rels").decode("utf-8", errors="replace")
|
||||
rid_to_target = dict(re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', rels_xml))
|
||||
print(f"Rels entries: {len(rid_to_target)}")
|
||||
# Show a sample
|
||||
for k, v in list(rid_to_target.items())[:3]:
|
||||
print(f" {k} -> {v}")
|
||||
except Exception as e:
|
||||
print(f"\nrels error: {e}")
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\debug_ppt2.py"
|
||||
@@ -0,0 +1,34 @@
|
||||
import zipfile, re, sys
|
||||
|
||||
ppt = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
|
||||
out = r"D:\F\NewI\opencode\daily-workspace\temp\debug_ppt_out.txt"
|
||||
|
||||
results = []
|
||||
|
||||
with zipfile.ZipFile(ppt, "r") as z:
|
||||
names = z.namelist()
|
||||
slide_files = [f for f in names if f.startswith("ppt/slides/slide") and f.endswith(".xml")]
|
||||
results.append(f"Total files in zip: {len(names)}")
|
||||
results.append(f"Slide files found: {len(slide_files)}")
|
||||
results.append(f"First 5: {slide_files[:5]}")
|
||||
|
||||
try:
|
||||
pres_xml = z.read("ppt/presentation.xml").decode("utf-8", errors="replace")
|
||||
sld_ids = re.findall(r'<p:sldId\b[^>]*r:id="([^"]+)"', pres_xml)
|
||||
results.append(f"sldIds: {sld_ids[:5]}")
|
||||
except Exception as e:
|
||||
results.append(f"pres error: {e}")
|
||||
|
||||
try:
|
||||
rels_xml = z.read("ppt/_rels/presentation.xml.rels").decode("utf-8", errors="replace")
|
||||
rid_to_target = dict(re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', rels_xml))
|
||||
results.append(f"rels count: {len(rid_to_target)}")
|
||||
for k, v in list(rid_to_target.items())[:3]:
|
||||
results.append(f" {k} -> {v}")
|
||||
except Exception as e:
|
||||
results.append(f"rels error: {e}")
|
||||
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(results))
|
||||
|
||||
print("Done, see", out)
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\debug_slide1.py" > "D:\F\NewI\opencode\daily-workspace\temp\debug_slide1_out.txt" 2>&1
|
||||
@@ -0,0 +1,23 @@
|
||||
import zipfile, re, os
|
||||
|
||||
ppt = r"D:\F\yc\课程上架\福田商圈夜校\课程视频\钢琴演奏入门第一课.pptx"
|
||||
out_dir = r"D:\F\NewI\opencode\daily-workspace\temp"
|
||||
slide1_out = os.path.join(out_dir, "slide1_texts.txt")
|
||||
xml_out = os.path.join(out_dir, "slide1_xml_preview.txt")
|
||||
|
||||
with zipfile.ZipFile(ppt, "r") as z:
|
||||
slide1_file = "ppt/slides/slide1.xml"
|
||||
content = z.read(slide1_file).decode("utf-8", errors="replace")
|
||||
all_texts = re.findall(r"<a:t[^>]*>([^<]*)</a:t>", content)
|
||||
|
||||
meaningful = [t for t in all_texts if t.strip()]
|
||||
with open(slide1_out, "w", encoding="utf-8") as f:
|
||||
f.write(f"Total fragments: {len(all_texts)}\n")
|
||||
f.write(f"Meaningful fragments: {len(meaningful)}\n\n")
|
||||
for i, t in enumerate(meaningful):
|
||||
f.write(f"[{i}] {t}\n")
|
||||
|
||||
with open(xml_out, "w", encoding="utf-8") as f:
|
||||
f.write(content[:8000])
|
||||
|
||||
print("Done")
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\temp\do_install.py"
|
||||
@@ -0,0 +1,12 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
venv_python = r"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
result = subprocess.run(
|
||||
[venv_python, "-m", "pip", "install", "python-pptx"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
print("STDOUT:", result.stdout)
|
||||
print("STDERR:", result.stderr)
|
||||
print("Return code:", result.returncode)
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo Installing python-pptx...
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -m pip install python-pptx -q
|
||||
echo Done
|
||||
pause
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -m pip install python-pptx
|
||||
echo Exit: %errorlevel%
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -m pip install python-pptx > "D:\F\NewI\opencode\daily-workspace\temp\pip_out.txt" 2>&1
|
||||
echo Exit: %errorlevel%
|
||||
@@ -0,0 +1,12 @@
|
||||
# Kill all python processes related to our CLI
|
||||
Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep 3
|
||||
|
||||
# Verify killed
|
||||
$remaining = Get-Process python -ErrorAction SilentlyContinue
|
||||
if ($remaining) {
|
||||
Write-Host "Still running:"
|
||||
$remaining | ForEach-Object { Write-Host " PID:" $_.Id }
|
||||
} else {
|
||||
Write-Host "All python processes killed"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
f = open(r'D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt', 'r', encoding='utf-8')
|
||||
lines = f.readlines()
|
||||
f.close()
|
||||
for l in lines[:35]:
|
||||
print(l.rstrip())
|
||||
Binary file not shown.
Reference in New Issue
Block a user