@pls-compile-owo/fisheye 0.3.0 → 0.7.1

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 CHANGED
@@ -2,6 +2,75 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范与 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.7.1] - 2026-11-14
6
+
7
+ ### Added
8
+
9
+ - `auto --resume`:断点续跑完整版——manifest 与计划一致且拼图齐全时**跳过抽帧**,直接复用已有拼图继续后续阶段(`--force` 强制重跑)。
10
+
11
+ ## [0.7.0] - 2026-11-14
12
+
13
+ ### Added
14
+
15
+ - `batch <清单文件|条目...> [--concurrency N]` 子命令:一次处理多个 BV/本地文件/URL(`lib/batch.mjs`)。
16
+ - 字幕翻译:外文视频的官方字幕会同时读取 B站 翻译轨(`translated` 字段,落盘 `<BV>-official-subs-translated.txt`)。
17
+ - 断点续跑基础(`lib/resume.mjs`):manifest 记录计划/阶段,重跑时提示可复用产物(`--force` 强重跑)。
18
+ - 进度辅助(`lib/progress.mjs`):分段抽帧进度、阶段计时。
19
+ - MCP server 入包(`mcp-video-server.js`,可选组件,需自装 @modelcontextprotocol/sdk / zod)+ 中英 README + CONFIG 文档。
20
+
21
+ ## [0.6.0] - 2026-11-14
22
+
23
+ ### Added
24
+
25
+ - OCR 画面文字识别:`ocr <图片路径>` 子命令(tesseract,可选);selftest 显示 OCR 可用性(`lib/ocr.mjs`)。
26
+ - 任意平台视频:`url <视频链接>` 子命令(yt-dlp,可选,下载后走本地视频流程)(`lib/ytdlp.mjs`)。
27
+ - `clean [--target cache|out|all] [--yes]` 子命令:清理缓存/产物(dry-run 默认,--yes 才删)。
28
+ - `auto --audio`:无官方字幕时自动用 whisper 转写台词,跳过烧录字幕带。
29
+
30
+ ### Changed
31
+
32
+ - selftest 增加 OCR / yt-dlp 检查项。
33
+
34
+ ## [0.5.0] - 2026-11-14
35
+
36
+ ### Added
37
+
38
+ - `transcribe <BV> [--lang zh]` 子命令:用 whisper.cpp 转写音频,文本落 `out/<BV>-audio-transcript.txt`。
39
+ - `lib/audio.mjs` 自动探测 whisper.cpp 官方 Windows 包解压结构(`whisper/Release/`)。
40
+ - 单元测试覆盖扩展到全部模块:retry / budget / i18n / frame / env / audio / bili(共 46 个用例)。
41
+ - 外部自检脚本增加「模块导入冒烟」(10 个 lib 模块全部可加载)。
42
+
43
+ ## [0.4.2] - 2026-11-14
44
+
45
+ ### Fixed
46
+
47
+ - `lib/env.mjs` 缺少 `execFileSync` 导入:`onPath()` 失败导致 PATH 上的 ffmpeg/python 永远无法被识别(影响 `resolveTool` 的 PATH 兜底)。
48
+ - `selftest`:PATH 上的 python 无法执行(如 Windows 商店占位符)时降级为跳过而不是失败。
49
+
50
+ ## [0.4.1] - 2026-11-14
51
+
52
+ ### Added
53
+
54
+ - 音频转写能力探测(`lib/audio.mjs`):检测 whisper.cpp,`selftest` 显示音频转写是否可用;不可用时优雅提示安装方式。
55
+ - README 环境变量表补充 `FISHEYE_WHISPER` / `FISHEYE_WHISPER_MODEL`。
56
+
57
+ ### Fixed
58
+
59
+ - 首次运行(`out/`、`cache/` 目录不存在)时字幕缓存写入与视频下载不再报 ENOENT。
60
+
61
+ ## [0.4.0] - 2026-11-14
62
+
63
+ ### Added
64
+
65
+ - B站 API 请求自动重试:网络错误 / 超时 / 5xx / 429 自动重试,指数退避 + 抖动;4xx 不重试(`lib/retry.mjs`)。
66
+ - token 预算估算可配置:`FISHEYE_TOKENS_PER_GRID` / `FISHEYE_TOKENS_PER_SUBS` / `FISHEYE_TOKENS_PER_FINE` 覆盖单张成本,`FISHEYE_TOKEN_MODEL`(gpt / qwen / gemini / 数字)按模型 tokenizer 修正(`lib/budget.mjs`)。
67
+ - CLI 中英双语:`FISHEYE_LANG=en` 输出英文,默认中文(`lib/i18n.mjs`)。
68
+
69
+ ### Changed
70
+
71
+ - `lib/bili.mjs` 的请求统一走 `fetchWithRetry`(函数签名与返回值不变)。
72
+ - `lib/stages.mjs` 的预算估算改用 `lib/budget.mjs`(默认值与旧 COST 一致)。
73
+
5
74
  ## [0.3.0] - 2026-11-14
6
75
 
7
76
  ### Changed
package/README.md CHANGED
@@ -68,6 +68,18 @@ npm link # 可选:装成全局命令
68
68
 
69
69
  > ⚠️ 这个文件已被 `.gitignore` 排除,**永远不要提交**。
70
70
 
71
+ ### 环境变量
72
+
73
+ | 变量 | 说明 |
74
+ |---|---|
75
+ | `FISHEYE_LANG` | CLI 输出语言:`zh`(默认)/ `en` |
76
+ | `FISHEYE_TOKENS_PER_GRID` / `FISHEYE_TOKENS_PER_SUBS` / `FISHEYE_TOKENS_PER_FINE` | 单张拼图 / 字幕带 / 精读帧的 token 估算覆盖(默认 1200 / 1000 / 1300) |
77
+ | `FISHEYE_TOKEN_MODEL` | 按模型 tokenizer 修正估算:`gpt`(1.0)/ `qwen`(1.35)/ `gemini`(0.8)/ 数字 |
78
+ | `FISHEYE_WHISPER` | whisper.cpp 可执行文件路径(音频转写,可选;不装则跳过) |
79
+ | `FISHEYE_WHISPER_MODEL` | whisper 模型文件(ggml-*.bin,可选) |
80
+ | `FISHEYE_HOME` | 数据目录(cache/out 位置,默认项目目录) |
81
+ | `FFMPEG` / `PYTHON` / `BILI_CRED` | 工具与登录态路径覆盖 |
82
+
71
83
  ### 依赖路径(一般不用管)
72
84
 
73
85
  fisheye 按这个顺序找 ffmpeg / python / 凭证:
@@ -147,6 +159,15 @@ node fisheye.mjs grab BV1ziWP6qENy 82.5,145.0
147
159
  00:27 弹幕热区 —— 弹幕热区 19 条
148
160
  ```
149
161
 
162
+ ## 断点续跑
163
+
164
+ 长视频处理中断后,重新跑同样的命令会提示可复用产物:
165
+
166
+ ```bash
167
+ node fisheye.mjs auto BV1xxx --budget 20000 --resume # 跳过已抽帧阶段,复用拼图
168
+ node fisheye.mjs auto BV1xxx --budget 20000 --force # 强制全部重跑
169
+ ```
170
+
150
171
  ## 命令
151
172
 
152
173
  | 命令 | 作用 |
@@ -193,6 +214,46 @@ node fisheye.mjs local <视频文件> [--grid 3x3] [--from 30] [--to 90]
193
214
  不依赖 B站 登录与字幕:ffmpeg 解析时长 → 按 grid 等间隔抽帧拼图 → 输出 `SHEET: <拼图路径>`。
194
215
  把拼图路径交给模型 `read_image` 即可"看"本地视频。适合 mp4/mkv/avi/mov/webm。
195
216
 
217
+ ## 音频转写
218
+
219
+ 装了 whisper.cpp 后,可以给无字幕视频做语音转写:
220
+
221
+ ```bash
222
+ node fisheye.mjs transcribe BV1xxx [--lang zh]
223
+ ```
224
+
225
+ 自动探测顺序:`FISHEYE_WHISPER` 环境变量 → PATH → 常见安装路径(含 `~/.dsh/surfcore/whisper/Release/`)。
226
+ 模型放 `~/.dsh/surfcore/whisper/`(`ggml-*.bin`)或用 `FISHEYE_WHISPER_MODEL` 指定。
227
+ 转写文本落 `out/<BV>-audio-transcript.txt`。
228
+
229
+ ## OCR(图片文字识别)
230
+
231
+ 装了 tesseract 后可以识别图片/画面里的文字:
232
+
233
+ ```bash
234
+ node fisheye.mjs ocr screenshot.png [--lang chi_sim+eng]
235
+ ```
236
+
237
+ 自动探测 `FISHEYE_TESSERACT` → PATH → 常见安装路径;语言包缺 `chi_sim` 时自动降级到 `eng`。
238
+ 识别结果落 `out/<图片名>.ocr.txt`。
239
+
240
+ ## 任意平台视频(yt-dlp)
241
+
242
+ 装了 yt-dlp 后可以直接给任意平台(YouTube/抖音/Twitter…)的链接:
243
+
244
+ ```bash
245
+ node fisheye.mjs url "https://www.youtube.com/watch?v=xxx"
246
+ ```
247
+
248
+ 下载(yt-dlp)→ 自动走本地视频抽帧流程。探测顺序:`FISHEYE_YTDLP` → PATH → 常见安装路径。
249
+
250
+ ## 清理
251
+
252
+ ```bash
253
+ node fisheye.mjs clean # 预览将清理的缓存/产物(dry-run)
254
+ node fisheye.mjs clean --target out --yes # 实际删除 out/
255
+ ```
256
+
196
257
  ## 它怎么工作
197
258
 
198
259
  ```
@@ -240,7 +301,7 @@ B 站的弹幕密度本身就是内容信号。实测一条 40 分钟视频:
240
301
  ## 开发
241
302
 
242
303
  ```bash
243
- node --test test/ # 单元测试(参数解析 / 弹幕分析)
304
+ node --test test/ # 单元测试(全部模块:46 个用例)
244
305
  node test-danmaku-filter.js # 弹幕过滤规则测试(51 条用例)
245
306
  node fisheye.mjs selftest --deep # 含抽帧/字幕带实测的自检
246
307
  ```
package/fisheye.mjs CHANGED
@@ -15,40 +15,30 @@
15
15
  // · 拼图每格顺序固定,manifest 按「从左到右、从上到下」记录时间,供 agent 对照
16
16
  // · 拼图与精读用同一种 seek,保证同一时间戳取到同一帧
17
17
  // · 所有子进程用 stdio:'ignore'(沙箱禁管道捕获),数据走临时文件
18
+ import fs from 'node:fs'
18
19
  import path from 'node:path'
19
20
  import { PKG_DIR, WS, CFG_FILE, FFMPEG, CRED } from './lib/env.mjs'
20
21
  import { parseTime, num, pageNum } from './lib/util.mjs'
21
- import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal } from './lib/stages.mjs'
22
+ import { t } from './lib/i18n.mjs'
23
+ import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal, stageTranscribe, stageClean, stageOcr, stageUrl, stageBatch } from './lib/stages.mjs'
22
24
 
23
25
  const IS_SELFTEST = process.argv[2] === 'selftest'
24
26
 
25
27
  // local 处理本地文件,不需要 B站 登录态(但仍然需要 ffmpeg)
26
28
  const IS_LOCAL = process.argv[2] === 'local'
29
+ // clean / ocr 只动本地文件,不需要 ffmpeg 与 B站 凭证
30
+ const IS_CLEAN = process.argv[2] === 'clean'
31
+ const IS_OCR = process.argv[2] === 'ocr'
32
+ // url 需要 yt-dlp + ffmpeg,但不需要 B站 凭证
33
+ const IS_URL = process.argv[2] === 'url'
27
34
 
28
- if (!IS_SELFTEST) {
35
+ if (!IS_SELFTEST && !IS_CLEAN && !IS_OCR) {
29
36
  if (!FFMPEG) {
30
- console.error(`找不到 ffmpeg。
31
-
32
- 安装方式(任选其一):
33
- 1. 装到 PATH:https://ffmpeg.org/download.html (Windows 用 gyan.dev 的 build)
34
- 2. 放项目里:把 ffmpeg 可执行文件放到 ${path.join(PKG_DIR, 'bin')}/
35
- 3. 设环境变量:FFMPEG=<ffmpeg 完整路径>
36
- 4. 写配置:${CFG_FILE} 里加 {"ffmpeg": "<完整路径>"}`)
37
+ console.error(t('err.ffmpeg.missing', { binDir: path.join(PKG_DIR, 'bin'), cfgFile: CFG_FILE }))
37
38
  process.exit(2)
38
39
  }
39
- if (!IS_LOCAL && !CRED) {
40
- console.error(`找不到 B站登录态文件。
41
-
42
- 创建 ${path.join(WS, 'credentials.json')},内容:
43
- {
44
- "sessdata": "<B站 SESSDATA>",
45
- "bili_jct": "<bili_jct>",
46
- "buvid3": "<buvid3>"
47
- }
48
- 获取方法:浏览器登录 B站 → F12 → Application → Cookies → bilibili.com,
49
- 复制这三个值。或用环境变量 BILI_CRED=<文件路径> 指向已有文件。
50
-
51
- (也可以只跑 selftest 先看环境是否就绪:node fisheye.mjs selftest)`)
40
+ if (!IS_LOCAL && !IS_CLEAN && !IS_OCR && !IS_URL && !CRED) {
41
+ console.error(t('err.cred.missing', { credPath: path.join(WS, 'credentials.json') }))
52
42
  process.exit(2)
53
43
  }
54
44
  }
@@ -71,7 +61,7 @@ const opt = (name, dflt) => {
71
61
  if (stage === 'local') {
72
62
  const fileArg = argv[1]
73
63
  if (!fileArg || fileArg.startsWith('--')) {
74
- console.error('用法: node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t]')
64
+ console.error(t('err.local.usage'))
75
65
  process.exit(2)
76
66
  }
77
67
  try {
@@ -81,25 +71,77 @@ if (stage === 'local') {
81
71
  to: parseTime(opt('to', null), 'to')
82
72
  })
83
73
  } catch (error) {
84
- console.error(`[错误] ${error?.message ?? error}`)
74
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
85
75
  process.exit(1)
86
76
  }
87
77
  process.exit(0)
88
78
  }
89
- if (!stage || (stage !== 'selftest' && !bvid)) {
79
+
80
+ if (stage === 'ocr') {
81
+ const img = argv[1]
82
+ if (!img || img.startsWith('--')) {
83
+ console.error(t('err.ocr.usage'))
84
+ process.exit(2)
85
+ }
86
+ try {
87
+ await stageOcr(img, { lang: opt('lang', null) })
88
+ } catch (error) {
89
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
90
+ process.exit(1)
91
+ }
92
+ process.exit(0)
93
+ }
94
+ if (stage === 'url') {
95
+ const link = argv[1]
96
+ if (!link || link.startsWith('--')) {
97
+ console.error(t('err.url.usage'))
98
+ process.exit(2)
99
+ }
100
+ try {
101
+ await stageUrl(link, {
102
+ grid: opt('grid', '3x3'),
103
+ from: parseTime(opt('from', null), 'from'),
104
+ to: parseTime(opt('to', null), 'to')
105
+ })
106
+ } catch (error) {
107
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
108
+ process.exit(1)
109
+ }
110
+ process.exit(0)
111
+ }
112
+
113
+
114
+ if (stage === 'batch') {
115
+ const arg = argv[1]
116
+ const listText = (arg && !arg.startsWith('--') && fs.existsSync(arg))
117
+ ? fs.readFileSync(arg, 'utf8')
118
+ : argv.slice(1).filter((a) => !a.startsWith('--')).join('\n')
119
+ if (!listText.trim()) {
120
+ console.error(t('err.batch.usage'))
121
+ process.exit(2)
122
+ }
123
+ try {
124
+ await stageBatch(listText, {
125
+ concurrency: num(opt('concurrency', 1), 'concurrency', { min: 1, max: 8 }),
126
+ budget: opt('budget', null) ? num(opt('budget'), 'budget', { min: 2000, max: 200000 }) : 20000,
127
+ grid: opt('grid', '3x3'),
128
+ audio: argv.includes('--audio'),
129
+ lang: opt('lang', null),
130
+ from: parseTime(opt('from', null), 'from'),
131
+ to: parseTime(opt('to', null), 'to')
132
+ })
133
+ } catch (error) {
134
+ console.error(t('err.local.failed', { message: error?.message ?? error }))
135
+ process.exit(1)
136
+ }
137
+ process.exit(0)
138
+ }
139
+
140
+ if (!stage || (stage !== 'selftest' && stage !== 'clean' && stage !== 'ocr' && stage !== 'url' && stage !== 'batch' && !bvid)) {
90
141
  if (rawBvid && stage !== 'selftest') {
91
- console.error(`[错误] BV 号格式不对:${rawBvid}(应为 BV + 10 位字母数字,如 BV1ziWP6qENy)\n`)
142
+ console.error(`${t('err.bvFormat', { raw: rawBvid })}\n`)
92
143
  }
93
- console.log('用法:')
94
- console.log(' node fisheye.mjs auto <BV> [--budget 20000] [--density 6] ← 推荐:自动规划并铺网')
95
- console.log(' node fisheye.mjs refine <BV> --template ← 生成精读决策模板')
96
- console.log(' node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读')
97
- console.log(' node fisheye.mjs refine <BV> ← 几何兜底(零 token)')
98
- console.log(' node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]')
99
- console.log(' node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]')
100
- console.log(' node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点')
101
- console.log(' node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)')
102
- console.log(' node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)')
144
+ console.log(t('usage'))
103
145
  process.exit(2)
104
146
  }
105
147
  // 顶层错误兜底:不要把堆栈直接甩给用户
@@ -119,7 +161,9 @@ try {
119
161
  await stageAuto(bvid,
120
162
  num(opt('budget', 20000), 'budget', { min: 2000, max: 200000 }),
121
163
  { density: d === null ? null : num(d, 'density', { min: 0.5, max: 60 }), page, from, to,
122
- useDanmakuFrames: !argv.includes('--no-danmaku-frames') })
164
+ useDanmakuFrames: !argv.includes('--no-danmaku-frames'),
165
+ audio: argv.includes('--audio'), audioLang: opt('lang', null),
166
+ resume: argv.includes('--resume'), force: argv.includes('--force') })
123
167
  } else if (stage === 'refine') {
124
168
  await stageRefine(bvid, { template: argv.includes('--template'), decisions: opt('decisions', null) })
125
169
  } else if (stage === 'grid') {
@@ -133,9 +177,14 @@ try {
133
177
  opt('band', 'auto'),
134
178
  { page, range })
135
179
  } else if (stage === 'grab') await stageGrab(bvid, argv[2] ?? '', { page })
136
- else { console.error('未知阶段: ' + stage); process.exit(2) }
180
+ else if (stage === 'clean') {
181
+ // 清理缓存/产物:本地操作,不需要 BV 号
182
+ await stageClean({ target: opt('target', 'all'), yes: argv.includes('--yes') })
183
+ }
184
+ else if (stage === 'transcribe') await stageTranscribe(bvid, { page, lang: opt('lang', null) })
185
+ else { console.error(t('err.unknownStage', { stage })); process.exit(2) }
137
186
  } catch (e) {
138
- console.error(`\n错误:${e.message}`)
187
+ console.error(t('err.fatal', { message: e.message }))
139
188
  if (process.env.FISHEYE_DEBUG) console.error(e.stack)
140
189
  process.exit(1)
141
190
  }