@pls-compile-owo/fisheye 0.4.2 → 0.7.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 +48 -0
- package/README.en.md +17 -0
- package/README.md +137 -2
- package/fisheye.mjs +79 -5
- package/lib/audio.mjs +1 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +69 -14
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +18 -2
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/stages.mjs +290 -26
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
package/lib/ytdlp.mjs
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// 通用平台视频(可选):yt-dlp 下载 → 本地 mp4 → 交给本地视频模式(抽帧 / 字幕带)
|
|
2
|
+
//
|
|
3
|
+
// 定位:fisheye 原来只有两路信息源——B站(官方 API)和本地文件。
|
|
4
|
+
// YouTube / 抖音 / Twitter 这些平台没有统一的公开 API,但 yt-dlp 一个二进制就能抓,
|
|
5
|
+
// 下载完的视频和用户手动放进来的本地文件没有任何区别,所以这里只负责「拿到一个视频路径」,
|
|
6
|
+
// 后续抽帧、字幕、预算分配全部复用现成流程。
|
|
7
|
+
//
|
|
8
|
+
// 设计约束(和 audio.mjs 一致):
|
|
9
|
+
// · 零 npm 依赖,Node ≥18
|
|
10
|
+
// · 子进程一律 spawnSync;下载主流程 stdio:'ignore'(沙箱禁管道捕获),
|
|
11
|
+
// 探测类命令(where / --version)把 stdout+stderr 重定向到临时文件再读
|
|
12
|
+
// · 这是**可选能力**:没有 yt-dlp / URL 非法 / 网络失败 / 超时一律返回 null,绝不让整个看片流程失败
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { FFMPEG, PKG_DIR } from './env.mjs'
|
|
19
|
+
|
|
20
|
+
const IS_WIN = process.platform === 'win32'
|
|
21
|
+
const EXE = (name) => (IS_WIN ? `${name}.exe` : name)
|
|
22
|
+
|
|
23
|
+
// yt-dlp 在 PATH 上的命令名;Windows 下 where 能靠 PATHEXT 命中 yt-dlp.exe
|
|
24
|
+
const YTDLP_NAMES = ['yt-dlp', 'yt-dlp.exe']
|
|
25
|
+
|
|
26
|
+
// 下载产物可能是这几种容器(--merge-output-format mp4 时是 mp4,降级单文件时可能是 webm/mkv)
|
|
27
|
+
const MEDIA_EXT = new Set(['.mp4', '.mkv', '.webm', '.mov', '.m4v', '.flv', '.ts'])
|
|
28
|
+
|
|
29
|
+
// -f 选择器:默认 best video + best audio,合不上时退回「最佳单文件」
|
|
30
|
+
const QUALITY = {
|
|
31
|
+
best: 'bv*+ba/b',
|
|
32
|
+
'2160': 'bv*[height<=2160]+ba/b[height<=2160]/b',
|
|
33
|
+
'1440': 'bv*[height<=1440]+ba/b[height<=1440]/b',
|
|
34
|
+
'1080': 'bv*[height<=1080]+ba/b[height<=1080]/b',
|
|
35
|
+
'720': 'bv*[height<=720]+ba/b[height<=720]/b',
|
|
36
|
+
'480': 'bv*[height<=480]+ba/b[height<=480]/b',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --version 探测要启动一次单文件 exe(约 0.3~2s),findYtdlp / ytdlpCaps / downloadVideo 都会调,缓存一次
|
|
40
|
+
const versionCache = new Map()
|
|
41
|
+
|
|
42
|
+
const stripQuotes = (s) => String(s ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
|
|
43
|
+
|
|
44
|
+
function readText(file) {
|
|
45
|
+
try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '') } catch { return '' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tmpFile(tag) {
|
|
49
|
+
return path.join(os.tmpdir(), `fisheye-${tag}-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 跑一个短命令并把 stdout+stderr 落到临时文件。
|
|
54
|
+
* 沙箱里不能捕获管道,所以不能用 spawnSync 的 'pipe' —— 只能 fd 重定向。
|
|
55
|
+
*/
|
|
56
|
+
function capture(cmd, args, timeoutMs = 8000) {
|
|
57
|
+
const file = tmpFile('cap')
|
|
58
|
+
let fd = null
|
|
59
|
+
try {
|
|
60
|
+
fd = fs.openSync(file, 'w')
|
|
61
|
+
const r = spawnSync(cmd, args, {
|
|
62
|
+
stdio: ['ignore', fd, fd],
|
|
63
|
+
timeout: timeoutMs,
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
ok: r.status === 0,
|
|
68
|
+
status: r.status,
|
|
69
|
+
error: r.error,
|
|
70
|
+
out: readText(file).slice(0, 65536),
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { ok: false, status: null, error: e, out: '' }
|
|
74
|
+
} finally {
|
|
75
|
+
if (fd !== null) try { fs.closeSync(fd) } catch {}
|
|
76
|
+
try { fs.unlinkSync(file) } catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// where.exe / which 的全部命中行(没有命中返回 [])
|
|
81
|
+
function whereAll(name) {
|
|
82
|
+
const r = capture(IS_WIN ? 'where.exe' : 'which', [name], 5000)
|
|
83
|
+
if (r.status !== 0) return []
|
|
84
|
+
return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 判断一个 where 命中项是否值得 spawn。
|
|
89
|
+
* Windows 上 where 会命中 .cpl/.dll 之类(PATHEXT),spawn 会 ENOENT;
|
|
90
|
+
* .bat/.cmd 也排除:spawnSync 不带 shell 无法直接执行它们。
|
|
91
|
+
*/
|
|
92
|
+
function spawnable(p) {
|
|
93
|
+
if (!p) return false
|
|
94
|
+
const ext = path.extname(p).toLowerCase()
|
|
95
|
+
if (IS_WIN && ['.cpl', '.dll', '.msi', '.sys', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.js'].includes(ext)) return false
|
|
96
|
+
const looksPath = p.includes('/') || p.includes('\\')
|
|
97
|
+
if (looksPath && !fs.existsSync(p)) return false
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(p)) {
|
|
100
|
+
const st = fs.statSync(p)
|
|
101
|
+
if (!st.isFile() || st.size === 0) return false
|
|
102
|
+
}
|
|
103
|
+
} catch { return false }
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// yt-dlp --version 输出形如 2026.10.26(nightly 多一段数字),用它确认「这个文件真的是 yt-dlp」
|
|
108
|
+
function looksLikeVersion(text) {
|
|
109
|
+
return /^\d{4}\.\d{2}\.\d{2}/m.test(String(text ?? '').trim())
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function versionOf(cmd) {
|
|
113
|
+
const key = String(cmd).toLowerCase()
|
|
114
|
+
if (versionCache.has(key)) return versionCache.get(key)
|
|
115
|
+
const r = capture(cmd, ['--version'], 10000)
|
|
116
|
+
const version = looksLikeVersion(r.out) ? r.out.trim().split(/\r?\n/)[0] : null
|
|
117
|
+
const info = { ok: r.ok && Boolean(version), version, status: r.status, error: r.error }
|
|
118
|
+
versionCache.set(key, info)
|
|
119
|
+
return info
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 常见安装位置:用户按 README 手动放的位置优先于系统级安装
|
|
123
|
+
function commonYtdlpPaths(env = process.env) {
|
|
124
|
+
const home = os.homedir()
|
|
125
|
+
const localAppData = stripQuotes(env.LOCALAPPDATA) || path.join(home, 'AppData', 'Local')
|
|
126
|
+
return [
|
|
127
|
+
path.join(home, '.dsh', 'surfcore', 'yt-dlp', EXE('yt-dlp')),
|
|
128
|
+
path.join(localAppData, 'Programs', 'yt-dlp', EXE('yt-dlp')),
|
|
129
|
+
path.join(localAppData, 'Microsoft', 'WinGet', 'Links', EXE('yt-dlp')),
|
|
130
|
+
path.join(PKG_DIR, 'bin', EXE('yt-dlp')),
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 探测 yt-dlp,同时把探测过程记成 notes(给 selftest / caps 用)。
|
|
136
|
+
* @returns {{ ytdlp: { cmd: string, source: string, version: string|null } | null, notes: string[] }}
|
|
137
|
+
*/
|
|
138
|
+
function findYtdlpDetailed({ env = process.env } = {}) {
|
|
139
|
+
const notes = []
|
|
140
|
+
|
|
141
|
+
// 1) 环境变量显式指定(用户可以指到任意位置的单文件 exe)
|
|
142
|
+
const fromEnv = stripQuotes(env.FISHEYE_YTDLP)
|
|
143
|
+
if (fromEnv) {
|
|
144
|
+
if (!spawnable(fromEnv)) {
|
|
145
|
+
notes.push(`FISHEYE_YTDLP=${fromEnv} 不是可执行文件,已忽略`)
|
|
146
|
+
} else {
|
|
147
|
+
const v = versionOf(fromEnv)
|
|
148
|
+
if (v.ok) return { ytdlp: { cmd: fromEnv, source: '环境变量 FISHEYE_YTDLP', version: v.version }, notes }
|
|
149
|
+
notes.push(`FISHEYE_YTDLP=${fromEnv} 跑 --version 不像 yt-dlp,已忽略`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2) PATH
|
|
154
|
+
for (const name of YTDLP_NAMES) {
|
|
155
|
+
for (const cmd of whereAll(name).filter(spawnable)) {
|
|
156
|
+
const v = versionOf(cmd)
|
|
157
|
+
if (v.ok) return { ytdlp: { cmd, source: `PATH(${name})`, version: v.version }, notes }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3) 常见安装路径(用户手动解压 yt-dlp.exe 时它通常不在 PATH 里)
|
|
162
|
+
for (const cmd of commonYtdlpPaths(env)) {
|
|
163
|
+
if (!fs.existsSync(cmd) || !spawnable(cmd)) continue
|
|
164
|
+
const v = versionOf(cmd)
|
|
165
|
+
if (v.ok) return { ytdlp: { cmd, source: '常见安装路径', version: v.version }, notes }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { ytdlp: null, notes }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** ffmpeg 解析:显式参数 → FFMPEG 环境变量 → env.mjs → 本机常见目录 → PATH */
|
|
172
|
+
function resolveFfmpeg({ env = process.env, explicit } = {}) {
|
|
173
|
+
const home = os.homedir()
|
|
174
|
+
const cands = [
|
|
175
|
+
explicit,
|
|
176
|
+
env.FFMPEG,
|
|
177
|
+
FFMPEG,
|
|
178
|
+
path.join(home, '.dsh', 'surfcore', 'bili', 'bin', EXE('ffmpeg')),
|
|
179
|
+
path.join(home, '.dsh', 'surfcore', 'bin', EXE('ffmpeg')),
|
|
180
|
+
path.join(PKG_DIR, 'bin', EXE('ffmpeg')),
|
|
181
|
+
].filter(Boolean).map(stripQuotes)
|
|
182
|
+
|
|
183
|
+
for (const c of cands) {
|
|
184
|
+
try {
|
|
185
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
|
186
|
+
} catch { /* 下一个 */ }
|
|
187
|
+
}
|
|
188
|
+
return whereAll('ffmpeg').filter(spawnable)[0] ?? null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 探测可用的 yt-dlp。
|
|
193
|
+
* 顺序:FISHEYE_YTDLP → PATH 上的 yt-dlp → 常见路径(~/.dsh/surfcore/yt-dlp、%LOCALAPPDATA%\Programs\yt-dlp、项目 bin/)。
|
|
194
|
+
* @param {{ env?: NodeJS.ProcessEnv, ffmpeg?: string }} [opts]
|
|
195
|
+
* @returns {{ cmd: string, ffmpeg: string|null, version: string|null, source: string } | null}
|
|
196
|
+
*/
|
|
197
|
+
export function findYtdlp({ env, ffmpeg } = {}) {
|
|
198
|
+
try {
|
|
199
|
+
const e = env ?? process.env
|
|
200
|
+
const { ytdlp } = findYtdlpDetailed({ env: e })
|
|
201
|
+
if (!ytdlp) return null
|
|
202
|
+
return {
|
|
203
|
+
cmd: ytdlp.cmd,
|
|
204
|
+
ffmpeg: resolveFfmpeg({ env: e, explicit: ffmpeg }),
|
|
205
|
+
version: ytdlp.version,
|
|
206
|
+
source: ytdlp.source,
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 能力描述(给 CLI / selftest 打印用)。
|
|
215
|
+
* available 为 true 表示「现在能把一个平台 URL 下成视频文件」。
|
|
216
|
+
* 没有 ffmpeg 仍然可用,只是退化为单文件格式(不少平台没有单文件高清流,画质会低一些)。
|
|
217
|
+
* @param {{ env?: NodeJS.ProcessEnv, ffmpeg?: string, ytdlp?: object }} [options]
|
|
218
|
+
* @returns {{ available: boolean, reason: string, summary: string, hint: string|null,
|
|
219
|
+
* cmd: string|null, ffmpeg: string|null, version: string|null, notes: string[] }}
|
|
220
|
+
*/
|
|
221
|
+
export function ytdlpCaps(options = {}) {
|
|
222
|
+
const env = options.env ?? process.env
|
|
223
|
+
try {
|
|
224
|
+
const detail = options.ytdlp
|
|
225
|
+
? { ytdlp: options.ytdlp, notes: [] }
|
|
226
|
+
: findYtdlpDetailed({ env })
|
|
227
|
+
const y = detail.ytdlp
|
|
228
|
+
const ffmpeg = resolveFfmpeg({ env, explicit: options.ffmpeg })
|
|
229
|
+
const caps = {
|
|
230
|
+
available: false,
|
|
231
|
+
reason: '',
|
|
232
|
+
summary: '',
|
|
233
|
+
hint: null,
|
|
234
|
+
cmd: y?.cmd ?? null,
|
|
235
|
+
version: y?.version ?? null,
|
|
236
|
+
ffmpeg: ffmpeg ?? null,
|
|
237
|
+
notes: detail.notes ?? [],
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!y) {
|
|
241
|
+
caps.reason = '未找到 yt-dlp(已试 FISHEYE_YTDLP / PATH / ~/.dsh/surfcore/yt-dlp / %LOCALAPPDATA%\\Programs\\yt-dlp / 项目 bin/)'
|
|
242
|
+
caps.hint = '从 https://github.com/yt-dlp/yt-dlp/releases/latest 下载 yt-dlp.exe 放到 ~/.dsh/surfcore/yt-dlp/,'
|
|
243
|
+
+ '或设 FISHEYE_YTDLP=<yt-dlp 完整路径>'
|
|
244
|
+
} else {
|
|
245
|
+
caps.available = true
|
|
246
|
+
const bits = [`yt-dlp ${y.version ?? ''}(${path.basename(y.cmd)},${y.source})`.replace(' ', ' ')]
|
|
247
|
+
if (ffmpeg) bits.push(`ffmpeg ${path.basename(ffmpeg)}(合并音视频)`)
|
|
248
|
+
else bits.push('无 ffmpeg(降级单文件格式,画质受限)')
|
|
249
|
+
caps.reason = bits.join(';')
|
|
250
|
+
if (!ffmpeg) caps.hint = '设 FFMPEG=<ffmpeg 完整路径> 或把 ffmpeg 装到 PATH,才能合并 1080p+ 的分离音视频流'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
caps.summary = `通用平台视频(yt-dlp):${caps.available ? '可用' : '不可用'} —— ${caps.reason}`
|
|
254
|
+
return caps
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return {
|
|
257
|
+
available: false,
|
|
258
|
+
reason: `能力探测异常:${String(e?.message ?? e).slice(0, 120)}`,
|
|
259
|
+
summary: '通用平台视频(yt-dlp):不可用 —— 能力探测异常',
|
|
260
|
+
hint: null,
|
|
261
|
+
cmd: null,
|
|
262
|
+
version: null,
|
|
263
|
+
ffmpeg: null,
|
|
264
|
+
notes: [],
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 校验 URL:只接受 http/https,其余(file:、javascript:、ftp:、裸字符串)一律拒绝。
|
|
271
|
+
* @returns {string|null} 归一化后的 URL
|
|
272
|
+
*/
|
|
273
|
+
function normalizeUrl(input) {
|
|
274
|
+
if (typeof input !== 'string') return null
|
|
275
|
+
const raw = input.trim()
|
|
276
|
+
if (!raw || raw.length > 8192) return null
|
|
277
|
+
let u
|
|
278
|
+
try { u = new URL(raw) } catch { return null }
|
|
279
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
|
|
280
|
+
if (!u.hostname) return null
|
|
281
|
+
return u.toString()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 目录里 sinceMs 之后生成的最新媒体文件(排除 .part / .ytdl 半成品)
|
|
285
|
+
function newestMedia(dir, sinceMs) {
|
|
286
|
+
let names = []
|
|
287
|
+
try { names = fs.readdirSync(dir) } catch { return null }
|
|
288
|
+
let best = null
|
|
289
|
+
for (const n of names) {
|
|
290
|
+
if (n.startsWith('.')) continue
|
|
291
|
+
const ext = path.extname(n).toLowerCase()
|
|
292
|
+
if (!MEDIA_EXT.has(ext)) continue
|
|
293
|
+
if (/\.(part|ytdl|temp)$/i.test(n)) continue
|
|
294
|
+
const full = path.join(dir, n)
|
|
295
|
+
try {
|
|
296
|
+
const st = fs.statSync(full)
|
|
297
|
+
if (!st.isFile() || st.size === 0) continue
|
|
298
|
+
if (st.mtimeMs < sinceMs) continue
|
|
299
|
+
if (!best || st.mtimeMs > best.mtimeMs) best = { file: full, mtimeMs: st.mtimeMs }
|
|
300
|
+
} catch { /* 跳过读不到的文件 */ }
|
|
301
|
+
}
|
|
302
|
+
return best?.file ?? null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 用 yt-dlp 把一个平台 URL 下载成视频文件(放进 outDir)。
|
|
307
|
+
*
|
|
308
|
+
* @param {string} url http(s) 视频页链接
|
|
309
|
+
* @param {string} outDir 产物目录(不存在会自动创建)
|
|
310
|
+
* @param {{ timeoutMs?: number, quiet?: boolean, quality?: 'best'|'2160'|'1440'|'1080'|'720'|'480',
|
|
311
|
+
* ytdlp?: object, ffmpeg?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
312
|
+
* @returns {{ file: string, cmd: string } | null} 失败 / 超时 / 无 yt-dlp / URL 非法 → null(不抛)
|
|
313
|
+
*/
|
|
314
|
+
export function downloadVideo(url, outDir, options = {}) {
|
|
315
|
+
const env = options.env ?? process.env
|
|
316
|
+
const quiet = options.quiet === true
|
|
317
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 600000
|
|
318
|
+
const warn = (msg) => { if (!quiet) console.warn(`[yt-dlp] ${msg}`) }
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const target = normalizeUrl(url)
|
|
322
|
+
if (!target) {
|
|
323
|
+
warn(`跳过下载:不是合法的 http(s) 链接(${String(url ?? '').slice(0, 120) || '(空)'})`)
|
|
324
|
+
return null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!outDir || typeof outDir !== 'string') {
|
|
328
|
+
warn('没有指定输出目录,跳过下载')
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const found = options.ytdlp ?? findYtdlp({ env, ffmpeg: options.ffmpeg })
|
|
333
|
+
if (!found?.cmd) {
|
|
334
|
+
warn('没有 yt-dlp,跳过下载(放一个 yt-dlp.exe 到 ~/.dsh/surfcore/yt-dlp/ 或设 FISHEYE_YTDLP)')
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const dir = path.resolve(outDir)
|
|
339
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
340
|
+
|
|
341
|
+
const quality = QUALITY[String(options.quality ?? 'best')] ? String(options.quality ?? 'best') : 'best'
|
|
342
|
+
const started = Date.now() - 2000 // 给文件系统 mtime 精度留余量
|
|
343
|
+
|
|
344
|
+
// --no-playlist:只下一个视频,别把整个播放列表拖下来
|
|
345
|
+
const args = ['--no-playlist']
|
|
346
|
+
if (found.ffmpeg) {
|
|
347
|
+
args.push('-f', QUALITY[quality], '--merge-output-format', 'mp4')
|
|
348
|
+
// ffmpeg 不在 PATH 时(本机就在 surfcore\bili\bin),必须显式告诉 yt-dlp 去哪找
|
|
349
|
+
args.push('--ffmpeg-location', path.dirname(found.ffmpeg))
|
|
350
|
+
} else {
|
|
351
|
+
// 没有 ffmpeg 就无法合并分离的音视频流,退回「最佳单文件」
|
|
352
|
+
args.push('-f', 'b')
|
|
353
|
+
}
|
|
354
|
+
args.push('-o', path.join(dir, '%(title).80s [%(id)s].%(ext)s'))
|
|
355
|
+
args.push(target)
|
|
356
|
+
|
|
357
|
+
warn(`下载中(quality=${quality},最多等 ${Math.round(timeoutMs / 1000)}s):${target}`)
|
|
358
|
+
const r = spawnSync(found.cmd, args, {
|
|
359
|
+
stdio: 'ignore',
|
|
360
|
+
timeout: timeoutMs,
|
|
361
|
+
windowsHide: true,
|
|
362
|
+
env,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const timedOut = Boolean(r.error)
|
|
366
|
+
&& (r.error.code === 'ETIMEDOUT' || r.error.errno === 'ETIMEDOUT' || r.signal === 'SIGTERM')
|
|
367
|
+
if (timedOut) {
|
|
368
|
+
warn(`下载超时(${Math.round(timeoutMs / 1000)}s),放弃:${target}`)
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
if (r.error) {
|
|
372
|
+
warn(`启动 yt-dlp 失败:${String(r.error.message ?? r.error).slice(0, 160)}`)
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 成功时优先取本次新生成的文件;失败时隔一段时间才出现的半成品也不认
|
|
377
|
+
let file = newestMedia(dir, started)
|
|
378
|
+
if (!file && r.status === 0) {
|
|
379
|
+
// yt-dlp 遇到同名已存在文件会直接跳过,此时 mtime 是旧的 —— 退回目录里最新的媒体文件
|
|
380
|
+
file = newestMedia(dir, 0)
|
|
381
|
+
if (file) warn(`没有发现本次新生成的文件,使用 outDir 里最新的:${path.basename(file)}`)
|
|
382
|
+
}
|
|
383
|
+
if (!file) {
|
|
384
|
+
warn(`yt-dlp 退出码 ${r.status},outDir 里没有可用的视频文件:${dir}`)
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { file, cmd: found.cmd }
|
|
389
|
+
} catch (e) {
|
|
390
|
+
warn(`下载异常:${String(e?.message ?? e).slice(0, 160)}`)
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// fisheye 的 MCP(Model Context Protocol)server —— 可选组件,不是 fisheye 内核依赖。
|
|
2
|
+
//
|
|
3
|
+
// 作用:把 fisheye 的能力包装成 MCP 工具(stdio),让 AI agent(DSH / Claude Code /
|
|
4
|
+
// Cursor 等支持 MCP 的环境)不必自己拼命令行,就能在 token 预算内「看」一条 B站 视频。
|
|
5
|
+
//
|
|
6
|
+
// 工具:
|
|
7
|
+
// vwatch_scan —— 快速扫一眼:单段九宫格拼图,直接返回画面
|
|
8
|
+
// vwatch_deep —— 深入看懂:自动规划抽帧 + 官方字幕 → 交给独立 headless 会话读完 →
|
|
9
|
+
// 只返回「它自己看完能写出的」完整复述(不占当前对话上下文)
|
|
10
|
+
//
|
|
11
|
+
// 工具名沿用 vwatch_*(fisheye 的旧名),以免破坏已有的 MCP 配置;想看 fisheye 本体
|
|
12
|
+
// 请直接用 `node fisheye.mjs <命令>`。
|
|
13
|
+
//
|
|
14
|
+
// ── 可选依赖:fisheye 核心保持零依赖,本文件的两个包请自行安装 ────────────────
|
|
15
|
+
//
|
|
16
|
+
// npm i @modelcontextprotocol/sdk zod
|
|
17
|
+
//
|
|
18
|
+
// 没装这两个包时不要启动本 server —— node 会在 import 阶段直接报 MODULE_NOT_FOUND,
|
|
19
|
+
// 这是预期行为(MCP 是可选组件,CLI 本身完全不需要它们)。
|
|
20
|
+
//
|
|
21
|
+
// 除 npm 包外还需要:
|
|
22
|
+
// · node 18+(与 fisheye 一致)
|
|
23
|
+
// · 同目录的 fisheye.mjs 与 lib/(本仓库)
|
|
24
|
+
// · ffmpeg(FFMPEG 环境变量、~/.config/fisheye/config.json,或 PATH)
|
|
25
|
+
// · B站 登录态(BILI_CRED 或 credentials.json):两个工具都走 B站 接口
|
|
26
|
+
// · vwatch_deep 的「独立会话复述」需要本机装有 DeepSeek Harness 桌面版;
|
|
27
|
+
// 没有时会自动降级,直接返回抽帧规划 + 官方字幕原文
|
|
28
|
+
//
|
|
29
|
+
// 安全:只认 BV 号(正则提取),execFile 参数数组,不拼命令行、不接受任意路径。
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { execFile } from 'node:child_process';
|
|
35
|
+
import { promisify } from 'node:util';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
38
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
|
|
41
|
+
const execFileP = promisify(execFile);
|
|
42
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const FISHEYE = path.join(HERE, 'fisheye.mjs');
|
|
44
|
+
// 与 lib/env.mjs 保持一致:FISHEYE_HOME 可把数据目录挪到别处
|
|
45
|
+
const WS = process.env.FISHEYE_HOME || HERE;
|
|
46
|
+
const OUT = path.join(WS, 'out');
|
|
47
|
+
|
|
48
|
+
/** 只认 BV 号(或含 BV 号的链接),防止把任意字符串当参数递进子进程。 */
|
|
49
|
+
function normalizeBvid(input) {
|
|
50
|
+
const m = /(BV[0-9A-Za-z]{10})/.exec(String(input ?? '').trim());
|
|
51
|
+
return m ? m[1] : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeTime(v, label) {
|
|
55
|
+
const s = String(v ?? '').trim();
|
|
56
|
+
if (!s) return '';
|
|
57
|
+
if (!/^\d+(:\d{1,2}){0,2}$/.test(s)) throw new Error(`${label} 时间格式无效:${s}(支持 330 / 5:30 / 1:05:30)`);
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runFisheye(args, timeoutMs = 600_000) {
|
|
62
|
+
const { stdout, stderr } = await execFileP(process.execPath, [FISHEYE, ...args], {
|
|
63
|
+
cwd: HERE, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true, env: { ...process.env }
|
|
64
|
+
});
|
|
65
|
+
return `${stdout ?? ''}\n${stderr ?? ''}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readManifest(id) {
|
|
69
|
+
try {
|
|
70
|
+
const mf = JSON.parse(fs.readFileSync(path.join(OUT, `${id}-manifest.json`), 'utf8'));
|
|
71
|
+
return Array.isArray(mf?.chunks) ? mf : null;
|
|
72
|
+
} catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readSubs(id) {
|
|
76
|
+
const candidates = [path.join(OUT, `${id}-official-subs.txt`), path.join(OUT, `${id}-official-subs-plain.txt`)];
|
|
77
|
+
for (const f of candidates) {
|
|
78
|
+
try { const t = fs.readFileSync(f, 'utf8').trim(); if (t) return t; } catch {}
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 独立 headless 会话(自清洁:临时工作区,跑完即删,会话文件不残留)────────────
|
|
84
|
+
function findDesktopExe() {
|
|
85
|
+
const candidates = [
|
|
86
|
+
process.env.DSH_DESKTOP_EXE,
|
|
87
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs', 'DeepSeek Harness', 'DeepSeek Harness.exe') : '',
|
|
88
|
+
path.join('C:\\Program Files', 'DeepSeek Harness', 'DeepSeek Harness.exe'),
|
|
89
|
+
path.join('C:\\Program Files (x86)', 'DeepSeek Harness', 'DeepSeek Harness.exe')
|
|
90
|
+
].filter(Boolean);
|
|
91
|
+
return candidates.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) || '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dshHome() { return process.env.DSH_HOME || path.join(os.homedir(), '.dsh'); }
|
|
95
|
+
|
|
96
|
+
// ── 视频独立会话工作记录(state/video-jobs.jsonl,每行一条)────────────────────
|
|
97
|
+
const VIDEO_JOB_FILE = path.join(HERE, 'state', 'video-jobs.jsonl');
|
|
98
|
+
|
|
99
|
+
function videoJobCount() {
|
|
100
|
+
try {
|
|
101
|
+
const text = fs.readFileSync(VIDEO_JOB_FILE, 'utf8').trim();
|
|
102
|
+
return text ? text.split('\n').filter(Boolean).length : 0;
|
|
103
|
+
} catch { return 0; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function appendVideoJob(job) {
|
|
107
|
+
try {
|
|
108
|
+
fs.mkdirSync(path.dirname(VIDEO_JOB_FILE), { recursive: true });
|
|
109
|
+
fs.appendFileSync(VIDEO_JOB_FILE, JSON.stringify({ seq: videoJobCount() + 1, at: new Date().toISOString(), ...job }) + '\n', 'utf8');
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanupVideoSessions() {
|
|
114
|
+
const root = path.join(dshHome(), 'sessions');
|
|
115
|
+
let removed = 0;
|
|
116
|
+
try {
|
|
117
|
+
for (const ws of fs.readdirSync(root, { withFileTypes: true })) {
|
|
118
|
+
if (!ws.isDirectory() || !ws.name.includes('dsh-video-')) continue;
|
|
119
|
+
try { fs.rmSync(path.join(root, ws.name), { recursive: true, force: true }); removed += 1; } catch {}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
return removed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runHeadless(prompt, timeoutMs = 600_000) {
|
|
126
|
+
const exe = findDesktopExe();
|
|
127
|
+
if (!exe) throw new Error('找不到 DeepSeek Harness.exe(独立会话模式需要桌面版;可用环境变量 DSH_DESKTOP_EXE 指定)');
|
|
128
|
+
const bin = path.join(path.dirname(exe), 'resources', 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
129
|
+
const tmpDir = path.join(os.tmpdir(), `dsh-video-${randomUUID()}`);
|
|
130
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
131
|
+
const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1', DSH_HOME: dshHome() };
|
|
132
|
+
try {
|
|
133
|
+
const { stdout, stderr } = await execFileP(
|
|
134
|
+
exe,
|
|
135
|
+
['--expose-internals', bin, '--profile', 'headless', prompt],
|
|
136
|
+
{ cwd: tmpDir, env, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }
|
|
137
|
+
);
|
|
138
|
+
const answer = String(stdout ?? '').trim();
|
|
139
|
+
if (!answer) throw new Error(`headless 没有输出(stderr 末段:${String(stderr ?? '').slice(-500)})`);
|
|
140
|
+
return answer;
|
|
141
|
+
} finally {
|
|
142
|
+
cleanupVideoSessions();
|
|
143
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildDeepPrompt(id, fisheyeText, subs, sheets) {
|
|
148
|
+
const sheetList = sheets.length ? sheets.map((s, i) => `${i + 1}. ${s}`).join('\n') : '(本次没有抽帧拼图,只有字幕文本)';
|
|
149
|
+
return [
|
|
150
|
+
'你是独立视频处理会话。请深入看懂下面这条 B站 视频,然后写出完整的视频内容复述,要求达到「你自己逐帧看过画面、也读过字幕」之后的转述水平。',
|
|
151
|
+
'',
|
|
152
|
+
'══ 素材 ══',
|
|
153
|
+
'【fisheye 规划与信息】',
|
|
154
|
+
fisheyeText || '(空)',
|
|
155
|
+
'',
|
|
156
|
+
'【官方字幕全文】',
|
|
157
|
+
subs || '(无官方字幕)',
|
|
158
|
+
'',
|
|
159
|
+
`【抽帧拼图文件(按时间顺序,共 ${sheets.length} 张)】`,
|
|
160
|
+
sheetList,
|
|
161
|
+
'',
|
|
162
|
+
'══ 任务 ══',
|
|
163
|
+
'1. 用 read_image 依次读取上面每一张拼图文件(拼图内部按从左到右、从上到下的时间顺序)。',
|
|
164
|
+
'2. 结合字幕与画面,写出一份完整复述:视频主线;关键桥段按时间顺序,字幕和画面互相印证,画面里有但字幕没说的补上,字幕与画面不一致处指出;出现过的名字、数字、梗、结尾画面。',
|
|
165
|
+
'3. 输出要求:完整、准确、第一人称口吻(「我看下来……」),像自己真的看完后在转述;不要提「拼图/素材/任务/read_image」这些词;只有画面确实糊到无法辨认时才说看不清。',
|
|
166
|
+
'直接输出复述正文,不要标题,不要分点编号。'
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const server = new McpServer({ name: 'fisheye', version: '0.3.0' });
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
'vwatch_scan',
|
|
174
|
+
'快速扫一眼一条 B站 视频:对开头一段抽帧拼成九宫格截图,直接返回画面(不含文字总结)。适合"这是什么视频"这种扫一眼需求;要完整内容请用 vwatch_deep。只读公开视频;约 20~60 秒。',
|
|
175
|
+
{
|
|
176
|
+
bvid: z.string().describe('B站 视频的 BV 号(如 BV1iLYm6VET3)或含 BV 号的视频链接'),
|
|
177
|
+
grid: z.string().optional().describe('抽帧格数:3x3(9帧,默认)、4x4(16帧)、5x5(25帧)')
|
|
178
|
+
},
|
|
179
|
+
async ({ bvid, grid }) => {
|
|
180
|
+
const id = normalizeBvid(bvid);
|
|
181
|
+
if (!id) return { content: [{ type: 'text', text: '没有识别出 BV 号,请传 BV 号或含 BV 号的视频链接。' }] };
|
|
182
|
+
const g = ['3x3', '4x4', '5x5'].includes(String(grid ?? '').trim()) ? String(grid).trim() : '3x3';
|
|
183
|
+
try {
|
|
184
|
+
const out = await runFisheye(['grid', id, '--chunk', '60', '--grid', g]);
|
|
185
|
+
const mf = readManifest(id);
|
|
186
|
+
const sheet = (mf?.chunks ?? []).map((c) => c.sheet).find((s) => s && fs.existsSync(s));
|
|
187
|
+
if (!sheet) {
|
|
188
|
+
return { content: [{ type: 'text', text: `没有生成拼图(BV=${id})。输出末段:${out.slice(-600)}` }] };
|
|
189
|
+
}
|
|
190
|
+
const bytes = fs.readFileSync(sheet);
|
|
191
|
+
if (bytes.length > 4 * 1024 * 1024) return { content: [{ type: 'text', text: '截图过大,已跳过。' }] };
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{ type: 'text', text: `视频开头一段的 ${g} 抽帧画面(BV=${id},仅第一段;看全片内容请用 vwatch_deep)。` },
|
|
195
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: 'image/jpeg' }
|
|
196
|
+
]
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const message = String(error?.message ?? error);
|
|
200
|
+
const hint = /timeout|timed out/i.test(message) ? '(超时:视频大或网络慢,稍后再试)' : '';
|
|
201
|
+
return { content: [{ type: 'text', text: `扫一眼失败(BV=${id}):${message}${hint}` }] };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
server.tool(
|
|
207
|
+
'vwatch_deep',
|
|
208
|
+
'深入看懂一条 B站 视频:自动按 token 预算决定采样密度、优先读取官方字幕(省 token 且比 OCR 准)、长视频分成多张拼图;然后全部交给独立的视频处理会话读完,只返回「它自己读完能写出的」完整复述(主线+桥段+字幕画面互证+细节),不占当前对话上下文。参数 bvid 传 BV 号或链接;from/to 可只看一段;page 选分P;budget 控制采样密度(默认 20000)。整个流程 1~4 分钟;同一个视频不要重复看。',
|
|
209
|
+
{
|
|
210
|
+
bvid: z.string().describe('B站 视频的 BV 号(如 BV1iLYm6VET3)或含 BV 号的视频链接'),
|
|
211
|
+
from: z.string().optional().describe('只看这一段:起始时间,如 330 / 5:30 / 1:05:30'),
|
|
212
|
+
to: z.string().optional().describe('只看这一段:结束时间,写法同 from'),
|
|
213
|
+
page: z.string().optional().describe('分P 序号(1~200),默认 1'),
|
|
214
|
+
budget: z.string().optional().describe('token 预算(2000~200000),默认 20000;越小采样越稀')
|
|
215
|
+
},
|
|
216
|
+
async ({ bvid, from, to, page, budget }) => {
|
|
217
|
+
const id = normalizeBvid(bvid);
|
|
218
|
+
if (!id) return { content: [{ type: 'text', text: '没有识别出 BV 号,请传 BV 号或含 BV 号的视频链接。' }] };
|
|
219
|
+
let args;
|
|
220
|
+
try {
|
|
221
|
+
args = ['auto', id];
|
|
222
|
+
const b = Number(budget ?? 20000);
|
|
223
|
+
if (Number.isFinite(b)) args.push('--budget', String(Math.min(200000, Math.max(2000, Math.round(b)))));
|
|
224
|
+
const f = safeTime(from, 'from');
|
|
225
|
+
const t = safeTime(to, 'to');
|
|
226
|
+
if (f) args.push('--from', f);
|
|
227
|
+
if (t) args.push('--to', t);
|
|
228
|
+
const p = Number(page ?? 1);
|
|
229
|
+
if (Number.isInteger(p) && p >= 1 && p <= 200) args.push('--p', String(p));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { content: [{ type: 'text', text: String(error?.message ?? error) }] };
|
|
232
|
+
}
|
|
233
|
+
const t0 = Date.now();
|
|
234
|
+
try {
|
|
235
|
+
const fisheyeText = (await runFisheye(args)).trim();
|
|
236
|
+
const subs = readSubs(id);
|
|
237
|
+
const mf = readManifest(id);
|
|
238
|
+
const sheets = (mf?.chunks ?? []).map((c) => c.sheet).filter((s) => s && fs.existsSync(s));
|
|
239
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
240
|
+
|
|
241
|
+
let summary = null;
|
|
242
|
+
try {
|
|
243
|
+
summary = await runHeadless(buildDeepPrompt(id, fisheyeText, subs, sheets));
|
|
244
|
+
} catch {}
|
|
245
|
+
|
|
246
|
+
const header = `视频:${id}(抽帧+字幕耗时 ${secs}s${summary ? ',独立会话已读完并复述' : ';独立会话不可用,以下为原始素材'})\n`;
|
|
247
|
+
if (summary) {
|
|
248
|
+
appendVideoJob({ tool: 'vwatch_deep', bvid: id, args: { from: String(from ?? ''), to: String(to ?? ''), page: String(page ?? 1), budget: String(budget ?? 20000) }, chars: summary.length, output: summary });
|
|
249
|
+
return { content: [{ type: 'text', text: `${header}—— 独立会话的完整复述 ——\n${summary}` }] };
|
|
250
|
+
}
|
|
251
|
+
const fallback = [`${header}`, '', fisheyeText, '', subs ? `══ 官方字幕 ══\n${subs}` : ''].filter(Boolean).join('\n');
|
|
252
|
+
return { content: [{ type: 'text', text: fallback }] };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const message = String(error?.message ?? error);
|
|
255
|
+
const hint = /timeout|timed out/i.test(message) ? '(超时:视频太大/网络慢/预算算出的张数太多,可调小 budget 或加 from/to 缩小范围再试)' : '';
|
|
256
|
+
return { content: [{ type: 'text', text: `深度看视频失败(BV=${id}):${message}${hint}` }] };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pls-compile-owo/fisheye",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Token-budget-aware video understanding for AI agents: official subtitles -> burned-in subtitle band -> danmaku highlights -> adaptive keyframe grids -> targeted refine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"README.en.md",
|
|
19
19
|
"CHANGELOG.md",
|
|
20
20
|
"LICENSE",
|
|
21
|
-
"docs/"
|
|
21
|
+
"docs/",
|
|
22
|
+
"mcp-video-server.js"
|
|
22
23
|
],
|
|
23
24
|
"engines": {
|
|
24
25
|
"node": ">=18"
|