@orderg-x/dsh-video-preview 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -14,13 +14,15 @@
14
14
 
15
15
  让 DSH(DeepSeek Harness)的对话流里直接播放本地视频 / 音频:模型录屏、转码、配音生成的文件,回合结束时播放器自动出现在回复正下方——Claude 桌面端 / Codex 同款位置,不用点任何东西,也不用模型专门调工具。说一句「播一下 ~/Movies/a.mp4」也行。
16
16
 
17
- > **一句话**:DeepSeek Harness(DSH)插件,在对话里直接播放本地视频和音频。模型录屏、转码、配音做出来的文件,播放器自动出现在回复下面;别的文件用 `play_video` 工具播。
17
+ 模型自己也能「看」:`look_video` 按时间截几帧拼成一张图交给模型,顺便量声音、找黑屏、查手机能不能播——做完视频它能自己检查,你说「不对」时它能看到你说的是哪一段。
18
+
19
+ > **一句话**:DeepSeek Harness(DSH)插件,在对话里直接播放本地视频和音频。模型录屏、转码、配音做出来的文件,播放器自动出现在回复下面;别的文件用 `play_video` 工具播;模型用 `look_video` 自己看画面、听声音。
18
20
  >
19
21
  > **什么时候用**:「录个屏」「把视频转成 mp4」「生成一段配音」之后,想直接在对话里看、听。
20
22
  >
21
23
  > **安装**:`dsh plugin add @orderg-x/dsh-video-preview`(npm 包),重启 DSH。
22
24
  >
23
- > **English:** a DeepSeek Harness (DSH) plugin that plays local video and audio inline in the chat. Files the model makes (screen recordings, transcodes, voiceovers) get a player under its reply automatically, and the `play_video` tool plays any other file. Install with the `dsh plugin add` command above, then restart DSH.
25
+ > **English:** a DeepSeek Harness (DSH) plugin that plays local video and audio inline in the chat. Files the model makes (screen recordings, transcodes, voiceovers) get a player under its reply automatically, and the `play_video` tool plays any other file. The `look_video` tool lets the model see a video itself: a contact sheet of frames, plus checks for silence, black frames and phone compatibility. Install with the `dsh plugin add` command above, then restart DSH.
24
26
 
25
27
  ![demo](assets/demo.gif)
26
28
 
@@ -72,6 +74,24 @@ DSH 的消息渲染块只有 text / image / file / table,**没有 video**,
72
74
  | `autoplay` | 否 | 自动播放(浏览器通常会拦) |
73
75
  | `loop` | 否 | 循环播放 |
74
76
 
77
+ ## 模型自己看视频(look_video)
78
+
79
+ 模型看不了视频文件本身,`look_video` 替它看:
80
+
81
+ - **截帧拼图**:按时长均匀截 4–12 帧,拼成一张图交给模型,每格标时间;也可以只看一段(`from` / `to`),或者要某几刻的大图(`at`,最多 6 张)
82
+ - **听声音**:整段几乎没声、开头 / 结尾 / 中间没声、声音太小、爆音(削波)、只有一边声道
83
+ - **看画面**:黑屏(第一帧黑会让封面变黑)、整段画面没动、中间一大段卡住
84
+ - **手机能不能播**:读 mp4 / mov 文件头(不解码),查编码(H.264 10bit / 4:4:4、hev1、AC-3、ALAC……)、文件头在末尾(网页要下完才能播)、超过 4K、没录完;有问题给出 ffmpeg 改法
85
+
86
+ 怎么做的:在本机一个看不见的 Chrome / Edge 里打开视频截帧、解码声音(和设计工作台的体检同一套),看一次一秒左右。没装 Chrome / Edge 时只给文件头信息。
87
+
88
+ | 参数 | 必填 | 说明 |
89
+ | --- | --- | --- |
90
+ | `path` | 是 | 本地文件路径(视频或音频) |
91
+ | `at` | 否 | 要看大图的时间点,例如 `["0:12", "1:05.5"]` |
92
+ | `from` / `to` | 否 | 只看这一段 |
93
+ | `frames` | 否 | 拼图里截几帧(默认按时长,最多 16) |
94
+
75
95
  ## 能播什么
76
96
 
77
97
  DSH 的播放器是 Chromium 内核,能播什么由它决定:
@@ -109,12 +129,17 @@ v0.3.0 之前的版本只看 cookie 名字,网页能伪造 cookie 读到任意
109
129
  │
110
130
  └─append deliverables/presented 事件(官方 present 工具同款通道)
111
131
  自动 session/event 收集这一轮的路径 → agent/turn-stopping 时挑出新文件 → 同一条通道
132
+ 看视频 look_video ──读文件头(probe)──▶ 临时本机服务(随机口令、只收本机、Range)
133
+ └─无头 Chrome / Edge 截帧 + 解码声音 ──▶ 拼图存进 DSH 附件库,图和体检结果一起交给模型
112
134
  路由 /dsh-video-preview/media 签名 token · HTTP Range 流式(大文件不进内存)
113
135
  /dsh-video-preview/preview DSH 登录态 · 供侧边栏 / 回复下方的播放器
114
136
  客户端 turnTail 槽 / documentPreviews 渲染器 / toolview 槽
115
137
  ```
116
138
 
117
139
  - `lib/index.js` —— 服务端:工具 + deliverables 事件 + 两条流式路由(读盘出错只断当前请求,不会带崩 DSH)
140
+ - `lib/look.js` —— look_video:取帧时间、浏览器里截帧拼图和量声音、声音 / 画面的问题、给模型的字
141
+ - `lib/probe.js` —— 读 mp4 / mov 的盒子(不解码):编码、时长、帧率、旋转、文件头位置,手机兼容规则
142
+ - `lib/browser.js` —— 无头 Chrome / Edge(CDP 管道,闲置自动关)
118
143
  - `lib/auto-present.js` —— 自动播放:挖路径、挑新文件、回合收尾前呈现(回合结束后才 append 的话,界面要重开会话才刷出来)
119
144
  - `lib/client.js` —— 客户端:回复下方播放器 / 侧边栏预览 / 工具块兜底 / 出错诊断(`exports.inject` 是客户端**短服务名**,写包名会一直 pending)
120
145
  - `locale/*.json`、`icon.svg` —— DSH 插件页的卡片标题、说明和图标
@@ -126,10 +151,17 @@ v0.3.0 之前的版本只看 cookie 名字,网页能伪造 cookie 读到任意
126
151
  node test/test-routes.mjs # 把门、Range、HEAD、读盘出错、工具的格式提示
127
152
  node test/test-client.mjs # 三个挂载点、视频卡 / 音频条、折叠、出错诊断
128
153
  node test/test-auto.mjs # 自动播放:新 / 老文件、正文优先、各种路径写法、不重复、重放不动手
154
+ node test/test-look.mjs # look_video:手搓 mp4 测编码规则;有 Chrome / Edge 时用 test/fixtures 的样片测黑屏、单声道、没声、画面不动
129
155
  ```
130
156
 
131
157
  ## 已验证
132
158
 
133
159
  DeepSeek Harness 0.2.1-rc.2 桌面端端到端实测:回复下方播放器(mp4 / HEVC 的 mov / m4a,中文文件名)、侧边栏预览和播放、avi 的转码提示、文件被移走后的出错提示;伪造登录 cookie 的请求被拒;模型用 say / avconvert 生成的 m4a、mp4(含带空格的中文文件名)没调 `play_video`,回合结束时播放器实时出现且能播;视频卡悬停预览 / 点击播放 / 下载、音频条播放、超过 3 个时折叠展开、深浅两种主题下的配色。
134
160
 
161
+ `look_video` 在同一台机器的 DSH 桌面端用 DeepSeek-V4.1-Flash 实测:用户说「三个视频帮我看看有没有问题」,模型自己对三个文件调 `look_video`,答出黑屏、爆音、HEVC / WebM 在别处放不了;问「1 秒左右是不是闪了一下」,模型用 `from` / `to` 逐帧看了那一小段,11 秒答出是黑场硬切到亮色。
162
+
135
163
  参考实现:[@goodandready/dsh-image-gen](https://www.npmjs.com/package/@goodandready/dsh-image-gen)(客户端插件套路)与官方 `dsh-tool-present`(deliverables 通道)。
164
+
165
+ ## 反馈
166
+
167
+ 遇到问题或想要新功能,[提一个 issue](https://github.com/OrderG-X/dsh-video-preview/issues/new/choose)。报问题时写上 DSH 版本和插件版本,修起来最快。
@@ -89,6 +89,13 @@ export function sessionCwd(ctx, session) {
89
89
  return typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : ''
90
90
  }
91
91
 
92
+ /** Windows 上 DSH 用 Git Bash 跑命令,模型给的路径常是 /c/Users/… 这种写法:换成 C:\\Users\\…;别的系统原样 */
93
+ export function fromBashPath(p, platform = process.platform) {
94
+ if (platform !== 'win32') return p
95
+ const m = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(p)
96
+ return m ? `${m[1].toUpperCase()}:\\${(m[2] || '').replace(/\//g, '\\')}` : p
97
+ }
98
+
92
99
  /**
93
100
  * 候选片段 → 每种写法一组绝对路径(相对路径按工作目录挨个试,越靠后的目录越新)。
94
101
  * 写法按顺序:字面的先试(文件名本身就可能带 %20),再试 URL 解码、shell 转义去掉的。
@@ -103,9 +110,12 @@ function resolveAll(raw, cwds) {
103
110
  } catch { /* 不是编码过的,只用字面的 */ }
104
111
  }
105
112
  for (const v of [...variants]) {
106
- if (v.includes('\\')) variants.push(v.replace(/\\(.)/g, '$1')) // shell 里转义的空格:My\ Movies
113
+ if (!v.includes('\\')) continue
114
+ // shell 里转义的空格:My\ Movies。Windows 上反斜杠是路径分隔符,只去掉空格前面那个
115
+ variants.push(process.platform === 'win32' ? v.replace(/\\ /g, ' ') : v.replace(/\\(.)/g, '$1'))
107
116
  }
108
117
  return variants.map((v) => {
118
+ v = fromBashPath(v)
109
119
  if (v === '~' || v.startsWith('~/')) v = path.join(os.homedir(), v.slice(1))
110
120
  return path.isAbsolute(v) ? [path.normalize(v)] : cwds.map((c) => path.resolve(c, v))
111
121
  })
package/lib/browser.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * 无头浏览器:look_video 在电脑上另开一个看不见的 Chrome(或 Edge、Chromium、Brave),
3
+ * 打开视频按时间取帧、解出声音来量,和 DSH 自己的界面无关。
4
+ *
5
+ * 用 --remote-debugging-pipe 和它说话(不开端口,别的程序连不进来);临时用户目录,用完删掉;
6
+ * 闲置一会儿自己关,插件停用时一起关。找不到浏览器 → available() 是 false,调用的地方只给文件信息。
7
+ * (和 dsh-design-studio 的 browser.js 同一份,只改了临时目录的前缀)
8
+ */
9
+
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { spawn, execFile } from 'node:child_process'
14
+
15
+ const IDLE_MS = 90000 // 闲置多久关掉
16
+ const CALL_MS = 30000 // 一条指令最多等多久
17
+ const LOAD_MS = 15000 // 页面载入最多等多久(到点照样往下走:有个图一直载不完也能量)
18
+ const MAX_PAGES = 3 // 同时开几个页面
19
+
20
+ /** 这台电脑上的 Chromium 系浏览器;DSH_VIDEO_BROWSER / DSH_DESIGN_BROWSER / CHROME_BIN 可以指定 */
21
+ export function findBrowser({ env = process.env, platform = process.platform, exists = fs.existsSync, home = os.homedir() } = {}) {
22
+ const roots = [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)
23
+ const mac = (app, bin) => [`/Applications/${app}.app/Contents/MacOS/${bin}`, `${home}/Applications/${app}.app/Contents/MacOS/${bin}`]
24
+ const list = {
25
+ darwin: [...mac('Google Chrome', 'Google Chrome'), ...mac('Microsoft Edge', 'Microsoft Edge'), ...mac('Chromium', 'Chromium'), ...mac('Brave Browser', 'Brave Browser')],
26
+ win32: roots.flatMap((d) => [`${d}\\Google\\Chrome\\Application\\chrome.exe`, `${d}\\Microsoft\\Edge\\Application\\msedge.exe`, `${d}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`]),
27
+ linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/microsoft-edge', '/usr/bin/brave-browser'],
28
+ }
29
+ for (const p of [env.DSH_VIDEO_BROWSER, env.DSH_DESIGN_BROWSER, env.CHROME_BIN, ...(list[platform] || [])]) {
30
+ if (!p) continue
31
+ try { if (exists(p)) return p } catch { /* 读不了就下一个 */ }
32
+ }
33
+ return null
34
+ }
35
+
36
+ /** CDP 管道上的消息:JSON 后面跟一个 \0。一块数据里可能有半条、也可能有好几条 */
37
+ export function frameReader(onMsg) {
38
+ let buf = Buffer.alloc(0)
39
+ return (chunk) => {
40
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk
41
+ let i
42
+ while ((i = buf.indexOf(0)) >= 0) {
43
+ const text = buf.subarray(0, i).toString('utf8')
44
+ buf = buf.subarray(i + 1)
45
+ let msg
46
+ try { msg = JSON.parse(text) } catch { continue }
47
+ onMsg(msg)
48
+ }
49
+ }
50
+ }
51
+
52
+ const PREFIX = 'dsh-video-browser-'
53
+ const STALE_MS = 3600 * 1000 // 一小时没动过的临时用户目录:上回没删掉的(DSH 被强退、插件进程直接退出)
54
+ let swept = false
55
+
56
+ /** 清掉以前留下的临时用户目录(每个进程只扫一次;正在用的浏览器闲置 90 秒就关,不会一小时不动) */
57
+ function sweep(dir = os.tmpdir(), now = Date.now()) {
58
+ if (swept) return
59
+ swept = true
60
+ let names = []
61
+ try { names = fs.readdirSync(dir) } catch { return }
62
+ for (const n of names) {
63
+ if (!n.startsWith(PREFIX)) continue
64
+ const p = path.join(dir, n)
65
+ try { if (now - fs.statSync(p).mtimeMs > STALE_MS) fs.rmSync(p, { recursive: true, force: true }) } catch { /* 下次再说 */ }
66
+ }
67
+ }
68
+
69
+ export function createBrowser({ bin = findBrowser(), log = () => {}, idleMs = IDLE_MS, launch = spawn, platform = process.platform } = {}) {
70
+ let proc = null
71
+ let starting = null
72
+ let dir = ''
73
+ let seq = 0
74
+ let open = 0
75
+ let idle = null
76
+ let dead = false // destroy() 之后:不再开浏览器,排队的、新来的都直接失败
77
+ const pending = new Map() // 指令 id → { resolve, reject, timer }
78
+ const waiters = new Set() // 等事件的:{ method, sessionId, done }
79
+ const queue = [] // 等空位开页面的
80
+
81
+ const fail = (err) => {
82
+ for (const p of pending.values()) { clearTimeout(p.timer); p.reject(err) }
83
+ pending.clear()
84
+ }
85
+
86
+ const onMsg = (msg) => {
87
+ if (msg.id) {
88
+ const p = pending.get(msg.id)
89
+ if (!p) return
90
+ pending.delete(msg.id)
91
+ clearTimeout(p.timer)
92
+ if (msg.error) p.reject(new Error(msg.error.message || 'cdp error'))
93
+ else p.resolve(msg.result || {})
94
+ return
95
+ }
96
+ for (const w of [...waiters]) if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) w.done(msg.params || {})
97
+ }
98
+
99
+ function call(method, params = {}, sessionId) {
100
+ if (!proc) return Promise.reject(new Error('browser not running'))
101
+ return new Promise((resolve, reject) => {
102
+ const id = ++seq
103
+ const timer = setTimeout(() => { if (pending.delete(id)) reject(new Error(`${method} timeout`)) }, CALL_MS)
104
+ pending.set(id, { resolve, reject, timer })
105
+ try { proc.stdio[3].write(`${JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })}\0`) } catch (e) { pending.delete(id); clearTimeout(timer); reject(e) }
106
+ })
107
+ }
108
+
109
+ /** 等一个事件(到点就当等到了,返回 null) */
110
+ function once(method, sessionId, ms) {
111
+ let w
112
+ const p = new Promise((resolve) => {
113
+ const timer = setTimeout(() => { waiters.delete(w); resolve(null) }, ms)
114
+ w = { method, sessionId, done: (v) => { clearTimeout(timer); waiters.delete(w); resolve(v) } }
115
+ waiters.add(w)
116
+ })
117
+ return p
118
+ }
119
+
120
+ // 宿主进程退出时浏览器来不及报「退出了」:那时同步删掉临时目录
121
+ let onHostExit = null
122
+ function cleanup() {
123
+ if (onHostExit) { process.removeListener('exit', onHostExit); onHostExit = null }
124
+ proc = null
125
+ starting = null
126
+ fail(new Error('browser exited'))
127
+ for (const w of [...waiters]) w.done(null) // 等页面载入的不用再等了
128
+ const d = dir
129
+ dir = ''
130
+ // 自己建的临时用户目录:浏览器退了马上删(Windows 上进程刚退时文件可能还占着,隔一会儿再试,最多三次;
131
+ // 还删不掉的,下次开浏览器时按时间清掉)
132
+ if (d) {
133
+ let tries = 0
134
+ const rm = () => {
135
+ try { fs.rmSync(d, { recursive: true, force: true }) } catch (e) {
136
+ if (++tries < 4) setTimeout(rm, 2000).unref?.()
137
+ else log(new Error(`临时目录删不掉,留在 ${d}:${e && e.message}`))
138
+ }
139
+ }
140
+ rm()
141
+ }
142
+ }
143
+
144
+ async function start() {
145
+ if (proc) return
146
+ if (starting) return starting
147
+ if (!bin) throw new Error('no-browser')
148
+ if (dead) throw new Error('browser closed')
149
+ sweep()
150
+ starting = (async () => {
151
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), PREFIX))
152
+ const d = dir
153
+ onHostExit = () => { try { fs.rmSync(d, { recursive: true, force: true }) } catch { /* 下次开浏览器时按时间清 */ } }
154
+ process.once('exit', onHostExit)
155
+ const args = ['--headless=new', '--remote-debugging-pipe', `--user-data-dir=${dir}`, '--no-first-run', '--no-default-browser-check',
156
+ '--disable-extensions', '--disable-background-networking', '--disable-sync', '--disable-default-apps', '--disable-component-update',
157
+ '--mute-audio', '--hide-scrollbars', '--force-color-profile=srgb',
158
+ ...(platform === 'darwin' ? ['--use-mock-keychain'] : []),
159
+ ...(platform === 'linux' && process.getuid && process.getuid() === 0 ? ['--no-sandbox'] : []),
160
+ 'about:blank']
161
+ const p = launch(bin, args, { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'], windowsHide: true })
162
+ proc = p
163
+ p.on('exit', () => { if (proc === p) cleanup() })
164
+ p.on('error', (e) => { log(e); if (proc === p) cleanup() })
165
+ p.stdio[4].on('data', frameReader(onMsg))
166
+ p.stdio[3].on('error', () => {})
167
+ await call('Browser.getVersion')
168
+ })()
169
+ try { await starting } catch (e) { stop(); throw e } finally { starting = null }
170
+ }
171
+
172
+ function stop() {
173
+ clearTimeout(idle)
174
+ const p = proc
175
+ if (!p) return
176
+ try { p.stdio[3].end() } catch { /* 已经断了 */ }
177
+ if (platform === 'win32') {
178
+ execFile('taskkill', ['/pid', String(p.pid), '/T', '/F'], (err) => {
179
+ if (!err) return
180
+ log(err)
181
+ try { p.kill() } catch { /* 已经退了 */ }
182
+ })
183
+ } else { try { p.kill('SIGKILL') } catch { /* 已经退了 */ } }
184
+ }
185
+
186
+ /** 插件停用:关掉浏览器,排队等页面的都失败,之后再也不开 */
187
+ function destroy() {
188
+ dead = true
189
+ for (const next of queue.splice(0)) next()
190
+ stop()
191
+ }
192
+
193
+ const touch = () => {
194
+ clearTimeout(idle)
195
+ if (!open) idle = setTimeout(() => { if (!open) stop() }, idleMs)
196
+ }
197
+
198
+ /**
199
+ * 开一个 w×h 的页面载入 url,fn(page) 里量 / 截图,完了关掉。
200
+ * page.eval(表达式):在页面里算(Promise 会等),返回 JSON 值
201
+ * page.shot({ clip }):截视口;clip({ x, y, width, height },CSS 像素,按整页坐标 = 视口位置 + 滚动距离)只截那一块 → JPEG Buffer
202
+ */
203
+ async function withPage({ url, w, h, scale = 1, dark = false }, fn) {
204
+ if (dead) throw new Error('browser closed')
205
+ if (open >= MAX_PAGES) await new Promise((r) => queue.push(r))
206
+ if (dead) throw new Error('browser closed')
207
+ open++
208
+ clearTimeout(idle)
209
+ let targetId = ''
210
+ try {
211
+ await start()
212
+ ;({ targetId } = await call('Target.createTarget', { url: 'about:blank' }))
213
+ const { sessionId } = await call('Target.attachToTarget', { targetId, flatten: true })
214
+ const c = (m, p) => call(m, p, sessionId)
215
+ await c('Page.enable')
216
+ // 和右侧栏里一样按桌面方式排版(mobile: false):页面宽就是 w,不看 <meta viewport>
217
+ await c('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: scale, mobile: false })
218
+ await c('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: dark ? 'dark' : 'light' }, { name: 'prefers-reduced-motion', value: 'reduce' }] })
219
+ const loaded = once('Page.loadEventFired', sessionId, LOAD_MS)
220
+ const nav = await c('Page.navigate', { url })
221
+ if (nav.errorText) throw new Error(nav.errorText)
222
+ await loaded
223
+ const page = {
224
+ eval: async (expression) => {
225
+ const r = await c('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
226
+ if (r.exceptionDetails) throw new Error((r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text || 'eval failed')
227
+ return r.result ? r.result.value : undefined
228
+ },
229
+ shot: async ({ clip, quality = 82 } = {}) => {
230
+ const r = await c('Page.captureScreenshot', { format: 'jpeg', quality, ...(clip ? { clip: { ...clip, scale: 1 } } : {}) })
231
+ return Buffer.from(r.data, 'base64')
232
+ },
233
+ }
234
+ return await fn(page)
235
+ } finally {
236
+ if (targetId && proc) call('Target.closeTarget', { targetId }).catch(() => {})
237
+ open--
238
+ const next = queue.shift()
239
+ if (next) next()
240
+ touch()
241
+ }
242
+ }
243
+
244
+ return {
245
+ available: () => !!bin,
246
+ bin: () => bin,
247
+ withPage,
248
+ stop,
249
+ destroy,
250
+ running: () => !!proc,
251
+ }
252
+ }
package/lib/client.js CHANGED
@@ -503,7 +503,7 @@ window.__ModuleLoader__.load({
503
503
  items.push({
504
504
  url: '/dsh-video-preview/preview?p=' + encodeURIComponent(p),
505
505
  kind: isAudio ? 'audio' : 'video',
506
- name: (typeof f.title === 'string' && f.title) || p.split('/').pop() || p,
506
+ name: (typeof f.title === 'string' && f.title) || p.split(/[\\/]/).pop() || p,
507
507
  autoplay: !!f.autoplay,
508
508
  loop: !!f.loop,
509
509
  })
@@ -573,7 +573,7 @@ window.__ModuleLoader__.load({
573
573
  return h('p', { style: { padding: 16, color: '#888', fontSize: 13 } }, t(key, ZH[key]))
574
574
  }
575
575
  const audio = AUDIO_RE.test(p)
576
- const media = { url: src, kind: audio ? 'audio' : 'video', name: p.split('/').pop() || p }
576
+ const media = { url: src, kind: audio ? 'audio' : 'video', name: p.split(/[\\/]/).pop() || p }
577
577
  if (reason) {
578
578
  return h('div', { style: { padding: 16 } }, h(ErrorCard, { reason, media, t }))
579
579
  }
package/lib/index.js CHANGED
@@ -22,10 +22,15 @@
22
22
  */
23
23
 
24
24
  import fs from 'node:fs'
25
+ import os from 'node:os'
25
26
  import path from 'node:path'
26
27
  import crypto from 'node:crypto'
28
+ import http from 'node:http'
27
29
  import { pipeline } from 'node:stream'
28
- import { autoPresent, sessionCwd } from './auto-present.js'
30
+ import { autoPresent, sessionCwd, fromBashPath } from './auto-present.js'
31
+ import { createBrowser } from './browser.js'
32
+ import { probeFile } from './probe.js'
33
+ import { inspect, formatLook, parseTime, MAX_AT, MAX_FRAMES, MAX_AUDIO_BYTES, MAX_AUDIO_SECONDS } from './look.js'
29
34
 
30
35
  export const name = '@orderg-x/dsh-video-preview'
31
36
  export const inject = ['tools', 'webServer', 'sessionProjections']
@@ -61,7 +66,7 @@ const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
61
66
  * 创建的会话)token 就对不上,媒体请求全部 403。
62
67
  */
63
68
  function loadSecret() {
64
- const file = path.join(process.env.HOME || '.', '.dsh', 'cache', 'dsh-video-preview.secret')
69
+ const file = path.join(os.homedir(), '.dsh', 'cache', 'dsh-video-preview.secret')
65
70
  try {
66
71
  const s = fs.readFileSync(file, 'utf8').trim()
67
72
  if (s) return s
@@ -110,6 +115,9 @@ function kindOf(file) {
110
115
  }
111
116
 
112
117
  /** shell 单引号:文件名里的 $(...)、反引号、双引号都原样当字面,单引号写成 '\'' */
118
+ /** 各系统装 ffmpeg 的一句话 */
119
+ const FFMPEG_INSTALL = { darwin: 'brew install ffmpeg', win32: 'winget install ffmpeg', linux: 'sudo apt install ffmpeg(或用系统自带的包管理器)' }
120
+
113
121
  function shq(s) {
114
122
  return "'" + String(s).replace(/'/g, "'\\''") + "'"
115
123
  }
@@ -127,7 +135,7 @@ function convertHint(file) {
127
135
 
128
136
  /** 工具参数里的路径 → 绝对路径:~ 换成家目录,相对路径按会话的工作目录算;没有工作目录就返回空串 */
129
137
  function resolvePath(raw, cwd) {
130
- const p = String(raw || '').replace(/^~(?=\/|$)/, process.env.HOME || '~')
138
+ const p = fromBashPath(String(raw || '')).replace(/^~(?=[\\/]|$)/, os.homedir())
131
139
  if (path.isAbsolute(p)) return path.resolve(p)
132
140
  return cwd ? path.resolve(cwd, p) : ''
133
141
  }
@@ -401,7 +409,7 @@ export function apply(ctx) {
401
409
  const { out, cmd } = convertHint(file)
402
410
  throw new Error(
403
411
  `DSH 的播放器(Chromium 内核)放不了 ${ext} 格式。先转成 ${path.extname(out)} 再播:\n${cmd}\n` +
404
- `没装 ffmpeg 的话:brew install ffmpeg。转好后用 play_video 播 ${out}`)
412
+ `没装 ffmpeg 的话:${FFMPEG_INSTALL[process.platform] || FFMPEG_INSTALL.linux}。转好后用 play_video 播 ${out}`)
405
413
  }
406
414
  const kind = kindOf(file)
407
415
  if (!kind) {
@@ -444,6 +452,15 @@ export function apply(ctx) {
444
452
 
445
453
  ctx.tools.register(tool)
446
454
 
455
+ // look_video 用的无头浏览器:用到时才开,闲置自己关,插件停用时关掉
456
+ let browser = null
457
+ ctx.effect(() => {
458
+ const b = createBrowser({ log: (e) => { try { ctx.logger.warn(`dsh-video-preview: 无头浏览器:${e && e.message ? e.message : e}`) } catch { /* 没 logger 就算了 */ } } })
459
+ browser = b
460
+ return () => { b.destroy(); if (browser === b) browser = null }
461
+ }, 'dsh-video-preview: headless browser')
462
+ ctx.tools.register(lookTool(ctx, () => browser))
463
+
447
464
  if (typeof ctx.on === 'function') {
448
465
  autoPresent(ctx, {
449
466
  kindOf,
@@ -453,3 +470,161 @@ export function apply(ctx) {
453
470
  })
454
471
  }
455
472
  }
473
+
474
+ // ── look_video:让模型自己看视频 ────────────────────────────────────────────
475
+
476
+ /**
477
+ * 给无头浏览器开的临时服务:只在 127.0.0.1 上、只给这一个文件、路径里带随机口令,看完就关。
478
+ * 和 DSH 自己的服务分开:不用登录态,页面和视频同源(能整个读出来解声音、画到画布上取帧)。
479
+ */
480
+ export async function serveOne(real) {
481
+ const key = crypto.randomBytes(16).toString('hex')
482
+ const server = http.createServer(async (req, res) => {
483
+ try {
484
+ const host = String(req.headers.host || '')
485
+ const p = new URL(req.url, 'http://127.0.0.1').pathname
486
+ if (!LOOPBACK.has(req.socket.remoteAddress) || !LOOPBACK_HOST.test(host)) return reply(res, 403, 'forbidden')
487
+ if (p === `/${key}/`) {
488
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
489
+ return res.end('<!doctype html><meta charset="utf-8"><title>look</title><body style="margin:0">')
490
+ }
491
+ if (p !== `/${key}/media`) return reply(res, 404, 'not found')
492
+ const stat = await fs.promises.stat(real)
493
+ stream(req, res, { real, stat })
494
+ } catch {
495
+ try { reply(res, 500, 'error') } catch { /* 已经回过了 */ }
496
+ }
497
+ })
498
+ await new Promise((ok, no) => { server.once('error', no); server.listen(0, '127.0.0.1', ok) })
499
+ const origin = `http://127.0.0.1:${server.address().port}`
500
+ return {
501
+ page: `${origin}/${key}/`,
502
+ src: `${origin}/${key}/media`,
503
+ close: () => new Promise((ok) => { server.closeAllConnections?.(); server.close(() => ok()) }),
504
+ }
505
+ }
506
+
507
+ /** look_video 的图:存进 DSH 的附件库,附件引用放进工具结果;附件库用不了就存成文件(一天前的顺手删掉) */
508
+ const LOOK_DIR = path.join(os.tmpdir(), 'dsh-video-look')
509
+
510
+ function pruneLookDir(maxAge = 24 * 3600 * 1000) {
511
+ let names = []
512
+ try { names = fs.readdirSync(LOOK_DIR) } catch { return }
513
+ const old = Date.now() - maxAge
514
+ for (const n of names) {
515
+ const f = path.join(LOOK_DIR, n)
516
+ try { if (fs.statSync(f).mtimeMs < old) fs.rmSync(f, { force: true }) } catch { /* 下次再说 */ }
517
+ }
518
+ }
519
+
520
+ async function keepImages(ctx, jpgs, base, log) {
521
+ const attachments = ctx && typeof ctx.get === 'function' ? ctx.get('attachments') : null
522
+ const name = (i) => `${base.replace(/[^\w.-]+/g, '-').slice(0, 60)}-${i + 1}.jpg`
523
+ if (attachments) {
524
+ try {
525
+ const refs = await attachments.saveImages(jpgs.map((b, i) => ({ data: b, mediaType: 'image/jpeg', name: name(i) })))
526
+ return { images: [...refs] }
527
+ } catch (e) { log(e) }
528
+ }
529
+ pruneLookDir()
530
+ fs.mkdirSync(LOOK_DIR, { recursive: true })
531
+ const files = jpgs.map((b, i) => {
532
+ const f = path.join(LOOK_DIR, `${Date.now().toString(36)}-${name(i)}`)
533
+ fs.writeFileSync(f, b)
534
+ return f
535
+ })
536
+ return { files }
537
+ }
538
+
539
+ /** 工具结果显示成:文字 + 一张张图 */
540
+ function withImages(value) {
541
+ const blocks = [{ type: 'text', text: value.text }]
542
+ for (const ref of Array.isArray(value.images) ? value.images : []) blocks.push({ type: 'image', attachment: ref })
543
+ return blocks
544
+ }
545
+
546
+ /** 只读文件头就够的格式(probe.js 认得的) */
547
+ const ISO_EXT = new Set(['.mp4', '.m4v', '.mov', '.m4a', '.3gp'])
548
+
549
+ export function lookTool(ctx, getBrowser) {
550
+ const log = (e) => { try { ctx.logger.warn(`dsh-video-preview: look_video:${e && e.message ? e.message : e}`) } catch { /* 没 logger 就算了 */ } }
551
+ return defineTool({
552
+ name: 'look_video',
553
+ description:
554
+ '看一个本地视频(或音频)的内容:在电脑上用看不见的 Chrome / Edge 打开,按时间取几帧拼成一张图交给你(每格左上角标了时间),' +
555
+ '再量声音(有没有声、多大、哪段没声、有没有爆音、是不是只有一边有声),顺便查手机上能不能放(编码、文件头位置)。' +
556
+ '你做完、改完视频(录屏、剪辑、转码、配音、合成动画)后,交给用户之前先用它看一眼;用户说视频「不对」「黑屏」「没声音」「手机放不了」时也用它。' +
557
+ `at 给几个时间点(如 ["0:12", "1:05"],最多 ${MAX_AT} 个)看那几刻的大图;from / to 只看一段;frames 改取几帧(最多 ${MAX_FRAMES})。` +
558
+ '要逐帧看某一刻附近(闪一下、跳帧、颜色突变),把 from / to 缩到那前后零点几秒、frames 设大,不用自己写网页或脚本去解码。',
559
+ parameters: {
560
+ path: { type: 'string', required: true, description: '本地文件的绝对路径,例如 /Users/me/Movies/demo.mp4' },
561
+ at: { type: 'array', items: { type: 'string' }, description: `可选:看这几刻的大图,写成 "0:12"、"1:05.5" 或秒数 "83"(最多 ${MAX_AT} 个)` },
562
+ from: { type: 'string', description: '可选:只看从这里开始的一段,如 "0:30"' },
563
+ to: { type: 'string', description: '可选:只看到这里为止,如 "1:00"' },
564
+ frames: { type: 'number', description: `可选:拼图里取几帧(默认按长短 4–12 帧,最多 ${MAX_FRAMES})` },
565
+ },
566
+ output: {
567
+ schema: {
568
+ type: 'object',
569
+ additionalProperties: false,
570
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
571
+ required: ['text'],
572
+ },
573
+ render: (_args, value) => withImages(value),
574
+ },
575
+ async execute(args, exec) {
576
+ const a = args || {}
577
+ const raw = String(a.path || '')
578
+ if (!raw) throw new Error('path 不能为空')
579
+ const session = exec && exec.agent && exec.agent.session
580
+ const file = resolvePath(raw, session ? sessionCwd(ctx, session) : '')
581
+ if (!file) throw new Error(`拿不到会话的工作目录,请给绝对路径:${raw}`)
582
+ let real, st
583
+ try {
584
+ real = await fs.promises.realpath(file)
585
+ st = await fs.promises.stat(real)
586
+ } catch {
587
+ throw new Error(`找不到文件:${file}`)
588
+ }
589
+ if (!st.isFile()) throw new Error(`不是文件:${file}`)
590
+ const ext = path.extname(real).toLowerCase()
591
+ if (UNPLAYABLE_EXT.has(ext)) {
592
+ const { out, cmd } = convertHint(real)
593
+ return { text: `${ext} 格式浏览器打不开,看不了。先转成 ${path.extname(out)}:\n${cmd}\n转好后再用 look_video 看 ${out}` }
594
+ }
595
+ const kind = kindOf(real)
596
+ if (!kind) return { text: `不是视频 / 音频文件(${ext || '没有扩展名'})。能看的:${[...VIDEO_EXT, ...AUDIO_EXT].join(' ')}` }
597
+
598
+ const at = (Array.isArray(a.at) ? a.at : a.at ? [a.at] : []).slice(0, MAX_AT).map(parseTime)
599
+ if (at.some((t) => Number.isNaN(t))) return { text: `认不出时间:${JSON.stringify(a.at)}。写成 "0:12"、"1:05.5" 或秒数 "83"。` }
600
+ const from = a.from != null && a.from !== '' ? parseTime(a.from) : 0
601
+ const to = a.to != null && a.to !== '' ? parseTime(a.to) : Infinity
602
+ if (Number.isNaN(from) || Number.isNaN(to)) return { text: `认不出 from / to:${a.from ?? ''} / ${a.to ?? ''}。写成 "0:30" 这样。` }
603
+ if (to <= from) return { text: `to(${a.to})要比 from(${a.from})晚。` }
604
+
605
+ const probe = ISO_EXT.has(ext) ? await probeFile(real).catch(() => null) : null
606
+ const base = { name: path.basename(real), size: fmtSize(st.size), probe, kind, ext }
607
+ const b = getBrowser()
608
+ if (!b || !b.available()) {
609
+ return { text: `${formatLook({ ...base, res: null })}\n\n这台电脑上没找到 Chrome / Edge,看不了画面、量不了声音(上面只是文件头里的信息)。` }
610
+ }
611
+ const noTrack = probe && !probe.audio && !probe.noMoov
612
+ const long = probe && probe.duration > MAX_AUDIO_SECONDS
613
+ const audioSkipped = st.size > MAX_AUDIO_BYTES ? `文件太大(${fmtSize(st.size)}),没量声音。` : long ? `超过 ${MAX_AUDIO_SECONDS / 60} 分钟,没量声音。` : ''
614
+ const srv = await serveOne(real)
615
+ let res
616
+ try {
617
+ res = await inspect(b, { page: srv.page, src: srv.src, kind, at: at.length ? at : null, from, to, frames: a.frames, wantAudio: !noTrack && !audioSkipped })
618
+ } catch (e) {
619
+ log(e)
620
+ return { text: `${formatLook({ ...base, res: null })}\n\n无头浏览器没看成(${e && e.message ? e.message : e}),上面只是文件头里的信息。` }
621
+ } finally {
622
+ srv.close()
623
+ }
624
+ const jpgs = res.jpgs.map((x) => Buffer.from(x, 'base64'))
625
+ const kept = jpgs.length ? await keepImages(ctx, jpgs, path.basename(real, ext), log) : {}
626
+ const text = formatLook({ ...base, res, audioSkipped, images: kept.images ? kept.images.length : 0, files: kept.files || [] })
627
+ return kept.images ? { text, images: kept.images } : { text }
628
+ },
629
+ })
630
+ }