@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 +34 -2
- package/lib/auto-present.js +11 -1
- package/lib/browser.js +252 -0
- package/lib/client.js +2 -2
- package/lib/index.js +179 -4
- package/lib/look.js +475 -0
- package/lib/probe.js +262 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -2
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
|
+
}
|