From 8bce4ac23b25a8b77089b16230763629aef2c0c9 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 7 Feb 2026 01:57:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=AD=97=E5=B9=95=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 subtitle_generator.py 核心模块 - AudioDownloader: 使用 yt-dlp 下载视频音频 - WhisperTranscriber: Whisper 语音识别器 - SubtitleFormatter: SRT 格式化器 - SubtitleCache: 缓存管理器 - SubtitleGenerator: 统一接口 - 集成到 pipeline.py - 添加 _ensure_subtitle() 方法 - 自动检测无字幕视频并生成字幕 - 支持缓存机制避免重复处理 - 更新配置文件 - config.yaml.example 添加字幕配置项 - requirements.txt 添加依赖包 - 文档更新 - SKILL.md 添加字幕生成说明 - SUBTITLE_FEATURE.md 详细功能文档 - 测试脚本 - test_subtitle_generator.py 完整测试套件 特性: - 本地处理,数据不上传,隐私安全 - 支持中英文识别及自动检测 - 多种模型可选(tiny/base/small/medium/large) - 智能缓存,提升处理速度 - 完善的错误处理和日志输出 --- skills/obsidian-kb/SKILL.md | 30 ++ skills/obsidian-kb/SUBTITLE_FEATURE.md | 373 +++++++++++++ skills/obsidian-kb/config.yaml.example | 51 ++ skills/obsidian-kb/requirements.txt | 7 + skills/obsidian-kb/src/pipeline.py | 275 ++++++---- skills/obsidian-kb/subtitle_generator.py | 493 ++++++++++++++++++ skills/obsidian-kb/test_subtitle_generator.py | 245 +++++++++ 7 files changed, 1382 insertions(+), 92 deletions(-) create mode 100644 skills/obsidian-kb/SUBTITLE_FEATURE.md create mode 100644 skills/obsidian-kb/config.yaml.example create mode 100644 skills/obsidian-kb/subtitle_generator.py create mode 100644 skills/obsidian-kb/test_subtitle_generator.py diff --git a/skills/obsidian-kb/SKILL.md b/skills/obsidian-kb/SKILL.md index 4074d64..c3be7f2 100644 --- a/skills/obsidian-kb/SKILL.md +++ b/skills/obsidian-kb/SKILL.md @@ -135,6 +135,28 @@ https://www.youtube.com/watch?v=dQw4w9WgXcQ https://zhuanlan.zhihu.com/p/123456789 ``` +### 启用字幕生成(可选) + +如果视频没有字幕,可以启用本地字幕生成功能: + +```bash +# 1. 安装依赖 +pip install openai-whisper yt-dlp torch + +# 2. 启用配置(编辑 config.yaml) +subtitle: + enabled: true + model: base # 推荐使用 base 模型 +``` + +**说明:** +- 使用 Whisper 本地模型,数据不上传,隐私安全 +- 首次使用会自动下载模型文件(~74MB) +- 10分钟视频约需 2-5 分钟处理时间(取决于硬件) +- 生成的字幕会自动缓存,重复使用无需重新生成 + +**详细文档:** 见 `SUBTITLE_FEATURE.md` + ### 方式2: 使用命令 显式调用命令添加内容: @@ -322,6 +344,14 @@ chunking: max_chunk_duration_seconds: 1800 # 最大分段时长 long_video_threshold: 1800 # 长视频阈值 (30分钟) +# 字幕生成配置(新增) +subtitle: + enabled: false # 是否启用自动生成字幕 + model: base # Whisper 模型大小: tiny/base/small/medium/large + language: null # 语言代码: zh/en/null(自动检测) + cache_dir: cache/subtitles # 字幕缓存目录 + audio_cache_dir: cache/audio # 音频缓存目录 + # 数据源配置 sources: bilibili: diff --git a/skills/obsidian-kb/SUBTITLE_FEATURE.md b/skills/obsidian-kb/SUBTITLE_FEATURE.md new file mode 100644 index 0000000..ab80616 --- /dev/null +++ b/skills/obsidian-kb/SUBTITLE_FEATURE.md @@ -0,0 +1,373 @@ +# Obsidian KB - 本地字幕生成功能 + +## 📖 功能概述 + +为无字幕视频自动生成字幕,使 AI 分析功能不受字幕限制,覆盖更多视频内容。 + +### 核心特性 + +- ✅ **本地处理**: 使用 Whisper 本地模型,数据不上传,隐私安全 +- ✅ **完全免费**: 无 API 费用,仅需一次性下载模型 +- ✅ **缓存机制**: 避免重复生成,提升处理速度 +- ✅ **多语言支持**: 支持中英文识别及自动检测 +- ✅ **灵活配置**: 可选择不同大小的模型平衡速度与准确度 + +--- + +## 🏗️ 架构设计 + +### 核心模块 + +``` +subtitle_generator.py +├── AudioDownloader # 音频下载器 (yt-dlp) +├── WhisperTranscriber # Whisper 识别器 +├── SubtitleFormatter # SRT 格式化器 +├── SubtitleCache # 缓存管理器 +└── SubtitleGenerator # 统一接口 +``` + +### 工作流程 + +``` +用户请求视频处理 + ↓ +检查是否有字幕 + ↓ [有字幕] +使用现有字幕 + ↓ [无字幕 + 功能已启用] +下载音频 (yt-dlp) + ↓ +语音识别 (Whisper) + ↓ +转换为 SRT 格式 + ↓ +保存到缓存 + ↓ +继续 AI 分析 +``` + +--- + +## 🚀 快速开始 + +### 1. 安装依赖 + +```bash +cd skills/obsidian-kb + +# 激活虚拟环境(如果使用) +source venv/bin/activate # Linux/Mac +# 或 +./venv/Scripts/activate # Windows + +# 安装依赖 +pip install openai-whisper yt-dlp torch +``` + +### 2. 系统依赖 + +确保系统已安装 `ffmpeg`: + +```bash +# Ubuntu/Debian +sudo apt install ffmpeg + +# macOS +brew install ffmpeg + +# Windows +# 从 https://ffmpeg.org/ 下载并安装 +``` + +验证安装: +```bash +ffmpeg -version +``` + +### 3. 配置 + +编辑 `config.yaml`: + +```yaml +# 字幕生成配置 +subtitle: + enabled: true # 启用自动生成 + model: base # Whisper 模型大小 + language: null # 语言: zh/en/null(自动检测) + cache_dir: cache/subtitles # 字幕缓存目录 + audio_cache_dir: cache/audio # 音频缓存目录 +``` + +### 4. 使用 + +功能启用后,处理无字幕视频时会自动生成字幕: + +```python +from src.pipeline import ProcessingPipeline +from src.models import Content + +pipeline = ProcessingPipeline() +content = Content(url="https://www.bilibili.com/video/BVxxxxx") + +# 处理时会自动生成字幕(如果需要) +pipeline.process_full(content) +``` + +--- + +## 📊 模型选择 + +Whisper 提供多种大小的模型,可根据需求选择: + +| 模型 | 大小 | 10分钟视频处理时间 | 准确度 | 推荐场景 | +|------|------|-------------------|--------|----------| +| tiny | ~39MB | ~30秒 | ⭐⭐ | 快速测试、批量处理 | +| **base** | **~74MB** | **~2分钟** | **⭐⭐⭐⭐** | **日常使用(推荐)** | +| small | ~244MB | ~5分钟 | ⭐⭐⭐⭐⭐ | 高质量需求 | +| medium | ~769MB | ~10分钟 | ⭐⭐⭐⭐⭐ | 专业用途 | +| large | ~1550MB | ~15分钟+ | ⭐⭐⭐⭐⭐ | 最高准确度 | + +### 推荐配置 + +- **默认**: `base` - 速度与准确度的最佳平衡 +- **低配机器**: `tiny` - 最小资源占用 +- **高质量需求**: `small` - 更好的识别效果 +- **有 GPU**: `medium` / `large` - 充分利用硬件加速 + +--- + +## 🎯 使用场景 + +### 1. 无字幕视频 + +B站、YouTube 等平台的部分视频没有提供字幕,启用此功能后可以自动生成。 + +### 2. 批量处理 + +处理多个视频时,缓存机制会显著提升速度: + +```python +# 第一次处理:下载 + 识别(约 2-5 分钟) +pipeline.process_full(content1) + +# 第二次处理相同视频:直接读取缓存(约 1 秒) +pipeline.process_full(content1) +``` + +### 3. 多语言内容 + +支持中英文混合内容的自动识别: + +```yaml +subtitle: + language: null # 自动检测语言 +``` + +或指定语言: + +```yaml +subtitle: + language: zh # 强制使用中文 +``` + +--- + +## 🧪 测试 + +运行测试脚本验证功能: + +```bash +cd skills/obsidian-kb +python test_subtitle_generator.py +``` + +测试覆盖: +- ✅ 音频下载 +- ✅ Whisper 识别 +- ✅ SRT 格式化 +- ✅ 缓存机制 +- ✅ 完整工作流程 + +--- + +## 📦 API 文档 + +### SubtitleGenerator + +主要接口类,整合所有功能。 + +```python +from subtitle_generator import SubtitleGenerator + +generator = SubtitleGenerator( + model_size='base', # 模型大小 + cache_dir='cache/subtitles' # 缓存目录 +) + +# 生成字幕 +srt_text = generator.generate( + url='https://www.bilibili.com/video/BVxxxxx', + content_id='unique_id', + language='zh', # 可选:指定语言 + use_cache=True # 可选:使用缓存 +) +``` + +### 便捷函数 + +快速生成字幕的便捷函数: + +```python +from subtitle_generator import generate_subtitle + +srt_text = generate_subtitle( + url='https://www.bilibili.com/video/BVxxxxx', + content_id='unique_id', + model='base', + language='zh' +) +``` + +--- + +## 🔧 高级配置 + +### GPU 加速 + +如果系统有 NVIDIA GPU 并安装了 CUDA,Whisper 会自动使用 GPU 加速: + +```python +import torch +print(f"GPU 可用: {torch.cuda.is_available()}") +``` + +使用 GPU 时,处理速度可提升 5-10 倍。 + +### 清理缓存 + +手动清理字幕缓存: + +```python +from subtitle_generator import SubtitleCache + +cache = SubtitleCache() +cache.clear() +``` + +或直接删除目录: + +```bash +rm -rf cache/subtitles/* +``` + +### 自定义模型路径 + +如果需要使用自定义模型文件: + +```python +import whisper + +model = whisper.load_model( + '/path/to/custom/model.pt', + device='cpu' +) +``` + +--- + +## ⚠️ 注意事项 + +### 1. 存储空间 + +- **模型文件**: ~150MB (tiny) - 3GB (large) +- **音频缓存**: 每分钟视频约 1-2MB(处理完成后自动清理) +- **字幕缓存**: 每分钟视频约 1-3KB(长期保留) + +建议: +- 使用 `base` 模型作为默认 +- 定期清理 `cache/audio` 目录 +- 字幕缓存可以长期保留 + +### 2. 处理时间 + +- **无 GPU**: 10分钟视频约 2-5 分钟(base 模型) +- **有 GPU**: 10分钟视频约 20-40 秒(base 模型) + +### 3. 隐私安全 + +- ✅ Whisper 本地处理,音频不上传 +- ✅ 缓存文件在本地,不共享 +- ✅ 开源软件,代码透明 + +### 4. 识别准确度 + +Whisper 的识别效果取决于: +- **音频质量**: 清晰的音频效果更好 +- **背景噪音**: 嘈杂环境可能影响识别 +- **说话方式**: 语速过快、口音过重可能降低准确度 + +建议: +- 优先处理高质量音频 +- 对生成的字幕进行人工审核 +- 复杂内容可考虑使用人工字幕 + +### 5. 错误处理 + +系统已完善的错误处理机制: + +- **下载失败**: 记录错误,继续处理 +- **识别失败**: 记录错误,继续处理 +- **格式化失败**: 使用原始文本 + +任何错误都不会中断主流程。 + +--- + +## 🚀 后续优化方向 + +### 1. GPU 加速支持 + +- [ ] 自动检测 GPU 可用性 +- [ ] 支持 CUDA / MPS (Apple Silicon) +- [ ] 多 GPU 支持 + +### 2. 模型优化 + +- [ ] 支持量化模型(减小体积) +- [ ] 支持蒸馏模型(提升速度) +- [ ] 支持自定义模型微调 + +### 3. 功能增强 + +- [ ] 批量处理多个视频 +- [ ] 支持更多视频平台 +- [ ] 字幕编辑器 Web 界面 +- [ ] 实时字幕生成 + +### 4. 云端集成 + +- [ ] 可选切换到云 API(阿里云/腾讯云) +- [ ] 混合模式:本地优先,云端降级 +- [ ] 分布式处理支持 + +--- + +## 📄 许可证 + +本功能使用以下开源组件: + +- **openai-whisper**: MIT License +- **yt-dlp**: Unlicense +- **torch**: BSD License + +--- + +## 🤝 贡献 + +欢迎贡献代码、报告问题或提出建议! + +--- + +**最后更新**: 2026-02-07 +**版本**: 1.0.0 diff --git a/skills/obsidian-kb/config.yaml.example b/skills/obsidian-kb/config.yaml.example new file mode 100644 index 0000000..71d0a8f --- /dev/null +++ b/skills/obsidian-kb/config.yaml.example @@ -0,0 +1,51 @@ +# obsidian-kb 配置文件示例 +# 复制此文件为 config.yaml 并修改配置 + +# Obsidian 笔记库路径 +obsidian_vault: ~/Documents/ObsidianVault + +# 笔记输出目录(相对于 vault) +output_dir: AI_Generated + +# Git 同步配置 +git: + enabled: false + repo: ~/Documents/ObsidianVault + branch: main + auto_commit: true + auto_push: true + +# AI 客户端配置 +ai_client: + provider: openai # openai 或 anthropic + model: gpt-4o-mini + api_key: ${OPENAI_API_KEY} # 从环境变量读取 + base_url: null # 自定义 API 端点(可选) + temperature: 0.7 + max_tokens: 4000 + +# 内容处理配置 +content: + max_video_length: 3600 # 最大视频长度(秒),超过则跳过 + min_transcript_length: 100 # 最小字幕长度(字符) + chunk_size: 2000 # 文本分块大小(字符) + chunk_overlap: 200 # 分块重叠大小(字符) + +# 模板配置 +templates: + note_template: templates/default_note.md + summary_template: templates/default_summary.md + deep_analysis_template: templates/default_deep_analysis.md + +# 字幕生成配置(新增) +subtitle: + enabled: false # 是否启用自动生成字幕 + model: base # Whisper 模型大小: tiny/base/small/medium/large + language: null # 语言代码: zh/en/null(自动检测) + cache_dir: cache/subtitles # 字幕缓存目录 + audio_cache_dir: cache/audio # 音频缓存目录 + +# 日志配置 +logging: + level: INFO # DEBUG, INFO, WARNING, ERROR + file: logs/obsidian-kb.log diff --git a/skills/obsidian-kb/requirements.txt b/skills/obsidian-kb/requirements.txt index bd47d0f..012d3c5 100644 --- a/skills/obsidian-kb/requirements.txt +++ b/skills/obsidian-kb/requirements.txt @@ -9,6 +9,13 @@ jinja2>=3.1.2 openai>=1.0.0 anthropic>=0.7.0 +# Subtitle generation +openai-whisper>=20230314 +yt-dlp>=2023.1.6 +# Use CPU version for torch (smaller, faster to install) +# To use GPU, remove --index-url and install with: pip install torch +torch>=2.0.0 + # Testing pytest>=7.4.0 pytest-cov>=4.1.0 diff --git a/skills/obsidian-kb/src/pipeline.py b/skills/obsidian-kb/src/pipeline.py index 78657f9..05afbe8 100644 --- a/skills/obsidian-kb/src/pipeline.py +++ b/skills/obsidian-kb/src/pipeline.py @@ -4,7 +4,7 @@ C4.3: 实现完整处理流程 import os from datetime import datetime from typing import Optional -from src.models import Content, ContentStatus, AnalysisResult +from src.models import Content, ContentStatus, AnalysisResult, ContentType from src.light_scanner import LightScanner from src.deep_analyzer import DeepAnalyzer from src.config import get_config @@ -12,29 +12,172 @@ from src.config import get_config class ProcessingPipeline: """内容处理管道 - 协调轻量扫描、深度分析、笔记生成和同步""" - + def __init__(self, ai_client=None, git_client=None): self.config = get_config() self.ai_client = ai_client self.git_client = git_client - + # 初始化处理器 self.light_scanner = LightScanner(ai_client=ai_client) self.deep_analyzer = DeepAnalyzer(ai_client=ai_client) - + + # 初始化字幕生成器(如果启用) + self.subtitle_generator = None + self._init_subtitle_generator() + + def _init_subtitle_generator(self): + """初始化字幕生成器""" + subtitle_config = self.config.get('subtitle', {}) + + if subtitle_config.get('enabled', False): + try: + # 延迟导入,避免依赖问题 + from subtitle_generator import SubtitleGenerator + + model_size = subtitle_config.get('model', 'base') + cache_dir = subtitle_config.get('cache_dir', 'cache/subtitles') + + self.subtitle_generator = SubtitleGenerator( + model_size=model_size, + cache_dir=cache_dir + ) + + print(f"[Pipeline] 字幕生成器已初始化 (model={model_size})") + except ImportError as e: + print(f"[Pipeline] 字幕生成器初始化失败: {e}") + print(f"[Pipeline] 提示: 需要安装 openai-whisper 和 yt-dlp") + self.subtitle_generator = None + except Exception as e: + print(f"[Pipeline] 字幕生成器初始化失败: {e}") + self.subtitle_generator = None + + def _ensure_subtitle(self, content: Content) -> str: + """ + 确保内容有字幕,如果没有则生成 + + Args: + content: Content对象 + + Returns: + str: 字幕文本 + """ + # 1. 检查是否已有字幕 + if content.transcript and len(content.transcript) > 100: + print(f"[Pipeline] 使用现有字幕 (长度: {len(content.transcript)} 字符)") + return content.transcript + + # 2. 检查是否启用自动生成 + if not self.subtitle_generator: + print(f"[Pipeline] 字幕生成器未启用或初始化失败,跳过生成") + return "" + + # 3. 只处理视频内容 + if content.content_type != ContentType.VIDEO: + print(f"[Pipeline] 非视频内容,跳过字幕生成") + return "" + + # 4. 生成字幕 + try: + print(f"[Pipeline] 开始自动生成字幕...") + + subtitle_config = self.config.get('subtitle', {}) + audio_cache_dir = subtitle_config.get('audio_cache_dir', 'cache/audio') + language = subtitle_config.get('language', None) + + # 生成字幕 + srt_text = self.subtitle_generator.generate( + url=content.url, + content_id=content.id, + audio_cache_dir=audio_cache_dir, + language=language, + use_cache=True + ) + + # 更新内容 + content.transcript = srt_text + + print(f"[Pipeline] 字幕生成完成 (长度: {len(srt_text)} 字符)") + return srt_text + + except Exception as e: + print(f"[Pipeline] 字幕生成失败: {e}") + import traceback + traceback.print_exc() + return "" + + def process_full(self, content: Content, content_text: str = None) -> Content: + """ + 完整处理流程: + 轻量扫描 → 入库 → 深度分析 → 生成笔记 → Git同步 + + Args: + content: Content对象 + content_text: 内容文本(可选,如果没有则自动生成字幕) + + Returns: + 完全处理后的Content对象 + """ + print(f"\n{'='*60}") + print(f"[Pipeline] 开始完整处理流程: {content.id}") + print(f"{'='*60}\n") + + try: + # Step 0: 确保有字幕(新增) + print("\n📝 Step 0: 确保字幕可用") + content_text = content_text or self._ensure_subtitle(content) + + if not content_text or len(content_text) < 50: + print(f"[Pipeline] 警告: 字幕过短或为空,可能影响分析质量") + + # Step 1: 轻量扫描 + print("\n📋 Step 1: 轻量扫描") + content = self.light_scan(content, content_text) + + # Step 2: 入库(保存到数据库/文件系统) + print("\n💾 Step 2: 保存内容") + self._save_content(content) + + # Step 3: 深度分析 + print("\n🔍 Step 3: 深度分析") + content = self.deep_analyze(content, content_text) + + # Step 4: 生成笔记 + print("\n📝 Step 4: 生成Obsidian笔记") + content = self._generate_note(content) + + # Step 5: Git同步 + if self.config.git.enabled: + print("\n🔄 Step 5: Git同步") + self._sync_to_git(content) + else: + print("\n⏭️ Step 5: Git同步已禁用,跳过") + + content.update_status(ContentStatus.GENERATED) + + print(f"\n{'='*60}") + print(f"[Pipeline] 处理完成: {content.id}") + print(f"{'='*60}\n") + + return content + + except Exception as e: + print(f"\n❌ [Pipeline] 处理流程失败: {e}") + raise + def light_scan(self, content: Content, content_text: str) -> Content: """ 轻量扫描阶段 - + Args: content: Content对象 content_text: 内容文本 - + Returns: 更新后的Content对象 """ print(f"[Pipeline] 开始轻量扫描: {content.id}") - + try: content = self.light_scanner.scan(content, content_text) print(f"[Pipeline] 轻量扫描完成: 摘要长度={len(content.summary)}, 关键词={content.key_points}, 质量分={content.quality_metrics.overall_score:.2f}") @@ -42,20 +185,20 @@ class ProcessingPipeline: except Exception as e: print(f"[Pipeline] 轻量扫描失败: {e}") raise - + def deep_analyze(self, content: Content, content_text: str) -> Content: """ 深度分析阶段 - + Args: content: Content对象 content_text: 内容文本 - + Returns: 更新后的Content对象 """ print(f"[Pipeline] 开始深度分析: {content.id}") - + try: analysis_result = self.deep_analyzer.analyze(content, content_text) print(f"[Pipeline] 深度分析完成: 主题数={len(analysis_result.main_topics)}, 时间轴段数={len(analysis_result.timeline)}") @@ -64,102 +207,50 @@ class ProcessingPipeline: print(f"[Pipeline] 深度分析失败: {e}") content.update_status(ContentStatus.ERROR) raise - - def process_full(self, content: Content, content_text: str) -> Content: - """ - 完整处理流程: - 轻量扫描 → 入库 → 深度分析 → 生成笔记 → Git同步 - - Args: - content: Content对象 - content_text: 内容文本 - - Returns: - 完全处理后的Content对象 - """ - print(f"\n{'='*60}") - print(f"[Pipeline] 开始完整处理流程: {content.id}") - print(f"{'='*60}\n") - - try: - # Step 1: 轻量扫描 - print("\n📋 Step 1: 轻量扫描") - content = self.light_scan(content, content_text) - - # Step 2: 入库(保存到数据库/文件系统) - print("\n💾 Step 2: 保存内容") - self._save_content(content) - - # Step 3: 深度分析 - print("\n🔍 Step 3: 深度分析") - content = self.deep_analyze(content, content_text) - - # Step 4: 生成笔记 - print("\n📝 Step 4: 生成Obsidian笔记") - content = self._generate_note(content) - - # Step 5: Git同步 - if self.config.git.enabled: - print("\n🔄 Step 5: Git同步") - self._sync_to_git(content) - else: - print("\n⏭️ Step 5: Git同步已禁用,跳过") - - content.update_status(ContentStatus.GENERATED) - - print(f"\n{'='*60}") - print(f"[Pipeline] 处理完成: {content.id}") - print(f"{'='*60}\n") - - return content - - except Exception as e: - print(f"\n❌ [Pipeline] 处理流程失败: {e}") - raise - + def _save_content(self, content: Content) -> None: """保存内容到存储""" # 创建存储目录 storage_dir = os.path.expanduser("~/.obsidian-kb/storage") os.makedirs(storage_dir, exist_ok=True) - + # 保存JSON文件 import json file_path = os.path.join(storage_dir, f"{content.id}.json") - + with open(file_path, 'w', encoding='utf-8') as f: json.dump(content.to_dict(), f, ensure_ascii=False, indent=2) - + print(f"[Pipeline] 内容已保存: {file_path}") - + def _generate_note(self, content: Content) -> Content: """生成Obsidian笔记""" # 构建笔记内容 note_content = self._build_note_content(content) - + # 确定笔记路径 vault_path = os.path.expanduser(self.config.obsidian.vault_path) notes_folder = self.config.obsidian.notes_folder - + note_dir = os.path.join(vault_path, notes_folder) os.makedirs(note_dir, exist_ok=True) - + # 生成文件名 safe_title = self._sanitize_filename(content.title or content.id) timestamp = datetime.now().strftime("%Y%m%d") note_filename = f"{timestamp}_{safe_title}.md" note_path = os.path.join(note_dir, note_filename) - + # 写入文件 with open(note_path, 'w', encoding='utf-8') as f: f.write(note_content) - + content.output_path = note_path content.update_status(ContentStatus.GENERATED) - + print(f"[Pipeline] 笔记已生成: {note_path}") return content - + def _build_note_content(self, content: Content) -> str: """构建Obsidian笔记内容""" lines = [ @@ -171,11 +262,11 @@ class ProcessingPipeline: f"- **作者**: {content.author or '未知'}", f"- **类型**: {content.content_type.value}", ] - + if content.duration: duration_str = self._format_duration(content.duration) lines.append(f"- **时长**: {duration_str}") - + lines.extend([ f"- **质量评分**: {content.quality_metrics.overall_score:.1f}/1.0", f"- **处理时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}", @@ -185,13 +276,13 @@ class ProcessingPipeline: "", "## 🏷️ 关键词", ]) - + # 添加关键词标签 if content.key_points: keyword_tags = ' '.join([f"#{kw.replace(' ', '_')}" for kw in content.key_points[:5]]) lines.append(keyword_tags) lines.append('') - + # 添加时间轴 if content.timeline: lines.extend([ @@ -199,12 +290,12 @@ class ProcessingPipeline: "## ⏱️ 时间轴", "", ]) - + for item in content.timeline: lines.append(f"### {item.timestamp} - {item.title}") lines.append(item.description) lines.append('') - + # 添加原始内容(折叠) lines.extend([ "", @@ -217,38 +308,38 @@ class ProcessingPipeline: "---", f"*由 Obsidian KB 自动生成 - {datetime.now().strftime('%Y-%m-%d %H:%M')}*" ]) - + return '\n'.join(lines) - + def _sync_to_git(self, content: Content) -> None: """同步到Git仓库""" if not self.git_client: print("[Pipeline] Git客户端未配置,跳过同步") return - + try: vault_path = os.path.expanduser(self.config.obsidian.vault_path) - + # 添加文件 relative_path = os.path.relpath(content.output_path, vault_path) self.git_client.add(relative_path) - + # 提交 commit_msg = self.config.git.commit_message_template.format( title=content.title or content.id ) self.git_client.commit(commit_msg) - + # 推送(如果启用) if self.config.git.auto_push: self.git_client.push() - + print(f"[Pipeline] Git同步完成") - + except Exception as e: print(f"[Pipeline] Git同步失败: {e}") # Git同步失败不阻断主流程 - + def _sanitize_filename(self, filename: str) -> str: """清理文件名""" import re @@ -258,13 +349,13 @@ class ProcessingPipeline: if len(filename) > 50: filename = filename[:50] return filename.strip() - + def _format_duration(self, seconds: int) -> str: """格式化时长""" hours = seconds // 3600 minutes = (seconds % 3600) // 60 secs = seconds % 60 - + if hours > 0: return f"{hours}小时{minutes}分钟" elif minutes > 0: diff --git a/skills/obsidian-kb/subtitle_generator.py b/skills/obsidian-kb/subtitle_generator.py new file mode 100644 index 0000000..cf308bf --- /dev/null +++ b/skills/obsidian-kb/subtitle_generator.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" +字幕生成器 - 为无字幕视频自动生成 SRT 格式字幕 + +使用 OpenAI Whisper 进行语音识别,支持本地离线处理 +""" + +import os +import time +import hashlib +import logging +from pathlib import Path +from typing import Optional, Callable, Dict, Any + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class AudioDownloader: + """使用 yt-dlp 下载视频音频""" + + def __init__(self): + """初始化音频下载器""" + self.ydl_opts = { + 'format': 'bestaudio/best', + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }], + 'quiet': True, + 'no_warnings': True, + } + + def download_audio(self, url: str, output_path: str) -> str: + """ + 下载视频音频 + + Args: + url: 视频URL + output_path: 输出音频路径(可以是模板,如 'cache/audio/%(id)s.mp3') + + Returns: + str: 实际保存的音频文件路径 + + Raises: + Exception: 下载失败时抛出异常 + """ + try: + import yt_dlp + + ydl_opts = self.ydl_opts.copy() + ydl_opts['outtmpl'] = output_path + + logger.info(f"开始下载音频: {url}") + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + # 替换扩展名 + actual_path = output_path.replace('.%(ext)s', '.mp3') + if not os.path.exists(actual_path): + # 尝试其他可能的文件名 + actual_path = output_path.rsplit('.', 1)[0] + '.mp3' + + if os.path.exists(actual_path): + logger.info(f"音频下载成功: {actual_path}") + return actual_path + else: + raise FileNotFoundError(f"下载的音频文件未找到: {actual_path}") + + except Exception as e: + logger.error(f"音频下载失败: {e}") + raise + + +class WhisperTranscriber: + """Whisper 语音识别器""" + + # 模型选择 + MODELS = { + 'tiny': 39000000, # 最快,准确度最低 (~39M) + 'base': 74000000, # 推荐,速度/准确度平衡 (~74M) + 'small': 244000000, # 准确度更高 (~244M) + 'medium': 769000000, # 接近专业级 (~769M) + 'large': 1550000000, # 最高准确度 (~1.55GB) + } + + def __init__(self, model_size: str = 'base'): + """ + 初始化识别器 + + Args: + model_size: 模型大小,默认 'base' + """ + self.model_size = model_size + self.model = None + self.device = None + + def load_model(self): + """加载 Whisper 模型(懒加载)""" + if self.model is None: + logger.info(f"正在加载 Whisper 模型 ({self.model_size})...") + start_time = time.time() + + try: + import whisper + import torch + + # 自动检测 GPU + self.device = "cuda" if torch.cuda.is_available() else "cpu" + logger.info(f"使用设备: {self.device}") + + self.model = whisper.load_model( + self.model_size, + device=self.device + ) + + load_time = time.time() - start_time + logger.info(f"模型加载完成 - 耗时: {load_time:.1f}秒") + + except Exception as e: + logger.error(f"模型加载失败: {e}") + raise + + def transcribe( + self, + audio_path: str, + language: Optional[str] = None, + progress_callback: Optional[Callable[[str, float], None]] = None + ) -> Dict[str, Any]: + """ + 语音识别 + + Args: + audio_path: 音频文件路径 + language: 语言代码('zh'、'en'、None=自动检测) + progress_callback: 进度回调函数 callback(message, progress_percent) + + Returns: + dict: Whisper 识别结果,包含 'text' 和 'segments' + + Raises: + Exception: 识别失败时抛出异常 + """ + self.load_model() + + logger.info(f"开始语音识别: {audio_path}") + if language: + logger.info(f"指定语言: {language}") + else: + logger.info("语言: 自动检测") + + start_time = time.time() + + try: + result = self.model.transcribe( + audio_path, + language=language, + task='transcribe', + verbose=False, + fp16=False # CPU 上不使用 FP16 + ) + + duration = time.time() - start_time + logger.info(f"识别完成 - 耗时: {duration:.1f}秒") + + return result + + except Exception as e: + logger.error(f"语音识别失败: {e}") + raise + + def get_segments(self, result: Dict[str, Any]) -> list: + """ + 获取分段信息 + + Args: + result: Whisper 识别结果 + + Returns: + list: segments 列表 + """ + return result.get('segments', []) + + +class SubtitleFormatter: + """字幕格式化器(SRT 格式)""" + + @staticmethod + def to_srt(segments: list) -> str: + """ + 将 Whisper segments 转换为 SRT 格式 + + Args: + segments: Whisper segments 列表 + + Returns: + str: SRT 格式文本 + """ + srt_lines = [] + + for i, segment in enumerate(segments, 1): + # SRT 格式: + # 序号 + # 开始时间 --> 结束时间 + # 文本 + # 空行 + + start_time = SubtitleFormatter._seconds_to_srt_time(segment['start']) + end_time = SubtitleFormatter._seconds_to_srt_time(segment['end']) + text = segment['text'].strip() + + srt_lines.append(str(i)) + srt_lines.append(f"{start_time} --> {end_time}") + srt_lines.append(text) + srt_lines.append("") + + return "\n".join(srt_lines) + + @staticmethod + def _seconds_to_srt_time(seconds: float) -> str: + """ + 将秒数转换为 SRT 时间格式 (HH:MM:SS,mmm) + + Args: + seconds: 秒数 + + Returns: + str: SRT 时间格式 + """ + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds - int(seconds)) * 1000) + + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + +class SubtitleCache: + """字幕缓存管理器""" + + def __init__(self, cache_dir: str = 'cache/subtitles'): + """ + 初始化缓存 + + Args: + cache_dir: 缓存目录 + """ + self.cache_dir = Path(cache_dir) + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def get_cache_key(self, url: str) -> str: + """ + 生成缓存键 + + Args: + url: 视频URL + + Returns: + str: 缓存键(SHA256 哈希的前8位) + """ + return hashlib.sha256(url.encode()).hexdigest()[:8] + + def get_cache_path(self, url: str) -> str: + """ + 获取缓存文件路径 + + Args: + url: 视频URL + + Returns: + str: 缓存文件路径 + """ + cache_key = self.get_cache_key(url) + return str(self.cache_dir / f"{cache_key}.srt") + + def get(self, url: str) -> Optional[str]: + """ + 获取缓存字幕 + + Args: + url: 视频URL + + Returns: + str | None: 缓存的字幕内容,如果不存在返回 None + """ + cache_path = self.get_cache_path(url) + + if os.path.exists(cache_path): + logger.info(f"使用缓存字幕: {cache_path}") + try: + with open(cache_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + logger.warning(f"读取缓存失败: {e}") + return None + + return None + + def set(self, url: str, subtitle: str) -> str: + """ + 保存缓存字幕 + + Args: + url: 视频URL + subtitle: 字幕内容 + + Returns: + str: 缓存文件路径 + """ + cache_path = self.get_cache_path(url) + + try: + with open(cache_path, 'w', encoding='utf-8') as f: + f.write(subtitle) + logger.info(f"字幕已缓存: {cache_path}") + return cache_path + except Exception as e: + logger.error(f"保存缓存失败: {e}") + raise + + def exists(self, url: str) -> bool: + """检查缓存是否存在""" + return os.path.exists(self.get_cache_path(url)) + + def clear_cache(self): + """清空所有缓存""" + try: + for file in self.cache_dir.glob('*.srt'): + file.unlink() + logger.info("缓存已清空") + except Exception as e: + logger.error(f"清空缓存失败: {e}") + + +class SubtitleGenerator: + """字幕生成器统一接口""" + + def __init__( + self, + model_size: str = 'base', + cache_dir: str = 'cache/subtitles', + audio_cache_dir: str = 'cache/audio' + ): + """ + 初始化生成器 + + Args: + model_size: Whisper 模型大小 + cache_dir: 字幕缓存目录 + audio_cache_dir: 音频缓存目录 + """ + self.audio_downloader = AudioDownloader() + self.transcriber = WhisperTranscriber(model_size) + self.formatter = SubtitleFormatter() + self.cache = SubtitleCache(cache_dir) + self.audio_cache_dir = Path(audio_cache_dir) + self.audio_cache_dir.mkdir(parents=True, exist_ok=True) + + def generate( + self, + url: str, + content_id: str = None, + language: Optional[str] = None, + use_cache: bool = True, + keep_audio: bool = False + ) -> str: + """ + 生成字幕 + + Args: + url: 视频URL + content_id: 内容ID(用于音频文件命名) + language: 语言代码(None=自动检测) + use_cache: 是否使用缓存 + keep_audio: 是否保留音频文件 + + Returns: + str: SRT 格式字幕 + + Raises: + Exception: 生成失败时抛出异常 + """ + # 检查缓存 + if use_cache and self.cache.exists(url): + logger.info(f"使用缓存字幕: {url}") + cached_subtitle = self.cache.get(url) + if cached_subtitle: + return cached_subtitle + + logger.info(f"开始生成字幕: {url}") + audio_path = None + + try: + # 生成 content_id + if content_id is None: + content_id = self.cache.get_cache_key(url) + + # 下载音频 + audio_path = str(self.audio_cache_dir / f"{content_id}.mp3") + logger.info(f"下载音频到: {audio_path}") + audio_path = self.audio_downloader.download_audio(url, audio_path) + + # 语音识别 + result = self.transcriber.transcribe(audio_path, language=language) + segments = self.transcriber.get_segments(result) + + if not segments: + logger.warning("未识别到任何语音内容") + return "" + + # 转换为 SRT + srt_text = self.formatter.to_srt(segments) + logger.info(f"SRT 字幕生成成功,共 {len(segments)} 段") + + # 保存缓存 + self.cache.set(url, srt_text) + + # 清理音频文件 + if not keep_audio and os.path.exists(audio_path): + os.remove(audio_path) + logger.info(f"已删除音频文件: {audio_path}") + + return srt_text + + except Exception as e: + logger.error(f"字幕生成失败: {e}") + # 清理失败的音频文件 + if audio_path and os.path.exists(audio_path) and not keep_audio: + try: + os.remove(audio_path) + except: + pass + raise + + +def generate_subtitle( + url: str, + model_size: str = 'base', + language: Optional[str] = None, + cache_dir: str = 'cache/subtitles', + audio_cache_dir: str = 'cache/audio' +) -> str: + """ + 便捷函数:生成字幕 + + Args: + url: 视频URL + model_size: Whisper 模型大小 + language: 语言代码(None=自动检测) + cache_dir: 字幕缓存目录 + audio_cache_dir: 音频缓存目录 + + Returns: + str: SRT 格式字幕 + """ + generator = SubtitleGenerator( + model_size=model_size, + cache_dir=cache_dir, + audio_cache_dir=audio_cache_dir + ) + return generator.generate(url, language=language) + + +if __name__ == '__main__': + # 测试示例 + import sys + + if len(sys.argv) < 2: + print("用法: python subtitle_generator.py <视频URL> [语言]") + print("示例: python subtitle_generator.py https://www.bilibili.com/video/BV1xx411c7mD zh") + sys.exit(1) + + url = sys.argv[1] + language = sys.argv[2] if len(sys.argv) > 2 else None + + print("=" * 60) + print("字幕生成器") + print("=" * 60) + + try: + subtitle = generate_subtitle(url, language=language) + print("\n" + "=" * 60) + print("生成成功!字幕预览(前500字符):") + print("=" * 60) + print(subtitle[:500]) + if len(subtitle) > 500: + print("\n...") + print(f"\n总字符数: {len(subtitle)}") + except Exception as e: + print(f"\n错误: {e}") + sys.exit(1) diff --git a/skills/obsidian-kb/test_subtitle_generator.py b/skills/obsidian-kb/test_subtitle_generator.py new file mode 100644 index 0000000..e64f091 --- /dev/null +++ b/skills/obsidian-kb/test_subtitle_generator.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +字幕生成器测试脚本 + +测试各个模块的功能 +""" + +import os +import sys +import tempfile +from pathlib import Path + +# 添加项目路径 +sys.path.insert(0, str(Path(__file__).parent)) + + +def test_audio_downloader(): + """测试音频下载器""" + print("\n" + "="*60) + print("测试 1: 音频下载器") + print("="*60) + + from subtitle_generator import AudioDownloader + + # 使用一个短测试视频 + test_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" # Rick Astley - Never Gonna Give You Up + + downloader = AudioDownloader() + + try: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = os.path.join(tmpdir, "test_audio.%(ext)s") + result_path = downloader.download_audio(test_url, output_path) + + if os.path.exists(result_path): + size_mb = os.path.getsize(result_path) / (1024 * 1024) + print(f"✅ 音频下载成功") + print(f" 文件: {result_path}") + print(f" 大小: {size_mb:.2f} MB") + return True + else: + print(f"❌ 音频文件未找到: {result_path}") + return False + + except Exception as e: + print(f"❌ 音频下载失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_whisper_transcriber(): + """测试 Whisper 识别器""" + print("\n" + "="*60) + print("测试 2: Whisper 识别器") + print("="*60) + + from subtitle_generator import WhisperTranscriber + + # 创建测试音频(使用 ffmpeg 生成静音) + test_audio = "/tmp/test_silent.mp3" + os.system(f"ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 3 -q:a 9 -y {test_audio} 2>/dev/null") + + try: + transcriber = WhisperTranscriber(model_size='tiny') # 使用最小的模型 + result = transcriber.transcribe(test_audio) + + print(f"✅ Whisper 识别完成") + print(f" 识别文本: {result['text'][:50]}...") + print(f" 分段数: {len(result.get('segments', []))}") + + return True + + except Exception as e: + print(f"❌ Whisper 识别失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_subtitle_formatter(): + """测试 SRT 格式化器""" + print("\n" + "="*60) + print("测试 3: SRT 格式化器") + print("="*60) + + from subtitle_generator import SubtitleFormatter + + # 测试数据 + test_segments = [ + {'start': 0.0, 'end': 2.5, 'text': '这是一段测试字幕'}, + {'start': 3.0, 'end': 5.7, 'text': '这是第二段字幕'}, + {'start': 6.0, 'end': 8.2, 'text': 'This is the third subtitle'}, + ] + + try: + srt_text = SubtitleFormatter.to_srt(test_segments) + + print(f"✅ SRT 格式化成功") + print(f" 生成内容预览:") + print(" " + "-" * 40) + for line in srt_text.split('\n')[:10]: + print(f" {line}") + print(" " + "-" * 40) + + # 验证格式 + assert '00:00:00,000 --> 00:00:02,500' in srt_text + assert '这是一段测试字幕' in srt_text + + return True + + except Exception as e: + print(f"❌ SRT 格式化失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_subtitle_cache(): + """测试缓存管理器""" + print("\n" + "="*60) + print("测试 4: 缓存管理器") + print("="*60) + + from subtitle_generator import SubtitleCache + + test_url = "https://example.com/video/123" + test_subtitle = """1 +00:00:00,000 --> 00:00:02,500 +测试字幕内容 +""" + + try: + cache = SubtitleCache(cache_dir='/tmp/test_subtitle_cache') + + # 测试写入 + cache_path = cache.set(test_url, test_subtitle) + print(f"✅ 缓存写入成功: {cache_path}") + + # 测试读取 + cached_content = cache.get(test_url) + assert cached_content == test_subtitle + print(f"✅ 缓存读取成功") + + # 测试存在性 + assert cache.exists(test_url) + print(f"✅ 缓存存在性检查成功") + + # 测试缓存键 + cache_key = cache.get_cache_key(test_url) + print(f" 缓存键: {cache_key}") + + return True + + except Exception as e: + print(f"❌ 缓存测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_full_workflow(): + """测试完整工作流程""" + print("\n" + "="*60) + print("测试 5: 完整工作流程") + print("="*60) + + from subtitle_generator import SubtitleGenerator + + test_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + + try: + generator = SubtitleGenerator(model_size='tiny') + + print(f"开始生成字幕...") + srt_text = generator.generate( + url=test_url, + content_id='test001', + language='en', + use_cache=False + ) + + print(f"✅ 字幕生成完成") + print(f" 总字符数: {len(srt_text)}") + print(f" 预览(前300字符):") + print(" " + "-" * 40) + print(" " + srt_text[:300]) + print(" " + "-" * 40) + + # 验证缓存 + print(f"\n验证缓存...") + cached = generator.generate( + url=test_url, + content_id='test002', + use_cache=True + ) + assert cached == srt_text + print(f"✅ 缓存验证成功") + + return True + + except Exception as e: + print(f"❌ 完整工作流程失败: {e}") + import traceback + traceback.print_exc() + return False + + +def main(): + """运行所有测试""" + print("\n" + "="*60) + print("字幕生成器测试套件") + print("="*60) + + results = {} + + # 运行各个测试 + results['audio_downloader'] = test_audio_downloader() + results['whisper_transcriber'] = test_whisper_transcriber() + results['subtitle_formatter'] = test_subtitle_formatter() + results['subtitle_cache'] = test_subtitle_cache() + results['full_workflow'] = test_full_workflow() + + # 输出汇总 + print("\n" + "="*60) + print("测试结果汇总") + print("="*60) + + for test_name, passed in results.items(): + status = "✅ 通过" if passed else "❌ 失败" + print(f"{test_name:25s} {status}") + + total = len(results) + passed = sum(results.values()) + + print("\n" + "="*60) + print(f"总计: {passed}/{total} 测试通过") + print("="*60) + + return all(results.values()) + + +if __name__ == '__main__': + success = main() + sys.exit(0 if success else 1)