@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/utils.ts ADDED
@@ -0,0 +1,1295 @@
1
+ import {
2
+ Browser,
3
+ computeExecutablePath,
4
+ getInstalledBrowsers,
5
+ getVersionComparator,
6
+ install,
7
+ } from '@puppeteer/browsers'
8
+ import { spawn } from 'child_process'
9
+ import axios from 'axios'
10
+ import { createHash } from 'crypto'
11
+ import * as dns from 'dns'
12
+ import FormData from 'form-data'
13
+ import fs, { WriteStream, createWriteStream } from 'fs'
14
+ import { Agent } from 'https'
15
+ import * as ipaddrJs from 'ipaddr.js'
16
+ import net from 'net'
17
+ import NodeCache from 'node-cache'
18
+ import * as OSUtils from 'node-os-utils'
19
+ import os, { networkInterfaces } from 'os'
20
+ import path, { dirname } from 'path'
21
+ import pidtree from 'pidtree'
22
+ import pidusage from 'pidusage'
23
+ import puppeteer, { Page } from 'puppeteer-core'
24
+
25
+ import { Session } from './session'
26
+
27
+ // eslint-disable-next-line
28
+ const ps = require('pidusage/lib/ps')
29
+
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ export const { Log } = require('debug-level')
32
+
33
+ interface Logger {
34
+ error: (...args: unknown[]) => void
35
+ warn: (...args: unknown[]) => void
36
+ info: (...args: unknown[]) => void
37
+ debug: (...args: unknown[]) => void
38
+ log: (...args: unknown[]) => void
39
+ }
40
+
41
+ export function logger(name: string, options = {}): Logger {
42
+ return new Log(name, { splitLine: false, ...options })
43
+ }
44
+
45
+ export class LoggerInterface {
46
+ name?: string
47
+
48
+ private logInit(args: unknown[]): void {
49
+ if (this.name) {
50
+ args.unshift(`[${this.name}]`)
51
+ }
52
+ }
53
+
54
+ debug(...args: unknown[]): void {
55
+ this.logInit(args)
56
+ log.debug(...args)
57
+ }
58
+
59
+ info(...args: unknown[]): void {
60
+ this.logInit(args)
61
+ log.info(...args)
62
+ }
63
+
64
+ warn(...args: unknown[]): void {
65
+ this.logInit(args)
66
+ log.warn(...args)
67
+ }
68
+
69
+ error(...args: unknown[]): void {
70
+ this.logInit(args)
71
+ log.error(...args)
72
+ }
73
+
74
+ log(...args: unknown[]): void {
75
+ this.logInit(args)
76
+ log.log(...args)
77
+ }
78
+ }
79
+
80
+ const log = logger('webrtcperf:utils')
81
+
82
+ /**
83
+ * Resolves the absolute path from the package installation directory.
84
+ * @param relativePath The relative path.
85
+ * @returns The absolute path.
86
+ */
87
+ export function resolvePackagePath(relativePath: string): string {
88
+ if ('__nexe' in process) {
89
+ return relativePath
90
+ }
91
+ if (process.env.WEBPACK) {
92
+ return path.join(path.dirname(__filename), relativePath)
93
+ }
94
+ const libPath = require.resolve(relativePath)
95
+ if (fs.existsSync(libPath)) {
96
+ return libPath
97
+ }
98
+ for (const d of ['..', '../..']) {
99
+ const p = path.join(__dirname, d, relativePath)
100
+ if (fs.existsSync(p)) {
101
+ return require.resolve(p)
102
+ }
103
+ }
104
+ throw new Error(`resolvePackagePath: ${relativePath} not found`)
105
+ }
106
+
107
+ /**
108
+ * Calculates the sha256 sum.
109
+ * @param data The string input
110
+ */
111
+ export function sha256(data: string): string {
112
+ return createHash('sha256').update(data).digest('hex')
113
+ }
114
+
115
+ const ProcessStatsCache = new NodeCache({ stdTTL: 5, checkperiod: 10 })
116
+ const ProcessChildrenCache = new NodeCache({ stdTTL: 15, checkperiod: 15 })
117
+
118
+ interface ProcessStat {
119
+ cpu: number
120
+ memory: number
121
+ }
122
+
123
+ /**
124
+ * Returns the process stats.
125
+ * @param pid The process pid
126
+ * @param children If process children should be taken into account.
127
+ * @returns
128
+ */
129
+ export async function getProcessStats(pid = 0, children = false): Promise<ProcessStat> {
130
+ const processPid = pid || process.pid
131
+ let stat: ProcessStat | undefined = ProcessStatsCache.get(processPid)
132
+ if (stat) {
133
+ return stat
134
+ }
135
+
136
+ const pidStats = await pidusage(processPid)
137
+ if (pidStats) {
138
+ stat = {
139
+ cpu: pidStats.cpu,
140
+ memory: pidStats.memory / 1e6,
141
+ }
142
+ } else {
143
+ stat = { cpu: 0, memory: 0 }
144
+ }
145
+
146
+ if (children) {
147
+ try {
148
+ let childrenPids: number[] | undefined = ProcessChildrenCache.get(processPid)
149
+ if (!childrenPids?.length) {
150
+ childrenPids = await pidtree(processPid)
151
+ if (childrenPids?.length) {
152
+ ProcessChildrenCache.set(processPid, childrenPids)
153
+ }
154
+ }
155
+ if (childrenPids?.length) {
156
+ const pidStats = await pidusage(childrenPids)
157
+ for (const p of childrenPids) {
158
+ if (pidStats[p]) {
159
+ stat.cpu += pidStats[p].cpu
160
+ stat.memory += pidStats[p].memory / 1e6
161
+ }
162
+ }
163
+ }
164
+ } catch (err) {
165
+ log.error(`getProcessStats children error: ${(err as Error).stack}`)
166
+ }
167
+ }
168
+ ProcessStatsCache.set(processPid, stat)
169
+ return stat
170
+ }
171
+
172
+ // Socket stats.
173
+ interface SocketStat {
174
+ recvBytes: number
175
+ sendBytes: number
176
+ }
177
+
178
+ export async function getSocketStats(processPid: number): Promise<SocketStat> {
179
+ const stats: SocketStat = { recvBytes: 0, sendBytes: 0 }
180
+ try {
181
+ const { stdout } = await runShellCommand(`ss -nOHpti | { grep pid=${processPid} || true; }`)
182
+ for (const { groups } of stdout.matchAll(/bytes_sent:(?<sendBytes>\d+).+bytes_received:(?<recvBytes>\d+)/g)) {
183
+ if (!groups) continue
184
+ const recvBytes = parseInt(groups.recvBytes)
185
+ const sendBytes = parseInt(groups.sendBytes)
186
+ stats.recvBytes += recvBytes
187
+ stats.sendBytes += sendBytes
188
+ }
189
+ } catch (err) {
190
+ log.error(`socketStats error: ${(err as Error).stack}`)
191
+ }
192
+ return stats
193
+ }
194
+
195
+ // System stats.
196
+ const SystemStatsCache = new NodeCache({ stdTTL: 30, checkperiod: 60 })
197
+
198
+ export interface SystemStats {
199
+ usedCpu: number
200
+ usedMemory: number
201
+ usedGpu: number
202
+ usedGpuMemory: number
203
+ }
204
+
205
+ async function updateSystemStats(): Promise<void> {
206
+ const [cpu, memInfo, gpuStats] = await Promise.all([OSUtils.cpu.free(10000), OSUtils.mem.info(), systemGpuStats()])
207
+ const stat = {
208
+ usedCpu: 100 - cpu,
209
+ usedMemory: 100 - memInfo.freeMemPercentage,
210
+ usedGpu: gpuStats.gpu,
211
+ usedGpuMemory: gpuStats.mem,
212
+ }
213
+ SystemStatsCache.set('default', stat)
214
+ }
215
+
216
+ let systemStatsInterval: NodeJS.Timeout | null = null
217
+
218
+ export function getSystemStats(): SystemStats | undefined {
219
+ if (!systemStatsInterval) {
220
+ startUpdateSystemStats()
221
+ }
222
+ return SystemStatsCache.get<SystemStats>('default')
223
+ }
224
+
225
+ export function startUpdateSystemStats(): void {
226
+ if (systemStatsInterval) {
227
+ return
228
+ }
229
+ systemStatsInterval = setInterval(updateSystemStats, 5000)
230
+ }
231
+
232
+ export function stopTimers(): void {
233
+ if (systemStatsInterval) {
234
+ clearInterval(systemStatsInterval)
235
+ systemStatsInterval = null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Sleeps for the specified amount of time.
241
+ * @param ms
242
+ */
243
+ export function sleep(ms: number): Promise<void> {
244
+ return new Promise(resolve => setTimeout(() => resolve(), ms))
245
+ }
246
+
247
+ declare global {
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
+ let getActiveAudioTracks: () => any[]
250
+ let publisherSetMuted: (muted: boolean) => Promise<void>
251
+ }
252
+
253
+ let randomActivateAudioTimeoutId: NodeJS.Timeout | null = null
254
+ let randomActivateAudioRunning = false
255
+
256
+ export function startRandomActivateAudio(
257
+ sessions: Map<number, Session>,
258
+ randomAudioPeriod: number,
259
+ randomAudioProbability: number,
260
+ randomAudioRange: number,
261
+ ): void {
262
+ if (randomActivateAudioRunning) return
263
+ randomActivateAudioRunning = true
264
+ void randomActivateAudio(sessions, randomAudioPeriod, randomAudioProbability, randomAudioRange)
265
+ }
266
+
267
+ export function stopRandomActivateAudio(): void {
268
+ randomActivateAudioRunning = false
269
+ if (randomActivateAudioTimeoutId) clearTimeout(randomActivateAudioTimeoutId)
270
+ }
271
+
272
+ /**
273
+ * Randomly activate audio from one tab at time.
274
+ * @param sessions The sessions Map
275
+ * @param randomAudioPeriod If set, the function will be called in loop
276
+ * @param randomAudioProbability The activation probability
277
+ * @param randomAudioRange The number of pages to include into the automation
278
+ */
279
+ export async function randomActivateAudio(
280
+ sessions: Map<number, Session>,
281
+ randomAudioPeriod: number,
282
+ randomAudioProbability: number,
283
+ randomAudioRange: number,
284
+ ): Promise<void> {
285
+ if (!randomAudioPeriod || !randomActivateAudioRunning) {
286
+ return
287
+ }
288
+ try {
289
+ let pages: (Page | null)[] = []
290
+ for (const session of sessions.values()) {
291
+ const sessionPages = [...session.pages.values()]
292
+ if (randomAudioRange) {
293
+ if (session.id > randomAudioRange) {
294
+ break
295
+ }
296
+ sessionPages.splice(randomAudioRange - session.id)
297
+ }
298
+ pages = pages.concat(sessionPages)
299
+ }
300
+ // Remove pages with no audio tracks.
301
+ for (const [i, page] of pages.entries()) {
302
+ if (!page) {
303
+ continue
304
+ }
305
+ let active = 0
306
+ try {
307
+ active = await page.evaluate(() => getActiveAudioTracks().length)
308
+ } catch (err) {
309
+ log.error(`randomActivateAudio error: ${(err as Error).stack}`)
310
+ }
311
+ if (!active) {
312
+ pages[i] = null
313
+ }
314
+ }
315
+ const pagesWithAudio: Page[] = pages.filter(p => !!p)
316
+ //
317
+ const index = Math.floor(Math.random() * pagesWithAudio.length)
318
+ const enable = Math.round(100 * Math.random()) <= randomAudioProbability
319
+ log.debug('randomActivateAudio %j', {
320
+ pages: pagesWithAudio.length,
321
+ randomAudioProbability,
322
+ index,
323
+ enable,
324
+ })
325
+ for (const [i, page] of pagesWithAudio.entries()) {
326
+ try {
327
+ if (i === index) {
328
+ log.debug(`Changing audio in page ${i + 1}/${pagesWithAudio.length} (enable: ${enable})`)
329
+ await page.evaluate(async enable => {
330
+ if (typeof publisherSetMuted !== 'undefined') {
331
+ await publisherSetMuted(!enable)
332
+ } else {
333
+ getActiveAudioTracks().forEach(track => {
334
+ track.enabled = enable
335
+ // track.dispatchEvent(new Event('custom-enabled'));
336
+ })
337
+ }
338
+ }, enable)
339
+ } else {
340
+ await page.evaluate(async () => {
341
+ if (typeof publisherSetMuted !== 'undefined') {
342
+ await publisherSetMuted(true)
343
+ } else {
344
+ getActiveAudioTracks().forEach(track => {
345
+ track.enabled = false
346
+ // track.dispatchEvent(new Event('custom-enabled'));
347
+ })
348
+ }
349
+ })
350
+ }
351
+ } catch (err) {
352
+ log.error(`randomActivateAudio in page ${i + 1}/${pagesWithAudio.length} error: ${(err as Error).stack}`)
353
+ }
354
+ }
355
+ } catch (err) {
356
+ log.error(`randomActivateAudio error: ${(err as Error).stack}`)
357
+ } finally {
358
+ if (randomActivateAudioRunning) {
359
+ const nextTime = randomAudioPeriod * (1 + Math.random())
360
+ if (randomActivateAudioTimeoutId) clearTimeout(randomActivateAudioTimeoutId)
361
+ randomActivateAudioTimeoutId = setTimeout(
362
+ randomActivateAudio,
363
+ nextTime * 1000,
364
+ sessions,
365
+ randomAudioPeriod,
366
+ randomAudioProbability,
367
+ randomAudioRange,
368
+ )
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * The {@link downloadUrl} output.
375
+ */
376
+ export interface DownloadData {
377
+ /** Download data. */
378
+ data: string
379
+ /** Start byte range. */
380
+ start: number
381
+ /** End byte range. */
382
+ end: number
383
+ /** Total returned size. */
384
+ total: number
385
+ }
386
+
387
+ /**
388
+ * Downloads the specified `url` to a local file or returning the file content
389
+ * as {@link DownloadData} object.
390
+ * @param url The remote url to download.
391
+ * @param auth The basic authentication (`user:password`).
392
+ * @param outputLocationPath The file output. If not specified, the download
393
+ * content will be returned as {@link DownloadData} instance.
394
+ * @param range The HTTP byte range to download (e.g. `10-100`).
395
+ * @param timeout The download timeout in milliseconds.
396
+ */
397
+ export async function downloadUrl(
398
+ url: string,
399
+ auth?: string,
400
+ outputLocationPath?: string,
401
+ range?: string,
402
+ timeout = 60000,
403
+ ): Promise<void | DownloadData> {
404
+ log.debug(`downloadUrl url=${url} ${outputLocationPath}`)
405
+ const authParts = auth?.split(':')
406
+ let writer: WriteStream | null = null
407
+ if (outputLocationPath) {
408
+ await fs.promises.mkdir(dirname(outputLocationPath), {
409
+ recursive: true,
410
+ })
411
+ writer = createWriteStream(outputLocationPath)
412
+ }
413
+ const response = await axios({
414
+ method: 'get',
415
+ url,
416
+ auth: authParts
417
+ ? {
418
+ username: authParts[0],
419
+ password: authParts[1],
420
+ }
421
+ : undefined,
422
+ headers: range
423
+ ? {
424
+ Range: `bytes=${range}`,
425
+ }
426
+ : undefined,
427
+ timeout,
428
+ onDownloadProgress: event => {
429
+ log.debug(`downloadUrl fileUrl=${url} progress=${event.progress || event.bytes}`)
430
+ },
431
+ httpsAgent: new Agent({
432
+ rejectUnauthorized: false,
433
+ }),
434
+ responseType: writer ? 'stream' : 'text',
435
+ })
436
+ if (writer) {
437
+ return new Promise((resolve, reject) => {
438
+ if (!writer) {
439
+ return
440
+ }
441
+ response.data.pipe(writer)
442
+ let error: Error | null = null
443
+ writer.once('error', err => {
444
+ error = err
445
+ if (writer) writer.close()
446
+ reject(err)
447
+ })
448
+ writer.once('close', () => {
449
+ if (!error) {
450
+ resolve()
451
+ }
452
+ })
453
+ })
454
+ } else {
455
+ /* log.debug(`downloadUrl ${response.data.length} bytes, headers=${
456
+ JSON.stringify(response.headers)}`); */
457
+ let start = 0
458
+ let end = 0
459
+ let total = 0
460
+ if (response.headers['content-range']) {
461
+ log.debug(`downloadUrl ${response.data.length} bytes, content-range=${response.headers['content-range']}`)
462
+ const contentRange = response.headers['content-range'].split('/')
463
+ const rangeParts = contentRange[0].split('-')
464
+ total = parseInt(contentRange[1])
465
+ if (rangeParts.length === 2) {
466
+ start = parseInt(rangeParts[0])
467
+ end = parseInt(rangeParts[1])
468
+ } else if (contentRange[0].startsWith('-')) {
469
+ end = parseInt(rangeParts[0])
470
+ } else if (contentRange[0].endsWith('-')) {
471
+ start = parseInt(rangeParts[0])
472
+ end = total
473
+ }
474
+ }
475
+ return {
476
+ data: response.data,
477
+ start,
478
+ end,
479
+ total,
480
+ }
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Uploads the file to the specified `url`.
486
+ * @param filePath The file path to upload.
487
+ * @param url The remote url to upload.
488
+ * @param auth The basic authentication (`user:password`).
489
+ */
490
+ export async function uploadUrl(filePath: string, url: string, auth?: string): Promise<string> {
491
+ log.debug(`uploadUrl ${filePath} to ${url}`)
492
+ const authParts = auth?.split(':')
493
+ const formData = new FormData()
494
+ formData.append('file', fs.createReadStream(filePath))
495
+ const response = await axios({
496
+ method: 'post',
497
+ url,
498
+ auth: authParts
499
+ ? {
500
+ username: authParts[0],
501
+ password: authParts[1],
502
+ }
503
+ : undefined,
504
+ headers: formData.getHeaders(),
505
+ timeout: 3600 * 1000,
506
+ httpsAgent: new Agent({
507
+ rejectUnauthorized: false,
508
+ }),
509
+ responseType: 'text',
510
+ data: formData,
511
+ })
512
+ return response.data as string
513
+ }
514
+
515
+ const HideAuthRegExp = new RegExp('(http[s]{0,1}://)(.+?:.+?@)', 'g')
516
+
517
+ /**
518
+ * Hides the authentication part from an HTTP url.
519
+ * @param data
520
+ */
521
+ export function hideAuth(data: string): string {
522
+ if (!data) {
523
+ return data
524
+ }
525
+ return data.replace(HideAuthRegExp, '$1')
526
+ }
527
+
528
+ /** Exit handler callback. */
529
+ export type ExitHandler = (signal?: string) => Promise<void>
530
+
531
+ const exitHandlers = new Set<ExitHandler>()
532
+
533
+ /**
534
+ * Register an {@link ExitHandler} callback that will be executed at the
535
+ * nodejs process exit.
536
+ * @param exitHandler
537
+ */
538
+ export function registerExitHandler(exitHandler: ExitHandler): void {
539
+ exitHandlers.add(exitHandler)
540
+ }
541
+
542
+ /**
543
+ * Un-registers the {@link ExitHandler} callback.
544
+ * @param exitHandler
545
+ */
546
+ export function unregisterExitHandler(exitHandler: ExitHandler): void {
547
+ exitHandlers.delete(exitHandler)
548
+ }
549
+
550
+ const runExitHandlers = async (signal?: string): Promise<void> => {
551
+ let i = 0
552
+ for (const exitHandler of exitHandlers.values()) {
553
+ const id = `${i + 1}/${exitHandlers.size}`
554
+ log.debug(`running exitHandler ${id}`)
555
+ try {
556
+ await exitHandler(signal)
557
+ log.debug(` exitHandler ${id} done`)
558
+ } catch (err) {
559
+ log.error(`exitHandler ${id} error: ${err}`)
560
+ }
561
+ i++
562
+ }
563
+ exitHandlers.clear()
564
+ }
565
+
566
+ let runExitHandlersPromise: Promise<void> | null = null
567
+
568
+ /**
569
+ * Runs the registered exit handlers immediately.
570
+ * @param signal The process exit signal.
571
+ */
572
+ export async function runExitHandlersNow(signal?: string): Promise<void> {
573
+ if (!runExitHandlersPromise) {
574
+ runExitHandlersPromise = runExitHandlers(signal)
575
+ }
576
+ await runExitHandlersPromise
577
+ stopTimers()
578
+ }
579
+
580
+ const SIGNALS = [
581
+ 'beforeExit',
582
+ 'uncaughtException',
583
+ 'unhandledRejection',
584
+ 'SIGHUP',
585
+ 'SIGINT',
586
+ 'SIGQUIT',
587
+ 'SIGILL',
588
+ 'SIGTRAP',
589
+ 'SIGABRT',
590
+ 'SIGBUS',
591
+ 'SIGFPE',
592
+ 'SIGUSR1',
593
+ 'SIGSEGV',
594
+ 'SIGUSR2',
595
+ 'SIGTERM',
596
+ ]
597
+ process.setMaxListeners(process.getMaxListeners() + SIGNALS.length)
598
+ SIGNALS.forEach(event =>
599
+ process.once(event, async signal => {
600
+ if (signal instanceof Error) {
601
+ log.error(`Exit on error: ${signal.stack || signal.message}`)
602
+ } else {
603
+ log.debug(`Exit on signal: ${signal}`)
604
+ }
605
+ await runExitHandlersNow(signal)
606
+ process.exit(0)
607
+ }),
608
+ )
609
+
610
+ /**
611
+ * Downloads the configured chrome executable if it doesn't exists into the
612
+ * `$HOME/.webrtcperf/chrome` directory.
613
+ * @returns The revision info.
614
+ */
615
+ export async function checkChromeExecutable(): Promise<string> {
616
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
617
+ const { loadConfig } = require('./config')
618
+ const config = loadConfig()
619
+ const cacheDir = path.join(os.homedir(), '.webrtcperf/chrome')
620
+
621
+ const fixSemVer = (v: string) => v.split('.').slice(0, 3).join('.')
622
+
623
+ const browsers = await getInstalledBrowsers({ cacheDir })
624
+ const revisions = browsers.map(b => fixSemVer(b.buildId))
625
+ const browser = Browser.CHROME
626
+ revisions.sort(getVersionComparator(browser))
627
+ log.debug(`Available chrome versions: ${revisions}`)
628
+ const requiredRevision = config.chromiumVersion
629
+ if (!requiredRevision) throw new Error('Chromium version not set')
630
+ if (!revisions.includes(fixSemVer(requiredRevision))) {
631
+ log.info(`Downloading chrome ${requiredRevision}...`)
632
+ let progress = 0
633
+ await install({
634
+ browser,
635
+ buildId: requiredRevision,
636
+ cacheDir,
637
+ downloadProgressCallback: (downloadedBytes, totalBytes) => {
638
+ const cur = Math.round((100 * downloadedBytes) / totalBytes)
639
+ if (cur - progress > 1) {
640
+ progress = cur
641
+ log.info(` ${progress}%`)
642
+ }
643
+ },
644
+ })
645
+ log.info(`Downloading chrome ${requiredRevision} done.`)
646
+ }
647
+ return computeExecutablePath({
648
+ browser,
649
+ cacheDir,
650
+ buildId: requiredRevision,
651
+ })
652
+ }
653
+
654
+ export function clampMinMax(value: number, min: number, max: number): number {
655
+ return Math.max(Math.min(value, max), min)
656
+ }
657
+
658
+ /** Runs the shell command asynchronously. */
659
+ export async function runShellCommand(
660
+ cmd: string,
661
+ verbose = false,
662
+ maxBuffer = 1024 * 1024,
663
+ ): Promise<{ stdout: string; stderr: string }> {
664
+ if (verbose) log.debug(`runShellCommand cmd: ${cmd}`)
665
+ return new Promise((resolve, reject) => {
666
+ const p = spawn(cmd, {
667
+ shell: true,
668
+ stdio: ['ignore', 'pipe', 'pipe'],
669
+ detached: true,
670
+ })
671
+ let stdout = ''
672
+ let stderr = ''
673
+ p.stdout.on('data', data => {
674
+ if (maxBuffer && stdout.length > maxBuffer) {
675
+ stdout = stdout.slice(data.length)
676
+ }
677
+ stdout += data
678
+ })
679
+ p.stderr.on('data', data => {
680
+ if (maxBuffer && stderr.length > maxBuffer) {
681
+ stderr = stderr.slice(data.length)
682
+ }
683
+ stderr += data
684
+ })
685
+ p.once('error', err => reject(err))
686
+ p.once('close', code => {
687
+ if (code !== 0) {
688
+ reject(new Error(`runShellCommand cmd: ${cmd} failed with code ${code}: ${stderr}`))
689
+ } else {
690
+ if (verbose) log.debug(`runShellCommand cmd: ${cmd} done`, { stdout, stderr })
691
+ resolve({ stdout, stderr })
692
+ }
693
+ })
694
+ })
695
+ }
696
+
697
+ //
698
+ const ipCache = new Map<string, { host: string; timestamp: number }>()
699
+
700
+ /**
701
+ * Resolves the IP address hostname.
702
+ * @param ip The IP address.
703
+ * @param cacheTime The number of milliseconds to keep the resolved hostname
704
+ * into the memory cache.
705
+ * @returns The IP address hostname.
706
+ */
707
+ export async function resolveIP(ip: string, cacheTime = 60 * 60 * 1000): Promise<string> {
708
+ if (!ip) return ''
709
+ if (ipaddrJs.parse(ip).range() === 'private') {
710
+ return ip
711
+ }
712
+ const timestamp = Date.now()
713
+ const ret = ipCache.get(ip)
714
+ if (!ret || timestamp - ret.timestamp > cacheTime) {
715
+ const host = await Promise.race([
716
+ sleep(1000),
717
+ dns.promises
718
+ .reverse(ip)
719
+ .then(hosts => {
720
+ if (hosts.length) {
721
+ log.debug(`resolveIP ${ip} -> ${hosts.join(', ')}`)
722
+ ipCache.set(ip, {
723
+ host: hosts[0],
724
+ // Keep the value for 10 min.
725
+ timestamp: timestamp + 10 * cacheTime,
726
+ })
727
+ return hosts[0]
728
+ } else {
729
+ ipCache.set(ip, { host: ip, timestamp })
730
+ return ip
731
+ }
732
+ })
733
+ .catch(err => {
734
+ log.debug(`resolveIP error: ${(err as Error).stack}`)
735
+ ipCache.set(ip, { host: ip, timestamp })
736
+ }),
737
+ ])
738
+ return host || ip
739
+ }
740
+ return ret?.host || ip
741
+ }
742
+
743
+ /**
744
+ * Strips the console logs characters from the provided string.
745
+ * @param str The input string.
746
+ * @returns The strippped string.
747
+ */
748
+ export function stripColors(str: string): string {
749
+ // eslint-disable-next-line no-control-regex
750
+ return str.replace(/\x1B[[(?);]{0,2}(;?\d)*./g, '')
751
+ }
752
+
753
+ const nvidiaGpuPresent = fs.existsSync('/usr/bin/nvidia-smi') && fs.existsSync('/dev/dri')
754
+
755
+ const macOS = process.platform === 'darwin' && fs.existsSync('/usr/sbin/ioreg')
756
+
757
+ /**
758
+ * Returns the GPU usage.
759
+ *
760
+ * On Linux, the `nvidia-smi` should be installed if Nvidia card is used.
761
+ * @returns The GPU usage.
762
+ */
763
+ export async function systemGpuStats(): Promise<{ gpu: number; mem: number }> {
764
+ try {
765
+ if (nvidiaGpuPresent) {
766
+ const { stdout } = await runShellCommand('nvidia-smi --query-gpu=utilization.gpu,utilization.memory --format=csv')
767
+ const line = stdout.split('\n')[1].trim()
768
+ const [gpu, mem] = line.split(',').map(s => parseFloat(s.replace(' %', '')))
769
+ return { gpu, mem }
770
+ } else if (macOS) {
771
+ const { stdout } = await runShellCommand('ioreg -r -d 1 -w 0 -c IOAccelerator | grep PerformanceStatistics\\"')
772
+ const stats = JSON.parse(stdout.trim().split(' = ')[1].replace(/=/g, ':'))
773
+ const gpu = stats['Device Utilization %'] || stats['GPU Activity(%)'] || 0
774
+ return { gpu, mem: 0 }
775
+ }
776
+ } catch (err) {
777
+ log.debug(`systemGpuStats error: ${(err as Error).stack}`)
778
+ }
779
+ return { gpu: 0, mem: 0 }
780
+ }
781
+
782
+ /**
783
+ * Schedules a function call at the specified time interval.
784
+ */
785
+ export class Scheduler {
786
+ private readonly name: string
787
+ private readonly interval: number
788
+ private readonly callback: (now: number) => Promise<void>
789
+ private readonly verbose: boolean
790
+
791
+ private running = false
792
+ private last = 0
793
+ private errorSum = 0
794
+ private statsTimeoutId?: NodeJS.Timeout
795
+
796
+ /**
797
+ * Scheduler.
798
+ * @param name Logging name.
799
+ * @param interval Update interval in seconds.
800
+ * @param callback Callback function.
801
+ * @param verbose Verbose logging.
802
+ */
803
+ constructor(name: string, interval: number, callback: (now: number) => Promise<void>, verbose = false) {
804
+ this.name = name
805
+ this.interval = interval * 1000
806
+ this.callback = callback
807
+ this.verbose = verbose
808
+ log.debug(`[${this.name}-scheduler] constructor interval=${this.interval}ms`)
809
+ }
810
+
811
+ start(): void {
812
+ log.debug(`[${this.name}-scheduler] start`)
813
+ this.running = true
814
+ this.scheduleNext()
815
+ }
816
+
817
+ stop(): void {
818
+ log.debug(`[${this.name}-scheduler] stop`)
819
+ this.running = false
820
+ if (this.statsTimeoutId) {
821
+ clearTimeout(this.statsTimeoutId)
822
+ }
823
+ }
824
+
825
+ private scheduleNext(): void {
826
+ if (!this.running) {
827
+ return
828
+ }
829
+ const now = Date.now()
830
+ if (this.last) {
831
+ this.errorSum += clampMinMax(now - this.last - this.interval, -this.interval, this.interval)
832
+ if (this.verbose) {
833
+ log.debug(`[${this.name}-scheduler] last=${now - this.last}ms drift=${this.errorSum}ms`)
834
+ }
835
+ }
836
+ this.last = now
837
+ this.statsTimeoutId = setTimeout(
838
+ async () => {
839
+ try {
840
+ const now = Date.now()
841
+ await this.callback(now)
842
+ const elapsed = Date.now() - now
843
+ if (elapsed > this.interval) {
844
+ log.warn(`[${this.name}-scheduler] callback elapsed=${elapsed}ms > ${this.interval}ms`)
845
+ } else if (this.verbose) {
846
+ log.debug(`[${this.name}-scheduler] callback elapsed=${elapsed}ms`)
847
+ }
848
+ } catch (err) {
849
+ log.error(`[${this.name}-scheduler] callback error: ${(err as Error).stack}`, err)
850
+ } finally {
851
+ this.scheduleNext()
852
+ }
853
+ },
854
+ this.interval - this.errorSum / 2,
855
+ )
856
+ }
857
+ }
858
+
859
+ //
860
+ export class PeerConnectionExternal {
861
+ public id: number
862
+ private process
863
+ private static cache = new Map<number, PeerConnectionExternal>()
864
+
865
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
866
+ constructor(options?: any) {
867
+ this.process = spawn('sleep', ['600'])
868
+ this.id = this.process.pid || -1
869
+ log.debug(`PeerConnectionExternal contructor: ${this.id}`, options)
870
+ PeerConnectionExternal.cache.set(this.id, this)
871
+
872
+ this.process.stdout.on('data', data => {
873
+ log.debug(`PeerConnectionExternal stdout: ${data}`)
874
+ })
875
+
876
+ this.process.stderr.on('data', data => {
877
+ log.debug(`PeerConnectionExternal stderr: ${data}`)
878
+ })
879
+
880
+ this.process.on('close', code => {
881
+ log.debug(`PeerConnectionExternal process exited with code ${code}`)
882
+ PeerConnectionExternal.cache.delete(this.id)
883
+ })
884
+ }
885
+
886
+ static get(id: number): PeerConnectionExternal | undefined {
887
+ return PeerConnectionExternal.cache.get(id)
888
+ }
889
+
890
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
891
+ async createOffer(options: any) {
892
+ log.debug(`PeerConnectionExternal createOffer`, { options })
893
+ return {}
894
+ }
895
+
896
+ setLocalDescription(description: string) {
897
+ log.debug(`PeerConnectionExternal setLocalDescription`, description)
898
+ }
899
+
900
+ setRemoteDescription(description: string) {
901
+ log.debug(`PeerConnectionExternal setRemoteDescription`, description)
902
+ }
903
+ }
904
+
905
+ export type PeerConnectionExternalMethod = 'createOffer' | 'setLocalDescription' | 'setRemoteDescription'
906
+
907
+ export function toTitleCase(s: string): string {
908
+ return s.charAt(0).toUpperCase() + s.slice(1)
909
+ }
910
+
911
+ export async function getFiles(dir: string, ext: string): Promise<string[]> {
912
+ const dirs = await fs.promises.readdir(dir, { withFileTypes: true })
913
+ const files = await Promise.all(
914
+ dirs.map(entry => {
915
+ const res = path.resolve(dir, entry.name)
916
+ return entry.isDirectory() ? getFiles(res, ext) : res
917
+ }),
918
+ )
919
+ return Array.prototype.concat(...files).filter(f => f.endsWith(ext))
920
+ }
921
+
922
+ /**
923
+ * Format number to the specified precision.
924
+ * @param value value to format
925
+ * @param precision precision
926
+ */
927
+ export function toPrecision(value: number, precision = 3): string {
928
+ return (Math.round(value * 10 ** precision) / 10 ** precision).toFixed(precision)
929
+ }
930
+
931
+ export async function getDefaultNetworkInterface(): Promise<string> {
932
+ const { stdout } = await runShellCommand(`ip route | awk '/default/ {print $5; exit}' | tr -d ''`)
933
+ return stdout.trim()
934
+ }
935
+
936
+ export async function checkNetworkInterface(device: string): Promise<void> {
937
+ await runShellCommand(`ip route | grep -q "dev ${device}"`)
938
+ }
939
+
940
+ export async function portForwarder(port: number, listenInterface?: string) {
941
+ if (!listenInterface) {
942
+ listenInterface = await getDefaultNetworkInterface()
943
+ }
944
+ const controller = new AbortController()
945
+ Object.entries(networkInterfaces()).forEach(([iface, nets]) => {
946
+ if (listenInterface !== '0.0.0.0' && iface !== listenInterface) return
947
+ if (!nets) return
948
+ for (const n of nets) {
949
+ if (n.internal || n.address === '127.0.0.1' || n.family !== 'IPv4') {
950
+ continue
951
+ }
952
+ const msg = `portForwarder on ${iface} (${n.address}:${port})`
953
+ const server = net
954
+ .createServer(from => {
955
+ const to = net.createConnection({ host: '127.0.0.1', port })
956
+ from.once('error', err => {
957
+ log.error(`${msg} error: ${err.stack}`)
958
+ to.destroy()
959
+ })
960
+ to.once('error', err => {
961
+ log.error(`${msg} error: ${err.stack}`)
962
+ from.destroy()
963
+ })
964
+ from.pipe(to)
965
+ to.pipe(from)
966
+ })
967
+ .listen({ port, host: n.address, signal: controller.signal })
968
+ server.on('listening', () => {
969
+ log.debug(`${msg} listening`)
970
+ })
971
+ server.once('error', err => {
972
+ log.error(`${msg} error: ${err.stack}`)
973
+ })
974
+ }
975
+ })
976
+
977
+ return () => {
978
+ log.debug(`portForwarder on port ${port} stop`)
979
+ controller.abort()
980
+ }
981
+ }
982
+
983
+ export async function pageScreenshot(
984
+ url: string,
985
+ filePath: string,
986
+ width = 1920,
987
+ height = 1024,
988
+ selector = 'body',
989
+ headers?: Record<string, string>,
990
+ extraCss?: string,
991
+ ): Promise<void> {
992
+ log.debug(`pageScreenshot ${url} -> ${filePath}`)
993
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
994
+ let executablePath = process.env.CHROMIUM_PATH
995
+ if (!executablePath || !fs.existsSync(executablePath)) {
996
+ executablePath = await checkChromeExecutable()
997
+ }
998
+ const browser = await puppeteer.launch({
999
+ headless: true,
1000
+ executablePath,
1001
+ defaultViewport: {
1002
+ width,
1003
+ height,
1004
+ deviceScaleFactor: 1,
1005
+ isMobile: false,
1006
+ hasTouch: false,
1007
+ isLandscape: false,
1008
+ },
1009
+ args: [
1010
+ '--no-sandbox',
1011
+ '--disable-setuid-sandbox',
1012
+ //'--remote-debugging-port=9222',
1013
+ ],
1014
+ })
1015
+ const page = await browser.newPage()
1016
+ if (headers) {
1017
+ await page.setExtraHTTPHeaders(headers)
1018
+ }
1019
+ if (extraCss) {
1020
+ await page.evaluateOnNewDocument((css: string) => {
1021
+ document.addEventListener('DOMContentLoaded', () => {
1022
+ const style = document.createElement('style')
1023
+ style.setAttribute('id', 'webrtcperf-extra-style')
1024
+ style.setAttribute('type', 'text/css')
1025
+ style.innerHTML = css
1026
+ document.head.appendChild(style)
1027
+ })
1028
+ }, extraCss)
1029
+ }
1030
+ await page.goto(url, {
1031
+ waitUntil: ['domcontentloaded', 'networkidle0'],
1032
+ timeout: 60 * 1000,
1033
+ })
1034
+ try {
1035
+ const element = await page.waitForSelector(selector, {
1036
+ visible: true,
1037
+ timeout: 15 * 1000,
1038
+ })
1039
+ if (!element) {
1040
+ throw new Error(`pageScreenshot selector "${selector}" not found`)
1041
+ }
1042
+ await element.screenshot({ path: filePath })
1043
+ } catch (err) {
1044
+ log.error(`pageScreenshot error: ${(err as Error).message}`)
1045
+ } finally {
1046
+ await page.close()
1047
+ await browser.close()
1048
+ }
1049
+ }
1050
+
1051
+ export function enabledForSession(index: number, value: boolean | string | number): boolean {
1052
+ if (value === true || value === 'true') {
1053
+ return true
1054
+ } else if (value === false || value === 'false' || value === undefined) {
1055
+ return false
1056
+ } else if (typeof value === 'string') {
1057
+ if (value.includes('-')) {
1058
+ const [start, end] = value.split('-').map(s => parseInt(s))
1059
+ if (isFinite(start) && index < start) {
1060
+ return false
1061
+ }
1062
+ if (isFinite(end) && index > end) {
1063
+ return false
1064
+ }
1065
+ return true
1066
+ } else {
1067
+ const indexes = value
1068
+ .split(',')
1069
+ .map(s => s.trim())
1070
+ .filter(s => s.length)
1071
+ .map(s => parseInt(s))
1072
+ return indexes.includes(index)
1073
+ }
1074
+ } else if (index === value) {
1075
+ return true
1076
+ }
1077
+ return false
1078
+ }
1079
+
1080
+ export function increaseKey(o: Record<string, number>, key: string, value?: number): void {
1081
+ if (value === undefined || !isFinite(value)) return
1082
+ if (o[key] === undefined) {
1083
+ o[key] = 0
1084
+ }
1085
+ o[key] += value
1086
+ }
1087
+
1088
+ export async function chunkedPromiseAll<T, R>(
1089
+ items: T[],
1090
+ f: (v: T, index: number) => Promise<R>,
1091
+ chunkSize = 1,
1092
+ ): Promise<R[]> {
1093
+ const results = Array<R>(items.length)
1094
+ for (let index = 0; index < items.length; index += chunkSize) {
1095
+ await Promise.allSettled(
1096
+ items.slice(index, index + chunkSize).map(async (item, i) => {
1097
+ const res = await f(item, index + i)
1098
+ if (res !== undefined) results[index + i] = res
1099
+ }),
1100
+ )
1101
+ }
1102
+ return results
1103
+ }
1104
+
1105
+ export function maybeNumber(s: string): string | number {
1106
+ const n = parseFloat(s)
1107
+ return !isNaN(n) ? n : s
1108
+ }
1109
+
1110
+ export enum FFProbeProcess {
1111
+ Skip = 'skip',
1112
+ Stop = 'stop',
1113
+ }
1114
+
1115
+ /**
1116
+ * It runs the ffprobe command to extract the video/video frames.
1117
+ * @param fpath The file path.
1118
+ * @param kind The kind of the media (video or audio).
1119
+ * @param entries Which entries to show.
1120
+ * @param filters Apply filters.
1121
+ * @param frameProcess A function to process the frame. The function return value could be:
1122
+ * - the modified frame object
1123
+ * - `FFProbeProcess.Skip` to skip the frame from the output
1124
+ * - `FFProbeProcess.Stop` to stop processing and immediately return the collected frames.
1125
+ */
1126
+ export async function ffprobe(
1127
+ fpath: string,
1128
+ kind = 'video',
1129
+ entries = '',
1130
+ filters = '',
1131
+ frameProcess?: (_frame: Record<string, string>) => Record<string, string> | FFProbeProcess,
1132
+ ): Promise<Record<string, string>[]> {
1133
+ const cmd = `\
1134
+ exec ffprobe -loglevel error ${kind === 'video' ? '-select_streams v' : ''} -show_frames -print_format compact \
1135
+ ${entries ? `-show_entries ${entries}` : ''} \
1136
+ -f lavfi -i '${kind === 'video' ? '' : 'a'}movie=${fpath}${filters ? `,${filters}` : ''}'`
1137
+ const frames = [] as Record<string, string>[]
1138
+ let stderr = ''
1139
+ let stopProcessing = false
1140
+ return new Promise((resolve, reject) => {
1141
+ const p = spawn(cmd, {
1142
+ shell: true,
1143
+ stdio: ['ignore', 'pipe', 'pipe'],
1144
+ })
1145
+ p.stdout.on('data', data => {
1146
+ if (stopProcessing) return
1147
+ const frame = data
1148
+ .toString()
1149
+ .split('|')
1150
+ .reduce(
1151
+ (prev: Record<string, string>, cur: string) => {
1152
+ const [key, value] = cur.split('=')
1153
+ if (value && !key.startsWith('side_datum')) {
1154
+ prev[key.replace(/[:.]/g, '_')] = value
1155
+ }
1156
+ return prev
1157
+ },
1158
+ {} as Record<string, string>,
1159
+ )
1160
+ if (frameProcess) {
1161
+ const newFrame = frameProcess(frame)
1162
+ if (newFrame === FFProbeProcess.Skip) {
1163
+ // Skip the frame.
1164
+ } else if (newFrame === FFProbeProcess.Stop) {
1165
+ stopProcessing = true
1166
+ p.kill('SIGINT')
1167
+ } else {
1168
+ frames.push(newFrame)
1169
+ }
1170
+ } else {
1171
+ frames.push(frame)
1172
+ }
1173
+ })
1174
+ p.stderr.on('data', data => {
1175
+ stderr += data
1176
+ })
1177
+ p.once('error', err => reject(err))
1178
+ p.once('close', code => {
1179
+ if (code !== 0 && !stopProcessing) {
1180
+ reject(new Error(`${cmd} failed with code ${code}: ${stderr}`))
1181
+ } else {
1182
+ resolve(frames)
1183
+ }
1184
+ })
1185
+ })
1186
+ }
1187
+
1188
+ export function buildIvfHeader(width = 1920, height = 1080, frameRate = 30, fourcc = 'MJPG') {
1189
+ const buffer = Buffer.alloc(32)
1190
+ buffer.write('DKIF', 0, 'utf8')
1191
+ buffer.writeUint16LE(0, 4) // version
1192
+ buffer.writeUint16LE(32, 6) // header size
1193
+ buffer.write(fourcc, 8, 'utf8')
1194
+ buffer.writeUint16LE(width, 12)
1195
+ buffer.writeUint16LE(height, 14)
1196
+ buffer.writeUint32LE(frameRate, 16)
1197
+ buffer.writeUint32LE(1, 20)
1198
+ buffer.writeUint32LE(0, 24) // frame count
1199
+ buffer.writeUint32LE(0, 28) // unused
1200
+ return buffer
1201
+ }
1202
+
1203
+ export async function ffmpeg(command = 'video', processFn: (_frame: Buffer) => void): Promise<void> {
1204
+ const port = 10000 + Math.floor(Math.random() * 10000)
1205
+ const cmd = `exec ffmpeg -hide_banner -loglevel warning ${command} zmq:tcp://127.0.0.1:${port}`
1206
+ log.debug(`${cmd}`)
1207
+ let stderr = ''
1208
+
1209
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1210
+ const zmq = require('zeromq')
1211
+ const sub = new zmq.Subscriber()
1212
+ const p = spawn(cmd, {
1213
+ shell: true,
1214
+ stdio: ['ignore', 'ignore', 'pipe'],
1215
+ })
1216
+ p.stderr.on('data', data => {
1217
+ stderr += data
1218
+ })
1219
+ p.once('error', err => {
1220
+ sub.close()
1221
+ throw err
1222
+ })
1223
+ p.once('close', code => {
1224
+ sub.close()
1225
+ if (code !== 0) {
1226
+ throw new Error(`${cmd} failed with code ${code}: ${stderr}`)
1227
+ }
1228
+ })
1229
+ sub.connect(`tcp://127.0.0.1:${port}`)
1230
+ sub.subscribe('')
1231
+ for await (const [msg] of sub) {
1232
+ await processFn(msg)
1233
+ }
1234
+ sub.close()
1235
+ }
1236
+
1237
+ export async function analyzeColors(fpath: string) {
1238
+ let Y = 0
1239
+ let U = 0
1240
+ let V = 0
1241
+ let SAT = 0
1242
+ let HUE = 0
1243
+ let count = 0
1244
+ await ffprobe(
1245
+ fpath,
1246
+ 'video',
1247
+ 'frame=lavfi.signalstats.YAVG,lavfi.signalstats.UAVG,lavfi.signalstats.VAVG,lavfi.signalstats.SATAVG,lavfi.signalstats.HUEAVG',
1248
+ 'signalstats',
1249
+ frame => {
1250
+ Y += parseFloat(frame.tag_lavfi_signalstats_YAVG)
1251
+ U += parseFloat(frame.tag_lavfi_signalstats_UAVG)
1252
+ V += parseFloat(frame.tag_lavfi_signalstats_VAVG)
1253
+ SAT += parseFloat(frame.tag_lavfi_signalstats_SATAVG)
1254
+ HUE += parseFloat(frame.tag_lavfi_signalstats_HUEAVG)
1255
+ count++
1256
+ return FFProbeProcess.Skip
1257
+ },
1258
+ )
1259
+ return { YAvg: Y / count, UAvg: U / count, VAvg: V / count, SatAvg: SAT / count, HueAvg: HUE / count }
1260
+ }
1261
+
1262
+ /**
1263
+ * Wait for the process to stop or kill it after the timeout.
1264
+ * @param pid The process pid
1265
+ * @param timeout The maximum wait time in milliseconds
1266
+ * @returns `true` if the process stopped, `false` if the process was killed.
1267
+ */
1268
+ export async function waitStopProcess(pid: number, timeout = 5000): Promise<boolean> {
1269
+ log.debug(`waitStopProcess pid: ${pid} timeout: ${timeout}`)
1270
+ const now = Date.now()
1271
+ while (Date.now() - now < timeout) {
1272
+ try {
1273
+ process.kill(pid, 0)
1274
+ await sleep(Math.max(timeout / 10, 200))
1275
+ } catch {
1276
+ return true
1277
+ }
1278
+ }
1279
+ log.warn(`waitStopProcess pid: ${pid} timeout`)
1280
+ try {
1281
+ process.kill(pid, 'SIGKILL')
1282
+ } catch {
1283
+ return true
1284
+ }
1285
+ return false
1286
+ }
1287
+
1288
+ export async function getDockerLogsPath(): Promise<string> {
1289
+ const containerId = await fs.promises.readFile(`${os.homedir()}/.webrtcperf/docker.id`, 'utf-8')
1290
+ const logPath = `/var/lib/docker/containers/${containerId}/${containerId}-json.log`
1291
+ if (!fs.existsSync(logPath)) {
1292
+ throw new Error(`docker logs path ${logPath} not found`)
1293
+ }
1294
+ return logPath
1295
+ }