Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0720a7657a | |||
| 2505f88b74 | |||
| b9dc5b163b | |||
| 5f3396378f | |||
| c99d05e42e | |||
| fc76ded3e4 | |||
| 440f481599 | |||
| 0fbf8757fa | |||
| 6a5ec9c04f | |||
| 086ce358a5 | |||
| 3325752f02 | |||
| 0b069bc9a3 | |||
| eb02c47dd8 | |||
| 088db28a77 | |||
| aad1548348 | |||
| cf5004cf6a |
@@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,105 +1,116 @@
|
||||
# 🎹 Piano Highlight Generator
|
||||
# Lesson Highlights Generator
|
||||
|
||||
钢琴课精华视频生成工具。自动从完整课程视频中提取精华片段,转录、纠错、生成字幕,批量烧录到视频中。
|
||||
教学视频精华片段生成工具。输入课程视频 + PPT,自动提取精华片段,转录、纠错、生成字幕,批量烧录到视频中。
|
||||
|
||||
## ✨ 功能特点
|
||||
## 功能特点
|
||||
|
||||
- **智能提取**: 自动检测视频中的精彩片段
|
||||
- **语音转录**: 支持 Whisper 多模型(tiny/base/small/medium/large)
|
||||
- **AI 纠错**: LLM 自动纠正转录错误,优化标题
|
||||
- **双语字幕**: 支持双轨字幕(标题轨 + 内容轨)
|
||||
- **状态持久化**: 支持暂停/恢复,可中断继续
|
||||
- **手动编辑**: 生成前可人工审核编辑标题和字幕内容
|
||||
- **PPT 驱动提取**:根据 PPT 知识点定位视频中的讲解片段
|
||||
- **语音转录 + 纠错**:Whisper 转录 + LLM 批量校正
|
||||
- **双轨字幕**:标题轨 + 内容轨
|
||||
- **CLI / GUI 双入口**:共享同一套底层逻辑
|
||||
|
||||
## 📋 系统要求
|
||||
## 快速开始
|
||||
|
||||
- Windows 10/11 或 macOS 10.15+
|
||||
- Python 3.10+
|
||||
- FFmpeg(必须,添加到 PATH)
|
||||
### 1. 配置 API
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装
|
||||
复制配置文件并填入 API Key:
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repo-url>
|
||||
cd piano-highlight-app
|
||||
|
||||
# 创建虚拟环境(推荐)
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate # Windows
|
||||
source venv/bin/activate # Linux/macOS
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装 FFmpeg(Windows - 使用 winget)
|
||||
winget install Gyan.FFmpeg
|
||||
|
||||
# 或 macOS
|
||||
brew install ffmpeg
|
||||
cp config.ini.example config.ini
|
||||
# 编辑 config.ini,填入 api_key
|
||||
```
|
||||
|
||||
### 2. 运行
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 配置
|
||||
|
||||
首次运行需要配置:
|
||||
1. **API 设置**: 选择 API 提供商(DeepSeek/硅基流动),输入 API Key
|
||||
2. **视频设置**: 选择输入视频、输出目录
|
||||
3. **转录设置**: 选择 Whisper 模型(推荐 medium)
|
||||
编辑 `config.py` 中的视频路径、PPT路径、API Key 等。所有配置集中在一个文件。
|
||||
|
||||
### 4. 生成
|
||||
### 4. 运行
|
||||
|
||||
1. 点击「开始处理」
|
||||
2. 等待各步骤完成
|
||||
3. **标题确认**: LLM 生成标题后,审核并编辑
|
||||
4. **字幕确认**: 查看字幕内容,可进一步编辑
|
||||
5. 等待烧录完成
|
||||
**完整流程(首次运行):**
|
||||
```bash
|
||||
.\run.bat
|
||||
```
|
||||
|
||||
## 📁 输出文件
|
||||
**快速烧录(仅修改字幕后重烧):**
|
||||
```bash
|
||||
.\burn.bat
|
||||
```
|
||||
|
||||
**GUI(推荐):**
|
||||
```bash
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
lesson-highlights/
|
||||
├── config.py # 统一配置(修改这里)
|
||||
├── run.py # 完整流水线
|
||||
├── burn_only.py # 快速烧录(跳过转录/字幕生成)
|
||||
├── run.bat # 运行完整流程
|
||||
├── burn.bat # 快速重烧字幕
|
||||
├── src/
|
||||
│ ├── main.py # GUI 入口
|
||||
│ ├── gui.py # GUI(参数输入,调用底层)
|
||||
│ ├── cli.py # CLI 入口
|
||||
│ └── core/ # 共享底层
|
||||
│ ├── ppt_parser.py # PPT 解析 + clips 生成
|
||||
│ ├── pipeline.py # 视频处理流水线
|
||||
│ ├── subtitle.py # 字幕生成
|
||||
│ └── ...
|
||||
├── config.ini # API 配置(不提交 git)
|
||||
├── config.ini.example # 配置模板
|
||||
└── docs/
|
||||
├── USAGE.md # 使用指南
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **PPT 解析**:提取 PPT 文本和知识点
|
||||
2. **Whisper 转录**:将视频语音转成文本
|
||||
3. **LLM 校正**:批量校正转录错误
|
||||
4. **片段提取**:根据 PPT 知识点定位视频片段
|
||||
5. **字幕烧录**:生成双轨字幕并烧入视频
|
||||
6. **合并输出**:拼接所有片段为最终视频
|
||||
|
||||
## API 配置
|
||||
|
||||
编辑 `config.ini`:
|
||||
|
||||
```ini
|
||||
[api]
|
||||
api_host = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
api_key = your_api_key_here
|
||||
```
|
||||
|
||||
支持火山方舟(doubao-seed-2.0-lite)或兼容 OpenAI API 的后端。
|
||||
|
||||
## 输出
|
||||
|
||||
```
|
||||
output/
|
||||
├── state.json # 处理状态
|
||||
├── clips/ # 提取的片段
|
||||
│ └── clip_001.mp4
|
||||
├── subtitles/ # 字幕文件
|
||||
│ ├── clip_001_title.srt # 标题轨
|
||||
│ └── clip_001_content.srt # 内容轨
|
||||
└── final/ # 最终输出
|
||||
└── clip_001_final.mp4
|
||||
├── generated_config.yaml # clips 配置(可手动修改后重新运行)
|
||||
├── intermediates/ # 中间文件
|
||||
│ ├── clip*.json # Whisper 转录结果
|
||||
│ └── clip*.mp4 # 提取的视频片段
|
||||
├── subs/ # 字幕文件
|
||||
│ ├── v1_title.srt # 标题轨(可手动修改)
|
||||
│ └── v1_content.srt # 正文字幕
|
||||
├── concat_merged.mp4 # 合并视频
|
||||
└── 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!
|
||||
- Python 3.10+
|
||||
- FFmpeg(已打包在 `ffmpeg/` 目录)
|
||||
- PySide6(GUI)
|
||||
- faster-whisper(转录,可选)
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,71 +0,0 @@
|
||||
@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
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -1,69 +0,0 @@
|
||||
@echo off
|
||||
REM Piano Highlight Generator - CLI Build Script for Windows
|
||||
REM Builds a standalone CLI executable from cli.py
|
||||
|
||||
set "PYTHON=D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
set "PYINSTALLER=D:\ProgramData\anaconda3\envs\py312_cuda\Scripts\pyinstaller.exe"
|
||||
|
||||
echo ================================================
|
||||
echo Piano Highlight Generator - CLI Build
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
REM Check Python
|
||||
"%PYTHON%" --version
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Install pyinstaller if needed
|
||||
"%PYTHON%" -c "import PyInstaller" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Installing PyInstaller...
|
||||
"%PYTHON%" -m pip install pyinstaller -q
|
||||
)
|
||||
|
||||
REM Build
|
||||
echo.
|
||||
echo Starting PyInstaller compilation...
|
||||
echo This may take several minutes...
|
||||
echo.
|
||||
|
||||
cd /d "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app"
|
||||
|
||||
"%PYTHON%" -m PyInstaller ^
|
||||
--name=PianoHighlightCLI ^
|
||||
--console ^
|
||||
--onefile ^
|
||||
--clean ^
|
||||
--distpath=dist_cli ^
|
||||
--workpath=build_cli ^
|
||||
--specpath=build_cli ^
|
||||
--additional-hooks-dir= ^
|
||||
--hidden-import=pkg_resources ^
|
||||
--hidden-import=faster_whisper ^
|
||||
--hidden-import=cv2 ^
|
||||
--hidden-import= yaml ^
|
||||
--hidden-import=requests ^
|
||||
--hidden-import=PIL ^
|
||||
--collect-all=faster_whisper ^
|
||||
--collect-all=transformers ^
|
||||
src/cli.py
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo BUILD FAILED!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo Build complete!
|
||||
echo ================================================
|
||||
echo.
|
||||
echo Output: dist_cli\PianoHighlightCLI.exe
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\burn_only.py" %*
|
||||
pause
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
快速烧录脚本 - 跳过所有转录/字幕生成步骤
|
||||
直接用已有的 clips + title.srt + content.srt 合并烧录
|
||||
|
||||
用法:
|
||||
python burn_only.py "D:\\path\\to\\output_dir"
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 必须指定输出目录
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python burn_only.py <output_dir>")
|
||||
print("示例: python burn_only.py \"D:\\path\\to\\output_dir\"")
|
||||
sys.exit(1)
|
||||
|
||||
OUTPUT = sys.argv[1]
|
||||
|
||||
TITLE_SRT = os.path.join(OUTPUT, "subs", "v1_title.srt")
|
||||
CONTENT_SRT = os.path.join(OUTPUT, "subs", "v1_content.srt")
|
||||
CLIPS_DIR = os.path.join(OUTPUT, "intermediates")
|
||||
MERGED_PATH = os.path.join(OUTPUT, "concat_merged.mp4")
|
||||
|
||||
print(f"[Fast Burn Mode]")
|
||||
print(f"Output: {OUTPUT}")
|
||||
print()
|
||||
|
||||
# 检查必要文件
|
||||
if not os.path.exists(TITLE_SRT):
|
||||
print(f"ERROR: title.srt not found\n{TITLE_SRT}")
|
||||
sys.exit(1)
|
||||
if not os.path.exists(CONTENT_SRT):
|
||||
print(f"ERROR: content.srt not found\n{CONTENT_SRT}")
|
||||
sys.exit(1)
|
||||
|
||||
# 导入 pipeline(src 目录)
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
||||
sys.path.insert(0, src_dir)
|
||||
from core import Pipeline
|
||||
|
||||
# 尝试从 generated_config.yaml 加载配置
|
||||
config_path = os.path.join(OUTPUT, 'generated_config.yaml')
|
||||
if os.path.exists(config_path):
|
||||
import yaml
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
pipeline_config = yaml.safe_load(f) or {}
|
||||
pipeline_config['output_dir'] = OUTPUT
|
||||
else:
|
||||
# 构造 minimal config
|
||||
pipeline_config = {
|
||||
'output_dir': OUTPUT,
|
||||
'clips': [],
|
||||
'video_src': None,
|
||||
'video_params': {},
|
||||
'term_corrections': {},
|
||||
'api_key': '',
|
||||
'api_host': '',
|
||||
}
|
||||
|
||||
pipeline = Pipeline(pipeline_config)
|
||||
|
||||
# 合并视频(如需要)
|
||||
if os.path.exists(MERGED_PATH):
|
||||
print(f"Found existing merged video: {MERGED_PATH}")
|
||||
merged_path = MERGED_PATH
|
||||
else:
|
||||
import glob
|
||||
clip_files = sorted(glob.glob(os.path.join(CLIPS_DIR, "clip*.mp4")))
|
||||
if not clip_files:
|
||||
print(f"ERROR: No clip videos found\n{CLIPS_DIR}\\clip*.mp4")
|
||||
sys.exit(1)
|
||||
print(f"Merging {len(clip_files)} clips...")
|
||||
merged_path = pipeline.step_merge(clip_files)
|
||||
print(f"Merged: {merged_path}")
|
||||
|
||||
# 烧录
|
||||
print("Burning subtitles...")
|
||||
final_path = pipeline.step_burn(merged_path, TITLE_SRT, CONTENT_SRT)
|
||||
print(f"\nDone: {final_path}")
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
全局配置 - API密钥和环境配置,不含项目路径
|
||||
|
||||
项目路径(VIDEO/PPT/OUTPUT等)请直接在 run.py / burn_only.py 中定义。
|
||||
"""
|
||||
import os
|
||||
|
||||
# ========== API 配置 ==========
|
||||
API_KEY = "b0359bed-09f2-49e2-a53c-32ba057412e3"
|
||||
API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
|
||||
# ========== 环境(一般不改)==========
|
||||
PYTHON = r"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
CLI_DIR = os.path.dirname(os.path.abspath(__file__)) # 本文件所在目录
|
||||
@@ -1,521 +0,0 @@
|
||||
# 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 小时**
|
||||
+71
-64
@@ -1,90 +1,97 @@
|
||||
# 架构设计
|
||||
|
||||
## 1. 技术栈
|
||||
## 1. 核心原则
|
||||
|
||||
| 层级 | 技术 | 选型理由 |
|
||||
|------|------|----------|
|
||||
| GUI 框架 | PySide6 (Qt for Python) | LGPL 许可,功能完备,信号槽机制适合异步更新 |
|
||||
| 打包工具 | Nuitka | 编译为 C,性能好,体积小 |
|
||||
| 状态持久化 | JSON 文件 | 简单,无需数据库依赖 |
|
||||
| 核心模块 | 复用现有脚本 | video.py, subtitle.py, llm.py, corrections.py |
|
||||
| 配置格式 | YAML/JSON | 用户友好,可读性好 |
|
||||
**CLI 和 GUI 共用同一套底层类库**,仅在表示层有差异:
|
||||
- **CLI**:命令行参数输入,日志输出到终端
|
||||
- **GUI**:PySide6 界面,参数输入界面化,日志输出到文本区
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
piano-highlight-app/
|
||||
lesson-highlights/
|
||||
├── config.py # 统一配置(所有路径/API只改这里)
|
||||
├── run.py # 完整流水线入口
|
||||
├── burn_only.py # 快速烧录入口(跳过转录/字幕生成)
|
||||
├── run.bat # 运行完整流程
|
||||
├── burn.bat # 快速重烧字幕
|
||||
├── 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/ # 核心模块(复用)
|
||||
│ ├── main.py # GUI 入口
|
||||
│ ├── gui.py # GUI(参数输入 → 调用底层)
|
||||
│ ├── cli.py # CLI 入口
|
||||
│ └── 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
|
||||
│ ├── ppt_parser.py # PPT 解析 + LLM clips 提取
|
||||
│ ├── pipeline.py # 视频处理流水线
|
||||
│ ├── subtitle.py # 字幕生成
|
||||
│ ├── video.py # 视频处理(提取/合并/烧录)
|
||||
│ ├── llm.py # LLM 调用
|
||||
│ ├── corrections.py # 术语纠正
|
||||
│ ├── constants.py # 常量配置
|
||||
│ └── errors.py # 错误处理
|
||||
├── config.ini # API 配置(不提交 git)
|
||||
├── config.ini.example # 配置模板
|
||||
├── start.bat # GUI 启动器
|
||||
└── docs/
|
||||
└── USAGE.md # 使用指南
|
||||
```
|
||||
|
||||
## 3. 核心类设计
|
||||
## 3. 核心模块
|
||||
|
||||
### StateManager(状态管理)
|
||||
### `parse_ppt_to_config()`
|
||||
|
||||
负责状态持久化,支持暂停/恢复。
|
||||
一键完成 PPT → clips 配置的完整流程:
|
||||
|
||||
### PipelineController(流水线控制)
|
||||
1. **PPT 解析**:提取文本和知识点
|
||||
2. **Whisper 转录**:视频 → `full_transcript.json`
|
||||
3. **LLM 校正**:批量校正 → `corrected_transcript.json`
|
||||
4. **LLM 提取片段**:根据知识点定位视频片段 → clips
|
||||
5. **重叠合并**:合并重叠片段
|
||||
|
||||
管理处理流程的 6 个步骤:
|
||||
1. extract - 片段提取
|
||||
2. transcribe - 语音转录
|
||||
3. title_correct - 标题生成与纠错
|
||||
4. generate_subtitles - 字幕生成
|
||||
5. merge - 片段合并
|
||||
6. burn - 字幕烧录
|
||||
### `Pipeline`
|
||||
|
||||
### Worker(后台工作线程)
|
||||
视频处理流水线:
|
||||
|
||||
在独立线程中执行流水线,通过信号与 UI 通信。
|
||||
1. **extract**:按时间戳提取片段
|
||||
2. **transcribe**:逐片段 Whisper 转录
|
||||
3. **correct_titles**:LLM 标题纠正
|
||||
4. **generate_subtitles**:生成双轨字幕
|
||||
5. **merge**:合并片段
|
||||
6. **burn**:烧录字幕
|
||||
|
||||
## 4. 流水线状态机
|
||||
## 4. 数据流
|
||||
|
||||
```
|
||||
Ready → Extracting → Transcribing → Title Correcting → Generating Subtitles → Merging → Burning → Completed
|
||||
↑ ↓
|
||||
└───────────── 用户可暂停并编辑标题 ─────────────┘
|
||||
视频 + PPT
|
||||
↓
|
||||
parse_ppt_to_config()
|
||||
↓
|
||||
config = {
|
||||
"video_src": ...,
|
||||
"clips": [{"title": ..., "start": ..., "end": ...}, ...],
|
||||
"output_dir": ...,
|
||||
"term_corrections": {...}
|
||||
}
|
||||
↓
|
||||
Pipeline(config).run()
|
||||
↓
|
||||
final.mp4
|
||||
```
|
||||
|
||||
## 5. 信号流
|
||||
## 5. 配置来源
|
||||
|
||||
| 信号 | 方向 | 说明 |
|
||||
|------|------|------|
|
||||
| config_changed | UI → Controller | 配置变更 |
|
||||
| progress_signal | Worker → UI | 进度更新 |
|
||||
| titles_ready_signal | Worker → UI | 标题列表准备好 |
|
||||
| titles_confirmed_signal | UI → Controller | 用户确认的标题 |
|
||||
API 配置统一从 `config.ini` 读取,不硬编码在代码中。
|
||||
|
||||
## 6. 状态文件格式
|
||||
CLI 支持参数覆盖:
|
||||
- `--api-key`
|
||||
- `--api-host`
|
||||
- `--verbose`
|
||||
|
||||
JSON 格式,包含配置、流水线状态、clips 列表等。
|
||||
## 6. 状态持久化
|
||||
|
||||
详见 design.md。
|
||||
中间结果保存在 `output/intermediates/`:
|
||||
- `full_transcript.json` - 原始转录
|
||||
- `corrected_transcript.json` - LLM 校正后
|
||||
- `ppt_knowledge_and_cleaned.json` - PPT 知识点和清理后文本
|
||||
|
||||
复用时检测 checkpoint,避免重复 LLM 调用。
|
||||
+15
-14
@@ -5,31 +5,32 @@ 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).
|
||||
|
||||
## [版本号] - 日期
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- 新功能
|
||||
- `docs/USAGE.md` - 使用指南(run.bat / burn.bat / 修改知识点流程)
|
||||
- `config.py` - 统一配置文件,所有路径和 API 配置集中管理
|
||||
- `run.py` / `burn_only.py` - 独立入口脚本
|
||||
- `--resume-from-burn` CLI 参数 - 快速烧录模式,跳过所有转录/字幕生成步骤
|
||||
|
||||
### Changed
|
||||
- 功能变更
|
||||
- `run.bat` / `burn.bat` 替代原有的 `run_lesson1.bat`(不再需要改多处配置)
|
||||
- `ppt_parser.py`: 重叠片段的 `title_segments` 用 transcript 关键词首次出现时间计算切分点
|
||||
- `pipeline.py`: 新增 `_recalculate_title_segments_from_transcript()`,在转录完成后用实际 transcript 数据修正标题切换时间
|
||||
- `subtitle.py`: 多标题片段中每个标题最多显示 `title_duration` 秒(原逻辑会一直显示到片段结束)
|
||||
- `pipeline.py`: `step_burn` 的 `title_fontsize` 默认值从 90 改为 60
|
||||
|
||||
### Fixed
|
||||
- 问题修复
|
||||
|
||||
### Deprecated
|
||||
- 弃用功能
|
||||
- `ppt_parser.py`: 不重叠的 clip 残留 `title_segments` 导致标题显示时长错误
|
||||
- `subtitle.py`: 重叠片段第二个标题显示时长超过 `title_duration`
|
||||
- `pipeline.py`: 快速烧录模式因 `video_params` 为空导致字号使用默认值 90 而非 60
|
||||
|
||||
### Removed
|
||||
- 移除的功能
|
||||
|
||||
### Security
|
||||
- 安全相关
|
||||
- `run_lesson1.bat` / `run_lesson1.py` - 旧入口,已由 `config.py` + `run.bat` / `burn.bat` 替代
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
### [1.0.0] - 2026-05-02
|
||||
## [1.0.0] - 2026-05-02
|
||||
|
||||
### Added
|
||||
- 初始版本发布
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Piano Highlight Generator - GUI 修正功能设计
|
||||
|
||||
## 背景
|
||||
|
||||
现有 GUI 仅有"选文件+开始处理"的功能,跑完后用户无法对结果进行修正。本设计在跑完之后提供知识点编辑和字幕编辑能力,支持增删改,并自动判断如何应用修改。
|
||||
|
||||
## 配置体系重构
|
||||
|
||||
### 原则
|
||||
|
||||
- **项目配置**跟项目走,保存到项目文件中
|
||||
- **全局配置**(API密钥等)不跟项目,放在独立文件
|
||||
|
||||
### 两类配置文件
|
||||
|
||||
**项目文件** (`generated_config.yaml`):
|
||||
```yaml
|
||||
video_src: "D:/...mp4" # 项目元信息
|
||||
ppt_path: "D:/...pptx" # 项目元信息
|
||||
output_dir: "D:/...output" # 项目元信息
|
||||
max_total_duration: 600
|
||||
clips: [...]
|
||||
video_params: {...}
|
||||
term_corrections: {...}
|
||||
# api_key/api_host 不在这里
|
||||
```
|
||||
|
||||
**全局配置** (`config.ini`):
|
||||
```ini
|
||||
[api]
|
||||
api_key = "..."
|
||||
api_host = "..."
|
||||
whisper_model = "large"
|
||||
```
|
||||
|
||||
### 迁移计划
|
||||
|
||||
- `config.py` 拆分:项目路径 → 传给 cli.py 时作为临时参数,不持久化;API 配置 → 留在 config.ini
|
||||
- `generated_config.yaml` 增加 `video_src`、`ppt_path`、`max_total_duration` 字段(现有流程跑完后补充写入)
|
||||
- CLI 新建项目时,`generated_config.yaml` 直接作为项目文件输出
|
||||
- GUI 打开已有项目时,从 `generated_config.yaml` 读取所有项目信息,API 配置从 `config.ini` 单独读取
|
||||
|
||||
## CLI/GUI 项目文件体系
|
||||
|
||||
`generated_config.yaml` 即为**项目文件**。CLI 和 GUI 共用同一套项目文件:
|
||||
|
||||
```
|
||||
output_dir/
|
||||
├── generated_config.yaml ← 项目文件(元信息 + clips 配置,唯一真源)
|
||||
├── intermediates/ ← 中间缓存(视频片段 + Whisper JSON)
|
||||
│ ├── clip1.json
|
||||
│ ├── clip1.mp4
|
||||
│ └── ...
|
||||
├── subs/ ← 字幕文件
|
||||
│ ├── v1_title.srt
|
||||
│ └── v1_content.srt
|
||||
├── concat_merged.mp4 ← 中间合并视频
|
||||
└── final.mp4 ← 最终输出
|
||||
```
|
||||
|
||||
**CLI 批量处理** → 每个视频输出独立 `output_dir/`(项目文件)
|
||||
**GUI 审核修改** → 打开任意 `output_dir/`,加载已有的 `generated_config.yaml`
|
||||
|
||||
GUI 打开项目时自动检测:
|
||||
- `generated_config.yaml` 存在 → 可编辑
|
||||
- `intermediates/clip*.json` 存在 → 对应 clip 可重生成
|
||||
- `subs/v1_title.srt` 存在 → 可编辑标题
|
||||
- `subs/v1_content.srt` 存在 → 可编辑字幕
|
||||
|
||||
## 名词定义
|
||||
|
||||
- **clip**:底层实现细节,表示一个视频片段(start/end/title_segments)
|
||||
- **知识点**:用户感知的单位,即 clip 的 title
|
||||
- **标题**:v1_title.srt,知识点名称的大字叠加层
|
||||
- **字幕**:v1_content.srt,实际转录文本的小字叠加层
|
||||
- **项目文件**:`generated_config.yaml`,项目元信息 + clips 配置的唯一真源
|
||||
- **全局配置**:`config.ini`,API 密钥等应用级配置
|
||||
|
||||
## 用户流程
|
||||
|
||||
### GUI 两种模式
|
||||
|
||||
**模式一:新建项目**
|
||||
```
|
||||
选择视频 + PPT → 完整流程(PPT解析→转录→字幕→烧录)→ 显示结果
|
||||
```
|
||||
|
||||
**模式二:打开已有项目**
|
||||
```
|
||||
选择 output_dir/ → 加载 generated_config.yaml → 显示 clip 列表和字幕
|
||||
→ 用户编辑 → 系统按需重生成 → 重烧
|
||||
```
|
||||
|
||||
### 完整编辑流程
|
||||
|
||||
```
|
||||
打开项目 → 显示 clip 列表(知识点标题)+ 字幕预览
|
||||
↓
|
||||
┌─────────┴─────────┐
|
||||
编辑知识点 编辑字幕
|
||||
(改/删/增标题) (改/删文本)
|
||||
↓ ↓
|
||||
系统自动判断如何重生成 直接应用用户修改
|
||||
(删json/重新匹配/处理重叠) (跳过LLM校正)
|
||||
↓ ↓
|
||||
└──────┬─────────────┘
|
||||
↓
|
||||
自动重烧
|
||||
↓
|
||||
用户用播放器查看(预览后续再说)
|
||||
```
|
||||
|
||||
## 知识点编辑
|
||||
|
||||
### 用户操作
|
||||
|
||||
- **改标题**:直接编辑文字
|
||||
- **删 clip**:删除该 clip
|
||||
- **新增知识点**:输入新标题(PPT 里没有的也行)
|
||||
|
||||
### 系统行为
|
||||
|
||||
| 操作 | 底层动作 |
|
||||
|------|---------|
|
||||
| 改 clip N 标题 | 删 `clipN.json` → 用新标题在 transcript 里重新匹配 start/end → 处理重叠 → 重烧 |
|
||||
| 删 clip N | 从 generated_config.yaml 删掉该 clip → 删 `clipN.json` + `clipN.mp4` → 重烧 |
|
||||
| 新增知识点 | 用新标题在 transcript 里匹配 start/end → 判断是否合并到相邻 clip(重叠处理)→ 重烧 |
|
||||
|
||||
### 未匹配处理
|
||||
|
||||
匹配不到(老师没讲完 PPT)的 clip:
|
||||
- GUI 显示"未匹配"标签
|
||||
- **不参与烧录**(不生成默认短时长占位)
|
||||
- 用户可改标题重试,或直接删掉
|
||||
|
||||
### 重叠处理
|
||||
|
||||
新增/修改后如果与现有 clip 重叠,系统自动:
|
||||
1. 合并重叠片段
|
||||
2. 生成 title_segments(按 transcript 关键词首次出现时间切分)
|
||||
3. 每个标题最多显示 title_duration 秒
|
||||
|
||||
## 字幕编辑
|
||||
|
||||
### 用户操作
|
||||
|
||||
直接编辑字幕文件中的文本(v1_content.srt)。
|
||||
|
||||
### 系统行为
|
||||
|
||||
- **改字幕文本**:以用户修改为准,跳过 LLM 校正,直接重烧
|
||||
- **删字幕条目**:用户删除某条,系统保留,其他正常重烧
|
||||
- **改时间轴**:不支持(时间轴由底层逻辑决定)
|
||||
|
||||
## 底层原子化支撑
|
||||
|
||||
底层需要提供以下能力,供 GUI 调用:
|
||||
|
||||
```
|
||||
# 新增:单标题重新匹配
|
||||
reextract_clip(config, clip_index, new_title, video_src, output_dir)
|
||||
→ 删 clipN.json → 在 corrected_transcript.json 里用新标题匹配 → 更新 generated_config.yaml → 重烧
|
||||
|
||||
# 新增:删除 clip
|
||||
delete_clip(config, clip_index, output_dir)
|
||||
→ 从 config 删 clip → 删 intermediates/clipN.json + clipN.mp4 → 重烧
|
||||
|
||||
# 新增:新增知识点(用新标题在 transcript 里匹配)
|
||||
add_clip_by_title(config, new_title, video_src, output_dir)
|
||||
→ 在 corrected_transcript.json 里匹配 → 判断合并/新增 clip → 更新 config → 重烧
|
||||
|
||||
# 已有(需检查):重烧标题轨
|
||||
reburn_titles(config, output_dir)
|
||||
|
||||
# 已有(需检查):重烧字幕轨(跳过 LLM 校正)
|
||||
reburn_subtitles(config, output_dir, user_texts=None)
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
generated_config.yaml ← 项目文件,元信息+clips配置唯一真源(编辑后同步更新)
|
||||
intermediates/ ← 中间缓存
|
||||
clip*.json ← Whisper 转录(按需删除重生成)
|
||||
clip*.mp4 ← 视频片段(删 clip 时删除)
|
||||
corrected_transcript.json ← LLM校正后的全量转录(新增/重匹配clip时用,不重生成)
|
||||
subs/
|
||||
v1_title.srt ← 标题轨(改标题后重烧)
|
||||
v1_content.srt ← 字幕轨(用户直接改文本,跳过 LLM 校正)
|
||||
config.ini ← 全局配置(API密钥等应用级配置,不跟项目)
|
||||
```
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **项目文件 = 项目元信息 + clips 配置**:`generated_config.yaml` 是唯一真源,CLI 和 GUI 共用
|
||||
2. **全局配置分离**:API 密钥等放在 `config.ini`,不写入项目文件
|
||||
3. **按需重生成**:只删除/重生成受影响的 clip,不动其他
|
||||
4. **字幕以用户为准**:字幕文本修改跳过 LLM 校正
|
||||
5. **CLI/GUI 底层复用**:所有原子操作在 core/ 里
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Phase 0: 配置体系重构
|
||||
|
||||
0. `generated_config.yaml` 增加 `video_src`、`ppt_path`、`max_total_duration` 字段,跑完流程后写入
|
||||
0. `config.py` 移除项目路径参数,改为运行时传参;API 配置留在 `config.ini`
|
||||
0. CLI 新建项目时直接输出含完整元信息的 `generated_config.yaml`
|
||||
|
||||
### Phase 1: 底层原子化
|
||||
|
||||
1. `core/ppt_parser.py` 提取 `_find_title_in_transcript(title, transcript)` — 给定标题在 corrected_transcript.json 里找匹配时间段,匹配不到返回 None
|
||||
2. `core/pipeline.py` 增加 `reextract_clip(clip_index, new_title)` — 删对应 json → 调用匹配 → 更新 config
|
||||
3. `core/pipeline.py` 增加 `delete_clip(clip_index)` — 从 config 删 → 删 json/mp4
|
||||
4. `core/pipeline.py` 增加 `add_clip_by_title(new_title)` — 匹配 → 判断合并/新增 → 更新 config
|
||||
5. `core/pipeline.py` 增加 `reburn_titles()` / `reburn_subtitles(user_texts=None)` — 支持只烧标题或只烧字幕,跳过 LLM 校正
|
||||
6. `burn_only.py` 适配新接口(已有大部分逻辑,可能需要调整参数)
|
||||
|
||||
### Phase 2: GUI 重构
|
||||
|
||||
7. GUI 支持两种启动模式:
|
||||
- "新建项目" — 选择视频+PPT,运行完整流程
|
||||
- "打开项目" — 选择已有 output_dir/,从 generated_config.yaml 加载元信息
|
||||
8. GUI 编辑界面:
|
||||
- 左侧:clip 列表(知识点标题),可改/删/增
|
||||
- 右侧:字幕预览(显示 v1_content.srt 内容),可编辑文本
|
||||
- 未匹配 clip 标记显示("未匹配"标签),不参与烧录
|
||||
9. 点"应用" → 调用底层原子函数 → 按需重生成 → 重烧
|
||||
10. CLI 完整性测试:CLI 跑完 → GUI 打开同一项目 → 改标题 → 重烧 → 验证结果
|
||||
|
||||
### Phase 3: 收尾
|
||||
|
||||
11. 删除死代码(原有 gui.py 里被替换掉的部分)
|
||||
12. 更新文档:USAGE.md 增加 GUI 编辑说明
|
||||
13. commit
|
||||
@@ -0,0 +1,260 @@
|
||||
# Piano Highlight Generator - GUI 编辑功能实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 GUI 编辑功能,支持增删改知识点和字幕,CLI/GUI 共用项目文件体系,底层原子化复用。
|
||||
|
||||
**Architecture:**
|
||||
- `generated_config.yaml` = 项目文件(元信息 + clips 配置)
|
||||
- `config.ini` = 全局配置(API 密钥等),不跟项目
|
||||
- 底层原子操作在 core/,CLI 和 GUI 共用
|
||||
- 按需重生成(只重烧受影响的 clip)
|
||||
|
||||
**Tech Stack:** PySide6 GUI, YAML, faster-whisper, FFmpeg
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: 配置体系重构
|
||||
|
||||
### Task 1: 项目文件增加元信息字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/pipeline.py` — Pipeline.run() 结束时写 `video_src`、`ppt_path`、`max_total_duration` 到 generated_config.yaml
|
||||
- Modify: `src/core/ppt_parser.py` — `_create_config()` 写入 `video_src` 和 `ppt_path`
|
||||
|
||||
**Steps:**
|
||||
- [ ] 在 `Pipeline.run()` 或 `step_generate_subtitles` 结束后,读取 config 中的 `video_src`/`ppt_path`,写入 generated_config.yaml
|
||||
- [ ] 确保 `_create_config()` 包含 `max_total_duration` 字段
|
||||
- [ ] 验证:跑完 run.bat 后,generated_config.yaml 包含 video_src 和 ppt_path
|
||||
|
||||
---
|
||||
|
||||
### Task 2: config.py 重构(项目路径 vs 全局配置分离)
|
||||
|
||||
**Files:**
|
||||
- Modify: `config.py` — 移除 VIDEO/PPT/OUTPUT,API 配置留在 config.ini
|
||||
- Modify: `run.py` — 从 config.py 只读 API 相关字段,项目路径由 CLI 参数指定
|
||||
- Modify: `src/cli.py` — 确认 --video/--ppt/--output 参数能正确传入 pipeline
|
||||
|
||||
**Steps:**
|
||||
- [ ] 修改 `config.py`:只保留 API_KEY/API_HOST/PYTHON/CLI_DIR,移除 VIDEO/PPT/OUTPUT/MAX_TOTAL_DURATION
|
||||
- [ ] 修改 `run.py`:从 config.py 读 API 配置,VIDEO/PPT/OUTPUT 通过 cli.py 参数传入(cli.py 已有 --video/--ppt/--output)
|
||||
- [ ] 验证:`run.bat` 能正常跑完整流程
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 底层原子化
|
||||
|
||||
### Task 3: 提取标题匹配函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/ppt_parser.py` — 提取 `_find_title_in_transcript(title, transcript_segments)` 函数
|
||||
- Create: `src/core/subtitle_matcher.py` (可选,如果逻辑复杂则独立)
|
||||
|
||||
**Steps:**
|
||||
- [ ] 在 `ppt_parser.py` 中提取 `_find_title_in_transcript(title, corrected_segments)`:
|
||||
- 输入:标题文字 + corrected_transcript.json 的 segments 列表
|
||||
- 处理:在每个 segment 的 text 中搜索标题关键词(子串匹配)
|
||||
- 返回:`(start, end)` 或 `None`(匹配不到)
|
||||
- [ ] 写单元测试验证:mock segments 数据,测试匹配返回正确时间戳,匹配不到返回 None
|
||||
- [ ] 验证:corrected_transcript.json 存在情况下,给定一个已知标题能找到对应时间段
|
||||
|
||||
---
|
||||
|
||||
### Task 4: reextract_clip — 单标题重新匹配
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/pipeline.py` — 增加 `reextract_clip(clip_index, new_title)` 方法
|
||||
|
||||
**Steps:**
|
||||
- [ ] 在 Pipeline 类中增加 `reextract_clip(self, clip_index, new_title)`:
|
||||
```python
|
||||
def reextract_clip(self, clip_index, new_title):
|
||||
clip = self.clips[clip_index]
|
||||
# 加载 corrected_transcript.json
|
||||
# 调用 _find_title_in_transcript(new_title, segments)
|
||||
# 匹配到 → 更新 clip['start']/clip['end']
|
||||
# 匹配不到 → clip['matched'] = False(或标记)
|
||||
# 调用 _merge_overlapping_clips 如有必要
|
||||
# 保存 updated config to generated_config.yaml
|
||||
# 删除 clip_index 对应的 json(触发重生成)
|
||||
```
|
||||
- [ ] 写测试:用 lesson1 的 output,跑完后 reextract 某个 clip 改标题,验证 config 更新
|
||||
|
||||
---
|
||||
|
||||
### Task 5: delete_clip — 删除 clip
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/pipeline.py` — 增加 `delete_clip(clip_index)` 方法
|
||||
|
||||
**Steps:**
|
||||
- [ ] 在 Pipeline 类中增加 `delete_clip(self, clip_index)`:
|
||||
```python
|
||||
def delete_clip(self, clip_index):
|
||||
# 从 self.clips 删除该 clip
|
||||
# 删除 intermediates/clipN.json 和 clipN.mp4
|
||||
# 保存 updated config to generated_config.yaml
|
||||
```
|
||||
- [ ] 写测试:跑完后删一个 clip,验证 json/mp4 删除、config 更新
|
||||
|
||||
---
|
||||
|
||||
### Task 6: add_clip_by_title — 新增知识点
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/pipeline.py` — 增加 `add_clip_by_title(new_title)` 方法
|
||||
|
||||
**Steps:**
|
||||
- [ ] 在 Pipeline 类中增加 `add_clip_by_title(self, new_title)`:
|
||||
```python
|
||||
def add_clip_by_title(self, new_title):
|
||||
# 调用 _find_title_in_transcript 匹配时间段
|
||||
# 匹配到 → 判断是否与现有 clip 重叠 → 合并处理
|
||||
# 匹配不到 → 标记 matched=False,不加入 self.clips(或加到待确认列表)
|
||||
# 保存 updated config
|
||||
```
|
||||
- [ ] 写测试:加一个新标题,验证 config 更新、json 不生成(待确认状态)
|
||||
|
||||
---
|
||||
|
||||
### Task 7: reburn_titles / reburn_subtitles — 部分重烧
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/pipeline.py` — 增加 `reburn_titles()` 和 `reburn_subtitles(user_texts=None)` 方法
|
||||
- Modify: `src/core/subtitle.py` — 检查 `generate_from_clips` 能否跳过 LLM 校正直接用用户文本
|
||||
|
||||
**Steps:**
|
||||
- [ ] `reburn_titles(self)`:
|
||||
```python
|
||||
def reburn_titles(self):
|
||||
# 用已有的 clip configs(不重生成 json)
|
||||
# 调用 subtitle_pipeline.generate_from_clips
|
||||
# 烧录标题轨到 subs/v1_title.srt
|
||||
```
|
||||
- [ ] `reburn_subtitles(self, user_texts=None)`:
|
||||
```python
|
||||
def reburn_subtitles(self, user_texts=None):
|
||||
# user_texts: 可选,直接用用户文本烧字幕,跳过 LLM 校正
|
||||
# 读取 v1_content.srt 或直接用传入的文本
|
||||
# 烧录字幕轨到 subs/v1_content.srt
|
||||
```
|
||||
- [ ] 修改 `burn_only.py` 适配新的 Pipeline 方法
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: GUI 重构
|
||||
|
||||
### Task 8: GUI 两种启动模式
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gui.py` — 重构为分步界面(启动页 → 处理/编辑页)
|
||||
|
||||
**Steps:**
|
||||
- [ ] 重构 GUI 启动页:两个选项按钮
|
||||
- "新建项目" → 跳转文件选择(视频+PPT+输出目录)
|
||||
- "打开已有项目" → 打开目录选择框 → 加载 generated_config.yaml
|
||||
- [ ] 实现"打开已有项目":
|
||||
```python
|
||||
def load_project(self, output_dir):
|
||||
config = load_yaml(os.path.join(output_dir, 'generated_config.yaml'))
|
||||
# 设置 video_src, ppt_path, output_dir
|
||||
# 初始化 Pipeline(config)
|
||||
# 加载 clips 列表显示
|
||||
# 加载字幕预览
|
||||
```
|
||||
- [ ] 验证:打开 lesson1 output 目录,能正确显示 clip 列表
|
||||
|
||||
---
|
||||
|
||||
### Task 9: GUI 编辑界面
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gui.py` — 增加编辑界面组件
|
||||
|
||||
**Steps:**
|
||||
- [ ] 左侧 clip 列表:
|
||||
- QListWidget 显示 clip 标题列表
|
||||
- 双击编辑标题
|
||||
- 右键菜单:删除、新增
|
||||
- 未匹配 clip 显示红色/警告图标
|
||||
- [ ] 右侧字幕预览:
|
||||
- QTextEdit 显示 v1_content.srt 内容
|
||||
- 用户可直接编辑
|
||||
- [ ] 底部"应用"按钮:
|
||||
- 收集所有修改
|
||||
- 调用底层原子操作
|
||||
- 进度显示
|
||||
- 重烧后刷新预览
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 应用按钮 — 原子操作集成
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gui.py` — 应用按钮连接到 Pipeline 原子方法
|
||||
|
||||
**Steps:**
|
||||
- [ ] 应用按钮逻辑:
|
||||
```python
|
||||
def on_apply(self):
|
||||
# 检测变化:
|
||||
# - clip 标题改了 → reextract_clip(i, new_title)
|
||||
# - clip 删了 → delete_clip(i)
|
||||
# - 新增知识点 → add_clip_by_title(title)
|
||||
# - 字幕改了 → reburn_subtitles(user_texts)
|
||||
# - 任一 clip 变了 → reburn_titles()
|
||||
# 最后调用 Pipeline.step_burn() 或 reburn 最终视频
|
||||
```
|
||||
- [ ] 未匹配 clip 不参与重烧,显示提示
|
||||
- [ ] 验证:改标题 → 点应用 → final.mp4 更新
|
||||
|
||||
---
|
||||
|
||||
### Task 11: CLI/GUI 互操作测试
|
||||
|
||||
**Steps:**
|
||||
- [ ] CLI run.bat 跑 lesson1 → 生成完整 output
|
||||
- [ ] GUI 打开同一 output 目录
|
||||
- [ ] 改 clip3 标题为"新标题" → 点应用 → 验证 config 更新、final.mp4 更新
|
||||
- [ ] GUI 删 clip5 → 点应用 → 验证 config 更新、final.mp4 更新
|
||||
- [ ] GUI 改字幕文本 → 点应用 → 验证 final.mp4 更新
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 收尾
|
||||
|
||||
### Task 12: 删除死代码
|
||||
|
||||
**Files:**
|
||||
- Identify: 检查 gui.py 中被替换掉的旧代码
|
||||
- Remove: 删除不再使用的 UI 组件和逻辑
|
||||
|
||||
---
|
||||
|
||||
### Task 13: 更新文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/USAGE.md` — 增加 GUI 编辑说明
|
||||
|
||||
---
|
||||
|
||||
### Task 14: commit
|
||||
|
||||
**Steps:**
|
||||
- [ ] git add -A
|
||||
- [ ] commit: "feat: GUI edit mode with clip/subtitle editing, config separation"
|
||||
- [ ] push
|
||||
|
||||
---
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [ ] 所有修改文件有备份/可回滚
|
||||
- [ ] 每个 task 后验证功能正常
|
||||
- [ ] CLI 完整流程测试通过
|
||||
- [ ] GUI 新建项目测试通过
|
||||
- [ ] GUI 打开已有项目测试通过
|
||||
- [ ] 改/删/增 clip 后 final.mp4 正确更新
|
||||
- [ ] 字幕编辑后 final.mp4 正确更新
|
||||
- [ ] 无新增警告或 lint 错误
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# 使用指南
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置
|
||||
|
||||
编辑项目根目录的 `config.py`:
|
||||
|
||||
```python
|
||||
VIDEO = r"D:\...\直播回放.mp4"
|
||||
PPT = r"D:\...\课程.pptx"
|
||||
OUTPUT = r"D:\...\output"
|
||||
MAX_TOTAL_DURATION = 600 # 精华片段总时长上限(秒)
|
||||
API_KEY = "your-api-key"
|
||||
API_HOST = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
```
|
||||
|
||||
所有路径和 API 配置只改这一个文件。
|
||||
|
||||
### 2. 完整流程(首次运行)
|
||||
|
||||
```bash
|
||||
run.bat
|
||||
```
|
||||
|
||||
或直接:
|
||||
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
完整流程:PPT解析 → Whisper转录 → LLM校正 → 字幕生成 → 合并 → 烧录
|
||||
|
||||
### 3. 修改字幕后快速重烧
|
||||
|
||||
改完 `v1_title.srt` 或 `v1_content.srt` 后,直接:
|
||||
|
||||
```bash
|
||||
burn.bat
|
||||
```
|
||||
|
||||
跳过所有转录/字幕生成步骤,直接用已有片段和字幕文件合并烧录。**只改字幕文本时用这个**。
|
||||
|
||||
## 修改知识点(替换PPT中的某个知识点)
|
||||
|
||||
LLM 从 PPT 提取了 clip 后,如果你想把其中一个换成 PPT 里另一个知识点(比如把"音高"换成"旋律"):
|
||||
|
||||
### 步骤
|
||||
|
||||
1. **改 `generated_config.yaml`**:把对应 clip 的 title 改成新知识点名称
|
||||
|
||||
```yaml
|
||||
clips:
|
||||
- title: 旋律 # ← 改成PPT里有的知识点
|
||||
start: 200
|
||||
end: 260
|
||||
```
|
||||
|
||||
2. **删该 clip 的中间文件**(让它重新生成):
|
||||
|
||||
```
|
||||
intermediates/clip5.json ← 删掉
|
||||
intermediates/clip5.mp4 ← 删掉
|
||||
```
|
||||
|
||||
3. **重新运行**:
|
||||
|
||||
```bash
|
||||
run.bat
|
||||
```
|
||||
|
||||
系统会跳过其他已有 JSON 的 clip,只重新生成被删除了 JSON 的那一个 clip。
|
||||
|
||||
### 原理
|
||||
|
||||
- `run.bat` 检测到 `clip*.json` 已存在,就跳过 Whisper 转录
|
||||
- 删掉某个 clip 的 JSON 后,系统认为它需要重新生成
|
||||
- 重新生成时用新的 title 去 transcript 里匹配,重新找时间范围
|
||||
|
||||
### 注意
|
||||
|
||||
- `start`/`end` 如果填错了,生成的视频片段时间会不对
|
||||
- 如果不确定新知识点的时间范围,可以先随便填一个,跑完看效果再调整
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
output/
|
||||
├── generated_config.yaml # clips 配置(可手动修改)
|
||||
├── intermediates/ # 中间文件(可删除特定clip的.json/.mp4重生成)
|
||||
│ ├── clip1.json # Whisper 转录结果
|
||||
│ ├── clip1.mp4 # 提取的视频片段
|
||||
│ └── ...
|
||||
├── subs/ # 字幕文件
|
||||
│ ├── v1_title.srt # 标题轨(可手动修改文本+时间轴)
|
||||
│ └── v1_content.srt # 正文字幕
|
||||
├── concat_merged.mp4 # 合并后的视频
|
||||
└── final.mp4 # 最终输出
|
||||
```
|
||||
|
||||
## 命令对比
|
||||
|
||||
| 命令 | 用途 | 耗时 |
|
||||
|------|------|------|
|
||||
| `run.bat` | 完整流程(PPT→视频) | 几十分钟 |
|
||||
| `burn.bat` | 只改字幕后快速重烧 | 几分钟 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: `burn.bat` 改了字号没变化?**
|
||||
A: `burn.bat` 直接烧已有的 SRT 文件,不走 `subtitle.py` 的生成逻辑。如果改了渲染参数(如字号)需要重新生成字幕,必须 `run.bat`。
|
||||
|
||||
**Q: 想改某个知识点的出现时间?**
|
||||
A: 直接改 `v1_title.srt` 里的时间轴,或者改 `generated_config.yaml` 然后删对应 clip 的 JSON 重新生成。
|
||||
|
||||
**Q: 想删掉某个 clip?**
|
||||
A: 从 `generated_config.yaml` 里删掉那一条,然后删对应 `intermediates/clip*.json` 和 `clip*.mp4`,最后 `run.bat`。
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
# 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**: 质量交付 - 审查、测试、打包
|
||||
@@ -1,5 +0,0 @@
|
||||
# Build dependencies for Nuitka compilation
|
||||
# Install with: pip install -r requirements-build.txt
|
||||
|
||||
nuitka>=1.7.0
|
||||
pandas>=1.5.0
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\piano-highlight-app\run.py"
|
||||
pause
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
完整流水线 - 从 PPT 解析到最终视频输出
|
||||
|
||||
项目路径在此文件中定义,config.py 只提供 API 和环境配置。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# 导入全局配置(API Key、环境等)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import config
|
||||
|
||||
# ========== 项目路径配置(按需修改)==========
|
||||
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"
|
||||
MAX_TOTAL_DURATION = 600 # 精华片段总时长上限(秒)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = os.path.dirname(config.PYTHON) + ";" + env.get("PATH", "")
|
||||
|
||||
cmd = [
|
||||
config.PYTHON,
|
||||
os.path.join(config.CLI_DIR, "src", "cli.py"),
|
||||
"--video", VIDEO,
|
||||
"--ppt", PPT,
|
||||
"--output", OUTPUT,
|
||||
"--api-key", config.API_KEY,
|
||||
"--api-host", config.API_HOST,
|
||||
"--max-total-duration", str(MAX_TOTAL_DURATION),
|
||||
"--verbose",
|
||||
]
|
||||
|
||||
print(f"Running pipeline...")
|
||||
print(f" Video: {VIDEO}")
|
||||
print(f" PPT: {PPT}")
|
||||
print(f" Output: {OUTPUT}")
|
||||
print()
|
||||
|
||||
proc = subprocess.Popen(cmd, cwd=config.CLI_DIR, env=env)
|
||||
proc.wait()
|
||||
@@ -1,13 +0,0 @@
|
||||
@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
|
||||
@@ -1,42 +0,0 @@
|
||||
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}")
|
||||
+48
-1
@@ -60,8 +60,12 @@ def parse_args():
|
||||
help='LLM API地址')
|
||||
parser.add_argument('--whisper-model', type=str, default='large',
|
||||
help='Whisper模型 (默认: large)')
|
||||
parser.add_argument('--max-total-duration', type=int, default=300,
|
||||
help='精华片段总时长上限(秒),默认300')
|
||||
parser.add_argument('--verbose', '-V', action='store_true',
|
||||
help='详细输出')
|
||||
parser.add_argument('--resume-from-burn', action='store_true',
|
||||
help='快速模式:跳过所有步骤,直接用已有片段和字幕文件合并烧录(用于手动修改SRT后快速重生成)')
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -77,7 +81,7 @@ def load_config_from_args(args) -> dict:
|
||||
'whisper_model': args.whisper_model,
|
||||
'video_params': {
|
||||
'fade_duration': 1,
|
||||
'title_fontsize': 90,
|
||||
'title_fontsize': 60,
|
||||
'title_color': 'FFFF00',
|
||||
'subtitle_fontsize': 24,
|
||||
'subtitle_color': 'FFFFFF',
|
||||
@@ -137,8 +141,15 @@ def generate_config_from_ppt(args) -> dict:
|
||||
progress_callback=progress_callback,
|
||||
api_key=args.api_key,
|
||||
api_host=args.api_host,
|
||||
max_total_duration=args.max_total_duration,
|
||||
)
|
||||
|
||||
# 补充API配置(parse_ppt_to_config不返回这些)
|
||||
if args.api_key:
|
||||
config['api_key'] = args.api_key
|
||||
if args.api_host:
|
||||
config['api_host'] = args.api_host
|
||||
|
||||
# 保存生成的配置
|
||||
config_path = os.path.join(args.output, 'generated_config.yaml')
|
||||
import yaml
|
||||
@@ -207,6 +218,42 @@ def main():
|
||||
|
||||
pipeline = Pipeline(config)
|
||||
|
||||
# 快速模式:跳过所有步骤,直接用已有片段和字幕合并烧录
|
||||
if args.resume_from_burn:
|
||||
import glob
|
||||
import shutil
|
||||
output_dir = config.get('output_dir')
|
||||
clips_dir = os.path.join(output_dir, 'clips')
|
||||
merged_dir = os.path.join(output_dir, 'merged')
|
||||
merged_path = os.path.join(merged_dir, 'merged.mp4')
|
||||
title_path = os.path.join(output_dir, 'title.srt')
|
||||
content_path = os.path.join(output_dir, 'content.srt')
|
||||
|
||||
# 检查必要文件
|
||||
if not os.path.exists(title_path):
|
||||
logger.error(f"找不到 title.srt: {title_path}")
|
||||
return 1
|
||||
if not os.path.exists(content_path):
|
||||
logger.error(f"找不到 content.srt: {content_path}")
|
||||
return 1
|
||||
|
||||
# 已有合并视频则直接烧录;否则先合并
|
||||
if os.path.exists(merged_path):
|
||||
logger.info(f"找到已有合并视频: {merged_path}")
|
||||
else:
|
||||
logger.info("开始合并片段...")
|
||||
clip_files = sorted(glob.glob(os.path.join(clips_dir, 'clip*.mp4')))
|
||||
if not clip_files:
|
||||
logger.error(f"找不到片段视频: {clips_dir}/clip*.mp4")
|
||||
return 1
|
||||
merged_path = pipeline.step_merge(clip_files)
|
||||
logger.info(f"合并完成: {merged_path}")
|
||||
|
||||
logger.info("开始烧录...")
|
||||
final_path = pipeline.step_burn(merged_path, title_path, content_path)
|
||||
logger.info(f"完成! 最终视频: {final_path}")
|
||||
return 0
|
||||
|
||||
logger.info("开始处理...")
|
||||
final_path = pipeline.run()
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ DEFAULT_OUTPUT_DIR = os.path.join(PROJECT_ROOT, "output")
|
||||
DEFAULT_VIDEO_PARAMS = {
|
||||
"fade_duration": 1,
|
||||
"title_duration": 3,
|
||||
"title_fontsize": 90,
|
||||
"title_fontsize": 60,
|
||||
"title_color": "FFFF00",
|
||||
"subtitle_fontsize": 24,
|
||||
"subtitle_color": "FFFFFF",
|
||||
|
||||
+3
-100
@@ -56,6 +56,8 @@ class LLMClient:
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
logger.info(f"[LLM] request chars={len(prompt)}, max_tokens={max_tokens}")
|
||||
|
||||
for attempt in range(LLM_MAX_RETRIES):
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
@@ -73,6 +75,7 @@ class LLMClient:
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "").strip()
|
||||
if content:
|
||||
logger.info(f"[LLM] response chars={len(content)}")
|
||||
return content
|
||||
|
||||
logger.warning(f"LLM: Empty content (attempt {attempt+1})")
|
||||
@@ -88,106 +91,6 @@ class LLMClient:
|
||||
|
||||
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
|
||||
|
||||
|
||||
+400
-70
@@ -12,7 +12,7 @@ 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 .subtitle import SubtitlePipeline, correct_subtitles_llm
|
||||
from .llm import LLMClient
|
||||
from .corrections import apply_all_corrections, load_term_corrections_from_config
|
||||
from .utils import ensure_dir
|
||||
@@ -223,16 +223,41 @@ class Pipeline:
|
||||
self.progress_callback('transcribing', int((i/total)*90), f"转录片段 {i}/{total}")
|
||||
|
||||
try:
|
||||
segments, _ = model.transcribe(clip_path, language='zh', beam_size=5)
|
||||
segments, _ = model.transcribe(clip_path, language='zh', beam_size=5, word_timestamps=True)
|
||||
|
||||
# 保存转录结果
|
||||
# 保存转录结果(按句末标点进一步切分)
|
||||
segments_data = []
|
||||
for seg in segments:
|
||||
segments_data.append({
|
||||
'start': seg.start,
|
||||
'end': seg.end,
|
||||
'text': seg.text.strip()
|
||||
})
|
||||
words = seg.words if hasattr(seg, 'words') else []
|
||||
if words:
|
||||
# 用 word-level 时间戳在句末标点处切分
|
||||
# 注意:标点可能附着在词后(如"吗?"、"奏,"),需 strip 后判断
|
||||
_END_MARKS = '。!??'
|
||||
sub_start = words[0].start
|
||||
sub_text_parts = []
|
||||
for word in words:
|
||||
sub_text_parts.append(word.word)
|
||||
# 剥离标点后判断是否为句末标记
|
||||
stripped = word.word.rstrip(',、,')
|
||||
if any(stripped.endswith(m) for m in _END_MARKS):
|
||||
sub_end = word.end
|
||||
sub_text = ''.join(sub_text_parts).strip()
|
||||
if sub_text:
|
||||
segments_data.append({'start': sub_start, 'end': sub_end, 'text': sub_text})
|
||||
sub_start = word.end
|
||||
sub_text_parts = []
|
||||
# 剩余未到句末的文本
|
||||
if sub_text_parts:
|
||||
remaining = ''.join(sub_text_parts).strip()
|
||||
if remaining:
|
||||
segments_data.append({'start': sub_start, 'end': words[-1].end, 'text': remaining})
|
||||
else:
|
||||
# fallback:无 word timestamps,直接用原 segment
|
||||
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)
|
||||
@@ -249,59 +274,58 @@ class Pipeline:
|
||||
self.step_callback('transcribing')
|
||||
return json_paths
|
||||
|
||||
def step_correct_titles(self, json_paths: List[str]) -> List[Dict[str, Any]]:
|
||||
def _recalculate_title_segments_from_transcript(
|
||||
self,
|
||||
clips: List[Dict],
|
||||
json_paths: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
Step 3: LLM标题纠正
|
||||
用 transcript 数据重新计算重叠片段的 title_segments 切分点。
|
||||
|
||||
Args:
|
||||
json_paths: JSON文件路径列表
|
||||
|
||||
Returns:
|
||||
corrected_clips: 纠正后的片段配置列表
|
||||
重叠片段的 switch_offset 应该按 transcript 中第二个标题关键词
|
||||
首次出现的时间来算,而不是按 clip 边界。
|
||||
"""
|
||||
self.step_callback('title_correcting')
|
||||
self.progress_callback('title_correcting', 0, "开始标题纠正...")
|
||||
for i, clip in enumerate(clips):
|
||||
ts = clip.get('title_segments')
|
||||
if not ts or len(ts) < 2:
|
||||
continue
|
||||
|
||||
corrected_clips = []
|
||||
total = len(self.clips)
|
||||
# 取第二个标题段 [title, offset]
|
||||
second_title, old_offset = ts[1]
|
||||
json_path = json_paths[i] if i < len(json_paths) else None
|
||||
if not json_path or not os.path.exists(json_path):
|
||||
continue
|
||||
|
||||
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):
|
||||
try:
|
||||
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', []))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 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}")
|
||||
# 在 transcript 中搜索 second_title 的首次出现时间
|
||||
first_time = None
|
||||
for seg in data.get('segments', []):
|
||||
for word_info in seg.get('words', []):
|
||||
w = word_info.get('word', '')
|
||||
# 关键词匹配(标题可能含多字符,取子串)
|
||||
if second_title and second_title in w:
|
||||
first_time = word_info['start']
|
||||
break
|
||||
if first_time is not None:
|
||||
break
|
||||
|
||||
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
|
||||
if first_time is not None:
|
||||
new_offset = first_time
|
||||
clip['title_segments'][1][1] = new_offset
|
||||
logger.info(
|
||||
f" clip{i+1} title_segments: "
|
||||
f"'{second_title}' 从 {old_offset:.2f}s → {new_offset:.2f}s"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f" clip{i+1} title_segments: "
|
||||
f"未在 transcript 中找到 '{second_title}',保留原 offset {old_offset:.2f}s"
|
||||
)
|
||||
|
||||
def step_generate_subtitles(self, corrected_clips: List[Dict], json_paths: List[str]) -> tuple:
|
||||
"""
|
||||
@@ -327,6 +351,7 @@ class Pipeline:
|
||||
'start': clip['start'],
|
||||
'end': clip['end'],
|
||||
'title': clip.get('title', clip.get('original_title', '')),
|
||||
'title_segments': clip.get('title_segments'), # 可能为None
|
||||
}
|
||||
clip_configs.append(clip_config)
|
||||
|
||||
@@ -357,6 +382,39 @@ class Pipeline:
|
||||
self.step_callback('generating_subtitles')
|
||||
return title_path, content_path
|
||||
|
||||
def step_correct_subtitles(self, title_path: str, content_path: str) -> str:
|
||||
"""
|
||||
Step 4.5: LLM纠正字幕内容
|
||||
|
||||
参考title.srt(时间轴锚点)和PPT原文(术语参考),
|
||||
修正content.srt中的错字、漏字、术语错误。
|
||||
|
||||
Args:
|
||||
title_path: 标题字幕路径
|
||||
content_path: 内容字幕路径
|
||||
|
||||
Returns:
|
||||
修正后的content_path
|
||||
"""
|
||||
ppt_text = self.config.get('ppt_text', '')
|
||||
if not ppt_text:
|
||||
logger.warning("PPT原文为空,跳过字幕纠正步骤")
|
||||
return content_path
|
||||
|
||||
self.step_callback('correcting_subtitles')
|
||||
self.progress_callback('correcting_subtitles', 0, "开始纠正字幕...")
|
||||
|
||||
corrected_path = correct_subtitles_llm(
|
||||
title_path=title_path,
|
||||
content_path=content_path,
|
||||
ppt_text=ppt_text,
|
||||
llm_client=self.llm_client,
|
||||
)
|
||||
|
||||
self.progress_callback('correcting_subtitles', 100, "字幕纠正完成")
|
||||
self.step_callback('correcting_subtitles')
|
||||
return corrected_path
|
||||
|
||||
def step_merge(self, clip_paths: List[str]) -> str:
|
||||
"""
|
||||
Step 5: 合并视频
|
||||
@@ -411,7 +469,7 @@ class Pipeline:
|
||||
title_path,
|
||||
content_path,
|
||||
final_path,
|
||||
title_fontsize=video_params.get('title_fontsize', 90),
|
||||
title_fontsize=video_params.get('title_fontsize', 60),
|
||||
title_color=video_params.get('title_color', 'FFFF00'),
|
||||
subtitle_fontsize=video_params.get('subtitle_fontsize', 24),
|
||||
subtitle_color=video_params.get('subtitle_color', 'FFFFFF')
|
||||
@@ -424,6 +482,279 @@ class Pipeline:
|
||||
self.step_callback('burning')
|
||||
return final_path
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""将 self.clips 等配置写回 generated_config.yaml。"""
|
||||
import yaml
|
||||
config_path = os.path.join(self.output_dir, 'generated_config.yaml')
|
||||
# 保留原有配置,只更新 clips 和 video_params
|
||||
saved_config = {}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
saved_config = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
saved_config['clips'] = self.clips
|
||||
# 同步 video_params
|
||||
if 'video_params' not in saved_config:
|
||||
saved_config['video_params'] = self.config.get('video_params', {})
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(saved_config, f, allow_unicode=True, default_flow_style=False)
|
||||
logger.info(f"配置已保存: {config_path}")
|
||||
|
||||
def reextract_clip(self, clip_index: int, new_title: str) -> bool:
|
||||
"""
|
||||
用新标题重新匹配单个 clip 的时间段。
|
||||
|
||||
Args:
|
||||
clip_index: clip 在 self.clips 中的索引
|
||||
new_title: 新的标题文字
|
||||
|
||||
Returns:
|
||||
bool: 是否匹配成功(匹配到了返回 True,匹配不到返回 False)
|
||||
"""
|
||||
# 1. 加载 corrected_transcript.json
|
||||
transcript_path = os.path.join(self.inter_dir, 'corrected_transcript.json')
|
||||
if not os.path.exists(transcript_path):
|
||||
logger.warning(f"corrected_transcript.json not found: {transcript_path}")
|
||||
return False
|
||||
|
||||
with open(transcript_path, 'r', encoding='utf-8') as f:
|
||||
corrected_segments = json.load(f)
|
||||
|
||||
# 2. 调用 PPTParser._find_title_in_transcript 匹配新标题
|
||||
from .ppt_parser import PPTParser
|
||||
# 用 __new__ 绕过 __init__,只设置 inter_dir
|
||||
parser = PPTParser.__new__(PPTParser)
|
||||
parser.inter_dir = self.inter_dir
|
||||
result = parser._find_title_in_transcript(new_title, corrected_segments)
|
||||
|
||||
if result is None:
|
||||
# 匹配不到:标记为 unmatched,不参与烧录
|
||||
self.clips[clip_index]['matched'] = False
|
||||
self.clips[clip_index]['title'] = new_title
|
||||
clip_json = os.path.join(self.inter_dir, f'clip{clip_index + 1}.json')
|
||||
if os.path.exists(clip_json):
|
||||
os.remove(clip_json)
|
||||
self._save_config()
|
||||
return False
|
||||
|
||||
start, end = result
|
||||
self.clips[clip_index]['title'] = new_title
|
||||
self.clips[clip_index]['start'] = start
|
||||
self.clips[clip_index]['end'] = end
|
||||
self.clips[clip_index]['matched'] = True
|
||||
|
||||
# 3. 删除对应 json(触发重新生成)
|
||||
clip_json = os.path.join(self.inter_dir, f'clip{clip_index + 1}.json')
|
||||
if os.path.exists(clip_json):
|
||||
os.remove(clip_json)
|
||||
|
||||
# 4. 重新合并重叠片段
|
||||
self.clips = parser._merge_overlapping_clips(self.clips)
|
||||
|
||||
# 5. 保存更新后的 config
|
||||
self._save_config()
|
||||
return True
|
||||
|
||||
def delete_clip(self, clip_index):
|
||||
"""
|
||||
删除指定 clip。
|
||||
|
||||
Args:
|
||||
clip_index: clip 在 self.clips 中的索引
|
||||
"""
|
||||
if clip_index < 0 or clip_index >= len(self.clips):
|
||||
return
|
||||
|
||||
# 从 self.clips 删除
|
||||
self.clips.pop(clip_index)
|
||||
|
||||
# 删除对应的中间文件(文件名是 clip{index+1} 因为是 1-based)
|
||||
clip_num = clip_index + 1
|
||||
for ext in ['.json', '.mp4', '_fade.mp4']:
|
||||
path = os.path.join(self.inter_dir, f'clip{clip_num}{ext}')
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
# 不重编号后续 clip 文件,GUI 加载时按顺序读取所有 clip*.* 文件即可
|
||||
|
||||
# 保存更新后的 config
|
||||
self._save_config()
|
||||
|
||||
def add_clip_by_title(self, new_title):
|
||||
"""
|
||||
用新标题在转录中匹配时间段,判断合并或新增。
|
||||
|
||||
Args:
|
||||
new_title: 新知识点标题
|
||||
|
||||
Returns:
|
||||
tuple: (clip_index, matched) — 新 clip 在 self.clips 中的索引,matched 是否匹配成功
|
||||
"""
|
||||
# 1. 加载 corrected_transcript.json
|
||||
transcript_path = os.path.join(self.inter_dir, 'corrected_transcript.json')
|
||||
if not os.path.exists(transcript_path):
|
||||
logger.warning(f"corrected_transcript.json not found")
|
||||
return None, False
|
||||
|
||||
with open(transcript_path, 'r', encoding='utf-8') as f:
|
||||
corrected_segments = json.load(f)
|
||||
|
||||
# 2. 匹配标题
|
||||
from .ppt_parser import PPTParser
|
||||
parser = PPTParser.__new__(PPTParser)
|
||||
parser.inter_dir = self.inter_dir
|
||||
result = parser._find_title_in_transcript(new_title, corrected_segments)
|
||||
|
||||
if result is None:
|
||||
# 匹配不到:不加入 clips(用户可在 GUI 中看到未匹配状态)
|
||||
logger.info(f"标题 '{new_title}' 在转录中未找到匹配")
|
||||
return None, False
|
||||
|
||||
start, end = result
|
||||
|
||||
# 3. 构建新 clip
|
||||
new_clip = {
|
||||
'title': new_title,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'matched': True,
|
||||
}
|
||||
|
||||
# 4. 判断是否与现有 clip 重叠
|
||||
overlapped_index = None
|
||||
for i, clip in enumerate(self.clips):
|
||||
if clip.get('matched', True) is False:
|
||||
continue
|
||||
if start < clip['end'] and end > clip['start']:
|
||||
overlapped_index = i
|
||||
break
|
||||
|
||||
if overlapped_index is not None:
|
||||
# 有重叠:合并到现有 clip
|
||||
self.clips.append(new_clip)
|
||||
self.clips = parser._merge_overlapping_clips(self.clips)
|
||||
else:
|
||||
# 无重叠:直接追加
|
||||
self.clips.append(new_clip)
|
||||
# 重新排序
|
||||
self.clips = sorted(self.clips, key=lambda c: c['start'])
|
||||
|
||||
# 找到新 clip 的索引
|
||||
new_clip_index = next(
|
||||
i for i, c in enumerate(self.clips)
|
||||
if c['title'] == new_title and c['start'] == start
|
||||
)
|
||||
|
||||
# 5. 保存 config
|
||||
self._save_config()
|
||||
return new_clip_index, True
|
||||
|
||||
def reburn_titles(self) -> str:
|
||||
"""
|
||||
只重烧标题轨。
|
||||
|
||||
用已有 intermediates/clip*.json 重新生成标题轨并烧录,
|
||||
保留用户修改过的字幕内容。
|
||||
|
||||
Returns:
|
||||
final_path: 最终视频路径
|
||||
"""
|
||||
import glob
|
||||
import shutil
|
||||
|
||||
# 确保有合并视频
|
||||
merged_path = os.path.join(self.output_dir, 'concat_merged.mp4')
|
||||
if not os.path.exists(merged_path):
|
||||
clip_files = sorted(glob.glob(os.path.join(self.inter_dir, 'clip*.mp4')))
|
||||
if not clip_files:
|
||||
raise ValueError("No clips found")
|
||||
merged_path = self.step_merge(clip_files)
|
||||
|
||||
# 收集 json paths
|
||||
json_paths = [
|
||||
os.path.join(self.inter_dir, f'clip{i+1}.json')
|
||||
for i in range(len(self.clips))
|
||||
]
|
||||
json_paths = [p for p in json_paths if os.path.exists(p)]
|
||||
if not json_paths:
|
||||
raise ValueError("No clip JSON files found")
|
||||
|
||||
# 备份用户字幕(如果存在)
|
||||
content_path = os.path.join(self.subs_dir, 'v1_content.srt')
|
||||
content_backup = content_path + '.reburn_titles.bak'
|
||||
if os.path.exists(content_path):
|
||||
shutil.copy(content_path, content_backup)
|
||||
|
||||
# 生成字幕(标题+内容)
|
||||
title_path, _ = self.step_generate_subtitles(self.clips, json_paths)
|
||||
|
||||
# 恢复用户字幕
|
||||
if os.path.exists(content_backup):
|
||||
shutil.move(content_backup, content_path)
|
||||
|
||||
# 烧录
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
return final_path
|
||||
|
||||
def reburn_subtitles(self, user_texts=None) -> str:
|
||||
"""
|
||||
只重烧字幕轨。
|
||||
|
||||
跳过 LLM 校正步骤,直接从 json 生成或使用用户提供的文本。
|
||||
|
||||
Args:
|
||||
user_texts: 可选,用户直接提供的 SRT 格式字幕文本。
|
||||
如果为 None,从 json 重新生成(跳过 LLM 校正)。
|
||||
|
||||
Returns:
|
||||
final_path: 最终视频路径
|
||||
"""
|
||||
import glob
|
||||
|
||||
# 确保有合并视频
|
||||
merged_path = os.path.join(self.output_dir, 'concat_merged.mp4')
|
||||
if not os.path.exists(merged_path):
|
||||
clip_files = sorted(glob.glob(os.path.join(self.inter_dir, 'clip*.mp4')))
|
||||
if not clip_files:
|
||||
raise ValueError("No clips found")
|
||||
merged_path = self.step_merge(clip_files)
|
||||
|
||||
# 获取标题轨(用已有的,不重新生成标题)
|
||||
title_path = os.path.join(self.subs_dir, 'v1_title.srt')
|
||||
if not os.path.exists(title_path):
|
||||
json_paths = [
|
||||
os.path.join(self.inter_dir, f'clip{i+1}.json')
|
||||
for i in range(len(self.clips))
|
||||
]
|
||||
json_paths = [p for p in json_paths if os.path.exists(p)]
|
||||
if json_paths:
|
||||
title_path, _ = self.step_generate_subtitles(self.clips, json_paths)
|
||||
|
||||
# 处理字幕内容
|
||||
if user_texts is not None:
|
||||
# 用户直接提供字幕文本,写入文件
|
||||
content_path = os.path.join(self.subs_dir, 'v1_content.srt')
|
||||
with open(content_path, 'w', encoding='utf-8') as f:
|
||||
f.write(user_texts)
|
||||
else:
|
||||
# 从 json 重新生成字幕(跳过 LLM 校正)
|
||||
json_paths = [
|
||||
os.path.join(self.inter_dir, f'clip{i+1}.json')
|
||||
for i in range(len(self.clips))
|
||||
]
|
||||
json_paths = [p for p in json_paths if os.path.exists(p)]
|
||||
if not json_paths:
|
||||
raise ValueError("No clip JSON files found")
|
||||
_, content_path = self.step_generate_subtitles(self.clips, json_paths)
|
||||
|
||||
# 烧录
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
return final_path
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def run(self) -> str:
|
||||
@@ -447,17 +778,14 @@ class Pipeline:
|
||||
# Step 2: 转录
|
||||
json_paths = self.step_transcribe(clip_paths)
|
||||
|
||||
# Step 3: 标题纠正
|
||||
corrected_clips = self.step_correct_titles(json_paths)
|
||||
# Step 2.5: 用 transcript 重新计算重叠片段的 title_segments 切分点
|
||||
self._recalculate_title_segments_from_transcript(self.clips, json_paths)
|
||||
|
||||
# Step 4: 生成字幕
|
||||
title_path, content_path = self.step_generate_subtitles(corrected_clips, json_paths)
|
||||
|
||||
# Step 5: 合并
|
||||
# Step 3-6: 生成字幕、纠正、合并、烧录
|
||||
title_path, content_path = self.step_generate_subtitles(self.clips, json_paths)
|
||||
corrected_content_path = self.step_correct_subtitles(title_path, content_path)
|
||||
merged_path = self.step_merge(clip_paths)
|
||||
|
||||
# Step 6: 烧录
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
final_path = self.step_burn(merged_path, title_path, corrected_content_path)
|
||||
|
||||
logger.info(f"Pipeline completed: {final_path}")
|
||||
return final_path
|
||||
@@ -474,23 +802,25 @@ class Pipeline:
|
||||
"""
|
||||
logger.info(f"Pipeline starting with user confirmation: {len(self.clips)} clips")
|
||||
|
||||
# Step 1-3: 同上
|
||||
# Step 1-2: 提取+转录
|
||||
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)
|
||||
|
||||
# Step 2.5: 用 transcript 重新计算重叠片段的 title_segments 切分点
|
||||
self._recalculate_title_segments_from_transcript(self.clips, 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'])
|
||||
if i < len(self.clips):
|
||||
self.clips[i]['title'] = confirmed.get('title', self.clips[i].get('title', ''))
|
||||
|
||||
# Step 4-6: 同上
|
||||
title_path, content_path = self.step_generate_subtitles(corrected_clips, json_paths)
|
||||
# Step 3-6: 生成字幕、纠正、合并、烧录
|
||||
title_path, content_path = self.step_generate_subtitles(self.clips, json_paths)
|
||||
corrected_content_path = self.step_correct_subtitles(title_path, content_path)
|
||||
merged_path = self.step_merge(clip_paths)
|
||||
final_path = self.step_burn(merged_path, title_path, content_path)
|
||||
final_path = self.step_burn(merged_path, title_path, corrected_content_path)
|
||||
|
||||
logger.info(f"Pipeline completed: {final_path}")
|
||||
return final_path
|
||||
|
||||
+94
-62
@@ -17,6 +17,8 @@ import zipfile
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Callable, Tuple
|
||||
|
||||
from .llm import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -36,6 +38,7 @@ class PPTParser:
|
||||
api_key: Optional[str] = None,
|
||||
api_host: Optional[str] = None,
|
||||
max_clip_duration: int = 30,
|
||||
max_total_duration: int = 300,
|
||||
):
|
||||
"""
|
||||
初始化PPT解析器
|
||||
@@ -48,6 +51,7 @@ class PPTParser:
|
||||
api_key: LLM API密钥
|
||||
api_host: LLM API地址
|
||||
max_clip_duration: 每个精华片段的最大时长(秒),默认30秒
|
||||
max_total_duration: 所有精华片段的总时长上限(秒),默认300秒(5分钟)
|
||||
"""
|
||||
self.video_path = video_path
|
||||
self.ppt_path = ppt_path
|
||||
@@ -56,6 +60,7 @@ class PPTParser:
|
||||
self.api_key = api_key
|
||||
self.api_host = api_host
|
||||
self.max_clip_duration = max_clip_duration
|
||||
self.max_total_duration = max_total_duration
|
||||
|
||||
self.inter_dir = os.path.join(output_dir, 'intermediates')
|
||||
os.makedirs(self.inter_dir, exist_ok=True)
|
||||
@@ -284,50 +289,19 @@ class PPTParser:
|
||||
|
||||
def _call_llm(self, prompt: str, max_tokens: int = 4096, timeout: int = 300, retries: int = 3) -> Optional[str]:
|
||||
"""
|
||||
带重试的 LLM 调用。
|
||||
使用实例的 api_key/api_host 创建 LLMClient 并调用 chat。
|
||||
|
||||
Args:
|
||||
prompt: 发送给 LLM 的提示词
|
||||
max_tokens: 最大 token 数
|
||||
timeout: 单次请求超时(秒)
|
||||
retries: 最大重试次数
|
||||
retries: 最大重试次数(chat() 内部也有重试,这里传 retries 但 chat() 忽略它)
|
||||
|
||||
Returns:
|
||||
LLM 返回的 content,失败返回 None
|
||||
"""
|
||||
import requests
|
||||
url = f"{self.api_host}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": "doubao-seed-2.0-lite",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.1
|
||||
}
|
||||
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
if content:
|
||||
return content
|
||||
logger.warning(f"LLM返回空内容(第{attempt+1}次尝试)")
|
||||
last_err = "空内容"
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"LLM请求超时(第{attempt+1}次尝试,timeout={timeout}s)")
|
||||
last_err = "超时"
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"LLM请求失败(第{attempt+1}次尝试): {e}")
|
||||
last_err = str(e)
|
||||
|
||||
logger.error(f"LLM调用失败(已重试{retries}次): {last_err}")
|
||||
return None
|
||||
client = LLMClient(api_key=self.api_key, api_host=self.api_host)
|
||||
return client.chat(prompt=prompt, max_tokens=max_tokens, timeout=timeout)
|
||||
|
||||
def llm_extract_knowledge_points_from_ppt(self) -> Tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||
"""
|
||||
@@ -415,7 +389,7 @@ class PPTParser:
|
||||
- 一种方法:如"放松练习"、"分手练习"、"慢速练习"、"唱谱法"
|
||||
- 一个专题:如"乐理基础"、"手型要求"、"课后作业"
|
||||
|
||||
【文本清理规则】(以不影响原文意思表达为前提):
|
||||
【文本清理规则】(用于 cleaned_text,不影响知识点提取):
|
||||
- 合并连续的空行(超过1个空行的压缩为1个)
|
||||
- 去除行首行尾多余空格
|
||||
- 保留页面之间的自然分段(每页独立段落)
|
||||
@@ -423,12 +397,16 @@ class PPTParser:
|
||||
- 无标点的长句子:如果一行文字超过50字且无标点,才合并到下一行
|
||||
- 保留专有名词、术语的原始写法
|
||||
|
||||
【重要规则】:
|
||||
【知识点提取规则】:
|
||||
1. 扫描全部页面:不要只找"知识点汇总页",每页都要看
|
||||
2. 原文保留:知识点原文是什么就写什么,不要解释、概括、翻译或扩展
|
||||
3. 拆分合并:被拆分的片段(如"的三"+"种方法"、"谱号、"+"大谱表、"等)要合并为完整知识词
|
||||
4. 标题过滤:忽略"本课主要知识点"、"课程回顾"、"本节课重要知识点"等纯导航/目录类标题
|
||||
5. 分类项处理:格式如"XX:子项1、子项2、子项3"时,冒号后的每个子项各自独立成知识点;但如果冒号后是完整句子或定义(如"XX:这是指……"),则整句描述的对象本身才是知识点
|
||||
5. 列表/定义项拆分:
|
||||
- 格式为"XX:子项1,子项2,子项3"时,冒号后的每个子项各自独立成知识点
|
||||
- 格式为多行列表(如"重复:xxx\n级进:xxx\n跳进:xxx"),每行各自独立成知识点
|
||||
- 如果冒号后是完整句子或定义(如"XX:这是指……"),则整句描述的对象本身才是知识点
|
||||
- **知识点标题不得包含括号、冒号、引号等任何标点符号**,只保留核心词(如"重复(旋律进行方式)"应输出为"重复","音高、和弦"应输出为"音高"和"和弦")
|
||||
6. 列表项过滤:只保留有独立含义的知识点,忽略序号、标点符号、无意义的装饰词
|
||||
7. 内容页优先:如果一个知识点在教学内容页展开讲解了,比仅出现在列表中更重要
|
||||
8. 最小粒度:宁可多输出几个独立的知识词,也不要合并成一个大而笼统的标题
|
||||
@@ -668,13 +646,24 @@ class PPTParser:
|
||||
for clip in sorted_clips[1:]:
|
||||
prev = merged[-1]
|
||||
if clip['start'] < prev['end']:
|
||||
# 重叠:prev延伸到clip的end,保留clip的标题(标题在clip原start处切换)
|
||||
# 重叠:prev延伸到clip的end,检测标题切换
|
||||
if clip['title'] != prev['title']:
|
||||
# 标题切换点 = clip['start'] 相对于 prev 起点的时间
|
||||
switch_offset = clip['start'] - prev['start']
|
||||
# 建立 title_segments
|
||||
prev['title_segments'] = [
|
||||
[prev['title'], 0],
|
||||
[clip['title'], switch_offset],
|
||||
]
|
||||
prev['title'] = prev['title'] # 保留第一个标题作主标题
|
||||
prev['end'] = clip['end']
|
||||
logger.info(f" 合并重叠: '{prev['title']}' 延伸至 {prev['end']}s,"
|
||||
f"标题在 {clip['start']}s 切换为 '{clip['title']}'")
|
||||
else:
|
||||
# 不重叠:直接添加
|
||||
merged.append(dict(clip))
|
||||
# 不重叠:直接添加,清除 title_segments(由系统默认处理)
|
||||
c = dict(clip)
|
||||
c.pop('title_segments', None)
|
||||
merged.append(c)
|
||||
|
||||
return merged
|
||||
|
||||
@@ -855,7 +844,11 @@ class PPTParser:
|
||||
|
||||
# PPT参考(完整文本 + 知识点列表)
|
||||
if ppt_full_text or ppt_knowledge:
|
||||
knowledge_lines = "\n".join([f" - {kp['title']}" for kp in (ppt_knowledge or [])])
|
||||
knowledge_list = ppt_knowledge or []
|
||||
# 带序号的列表,LLM 用序号引用,不许自由发挥
|
||||
knowledge_lines = "\n".join(
|
||||
[f" [{i}] {kp['title']}" for i, kp in enumerate(knowledge_list)]
|
||||
)
|
||||
knowledge_section = f"""
|
||||
【PPT参考文本(权威背景)】
|
||||
以下是与本节课配套的PPT完整内容,请以此为权威参考:
|
||||
@@ -887,14 +880,13 @@ class PPTParser:
|
||||
|
||||
【重要规则】
|
||||
1. 逐条处理:必须为列表中的**每一个知识点**搜索视频转录文本,找到讲解最集中的片段
|
||||
2. **title 必须完全等于知识点列表中的原名**,不许改写、不许概括、不许扩展
|
||||
- ✅ 正确:knowledge_point 是"弹琴的手型",title 就用"弹琴的手型"
|
||||
- ❌ 错误:title 用"手型支撑与放松的核心要求"(自己发挥)
|
||||
3. **knowledge_point 字段也必须用知识点列表中的原名**
|
||||
4. 时间必须精确:使用转录文本中的实际时间戳
|
||||
5. 时长控制:每个片段约5-15秒,重要内容可以稍长(最长不超过20秒)
|
||||
6. 总时长不超过180秒:如果知识点太多导致总时长超标,优先保留最重要的知识点,其余在not_found中说明
|
||||
7. 只输出JSON,不要添加任何解释
|
||||
2. **输出序号而非名称**:kp_idx 必须是列表中的序号(如 0、3、7),不许自己发挥名称
|
||||
- ✅ 正确:"kp_idx": 3 对应列表中第 4 项
|
||||
- ❌ 错误:"kp_idx": "重复(旋律进行方式)"(这是自由发挥,不是序号)
|
||||
3. 时间必须精确:使用转录文本中的实际时间戳
|
||||
4. 时长控制:每个片段约5-15秒,重要内容可以稍长(最长不超过20秒)
|
||||
5. 总时长不超过{self.max_total_duration}秒:如果知识点太多导致总时长超标,优先保留最重要的知识点,其余在not_found中说明
|
||||
6. 只输出JSON,不要添加任何解释
|
||||
|
||||
【视频转录文本(带时间戳)】
|
||||
{transcript_text}
|
||||
@@ -902,10 +894,10 @@ class PPTParser:
|
||||
请以以下JSON格式输出(不要输出其他内容):
|
||||
{{
|
||||
"clips": [
|
||||
{{"title": "知识点原名(不许改写)", "start": 开始秒数, "end": 结束秒数, "knowledge_point": "知识点原名"}},
|
||||
{{"title": "知识点原名", "start": 开始秒数, "end": 结束秒数, "knowledge_point": "知识点原名"}}
|
||||
{{"kp_idx": 序号, "start": 开始秒数, "end": 结束秒数}},
|
||||
{{"kp_idx": 序号, "start": 开始秒数, "end": 结束秒数}}
|
||||
],
|
||||
"not_found": ["知识点原名(必须与列表中的名称完全一致)"]
|
||||
"not_found": [序号, 序号]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
@@ -929,31 +921,41 @@ class PPTParser:
|
||||
return None
|
||||
|
||||
clips = parsed.get("clips", [])
|
||||
not_found = parsed.get("not_found", [])
|
||||
not_found_idxs = parsed.get("not_found", [])
|
||||
|
||||
if not clips and not not_found:
|
||||
if not clips and not not_found_idxs:
|
||||
return None
|
||||
|
||||
# 验证和清理
|
||||
# 通过序号映射回原始名称(序号 → 原始知识点名称)
|
||||
knowledge_list = ppt_knowledge or []
|
||||
title_map = {i: kp['title'] for i, kp in enumerate(knowledge_list)}
|
||||
|
||||
# 验证和清理:序号 → 原始名称
|
||||
validated = []
|
||||
for clip in clips:
|
||||
title = clip.get("title", "")
|
||||
kp_idx = int(clip.get("kp_idx", -1))
|
||||
if kp_idx not in title_map:
|
||||
logger.warning(f" 跳过无效序号 kp_idx={kp_idx}(超出范围 0-{len(title_map)-1})")
|
||||
continue
|
||||
title = title_map[kp_idx]
|
||||
start = max(0, float(clip.get("start", 0)))
|
||||
raw_end = float(clip.get("end", 0))
|
||||
end = min(raw_end, start + self.max_clip_duration)
|
||||
kp = clip.get("knowledge_point", "")
|
||||
validated.append({
|
||||
"title": title,
|
||||
"start": int(start),
|
||||
"end": int(end),
|
||||
"knowledge_point": kp,
|
||||
"knowledge_point": title,
|
||||
})
|
||||
|
||||
logger.info(f"LLM提取成功: {len(validated)} 个片段,{len(not_found)} 个未找到")
|
||||
# not_found 中的序号也映射回名称
|
||||
not_found_names = [title_map[i] for i in not_found_idxs if i in title_map]
|
||||
|
||||
logger.info(f"LLM提取成功: {len(validated)} 个片段,{len(not_found_names)} 个未找到")
|
||||
for c in validated:
|
||||
logger.info(f" [{c['knowledge_point']}] {c['title']}: {c['start']}s - {c['end']}s")
|
||||
if not_found:
|
||||
logger.info(f" 未找到知识点: {not_found}")
|
||||
if not_found_names:
|
||||
logger.info(f" 未找到知识点: {not_found_names}")
|
||||
|
||||
return validated
|
||||
|
||||
@@ -961,6 +963,30 @@ class PPTParser:
|
||||
logger.warning(f"LLM提取片段失败: {e}")
|
||||
return None
|
||||
|
||||
def _find_title_in_transcript(self, title: str, corrected_segments: List[Dict]) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
在转录文本中搜索标题关键词,返回首次匹配的时间段。
|
||||
|
||||
匹配逻辑:子串匹配(transcript text 包含 title 即为匹配)
|
||||
|
||||
Args:
|
||||
title: 知识点标题
|
||||
corrected_segments: corrected_transcript.json 的 segments 列表
|
||||
每个元素格式: {start, end, text}
|
||||
|
||||
Returns:
|
||||
(start, end) 时间戳元组,或 None(匹配不到)
|
||||
"""
|
||||
if not title or not corrected_segments:
|
||||
return None
|
||||
|
||||
# 搜索策略:在所有 segment text 中找包含 title 的 segment
|
||||
for seg in corrected_segments:
|
||||
text = seg.get('text', '')
|
||||
if title in text:
|
||||
return (seg['start'], seg['end'])
|
||||
return None
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def run(self) -> dict:
|
||||
@@ -1007,6 +1033,9 @@ class PPTParser:
|
||||
}, f, ensure_ascii=False)
|
||||
logger.info(f"已保存PPT知识点到checkpoint")
|
||||
|
||||
# 保存PPT原文供后续步骤使用
|
||||
self.ppt_text = ppt_cleaned_text or ""
|
||||
|
||||
# Step 3: LLM校正文本(以PPT全文为参考)- 带checkpoint复用
|
||||
self._report('parse', 30, "LLM校正文本...")
|
||||
corrected_checkpoint = os.path.join(self.inter_dir, "corrected_transcript.json")
|
||||
@@ -1049,9 +1078,12 @@ class PPTParser:
|
||||
"""创建配置字典"""
|
||||
return {
|
||||
"video_src": self.video_path,
|
||||
"ppt_path": self.ppt_path,
|
||||
"max_total_duration": self.max_total_duration,
|
||||
"clips": clips,
|
||||
"output_dir": self.output_dir,
|
||||
"term_corrections": self.term_corrections,
|
||||
"ppt_text": getattr(self, 'ppt_text', ''),
|
||||
"video_params": {
|
||||
"fade_duration": 1,
|
||||
"title_fontsize": 48,
|
||||
|
||||
+241
-11
@@ -228,15 +228,32 @@ class SubtitlePipeline:
|
||||
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')
|
||||
# 添加标题(使用title样式)
|
||||
if clip.get('title_segments'):
|
||||
# 多标题片段:遍历 title_segments [(title, start_offset), ...]
|
||||
# 每个标题最多显示 title_duration 秒
|
||||
segs = clip['title_segments']
|
||||
for j, (title, seg_start) in enumerate(segs):
|
||||
next_start = segs[j+1][1] if j+1 < len(segs) else clip_duration
|
||||
seg_end = min(seg_start + title_duration, next_start)
|
||||
title_track.add(
|
||||
offset + seg_start,
|
||||
offset + seg_end,
|
||||
title,
|
||||
style='title'
|
||||
)
|
||||
# 正文字幕从最后一个标题段结束后开始
|
||||
content_start = offset + segs[-1][1]
|
||||
else:
|
||||
# 单标题:标题显示3秒后正文才显示,避免重叠
|
||||
title_duration = min(3, clip_duration)
|
||||
title_track.add(offset, offset + title_duration, clip['title'], style='title')
|
||||
content_start = offset + title_duration
|
||||
|
||||
# 添加正文字幕 - 从标题结束后开始,避免重叠
|
||||
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:
|
||||
@@ -253,12 +270,37 @@ class SubtitlePipeline:
|
||||
# 只添加在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'
|
||||
)
|
||||
# pipeline.py 已按标点拆分,此处只处理意外超长segment(无标点且>8秒)
|
||||
duration = seg_end - seg_start
|
||||
if duration > 8.0:
|
||||
# 按标点拆分
|
||||
import re
|
||||
parts = re.split(r'(?<=[。!??!])', text)
|
||||
if len(parts) > 1:
|
||||
total_len = sum(len(p) for p in parts)
|
||||
if total_len > 0:
|
||||
cum_len = 0
|
||||
s_start = seg_start
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
cum_len += len(part)
|
||||
s_end = seg_start + duration * cum_len / total_len
|
||||
content_track.add(s_start, s_end, part, style='content')
|
||||
s_start = s_end
|
||||
continue
|
||||
# 无标点则平均拆分
|
||||
num_splits = max(2, int(duration / 8.0) + 1)
|
||||
chunk_len = len(text) // num_splits
|
||||
for i in range(num_splits):
|
||||
t_start = seg_start + duration * i / num_splits
|
||||
t_end = seg_start + duration * (i + 1) / num_splits
|
||||
chunk_text = text[i * chunk_len:(i + 1) * chunk_len].strip()
|
||||
if chunk_text:
|
||||
content_track.add(t_start, t_end, chunk_text, style='content')
|
||||
else:
|
||||
content_track.add(seg_start, seg_end, text, style='content')
|
||||
|
||||
# 保存两个轨道 - 标题使用SRT格式
|
||||
version = self._get_next_version()
|
||||
@@ -320,4 +362,192 @@ def load_clip_subtitles(inter_dir, clip_nums):
|
||||
if os.path.exists(json_path):
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
clips[num] = json.load(f)
|
||||
return clips
|
||||
return clips
|
||||
|
||||
|
||||
def parse_srt(content: str) -> list:
|
||||
"""
|
||||
解析SRT文本为字幕段列表
|
||||
|
||||
Args:
|
||||
content: SRT文件内容
|
||||
|
||||
Returns:
|
||||
[(index, start, end, text), ...]
|
||||
"""
|
||||
blocks = content.strip().split('\n\n')
|
||||
segments = []
|
||||
for block in blocks:
|
||||
lines = block.strip().split('\n')
|
||||
if len(lines) >= 3:
|
||||
try:
|
||||
idx = int(lines[0])
|
||||
times = lines[1].split(' --> ')
|
||||
start = times[0].strip().replace(',', '.')
|
||||
end = times[1].strip().replace(',', '.')
|
||||
text = '\n'.join(lines[2:])
|
||||
segments.append((idx, start, end, text))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return segments
|
||||
|
||||
|
||||
def format_srt(segments: list) -> str:
|
||||
"""
|
||||
将字幕段列表格式化为SRT文本
|
||||
|
||||
Args:
|
||||
segments: [(index, start, end, text), ...]
|
||||
|
||||
Returns:
|
||||
SRT格式字符串
|
||||
"""
|
||||
lines = []
|
||||
for i, (idx, start, end, text) in enumerate(segments):
|
||||
start_s = start.replace('.', ',')
|
||||
end_s = end.replace('.', ',')
|
||||
lines.append(f"{idx}\n{start_s} --> {end_s}\n{text}")
|
||||
return '\n\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def correct_subtitles_llm(
|
||||
title_path: str,
|
||||
content_path: str,
|
||||
ppt_text: str,
|
||||
llm_client,
|
||||
output_path: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
用LLM纠正字幕内容(idx|text格式,只发纯文本,保留时间轴)
|
||||
|
||||
参考title.srt(时间轴+知识点锚点)和PPT原文(术语纠错),
|
||||
修正content.srt中的错字、漏字、术语错误。
|
||||
|
||||
Args:
|
||||
title_path: 标题字幕SRT路径
|
||||
content_path: 内容字幕SRT路径(待修正)
|
||||
ppt_text: PPT原文(术语参考)
|
||||
llm_client: LLM客户端
|
||||
output_path: 修正后输出路径(默认覆盖原content_path)
|
||||
|
||||
Returns:
|
||||
修正后的字幕文件路径
|
||||
"""
|
||||
import json
|
||||
|
||||
# 读取原始字幕
|
||||
with open(title_path, 'r', encoding='utf-8') as f:
|
||||
title_srt = f.read()
|
||||
with open(content_path, 'r', encoding='utf-8') as f:
|
||||
content_srt = f.read()
|
||||
|
||||
# 解析SRT,保留完整timestamp
|
||||
content_segments = parse_srt(content_srt)
|
||||
|
||||
# 构建idx|text格式的纯文本
|
||||
lines_for_llm = []
|
||||
for seg in content_segments:
|
||||
idx, start, end, text = seg
|
||||
lines_for_llm.append(f"{idx}|{text}")
|
||||
transcript_text = '\n'.join(lines_for_llm)
|
||||
|
||||
# 构建prompt
|
||||
prompt = f"""你是一个钢琴教学视频的字幕纠错专家。
|
||||
|
||||
## 参考信息
|
||||
标题字幕(title.srt)- 权威知识点参考:
|
||||
{title_srt[:2000]}
|
||||
|
||||
PPT原文(ppt)- 术语权威参考:
|
||||
{ppt_text[:3000]}
|
||||
|
||||
## 任务
|
||||
修正以下转录文本中的错字、漏字、术语错误(如"骚"改为"sol","拿两个音速"改为"拿两个因素"等)。
|
||||
每行格式:序号|原始文字
|
||||
|
||||
## 待纠正文本({len(content_segments)}条):
|
||||
{transcript_text}
|
||||
|
||||
## 输出要求
|
||||
- 以JSON格式输出,只输出JSON,不要有任何其他解释
|
||||
- 用原始序号匹配,不要改变结构
|
||||
{{
|
||||
"corrected": [
|
||||
{{"idx": 序号, "text": "修正后的文字"}},
|
||||
{{"idx": 序号, "text": "修正后的文字"}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
# 调用LLM
|
||||
response = llm_client.chat(
|
||||
prompt=prompt,
|
||||
max_tokens=8192,
|
||||
)
|
||||
if not response:
|
||||
logger.warning("LLM返回为空,保留原字幕")
|
||||
return content_path
|
||||
|
||||
# 解析JSON
|
||||
try:
|
||||
import re
|
||||
# 去掉markdown代码块
|
||||
response_clean = response.strip()
|
||||
if response_clean.startswith('```'):
|
||||
lines = response_clean.split('\n')
|
||||
if lines[0].strip().strip('`'):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().strip('`'):
|
||||
lines = lines[:-1]
|
||||
response_clean = '\n'.join(lines)
|
||||
|
||||
# 提取JSON
|
||||
json_match = re.search(r'\{.*\}', response_clean, re.DOTALL)
|
||||
if not json_match:
|
||||
raise ValueError("No JSON found in response")
|
||||
result = json.loads(json_match.group())
|
||||
|
||||
corrected_list = result.get('corrected', [])
|
||||
# 建立 idx -> corrected_text 的映射
|
||||
corrected_map = {item['idx']: item['text'] for item in corrected_list}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"字幕纠正JSON解析失败,保留原字幕: {e}")
|
||||
return content_path
|
||||
|
||||
# 重建SRT,对比diff
|
||||
orig_by_idx = {seg[0]: seg[3] for seg in content_segments}
|
||||
changed = []
|
||||
|
||||
result_lines = []
|
||||
for seg in content_segments:
|
||||
idx, start, end, orig_text = seg
|
||||
new_text = corrected_map.get(idx, orig_text)
|
||||
|
||||
# 恢复SRT格式
|
||||
start_s = start.replace('.', ',')
|
||||
end_s = end.replace('.', ',')
|
||||
result_lines.append(f"{idx}\n{start_s} --> {end_s}\n{new_text}")
|
||||
|
||||
if new_text != orig_text:
|
||||
changed.append((idx, orig_text, new_text))
|
||||
|
||||
corrected_srt = '\n\n'.join(result_lines) + '\n'
|
||||
|
||||
# 保存
|
||||
if output_path is None:
|
||||
output_path = content_path
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(corrected_srt)
|
||||
|
||||
# Diff日志
|
||||
if changed:
|
||||
logger.info(f"字幕纠正,共 {len(changed)} 处修改:")
|
||||
for idx, old, new in changed:
|
||||
old_s = old[:50] + ('...' if len(old) > 50 else '')
|
||||
new_s = new[:50] + ('...' if len(new) > 50 else '')
|
||||
logger.info(f" [{idx:3d}] \"{old_s}\" → \"{new_s}\"")
|
||||
else:
|
||||
logger.info("字幕纠正,无修改")
|
||||
|
||||
logger.info(f"字幕已修正: {output_path}")
|
||||
return output_path
|
||||
+4
-11
@@ -146,7 +146,7 @@ def burn_subtitles(video_path, srt_path, output_path):
|
||||
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"):
|
||||
def burn_dual_subtitles(video_path, title_srt_path, content_srt_path, output_path, title_fontsize=60, title_color="FFFF00", subtitle_fontsize=24, subtitle_color="FFFFFF"):
|
||||
"""
|
||||
烧录两层字幕到视频(标题在屏幕正中,正文在下方)
|
||||
|
||||
@@ -163,7 +163,7 @@ def burn_dual_subtitles(video_path, title_srt_path, content_srt_path, output_pat
|
||||
Returns:
|
||||
True if success
|
||||
"""
|
||||
# Windows路径转义
|
||||
# Windows路径转义:D:/ 需要双反斜杠转义
|
||||
title_escaped = title_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||||
content_escaped = content_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||||
|
||||
@@ -180,19 +180,12 @@ def burn_dual_subtitles(video_path, title_srt_path, content_srt_path, output_pat
|
||||
title_bgr = html_to_bgr(title_color)
|
||||
subtitle_bgr = html_to_bgr(subtitle_color)
|
||||
|
||||
# 标题样式:使用SRT+force_style,Alignment=5水平居中,垂直位置由MarginV控制
|
||||
# 标题样式:使用SRT+force_style,Alignment=2水平居中,MarginV=150使其位于屏幕上偏下区域(36%高度)
|
||||
# 正文字样式:底部居中,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"
|
||||
title_style = f"FontName=微软雅黑,FontSize={title_fontsize},PrimaryColour={title_bgr},Alignment=2,MarginV=150,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]"
|
||||
|
||||
# 保留原始音频 - 映射视频输出和原始音频
|
||||
|
||||
+353
-13
@@ -10,6 +10,7 @@ import os
|
||||
import threading
|
||||
import logging
|
||||
import configparser
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# 设置环境
|
||||
@@ -23,9 +24,11 @@ if ffmpeg_path:
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLineEdit, QPushButton, QTextEdit, QLabel, QFileDialog,
|
||||
QMessageBox, QGroupBox, QFormLayout, QProgressBar
|
||||
QMessageBox, QGroupBox, QFormLayout, QProgressBar, QListWidget,
|
||||
QListWidgetItem, QInputDialog, QMenu
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
# 底层函数(与 CLI 共用)
|
||||
from core import parse_ppt_to_config, Pipeline
|
||||
@@ -63,16 +66,354 @@ class GUI(QMainWindow):
|
||||
|
||||
self.signaller = Signaller()
|
||||
self.worker_thread = None
|
||||
self._setup_ui()
|
||||
self.pipeline = None # 当前 Pipeline 实例
|
||||
self.project_config = None # 当前项目配置
|
||||
|
||||
# 连接信号
|
||||
self.signaller.log_signal.connect(self._append_log)
|
||||
self.signaller.finished_signal.connect(self._on_finished)
|
||||
|
||||
def _setup_ui(self):
|
||||
# 显示启动页
|
||||
self._setup_start_page()
|
||||
|
||||
def _clear_ui(self):
|
||||
"""清空当前 UI"""
|
||||
central = self.centralWidget()
|
||||
if central:
|
||||
central.deleteLater()
|
||||
|
||||
def _setup_start_page(self):
|
||||
"""启动页:新建项目 / 打开已有项目"""
|
||||
self.setWindowTitle("Piano Highlight Generator - GUI")
|
||||
self.setGeometry(100, 100, 500, 300)
|
||||
|
||||
self._clear_ui()
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# 标题
|
||||
title = QLabel("Piano Highlight Generator")
|
||||
title.setStyleSheet("font-size: 24px; font-weight: bold;")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
layout.addSpacing(40)
|
||||
|
||||
# 按钮
|
||||
self.new_project_btn = QPushButton("新建项目")
|
||||
self.new_project_btn.setMinimumHeight(50)
|
||||
self.new_project_btn.clicked.connect(self._on_new_project)
|
||||
layout.addWidget(self.new_project_btn)
|
||||
|
||||
self.open_project_btn = QPushButton("打开已有项目")
|
||||
self.open_project_btn.setMinimumHeight(50)
|
||||
self.open_project_btn.clicked.connect(self._on_open_project)
|
||||
layout.addWidget(self.open_project_btn)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def _on_new_project(self):
|
||||
"""新建项目:切换到文件选择界面"""
|
||||
self._setup_new_project_ui()
|
||||
|
||||
def _on_open_project(self):
|
||||
"""打开已有项目"""
|
||||
dir_path = QFileDialog.getExistingDirectory(self, "选择项目目录")
|
||||
if not dir_path:
|
||||
return
|
||||
self.load_project(dir_path)
|
||||
|
||||
def load_project(self, output_dir):
|
||||
"""
|
||||
加载已有项目,加载 generated_config.yaml,显示 clip 列表
|
||||
"""
|
||||
config_path = os.path.join(output_dir, 'generated_config.yaml')
|
||||
if not os.path.exists(config_path):
|
||||
QMessageBox.critical(self, "错误", f"不是有效的项目目录:\n{config_path}")
|
||||
return
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
self.project_config = yaml.safe_load(f)
|
||||
|
||||
self.project_config['output_dir'] = output_dir
|
||||
|
||||
# 初始化 Pipeline
|
||||
self.pipeline = Pipeline(self.project_config)
|
||||
|
||||
# 显示项目信息(Task 9 完成后替换为完整编辑界面)
|
||||
self._show_project_info()
|
||||
|
||||
def _show_project_info(self):
|
||||
"""显示项目基本信息(临时,过渡用)"""
|
||||
self.setWindowTitle("项目信息 - Piano Highlight Generator")
|
||||
self.setGeometry(100, 100, 600, 400)
|
||||
|
||||
self._clear_ui()
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
# 项目路径
|
||||
layout.addWidget(QLabel(f"<b>项目目录:</b> {self.project_config.get('output_dir', 'N/A')}"))
|
||||
|
||||
# 视频源
|
||||
video_src = self.project_config.get('video_src', 'N/A')
|
||||
layout.addWidget(QLabel(f"<b>视频:</b> {video_src}"))
|
||||
|
||||
# PPT 源
|
||||
ppt_src = self.project_config.get('ppt_src', 'N/A')
|
||||
layout.addWidget(QLabel(f"<b>PPT:</b> {ppt_src}"))
|
||||
|
||||
# Clips 数量
|
||||
clips = self.project_config.get('clips', [])
|
||||
layout.addWidget(QLabel(f"<b>Clips 数量:</b> {len(clips)}"))
|
||||
|
||||
# Clips 列表
|
||||
if clips:
|
||||
list_label = QLabel("<b>Clips 列表:</b>")
|
||||
layout.addWidget(list_label)
|
||||
for i, clip in enumerate(clips[:10]): # 只显示前10个
|
||||
title = clip.get('title', clip.get('content', 'N/A'))[:50]
|
||||
start = clip.get('start_time', 'N/A')
|
||||
end = clip.get('end_time', 'N/A')
|
||||
layout.addWidget(QLabel(f" {i+1}. [{start}-{end}] {title}"))
|
||||
if len(clips) > 10:
|
||||
layout.addWidget(QLabel(f" ... 还有 {len(clips) - 10} 个"))
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 按钮区
|
||||
btn_layout = QHBoxLayout()
|
||||
self.edit_btn = QPushButton("进入编辑模式")
|
||||
self.edit_btn.clicked.connect(self._setup_edit_ui) # Task 9 实现
|
||||
back_btn = QPushButton("返回")
|
||||
back_btn.clicked.connect(self._setup_start_page)
|
||||
btn_layout.addWidget(self.edit_btn)
|
||||
btn_layout.addWidget(back_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _setup_edit_ui(self):
|
||||
"""编辑界面:左侧 clip 列表 + 右侧字幕预览"""
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
main_layout = QVBoxLayout(central) # Changed to QVBoxLayout for bottom bar
|
||||
|
||||
# Top: Split left and right
|
||||
top_layout = QHBoxLayout()
|
||||
|
||||
# === 左侧:clip 列表 ===
|
||||
left_widget = QWidget()
|
||||
left_layout = QVBoxLayout(left_widget)
|
||||
|
||||
left_header = QLabel("知识点")
|
||||
left_header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
left_layout.addWidget(left_header)
|
||||
|
||||
# clip 列表
|
||||
self.clip_list = QListWidget()
|
||||
self.clip_list.itemDoubleClicked.connect(self._on_clip_double_clicked)
|
||||
self.clip_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.clip_list.customContextMenuRequested.connect(self._on_clip_context_menu)
|
||||
left_layout.addWidget(self.clip_list)
|
||||
|
||||
# 底部按钮
|
||||
left_btn_layout = QHBoxLayout()
|
||||
self.add_clip_btn = QPushButton("+ 新增知识点")
|
||||
self.add_clip_btn.clicked.connect(self._on_add_clip)
|
||||
self.add_clip_btn.setMaximumWidth(120)
|
||||
left_btn_layout.addWidget(self.add_clip_btn)
|
||||
left_btn_layout.addStretch()
|
||||
left_layout.addLayout(left_btn_layout)
|
||||
|
||||
top_layout.addWidget(left_widget, 1)
|
||||
|
||||
# === 右侧:字幕预览 ===
|
||||
right_widget = QWidget()
|
||||
right_layout = QVBoxLayout(right_widget)
|
||||
|
||||
right_header = QLabel("字幕预览(可直接编辑)")
|
||||
right_header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
right_layout.addWidget(right_header)
|
||||
|
||||
self.subtitle_edit = QTextEdit()
|
||||
self.subtitle_edit.setReadOnly(False) # 用户可编辑
|
||||
right_layout.addWidget(self.subtitle_edit)
|
||||
|
||||
right_btn_layout = QHBoxLayout()
|
||||
self.load_subtitle_btn = QPushButton("加载字幕")
|
||||
self.load_subtitle_btn.clicked.connect(self._load_subtitle_file)
|
||||
right_btn_layout.addWidget(self.load_subtitle_btn)
|
||||
right_layout.addLayout(right_btn_layout)
|
||||
|
||||
top_layout.addWidget(right_widget, 2)
|
||||
main_layout.addLayout(top_layout, 1) # Stretch factor 1 for top area
|
||||
|
||||
# === 底部状态栏 ===
|
||||
bottom_layout = QHBoxLayout()
|
||||
self.status_label = QLabel("就绪")
|
||||
bottom_layout.addWidget(self.status_label)
|
||||
bottom_layout.addStretch()
|
||||
|
||||
self.apply_btn = QPushButton("应用")
|
||||
self.apply_btn.setStyleSheet("font-weight: bold; background-color: #4CAF50; color: white;")
|
||||
self.apply_btn.clicked.connect(self._on_apply) # Task 10 实现
|
||||
bottom_layout.addWidget(self.apply_btn)
|
||||
|
||||
self.back_btn = QPushButton("返回")
|
||||
self.back_btn.clicked.connect(self._show_project_info)
|
||||
bottom_layout.addWidget(self.back_btn)
|
||||
|
||||
main_layout.addLayout(bottom_layout)
|
||||
|
||||
# 填充 clip 列表
|
||||
self._refresh_clip_list()
|
||||
|
||||
# 加载字幕
|
||||
self._load_subtitle_file()
|
||||
|
||||
def _refresh_clip_list(self):
|
||||
"""刷新 clip 列表"""
|
||||
self.clip_list.clear()
|
||||
for i, clip in enumerate(self.pipeline.clips):
|
||||
title = clip.get('title', '未知')
|
||||
matched = clip.get('matched', True)
|
||||
|
||||
item_text = f"Clip {i+1}: {title}"
|
||||
if not matched:
|
||||
item_text += " ⚠️ 未匹配"
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
if not matched:
|
||||
item.setBackground(QColor(255, 200, 200)) # 红色背景
|
||||
item.setData(Qt.ItemDataRole.UserRole, i) # 存索引
|
||||
self.clip_list.addItem(item)
|
||||
|
||||
def _on_clip_double_clicked(self, item):
|
||||
"""双击 clip 进入编辑模式"""
|
||||
clip_index = item.data(Qt.ItemDataRole.UserRole)
|
||||
# 简单的实现:弹出 QInputDialog 让用户输入新标题
|
||||
clip = self.pipeline.clips[clip_index]
|
||||
new_title, ok = QInputDialog.getText(
|
||||
self, "修改标题", f"Clip {clip_index+1} 标题:",
|
||||
text=clip.get('title', '')
|
||||
)
|
||||
if ok and new_title.strip():
|
||||
self.pipeline.reextract_clip(clip_index, new_title.strip())
|
||||
self._refresh_clip_list()
|
||||
self.status_label.setText(f"已修改 Clip {clip_index+1} 标题为: {new_title}")
|
||||
|
||||
def _on_clip_context_menu(self, pos):
|
||||
"""右键菜单:删除"""
|
||||
item = self.clip_list.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
clip_index = item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
menu = QMenu()
|
||||
delete_action = menu.addAction("删除此 Clip")
|
||||
action = menu.exec_(self.clip_list.mapToGlobal(pos))
|
||||
if action == delete_action:
|
||||
self.pipeline.delete_clip(clip_index)
|
||||
self._refresh_clip_list()
|
||||
self.status_label.setText(f"已删除 Clip {clip_index+1}")
|
||||
|
||||
def _on_add_clip(self):
|
||||
"""新增知识点"""
|
||||
new_title, ok = QInputDialog.getText(self, "新增知识点", "请输入知识点标题:")
|
||||
if ok and new_title.strip():
|
||||
idx, matched = self.pipeline.add_clip_by_title(new_title.strip())
|
||||
self._refresh_clip_list()
|
||||
if matched:
|
||||
self.status_label.setText(f"已新增: {new_title} (Clip {idx+1})")
|
||||
else:
|
||||
self.status_label.setText(f"已新增: {new_title},但未匹配到转录内容")
|
||||
|
||||
def _load_subtitle_file(self):
|
||||
"""加载 v1_content.srt 到字幕编辑框"""
|
||||
srt_path = os.path.join(self.pipeline.subs_dir, 'v1_content.srt')
|
||||
if os.path.exists(srt_path):
|
||||
with open(srt_path, 'r', encoding='utf-8') as f:
|
||||
self.subtitle_edit.setPlainText(f.read())
|
||||
else:
|
||||
self.subtitle_edit.setPlainText("# 字幕文件不存在")
|
||||
|
||||
def _on_apply(self):
|
||||
"""
|
||||
应用按钮:收集所有修改,调用底层原子操作,重烧最终视频。
|
||||
|
||||
逻辑:
|
||||
1. 检查字幕是否有修改(对比 v1_content.srt)
|
||||
- 有修改 → reburn_subtitles(user_texts)
|
||||
2. 检查 clips 是否有修改
|
||||
- clips 修改后(add/delete/reextract)已通过各自的方法自动保存
|
||||
- 但需要重新烧录标题轨 → reburn_titles()
|
||||
3. 最后合并+烧录最终视频
|
||||
"""
|
||||
self.apply_btn.setEnabled(False)
|
||||
self.status_label.setText("正在应用更改...")
|
||||
|
||||
def work():
|
||||
try:
|
||||
changed = False
|
||||
|
||||
# 1. 处理字幕修改
|
||||
srt_path = os.path.join(self.pipeline.subs_dir, 'v1_content.srt')
|
||||
current_text = self.subtitle_edit.toPlainText()
|
||||
|
||||
if os.path.exists(srt_path):
|
||||
with open(srt_path, 'r', encoding='utf-8') as f:
|
||||
original_text = f.read()
|
||||
else:
|
||||
original_text = ""
|
||||
|
||||
if current_text != original_text:
|
||||
# 字幕有修改,写入并重烧
|
||||
self.signaller.log_signal.emit("检测到字幕修改,保存并重烧...")
|
||||
self.status_label.setText("保存字幕更改...")
|
||||
with open(srt_path, 'w', encoding='utf-8') as f:
|
||||
f.write(current_text)
|
||||
self.pipeline.reburn_subtitles(current_text)
|
||||
changed = True
|
||||
|
||||
# 2. 检查是否有 unmatched clips(不参与烧录)
|
||||
unmatched = [i for i, c in enumerate(self.pipeline.clips) if not c.get('matched', True)]
|
||||
if unmatched:
|
||||
self.signaller.log_signal.emit(f"警告: {len(unmatched)} 个 clip 未匹配到转录内容,不参与烧录")
|
||||
self.status_label.setText(f"警告: {len(unmatched)} 个 clip 未匹配,跳过")
|
||||
|
||||
# 3. 只要 clips 有任何修改(add/delete/reextract),都需要重烧标题轨
|
||||
# 这些修改已经在各自的方法里保存了 config,只需要重烧
|
||||
# reburn_titles() 会用最新的 clips config 生成标题轨
|
||||
self.signaller.log_signal.emit("重烧标题轨...")
|
||||
self.status_label.setText("重烧标题轨...")
|
||||
self.pipeline.reburn_titles()
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.signaller.log_signal.emit("应用完成")
|
||||
self.status_label.setText("应用完成!")
|
||||
else:
|
||||
self.signaller.log_signal.emit("没有检测到更改")
|
||||
self.status_label.setText("没有更改")
|
||||
|
||||
except Exception as e:
|
||||
self.signaller.log_signal.emit(f"错误: {e}")
|
||||
import traceback
|
||||
self.signaller.log_signal.emit(traceback.format_exc())
|
||||
self.status_label.setText(f"错误: {e}")
|
||||
finally:
|
||||
self.apply_btn.setEnabled(True)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _setup_new_project_ui(self):
|
||||
"""新建项目的文件选择界面"""
|
||||
self.setWindowTitle("新建项目 - Piano Highlight Generator")
|
||||
self.setGeometry(100, 100, 700, 500)
|
||||
|
||||
self._clear_ui()
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
@@ -124,12 +465,11 @@ class GUI(QMainWindow):
|
||||
# === 按钮区 ===
|
||||
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())
|
||||
self.start_btn.clicked.connect(self._on_start_new)
|
||||
self.back_btn = QPushButton("返回")
|
||||
self.back_btn.clicked.connect(self._setup_start_page)
|
||||
btn_layout.addWidget(self.start_btn)
|
||||
btn_layout.addWidget(self.clear_btn)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.back_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _select_file(self, edit, filter_str):
|
||||
@@ -156,8 +496,8 @@ class GUI(QMainWindow):
|
||||
else:
|
||||
QMessageBox.critical(self, "错误", message)
|
||||
|
||||
def _on_start(self):
|
||||
# 收集参数
|
||||
def _on_start_new(self):
|
||||
"""开始新建项目流程"""
|
||||
video_path = self.video_edit.text().strip()
|
||||
ppt_path = self.ppt_edit.text().strip()
|
||||
output_dir = self.output_edit.text().strip()
|
||||
@@ -182,12 +522,13 @@ class GUI(QMainWindow):
|
||||
|
||||
# 后台线程执行
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._worker,
|
||||
target=self._worker_new,
|
||||
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):
|
||||
def _worker_new(self, video_path, ppt_path, output_dir, api_key, api_host):
|
||||
"""新建项目的工作线程"""
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
@@ -217,7 +558,6 @@ class GUI(QMainWindow):
|
||||
|
||||
# 保存配置
|
||||
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}")
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
# 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 ─┘
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
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'))
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -c "import pptx; print('pptx available')"
|
||||
@@ -1,3 +0,0 @@
|
||||
@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"
|
||||
@@ -1,10 +0,0 @@
|
||||
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)
|
||||
@@ -1,3 +0,0 @@
|
||||
@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"
|
||||
@@ -1,17 +0,0 @@
|
||||
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")
|
||||
@@ -1,4 +0,0 @@
|
||||
@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
|
||||
@@ -1,30 +0,0 @@
|
||||
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}")
|
||||
@@ -1,3 +0,0 @@
|
||||
@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"
|
||||
@@ -1,34 +0,0 @@
|
||||
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)
|
||||
@@ -1,3 +0,0 @@
|
||||
@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
|
||||
@@ -1,23 +0,0 @@
|
||||
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")
|
||||
@@ -1,3 +0,0 @@
|
||||
@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"
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
@@ -1,6 +0,0 @@
|
||||
@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
|
||||
@@ -1,4 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -m pip install python-pptx
|
||||
echo Exit: %errorlevel%
|
||||
@@ -1,4 +0,0 @@
|
||||
@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%
|
||||
@@ -1,12 +0,0 @@
|
||||
# Kill all python processes related to our CLI
|
||||
Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep 3
|
||||
|
||||
# Verify killed
|
||||
$remaining = Get-Process python -ErrorAction SilentlyContinue
|
||||
if ($remaining) {
|
||||
Write-Host "Still running:"
|
||||
$remaining | ForEach-Object { Write-Host " PID:" $_.Id }
|
||||
} else {
|
||||
Write-Host "All python processes killed"
|
||||
}
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
f = open(r'D:\F\NewI\opencode\daily-workspace\temp\cli_run_log.txt', 'r', encoding='utf-8')
|
||||
lines = f.readlines()
|
||||
f.close()
|
||||
for l in lines[:35]:
|
||||
print(l.rstrip())
|
||||
Binary file not shown.
Reference in New Issue
Block a user