@orderg-x/dsh-video-preview 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +138 -2
- package/assets/demo.gif +0 -0
- package/assets/hero.png +0 -0
- package/assets/hero.svg +70 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/auto-present.js +277 -0
- package/lib/client.js +696 -0
- package/lib/index.js +459 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-video-preview —— 让 DSH 的对话流里能直接播视频/音频。
|
|
3
|
+
*
|
|
4
|
+
* DSH 的渲染块只有 text / image / file / table,没有 video,工具块又默认折叠。
|
|
5
|
+
* 这个插件用四层组合把播放器送到用户眼前(全部走官方插件 API,零 @deepseek-ai/* 依赖):
|
|
6
|
+
*
|
|
7
|
+
* 服务端 play_video 工具:校验文件 → 返回带签名的 URL →
|
|
8
|
+
* 照官方 present 工具的机制 append deliverables/presented 事件
|
|
9
|
+
* 路由 /dsh-video-preview/media 签名 token 鉴权,HTTP Range 流式(能拖进度条)
|
|
10
|
+
* /dsh-video-preview/preview 宿主登录态鉴权(DSH 自己的 connection 服务验 cookie),供侧边栏/turnTail 用
|
|
11
|
+
* 自动 这一轮新生成的媒体(录屏、转码、TTS…),模型不调工具也自动呈现(见 auto-present.js)
|
|
12
|
+
* 客户端 turnTail 槽 → 播放器渲染在最终输出正下方(Claude/Codex 同款位置)
|
|
13
|
+
* documentPreviews 渲染器 → 侧边栏预览面板变播放器
|
|
14
|
+
* toolview 槽 → 展开工具块后的小播放器(兜底)
|
|
15
|
+
*
|
|
16
|
+
* 签名密钥持久化在 ~/.dsh/cache,同一台机器上 URL 跨重启、跨宿主(web/桌面)都有效。
|
|
17
|
+
*
|
|
18
|
+
* 把门(webServer 本身不鉴权,浏览器里任何网页都摸得到 127.0.0.1):
|
|
19
|
+
* - 两条路由都只给媒体文件(按扩展名,软链接按真实文件算),别的文件一律不给
|
|
20
|
+
* - Host 必须是本机名或 DSH 自己的地址(防 DNS 重绑定);跨站请求、Origin 不是自己的都拒
|
|
21
|
+
* - preview 没有 token,靠 DSH 的登录 cookie;拿不到 connection 服务时只收本机连接
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import crypto from 'node:crypto'
|
|
28
|
+
import { pipeline } from 'node:stream'
|
|
29
|
+
import { autoPresent, sessionCwd, fromBashPath } from './auto-present.js'
|
|
30
|
+
|
|
31
|
+
export const name = '@orderg-x/dsh-video-preview'
|
|
32
|
+
export const inject = ['tools', 'webServer', 'sessionProjections']
|
|
33
|
+
|
|
34
|
+
/** 包名:0.4.0 起叫 @orderg-x/dsh-video-preview(npm 上 dsh-video-preview 是别人的包)。之前从 GitHub 链接装的旧版叫 dsh-video-preview */
|
|
35
|
+
export const PKG = '@orderg-x/dsh-video-preview'
|
|
36
|
+
export const OLD_PKG = 'dsh-video-preview'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 旧版(dsh-video-preview)是不是也装着、启用着。两份一起跑会抢同一批路由、同一个 play_video 工具,
|
|
40
|
+
* 自动呈现也会出两遍,所以新版这时整个让开;客户端看路由回应头里的包名,也跟着让开。
|
|
41
|
+
* 两个包都由 bundle patch 插在同一层,看自己条目所在分组的配置列表(entry.parent.data)就知道。
|
|
42
|
+
*/
|
|
43
|
+
export function oldCopyLoaded(ctx) {
|
|
44
|
+
try {
|
|
45
|
+
const siblings = ctx && ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.parent && ctx.fiber.entry.parent.data
|
|
46
|
+
if (!Array.isArray(siblings)) return false
|
|
47
|
+
return siblings.some((o) => o && o.name === OLD_PKG && !o.disabled)
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROUTE = '/dsh-video-preview/media'
|
|
54
|
+
const PREVIEW_ROUTE = '/dsh-video-preview/preview'
|
|
55
|
+
|
|
56
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
57
|
+
const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 签名密钥:持久化在 ~/.dsh/cache 下(0600),同一台机器上跨重启、跨宿主(web / 桌面)一致。
|
|
61
|
+
* 为什么不用每次启动的随机 token:URL 会写进会话历史,宿主一重启(或在桌面端打开 web 端
|
|
62
|
+
* 创建的会话)token 就对不上,媒体请求全部 403。
|
|
63
|
+
*/
|
|
64
|
+
function loadSecret() {
|
|
65
|
+
const file = path.join(os.homedir(), '.dsh', 'cache', 'dsh-video-preview.secret')
|
|
66
|
+
try {
|
|
67
|
+
const s = fs.readFileSync(file, 'utf8').trim()
|
|
68
|
+
if (s) return s
|
|
69
|
+
} catch { /* 首次使用,落到下面生成 */ }
|
|
70
|
+
const s = crypto.randomBytes(32).toString('hex')
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
73
|
+
fs.writeFileSync(file, s, { mode: 0o600 })
|
|
74
|
+
} catch { /* 写不进盘就退化为进程内密钥:本进程内 URL 仍有效 */ }
|
|
75
|
+
return s
|
|
76
|
+
}
|
|
77
|
+
const SECRET = loadSecret()
|
|
78
|
+
|
|
79
|
+
/** token = HMAC(secret, 文件绝对路径)。同一台机器上同一文件的 URL 永远相同。 */
|
|
80
|
+
function tokenFor(file) {
|
|
81
|
+
return crypto.createHmac('sha256', SECRET).update(file).digest('hex').slice(0, 32)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tokenOk(file, t) {
|
|
85
|
+
const want = Buffer.from(tokenFor(file))
|
|
86
|
+
const got = Buffer.from(String(t || ''))
|
|
87
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// DSH 的播放器是 Chromium 内核,这些能直接播(mkv / 3gp 要看里面的编码,播不了前端会说清楚)
|
|
91
|
+
const VIDEO_EXT = new Set(['.mp4', '.m4v', '.mov', '.webm', '.ogv', '.mkv', '.3gp'])
|
|
92
|
+
const AUDIO_EXT = new Set(['.mp3', '.wav', '.ogg', '.oga', '.m4a', '.aac', '.flac', '.opus', '.weba'])
|
|
93
|
+
// 这些 Chromium 根本不认:与其给用户一个黑框,不如直接告诉模型先转码
|
|
94
|
+
const UNPLAYABLE_EXT = new Set(['.avi', '.flv', '.wmv', '.asf', '.wma', '.ts', '.mts', '.m2ts',
|
|
95
|
+
'.mpg', '.mpeg', '.vob', '.rm', '.rmvb', '.amr', '.ape'])
|
|
96
|
+
const UNPLAYABLE_AUDIO = new Set(['.wma', '.amr', '.ape'])
|
|
97
|
+
|
|
98
|
+
const MIME = {
|
|
99
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
100
|
+
'.ogv': 'video/ogg', '.mkv': 'video/x-matroska', '.3gp': 'video/3gpp',
|
|
101
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.oga': 'audio/ogg',
|
|
102
|
+
'.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac', '.opus': 'audio/ogg',
|
|
103
|
+
'.weba': 'audio/webm',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function kindOf(file) {
|
|
107
|
+
const ext = path.extname(file).toLowerCase()
|
|
108
|
+
if (VIDEO_EXT.has(ext)) return 'video'
|
|
109
|
+
if (AUDIO_EXT.has(ext)) return 'audio'
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** shell 单引号:文件名里的 $(...)、反引号、双引号都原样当字面,单引号写成 '\'' */
|
|
114
|
+
/** 各系统装 ffmpeg 的一句话 */
|
|
115
|
+
const FFMPEG_INSTALL = { darwin: 'brew install ffmpeg', win32: 'winget install ffmpeg', linux: 'sudo apt install ffmpeg(或用系统自带的包管理器)' }
|
|
116
|
+
|
|
117
|
+
function shq(s) {
|
|
118
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 转码建议:模型能照着跑,用户也看得懂(播不了的格式都不是 .mp4 / .m4a,输出名不会和输入撞) */
|
|
122
|
+
function convertHint(file) {
|
|
123
|
+
const ext = path.extname(file).toLowerCase()
|
|
124
|
+
const audio = UNPLAYABLE_AUDIO.has(ext)
|
|
125
|
+
const out = file.slice(0, file.length - ext.length) + (audio ? '.m4a' : '.mp4')
|
|
126
|
+
const cmd = audio
|
|
127
|
+
? `ffmpeg -i ${shq(file)} -c:a aac ${shq(out)}`
|
|
128
|
+
: `ffmpeg -i ${shq(file)} -c:v libx264 -c:a aac -movflags +faststart ${shq(out)}`
|
|
129
|
+
return { out, cmd }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 工具参数里的路径 → 绝对路径:~ 换成家目录,相对路径按会话的工作目录算;没有工作目录就返回空串 */
|
|
133
|
+
function resolvePath(raw, cwd) {
|
|
134
|
+
const p = fromBashPath(String(raw || '')).replace(/^~(?=[\\/]|$)/, os.homedir())
|
|
135
|
+
if (path.isAbsolute(p)) return path.resolve(p)
|
|
136
|
+
return cwd ? path.resolve(cwd, p) : ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function urlFor(file) {
|
|
140
|
+
const p = encodeURIComponent(file)
|
|
141
|
+
return `${ROUTE}?t=${tokenFor(file)}&p=${p}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 把门 ────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** DSH 自己对外的 host(绑在非本机地址时用得上);还没开始监听就拿不到 */
|
|
147
|
+
function ownHost(ctx) {
|
|
148
|
+
try { return new URL(ctx.webServer.getOrigin()).host } catch { return '' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Host / Sec-Fetch-Site / Origin 三道,照 DSH 自家路由的规矩 */
|
|
152
|
+
function fenceOk(req, own) {
|
|
153
|
+
const host = String(req.headers.host || '')
|
|
154
|
+
if (!LOOPBACK_HOST.test(host) && !(own && host === own)) return false
|
|
155
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
156
|
+
const origin = req.headers.origin
|
|
157
|
+
if (origin === undefined) return true
|
|
158
|
+
try { return new URL(origin).host === host } catch { return false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** DSH 的 connection 服务:验登录 cookie 用的就是它。拿不到(比如老版本)返回 null */
|
|
162
|
+
function connectionOf(ctx) {
|
|
163
|
+
try {
|
|
164
|
+
const c = ctx.get('connection')
|
|
165
|
+
return c && typeof c.requestRejection === 'function' ? c : null
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 每个回应都带这个头:前端出错时靠它分清「插件的路由在回话」还是「路由根本没挂上」;
|
|
172
|
+
// 值是包名,客户端靠它认出应答的是新版还是旧版(旧版的值是 '1')
|
|
173
|
+
const MARK = { 'X-DSH-Video-Preview': PKG }
|
|
174
|
+
|
|
175
|
+
function reply(res, code, text) {
|
|
176
|
+
res.writeHead(code, { ...MARK, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
177
|
+
res.end(text)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 只给真的媒体文件:扩展名按软链接指向的真实文件也要对。
|
|
182
|
+
* 全用异步读盘:文件在失联的网络盘上时只卡这一个请求,不卡整个宿主。
|
|
183
|
+
*/
|
|
184
|
+
async function mediaFile(file, res) {
|
|
185
|
+
if (!file || !path.isAbsolute(file) || !kindOf(file)) {
|
|
186
|
+
reply(res, 403, 'forbidden')
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
let real, stat
|
|
190
|
+
try {
|
|
191
|
+
real = await fs.promises.realpath(file)
|
|
192
|
+
stat = await fs.promises.stat(real)
|
|
193
|
+
} catch {
|
|
194
|
+
reply(res, 404, 'not found')
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
if (!kindOf(real) || !stat.isFile()) {
|
|
198
|
+
reply(res, 403, 'forbidden')
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await fs.promises.access(real, fs.constants.R_OK)
|
|
203
|
+
} catch {
|
|
204
|
+
reply(res, 403, 'forbidden')
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
return { real, stat }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 两条路由 ────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function makeHandlers(ctx) {
|
|
213
|
+
/** 带签名的地址:token 对得上才给(工具输出里的链接、工具块里的小播放器走这条) */
|
|
214
|
+
async function media(req, res) {
|
|
215
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
216
|
+
const file = url.searchParams.get('p') || ''
|
|
217
|
+
if (!fenceOk(req, ownHost(ctx)) || !tokenOk(file, url.searchParams.get('t'))) return reply(res, 403, 'forbidden')
|
|
218
|
+
const f = await mediaFile(file, res)
|
|
219
|
+
if (f) stream(req, res, f)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 没 token 的地址(turnTail、侧边栏只知道路径):要 DSH 的登录态 */
|
|
223
|
+
async function preview(req, res) {
|
|
224
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
225
|
+
const file = url.searchParams.get('p') || ''
|
|
226
|
+
const conn = connectionOf(ctx)
|
|
227
|
+
if (conn) {
|
|
228
|
+
const rejected = conn.requestRejection(req)
|
|
229
|
+
if (rejected) return reply(res, rejected, rejected === 401 ? 'unauthorized' : 'forbidden')
|
|
230
|
+
} else if (!LOOPBACK.has(req.socket.remoteAddress) || !fenceOk(req, ownHost(ctx))) {
|
|
231
|
+
return reply(res, 403, 'forbidden')
|
|
232
|
+
}
|
|
233
|
+
const f = await mediaFile(file, res)
|
|
234
|
+
if (f) stream(req, res, f)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 宿主会 await 处理函数、接住抛出的错(读盘的错 mediaFile 自己已经接住)
|
|
238
|
+
return { media, preview }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 只认单段 Range;格式不对或多段就当没带(回整个文件),起点越界才 416 */
|
|
242
|
+
function parseRange(header, total) {
|
|
243
|
+
if (!header || total === 0) return null
|
|
244
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
|
|
245
|
+
if (!m || (m[1] === '' && m[2] === '')) return null
|
|
246
|
+
if (m[1] === '') {
|
|
247
|
+
const n = Number(m[2])
|
|
248
|
+
if (n === 0) return 'unsatisfiable'
|
|
249
|
+
return { start: Math.max(0, total - n), end: total - 1 }
|
|
250
|
+
}
|
|
251
|
+
const start = Number(m[1])
|
|
252
|
+
const end = m[2] === '' ? total - 1 : Math.min(Number(m[2]), total - 1)
|
|
253
|
+
if (start >= total) return 'unsatisfiable'
|
|
254
|
+
if (end < start) return null
|
|
255
|
+
return { start, end }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 流式回文件,支持 Range(能拖进度条)。读盘出错只断这一个请求,不能把 DSH 带崩 */
|
|
259
|
+
function stream(req, res, { real, stat }) {
|
|
260
|
+
const total = stat.size
|
|
261
|
+
const head = {
|
|
262
|
+
...MARK,
|
|
263
|
+
// 整个文件多大:前端卡片上显示用(Content-Length 遇到 Range 只是一段,还可能被宿主中间件拿掉)
|
|
264
|
+
'X-DSH-Video-Size': String(total),
|
|
265
|
+
'Content-Type': MIME[path.extname(real).toLowerCase()] || 'application/octet-stream',
|
|
266
|
+
'Accept-Ranges': 'bytes',
|
|
267
|
+
'Cache-Control': 'no-store',
|
|
268
|
+
}
|
|
269
|
+
const range = parseRange(req.headers.range, total)
|
|
270
|
+
if (range === 'unsatisfiable') {
|
|
271
|
+
res.writeHead(416, { ...head, 'Content-Range': `bytes */${total}` })
|
|
272
|
+
return res.end()
|
|
273
|
+
}
|
|
274
|
+
if (range) {
|
|
275
|
+
res.writeHead(206, { ...head, 'Content-Length': range.end - range.start + 1, 'Content-Range': `bytes ${range.start}-${range.end}/${total}` })
|
|
276
|
+
} else {
|
|
277
|
+
res.writeHead(200, { ...head, 'Content-Length': total })
|
|
278
|
+
}
|
|
279
|
+
if (req.method === 'HEAD' || total === 0) return res.end()
|
|
280
|
+
// pipeline:读盘出错或对方中途断开,两头都会被关掉,fd 不漏、错误不往外抛
|
|
281
|
+
pipeline(fs.createReadStream(real, range || {}), res, () => {})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── 工具外壳(不 import 任何 @deepseek-ai/*,形状按 dsh-tools 的产出手搓)──────
|
|
285
|
+
|
|
286
|
+
function specToJsonSchema(spec) {
|
|
287
|
+
const properties = {}
|
|
288
|
+
const required = []
|
|
289
|
+
for (const [key, node] of Object.entries(spec)) {
|
|
290
|
+
const { required: isRequired, ...rest } = node
|
|
291
|
+
properties[key] = rest
|
|
292
|
+
if (isRequired) required.push(key)
|
|
293
|
+
}
|
|
294
|
+
return { type: 'object', properties, ...(required.length ? { required } : {}) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function defineTool(options) {
|
|
298
|
+
return {
|
|
299
|
+
name: options.name,
|
|
300
|
+
description: options.description,
|
|
301
|
+
parameters: specToJsonSchema(options.parameters),
|
|
302
|
+
output: { schema: options.output.schema, render: options.output.render },
|
|
303
|
+
...(options.presentCall ? { presentCall: options.presentCall } : {}),
|
|
304
|
+
execute: options.execute,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fmtSize(n) {
|
|
309
|
+
if (n > 1 << 30) return (n / (1 << 30)).toFixed(2) + ' GB'
|
|
310
|
+
if (n > 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
|
|
311
|
+
return Math.round(n / 1024) + ' KB'
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function apply(ctx) {
|
|
315
|
+
if (oldCopyLoaded(ctx)) {
|
|
316
|
+
try { ctx.logger.warn(`${OLD_PKG}(旧版)还装着:${PKG} 先不启动,移除旧版并重启 DSH 后接管`) } catch { /* 没 logger 就算了 */ }
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
const handlers = makeHandlers(ctx)
|
|
320
|
+
ctx.effect(() => ctx.webServer.register({
|
|
321
|
+
kind: 'exact',
|
|
322
|
+
path: ROUTE,
|
|
323
|
+
handler: handlers.media,
|
|
324
|
+
}), 'dsh-video-preview: media route')
|
|
325
|
+
ctx.effect(() => ctx.webServer.register({
|
|
326
|
+
kind: 'exact',
|
|
327
|
+
path: PREVIEW_ROUTE,
|
|
328
|
+
handler: handlers.preview,
|
|
329
|
+
}), 'dsh-video-preview: preview route')
|
|
330
|
+
|
|
331
|
+
const tool = defineTool({
|
|
332
|
+
name: 'play_video',
|
|
333
|
+
description:
|
|
334
|
+
'在对话里直接播放一个本地视频或音频文件,播放器出现在回复正下方,能拖进度条。' +
|
|
335
|
+
'用户想看 / 听某个已有的本地媒体文件时用它。' +
|
|
336
|
+
'你这一轮里自己生成、录制、下载、转码出来的视频音频不用调它:回复里写出文件的完整路径,回合结束时播放器会自动出现。' +
|
|
337
|
+
'能播:mp4 m4v mov webm ogv mkv、mp3 wav ogg m4a aac flac opus。' +
|
|
338
|
+
'avi / flv / wmv / wma / ts / mpg 这类播不了,要先用 ffmpeg 转成 mp4(音频转 m4a)再播。' +
|
|
339
|
+
'大文件不会读进内存,走 HTTP Range 流式传输。',
|
|
340
|
+
parameters: {
|
|
341
|
+
path: { type: 'string', required: true, description: '本地文件的绝对路径,例如 /Users/me/Movies/a.mp4' },
|
|
342
|
+
title: { type: 'string', description: '可选:播放器上方显示的标题,默认用文件名' },
|
|
343
|
+
autoplay: { type: 'boolean', description: '可选:是否自动播放(默认 false,浏览器通常也会拦)' },
|
|
344
|
+
loop: { type: 'boolean', description: '可选:是否循环播放(默认 false)' },
|
|
345
|
+
},
|
|
346
|
+
output: {
|
|
347
|
+
schema: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
additionalProperties: false,
|
|
350
|
+
properties: {
|
|
351
|
+
text: { type: 'string' },
|
|
352
|
+
url: { type: 'string' },
|
|
353
|
+
kind: { type: 'string' },
|
|
354
|
+
name: { type: 'string' },
|
|
355
|
+
size: { type: 'string' },
|
|
356
|
+
},
|
|
357
|
+
required: ['text', 'url'],
|
|
358
|
+
},
|
|
359
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
360
|
+
},
|
|
361
|
+
// 客户端 toolview 从这里读 url / kind / name
|
|
362
|
+
// 这里拿不到会话:相对路径不知道按哪个目录算,不签名(地址以工具输出为准,不能按 DSH 进程目录猜)
|
|
363
|
+
presentCall: (args) => {
|
|
364
|
+
const raw = String(args.path || '')
|
|
365
|
+
const file = resolvePath(raw, '')
|
|
366
|
+
let info = { url: '', kind: 'video', name: path.basename(file || raw), size: '', path: file || raw }
|
|
367
|
+
if (file) {
|
|
368
|
+
try {
|
|
369
|
+
const st = fs.statSync(file)
|
|
370
|
+
info = {
|
|
371
|
+
...info,
|
|
372
|
+
url: urlFor(file),
|
|
373
|
+
kind: kindOf(file) || 'video',
|
|
374
|
+
size: fmtSize(st.size),
|
|
375
|
+
}
|
|
376
|
+
} catch { /* 文件不存在时前端会显示错误 */ }
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
card: 'video-preview',
|
|
380
|
+
kind: 'other',
|
|
381
|
+
title: args.title || info.name,
|
|
382
|
+
rawInput: args,
|
|
383
|
+
...info,
|
|
384
|
+
autoplay: !!args.autoplay,
|
|
385
|
+
loop: !!args.loop,
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
async execute(args, exec) {
|
|
389
|
+
const raw = String(args.path || '')
|
|
390
|
+
if (!raw) throw new Error('path 不能为空')
|
|
391
|
+
const agent = (exec && exec.agent) || undefined
|
|
392
|
+
const session = agent && agent.session
|
|
393
|
+
// 相对路径按会话的工作目录算(和官方 present 一样),不按 DSH 进程的目录
|
|
394
|
+
const file = resolvePath(raw, session ? sessionCwd(ctx, session) : '')
|
|
395
|
+
if (!file) throw new Error(`拿不到会话的工作目录,请给绝对路径:${raw}`)
|
|
396
|
+
let st
|
|
397
|
+
try {
|
|
398
|
+
st = await fs.promises.stat(file)
|
|
399
|
+
} catch {
|
|
400
|
+
throw new Error(`找不到文件:${file}`)
|
|
401
|
+
}
|
|
402
|
+
if (!st.isFile()) throw new Error(`不是文件:${file}`)
|
|
403
|
+
const ext = path.extname(file).toLowerCase()
|
|
404
|
+
if (UNPLAYABLE_EXT.has(ext)) {
|
|
405
|
+
const { out, cmd } = convertHint(file)
|
|
406
|
+
throw new Error(
|
|
407
|
+
`DSH 的播放器(Chromium 内核)放不了 ${ext} 格式。先转成 ${path.extname(out)} 再播:\n${cmd}\n` +
|
|
408
|
+
`没装 ffmpeg 的话:${FFMPEG_INSTALL[process.platform] || FFMPEG_INSTALL.linux}。转好后用 play_video 播 ${out}`)
|
|
409
|
+
}
|
|
410
|
+
const kind = kindOf(file)
|
|
411
|
+
if (!kind) {
|
|
412
|
+
throw new Error(`不支持的格式:${ext || '(没有扩展名)'}。能播的:${[...VIDEO_EXT, ...AUDIO_EXT].join(' ')}`)
|
|
413
|
+
}
|
|
414
|
+
const url = urlFor(file)
|
|
415
|
+
const label = args.title || path.basename(file)
|
|
416
|
+
|
|
417
|
+
// 照官方 present 工具的机制:把视频作为 deliverable 呈现 →
|
|
418
|
+
// turnTail 渲染内联播放器 + 原生文件芯片(在侧边栏预览)
|
|
419
|
+
// title / autoplay / loop 跟着文件走,回复下方的播放器从这里读
|
|
420
|
+
try {
|
|
421
|
+
if (session && ctx.sessionProjections && typeof session.append === 'function') {
|
|
422
|
+
const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary')
|
|
423
|
+
if (boundary && Number.isInteger(boundary.lastTurn)) {
|
|
424
|
+
session.append('deliverables/presented', {
|
|
425
|
+
turn: boundary.lastTurn,
|
|
426
|
+
callId: (exec && exec.callId) || 'play_video',
|
|
427
|
+
files: [{
|
|
428
|
+
path: file,
|
|
429
|
+
description: `▶️ ${label}`,
|
|
430
|
+
...(args.title ? { title: String(args.title) } : {}),
|
|
431
|
+
...(args.autoplay ? { autoplay: true } : {}),
|
|
432
|
+
...(args.loop ? { loop: true } : {}),
|
|
433
|
+
}],
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch { /* 呈现失败不影响播放本体 */ }
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
text: `▶️ ${label}(${kind === 'video' ? '视频' : '音频'},${fmtSize(st.size)})\n${url}`,
|
|
441
|
+
url,
|
|
442
|
+
kind,
|
|
443
|
+
name: path.basename(file),
|
|
444
|
+
size: fmtSize(st.size),
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
ctx.tools.register(tool)
|
|
450
|
+
|
|
451
|
+
if (typeof ctx.on === 'function') {
|
|
452
|
+
autoPresent(ctx, {
|
|
453
|
+
kindOf,
|
|
454
|
+
log: (e) => {
|
|
455
|
+
try { ctx.logger.warn(`dsh-video-preview: 自动呈现出错:${e && e.message ? e.message : e}`) } catch { /* 没 logger 就算了 */ }
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orderg-x/dsh-video-preview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test --test-concurrency=1 test/*.mjs"
|
|
7
|
+
},
|
|
8
|
+
"description": "Inline video and audio playback for DeepSeek Harness: a play_video tool plus a native player under the reply and in the sidebar, served by a guarded, Range-capable streaming route.",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/OrderG-X/dsh-video-preview.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"dsh",
|
|
16
|
+
"deepseek-harness",
|
|
17
|
+
"dsh-plugin",
|
|
18
|
+
"video",
|
|
19
|
+
"player"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./lib/index.js",
|
|
23
|
+
"./client": "./lib/client.js",
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
26
|
+
"./icon.svg": "./icon.svg",
|
|
27
|
+
"./locale/*.json": "./locale/*.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"locale",
|
|
32
|
+
"icon.svg",
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"README.md",
|
|
35
|
+
"assets"
|
|
36
|
+
],
|
|
37
|
+
"icon": "./icon.svg",
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"platform": "web",
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-locale",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@deepseek-ai/dsh-tools": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|