@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/CHANGELOG.md +60 -0
- package/README.en.md +4 -0
- package/README.md +33 -5
- package/fisheye.mjs +18 -1437
- package/lib/audio.mjs +645 -0
- package/lib/bili.mjs +195 -0
- package/lib/budget.mjs +117 -0
- package/lib/danmaku.mjs +75 -0
- package/lib/env.mjs +61 -0
- package/lib/frame.mjs +192 -0
- package/lib/i18n.mjs +131 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +878 -0
- package/lib/util.mjs +78 -0
- package/package.json +6 -2
- package/test/danmaku.test.mjs +70 -0
- package/test/util.test.mjs +71 -0
package/fisheye.mjs
CHANGED
|
@@ -15,1447 +15,39 @@
|
|
|
15
15
|
// · 拼图每格顺序固定,manifest 按「从左到右、从上到下」记录时间,供 agent 对照
|
|
16
16
|
// · 拼图与精读用同一种 seek,保证同一时间戳取到同一帧
|
|
17
17
|
// · 所有子进程用 stdio:'ignore'(沙箱禁管道捕获),数据走临时文件
|
|
18
|
-
import fs from 'node:fs'
|
|
19
|
-
import os from 'node:os'
|
|
20
18
|
import path from 'node:path'
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
19
|
+
import { PKG_DIR, WS, CFG_FILE, FFMPEG, CRED } from './lib/env.mjs'
|
|
20
|
+
import { parseTime, num, pageNum } from './lib/util.mjs'
|
|
21
|
+
import { t } from './lib/i18n.mjs'
|
|
22
|
+
import { stageAuto, stageGrid, stageSubs, stageSelftest, stageRefine, stageGrab, stageLocal } from './lib/stages.mjs'
|
|
25
23
|
|
|
26
|
-
// 路径与依赖解析
|
|
27
|
-
// 这个脚本要能在「别人的机器」上跑,所以不写死任何绝对路径。
|
|
28
|
-
// 解析顺序:环境变量 → ~/.config/fisheye/config.json → 项目内 bin/ → PATH
|
|
29
|
-
// FFMPEG=... ffmpeg 可执行文件
|
|
30
|
-
// PYTHON=... python 可执行文件(字幕带检测用,可选)
|
|
31
|
-
// BILI_CRED=... B站登录态 JSON
|
|
32
|
-
// FISHEYE_HOME=... 数据目录(cache/out 放哪,默认脚本所在目录)
|
|
33
|
-
const PKG_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
34
|
-
const WS = process.env.FISHEYE_HOME || PKG_DIR
|
|
35
|
-
const CACHE = path.join(WS, 'cache')
|
|
36
|
-
const OUT = path.join(WS, 'out')
|
|
37
|
-
fs.mkdirSync(CACHE, { recursive: true })
|
|
38
|
-
fs.mkdirSync(OUT, { recursive: true })
|
|
39
|
-
|
|
40
|
-
const CFG_FILE = path.join(os.homedir(), '.config', 'fisheye', 'config.json')
|
|
41
|
-
let LOCAL_CFG = {}
|
|
42
|
-
try {
|
|
43
|
-
// 去掉 BOM —— Windows 记事本 / PowerShell 写的 UTF-8 常带 BOM,直接 JSON.parse 会炸
|
|
44
|
-
// (开发时配置文件明明存在却解析成 {},查了半天是 BOM)
|
|
45
|
-
LOCAL_CFG = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8').replace(/^\uFEFF/, '')) || {}
|
|
46
|
-
} catch {}
|
|
47
|
-
|
|
48
|
-
/** 命令能不能直接从 PATH 调起来(不捕获输出,沙箱禁管道) */
|
|
49
|
-
function onPath(cmd) {
|
|
50
|
-
try {
|
|
51
|
-
execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore', timeout: 5000 })
|
|
52
|
-
return true
|
|
53
|
-
} catch { return false }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** 按 环境变量 → 配置文件 → 项目内 bin → PATH 的顺序找工具;找不到返回 null */
|
|
57
|
-
function resolveTool(envVar, cfgKey, localCandidates, cmdName) {
|
|
58
|
-
if (process.env[envVar]) return { cmd: process.env[envVar], from: `环境变量 ${envVar}` }
|
|
59
|
-
if (LOCAL_CFG[cfgKey] && fs.existsSync(LOCAL_CFG[cfgKey])) return { cmd: LOCAL_CFG[cfgKey], from: CFG_FILE }
|
|
60
|
-
for (const c of localCandidates) if (fs.existsSync(c)) return { cmd: c, from: '项目内 bin/' }
|
|
61
|
-
if (onPath(cmdName)) return { cmd: cmdName, from: 'PATH' }
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const FFMPEG_RES = resolveTool('FFMPEG', 'ffmpeg', [
|
|
66
|
-
path.join(PKG_DIR, 'bin', 'ffmpeg.exe'), path.join(PKG_DIR, 'bin', 'ffmpeg'),
|
|
67
|
-
], 'ffmpeg')
|
|
68
|
-
const PYTHON_RES = resolveTool('PYTHON', 'python', [
|
|
69
|
-
path.join(PKG_DIR, '.venv', 'Scripts', 'python.exe'), path.join(PKG_DIR, '.venv', 'bin', 'python'),
|
|
70
|
-
], process.platform === 'win32' ? 'python' : 'python3')
|
|
71
|
-
|
|
72
|
-
const FFMPEG = FFMPEG_RES?.cmd ?? null
|
|
73
|
-
const PYTHON = PYTHON_RES?.cmd ?? null
|
|
74
|
-
|
|
75
|
-
/** B站登录态:环境变量 → config.json → 数据目录 → ~/.config/fisheye/ */
|
|
76
|
-
function resolveCred() {
|
|
77
|
-
if (process.env.BILI_CRED) return process.env.BILI_CRED
|
|
78
|
-
if (LOCAL_CFG.credentials && fs.existsSync(LOCAL_CFG.credentials)) return LOCAL_CFG.credentials
|
|
79
|
-
for (const c of [
|
|
80
|
-
path.join(WS, 'credentials.json'),
|
|
81
|
-
path.join(os.homedir(), '.config', 'fisheye', 'credentials.json'),
|
|
82
|
-
]) if (fs.existsSync(c)) return c
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
const CRED = resolveCred()
|
|
86
|
-
|
|
87
|
-
// 自检要能诊断「依赖缺失」本身,所以 selftest 时不在下面硬退出
|
|
88
24
|
const IS_SELFTEST = process.argv[2] === 'selftest'
|
|
25
|
+
|
|
89
26
|
// local 处理本地文件,不需要 B站 登录态(但仍然需要 ffmpeg)
|
|
90
27
|
const IS_LOCAL = process.argv[2] === 'local'
|
|
28
|
+
|
|
91
29
|
if (!IS_SELFTEST) {
|
|
92
30
|
if (!FFMPEG) {
|
|
93
|
-
console.error(
|
|
94
|
-
|
|
95
|
-
安装方式(任选其一):
|
|
96
|
-
1. 装到 PATH:https://ffmpeg.org/download.html (Windows 用 gyan.dev 的 build)
|
|
97
|
-
2. 放项目里:把 ffmpeg 可执行文件放到 ${path.join(PKG_DIR, 'bin')}/
|
|
98
|
-
3. 设环境变量:FFMPEG=<ffmpeg 完整路径>
|
|
99
|
-
4. 写配置:${CFG_FILE} 里加 {"ffmpeg": "<完整路径>"}`)
|
|
31
|
+
console.error(t('err.ffmpeg.missing', { binDir: path.join(PKG_DIR, 'bin'), cfgFile: CFG_FILE }))
|
|
100
32
|
process.exit(2)
|
|
101
33
|
}
|
|
102
34
|
if (!IS_LOCAL && !CRED) {
|
|
103
|
-
console.error(
|
|
104
|
-
|
|
105
|
-
创建 ${path.join(WS, 'credentials.json')},内容:
|
|
106
|
-
{
|
|
107
|
-
"sessdata": "<B站 SESSDATA>",
|
|
108
|
-
"bili_jct": "<bili_jct>",
|
|
109
|
-
"buvid3": "<buvid3>"
|
|
110
|
-
}
|
|
111
|
-
获取方法:浏览器登录 B站 → F12 → Application → Cookies → bilibili.com,
|
|
112
|
-
复制这三个值。或用环境变量 BILI_CRED=<文件路径> 指向已有文件。
|
|
113
|
-
|
|
114
|
-
(也可以只跑 selftest 先看环境是否就绪:node fisheye.mjs selftest)`)
|
|
35
|
+
console.error(t('err.cred.missing', { credPath: path.join(WS, 'credentials.json') }))
|
|
115
36
|
process.exit(2)
|
|
116
37
|
}
|
|
117
38
|
}
|
|
118
39
|
|
|
119
|
-
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'
|
|
120
|
-
let cred = {}
|
|
121
|
-
try {
|
|
122
|
-
// 去 BOM:README 让用户「复制模板为 credentials.json」,而模板/记事本/PowerShell 写的
|
|
123
|
-
// UTF-8 常带 BOM。不剥的话 JSON.parse 抛错 → cred 为空 → 匿名拿不到 AI 字幕、
|
|
124
|
-
// 部分播放地址 403,但正式运行不报错,只有 selftest 报「缺字段」。
|
|
125
|
-
cred = JSON.parse(fs.readFileSync(CRED, 'utf8').replace(/^\uFEFF/, '')) || {}
|
|
126
|
-
} catch { /* 自检会报 */ }
|
|
127
|
-
const H = {
|
|
128
|
-
'user-agent': UA,
|
|
129
|
-
referer: 'https://www.bilibili.com/',
|
|
130
|
-
cookie: `SESSDATA=${cred.sessdata ?? ''}; bili_jct=${cred.bili_jct ?? ''}; buvid3=${cred.buvid3 ?? ''}`,
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
|
|
134
|
-
const fmt = (s) => {
|
|
135
|
-
const h = Math.floor(s / 3600)
|
|
136
|
-
const m = Math.floor((s % 3600) / 60)
|
|
137
|
-
const sec = Math.floor(s % 60)
|
|
138
|
-
const mm = String(m).padStart(2, '0')
|
|
139
|
-
const ss = String(sec).padStart(2, '0')
|
|
140
|
-
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
|
141
|
-
}
|
|
142
|
-
// Windows 文件名不能带冒号,落盘用秒数
|
|
143
|
-
const fsec = (s) => String(Math.round(s)).padStart(4, '0')
|
|
144
|
-
|
|
145
|
-
// 从 JPEG 头读尺寸(沙箱禁管道,不能用 ffprobe 捕获输出)
|
|
146
|
-
function jpegSize(file) {
|
|
147
|
-
try {
|
|
148
|
-
const b = fs.readFileSync(file)
|
|
149
|
-
let i = 2
|
|
150
|
-
while (i < b.length - 8) {
|
|
151
|
-
if (b[i] !== 0xFF) { i++; continue }
|
|
152
|
-
const marker = b[i + 1]
|
|
153
|
-
const len = (b[i + 2] << 8) | b[i + 3]
|
|
154
|
-
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
|
155
|
-
return { h: (b[i + 5] << 8) | b[i + 6], w: (b[i + 7] << 8) | b[i + 8] }
|
|
156
|
-
}
|
|
157
|
-
i += 2 + len
|
|
158
|
-
}
|
|
159
|
-
} catch {}
|
|
160
|
-
return null
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// 参数校验(在 CLI 分发处用;Number('')===0、Infinity、NaN 都要挡掉)
|
|
164
|
-
const fail = (msg) => {
|
|
165
|
-
console.error(`参数错误:${msg}`)
|
|
166
|
-
process.exit(2)
|
|
167
|
-
}
|
|
168
|
-
const num = (v, name, { min, max } = {}) => {
|
|
169
|
-
const n = Number(v)
|
|
170
|
-
if (v === '' || !Number.isFinite(n)) fail(`--${name} 需要数字,收到:${JSON.stringify(v)}`)
|
|
171
|
-
if (min !== undefined && n < min) fail(`--${name} 不能小于 ${min}(收到 ${n})`)
|
|
172
|
-
if (max !== undefined && n > max) fail(`--${name} 不能大于 ${max}(收到 ${n})`)
|
|
173
|
-
return n
|
|
174
|
-
}
|
|
175
|
-
const gridStr = (v) => {
|
|
176
|
-
const s = String(v ?? '3x3').toLowerCase()
|
|
177
|
-
if (!/^[1-9]x[1-9]$/.test(s)) fail(`--grid 格式应为 NxM(如 3x3,行列 1~9),收到:${JSON.stringify(v)}`)
|
|
178
|
-
return s
|
|
179
|
-
}
|
|
180
|
-
// 时间参数:支持 330(秒)/ 5:30 / 1:05:30
|
|
181
|
-
const parseTime = (v, name) => {
|
|
182
|
-
if (v === null || v === undefined || v === '') return null
|
|
183
|
-
const s = String(v).trim()
|
|
184
|
-
if (/^\d+(\.\d+)?$/.test(s)) return Number(s)
|
|
185
|
-
const parts = s.split(':')
|
|
186
|
-
if (parts.length < 2 || parts.length > 3) fail(`--${name} 时间格式应为 330 / 5:30 / 1:05:30,收到:${JSON.stringify(v)}`)
|
|
187
|
-
const nums = parts.map(Number)
|
|
188
|
-
if (nums.some((n) => !Number.isFinite(n) || n < 0)) fail(`--${name} 时间格式非法:${JSON.stringify(v)}`)
|
|
189
|
-
return parts.length === 2 ? nums[0] * 60 + nums[1] : nums[0] * 3600 + nums[1] * 60 + nums[2]
|
|
190
|
-
}
|
|
191
|
-
const pageNum = (v) => {
|
|
192
|
-
if (v === null || v === undefined || v === '') return 1
|
|
193
|
-
const n = Number(v)
|
|
194
|
-
if (!Number.isInteger(n) || n < 1 || n > 200) fail(`--p 分P序号应为 1~200 的整数,收到:${JSON.stringify(v)}`)
|
|
195
|
-
return n
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 统一取视频元信息(带超时 + code 校验;之前三处各自 fetch,有的没查 code)
|
|
199
|
-
async function getView(bvid) {
|
|
200
|
-
const r = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
|
|
201
|
-
{ headers: H, signal: AbortSignal.timeout(15000) })
|
|
202
|
-
const v = await r.json()
|
|
203
|
-
if (v.code !== 0) throw new Error(`view code=${v.code} ${v.message}`)
|
|
204
|
-
return v.data
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 官方字幕(看视频最省的一条腿)
|
|
208
|
-
// 有官方/AI 字幕时,台词信息用文本拿(几百 token),比抽字幕带图(1000 token + OCR 误差)又快又准。
|
|
209
|
-
// 关键(踩过的坑,见 subtitles.mjs)
|
|
210
|
-
// ① 必须走 WBI 签名的 /x/player/wbi/v2,旧端点拿到的 subtitle_url 缺 auth_key → CDN 403
|
|
211
|
-
// ② auth_key 分钟级过期,拿到就得马上下载
|
|
212
|
-
// ③ 匿名拿不到 AI 字幕,必须带登录 cookie
|
|
213
|
-
const MIXIN_TAB = [46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52]
|
|
214
|
-
const keyOf = (u) => u.slice(u.lastIndexOf('/') + 1, u.lastIndexOf('.'))
|
|
215
|
-
const clean = (v) => String(v).replace(/[!'()*]/g, '')
|
|
216
|
-
|
|
217
|
-
async function getOfficialSubs(bvid, cid, range = null) {
|
|
218
|
-
try {
|
|
219
|
-
const nav = await (await fetch('https://api.bilibili.com/x/web-interface/nav',
|
|
220
|
-
{ headers: H, signal: AbortSignal.timeout(15000) })).json()
|
|
221
|
-
const raw = keyOf(nav.data?.wbi_img?.img_url ?? '') + keyOf(nav.data?.wbi_img?.sub_url ?? '')
|
|
222
|
-
if (raw.length < 64) return null
|
|
223
|
-
const mixin = MIXIN_TAB.map((i) => raw[i]).join('').slice(0, 32)
|
|
224
|
-
const wbi = (params) => {
|
|
225
|
-
const wts = Math.round(Date.now() / 1000)
|
|
226
|
-
const q = Object.keys({ ...params, wts }).sort()
|
|
227
|
-
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(clean(k === 'wts' ? wts : params[k]))}`).join('&')
|
|
228
|
-
return `${q}&w_rid=${crypto.createHash('md5').update(q + mixin).digest('hex')}`
|
|
229
|
-
}
|
|
230
|
-
const p = await (await fetch(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
|
|
231
|
-
{ headers: H, signal: AbortSignal.timeout(15000) })).json()
|
|
232
|
-
const subs = p?.data?.subtitle?.subtitles ?? []
|
|
233
|
-
if (!subs.length) return null
|
|
234
|
-
const pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
|
|
235
|
-
?? subs.find((s) => String(s.lan).startsWith('zh')) ?? subs[0]
|
|
236
|
-
if (!pick?.subtitle_url) return null
|
|
237
|
-
|
|
238
|
-
const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
|
|
239
|
-
const r = await fetch(url, {
|
|
240
|
-
headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
|
|
241
|
-
signal: AbortSignal.timeout(20000),
|
|
242
|
-
})
|
|
243
|
-
if (!r.ok) return null
|
|
244
|
-
let body = (JSON.parse(await r.text()).body ?? [])
|
|
245
|
-
if (!body.length) return null
|
|
246
|
-
// 只保留所处理区间内的字幕行(--from/--to)
|
|
247
|
-
if (range) body = body.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
|
|
248
|
-
if (!body.length) return null
|
|
249
|
-
|
|
250
|
-
const txt = path.join(OUT, `${bvid}-official-subs.txt`)
|
|
251
|
-
const plain = path.join(OUT, `${bvid}-official-subs-plain.txt`)
|
|
252
|
-
fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
|
|
253
|
-
fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
|
|
254
|
-
|
|
255
|
-
// 质量判断:B 站 AI 字幕在纯音乐/音效为主的视频里会输出大量「 歌词」噪声或无意义碎片,
|
|
256
|
-
// 这种字幕不能用(拿它当台词理解视频会全错),应该降级为看图
|
|
257
|
-
const effChars = body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, '').length)
|
|
258
|
-
const avgEff = effChars.reduce((a, b) => a + b, 0) / body.length
|
|
259
|
-
const uniqueRatio = new Set(body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, ''))).size / body.length
|
|
260
|
-
const chars = effChars.reduce((a, b) => a + b, 0)
|
|
261
|
-
const quality = avgEff < 2 || uniqueRatio < 0.4 ? 'noisy' : 'ok'
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
lan: pick.lan, lanDoc: pick.lan_doc, lines: body.length, txt, plain,
|
|
265
|
-
chars, quality, avgEff: Number(avgEff.toFixed(1)), uniqueRatio: Number(uniqueRatio.toFixed(2)),
|
|
266
|
-
head: body.slice(0, 5).map((l) => `[${fmt(l.from)}] ${l.content}`),
|
|
267
|
-
}
|
|
268
|
-
} catch (e) {
|
|
269
|
-
console.warn(`[官方字幕] 获取失败(按无官方字幕处理):${String(e.message).split('\n')[0].slice(0, 60)}`)
|
|
270
|
-
return null
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// 下载(一次,复用;分P 分开缓存)
|
|
275
|
-
/**
|
|
276
|
-
* 缓存视频是否有效。
|
|
277
|
-
* 只校验文件大小会把「下载中断的残缺文件 / 拷坏的 mp4」当有效缓存复用,
|
|
278
|
-
* 之后抽帧静默失败,极难排查。mp4 头 12 字节里一定有 ftyp box。
|
|
279
|
-
*/
|
|
280
|
-
function looksLikeMp4(p) {
|
|
281
|
-
try {
|
|
282
|
-
if (!fs.existsSync(p) || fs.statSync(p).size <= 1024) return false
|
|
283
|
-
const fd = fs.openSync(p, 'r')
|
|
284
|
-
const buf = Buffer.alloc(12)
|
|
285
|
-
fs.readSync(fd, buf, 0, 12, 0)
|
|
286
|
-
fs.closeSync(fd)
|
|
287
|
-
return buf.includes(Buffer.from('ftyp'))
|
|
288
|
-
} catch { return false }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function ensureMp4(bvid, pageNo = 1) {
|
|
292
|
-
const p = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
|
|
293
|
-
if (looksLikeMp4(p)) return p
|
|
294
|
-
|
|
295
|
-
const data = await getView(bvid)
|
|
296
|
-
const page = data.pages?.[pageNo - 1]
|
|
297
|
-
if (!page) throw new Error(`没有第 ${pageNo} 个分P(这条视频共 ${data.pages?.length ?? 1} 个)`)
|
|
298
|
-
const { cid } = page
|
|
299
|
-
const duration = page.duration ?? data.duration
|
|
300
|
-
const title = page.part && data.pages.length > 1 ? `${data.title} · P${pageNo} ${page.part}` : data.title
|
|
301
|
-
|
|
302
|
-
const pu = await (await fetch(
|
|
303
|
-
`https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&qn=80&fnval=1&platform=pc&high_quality=1`,
|
|
304
|
-
{ headers: H, signal: AbortSignal.timeout(15000) })).json()
|
|
305
|
-
const url = pu.data?.durl?.[0]?.url
|
|
306
|
-
if (!url) throw new Error(`没拿到直链 code=${pu.code}`)
|
|
307
|
-
|
|
308
|
-
// 下载到 .part 再 rename:网络中断时只会留下 .part,不会把半个 mp4 伪装成缓存
|
|
309
|
-
// (旧实现直接写最终路径,中断后下次运行会拿着残缺文件当缓存用)
|
|
310
|
-
const r = await fetch(url, {
|
|
311
|
-
headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/', range: 'bytes=0-' },
|
|
312
|
-
signal: AbortSignal.timeout(300000),
|
|
313
|
-
})
|
|
314
|
-
if (!r.ok) throw new Error(`下载失败 HTTP ${r.status}`)
|
|
315
|
-
const buf = Buffer.from(await r.arrayBuffer())
|
|
316
|
-
if (buf.length < 1024) throw new Error('下载内容过短,可能被中断(.part 已丢弃)')
|
|
317
|
-
const part = p + '.part'
|
|
318
|
-
fs.writeFileSync(part, buf)
|
|
319
|
-
fs.renameSync(part, p)
|
|
320
|
-
console.log(`[下载] ${title} ${duration}s → ${(fs.statSync(p).size / 1048576).toFixed(2)} MB`)
|
|
321
|
-
return p
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// 画面变化点:一次解码,把带分数的候选全部拿出来
|
|
325
|
-
// 旧做法是每个阈值各跑一遍 ffmpeg(0.3 / 0.15 / 0.08 三遍全片解码),慢且浪费。
|
|
326
|
-
// 现在只解码一遍(阈值放最低 0.05),把每处的 scene_score 一起记下,阈值阶梯在内存里做,零重复成本。
|
|
327
|
-
// 注意:沙箱下 child_process 的 stdout 管道被禁(EPERM),只能用「ffmpeg 写临时文件」的方式取输出。
|
|
328
|
-
function detectScenes(file) {
|
|
329
|
-
// 文件名带 pid + 时间戳:两个视频并发跑时不会互相覆盖
|
|
330
|
-
const name = `.scenes-${process.pid}-${Date.now()}.txt`
|
|
331
|
-
const abs = path.join(OUT, name)
|
|
332
|
-
try {
|
|
333
|
-
if (fs.existsSync(abs)) fs.rmSync(abs, { force: true })
|
|
334
|
-
execFileSync(FFMPEG, ['-hide_banner', '-i', file, '-vf',
|
|
335
|
-
`scale=320:-2,select='gt(scene,0.05)',metadata=print:file=${name}`, '-an', '-f', 'null', '-'],
|
|
336
|
-
{ stdio: 'ignore', cwd: OUT })
|
|
337
|
-
if (!fs.existsSync(abs)) return []
|
|
338
|
-
const out = fs.readFileSync(abs, 'utf8')
|
|
339
|
-
fs.rmSync(abs, { force: true })
|
|
340
|
-
// 输出是两行一组: "…pts_time:4.1" 紧跟 "lavfi.scene_score=0.42"
|
|
341
|
-
const ents = []
|
|
342
|
-
let cur = null
|
|
343
|
-
for (const line of out.split('\n')) {
|
|
344
|
-
const t = line.match(/pts_time:\s*([0-9.]+)/)
|
|
345
|
-
if (t) { cur = { t: Number(t[1]), score: 0 }; continue }
|
|
346
|
-
const s = line.match(/scene_score=([0-9.]+)/)
|
|
347
|
-
if (s && cur) { cur.score = Number(s[1]); ents.push(cur); cur = null }
|
|
348
|
-
}
|
|
349
|
-
return ents.filter((e) => e.t > 0.2)
|
|
350
|
-
} catch { return [] }
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// 段内取帧:等间隔骨架 + 吸附变化点(阈值阶梯 + 去重)
|
|
354
|
-
// 基准点 ((i+0.5)*dur/n) 让每格的吸附窗口 [base±dur/2n] 恰好铺满整段、且不越界。
|
|
355
|
-
// 从 [from,to] 里减掉广告段,返回剩余连续区间(用于「广告不占帧」)
|
|
356
|
-
function subtractAds(from, to, adSegs) {
|
|
357
|
-
let segs = [{ a: from, b: to }]
|
|
358
|
-
for (const ad of adSegs) {
|
|
359
|
-
const next = []
|
|
360
|
-
for (const s of segs) {
|
|
361
|
-
if (ad.to <= s.a || ad.from >= s.b) { next.push(s); continue }
|
|
362
|
-
if (ad.from > s.a) next.push({ a: s.a, b: ad.from })
|
|
363
|
-
if (ad.to < s.b) next.push({ a: ad.to, b: s.b })
|
|
364
|
-
}
|
|
365
|
-
segs = next
|
|
366
|
-
}
|
|
367
|
-
return segs.filter((s) => s.b - s.a > 0.5)
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function pickTimes(from, to, n, scenes, adSegs = []) {
|
|
371
|
-
const dur = to - from
|
|
372
|
-
const inAd = (t) => adSegs.some((ad) => t >= ad.from && t <= ad.to)
|
|
373
|
-
|
|
374
|
-
// 广告段时间从时间轴上「压缩掉」:在剩余有效时间里等间隔取基准点。
|
|
375
|
-
// 效果 = 帧数不变、广告不占帧、多出来的时长自动分给内容(密度提升)。
|
|
376
|
-
const segs = adSegs.length ? subtractAds(from, to, adSegs) : [{ a: from, b: to }]
|
|
377
|
-
if (!segs.length) return null // 整段都是广告 → 调用方跳过这张图
|
|
378
|
-
const totalEff = segs.reduce((s, x) => s + (x.b - x.a), 0)
|
|
379
|
-
const base = []
|
|
380
|
-
for (let i = 0; i < n; i++) {
|
|
381
|
-
let rem = ((i + 0.5) / n) * totalEff
|
|
382
|
-
let t = segs[segs.length - 1].b
|
|
383
|
-
for (const s of segs) {
|
|
384
|
-
const d = s.b - s.a
|
|
385
|
-
if (rem <= d) { t = s.a + rem; break }
|
|
386
|
-
rem -= d
|
|
387
|
-
}
|
|
388
|
-
base.push(t)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// 吸附窗口/去重下限按「有效时长」算,而不是原始段长。
|
|
392
|
-
// 段里有广告时,内容被压缩得更密,用原始 dur 会让窗口过大 → 多帧吸到同一点后被去重退回
|
|
393
|
-
// (40 分钟视频图2:跳过 118s 广告后 9 帧里有 5 帧被退回)
|
|
394
|
-
const timingBase = totalEff > 0 ? totalEff : dur
|
|
395
|
-
const half = timingBase / n / 2
|
|
396
|
-
const minGap = (timingBase / n) * 0.4 // 去重下限:相邻两帧至少隔这么多
|
|
397
|
-
|
|
398
|
-
const tryThreshold = (th) => {
|
|
399
|
-
// 吸附候选也排除广告段:不然帧会被吸到广告里的高变化点(片头/转场)
|
|
400
|
-
const cands = scenes.filter((s) => s.score >= th && !inAd(s.t))
|
|
401
|
-
const raw = base.map((b) => {
|
|
402
|
-
const near = cands.filter((s) => Math.abs(s.t - b) <= half)
|
|
403
|
-
if (!near.length) return b
|
|
404
|
-
// 窗口内可能挤着多个变化点:取「最剧烈」的而不是「最近」的——
|
|
405
|
-
// 时间代表性靠窗口保证(都在 ±half 内),视觉显著性决定落在哪一刻
|
|
406
|
-
return near.reduce((x, s) => (s.score > x.score ? s : x)).t
|
|
407
|
-
})
|
|
408
|
-
// snapped 统计的是「吸附能力」,必须在去重前算 —— 否则被去重退回的帧会被误记成「没吸到」
|
|
409
|
-
const snapped = raw.filter((t, i) => Math.abs(t - base[i]) > 1e-6).length
|
|
410
|
-
|
|
411
|
-
// 去重:吸附后若相邻两帧挤得比 minGap 还近,把后一个退回它自己的等间隔基准点
|
|
412
|
-
// (基准点之间间距 = dur/n,永远不会违反 minGap,所以退回一定安全)
|
|
413
|
-
const times = raw.slice()
|
|
414
|
-
for (let i = 1; i < times.length; i++) {
|
|
415
|
-
if (Math.abs(times[i] - times[i - 1]) < minGap) times[i] = base[i]
|
|
416
|
-
}
|
|
417
|
-
let dedup = 0
|
|
418
|
-
for (let i = 1; i < times.length; i++) {
|
|
419
|
-
if (Math.abs(raw[i] - times[i]) > 1e-6) dedup++
|
|
420
|
-
}
|
|
421
|
-
return { times, th, snapped, dedup, base }
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// 阈值阶梯:从严格到宽松,取第一个「≥75% 帧都吸附成功」的档位
|
|
425
|
-
// (段变短后吸附窗口 half 会变小,过半门槛太松,容易留下大量没吸到的帧)
|
|
426
|
-
let fallback = null
|
|
427
|
-
for (const th of [0.3, 0.15, 0.08]) {
|
|
428
|
-
const r = tryThreshold(th)
|
|
429
|
-
if (!fallback) fallback = r
|
|
430
|
-
if (r.snapped >= Math.ceil(n * 0.75)) return r
|
|
431
|
-
}
|
|
432
|
-
return fallback
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// 字幕行取帧:等间隔骨架 + 吸附变化点(字幕通常在镜头切换时更新,纯等间隔会取到
|
|
436
|
-
// 「还没出字幕」的过渡帧——第一条字幕带的行 1 经常是空的)
|
|
437
|
-
function snapSubsTimes(span, count, scenes, start = 0) {
|
|
438
|
-
const base = Array.from({ length: count }, (_, i) => start + ((i + 0.5) * span) / count)
|
|
439
|
-
const cands = scenes.filter((s) => s.score >= 0.15 && s.t >= start && s.t <= start + span)
|
|
440
|
-
if (!cands.length) return base
|
|
441
|
-
const half = span / count / 2
|
|
442
|
-
const minGap = (span / count) * 0.4
|
|
443
|
-
const times = base.map((b) => {
|
|
444
|
-
const near = cands.filter((s) => Math.abs(s.t - b) <= half)
|
|
445
|
-
if (!near.length) return b
|
|
446
|
-
return near.reduce((x, s) => (s.score > x.score ? s : x)).t
|
|
447
|
-
})
|
|
448
|
-
for (let i = 1; i < times.length; i++) {
|
|
449
|
-
if (Math.abs(times[i] - times[i - 1]) < minGap) times[i] = base[i]
|
|
450
|
-
}
|
|
451
|
-
return times
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// 弹幕:独立于画面和字幕的第三个信息源
|
|
455
|
-
// 为什么值得拉:弹幕密集的时段 = 观众真正觉得精彩的地方,可以直接指导精读选点;
|
|
456
|
-
// 而且它能看到画面和字幕里都没有的东西——大家在哪一秒笑、在刷什么梗。
|
|
457
|
-
// 数据源:https://comment.bilibili.com/<cid>.xml
|
|
458
|
-
// 每条 <d p="相对秒,模式,字号,颜色,...,>内容</d>,只取第一段(相对时间)和文本
|
|
459
|
-
async function getDanmaku(cid, range = null) {
|
|
460
|
-
try {
|
|
461
|
-
const r = await fetch(`https://comment.bilibili.com/${cid}.xml`, {
|
|
462
|
-
headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/' },
|
|
463
|
-
signal: AbortSignal.timeout(20000),
|
|
464
|
-
})
|
|
465
|
-
if (!r.ok) return null
|
|
466
|
-
const xml = await r.text()
|
|
467
|
-
const list = []
|
|
468
|
-
for (const m of xml.matchAll(/<d p="([^"]*)">([^<]*)<\/d>/g)) {
|
|
469
|
-
const t = Number(String(m[1]).split(',')[0])
|
|
470
|
-
const text = String(m[2] ?? '').trim()
|
|
471
|
-
if (!Number.isFinite(t) || !text) continue
|
|
472
|
-
if (range && (t < range.from || t > range.to)) continue
|
|
473
|
-
list.push({ t, text })
|
|
474
|
-
}
|
|
475
|
-
return list
|
|
476
|
-
} catch (e) {
|
|
477
|
-
console.warn(`[弹幕] 获取失败(忽略,不影响其余流程):${String(e.message).split('\n')[0].slice(0, 60)}`)
|
|
478
|
-
return null
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// 弹幕过滤逻辑在 danmaku-filter.mjs(与测试共用同一份实现,避免测试测副本)
|
|
483
|
-
|
|
484
|
-
// 广告段检测(视频内广告,不是弹幕文本广告)
|
|
485
|
-
// 判断依据来自案例 BV1i4W86zEUS(小约翰可汗 40 分钟视频,
|
|
486
|
-
// 用户指认 04:35-06:49 是鱼油广告)
|
|
487
|
-
// · 广告段弹幕密度只有全片的 31%(观众跳过 / 不互动)
|
|
488
|
-
// · 「感谢金主爸爸」这类关键词会在广告段附近出现
|
|
489
|
-
// 只用密度会误报(纯画面段、BGM 段也是低谷),所以分两级
|
|
490
|
-
// high = 密度低谷 且 附近有关键词命中(可直接标注为广告)
|
|
491
|
-
// low = 只有密度低谷(标成「低互动段」,不当广告)
|
|
492
|
-
const AD_SEGMENT_SIGNAL = /金主|恰饭|商单|硬广|软广|赞助|推广|接广|带货|下单|同款|优惠码/i;
|
|
493
|
-
|
|
494
|
-
function detectAdSegments(danmaku, duration, bucketSec = 30, stepSec = 10) {
|
|
495
|
-
if (!danmaku?.length || duration < 120) return []
|
|
496
|
-
const dens = []
|
|
497
|
-
for (let s = 0; s + bucketSec <= duration; s += stepSec) {
|
|
498
|
-
let n = 0
|
|
499
|
-
for (const d of danmaku) if (d.t >= s && d.t < s + bucketSec) n++
|
|
500
|
-
dens.push({ s, n })
|
|
501
|
-
}
|
|
502
|
-
if (!dens.length) return []
|
|
503
|
-
const nums = dens.map((d) => d.n).sort((a, b) => a - b)
|
|
504
|
-
const median = nums[Math.floor(nums.length / 2)] || 1
|
|
505
|
-
// 低密度:低于中位数的 45%
|
|
506
|
-
const spans = []
|
|
507
|
-
for (const d of dens) {
|
|
508
|
-
if (d.n >= median * 0.45) continue
|
|
509
|
-
const last = spans[spans.length - 1]
|
|
510
|
-
if (last && d.s - last.to <= stepSec * 2) last.to = d.s + bucketSec
|
|
511
|
-
else spans.push({ from: d.s, to: d.s + bucketSec, count: 0 })
|
|
512
|
-
}
|
|
513
|
-
const long = spans.filter((x) => x.to - x.from >= 60)
|
|
514
|
-
if (!long.length) return []
|
|
515
|
-
|
|
516
|
-
const hits = danmaku.filter((d) => AD_SEGMENT_SIGNAL.test(d.text))
|
|
517
|
-
return long.map((sp) => {
|
|
518
|
-
const near = hits.filter((h) => h.t >= sp.from - 20 && h.t <= sp.to + 20)
|
|
519
|
-
// dens 是「30s 窗、10s 步长」的滑窗,同一条弹幕会被相邻 3 个桶各计一次。
|
|
520
|
-
// 分母必须用「实际桶数」而不是 (时长/bucketSec),否则 ratio 虚高约 3 倍
|
|
521
|
-
// (出现 lowDensity = -25% 的负值,因为 ratio > 1)
|
|
522
|
-
const segBuckets = dens.filter((d) => d.s >= sp.from && d.s < sp.to)
|
|
523
|
-
const total = segBuckets.reduce((s, d) => s + d.n, 0)
|
|
524
|
-
const ratio = total / Math.max(1, segBuckets.length) / median
|
|
525
|
-
return {
|
|
526
|
-
from: sp.from,
|
|
527
|
-
to: sp.to,
|
|
528
|
-
dur: sp.to - sp.from,
|
|
529
|
-
ts: `${fmt(sp.from)}-${fmt(sp.to)}`,
|
|
530
|
-
lowDensity: Number((1 - ratio).toFixed(2)),
|
|
531
|
-
confidence: near.length ? 'high' : 'low',
|
|
532
|
-
keywords: near.slice(0, 3).map((h) => h.text),
|
|
533
|
-
}
|
|
534
|
-
})
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// 按时间桶聚合,找「观众最激动」的时段。
|
|
538
|
-
// 高频重复的弹幕最能代表那里在刷什么(梗 / 共鸣),所以每个热区取前 3 条并标出现次数。
|
|
539
|
-
function summarizeDanmaku(list, bucketSec = 5, topN = 6) {
|
|
540
|
-
if (!list?.length) return null
|
|
541
|
-
const buckets = new Map()
|
|
542
|
-
for (const d of list) {
|
|
543
|
-
const k = Math.floor(d.t / bucketSec) * bucketSec
|
|
544
|
-
if (!buckets.has(k)) buckets.set(k, [])
|
|
545
|
-
buckets.get(k).push(d.text)
|
|
546
|
-
}
|
|
547
|
-
return [...buckets.entries()]
|
|
548
|
-
.map(([start, texts]) => ({ start, texts }))
|
|
549
|
-
.sort((a, b) => b.texts.length - a.texts.length) // 先按密度挑出最热的 N 个桶
|
|
550
|
-
.slice(0, topN)
|
|
551
|
-
.sort((a, b) => a.start - b.start) // 再按时间顺序展示
|
|
552
|
-
.map(({ start, texts }) => {
|
|
553
|
-
const freq = new Map()
|
|
554
|
-
for (const t of texts) freq.set(t, (freq.get(t) ?? 0) + 1)
|
|
555
|
-
const samples = [...freq.entries()]
|
|
556
|
-
.sort((a, b) => b[1] - a[1])
|
|
557
|
-
.slice(0, 3)
|
|
558
|
-
.map(([t, n]) => (n > 1 ? `${t}×${n}` : t))
|
|
559
|
-
return { start, end: start + bucketSec, ts: fmt(start), count: texts.length, samples }
|
|
560
|
-
})
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
async function stageGrid(bvid, chunkSec, grid, pre = {}) {
|
|
564
|
-
const pageNo = pre.page ?? 1
|
|
565
|
-
const mp4 = pre.mp4 ?? await ensureMp4(bvid, pageNo)
|
|
566
|
-
const viewData = pre.view ?? await getView(bvid)
|
|
567
|
-
// 时长要用「所处理分P」的时长(多P 视频的 view.duration 是全部分P 总和)
|
|
568
|
-
const pageInfo = viewData.pages?.[pageNo - 1]
|
|
569
|
-
const title = pageInfo?.part && (viewData.pages?.length ?? 1) > 1
|
|
570
|
-
? `${viewData.title} · P${pageNo} ${pageInfo.part}` : viewData.title
|
|
571
|
-
const fullDuration = pageInfo?.duration ?? viewData.duration
|
|
572
|
-
const owner = viewData.owner?.name
|
|
573
|
-
const [cols, rows] = grid.split('x').map(Number)
|
|
574
|
-
const N = cols * rows
|
|
575
|
-
// 时间区间:只铺 [start, end]
|
|
576
|
-
const range = pre.range ?? null
|
|
577
|
-
const start = range?.from ?? 0
|
|
578
|
-
const end = Math.min(range?.to ?? fullDuration, fullDuration)
|
|
579
|
-
const span = Math.max(0.1, end - start)
|
|
580
|
-
if (range) console.log(`[区间] 铺网范围 ${fmt(start)}-${fmt(end)}`)
|
|
581
|
-
|
|
582
|
-
// 画面变化点(visualScenes)与「参与吸附的候选」(sceneCands)要分开
|
|
583
|
-
// 弹幕热区要参与吸附,但不能污染 manifest 里每格的 scene 分数(那是纯画面的指标)。
|
|
584
|
-
const visualScenes = pre.scenes ?? detectScenes(mp4)
|
|
585
|
-
const strong = visualScenes.filter((s) => s.score >= 0.15).length
|
|
586
|
-
console.log(`[场景] 候选 ${visualScenes.length} 处画面变化(score≥0.15 的 ${strong} 处)`)
|
|
587
|
-
|
|
588
|
-
// 弹幕热区也当作「重要时刻」参与吸附:B 站弹幕密度本身就是内容信号,
|
|
589
|
-
// 那些「画面没怎么变、但观众集体激动」的时刻,纯视觉检测抓不到。
|
|
590
|
-
// score 给 0.9 而不是 1.0 —— 同一窗口里真实画面变化优先,
|
|
591
|
-
// 避免一个热区把整段的帧全吸过去(那会破坏均匀覆盖)。
|
|
592
|
-
let sceneCands = visualScenes
|
|
593
|
-
const dmFrameCands = pre.useDanmakuFrames === false ? [] : (pre.danmakuHot ?? [])
|
|
594
|
-
if (dmFrameCands.length) {
|
|
595
|
-
sceneCands = [
|
|
596
|
-
...visualScenes,
|
|
597
|
-
...dmFrameCands.map((h) => ({ t: h.start + 2.5, score: 0.9, kind: 'danmaku' })),
|
|
598
|
-
]
|
|
599
|
-
console.log(`[取帧] 弹幕热区 ${dmFrameCands.length} 处参与吸附(score 0.9,低于真实画面变化)`)
|
|
600
|
-
}
|
|
601
|
-
// 字幕信息:pre 里显式传了(哪怕是 null=检测失败)就复用,不要再检测第二遍
|
|
602
|
-
let bandResult = pre.bandResult !== undefined ? pre.bandResult : null
|
|
603
|
-
if (pre.bandResult === undefined) {
|
|
604
|
-
try { bandResult = detectBand(mp4, fullDuration) } catch { /* 分析失败就当没有字幕 */ }
|
|
605
|
-
}
|
|
606
|
-
let videoSize = null
|
|
607
|
-
|
|
608
|
-
const chunks = []
|
|
609
|
-
// 分段边界:先在区间内均分,再把过短的尾段并进前一段
|
|
610
|
-
const bounds = []
|
|
611
|
-
for (let c = 0; c < Math.max(1, Math.ceil(span / chunkSec)); c++) {
|
|
612
|
-
bounds.push({ from: start + c * chunkSec, to: Math.min(end, start + (c + 1) * chunkSec) })
|
|
613
|
-
}
|
|
614
|
-
const tailMin = Math.min(20, chunkSec * 0.25)
|
|
615
|
-
if (bounds.length > 1 && bounds.at(-1).to - bounds.at(-1).from < tailMin) {
|
|
616
|
-
const tail = bounds.pop()
|
|
617
|
-
bounds.at(-1).to = tail.to
|
|
618
|
-
console.log(`[分段] 尾段只有 ${(tail.to - tail.from).toFixed(1)}s,已并入上一段`)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// 高置信度广告段:这些时间不占帧(get 交给内容),铺网时压缩掉
|
|
622
|
-
const adHigh = (pre.adSegments ?? []).filter((s) => s.confidence === 'high')
|
|
623
|
-
|
|
624
|
-
for (let c = 0; c < bounds.length; c++) {
|
|
625
|
-
const { from, to } = bounds[c]
|
|
626
|
-
const picked = pickTimes(from, to, N, sceneCands, adHigh)
|
|
627
|
-
if (!picked) {
|
|
628
|
-
// 整段都被高置信度广告占满(极罕见)——跳过这张图,不让它浪费预算
|
|
629
|
-
console.log(`[取帧] ${fmt(from)}-${fmt(to)} 整段都是疑似广告,跳过这张拼图`)
|
|
630
|
-
continue
|
|
631
|
-
}
|
|
632
|
-
const times = picked.times
|
|
633
|
-
const thText = picked.th === null ? '无变化点' : `阈值 ${picked.th}`
|
|
634
|
-
const dedupText = picked.dedup ? `,去重退回 ${picked.dedup} 帧` : ''
|
|
635
|
-
const adText = adHigh.length
|
|
636
|
-
? `(已跳过广告 ${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)`
|
|
637
|
-
: ''
|
|
638
|
-
console.log(`[取帧] ${fmt(from)}-${fmt(to)} ${thText},${picked.snapped}/${N} 帧吸附${dedupText}${adText}`)
|
|
639
|
-
// 变化点稀疏/聚集(如冲浪视频:大部分平静、巨浪集中在几秒)时吸附率会很低——
|
|
640
|
-
// 这是正常现象,等间隔骨架保覆盖,但要提醒:关键时刻可能被少量帧覆盖,看精读建议
|
|
641
|
-
if (picked.snapped < N * 0.5) {
|
|
642
|
-
console.log(` ↳ 变化点稀疏或高度聚集(${visualScenes.length} 处全片变化点),等间隔骨架兜底;关键瞬间见精读建议`)
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const tmp = path.join(OUT, `.f-${bvid}-${c}`)
|
|
646
|
-
fs.mkdirSync(tmp, { recursive: true })
|
|
647
|
-
const files = []
|
|
648
|
-
times.forEach((t, i) => {
|
|
649
|
-
const f = path.join(tmp, `f${String(i).padStart(2, '0')}.jpg`)
|
|
650
|
-
// -ss 放在 -i 前面 = 快速 seek(跳到最近关键帧),比精确 seek 快一个数量级。
|
|
651
|
-
// 拼图和精读用同一种 seek,保证「同一时间戳」两边取到的是同一帧。
|
|
652
|
-
if (!grabFrame(mp4, t, f)) {
|
|
653
|
-
// 抽帧失败(t 越界 / 解码出错):复制上一帧补齐,避免 tile 因缺帧直接报错
|
|
654
|
-
if (files.length) fs.copyFileSync(files[files.length - 1], f)
|
|
655
|
-
else return // 第一帧就失败 → 这一帧留空,下面按数量不足处理
|
|
656
|
-
}
|
|
657
|
-
files.push(f)
|
|
658
|
-
})
|
|
659
|
-
if (files.length < N) {
|
|
660
|
-
console.warn(`[警告] 本段只抽到 ${files.length}/${N} 帧,跳过该段(其余段不受影响)`)
|
|
661
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
662
|
-
continue
|
|
663
|
-
}
|
|
664
|
-
if (!videoSize) videoSize = jpegSize(files[0]) // 原片分辨率(供几何可读性计算)
|
|
665
|
-
|
|
666
|
-
const sheet = path.join(OUT, `${bvid}-c${String(c).padStart(2, '0')}_${fsec(from)}-${fsec(to)}_${cols}x${rows}.jpg`)
|
|
667
|
-
execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', path.join(tmp, `f%02d.jpg`), '-vf',
|
|
668
|
-
`scale=640:-1,tile=${cols}x${rows}`, '-frames:v', '1', '-q:v', '3', sheet], { stdio: 'ignore' })
|
|
669
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
670
|
-
|
|
671
|
-
// 这一格落在多强的画面变化上(供「哪些格值得精读」的判断参考:变化越强,通常信息越密)
|
|
672
|
-
const scoreAt = (t) => {
|
|
673
|
-
const c = visualScenes.filter((s) => Math.abs(s.t - t) < 0.06)
|
|
674
|
-
return c.length ? Number(Math.max(...c.map((s) => s.score)).toFixed(3)) : 0
|
|
675
|
-
}
|
|
676
|
-
// 有广告被跳过时,range 标注成「原始区间 + 实际内容范围」,否则会让人误以为覆盖到段尾
|
|
677
|
-
const adSkipped = Math.round(adHigh
|
|
678
|
-
.filter((a) => a.to > from && a.from < to)
|
|
679
|
-
.reduce((s, a) => s + Math.min(a.to, to) - Math.max(a.from, from), 0))
|
|
680
|
-
const effRange = adSkipped > 0 && times.length
|
|
681
|
-
? `${fmt(times[0])}-${fmt(times[times.length - 1])}`
|
|
682
|
-
: null
|
|
683
|
-
chunks.push({
|
|
684
|
-
chunk: c + 1,
|
|
685
|
-
from: Number(from.toFixed(1)),
|
|
686
|
-
to: Number(to.toFixed(1)),
|
|
687
|
-
range: `${fmt(from)}-${fmt(to)}`,
|
|
688
|
-
adSkipped, // 本段被跳过的疑似广告秒数
|
|
689
|
-
effectiveRange: effRange, // 实际内容覆盖范围(跳过广告后)
|
|
690
|
-
sheet,
|
|
691
|
-
grid: `${cols}x${rows}`,
|
|
692
|
-
threshold: picked.th,
|
|
693
|
-
snapped: picked.snapped,
|
|
694
|
-
cells: times.map((t, i) => ({
|
|
695
|
-
cell: i + 1,
|
|
696
|
-
row: Math.floor(i / cols) + 1,
|
|
697
|
-
col: (i % cols) + 1,
|
|
698
|
-
t: Number(t.toFixed(2)),
|
|
699
|
-
ts: fmt(t),
|
|
700
|
-
scene: scoreAt(t),
|
|
701
|
-
shift: Number((t - picked.base[i]).toFixed(1)), // 相对等间隔基准挪了多少秒
|
|
702
|
-
})),
|
|
703
|
-
})
|
|
704
|
-
const adNote = adSkipped > 0 ? `(跳过广告 ${adSkipped}s,内容实际 ${effRange})` : ''
|
|
705
|
-
console.log(`[拼图 ${c + 1}/${bounds.length}] ${fmt(from)}-${fmt(to)} ${N} 帧${adNote} → ${path.basename(sheet)}`)
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const sheetSize = chunks.length ? jpegSize(chunks[0].sheet) : null
|
|
709
|
-
const manifest = {
|
|
710
|
-
bvid, title, up: owner, duration: fullDuration, chunkSec,
|
|
711
|
-
page: pre.page ?? 1, // 分P 序号(grab/refine 靠它找对缓存文件)
|
|
712
|
-
range: range ? { from: Number(start.toFixed(1)), to: Number(end.toFixed(1)) } : null,
|
|
713
|
-
grid: `${cols}x${rows}`,
|
|
714
|
-
// 几何信息:refine --auto 用它算「拼图里字幕还剩几个像素」
|
|
715
|
-
sheetWidth: sheetSize?.w ?? null,
|
|
716
|
-
sheetHeight: sheetSize?.h ?? null,
|
|
717
|
-
videoWidth: videoSize?.w ?? null,
|
|
718
|
-
videoHeight: videoSize?.h ?? null,
|
|
719
|
-
subs: bandResult?.ok ? { top: bandResult.topRatio, bottom: bandResult.botRatio } : null,
|
|
720
|
-
subsSheet: pre.subsSheet ?? false, // 是否另外生成了字幕带
|
|
721
|
-
subsCovered: pre.subsCovered ?? pre.subsSheet ?? false, // 字幕信息是否已覆盖(official 或 burned-in)
|
|
722
|
-
subsSource: pre.subsSource ?? null, // 'official' | 'burned-in' | null
|
|
723
|
-
danmaku: pre.danmakuHot?.length
|
|
724
|
-
? { total: pre.danmakuTotal ?? null, hot: pre.danmakuHot }
|
|
725
|
-
: null, // 弹幕热区(refine 的几何兜底会合并进来)
|
|
726
|
-
adSegments: pre.adSegments ?? null, // 视频内疑似广告段(refine 会避开 high 置信度区间)
|
|
727
|
-
chunks,
|
|
728
|
-
createdAt: new Date().toISOString(),
|
|
729
|
-
}
|
|
730
|
-
const mf = path.join(OUT, `${bvid}-manifest.json`)
|
|
731
|
-
fs.writeFileSync(mf, JSON.stringify(manifest, null, 2), 'utf8')
|
|
732
|
-
console.log(`\nMANIFEST: ${mf}`)
|
|
733
|
-
console.log(`共 ${chunks.length} 张拼图 / ${chunks.reduce((a, c) => a + c.cells.length, 0)} 帧`)
|
|
734
|
-
console.log(`\n看图顺序(每张图从左到右、从上到下对应下列时间;括号里是该格的画面变化强度):`)
|
|
735
|
-
for (const c of chunks) {
|
|
736
|
-
console.log(` 图${c.chunk} [${c.range}] ${c.sheet}`)
|
|
737
|
-
console.log(` ${c.cells.map((x) => `#${x.cell}=${x.ts}${x.scene ? `(${x.scene})` : ''}`).join(' ')}`)
|
|
738
|
-
}
|
|
739
|
-
return manifest
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// 字幕带竖排:剪掉画面、只留底部字幕,竖着拼 N 条
|
|
743
|
-
// band 可以是比例(0.22)或 'auto'(先跑 analyze_subs.py 自动定位字幕带)
|
|
744
|
-
// 依据:讲解/翻唱类视频,字幕就是全部信息量;同样 token 预算下,字幕带比全画幅拼图清楚得多
|
|
745
|
-
|
|
746
|
-
function detectBand(mp4, duration) {
|
|
747
|
-
const probe = path.join(OUT, `.probe-${Date.now()}`)
|
|
748
|
-
try {
|
|
749
|
-
fs.mkdirSync(probe, { recursive: true })
|
|
750
|
-
// 逐帧快速 seek 抽 24 帧:fps 滤镜虽然只输出 24 帧,但必须全片解码,长视频下慢一个数量级
|
|
751
|
-
// 注意:这里**必须跨全片采样**,不能只看 --from/--to 区间 —— 字幕位置是视频级属性,
|
|
752
|
-
// 只在区间内采样时,区间里若恰好多帧无字幕,峰值会被画面边缘抢走(82%-90% 跑偏成 55%-83%)
|
|
753
|
-
for (let i = 0; i < 24; i++) {
|
|
754
|
-
const t = ((i + 0.5) * Math.max(duration, 1)) / 24
|
|
755
|
-
grabFrame(mp4, t, path.join(probe, `f${String(i).padStart(2, '0')}.jpg`))
|
|
756
|
-
}
|
|
757
|
-
const jsonPath = path.join(probe, 'subs.json')
|
|
758
|
-
execFileSync(PYTHON, [path.join(PKG_DIR, 'analyze_subs.py'), probe, jsonPath], { stdio: 'ignore' })
|
|
759
|
-
const r = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
|
760
|
-
return r.ok ? r : null
|
|
761
|
-
} catch (e) {
|
|
762
|
-
// Python 缺失、分析脚本报错、抽帧失败 —— 一律降级为「没有字幕」,不能让整个流程崩掉
|
|
763
|
-
console.warn(`[字幕] 检测失败(将按无字幕处理):${String(e.message).split('\n')[0].slice(0, 80)}`)
|
|
764
|
-
return null
|
|
765
|
-
} finally {
|
|
766
|
-
try { fs.rmSync(probe, { recursive: true, force: true }) } catch {}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// 抽一帧;失败返回 false 而不是抛异常(t 越界、解码失败都可能发生)
|
|
771
|
-
function grabFrame(mp4, t, outFile, quality = 3) {
|
|
772
|
-
try {
|
|
773
|
-
execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-ss', t.toFixed(3), '-i', mp4,
|
|
774
|
-
'-an', '-frames:v', '1', '-q:v', String(quality), outFile], { stdio: 'ignore' })
|
|
775
|
-
return fs.existsSync(outFile)
|
|
776
|
-
} catch {
|
|
777
|
-
return false
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function stageSubs(bvid, count, bandArg, pre = {}) {
|
|
782
|
-
const pageNo = pre.page ?? 1
|
|
783
|
-
const mp4 = pre.mp4 ?? path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
|
|
784
|
-
if (!fs.existsSync(mp4)) throw new Error(`没有缓存 mp4,先跑 auto 或 grid:${mp4}`)
|
|
785
|
-
const viewData = pre.view ?? await getView(bvid)
|
|
786
|
-
const page = viewData.pages?.[pageNo - 1]
|
|
787
|
-
const fullDuration = page?.duration ?? viewData.duration
|
|
788
|
-
// 时间区间:字幕行只取 [start, end] 内
|
|
789
|
-
const range = pre.range ?? null
|
|
790
|
-
const start = range?.from ?? 0
|
|
791
|
-
const span = Math.max(0.1, Math.min(range?.to ?? fullDuration, fullDuration) - start)
|
|
792
|
-
// 有场景数据就吸附变化点,没有就纯等间隔(单独跑 subs 时不额外做全片检测)
|
|
793
|
-
const scenes = pre.scenes ?? null
|
|
794
|
-
const times = scenes?.length
|
|
795
|
-
? snapSubsTimes(span, count, scenes, start)
|
|
796
|
-
: Array.from({ length: count }, (_, i) => start + ((i + 0.5) * span) / count)
|
|
797
|
-
if (range) console.log(`[区间] 字幕带范围 ${fmt(start)}-${fmt(start + span)}`)
|
|
798
|
-
|
|
799
|
-
// 决定裁剪范围:[top, bottom] 比例
|
|
800
|
-
let top, bottom, how
|
|
801
|
-
if (bandArg === 'auto') {
|
|
802
|
-
// 显式传了 null(=检测失败)就复用,别为它再检测第二遍
|
|
803
|
-
const r = pre.bandResult !== undefined ? pre.bandResult : detectBand(mp4, fullDuration)
|
|
804
|
-
if (r) {
|
|
805
|
-
top = r.topRatio
|
|
806
|
-
bottom = r.botRatio
|
|
807
|
-
how = `自动检测(${r.frames} 帧,像素 ${r.region.top}-${r.region.bottom}/${r.videoH})`
|
|
808
|
-
} else {
|
|
809
|
-
top = 0.78
|
|
810
|
-
bottom = 1.0
|
|
811
|
-
how = '自动检测失败,退回 0.78-1.0'
|
|
812
|
-
}
|
|
813
|
-
} else {
|
|
814
|
-
// band 必须是 0~0.6 的比例:NaN / 负数 / 超范围都会让 crop 表达式出错
|
|
815
|
-
const band = Number(bandArg)
|
|
816
|
-
if (!Number.isFinite(band) || band <= 0 || band >= 0.6) {
|
|
817
|
-
throw new Error(`--band 需是 0~0.6 的比例(如 0.22 = 底部 22%),收到:${JSON.stringify(bandArg)}`)
|
|
818
|
-
}
|
|
819
|
-
top = 1 - band
|
|
820
|
-
bottom = 1
|
|
821
|
-
how = `固定比例 ${band}`
|
|
822
|
-
}
|
|
823
|
-
console.log(`[字幕带] ${how} → 裁剪 ${(top * 100).toFixed(0)}%-${(bottom * 100).toFixed(0)}%`)
|
|
824
|
-
|
|
825
|
-
const tmp = path.join(OUT, `.s-${bvid}`)
|
|
826
|
-
fs.mkdirSync(tmp, { recursive: true })
|
|
827
|
-
let ok = 0
|
|
828
|
-
times.forEach((t, i) => {
|
|
829
|
-
const f = path.join(tmp, `f${String(i).padStart(2, '0')}.jpg`)
|
|
830
|
-
if (!grabFrame(mp4, t, f)) {
|
|
831
|
-
if (ok > 0) fs.copyFileSync(path.join(tmp, `f${String(i - 1).padStart(2, '0')}.jpg`), f)
|
|
832
|
-
else return
|
|
833
|
-
}
|
|
834
|
-
ok++
|
|
835
|
-
})
|
|
836
|
-
if (ok < count) {
|
|
837
|
-
console.warn(`[警告] 只抽到 ${ok}/${count} 帧,字幕带按实际帧数拼接`)
|
|
838
|
-
if (ok === 0) {
|
|
839
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
840
|
-
return { sheet: null, times: [], band: { top, bottom } }
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
const out = path.join(OUT, `${bvid}-subs_${count}行.jpg`)
|
|
844
|
-
try {
|
|
845
|
-
execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', path.join(tmp, 'f%02d.jpg'), '-vf',
|
|
846
|
-
`crop=iw:ih*${(bottom - top).toFixed(4)}:0:ih*${top.toFixed(4)},tile=1x${ok}`,
|
|
847
|
-
'-frames:v', '1', '-q:v', '3', out], { stdio: 'ignore' })
|
|
848
|
-
} catch {
|
|
849
|
-
console.warn('[字幕带] 拼接失败(已跳过,不影响拼图)')
|
|
850
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
851
|
-
return { sheet: null, times: [], band: { top, bottom } }
|
|
852
|
-
}
|
|
853
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
854
|
-
console.log(`SUBS: ${out}`)
|
|
855
|
-
console.log(` 每行对应时间: ${times.map((t, i) => `${i + 1}=${fmt(t)}`).join(' ')}`)
|
|
856
|
-
return { sheet: out, times: times.map((t) => Number(t.toFixed(1))), band: { top, bottom } }
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// auto:按 token 预算 + 内容变化率,自动决定采样密度、张数和路径
|
|
860
|
-
// 平衡原则:先保「看懂所需的最低帧密度」,再在剩余预算里加张数;
|
|
861
|
-
// 预算不够时诚实降级并报告,而不是悄悄变稀。
|
|
862
|
-
const COST = { grid: 1200, subs: 1000, fine: 1300 } // 单张成本估算(token)
|
|
863
|
-
const MAX_GRIDS = 12 // 拼图张数硬上限,防长视频失控
|
|
864
|
-
|
|
865
|
-
// selftest:自检
|
|
866
|
-
// 原则:默认不下载视频、几秒内跑完;--deep 才做抽帧和字幕带。
|
|
867
|
-
// 覆盖:依赖(ffmpeg/python/analyze_subs.py)→ 凭证 → API(视频/弹幕/字幕)→ 缓存 → 磁盘
|
|
868
|
-
function runCapture(cmd, args, timeoutMs = 15000) {
|
|
869
|
-
const tmp = path.join(WS, `.selftest-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
|
|
870
|
-
let fd = null
|
|
871
|
-
try {
|
|
872
|
-
fd = fs.openSync(tmp, 'w')
|
|
873
|
-
// 沙箱禁管道捕获 stdout,所以写到文件再读(和 detectScenes 同样的做法)
|
|
874
|
-
execFileSync(cmd, args, { stdio: ['ignore', fd, 'ignore'], timeout: timeoutMs })
|
|
875
|
-
return { ok: true, out: fs.readFileSync(tmp, 'utf8') }
|
|
876
|
-
} catch (e) {
|
|
877
|
-
return { ok: false, out: '', err: String(e?.message ?? e).split('\n')[0] }
|
|
878
|
-
} finally {
|
|
879
|
-
if (fd !== null) try { fs.closeSync(fd) } catch {}
|
|
880
|
-
try { fs.unlinkSync(tmp) } catch {}
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
async function stageSelftest(opts = {}) {
|
|
885
|
-
const rows = []
|
|
886
|
-
const rec = (mark, label, detail = '') => {
|
|
887
|
-
rows.push({ mark, label, detail })
|
|
888
|
-
console.log(` [${mark}] ${label.padEnd(18)} ${detail}`)
|
|
889
|
-
}
|
|
890
|
-
const t0 = Date.now()
|
|
891
|
-
const deep = opts.deep === true
|
|
892
|
-
console.log('\n== fisheye 自检 ==\n')
|
|
893
|
-
|
|
894
|
-
// 1) Node
|
|
895
|
-
const nv = process.versions.node
|
|
896
|
-
rec(Number(nv.split('.')[0]) >= 18 ? 'OK' : '!!', 'Node', `v${nv}${Number(nv.split('.')[0]) >= 18 ? '' : '(需要 ≥18)'}`)
|
|
897
|
-
|
|
898
|
-
// 2) ffmpeg:直接试着执行(PATH 命中时 FFMPEG 是命令名而不是路径,
|
|
899
|
-
// 用 existsSync 判断会误报「配置的路径不存在」——这是最常见的安装方式,必须走 runCapture)
|
|
900
|
-
if (!FFMPEG) rec('!!', 'ffmpeg', '未找到(装到 PATH,或设 FFMPEG 环境变量 / 写 ~/.config/fisheye/config.json)')
|
|
901
|
-
else {
|
|
902
|
-
const r = runCapture(FFMPEG, ['-version'], 10000)
|
|
903
|
-
if (r.ok) rec('OK', 'ffmpeg', r.out.split('\n')[0].slice(0, 58))
|
|
904
|
-
else rec('!!', 'ffmpeg', `无法执行(来源 ${FFMPEG_RES?.from ?? '?'}):${r.err.slice(0, 50)}`)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// 3) python(字幕带检测用,可选)
|
|
908
|
-
if (!PYTHON) rec('--', 'python', '未找到(字幕带功能会降级,其余不受影响)')
|
|
909
|
-
else {
|
|
910
|
-
const r = runCapture(PYTHON, ['--version'], 10000)
|
|
911
|
-
if (r.ok) rec('OK', 'python', r.out.trim())
|
|
912
|
-
else rec('!!', 'python', `无法执行(来源 ${PYTHON_RES?.from ?? '?'}):${r.err.slice(0, 50)}`)
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// 4) analyze_subs.py
|
|
916
|
-
const aspy = path.join(PKG_DIR, 'analyze_subs.py')
|
|
917
|
-
rec(fs.existsSync(aspy) ? 'OK' : '!!', 'analyze_subs.py', fs.existsSync(aspy) ? '存在' : '不存在(烧录字幕带检测会失败)')
|
|
918
|
-
|
|
919
|
-
// 5) 输出/缓存目录可写
|
|
920
|
-
try {
|
|
921
|
-
const t = path.join(OUT, `.selftest-${process.pid}.tmp`)
|
|
922
|
-
fs.writeFileSync(t, 'ok')
|
|
923
|
-
fs.unlinkSync(t)
|
|
924
|
-
rec('OK', '输出目录', '可写')
|
|
925
|
-
} catch (e) { rec('!!', '输出目录', `不可写:${String(e.message).slice(0, 50)}`) }
|
|
926
|
-
|
|
927
|
-
// 6) 凭证
|
|
928
|
-
if (!CRED) rec('!!', 'B站凭证', `未找到(创建 ${path.join(WS, 'credentials.json')},或设 BILI_CRED 环境变量)`)
|
|
929
|
-
else if (!fs.existsSync(CRED)) rec('!!', 'B站凭证', `配置的路径不存在:${CRED}`)
|
|
930
|
-
else {
|
|
931
|
-
const miss = ['sessdata', 'bili_jct'].filter((k) => !cred[k])
|
|
932
|
-
rec(miss.length ? '!!' : 'OK', 'B站凭证', miss.length ? `缺字段:${miss.join('/')}` : 'SESSDATA + bili_jct 已配置')
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// 7) 视频/弹幕/字幕 API —— 用缓存里的 BV,没有就用默认测试片
|
|
936
|
-
const cached = fs.readdirSync(CACHE).filter((f) => /^BV[\w]+(-p\d+)?\.mp4$/.test(f) && fs.statSync(path.join(CACHE, f)).size > 1024)
|
|
937
|
-
const testBv = cached.length ? cached[0].replace(/(-p\d+)?\.mp4$/, '') : 'BV1ziWP6qENy'
|
|
938
|
-
let view = null
|
|
939
|
-
try {
|
|
940
|
-
view = await getView(testBv)
|
|
941
|
-
rec('OK', '视频 API', `${testBv} ${view.duration}s「${String(view.title ?? '').slice(0, 16)}」`)
|
|
942
|
-
} catch (e) {
|
|
943
|
-
rec('!!', '视频 API', `请求失败:${String(e.message).split('\n')[0].slice(0, 50)}`)
|
|
944
|
-
}
|
|
945
|
-
const cid = view?.pages?.[0]?.cid
|
|
946
|
-
if (cid) {
|
|
947
|
-
try {
|
|
948
|
-
const dm = await getDanmaku(cid)
|
|
949
|
-
rec(dm?.length ? 'OK' : '--', '弹幕 API', dm?.length ? `${dm.length} 条(含广告过滤前)` : '无数据(视频可能关弹幕)')
|
|
950
|
-
} catch (e) { rec('!!', '弹幕 API', String(e.message).slice(0, 50)) }
|
|
951
|
-
try {
|
|
952
|
-
const subs = await getOfficialSubs(testBv, cid, null)
|
|
953
|
-
rec(subs ? 'OK' : '--', '官方字幕 API', subs ? `${subs.quality} / ${subs.lines} 行 / ${subs.chars} 字` : '这条视频无字幕轨(不影响,会用字幕带或画面)')
|
|
954
|
-
} catch (e) { rec('!!', '官方字幕 API', String(e.message).slice(0, 50)) }
|
|
955
|
-
} else {
|
|
956
|
-
rec('--', '弹幕/字幕 API', '视频 API 不可用,跳过')
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// 8) 缓存
|
|
960
|
-
const cacheMb = cached.reduce((s, f) => s + fs.statSync(path.join(CACHE, f)).size, 0) / 1024 / 1024
|
|
961
|
-
rec(cached.length ? 'OK' : '--', '缓存', cached.length ? `${cached.length} 个视频 / ${cacheMb.toFixed(1)} MB` : '空(首次跑会下载)')
|
|
962
|
-
|
|
963
|
-
// 9) 磁盘空间
|
|
964
|
-
try {
|
|
965
|
-
const st = fs.statfsSync(WS)
|
|
966
|
-
const freeGb = (st.bsize * st.bavail) / 1024 / 1024 / 1024
|
|
967
|
-
rec(freeGb > 1 ? 'OK' : '!!', '磁盘剩余', `${freeGb.toFixed(1)} GB${freeGb > 1 ? '' : '(长视频缓存可能不够)'}`)
|
|
968
|
-
} catch { rec('--', '磁盘剩余', 'statfs 不可用') }
|
|
969
|
-
|
|
970
|
-
// 10) --deep:抽帧 + 字幕带定位
|
|
971
|
-
if (deep) {
|
|
972
|
-
const mp4 = cached.length ? path.join(CACHE, cached[0]) : null
|
|
973
|
-
if (!mp4) {
|
|
974
|
-
rec('--', '抽帧实测', '没有缓存视频可测(先跑一次 auto)')
|
|
975
|
-
} else {
|
|
976
|
-
const tmp = path.join(OUT, `.selftest-frame-${process.pid}.jpg`)
|
|
977
|
-
const okFrame = grabFrame(mp4, 1.0, tmp) && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024
|
|
978
|
-
const size = okFrame ? jpegSize(tmp) : null
|
|
979
|
-
rec(okFrame ? 'OK' : '!!', '抽帧实测', okFrame ? `${cached[0]} → ${size ? `${size.w}x${size.h}` : 'ok'}` : '抽帧失败')
|
|
980
|
-
try { fs.unlinkSync(tmp) } catch {}
|
|
981
|
-
if (okFrame && fs.existsSync(aspy) && PYTHON) {
|
|
982
|
-
// 采样 3 帧喂给字幕带检测脚本
|
|
983
|
-
const dir = path.join(OUT, `.selftest-subs-${process.pid}`)
|
|
984
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
985
|
-
let n = 0
|
|
986
|
-
for (const t of [1, 3, 5]) {
|
|
987
|
-
const f = path.join(dir, `f${n}.jpg`)
|
|
988
|
-
if (grabFrame(mp4, t, f)) n++
|
|
989
|
-
}
|
|
990
|
-
const outJson = path.join(dir, 'r.json')
|
|
991
|
-
const r = runCapture(PYTHON, [aspy, dir, outJson], 30000)
|
|
992
|
-
let band = null
|
|
993
|
-
try { band = JSON.parse(fs.readFileSync(outJson, 'utf8')) } catch {}
|
|
994
|
-
rec(r.ok && band?.ok ? 'OK' : '!!', '字幕带实测', band?.ok
|
|
995
|
-
? `${(band.topRatio * 100).toFixed(0)}%-${(band.botRatio * 100).toFixed(0)}% 置信度 ${band.confidence}(仅 3 帧采样,数值仅供参考,只验证链路通)`
|
|
996
|
-
: `失败:${(r.err || '无 JSON 输出').slice(0, 50)}`)
|
|
997
|
-
try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
998
|
-
} else {
|
|
999
|
-
rec('--', '字幕带实测', '缺少 python 或 analyze_subs.py')
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// 汇总
|
|
1005
|
-
const okN = rows.filter((r) => r.mark === 'OK').length
|
|
1006
|
-
const badN = rows.filter((r) => r.mark === '!!').length
|
|
1007
|
-
const skipN = rows.filter((r) => r.mark === '--').length
|
|
1008
|
-
const secs = ((Date.now() - t0) / 1000).toFixed(1)
|
|
1009
|
-
console.log(`\n结果:${okN} 项通过${badN ? ` / ${badN} 项失败` : ''}${skipN ? ` / ${skipN} 项跳过` : ''}(${secs}s)`)
|
|
1010
|
-
if (badN) {
|
|
1011
|
-
console.log('\n怎么修:')
|
|
1012
|
-
console.log(' · ffmpeg/python 缺失 → 用环境变量指定路径:FFMPEG=/path/to/ffmpeg.exe PYTHON=...')
|
|
1013
|
-
console.log(' · 凭证缺失 → 在 B 站登录态文件里补 sessdata / bili_jct')
|
|
1014
|
-
console.log(' · API 失败 → 检查网络;bilibili 接口可能改了,需要更新代码')
|
|
1015
|
-
console.log(' · 字幕带失败 → 确认 python 有 numpy + Pillow')
|
|
1016
|
-
} else {
|
|
1017
|
-
console.log('✅ 全部通过')
|
|
1018
|
-
}
|
|
1019
|
-
if (!deep) console.log('\n(想看抽帧/字幕带实测,加 --deep)')
|
|
1020
|
-
return { ok: badN === 0, rows }
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
async function stageAuto(bvid, budget, opts = {}) {
|
|
1024
|
-
const pageNo = opts.page ?? 1
|
|
1025
|
-
const mp4 = await ensureMp4(bvid, pageNo)
|
|
1026
|
-
const view = await getView(bvid)
|
|
1027
|
-
const pageCount = view.pages?.length ?? 1
|
|
1028
|
-
const page = view.pages?.[pageNo - 1]
|
|
1029
|
-
if (pageNo > 1 && !page) throw new Error(`没有第 ${pageNo} 个分P(这条视频共 ${pageCount} 个)`)
|
|
1030
|
-
const fullDuration = page?.duration ?? view.duration
|
|
1031
|
-
const cid = page?.cid ?? view.cid
|
|
1032
|
-
|
|
1033
|
-
// 时间范围:所有内部计算用「区间长度」,但对外(manifest/日志/精读)都用全局秒数
|
|
1034
|
-
const start = Math.max(0, Number(opts.from ?? 0))
|
|
1035
|
-
const end = (opts.to !== null && opts.to !== undefined) ? Number(opts.to) : fullDuration
|
|
1036
|
-
// 上界校验:--from 超出视频时长时,后面的 duration 会算出负数,
|
|
1037
|
-
// 再往下 chunk 变负 → pickTimes 返回 null → 打印「整段都是疑似广告」,是误导性日志
|
|
1038
|
-
if (!Number.isFinite(start) || start >= fullDuration) {
|
|
1039
|
-
throw new Error(`--from(${Number.isFinite(start) ? start : opts.from}s) 超出视频时长(${fullDuration}s)`)
|
|
1040
|
-
}
|
|
1041
|
-
if (!Number.isFinite(end) || end <= start) {
|
|
1042
|
-
throw new Error(`--to(${opts.to ?? fullDuration}s) 必须大于 --from(${start}s),视频时长 ${fullDuration}s`)
|
|
1043
|
-
}
|
|
1044
|
-
const duration = Math.min(end, fullDuration) - start
|
|
1045
|
-
const range = start > 0 || end < fullDuration ? { from: start, to: Math.min(end, fullDuration) } : null
|
|
1046
|
-
|
|
1047
|
-
console.log(`\n=== 规划:${view.title}${pageCount > 1 ? ` · P${pageNo}/${pageCount}` : ''}(${fullDuration}s)===`)
|
|
1048
|
-
if (range) console.log(`[区间] 只处理 ${fmt(range.from)}-${fmt(range.to)}(${duration.toFixed(0)}s / 全片 ${fullDuration}s)`)
|
|
1049
|
-
if (pageCount > 1 && pageNo === 1) console.log(`[提示] 多P 视频,本次只处理 P1;要处理别的分P 用 --p N`)
|
|
1050
|
-
|
|
1051
|
-
// 拼图 token 按实际画面比例修正:COST.grid 以 1920x1080 拼图为基准,
|
|
1052
|
-
// 方形/竖屏视频的拼图面积大得多(1920x1920 贵 78%,竖屏贵 3 倍+),不算清楚会误导预算决策
|
|
1053
|
-
const dim = view.dimension ?? {}
|
|
1054
|
-
const vw = Number(dim.width) || 1280
|
|
1055
|
-
const vh = Number(dim.height) || 720
|
|
1056
|
-
const cellH = Math.round((640 * vh) / Math.max(vw, 1))
|
|
1057
|
-
const sheetPx = 640 * 3 * (cellH * 3)
|
|
1058
|
-
const gridCost = Math.max(300, Math.round((COST.grid * sheetPx) / (1920 * 1080)))
|
|
1059
|
-
if (Math.abs(sheetPx / (1920 * 1080) - 1) > 0.25) {
|
|
1060
|
-
console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准 1200 已按面积修正)`)
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// 1. 内容变化率:只统计所处理区间内的显著变化(score≥0.3)
|
|
1064
|
-
const scenes = detectScenes(mp4)
|
|
1065
|
-
const rangeScenes = range ? scenes.filter((s) => s.t >= range.from && s.t <= range.to) : scenes
|
|
1066
|
-
const strong = rangeScenes.filter((s) => s.score >= 0.3)
|
|
1067
|
-
const rate = duration > 0 ? strong.length / duration : 0
|
|
1068
|
-
console.log(`[内容] 显著变化 ${strong.length} 处${range ? '(区间内)' : ''},变化率 ${rate.toFixed(2)} 次/秒`)
|
|
1069
|
-
|
|
1070
|
-
// 2. 字幕:官方字幕优先(文本几百 token,比抽字幕带图快且准);没有才考虑烧录字幕带
|
|
1071
|
-
const officialRaw = await getOfficialSubs(bvid, cid, range)
|
|
1072
|
-
// 质量太差的官方字幕(音乐/音效被 ASR 成 碎片)不能用,误当台词会让理解完全跑偏
|
|
1073
|
-
const official = officialRaw?.quality === 'ok' ? officialRaw : null
|
|
1074
|
-
let bandResult = null
|
|
1075
|
-
let hasSubs = false
|
|
1076
|
-
if (official) {
|
|
1077
|
-
console.log(`[字幕] 官方${official.lanDoc ?? ''} ${official.lines} 行 / ${official.chars} 字 ≈ ${official.chars} token → 跳过字幕带`)
|
|
1078
|
-
console.log(` 文本: ${official.txt}`)
|
|
1079
|
-
if (official.chars > 8000) {
|
|
1080
|
-
console.log(` ⚠️ 字幕较长(${official.chars} 字 ≈ ${official.chars} token),建议只读关心的时段而不是全文`)
|
|
1081
|
-
}
|
|
1082
|
-
for (const line of official.head.slice(0, 3)) console.log(` ${line}`)
|
|
1083
|
-
} else {
|
|
1084
|
-
if (officialRaw) {
|
|
1085
|
-
console.log(`[字幕] 官方字幕质量差(平均有效 ${officialRaw.avgEff} 字/行、去重率 ${officialRaw.uniqueRatio})——疑为音乐/音效噪声,不采用`)
|
|
1086
|
-
}
|
|
1087
|
-
// 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
|
|
1088
|
-
bandResult = detectBand(mp4, fullDuration)
|
|
1089
|
-
// 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
|
|
1090
|
-
// 拼出来一堆非字幕行。低于 0.5 就不用字幕带,只靠画面拼图。
|
|
1091
|
-
const conf = bandResult?.confidence
|
|
1092
|
-
const confOk = conf === undefined || conf >= 0.5
|
|
1093
|
-
hasSubs = !!(bandResult?.ok && bandResult.bandRatio >= 0.015 && bandResult.bandRatio <= 0.35 && confOk)
|
|
1094
|
-
if (bandResult?.ok && !confOk) {
|
|
1095
|
-
console.log(`[字幕] 检出疑似字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%)但置信度仅 ${Number(conf).toFixed(2)}`)
|
|
1096
|
-
console.log(` —— 字幕位置漂移或不是每帧都有(抖音式动态字幕常见),跳过字幕带,只靠画面`)
|
|
1097
|
-
} else {
|
|
1098
|
-
console.log(`[字幕] ${hasSubs
|
|
1099
|
-
? `无可用官方字幕,检出烧录字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%,置信度 ${Number(conf).toFixed(2)})→ 字幕带 1 张`
|
|
1100
|
-
: '无可用字幕 → 只靠画面'}`)
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// 2.5 弹幕(第三个信息源:观众反应)
|
|
1105
|
-
// 不参与「能不能看懂」的判定,只用来找高光——观众在哪一段最激动,那里通常最值得精读
|
|
1106
|
-
const danmakuRaw = await getDanmaku(cid, range)
|
|
1107
|
-
const { kept: danmaku, dropped } = filterDanmaku(danmakuRaw)
|
|
1108
|
-
if (danmakuRaw?.length) {
|
|
1109
|
-
const parts = []
|
|
1110
|
-
if (dropped.ad) parts.push(`${dropped.ad} 条广告引流`)
|
|
1111
|
-
if (dropped.noise) parts.push(`${dropped.noise} 条无意义刷屏`)
|
|
1112
|
-
if (parts.length) {
|
|
1113
|
-
console.log(`[弹幕] 原始 ${danmakuRaw.length} 条,已过滤 ${parts.join(' / ')}`)
|
|
1114
|
-
for (const s of dropped.samples) console.log(` 样本:${s}`)
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
const dmHot = summarizeDanmaku(danmaku)
|
|
1118
|
-
// 视频内广告段(靠弹幕密度低谷 + 关键词;见 detectAdSegments 注释)
|
|
1119
|
-
const adSegs = detectAdSegments(danmaku, fullDuration)
|
|
1120
|
-
if (adSegs.length) {
|
|
1121
|
-
console.log(`[广告段] 弹幕密度低谷 ${adSegs.length} 段:`)
|
|
1122
|
-
for (const s of adSegs) {
|
|
1123
|
-
const tag = s.confidence === 'high' ? '疑似广告' : '低互动段(未必是广告)'
|
|
1124
|
-
const kw = s.keywords.length ? ` 关键词「${s.keywords.join('」「')}」` : ''
|
|
1125
|
-
console.log(` ${s.ts}(${s.dur}s)${tag} 密度比全片低 ${(s.lowDensity * 100).toFixed(0)}%${kw}`)
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
if (danmaku?.length) {
|
|
1129
|
-
console.log(`[弹幕] ${danmaku.length} 条${range ? '(区间内)' : ''}${dmHot?.length ? ',观众最激动的时段:' : ''}`)
|
|
1130
|
-
for (const h of dmHot ?? []) {
|
|
1131
|
-
console.log(` ${h.ts} ${String(h.count).padStart(3)} 条 「${h.samples.join('」「')}」`)
|
|
1132
|
-
}
|
|
1133
|
-
} else {
|
|
1134
|
-
console.log('[弹幕] 无(视频关弹幕 / 无人发 / 获取失败)')
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// 3. 看懂所需的最低帧间隔(可由 --density 强制覆盖)
|
|
1138
|
-
let maxGap
|
|
1139
|
-
if (opts.density) maxGap = opts.density
|
|
1140
|
-
else if (rate > 1.2) maxGap = 4 // 快剪 / MV / 游戏
|
|
1141
|
-
else if (rate > 0.5) maxGap = 6 // vlog / 教程
|
|
1142
|
-
else if (rate > 0.15) maxGap = 9 // 口播 / 慢节奏
|
|
1143
|
-
else maxGap = 13 // 静态 / 录屏
|
|
1144
|
-
console.log(`[密度] 目标 ≤${maxGap}s/帧(${opts.density ? '手动指定' : '按变化率推导'})`)
|
|
1145
|
-
|
|
1146
|
-
// 4. 张数:先满足密度,再看预算
|
|
1147
|
-
const framesNeeded = Math.ceil(duration / maxGap)
|
|
1148
|
-
const minGrids = Math.max(1, Math.ceil(framesNeeded / 9))
|
|
1149
|
-
|
|
1150
|
-
const subsCost = official ? Math.round(official.chars) : hasSubs ? COST.subs : 0
|
|
1151
|
-
const subsRows = duration <= 180 ? 14 : duration <= 600 ? 20 : 24
|
|
1152
|
-
const minFine = (official || hasSubs) ? 2 : 3 // 字幕已在手,精读需求自然变少
|
|
1153
|
-
const fixedCost = subsCost + minFine * COST.fine
|
|
1154
|
-
const maxGridsByBudget = Math.max(1, Math.floor((budget - fixedCost) / gridCost))
|
|
1155
|
-
|
|
1156
|
-
let gridCount = minGrids
|
|
1157
|
-
const notes = []
|
|
1158
|
-
if (gridCount > maxGridsByBudget) {
|
|
1159
|
-
notes.push(`预算只够 ${maxGridsByBudget} 张(密度会变稀)`)
|
|
1160
|
-
gridCount = maxGridsByBudget
|
|
1161
|
-
}
|
|
1162
|
-
if (gridCount > MAX_GRIDS) {
|
|
1163
|
-
notes.push(`触及张数上限 ${MAX_GRIDS}`)
|
|
1164
|
-
gridCount = MAX_GRIDS
|
|
1165
|
-
}
|
|
1166
|
-
gridCount = Math.max(1, gridCount)
|
|
1167
|
-
|
|
1168
|
-
const chunk = duration / gridCount
|
|
1169
|
-
const actualGap = duration / (gridCount * 9)
|
|
1170
|
-
|
|
1171
|
-
// 5. 报告
|
|
1172
|
-
const estTotal = subsCost + gridCount * gridCost + minFine * COST.fine
|
|
1173
|
-
console.log(`[张数] 密度需要 ${minGrids} 张,预算允许 ${maxGridsByBudget} 张 → 采用 ${gridCount} 张`)
|
|
1174
|
-
console.log(`[参数] chunk=${chunk.toFixed(1)}s(每张覆盖),实际平均 ${actualGap.toFixed(1)}s/帧`)
|
|
1175
|
-
console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${minFine * COST.fine} = ${estTotal}`)
|
|
1176
|
-
if (estTotal > budget) {
|
|
1177
|
-
console.log(`[警告] 最低配置已要 ${estTotal} token,超出预算 ${budget}(字幕带和精读预留不可再砍),将超支运行`)
|
|
1178
|
-
}
|
|
1179
|
-
if (actualGap > maxGap + 0.5) console.log(`[降级] 实际 ${actualGap.toFixed(1)}s/帧 稀于目标 ${maxGap}s/帧:${notes.join(';')}`)
|
|
1180
|
-
if (notes.length) console.log(`[备注] ${notes.join(';')}`)
|
|
1181
|
-
|
|
1182
|
-
// 6. 执行铺网
|
|
1183
|
-
console.log(`\n--- 生成 ---`)
|
|
1184
|
-
const pre = {
|
|
1185
|
-
mp4, view, scenes, bandResult, subsSheet: hasSubs, range, page: pageNo,
|
|
1186
|
-
subsCovered: !!(official || hasSubs), // 字幕信息是否已被覆盖(官方文本或烧录字幕带)
|
|
1187
|
-
subsSource: official ? 'official' : hasSubs ? 'burned-in' : null,
|
|
1188
|
-
danmakuHot: dmHot, // 写进 manifest,让 refine 也能用弹幕热区挑点
|
|
1189
|
-
danmakuTotal: danmaku?.length ?? 0,
|
|
1190
|
-
adSegments: adSegs, // 视频内疑似广告段(精读会避开 high 置信度的)
|
|
1191
|
-
useDanmakuFrames: opts.useDanmakuFrames !== false, // 弹幕热区是否也参与铺网取帧(默认是)
|
|
1192
|
-
}
|
|
1193
|
-
const manifest = await stageGrid(bvid, chunk, '3x3', pre)
|
|
1194
|
-
if (hasSubs) await stageSubs(bvid, subsRows, 'auto', { mp4, view, bandResult, scenes, range, page: pageNo })
|
|
1195
|
-
|
|
1196
|
-
// 7. 精读建议 = 几何规则(画面变化剧烈)+ 弹幕热区(观众反应最激烈)
|
|
1197
|
-
// 两个信号互补:几何只看像素,弹幕看人的反应;合并后按时间排序、同一时刻只留一个
|
|
1198
|
-
const geo = geometricPick(manifest)
|
|
1199
|
-
const dmPicks = (dmHot ?? []).map((h) => {
|
|
1200
|
-
const mid = h.start + 2.5
|
|
1201
|
-
return {
|
|
1202
|
-
t: mid,
|
|
1203
|
-
ts: fmt(mid), // 和 t 保持一致(桶中点),否则排序后显示会错乱
|
|
1204
|
-
reason: `弹幕热区 ${h.count} 条:「${h.samples.join('」「')}」`,
|
|
1205
|
-
source: '弹幕热区',
|
|
1206
|
-
}
|
|
1207
|
-
})
|
|
1208
|
-
const seen = new Set()
|
|
1209
|
-
const suggestions = []
|
|
1210
|
-
for (const p of [...dmPicks, ...geo]) {
|
|
1211
|
-
const key = Math.round(p.t)
|
|
1212
|
-
if (seen.has(key)) continue
|
|
1213
|
-
seen.add(key)
|
|
1214
|
-
suggestions.push(p)
|
|
1215
|
-
}
|
|
1216
|
-
suggestions.sort((a, b) => a.t - b.t)
|
|
1217
|
-
|
|
1218
|
-
console.log(`\n=== 完成 ===`)
|
|
1219
|
-
console.log(`预计 token ≈ ${estTotal}(若铺网信息已自足、跳过精读,实际会更低)`)
|
|
1220
|
-
if (official) {
|
|
1221
|
-
console.log(`\n[字幕] 台词已存成文本,直接读(别为字幕抽帧):`)
|
|
1222
|
-
console.log(` ${official.txt}`)
|
|
1223
|
-
}
|
|
1224
|
-
if (suggestions.length) {
|
|
1225
|
-
console.log(`\n[精读建议] ${suggestions.length} 个时间点(几何 ${geo.length} + 弹幕 ${dmPicks.length}):`)
|
|
1226
|
-
for (const p of suggestions) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
|
|
1227
|
-
console.log(` 需要时: node fisheye.mjs refine ${bvid}`)
|
|
1228
|
-
console.log(` (几何只能找「变化剧烈」、弹幕只能找「观众反应」;读图发现其他格看不清时,走 refine --template/--decisions)`)
|
|
1229
|
-
} else {
|
|
1230
|
-
console.log(`\n[精读建议] 两个信号都没命中;读图后如需精读:node fisheye.mjs refine ${bvid} --template`)
|
|
1231
|
-
}
|
|
1232
|
-
return { gridCount, chunk, actualGap, maxGap, hasSubs, estTotal, notes, danmaku: danmaku?.length ?? 0, top: suggestions }
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
// refine:决定哪些格需要精读,然后自动截原分辨率单帧
|
|
1236
|
-
// 两种模式
|
|
1237
|
-
// --template 生成决策模板(每格一行,agent 读完拼图后填 needDetail)
|
|
1238
|
-
// --decisions <file.json> 按 agent 的结构化判断执行
|
|
1239
|
-
// 都不传 几何兜底:只凭像素算,零 token(不读图)
|
|
1240
|
-
// 分工原则:语义判断("这格重要吗")只有读图能做;几何判断("这格的字一定糊")可以纯计算。
|
|
1241
|
-
const MIN_READABLE_SUBS_PX = 12 // 拼图里字幕低于这个像素高度就必然糊
|
|
1242
|
-
|
|
1243
|
-
function buildDecisionTemplate(manifest) {
|
|
1244
|
-
const cells = []
|
|
1245
|
-
for (const c of manifest.chunks) {
|
|
1246
|
-
for (const cell of c.cells) {
|
|
1247
|
-
cells.push({
|
|
1248
|
-
chunk: c.chunk, cell: cell.cell, t: cell.t, ts: cell.ts, scene: cell.scene,
|
|
1249
|
-
desc: '', // 填:这一格你看到了什么
|
|
1250
|
-
needDetail: false, // 填:是否要看原分辨率
|
|
1251
|
-
reason: '', // 填:为什么要看(字幕糊 / 特写 / 看不清某物)
|
|
1252
|
-
})
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
return {
|
|
1256
|
-
bvid: manifest.bvid,
|
|
1257
|
-
title: manifest.title,
|
|
1258
|
-
note: '逐格填 desc 和 needDetail;reason 写理由。needDetail=true 的会被自动截原图。',
|
|
1259
|
-
cells,
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
function geometricPick(manifest) {
|
|
1264
|
-
const { subs, videoWidth, videoHeight, sheetWidth } = manifest
|
|
1265
|
-
// 字幕是否已被覆盖(官方文本 / 烧录字幕带);旧 manifest 只有 subsSheet
|
|
1266
|
-
const subsCovered = manifest.subsCovered ?? manifest.subsSheet ?? false
|
|
1267
|
-
const picks = []
|
|
1268
|
-
// 弹幕热区也参与挑点:观众在哪一段最激动,那里通常最值得单独看原图
|
|
1269
|
-
for (const h of manifest.danmaku?.hot ?? []) {
|
|
1270
|
-
// t 取桶中点(不是桶起点),ts 也用中点 —— 两者必须一致,否则按 t 排序时显示会错乱
|
|
1271
|
-
const mid = Number(h.start) + 2.5
|
|
1272
|
-
picks.push({
|
|
1273
|
-
t: mid,
|
|
1274
|
-
ts: fmt(mid),
|
|
1275
|
-
scene: 1,
|
|
1276
|
-
chunk: 0,
|
|
1277
|
-
cell: 0,
|
|
1278
|
-
reasons: [`弹幕热区 ${h.count} 条:「${(h.samples ?? []).join('」「')}」`],
|
|
1279
|
-
})
|
|
1280
|
-
}
|
|
1281
|
-
for (const c of manifest.chunks) {
|
|
1282
|
-
const [cols] = c.grid.split('x').map(Number)
|
|
1283
|
-
const cellW = sheetWidth && cols ? sheetWidth / cols : null
|
|
1284
|
-
const scale = cellW && videoWidth ? cellW / videoWidth : null
|
|
1285
|
-
// 字幕在拼图里还剩多少像素
|
|
1286
|
-
const subsPx = subs && scale && videoHeight
|
|
1287
|
-
? (subs.bottom - subs.top) * videoHeight * scale
|
|
1288
|
-
: null
|
|
1289
|
-
for (const cell of c.cells) {
|
|
1290
|
-
const reasons = []
|
|
1291
|
-
// 字幕糊:只在「字幕信息还没被覆盖」时才值得逐帧精读
|
|
1292
|
-
if (subsPx !== null && subsPx < MIN_READABLE_SUBS_PX && !subsCovered) {
|
|
1293
|
-
reasons.push(`字幕在拼图里只剩 ${subsPx.toFixed(1)}px(低于 ${MIN_READABLE_SUBS_PX}px,必然糊)`)
|
|
1294
|
-
}
|
|
1295
|
-
if (cell.scene >= 0.85) reasons.push(`画面变化强度 ${cell.scene},信息密度高`)
|
|
1296
|
-
if (reasons.length) {
|
|
1297
|
-
picks.push({ t: cell.t, ts: cell.ts, chunk: c.chunk, cell: cell.cell, scene: cell.scene, reasons })
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
// 精读避开高置信度广告段:那里不值得花 token 看原图(标注给 agent 知道即可)
|
|
1302
|
-
const adHigh = (manifest.adSegments ?? []).filter((s) => s.confidence === 'high')
|
|
1303
|
-
const inAd = (t) => adHigh.some((s) => t >= s.from - 5 && t <= s.to + 5)
|
|
1304
|
-
|
|
1305
|
-
// 按信号强度排序,最多取 8 个(几何规则 + 弹幕热区合并后),避免把整片都标成必读
|
|
1306
|
-
// 去重按「秒」:不同信号可能指向同一时刻(82.18 和 82.5 显示都是 01:22),保留更靠前的
|
|
1307
|
-
// (scene 排序已把弹幕热区放前面,因为它的理由更具体:观众反应)
|
|
1308
|
-
const seenSec = new Set()
|
|
1309
|
-
const deduped = []
|
|
1310
|
-
for (const p of picks.sort((a, b) => b.scene - a.scene)) {
|
|
1311
|
-
if (inAd(p.t)) continue // 广告段不出现在精读建议里
|
|
1312
|
-
const sec = Math.floor(p.t)
|
|
1313
|
-
if (seenSec.has(sec)) continue
|
|
1314
|
-
seenSec.add(sec)
|
|
1315
|
-
deduped.push(p)
|
|
1316
|
-
}
|
|
1317
|
-
return deduped
|
|
1318
|
-
.slice(0, 8)
|
|
1319
|
-
.sort((a, b) => a.t - b.t) // 按时间顺序展示
|
|
1320
|
-
.map((p) => ({
|
|
1321
|
-
t: p.t,
|
|
1322
|
-
ts: p.ts,
|
|
1323
|
-
reason: p.reasons.join(';'),
|
|
1324
|
-
source: p.chunk ? `图${p.chunk} #${p.cell} 几何规则` : '弹幕热区',
|
|
1325
|
-
}))
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
async function stageRefine(bvid, opts) {
|
|
1329
|
-
const mfPath = path.join(OUT, `${bvid}-manifest.json`)
|
|
1330
|
-
if (!fs.existsSync(mfPath)) throw new Error(`没有 manifest,先跑 auto 或 grid:${mfPath}`)
|
|
1331
|
-
const manifest = JSON.parse(fs.readFileSync(mfPath, 'utf8'))
|
|
1332
|
-
|
|
1333
|
-
if (opts.template) {
|
|
1334
|
-
const tpl = buildDecisionTemplate(manifest)
|
|
1335
|
-
const p = path.join(OUT, `${bvid}-decisions.template.json`)
|
|
1336
|
-
fs.writeFileSync(p, JSON.stringify(tpl, null, 2), 'utf8')
|
|
1337
|
-
console.log(`决策模板: ${p}`)
|
|
1338
|
-
console.log(`共 ${tpl.cells.length} 格。读完拼图后填 needDetail=true,再执行:`)
|
|
1339
|
-
console.log(` node fisheye.mjs refine ${bvid} --decisions "${p}"`)
|
|
1340
|
-
return { template: p, cells: tpl.cells.length }
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
let picks
|
|
1344
|
-
if (opts.decisions) {
|
|
1345
|
-
// 去掉 BOM —— PowerShell 的 Set-Content -Encoding UTF8 和记事本都会加,JSON.parse 会炸
|
|
1346
|
-
const txt = fs.readFileSync(opts.decisions, 'utf8').replace(/^\uFEFF/, '')
|
|
1347
|
-
const d = JSON.parse(txt)
|
|
1348
|
-
const all = (d.cells ?? []).filter((x) => x.needDetail)
|
|
1349
|
-
// 校验时间范围:越界的时间点截不出帧,提前报出来而不是静默失败
|
|
1350
|
-
const dur = manifest.duration ?? Infinity
|
|
1351
|
-
const bad = all.filter((x) => typeof x.t !== 'number' || x.t < 0 || x.t > dur)
|
|
1352
|
-
if (bad.length) {
|
|
1353
|
-
console.warn(`[警告] ${bad.length} 个时间点越界或格式错误,已忽略:${bad.map((x) => x.t).join(', ')}`)
|
|
1354
|
-
}
|
|
1355
|
-
picks = all.filter((x) => typeof x.t === 'number' && x.t >= 0 && x.t <= dur).map((x) => ({
|
|
1356
|
-
t: x.t,
|
|
1357
|
-
ts: x.ts ?? fmt(x.t),
|
|
1358
|
-
reason: x.reason || x.desc || '模型判断需要细看',
|
|
1359
|
-
source: `图${x.chunk} #${x.cell}`,
|
|
1360
|
-
}))
|
|
1361
|
-
} else {
|
|
1362
|
-
picks = geometricPick(manifest)
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
if (!picks.length) {
|
|
1366
|
-
console.log('没有需要精读的点(几何规则未命中,也没给 decisions)')
|
|
1367
|
-
return { picks: [] }
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
console.log(`\n[精读] 选中 ${picks.length} 个时间点:`)
|
|
1371
|
-
for (const p of picks) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
|
|
1372
|
-
if (picks.length > 8) {
|
|
1373
|
-
console.warn(`[警告] ${picks.length} 帧 ≈ ${picks.length * COST.fine} token,建议先只留真正看不清的几帧再跑`)
|
|
1374
|
-
}
|
|
1375
|
-
console.log('')
|
|
1376
|
-
const grabbed = await stageGrab(bvid, picks.map((p) => p.t).join(','), { page: manifest.page ?? 1 })
|
|
1377
|
-
const est = picks.length * COST.fine
|
|
1378
|
-
console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${COST.fine})`)
|
|
1379
|
-
return { picks, grabbed }
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
async function stageGrab(bvid, timeStr, opts = {}) {
|
|
1383
|
-
const pageNo = opts.page ?? 1
|
|
1384
|
-
const mp4 = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
|
|
1385
|
-
if (!fs.existsSync(mp4)) throw new Error(`没有缓存 mp4,先跑 auto 或 grid:${mp4}`)
|
|
1386
|
-
// 注意 Number('') === 0:必须先滤掉空串,否则空输入会变成「0 秒」
|
|
1387
|
-
const times = timeStr
|
|
1388
|
-
.split(',')
|
|
1389
|
-
.map((s) => s.trim())
|
|
1390
|
-
.filter((s) => s !== '')
|
|
1391
|
-
.map(Number)
|
|
1392
|
-
.filter((n) => Number.isFinite(n))
|
|
1393
|
-
if (!times.length) {
|
|
1394
|
-
console.error('没有有效时间点(格式:44,59.5,79.9)')
|
|
1395
|
-
return []
|
|
1396
|
-
}
|
|
1397
|
-
const out = []
|
|
1398
|
-
for (const t of times) {
|
|
1399
|
-
// 2 位小数:1.73 和 1.74 若只留 1 位都会变成 1_7s,后一个会覆盖前一个
|
|
1400
|
-
const f = path.join(OUT, `${bvid}-grab-${t.toFixed(2).replace('.', '_')}s.jpg`)
|
|
1401
|
-
// 和拼图用同一种 seek(-ss 前置),保证「拼图上第 N 格」和「精读的同一时间点」是同一帧。
|
|
1402
|
-
if (!grabFrame(mp4, t, f, 2)) {
|
|
1403
|
-
console.warn(`[精读帧] ${fmt(t)} 抽取失败(时间越界或解码出错),跳过`)
|
|
1404
|
-
continue
|
|
1405
|
-
}
|
|
1406
|
-
out.push({ t, ts: fmt(t), file: f })
|
|
1407
|
-
console.log(`[精读帧] ${fmt(t)} → ${f}`)
|
|
1408
|
-
}
|
|
1409
|
-
console.log(JSON.stringify(out, null, 2))
|
|
1410
|
-
return out
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
// 本地视频:不依赖 B站,直接用 ffmpeg 抽帧拼图(fisheye local <文件>)
|
|
1415
|
-
async function stageLocal(inputFile, opts = {}) {
|
|
1416
|
-
const file = path.resolve(inputFile)
|
|
1417
|
-
if (!fs.existsSync(file)) throw new Error(`文件不存在: ${file}`)
|
|
1418
|
-
if (!FFMPEG) throw new Error('找不到 ffmpeg(设置 FFMPEG 环境变量,或放到 ~/.dsh/surfcore/bili/bin/ffmpeg.exe)')
|
|
1419
|
-
const grid = String(opts.grid || '3x3').toLowerCase()
|
|
1420
|
-
const [cols, rows] = grid.split('x').map(Number)
|
|
1421
|
-
if (!cols || !rows || cols < 1 || rows < 1 || cols > 9 || rows > 9) throw new Error('grid 格式应为 3x3 / 4x4 / 5x5')
|
|
1422
|
-
const n = cols * rows
|
|
1423
|
-
// 用 ffmpeg 解析时长:stderr 写临时文件,避免依赖管道捕获
|
|
1424
|
-
const probeTmp = path.join(os.tmpdir(), `fisheye-local-probe-${process.pid}-${Date.now()}.txt`)
|
|
1425
|
-
try {
|
|
1426
|
-
const fd = fs.openSync(probeTmp, 'w')
|
|
1427
|
-
spawnSync(FFMPEG, ['-hide_banner', '-i', file, '-f', 'null', '-'], { stdio: ['ignore', 'ignore', fd], timeout: 30000 })
|
|
1428
|
-
fs.closeSync(fd)
|
|
1429
|
-
} catch {}
|
|
1430
|
-
let text = ''
|
|
1431
|
-
try { text = fs.readFileSync(probeTmp, 'utf8') } catch {}
|
|
1432
|
-
try { fs.unlinkSync(probeTmp) } catch {}
|
|
1433
|
-
const durm = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text)
|
|
1434
|
-
if (!durm) throw new Error('读不出视频时长(ffmpeg 无法解析该文件?)')
|
|
1435
|
-
const duration = (+durm[1]) * 3600 + (+durm[2]) * 60 + (+durm[3])
|
|
1436
|
-
const from = Math.max(0, Number(opts.from) || 0)
|
|
1437
|
-
const to = Number.isFinite(opts.to) && opts.to > from ? Math.min(opts.to, duration) : duration
|
|
1438
|
-
const segDur = Math.max(0.5, to - from)
|
|
1439
|
-
const interval = Math.max(0.1, segDur / n)
|
|
1440
|
-
const base = path.basename(file, path.extname(file)).replace(/[^\w.-]+/g, '_').slice(0, 40)
|
|
1441
|
-
fs.mkdirSync(OUT, { recursive: true })
|
|
1442
|
-
const outPath = path.join(OUT, `local-${base}-${grid}-${Date.now()}.jpg`)
|
|
1443
|
-
const vf = `fps=1/${interval.toFixed(4)},scale=480:-2,tile=${cols}x${rows}`
|
|
1444
|
-
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 })
|
|
1445
|
-
if (r.status !== 0 || !fs.existsSync(outPath)) throw new Error('抽帧拼图失败(ffmpeg 返回非 0 或没有输出图)')
|
|
1446
|
-
console.log(`时长: ${duration.toFixed(1)}s | 区间: ${from.toFixed(1)}-${to.toFixed(1)}s | 抽帧: ${n} 张 (${grid}) | 间隔 ${interval.toFixed(1)}s`)
|
|
1447
|
-
console.log(`SHEET: ${outPath}`)
|
|
1448
|
-
return { sheet: outPath, duration, from, to, grid }
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// CLI
|
|
1452
40
|
const argv = process.argv.slice(2)
|
|
41
|
+
|
|
1453
42
|
const stage = argv[0]
|
|
1454
43
|
// BV 号固定是 BV + 10 位字母数字。入口就校验,不接受任意字符串
|
|
1455
44
|
// 它会被拼进文件名(out/cache)和 API URL —— 放过 "../" 就是路径穿越,放过 "&?#" 会污染 query。
|
|
1456
45
|
// 这个工具要给 AI agent 用,传入的 BV 号未必可信。
|
|
46
|
+
|
|
1457
47
|
const rawBvid = argv[1]
|
|
48
|
+
|
|
1458
49
|
const bvid = rawBvid && /^BV[0-9A-Za-z]{10}$/.test(rawBvid) ? rawBvid : ''
|
|
50
|
+
|
|
1459
51
|
const opt = (name, dflt) => {
|
|
1460
52
|
const i = argv.indexOf(`--${name}`)
|
|
1461
53
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt
|
|
@@ -1463,7 +55,7 @@ const opt = (name, dflt) => {
|
|
|
1463
55
|
if (stage === 'local') {
|
|
1464
56
|
const fileArg = argv[1]
|
|
1465
57
|
if (!fileArg || fileArg.startsWith('--')) {
|
|
1466
|
-
console.error('
|
|
58
|
+
console.error(t('err.local.usage'))
|
|
1467
59
|
process.exit(2)
|
|
1468
60
|
}
|
|
1469
61
|
try {
|
|
@@ -1473,25 +65,16 @@ if (stage === 'local') {
|
|
|
1473
65
|
to: parseTime(opt('to', null), 'to')
|
|
1474
66
|
})
|
|
1475
67
|
} catch (error) {
|
|
1476
|
-
console.error(
|
|
68
|
+
console.error(t('err.local.failed', { message: error?.message ?? error }))
|
|
1477
69
|
process.exit(1)
|
|
1478
70
|
}
|
|
1479
71
|
process.exit(0)
|
|
1480
72
|
}
|
|
1481
73
|
if (!stage || (stage !== 'selftest' && !bvid)) {
|
|
1482
74
|
if (rawBvid && stage !== 'selftest') {
|
|
1483
|
-
console.error(
|
|
75
|
+
console.error(`${t('err.bvFormat', { raw: rawBvid })}\n`)
|
|
1484
76
|
}
|
|
1485
|
-
console.log('
|
|
1486
|
-
console.log(' node fisheye.mjs auto <BV> [--budget 20000] [--density 6] ← 推荐:自动规划并铺网')
|
|
1487
|
-
console.log(' node fisheye.mjs refine <BV> --template ← 生成精读决策模板')
|
|
1488
|
-
console.log(' node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读')
|
|
1489
|
-
console.log(' node fisheye.mjs refine <BV> ← 几何兜底(零 token)')
|
|
1490
|
-
console.log(' node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]')
|
|
1491
|
-
console.log(' node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]')
|
|
1492
|
-
console.log(' node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点')
|
|
1493
|
-
console.log(' node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)')
|
|
1494
|
-
console.log(' node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)')
|
|
77
|
+
console.log(t('usage'))
|
|
1495
78
|
process.exit(2)
|
|
1496
79
|
}
|
|
1497
80
|
// 顶层错误兜底:不要把堆栈直接甩给用户
|
|
@@ -1525,11 +108,9 @@ try {
|
|
|
1525
108
|
opt('band', 'auto'),
|
|
1526
109
|
{ page, range })
|
|
1527
110
|
} else if (stage === 'grab') await stageGrab(bvid, argv[2] ?? '', { page })
|
|
1528
|
-
else { console.error('
|
|
111
|
+
else { console.error(t('err.unknownStage', { stage })); process.exit(2) }
|
|
1529
112
|
} catch (e) {
|
|
1530
|
-
console.error(
|
|
113
|
+
console.error(t('err.fatal', { message: e.message }))
|
|
1531
114
|
if (process.env.FISHEYE_DEBUG) console.error(e.stack)
|
|
1532
115
|
process.exit(1)
|
|
1533
116
|
}
|
|
1534
|
-
|
|
1535
|
-
|