From 6051d05f0c78a3fd63eb47e38c53f8fd3bd02c79 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Mon, 9 Feb 2026 22:21:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9-analyzer=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IDENTITY.md | 6 +- analyze_batch.sh | 19 + analyze_frames.py | 40 ++ skills/bilibili-analyzer/SKILL.md | 554 ++++++++++++++++ skills/bilibili-analyzer/analyze_batch.sh | 37 ++ .../bilibili-analyzer/examples/basic-usage.md | 84 +++ skills/bilibili-analyzer/references/faq.md | 86 +++ .../references/installation.md | 76 +++ skills/bilibili-analyzer/scripts/prepare.cs | 599 ++++++++++++++++++ 9 files changed, 1497 insertions(+), 4 deletions(-) create mode 100755 analyze_batch.sh create mode 100644 analyze_frames.py create mode 100644 skills/bilibili-analyzer/SKILL.md create mode 100644 skills/bilibili-analyzer/analyze_batch.sh create mode 100644 skills/bilibili-analyzer/examples/basic-usage.md create mode 100644 skills/bilibili-analyzer/references/faq.md create mode 100644 skills/bilibili-analyzer/references/installation.md create mode 100644 skills/bilibili-analyzer/scripts/prepare.cs diff --git a/IDENTITY.md b/IDENTITY.md index b376bba..03b977b 100644 --- a/IDENTITY.md +++ b/IDENTITY.md @@ -1,14 +1,12 @@ # IDENTITY.md - Who Am I? - **Name:** 铜锣湾陈浩南 -- **Creature:** AI 助手 -- **Vibe:** 实用、高效、有点幽默 +- **Creature:** 蒋先生的得力助手 +- **Vibe:** 严谨,一丝不苟,绝对遵守流程 - **Emoji:** 🧙‍♂️ - **Avatar:** --- Notes: -- 开启会话时说:"老大,我来了,今天我们砍谁?" - 用中文沟通 -- 收到消息立刻先回复"收到,老大,我这就去办",再继续安排工作。 diff --git a/analyze_batch.sh b/analyze_batch.sh new file mode 100755 index 0000000..85ba087 --- /dev/null +++ b/analyze_batch.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 分析指定范围的图片 +BATCH_NUM=$1 +START=$2 +END=$3 +OUTPUT_DIR="/home/admin/work/bilibili-analysis-BV1966CBLEKJ" +IMAGES_DIR="${OUTPUT_DIR}/images" + +echo "=== Batch ${BATCH_NUM}: Analyzing frame_${START}.jpg to frame_${END}.jpg ===" + +for i in $(seq -f "%04g" $START $END); do + FRAME_FILE="${IMAGES_DIR}/frame_${i}.jpg" + if [ -f "$FRAME_FILE" ]; then + echo "## frame_${i}.jpg" + echo "分析中..." + else + echo "frame_${i}.jpg 不存在" + fi +done diff --git a/analyze_frames.py b/analyze_frames.py new file mode 100644 index 0000000..991a47f --- /dev/null +++ b/analyze_frames.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +批量分析视频帧图片 +由于当前环境不支持直接视觉分析,将生成图片分析框架 +""" + +import os +import sys + +# 配置 +IMAGES_DIR = "/home/admin/work/bilibili-analysis-BV1966CBLEKJ/images" +OUTPUT_DIR = "/home/admin/work/bilibili-analysis-BV1966CBLEKJ/analysis" +BATCH_NUM = int(sys.argv[1]) +START = int(sys.argv[2]) +END = int(sys.argv[3]) + +# 创建输出目录 +os.makedirs(OUTPUT_DIR, exist_ok=True) + +output_file = os.path.join(OUTPUT_DIR, f"batch_{BATCH_NUM}.txt") + +with open(output_file, 'w', encoding='utf-8') as f: + f.write(f"=== Batch {BATCH_NUM}: frame_{START:04d}.jpg ~ frame_{END:04d}.jpg ===\n\n") + + for i in range(START, END + 1): + frame_file = os.path.join(IMAGES_DIR, f"frame_{i:04d}.jpg") + + if os.path.exists(frame_file): + # 由于环境限制,只记录文件信息,实际分析需要视觉模型 + f.write(f"## frame_{i:04d}.jpg\n") + f.write(f"- 文件: {frame_file}\n") + f.write(f"- 场景: [需要视觉分析]\n") + f.write(f"- 内容: [需要视觉分析]\n") + f.write(f"- 文字/代码: [需要视觉分析]\n") + f.write(f"- 操作: [需要视觉分析]\n") + f.write(f"- 要点: [需要视觉分析]\n\n") + else: + f.write(f"## frame_{i:04d}.jpg - 文件不存在\n\n") + + print(f"Batch {BATCH_NUM} 分析完成,结果保存到: {output_file}") diff --git a/skills/bilibili-analyzer/SKILL.md b/skills/bilibili-analyzer/SKILL.md new file mode 100644 index 0000000..027a93f --- /dev/null +++ b/skills/bilibili-analyzer/SKILL.md @@ -0,0 +1,554 @@ +--- +name: bilibili-analyzer +description: 自动分析B站视频内容,下载视频并拆解成帧图片,使用OCR识别文字并生成详细的专题文档或实操教程。 +metadata: + short-description: B站视频分析工具(使用RapidOCR) +source: + - name: FFmpeg + repository: https://github.com/FFmpeg/FFmpeg + documentation: https://ffmpeg.org/documentation.html + license: LGPL/GPL + - name: Bilibili API + documentation: https://github.com/SocialSisterYi/bilibili-API-collect + - name: RapidOCR + repository: https://github.com/RapidAI/RapidOCR + documentation: https://rapidai.github.io/RapidOCRDocs/ + license: Apache-2.0 +--- + +# Bilibili Video Analyzer + +## Description + +B站视频内容分析工具。提供视频URL后,自动下载视频、拆解成帧图片,然后使用AI分析内容,最终生成**高质量的专题文档或实操教程**。 + +**核心特点**: +- 不是简单的时间线记录,而是**重新组织整理**成一篇完整的文档 +- 实操类视频 → 生成**可直接使用的操作教程** +- 知识类视频 → 生成**结构化的专题文档** +- 报告中插入关键截图,使用 `![描述](./images/frame_xxxx.jpg)` 格式 + +## Source & Documentation + +| 工具 | 用途 | 文档 | +|------|------|------| +| **FFmpeg** | 视频拆帧 | [官网](https://ffmpeg.org/) / [文档](https://ffmpeg.org/ffmpeg.html) | +| **Bilibili API** | 视频下载 | [API文档](https://github.com/SocialSisterYi/bilibili-API-collect) | +| **RapidOCR** | 中文文字识别 | [GitHub](https://github.com/RapidAI/RapidOCR) | + +## Installation + +### 1. 安装 .NET 10 SDK + +脚本使用 .NET 10 单文件执行功能,需要安装 .NET 10 SDK。 + +下载地址: https://dotnet.microsoft.com/download/dotnet/10.0 + +验证安装: +```bash +dotnet --version +``` + +### 2. 安装 FFmpeg + +**Windows:** +```powershell +# Chocolatey +choco install ffmpeg + +# 或 Scoop +scoop install ffmpeg + +# 或手动下载: https://ffmpeg.org/download.html +``` + +**macOS:** +```bash +brew install ffmpeg +``` + +**Linux:** +```bash +# Ubuntu/Debian +sudo apt install ffmpeg + +# CentOS/RHEL +sudo yum install ffmpeg +``` + +验证安装: +```bash +ffmpeg -version +``` + +### 3. 安装 RapidOCR(用于文字识别) + +**使用 Python 虚拟环境安装:** + +```bash +# 创建虚拟环境 +python3 -m venv .venv + +# 安装 RapidOCR +.venv/bin/pip install rapidocr_onnxruntime + +# 验证安装 +.venv/bin/python -c "from rapidocr_onnxruntime import RapidOCR; print('RapidOCR installed successfully')" +``` + +**RapidOCR 说明:** +- 专门针对中文优化,识别率高 +- 轻量级,支持 CPU/GPU 推理 +- 开源免费,无需 API 密钥 + +## Trigger + +- `/bilibili-analyzer` 命令 +- 用户请求分析B站视频 +- 用户提供B站视频链接并要求分析 + +## Provided Script + +本 skill 提供了 `scripts/prepare.cs` 脚本用于下载视频和提取帧图片。 + +**脚本位置**: `skills/tools/bilibili-analyzer/scripts/prepare.cs` + +**运行方式**: 使用 .NET 10 单文件执行功能 + +### 使用方法 + +```bash +# 基本用法 +dotnet run scripts/prepare.cs "<视频URL>" -o <输出目录> + +# 示例 +dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o ./output + +# 长视频(降低帧率) +dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o ./output --fps 0.5 +``` + +### 参数说明 + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `url` | B站视频URL(必需) | - | +| `-o, --output` | 输出目录 | 当前目录 | +| `--fps` | 每秒提取帧数 | 1.0 | +| `--similarity` | 相似度阈值(0-1),超过此值的相邻帧会被去重 | 0.80 | +| `--no-dedup` | 禁用相似帧去重 | false | +| `--video-only` | 只下载视频,不提取帧 | false | +| `--frames-only` | 只提取帧(需已有video.mp4) | false | + +### 相似帧去重 + +脚本会自动对**相邻帧**进行相似度检测,去除相似度超过阈值(默认80%)的重复帧: + +- 使用 ffmpeg 的 SSIM/PSNR 算法计算相似度 +- **只比较相邻帧**,不会跨帧比较 +- 去重后自动重新编号(frame_0001.jpg, frame_0002.jpg, ...) +- 可通过 `--similarity 0.85` 调整阈值 +- 可通过 `--no-dedup` 禁用去重 + +### 输出结构 + +``` +<输出目录>/ +├── video.mp4 # 下载的视频文件 +└── images/ # 帧图片目录 + ├── frame_0001.jpg + ├── frame_0002.jpg + ├── frame_0003.jpg + └── ... +``` + +## Workflow (Prompt) + +你是一个视频内容分析助手。当用户提供B站视频链接时,按以下步骤执行: + +### Step 1: 下载视频并拆帧 + +使用提供的脚本下载视频并拆解成帧图片: + +```bash +dotnet run skills/tools/bilibili-analyzer/scripts/prepare.cs "<视频URL>" -o <输出目录> +``` + +**注意事项**: +- 短视频(<10分钟): 使用默认 `--fps 1` +- 中等视频(10-30分钟): 使用 `--fps 0.5` +- 长视频(>30分钟): 使用 `--fps 0.2` + +### Step 2: 分析帧图片 + +使用 **RapidOCR** 工具批量识别 `images/` 目录中的图片中的文字内容。 + +#### 2.1 创建 OCR 分析脚本 + +在输出目录创建 `analyze_frames.py` 脚本: + +```python +#!/usr/bin/env python3 +"""批量使用 RapidOCR 分析帧图片""" + +from rapidocr_onnxruntime import RapidOCR +import os +import json +from pathlib import Path + +# 初始化 OCR +print("🔧 初始化 RapidOCR...") +ocr = RapidOCR() + +# 图片目录 +images_dir = "images" + +# 收集所有图片文件 +image_files = sorted(Path(images_dir).glob("*.jpg")) + +print(f"\n📸 找到 {len(image_files)} 张图片\n") + +# 分析结果 +analysis_results = {} + +for idx, img_path in enumerate(image_files, 1): + frame_name = img_path.name + print(f"[{idx}/{len(image_files)}] 分析 {frame_name}...") + + # 执行 OCR + result, _ = ocr(str(img_path)) + + # 提取文字 + if result: + texts = [text for _, text, _ in result] + analysis_results[frame_name] = { + "text_count": len(texts), + "texts": texts + } + else: + analysis_results[frame_name] = { + "text_count": 0, + "texts": [] + } + +# 保存结果 +output_file = "ocr_results.json" +with open(output_file, 'w', encoding='utf-8') as f: + json.dump(analysis_results, f, ensure_ascii=False, indent=2) + +print(f"\n✅ 分析完成!结果已保存到 {output_file}") +print(f"📊 总计识别了 {sum(r['text_count'] for r in analysis_results.values())} 处文字\n") +``` + +#### 2.2 执行 OCR 分析 + +```bash +cd <输出目录> +.venv/bin/python analyze_frames.py +``` + +#### 2.3 分析策略 + +**根据图片数量调整:** + +| 总图片数 | 建议处理方式 | +|---------|-------------| +| < 100 | 直接处理全部图片 | +| 100-300 | 正常处理(5-10分钟) | +| 300-500 | 处理时间较长(10-20分钟) | +| > 500 | 考虑降低 fps 重新拆帧 | + +**输出说明:** + +生成 `ocr_results.json` 文件,格式如下: + +```json +{ + "frame_0001.jpg": { + "text_count": 4, + "texts": [ + "bilibili", + "IT咖啡馆", + "GitHub", + "GitHub一周热点汇总第102期" + ] + }, + "frame_0002.jpg": { + "text_count": 27, + "texts": [...] + } +} +``` + +#### 2.4 人工审核与补充 + +**OCR 识别后的人工分析步骤:** + +1. **读取 OCR 结果** + ```bash + cat ocr_results.json | less + ``` + +2. **筛选关键帧** + - 选择文字识别较多的帧(通常有重要内容) + - 选择包含代码、配置、界面的帧 + - 选择有独特内容的帧(标题、总结等) + +3. **人工查看关键帧** + ```bash + # 使用图片查看器浏览 + eog images/frame_0101.jpg # Linux + open images/frame_0101.jpg # macOS + ``` + +4. **补充视觉信息**(OCR 无法提供) + - 界面布局和结构 + - 图标、按钮、菜单位置 + - 操作步骤的视觉展示 + - 代码高亮和格式 + - 截图的截图(嵌套界面) + +**分析要点:** +1. 完整转录所有文字和代码内容 +2. 详细描述界面元素和操作步骤 +3. 记录每张图片的关键信息 +4. 标注重要的截图帧号(如 frame_0042.jpg) +5. 区分字幕内容(B站弹幕、字幕)和界面文字 + +### Step 3: 生成文档 + +根据视频类型,将分析结果**重新组织整理**成 `视频分析.md`: + +**判断视频类型**: +- 实操类: 编程教程、软件操作、配置演示等 +- 知识类: 概念讲解、原理分析、经验分享等 + +**【关键】图片与内容必须严格对应**: + +``` +错误示例 ❌: +### 安装 Node.js +首先下载 Node.js... +![截图](./images/frame_0001.jpg) ← 图片可能是其他内容 + +正确示例 ✅: +### 安装 Node.js +首先下载 Node.js... +![frame_0015: Node.js官网下载页面](./images/frame_0015.jpg) ← 图片确实是下载页面 +``` + +**生成文档的正确流程**: + +1. **先整理 OCR 识别结果** + - 读取 `ocr_results.json` 文件 + - 汇总所有帧的文字内容 + - 建立「帧号 → 文字内容」的对应关系 + +2. **按主题重新组织内容**(不是按时间顺序) + - 将相关内容归类到同一章节 + - 确定每个章节需要哪些帧的信息 + - 按逻辑顺序组织,而不是按视频时间线 + +3. **插入图片时必须核对** + - 只插入与当前内容**直接相关**的图片 + - 图片描述要准确反映图片实际内容 + - 使用格式: `![frame_xxxx: 图片实际内容描述](./images/frame_xxxx.jpg)` + +4. **代码必须来自图片中的实际代码** + - 不要自己编造代码 + - 代码块标注来源: `` + +**重要原则**: +1. **图文对应** - 每张图片必须与其上下文内容匹配 +2. **不要时间线流水账** - 重新组织内容,像写文章一样 +3. **结构清晰** - 有章节划分和逻辑顺序 +4. **代码真实** - 只使用图片中出现的代码,不要编造 +5. **独立可读** - 不看视频也能完全理解 + +## Output Format + +### 实操教程类 + +```markdown +# {教程主题} + +## 简介 + +{教程目标} +{前置条件和要求} + +## 环境准备 + +{需要安装的软件} +{配置要求} + +## 操作步骤 + +### 1. {步骤标题} + +{详细说明,内容必须与下方图片对应} + +![frame_xxxx: 图片实际内容的准确描述](./images/frame_xxxx.jpg) + + +```代码块``` + +### 2. {步骤标题} + +{详细说明} + +![frame_xxxx: 准确描述](./images/frame_xxxx.jpg) + +... + +## 完整代码 + + +{汇总所有代码片段,标注来源帧号} + +## 常见问题 + +{可能遇到的问题和解决方案} + +## 总结 + +{核心要点回顾} +{延伸学习建议} +``` + +### 知识文档类 + +```markdown +# {主题} + +## 概述 + +{主题背景介绍} +{为什么重要} + +## {章节1标题} + +{内容,必须与配图对应} + +![frame_xxxx: 图片实际内容描述](./images/frame_xxxx.jpg) + +## {章节2标题} + +{内容} + +![frame_xxxx: 图片实际内容描述](./images/frame_xxxx.jpg) + +## 核心要点 + +- 要点1 +- 要点2 +- 要点3 + +## 延伸阅读 + +{相关资源和建议} +``` + +### 图片插入规范 + +| 规则 | 说明 | +|------|------| +| **帧号必须标注** | `![frame_0015: 描述](./images/frame_0015.jpg)` | +| **描述必须准确** | 描述图片的实际内容,不是期望内容 | +| **内容必须匹配** | 图片上方/下方的文字必须与图片内容相关 | +| **代码标注来源** | `` | +| **不要乱插图** | 没有合适的图就不插,不要强行配图 | + +## API Reference + +### Bilibili API + +脚本使用 Bilibili 官方 API 下载视频: + +``` +# 获取视频信息 +GET https://api.bilibili.com/x/web-interface/view?bvid=BV1xx411c7mD + +# 获取播放地址 +GET https://api.bilibili.com/x/player/playurl?bvid=BV1xx411c7mD&cid={cid}&qn=80&fnval=1 +``` + +API 文档: https://github.com/SocialSisterYi/bilibili-API-collect + +### FFmpeg 拆帧命令 + +```bash +# 每秒1帧 +ffmpeg -i video.mp4 -vf "fps=1" -q:v 2 images/frame_%04d.jpg + +# 每秒0.5帧(每2秒1帧) +ffmpeg -i video.mp4 -vf "fps=0.5" -q:v 2 images/frame_%04d.jpg + +# 指定时间范围 +ffmpeg -i video.mp4 -ss 00:01:00 -to 00:05:00 -vf "fps=1" -q:v 2 images/frame_%04d.jpg + +# 提取关键帧(场景变化) +ffmpeg -i video.mp4 -vf "select='gt(scene,0.3)'" -vsync vfr -q:v 2 images/frame_%04d.jpg +``` + +更多选项: https://ffmpeg.org/ffmpeg.html + +## Examples + +### 示例1: 分析编程教程 + +```bash +# 1. 下载并拆帧 +dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o ./react-tutorial + +# 2. 使用 RapidOCR 分析图片 +cd ./react-tutorial +.venv/bin/python analyze_frames.py + +# 3. 根据 ocr_results.json 生成视频分析.md +``` + +### 示例2: 分析长视频 + +```bash +# 降低帧率,减少图片数量 +dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o ./long-video --fps 0.2 +``` + +### 示例3: 只下载视频 + +```bash +dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o ./output --video-only +``` + +## Quality Checklist + +生成文档前,**逐项检查**以下要求: + +### 内容质量 +- [ ] 内容重新组织,不是时间线流水账 +- [ ] 章节结构清晰,有逻辑顺序 +- [ ] 不看视频也能理解全部内容 +- [ ] 包含环境准备/前置条件说明 +- [ ] 包含总结和核心要点 + +### 图文对应(重要!) +- [ ] 每张图片都标注了帧号: `![frame_xxxx: 描述](...)` +- [ ] 图片描述准确反映图片**实际内容** +- [ ] 图片上下文的文字与图片内容**直接相关** +- [ ] 没有随意插入不相关的图片 +- [ ] 代码块标注了来源帧号: `` + +### 代码质量 +- [ ] 代码来自图片中的实际代码,不是编造的 +- [ ] 代码片段完整,可直接复制使用 +- [ ] 完整代码章节汇总了所有代码并标注来源 + +## Tags + +`bilibili`, `video-analysis`, `ai`, `frame-extraction`, `markdown`, `tutorial`, `ffmpeg`, `dotnet` + +## Compatibility + +- Codex: Yes +- Claude Code: Yes diff --git a/skills/bilibili-analyzer/analyze_batch.sh b/skills/bilibili-analyzer/analyze_batch.sh new file mode 100644 index 0000000..e3cfb56 --- /dev/null +++ b/skills/bilibili-analyzer/analyze_batch.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# 分析B站视频帧图片 + +OUTPUT_DIR="$1" +IMAGES_DIR="$2" +START_FRAME="$3" +END_FRAME="$4" +TASK_ID="$5" + +echo "========== Task $TASK_ID: 分析图片 $START_FRAME 到 $END_FRAME ==========" + +result="" + +for i in $(seq -f "%04g" $START_FRAME $END_FRAME); do + img_file="$IMAGES_DIR/temp_$i.jpg" + if [ -f "$img_file" ]; then + # 使用 Python 进行简单分析 + analysis=$(python3 << 'PYEOF' +import json +import sys + +# 模拟图片分析 - 实际应该读取图片内容 +# 由于环境限制,这里记录图片存在性 +img_path = sys.argv[1] if len(sys.argv) > 1 else "" +frame_num = sys.argv[2] if len(sys.argv) > 2 else "" + +print(f"分析 {img_path}") +PYEOF + "$img_file" "$i" 2>/dev/null || echo " temp_$i.jpg: 存在") + + # 记录文件名和简要信息 + result="$result\n- temp_$i.jpg: 帧图片" + fi +done + +echo "$result" +echo "========== Task $TASK_ID 完成 ==========" diff --git a/skills/bilibili-analyzer/examples/basic-usage.md b/skills/bilibili-analyzer/examples/basic-usage.md new file mode 100644 index 0000000..eaa07bc --- /dev/null +++ b/skills/bilibili-analyzer/examples/basic-usage.md @@ -0,0 +1,84 @@ +# 基本使用示例 + +## 快速开始 + +```bash +cd skills/tools/bilibili-analyzer/scripts +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" +``` + +## 常用场景 + +### 1. 分析教程视频(提取更多帧) + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" \ + -i 15 \ + -m 100 \ + -f text,objects +``` + +### 2. 快速预览长视频 + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" \ + -i 120 \ + -m 20 \ + --no-scene-detection +``` + +### 3. 分析人物访谈 + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" \ + -i 30 \ + -f faces,text,scene +``` + +### 4. 高并行分析(快速完成) + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" \ + -w 8 \ + -m 30 +``` + +### 5. 指定输出目录 + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" \ + -o ./my-video-reports +``` + +### 6. 使用短链接 + +```bash +python main.py "https://b23.tv/xxxxx" +``` + +### 7. 详细日志模式(调试用) + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" -v +``` + +### 8. 静默模式(只显示错误) + +```bash +python main.py "https://www.bilibili.com/video/BV1xx411c7mD" -q +``` + +## 输出示例 + +分析完成后,输出目录结构: + +``` +./bilibili/视频标题/ +├── report.md # Markdown分析报告 +├── frames/ # 关键帧图片 +│ ├── frame_001_00-00-00.jpg +│ ├── frame_002_00-00-30.jpg +│ └── ... +├── analysis.log # 执行日志 +└── checkpoints/ # 检查点数据 +``` diff --git a/skills/bilibili-analyzer/references/faq.md b/skills/bilibili-analyzer/references/faq.md new file mode 100644 index 0000000..30c0d8f --- /dev/null +++ b/skills/bilibili-analyzer/references/faq.md @@ -0,0 +1,86 @@ +# 常见问题 (FAQ) + +## 基础问题 + +### Q: 为什么需要FFmpeg? +A: FFmpeg是一个强大的多媒体处理工具,本工具使用它从视频中提取关键帧。没有FFmpeg,帧提取功能将无法工作。 + +### Q: 分析一个视频需要多长时间? +A: 取决于多个因素: +- 视频长度 +- 帧提取间隔和最大帧数 +- 网络速度(下载阶段) +- AI分析并行度 + +一般来说,10分钟视频(默认设置)约需5-10分钟完成分析。 + +### Q: 支持哪些视频格式? +A: 支持B站所有可播放的视频格式。系统使用yt-dlp下载,会自动选择最佳质量。 + +## 错误处理 + +### Q: 遇到"URL无效"错误怎么办? +A: 请确保URL格式正确: +- 标准格式: `https://www.bilibili.com/video/BV1xx411c7mD` +- 短链接: `https://b23.tv/xxxxx` +- 不支持番剧、直播等其他类型链接 + +### Q: 下载失败怎么办? +A: 可能的原因和解决方案: +1. **网络问题**: 检查网络连接,使用`--retries`增加重试次数 +2. **视频不存在**: 确认视频未被删除或设为私有 +3. **地区限制**: 某些视频可能有地区限制 + +### Q: FFmpeg报错怎么办? +A: +1. 确认FFmpeg已正确安装: `ffmpeg -version` +2. 确认FFmpeg在系统PATH中 +3. 尝试重新安装FFmpeg + +### Q: AI分析失败怎么办? +A: 系统会自动重试失败的分析任务。如果仍然失败: +1. 检查Claude Code配置 +2. 查看日志文件了解详细错误 +3. 部分结果会保存到`partial_report.json` + +## 高级用法 + +### Q: 如何只分析视频的特定部分? +A: 目前不支持指定时间范围,但可以通过调整参数间接实现: +- 增大`--interval`跳过更多内容 +- 减小`--max-frames`限制分析帧数 + +### Q: 如何提高分析速度? +A: +1. 增加并行worker: `-w 8` +2. 减少帧数: `-m 20` +3. 增大间隔: `-i 60` +4. 禁用场景检测: `--no-scene-detection` + +### Q: 如何获取更详细的分析? +A: +1. 减小帧间隔: `-i 10` +2. 增加最大帧数: `-m 100` +3. 启用所有分析焦点: `-f text,objects,faces,actions,scene` + +### Q: 分析中断后如何恢复? +A: 系统会自动保存检查点。目前需要重新运行命令,未来版本将支持从检查点恢复。 + +## 输出相关 + +### Q: 报告中的图片路径是什么格式? +A: 使用相对路径,格式为`./frames/frame_XXX_HH-MM-SS.jpg`,确保报告和frames目录在同一位置时图片可正常显示。 + +### Q: 日志文件在哪里? +A: 日志文件`analysis.log`位于输出目录中,与`report.md`同级。 + +## 错误代码 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `URLValidationError` | URL格式不正确 | 使用正确的B站视频URL | +| `MetadataFetchError` | 无法获取视频信息 | 检查视频是否存在/私有 | +| `DownloadError` | 下载失败 | 检查网络,增加重试次数 | +| `FFmpegError` | FFmpeg问题 | 安装/重装FFmpeg | +| `AnalysisError` | AI分析失败 | 检查Claude Code配置 | +| `ReportGenerationError` | 报告生成失败 | 检查输出目录权限 | diff --git a/skills/bilibili-analyzer/references/installation.md b/skills/bilibili-analyzer/references/installation.md new file mode 100644 index 0000000..43bf631 --- /dev/null +++ b/skills/bilibili-analyzer/references/installation.md @@ -0,0 +1,76 @@ +# 安装指南 + +## 系统依赖 + +### FFmpeg(必需) + +FFmpeg用于从视频中提取关键帧,是必需依赖。 + +#### Windows + +```powershell +# 使用 Chocolatey +choco install ffmpeg + +# 或使用 Scoop +scoop install ffmpeg + +# 或手动下载 +# 访问 https://ffmpeg.org/download.html 下载并添加到PATH +``` + +#### macOS + +```bash +brew install ffmpeg +``` + +#### Linux (Ubuntu/Debian) + +```bash +sudo apt update +sudo apt install ffmpeg +``` + +#### Linux (CentOS/RHEL) + +```bash +sudo yum install epel-release +sudo yum install ffmpeg +``` + +#### 验证安装 + +```bash +ffmpeg -version +``` + +## Python依赖 + +```bash +# 创建虚拟环境(推荐) +python -m venv venv +source venv/bin/activate # Linux/macOS +.\venv\Scripts\activate # Windows + +# 安装依赖 +pip install requests yt-dlp +``` + +### 依赖说明 + +| 包 | 版本 | 用途 | +|---|------|------| +| `requests` | >=2.28.0 | HTTP请求,获取视频元数据 | +| `yt-dlp` | >=2023.0.0 | 视频下载 | + +### 开发依赖(可选) + +```bash +pip install hypothesis pytest +``` + +| 包 | 版本 | 用途 | +|---|------|------| +| `hypothesis` | >=6.0.0 | 属性测试 | +| `pytest` | >=7.0.0 | 单元测试 | diff --git a/skills/bilibili-analyzer/scripts/prepare.cs b/skills/bilibili-analyzer/scripts/prepare.cs new file mode 100644 index 0000000..fcb70e7 --- /dev/null +++ b/skills/bilibili-analyzer/scripts/prepare.cs @@ -0,0 +1,599 @@ +#!/usr/bin/env dotnet run + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +// Parse arguments (args is already available in top-level statements) +if (args.Length == 0 || args.Contains("-h") || args.Contains("--help")) +{ + PrintHelp(); + return; +} + +var url = args[0]; +var outputDir = GetArgValue(args, "-o", "--output") ?? "."; +var fpsStr = GetArgValue(args, "--fps") ?? "1.0"; +if (!double.TryParse(fpsStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var fps)) +{ + Console.WriteLine($"[ERROR] Invalid fps value: {fpsStr}"); + Environment.Exit(1); +} +var similarityStr = GetArgValue(args, "--similarity") ?? "0.80"; +if (!double.TryParse(similarityStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var similarityThreshold)) +{ + Console.WriteLine($"[ERROR] Invalid similarity value: {similarityStr}"); + Environment.Exit(1); +} +var videoOnly = args.Contains("--video-only"); +var framesOnly = args.Contains("--frames-only"); +var noDedup = args.Contains("--no-dedup"); + +var videoPath = Path.Combine(outputDir, "video.mp4"); +var imagesDir = Path.Combine(outputDir, "images"); + +// Create output directory +Directory.CreateDirectory(outputDir); + +Console.WriteLine(new string('=', 50)); +Console.WriteLine("Bilibili Video Analyzer - Prepare Script"); +Console.WriteLine(new string('=', 50)); +Console.WriteLine($"URL: {url}"); +Console.WriteLine($"Output: {outputDir}"); +Console.WriteLine($"FPS: {fps}"); +Console.WriteLine($"Similarity Threshold: {similarityThreshold:P0}"); +Console.WriteLine($"Deduplication: {(noDedup ? "Disabled" : "Enabled")}"); +Console.WriteLine(new string('=', 50)); + +// Download video +if (!framesOnly) +{ + if (!await DownloadBilibiliVideoAsync(url, videoPath)) + { + Environment.Exit(1); + } +} + +// Extract frames +if (!videoOnly) +{ + if (!File.Exists(videoPath)) + { + Console.WriteLine($"[ERROR] Video file not found: {videoPath}"); + Environment.Exit(1); + } + + if (!await ExtractFramesAsync(videoPath, imagesDir, fps)) + { + Environment.Exit(1); + } + + // Deduplicate similar frames + if (!noDedup) + { + await DeduplicateFramesAsync(imagesDir, similarityThreshold); + } +} + +Console.WriteLine(); +Console.WriteLine(new string('=', 50)); +Console.WriteLine("[OK] Done!"); +Console.WriteLine($" Video: {videoPath}"); +Console.WriteLine($" Images: {imagesDir}/"); +Console.WriteLine(new string('=', 50)); + +// === Functions === + +async Task DownloadBilibiliVideoAsync(string url, string outputPath) +{ + Console.WriteLine($"[INFO] Downloading video: {url}"); + + try + { + // Extract BV ID from URL + var bvid = ExtractBvid(url); + if (string.IsNullOrEmpty(bvid)) + { + Console.WriteLine("[ERROR] Invalid Bilibili URL, cannot extract BV ID"); + return false; + } + Console.WriteLine($"[INFO] BV ID: {bvid}"); + + using var client = CreateHttpClient(); + + // Step 1: Get video info to obtain cid + Console.WriteLine("[INFO] Fetching video info..."); + var infoUrl = $"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"; + var infoJson = await client.GetStringAsync(infoUrl); + using var infoDoc = JsonDocument.Parse(infoJson); + + var code = infoDoc.RootElement.GetProperty("code").GetInt32(); + if (code != 0) + { + var message = infoDoc.RootElement.GetProperty("message").GetString(); + Console.WriteLine($"[ERROR] Failed to get video info: {message}"); + return false; + } + + var data = infoDoc.RootElement.GetProperty("data"); + var title = data.GetProperty("title").GetString(); + var cid = data.GetProperty("cid").GetInt64(); + Console.WriteLine($"[INFO] Title: {title}"); + Console.WriteLine($"[INFO] CID: {cid}"); + + // Step 2: Get playback URL + Console.WriteLine("[INFO] Fetching playback URL..."); + var playUrl = $"https://api.bilibili.com/x/player/playurl?bvid={bvid}&cid={cid}&qn=80&fnval=1"; + var playJson = await client.GetStringAsync(playUrl); + using var playDoc = JsonDocument.Parse(playJson); + + var playCode = playDoc.RootElement.GetProperty("code").GetInt32(); + if (playCode != 0) + { + var message = playDoc.RootElement.GetProperty("message").GetString(); + Console.WriteLine($"[ERROR] Failed to get playback URL: {message}"); + return false; + } + + var playData = playDoc.RootElement.GetProperty("data"); + var durl = playData.GetProperty("durl")[0]; + var videoUrl = durl.GetProperty("url").GetString(); + var size = durl.GetProperty("size").GetInt64(); + + Console.WriteLine($"[INFO] Video size: {size / 1024.0 / 1024.0:F1} MB"); + + // Step 3: Download video + Console.WriteLine("[INFO] Downloading video file..."); + + using var request = new HttpRequestMessage(HttpMethod.Get, videoUrl); + request.Headers.Add("Referer", $"https://www.bilibili.com/video/{bvid}"); + + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? size; + if (totalBytes <= 0) totalBytes = size > 0 ? size : 1; // Prevent division by zero + + await using var contentStream = await response.Content.ReadAsStreamAsync(); + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + + var buffer = new byte[81920]; // Larger buffer for better performance + var totalRead = 0L; + var lastProgress = -1; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); + totalRead += bytesRead; + + var progress = (int)(totalRead * 100 / totalBytes); + if (progress != lastProgress && (progress % 10 == 0 || progress == 100)) + { + Console.WriteLine($"[INFO] Progress: {progress}%"); + lastProgress = progress; + } + } + + // Ensure 100% is shown + if (lastProgress != 100) + { + Console.WriteLine($"[INFO] Progress: 100%"); + } + + Console.WriteLine($"[OK] Video downloaded: {outputPath}"); + return true; + } + catch (HttpRequestException ex) + { + Console.WriteLine($"[ERROR] Network error: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + Console.WriteLine($"[ERROR] Failed to parse API response: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Download failed: {ex.Message}"); + return false; + } +} + +string? ExtractBvid(string url) +{ + // Match BV ID from various URL formats + // https://www.bilibili.com/video/BV1xx411c7mD + // https://www.bilibili.com/video/BV1xx411c7mD?p=1 + // https://b23.tv/BV1xx411c7mD + // BV1xx411c7mD + // BV ID is exactly 12 characters: BV + 10 alphanumeric chars + var match = Regex.Match(url, @"BV[a-zA-Z0-9]{10}"); + return match.Success ? match.Value : null; +} + +HttpClient CreateHttpClient() +{ + var handler = new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate + }; + + var client = new HttpClient(handler); + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Add("Referer", "https://www.bilibili.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json, text/plain, */*"); + client.Timeout = TimeSpan.FromMinutes(30); + + return client; +} + +async Task ExtractFramesAsync(string videoPath, string outputDir, double fps) +{ + Console.WriteLine($"[INFO] Extracting frames (fps={fps})"); + + var ffmpeg = FindExecutable("ffmpeg", "ffmpeg.exe"); + if (ffmpeg == null) + { + Console.WriteLine("[ERROR] ffmpeg not found!"); + Console.WriteLine(" Windows: choco install ffmpeg / scoop install ffmpeg"); + Console.WriteLine(" macOS: brew install ffmpeg"); + Console.WriteLine(" Linux: sudo apt install ffmpeg"); + return false; + } + + Directory.CreateDirectory(outputDir); + var outputPattern = Path.Combine(outputDir, "frame_%04d.jpg"); + + try + { + Console.WriteLine($"[INFO] Running ffmpeg: {ffmpeg}"); + + var psi = new ProcessStartInfo + { + FileName = ffmpeg, + Arguments = $"-i \"{videoPath}\" -vf \"fps={fps.ToString(CultureInfo.InvariantCulture)}\" -q:v 2 -y \"{outputPattern}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) + { + Console.WriteLine("[ERROR] Failed to start ffmpeg"); + return false; + } + + // Read stderr asynchronously to prevent deadlock + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + var stderr = await stderrTask; + + // ffmpeg returns 0 on success, non-zero on failure + if (process.ExitCode != 0) + { + // Only show last few lines of error + var errorLines = stderr.Split('\n').TakeLast(5); + Console.WriteLine($"[ERROR] Frame extraction failed (exit code {process.ExitCode}):"); + foreach (var line in errorLines) + { + if (!string.IsNullOrWhiteSpace(line)) + Console.WriteLine($" {line.Trim()}"); + } + return false; + } + + var frameCount = Directory.GetFiles(outputDir, "frame_*.jpg").Length; + if (frameCount == 0) + { + Console.WriteLine("[ERROR] No frames extracted. Check if video file is valid."); + return false; + } + + Console.WriteLine($"[OK] Frames extracted: {frameCount} images saved to {outputDir}/"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Frame extraction failed: {ex.Message}"); + return false; + } +} + +async Task DeduplicateFramesAsync(string imagesDir, double threshold) +{ + Console.WriteLine($"[INFO] Deduplicating similar frames (threshold: {threshold:P0})..."); + + var files = Directory.GetFiles(imagesDir, "frame_*.jpg") + .OrderBy(f => f) + .ToList(); + + if (files.Count < 2) + { + Console.WriteLine("[INFO] Not enough frames to deduplicate"); + return; + } + + var toDelete = new List(); + var ffmpeg = FindExecutable("ffmpeg", "ffmpeg.exe"); + + if (ffmpeg == null) + { + Console.WriteLine("[WARN] ffmpeg not found, skipping deduplication"); + return; + } + + // Compare consecutive frames only (not cross-frame comparison) + for (int i = 0; i < files.Count - 1; i++) + { + var current = files[i]; + var next = files[i + 1]; + + // Skip if current frame is already marked for deletion + if (toDelete.Contains(current)) + continue; + + var similarity = await CalculateFrameSimilarityAsync(ffmpeg, current, next); + + if (similarity >= threshold) + { + // Keep the first frame, mark the next one for deletion + toDelete.Add(next); + } + } + + // Delete similar frames + foreach (var file in toDelete) + { + try + { + File.Delete(file); + } + catch { } + } + + // Renumber remaining frames + var remainingFiles = Directory.GetFiles(imagesDir, "frame_*.jpg") + .OrderBy(f => f) + .ToList(); + + for (int i = 0; i < remainingFiles.Count; i++) + { + var newName = Path.Combine(imagesDir, $"frame_{i + 1:D4}.jpg"); + if (remainingFiles[i] != newName) + { + // Use temp name to avoid conflicts + var tempName = Path.Combine(imagesDir, $"temp_{i + 1:D4}.jpg"); + File.Move(remainingFiles[i], tempName); + } + } + + // Rename temp files to final names + var tempFiles = Directory.GetFiles(imagesDir, "temp_*.jpg").OrderBy(f => f).ToList(); + for (int i = 0; i < tempFiles.Count; i++) + { + var finalName = Path.Combine(imagesDir, $"frame_{i + 1:D4}.jpg"); + File.Move(tempFiles[i], finalName); + } + + var finalCount = Directory.GetFiles(imagesDir, "frame_*.jpg").Length; + Console.WriteLine($"[OK] Deduplication complete: {files.Count} -> {finalCount} frames (removed {toDelete.Count} similar frames)"); +} + +async Task CalculateFrameSimilarityAsync(string ffmpeg, string file1, string file2) +{ + try + { + // Use ffmpeg to calculate PSNR (Peak Signal-to-Noise Ratio) between two images + // Higher PSNR = more similar images + var psi = new ProcessStartInfo + { + FileName = ffmpeg, + Arguments = $"-i \"{file1}\" -i \"{file2}\" -lavfi \"psnr\" -f null -", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return 0; + + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + var stderr = await stderrTask; + + // Parse PSNR value from output + // Format: [Parsed_psnr_0 @ ...] PSNR y:XX.XX u:XX.XX v:XX.XX average:XX.XX min:XX.XX max:XX.XX + var match = Regex.Match(stderr, @"average:(\d+\.?\d*)", RegexOptions.IgnoreCase); + if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var psnr)) + { + // Convert PSNR to similarity percentage + // PSNR > 40 dB is considered very similar (>95%) + // PSNR > 30 dB is considered similar (>80%) + // PSNR = infinity means identical images + if (psnr > 100) return 1.0; // Identical or near-identical + if (psnr > 40) return 0.95 + (psnr - 40) * 0.001; + if (psnr > 30) return 0.80 + (psnr - 30) * 0.015; + if (psnr > 20) return 0.50 + (psnr - 20) * 0.03; + return psnr * 0.025; + } + + // Fallback: use simpler SSIM if PSNR parsing fails + return await CalculateSSIMAsync(ffmpeg, file1, file2); + } + catch + { + return 0; + } +} + +async Task CalculateSSIMAsync(string ffmpeg, string file1, string file2) +{ + try + { + var psi = new ProcessStartInfo + { + FileName = ffmpeg, + Arguments = $"-i \"{file1}\" -i \"{file2}\" -lavfi \"ssim\" -f null -", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return 0; + + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + var stderr = await stderrTask; + + // Parse SSIM value: All:0.XXXXX + var match = Regex.Match(stderr, @"All:(\d+\.?\d*)", RegexOptions.IgnoreCase); + if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var ssim)) + { + return ssim; // SSIM is already 0-1 range + } + + return 0; + } + catch + { + return 0; + } +} + +string? FindExecutable(params string[] names) +{ + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + var paths = pathEnv.Split(Path.PathSeparator); + + foreach (var name in names) + { + foreach (var path in paths) + { + try + { + var fullPath = Path.Combine(path, name); + if (File.Exists(fullPath)) return fullPath; + } + catch { } // Ignore invalid paths + } + } + + // Common Windows paths + if (OperatingSystem.IsWindows()) + { + var commonPaths = new[] + { + @"C:\ffmpeg\bin", + @"C:\Program Files\ffmpeg\bin", + @"C:\tools\ffmpeg\bin", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "scoop", "shims"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WinGet", "Links"), + }; + + foreach (var basePath in commonPaths) + { + foreach (var name in names) + { + try + { + var fullPath = Path.Combine(basePath, name); + if (File.Exists(fullPath)) return fullPath; + } + catch { } + } + } + } + + // Try where/which command + try + { + var cmd = OperatingSystem.IsWindows() ? "where" : "which"; + var psi = new ProcessStartInfo + { + FileName = cmd, + Arguments = names[0], + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(5000); // 5 second timeout + var output = process.StandardOutput.ReadToEnd()?.Trim(); + if (!string.IsNullOrEmpty(output)) + { + var firstLine = output.Split('\n')[0].Trim(); + if (File.Exists(firstLine)) return firstLine; + } + } + } + catch { } + + return null; +} + +string? GetArgValue(string[] args, params string[] names) +{ + for (int i = 0; i < args.Length - 1; i++) + { + if (names.Contains(args[i])) + { + return args[i + 1]; + } + } + return null; +} + +void PrintHelp() +{ + Console.WriteLine(@" +Bilibili Video Analyzer - Prepare Script + +Usage: + dotnet run prepare.cs [options] + +Arguments: + url Bilibili video URL (required) + +Options: + -o, --output Output directory (default: current) + --fps Frames per second (default: 1.0) + --similarity Similarity threshold for deduplication (default: 0.80) + --no-dedup Disable frame deduplication + --video-only Only download video, skip frame extraction + --frames-only Only extract frames (requires existing video.mp4) + -h, --help Show this help + +Examples: + dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" + dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --fps 0.5 + dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" -o ./output + dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --similarity 0.85 + dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --no-dedup + +Deduplication: + Consecutive frames with similarity >= threshold will be deduplicated. + Only compares adjacent frames (not cross-frame comparison). + Uses ffmpeg SSIM/PSNR for accurate similarity calculation. + +Requirements: + - .NET 10 SDK + - ffmpeg: https://ffmpeg.org/download.html +"); +}