修改-analyzer技能
This commit is contained in:
+2
-4
@@ -1,14 +1,12 @@
|
||||
# IDENTITY.md - Who Am I?
|
||||
|
||||
- **Name:** 铜锣湾陈浩南
|
||||
- **Creature:** AI 助手
|
||||
- **Vibe:** 实用、高效、有点幽默
|
||||
- **Creature:** 蒋先生的得力助手
|
||||
- **Vibe:** 严谨,一丝不苟,绝对遵守流程
|
||||
- **Emoji:** 🧙♂️
|
||||
- **Avatar:**
|
||||
|
||||
---
|
||||
|
||||
Notes:
|
||||
- 开启会话时说:"老大,我来了,今天我们砍谁?"
|
||||
- 用中文沟通
|
||||
- 收到消息立刻先回复"收到,老大,我这就去办",再继续安排工作。
|
||||
|
||||
Executable
+19
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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分析内容,最终生成**高质量的专题文档或实操教程**。
|
||||
|
||||
**核心特点**:
|
||||
- 不是简单的时间线记录,而是**重新组织整理**成一篇完整的文档
|
||||
- 实操类视频 → 生成**可直接使用的操作教程**
|
||||
- 知识类视频 → 生成**结构化的专题文档**
|
||||
- 报告中插入关键截图,使用 `` 格式
|
||||
|
||||
## 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...
|
||||
 ← 图片可能是其他内容
|
||||
|
||||
正确示例 ✅:
|
||||
### 安装 Node.js
|
||||
首先下载 Node.js...
|
||||
 ← 图片确实是下载页面
|
||||
```
|
||||
|
||||
**生成文档的正确流程**:
|
||||
|
||||
1. **先整理 OCR 识别结果**
|
||||
- 读取 `ocr_results.json` 文件
|
||||
- 汇总所有帧的文字内容
|
||||
- 建立「帧号 → 文字内容」的对应关系
|
||||
|
||||
2. **按主题重新组织内容**(不是按时间顺序)
|
||||
- 将相关内容归类到同一章节
|
||||
- 确定每个章节需要哪些帧的信息
|
||||
- 按逻辑顺序组织,而不是按视频时间线
|
||||
|
||||
3. **插入图片时必须核对**
|
||||
- 只插入与当前内容**直接相关**的图片
|
||||
- 图片描述要准确反映图片实际内容
|
||||
- 使用格式: ``
|
||||
|
||||
4. **代码必须来自图片中的实际代码**
|
||||
- 不要自己编造代码
|
||||
- 代码块标注来源: `<!-- 来自 frame_0025 -->`
|
||||
|
||||
**重要原则**:
|
||||
1. **图文对应** - 每张图片必须与其上下文内容匹配
|
||||
2. **不要时间线流水账** - 重新组织内容,像写文章一样
|
||||
3. **结构清晰** - 有章节划分和逻辑顺序
|
||||
4. **代码真实** - 只使用图片中出现的代码,不要编造
|
||||
5. **独立可读** - 不看视频也能完全理解
|
||||
|
||||
## Output Format
|
||||
|
||||
### 实操教程类
|
||||
|
||||
```markdown
|
||||
# {教程主题}
|
||||
|
||||
## 简介
|
||||
|
||||
{教程目标}
|
||||
{前置条件和要求}
|
||||
|
||||
## 环境准备
|
||||
|
||||
{需要安装的软件}
|
||||
{配置要求}
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. {步骤标题}
|
||||
|
||||
{详细说明,内容必须与下方图片对应}
|
||||
|
||||

|
||||
|
||||
<!-- 代码来自 frame_xxxx -->
|
||||
```代码块```
|
||||
|
||||
### 2. {步骤标题}
|
||||
|
||||
{详细说明}
|
||||
|
||||

|
||||
|
||||
...
|
||||
|
||||
## 完整代码
|
||||
|
||||
<!-- 汇总自 frame_xxxx, frame_xxxx, frame_xxxx -->
|
||||
{汇总所有代码片段,标注来源帧号}
|
||||
|
||||
## 常见问题
|
||||
|
||||
{可能遇到的问题和解决方案}
|
||||
|
||||
## 总结
|
||||
|
||||
{核心要点回顾}
|
||||
{延伸学习建议}
|
||||
```
|
||||
|
||||
### 知识文档类
|
||||
|
||||
```markdown
|
||||
# {主题}
|
||||
|
||||
## 概述
|
||||
|
||||
{主题背景介绍}
|
||||
{为什么重要}
|
||||
|
||||
## {章节1标题}
|
||||
|
||||
{内容,必须与配图对应}
|
||||
|
||||

|
||||
|
||||
## {章节2标题}
|
||||
|
||||
{内容}
|
||||
|
||||

|
||||
|
||||
## 核心要点
|
||||
|
||||
- 要点1
|
||||
- 要点2
|
||||
- 要点3
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
{相关资源和建议}
|
||||
```
|
||||
|
||||
### 图片插入规范
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **帧号必须标注** | `` |
|
||||
| **描述必须准确** | 描述图片的实际内容,不是期望内容 |
|
||||
| **内容必须匹配** | 图片上方/下方的文字必须与图片内容相关 |
|
||||
| **代码标注来源** | `<!-- 代码来自 frame_0025 -->` |
|
||||
| **不要乱插图** | 没有合适的图就不插,不要强行配图 |
|
||||
|
||||
## 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
|
||||
@@ -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 完成 =========="
|
||||
@@ -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/ # 检查点数据
|
||||
```
|
||||
@@ -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` | 报告生成失败 | 检查输出目录权限 |
|
||||
@@ -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 | 单元测试 |
|
||||
@@ -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<bool> 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<bool> 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<string>();
|
||||
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<double> 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<double> 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 <url> [options]
|
||||
|
||||
Arguments:
|
||||
url Bilibili video URL (required)
|
||||
|
||||
Options:
|
||||
-o, --output <dir> Output directory (default: current)
|
||||
--fps <value> Frames per second (default: 1.0)
|
||||
--similarity <value> 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
|
||||
");
|
||||
}
|
||||
Reference in New Issue
Block a user