@pls-compile-owo/fisheye 0.2.1 → 0.4.2

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/stages.mjs ADDED
@@ -0,0 +1,878 @@
1
+ // 五个阶段 + 本地视频:grid / subs / auto / refine / grab / local / selftest
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import crypto from 'node:crypto'
6
+ import { execFileSync, spawnSync } from 'node:child_process'
7
+ import { spamReason, filterDanmaku } from '../danmaku-filter.mjs'
8
+ import { PKG_DIR, WS, CACHE, OUT, CFG_FILE, LOCAL_CFG, onPath, resolveTool, FFMPEG, PYTHON, PYTHON_RES, CRED } from './env.mjs'
9
+ import { fmt, fsec, jpegSize, fail, num, gridStr, parseTime, pageNum } from './util.mjs'
10
+ import { UA, cred, H, getView, MIXIN_TAB, keyOf, clean, getOfficialSubs, looksLikeMp4, ensureMp4, getDanmaku } from './bili.mjs'
11
+ import { detectScenes, subtractAds, pickTimes, snapSubsTimes, detectBand, grabFrame, runCapture } from './frame.mjs'
12
+ import { audioCaps } from './audio.mjs'
13
+ import { AD_SEGMENT_SIGNAL, detectAdSegments, summarizeDanmaku } from './danmaku.mjs'
14
+ import { estimateTokens, tokensPerUnit, modelFactor, COST } from './budget.mjs'
15
+
16
+ export async function stageGrid(bvid, chunkSec, grid, pre = {}) {
17
+ const pageNo = pre.page ?? 1
18
+ const mp4 = pre.mp4 ?? await ensureMp4(bvid, pageNo)
19
+ const viewData = pre.view ?? await getView(bvid)
20
+ // 时长要用「所处理分P」的时长(多P 视频的 view.duration 是全部分P 总和)
21
+ const pageInfo = viewData.pages?.[pageNo - 1]
22
+ const title = pageInfo?.part && (viewData.pages?.length ?? 1) > 1
23
+ ? `${viewData.title} · P${pageNo} ${pageInfo.part}` : viewData.title
24
+ const fullDuration = pageInfo?.duration ?? viewData.duration
25
+ const owner = viewData.owner?.name
26
+ const [cols, rows] = grid.split('x').map(Number)
27
+ const N = cols * rows
28
+ // 时间区间:只铺 [start, end]
29
+ const range = pre.range ?? null
30
+ const start = range?.from ?? 0
31
+ const end = Math.min(range?.to ?? fullDuration, fullDuration)
32
+ const span = Math.max(0.1, end - start)
33
+ if (range) console.log(`[区间] 铺网范围 ${fmt(start)}-${fmt(end)}`)
34
+
35
+ // 画面变化点(visualScenes)与「参与吸附的候选」(sceneCands)要分开
36
+ // 弹幕热区要参与吸附,但不能污染 manifest 里每格的 scene 分数(那是纯画面的指标)。
37
+ const visualScenes = pre.scenes ?? detectScenes(mp4)
38
+ const strong = visualScenes.filter((s) => s.score >= 0.15).length
39
+ console.log(`[场景] 候选 ${visualScenes.length} 处画面变化(score≥0.15 的 ${strong} 处)`)
40
+
41
+ // 弹幕热区也当作「重要时刻」参与吸附:B 站弹幕密度本身就是内容信号,
42
+ // 那些「画面没怎么变、但观众集体激动」的时刻,纯视觉检测抓不到。
43
+ // score 给 0.9 而不是 1.0 —— 同一窗口里真实画面变化优先,
44
+ // 避免一个热区把整段的帧全吸过去(那会破坏均匀覆盖)。
45
+ let sceneCands = visualScenes
46
+ const dmFrameCands = pre.useDanmakuFrames === false ? [] : (pre.danmakuHot ?? [])
47
+ if (dmFrameCands.length) {
48
+ sceneCands = [
49
+ ...visualScenes,
50
+ ...dmFrameCands.map((h) => ({ t: h.start + 2.5, score: 0.9, kind: 'danmaku' })),
51
+ ]
52
+ console.log(`[取帧] 弹幕热区 ${dmFrameCands.length} 处参与吸附(score 0.9,低于真实画面变化)`)
53
+ }
54
+ // 字幕信息:pre 里显式传了(哪怕是 null=检测失败)就复用,不要再检测第二遍
55
+ let bandResult = pre.bandResult !== undefined ? pre.bandResult : null
56
+ if (pre.bandResult === undefined) {
57
+ try { bandResult = detectBand(mp4, fullDuration) } catch { /* 分析失败就当没有字幕 */ }
58
+ }
59
+ let videoSize = null
60
+
61
+ const chunks = []
62
+ // 分段边界:先在区间内均分,再把过短的尾段并进前一段
63
+ const bounds = []
64
+ for (let c = 0; c < Math.max(1, Math.ceil(span / chunkSec)); c++) {
65
+ bounds.push({ from: start + c * chunkSec, to: Math.min(end, start + (c + 1) * chunkSec) })
66
+ }
67
+ const tailMin = Math.min(20, chunkSec * 0.25)
68
+ if (bounds.length > 1 && bounds.at(-1).to - bounds.at(-1).from < tailMin) {
69
+ const tail = bounds.pop()
70
+ bounds.at(-1).to = tail.to
71
+ console.log(`[分段] 尾段只有 ${(tail.to - tail.from).toFixed(1)}s,已并入上一段`)
72
+ }
73
+
74
+ // 高置信度广告段:这些时间不占帧(get 交给内容),铺网时压缩掉
75
+ const adHigh = (pre.adSegments ?? []).filter((s) => s.confidence === 'high')
76
+
77
+ for (let c = 0; c < bounds.length; c++) {
78
+ const { from, to } = bounds[c]
79
+ const picked = pickTimes(from, to, N, sceneCands, adHigh)
80
+ if (!picked) {
81
+ // 整段都被高置信度广告占满(极罕见)——跳过这张图,不让它浪费预算
82
+ console.log(`[取帧] ${fmt(from)}-${fmt(to)} 整段都是疑似广告,跳过这张拼图`)
83
+ continue
84
+ }
85
+ const times = picked.times
86
+ const thText = picked.th === null ? '无变化点' : `阈值 ${picked.th}`
87
+ const dedupText = picked.dedup ? `,去重退回 ${picked.dedup} 帧` : ''
88
+ const adText = adHigh.length
89
+ ? `(已跳过广告 ${Math.round(adHigh.filter((a) => a.to > from && a.from < to).reduce((s, a) => s + Math.min(a.to, to) - Math.max(a.from, from), 0))}s)`
90
+ : ''
91
+ console.log(`[取帧] ${fmt(from)}-${fmt(to)} ${thText},${picked.snapped}/${N} 帧吸附${dedupText}${adText}`)
92
+ // 变化点稀疏/聚集(如冲浪视频:大部分平静、巨浪集中在几秒)时吸附率会很低——
93
+ // 这是正常现象,等间隔骨架保覆盖,但要提醒:关键时刻可能被少量帧覆盖,看精读建议
94
+ if (picked.snapped < N * 0.5) {
95
+ console.log(` ↳ 变化点稀疏或高度聚集(${visualScenes.length} 处全片变化点),等间隔骨架兜底;关键瞬间见精读建议`)
96
+ }
97
+
98
+ const tmp = path.join(OUT, `.f-${bvid}-${c}`)
99
+ fs.mkdirSync(tmp, { recursive: true })
100
+ const files = []
101
+ times.forEach((t, i) => {
102
+ const f = path.join(tmp, `f${String(i).padStart(2, '0')}.jpg`)
103
+ // -ss 放在 -i 前面 = 快速 seek(跳到最近关键帧),比精确 seek 快一个数量级。
104
+ // 拼图和精读用同一种 seek,保证「同一时间戳」两边取到的是同一帧。
105
+ if (!grabFrame(mp4, t, f)) {
106
+ // 抽帧失败(t 越界 / 解码出错):复制上一帧补齐,避免 tile 因缺帧直接报错
107
+ if (files.length) fs.copyFileSync(files[files.length - 1], f)
108
+ else return // 第一帧就失败 → 这一帧留空,下面按数量不足处理
109
+ }
110
+ files.push(f)
111
+ })
112
+ if (files.length < N) {
113
+ console.warn(`[警告] 本段只抽到 ${files.length}/${N} 帧,跳过该段(其余段不受影响)`)
114
+ fs.rmSync(tmp, { recursive: true, force: true })
115
+ continue
116
+ }
117
+ if (!videoSize) videoSize = jpegSize(files[0]) // 原片分辨率(供几何可读性计算)
118
+
119
+ const sheet = path.join(OUT, `${bvid}-c${String(c).padStart(2, '0')}_${fsec(from)}-${fsec(to)}_${cols}x${rows}.jpg`)
120
+ execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', path.join(tmp, `f%02d.jpg`), '-vf',
121
+ `scale=640:-1,tile=${cols}x${rows}`, '-frames:v', '1', '-q:v', '3', sheet], { stdio: 'ignore' })
122
+ fs.rmSync(tmp, { recursive: true, force: true })
123
+
124
+ // 这一格落在多强的画面变化上(供「哪些格值得精读」的判断参考:变化越强,通常信息越密)
125
+ const scoreAt = (t) => {
126
+ const c = visualScenes.filter((s) => Math.abs(s.t - t) < 0.06)
127
+ return c.length ? Number(Math.max(...c.map((s) => s.score)).toFixed(3)) : 0
128
+ }
129
+ // 有广告被跳过时,range 标注成「原始区间 + 实际内容范围」,否则会让人误以为覆盖到段尾
130
+ const adSkipped = Math.round(adHigh
131
+ .filter((a) => a.to > from && a.from < to)
132
+ .reduce((s, a) => s + Math.min(a.to, to) - Math.max(a.from, from), 0))
133
+ const effRange = adSkipped > 0 && times.length
134
+ ? `${fmt(times[0])}-${fmt(times[times.length - 1])}`
135
+ : null
136
+ chunks.push({
137
+ chunk: c + 1,
138
+ from: Number(from.toFixed(1)),
139
+ to: Number(to.toFixed(1)),
140
+ range: `${fmt(from)}-${fmt(to)}`,
141
+ adSkipped, // 本段被跳过的疑似广告秒数
142
+ effectiveRange: effRange, // 实际内容覆盖范围(跳过广告后)
143
+ sheet,
144
+ grid: `${cols}x${rows}`,
145
+ threshold: picked.th,
146
+ snapped: picked.snapped,
147
+ cells: times.map((t, i) => ({
148
+ cell: i + 1,
149
+ row: Math.floor(i / cols) + 1,
150
+ col: (i % cols) + 1,
151
+ t: Number(t.toFixed(2)),
152
+ ts: fmt(t),
153
+ scene: scoreAt(t),
154
+ shift: Number((t - picked.base[i]).toFixed(1)), // 相对等间隔基准挪了多少秒
155
+ })),
156
+ })
157
+ const adNote = adSkipped > 0 ? `(跳过广告 ${adSkipped}s,内容实际 ${effRange})` : ''
158
+ console.log(`[拼图 ${c + 1}/${bounds.length}] ${fmt(from)}-${fmt(to)} ${N} 帧${adNote} → ${path.basename(sheet)}`)
159
+ }
160
+
161
+ const sheetSize = chunks.length ? jpegSize(chunks[0].sheet) : null
162
+ const manifest = {
163
+ bvid, title, up: owner, duration: fullDuration, chunkSec,
164
+ page: pre.page ?? 1, // 分P 序号(grab/refine 靠它找对缓存文件)
165
+ range: range ? { from: Number(start.toFixed(1)), to: Number(end.toFixed(1)) } : null,
166
+ grid: `${cols}x${rows}`,
167
+ // 几何信息:refine --auto 用它算「拼图里字幕还剩几个像素」
168
+ sheetWidth: sheetSize?.w ?? null,
169
+ sheetHeight: sheetSize?.h ?? null,
170
+ videoWidth: videoSize?.w ?? null,
171
+ videoHeight: videoSize?.h ?? null,
172
+ subs: bandResult?.ok ? { top: bandResult.topRatio, bottom: bandResult.botRatio } : null,
173
+ subsSheet: pre.subsSheet ?? false, // 是否另外生成了字幕带
174
+ subsCovered: pre.subsCovered ?? pre.subsSheet ?? false, // 字幕信息是否已覆盖(official 或 burned-in)
175
+ subsSource: pre.subsSource ?? null, // 'official' | 'burned-in' | null
176
+ danmaku: pre.danmakuHot?.length
177
+ ? { total: pre.danmakuTotal ?? null, hot: pre.danmakuHot }
178
+ : null, // 弹幕热区(refine 的几何兜底会合并进来)
179
+ adSegments: pre.adSegments ?? null, // 视频内疑似广告段(refine 会避开 high 置信度区间)
180
+ chunks,
181
+ createdAt: new Date().toISOString(),
182
+ }
183
+ const mf = path.join(OUT, `${bvid}-manifest.json`)
184
+ fs.writeFileSync(mf, JSON.stringify(manifest, null, 2), 'utf8')
185
+ console.log(`\nMANIFEST: ${mf}`)
186
+ console.log(`共 ${chunks.length} 张拼图 / ${chunks.reduce((a, c) => a + c.cells.length, 0)} 帧`)
187
+ console.log(`\n看图顺序(每张图从左到右、从上到下对应下列时间;括号里是该格的画面变化强度):`)
188
+ for (const c of chunks) {
189
+ console.log(` 图${c.chunk} [${c.range}] ${c.sheet}`)
190
+ console.log(` ${c.cells.map((x) => `#${x.cell}=${x.ts}${x.scene ? `(${x.scene})` : ''}`).join(' ')}`)
191
+ }
192
+ return manifest
193
+ }
194
+
195
+ // 字幕带竖排:剪掉画面、只留底部字幕,竖着拼 N 条
196
+ // band 可以是比例(0.22)或 'auto'(先跑 analyze_subs.py 自动定位字幕带)
197
+ // 依据:讲解/翻唱类视频,字幕就是全部信息量;同样 token 预算下,字幕带比全画幅拼图清楚得多
198
+
199
+ export async function stageSubs(bvid, count, bandArg, pre = {}) {
200
+ const pageNo = pre.page ?? 1
201
+ const mp4 = pre.mp4 ?? path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
202
+ if (!fs.existsSync(mp4)) throw new Error(`没有缓存 mp4,先跑 auto 或 grid:${mp4}`)
203
+ const viewData = pre.view ?? await getView(bvid)
204
+ const page = viewData.pages?.[pageNo - 1]
205
+ const fullDuration = page?.duration ?? viewData.duration
206
+ // 时间区间:字幕行只取 [start, end] 内
207
+ const range = pre.range ?? null
208
+ const start = range?.from ?? 0
209
+ const span = Math.max(0.1, Math.min(range?.to ?? fullDuration, fullDuration) - start)
210
+ // 有场景数据就吸附变化点,没有就纯等间隔(单独跑 subs 时不额外做全片检测)
211
+ const scenes = pre.scenes ?? null
212
+ const times = scenes?.length
213
+ ? snapSubsTimes(span, count, scenes, start)
214
+ : Array.from({ length: count }, (_, i) => start + ((i + 0.5) * span) / count)
215
+ if (range) console.log(`[区间] 字幕带范围 ${fmt(start)}-${fmt(start + span)}`)
216
+
217
+ // 决定裁剪范围:[top, bottom] 比例
218
+ let top, bottom, how
219
+ if (bandArg === 'auto') {
220
+ // 显式传了 null(=检测失败)就复用,别为它再检测第二遍
221
+ const r = pre.bandResult !== undefined ? pre.bandResult : detectBand(mp4, fullDuration)
222
+ if (r) {
223
+ top = r.topRatio
224
+ bottom = r.botRatio
225
+ how = `自动检测(${r.frames} 帧,像素 ${r.region.top}-${r.region.bottom}/${r.videoH})`
226
+ } else {
227
+ top = 0.78
228
+ bottom = 1.0
229
+ how = '自动检测失败,退回 0.78-1.0'
230
+ }
231
+ } else {
232
+ // band 必须是 0~0.6 的比例:NaN / 负数 / 超范围都会让 crop 表达式出错
233
+ const band = Number(bandArg)
234
+ if (!Number.isFinite(band) || band <= 0 || band >= 0.6) {
235
+ throw new Error(`--band 需是 0~0.6 的比例(如 0.22 = 底部 22%),收到:${JSON.stringify(bandArg)}`)
236
+ }
237
+ top = 1 - band
238
+ bottom = 1
239
+ how = `固定比例 ${band}`
240
+ }
241
+ console.log(`[字幕带] ${how} → 裁剪 ${(top * 100).toFixed(0)}%-${(bottom * 100).toFixed(0)}%`)
242
+
243
+ const tmp = path.join(OUT, `.s-${bvid}`)
244
+ fs.mkdirSync(tmp, { recursive: true })
245
+ let ok = 0
246
+ times.forEach((t, i) => {
247
+ const f = path.join(tmp, `f${String(i).padStart(2, '0')}.jpg`)
248
+ if (!grabFrame(mp4, t, f)) {
249
+ if (ok > 0) fs.copyFileSync(path.join(tmp, `f${String(i - 1).padStart(2, '0')}.jpg`), f)
250
+ else return
251
+ }
252
+ ok++
253
+ })
254
+ if (ok < count) {
255
+ console.warn(`[警告] 只抽到 ${ok}/${count} 帧,字幕带按实际帧数拼接`)
256
+ if (ok === 0) {
257
+ fs.rmSync(tmp, { recursive: true, force: true })
258
+ return { sheet: null, times: [], band: { top, bottom } }
259
+ }
260
+ }
261
+ const out = path.join(OUT, `${bvid}-subs_${count}行.jpg`)
262
+ try {
263
+ execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', path.join(tmp, 'f%02d.jpg'), '-vf',
264
+ `crop=iw:ih*${(bottom - top).toFixed(4)}:0:ih*${top.toFixed(4)},tile=1x${ok}`,
265
+ '-frames:v', '1', '-q:v', '3', out], { stdio: 'ignore' })
266
+ } catch {
267
+ console.warn('[字幕带] 拼接失败(已跳过,不影响拼图)')
268
+ fs.rmSync(tmp, { recursive: true, force: true })
269
+ return { sheet: null, times: [], band: { top, bottom } }
270
+ }
271
+ fs.rmSync(tmp, { recursive: true, force: true })
272
+ console.log(`SUBS: ${out}`)
273
+ console.log(` 每行对应时间: ${times.map((t, i) => `${i + 1}=${fmt(t)}`).join(' ')}`)
274
+ return { sheet: out, times: times.map((t) => Number(t.toFixed(1))), band: { top, bottom } }
275
+ }
276
+
277
+ // auto:按 token 预算 + 内容变化率,自动决定采样密度、张数和路径
278
+ // 平衡原则:先保「看懂所需的最低帧密度」,再在剩余预算里加张数;
279
+ // 预算不够时诚实降级并报告,而不是悄悄变稀。
280
+
281
+ // 单张成本不再写死:统一走 lib/budget.mjs 的 estimateTokens(kind, count)。
282
+ // 默认基准仍是 grid 1200 / subs 1000 / fine 1300(与旧版完全一致),
283
+ // 但支持 FISHEYE_TOKENS_PER_* 覆盖与 FISHEYE_TOKEN_MODEL 模型系数。
284
+ // COST 保留导出只为兼容旧的外部引用,内部不再直接乘它(它看不到环境变量)。
285
+ export { COST }
286
+
287
+ export const MAX_GRIDS = 12 // 拼图张数硬上限,防长视频失控
288
+
289
+ // selftest:自检
290
+ // 原则:默认不下载视频、几秒内跑完;--deep 才做抽帧和字幕带。
291
+ // 覆盖:依赖(ffmpeg/python/analyze_subs.py)→ 凭证 → API(视频/弹幕/字幕)→ 缓存 → 磁盘
292
+
293
+ export async function stageSelftest(opts = {}) {
294
+ const rows = []
295
+ const rec = (mark, label, detail = '') => {
296
+ rows.push({ mark, label, detail })
297
+ console.log(` [${mark}] ${label.padEnd(18)} ${detail}`)
298
+ }
299
+ const t0 = Date.now()
300
+ const deep = opts.deep === true
301
+ console.log('\n== fisheye 自检 ==\n')
302
+
303
+ // 1) Node
304
+ const nv = process.versions.node
305
+ rec(Number(nv.split('.')[0]) >= 18 ? 'OK' : '!!', 'Node', `v${nv}${Number(nv.split('.')[0]) >= 18 ? '' : '(需要 ≥18)'}`)
306
+
307
+ // 2) ffmpeg:直接试着执行(PATH 命中时 FFMPEG 是命令名而不是路径,
308
+ // 用 existsSync 判断会误报「配置的路径不存在」——这是最常见的安装方式,必须走 runCapture)
309
+ if (!FFMPEG) rec('!!', 'ffmpeg', '未找到(装到 PATH,或设 FFMPEG 环境变量 / 写 ~/.config/fisheye/config.json)')
310
+ else {
311
+ const r = runCapture(FFMPEG, ['-version'], 10000)
312
+ if (r.ok) rec('OK', 'ffmpeg', r.out.split('\n')[0].slice(0, 58))
313
+ else rec('!!', 'ffmpeg', `无法执行(来源 ${FFMPEG_RES?.from ?? '?'}):${r.err.slice(0, 50)}`)
314
+ }
315
+
316
+ // 3) python(字幕带检测用,可选)
317
+ if (!PYTHON) rec('--', 'python', '未找到(字幕带功能会降级,其余不受影响)')
318
+ else {
319
+ const r = runCapture(PYTHON, ['--version'], 10000)
320
+ if (r.ok) rec('OK', 'python', r.out.trim())
321
+ else rec('--', 'python', `无法执行(${PYTHON_RES?.from ?? '?'},可能是 Windows 商店占位符)——字幕带功能降级`)
322
+ }
323
+
324
+ // 4) analyze_subs.py
325
+ const aspy = path.join(PKG_DIR, 'analyze_subs.py')
326
+ rec(fs.existsSync(aspy) ? 'OK' : '!!', 'analyze_subs.py', fs.existsSync(aspy) ? '存在' : '不存在(烧录字幕带检测会失败)')
327
+
328
+ // 5) 输出/缓存目录可写
329
+ try {
330
+ const t = path.join(OUT, `.selftest-${process.pid}.tmp`)
331
+ fs.writeFileSync(t, 'ok')
332
+ fs.unlinkSync(t)
333
+ rec('OK', '输出目录', '可写')
334
+ } catch (e) { rec('!!', '输出目录', `不可写:${String(e.message).slice(0, 50)}`) }
335
+
336
+ // 6) 凭证
337
+ if (!CRED) rec('!!', 'B站凭证', `未找到(创建 ${path.join(WS, 'credentials.json')},或设 BILI_CRED 环境变量)`)
338
+ else if (!fs.existsSync(CRED)) rec('!!', 'B站凭证', `配置的路径不存在:${CRED}`)
339
+ else {
340
+ const miss = ['sessdata', 'bili_jct'].filter((k) => !cred[k])
341
+ rec(miss.length ? '!!' : 'OK', 'B站凭证', miss.length ? `缺字段:${miss.join('/')}` : 'SESSDATA + bili_jct 已配置')
342
+ }
343
+
344
+ // 7) 视频/弹幕/字幕 API —— 用缓存里的 BV,没有就用默认测试片
345
+ if (!fs.existsSync(CACHE)) fs.mkdirSync(CACHE, { recursive: true })
346
+ const cached = fs.readdirSync(CACHE).filter((f) => /^BV[\w]+(-p\d+)?\.mp4$/.test(f) && fs.statSync(path.join(CACHE, f)).size > 1024)
347
+ const testBv = cached.length ? cached[0].replace(/(-p\d+)?\.mp4$/, '') : 'BV1ziWP6qENy'
348
+ let view = null
349
+ try {
350
+ view = await getView(testBv)
351
+ rec('OK', '视频 API', `${testBv} ${view.duration}s「${String(view.title ?? '').slice(0, 16)}」`)
352
+ } catch (e) {
353
+ rec('!!', '视频 API', `请求失败:${String(e.message).split('\n')[0].slice(0, 50)}`)
354
+ }
355
+ const cid = view?.pages?.[0]?.cid
356
+ if (cid) {
357
+ try {
358
+ const dm = await getDanmaku(cid)
359
+ rec(dm?.length ? 'OK' : '--', '弹幕 API', dm?.length ? `${dm.length} 条(含广告过滤前)` : '无数据(视频可能关弹幕)')
360
+ } catch (e) { rec('!!', '弹幕 API', String(e.message).slice(0, 50)) }
361
+ try {
362
+ const subs = await getOfficialSubs(testBv, cid, null)
363
+ rec(subs ? 'OK' : '--', '官方字幕 API', subs ? `${subs.quality} / ${subs.lines} 行 / ${subs.chars} 字` : '这条视频无字幕轨(不影响,会用字幕带或画面)')
364
+ } catch (e) { rec('!!', '官方字幕 API', String(e.message).slice(0, 50)) }
365
+ try {
366
+ const audio = audioCaps()
367
+ rec(audio.available ? 'OK' : '--', '音频转写', audio.summary)
368
+ } catch (e) { rec('!!', '音频转写', String(e.message).slice(0, 50)) }
369
+ } else {
370
+ rec('--', '弹幕/字幕 API', '视频 API 不可用,跳过')
371
+ }
372
+
373
+ // 8) 缓存
374
+ const cacheMb = cached.reduce((s, f) => s + fs.statSync(path.join(CACHE, f)).size, 0) / 1024 / 1024
375
+ rec(cached.length ? 'OK' : '--', '缓存', cached.length ? `${cached.length} 个视频 / ${cacheMb.toFixed(1)} MB` : '空(首次跑会下载)')
376
+
377
+ // 9) 磁盘空间
378
+ try {
379
+ const st = fs.statfsSync(WS)
380
+ const freeGb = (st.bsize * st.bavail) / 1024 / 1024 / 1024
381
+ rec(freeGb > 1 ? 'OK' : '!!', '磁盘剩余', `${freeGb.toFixed(1)} GB${freeGb > 1 ? '' : '(长视频缓存可能不够)'}`)
382
+ } catch { rec('--', '磁盘剩余', 'statfs 不可用') }
383
+
384
+ // 10) --deep:抽帧 + 字幕带定位
385
+ if (deep) {
386
+ const mp4 = cached.length ? path.join(CACHE, cached[0]) : null
387
+ if (!mp4) {
388
+ rec('--', '抽帧实测', '没有缓存视频可测(先跑一次 auto)')
389
+ } else {
390
+ const tmp = path.join(OUT, `.selftest-frame-${process.pid}.jpg`)
391
+ const okFrame = grabFrame(mp4, 1.0, tmp) && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024
392
+ const size = okFrame ? jpegSize(tmp) : null
393
+ rec(okFrame ? 'OK' : '!!', '抽帧实测', okFrame ? `${cached[0]} → ${size ? `${size.w}x${size.h}` : 'ok'}` : '抽帧失败')
394
+ try { fs.unlinkSync(tmp) } catch {}
395
+ if (okFrame && fs.existsSync(aspy) && PYTHON) {
396
+ // 采样 3 帧喂给字幕带检测脚本
397
+ const dir = path.join(OUT, `.selftest-subs-${process.pid}`)
398
+ fs.mkdirSync(dir, { recursive: true })
399
+ let n = 0
400
+ for (const t of [1, 3, 5]) {
401
+ const f = path.join(dir, `f${n}.jpg`)
402
+ if (grabFrame(mp4, t, f)) n++
403
+ }
404
+ const outJson = path.join(dir, 'r.json')
405
+ const r = runCapture(PYTHON, [aspy, dir, outJson], 30000)
406
+ let band = null
407
+ try { band = JSON.parse(fs.readFileSync(outJson, 'utf8')) } catch {}
408
+ rec(r.ok && band?.ok ? 'OK' : '!!', '字幕带实测', band?.ok
409
+ ? `${(band.topRatio * 100).toFixed(0)}%-${(band.botRatio * 100).toFixed(0)}% 置信度 ${band.confidence}(仅 3 帧采样,数值仅供参考,只验证链路通)`
410
+ : `失败:${(r.err || '无 JSON 输出').slice(0, 50)}`)
411
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
412
+ } else {
413
+ rec('--', '字幕带实测', '缺少 python 或 analyze_subs.py')
414
+ }
415
+ }
416
+ }
417
+
418
+ // 汇总
419
+ const okN = rows.filter((r) => r.mark === 'OK').length
420
+ const badN = rows.filter((r) => r.mark === '!!').length
421
+ const skipN = rows.filter((r) => r.mark === '--').length
422
+ const secs = ((Date.now() - t0) / 1000).toFixed(1)
423
+ console.log(`\n结果:${okN} 项通过${badN ? ` / ${badN} 项失败` : ''}${skipN ? ` / ${skipN} 项跳过` : ''}(${secs}s)`)
424
+ if (badN) {
425
+ console.log('\n怎么修:')
426
+ console.log(' · ffmpeg/python 缺失 → 用环境变量指定路径:FFMPEG=/path/to/ffmpeg.exe PYTHON=...')
427
+ console.log(' · 凭证缺失 → 在 B 站登录态文件里补 sessdata / bili_jct')
428
+ console.log(' · API 失败 → 检查网络;bilibili 接口可能改了,需要更新代码')
429
+ console.log(' · 字幕带失败 → 确认 python 有 numpy + Pillow')
430
+ } else {
431
+ console.log('✅ 全部通过')
432
+ }
433
+ if (!deep) console.log('\n(想看抽帧/字幕带实测,加 --deep)')
434
+ return { ok: badN === 0, rows }
435
+ }
436
+
437
+ export async function stageAuto(bvid, budget, opts = {}) {
438
+ const pageNo = opts.page ?? 1
439
+ const mp4 = await ensureMp4(bvid, pageNo)
440
+ const view = await getView(bvid)
441
+ const pageCount = view.pages?.length ?? 1
442
+ const page = view.pages?.[pageNo - 1]
443
+ if (pageNo > 1 && !page) throw new Error(`没有第 ${pageNo} 个分P(这条视频共 ${pageCount} 个)`)
444
+ const fullDuration = page?.duration ?? view.duration
445
+ const cid = page?.cid ?? view.cid
446
+
447
+ // 时间范围:所有内部计算用「区间长度」,但对外(manifest/日志/精读)都用全局秒数
448
+ const start = Math.max(0, Number(opts.from ?? 0))
449
+ const end = (opts.to !== null && opts.to !== undefined) ? Number(opts.to) : fullDuration
450
+ // 上界校验:--from 超出视频时长时,后面的 duration 会算出负数,
451
+ // 再往下 chunk 变负 → pickTimes 返回 null → 打印「整段都是疑似广告」,是误导性日志
452
+ if (!Number.isFinite(start) || start >= fullDuration) {
453
+ throw new Error(`--from(${Number.isFinite(start) ? start : opts.from}s) 超出视频时长(${fullDuration}s)`)
454
+ }
455
+ if (!Number.isFinite(end) || end <= start) {
456
+ throw new Error(`--to(${opts.to ?? fullDuration}s) 必须大于 --from(${start}s),视频时长 ${fullDuration}s`)
457
+ }
458
+ const duration = Math.min(end, fullDuration) - start
459
+ const range = start > 0 || end < fullDuration ? { from: start, to: Math.min(end, fullDuration) } : null
460
+
461
+ console.log(`\n=== 规划:${view.title}${pageCount > 1 ? ` · P${pageNo}/${pageCount}` : ''}(${fullDuration}s)===`)
462
+ if (range) console.log(`[区间] 只处理 ${fmt(range.from)}-${fmt(range.to)}(${duration.toFixed(0)}s / 全片 ${fullDuration}s)`)
463
+ if (pageCount > 1 && pageNo === 1) console.log(`[提示] 多P 视频,本次只处理 P1;要处理别的分P 用 --p N`)
464
+
465
+ // 拼图 token 按实际画面比例修正:estimateTokens('grid', 1) 以 16:9(约 1920x1080)拼图为基准,
466
+ // 方形/竖屏视频的拼图面积大得多(1920x1920 贵 78%,竖屏贵 3 倍+),不算清楚会误导预算决策
467
+ const dim = view.dimension ?? {}
468
+ const vw = Number(dim.width) || 1280
469
+ const vh = Number(dim.height) || 720
470
+ const cellH = Math.round((640 * vh) / Math.max(vw, 1))
471
+ const sheetPx = 640 * 3 * (cellH * 3)
472
+ const gridBase = estimateTokens('grid', 1)
473
+ const gridCost = Math.max(300, Math.round((gridBase * sheetPx) / (1920 * 1080)))
474
+ const factor = modelFactor()
475
+ if (factor !== 1) {
476
+ console.log(`[预算] 模型系数 ×${factor}(FISHEYE_TOKEN_MODEL,按该 tokenizer 的图像成本修正)`)
477
+ }
478
+ if (Math.abs(sheetPx / (1920 * 1080) - 1) > 0.25) {
479
+ console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准 ${gridBase} 已按面积修正)`)
480
+ }
481
+
482
+ // 1. 内容变化率:只统计所处理区间内的显著变化(score≥0.3)
483
+ const scenes = detectScenes(mp4)
484
+ const rangeScenes = range ? scenes.filter((s) => s.t >= range.from && s.t <= range.to) : scenes
485
+ const strong = rangeScenes.filter((s) => s.score >= 0.3)
486
+ const rate = duration > 0 ? strong.length / duration : 0
487
+ console.log(`[内容] 显著变化 ${strong.length} 处${range ? '(区间内)' : ''},变化率 ${rate.toFixed(2)} 次/秒`)
488
+
489
+ // 2. 字幕:官方字幕优先(文本几百 token,比抽字幕带图快且准);没有才考虑烧录字幕带
490
+ const officialRaw = await getOfficialSubs(bvid, cid, range)
491
+ // 质量太差的官方字幕(音乐/音效被 ASR 成 碎片)不能用,误当台词会让理解完全跑偏
492
+ const official = officialRaw?.quality === 'ok' ? officialRaw : null
493
+ let bandResult = null
494
+ let hasSubs = false
495
+ if (official) {
496
+ console.log(`[字幕] 官方${official.lanDoc ?? ''} ${official.lines} 行 / ${official.chars} 字 ≈ ${official.chars} token → 跳过字幕带`)
497
+ console.log(` 文本: ${official.txt}`)
498
+ if (official.chars > 8000) {
499
+ console.log(` ⚠️ 字幕较长(${official.chars} 字 ≈ ${official.chars} token),建议只读关心的时段而不是全文`)
500
+ }
501
+ for (const line of official.head.slice(0, 3)) console.log(` ${line}`)
502
+ } else {
503
+ if (officialRaw) {
504
+ console.log(`[字幕] 官方字幕质量差(平均有效 ${officialRaw.avgEff} 字/行、去重率 ${officialRaw.uniqueRatio})——疑为音乐/音效噪声,不采用`)
505
+ }
506
+ try {
507
+ const audio = audioCaps()
508
+ if (audio.available) console.log('[音频] whisper 可用:如需台词文本,可先转写(详见 README 环境变量)')
509
+ else console.log('[音频] 转写不可用:' + audio.hint)
510
+ } catch {}
511
+ // 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
512
+ bandResult = detectBand(mp4, fullDuration)
513
+ // 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
514
+ // 拼出来一堆非字幕行。低于 0.5 就不用字幕带,只靠画面拼图。
515
+ const conf = bandResult?.confidence
516
+ const confOk = conf === undefined || conf >= 0.5
517
+ hasSubs = !!(bandResult?.ok && bandResult.bandRatio >= 0.015 && bandResult.bandRatio <= 0.35 && confOk)
518
+ if (bandResult?.ok && !confOk) {
519
+ console.log(`[字幕] 检出疑似字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%)但置信度仅 ${Number(conf).toFixed(2)}`)
520
+ console.log(` —— 字幕位置漂移或不是每帧都有(抖音式动态字幕常见),跳过字幕带,只靠画面`)
521
+ } else {
522
+ console.log(`[字幕] ${hasSubs
523
+ ? `无可用官方字幕,检出烧录字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%,置信度 ${Number(conf).toFixed(2)})→ 字幕带 1 张`
524
+ : '无可用字幕 → 只靠画面'}`)
525
+ }
526
+ }
527
+
528
+ // 2.5 弹幕(第三个信息源:观众反应)
529
+ // 不参与「能不能看懂」的判定,只用来找高光——观众在哪一段最激动,那里通常最值得精读
530
+ const danmakuRaw = await getDanmaku(cid, range)
531
+ const { kept: danmaku, dropped } = filterDanmaku(danmakuRaw)
532
+ if (danmakuRaw?.length) {
533
+ const parts = []
534
+ if (dropped.ad) parts.push(`${dropped.ad} 条广告引流`)
535
+ if (dropped.noise) parts.push(`${dropped.noise} 条无意义刷屏`)
536
+ if (parts.length) {
537
+ console.log(`[弹幕] 原始 ${danmakuRaw.length} 条,已过滤 ${parts.join(' / ')}`)
538
+ for (const s of dropped.samples) console.log(` 样本:${s}`)
539
+ }
540
+ }
541
+ const dmHot = summarizeDanmaku(danmaku)
542
+ // 视频内广告段(靠弹幕密度低谷 + 关键词;见 detectAdSegments 注释)
543
+ const adSegs = detectAdSegments(danmaku, fullDuration)
544
+ if (adSegs.length) {
545
+ console.log(`[广告段] 弹幕密度低谷 ${adSegs.length} 段:`)
546
+ for (const s of adSegs) {
547
+ const tag = s.confidence === 'high' ? '疑似广告' : '低互动段(未必是广告)'
548
+ const kw = s.keywords.length ? ` 关键词「${s.keywords.join('」「')}」` : ''
549
+ console.log(` ${s.ts}(${s.dur}s)${tag} 密度比全片低 ${(s.lowDensity * 100).toFixed(0)}%${kw}`)
550
+ }
551
+ }
552
+ if (danmaku?.length) {
553
+ console.log(`[弹幕] ${danmaku.length} 条${range ? '(区间内)' : ''}${dmHot?.length ? ',观众最激动的时段:' : ''}`)
554
+ for (const h of dmHot ?? []) {
555
+ console.log(` ${h.ts} ${String(h.count).padStart(3)} 条 「${h.samples.join('」「')}」`)
556
+ }
557
+ } else {
558
+ console.log('[弹幕] 无(视频关弹幕 / 无人发 / 获取失败)')
559
+ }
560
+
561
+ // 3. 看懂所需的最低帧间隔(可由 --density 强制覆盖)
562
+ let maxGap
563
+ if (opts.density) maxGap = opts.density
564
+ else if (rate > 1.2) maxGap = 4 // 快剪 / MV / 游戏
565
+ else if (rate > 0.5) maxGap = 6 // vlog / 教程
566
+ else if (rate > 0.15) maxGap = 9 // 口播 / 慢节奏
567
+ else maxGap = 13 // 静态 / 录屏
568
+ console.log(`[密度] 目标 ≤${maxGap}s/帧(${opts.density ? '手动指定' : '按变化率推导'})`)
569
+
570
+ // 4. 张数:先满足密度,再看预算
571
+ const framesNeeded = Math.ceil(duration / maxGap)
572
+ const minGrids = Math.max(1, Math.ceil(framesNeeded / 9))
573
+
574
+ const subsCost = official ? Math.round(official.chars) : hasSubs ? estimateTokens('subs', 1) : 0
575
+ const subsRows = duration <= 180 ? 14 : duration <= 600 ? 20 : 24
576
+ const minFine = (official || hasSubs) ? 2 : 3 // 字幕已在手,精读需求自然变少
577
+ const fineReserve = estimateTokens('fine', minFine)
578
+ const fixedCost = subsCost + fineReserve
579
+ const maxGridsByBudget = Math.max(1, Math.floor((budget - fixedCost) / gridCost))
580
+
581
+ let gridCount = minGrids
582
+ const notes = []
583
+ if (gridCount > maxGridsByBudget) {
584
+ notes.push(`预算只够 ${maxGridsByBudget} 张(密度会变稀)`)
585
+ gridCount = maxGridsByBudget
586
+ }
587
+ if (gridCount > MAX_GRIDS) {
588
+ notes.push(`触及张数上限 ${MAX_GRIDS}`)
589
+ gridCount = MAX_GRIDS
590
+ }
591
+ gridCount = Math.max(1, gridCount)
592
+
593
+ const chunk = duration / gridCount
594
+ const actualGap = duration / (gridCount * 9)
595
+
596
+ // 5. 报告
597
+ const estTotal = subsCost + gridCount * gridCost + fineReserve
598
+ console.log(`[张数] 密度需要 ${minGrids} 张,预算允许 ${maxGridsByBudget} 张 → 采用 ${gridCount} 张`)
599
+ console.log(`[参数] chunk=${chunk.toFixed(1)}s(每张覆盖),实际平均 ${actualGap.toFixed(1)}s/帧`)
600
+ console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${fineReserve} = ${estTotal}`)
601
+ if (estTotal > budget) {
602
+ console.log(`[警告] 最低配置已要 ${estTotal} token,超出预算 ${budget}(字幕带和精读预留不可再砍),将超支运行`)
603
+ }
604
+ if (actualGap > maxGap + 0.5) console.log(`[降级] 实际 ${actualGap.toFixed(1)}s/帧 稀于目标 ${maxGap}s/帧:${notes.join(';')}`)
605
+ if (notes.length) console.log(`[备注] ${notes.join(';')}`)
606
+
607
+ // 6. 执行铺网
608
+ console.log(`\n--- 生成 ---`)
609
+ const pre = {
610
+ mp4, view, scenes, bandResult, subsSheet: hasSubs, range, page: pageNo,
611
+ subsCovered: !!(official || hasSubs), // 字幕信息是否已被覆盖(官方文本或烧录字幕带)
612
+ subsSource: official ? 'official' : hasSubs ? 'burned-in' : null,
613
+ danmakuHot: dmHot, // 写进 manifest,让 refine 也能用弹幕热区挑点
614
+ danmakuTotal: danmaku?.length ?? 0,
615
+ adSegments: adSegs, // 视频内疑似广告段(精读会避开 high 置信度的)
616
+ useDanmakuFrames: opts.useDanmakuFrames !== false, // 弹幕热区是否也参与铺网取帧(默认是)
617
+ }
618
+ const manifest = await stageGrid(bvid, chunk, '3x3', pre)
619
+ if (hasSubs) await stageSubs(bvid, subsRows, 'auto', { mp4, view, bandResult, scenes, range, page: pageNo })
620
+
621
+ // 7. 精读建议 = 几何规则(画面变化剧烈)+ 弹幕热区(观众反应最激烈)
622
+ // 两个信号互补:几何只看像素,弹幕看人的反应;合并后按时间排序、同一时刻只留一个
623
+ const geo = geometricPick(manifest)
624
+ const dmPicks = (dmHot ?? []).map((h) => {
625
+ const mid = h.start + 2.5
626
+ return {
627
+ t: mid,
628
+ ts: fmt(mid), // 和 t 保持一致(桶中点),否则排序后显示会错乱
629
+ reason: `弹幕热区 ${h.count} 条:「${h.samples.join('」「')}」`,
630
+ source: '弹幕热区',
631
+ }
632
+ })
633
+ const seen = new Set()
634
+ const suggestions = []
635
+ for (const p of [...dmPicks, ...geo]) {
636
+ const key = Math.round(p.t)
637
+ if (seen.has(key)) continue
638
+ seen.add(key)
639
+ suggestions.push(p)
640
+ }
641
+ suggestions.sort((a, b) => a.t - b.t)
642
+
643
+ console.log(`\n=== 完成 ===`)
644
+ console.log(`预计 token ≈ ${estTotal}(若铺网信息已自足、跳过精读,实际会更低)`)
645
+ if (official) {
646
+ console.log(`\n[字幕] 台词已存成文本,直接读(别为字幕抽帧):`)
647
+ console.log(` ${official.txt}`)
648
+ }
649
+ if (suggestions.length) {
650
+ console.log(`\n[精读建议] ${suggestions.length} 个时间点(几何 ${geo.length} + 弹幕 ${dmPicks.length}):`)
651
+ for (const p of suggestions) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
652
+ console.log(` 需要时: node fisheye.mjs refine ${bvid}`)
653
+ console.log(` (几何只能找「变化剧烈」、弹幕只能找「观众反应」;读图发现其他格看不清时,走 refine --template/--decisions)`)
654
+ } else {
655
+ console.log(`\n[精读建议] 两个信号都没命中;读图后如需精读:node fisheye.mjs refine ${bvid} --template`)
656
+ }
657
+ return { gridCount, chunk, actualGap, maxGap, hasSubs, estTotal, notes, danmaku: danmaku?.length ?? 0, top: suggestions }
658
+ }
659
+
660
+ // refine:决定哪些格需要精读,然后自动截原分辨率单帧
661
+ // 两种模式
662
+ // --template 生成决策模板(每格一行,agent 读完拼图后填 needDetail)
663
+ // --decisions <file.json> 按 agent 的结构化判断执行
664
+ // 都不传 几何兜底:只凭像素算,零 token(不读图)
665
+ // 分工原则:语义判断("这格重要吗")只有读图能做;几何判断("这格的字一定糊")可以纯计算。
666
+
667
+ export const MIN_READABLE_SUBS_PX = 12 // 拼图里字幕低于这个像素高度就必然糊
668
+
669
+ export function buildDecisionTemplate(manifest) {
670
+ const cells = []
671
+ for (const c of manifest.chunks) {
672
+ for (const cell of c.cells) {
673
+ cells.push({
674
+ chunk: c.chunk, cell: cell.cell, t: cell.t, ts: cell.ts, scene: cell.scene,
675
+ desc: '', // 填:这一格你看到了什么
676
+ needDetail: false, // 填:是否要看原分辨率
677
+ reason: '', // 填:为什么要看(字幕糊 / 特写 / 看不清某物)
678
+ })
679
+ }
680
+ }
681
+ return {
682
+ bvid: manifest.bvid,
683
+ title: manifest.title,
684
+ note: '逐格填 desc 和 needDetail;reason 写理由。needDetail=true 的会被自动截原图。',
685
+ cells,
686
+ }
687
+ }
688
+
689
+ export function geometricPick(manifest) {
690
+ const { subs, videoWidth, videoHeight, sheetWidth } = manifest
691
+ // 字幕是否已被覆盖(官方文本 / 烧录字幕带);旧 manifest 只有 subsSheet
692
+ const subsCovered = manifest.subsCovered ?? manifest.subsSheet ?? false
693
+ const picks = []
694
+ // 弹幕热区也参与挑点:观众在哪一段最激动,那里通常最值得单独看原图
695
+ for (const h of manifest.danmaku?.hot ?? []) {
696
+ // t 取桶中点(不是桶起点),ts 也用中点 —— 两者必须一致,否则按 t 排序时显示会错乱
697
+ const mid = Number(h.start) + 2.5
698
+ picks.push({
699
+ t: mid,
700
+ ts: fmt(mid),
701
+ scene: 1,
702
+ chunk: 0,
703
+ cell: 0,
704
+ reasons: [`弹幕热区 ${h.count} 条:「${(h.samples ?? []).join('」「')}」`],
705
+ })
706
+ }
707
+ for (const c of manifest.chunks) {
708
+ const [cols] = c.grid.split('x').map(Number)
709
+ const cellW = sheetWidth && cols ? sheetWidth / cols : null
710
+ const scale = cellW && videoWidth ? cellW / videoWidth : null
711
+ // 字幕在拼图里还剩多少像素
712
+ const subsPx = subs && scale && videoHeight
713
+ ? (subs.bottom - subs.top) * videoHeight * scale
714
+ : null
715
+ for (const cell of c.cells) {
716
+ const reasons = []
717
+ // 字幕糊:只在「字幕信息还没被覆盖」时才值得逐帧精读
718
+ if (subsPx !== null && subsPx < MIN_READABLE_SUBS_PX && !subsCovered) {
719
+ reasons.push(`字幕在拼图里只剩 ${subsPx.toFixed(1)}px(低于 ${MIN_READABLE_SUBS_PX}px,必然糊)`)
720
+ }
721
+ if (cell.scene >= 0.85) reasons.push(`画面变化强度 ${cell.scene},信息密度高`)
722
+ if (reasons.length) {
723
+ picks.push({ t: cell.t, ts: cell.ts, chunk: c.chunk, cell: cell.cell, scene: cell.scene, reasons })
724
+ }
725
+ }
726
+ }
727
+ // 精读避开高置信度广告段:那里不值得花 token 看原图(标注给 agent 知道即可)
728
+ const adHigh = (manifest.adSegments ?? []).filter((s) => s.confidence === 'high')
729
+ const inAd = (t) => adHigh.some((s) => t >= s.from - 5 && t <= s.to + 5)
730
+
731
+ // 按信号强度排序,最多取 8 个(几何规则 + 弹幕热区合并后),避免把整片都标成必读
732
+ // 去重按「秒」:不同信号可能指向同一时刻(82.18 和 82.5 显示都是 01:22),保留更靠前的
733
+ // (scene 排序已把弹幕热区放前面,因为它的理由更具体:观众反应)
734
+ const seenSec = new Set()
735
+ const deduped = []
736
+ for (const p of picks.sort((a, b) => b.scene - a.scene)) {
737
+ if (inAd(p.t)) continue // 广告段不出现在精读建议里
738
+ const sec = Math.floor(p.t)
739
+ if (seenSec.has(sec)) continue
740
+ seenSec.add(sec)
741
+ deduped.push(p)
742
+ }
743
+ return deduped
744
+ .slice(0, 8)
745
+ .sort((a, b) => a.t - b.t) // 按时间顺序展示
746
+ .map((p) => ({
747
+ t: p.t,
748
+ ts: p.ts,
749
+ reason: p.reasons.join(';'),
750
+ source: p.chunk ? `图${p.chunk} #${p.cell} 几何规则` : '弹幕热区',
751
+ }))
752
+ }
753
+
754
+ export async function stageRefine(bvid, opts) {
755
+ const mfPath = path.join(OUT, `${bvid}-manifest.json`)
756
+ if (!fs.existsSync(mfPath)) throw new Error(`没有 manifest,先跑 auto 或 grid:${mfPath}`)
757
+ const manifest = JSON.parse(fs.readFileSync(mfPath, 'utf8'))
758
+
759
+ if (opts.template) {
760
+ const tpl = buildDecisionTemplate(manifest)
761
+ const p = path.join(OUT, `${bvid}-decisions.template.json`)
762
+ fs.writeFileSync(p, JSON.stringify(tpl, null, 2), 'utf8')
763
+ console.log(`决策模板: ${p}`)
764
+ console.log(`共 ${tpl.cells.length} 格。读完拼图后填 needDetail=true,再执行:`)
765
+ console.log(` node fisheye.mjs refine ${bvid} --decisions "${p}"`)
766
+ return { template: p, cells: tpl.cells.length }
767
+ }
768
+
769
+ let picks
770
+ if (opts.decisions) {
771
+ // 去掉 BOM —— PowerShell 的 Set-Content -Encoding UTF8 和记事本都会加,JSON.parse 会炸
772
+ const txt = fs.readFileSync(opts.decisions, 'utf8').replace(/^\uFEFF/, '')
773
+ const d = JSON.parse(txt)
774
+ const all = (d.cells ?? []).filter((x) => x.needDetail)
775
+ // 校验时间范围:越界的时间点截不出帧,提前报出来而不是静默失败
776
+ const dur = manifest.duration ?? Infinity
777
+ const bad = all.filter((x) => typeof x.t !== 'number' || x.t < 0 || x.t > dur)
778
+ if (bad.length) {
779
+ console.warn(`[警告] ${bad.length} 个时间点越界或格式错误,已忽略:${bad.map((x) => x.t).join(', ')}`)
780
+ }
781
+ picks = all.filter((x) => typeof x.t === 'number' && x.t >= 0 && x.t <= dur).map((x) => ({
782
+ t: x.t,
783
+ ts: x.ts ?? fmt(x.t),
784
+ reason: x.reason || x.desc || '模型判断需要细看',
785
+ source: `图${x.chunk} #${x.cell}`,
786
+ }))
787
+ } else {
788
+ picks = geometricPick(manifest)
789
+ }
790
+
791
+ if (!picks.length) {
792
+ console.log('没有需要精读的点(几何规则未命中,也没给 decisions)')
793
+ return { picks: [] }
794
+ }
795
+
796
+ console.log(`\n[精读] 选中 ${picks.length} 个时间点:`)
797
+ for (const p of picks) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
798
+ if (picks.length > 8) {
799
+ console.warn(`[警告] ${picks.length} 帧 ≈ ${estimateTokens('fine', picks.length)} token,建议先只留真正看不清的几帧再跑`)
800
+ }
801
+ console.log('')
802
+ const grabbed = await stageGrab(bvid, picks.map((p) => p.t).join(','), { page: manifest.page ?? 1 })
803
+ const est = estimateTokens('fine', picks.length)
804
+ console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${tokensPerUnit('fine')})`)
805
+ return { picks, grabbed }
806
+ }
807
+
808
+ export async function stageGrab(bvid, timeStr, opts = {}) {
809
+ const pageNo = opts.page ?? 1
810
+ const mp4 = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
811
+ if (!fs.existsSync(mp4)) throw new Error(`没有缓存 mp4,先跑 auto 或 grid:${mp4}`)
812
+ // 注意 Number('') === 0:必须先滤掉空串,否则空输入会变成「0 秒」
813
+ const times = timeStr
814
+ .split(',')
815
+ .map((s) => s.trim())
816
+ .filter((s) => s !== '')
817
+ .map(Number)
818
+ .filter((n) => Number.isFinite(n))
819
+ if (!times.length) {
820
+ console.error('没有有效时间点(格式:44,59.5,79.9)')
821
+ return []
822
+ }
823
+ const out = []
824
+ for (const t of times) {
825
+ // 2 位小数:1.73 和 1.74 若只留 1 位都会变成 1_7s,后一个会覆盖前一个
826
+ const f = path.join(OUT, `${bvid}-grab-${t.toFixed(2).replace('.', '_')}s.jpg`)
827
+ // 和拼图用同一种 seek(-ss 前置),保证「拼图上第 N 格」和「精读的同一时间点」是同一帧。
828
+ if (!grabFrame(mp4, t, f, 2)) {
829
+ console.warn(`[精读帧] ${fmt(t)} 抽取失败(时间越界或解码出错),跳过`)
830
+ continue
831
+ }
832
+ out.push({ t, ts: fmt(t), file: f })
833
+ console.log(`[精读帧] ${fmt(t)} → ${f}`)
834
+ }
835
+ console.log(JSON.stringify(out, null, 2))
836
+ return out
837
+ }
838
+
839
+
840
+ // 本地视频:不依赖 B站,直接用 ffmpeg 抽帧拼图(fisheye local <文件>)
841
+
842
+ export async function stageLocal(inputFile, opts = {}) {
843
+ const file = path.resolve(inputFile)
844
+ if (!fs.existsSync(file)) throw new Error(`文件不存在: ${file}`)
845
+ if (!FFMPEG) throw new Error('找不到 ffmpeg(设置 FFMPEG 环境变量,或放到 ~/.dsh/surfcore/bili/bin/ffmpeg.exe)')
846
+ const grid = String(opts.grid || '3x3').toLowerCase()
847
+ const [cols, rows] = grid.split('x').map(Number)
848
+ if (!cols || !rows || cols < 1 || rows < 1 || cols > 9 || rows > 9) throw new Error('grid 格式应为 3x3 / 4x4 / 5x5')
849
+ const n = cols * rows
850
+ // 用 ffmpeg 解析时长:stderr 写临时文件,避免依赖管道捕获
851
+ const probeTmp = path.join(os.tmpdir(), `fisheye-local-probe-${process.pid}-${Date.now()}.txt`)
852
+ try {
853
+ const fd = fs.openSync(probeTmp, 'w')
854
+ spawnSync(FFMPEG, ['-hide_banner', '-i', file, '-f', 'null', '-'], { stdio: ['ignore', 'ignore', fd], timeout: 30000 })
855
+ fs.closeSync(fd)
856
+ } catch {}
857
+ let text = ''
858
+ try { text = fs.readFileSync(probeTmp, 'utf8') } catch {}
859
+ try { fs.unlinkSync(probeTmp) } catch {}
860
+ const durm = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text)
861
+ if (!durm) throw new Error('读不出视频时长(ffmpeg 无法解析该文件?)')
862
+ const duration = (+durm[1]) * 3600 + (+durm[2]) * 60 + (+durm[3])
863
+ const from = Math.max(0, Number(opts.from) || 0)
864
+ const to = Number.isFinite(opts.to) && opts.to > from ? Math.min(opts.to, duration) : duration
865
+ const segDur = Math.max(0.5, to - from)
866
+ const interval = Math.max(0.1, segDur / n)
867
+ const base = path.basename(file, path.extname(file)).replace(/[^\w.-]+/g, '_').slice(0, 40)
868
+ fs.mkdirSync(OUT, { recursive: true })
869
+ const outPath = path.join(OUT, `local-${base}-${grid}-${Date.now()}.jpg`)
870
+ const vf = `fps=1/${interval.toFixed(4)},scale=480:-2,tile=${cols}x${rows}`
871
+ const r = spawnSync(FFMPEG, ['-y', '-ss', String(from.toFixed(3)), '-t', String(segDur.toFixed(3)), '-i', file, '-vf', vf, '-frames:v', '1', '-q:v', '3', outPath], { stdio: 'ignore', timeout: 600000 })
872
+ if (r.status !== 0 || !fs.existsSync(outPath)) throw new Error('抽帧拼图失败(ffmpeg 返回非 0 或没有输出图)')
873
+ console.log(`时长: ${duration.toFixed(1)}s | 区间: ${from.toFixed(1)}-${to.toFixed(1)}s | 抽帧: ${n} 张 (${grid}) | 间隔 ${interval.toFixed(1)}s`)
874
+ console.log(`SHEET: ${outPath}`)
875
+ return { sheet: outPath, duration, from, to, grid }
876
+ }
877
+
878
+ // CLI