Initial commit: skills library

- 70 skills with code and documentation
- Add .gitignore (ignore __pycache__, output/, temp/, venv/)
- Clean up test intermediates and caches
This commit is contained in:
hmo
2026-04-26 19:27:40 +08:00
commit 04db423416
861 changed files with 210414 additions and 0 deletions
@@ -0,0 +1,233 @@
# piano-lesson-highlight-generator 环境配置文档
> **最后更新**: 2026-04-03
> **Python 版本**: 3.12.13
> **PyTorch 版本**: 2.5.1+cu121 (CUDA 12.1)
> **faster-whisper**: 1.2.1
---
## 🚨 环境路径(最重要,先看这个)
**实际使用的 Python 环境**
```
D:\ProgramData\anaconda3\envs\py312_cuda\python.exe
```
### ⚠️ 两个容易混淆的 conda 安装
| 路径 | 类型 | PyTorch | CUDA | 能用吗? |
|------|------|---------|------|---------|
| `D:\AI\Miniconda3\python.exe` | Minicondabase | 2.11.0+**cpu** | **False** | ❌ **不能用**,转录会超时 |
| `D:\ProgramData\anaconda3\envs\py312_cuda\python.exe` | Anaconda(新建) | 2.5.1+**cu121** | **True** | ✅ **用这个** |
**所有运行命令都必须使用**
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe"
```
**绝对不要用**
```bash
"D:/AI/Miniconda3/python.exe" # CPU 版本,转录超时
conda activate py312_cuda # bash 中不工作
```
### 为什么需要新建环境
原环境 `D:\AI\Miniconda3` 的 PyTorch 是纯 CPU 版本(2.11.0+cpu),转录 100 分钟视频需要 100+ 分钟,会超时。
**必须使用** `D:\ProgramData\anaconda3\envs\py312_cuda\python.exe`
---
## 环境创建步骤
### 1. 创建 conda 环境
```bash
"D:/ProgramData/anaconda3/Scripts/conda.exe" create -n py312_cuda python=3.12 -y
```
### 2. 安装 CUDA PyTorch
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
```
> 注意:必须安装 `+cu121` 版本,不是 CPU 版本。
### 3. 安装 Python 依赖
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -m pip install faster-whisper zhconv pypinyin pyyaml
```
### 4. 验证环境
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -c "
import torch
from faster_whisper import WhisperModel
print(f'Python: 3.12')
print(f'PyTorch: {torch.__version__}')
print(f'CUDA: {torch.cuda.is_available()}')
print(f'faster-whisper: OK')
"
```
预期输出:
```
Python: 3.12
PyTorch: 2.5.1+cu121
CUDA: True
faster-whisper: OK
```
### 5. 本地模型路径
Whisper large-v3 模型需要下载到本地:
```
D:/AI/LM-Models/faster-whisper/large-v3/
```
包含文件:
- `config.json`
- `model.bin`
- `preprocessor_config.json`
- `tokenizer.json`
- `vocabulary.json`
如果模型不在该路径,脚本会尝试从 HuggingFace 下载(很慢)。
### 6. 完整依赖清单
| 包 | 版本 | 用途 |
|---|------|------|
| Python | 3.12.13 | 运行时 |
| torch | 2.5.1+cu121 | GPU 加速 |
| torchvision | 0.20.1+cu121 | 图像处理 |
| torchaudio | 2.5.1+cu121 | 音频处理 |
| faster-whisper | 1.2.1 | Whisper 转录 |
| ctranslate2 | 4.7.1 | Whisper 后端 |
| zhconv | 1.4.3 | 繁体转简体 |
| pypinyin | 0.55.0 | 拼音转换(AI 纠错) |
| pyyaml | 6.0.3 | 配置文件解析 |
| av | 17.0.0 | 音视频处理 |
| numpy | 2.4.3 | 数值计算 |
| onnxruntime | 1.24.4 | Whisper 推理 |
| tokenizers | 0.22.2 | 文本分词 |
---
## 已知问题
### 问题 0:原环境 D:\AI\Miniconda3 没有 GPU 支持(关键!)
**原 session 的错误记录**:之前有 AI 记录了 `D:\AI\Miniconda3` "具备 GPU 加速能力"——**这是错的!**
验证:
```bash
"D:/AI/Miniconda3/python.exe" -c "import torch; print(torch.cuda.is_available())"
# 输出: False
```
该环境的 PyTorch 是 `2.11.0+cpu`,**纯 CPU 版本**。之前能跑通是因为复用了已有的 `full_transcript.json`,没有重新转录。如果需要重新转录,**必须用有 CUDA 的环境**。
### 问题 1Python 3.8 环境无法安装 faster-whisper
`matanyone` 环境(Python 3.8.20)有 GPU PyTorch,但 `tokenizers` 包需要 Python >= 3.9,导致无法安装 faster-whisper。
**解决方案**:创建 Python 3.12 环境(见上文)。
### 问题 2conda activate 在 bash 中不工作
bash 工具中 `conda activate py312_cuda` 会报错。
**解决方案**:直接使用完整路径调用 Python:
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" script.py
```
---
## GPU 资源管理
### 转录前清理
脚本会在转录前自动清理残留的 Python 进程,释放 GPU 显存。
### 转录后释放
转录完成后,脚本会:
1. `del model` — 删除模型对象
2. `gc.collect()` — 强制垃圾回收
3. `torch.cuda.empty_cache()` — 清空 CUDA 缓存
### 如果 GPU 显存未释放
手动清理:
```bash
cmd /c "taskkill /F /IM python.exe /T"
```
---
## 运行命令模板
```bash
cd .opencode/skills/piano-lesson-highlight-generator
# 知识点提取 + config 生成
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/extract_terms_from_ppt.py <pptx路径> <视频路径> <config输出路径>
# 精华视频生成
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/generate_highlights.py --config <config路径>
```
---
## 项目目录结构
```
projects/piano-lesson-highlights/
├── data/ # 原始输入数据
│ ├── lesson2/
│ │ ├── video.mp4 # 原始视频
│ │ └── course.pptx # 配套 PPT
│ └── lesson3/
│ └── ...
└── cases/ # 每个案例的工作区
├── lesson2/
│ ├── config.yaml # 自动生成的配置
│ ├── intermediates/
│ │ └── full_transcript.json # 完整转录(可复用)
│ └── output/
│ ├── v1_final.mp4 # 最终精华视频
│ ├── subs/
│ │ ├── v1_original.srt
│ │ ├── v1_terms.srt
│ │ └── v1_ai.srt
│ └── intermediates/
│ ├── clip1.mp4 ~ clipN.mp4
│ ├── clip1_fade.mp4 ~ clipN_fade.mp4
│ ├── clip1.json ~ clipN.json
│ ├── concated.mp4
│ └── concat_list.txt
└── lesson3/
└── ...
```
---
## 技能代码位置
```
.opencode/skills/piano-lesson-highlight-generator/
├── scripts/
│ ├── extract_terms_from_ppt.py # 知识点提取 + 转录 + config 生成
│ └── generate_highlights.py # 精华视频生成
├── SKILL.md # 技能说明
├── STATUS.md # 会话状态
├── ENVIRONMENT.md # 本文件
└── README.md # 使用说明
```
@@ -0,0 +1,75 @@
# 钢琴课精华视频生成技能使用说明
## 快速开始
### 1. 准备工作
1. 准备好原始钢琴课视频文件(MP4格式)
2. 准备配套PPT/PDF文件,提取知识点的名称和对应的时间点
3. 复制`references/config_template.yaml`为你的项目配置文件,比如`my_course_config.yaml`
### 2. 编辑配置文件
修改你的配置文件:
```yaml
video_src: "你的视频路径.mp4"
clips:
- title: "知识点1名称"
start: 开始时间(秒)
end: 结束时间(秒)
- title: "知识点2名称"
start: 开始时间
end: 结束时间
# 添加更多知识点...
term_corrections:
# 扩展你的术语纠正规则
"错误词": "正确词"
```
### 3. 运行生成脚本
```bash
python scripts/generate_highlights.py --config your_config.yaml --output ./output_dir
```
### 4. 审核与修改
生成后会在输出目录下得到:
- `vN_final.mp4` - 最终视频(N为版本号,自动递增)
- `subs/v1_original.srt` - 原始转录字幕
- `subs/v1_terms.srt` - 术语库纠正后字幕
- `subs/v1_ai.srt` - AI上下文纠正后字幕
如果需要修改字幕,直接编辑对应版本的srt文件,然后重新运行脚本即可生成新版本。
## 高级用法
### 自定义术语库
参考`references/terms_template.md`扩展你的术语库,添加到配置文件的`term_corrections`字段。
### 调整样式
在配置文件中修改:
- 标题大小、颜色、显示时长
- 字幕大小、颜色
- 转场时长
- 转录模型(如果需要更高精度用large模型)
### 重新生成
如果只修改了字幕,不需要重新转录,可以直接使用修改后的srt文件烧录:
```bash
ffmpeg -i concated.mp4 -vf "subtitles=modified.srt" -c:a copy new_version.mp4
```
## 输入要求
- 视频格式:MP4/H.264编码
- 视频时长:建议不超过2小时
- 知识点数量:建议3-10个,每个20-60秒
- 音频质量:清晰无杂音,识别效果更好
## 常见问题
### 字幕识别不准怎么办?
1. 扩展术语库,添加更多常见错误映射
2. 使用更大的Whisper模型(large
3. 手动修改srt文件重新生成
### 时间戳不对怎么办?
检查配置文件中的知识点开始/结束时间是否正确,确保时间是视频中的实际秒数。
### 字幕不同步怎么办?
确保转录和切分使用的是同一个原始视频,没有经过二次编辑。
+276
View File
@@ -0,0 +1,276 @@
---
name: piano-lesson-highlight-generator
description: 钢琴课精华宣传视频自动生成工具,支持从配套PPT/PDF提取知识点和音乐术语库,自动完成视频剪辑、语音转录、三级字幕纠错、添加标题和转场,生成高质量课程宣传视频。触发词:钢琴课精华、钢琴宣传视频、课程精华剪辑、钢琴课剪精华
---
# 钢琴课精华视频生成技能
## 功能说明
从钢琴课直播/录播视频中提取知识点,自动生成带字幕、标题卡、转场效果的精华宣传视频。
## 适用场景
- 钢琴课课后精华片段制作
- 钢琴培训机构宣传视频生成
- 课程知识点切片归档
- 同类型音乐/艺术类课程视频剪辑
---
## 🚨 环境配置(最重要,先看这里)
### 必须使用的 Python 环境
```
D:\ProgramData\anaconda3\envs\py312_cuda\python.exe
```
### ⚠️ 两个容易混淆的 conda 安装
| 路径 | PyTorch | CUDA | 能用吗? |
|------|---------|------|---------|
| `D:\AI\Miniconda3\python.exe` | 2.11.0+**cpu** | **False** | ❌ **不能用**,转录会超时 |
| `D:\ProgramData\anaconda3\envs\py312_cuda\python.exe` | 2.5.1+**cu121** | **True** | ✅ **用这个** |
> **关键区别**Miniconda 的 PyTorch 是纯 CPU 版本,转录 100 分钟视频会超时(30+ 分钟)。必须用 `py312_cuda` 环境。
### 验证环境
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -c "import torch; print(torch.cuda.is_available())"
# 必须输出 True
```
### 模型路径
```
D:/AI/LM-Models/faster-whisper/large-v3/
```
包含 5 个文件:config.json, model.bin, preprocessor_config.json, tokenizer.json, vocabulary.json
### 完整依赖清单
| 包 | 版本 | 用途 |
|---|------|------|
| Python | 3.12.13 | 运行时 |
| torch | 2.5.1+cu121 | GPU 加速 |
| faster-whisper | 1.2.1 | Whisper 转录 |
| zhconv | 1.4.3 | 繁体转简体 |
| pypinyin | 0.55.0 | 拼音转换(AI 纠错) |
| pyyaml | 6.0.3 | 配置文件解析 |
### 如果环境不存在,重建步骤
```bash
# 1. 创建环境
"D:/ProgramData/anaconda3/Scripts/conda.exe" create -n py312_cuda python=3.12 -y
# 2. 安装 CUDA PyTorch
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# 3. 安装依赖
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -m pip install faster-whisper zhconv pypinyin pyyaml
```
---
## 调用方式
```bash
cd .opencode/skills/piano-lesson-highlight-generator
# 步骤1:知识点提取 + config 生成(含 GPU 转录,约 20-30 分钟)
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/extract_terms_from_ppt.py <pptx路径> <视频路径> <config输出路径>
# 步骤2:精华视频生成(复用转录,约 2-3 分钟)
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/generate_highlights.py --config <config路径>
```
### 完整示例
```bash
cd .opencode/skills/piano-lesson-highlight-generator
# 提取知识点(自动转录视频 + 匹配时间戳 + 生成 config)
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/extract_terms_from_ppt.py ^
"D:/F/NewI/opencode/daily-workspace/projects/piano-lesson-highlights/data/lesson3/course.pptx" ^
"D:/F/yc/课程上架/福田商圈夜校/课程视频/直播回放-03月30日.mp4" ^
"D:/F/NewI/opencode/daily-workspace/projects/piano-lesson-highlights/cases/lesson3/config.yaml"
# 生成视频
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" scripts/generate_highlights.py ^
--config "D:/F/NewI/opencode/daily-workspace/projects/piano-lesson-highlights/cases/lesson3/config.yaml"
```
---
## 项目目录结构
```
projects/piano-lesson-highlights/
├── data/ # 原始输入数据(只读)
│ ├── lesson2/
│ │ ├── video.mp4
│ │ └── course.pptx
│ └── lesson3/
│ └── ...
└── cases/ # 每个案例的工作区(独立)
├── lesson2/
│ ├── config.yaml # 自动生成的配置
│ ├── intermediates/
│ │ └── full_transcript.json # 完整转录(可复用)
│ └── output/
│ ├── v1_final.mp4 # 最终视频
│ ├── subs/
│ │ ├── v1_original.srt
│ │ ├── v1_terms.srt
│ │ └── v1_ai.srt
│ └── intermediates/
│ ├── clip1.mp4 ~ clipN.mp4
│ ├── clip1_fade.mp4 ~ clipN_fade.mp4
│ ├── clip1.json ~ clipN.json
│ └── concated.mp4
└── lesson3/
└── ...
```
**铁律**:每次处理新视频时,必须创建新的独立目录,不得复用旧的 intermediates 或 subs 文件夹。
---
## 工作流程
### 步骤1:知识点提取(extract_terms_from_ppt.py
1. **从 PPT 提取知识点**:解析 PPTX XML,找到"本课主要知识点"页面
2. **GPU 转录整段视频**:每 5 分钟一段,使用 faster-whisper large-v3 + CUDA
- 转录前自动清理残留 Python 进程,释放 GPU 显存
- 转录完成后显式释放模型(`del model` + `gc.collect()` + `torch.cuda.empty_cache()`
3. **定位教学锚点**:找到知识点首次出现时间,作为教学开始
4. **定位作业锚点**:用"作业"词密度定位,而非固定引导语
5. **匹配知识点到时间戳**
- 使用完整关键词 + 核心子词 + 相关词映射 + 数字归一化
- 评分综合考量:出现次数、文本量、讲解密度、孤立程度
- 排除导读区(无讲解特征 + segment ≤ 2 个 → 跳过)
- 排除回顾区(靠近作业时间惩罚 + 回顾性语言检测)
6. **匹配作业片段**
- 模糊匹配 30+ 种口语化结束表达
- 检测到结束语言后 +30s 兜底
- 无结束语言时用 45s 间隔截断
7. **生成 config.yaml**
### 步骤2:精华视频生成(generate_highlights.py
1. **提取视频片段**:按 config 时间点截取,添加 1s 淡入淡出
- 自动修复重叠:调整前一个片段的 end 使其等于后一个的 start
2. **转录片段**:优先复用 `full_transcript.json`,按时间切片映射
- 时间戳限制在 clip 实际时长范围内
- 内容验证:先应用术语纠正再匹配关键词
3. **三级字幕**
- v1_original:原始转录 + 繁简转换
- v1_terms:术语库纠正(黑剑→黑键、副点→附点等)
- v1_ai:AI 上下文纠错(语义异常 + 拼音推断 + 专有名词补全)
4. **视频合成**:标题卡 + 字幕烧录 + 片段合并
---
## 配置文件格式(YAML
```yaml
video_src: "视频文件路径.mp4"
output_dir: "输出目录路径"
# 知识点片段
clips:
- title: "八分音符"
start: 2332
end: 2362
- title: "作业"
start: 6515
end: 6997
# 术语纠正表
term_corrections:
黑剑: 黑键
负点: 附点
副点: 附点
实质: 时值
演音: 延音
言音: 延音
阅历: 乐理
# 视频参数
video_params:
fade_duration: 1
title_duration: 3
title_fontsize: 90
title_color: FFFF00
subtitle_fontsize: 24
subtitle_color: FFFFFF
whisper_model: large
use_fast_whisper: true
whisper_model_path: "D:/AI/LM-Models/faster-whisper/large-v3"
```
---
## 知识点匹配架构
### 相关度评分
| 匹配类型 | 分数 | 示例 |
|---------|------|------|
| 完整关键词 | 3.0 | "附点音符" 匹配 "附点音符" |
| 数字归一化 | 2.5 | "十六分音符" 匹配 "16分音符" |
| 核心子词 | 2.0 | "双音的支撑" 匹配 "双音支撑" |
| 相关词映射 | 1.5 | "升降记号" 匹配 "升号"/"降号" |
| 前缀匹配 | 1.5 | "附点音符" 匹配 "附点" |
| 核心词 | 1.0 | 3-4 字核心词匹配 |
### 教学特征识别
| 特征 | 检测方式 |
|------|---------|
| 实际教学 | 讲解词占比高(因为/所以/就是/什么意思/为什么/怎么/弹/按/练) |
| 导读提及 | 无讲解特征 + segment ≤ 2 个 → 跳过 |
| 回顾总结 | 靠近作业时间 + "刚才"/"今天学了" → 降权 |
| 推迟预告 | "等下再说"/"后面讲" → 大幅降权 |
### 作业结束检测(模糊匹配 30+ 种表达)
| 类型 | 匹配模式 | 示例 |
|------|---------|------|
| 明确下课 | `下课``拜拜``再见` | "下课" |
| 作业完成 | `作业.*就这样``作业.*就这些``作业.*讲到这里` | "今天的作业就这样" |
| 通用结束 | `就到这里``就这样了``说完了``没什么.*说的` | "就这些了" |
| 群发通知 | `发群``到时候.*发` | "到时候我发群里" |
---
## 验证标准
- [ ] GPU 加速下,转录 100 分钟视频约 20-30 分钟
- [ ] 最终视频总时长建议 < 15 分钟
- [ ] 每个知识点开头显示 3 秒黄色标题卡(90 号字,黑边,居中)
- [ ] 字幕为简体中文,24 号白字黑边,位于底部
- [ ] 片段间有 1 秒淡入淡出转场
- [ ] 输出文件真实存在且可播放
---
## 已知限制
1. **Python 版本**:必须 3.123.13 不支持 CUDA 版 PyTorch
2. **GPU 依赖**:必须 GPU 加速,CPU 转录 100 分钟视频需要 100+ 分钟
3. **转录精度**:Whisper 对专业音乐术语可能有识别误差(如"附点"→"副点"),依赖术语纠正表修正
4. **数字识别**:Whisper 可能将中文数字识别为阿拉伯数字("十六"→"16"),已内置数字归一化匹配
5. **中文字体**:标题卡需要 Windows 系统字体 `C:/Windows/Fonts/msyh.ttc`(微软雅黑)
---
## 重新生成机制
如果用户发现字幕错误:
1. 告知具体错误内容 + 需要基于哪个版本修改
2. 直接修改对应的 srt 文件
3. 使用 ffmpeg 重新烧录:`ffmpeg -i concated.mp4 -vf "subtitles=修改后的.srt:force_style='...'" -c:a copy new_version.mp4`
+226
View File
@@ -0,0 +1,226 @@
# 📋 钢琴课精华视频生成技能 - 会话状态与交接文档
> **生成时间**: 2026-04-02
> **最后更新**: 2026-04-03
> **当前状态**: ✅ 全流程验证通过,环境配置已固化
---
## 🎯 核心目标
利用 `piano-lesson-highlight-generator` 技能,根据 PPT 和视频自动生成精华视频(<15分钟),包含标题卡、字幕、转场。
---
## 🔧 环境配置
**详见 `ENVIRONMENT.md`**
关键信息:
- **Python 环境**: `D:/ProgramData/anaconda3/envs/py312_cuda/python.exe`Python 3.12.13
- **PyTorch**: 2.5.1+cu121**必须 GPU**CPU 会超时)
- **Whisper 模型**: `D:/AI/LM-Models/faster-whisper/large-v3/`
- **运行命令**: 直接使用完整路径调用 Python,不要用 `conda activate`
### 环境验证
```bash
"D:/ProgramData/anaconda3/envs/py312_cuda/python.exe" -c "import torch; print(torch.cuda.is_available())"
# 必须输出 True
```
---
## 📊 已解决的结构性问题
### 1. 知识点匹配错误(核心修复)
| 问题 | 根因 | 修复方案 | 状态 |
|------|------|----------|------|
| 匹配到导读区而非教学区 | 搜索词按顺序匹配,找到第一个就 break;评分只看密度 | 完整关键词+核心子词+相关词映射+数字归一化收集所有 segment;评分综合考量出现次数、文本量、讲解密度、孤立程度 | ✅ |
| 匹配到回顾区而非教学区 | 回顾区有更多相关 segment | 靠近作业时间惩罚 + 回顾性语言检测("刚才"、"刚刚"等) | ✅ |
| 匹配到"等下再说"而非实际教学 | 预告和实际教学无法区分 | 预告/讲解特征词检测;推迟语言检测;导读过滤(无讲解 + segment ≤ 2 → 跳过) | ✅ |
| 通用词误匹配(如"记号"匹配到无关内容) | 搜索词包含 2 字通用词 | 通用后缀过滤(记号、符号、音符等不单独匹配);相关词映射 | ✅ |
| 数字不匹配("十六分音符"≠"16分音符" | Whisper 将中文数字识别为阿拉伯数字 | 数字归一化匹配(中文数字↔阿拉伯数字互转) | ✅ |
| "掀起你的盖头来"只在导读出现 | 转录中未出现该歌曲名 | 导读过滤:无讲解特征 + segment ≤ 2 → 跳过 | ✅ |
### 2. 字幕同步问题
| 问题 | 根因 | 修复方案 | 状态 |
|------|------|----------|------|
| 字幕 1分18秒开始乱套 | clip3/clip4 重叠导致 JSON 内容重复,偏移计算用 config duration 而非实际 segments end | 偏移改用 JSON segments 实际最大 end 时间 | ✅ |
| 字幕滞后 2-3 秒 | 转录过滤包含跨边界片段,调整后的时间戳超出 clip 实际时长 | 限制时间戳在 clip 实际时长范围内 | ✅ |
| clip2(延音线)被跳过 | Whisper 把"延音"识别为"言音",内容验证失败 | 内容验证时先应用术语纠正(言音→延音、演音→延音等) | ✅ |
### 3. 作业片段匹配
| 问题 | 根因 | 修复方案 | 状态 |
|------|------|----------|------|
| 作业片段匹配不到 | 引导语列表不全 | 用"作业"词密度替代引导语匹配 | ✅ |
| 作业片段截断太早 | detect_gap_cutoff 对作业场景太敏感 | 间隔阈值从 15s 放宽到 30s;视频末尾前 2 分钟兜底 | ✅ |
| 作业结束不准确 | 无法识别口语化结束表达 | 模糊匹配 30+ 种口语表达("作业.*就这样"、"作业.*就这些"、"下课"等) | ✅ |
### 4. 去重逻辑优化
| 问题 | 根因 | 修复方案 | 状态 |
|------|------|----------|------|
| 多个知识点在同一区域被丢弃 | 去重逻辑只保留密度最高的,丢弃其他 | 调整边界让相邻知识点共存,而非丢弃 | ✅ |
### 5. GPU 资源管理
| 问题 | 根因 | 修复方案 | 状态 |
|------|------|----------|------|
| 僵尸进程占用 GPU 显存 | 多次启动转录进程,旧进程未释放 | 转录后 `del model` + `gc.collect()` + `torch.cuda.empty_cache()` | ✅ |
| `taskkill` 自杀命令 | 清理命令杀掉了当前进程 | 移除转录前的 taskkill,改为转录后显式释放模型 | ✅ |
---
## 🏗️ 知识点匹配架构
### 评分公式
```
score = (base_score + full_bonus) × text_bonus × isolation_penalty × teaching_bonus × review_penalty × defer_penalty × preview_penalty
```
| 因子 | 计算方式 | 作用 |
|------|---------|------|
| base_score | total_count × avg_rel | 相关 segment 数量 × 平均相关度 |
| full_bonus | full_count × 2.0 | 完整关键词出现次数奖励 |
| text_bonus | min(total_text_len / 30, 5.0) | 上下文文本量加成 |
| isolation_penalty | 1.0 / (1.0 + other_kw_count × 0.5) | 和其他知识点一起出现惩罚 |
| teaching_bonus | 1.0 + teaching_density × 2.0 | 讲解词占比加成 |
| review_penalty | max(0.1, time_to_homework / 300) | 靠近作业时间惩罚 |
| defer_penalty | max(0.1, 1.0 - defer_ratio × 2.0) | 推迟语言惩罚 |
| preview_penalty | max(0.1, 1.0 - preview_ratio × 2.0) | 预告语言惩罚 |
### 相关度评分
| 匹配类型 | 分数 | 示例 |
|---------|------|------|
| 完整关键词 | 3.0 | "附点音符" 匹配 "附点音符" |
| 数字归一化 | 2.5 | "十六分音符" 匹配 "16分音符" |
| 核心子词 | 2.0 | "双音的支撑" 匹配 "双音支撑" |
| 相关词映射 | 1.5 | "升降记号" 匹配 "升号"/"降号" |
| 前缀匹配 | 1.5 | "附点音符" 匹配 "附点" |
| 核心词 | 1.0 | 3-4 字核心词匹配 |
### 相关词映射
```python
related_terms = {
"升降记号": ["升号", "降号", "升记", "降记", "升降", "升半", "降半"],
"还原记号": ["还原"],
"附点音符": ["附点"],
"延音线": ["延音", "同音连线"],
"双音的支撑": ["双音", "支撑"],
"婚礼进行曲": ["婚礼"],
"掀起你的盖头来": ["盖头来", "盖头", "掀起"],
"十六分音符": ["16分", "十六分"],
"八分音符": ["8分", "八分"],
}
```
### 特征词检测
| 类型 | 关键词 |
|------|--------|
| 推迟语言 | "等下再说"、"等下讲"、"后面再说"、"稍后再说"、"先不说" |
| 预告语言 | "先说一下"、"先讲一下"、"首先说"、"我先说"、"提一下" |
| 讲解语言 | "因为"、"所以"、"就是"、"什么意思"、"为什么"、"怎么"、"比如说"、"大家看"、"弹"、"按"、"练" |
| 回顾语言 | "刚才"、"刚刚"、"今天学"、"今天讲"、"回顾"、"练习一下"、"复习" |
### 作业结束检测(模糊匹配 30+ 种表达)
| 类型 | 匹配模式 | 示例 |
|------|---------|------|
| 明确下课 | `下课``拜拜``再见` | "下课" |
| 作业完成 | `作业.*就这样``作业.*就这些``作业.*讲到这里` | "今天的作业就这样"、"关于作业就讲到这里" |
| 通用结束 | `就到这里``就这样了``说完了``没什么.*说的` | "就这些了"、"没什么要说的了" |
| 群发通知 | `发群``到时候.*发` | "到时候我发群里" |
---
## 📂 项目目录结构
```
projects/piano-lesson-highlights/
├── data/ # 原始输入数据
│ ├── lesson2/
│ │ ├── video.mp4
│ │ └── course.pptx
│ └── lesson3/
│ ├── video.mp4
│ └── course.pptx
└── cases/ # 每个案例的工作区
├── lesson2/
│ ├── config.yaml
│ ├── intermediates/
│ │ └── full_transcript.json
│ └── output/
│ ├── v1_final.mp4
│ └── subs/
└── lesson3/
├── config.yaml
├── intermediates/
│ └── full_transcript.json
└── output/
├── v1_final.mp4
└── subs/
```
---
## ✅ lesson2 验证结果
| # | 知识点 | 匹配位置 | 实际内容 | 状态 |
|---|--------|---------|---------|------|
| 1 | 附点音符 | 3126s (52min) | 实际教学区 | ✅ |
| 2 | 延音线 | 4040s (67min) | 实际教学区 | ✅ |
| 3 | 升降记号 | 4237s (70min) | 实际教学区 | ✅ |
| 4 | 还原记号 | 4682s (78min) | 实际教学区 | ✅ |
| 5 | 双音的支撑 | 5051s (84min) | 实际教学区 | ✅ |
| 6 | 婚礼进行曲 | 5345s (89min) | 实际教学区 | ✅ |
| 7 | 作业 | 6515s-6998s (482s) | 作业区 | ✅ |
**产出**
- v1_final.mp4: 679秒(11.3分钟),133.3 MB
- 380 条字幕(original/terms/ai 三个版本)
---
## ✅ lesson3 验证结果
| # | 知识点 | 匹配位置 | 实际内容 | 状态 |
|---|--------|---------|---------|------|
| 1 | 八分音符 | 2332s (38min) | 实际教学区 | ✅ |
| 2 | 十六分音符 | 2456s (40min) | 实际教学区(数字归一化匹配) | ✅ |
| 3 | 反复记号 | 3296s (54min) | 实际教学区 | ✅ |
| 4 | 高八度 | 3315s (55min) | 实际教学区 | ✅ |
| 5 | 低八度记号 | 3369s (56min) | 实际教学区 | ✅ |
| 6 | 作业 | 5272s-5981s (709s) | 作业区(下课 5951s + 30s | ✅ |
| ❌ | 掀起你的盖头来 | 已跳过 | 只在导读出现,无实际教学 | ✅ 正确跳过 |
**产出**
- v1_final.mp4: 867秒(14.5分钟),69.8 MB
- 417 条字幕(original/terms/ai 三个版本)
---
## ⏳ 待处理
| 项目 | 状态 | 说明 |
|------|------|------|
| 字幕 AI 纠错验证 | 待人工 | 老莫需要看 v1_ai.srt 的纠错效果 |
| 视频质量审核 | 待人工 | 老莫需要看 v1_final.mp4 确认标题卡、转场、字幕同步 |
---
## 💡 关键经验
1. **转录数据可复用**:同一节课的 `full_transcript.json` 可以直接复制使用,不需要重新转录
2. **GPU 是必须的**:CPU 转录 100 分钟视频会超时(100+ 分钟),GPU 约 20-30 分钟
3. **术语纠正很重要**Whisper 会把"附点"识别为"副点"、"延音"识别为"言音"等
4. **教学 vs 导读 vs 回顾**:需要综合多种特征才能准确区分,单一指标不可靠
5. **数字归一化**:Whisper 可能将中文数字识别为阿拉伯数字("十六"→"16"),需要双向匹配
6. **作业结束检测**:老师口语表达多样,必须用模糊匹配覆盖 30+ 种表达
7. **GPU 资源管理**:转录完成后必须显式释放模型,否则显存泄漏
@@ -0,0 +1,47 @@
# 钢琴课精华生成配置模板
# 复制此文件修改为你的项目配置
# 原始视频路径
video_src: "D:/path/to/your/video.mp4"
# 知识点列表(按顺序)
clips:
- title: "八分音符"
start: 820 # 开始时间(秒)
end: 845 # 结束时间(秒)
- title: "三音支撑复习"
start: 1330
end: 1355
- title: "附点音符"
start: 3120
end: 3150
- title: "同音连线"
start: 4120
end: 4150
- title: "双音支撑"
start: 5045
end: 5075
# 术语纠正库(错误:正确)
term_corrections:
"实质": "时值"
"负点": "附点"
"副点": "附点"
"黑剑": "黑键"
"言音": "延音"
"演音": "延音"
"深圳": "升音"
"月理": "乐理"
"阅历": "乐理"
"月里": "乐理"
"副典": "附点"
"婚姻": "延音"
# 可配置参数
fade_duration: 1 # 片段淡入淡出时长(秒)
title_duration: 3 # 标题显示时长(秒)
title_fontsize: 60 # 标题字号
title_color: "&HFFFF00" # 标题颜色(黄色)
subtitle_fontsize: 24 # 对白字幕字号
subtitle_color: "&HFFFFFF" # 对白字幕颜色(白色)
whisper_model: "medium" # Whisper转录模型 (tiny/base/small/medium/large)
@@ -0,0 +1,3 @@
# Example Reference
This is an example reference file. Delete if not needed.
@@ -0,0 +1,51 @@
# 音乐术语库模板
# 可根据实际课程扩展术语
## 基础乐理术语
| 正确写法 | 常见识别错误 |
|----------|--------------|
| 附点 | 负点,副点,副典 |
| 时值 | 实质,十指 |
| 延音 | 言音,演音,婚姻 |
| 黑键 | 黑剑,黑件 |
| 白键 | 白剑,白件 |
| 乐理 | 月理,阅历,月里 |
| 升音 | 深圳,声音 |
| 降音 | 基因,建议 |
| 音阶 | 银价,英杰 |
| 琶音 | 爬音,怕音 |
| 和弦 | 和弦,和县 |
| 八度 | 八度,八度 |
| 半音 | 半音,伴音 |
| 全音 | 全音,权益 |
| 节拍 | 节拍, jie拍 |
| 节奏 | 节奏,截肢 |
| 速度 | 速度,苏州 |
| 力度 | 力度,粒度 |
## 演奏技巧术语
| 正确写法 | 常见识别错误 |
|----------|--------------|
| 连奏 | 连走,连揍 |
| 断奏 | 短奏,断走 |
| 跳音 | 调音,条印 |
| 保持音 | 保证音,保护音 |
| 重音 | 中音,中音 |
| 轻音 | 清音,庆云 |
| 琶音 | 爬音,怕音 |
| 刮奏 | 刮走,刮走 |
| 轮指 | 轮子,伦指 |
| 颤音 | 抖音,掺音 |
## 课程相关术语
| 正确写法 | 常见识别错误 |
|----------|--------------|
| 一组排 | 一数排,一竖排 |
| 三音支撑 | 三音之城,三音支付 |
| 双音支撑 | 双音之城,双音支付 |
| 同音连线 | 同音字线,同一连线 |
| 附点音符 | 负点音符,副点音符 |
| 八分音符 | 8分音符,八分衣服 |
| 四分音符 | 4分音符,四分衣服 |
| 二分音符 | 2分音符,二分衣服 |
| 全音符 | 全衣服,全音符 |
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
字幕纠错词库
集中管理所有语音识别纠错规则,方便维护和扩展。
"""
# 直接替换表:错误词 -> 正确词
# 这些是已知的固定错误,直接替换即可
DIRECT_FIXES = {
# 休止相关
"羞耻": "休止",
"休指": "休止",
"修止": "休止",
"八分羞耻": "八分休止",
"四分羞耻": "四分休止",
"十六分羞耻": "十六分休止",
"二分羞耻": "二分休止",
"全羞耻": "全休止",
"分羞耻": "分休止",
# 附点相关
"负点": "附点",
"副点": "附点",
"付点": "附点",
"浮点": "附点",
# 时值相关
"实质": "时值",
"实值": "时值",
"失值": "时值",
# 延音相关
"演音": "延音",
"言音": "延音",
"盐音": "延音",
"延因": "延音",
# 乐理相关
"阅历": "乐理",
"月理": "乐理",
"乐里": "乐理",
"乐礼": "乐理",
# 音符相关
"音苻": "音符",
"音扶": "音符",
"因符": "音符",
# 调号相关
"调苻": "调号",
"调扶": "调号",
"吊号": "调号",
# 拍号相关
"拍苻": "拍符",
"拍扶": "拍符",
"排符": "拍符",
# 谱号相关
"谱苻": "谱号",
"谱扶": "谱号",
"普号": "谱号",
# 手位相关
"首位": "手位",
"守位": "手位",
"受位": "手位",
"手味": "手位",
# 指法相关
"只发": "指法",
"织法": "指法",
"纸法": "指法",
"直发": "指法",
# 抬指相关
"台指": "抬指",
"抬纸": "抬指",
"台纸": "抬指",
"太指": "抬指",
# 支撑相关
"只撑": "支撑",
"肢撑": "支撑",
"知撑": "支撑",
"直撑": "支撑",
# 反复相关
"反服": "反复",
"反副": "反复",
"翻复": "反复",
"反赴": "反复",
# 高八度相关
"搞八度": "高八度",
"搞八渡": "高八度",
"膏八度": "高八度",
# 低八度相关
"底八度": "低八度",
"抵八度": "低八度",
# 连音相关
"联音": "连音",
"连因": "连音",
"莲音": "连音",
# 跳音相关
"挑音": "跳音",
"跳因": "跳音",
"眺音": "跳音",
# 还原相关
"还原记好": "还原记号",
"缓原记号": "还原记号",
"环原记号": "还原记号",
# 节拍相关
"节牌": "节拍",
"结拍": "节拍",
"结牌": "节拍",
# 节奏相关
"节凑": "节奏",
"接奏": "节奏",
# 分手相关
"分首": "分手",
"分守": "分手",
"分收": "分手",
# 慢练相关
"漫练": "慢练",
"曼练": "慢练",
"慢炼": "慢练",
# 强弱相关
"强若": "强弱",
"强落": "强弱",
"墙弱": "强弱",
# 其他
"负其实": "附其实",
"负加": "附加",
"一数排": "一组排",
}
# 歌曲名称补全表:片段 -> 完整名称
SONG_NAME_FIXES = {
"盖头来": "《掀起你的盖头来》",
"掀起我的盖头来": "《掀起你的盖头来》",
"掀起盖头来": "《掀起你的盖头来》",
"小星星": "《小星星》",
"两只老虎": "《两只老虎》",
"欢乐颂": "《欢乐颂》",
"献给爱丽丝": "《献给爱丽丝》",
"月光": "《月光奏鸣曲》",
"梦中的婚礼": "《梦中的婚礼》",
"水边的阿狄丽娜": "《水边的阿狄丽娜》",
"土耳其进行曲": "《土耳其进行曲》",
"小步舞曲": "《小步舞曲》",
"爱的罗曼史": "《爱的罗曼史》",
}
# 语义异常词列表:这些词在钢琴教学语境中几乎不可能出现
ANOMALY_WORDS = [
"羞耻",
"尴尬",
"恶心",
"呕吐",
"死亡",
"杀人",
"犯罪",
"监狱",
"股票",
"基金",
"彩票",
"赌博",
"毒品",
"战争",
"武器",
"休指",
"修止",
]
# 音乐术语词库:用于异常词的候选替换
MUSIC_TERMS = {
"八分音符",
"十六分音符",
"四分音符",
"二分音符",
"全音符",
"休止",
"八分休止",
"十六分休止",
"四分休止",
"二分休止",
"全休止",
"附点",
"反复记号",
"反复",
"高八度",
"低八度",
"八度记号",
"还原记号",
"还原",
"连音线",
"延音线",
"延音",
"连音",
"跳音",
"调号",
"拍号",
"谱号",
"升降号",
"指法",
"支撑",
"手位",
"手腕",
"放松",
"慢练",
"分手",
"合手",
"视奏",
"节拍",
"节奏",
"强弱",
"力度",
"踏板",
"音程",
"抬指",
"落键",
"断奏",
"连奏",
"把位",
"双音",
"音阶",
"琶音",
"和弦",
"时值",
"语言节奏",
"《掀起你的盖头来》",
}
# 正则模式替换规则
ANOMALY_PATTERNS = [
{
"pattern": r"([一二三四五六七八九十百千万\d]+)分羞耻",
"replace": r"\1分休止",
"reason": "音乐教学中'羞耻'语义异常,结合'X分'上下文推断为'休止'",
},
{
"pattern": r"分羞耻",
"replace": "分休止",
"reason": "音乐教学中'分羞耻'语义异常,推断为'分休止'",
},
{
"pattern": r"([一二三四五六七八九十百千万\d]+)分休指",
"replace": r"\1分休止",
"reason": "音乐教学中'休指'语义异常,推断为'休止'",
},
{
"pattern": r"盖头来",
"replace": "《掀起你的盖头来》",
"reason": "'盖头来'是歌曲名片段,补全为完整歌曲名",
},
{
"pattern": r"掀起我的盖头来",
"replace": "《掀起你的盖头来》",
"reason": "歌曲名纠正",
},
]
@@ -0,0 +1,4 @@
#!/usr/bin/env python3
"""Example script - delete if not needed."""
print("Hello from skill!")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""
钢琴课精华视频生成主脚本
通用版本,支持配置化
GPU 资源管理:
- 转录前清理残留 Python 进程,释放 GPU 显存
- 转录完成后显式释放模型,避免显存泄漏
"""
import subprocess
import os
import json
import yaml
import gc
import torch
import argparse
import re
import zhconv
from pypinyin import pinyin, Style
from correction_dict import (
DIRECT_FIXES,
SONG_NAME_FIXES,
ANOMALY_WORDS,
MUSIC_TERMS,
ANOMALY_PATTERNS,
)
def get_pinyin(text):
"""获取文本的拼音(无声调)"""
return "".join([item[0] for item in pinyin(text, style=Style.NORMAL)])
def pinyin_similarity(word1, word2):
"""计算两个词的拼音相似度(考虑声母韵母近似)"""
py1 = get_pinyin(word1)
py2 = get_pinyin(word2)
if py1 == py2:
return 1.0
max_len = max(len(py1), len(py2))
if max_len == 0:
return 0
# 字符级编辑距离
common = sum(1 for c1, c2 in zip(py1, py2) if c1 == c2)
return common / max_len
def detect_anomalies_in_text(text, knowledge_terms=None):
"""
检测文本中的语义异常词
返回: list of (异常词, 建议替换词, 原因)
"""
if knowledge_terms is None:
knowledge_terms = set()
anomalies = []
# 第一步:基于正则模式的异常检测
for rule in ANOMALY_PATTERNS:
matches = re.findall(rule["pattern"], text)
if matches:
for match in matches:
# 获取完整匹配
full_match = re.search(rule["pattern"], text)
if full_match:
original = full_match.group(0)
replacement = full_match.expand(rule["replace"])
anomalies.append((original, replacement, rule["reason"]))
# 第二步:独立异常词检测 + 上下文推断
for anomaly in ANOMALY_WORDS:
if anomaly in text:
# 检查异常词周围的上下文
idx = text.find(anomaly)
context_start = max(0, idx - 10)
context_end = min(len(text), idx + len(anomaly) + 10)
context = text[context_start:context_end]
# 检查上下文中是否有音乐术语
has_music_context = any(term in context for term in MUSIC_TERMS)
has_music_context = has_music_context or any(
term in context for term in knowledge_terms
)
# 检查前后是否有数字+分的模式(如"八分"、"四分"、"十六分"
has_note_context = bool(
re.search(r"[一二三四五六七八九十百千万\d]+分", context)
)
if has_music_context or has_note_context:
# 在音乐术语词库中查找拼音近似的词
anomaly_py = get_pinyin(anomaly)
best_match = None
best_score = 0
for term in MUSIC_TERMS:
score = pinyin_similarity(anomaly, term)
if score > best_score and score >= 0.5:
best_score = score
best_match = term
# 也检查知识点列表
for term in knowledge_terms:
score = pinyin_similarity(anomaly, term)
if score > best_score and score >= 0.5:
best_score = score
best_match = term
if best_match:
reason = (
f"'{anomaly}'在音乐教学语境中语义异常,"
f"上下文包含音乐术语,"
f"拼音相似度{best_score:.2f},推断为'{best_match}'"
)
anomalies.append((anomaly, best_match, reason))
return anomalies
def ai_context_correct(text, clip_title="", all_clips=None):
"""
AI上下文纠错:基于语义异常检测 + 上下文推断 + 拼音相似度
工作流程:
1. 直接替换已知的固定错误(安全网)
2. 检测语义异常(与音乐教学无关的词、语法不通的词)
3. 分析异常词的上下文(前后10个字符)
4. 结合知识点列表和音乐术语词库,用拼音相似度匹配最合理的替换
5. 应用替换
"""
if all_clips is None:
all_clips = []
# 第零步:直接替换已知的固定错误(安全网,确保一定生效)
direct_fixes = {
"羞耻": "休止",
"休指": "休止",
"修止": "休止",
"八分羞耻": "八分休止",
"四分羞耻": "四分休止",
"十六分羞耻": "十六分休止",
"二分羞耻": "二分休止",
"全羞耻": "全休止",
"分羞耻": "分休止",
"盖头来": "《掀起你的盖头来》",
"掀起我的盖头来": "《掀起你的盖头来》",
}
for wrong, correct in direct_fixes.items():
text = text.replace(wrong, correct)
# 收集所有知识点名称
knowledge_terms = set()
for clip in all_clips:
title = clip.get("title", "")
title = re.sub(r"^知识点\d+[:]\s*", "", title)
if title:
knowledge_terms.add(title)
for kw in MUSIC_TERMS:
if kw in title:
knowledge_terms.add(kw)
# 第一步:术语库直接替换(已知的固定错误)
term_corrections = {
"负点": "附点",
"副点": "附点",
"付点": "附点",
"实质": "时值",
"实值": "时值",
"演音": "延音",
"言音": "延音",
"阅历": "乐理",
"月理": "乐理",
"音苻": "音符",
"调苻": "调号",
"拍苻": "拍符",
"谱苻": "谱号",
"首位": "手位",
"守位": "手位",
"只发": "指法",
"织法": "指法",
"台指": "抬指",
"抬纸": "抬指",
"只撑": "支撑",
"肢撑": "支撑",
"反服": "反复",
"反副": "反复",
"搞八度": "高八度",
"搞八渡": "高八度",
"底八度": "低八度",
"联音": "连音",
"连因": "连音",
"挑音": "跳音",
"还原记好": "还原记号",
"缓原记号": "还原记号",
"节牌": "节拍",
"节凑": "节奏",
"分首": "分手",
"分守": "分手",
"漫练": "慢练",
"曼练": "慢练",
"强若": "强弱",
"强落": "强弱",
"八分音苻": "八分音符",
"十六分音苻": "十六分音符",
"负其实": "附其实",
"负加": "附加",
"一数排": "一组排",
}
for wrong, correct in term_corrections.items():
text = text.replace(wrong, correct)
# 第二步:语义异常检测 + 上下文推断
anomalies = detect_anomalies_in_text(text, knowledge_terms)
for original, replacement, reason in anomalies:
if original in text:
text = text.replace(original, replacement)
# 第三步:歌曲名称补全
song_names = {
"盖头来": "《掀起你的盖头来》",
"掀起我的盖头来": "《掀起你的盖头来》",
"小星星": "《小星星》",
"两只老虎": "《两只老虎》",
"欢乐颂": "《欢乐颂》",
"献给爱丽丝": "《献给爱丽丝》",
"土耳其进行曲": "《土耳其进行曲》",
"小步舞曲": "《小步舞曲》",
}
for fragment, full_name in song_names.items():
if fragment in text and full_name not in text:
text = text.replace(fragment, full_name)
return text
def load_config(config_path):
"""加载配置文件"""
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def run_cmd(cmd, capture=True):
"""执行命令"""
print(f"[CMD] {cmd[:100]}...")
if capture:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="ignore",
)
if result.returncode != 0:
print(f"[ERR] {result.stderr[:200] if result.stderr else 'unknown'}")
return result.returncode == 0
return os.system(cmd) == 0
def to_srt_time(t):
"""秒转SRT时间格式"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def extract_clips(config, output_dir):
"""提取知识点片段"""
print("\n[步骤1] 提取视频片段...")
clip_paths = []
inter_dir = os.path.join(output_dir, "intermediates")
os.makedirs(inter_dir, exist_ok=True)
# 铁律:检测并修复重叠片段
clips = config["clips"]
filtered_clips = []
for i, clip in enumerate(clips):
new_clip = dict(clip) # 复制一份
if filtered_clips and new_clip["start"] < filtered_clips[-1]["end"]:
# 重叠:调整前一个片段的end时间
old_end = filtered_clips[-1]["end"]
filtered_clips[-1]["end"] = new_clip["start"]
print(
f" [FIX] 重叠修复: {filtered_clips[-1]['title']} end {old_end}s -> {new_clip['start']}s"
)
filtered_clips.append(new_clip)
# 移除时长<=0的片段(重叠修复后可能出现)
valid_clips = []
for clip in filtered_clips:
if clip["end"] - clip["start"] > 0:
valid_clips.append(clip)
else:
print(f" [SKIP] {clip['title']} 时长为0,跳过")
for i, clip in enumerate(valid_clips):
idx = i + 1
start = clip["start"]
end = clip["end"]
duration = end - start
if duration <= 0:
print(f" [SKIP] {clip['title']} 时长为0,跳过")
continue
out_path = os.path.join(inter_dir, f"clip{idx}.mp4")
fade_dur = config.get("fade_duration", 1)
cmd = f'ffmpeg -y -ss {start} -i "{config["video_src"]}" -t {duration} -c:v libx264 -preset fast -crf 20 -c:a aac -y "{out_path}"'
if run_cmd(cmd):
# 添加淡入淡出
faded_path = os.path.join(inter_dir, f"clip{idx}_fade.mp4")
cmd = f'ffmpeg -y -i "{out_path}" -vf "fade=t=in:st=0:d={fade_dur},fade=t=out:st={duration - fade_dur}:d={fade_dur}" -af "afade=t=in:st=0:d={fade_dur},afade=t=out:st={duration - fade_dur}:d={fade_dur}" -c:v libx264 -crf 20 -c:a aac -y "{faded_path}"'
run_cmd(cmd)
clip_paths.append(faded_path)
# 移除标题中的emoji避免终端编码错误
clean_title = clip["title"].encode("gbk", errors="ignore").decode("gbk")
print(f" clip{idx}: {clean_title} ({duration}s) OK")
else:
clean_title = clip["title"].encode("gbk", errors="ignore").decode("gbk")
print(f" clip{idx}: {clean_title} FAILED")
return clip_paths, valid_clips
def transcribe_clips(clip_paths, config, output_dir):
"""转录片段(使用本地模型,GPU优先,CPU保底)"""
print("\n[步骤2] 转录片段...")
json_paths = []
video_params = config.get("video_params", {})
model = video_params.get("whisper_model", "large")
model_path = video_params.get(
"whisper_model_path", "D:/AI/LM-Models/faster-whisper/large-v3"
)
inter_dir = os.path.join(output_dir, "intermediates")
# 尝试加载完整转录文件(由extract_terms_from_ppt.py生成)
# 可能在output/intermediates/或上一级的intermediates/
full_transcript_path = os.path.join(inter_dir, "full_transcript.json")
if not os.path.exists(full_transcript_path):
parent_inter_dir = os.path.join(os.path.dirname(output_dir), "intermediates")
full_transcript_path = os.path.join(parent_inter_dir, "full_transcript.json")
full_transcript = None
if os.path.exists(full_transcript_path):
with open(full_transcript_path, "r", encoding="utf-8") as f:
full_transcript = json.load(f)
print(
f" [INFO] 加载完整转录文件: {len(full_transcript)} 个片段 ({full_transcript_path})"
)
use_fast_whisper = video_params.get("use_fast_whisper", True)
if use_fast_whisper:
from faster_whisper import WhisperModel
# 先尝试GPU,不行就用CPU,保证能运行
model = None
try:
model = WhisperModel(model_path, device="cuda", compute_type="float16")
print(" [INFO] 使用CUDA GPU加速转录")
except Exception as e:
print(f" [WARNING] GPU不可用,使用CPU转录: {str(e)[:50]}")
model = WhisperModel(model_path, device="cpu", compute_type="int8")
for i, (path, clip) in enumerate(zip(clip_paths, config["clips"]), 1):
print(f" 转录 clip{i} ({clip['title']})...")
# 如果有完整转录,直接使用对应时间段的内容
if full_transcript:
clip_start = clip["start"]
clip_end = clip["end"]
# 放宽时间匹配:只要片段与 clip 有重叠就包含(而非严格要求 start 在范围内)
# 原因:Whisper 的一句话可能跨越片段边界,过严过滤会导致内容缺失
clip_segments = [
seg
for seg in full_transcript
if seg["end"] > clip_start and seg["start"] < clip_end
]
if clip_segments:
# 调整时间戳为相对于片段开始,并限制在 clip 实际时长内
clip_duration = clip_end - clip_start
result = {"text": "", "segments": []}
for seg in clip_segments:
adj_start = max(0, seg["start"] - clip_start)
adj_end = seg["end"] - clip_start
# 限制在 clip 实际时长范围内
if adj_start >= clip_duration:
continue
adj_end = min(adj_end, clip_duration)
if adj_end <= adj_start:
adj_end = adj_start + 0.1
result["text"] += seg["text"]
result["segments"].append(
{
"start": adj_start,
"end": adj_end,
"text": seg["text"],
}
)
# 内容验证 - 使用多种关键词形式
title = clip.get("title", "")
clean_title = re.sub(r"^知识点\d+[:]\s*", "", title)
clean_title = re.sub(r"[《》]", "", clean_title)
keywords = [clean_title]
# 去掉"的"、"与"、"和"等连接词
shorter = re.sub(r"[的与和及]", "", clean_title)
if shorter != clean_title:
keywords.append(shorter)
# 提取所有2-4字符的中文词组(从短到长)
core_words = []
for length in [2, 3, 4]:
words = re.findall(
r"[\u4e00-\u9fff]{" + str(length) + r"}", clean_title
)
core_words.extend(words)
keywords.extend(core_words)
keywords = list(dict.fromkeys(keywords))
# 对转录文本应用术语纠正后再验证(Whisper 可能把"延音"识别为"演音"/"言音"等)
term_corrections = dict(config.get("term_corrections", {}))
# 补充内置纠正规则
term_corrections.update(
{
"言音": "延音",
"演音": "延音",
"副点": "附点",
"负点": "附点",
"付点": "附点",
}
)
transcript_text = result["text"]
for wrong, correct in term_corrections.items():
transcript_text = transcript_text.replace(wrong, correct)
match_count = sum(1 for kw in keywords if kw in transcript_text)
matched = [kw for kw in keywords if kw in transcript_text]
if keywords and match_count == 0:
print(
f" [SKIP] 内容不匹配: 标题'{clean_title}',关键词{keywords},转录中未找到"
)
print(f" 转录内容: {transcript_text[:100]}...")
json_paths.append(None)
continue
json_path = os.path.join(inter_dir, f"clip{i}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
json_paths.append(json_path)
print(
f" clip{i}完成 ({match_count}/{len(keywords)} 关键词匹配: {matched})"
)
continue
# 如果没有完整转录,则重新转录
segments, info = model.transcribe(path, language="zh", beam_size=5)
result = {"text": "", "segments": []}
for seg in segments:
result["text"] += seg.text
result["segments"].append(
{"start": seg.start, "end": seg.end, "text": seg.text}
)
# 内容验证
title = clip.get("title", "")
clean_title = re.sub(r"^知识点\d+[:]\s*", "", title)
clean_title = re.sub(r"[《》]", "", clean_title)
keywords = [clean_title]
if len(clean_title) > 6:
for length in [6, 5, 4, 3]:
if len(clean_title) >= length:
keywords.append(clean_title[-length:])
keywords = list(dict.fromkeys(keywords))
transcript_text = result["text"]
match_count = sum(1 for kw in keywords if kw in transcript_text)
matched = [kw for kw in keywords if kw in transcript_text]
if keywords and match_count == 0:
print(
f" [SKIP] 内容不匹配: 标题'{clean_title}',关键词{keywords},转录中未找到"
)
print(f" 转录内容: {transcript_text[:100]}...")
json_paths.append(None)
continue
json_path = os.path.join(inter_dir, f"clip{i}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
json_paths.append(json_path)
print(
f" clip{i}完成 ({match_count}/{len(keywords)} 关键词匹配: {matched})"
)
# 释放 GPU 资源
print(" [GPU] 释放模型资源...")
if model is not None:
del model
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
print(" [GPU] 资源已释放")
return json_paths
def generate_subtitles(clip_paths, json_paths, config, output_dir):
"""生成三级字幕"""
print("\n[步骤3] 生成字幕...")
subs_dir = os.path.join(output_dir, "subs")
os.makedirs(subs_dir, exist_ok=True)
# 计算偏移:用 JSON 中 segments 的实际最大 end 时间,而非 config 中的 duration
# 原因:放宽的转录过滤可能包含跨边界的片段,实际时长可能略大于 config duration
offsets = []
current = 0
valid_clips = []
for i, (clip, jp) in enumerate(zip(config["clips"], json_paths)):
if jp and os.path.exists(jp):
offsets.append(current)
valid_clips.append(clip)
# 用 JSON 中 segments 的实际最大 end 作为偏移增量
with open(jp, "r", encoding="utf-8") as f:
data = json.load(f)
segs = data.get("segments", [])
if segs:
actual_duration = max(s["end"] for s in segs)
else:
actual_duration = clip["end"] - clip["start"]
current += actual_duration
else:
print(f" [SKIP] 字幕跳过: {clip['title']} (内容不匹配)")
term_corrections = config.get("term_corrections", {})
# 生成三个版本
for version in ["original", "terms", "ai"]:
srt_lines = []
sub_idx = 1
# 标题
title_dur = config.get("title_duration", 3)
for i, clip in enumerate(valid_clips):
offset = offsets[i]
srt_lines.append(f"{sub_idx}")
srt_lines.append(
f"{to_srt_time(offset)} --> {to_srt_time(min(offset + title_dur, offset + 25))}"
)
srt_lines.append(clip["title"])
srt_lines.append("")
sub_idx += 1
# 对白
for i, clip in enumerate(valid_clips):
json_path = json_paths[i]
if not json_path or not os.path.exists(json_path):
continue
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
for seg in data.get("segments", []):
text = seg["text"].strip()
if not text:
continue
# 第一步:繁体转简体(必须在所有纠正之前,确保后续处理都是简体)
text = zhconv.convert(text, "zh-cn")
# 纠正处理
if version == "terms" or version == "ai":
for wrong, correct in term_corrections.items():
text = text.replace(wrong, correct)
# AI上下文纠正:基于语义异常检测 + 同音推断 + 知识点上下文
if version == "ai":
# 第一步:术语库纠正
for wrong, correct in term_corrections.items():
text = text.replace(wrong, correct)
# 第二步:直接替换已知错误(安全网,确保一定生效)
direct_fixes = {
"羞耻": "休止",
"休指": "休止",
"修止": "休止",
"八分羞耻": "八分休止",
"四分羞耻": "四分休止",
"十六分羞耻": "十六分休止",
"二分羞耻": "二分休止",
"全羞耻": "全休止",
"分羞耻": "分休止",
"盖头来": "《掀起你的盖头来》",
"掀起我的盖头来": "《掀起你的盖头来》",
"负点": "附点",
"副点": "附点",
"付点": "附点",
"实质": "时值",
"演音": "延音",
"言音": "延音",
"阅历": "乐理",
"月理": "乐理",
"音苻": "音符",
"调苻": "调号",
"拍苻": "拍符",
"谱苻": "谱号",
"首位": "手位",
"守位": "手位",
"只发": "指法",
"织法": "指法",
"台指": "抬指",
"抬纸": "抬指",
"只撑": "支撑",
"肢撑": "支撑",
"反服": "反复",
"反副": "反复",
"搞八度": "高八度",
"搞八渡": "高八度",
"底八度": "低八度",
"联音": "连音",
"连因": "连音",
"挑音": "跳音",
"还原记好": "还原记号",
"缓原记号": "还原记号",
"节牌": "节拍",
"节凑": "节奏",
"分首": "分手",
"分守": "分手",
"漫练": "慢练",
"曼练": "慢练",
"强若": "强弱",
"强落": "强弱",
"负其实": "附其实",
"负加": "附加",
"一数排": "一组排",
}
for wrong, correct in direct_fixes.items():
text = text.replace(wrong, correct)
# 第三步:语义异常检测与同音修正
original_text = text
text = ai_context_correct(
text, clip.get("title", ""), config.get("clips", [])
)
if original_text != text:
print(f' [AI纠正] "{original_text}" -> "{text}"')
abs_start = offsets[i] + seg["start"]
abs_end = offsets[i] + seg["end"]
srt_lines.append(f"{sub_idx}")
srt_lines.append(f"{to_srt_time(abs_start)} --> {to_srt_time(abs_end)}")
srt_lines.append(text)
srt_lines.append("")
sub_idx += 1
# 保存
out_path = os.path.join(subs_dir, f"v1_{version}.srt")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(srt_lines))
print(f" 生成v1_{version}.srt: {sub_idx - 1}")
return os.path.join(subs_dir, "v1_ai.srt")
def merge_and_burn(clip_paths, subtitle_path, config, output_dir):
"""合并片段、添加标题卡并烧录字幕"""
print("\n[步骤4] 合并片段、添加标题卡并烧录字幕...")
# 合并片段(只合并内容匹配的片段)
inter_dir = os.path.join(output_dir, "intermediates")
list_path = os.path.join(inter_dir, "concat_list.txt")
with open(list_path, "w", encoding="utf-8") as f:
for i, p in enumerate(clip_paths):
# 跳过内容不匹配的片段
json_path = os.path.join(inter_dir, f"clip{i + 1}.json")
if json_path and os.path.exists(json_path):
f.write(f"file '{p}'\n")
concat_path = os.path.join(inter_dir, "concated.mp4")
cmd = f'ffmpeg -y -f concat -safe 0 -i "{list_path}" -c copy -y "{concat_path}"'
run_cmd(cmd)
# 烧录字幕 - Windows路径需要转义
sub_path_fixed = subtitle_path.replace("\\", "/").replace(":", "\\\\:")
title_style = f"FontSize={config.get('title_fontsize', 60)},PrimaryColour={config.get('title_color', '&HFFFF00')},Bold=1,MarginV=200"
sub_style = f"FontSize={config.get('subtitle_fontsize', 24)},PrimaryColour={config.get('subtitle_color', '&HFFFFFF')},OutlineColour=&H000000,BorderStyle=3,Outline=1,MarginV=30"
# 构建标题卡滤镜(每个知识点开头显示3秒黄色大字居中)
# 重要:标题偏移量必须基于实际提取的片段时长,且只使用内容匹配的片段
title_filters = []
current_offset = 0
for i, clip_path in enumerate(clip_paths):
# 跳过内容不匹配的片段
json_path = os.path.join(inter_dir, f"clip{i + 1}.json")
if not json_path or not os.path.exists(json_path):
continue
clip = config["clips"][i]
title_text = clip["title"]
# 去掉"知识点X"前缀
title_text = re.sub(r"^知识点\d+[:]\s*", "", title_text)
# 转义特殊字符
title_text_escaped = title_text.replace("'", "\\'").replace(":", "\\:")
# 获取实际片段时长
result = subprocess.run(
f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "{clip_path}"',
shell=True,
capture_output=True,
text=True,
)
try:
actual_duration = float(result.stdout.strip())
except:
actual_duration = clip["end"] - clip["start"]
title_dur = config.get("title_duration", 3)
filter_str = f"drawtext=text='{title_text_escaped}':fontfile='C\\:/Windows/Fonts/msyh.ttc':fontsize={config.get('title_fontsize', 90)}:fontcolor=yellow:x=(w-text_w)/2:y=(h-text_h)/2:enable='between(t,{current_offset},{current_offset + min(title_dur, actual_duration)})':borderw=4:bordercolor=black"
title_filters.append(filter_str)
current_offset += actual_duration
# 合并标题卡和字幕滤镜
all_filters = title_filters + [
f"subtitles={sub_path_fixed}:force_style='{sub_style}'"
]
vf_str = ",".join(all_filters)
# 获取下一个版本号
version = 1
while os.path.exists(os.path.join(output_dir, f"v{version}_final.mp4")):
version += 1
final_path = os.path.join(output_dir, f"v{version}_final.mp4")
cmd = f'ffmpeg -y -i "{concat_path}" -vf "{vf_str}" -c:v libx264 -crf 20 -c:a aac -y "{final_path}"'
run_cmd(cmd)
print(f"\n完成!输出: {final_path}")
return final_path
def main():
parser = argparse.ArgumentParser(description="钢琴课精华视频生成工具")
parser.add_argument("--config", required=True, help="配置文件路径")
parser.add_argument("--output", default=None, help="输出目录")
args = parser.parse_args()
config = load_config(args.config)
# Use config's output_dir if --output not specified
output_dir = args.output or config.get("output_dir", "./output")
os.makedirs(output_dir, exist_ok=True)
clip_paths, filtered_clips = extract_clips(config, output_dir)
# Update config with filtered clips (remove overlapping ones)
config["clips"] = filtered_clips
json_paths = transcribe_clips(clip_paths, config, output_dir)
subtitle_path = generate_subtitles(clip_paths, json_paths, config, output_dir)
final_path = merge_and_burn(clip_paths, subtitle_path, config, output_dir)
print(f"\n=== 生成完成 ===")
print(f"视频文件: {final_path}")
print(f"字幕文件: {os.path.join(output_dir, 'subs/')}")
if __name__ == "__main__":
main()