@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/bili.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // B站 API:视频信息 / WBI 签名 / 官方字幕 / 播放地址 / 弹幕
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 { FFMPEG, CACHE, WS, CRED, OUT } from './env.mjs'
8
+ import { fmt } from './util.mjs'
9
+ // 所有 B站请求都走 fetchWithRetry:网络抖动 / 429 / 5xx 时自动重试(指数退避),幂等 GET 才重试
10
+ import { fetchWithRetry } from './retry.mjs'
11
+
12
+ export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
13
+
14
+ export let cred = {}
15
+ try {
16
+ // 去 BOM:README 让用户「复制模板为 credentials.json」,而模板/记事本/PowerShell 写的
17
+ // UTF-8 常带 BOM。不剥的话 JSON.parse 抛错 → cred 为空 → 匿名拿不到 AI 字幕、
18
+ // 部分播放地址 403,但正式运行不报错,只有 selftest 报「缺字段」。
19
+ cred = JSON.parse(fs.readFileSync(CRED, 'utf8').replace(/^\uFEFF/, '')) || {}
20
+ } catch { /* 自检会报 */ }
21
+
22
+ export const H = {
23
+ 'user-agent': UA,
24
+ referer: 'https://www.bilibili.com/',
25
+ cookie: `SESSDATA=${cred.sessdata ?? ''}; bili_jct=${cred.bili_jct ?? ''}; buvid3=${cred.buvid3 ?? ''}`,
26
+ }
27
+
28
+ // 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
29
+
30
+ export async function getView(bvid) {
31
+ const r = await fetchWithRetry(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
32
+ { headers: H }, { timeoutMs: 15000 })
33
+ const v = await r.json()
34
+ if (v.code !== 0) throw new Error(`view code=${v.code} ${v.message}`)
35
+ return v.data
36
+ }
37
+
38
+ // 官方字幕(看视频最省的一条腿)
39
+ // 有官方/AI 字幕时,台词信息用文本拿(几百 token),比抽字幕带图(1000 token + OCR 误差)又快又准。
40
+ // 关键(踩过的坑,见 subtitles.mjs)
41
+ // ① 必须走 WBI 签名的 /x/player/wbi/v2,旧端点拿到的 subtitle_url 缺 auth_key → CDN 403
42
+ // ② auth_key 分钟级过期,拿到就得马上下载
43
+ // ③ 匿名拿不到 AI 字幕,必须带登录 cookie
44
+
45
+ export 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]
46
+
47
+ export const keyOf = (u) => u.slice(u.lastIndexOf('/') + 1, u.lastIndexOf('.'))
48
+
49
+ export const clean = (v) => String(v).replace(/[!'()*]/g, '')
50
+
51
+ export async function getOfficialSubs(bvid, cid, range = null) {
52
+ try {
53
+ const nav = await (await fetchWithRetry('https://api.bilibili.com/x/web-interface/nav',
54
+ { headers: H }, { timeoutMs: 15000 })).json()
55
+ const raw = keyOf(nav.data?.wbi_img?.img_url ?? '') + keyOf(nav.data?.wbi_img?.sub_url ?? '')
56
+ if (raw.length < 64) return null
57
+ const mixin = MIXIN_TAB.map((i) => raw[i]).join('').slice(0, 32)
58
+ const wbi = (params) => {
59
+ const wts = Math.round(Date.now() / 1000)
60
+ const q = Object.keys({ ...params, wts }).sort()
61
+ .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(clean(k === 'wts' ? wts : params[k]))}`).join('&')
62
+ return `${q}&w_rid=${crypto.createHash('md5').update(q + mixin).digest('hex')}`
63
+ }
64
+ const p = await (await fetchWithRetry(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
65
+ { headers: H }, { timeoutMs: 15000 })).json()
66
+ const subs = p?.data?.subtitle?.subtitles ?? []
67
+ if (!subs.length) return null
68
+ const pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
69
+ ?? subs.find((s) => String(s.lan).startsWith('zh')) ?? subs[0]
70
+ if (!pick?.subtitle_url) return null
71
+
72
+ const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
73
+ const r = await fetchWithRetry(url, {
74
+ headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
75
+ }, { timeoutMs: 20000 })
76
+ if (!r.ok) return null
77
+ let body = (JSON.parse(await r.text()).body ?? [])
78
+ if (!body.length) return null
79
+ // 只保留所处理区间内的字幕行(--from/--to)
80
+ if (range) body = body.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
81
+ if (!body.length) return null
82
+
83
+ fs.mkdirSync(OUT, { recursive: true })
84
+ const txt = path.join(OUT, `${bvid}-official-subs.txt`)
85
+ const plain = path.join(OUT, `${bvid}-official-subs-plain.txt`)
86
+ fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
87
+ fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
88
+
89
+ // 质量判断:B 站 AI 字幕在纯音乐/音效为主的视频里会输出大量「 歌词」噪声或无意义碎片,
90
+ // 这种字幕不能用(拿它当台词理解视频会全错),应该降级为看图
91
+ const effChars = body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, '').length)
92
+ const avgEff = effChars.reduce((a, b) => a + b, 0) / body.length
93
+ const uniqueRatio = new Set(body.map((l) => String(l.content ?? '').replace(/[♪\s]/g, ''))).size / body.length
94
+ const chars = effChars.reduce((a, b) => a + b, 0)
95
+ const quality = avgEff < 2 || uniqueRatio < 0.4 ? 'noisy' : 'ok'
96
+
97
+ return {
98
+ lan: pick.lan, lanDoc: pick.lan_doc, lines: body.length, txt, plain,
99
+ chars, quality, avgEff: Number(avgEff.toFixed(1)), uniqueRatio: Number(uniqueRatio.toFixed(2)),
100
+ head: body.slice(0, 5).map((l) => `[${fmt(l.from)}] ${l.content}`),
101
+ }
102
+ } catch (e) {
103
+ console.warn(`[官方字幕] 获取失败(按无官方字幕处理):${String(e.message).split('\n')[0].slice(0, 60)}`)
104
+ return null
105
+ }
106
+ }
107
+
108
+ // 下载(一次,复用;分P 分开缓存)
109
+ /**
110
+ * 缓存视频是否有效。
111
+ * 只校验文件大小会把「下载中断的残缺文件 / 拷坏的 mp4」当有效缓存复用,
112
+ * 之后抽帧静默失败,极难排查。mp4 头 12 字节里一定有 ftyp box。
113
+ */
114
+
115
+ export function looksLikeMp4(p) {
116
+ try {
117
+ if (!fs.existsSync(p) || fs.statSync(p).size <= 1024) return false
118
+ const fd = fs.openSync(p, 'r')
119
+ const buf = Buffer.alloc(12)
120
+ fs.readSync(fd, buf, 0, 12, 0)
121
+ fs.closeSync(fd)
122
+ return buf.includes(Buffer.from('ftyp'))
123
+ } catch { return false }
124
+ }
125
+
126
+ export async function ensureMp4(bvid, pageNo = 1) {
127
+ fs.mkdirSync(CACHE, { recursive: true })
128
+ const p = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
129
+ if (looksLikeMp4(p)) return p
130
+
131
+ const data = await getView(bvid)
132
+ const page = data.pages?.[pageNo - 1]
133
+ if (!page) throw new Error(`没有第 ${pageNo} 个分P(这条视频共 ${data.pages?.length ?? 1} 个)`)
134
+ const { cid } = page
135
+ const duration = page.duration ?? data.duration
136
+ const title = page.part && data.pages.length > 1 ? `${data.title} · P${pageNo} ${page.part}` : data.title
137
+
138
+ const pu = await (await fetchWithRetry(
139
+ `https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&qn=80&fnval=1&platform=pc&high_quality=1`,
140
+ { headers: H }, { timeoutMs: 15000 })).json()
141
+ const url = pu.data?.durl?.[0]?.url
142
+ if (!url) throw new Error(`没拿到直链 code=${pu.code}`)
143
+
144
+ // 下载到 .part 再 rename:网络中断时只会留下 .part,不会把半个 mp4 伪装成缓存
145
+ // (旧实现直接写最终路径,中断后下次运行会拿着残缺文件当缓存用)
146
+ const r = await fetchWithRetry(url, {
147
+ headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/', range: 'bytes=0-' },
148
+ }, { timeoutMs: 300000 })
149
+ if (!r.ok) throw new Error(`下载失败 HTTP ${r.status}`)
150
+ const buf = Buffer.from(await r.arrayBuffer())
151
+ if (buf.length < 1024) throw new Error('下载内容过短,可能被中断(.part 已丢弃)')
152
+ const part = p + '.part'
153
+ fs.writeFileSync(part, buf)
154
+ fs.renameSync(part, p)
155
+ console.log(`[下载] ${title} ${duration}s → ${(fs.statSync(p).size / 1048576).toFixed(2)} MB`)
156
+ return p
157
+ }
158
+
159
+ // 画面变化点:一次解码,把带分数的候选全部拿出来
160
+ // 旧做法是每个阈值各跑一遍 ffmpeg(0.3 / 0.15 / 0.08 三遍全片解码),慢且浪费。
161
+ // 现在只解码一遍(阈值放最低 0.05),把每处的 scene_score 一起记下,阈值阶梯在内存里做,零重复成本。
162
+ // 注意:沙箱下 child_process 的 stdout 管道被禁(EPERM),只能用「ffmpeg 写临时文件」的方式取输出。
163
+
164
+ export async function getDanmaku(cid, range = null) {
165
+ try {
166
+ const r = await fetchWithRetry(`https://comment.bilibili.com/${cid}.xml`, {
167
+ headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/' },
168
+ }, { timeoutMs: 20000 })
169
+ if (!r.ok) return null
170
+ const xml = await r.text()
171
+ const list = []
172
+ for (const m of xml.matchAll(/<d p="([^"]*)">([^<]*)<\/d>/g)) {
173
+ const t = Number(String(m[1]).split(',')[0])
174
+ const text = String(m[2] ?? '').trim()
175
+ if (!Number.isFinite(t) || !text) continue
176
+ if (range && (t < range.from || t > range.to)) continue
177
+ list.push({ t, text })
178
+ }
179
+ return list
180
+ } catch (e) {
181
+ console.warn(`[弹幕] 获取失败(忽略,不影响其余流程):${String(e.message).split('\n')[0].slice(0, 60)}`)
182
+ return null
183
+ }
184
+ }
185
+
186
+ // 弹幕过滤逻辑在 danmaku-filter.mjs(与测试共用同一份实现,避免测试测副本)
187
+
188
+ // 广告段检测(视频内广告,不是弹幕文本广告)
189
+ // 判断依据来自案例 BV1i4W86zEUS(小约翰可汗 40 分钟视频,
190
+ // 用户指认 04:35-06:49 是鱼油广告)
191
+ // · 广告段弹幕密度只有全片的 31%(观众跳过 / 不互动)
192
+ // · 「感谢金主爸爸」这类关键词会在广告段附近出现
193
+ // 只用密度会误报(纯画面段、BGM 段也是低谷),所以分两级
194
+ // high = 密度低谷 且 附近有关键词命中(可直接标注为广告)
195
+ // low = 只有密度低谷(标成「低互动段」,不当广告)
package/lib/budget.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // lib/budget.mjs —— token 预算估算(零依赖,Node ≥18)
2
+ //
3
+ // 为什么单独拆一个模块:
4
+ // fisheye 的整个「抽多少帧 / 拼几张图」决策都建立在「一张图 ≈ 多少 token」上。
5
+ // 旧版把这个值写死在 stages.mjs(COST = { grid: 1200, subs: 1000, fine: 1300 }),
6
+ // 但不同模型的图像 tokenizer 差异很大——同一张 1920x1080 拼图,
7
+ // Gemini 对单图按固定档位计(约 258 token 起),GPT-4o high detail 按 512px 瓦片计(约 1.1k~2.1k),
8
+ // Qwen-VL 系列走动态分辨率(28px patch,token 更多)。固定系数会系统性高估或低估 20~30%,
9
+ // 直接让「预算只够几张」算错,最后要么超支、要么白白变稀。
10
+ //
11
+ // 估算公式:
12
+ // estimateTokens(kind, count) = round(tokensPerUnit(kind) * count)
13
+ // tokensPerUnit(kind) = round(基准(kind) * 模型系数) // 单张/单帧成本
14
+ //
15
+ // 基准值(环境变量可覆盖,单位 token;覆盖值须为正数,非法值忽略并告警一次):
16
+ // grid = 1200 FISHEYE_TOKENS_PER_GRID
17
+ // 3x3 拼图(9 格 × 640px,约 1920x1080)。stageAuto 还会按实际画面面积再修正,
18
+ // 所以这里只是 16:9 的基准值。
19
+ // subs = 1000 FISHEYE_TOKENS_PER_SUBS
20
+ // 烧录字幕带(1xN 行,只裁画面里的字幕条,信息密度高但仍是图)。
21
+ // fine = 1300 FISHEYE_TOKENS_PER_FINE
22
+ // 精读用单帧原图(不压成 640 格,通常比拼图里的单格更贵一点)。
23
+ //
24
+ // 模型系数(FISHEYE_TOKEN_MODEL,默认 1.0 = 基准,保持向后兼容):
25
+ // gpt 1.0 GPT-4o / 4.1 一类按 512px 瓦片计费的基准
26
+ // qwen 1.35 Qwen2-VL / 2.5-VL 动态分辨率,同图 token 明显更多(保守取 1.35)
27
+ // gemini 0.8 Gemini 单图固定档位计费,token 更少;取 0.8 作为保守下限(不按 258 的极端值算)
28
+ // 也可直接写数字(如 1.2),会被收敛到 [0.25, 4] 区间
29
+ //
30
+ // 兼容性:三个环境变量都不设、FISHEYE_TOKEN_MODEL 也不设时,
31
+ // estimateTokens('grid'|'subs'|'fine', n) 与旧版 COST.* × n 完全一致。
32
+
33
+ // 默认单张成本(token)。导出 COST 是为了兼容旧代码里 `import { COST } from './stages.mjs'` 的用法。
34
+ const DEFAULTS = Object.freeze({ grid: 1200, subs: 1000, fine: 1300 })
35
+
36
+ export const COST = DEFAULTS
37
+
38
+ // 模型系数表:只描述「相对基准模型,同一张图的 token 大致贵/便宜多少」。
39
+ // 数值来自公开的计费规则量级(图像 500~4000 token/图),不是精确 tokenizer 复刻;
40
+ // 目的是把 20~30% 的系统性偏差收窄,而不是追求逐像素精确。
41
+ export const MODEL_FACTORS = Object.freeze({
42
+ gpt: 1,
43
+ qwen: 1.35,
44
+ gemini: 0.8,
45
+ })
46
+
47
+ const ENV_PER_UNIT = Object.freeze({
48
+ grid: 'FISHEYE_TOKENS_PER_GRID',
49
+ subs: 'FISHEYE_TOKENS_PER_SUBS',
50
+ fine: 'FISHEYE_TOKENS_PER_FINE',
51
+ })
52
+
53
+ const FACTOR_MIN = 0.25
54
+ const FACTOR_MAX = 4
55
+
56
+ // 同一个错误只提示一次,避免在每个拼图的日志里刷屏
57
+ const warned = new Set()
58
+ function warnOnce(key, message) {
59
+ if (warned.has(key)) return
60
+ warned.add(key)
61
+ console.warn(`[预算] ${message}`)
62
+ }
63
+
64
+ function toPositiveNumber(raw) {
65
+ if (raw === undefined || raw === null) return null
66
+ const text = String(raw).trim()
67
+ if (!text) return null
68
+ const n = Number(text)
69
+ return Number.isFinite(n) && n > 0 ? n : null
70
+ }
71
+
72
+ /** 解析模型系数;无法识别/非法时回退 1.0(并告警一次),保证环境变量写错不会瘫痪 CLI */
73
+ export function modelFactor(raw = process.env.FISHEYE_TOKEN_MODEL) {
74
+ if (raw === undefined || raw === null || String(raw).trim() === '') return 1
75
+ const key = String(raw).trim().toLowerCase()
76
+ if (Object.prototype.hasOwnProperty.call(MODEL_FACTORS, key)) return MODEL_FACTORS[key]
77
+ const n = toPositiveNumber(key)
78
+ if (n !== null) {
79
+ const clamped = Math.min(FACTOR_MAX, Math.max(FACTOR_MIN, n))
80
+ if (clamped !== n) warnOnce(`factor:${n}`, `FISHEYE_TOKEN_MODEL=${raw} 超出合理范围,已收敛为 ×${clamped}`)
81
+ return clamped
82
+ }
83
+ warnOnce(`model:${key}`, `FISHEYE_TOKEN_MODEL=${raw} 不认识(可用 gpt / qwen / gemini,或 0.5~2 的数字),按 ×1 估算`)
84
+ return 1
85
+ }
86
+
87
+ /** 单张(单帧)成本:基准 × 模型系数,四舍五入到整数 token */
88
+ export function tokensPerUnit(kind) {
89
+ const base = DEFAULTS[kind]
90
+ if (base === undefined) {
91
+ throw new Error(`tokensPerUnit:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
92
+ }
93
+ const envVar = ENV_PER_UNIT[kind]
94
+ const raw = process.env[envVar]
95
+ let unit = base
96
+ if (raw !== undefined && String(raw).trim() !== '') {
97
+ const n = toPositiveNumber(raw)
98
+ if (n === null) warnOnce(`env:${envVar}`, `${envVar}=${raw} 不是正数,忽略,仍用默认 ${base}`)
99
+ else unit = n
100
+ }
101
+ return Math.round(unit * modelFactor())
102
+ }
103
+
104
+ /**
105
+ * 估算 count 个单位的 token 消耗。
106
+ * @param {'grid'|'subs'|'fine'} kind 单位类型:拼图 / 烧录字幕带 / 精读单帧
107
+ * @param {number} [count=1] 数量;非有限数或 ≤0 视为 0(空列表不花钱)
108
+ * @returns {number} 整数 token
109
+ */
110
+ export function estimateTokens(kind, count = 1) {
111
+ if (DEFAULTS[kind] === undefined) {
112
+ throw new Error(`estimateTokens:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
113
+ }
114
+ const n = count === undefined || count === null ? 1 : Number(count)
115
+ if (!Number.isFinite(n) || n <= 0) return 0
116
+ return Math.round(tokensPerUnit(kind) * n)
117
+ }
@@ -0,0 +1,75 @@
1
+ // 弹幕分析:广告段检测 / 热区摘要(过滤规则在 danmaku-filter.mjs)
2
+ import { fmt } from './util.mjs'
3
+
4
+
5
+ export const AD_SEGMENT_SIGNAL = /金主|恰饭|商单|硬广|软广|赞助|推广|接广|带货|下单|同款|优惠码/i;
6
+
7
+ export function detectAdSegments(danmaku, duration, bucketSec = 30, stepSec = 10) {
8
+ if (!danmaku?.length || duration < 120) return []
9
+ const dens = []
10
+ for (let s = 0; s + bucketSec <= duration; s += stepSec) {
11
+ let n = 0
12
+ for (const d of danmaku) if (d.t >= s && d.t < s + bucketSec) n++
13
+ dens.push({ s, n })
14
+ }
15
+ if (!dens.length) return []
16
+ const nums = dens.map((d) => d.n).sort((a, b) => a - b)
17
+ const median = nums[Math.floor(nums.length / 2)] || 1
18
+ // 低密度:低于中位数的 45%
19
+ const spans = []
20
+ for (const d of dens) {
21
+ if (d.n >= median * 0.45) continue
22
+ const last = spans[spans.length - 1]
23
+ if (last && d.s - last.to <= stepSec * 2) last.to = d.s + bucketSec
24
+ else spans.push({ from: d.s, to: d.s + bucketSec, count: 0 })
25
+ }
26
+ const long = spans.filter((x) => x.to - x.from >= 60)
27
+ if (!long.length) return []
28
+
29
+ const hits = danmaku.filter((d) => AD_SEGMENT_SIGNAL.test(d.text))
30
+ return long.map((sp) => {
31
+ const near = hits.filter((h) => h.t >= sp.from - 20 && h.t <= sp.to + 20)
32
+ // dens 是「30s 窗、10s 步长」的滑窗,同一条弹幕会被相邻 3 个桶各计一次。
33
+ // 分母必须用「实际桶数」而不是 (时长/bucketSec),否则 ratio 虚高约 3 倍
34
+ // (出现 lowDensity = -25% 的负值,因为 ratio > 1)
35
+ const segBuckets = dens.filter((d) => d.s >= sp.from && d.s < sp.to)
36
+ const total = segBuckets.reduce((s, d) => s + d.n, 0)
37
+ const ratio = total / Math.max(1, segBuckets.length) / median
38
+ return {
39
+ from: sp.from,
40
+ to: sp.to,
41
+ dur: sp.to - sp.from,
42
+ ts: `${fmt(sp.from)}-${fmt(sp.to)}`,
43
+ lowDensity: Number((1 - ratio).toFixed(2)),
44
+ confidence: near.length ? 'high' : 'low',
45
+ keywords: near.slice(0, 3).map((h) => h.text),
46
+ }
47
+ })
48
+ }
49
+
50
+ // 按时间桶聚合,找「观众最激动」的时段。
51
+ // 高频重复的弹幕最能代表那里在刷什么(梗 / 共鸣),所以每个热区取前 3 条并标出现次数。
52
+
53
+ export function summarizeDanmaku(list, bucketSec = 5, topN = 6) {
54
+ if (!list?.length) return null
55
+ const buckets = new Map()
56
+ for (const d of list) {
57
+ const k = Math.floor(d.t / bucketSec) * bucketSec
58
+ if (!buckets.has(k)) buckets.set(k, [])
59
+ buckets.get(k).push(d.text)
60
+ }
61
+ return [...buckets.entries()]
62
+ .map(([start, texts]) => ({ start, texts }))
63
+ .sort((a, b) => b.texts.length - a.texts.length) // 先按密度挑出最热的 N 个桶
64
+ .slice(0, topN)
65
+ .sort((a, b) => a.start - b.start) // 再按时间顺序展示
66
+ .map(({ start, texts }) => {
67
+ const freq = new Map()
68
+ for (const t of texts) freq.set(t, (freq.get(t) ?? 0) + 1)
69
+ const samples = [...freq.entries()]
70
+ .sort((a, b) => b[1] - a[1])
71
+ .slice(0, 3)
72
+ .map(([t, n]) => (n > 1 ? `${t}×${n}` : t))
73
+ return { start, end: start + bucketSec, ts: fmt(start), count: texts.length, samples }
74
+ })
75
+ }
package/lib/env.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // 路径与依赖解析:不写死绝对路径,解析顺序:环境变量 → ~/.config/fisheye/config.json → 项目内 bin/ → PATH
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { execFileSync } from 'node:child_process'
7
+
8
+ export const PKG_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
+ export const WS = process.env.FISHEYE_HOME || PKG_DIR
10
+ export const CACHE = path.join(WS, 'cache')
11
+ export const OUT = path.join(WS, 'out')
12
+ export const CFG_FILE = path.join(os.homedir(), '.config', 'fisheye', 'config.json')
13
+ export let LOCAL_CFG = {}
14
+ try {
15
+ LOCAL_CFG = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8').replace(/^\uFEFF/, '')) || {}
16
+ } catch { LOCAL_CFG = {} }
17
+
18
+ export function onPath(cmd) {
19
+ try {
20
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore', timeout: 5000 })
21
+ return true
22
+ } catch { return false }
23
+ }
24
+
25
+ /** 按 环境变量 → 配置文件 → 项目内 bin → PATH 的顺序找工具;找不到返回 null */
26
+
27
+ export function resolveTool(envVar, cfgKey, localCandidates, cmdName) {
28
+ if (process.env[envVar]) return { cmd: process.env[envVar], from: `环境变量 ${envVar}` }
29
+ if (LOCAL_CFG[cfgKey] && fs.existsSync(LOCAL_CFG[cfgKey])) return { cmd: LOCAL_CFG[cfgKey], from: CFG_FILE }
30
+ for (const c of localCandidates) if (fs.existsSync(c)) return { cmd: c, from: '项目内 bin/' }
31
+ if (onPath(cmdName)) return { cmd: cmdName, from: 'PATH' }
32
+ return null
33
+ }
34
+
35
+ export const FFMPEG_RES = resolveTool('FFMPEG', 'ffmpeg', [
36
+ path.join(PKG_DIR, 'bin', 'ffmpeg.exe'), path.join(PKG_DIR, 'bin', 'ffmpeg'),
37
+ ], 'ffmpeg')
38
+
39
+ export const PYTHON_RES = resolveTool('PYTHON', 'python', [
40
+ path.join(PKG_DIR, '.venv', 'Scripts', 'python.exe'), path.join(PKG_DIR, '.venv', 'bin', 'python'),
41
+ ], process.platform === 'win32' ? 'python' : 'python3')
42
+
43
+ export const FFMPEG = FFMPEG_RES?.cmd ?? null
44
+
45
+ export const PYTHON = PYTHON_RES?.cmd ?? null
46
+
47
+ /** B站登录态:环境变量 → config.json → 数据目录 → ~/.config/fisheye/ */
48
+
49
+ export function resolveCred() {
50
+ if (process.env.BILI_CRED) return process.env.BILI_CRED
51
+ if (LOCAL_CFG.credentials && fs.existsSync(LOCAL_CFG.credentials)) return LOCAL_CFG.credentials
52
+ for (const c of [
53
+ path.join(WS, 'credentials.json'),
54
+ path.join(os.homedir(), '.config', 'fisheye', 'credentials.json'),
55
+ ]) if (fs.existsSync(c)) return c
56
+ return null
57
+ }
58
+
59
+ export const CRED = resolveCred()
60
+
61
+ // 自检要能诊断「依赖缺失」本身,所以 selftest 时不在下面硬退出
package/lib/frame.mjs ADDED
@@ -0,0 +1,192 @@
1
+ // 抽帧与时间点选择:场景检测 / 广告剔除 / 取帧计划 / 字幕带定位 / 单帧截取 / 子进程封装
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { execFileSync, spawnSync } from 'node:child_process'
6
+ import { FFMPEG, PYTHON, WS, OUT } from './env.mjs'
7
+
8
+ export function detectScenes(file) {
9
+ // 文件名带 pid + 时间戳:两个视频并发跑时不会互相覆盖
10
+ const name = `.scenes-${process.pid}-${Date.now()}.txt`
11
+ const abs = path.join(OUT, name)
12
+ try {
13
+ if (fs.existsSync(abs)) fs.rmSync(abs, { force: true })
14
+ execFileSync(FFMPEG, ['-hide_banner', '-i', file, '-vf',
15
+ `scale=320:-2,select='gt(scene,0.05)',metadata=print:file=${name}`, '-an', '-f', 'null', '-'],
16
+ { stdio: 'ignore', cwd: OUT })
17
+ if (!fs.existsSync(abs)) return []
18
+ const out = fs.readFileSync(abs, 'utf8')
19
+ fs.rmSync(abs, { force: true })
20
+ // 输出是两行一组: "…pts_time:4.1" 紧跟 "lavfi.scene_score=0.42"
21
+ const ents = []
22
+ let cur = null
23
+ for (const line of out.split('\n')) {
24
+ const t = line.match(/pts_time:\s*([0-9.]+)/)
25
+ if (t) { cur = { t: Number(t[1]), score: 0 }; continue }
26
+ const s = line.match(/scene_score=([0-9.]+)/)
27
+ if (s && cur) { cur.score = Number(s[1]); ents.push(cur); cur = null }
28
+ }
29
+ return ents.filter((e) => e.t > 0.2)
30
+ } catch { return [] }
31
+ }
32
+
33
+ // 段内取帧:等间隔骨架 + 吸附变化点(阈值阶梯 + 去重)
34
+ // 基准点 ((i+0.5)*dur/n) 让每格的吸附窗口 [base±dur/2n] 恰好铺满整段、且不越界。
35
+ // 从 [from,to] 里减掉广告段,返回剩余连续区间(用于「广告不占帧」)
36
+
37
+ export function subtractAds(from, to, adSegs) {
38
+ let segs = [{ a: from, b: to }]
39
+ for (const ad of adSegs) {
40
+ const next = []
41
+ for (const s of segs) {
42
+ if (ad.to <= s.a || ad.from >= s.b) { next.push(s); continue }
43
+ if (ad.from > s.a) next.push({ a: s.a, b: ad.from })
44
+ if (ad.to < s.b) next.push({ a: ad.to, b: s.b })
45
+ }
46
+ segs = next
47
+ }
48
+ return segs.filter((s) => s.b - s.a > 0.5)
49
+ }
50
+
51
+ export function pickTimes(from, to, n, scenes, adSegs = []) {
52
+ const dur = to - from
53
+ const inAd = (t) => adSegs.some((ad) => t >= ad.from && t <= ad.to)
54
+
55
+ // 广告段时间从时间轴上「压缩掉」:在剩余有效时间里等间隔取基准点。
56
+ // 效果 = 帧数不变、广告不占帧、多出来的时长自动分给内容(密度提升)。
57
+ const segs = adSegs.length ? subtractAds(from, to, adSegs) : [{ a: from, b: to }]
58
+ if (!segs.length) return null // 整段都是广告 → 调用方跳过这张图
59
+ const totalEff = segs.reduce((s, x) => s + (x.b - x.a), 0)
60
+ const base = []
61
+ for (let i = 0; i < n; i++) {
62
+ let rem = ((i + 0.5) / n) * totalEff
63
+ let t = segs[segs.length - 1].b
64
+ for (const s of segs) {
65
+ const d = s.b - s.a
66
+ if (rem <= d) { t = s.a + rem; break }
67
+ rem -= d
68
+ }
69
+ base.push(t)
70
+ }
71
+
72
+ // 吸附窗口/去重下限按「有效时长」算,而不是原始段长。
73
+ // 段里有广告时,内容被压缩得更密,用原始 dur 会让窗口过大 → 多帧吸到同一点后被去重退回
74
+ // (40 分钟视频图2:跳过 118s 广告后 9 帧里有 5 帧被退回)
75
+ const timingBase = totalEff > 0 ? totalEff : dur
76
+ const half = timingBase / n / 2
77
+ const minGap = (timingBase / n) * 0.4 // 去重下限:相邻两帧至少隔这么多
78
+
79
+ const tryThreshold = (th) => {
80
+ // 吸附候选也排除广告段:不然帧会被吸到广告里的高变化点(片头/转场)
81
+ const cands = scenes.filter((s) => s.score >= th && !inAd(s.t))
82
+ const raw = base.map((b) => {
83
+ const near = cands.filter((s) => Math.abs(s.t - b) <= half)
84
+ if (!near.length) return b
85
+ // 窗口内可能挤着多个变化点:取「最剧烈」的而不是「最近」的——
86
+ // 时间代表性靠窗口保证(都在 ±half 内),视觉显著性决定落在哪一刻
87
+ return near.reduce((x, s) => (s.score > x.score ? s : x)).t
88
+ })
89
+ // snapped 统计的是「吸附能力」,必须在去重前算 —— 否则被去重退回的帧会被误记成「没吸到」
90
+ const snapped = raw.filter((t, i) => Math.abs(t - base[i]) > 1e-6).length
91
+
92
+ // 去重:吸附后若相邻两帧挤得比 minGap 还近,把后一个退回它自己的等间隔基准点
93
+ // (基准点之间间距 = dur/n,永远不会违反 minGap,所以退回一定安全)
94
+ const times = raw.slice()
95
+ for (let i = 1; i < times.length; i++) {
96
+ if (Math.abs(times[i] - times[i - 1]) < minGap) times[i] = base[i]
97
+ }
98
+ let dedup = 0
99
+ for (let i = 1; i < times.length; i++) {
100
+ if (Math.abs(raw[i] - times[i]) > 1e-6) dedup++
101
+ }
102
+ return { times, th, snapped, dedup, base }
103
+ }
104
+
105
+ // 阈值阶梯:从严格到宽松,取第一个「≥75% 帧都吸附成功」的档位
106
+ // (段变短后吸附窗口 half 会变小,过半门槛太松,容易留下大量没吸到的帧)
107
+ let fallback = null
108
+ for (const th of [0.3, 0.15, 0.08]) {
109
+ const r = tryThreshold(th)
110
+ if (!fallback) fallback = r
111
+ if (r.snapped >= Math.ceil(n * 0.75)) return r
112
+ }
113
+ return fallback
114
+ }
115
+
116
+ // 字幕行取帧:等间隔骨架 + 吸附变化点(字幕通常在镜头切换时更新,纯等间隔会取到
117
+ // 「还没出字幕」的过渡帧——第一条字幕带的行 1 经常是空的)
118
+
119
+ export function snapSubsTimes(span, count, scenes, start = 0) {
120
+ const base = Array.from({ length: count }, (_, i) => start + ((i + 0.5) * span) / count)
121
+ const cands = scenes.filter((s) => s.score >= 0.15 && s.t >= start && s.t <= start + span)
122
+ if (!cands.length) return base
123
+ const half = span / count / 2
124
+ const minGap = (span / count) * 0.4
125
+ const times = base.map((b) => {
126
+ const near = cands.filter((s) => Math.abs(s.t - b) <= half)
127
+ if (!near.length) return b
128
+ return near.reduce((x, s) => (s.score > x.score ? s : x)).t
129
+ })
130
+ for (let i = 1; i < times.length; i++) {
131
+ if (Math.abs(times[i] - times[i - 1]) < minGap) times[i] = base[i]
132
+ }
133
+ return times
134
+ }
135
+
136
+ // 弹幕:独立于画面和字幕的第三个信息源
137
+ // 为什么值得拉:弹幕密集的时段 = 观众真正觉得精彩的地方,可以直接指导精读选点;
138
+ // 而且它能看到画面和字幕里都没有的东西——大家在哪一秒笑、在刷什么梗。
139
+ // 数据源:https://comment.bilibili.com/<cid>.xml
140
+ // 每条 <d p="相对秒,模式,字号,颜色,...,>内容</d>,只取第一段(相对时间)和文本
141
+
142
+ export function detectBand(mp4, duration) {
143
+ const probe = path.join(OUT, `.probe-${Date.now()}`)
144
+ try {
145
+ fs.mkdirSync(probe, { recursive: true })
146
+ // 逐帧快速 seek 抽 24 帧:fps 滤镜虽然只输出 24 帧,但必须全片解码,长视频下慢一个数量级
147
+ // 注意:这里**必须跨全片采样**,不能只看 --from/--to 区间 —— 字幕位置是视频级属性,
148
+ // 只在区间内采样时,区间里若恰好多帧无字幕,峰值会被画面边缘抢走(82%-90% 跑偏成 55%-83%)
149
+ for (let i = 0; i < 24; i++) {
150
+ const t = ((i + 0.5) * Math.max(duration, 1)) / 24
151
+ grabFrame(mp4, t, path.join(probe, `f${String(i).padStart(2, '0')}.jpg`))
152
+ }
153
+ const jsonPath = path.join(probe, 'subs.json')
154
+ execFileSync(PYTHON, [path.join(PKG_DIR, 'analyze_subs.py'), probe, jsonPath], { stdio: 'ignore' })
155
+ const r = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
156
+ return r.ok ? r : null
157
+ } catch (e) {
158
+ // Python 缺失、分析脚本报错、抽帧失败 —— 一律降级为「没有字幕」,不能让整个流程崩掉
159
+ console.warn(`[字幕] 检测失败(将按无字幕处理):${String(e.message).split('\n')[0].slice(0, 80)}`)
160
+ return null
161
+ } finally {
162
+ try { fs.rmSync(probe, { recursive: true, force: true }) } catch {}
163
+ }
164
+ }
165
+
166
+ // 抽一帧;失败返回 false 而不是抛异常(t 越界、解码失败都可能发生)
167
+
168
+ export function grabFrame(mp4, t, outFile, quality = 3) {
169
+ try {
170
+ execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-ss', t.toFixed(3), '-i', mp4,
171
+ '-an', '-frames:v', '1', '-q:v', String(quality), outFile], { stdio: 'ignore' })
172
+ return fs.existsSync(outFile)
173
+ } catch {
174
+ return false
175
+ }
176
+ }
177
+
178
+ export function runCapture(cmd, args, timeoutMs = 15000) {
179
+ const tmp = path.join(WS, `.selftest-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
180
+ let fd = null
181
+ try {
182
+ fd = fs.openSync(tmp, 'w')
183
+ // 沙箱禁管道捕获 stdout,所以写到文件再读(和 detectScenes 同样的做法)
184
+ execFileSync(cmd, args, { stdio: ['ignore', fd, 'ignore'], timeout: timeoutMs })
185
+ return { ok: true, out: fs.readFileSync(tmp, 'utf8') }
186
+ } catch (e) {
187
+ return { ok: false, out: '', err: String(e?.message ?? e).split('\n')[0] }
188
+ } finally {
189
+ if (fd !== null) try { fs.closeSync(fd) } catch {}
190
+ try { fs.unlinkSync(tmp) } catch {}
191
+ }
192
+ }