@orderg-x/dsh-video-preview 0.4.1 → 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,12 +151,15 @@ 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 通道)。
136
164
 
137
165
  ## 反馈
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/index.js CHANGED
@@ -25,8 +25,12 @@ import fs from 'node:fs'
25
25
  import os from 'node:os'
26
26
  import path from 'node:path'
27
27
  import crypto from 'node:crypto'
28
+ import http from 'node:http'
28
29
  import { pipeline } from 'node:stream'
29
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'
30
34
 
31
35
  export const name = '@orderg-x/dsh-video-preview'
32
36
  export const inject = ['tools', 'webServer', 'sessionProjections']
@@ -448,6 +452,15 @@ export function apply(ctx) {
448
452
 
449
453
  ctx.tools.register(tool)
450
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
+
451
464
  if (typeof ctx.on === 'function') {
452
465
  autoPresent(ctx, {
453
466
  kindOf,
@@ -457,3 +470,161 @@ export function apply(ctx) {
457
470
  })
458
471
  }
459
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
+ }
package/lib/look.js ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * look_video 的里子:在无头浏览器里打开视频,按时间取帧(拼成一张带时间的图,或几张大图),
3
+ * 把声音解出来量(有没有声、多大、哪段没声、有没有爆音、是不是只有一边有声),再把这些变成给模型看的字。
4
+ *
5
+ * pageTools 注进页面的函数(在浏览器里跑,Node 不执行):open / frames / audio
6
+ * inspect 开页面、取帧、量声音 → 原始结果
7
+ * findings 原始结果 + 文件头信息(probe.js)→ 问题清单
8
+ * formatLook 给模型的字
9
+ */
10
+
11
+ import { videoName, audioName, compatOf } from './probe.js'
12
+
13
+ export const MAX_FRAMES = 16 // 拼图里最多几帧
14
+ export const MAX_AT = 6 // at 一次最多看几刻的大图
15
+ export const MAX_AUDIO_BYTES = 400 << 20 // 再大就不解声音了(要整个读进浏览器)
16
+ export const MAX_AUDIO_SECONDS = 30 * 60 // 再长也不解(解出来的采样太多)
17
+ const SHEET_W = 1536 // 拼图宽
18
+ const BIG_EDGE = 1280 // 单张大图的长边
19
+
20
+ /** 「1:23」「1:23.5」「83」「83s」「01:02:03」→ 秒;认不出 → NaN */
21
+ export function parseTime(v) {
22
+ if (typeof v === 'number') return Number.isFinite(v) && v >= 0 ? v : NaN
23
+ const s = String(v ?? '').trim().replace(/s$/i, '')
24
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(s)) return NaN
25
+ return s.split(':').reduce((sum, x) => sum * 60 + Number(x), 0)
26
+ }
27
+
28
+ /** 秒 → 「0:05」「1:23」「1:02:03」;短片(一分钟以内)带一位小数,取帧的时间才分得清 */
29
+ export function fmtTime(t, short = false) {
30
+ const x = Math.max(0, t)
31
+ if (short) {
32
+ const s = Math.round(x * 10) / 10
33
+ return `0:${s < 10 ? '0' : ''}${s.toFixed(1)}`
34
+ }
35
+ const total = Math.round(x)
36
+ const h = Math.floor(total / 3600)
37
+ const m = Math.floor((total % 3600) / 60)
38
+ const sec = String(total % 60).padStart(2, '0')
39
+ return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
40
+ }
41
+
42
+ /** 默认取几帧:越长取越多 */
43
+ export function frameCount(duration) {
44
+ if (duration <= 3) return 4
45
+ if (duration <= 10) return 6
46
+ if (duration <= 60) return 9
47
+ return 12
48
+ }
49
+
50
+ /**
51
+ * 在 [from, to] 里均匀取 n 帧,头尾都取(第一帧就是封面,最后一帧看收尾)。
52
+ * 最后一帧往前让一点:正好落在结尾时很多视频解不出画面。
53
+ */
54
+ export function pickTimes(duration, { frames, from = 0, to = duration } = {}) {
55
+ const end = Math.min(duration, to)
56
+ const start = Math.min(Math.max(0, from), end)
57
+ const n = Math.max(1, Math.min(MAX_FRAMES, Math.round(frames || frameCount(end - start))))
58
+ const last = Math.max(start, end - Math.min(0.1, (end - start) / 20))
59
+ if (n === 1 || last <= start) return [start]
60
+ return Array.from({ length: n }, (_, i) => Math.round((start + ((last - start) * i) / (n - 1)) * 1000) / 1000)
61
+ }
62
+
63
+ // ── 页面里跑的 ──────────────────────────────────────────────────────────────
64
+
65
+ /* eslint-disable */
66
+ /** 注进页面:window.__dshLook = { open, frames, audio }。这个函数只在浏览器里执行 */
67
+ export function pageTools() {
68
+ if (window.__dshLook) return true
69
+ let v = null
70
+ const wait = (el, ev, ms) => new Promise((ok) => {
71
+ const t = setTimeout(() => done(false), ms)
72
+ const done = (x) => { clearTimeout(t); el.removeEventListener(ev, on); ok(x) }
73
+ const on = () => done(true)
74
+ el.addEventListener(ev, on)
75
+ })
76
+ async function seek(t) {
77
+ if (Math.abs(v.currentTime - t) < 0.0005 && v.readyState >= 2) return true
78
+ v.currentTime = t
79
+ const ok = await wait(v, 'seeked', 8000)
80
+ // 跳完再等这一帧真画出来(有的视频 seeked 早于画面);等不到也照样往下走
81
+ if ('requestVideoFrameCallback' in v) await Promise.race([new Promise((r) => v.requestVideoFrameCallback(() => r())), new Promise((r) => setTimeout(r, 150))])
82
+ return ok
83
+ }
84
+ async function open(src) {
85
+ v = document.createElement('video')
86
+ v.muted = true
87
+ v.preload = 'auto'
88
+ v.playsInline = true
89
+ v.src = src
90
+ document.body.append(v)
91
+ const ok = await Promise.race([wait(v, 'loadeddata', 20000), new Promise((r) => v.addEventListener('error', () => r(false), { once: true }))])
92
+ if (!ok) return { error: v.error ? v.error.code : 0, message: v.error ? v.error.message : 'timeout' }
93
+ let duration = v.duration
94
+ // 浏览器录的 webm 常常不写时长(Infinity):跳到很远的地方,浏览器就知道多长了
95
+ if (!Number.isFinite(duration)) {
96
+ v.currentTime = 1e9
97
+ await wait(v, 'seeked', 8000)
98
+ duration = Number.isFinite(v.duration) ? v.duration : v.currentTime
99
+ v.currentTime = 0
100
+ await wait(v, 'seeked', 8000)
101
+ }
102
+ return { duration, w: v.videoWidth, h: v.videoHeight }
103
+ }
104
+ const luma = (d, i) => d[i] * 0.2126 + d[i + 1] * 0.7152 + d[i + 2] * 0.0722
105
+ /**
106
+ * 一帧的亮度、反差、亮着的点占多少(千分之几,160×90 上数,黑底上的字也数得到),
107
+ * 和一个 16×9 的灰度指纹(比前后帧是不是一样)
108
+ */
109
+ function stats(sg, mg) {
110
+ const d = sg.getImageData(0, 0, 16, 9).data
111
+ const fp = []
112
+ let sum = 0
113
+ for (let i = 0; i < d.length; i += 4) { const y = luma(d, i); fp.push(Math.round(y)); sum += y }
114
+ const mean = sum / fp.length
115
+ let sq = 0
116
+ for (const y of fp) sq += (y - mean) * (y - mean)
117
+ const m = mg.getImageData(0, 0, 160, 90).data
118
+ let lit = 0
119
+ for (let i = 0; i < m.length; i += 4) if (luma(m, i) > 48) lit++
120
+ return { y: Math.round(mean), sd: Math.round(Math.sqrt(sq / fp.length)), lit: Math.round((lit / (m.length / 4)) * 10000) / 10, fp }
121
+ }
122
+ function label(g, text, x, y, size) {
123
+ g.font = `600 ${size}px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`
124
+ const w = g.measureText(text).width + size * 0.8
125
+ const h = size * 1.5
126
+ g.fillStyle = 'rgba(0,0,0,.72)'
127
+ g.beginPath()
128
+ g.roundRect(x, y, w, h, h / 2)
129
+ g.fill()
130
+ g.fillStyle = '#fff'
131
+ g.textBaseline = 'middle'
132
+ g.fillText(text, x + size * 0.4, y + h / 2)
133
+ }
134
+ /**
135
+ * 取帧。sheet:拼成一张(cols 列、每格 cw×ch,左上角标时间);否则每帧一张大图(长边 edge)。
136
+ * 返回 { jpgs: [base64…], stats: [{ t, y, sd, lit, fp, ok }] }
137
+ */
138
+ async function frames(times, labels, o) {
139
+ const W = v.videoWidth || 16
140
+ const H = v.videoHeight || 9
141
+ const small = document.createElement('canvas')
142
+ small.width = 16
143
+ small.height = 9
144
+ const sg = small.getContext('2d', { willReadFrequently: true })
145
+ const mid = document.createElement('canvas')
146
+ mid.width = 160
147
+ mid.height = 90
148
+ const mg = mid.getContext('2d', { willReadFrequently: true })
149
+ const out = { jpgs: [], stats: [] }
150
+ let sheet = null
151
+ let g = null
152
+ if (o.sheet) {
153
+ sheet = document.createElement('canvas')
154
+ sheet.width = o.cols * o.cw + (o.cols + 1) * o.gap
155
+ sheet.height = Math.ceil(times.length / o.cols) * o.ch + (Math.ceil(times.length / o.cols) + 1) * o.gap
156
+ g = sheet.getContext('2d')
157
+ g.fillStyle = '#1b1d22'
158
+ g.fillRect(0, 0, sheet.width, sheet.height)
159
+ }
160
+ for (let i = 0; i < times.length; i++) {
161
+ const ok = await seek(times[i])
162
+ sg.drawImage(v, 0, 0, 16, 9)
163
+ mg.drawImage(v, 0, 0, 160, 90)
164
+ out.stats.push({ t: times[i], ok, ...stats(sg, mg) })
165
+ if (sheet) {
166
+ const x = o.gap + (i % o.cols) * (o.cw + o.gap)
167
+ const y = o.gap + Math.floor(i / o.cols) * (o.ch + o.gap)
168
+ g.drawImage(v, x, y, o.cw, o.ch)
169
+ label(g, labels[i], x + 6, y + 6, Math.max(13, Math.round(o.cw / 18)))
170
+ } else {
171
+ const k = Math.min(1, o.edge / Math.max(W, H))
172
+ const c = document.createElement('canvas')
173
+ c.width = Math.round(W * k)
174
+ c.height = Math.round(H * k)
175
+ const cg = c.getContext('2d')
176
+ cg.drawImage(v, 0, 0, c.width, c.height)
177
+ label(cg, labels[i], 10, 10, Math.max(16, Math.round(c.width / 40)))
178
+ out.jpgs.push(c.toDataURL('image/jpeg', 0.85).split(',')[1])
179
+ }
180
+ }
181
+ if (sheet) out.jpgs.push(sheet.toDataURL('image/jpeg', 0.85).split(',')[1])
182
+ return out
183
+ }
184
+ /**
185
+ * 把声音整个解出来(16 kHz)量:每声道的峰值、均方根、削波的比例,和每 0.1 秒一格的响度(dB,混成一路)。
186
+ * 没有声轨 / 解不出来 → { error }
187
+ */
188
+ async function audio(src) {
189
+ let buf
190
+ try {
191
+ const ab = await (await fetch(src)).arrayBuffer()
192
+ buf = await new OfflineAudioContext(1, 1, 16000).decodeAudioData(ab)
193
+ } catch (e) {
194
+ return { error: String((e && e.message) || e) }
195
+ }
196
+ const n = buf.length
197
+ const chs = buf.numberOfChannels
198
+ const data = Array.from({ length: chs }, (_, c) => buf.getChannelData(c))
199
+ const per = data.map((d) => {
200
+ let peak = 0
201
+ let sq = 0
202
+ let clip = 0
203
+ for (let i = 0; i < n; i++) { const a = Math.abs(d[i]); if (a > peak) peak = a; sq += d[i] * d[i]; if (a >= 0.99) clip++ }
204
+ return { peak, rms: Math.sqrt(sq / Math.max(1, n)), clip }
205
+ })
206
+ const step = Math.round(buf.sampleRate / 10)
207
+ const win = []
208
+ for (let s = 0; s < n; s += step) {
209
+ let sq = 0
210
+ const e = Math.min(n, s + step)
211
+ for (let c = 0; c < chs; c++) for (let i = s; i < e; i++) sq += data[c][i] * data[c][i]
212
+ const r = Math.sqrt(sq / Math.max(1, (e - s) * chs))
213
+ win.push(r > 0 ? Math.max(-100, Math.round(20 * Math.log10(r))) : -100)
214
+ }
215
+ return { duration: buf.duration, channels: chs, samples: n, per, win }
216
+ }
217
+ window.__dshLook = { open, frames, audio }
218
+ return true
219
+ }
220
+ /* eslint-enable */
221
+
222
+ // ── 开页面量 ─────────────────────────────────────────────────────────────────
223
+
224
+ /** 拼图的排法:横的 3–4 列,竖的 4–6 列;整张长边不超过 1568 */
225
+ export function sheetLayout(w, h, n) {
226
+ const portrait = h > w
227
+ const cols = Math.min(n, portrait ? (n <= 4 ? n : n <= 10 ? 5 : 6) : n <= 4 ? 2 : n <= 9 ? 3 : 4)
228
+ const rows = Math.ceil(n / cols)
229
+ const gap = 8
230
+ let cw = Math.floor((SHEET_W - (cols + 1) * gap) / cols)
231
+ let ch = Math.round((cw * (h || 9)) / (w || 16))
232
+ const tall = rows * ch + (rows + 1) * gap
233
+ if (tall > 1568) {
234
+ const k = (1568 - (rows + 1) * gap) / (rows * ch)
235
+ cw = Math.floor(cw * k)
236
+ ch = Math.floor(ch * k)
237
+ }
238
+ return { cols, cw, ch, gap }
239
+ }
240
+
241
+ /**
242
+ * 在无头浏览器里看一个文件:src 是这个文件在本机临时服务上的地址(同源,能跳进度、能整个读)。
243
+ * kind 'video' | 'audio'
244
+ * at 要看的时间点(秒);给了就出几张大图,不给就按 from / to / frames 均匀取、拼成一张
245
+ * wantAudio 要不要量声音(文件太大、明知没声轨时不量)
246
+ * → { info: { duration, w, h } | { error }, times, sheet, jpgs, stats, audio }
247
+ */
248
+ export async function inspect(browser, { page, src, kind, at, from, to, frames, wantAudio = true }) {
249
+ return browser.withPage({ url: page, w: 800, h: 600 }, async (p) => {
250
+ await p.eval(`(${pageTools.toString()})()`)
251
+ const info = await p.eval(`window.__dshLook.open(${JSON.stringify(src)})`)
252
+ const res = { info, times: [], sheet: false, jpgs: [], stats: [], audio: null }
253
+ if (info && !info.error && kind === 'video' && info.w > 0) {
254
+ const short = info.duration < 60
255
+ const times = at && at.length
256
+ ? at.map((t) => Math.min(Math.max(0, t), Math.max(0, info.duration - 0.05)))
257
+ : pickTimes(info.duration, { frames, from, to })
258
+ const labels = times.map((t) => fmtTime(t, short))
259
+ const sheet = !(at && at.length)
260
+ const o = sheet ? { sheet: true, ...sheetLayout(info.w, info.h, times.length) } : { sheet: false, edge: BIG_EDGE }
261
+ // 分几次取:一次 eval 有时间上限,帧多的长视频分开取稳一点
262
+ if (sheet) {
263
+ const r = await p.eval(`window.__dshLook.frames(${JSON.stringify(times)}, ${JSON.stringify(labels)}, ${JSON.stringify(o)})`)
264
+ res.jpgs = r.jpgs
265
+ res.stats = r.stats
266
+ } else {
267
+ for (let i = 0; i < times.length; i++) {
268
+ const r = await p.eval(`window.__dshLook.frames(${JSON.stringify([times[i]])}, ${JSON.stringify([labels[i]])}, ${JSON.stringify(o)})`)
269
+ res.jpgs.push(...r.jpgs)
270
+ res.stats.push(...r.stats)
271
+ }
272
+ }
273
+ res.times = times
274
+ res.sheet = sheet
275
+ }
276
+ // 画面打不开(比如这台电脑解不了 HEVC)声音也可能解得出来,照样量;量的时候出错(太长超时)只算声音没量成
277
+ if (wantAudio && info && info.duration > MAX_AUDIO_SECONDS) res.audio = { skipped: `超过 ${MAX_AUDIO_SECONDS / 60} 分钟,没量声音。` }
278
+ else if (wantAudio) {
279
+ try {
280
+ res.audio = await p.eval(`window.__dshLook.audio(${JSON.stringify(src)})`)
281
+ } catch (e) {
282
+ res.audio = { error: String((e && e.message) || e), failed: true }
283
+ }
284
+ }
285
+ return res
286
+ })
287
+ }
288
+
289
+ // ── 看出来的问题 ─────────────────────────────────────────────────────────────
290
+
291
+ const SILENT_DB = -50 // 这一格比这还小就算没声
292
+ const QUIET_DB = -35 // 有声的部分平均比这还小:声音太小
293
+
294
+ /** 每 0.1 秒一格的响度 → 没声的段 [[开始秒, 结束秒]](至少 minSec 秒) */
295
+ export function silences(win, minSec = 1.5) {
296
+ const out = []
297
+ let s = -1
298
+ for (let i = 0; i <= win.length; i++) {
299
+ const quiet = i < win.length && win[i] < SILENT_DB
300
+ if (quiet && s < 0) s = i
301
+ if (!quiet && s >= 0) {
302
+ if ((i - s) / 10 >= minSec) out.push([s / 10, i / 10])
303
+ s = -1
304
+ }
305
+ }
306
+ return out
307
+ }
308
+
309
+ const db = (x) => (x > 0 ? Math.round(20 * Math.log10(x) * 10) / 10 : -100)
310
+
311
+ /** 声音 → { summary, issues: [{ level, text }] } */
312
+ export function audioFindings(a, { duration, short = false } = {}) {
313
+ if (!a || a.error) return null
314
+ const issues = []
315
+ const peak = Math.max(...a.per.map((c) => c.peak))
316
+ const rms = Math.sqrt(a.per.reduce((s, c) => s + c.rms * c.rms, 0) / a.per.length)
317
+ const loud = a.win.filter((x) => x >= SILENT_DB)
318
+ const active = loud.length ? Math.round(10 * Math.log10(loud.reduce((s, x) => s + 10 ** (x / 10), 0) / loud.length)) : -100
319
+ const total = duration || a.duration
320
+ const t = (x) => fmtTime(x, short)
321
+ if (db(peak) < SILENT_DB) {
322
+ issues.push({ level: 'bad', text: `整段几乎没有声音(最大 ${db(peak)} dB)` })
323
+ return { summary: `有声轨,但几乎没声(最大 ${db(peak)} dB)`, issues, peak: db(peak), active }
324
+ }
325
+ for (const [s, e] of silences(a.win)) {
326
+ const atStart = s < 0.15
327
+ const atEnd = e >= a.win.length / 10 - 0.15
328
+ if (atStart && atEnd) continue
329
+ if (atStart) issues.push({ level: 'warn', text: `开头 ${t(0)}–${t(e)} 没声音` })
330
+ else if (atEnd) issues.push({ level: 'warn', text: `结尾 ${t(s)}–${t(Math.min(e, total))} 没声音` })
331
+ else if (e - s >= 3) issues.push({ level: 'warn', text: `${t(s)}–${t(e)} 没声音(${Math.round(e - s)} 秒)` })
332
+ }
333
+ if (active < QUIET_DB) issues.push({ level: 'warn', text: `声音偏小:有声的部分平均 ${active} dB(一般 -20 dB 左右),手机外放可能听不清` })
334
+ const clipRate = a.per.reduce((s, c) => s + c.clip, 0) / Math.max(1, a.samples * a.per.length)
335
+ if (clipRate > 0.001) issues.push({ level: 'bad', text: `有爆音(削波):${(clipRate * 100).toFixed(1)}% 的采样顶到了最大值;把音量调小再导出(ffmpeg 加 -af volume=-6dB)` })
336
+ if (a.channels === 2) {
337
+ const [l, r] = a.per.map((c) => db(c.rms))
338
+ if (l > SILENT_DB + 10 && r < SILENT_DB) issues.push({ level: 'bad', text: '只有左声道有声音(右边没声):戴耳机只有一边响;转成单声道或复制到两边(ffmpeg 加 -af pan=stereo|c0=c0|c1=c0)' })
339
+ if (r > SILENT_DB + 10 && l < SILENT_DB) issues.push({ level: 'bad', text: '只有右声道有声音(左边没声):戴耳机只有一边响;复制到两边(ffmpeg 加 -af pan=stereo|c0=c1|c1=c1)' })
340
+ }
341
+ return { summary: `有声,${a.channels === 1 ? '单声道' : a.channels === 2 ? '立体声' : `${a.channels} 声道`},有声的部分平均 ${active} dB,最大 ${db(peak)} dB`, issues, peak: db(peak), active }
342
+ }
343
+
344
+ const BLACK_Y = 16 // 平均亮度
345
+ const BLACK_LIT = 3 // 亮着的点不到千分之三(黑底上有字、有画面的不算黑屏;只有鼠标指针的还算)
346
+ const SAME = 2.5 // 两帧指纹平均差多少以内算一样
347
+
348
+ const fpDiff = (a, b) => a.reduce((s, x, i) => s + Math.abs(x - b[i]), 0) / a.length
349
+
350
+ /** 取到的帧 → 问题:黑屏、画面一直没变 */
351
+ export function frameFindings(stats, { duration, short = false, sheet = true } = {}) {
352
+ const issues = []
353
+ if (!stats.length) return issues
354
+ const t = (x) => fmtTime(x, short)
355
+ const black = stats.filter((s) => s.y <= BLACK_Y && s.lit <= BLACK_LIT)
356
+ const failed = stats.filter((s) => !s.ok)
357
+ if (failed.length) issues.push({ level: 'warn', text: `有 ${failed.length} 帧跳不过去(${failed.map((s) => t(s.t)).join('、')}):文件可能不能拖进度条(浏览器录的、没写索引的),那几格可能是旁边的画面` })
358
+ if (sheet && black.length === stats.length) {
359
+ issues.push({ level: 'bad', text: `取的 ${stats.length} 帧全是黑的:画面可能没录上(录屏没权限、录错了屏幕、画面被遮住)` })
360
+ return issues
361
+ }
362
+ if (black.length) {
363
+ const first = stats[0] === black[0]
364
+ const last = stats[stats.length - 1] === black[black.length - 1] && stats.length > 1
365
+ if (first && sheet) issues.push({ level: 'warn', text: `第一帧(${t(stats[0].t)})是黑的:封面会是黑的,有的平台拿第一帧当缩略图` })
366
+ const mid = black.filter((s) => (s !== stats[0] || !sheet) && (s !== stats[stats.length - 1] || !sheet))
367
+ if (mid.length) issues.push({ level: 'warn', text: `这几刻是黑屏:${mid.map((s) => t(s.t)).join('、')}` })
368
+ if (last && sheet) issues.push({ level: 'warn', text: `最后一帧(${t(stats[stats.length - 1].t)})是黑的:结尾可能多了一段黑屏` })
369
+ }
370
+ if (sheet && stats.length >= 3 && duration > 2) {
371
+ const same = stats.slice(1).every((s, i) => fpDiff(s.fp, stats[i].fp) <= SAME)
372
+ if (same) issues.push({ level: 'warn', text: `取的 ${stats.length} 帧几乎一模一样:整段画面可能没动(录屏卡住了、录到了静止的窗口);要是本来就是静态画面就没事` })
373
+ else {
374
+ // 连着好几格一样,而且占了一大段
375
+ let s = 0
376
+ for (let i = 1; i <= stats.length; i++) {
377
+ const cont = i < stats.length && fpDiff(stats[i].fp, stats[i - 1].fp) <= SAME
378
+ if (!cont) {
379
+ if (i - s >= 4 && stats[i - 1].t - stats[s].t >= duration * 0.3) issues.push({ level: 'warn', text: `${t(stats[s].t)}–${t(stats[i - 1].t)} 画面没变(${i - s} 帧一样):要是本该有动作,可能卡住了` })
380
+ s = i
381
+ }
382
+ }
383
+ }
384
+ }
385
+ return issues
386
+ }
387
+
388
+ // ── 给模型的字 ───────────────────────────────────────────────────────────────
389
+
390
+ const ERR = { 1: '读取被中止', 2: '网络错误(文件读不全)', 3: '解码出错(文件可能坏了,或者编码浏览器不支持)', 4: '浏览器不支持这个格式或编码' }
391
+
392
+ /**
393
+ * 拼成给模型的字。
394
+ * name, size 文件名、大小(字符串)
395
+ * probe probe.js 读到的文件头(可能是 null)
396
+ * res inspect 的结果(没浏览器时是 null)
397
+ * kind 'video' | 'audio'
398
+ * audioSkipped 没量声音的原因(可选)
399
+ * images 实际交出去几张图
400
+ */
401
+ /** 不是 MP4 / MOV 的容器:手机上能不能放只看扩展名就知道个大概 */
402
+ const CONTAINER = {
403
+ '.webm': 'WebM:iPhone 上的微信和一些老设备放不了;要发给别人看就转成 MP4(H.264 + AAC)',
404
+ '.mkv': 'MKV:手机、微信、网页大多放不了;要发给别人看就转成 MP4(H.264 + AAC),多数时候不用重新编码:ffmpeg -i 原文件 -c copy 新文件.mp4',
405
+ '.ogv': 'OGV:苹果设备和微信放不了;要发给别人看就转成 MP4(H.264 + AAC)',
406
+ }
407
+
408
+ export function formatLook({ name, size, probe, res, kind, ext = '', audioSkipped = '', images = 0, files = [] }) {
409
+ const info = res && res.info
410
+ const duration = (info && !info.error && info.duration) || (probe && probe.duration) || 0
411
+ const short = duration > 0 && duration < 60
412
+ const v = probe && probe.video
413
+ const w = (info && info.w) || (v && (v.dw || v.w)) || 0
414
+ const h = (info && info.h) || (v && (v.dh || v.h)) || 0
415
+ const codec = [videoName(v), audioName(probe && probe.audio)].filter(Boolean).join(' + ')
416
+ const head = [
417
+ kind === 'video' && w ? `${w}×${h}` : '',
418
+ duration ? fmtTime(duration) : '',
419
+ v && v.fps ? `${Math.round(v.fps)} fps` : '',
420
+ codec,
421
+ size,
422
+ ].filter(Boolean).join(',')
423
+ const lines = [`看了 ${name}(${head}):`]
424
+
425
+ if (info && info.error) {
426
+ lines.push(`浏览器打不开:${ERR[info.error] || info.message || '原因不明'}。`)
427
+ }
428
+ if (res && res.jpgs.length) {
429
+ if (res.sheet) {
430
+ lines.push(`图 1:${res.times.length} 帧,从 ${fmtTime(res.times[0], short)} 到 ${fmtTime(res.times[res.times.length - 1], short)} 均匀取,从左到右、从上到下,每格左上角是时间。`)
431
+ } else {
432
+ res.times.forEach((t, i) => lines.push(`图 ${i + 1}:${fmtTime(t, short)} 那一刻(原画面缩到长边 ${BIG_EDGE} 以内)。`))
433
+ }
434
+ if (!images && files.length) lines.push(`(这个 DSH 版本没法把图直接给你,存成了文件:\n${files.join('\n')})`)
435
+ } else if (kind === 'video' && info && !info.error && !info.w) {
436
+ lines.push('这个文件里没有画面(只有声音)。')
437
+ }
438
+
439
+ const issues = []
440
+ if (res && res.stats.length) issues.push(...frameFindings(res.stats, { duration, short, sheet: res.sheet }))
441
+
442
+ // 声音
443
+ const a = res && res.audio
444
+ const hasTrack = probe ? !!probe.audio : null
445
+ if (audioSkipped || (a && a.skipped)) lines.push(`声音:${audioSkipped || a.skipped}`)
446
+ else if (probe && !probe.audio && kind === 'video' && !probe.noMoov) {
447
+ lines.push('声音:没有声轨(这个视频是哑的)。要是本该有配音 / 背景音乐,是合成时没把声音放进去。')
448
+ } else if (a && a.failed) {
449
+ lines.push(`声音:没量成(${a.error})。`)
450
+ } else if (a && a.error) {
451
+ lines.push(hasTrack ? `声音:有声轨(${audioName(probe.audio)}),但浏览器解不出来。` : '声音:没有声轨,或者浏览器解不出来。')
452
+ } else if (a) {
453
+ const f = audioFindings(a, { duration, short })
454
+ lines.push(`声音:${f.summary}。`)
455
+ issues.push(...f.issues)
456
+ }
457
+
458
+ issues.push(...compatOf(probe))
459
+ if (CONTAINER[ext]) issues.push({ level: 'warn', text: CONTAINER[ext] })
460
+ if (issues.length) {
461
+ lines.push('', '问题:')
462
+ for (const it of issues) lines.push(`- ${it.level === 'bad' ? '【严重】' : ''}${it.text}`)
463
+ } else if (res && !(info && info.error)) {
464
+ // 只说查过的
465
+ const ok = [
466
+ res.stats.length ? '画面没有黑屏、没卡住' : '',
467
+ a && !a.error && !a.skipped ? '声音正常' : '',
468
+ probe ? '编码手机上能放' : '',
469
+ ].filter(Boolean)
470
+ if (ok.length) lines.push('', `没发现问题:${ok.join(',')}。`)
471
+ }
472
+ if (res && res.sheet && res.jpgs.length) lines.push('', `要看某一刻的大图:look_video 带 at,例如 at: ["${fmtTime(duration / 2, short)}"];只看一段:from / to。`)
473
+ else if (res && res.jpgs.length) lines.push('', '要逐帧看那附近(找闪一下、跳帧、颜色突变):from / to 缩到那一小段(比如前后 0.3 秒),frames 设成 16。')
474
+ return lines.join('\n')
475
+ }
package/lib/probe.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * 不解码,只读 MP4 / MOV / M4A 的「盒子」:多长、画面多大、什么编码、有没有声轨、文件头(moov)在前还是在后。
3
+ * 手机能不能播主要看这些(H.264 是不是 4:2:0、HEVC 的标签、moov 在不在前面)。
4
+ * 只读顶层盒子的头和 moov(最多 64 MB),大文件不进内存。认不出来的格式(webm、mkv……)返回 null。
5
+ */
6
+
7
+ import fs from 'node:fs'
8
+
9
+ const MAX_MOOV = 64 << 20
10
+ const TOP = new Set(['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip', 'pnot', 'uuid', 'moof', 'mfra', 'styp', 'sidx', 'meta'])
11
+
12
+ /** buf 里一个挨一个的盒子:{ type, body }(body 是去掉盒子头的那段) */
13
+ function* boxes(buf) {
14
+ let p = 0
15
+ while (p + 8 <= buf.length) {
16
+ let len = buf.readUInt32BE(p)
17
+ const type = buf.toString('latin1', p + 4, p + 8)
18
+ let hdr = 8
19
+ if (len === 1) {
20
+ if (p + 16 > buf.length) return
21
+ len = Number(buf.readBigUInt64BE(p + 8))
22
+ hdr = 16
23
+ } else if (len === 0) len = buf.length - p
24
+ if (len < hdr || p + len > buf.length) return
25
+ yield { type, body: buf.subarray(p + hdr, p + len) }
26
+ p += len
27
+ }
28
+ }
29
+
30
+ const child = (buf, type) => {
31
+ if (!buf) return null
32
+ for (const b of boxes(buf)) if (b.type === type) return b.body
33
+ return null
34
+ }
35
+ const dig = (buf, ...types) => types.reduce((b, t) => child(b, t), buf)
36
+
37
+ /** mvhd / mdhd:{ scale, dur }(版本 1 的时长是 64 位) */
38
+ function timeOf(body) {
39
+ if (!body || body.length < 20) return null
40
+ if (body[0] === 1) return body.length >= 32 ? { scale: body.readUInt32BE(20), dur: Number(body.readBigUInt64BE(24)) } : null
41
+ return { scale: body.readUInt32BE(12), dur: body.readUInt32BE(16) }
42
+ }
43
+
44
+ /** esds 里的 objectTypeIndication:0x40 / 0x66-0x68 是 AAC,0x69 / 0x6B 是 MP3 */
45
+ function esdsType(esds) {
46
+ if (!esds || esds.length < 6) return 0
47
+ let p = 4 // version + flags
48
+ const len = () => { let n = 0; for (let i = 0; i < 4 && p < esds.length; i++) { const b = esds[p++]; n = (n << 7) | (b & 0x7f); if (!(b & 0x80)) break } return n }
49
+ while (p < esds.length) {
50
+ const tag = esds[p++]
51
+ const n = len()
52
+ if (tag === 0x03) { const flags = esds[p + 2]; p += 3; if (flags & 0x80) p += 2; if (flags & 0x40) p += 1 + esds[p]; if (flags & 0x20) p += 2; continue }
53
+ if (tag === 0x04) return esds[p] || 0
54
+ p += n
55
+ }
56
+ return 0
57
+ }
58
+
59
+ /** 一条轨道的 stsd 第一项:编码标签和细节 */
60
+ function sampleEntry(stsd, kind) {
61
+ if (!stsd || stsd.length < 16) return null
62
+ const entries = stsd.subarray(8)
63
+ const first = boxes(entries).next().value
64
+ if (!first) return null
65
+ const e = { tag: first.type }
66
+ const b = first.body
67
+ if (kind === 'vide' && b.length >= 78) {
68
+ e.w = b.readUInt16BE(24)
69
+ e.h = b.readUInt16BE(26)
70
+ const kids = b.subarray(78)
71
+ const avcC = child(kids, 'avcC')
72
+ if (avcC && avcC.length >= 4) { e.profile = avcC[1]; e.level = avcC[3] }
73
+ const hvcC = child(kids, 'hvcC')
74
+ if (hvcC && hvcC.length >= 2) e.profile = hvcC[1] & 0x1f
75
+ // 颜色:nclx(MP4)/ nclc(MOV)= 原色、传输特性、矩阵;传输特性 16 = PQ、18 = HLG,就是 HDR
76
+ const colr = child(kids, 'colr')
77
+ if (colr && colr.length >= 10) {
78
+ const type = colr.toString('latin1', 0, 4)
79
+ if (type === 'nclx' || type === 'nclc') e.color = { prim: colr.readUInt16BE(4), trc: colr.readUInt16BE(6), matrix: colr.readUInt16BE(8), full: type === 'nclx' && colr.length >= 11 ? !!(colr[10] & 0x80) : undefined }
80
+ }
81
+ // 杜比视界:dvh1 / dvhe 标签,或者 hvc1 里带 dvcC / dvvC(iPhone 录的 HDR)
82
+ if (/^dv(h1|he|a1|av)$/.test(first.type) || child(kids, 'dvcC') || child(kids, 'dvvC')) e.dolby = true
83
+ const trc = e.color && e.color.trc
84
+ if (trc === 16 || trc === 18 || e.dolby) e.hdr = e.dolby ? '杜比视界' : trc === 18 ? 'HLG' : 'PQ'
85
+ } else if (kind === 'soun' && b.length >= 28) {
86
+ const ver = b.readUInt16BE(8)
87
+ e.channels = b.readUInt16BE(16)
88
+ e.rate = b.readUInt32BE(24) >>> 16
89
+ const skip = ver === 1 ? 16 : ver === 2 ? 36 : 0
90
+ const kids = b.subarray(28 + skip)
91
+ const esds = child(kids, 'esds') || dig(kids, 'wave', 'esds')
92
+ if (esds) e.oti = esdsType(esds)
93
+ }
94
+ return e
95
+ }
96
+
97
+ function parseTrak(trak) {
98
+ const tkhd = child(trak, 'tkhd')
99
+ const mdia = child(trak, 'mdia')
100
+ const hdlr = child(mdia, 'hdlr')
101
+ if (!hdlr || hdlr.length < 12) return null
102
+ const kind = hdlr.toString('latin1', 8, 12)
103
+ const t = timeOf(child(mdia, 'mdhd'))
104
+ const stbl = dig(mdia, 'minf', 'stbl')
105
+ const track = { kind, ...(sampleEntry(child(stbl, 'stsd'), kind) || {}), seconds: t && t.scale ? t.dur / t.scale : 0 }
106
+ if (kind === 'vide' && tkhd && tkhd.length >= 84) {
107
+ // 显示尺寸(16.16 定点数,在 tkhd 末尾)和旋转(矩阵前两项)
108
+ track.dw = Math.round(tkhd.readUInt32BE(tkhd.length - 8) / 65536)
109
+ track.dh = Math.round(tkhd.readUInt32BE(tkhd.length - 4) / 65536)
110
+ const m = tkhd[0] === 1 ? 52 : 40
111
+ const a = tkhd.readInt32BE(m) / 65536
112
+ const b = tkhd.readInt32BE(m + 4) / 65536
113
+ track.rotate = (Math.round(Math.atan2(b, a) * 180 / Math.PI) + 360) % 360
114
+ const stts = child(stbl, 'stts')
115
+ if (stts && stts.length >= 8) {
116
+ const n = Math.min(stts.readUInt32BE(4), Math.floor((stts.length - 8) / 8))
117
+ let frames = 0
118
+ const deltas = new Set()
119
+ for (let i = 0; i < n; i++) { frames += stts.readUInt32BE(8 + i * 8); deltas.add(stts.readUInt32BE(12 + i * 8)) }
120
+ track.frames = frames
121
+ if (track.seconds > 0) track.fps = Math.round((frames / track.seconds) * 100) / 100
122
+ track.vfr = deltas.size > 2
123
+ }
124
+ }
125
+ return track
126
+ }
127
+
128
+ /**
129
+ * 读一个文件 → { container, brand, size, duration, faststart, fragmented, video, audio, tracks };
130
+ * 不是 MP4 / MOV 一类的返回 null。没有 moov(没录完、被截断)时 noMoov: true。
131
+ */
132
+ export async function probeFile(file) {
133
+ const fh = await fs.promises.open(file, 'r')
134
+ try {
135
+ const { size } = await fh.stat()
136
+ const read = async (pos, len) => {
137
+ const b = Buffer.alloc(len)
138
+ const { bytesRead } = await fh.read(b, 0, len, pos)
139
+ return b.subarray(0, bytesRead)
140
+ }
141
+ const top = []
142
+ let pos = 0
143
+ while (pos + 8 <= size && top.length < 4096) {
144
+ const h = await read(pos, 16)
145
+ if (h.length < 8) break
146
+ let len = h.readUInt32BE(0)
147
+ const type = h.toString('latin1', 4, 8)
148
+ let hdr = 8
149
+ if (len === 1) {
150
+ if (h.length < 16) break
151
+ len = Number(h.readBigUInt64BE(8))
152
+ hdr = 16
153
+ } else if (len === 0) len = size - pos
154
+ if (len < hdr || !/^[\x20-\x7e]{4}$/.test(type)) break
155
+ top.push({ type, pos, len, hdr })
156
+ pos += len
157
+ }
158
+ if (!top.length || !TOP.has(top[0].type)) return null
159
+ const ftyp = top.find((b) => b.type === 'ftyp')
160
+ const moov = top.find((b) => b.type === 'moov')
161
+ const mdat = top.find((b) => b.type === 'mdat')
162
+ const brand = ftyp ? (await read(ftyp.pos + ftyp.hdr, 4)).toString('latin1') : ''
163
+ const out = {
164
+ container: brand === 'qt ' ? 'mov' : 'mp4',
165
+ brand: brand.trim(),
166
+ size,
167
+ duration: 0,
168
+ faststart: !!moov && (!mdat || moov.pos < mdat.pos),
169
+ fragmented: top.some((b) => b.type === 'moof'),
170
+ truncated: pos > size,
171
+ video: null,
172
+ audio: null,
173
+ tracks: [],
174
+ }
175
+ if (!moov) return { ...out, noMoov: true }
176
+ if (moov.len > MAX_MOOV) return { ...out, bigMoov: true }
177
+ const body = (await read(moov.pos, moov.len)).subarray(moov.hdr)
178
+ const mv = timeOf(child(body, 'mvhd'))
179
+ if (mv && mv.scale) out.duration = mv.dur / mv.scale
180
+ if (child(body, 'mvex')) out.fragmented = true
181
+ for (const b of boxes(body)) {
182
+ if (b.type !== 'trak') continue
183
+ const t = parseTrak(b.body)
184
+ if (!t) continue
185
+ out.tracks.push(t)
186
+ if (t.kind === 'vide' && !out.video) out.video = t
187
+ if (t.kind === 'soun' && !out.audio) out.audio = t
188
+ }
189
+ if (!out.duration) out.duration = Math.max(0, ...out.tracks.map((t) => t.seconds || 0))
190
+ return out
191
+ } finally {
192
+ await fh.close()
193
+ }
194
+ }
195
+
196
+ // ── 编码叫什么、手机能不能播 ───────────────────────────────────────────────
197
+
198
+ const H264_PROFILE = { 66: 'Baseline', 77: 'Main', 88: 'Extended', 100: 'High', 110: 'High 10', 122: 'High 4:2:2', 244: 'High 4:4:4', 44: 'CAVLC 4:4:4' }
199
+ const VIDEO_NAME = { avc1: 'H.264', avc3: 'H.264', hvc1: 'HEVC', hev1: 'HEVC', dvh1: 'HEVC', dvhe: 'HEVC', dva1: 'H.264', dvav: 'H.264', av01: 'AV1', vp09: 'VP9', vp08: 'VP8', mp4v: 'MPEG-4 Part 2', apcn: 'ProRes', apch: 'ProRes', apcs: 'ProRes', apco: 'ProRes', ap4h: 'ProRes 4444', jpeg: 'Motion JPEG', 'png ': 'PNG', 'rle ': 'Animation', s263: 'H.263', h263: 'H.263' }
200
+ const PCM = new Set(['lpcm', 'sowt', 'twos', 'in24', 'in32', 'fl32', 'fl64', 'raw ', 'ipcm', 'fpcm'])
201
+
202
+ /** 视频编码的名字,例如「H.264 High」「HEVC Main 10 HDR(HLG)」 */
203
+ export function videoName(v) {
204
+ if (!v) return ''
205
+ const base = VIDEO_NAME[v.tag] || v.tag.trim()
206
+ const hdr = v.hdr ? ` HDR(${v.hdr})` : ''
207
+ if (base === 'H.264') return (H264_PROFILE[v.profile] ? `${base} ${H264_PROFILE[v.profile]}` : base) + hdr
208
+ if (base === 'HEVC') return `${base}${v.profile === 2 ? ' Main 10' : v.profile === 4 ? ' RExt' : ''}${hdr}`
209
+ return base + hdr
210
+ }
211
+
212
+ /** 音频编码的名字 */
213
+ export function audioName(a) {
214
+ if (!a) return ''
215
+ if (a.tag === 'mp4a') return a.oti === 0x69 || a.oti === 0x6b ? 'MP3' : 'AAC'
216
+ if (PCM.has(a.tag)) return '未压缩 PCM'
217
+ return { Opus: 'Opus', opus: 'Opus', 'ac-3': 'AC-3', 'ec-3': 'E-AC-3', alac: 'ALAC', samr: 'AMR', 'fLaC': 'FLAC', '.mp3': 'MP3' }[a.tag] || a.tag.trim()
218
+ }
219
+
220
+ /**
221
+ * 别处(手机、微信、网页、苹果设备)能不能播:[{ level: 'bad' | 'warn', text }]。
222
+ * bad = 很多地方放不了,warn = 有些地方放不了或体验差。只看文件头能看出来的。
223
+ */
224
+ export function compatOf(p) {
225
+ if (!p) return []
226
+ const out = []
227
+ const v = p.video
228
+ const a = p.audio
229
+ if (p.noMoov) out.push({ level: 'bad', text: '文件里没有索引(moov):多半没录完或被截断了,大多数播放器打不开;重新导出一次' })
230
+ if (v) {
231
+ const name = videoName(v)
232
+ // 杜比视界的标签按底下的编码算:dvh1 像 hvc1,dvhe 像 hev1,dva1 / dvav 像 avc1
233
+ const tag = { dvh1: 'hvc1', dvhe: 'hev1', dva1: 'avc1', dvav: 'avc3' }[v.tag] || v.tag
234
+ if ((tag === 'avc1' || tag === 'avc3') && [110, 122, 244, 44].includes(v.profile)) {
235
+ out.push({ level: 'bad', text: `${name}(不是 8 位 4:2:0):iPhone、微信、QuickTime 和很多电视放不了;重新编码时加 -pix_fmt yuv420p` })
236
+ } else if (tag === 'hev1') {
237
+ out.push({ level: 'bad', text: 'HEVC 的标签是 hev1:iPhone、Safari、QuickTime 放不了;不用重新编码,重新封装就行:ffmpeg -i 原文件 -c copy -tag:v hvc1 新文件.mp4' })
238
+ } else if (tag === 'hvc1') {
239
+ out.push({ level: 'warn', text: 'HEVC:苹果设备能放;部分安卓、没装扩展的 Windows、一些网页放不了;要到处都能放就转成 H.264' })
240
+ } else if (v.tag === 'av01') {
241
+ out.push({ level: 'warn', text: 'AV1:较新的手机和浏览器才行,老 iPhone、老电脑放不了' })
242
+ } else if (v.tag === 'vp09' || v.tag === 'vp08') {
243
+ out.push({ level: 'warn', text: `${name} 装在 MP4 里:iPhone、微信可能放不了` })
244
+ } else if (!['avc1', 'avc3'].includes(tag)) {
245
+ out.push({ level: 'bad', text: `${name}:浏览器和手机放不了(老编码或剪辑用的格式);要分享就转成 H.264` })
246
+ }
247
+ const w = v.dw || v.w
248
+ const h = v.dh || v.h
249
+ if (v.hdr) out.push({ level: 'warn', text: `HDR 视频(${v.hdr}):iPhone、新款 Mac 上正常;发到微信、很多安卓、网页和不支持 HDR 的屏幕上常常发灰、颜色变淡或过曝;要到处都正常就转成普通的 SDR(BT.709),iPhone 上可以在「设置 → 相机 → 录制视频」关掉 HDR 再录` })
250
+ if (Math.max(w, h) > 4096 || Math.min(w, h) > 2304) out.push({ level: 'warn', text: `画面 ${w}×${h} 超过 4K:很多手机放不了或很卡` })
251
+ }
252
+ if (a) {
253
+ const name = audioName(a)
254
+ if (a.tag === 'ac-3' || a.tag === 'ec-3' || a.tag === 'samr') out.push({ level: 'bad', text: `声音是 ${name}:Chrome(DSH 的播放器)和很多手机放不出声;转成 AAC:-c:a aac` })
255
+ else if (a.tag === 'Opus' || a.tag === 'opus') out.push({ level: 'warn', text: '声音是 Opus 装在 MP4 里:老 iPhone、Safari 放不出声;要稳就用 AAC' })
256
+ else if (a.tag === 'alac') out.push({ level: 'warn', text: '声音是 ALAC(苹果无损):Chrome(DSH 的播放器)和很多安卓放不出声;要分享就转成 AAC' })
257
+ }
258
+ if (!p.faststart && !p.noMoov && p.size > 2 << 20) {
259
+ out.push({ level: 'warn', text: '文件头(moov)在末尾:网页、手机在线看要整个下完才开始放;重新封装就行:ffmpeg -i 原文件 -c copy -movflags +faststart 新文件.mp4' })
260
+ }
261
+ return out
262
+ }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Video Player",
4
- "description": "Plays local video and audio right in the chat: recordings and voiceovers the model makes get a player under the reply automatically, and open full-size in the sidebar."
4
+ "description": "Plays local video and audio right in the chat: recordings and voiceovers the model makes get a player under the reply automatically, and open full-size in the sidebar. The model can also look at a video itself: frame contact sheets, audio checks and phone compatibility."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "视频播放",
4
- "description": "在对话里直接播放本地视频和音频:模型录的屏、配的音会自动出播放器,就在回复下面;侧边栏里也能大屏看。"
4
+ "description": "在对话里直接播放本地视频和音频:模型录的屏、配的音会自动出播放器,就在回复下面;侧边栏里也能大屏看。模型也能自己看视频:截帧拼图、听声音、查手机能不能播。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@orderg-x/dsh-video-preview",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1 test/*.mjs"
7
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.",
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, and a look_video tool that shows the model frame contact sheets with audio, black-frame and phone-compatibility checks.",
9
9
  "license": "MIT",
10
10
  "repository": {
11
11
  "type": "git",