Initial commit: lesson-highlights generator

This commit is contained in:
hmo
2026-05-03 03:07:22 +08:00
commit 9e62247a60
55 changed files with 6189 additions and 0 deletions
+54
View File
@@ -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
View File
+10
View File
@@ -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.
+105
View File
@@ -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
# 安装 FFmpegWindows - 使用 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
View File
@@ -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.
+71
View File
@@ -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
+61
View File
@@ -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 ""
+69
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[api]
api_host = "https://ark.cn-beijing.volces.com/api/coding/v3"
api_key = "YOUR_API_KEY_HERE"
+521
View File
@@ -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 小时**
+90
View File
@@ -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。
+42
View File
@@ -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
View File
@@ -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**: 质量交付 - 审查、测试、打包
+71
View File
@@ -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
+5
View File
@@ -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
+8
View File
@@ -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
+13
View File
@@ -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
+42
View File
@@ -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}")
+1
View File
@@ -0,0 +1 @@
# Piano Highlight Generator App
+228
View File
@@ -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())
+21
View File
@@ -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
)
+130
View File
@@ -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
+233
View File
@@ -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
+384
View File
@@ -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
View File
@@ -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
+518
View File
@@ -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
+323
View File
@@ -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
+112
View File
@@ -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
+308
View File
@@ -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_styleAlignment=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
View File
@@ -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
View File
@@ -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()
+112
View File
@@ -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
+477
View File
@@ -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 ─┘
```
+9
View File
@@ -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'))
+3
View File
@@ -0,0 +1,3 @@
@echo off
chcp 65001 >nul
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -c "import pptx; print('pptx available')"
+3
View File
@@ -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"
+10
View File
@@ -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)
+3
View File
@@ -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"
+17
View File
@@ -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")
+4
View File
@@ -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
+30
View File
@@ -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}")
+3
View File
@@ -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"
+34
View File
@@ -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)
+3
View File
@@ -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
+23
View File
@@ -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")
+3
View File
@@ -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"
+12
View File
@@ -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)
+6
View File
@@ -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
+4
View File
@@ -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%
+4
View File
@@ -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%
+12
View File
@@ -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"
}
BIN
View File
Binary file not shown.
+5
View File
@@ -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())
BIN
View File
Binary file not shown.