@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/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,8 +1,11 @@
1
1
  {
2
2
  "name": "@orderg-x/dsh-video-preview",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
- "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.",
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, and a look_video tool that shows the model frame contact sheets with audio, black-frame and phone-compatibility checks.",
6
9
  "license": "MIT",
7
10
  "repository": {
8
11
  "type": "git",