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,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()