@pls-compile-owo/fisheye 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范与 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## [0.3.0] - 2026-11-14
6
+
7
+ ### Changed
8
+
9
+ - 主程序拆分为模块:`lib/env.mjs`(路径/依赖解析)、`lib/util.mjs`(时间/参数工具)、`lib/bili.mjs`(B站 API)、`lib/frame.mjs`(抽帧与时间点)、`lib/danmaku.mjs`(弹幕分析)、`lib/stages.mjs`(六个子命令);入口只保留 CLI 分发。
10
+ - `selftest`:缓存目录缺失时不再报错。
11
+
12
+ ### Added
13
+
14
+ - 单元测试(`node --test`):参数解析、弹幕分析。
15
+ - GitHub Actions CI:Node 18/20/22 语法检查 + 单元测试。
16
+
17
+ ## [0.2.1] - 2026-11-14
18
+
19
+ 首个公开发布版本。
20
+
21
+ ### Added
22
+
23
+ - 六个子命令:`auto`(自动规划铺网)、`grid`(分段铺网)、`subs`(字幕带)、`refine`(关键帧精读)、`grab`(按时间点截帧)、`local`(本地视频)。
24
+ - 四层信息源优先级:官方字幕 → 烧录字幕带 → 弹幕热区 → 画面拼图。
25
+ - 字幕带自动定位(行梯度 + 跨帧分位 + 峰值检测)与官方字幕质量判断。
26
+ - 九宫格铺网:等间隔骨架 + 吸附画面变化点 + 阈值阶梯 + 去重。
27
+ - 关键帧精读:模型决策(--template / --decisions)与几何兜底两种模式。
28
+ - 弹幕过滤(刷屏 / 重复 / 引流 / 联系方式)。
29
+ - `selftest` 自检:依赖、凭证、API、磁盘。
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fisheye contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Third-party code:
26
+ - danmaku-filter.mjs is derived from SurfCore (https://github.com/Mieksido/surfcore),
27
+ MIT License, Copyright (c) 2026 Derpyu520 and qq-bridge / QSH contributors.
package/README.en.md ADDED
@@ -0,0 +1,97 @@
1
+ # fisheye
2
+
3
+ [English](./README.en.md) | [中文](./README.md)
4
+
5
+ > Let AI agents *see* videos on a token budget — instead of grabbing one frame every 10 seconds.
6
+
7
+ A CLI tool that **understands a Bilibili video within a given token budget**:
8
+
9
+ ```bash
10
+ node fisheye.mjs auto BV1xxx --budget 20000
11
+ ```
12
+
13
+ ## Why
14
+
15
+ Naive sampling ("1 frame per second") explodes token cost:
16
+
17
+ | Approach | 1-min video | 40-min video |
18
+ |---|---|---|
19
+ | 1 fps sampling | ~60 frames ≈ **15k tokens** | ~2400 frames ≈ **600k tokens** (over context) |
20
+ | fisheye | 1–2 grids (9–18 frames) ≈ **3–6k tokens** | 12 grids ≈ **30k tokens** |
21
+
22
+ The difference is not "sampling less" — it's **sampling the right moments and choosing the cheapest channel**.
23
+
24
+ ## Design: four information channels
25
+
26
+ | Priority | Channel | Cost | When |
27
+ |---|---|---|---|
28
+ | ① | Official subtitles | a few hundred tokens | video has a CC/AI subtitle track |
29
+ | ② | Burned-in subtitle band | ~1k tokens | no subtitle track, subtitles baked into frames |
30
+ | ③ | Danmaku hotspots | ~100 tokens | find where viewers reacted (invisible to pixels/subtitles) |
31
+ | ④ | Keyframe grids (3×3) | 1.2k–3.8k tokens/grid | fallback: when pixels are actually needed |
32
+
33
+ Then refine only a few key moments at original resolution.
34
+
35
+ ## Install
36
+
37
+ Requirements: Node.js 18+, ffmpeg.
38
+
39
+ ```bash
40
+ # Option 1: install from npm (recommended)
41
+ npm i -g @pls-compile-owo/fisheye
42
+
43
+ # Option 2: from source
44
+ git clone <repo> && cd fisheye
45
+ cp credentials.example.json credentials.json # fill SESSDATA / bili_jct / buvid3
46
+ node fisheye.mjs selftest --deep
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ # Bilibili video (auto planning + grids)
53
+ node fisheye.mjs auto BV1xxx --budget 20000 [--density 6] [--from 30] [--to 90] [--p 2]
54
+
55
+ # Local video file (no Bilibili login needed)
56
+ node fisheye.mjs local ./video.mp4 [--grid 3x3] [--from 30] [--to 90]
57
+
58
+ # Manual stages
59
+ node fisheye.mjs grid BV1xxx --chunk 60 --grid 3x3
60
+ node fisheye.mjs subs BV1xxx --count 14
61
+ node fisheye.mjs refine BV1xxx [--template | --decisions d.json]
62
+ node fisheye.mjs grab BV1xxx 69.6,75.3
63
+ ```
64
+
65
+ The CLI prints `SHEET: <path>` — feed that image to your multimodal model via
66
+ `read_image`. (Optional: wrap the CLI in an MCP server if your agent environment supports it.)
67
+
68
+ ## Docs
69
+
70
+ - `docs/DESIGN.md` — design rationale
71
+ - `docs/EXPERIMENTS.md` — five rounds of real experiments
72
+ - `docs/CONFIG.md` — configuration & troubleshooting
73
+
74
+ ## Credits
75
+
76
+ fisheye started as a video-understanding command inside a DSH plugin and was later
77
+ split into a standalone CLI. Its design ideas (official subtitles first, density by
78
+ motion rate, refine loop) and part of the directory layout were informed by
79
+ **SurfCore** (a QQ ↔ DeepSeek Harness bridge and Bilibili tooling plugin),
80
+ MIT License, Copyright (c) 2026 Derpyu520 and qq-bridge / QSH contributors.
81
+
82
+ `danmaku-filter.mjs` is derived from the same module in SurfCore and keeps the
83
+ original MIT copyright notice — see [LICENSE](./LICENSE) for the third-party code notice.
84
+
85
+ Third-party dependencies:
86
+
87
+ | Dependency | Purpose | License |
88
+ |---|---|---|
89
+ | [ffmpeg](https://ffmpeg.org/) | frame extraction, tiling, subtitle-band slicing | LGPL/GPL |
90
+ | [numpy](https://numpy.org/) · [Pillow](https://python-pillow.org/) | subtitle-band detection (optional) | BSD / MIT-CMU |
91
+
92
+ ## License
93
+
94
+ [MIT](./LICENSE) © 2026 fisheye contributors
95
+
96
+ For personal study/research only. Bilibili video, subtitle, danmaku and comment
97
+ copyrights belong to their original authors/uploaders.
package/README.md CHANGED
@@ -1,3 +1,301 @@
1
- # Temporary Holding Version
1
+ # fisheye
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](./README.en.md) | [中文](./README.md)
4
+
5
+ > 让 AI agent 按需看图,而不是每 10 秒抓一帧。
6
+
7
+ 一个命令行工具:**在 token 预算内,尽可能看懂一条 B 站视频**。
8
+
9
+ ```
10
+ node fisheye.mjs auto BV1xxx --budget 20000
11
+ ```
12
+
13
+ ---
14
+
15
+ ## 为什么
16
+
17
+ 给 AI 看视频最朴素的做法是「1 秒 1 帧」:
18
+
19
+ | 方式 | 1 分钟视频 | 40 分钟视频 |
20
+ |---|---|---|
21
+ | 1 fps 均匀采样 | ~60 帧 ≈ **1.5 万 token** | ~2400 帧 ≈ **60 万 token**(超上下文) |
22
+ | fisheye | 1~2 张拼图(9~18 帧)≈ **3~6 千 token** | 12 张拼图 ≈ **3 万 token** |
23
+
24
+ 差别不在「抽得少」,而在**抽得准**——fisheye 判断哪些时间点值得看,以及**用什么方式看最省**。
25
+
26
+ ## 核心思路:四路信息源 + 一个闭环
27
+
28
+ 不是所有信息都要用图片读。按「单位 token 的信息量」排序,能省的先省:
29
+
30
+ | 优先级 | 信息源 | 成本 | 什么时候用 |
31
+ |---|---|---|---|
32
+ | ① | **官方字幕**(CC 字幕轨,优先 AI 中文字幕) | 几百 token | 视频有字幕时——文本比图便宜一个数量级 |
33
+ | ② | **烧录字幕带**(字幕烧进画面) | ~1000 token | 无官方字幕但画面底部有字幕 |
34
+ | ③ | **弹幕热区**(观众反应) | ~100 token | 找观众最激动的时间点,**画面和字幕都看不出这个** |
35
+ | ④ | **画面拼图**(3×3 网格) | 1200~3800 token/张 | 兜底:真正需要看画面时 |
36
+
37
+ 然后对少数关键点做**精读**(原分辨率单帧),只在那里花大钱。
38
+
39
+ ## 安装
40
+
41
+ **前置**:Node.js 18+、ffmpeg
42
+
43
+ **可选**:Python 3.9+(带 `numpy` 和 `Pillow`)——不装也能用,只是「烧录字幕带」功能会降级
44
+
45
+ ```bash
46
+ # 方式一:npm 全局安装(推荐)
47
+ npm i -g @pls-compile-owo/fisheye
48
+
49
+ # 方式二:从源码
50
+ git clone <this-repo>
51
+ cd fisheye
52
+ npm link # 可选:装成全局命令
53
+ ```
54
+
55
+ ### 配置 B 站登录态
56
+
57
+ 复制 `credentials.example.json` 为 `credentials.json`,填入三个值:
58
+
59
+ ```json
60
+ {
61
+ "sessdata": "<你的 SESSDATA>",
62
+ "bili_jct": "<你的 bili_jct>",
63
+ "buvid3": "<你的 buvid3>"
64
+ }
65
+ ```
66
+
67
+ 获取:浏览器登录 B 站 → F12 → Application → Cookies → `bilibili.com`,复制这三个字段。
68
+
69
+ > ⚠️ 这个文件已被 `.gitignore` 排除,**永远不要提交**。
70
+
71
+ ### 依赖路径(一般不用管)
72
+
73
+ fisheye 按这个顺序找 ffmpeg / python / 凭证:
74
+
75
+ ```
76
+ 环境变量(FFMPEG / PYTHON / BILI_CRED)
77
+ → ~/.config/fisheye/config.json
78
+ → 项目目录下的 bin/ 和 credentials.json
79
+ → PATH
80
+ ```
81
+
82
+ 都找不到时才报错,并直接给出修复命令。也可以手动写配置:
83
+
84
+ ```json
85
+ // ~/.config/fisheye/config.json
86
+ {
87
+ "ffmpeg": "C:/tools/ffmpeg/bin/ffmpeg.exe",
88
+ "python": "C:/Python312/python.exe",
89
+ "credentials": "D:/secrets/bili.json"
90
+ }
91
+ ```
92
+
93
+ ## 快速开始
94
+
95
+ ```bash
96
+ # 1. 先自检(0.6 秒,确认依赖/凭证/API 都通)
97
+ node fisheye.mjs selftest
98
+
99
+ # 2. 自动规划并铺网
100
+ node fisheye.mjs auto BV1ziWP6qENy --budget 20000
101
+
102
+ # 3. 看 out/ 目录里的拼图,或读 manifest 决定要不要精读
103
+ node fisheye.mjs refine BV1ziWP6qENy
104
+
105
+ # 4. 精读:截原分辨率单帧
106
+ node fisheye.mjs grab BV1ziWP6qENy 82.5,145.0
107
+ ```
108
+
109
+ ### 常用参数
110
+
111
+ ```bash
112
+ --budget 20000 # token 预算(决定张数上限)
113
+ --from 1:30 --to 3:00 # 只看一段
114
+ --density 6 # 强制 6 秒/帧(覆盖自动推导)
115
+ --no-danmaku-frames # 弹幕热区不参与铺网取帧
116
+ --p 2 # 多 P 视频的第 2 P
117
+ ```
118
+
119
+ ## 输出
120
+
121
+ `auto` 会在 `out/` 生成:
122
+
123
+ | 文件 | 内容 |
124
+ |---|---|
125
+ | `<BV>-cNN_起-止_3x3.jpg` | 拼图(每格一张画面,从左到右、从上到下按时间排列) |
126
+ | `<BV>-subs_N行.jpg` | 字幕带(只有字幕区,竖排 N 行) |
127
+ | `<BV>-official-subs.txt` | 官方字幕全文(带时间戳);另有一份 `-official-subs-plain.txt` 纯文本 |
128
+ | `<BV>-manifest.json` | **结构化索引**:每张图的时间范围、每格时间戳、变化强度、弹幕热区、广告段 |
129
+
130
+ 终端输出一份「规划报告」,解释每个决策。
131
+
132
+ > 下面的示例**拼自三条不同视频**(竖屏猫狗 / LOL 游戏 / 40 分钟长视频),只用来说明每一段的格式——数字之间不用互相推导。
133
+
134
+ ```
135
+ [比例] 画面 1080x1920 → 拼图 1920x3414,单张约 3793 token
136
+ [内容] 显著变化 14 处,变化率 0.13 次/秒
137
+ [字幕] 无可用官方字幕,检出烧录字幕带(82%-90%,置信度 0.63)→ 字幕带 1 张
138
+ [广告段] 弹幕密度低谷 1 段:
139
+ 04:40-06:40(120s)疑似广告 密度比全片低 34% 关键词「感谢金主爸爸」
140
+ [弹幕] 335 条,观众最激动的时段:
141
+ 01:25 34 条 「打破桎梏×8」「翻山覆海×3」
142
+ [密度] 目标 ≤13s/帧(按变化率推导)
143
+ [张数] 密度需要 1 张,预算允许 4 张 → 采用 1 张
144
+ [取帧] 00:00-01:49 阈值 0.3,8/9 帧吸附
145
+ [拼图 1/1] 00:00-01:49 9 帧 → BVxxx-c00_0000-0109_3x3.jpg
146
+ [精读建议] 2 个时间点(几何 1 + 弹幕 1):
147
+ 00:27 弹幕热区 —— 弹幕热区 19 条
148
+ ```
149
+
150
+ ## 命令
151
+
152
+ | 命令 | 作用 |
153
+ |---|---|
154
+ | `auto <BV>` | 推荐入口:自动规划密度/张数/字幕策略,然后铺网 |
155
+ | `grid <BV>` | 只铺网(`--chunk` 秒/段、`--grid` 如 3x3) |
156
+ | `subs <BV>` | 只生成字幕带(`--count` 行数、`--band auto\|0.22`) |
157
+ | `refine <BV>` | 挑出值得精读的时间点,**并直接截好原图**(三种模式:模板 / 决策文件 / 几何兜底) |
158
+ | `grab <BV> <t1,t2>` | 按时间点截原分辨率单帧(手动指定) |
159
+ | `selftest [--deep]` | 自检:依赖 / 凭证 / API / 缓存 / 磁盘 |
160
+
161
+ > `grid` / `subs` / `refine` / `grab` 都要求 `cache/<BV>.mp4` 已存在——第一次用请先跑 `auto`(它会下载)。
162
+
163
+ ### 参数范围
164
+
165
+ | 参数 | 范围 | 默认 |
166
+ |---|---|---|
167
+ | `--budget` | 2000 ~ 200000 | 20000 |
168
+ | `--density` | 0.5 ~ 60(秒/帧) | 按变化率自动 |
169
+ | `--p` | 1 ~ 200 | 1 |
170
+ | `--chunk`(grid) | 1 ~ 3600(秒) | 按张数算 |
171
+ | `--count`(subs) | 1 ~ 50(行) | 自动 |
172
+ | `--grid`(grid) | 行列各 1 ~ 9 | 3x3 |
173
+ | `--band`(subs) | 0 ~ 0.6(相对高度) | auto |
174
+
175
+ ### refine 的三种挑点方式
176
+
177
+ | 方式 | 依据 | 成本 |
178
+ |---|---|---|
179
+ | `--template` | 生成模板,让模型看拼图后填「哪格看不清」 | 已付(看图时) |
180
+ | `--decisions <file>` | 按模型的结构化判断截帧 | 已付 |
181
+ | 无参数 | **几何兜底**:场景变化强度 ≥0.85 + 弹幕热区 | 每个选中点 1300 token(只截命中的点) |
182
+
183
+ > `refine` 列完点会**直接执行截帧**(没有「只列不截」的开关)。想先只看建议,用 `refine --template`——它只写模板文件,不截帧。
184
+
185
+ > 几何规则只能找「画面变化剧烈」,找不出「有文字」——后者必须真的看图。这是工具的设计边界,不是 bug。
186
+
187
+ ## 本地视频
188
+
189
+ ```bash
190
+ node fisheye.mjs local <视频文件> [--grid 3x3] [--from 30] [--to 90]
191
+ ```
192
+
193
+ 不依赖 B站 登录与字幕:ffmpeg 解析时长 → 按 grid 等间隔抽帧拼图 → 输出 `SHEET: <拼图路径>`。
194
+ 把拼图路径交给模型 `read_image` 即可"看"本地视频。适合 mp4/mkv/avi/mov/webm。
195
+
196
+ ## 它怎么工作
197
+
198
+ ```
199
+ ① 取元信息(分P / 时长 / 分辨率)
200
+ ② 时间范围(--from/--to)
201
+ ③ 画面比例 → 单张拼图 token 估算(竖屏是横屏的 3 倍!)
202
+ ④ 场景检测(ffmpeg 一次解码,320p)→ 内容变化率
203
+ ⑤ 字幕决策:官方字幕 > 烧录字幕带 > 只看画面
204
+ ⑥ 弹幕:热区(观众高光)+ 广告段(密度低谷 + 关键词)
205
+ ⑦ 密度决策:变化率 → 目标帧间隔(4/6/9/13 秒)
206
+ ⑧ 张数决策:密度需求 vs 预算封顶(硬上限 12 张)
207
+ ⑨ 铺网:分段 → 取帧(等间隔骨架 + 吸附画面变化点)→ 拼图
208
+ ⑩ 精读建议:场景变化 + 弹幕热区,避开广告段
209
+ ```
210
+
211
+ ### 几个关键设计
212
+
213
+ **为什么取帧是「等间隔 + 吸附」而不是纯关键帧?**
214
+
215
+ - 只等间隔 → 会漏掉「只闪一两秒的关键画面」
216
+ - 只吸附关键帧 → 转场全挤在开头,中段空档
217
+ - 混合:等间隔保覆盖,每个基准点在 ±半格内吸附到画面变化点
218
+
219
+ **为什么字幕带不用 OCR?**
220
+
221
+ 中文 OCR 在低分辨率、花哨排版下错误率高。把字幕带切下来交给多模态模型直接读,反而更准,成本也接近。代价是「一行字幕占一张图的宽度」,所以只切字幕区竖排。
222
+
223
+ **为什么弹幕能参与取帧?**
224
+
225
+ B 站的弹幕密度本身就是内容信号。实测一条 40 分钟视频:广告段的弹幕密度只有全片的 **31%**(观众跳过),而观众最激动的地方弹幕密度是 3~5 倍。
226
+
227
+ **为什么广告段可以不占帧?**
228
+
229
+ 把广告段从时间轴上「压缩掉」,剩余有效时间重新均分——帧数不变,内容密度提升。实测某张图:跳过 118 秒广告后,平均帧间隔从 22.1 秒降到 **8.3 秒**。
230
+
231
+ ## 已知限制
232
+
233
+ - **只支持 B 站**(接口依赖 BV 号 / cid / 字幕轨)
234
+ - **依赖登录态**:部分视频的字幕/播放地址需要 cookies
235
+ - **语义盲区**:场景检测只看像素变化,抓不到「同一个场景里的叙事切换」。变化率低的混剪视频可能被判「慢」,需要 `--density` 手动加密
236
+ - **广告段检测依赖弹幕**:无弹幕/关弹幕的视频检测不到;且只认「密度低谷 + 广告关键词」,观众讨论热烈的广告可能漏检
237
+ - **长视频成本高**:40 分钟视频官方字幕就要 1.2 万 token
238
+ - **B 站接口会变**:`selftest` 能帮你快速定位是哪个环节挂了
239
+
240
+ ## 开发
241
+
242
+ ```bash
243
+ node --test test/ # 单元测试(参数解析 / 弹幕分析)
244
+ node test-danmaku-filter.js # 弹幕过滤规则测试(51 条用例)
245
+ node fisheye.mjs selftest --deep # 含抽帧/字幕带实测的自检
246
+ ```
247
+
248
+ 项目结构:
249
+
250
+ | 文件 | 职责 |
251
+ |---|---|
252
+ | `fisheye.mjs` | CLI 入口(参数解析 / 依赖检查 / 命令分发) |
253
+ | `lib/env.mjs` | 路径与依赖解析(ffmpeg / python / 凭证 / 配置) |
254
+ | `lib/util.mjs` | 时间格式化 / 参数校验 / 文件头探测 |
255
+ | `lib/bili.mjs` | B站 API(视频信息 / WBI 签名 / 官方字幕 / 弹幕) |
256
+ | `lib/frame.mjs` | 抽帧与时间点选择(场景检测 / 字幕带定位 / 单帧截取) |
257
+ | `lib/danmaku.mjs` | 弹幕分析(广告段检测 / 热区摘要) |
258
+ | `lib/stages.mjs` | 六个子命令(auto / grid / subs / refine / grab / local) |
259
+ | `test/` | `node:test` 单元测试;CI 见 `.github/workflows/ci.yml` |
260
+
261
+ 更多文档:
262
+
263
+ - [`docs/DESIGN.md`](docs/DESIGN.md) — 设计决策与取舍
264
+ - [`docs/EXPERIMENTS.md`](docs/EXPERIMENTS.md) — 开发日志(十轮迭代,含实测数据)
265
+ - [`docs/CONFIG.md`](docs/CONFIG.md) — 配置与故障排查
266
+ - [`docs/REVIEW.md`](docs/REVIEW.md) — 验证流程与评审清单(改完代码跑这个)
267
+
268
+ ## 合规
269
+
270
+ 本项目仅用于**个人学习与研究**:
271
+
272
+ - 请遵守 B 站《用户协议》,勿商用、勿批量抓取
273
+ - 登录态 cookies 属于你的账号凭据,**泄露可能导致账号风险**
274
+ - 视频与字幕版权归原作者与平台所有,工具输出仅供个人参考
275
+
276
+ ## 致谢
277
+
278
+ ### 灵感来源与代码来源
279
+
280
+ fisheye 最初作为一个 DSH 插件内部的视频理解命令开发,之后独立为 CLI。
281
+ 设计思路(官方字幕优先、按变化率选密度、精读闭环)与部分目录约定参考了 **SurfCore**
282
+ (QQ ↔ DeepSeek Harness 桥接与 B站 工具插件,MIT License,
283
+ Copyright (c) 2026 Derpyu520 and qq-bridge / QSH contributors)。
284
+
285
+ `danmaku-filter.mjs`(弹幕垃圾过滤)衍生自 SurfCore 中的同名模块,按 MIT 许可保留原版权声明,
286
+ 详见 [LICENSE](./LICENSE) 末尾的第三方代码声明。
287
+
288
+ ### 第三方依赖
289
+
290
+ | 依赖 | 用途 | 许可 |
291
+ |---|---|---|
292
+ | [ffmpeg](https://ffmpeg.org/) | 抽帧、拼图、字幕带切片 | LGPL/GPL |
293
+ | [numpy](https://numpy.org/) · [Pillow](https://python-pillow.org/) | 字幕带位置检测(可选) | BSD / MIT-CMU |
294
+
295
+ ### 思想来源
296
+
297
+ 「先粗看再精读」这件事不是新想法,学术上叫 *hierarchical / agentic video understanding*。这个工具的价值不在提出新方法,而在把「token 预算」当成一等约束来工程设计——这一点在开源生态里反而少见,大多数方案默认算力免费。
298
+
299
+ ## License
300
+
301
+ MIT
@@ -0,0 +1,126 @@
1
+ # analyze_subs.py — 从一批帧里自动找出「字幕带」的纵向范围
2
+ #
3
+ # 原理:字幕是硬字幕,位置固定、每帧都在;画面内容在动,细节位置不稳定。
4
+ # ① 每帧只看底部 45% 区域,按行算水平梯度(文字=高频边缘)
5
+ # ② 跨帧取 30 分位 —— 字幕行每帧都强,低分位也高;偶发的画面细节被压掉
6
+ # ③ 平滑后找连续的高峰段 = 字幕带
7
+ #
8
+ # 用法: python analyze_subs.py <帧目录> <输出json>
9
+ import sys, os, json, glob
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+
14
+ def analyze(frames_dir):
15
+ files = sorted(glob.glob(os.path.join(frames_dir, '*.jpg')))
16
+ if not files:
17
+ return {"ok": False, "reason": "没有帧"}
18
+
19
+ profiles, H = [], None
20
+ for f in files:
21
+ g = np.asarray(Image.open(f).convert('L'), dtype=np.float32)
22
+ if H is None:
23
+ H = g.shape[0]
24
+ y0 = int(H * 0.55) # 只看底部 45%
25
+ band = g[y0:, :]
26
+ dx = np.abs(np.diff(band, axis=1)).mean(axis=1) # 每行水平梯度
27
+ profiles.append(dx)
28
+
29
+ L = min(len(p) for p in profiles)
30
+ P = np.stack([p[:L] for p in profiles]) # (n, L)
31
+ stable = np.percentile(P, 30, axis=0) # 跨帧低分位
32
+
33
+ k = 3
34
+ smooth = np.convolve(stable, np.ones(k) / k, mode='same') if L > k else stable
35
+
36
+ # 字幕几乎总在画面底部,先限定搜索区(底部 40%)
37
+ search_lo = max(0, int(len(smooth) * 0.25))
38
+ region = smooth[search_lo:]
39
+ if len(region) == 0:
40
+ region, search_lo = smooth, 0
41
+
42
+ # 找峰值,而不是套绝对阈值 —— 字幕是局部尖峰,画面平台容易抢先命中
43
+ base = float(np.percentile(region, 20)) # 基线
44
+ peak_local = int(region.argmax())
45
+ peak_val = float(region[peak_local])
46
+ thr = base + (peak_val - base) * 0.4 # 峰高的 40% 作为边界
47
+ peak_idx = search_lo + peak_local
48
+
49
+ # 从峰向两侧扩展到强度掉到阈值以下(允许 25 行间隙:中文行和日文行之间通常隔 20~30px)
50
+ gap_allow = 25
51
+ s = e = peak_idx
52
+ gap = 0
53
+ i = peak_idx - 1
54
+ while i >= 0:
55
+ if smooth[i] > thr:
56
+ s, gap = i, 0
57
+ elif gap >= gap_allow:
58
+ break
59
+ else:
60
+ gap += 1
61
+ i -= 1
62
+ gap = 0
63
+ i = peak_idx + 1
64
+ while i < len(smooth):
65
+ if smooth[i] > thr:
66
+ e, gap = i, 0
67
+ elif gap >= gap_allow:
68
+ break
69
+ else:
70
+ gap += 1
71
+ i += 1
72
+ e += 1
73
+
74
+ # 高度上限:字幕带不该超过搜索区的 1/4。
75
+ # 竖屏 1080x1920 实测踩过:阈值扩展会一路把画面中部整片内容连进来(检测出 531px,
76
+ # 而字幕只有 ~60px),拼出来 14 行里 11 行是画面。超限时收敛到峰值附近。
77
+ max_h = max(4, len(smooth) // 4)
78
+ if e - s > max_h:
79
+ half = max_h // 2
80
+ s = max(0, peak_idx - half)
81
+ e = min(len(smooth), s + max_h)
82
+
83
+ if e - s < 2: # 兜底:最底部 20%
84
+ s, e = max(0, L - int(L * 0.44)), L
85
+
86
+ # 置信度:这个区间里「每帧都明显比画面自身更强」的帧占多少。
87
+ # 抖音式动态字幕(位置漂移、不是每帧都有)会得到低分,调用方据此决定要不要用字幕带。
88
+ # 用 75 分位而不是 mean —— 字幕只占带内几十像素,mean 会被画面内容稀释(实测得 0.00)
89
+ if e > s:
90
+ per_frame_peak = np.percentile(P[:, s:e], 75, axis=1)
91
+ per_frame_base = np.percentile(P, 50, axis=1)
92
+ confidence = float((per_frame_peak > per_frame_base * 1.5).mean())
93
+ else:
94
+ confidence = 0.0
95
+
96
+ pad = 3 # 上下留点余量,别切到笔画
97
+ s = max(0, s - pad)
98
+ e = min(L, e + pad)
99
+
100
+ top, bot = y0 + s, y0 + e
101
+ return {
102
+ "ok": True,
103
+ "videoH": int(H),
104
+ "frames": len(files),
105
+ "region": {"top": int(top), "bottom": int(bot)},
106
+ "topRatio": round(top / H, 4),
107
+ "botRatio": round(bot / H, 4),
108
+ "bandRatio": round((bot - top) / H, 4),
109
+ "confidence": round(confidence, 3), # 这个区间里有多少帧真有东西(<0.5 说明字幕不规律)
110
+ "threshold": round(thr, 2),
111
+ "profile": [round(float(x), 2) for x in smooth], # 调试用
112
+ }
113
+
114
+
115
+ if __name__ == '__main__':
116
+ src = sys.argv[1]
117
+ dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(src, 'subs-analysis.json')
118
+ result = analyze(src)
119
+ with open(dst, 'w', encoding='utf-8') as f:
120
+ json.dump(result, f, ensure_ascii=False, indent=2)
121
+ if result.get('ok'):
122
+ print(f"字幕带: top={result['topRatio']:.2f} bot={result['botRatio']:.2f} "
123
+ f"高度={result['bandRatio']:.2f} 置信度={result['confidence']:.2f} "
124
+ f"(像素 {result['region']['top']}-{result['region']['bottom']} / {result['videoH']})")
125
+ else:
126
+ print("检测失败:", result.get('reason'))
@@ -0,0 +1,5 @@
1
+ {
2
+ "sessdata": "填入你的 SESSDATA",
3
+ "bili_jct": "填入你的 bili_jct",
4
+ "buvid3": "填入你的 buvid3"
5
+ }
@@ -0,0 +1,54 @@
1
+ // 弹幕垃圾过滤(被 fisheye.mjs 和 test-danmaku-filter.js 共用)
2
+ // 为什么单独一个模块:测试必须测「真实实现」而不是复制一份规则。
3
+ // 之前测试文件里复制了一套 AD_RULES,改 fisheye.mjs 的规则时测试不会报警——回归防护是假的。
4
+
5
+ // 原则:只掐「明确的广告引流」和「纯无意义刷屏」,**绝不碰梗**。
6
+ // 「前面的」「666」「awsl」「哈哈哈哈」「打破桎梏×8」都是观众共鸣信号,
7
+ // 过滤掉等于把热区判断的依据砍了。
8
+ // 踩过的坑(2026-11-11):第一版规则太宽,把正常情绪弹幕
9
+ // 「没事,这次我不怕了,我再也不怕看这个场面了」当成「主页引流」过滤了——
10
+ // 因为 `看` + `这` 命中了 (关注|点|看|进)+(我|这|主页|简介)。
11
+ // 教训:单字动词(看/点/戳/滴滴)在中文里太常见,绝不能用;只认「完整短语」。
12
+ export const AD_RULES = [
13
+ { re: /(?:加|➕|+)\s*(?:我|微信|威信|薇信|企鹅|群|q群|Q群|vx|VX|V|v)/, why: '引流' },
14
+ { re: /(?:vx|VX|v信|V信|威信|薇信|weixin|wechat)\s*[::=]\s*\S/i, why: '微信引流' },
15
+ { re: /(?:私信|私我|私聊)\s*我/, why: '私信引流' },
16
+ { re: /(?:关注我|我(?:的)?主页|我(?:的)?头像|简介(?:里)?有)/, why: '主页引流' },
17
+ { re: /(?:免费|白嫖)\s*(?:送|领|拿)/, why: '诈骗话术' },
18
+ { re: /https?:\/\/|www\./i, why: '链接' },
19
+ { re: /[¥$]\s*\d+|\d+\s*元\s*(?:起|包邮|优惠)/, why: '标价' },
20
+ { re: /(?:^|[^\d])\d{7,11}(?:[^\d]|$)/, why: '疑似联系方式' }, // 7~11 位(6 位可能是正常数字)
21
+ ]
22
+
23
+ /** 这条弹幕该不该被过滤?返回原因(空串 = 保留) */
24
+ export function spamReason(text) {
25
+ const t = String(text ?? '').trim()
26
+ if (!t) return '空'
27
+ for (const r of AD_RULES) {
28
+ if (r.re.test(t)) return r.why
29
+ }
30
+ // 纯情绪标点是正常弹幕:「???」「!!!」「……」「¿」都是 B 站最常见的反应
31
+ // (测试用例里「???」第一版就被误杀了,后来「¿」又误杀一次)
32
+ if (/^[??!!。.、,,~~…·¿¡\s]+$/.test(t)) return ''
33
+ // 纯无意义刷屏(不是梗,是噪声)
34
+ // 表情符号也是正常弹幕(😂😂😂 被判「纯符号」就违背了「绝不碰梗」的原则)
35
+ if (!/[\p{L}\p{N}\p{Extended_Pictographic}]/u.test(t)) return '纯符号'
36
+ // 单字符重复 4 次以上,且是**字母**才过滤。
37
+ // 数字重复不是垃圾——「666」「66666」「1111」在 B 站是梗文化(实测把 66666 误杀过)
38
+ if (/^(.)\1{3,}$/.test(t) && /^[a-zA-Z]$/.test(t[0])) return '重复刷屏'
39
+ return ''
40
+ }
41
+
42
+ /** 批量过滤:返回 { kept, dropped },dropped 里记账 + 留样本方便核对有没有误伤 */
43
+ export function filterDanmaku(list) {
44
+ const kept = []
45
+ const dropped = { ad: 0, noise: 0, samples: [] }
46
+ for (const d of list ?? []) {
47
+ const why = spamReason(d.text)
48
+ if (!why) { kept.push(d); continue }
49
+ if (['引流', '微信引流', '微信/QQ 号', '私信引流', '主页引流', '诈骗话术', '链接', '标价', '疑似联系方式'].includes(why)) dropped.ad += 1
50
+ else dropped.noise += 1
51
+ if (dropped.samples.length < 4) dropped.samples.push(`「${d.text}」→ ${why}`)
52
+ }
53
+ return { kept, dropped }
54
+ }