@vpalmisano/webrtcperf 4.0.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.
Files changed (53) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +296 -0
  3. package/app.min.js +2 -0
  4. package/build/src/app.d.ts +6 -0
  5. package/build/src/app.js +207 -0
  6. package/build/src/app.js.map +1 -0
  7. package/build/src/config.d.ts +104 -0
  8. package/build/src/config.js +880 -0
  9. package/build/src/config.js.map +1 -0
  10. package/build/src/generate-config-docs.d.ts +1 -0
  11. package/build/src/generate-config-docs.js +41 -0
  12. package/build/src/generate-config-docs.js.map +1 -0
  13. package/build/src/index.d.ts +9 -0
  14. package/build/src/index.js +26 -0
  15. package/build/src/index.js.map +1 -0
  16. package/build/src/media.d.ts +33 -0
  17. package/build/src/media.js +113 -0
  18. package/build/src/media.js.map +1 -0
  19. package/build/src/rtcstats.d.ts +302 -0
  20. package/build/src/rtcstats.js +418 -0
  21. package/build/src/rtcstats.js.map +1 -0
  22. package/build/src/server.d.ts +173 -0
  23. package/build/src/server.js +639 -0
  24. package/build/src/server.js.map +1 -0
  25. package/build/src/session.d.ts +277 -0
  26. package/build/src/session.js +1552 -0
  27. package/build/src/session.js.map +1 -0
  28. package/build/src/stats.d.ts +243 -0
  29. package/build/src/stats.js +1383 -0
  30. package/build/src/stats.js.map +1 -0
  31. package/build/src/utils.d.ts +249 -0
  32. package/build/src/utils.js +1220 -0
  33. package/build/src/utils.js.map +1 -0
  34. package/build/src/visqol.d.ts +6 -0
  35. package/build/src/visqol.js +61 -0
  36. package/build/src/visqol.js.map +1 -0
  37. package/build/src/vmaf.d.ts +83 -0
  38. package/build/src/vmaf.js +624 -0
  39. package/build/src/vmaf.js.map +1 -0
  40. package/build/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +129 -0
  42. package/src/app.ts +241 -0
  43. package/src/config.ts +852 -0
  44. package/src/generate-config-docs.ts +47 -0
  45. package/src/index.ts +9 -0
  46. package/src/media.ts +151 -0
  47. package/src/rtcstats.ts +507 -0
  48. package/src/server.ts +645 -0
  49. package/src/session.ts +1908 -0
  50. package/src/stats.ts +1668 -0
  51. package/src/utils.ts +1295 -0
  52. package/src/visqol.ts +62 -0
  53. package/src/vmaf.ts +771 -0
package/src/vmaf.ts ADDED
@@ -0,0 +1,771 @@
1
+ import fs from 'fs'
2
+ import json5 from 'json5'
3
+ import path from 'path'
4
+ import os from 'os'
5
+
6
+ import { FFProbeProcess, analyzeColors, chunkedPromiseAll, ffprobe, getFiles, logger, runShellCommand } from './utils'
7
+ import { FastStats } from './stats'
8
+
9
+ const log = logger('webrtcperf:vmaf')
10
+
11
+ const cpus = os.cpus().length
12
+
13
+ export interface IvfFrame {
14
+ pts: number
15
+ recognizedPts?: number
16
+ index: number
17
+ position: number
18
+ size: number
19
+ }
20
+
21
+ export async function parseVideo(fpath: string) {
22
+ let width = 0
23
+ let height = 0
24
+ let frameRate = 0
25
+ await ffprobe(fpath, 'video', 'frame=pts,width,height,duration_time', '', frame => {
26
+ const w = parseInt(frame.width)
27
+ const h = parseInt(frame.height)
28
+ if (w > width) width = w
29
+ if (h > height) height = h
30
+ const duration = parseFloat(frame.duration_time)
31
+ if (duration) {
32
+ frameRate = Math.max(Math.round(1 / duration), frameRate)
33
+ }
34
+ return FFProbeProcess.Skip
35
+ })
36
+ return { width, height, frameRate }
37
+ }
38
+
39
+ /**
40
+ * It prepares a video file for VMAF evaluation applying a timestamp video overlay.
41
+ * @param name The input video file path with the output id (e.g `filename.flv,1`).
42
+ * @param crop If the video should be cropped.
43
+ * @param keepSourceFile If the source file should be kept.
44
+ */
45
+ export async function prepareVideo(
46
+ {
47
+ vmafPrepareVideo,
48
+ vmafVideoCrop,
49
+ videoWidth,
50
+ videoHeight,
51
+ videoFramerate,
52
+ videoDuration,
53
+ }: {
54
+ vmafPrepareVideo: string
55
+ vmafVideoCrop?: string
56
+ videoWidth: number
57
+ videoHeight: number
58
+ videoFramerate: number
59
+ videoDuration: number
60
+ },
61
+ keepSourceFile = true,
62
+ ) {
63
+ const [fpath, id] = vmafPrepareVideo.split(',')
64
+ const outputPath = path.join(path.dirname(fpath), `${id}_send.mp4`)
65
+ if (fs.existsSync(outputPath)) {
66
+ throw new Error(`Output file ${outputPath} already exists`)
67
+ }
68
+ const { width, height, frameRate } = await parseVideo(fpath)
69
+ log.info(
70
+ `prepareVideo ${fpath} ${width}x${height}@${frameRate} -> ${outputPath} ${vmafVideoCrop && `crop: ${vmafVideoCrop}`}`,
71
+ )
72
+ const fontsize = Math.round((videoHeight || height) / 18)
73
+ const textHeight = Math.round(fontsize * 1.2)
74
+ const filter = vmafVideoCrop ? cropFilter(json5.parse(vmafVideoCrop), 0, ',') : ''
75
+ await runShellCommand(
76
+ `ffmpeg -hide_banner -loglevel warning -threads ${Math.min(cpus, 16)} \
77
+ ${videoDuration ? `-t ${videoDuration}` : ''} \
78
+ -i ${fpath} \
79
+ -filter_complex "[0:v]scale=w=${videoWidth || width}:h=${videoHeight || height},fps=${videoFramerate || frameRate},${filter}\
80
+ drawbox=x=0:y=0:w=iw:h=${textHeight}:color=black:t=fill,\
81
+ drawtext=fontfile=/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf:text='${id || 0}-%{eif\\:t*1000\\:u}':fontcolor=white:fontsize=${fontsize}:x=(w-text_w)/2:y=(${textHeight}-text_h)/2[out]" \
82
+ -map [out] -fps_mode vfr -c:v libx264 -crf 10 -an \
83
+ -f mp4 -movflags +faststart ${outputPath}`,
84
+ true,
85
+ )
86
+
87
+ if (!keepSourceFile) {
88
+ await fs.promises.unlink(fpath)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * It converts a video file to VP8/IVF format.
94
+ * @param fpath The input video file path.
95
+ * @param crop The crop filter.
96
+ * @param keepSourceFile If the source file should be kept.
97
+ */
98
+ export async function convertToIvf(fpath: string, crop?: string, keepSourceFile = true) {
99
+ const { width, height, frameRate } = await parseVideo(fpath)
100
+ const outputPath = fpath.replace(/\.[^.]+$/, '.ivf.raw')
101
+ log.debug(`convertToIvf ${fpath} ${width}x${height}@${frameRate} -> ${outputPath} crop:`, crop)
102
+
103
+ const filter = crop ? `-vf '${cropFilter(json5.parse(crop))}'` : ''
104
+ await runShellCommand(
105
+ `ffmpeg -y -hide_banner -y -loglevel warning -i ${fpath} -map 0:v \
106
+ -c:v vp8 -quality best -cpu-used 0 -crf 1 -b:v 20M -qmin 1 -qmax 10 \
107
+ -g 1 -threads ${Math.min(cpus, 16)} ${filter} -an \
108
+ -f ivf ${outputPath}`,
109
+ true,
110
+ )
111
+
112
+ await fixIvfFrames(outputPath, keepSourceFile)
113
+ }
114
+
115
+ /**
116
+ * It recognizes the frames of a video file using OCR.
117
+ * @param fpath The input video file path.
118
+ * @param recover If missing frames should be recovered.
119
+ * @param crop If the video should be cropped.
120
+ * @param debug Enable debug logging.
121
+ */
122
+ export async function recognizeFrames(fpath: string, recover = false, debug = false) {
123
+ const { width, height, frameRate } = await parseVideo(fpath)
124
+ const fname = path.basename(fpath)
125
+ const frames = new Map<number, number>()
126
+ let skipped = 0
127
+ let failed = 0
128
+ let recovered = 0
129
+ let firstTimestamp = 0
130
+ let lastTimestamp = 0
131
+ let participantDisplayName = ''
132
+ const regExp = /(?<name>[0-9]{1,6})-(?<time>[0-9]{1,13})/
133
+ await ffprobe(
134
+ fpath,
135
+ 'video',
136
+ 'frame=pts,frame_tags=lavfi.ocr.text,lavfi.ocr.confidence',
137
+ `scale=w=1280:h=-1:flags=bicubic,crop=w=min(iw\\,ih):h=max((ih/15)\\,32):x=(iw-ow)/2:y=0:exact=1,ocr=whitelist=0123456789-`,
138
+ frame => {
139
+ const pts = parseInt(frame.pts)
140
+ if ((!frames.has(pts) || !frames.get(pts)) && frameRate) {
141
+ const confidence = parseFloat(frame.tag_lavfi_ocr_confidence?.trim() || '0')
142
+ const textMatch = regExp.exec(frame.tag_lavfi_ocr_text?.trim() || '')
143
+ if (confidence > 0 && textMatch) {
144
+ const { name, time } = textMatch.groups as { name: string; time: string }
145
+ participantDisplayName = `Participant-${name.padStart(6, '0')}`
146
+ const recognizedTime = parseInt(time)
147
+ const recognizedPts = Math.round((frameRate * recognizedTime) / 1000)
148
+ if (debug) {
149
+ log.debug(
150
+ `recognized frame ${fname} confidence=${confidence} pts=${pts} name=${name} time=${time} recognized=${recognizedPts}`,
151
+ )
152
+ }
153
+ frames.set(pts, recognizedPts)
154
+ if (!firstTimestamp) firstTimestamp = recognizedPts / frameRate
155
+ lastTimestamp = recognizedPts / frameRate
156
+ } else {
157
+ if (recover) frames.set(pts, 0)
158
+ failed++
159
+ }
160
+ } else {
161
+ skipped++
162
+ }
163
+ return FFProbeProcess.Skip
164
+ },
165
+ )
166
+
167
+ if (recover) {
168
+ const ptsIndex = Array.from(frames.keys()).sort((a, b) => a - b)
169
+ for (const [i, pts] of ptsIndex.entries()) {
170
+ const recognizedPts = frames.get(pts)
171
+ if (!recognizedPts && i) {
172
+ const prevRecognizedPts = frames.get(ptsIndex[i - 1])
173
+ if (prevRecognizedPts) {
174
+ frames.set(pts, prevRecognizedPts + pts - ptsIndex[i - 1])
175
+ recovered++
176
+ } else {
177
+ frames.delete(pts)
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ log.info(
184
+ `recognizeFrames ${fname} ${width}x${height}@${frameRate} "${participantDisplayName}" frames: ${frames.size} skipped: ${skipped} recovered: ${recovered} failed: ${failed} \
185
+ ts: ${firstTimestamp.toFixed(2)}-${lastTimestamp.toFixed(2)} (${(lastTimestamp - firstTimestamp).toFixed(2)})`,
186
+ )
187
+ return { width, height, frameRate, frames, participantDisplayName }
188
+ }
189
+
190
+ async function parseIvf(fpath: string, runRecognizer = false) {
191
+ const { width, height } = await parseVideo(fpath)
192
+ const fname = path.basename(fpath)
193
+ const fd = await fs.promises.open(fpath, 'r')
194
+ const headerData = new ArrayBuffer(32)
195
+ const headerView = new DataView(headerData)
196
+ const ret = await fd.read(headerView, 0, 32, 0)
197
+ if (ret.bytesRead !== 32) {
198
+ await fd.close()
199
+ throw new Error('Invalid IVF file')
200
+ }
201
+ const den = headerView.getUint32(16, true)
202
+ const num = headerView.getUint32(20, true)
203
+ const frameRate = den / num
204
+ let participantDisplayName = ''
205
+ let skipped = 0
206
+
207
+ const frameHeaderView = new DataView(new ArrayBuffer(12))
208
+ let index = 0
209
+ let position = 32
210
+ let bytesRead = 0
211
+ const frames = new Map<number, IvfFrame>()
212
+ let firstTimestamp = 0
213
+ let lastTimestamp = 0
214
+ do {
215
+ const ret = await fd.read(frameHeaderView, 0, frameHeaderView.byteLength, position)
216
+ bytesRead = ret.bytesRead
217
+ if (bytesRead !== 12) {
218
+ break
219
+ }
220
+ const size = frameHeaderView.getUint32(0, true)
221
+ const pts = Number(frameHeaderView.getBigUint64(4, true))
222
+ /* if (pts <= ptsIndex[ptsIndex.length - 1]) {
223
+ log.warn(`IVF file ${fname}: pts ${pts} <= prev ${ptsIndex[ptsIndex.length - 1]}`)
224
+ } */
225
+ if (frames.has(pts)) {
226
+ /* log.debug(`IVF file ${fname}: pts ${pts} already present, skipping`) */
227
+ skipped++
228
+ } else {
229
+ frames.set(pts, { pts, index, position, size: size + 12 })
230
+ index++
231
+ if (!firstTimestamp) {
232
+ firstTimestamp = pts / frameRate
233
+ }
234
+ lastTimestamp = pts / frameRate
235
+ }
236
+ position += size + 12
237
+ } while (bytesRead === 12)
238
+ await fd.close()
239
+
240
+ log.debug(
241
+ `parseIvf ${fname}: ${width}x${height}@${frameRate} \
242
+ frames: ${frames.size} skipped: ${skipped} \
243
+ ts: ${firstTimestamp.toFixed(2)}-${lastTimestamp.toFixed(2)} (${(lastTimestamp - firstTimestamp).toFixed(2)}s)`,
244
+ )
245
+
246
+ if (runRecognizer) {
247
+ const { frames: ptsToRecognized, participantDisplayName: name } = await recognizeFrames(fpath)
248
+ participantDisplayName = name
249
+ for (const [pts, frame] of frames.entries()) {
250
+ const recognizedPts = ptsToRecognized.get(pts)
251
+ if (!recognizedPts) continue
252
+ frame.recognizedPts = recognizedPts
253
+ }
254
+ }
255
+
256
+ return {
257
+ width,
258
+ height,
259
+ frameRate,
260
+ frames,
261
+ participantDisplayName,
262
+ }
263
+ }
264
+
265
+ export async function fixIvfFrames(filePath: string, keepSourceFile = true) {
266
+ const fname = path.basename(filePath)
267
+ const dirPath = path.dirname(filePath)
268
+ if (!fname.endsWith('.ivf.raw')) {
269
+ throw new Error(`fixIvfFrames ${fname}: invalid file extension, expected ".ivf.raw"`)
270
+ }
271
+ const { width, height, frames, participantDisplayName } = await parseIvf(filePath, true)
272
+ if (!participantDisplayName) {
273
+ throw new Error(`fixIvfFrames ${fname}: no participant name found`)
274
+ }
275
+ if (!frames.size) {
276
+ throw new Error(`fixIvfFrames ${fname}: no frames found`)
277
+ }
278
+ log.debug(`fixIvfFrames ${fname} width=${width} height=${height} (${frames.size} frames)`)
279
+ const fd = await fs.promises.open(filePath, 'r')
280
+
281
+ const parts = path.basename(filePath).split('_')
282
+ if (!parts[1].startsWith('send') && !parts[1].startsWith('recv')) {
283
+ throw new Error(`fixIvfFrames ${fname}: invalid file name, expected "<name>_send" or "<name>_recv"`)
284
+ }
285
+ const outFilePath = path.join(
286
+ dirPath,
287
+ parts[1].startsWith('send') ? `${participantDisplayName}.ivf` : `${participantDisplayName}_recv-by_${parts[0]}.ivf`,
288
+ )
289
+
290
+ const fixedFd = await fs.promises.open(outFilePath, 'w')
291
+ const headerView = new DataView(new ArrayBuffer(32))
292
+ await fd.read(headerView, 0, headerView.byteLength, 0)
293
+
294
+ let position = 32
295
+ let writtenFrames = 0
296
+
297
+ const ptsIndex = Array.from(frames.keys())
298
+ .filter(pts => frames.get(pts)?.recognizedPts)
299
+ .sort((a, b) => {
300
+ if (a === b) return (frames.get(a)?.recognizedPts || 0) - (frames.get(b)?.recognizedPts || 0)
301
+ return a - b
302
+ })
303
+ for (const [i, pts] of ptsIndex.entries()) {
304
+ const frame = frames.get(pts)
305
+ if (!frame || !frame.recognizedPts) {
306
+ log.warn(`fixIvfFrames ${fname}: pts ${pts} not found, skipping`)
307
+ continue
308
+ }
309
+
310
+ const prevFrame = frames.get(ptsIndex[i - 1])
311
+ const nextFrame = frames.get(ptsIndex[i + 1])
312
+ // Skip frames that are not in the correct order.
313
+ if (nextFrame?.recognizedPts && (nextFrame?.recognizedPts || 0) < frame.recognizedPts) {
314
+ continue
315
+ }
316
+ // Keep duplicated frames.
317
+ if (prevFrame?.recognizedPts && frame.recognizedPts === prevFrame.recognizedPts) {
318
+ /* log.warn(
319
+ `${frame.index} pts=${pts}:${frame.recognizedPts} prev ${prevFrame.pts}(${pts - prevFrame.pts}):${prevFrame.recognizedPts}(${frame.recognizedPts - prevFrame.recognizedPts}) next ${nextFrame?.pts}(${(nextFrame?.pts || 0) - pts}):${nextFrame?.recognizedPts}(${(nextFrame?.recognizedPts || 0) - frame.recognizedPts})`,
320
+ ) */
321
+ frame.recognizedPts = prevFrame.recognizedPts + 1
322
+ }
323
+ const frameView = new DataView(new ArrayBuffer(frame.size))
324
+ await fd.read(frameView, 0, frame.size, frame.position)
325
+ frameView.setBigUint64(4, BigInt(frame.recognizedPts), true)
326
+ await fixedFd.write(new Uint8Array(frameView.buffer), 0, frameView.byteLength, position)
327
+ position += frameView.byteLength
328
+ writtenFrames++
329
+ }
330
+
331
+ headerView.setUint16(12, width, true)
332
+ headerView.setUint16(14, height, true)
333
+ headerView.setUint32(24, writtenFrames, true)
334
+ await fixedFd.write(new Uint8Array(headerView.buffer), 0, headerView.byteLength, 0)
335
+
336
+ await fd.close()
337
+ await fixedFd.close()
338
+
339
+ if (!keepSourceFile) {
340
+ await fs.promises.unlink(filePath)
341
+ }
342
+
343
+ return { participantDisplayName, outFilePath }
344
+ }
345
+
346
+ export async function fixIvfFiles(directory: string, keepSourceFiles = true) {
347
+ const reference = new Map<string, string>()
348
+ const degraded = new Map<string, string[]>()
349
+
350
+ const addFile = (participantDisplayName: string, outFilePath: string) => {
351
+ if (outFilePath.includes('_recv-by_')) {
352
+ if (!degraded.has(participantDisplayName)) {
353
+ degraded.set(participantDisplayName, [])
354
+ }
355
+ degraded.get(participantDisplayName)?.push(outFilePath)
356
+ } else {
357
+ reference.set(participantDisplayName, outFilePath)
358
+ }
359
+ }
360
+
361
+ const ivfFiles = await getFiles(directory, '.ivf')
362
+ if (ivfFiles.length) {
363
+ log.debug(`using existing ${ivfFiles.length} ivf files`)
364
+ for (const outFilePath of ivfFiles) {
365
+ try {
366
+ const participantDisplayName = path.basename(outFilePath).replace('.ivf', '').split('_')[0]
367
+ addFile(participantDisplayName, outFilePath)
368
+ } catch (err) {
369
+ log.error(`fixIvfFrames error: ${(err as Error).stack}`)
370
+ }
371
+ }
372
+ }
373
+
374
+ const rawFiles = await getFiles(directory, '.ivf.raw')
375
+ if (rawFiles.length) {
376
+ log.debug(`processing ${rawFiles.length} raw ivf files`)
377
+ const results = await chunkedPromiseAll<
378
+ string,
379
+ { participantDisplayName: string; outFilePath: string } | undefined
380
+ >(
381
+ rawFiles,
382
+ async filePath => {
383
+ try {
384
+ const { participantDisplayName, outFilePath } = await fixIvfFrames(filePath, keepSourceFiles)
385
+ return { participantDisplayName, outFilePath }
386
+ } catch (err) {
387
+ log.error(`fixIvfFrames error: ${(err as Error).stack}`)
388
+ }
389
+ },
390
+ Math.ceil(cpus / 4),
391
+ )
392
+ for (const res of results) {
393
+ if (!res) continue
394
+ const { participantDisplayName, outFilePath } = res
395
+ addFile(participantDisplayName, outFilePath)
396
+ }
397
+ }
398
+
399
+ return { reference, degraded }
400
+ }
401
+
402
+ async function filterIvfFrames(fpath: string, frames: IvfFrame[]) {
403
+ const outFilePath = fpath.replace('.ivf', '.filtered.ivf')
404
+ const fd = await fs.promises.open(fpath, 'r')
405
+ const fixedFd = await fs.promises.open(outFilePath, 'w')
406
+ const headerView = new DataView(new ArrayBuffer(32))
407
+ await fd.read(headerView, 0, headerView.byteLength, 0)
408
+
409
+ let position = 32
410
+ let writtenFrames = 0
411
+ for (const frame of frames.values()) {
412
+ const frameView = new DataView(new ArrayBuffer(frame.size))
413
+ await fd.read(frameView, 0, frame.size, frame.position)
414
+ await fixedFd.write(new Uint8Array(frameView.buffer), 0, frameView.byteLength, position)
415
+ position += frameView.byteLength
416
+ writtenFrames++
417
+ }
418
+
419
+ headerView.setUint32(24, writtenFrames, true)
420
+ await fixedFd.write(new Uint8Array(headerView.buffer), 0, headerView.byteLength, 0)
421
+
422
+ await fd.close()
423
+ await fixedFd.close()
424
+ return outFilePath
425
+ }
426
+
427
+ export interface VmafScore {
428
+ sender: string
429
+ receiver: string
430
+ min: number
431
+ max: number
432
+ mean: number
433
+ harmonic_mean: number
434
+ }
435
+
436
+ export async function runVmaf(
437
+ referencePath: string,
438
+ degradedPath: string,
439
+ preview: boolean,
440
+ cropConfig: VmafCrop = {},
441
+ cropTimeOverlay = false,
442
+ ) {
443
+ const comparisonDir = path.dirname(degradedPath)
444
+ const comparisonName = path.basename(degradedPath.replace(/\.[^.]+$/, ''))
445
+ const cropDest = cropConfig[comparisonName]
446
+ const crop = { ref: fixCrop(cropDest?.ref), deg: fixCrop(cropDest?.deg) }
447
+
448
+ log.info('runVmaf', { referencePath, degradedPath, preview, crop })
449
+ await fs.promises.mkdir(path.join(comparisonDir, comparisonName), { recursive: true })
450
+ const vmafLogPath = path.join(comparisonDir, comparisonName, 'vmaf-log.json')
451
+ const psnrLogPath = path.join(comparisonDir, comparisonName, 'psnr.log')
452
+ const comparisonPath = path.join(comparisonDir, comparisonName, 'comparison.mp4')
453
+
454
+ const sender = path.basename(referencePath).replace('.ivf', '')
455
+ const receiver = path.basename(degradedPath).replace('.ivf', '').split('_recv-by_')[1]
456
+
457
+ const { frameRate: refFrameRate, frames: refFrames } = await parseIvf(referencePath, false)
458
+ const {
459
+ width: degWidth,
460
+ height: degHeight,
461
+ frameRate: degFrameRate,
462
+ frames: degFrames,
463
+ } = await parseIvf(degradedPath, false)
464
+
465
+ const textHeight = cropTimeOverlay ? '(ih/15)' : ''
466
+ if (textHeight) {
467
+ crop.ref.h = `${crop.ref.h}-${textHeight}`
468
+ crop.ref.y = `${crop.ref.y}+${textHeight}`
469
+
470
+ crop.deg.h = `${crop.deg.h}-${textHeight}`
471
+ crop.deg.y = `${crop.deg.y}+${textHeight}`
472
+ }
473
+
474
+ if (refFrameRate !== degFrameRate) {
475
+ throw new Error(`runVmaf: frame rates do not match: ref=${refFrameRate} deg=${degFrameRate}`)
476
+ }
477
+
478
+ // Find common frames.
479
+ const commonRefFrames = []
480
+ const commonDegFrames = []
481
+ let firstPts = 0
482
+ let lastPts = 0
483
+ for (const [pts, refFrame] of refFrames.entries()) {
484
+ const degFrame = degFrames.get(pts)
485
+ if (degFrame) {
486
+ commonRefFrames.push(refFrame)
487
+ commonDegFrames.push(degFrame)
488
+ if (!firstPts) {
489
+ firstPts = pts
490
+ }
491
+ lastPts = pts
492
+ }
493
+ }
494
+ const duration = (lastPts - firstPts) / refFrameRate
495
+
496
+ referencePath = await filterIvfFrames(referencePath, commonRefFrames)
497
+ degradedPath = await filterIvfFrames(degradedPath, commonDegFrames)
498
+ log.debug(
499
+ `common frames: ${commonRefFrames.length} ref: ${refFrames.size} deg: ${degFrames.size} duration: ${duration}s`,
500
+ {
501
+ crop,
502
+ },
503
+ )
504
+
505
+ const ffmpegCmd = `ffmpeg -hide_banner -loglevel warning -y -threads ${Math.min(cpus, 16)} \
506
+ -i ${degradedPath} \
507
+ -i ${referencePath} \
508
+ `
509
+
510
+ const filter = `\
511
+ [0:v]\
512
+ scale=w=-1:h=${degHeight}:flags=bicubic,\
513
+ ${cropFilter(crop.deg, 0, ',')}\
514
+ ${splitFilter(['deg_vmaf', 'deg_psnr', preview ? 'deg_preview' : ''])};\
515
+ [1:v]\
516
+ scale=w=-1:h=${degHeight}:flags=bicubic,crop=w=${degWidth}:x=(iw-${degWidth})/2,\
517
+ ${cropFilter(crop.ref, 0, ',')}\
518
+ ${splitFilter(['ref_vmaf', 'ref_psnr', preview ? 'ref_preview' : ''])};\
519
+ [deg_vmaf][ref_vmaf]libvmaf=model='path=/usr/share/model/vmaf_v0.6.1.json':log_fmt=json:log_path=${vmafLogPath}:n_subsample=1:n_threads=${cpus}:shortest=1[vmaf];\
520
+ [deg_psnr][ref_psnr]psnr=stats_file=${psnrLogPath}[psnr]\
521
+ `
522
+
523
+ const cmd = preview
524
+ ? `${ffmpegCmd} \
525
+ -filter_complex "${filter};[ref_preview][deg_preview]hstack[stacked]" \
526
+ -map [vmaf] -f null - \
527
+ -map [psnr] -f null - \
528
+ -map [stacked] -fps_mode vfr -c:v libx264 -crf 10 -f mp4 -movflags +faststart ${comparisonPath} \
529
+ `
530
+ : `${ffmpegCmd} \
531
+ -filter_complex "${filter}" \
532
+ -map [vmaf] -f null - \
533
+ -map [psnr] -f null - \
534
+ `
535
+
536
+ log.debug('runVmaf', cmd)
537
+ try {
538
+ const { stdout, stderr } = await runShellCommand(cmd)
539
+
540
+ const vmafLog = JSON.parse(await fs.promises.readFile(vmafLogPath, 'utf-8'))
541
+ log.debug('runVmaf', {
542
+ stdout,
543
+ stderr,
544
+ })
545
+ const metrics = {
546
+ sender,
547
+ receiver,
548
+
549
+ ...vmafLog.pooled_metrics.vmaf,
550
+ } as VmafScore
551
+
552
+ log.info(`VMAF metrics ${vmafLogPath}:`, metrics)
553
+
554
+ try {
555
+ await writeGraph(vmafLogPath)
556
+ } catch (err) {
557
+ log.error(`writeGraph error: ${(err as Error).stack}`)
558
+ }
559
+
560
+ return metrics
561
+ } finally {
562
+ await fs.promises.unlink(degradedPath)
563
+ await fs.promises.unlink(referencePath)
564
+ }
565
+ }
566
+
567
+ async function writeGraph(vmafLogPath: string) {
568
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
569
+ const { ChartJSNodeCanvas } = require('chartjs-node-canvas')
570
+
571
+ const vmafLog = JSON.parse(await fs.promises.readFile(vmafLogPath, 'utf-8')) as {
572
+ frames: {
573
+ frameNum: number
574
+ metrics: { vmaf: number }
575
+ }[]
576
+ pooled_metrics: {
577
+ vmaf: { min: number; max: number; mean: number; harmonic_mean: number }
578
+ }
579
+ }
580
+ const { min, max, mean } = vmafLog.pooled_metrics.vmaf
581
+
582
+ const fpath = vmafLogPath.replace('.json', '.png')
583
+
584
+ const decimation = Math.ceil(vmafLog.frames.length / 500)
585
+ const stats = new FastStats()
586
+ const data = vmafLog.frames
587
+ .reduce(
588
+ (prev, cur) => {
589
+ if (cur.frameNum % decimation === 0) {
590
+ prev.push({
591
+ x: cur.frameNum,
592
+ y: cur.metrics.vmaf,
593
+ count: 1,
594
+ })
595
+ } else {
596
+ prev[prev.length - 1].y += cur.metrics.vmaf
597
+ prev[prev.length - 1].count++
598
+ }
599
+ stats.push(cur.metrics.vmaf)
600
+ return prev
601
+ },
602
+ [] as { x: number; y: number; count: 1 }[],
603
+ )
604
+ .map(d => ({ x: d.x, y: d.y / d.count }))
605
+
606
+ const chartJSNodeCanvas = new ChartJSNodeCanvas({
607
+ width: 1280,
608
+ height: 720,
609
+ backgroundColour: 'white',
610
+ })
611
+
612
+ const buffer = await chartJSNodeCanvas.renderToBuffer({
613
+ type: 'line',
614
+ data: {
615
+ labels: data.map(d => d.x),
616
+ datasets: [
617
+ {
618
+ label: `VMAF score (min: ${min.toFixed(2)}, max: ${max.toFixed(
619
+ 2,
620
+ )}, mean: ${mean.toFixed(2)}, P5: ${stats.percentile(5).toFixed(2)})`,
621
+ data: data.map(d => d.y),
622
+ fill: false,
623
+ borderColor: 'rgb(0, 0, 0)',
624
+ borderWidth: 1,
625
+ pointRadius: 0,
626
+ },
627
+ ],
628
+ },
629
+ options: {
630
+ plugins: {
631
+ title: {
632
+ display: true,
633
+ text: path.basename(vmafLogPath).replace('.vmaf.json', '').replace(/_/g, ' '),
634
+ },
635
+ },
636
+ scales: {
637
+ y: {
638
+ min: 0,
639
+ max: 100,
640
+ },
641
+ },
642
+ },
643
+ })
644
+
645
+ await fs.promises.writeFile(fpath, buffer)
646
+ }
647
+
648
+ interface Crop {
649
+ w: string
650
+ h: string
651
+ x: string
652
+ y: string
653
+ }
654
+
655
+ type VmafCrop = Record<
656
+ string,
657
+ {
658
+ ref?: Crop
659
+ deg?: Crop
660
+ }
661
+ >
662
+
663
+ const fixCrop = (c?: Crop) => {
664
+ return {
665
+ w: c?.w ?? 'iw',
666
+ h: c?.h ?? 'ih',
667
+ x: c?.x ?? '0',
668
+ y: c?.y ?? '0',
669
+ }
670
+ }
671
+
672
+ const cropFilter = (crop: Crop, exact = 0, suffix = '') => {
673
+ const { w, h, x, y } = crop
674
+ if (!x && !w && !x && !y) return ''
675
+ return `crop=w=${w}:h=${h}:x=${x}:y=${y}:exact=${exact}${suffix}`
676
+ }
677
+
678
+ const splitFilter = (outputs: string[], suffix = '') => {
679
+ const out = outputs
680
+ .filter(o => !!o)
681
+ .map(o => `[${o}]`)
682
+ .join('')
683
+ if (!out) return ''
684
+ return `split=${outputs.length}${out}${suffix}`
685
+ }
686
+
687
+ interface VmafConfig {
688
+ vmafPath: string
689
+ vmafPreview: boolean
690
+ vmafKeepIntermediateFiles: boolean
691
+ vmafKeepSourceFiles: boolean
692
+ vmafCrop?: string
693
+ }
694
+
695
+ export async function calculateVmafScore(config: VmafConfig): Promise<VmafScore[]> {
696
+ const { vmafPath, vmafPreview, vmafKeepIntermediateFiles, vmafKeepSourceFiles, vmafCrop } = config
697
+ if (!fs.existsSync(config.vmafPath)) {
698
+ throw new Error(`VMAF path ${config.vmafPath} does not exist`)
699
+ }
700
+ log.debug(`calculateVmafScore referencePath=${vmafPath}`)
701
+
702
+ const { reference, degraded } = await fixIvfFiles(vmafPath, vmafKeepSourceFiles)
703
+
704
+ const crop: VmafCrop | undefined = vmafCrop ? json5.parse(vmafCrop) : undefined
705
+
706
+ const ret: VmafScore[] = []
707
+ for (const participantDisplayName of reference.keys()) {
708
+ const vmafReferencePath = reference.get(participantDisplayName)
709
+ if (!vmafReferencePath) continue
710
+ for (const degradedPath of degraded.get(participantDisplayName) ?? []) {
711
+ try {
712
+ const metrics = await runVmaf(vmafReferencePath, degradedPath, vmafPreview, crop)
713
+ ret.push(metrics)
714
+ } catch (err) {
715
+ log.error(`runVmaf error: ${(err as Error).message}`)
716
+ } finally {
717
+ if (!vmafKeepIntermediateFiles) {
718
+ await fs.promises.unlink(degradedPath)
719
+ }
720
+ }
721
+ }
722
+ if (!vmafKeepIntermediateFiles) {
723
+ await fs.promises.unlink(vmafReferencePath)
724
+ }
725
+ }
726
+ await fs.promises.writeFile(path.join(vmafPath, 'vmaf.json'), JSON.stringify(ret, undefined, 2))
727
+
728
+ return ret
729
+ }
730
+
731
+ if (require.main === module) {
732
+ ;(async (): Promise<void> => {
733
+ switch (process.argv[2]) {
734
+ case 'convert':
735
+ await convertToIvf(process.argv[3], process.argv[4], false)
736
+ break
737
+ case 'parse': {
738
+ const { frames } = await parseIvf(process.argv[3], true)
739
+ console.log(frames)
740
+ break
741
+ }
742
+ case 'fix':
743
+ await fixIvfFrames(process.argv[3], true)
744
+ break
745
+ case 'analyze':
746
+ console.log(JSON.stringify(await analyzeColors(process.argv[3]), null, 2))
747
+ break
748
+ case 'graph':
749
+ await writeGraph(process.argv[3])
750
+ break
751
+ case 'vmaf':
752
+ await calculateVmafScore({
753
+ vmafPath: process.argv[3],
754
+ vmafPreview: true,
755
+ vmafKeepIntermediateFiles: true,
756
+ vmafKeepSourceFiles: true,
757
+ vmafCrop: json5.stringify({
758
+ 'Participant-000001_recv-by_Participant-000000': {
759
+ ref: { w: '', h: '', x: '', y: '' },
760
+ deg: { w: '', h: '', x: '', y: '' },
761
+ },
762
+ }),
763
+ })
764
+ break
765
+ default:
766
+ throw new Error(`Invalid command: ${process.argv[2]}`)
767
+ }
768
+ })()
769
+ .catch(err => console.error(err))
770
+ .finally(() => process.exit(0))
771
+ }