@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 +69 -0
- package/README.md +62 -1
- package/fisheye.mjs +88 -39
- package/lib/audio.mjs +646 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +86 -30
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +147 -0
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +317 -32
- package/lib/util.mjs +9 -8
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
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
|
@@ -6,6 +6,8 @@ import crypto from 'node:crypto'
|
|
|
6
6
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
7
7
|
import { FFMPEG, CACHE, WS, CRED, OUT } from './env.mjs'
|
|
8
8
|
import { fmt } from './util.mjs'
|
|
9
|
+
// 所有 B站请求都走 fetchWithRetry:网络抖动 / 429 / 5xx 时自动重试(指数退避),幂等 GET 才重试
|
|
10
|
+
import { fetchWithRetry } from './retry.mjs'
|
|
9
11
|
|
|
10
12
|
export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
|
11
13
|
|
|
@@ -26,8 +28,8 @@ export const H = {
|
|
|
26
28
|
// 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
|
|
27
29
|
|
|
28
30
|
export async function getView(bvid) {
|
|
29
|
-
const r = await
|
|
30
|
-
{ headers: H,
|
|
31
|
+
const r = await fetchWithRetry(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
|
|
32
|
+
{ headers: H }, { timeoutMs: 15000 })
|
|
31
33
|
const v = await r.json()
|
|
32
34
|
if (v.code !== 0) throw new Error(`view code=${v.code} ${v.message}`)
|
|
33
35
|
return v.data
|
|
@@ -46,10 +48,38 @@ export const keyOf = (u) => u.slice(u.lastIndexOf('/') + 1, u.lastIndexOf('.'))
|
|
|
46
48
|
|
|
47
49
|
export const clean = (v) => String(v).replace(/[!'()*]/g, '')
|
|
48
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
|
+
|
|
49
79
|
export async function getOfficialSubs(bvid, cid, range = null) {
|
|
50
80
|
try {
|
|
51
|
-
const nav = await (await
|
|
52
|
-
{ headers: H,
|
|
81
|
+
const nav = await (await fetchWithRetry('https://api.bilibili.com/x/web-interface/nav',
|
|
82
|
+
{ headers: H }, { timeoutMs: 15000 })).json()
|
|
53
83
|
const raw = keyOf(nav.data?.wbi_img?.img_url ?? '') + keyOf(nav.data?.wbi_img?.sub_url ?? '')
|
|
54
84
|
if (raw.length < 64) return null
|
|
55
85
|
const mixin = MIXIN_TAB.map((i) => raw[i]).join('').slice(0, 32)
|
|
@@ -59,31 +89,56 @@ export async function getOfficialSubs(bvid, cid, range = null) {
|
|
|
59
89
|
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(clean(k === 'wts' ? wts : params[k]))}`).join('&')
|
|
60
90
|
return `${q}&w_rid=${crypto.createHash('md5').update(q + mixin).digest('hex')}`
|
|
61
91
|
}
|
|
62
|
-
const p = await (await
|
|
63
|
-
{ headers: H,
|
|
92
|
+
const p = await (await fetchWithRetry(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
|
|
93
|
+
{ headers: H }, { timeoutMs: 15000 })).json()
|
|
64
94
|
const subs = p?.data?.subtitle?.subtitles ?? []
|
|
65
95
|
if (!subs.length) return null
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
])
|
|
80
115
|
if (!body.length) return null
|
|
81
116
|
|
|
82
|
-
|
|
117
|
+
fs.mkdirSync(OUT, { recursive: true })
|
|
118
|
+
const txt = path.join(OUT, `${bvid}-official-subs.txt`)
|
|
83
119
|
const plain = path.join(OUT, `${bvid}-official-subs-plain.txt`)
|
|
84
120
|
fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
|
|
85
121
|
fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
|
|
86
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
|
+
|
|
87
142
|
// 质量判断:B 站 AI 字幕在纯音乐/音效为主的视频里会输出大量「 歌词」噪声或无意义碎片,
|
|
88
143
|
// 这种字幕不能用(拿它当台词理解视频会全错),应该降级为看图
|
|
89
144
|
const effChars = body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, '').length)
|
|
@@ -93,9 +148,11 @@ export async function getOfficialSubs(bvid, cid, range = null) {
|
|
|
93
148
|
const quality = avgEff < 2 || uniqueRatio < 0.4 ? 'noisy' : 'ok'
|
|
94
149
|
|
|
95
150
|
return {
|
|
96
|
-
lan:
|
|
151
|
+
lan: tracks.main.lan, lanDoc: tracks.main.lan_doc, lines: body.length, txt, plain,
|
|
97
152
|
chars, quality, avgEff: Number(avgEff.toFixed(1)), uniqueRatio: Number(uniqueRatio.toFixed(2)),
|
|
98
153
|
head: body.slice(0, 5).map((l) => `[${fmt(l.from)}] ${l.content}`),
|
|
154
|
+
// 中文翻译轨(没有时为 null):原文 lines/txt 不变,翻译单独给,调用方自己决定怎么用
|
|
155
|
+
translated,
|
|
99
156
|
}
|
|
100
157
|
} catch (e) {
|
|
101
158
|
console.warn(`[官方字幕] 获取失败(按无官方字幕处理):${String(e.message).split('\n')[0].slice(0, 60)}`)
|
|
@@ -122,6 +179,7 @@ export function looksLikeMp4(p) {
|
|
|
122
179
|
}
|
|
123
180
|
|
|
124
181
|
export async function ensureMp4(bvid, pageNo = 1) {
|
|
182
|
+
fs.mkdirSync(CACHE, { recursive: true })
|
|
125
183
|
const p = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
|
|
126
184
|
if (looksLikeMp4(p)) return p
|
|
127
185
|
|
|
@@ -132,18 +190,17 @@ export async function ensureMp4(bvid, pageNo = 1) {
|
|
|
132
190
|
const duration = page.duration ?? data.duration
|
|
133
191
|
const title = page.part && data.pages.length > 1 ? `${data.title} · P${pageNo} ${page.part}` : data.title
|
|
134
192
|
|
|
135
|
-
const pu = await (await
|
|
193
|
+
const pu = await (await fetchWithRetry(
|
|
136
194
|
`https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&qn=80&fnval=1&platform=pc&high_quality=1`,
|
|
137
|
-
{ headers: H,
|
|
195
|
+
{ headers: H }, { timeoutMs: 15000 })).json()
|
|
138
196
|
const url = pu.data?.durl?.[0]?.url
|
|
139
197
|
if (!url) throw new Error(`没拿到直链 code=${pu.code}`)
|
|
140
198
|
|
|
141
199
|
// 下载到 .part 再 rename:网络中断时只会留下 .part,不会把半个 mp4 伪装成缓存
|
|
142
200
|
// (旧实现直接写最终路径,中断后下次运行会拿着残缺文件当缓存用)
|
|
143
|
-
const r = await
|
|
201
|
+
const r = await fetchWithRetry(url, {
|
|
144
202
|
headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/', range: 'bytes=0-' },
|
|
145
|
-
|
|
146
|
-
})
|
|
203
|
+
}, { timeoutMs: 300000 })
|
|
147
204
|
if (!r.ok) throw new Error(`下载失败 HTTP ${r.status}`)
|
|
148
205
|
const buf = Buffer.from(await r.arrayBuffer())
|
|
149
206
|
if (buf.length < 1024) throw new Error('下载内容过短,可能被中断(.part 已丢弃)')
|
|
@@ -161,10 +218,9 @@ export async function ensureMp4(bvid, pageNo = 1) {
|
|
|
161
218
|
|
|
162
219
|
export async function getDanmaku(cid, range = null) {
|
|
163
220
|
try {
|
|
164
|
-
const r = await
|
|
221
|
+
const r = await fetchWithRetry(`https://comment.bilibili.com/${cid}.xml`, {
|
|
165
222
|
headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/' },
|
|
166
|
-
|
|
167
|
-
})
|
|
223
|
+
}, { timeoutMs: 20000 })
|
|
168
224
|
if (!r.ok) return null
|
|
169
225
|
const xml = await r.text()
|
|
170
226
|
const list = []
|
package/lib/budget.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// lib/budget.mjs —— token 预算估算(零依赖,Node ≥18)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独拆一个模块:
|
|
4
|
+
// fisheye 的整个「抽多少帧 / 拼几张图」决策都建立在「一张图 ≈ 多少 token」上。
|
|
5
|
+
// 旧版把这个值写死在 stages.mjs(COST = { grid: 1200, subs: 1000, fine: 1300 }),
|
|
6
|
+
// 但不同模型的图像 tokenizer 差异很大——同一张 1920x1080 拼图,
|
|
7
|
+
// Gemini 对单图按固定档位计(约 258 token 起),GPT-4o high detail 按 512px 瓦片计(约 1.1k~2.1k),
|
|
8
|
+
// Qwen-VL 系列走动态分辨率(28px patch,token 更多)。固定系数会系统性高估或低估 20~30%,
|
|
9
|
+
// 直接让「预算只够几张」算错,最后要么超支、要么白白变稀。
|
|
10
|
+
//
|
|
11
|
+
// 估算公式:
|
|
12
|
+
// estimateTokens(kind, count) = round(tokensPerUnit(kind) * count)
|
|
13
|
+
// tokensPerUnit(kind) = round(基准(kind) * 模型系数) // 单张/单帧成本
|
|
14
|
+
//
|
|
15
|
+
// 基准值(环境变量可覆盖,单位 token;覆盖值须为正数,非法值忽略并告警一次):
|
|
16
|
+
// grid = 1200 FISHEYE_TOKENS_PER_GRID
|
|
17
|
+
// 3x3 拼图(9 格 × 640px,约 1920x1080)。stageAuto 还会按实际画面面积再修正,
|
|
18
|
+
// 所以这里只是 16:9 的基准值。
|
|
19
|
+
// subs = 1000 FISHEYE_TOKENS_PER_SUBS
|
|
20
|
+
// 烧录字幕带(1xN 行,只裁画面里的字幕条,信息密度高但仍是图)。
|
|
21
|
+
// fine = 1300 FISHEYE_TOKENS_PER_FINE
|
|
22
|
+
// 精读用单帧原图(不压成 640 格,通常比拼图里的单格更贵一点)。
|
|
23
|
+
//
|
|
24
|
+
// 模型系数(FISHEYE_TOKEN_MODEL,默认 1.0 = 基准,保持向后兼容):
|
|
25
|
+
// gpt 1.0 GPT-4o / 4.1 一类按 512px 瓦片计费的基准
|
|
26
|
+
// qwen 1.35 Qwen2-VL / 2.5-VL 动态分辨率,同图 token 明显更多(保守取 1.35)
|
|
27
|
+
// gemini 0.8 Gemini 单图固定档位计费,token 更少;取 0.8 作为保守下限(不按 258 的极端值算)
|
|
28
|
+
// 也可直接写数字(如 1.2),会被收敛到 [0.25, 4] 区间
|
|
29
|
+
//
|
|
30
|
+
// 兼容性:三个环境变量都不设、FISHEYE_TOKEN_MODEL 也不设时,
|
|
31
|
+
// estimateTokens('grid'|'subs'|'fine', n) 与旧版 COST.* × n 完全一致。
|
|
32
|
+
|
|
33
|
+
// 默认单张成本(token)。导出 COST 是为了兼容旧代码里 `import { COST } from './stages.mjs'` 的用法。
|
|
34
|
+
const DEFAULTS = Object.freeze({ grid: 1200, subs: 1000, fine: 1300 })
|
|
35
|
+
|
|
36
|
+
export const COST = DEFAULTS
|
|
37
|
+
|
|
38
|
+
// 模型系数表:只描述「相对基准模型,同一张图的 token 大致贵/便宜多少」。
|
|
39
|
+
// 数值来自公开的计费规则量级(图像 500~4000 token/图),不是精确 tokenizer 复刻;
|
|
40
|
+
// 目的是把 20~30% 的系统性偏差收窄,而不是追求逐像素精确。
|
|
41
|
+
export const MODEL_FACTORS = Object.freeze({
|
|
42
|
+
gpt: 1,
|
|
43
|
+
qwen: 1.35,
|
|
44
|
+
gemini: 0.8,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const ENV_PER_UNIT = Object.freeze({
|
|
48
|
+
grid: 'FISHEYE_TOKENS_PER_GRID',
|
|
49
|
+
subs: 'FISHEYE_TOKENS_PER_SUBS',
|
|
50
|
+
fine: 'FISHEYE_TOKENS_PER_FINE',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const FACTOR_MIN = 0.25
|
|
54
|
+
const FACTOR_MAX = 4
|
|
55
|
+
|
|
56
|
+
// 同一个错误只提示一次,避免在每个拼图的日志里刷屏
|
|
57
|
+
const warned = new Set()
|
|
58
|
+
function warnOnce(key, message) {
|
|
59
|
+
if (warned.has(key)) return
|
|
60
|
+
warned.add(key)
|
|
61
|
+
console.warn(`[预算] ${message}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toPositiveNumber(raw) {
|
|
65
|
+
if (raw === undefined || raw === null) return null
|
|
66
|
+
const text = String(raw).trim()
|
|
67
|
+
if (!text) return null
|
|
68
|
+
const n = Number(text)
|
|
69
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 解析模型系数;无法识别/非法时回退 1.0(并告警一次),保证环境变量写错不会瘫痪 CLI */
|
|
73
|
+
export function modelFactor(raw = process.env.FISHEYE_TOKEN_MODEL) {
|
|
74
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return 1
|
|
75
|
+
const key = String(raw).trim().toLowerCase()
|
|
76
|
+
if (Object.prototype.hasOwnProperty.call(MODEL_FACTORS, key)) return MODEL_FACTORS[key]
|
|
77
|
+
const n = toPositiveNumber(key)
|
|
78
|
+
if (n !== null) {
|
|
79
|
+
const clamped = Math.min(FACTOR_MAX, Math.max(FACTOR_MIN, n))
|
|
80
|
+
if (clamped !== n) warnOnce(`factor:${n}`, `FISHEYE_TOKEN_MODEL=${raw} 超出合理范围,已收敛为 ×${clamped}`)
|
|
81
|
+
return clamped
|
|
82
|
+
}
|
|
83
|
+
warnOnce(`model:${key}`, `FISHEYE_TOKEN_MODEL=${raw} 不认识(可用 gpt / qwen / gemini,或 0.5~2 的数字),按 ×1 估算`)
|
|
84
|
+
return 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 单张(单帧)成本:基准 × 模型系数,四舍五入到整数 token */
|
|
88
|
+
export function tokensPerUnit(kind) {
|
|
89
|
+
const base = DEFAULTS[kind]
|
|
90
|
+
if (base === undefined) {
|
|
91
|
+
throw new Error(`tokensPerUnit:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
|
|
92
|
+
}
|
|
93
|
+
const envVar = ENV_PER_UNIT[kind]
|
|
94
|
+
const raw = process.env[envVar]
|
|
95
|
+
let unit = base
|
|
96
|
+
if (raw !== undefined && String(raw).trim() !== '') {
|
|
97
|
+
const n = toPositiveNumber(raw)
|
|
98
|
+
if (n === null) warnOnce(`env:${envVar}`, `${envVar}=${raw} 不是正数,忽略,仍用默认 ${base}`)
|
|
99
|
+
else unit = n
|
|
100
|
+
}
|
|
101
|
+
return Math.round(unit * modelFactor())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 估算 count 个单位的 token 消耗。
|
|
106
|
+
* @param {'grid'|'subs'|'fine'} kind 单位类型:拼图 / 烧录字幕带 / 精读单帧
|
|
107
|
+
* @param {number} [count=1] 数量;非有限数或 ≤0 视为 0(空列表不花钱)
|
|
108
|
+
* @returns {number} 整数 token
|
|
109
|
+
*/
|
|
110
|
+
export function estimateTokens(kind, count = 1) {
|
|
111
|
+
if (DEFAULTS[kind] === undefined) {
|
|
112
|
+
throw new Error(`estimateTokens:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
|
|
113
|
+
}
|
|
114
|
+
const n = count === undefined || count === null ? 1 : Number(count)
|
|
115
|
+
if (!Number.isFinite(n) || n <= 0) return 0
|
|
116
|
+
return Math.round(tokensPerUnit(kind) * n)
|
|
117
|
+
}
|
package/lib/env.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
6
7
|
|
|
7
8
|
export const PKG_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
8
9
|
export const WS = process.env.FISHEYE_HOME || PKG_DIR
|
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 + 时间戳:两个视频并发跑时不会互相覆盖
|