@pls-compile-owo/fisheye 0.4.2 → 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,44 @@
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
+
5
43
  ## [0.4.2] - 2026-11-14
6
44
 
7
45
  ### Fixed
package/README.md CHANGED
@@ -159,6 +159,15 @@ node fisheye.mjs grab BV1ziWP6qENy 82.5,145.0
159
159
  00:27 弹幕热区 —— 弹幕热区 19 条
160
160
  ```
161
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
+
162
171
  ## 命令
163
172
 
164
173
  | 命令 | 作用 |
@@ -205,6 +214,46 @@ node fisheye.mjs local <视频文件> [--grid 3x3] [--from 30] [--to 90]
205
214
  不依赖 B站 登录与字幕:ffmpeg 解析时长 → 按 grid 等间隔抽帧拼图 → 输出 `SHEET: <拼图路径>`。
206
215
  把拼图路径交给模型 `read_image` 即可"看"本地视频。适合 mp4/mkv/avi/mov/webm。
207
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
+
208
257
  ## 它怎么工作
209
258
 
210
259
  ```
@@ -252,7 +301,7 @@ B 站的弹幕密度本身就是内容信号。实测一条 40 分钟视频:
252
301
  ## 开发
253
302
 
254
303
  ```bash
255
- node --test test/ # 单元测试(参数解析 / 弹幕分析)
304
+ node --test test/ # 单元测试(全部模块:46 个用例)
256
305
  node test-danmaku-filter.js # 弹幕过滤规则测试(51 条用例)
257
306
  node fisheye.mjs selftest --deep # 含抽帧/字幕带实测的自检
258
307
  ```
package/fisheye.mjs CHANGED
@@ -15,23 +15,29 @@
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
22
  import { t } from './lib/i18n.mjs'
22
- import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal } from './lib/stages.mjs'
23
+ import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal, stageTranscribe, stageClean, stageOcr, stageUrl, stageBatch } from './lib/stages.mjs'
23
24
 
24
25
  const IS_SELFTEST = process.argv[2] === 'selftest'
25
26
 
26
27
  // local 处理本地文件,不需要 B站 登录态(但仍然需要 ffmpeg)
27
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'
28
34
 
29
- if (!IS_SELFTEST) {
35
+ if (!IS_SELFTEST && !IS_CLEAN && !IS_OCR) {
30
36
  if (!FFMPEG) {
31
37
  console.error(t('err.ffmpeg.missing', { binDir: path.join(PKG_DIR, 'bin'), cfgFile: CFG_FILE }))
32
38
  process.exit(2)
33
39
  }
34
- if (!IS_LOCAL && !CRED) {
40
+ if (!IS_LOCAL && !IS_CLEAN && !IS_OCR && !IS_URL && !CRED) {
35
41
  console.error(t('err.cred.missing', { credPath: path.join(WS, 'credentials.json') }))
36
42
  process.exit(2)
37
43
  }
@@ -70,7 +76,68 @@ if (stage === 'local') {
70
76
  }
71
77
  process.exit(0)
72
78
  }
73
- 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)) {
74
141
  if (rawBvid && stage !== 'selftest') {
75
142
  console.error(`${t('err.bvFormat', { raw: rawBvid })}\n`)
76
143
  }
@@ -94,7 +161,9 @@ try {
94
161
  await stageAuto(bvid,
95
162
  num(opt('budget', 20000), 'budget', { min: 2000, max: 200000 }),
96
163
  { density: d === null ? null : num(d, 'density', { min: 0.5, max: 60 }), page, from, to,
97
- 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') })
98
167
  } else if (stage === 'refine') {
99
168
  await stageRefine(bvid, { template: argv.includes('--template'), decisions: opt('decisions', null) })
100
169
  } else if (stage === 'grid') {
@@ -108,6 +177,11 @@ try {
108
177
  opt('band', 'auto'),
109
178
  { page, range })
110
179
  } else if (stage === 'grab') await stageGrab(bvid, argv[2] ?? '', { page })
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) })
111
185
  else { console.error(t('err.unknownStage', { stage })); process.exit(2) }
112
186
  } catch (e) {
113
187
  console.error(t('err.fatal', { message: e.message }))
package/lib/audio.mjs CHANGED
@@ -145,6 +145,7 @@ function commonWhisperPaths(env = process.env) {
145
145
  path.join(home, '.dsh', 'surfcore', 'whisper', 'bin'),
146
146
  path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin'),
147
147
  path.join(home, '.dsh', 'surfcore', 'whisper', 'build', 'bin', 'Release'),
148
+ path.join(home, '.dsh', 'surfcore', 'whisper', 'Release'),
148
149
  // 本机实际布局:ffmpeg 和 B站下载器放在 bili/bin,whisper 也可能被丢在同一处
149
150
  path.join(home, '.dsh', 'surfcore', 'bili', 'bin'),
150
151
  path.join(home, '.dsh', 'surfcore', 'bin'),
package/lib/batch.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // 批量处理核心:把多条 BV / 本地文件 / URL 交给同一个 runner 跑。
2
+ // 纯逻辑:不读文件系统、不起子进程、不碰网络、零第三方依赖。
3
+ // 文件是否存在、视频能不能解析,统一交给调用方传入的 runner 判断——
4
+ // 这样 parseBatchList 是可测的纯函数,批量流程也能在单测里跑完。
5
+ //
6
+ // 典型接法(后续 batch 子命令):
7
+ // const { items, errors } = parseBatchList(text)
8
+ // // errors 先报给用户,items 再进 runBatch
9
+ // const summary = await runBatch(items, (item) => runOne(item, opts), {
10
+ // concurrency: 2,
11
+ // onItem: (item, i) => console.log(`[${i + 1}/${items.length}] ${item.value}`)
12
+ // })
13
+ // console.log(formatBatchReport(summary))
14
+
15
+ // BV 号:BV + 10 位字母数字(与 fisheye.mjs 入口校验保持一致)
16
+ const BV_RE = /^BV[0-9A-Za-z]{10}$/
17
+ const URL_RE = /^https?:\/\//i
18
+ // Windows 盘符:C:\ / C:/ / 裸 C:
19
+ const DRIVE_RE = /^[A-Za-z]:(?:[\\/]|$)/
20
+ // 常见视频扩展名(URL 已在前面单独判定,这里只看裸文件名)
21
+ const VIDEO_EXT_RE = /\.(mp4|mkv|avi|mov|webm)$/i
22
+
23
+ // 路径形态判断(纯字符串,不看磁盘)
24
+ function looksLikeFile(s) {
25
+ if (DRIVE_RE.test(s)) return true
26
+ if (s.startsWith('\\\\') || s.startsWith('//')) return true // UNC \\server\share
27
+ if (s.startsWith('./') || s.startsWith('.\\') || s.startsWith('../') || s.startsWith('..\\')) return true
28
+ if (s.startsWith('/')) return true
29
+ if (VIDEO_EXT_RE.test(s)) return true // 裸文件名:video.mp4
30
+ if (/[\\/]/.test(s)) return true // 含路径分隔符
31
+ return false
32
+ }
33
+
34
+ function classify(value) {
35
+ if (BV_RE.test(value)) return 'bv'
36
+ if (URL_RE.test(value)) return 'url'
37
+ if (looksLikeFile(value)) return 'file'
38
+ return null
39
+ }
40
+
41
+ // 统一成「物理行」数组:字符串直接按行拆;数组每项再按行拆(一项多行也认)
42
+ function toLines(input) {
43
+ if (typeof input === 'string') return input.split(/\r?\n/)
44
+ if (!Array.isArray(input)) return null
45
+ const out = []
46
+ for (const entry of input) {
47
+ if (entry === null || entry === undefined) continue
48
+ out.push(...String(entry).split(/\r?\n/))
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * 解析批量输入。
55
+ * 每行 trim 后:空行 / `#` 开头的注释行跳过;BV 号 → bv;http(s):// → url;
56
+ * 像文件路径(盘符/UNC/相对路径/含分隔符/视频扩展名)→ file;其余进 errors。
57
+ * @param {string|string[]} input 多行文本,或每项一行的数组
58
+ * @returns {{ items: Array<{kind:'bv'|'file'|'url', value:string}>,
59
+ * errors: Array<{line:number, raw:string, reason:string}> }}
60
+ */
61
+ export function parseBatchList(input) {
62
+ const lines = toLines(input)
63
+ // 类型不对时也走 errors,不要抛异常——CLI 层还要把错误列给用户看
64
+ if (lines === null) {
65
+ return {
66
+ items: [],
67
+ errors: [{ line: 0, raw: String(input ?? ''), reason: '批量清单必须是字符串或数组' }]
68
+ }
69
+ }
70
+ const items = []
71
+ const errors = []
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const raw = lines[i]
74
+ const value = raw.trim()
75
+ if (value === '' || value.startsWith('#')) continue
76
+ const kind = classify(value)
77
+ if (kind) items.push({ kind, value })
78
+ else errors.push({ line: i + 1, raw: value, reason: '无法识别:既不是 BV 号、URL,也不是视频文件路径' })
79
+ }
80
+ return { items, errors }
81
+ }
82
+
83
+ const nowMs = () => performance.now()
84
+
85
+ function errorText(error) {
86
+ if (error instanceof Error) return error.message || error.name || '未知错误'
87
+ if (typeof error === 'string') return error
88
+ if (error === null || error === undefined) return '未知错误'
89
+ try {
90
+ const s = JSON.stringify(error)
91
+ return s === undefined ? String(error) : s
92
+ } catch {
93
+ return String(error) // 循环引用、BigInt 之类
94
+ }
95
+ }
96
+
97
+ async function runOne(item, runner) {
98
+ const started = nowMs()
99
+ try {
100
+ const result = await runner(item)
101
+ return { item, ok: true, result, ms: Math.round(nowMs() - started) }
102
+ } catch (error) {
103
+ return { item, ok: false, error: errorText(error), ms: Math.round(nowMs() - started) }
104
+ }
105
+ }
106
+
107
+ function normalizeConcurrency(value, total) {
108
+ const n = Math.floor(Number(value))
109
+ if (!Number.isFinite(n) || n < 1) return 1
110
+ return Math.min(n, total)
111
+ }
112
+
113
+ /**
114
+ * 批量执行:串行或简单并发池;单项失败只记录,不中断整体。
115
+ * 结果顺序始终与 items 一致(并发完成顺序不回写结果顺序)。
116
+ * onItem 在每项 runner 之前调用,抛错会中断整批(进度回调不该抛错)。
117
+ * @param {Array} items parseBatchList().items 或任意可迭代项
118
+ * @param {(item:any, index:number)=>Promise<any>} runner 单条处理逻辑
119
+ * @param {{concurrency?:number, onItem?:null|((item:any, index:number)=>any)}} [options]
120
+ * @returns {Promise<{results: Array<{item:any, ok:boolean, result?:any, error?:string, ms:number}>,
121
+ * total:number, ok:number, failed:number}>}
122
+ */
123
+ export async function runBatch(items, runner, { concurrency = 1, onItem = null } = {}) {
124
+ if (!Array.isArray(items)) throw new TypeError('runBatch: items 必须是数组')
125
+ if (typeof runner !== 'function') throw new TypeError('runBatch: runner 必须是函数')
126
+ if (onItem !== null && typeof onItem !== 'function') throw new TypeError('runBatch: onItem 必须是函数或 null')
127
+
128
+ const total = items.length
129
+ if (total === 0) return { results: [], total: 0, ok: 0, failed: 0 }
130
+
131
+ const limit = normalizeConcurrency(concurrency, total)
132
+ const results = new Array(total)
133
+ let next = 0
134
+
135
+ // 简单并发池:limit 个 worker 抢同一个游标,谁空谁取下一个
136
+ async function worker() {
137
+ for (;;) {
138
+ const index = next++
139
+ if (index >= total) return
140
+ const item = items[index]
141
+ if (onItem) await onItem(item, index)
142
+ results[index] = await runOne(item, runner)
143
+ }
144
+ }
145
+ await Promise.all(Array.from({ length: limit }, () => worker()))
146
+
147
+ const ok = results.reduce((n, r) => n + (r.ok ? 1 : 0), 0)
148
+ return { results, total, ok, failed: total - ok }
149
+ }
150
+
151
+ // 报告里的耗时:按 ms 展示,超过 1 秒给一位小数,超过 1 分钟给 XmYs
152
+ function formatDuration(ms) {
153
+ if (!Number.isFinite(ms) || ms <= 0) return '0ms'
154
+ if (ms < 1000) return `${Math.round(ms)}ms`
155
+ if (ms < 60000) {
156
+ const s = Math.round(ms / 100) / 10
157
+ return s >= 60 ? '1m0s' : `${s.toFixed(1)}s`
158
+ }
159
+ const m = Math.floor(ms / 60000)
160
+ const s = Math.round((ms % 60000) / 1000)
161
+ return s === 60 ? `${m + 1}m0s` : `${m}m${s}s`
162
+ }
163
+
164
+ function describeItem(item) {
165
+ if (!item || typeof item !== 'object') return String(item)
166
+ const value = item.value ?? ''
167
+ return item.kind ? `[${item.kind}] ${value}` : String(value)
168
+ }
169
+
170
+ /**
171
+ * 生成人类可读的汇总文本(不含尾随换行,方便 console.log 直接用)。
172
+ * 耗时取 results 里各条 ms 之和(并发时是累计值,不是墙钟时间)。
173
+ * @param {object} summary runBatch() 的返回值
174
+ * @returns {string}
175
+ */
176
+ export function formatBatchReport(summary) {
177
+ const results = Array.isArray(summary?.results) ? summary.results : []
178
+ const total = Number.isFinite(summary?.total) ? summary.total : results.length
179
+ const ok = Number.isFinite(summary?.ok) ? summary.ok : results.filter((r) => r?.ok).length
180
+ const failed = Number.isFinite(summary?.failed) ? summary.failed : Math.max(0, total - ok)
181
+ const spent = results.reduce((sum, r) => sum + (Number.isFinite(r?.ms) ? r.ms : 0), 0)
182
+
183
+ const lines = [`批量处理:共 ${total} 条,成功 ${ok} 条,失败 ${failed} 条,耗时合计 ${formatDuration(spent)}`]
184
+ if (failed > 0) {
185
+ lines.push('失败明细:')
186
+ const failures = results.filter((r) => r && !r.ok)
187
+ if (failures.length === 0) lines.push(' (没有明细)')
188
+ else failures.forEach((r, i) => lines.push(` ${i + 1}. ${describeItem(r.item)} —— ${r.error ?? '未知错误'}`))
189
+ }
190
+ return lines.join('\n')
191
+ }
package/lib/bili.mjs CHANGED
@@ -48,6 +48,34 @@ export const keyOf = (u) => u.slice(u.lastIndexOf('/') + 1, u.lastIndexOf('.'))
48
48
 
49
49
  export const clean = (v) => String(v).replace(/[!'()*]/g, '')
50
50
 
51
+ /**
52
+ * 从 B站 subtitle.subtitles 列表里挑「主轨(原文)」和「翻译轨(中文)」。
53
+ * 外文视频(英文/日文…)的 AI 字幕除了原文轨还会带一条中文机器翻译轨(l2,lan 含 zh),
54
+ * 只取原文就成了「只转写不翻译」——所以这里把两条轨分开,交给 getOfficialSubs 分别下载。
55
+ *
56
+ * 语义:
57
+ * · 有外文轨 + 中文轨:main = 第一条外文轨(保留原话),translate = 中文轨
58
+ * · 只有中文轨:main = 中文轨,translate = null(原有行为,不产生「中文翻译中文」)
59
+ * · 只有外文轨:main = 外文轨,translate = null
60
+ * · 空 / 无有效 lan:null
61
+ * · preferZh=false:不做翻译,中文轨直接当主轨(只要中文文本的调用方)
62
+ * 中文轨优先级沿用旧逻辑:ai-zh → zh-CN → 其它 zh*(B站 AI 中文覆盖最全)。
63
+ * 纯函数,不发请求,便于单测。
64
+ */
65
+ export function pickSubtitleTracks(subs, preferZh = true) {
66
+ const list = (Array.isArray(subs) ? subs : []).filter((s) => s && typeof s.lan === 'string' && s.lan)
67
+ if (!list.length) return null
68
+ const isZh = (s) => /zh/i.test(s.lan)
69
+ const zh = list.find((s) => s.lan === 'ai-zh') ?? list.find((s) => s.lan === 'zh-CN')
70
+ ?? list.find(isZh) ?? null
71
+ const foreign = list.find((s) => !isZh(s)) ?? null
72
+ // 只要中文:中文轨当主轨,不再单列翻译
73
+ if (!preferZh) return { main: zh ?? foreign, translate: null }
74
+ // 没有外文轨(中文原声视频 / 只有单条中文字幕):保持旧行为
75
+ if (!foreign) return { main: zh, translate: null }
76
+ return { main: foreign, translate: zh }
77
+ }
78
+
51
79
  export async function getOfficialSubs(bvid, cid, range = null) {
52
80
  try {
53
81
  const nav = await (await fetchWithRetry('https://api.bilibili.com/x/web-interface/nav',
@@ -65,19 +93,25 @@ export async function getOfficialSubs(bvid, cid, range = null) {
65
93
  { headers: H }, { timeoutMs: 15000 })).json()
66
94
  const subs = p?.data?.subtitle?.subtitles ?? []
67
95
  if (!subs.length) return null
68
- const pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
69
- ?? subs.find((s) => String(s.lan).startsWith('zh')) ?? subs[0]
70
- if (!pick?.subtitle_url) return null
71
-
72
- const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
73
- const r = await fetchWithRetry(url, {
74
- headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
75
- }, { timeoutMs: 20000 })
76
- if (!r.ok) return null
77
- let body = (JSON.parse(await r.text()).body ?? [])
78
- if (!body.length) return null
79
- // 只保留所处理区间内的字幕行(--from/--to)
80
- if (range) body = body.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
96
+ const tracks = pickSubtitleTracks(subs)
97
+ if (!tracks?.main?.subtitle_url) return null
98
+
99
+ const subHeaders = { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` }
100
+ const subUrl = (u) => (String(u).startsWith('http') ? String(u) : 'https:' + String(u))
101
+ // 下载一条字幕轨并按 --from/--to 过滤。翻译轨失败只丢翻译,不能让整条字幕失败。
102
+ const fetchBody = async (u) => {
103
+ const r = await fetchWithRetry(subUrl(u), { headers: subHeaders }, { timeoutMs: 20000 })
104
+ if (!r.ok) return []
105
+ const raw = JSON.parse(await r.text()).body ?? []
106
+ if (!range) return raw
107
+ return raw.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
108
+ }
109
+ // 主轨 + 翻译轨并行下载:auth_key 分钟级过期,串行两次请求更容易踩到期
110
+ const translateTrack = tracks.translate?.subtitle_url ? tracks.translate : null
111
+ const [body, transBody] = await Promise.all([
112
+ fetchBody(tracks.main.subtitle_url),
113
+ translateTrack ? fetchBody(translateTrack.subtitle_url).catch(() => []) : Promise.resolve([]),
114
+ ])
81
115
  if (!body.length) return null
82
116
 
83
117
  fs.mkdirSync(OUT, { recursive: true })
@@ -86,6 +120,25 @@ export async function getOfficialSubs(bvid, cid, range = null) {
86
120
  fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
87
121
  fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
88
122
 
123
+ // 翻译轨:中文机器翻译(外文视频才有)。chars 用与原文相同的「去 ♪/空白」算法,
124
+ // 避免译文里的空格让字数虚高;全空的翻译轨按「没有翻译」处理。
125
+ let translated = null
126
+ if (transBody?.length) {
127
+ const tChars = transBody.reduce(
128
+ (a, l) => a + String(l.content ?? '').replace(/[♪\s]/g, '').length, 0)
129
+ if (tChars > 0) {
130
+ const file = path.join(OUT, `${bvid}-official-subs-translated.txt`)
131
+ fs.writeFileSync(file, transBody.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
132
+ translated = {
133
+ lan: translateTrack.lan,
134
+ lines: transBody.length,
135
+ chars: tChars,
136
+ txt: transBody.map((l) => String(l.content ?? '')).join('\n'),
137
+ file,
138
+ }
139
+ }
140
+ }
141
+
89
142
  // 质量判断:B 站 AI 字幕在纯音乐/音效为主的视频里会输出大量「 歌词」噪声或无意义碎片,
90
143
  // 这种字幕不能用(拿它当台词理解视频会全错),应该降级为看图
91
144
  const effChars = body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, '').length)
@@ -95,9 +148,11 @@ export async function getOfficialSubs(bvid, cid, range = null) {
95
148
  const quality = avgEff < 2 || uniqueRatio < 0.4 ? 'noisy' : 'ok'
96
149
 
97
150
  return {
98
- lan: pick.lan, lanDoc: pick.lan_doc, lines: body.length, txt, plain,
151
+ lan: tracks.main.lan, lanDoc: tracks.main.lan_doc, lines: body.length, txt, plain,
99
152
  chars, quality, avgEff: Number(avgEff.toFixed(1)), uniqueRatio: Number(uniqueRatio.toFixed(2)),
100
153
  head: body.slice(0, 5).map((l) => `[${fmt(l.from)}] ${l.content}`),
154
+ // 中文翻译轨(没有时为 null):原文 lines/txt 不变,翻译单独给,调用方自己决定怎么用
155
+ translated,
101
156
  }
102
157
  } catch (e) {
103
158
  console.warn(`[官方字幕] 获取失败(按无官方字幕处理):${String(e.message).split('\n')[0].slice(0, 60)}`)
package/lib/frame.mjs CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { execFileSync, spawnSync } from 'node:child_process'
6
- import { FFMPEG, PYTHON, WS, OUT } from './env.mjs'
6
+ import { FFMPEG, PYTHON, WS, OUT, PKG_DIR } from './env.mjs'
7
7
 
8
8
  export function detectScenes(file) {
9
9
  // 文件名带 pid + 时间戳:两个视频并发跑时不会互相覆盖
package/lib/i18n.mjs CHANGED
@@ -19,7 +19,7 @@ const MESSAGES = {
19
19
  zh: {
20
20
  // —— 用法帮助 ——
21
21
  usage: `用法:
22
- node fisheye.mjs auto <BV> [--budget 20000] [--density 6] ← 推荐:自动规划并铺网
22
+ node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] ← 推荐:自动规划并铺网(--audio 无字幕转写 / --resume 断点续跑)
23
23
  node fisheye.mjs refine <BV> --template ← 生成精读决策模板
24
24
  node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读
25
25
  node fisheye.mjs refine <BV> ← 几何兜底(零 token)
@@ -27,6 +27,11 @@ const MESSAGES = {
27
27
  node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
28
28
  node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点
29
29
  node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)
30
+ node fisheye.mjs transcribe <BV> [--lang zh] ← 音频转写(需 whisper.cpp)
31
+ node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng] ← OCR 识别图片文字(需 tesseract)
32
+ node fisheye.mjs url <视频链接> [--grid 3x3] ← 任意平台视频(需 yt-dlp)
33
+ node fisheye.mjs clean [--target cache|out|all] [--yes] ← 清理缓存/产物
34
+ node fisheye.mjs batch <清单文件|条目...> [--concurrency 2] ← 批量处理(每行一个 BV/路径/URL)
30
35
  node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)`,
31
36
 
32
37
  // —— 依赖缺失 ——
@@ -55,6 +60,9 @@ const MESSAGES = {
55
60
  'err.unknownStage': '未知阶段: {stage}',
56
61
  'err.bvFormat': '[错误] BV 号格式不对:{raw}(应为 BV + 10 位字母数字,如 BV1ziWP6qENy)',
57
62
  'err.local.usage': '用法: node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t]',
63
+ 'err.ocr.usage': '用法: node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng]',
64
+ 'err.url.usage': '用法: node fisheye.mjs url <视频链接> [--grid 3x3] [--from t] [--to t]',
65
+ 'err.batch.usage': '用法: node fisheye.mjs batch <清单文件|条目...> [--concurrency 2]',
58
66
  'err.fatal': '\n错误:{message}',
59
67
  'err.local.failed': '[错误] {message}',
60
68
 
@@ -71,7 +79,7 @@ const MESSAGES = {
71
79
 
72
80
  en: {
73
81
  usage: `Usage:
74
- node fisheye.mjs auto <BV> [--budget 20000] [--density 6] # recommended: auto-plan and build grids
82
+ node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] # recommended: auto-plan and build grids (--audio transcribe / --resume resume)
75
83
  node fisheye.mjs refine <BV> --template # generate a refine decision template
76
84
  node fisheye.mjs refine <BV> --decisions <file.json> # refine using model decisions
77
85
  node fisheye.mjs refine <BV> # geometric fallback (zero tokens)
@@ -79,6 +87,11 @@ const MESSAGES = {
79
87
  node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
80
88
  node fisheye.mjs grab <BV> <t1,t2,t3> # pick timestamps manually
81
89
  node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t] # analyze a local video (mp4/mkv/...)
90
+ node fisheye.mjs transcribe <BV> [--lang zh] # transcribe audio (requires whisper.cpp)
91
+ node fisheye.mjs ocr <image path> [--lang chi_sim+eng] # OCR text from an image (requires tesseract)
92
+ node fisheye.mjs url <video link> [--grid 3x3] # any platform video (requires yt-dlp)
93
+ node fisheye.mjs clean [--target cache|out|all] [--yes] # clean cache/out artifacts
94
+ node fisheye.mjs batch <list file|items...> [--concurrency 2] # batch process (one BV/path/URL per line)
82
95
  node fisheye.mjs selftest [--deep] # self-check (deps/credentials/API/cache/disk)`,
83
96
 
84
97
  'err.ffmpeg.missing': `ffmpeg not found.
@@ -105,6 +118,9 @@ then copy those three values. Or set BILI_CRED=<file path> to point at an existi
105
118
  'err.unknownStage': 'Unknown stage: {stage}',
106
119
  'err.bvFormat': '[error] Invalid BV id: {raw} (expected BV + 10 alphanumeric chars, e.g. BV1ziWP6qENy)',
107
120
  'err.local.usage': 'Usage: node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t]',
121
+ 'err.ocr.usage': 'Usage: node fisheye.mjs ocr <image path> [--lang chi_sim+eng]',
122
+ 'err.url.usage': 'Usage: node fisheye.mjs url <video link> [--grid 3x3] [--from t] [--to t]',
123
+ 'err.batch.usage': 'Usage: node fisheye.mjs batch <list file|items...> [--concurrency 2]',
108
124
  'err.fatal': '\nError: {message}',
109
125
  'err.local.failed': '[error] {message}',
110
126