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:
@@ -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` | Miniconda(base) | 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 的环境**。
|
||||
|
||||
### 问题 1:Python 3.8 环境无法安装 faster-whisper
|
||||
|
||||
`matanyone` 环境(Python 3.8.20)有 GPU PyTorch,但 `tokenizers` 包需要 Python >= 3.9,导致无法安装 faster-whisper。
|
||||
|
||||
**解决方案**:创建 Python 3.12 环境(见上文)。
|
||||
|
||||
### 问题 2:conda 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文件重新生成
|
||||
|
||||
### 时间戳不对怎么办?
|
||||
检查配置文件中的知识点开始/结束时间是否正确,确保时间是视频中的实际秒数。
|
||||
|
||||
### 字幕不同步怎么办?
|
||||
确保转录和切分使用的是同一个原始视频,没有经过二次编辑。
|
||||
@@ -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.12,3.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`
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user