@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/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
|
+
}
|
package/lib/danmaku.mjs
ADDED
|
@@ -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
|
+
}
|