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