@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/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 fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
30
- { headers: H, signal: AbortSignal.timeout(15000) })
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 fetch('https://api.bilibili.com/x/web-interface/nav',
52
- { headers: H, signal: AbortSignal.timeout(15000) })).json()
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 fetch(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
63
- { headers: H, signal: AbortSignal.timeout(15000) })).json()
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 pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
67
- ?? subs.find((s) => String(s.lan).startsWith('zh')) ?? subs[0]
68
- if (!pick?.subtitle_url) return null
69
-
70
- const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
71
- const r = await fetch(url, {
72
- headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
73
- signal: AbortSignal.timeout(20000),
74
- })
75
- if (!r.ok) return null
76
- let body = (JSON.parse(await r.text()).body ?? [])
77
- if (!body.length) return null
78
- // 只保留所处理区间内的字幕行(--from/--to)
79
- 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
+ ])
80
115
  if (!body.length) return null
81
116
 
82
- const txt = path.join(OUT, `${bvid}-official-subs.txt`)
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: 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,
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 fetch(
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, signal: AbortSignal.timeout(15000) })).json()
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 fetch(url, {
201
+ const r = await fetchWithRetry(url, {
144
202
  headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/', range: 'bytes=0-' },
145
- signal: AbortSignal.timeout(300000),
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 fetch(`https://comment.bilibili.com/${cid}.xml`, {
221
+ const r = await fetchWithRetry(`https://comment.bilibili.com/${cid}.xml`, {
165
222
  headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/' },
166
- signal: AbortSignal.timeout(20000),
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 + 时间戳:两个视频并发跑时不会互相覆盖