Compare commits

16 Commits

Author SHA1 Message Date
hmo 0720a7657a chore: remove dead code from gui.py, fix burn_only.py compatibility 2026-05-04 00:37:29 +08:00
hmo 2505f88b74 feat(gui): integrate apply button with reburn logic 2026-05-04 00:34:05 +08:00
hmo b9dc5b163b feat(gui): add clip list and subtitle preview edit UI 2026-05-04 00:31:32 +08:00
hmo 5f3396378f feat(gui): add two startup modes (new/open project) 2026-05-04 00:28:40 +08:00
hmo c99d05e42e feat: add Pipeline.reburn_titles and reburn_subtitles 2026-05-04 00:26:10 +08:00
hmo fc76ded3e4 feat: add Pipeline.add_clip_by_title 2026-05-04 00:22:55 +08:00
hmo 440f481599 feat: add Pipeline.delete_clip 2026-05-04 00:21:15 +08:00
hmo 0fbf8757fa feat: add Pipeline.reextract_clip for single-title re-matching 2026-05-04 00:19:37 +08:00
hmo 6a5ec9c04f feat: add _find_title_in_transcript for single-title re-matching 2026-05-04 00:17:27 +08:00
hmo 086ce358a5 refactor: separate config.py into global (API) vs project (paths) 2026-05-04 00:14:23 +08:00
hmo 3325752f02 feat: add ppt_path and max_total_duration to generated_config.yaml 2026-05-04 00:11:59 +08:00
hmo 0b069bc9a3 docs: add implementation plan for GUI edit feature 2026-05-04 00:03:16 +08:00
hmo eb02c47dd8 docs: update GUI design - config separation and project file architecture 2026-05-04 00:01:47 +08:00
hmo 088db28a77 docs: add GUI edit design spec 2026-05-03 23:49:14 +08:00
hmo aad1548348 refactor: extract config.py, add burn_only, fix title_segments and font size
- Extract all path/API config to config.py (single source of truth)
- Add run.py / burn_only.py / run.bat / burn.bat entry points
- burn_only: skip transcription/subtitle gen, fast reburn existing SRTs
- Fix title_segments: use transcript keyword time for split point
- Fix subtitle: each overlapping title shows max title_duration (not full clip)
- Fix burn_only font size: default from 90 to 60
- Delete old run_lesson1.bat/py, temp debug scripts
- Update README, ARCHITECTURE, CHANGELOG, add USAGE.md
2026-05-03 23:22:10 +08:00
hmo cf5004cf6a Clean up: remove junk files, update docs
- Delete: build scripts, old docs (README_BUILD, design.md, proposal.md, etc.)
- Update README.md and docs/ARCHITECTURE.md to reflect new architecture
- Keep run_lesson1.bat/py, start.bat, config.ini
2026-05-03 03:30:24 +08:00
52 changed files with 2076 additions and 2320 deletions
View File
-10
View File
@@ -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.
+91 -80
View File
@@ -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
# 安装 FFmpegWindows - 使用 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/` 目录)
- PySide6GUI
- faster-whisper(转录,可选)
-148
View File
@@ -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.
-71
View File
@@ -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
-61
View File
@@ -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 ""
-69
View File
@@ -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
+3
View File
@@ -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
+80
View File
@@ -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)
# 导入 pipelinesrc 目录)
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}")
+15
View File
@@ -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__)) # 本文件所在目录
-521
View 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
View File
@@ -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
View File
@@ -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
- 初始版本发布
+234
View File
@@ -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
+260
View File
@@ -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/OUTPUTAPI 配置留在 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
View File
@@ -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
View File
@@ -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**: 质量交付 - 审查、测试、打包
-5
View File
@@ -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
+3
View File
@@ -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
+43
View File
@@ -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()
-13
View File
@@ -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
-42
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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_styleAlignment=5水平居中,垂直位置由MarginV控制
# 标题样式:使用SRT+force_styleAlignment=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
View File
@@ -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}")
-477
View File
@@ -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 ─┘
```
-9
View File
@@ -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'))
-3
View File
@@ -1,3 +0,0 @@
@echo off
chcp 65001 >nul
"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe" -c "import pptx; print('pptx available')"
-3
View File
@@ -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"
-10
View File
@@ -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)
-3
View File
@@ -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"
-17
View File
@@ -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")
-4
View File
@@ -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
-30
View File
@@ -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}")
-3
View File
@@ -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"
-34
View File
@@ -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)
-3
View File
@@ -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
-23
View File
@@ -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")
-3
View File
@@ -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"
-12
View File
@@ -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)
-6
View File
@@ -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
-4
View File
@@ -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%
-4
View File
@@ -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%
-12
View File
@@ -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"
}
BIN
View File
Binary file not shown.
-5
View File
@@ -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())
BIN
View File
Binary file not shown.