@vpalmisano/webrtcperf 4.2.2 → 4.4.1

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/src/app.ts CHANGED
@@ -23,6 +23,8 @@ import { calculateVisqolScore } from './visqol'
23
23
  import { calculateVmafScore, convertToIvf, prepareVideo } from './vmaf'
24
24
  import path from 'path'
25
25
  import { markedTerminal } from 'marked-terminal'
26
+ import { EventEmitter } from 'events'
27
+ import { runWithDocker } from './docker'
26
28
 
27
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
30
  const { marked } = require('marked')
@@ -56,141 +58,167 @@ Default value: \`${value.default}\`
56
58
  }
57
59
  }
58
60
 
59
- async function postTest(config: Config): Promise<void> {
60
- // vmaf score.
61
- if (config.vmafPath) {
62
- console.log('Calculating VMAF score...')
63
- try {
64
- await calculateVmafScore(config)
65
- } catch (err: unknown) {
66
- log.error(`vmaf score error: ${(err as Error).stack}`)
61
+ export class Application extends EventEmitter {
62
+ readonly config: Config
63
+ readonly stats: Stats
64
+ readonly server?: Server
65
+ private mediaPaths: MediaPath[] = []
66
+
67
+ constructor(config: Config) {
68
+ super()
69
+ if (!config.startTimestamp) {
70
+ config.startTimestamp = Date.now()
71
+ }
72
+ this.config = config
73
+ this.stats = new Stats(config)
74
+ if (config.serverPort) {
75
+ this.server = new Server(config, this.stats)
67
76
  }
68
77
  }
69
78
 
70
- // visqol score
71
- if (config.visqolPath) {
72
- console.log('Calculating Visqol score...')
73
- try {
74
- await calculateVisqolScore(config)
75
- } catch (err: unknown) {
76
- log.error(`visqol score error: ${(err as Error).stack}`)
79
+ async start() {
80
+ log.debug(`start (runDuration: ${this.config.runDuration})`)
81
+ await this.stats.start()
82
+ if (this.server) {
83
+ await this.server.start()
77
84
  }
78
- }
79
- }
85
+ const config = this.config
80
86
 
81
- export async function setupApplication(config: Config): Promise<{ stats: Stats; stop: () => Promise<void> }> {
82
- if (!config.startTimestamp) {
83
- config.startTimestamp = Date.now()
84
- }
87
+ // Handle vmaf commands.
88
+ if (config.vmafPrepareVideo) {
89
+ await prepareVideo(config, true)
90
+ }
91
+ if (config.vmafProcessVideo) {
92
+ await convertToIvf(
93
+ config.vmafProcessVideo,
94
+ config.vmafVideoCrop,
95
+ config.vmafKeepSourceFiles,
96
+ config.vmafSkipDuplicated,
97
+ )
98
+ }
85
99
 
86
- // Stats.
87
- const stats = new Stats(config)
88
- await stats.start()
100
+ // Handle sessions.
101
+ if (config.sessions > 0) {
102
+ // Prepare fake video and audio.
103
+ if (config.videoPath && !this.mediaPaths.length) {
104
+ for (const videoPath of config.videoPath.split(',')) {
105
+ const ret = await prepareFakeMedia({ ...config, videoPath })
106
+ this.mediaPaths.push(ret)
107
+ }
108
+ }
89
109
 
90
- // Control server.
91
- let server: Server | undefined
92
- if (config.serverPort) {
93
- server = new Server(config, stats)
94
- await server.start()
95
- }
110
+ // Network throttle.
111
+ if (config.throttleConfig) {
112
+ await startThrottle(config.throttleConfig)
113
+ }
96
114
 
97
- // If sessions are set, prepare fake video/audio and start sessions.
98
- if (config.sessions > 0) {
99
- // Prepare fake video and audio.
100
- const mediaPaths: MediaPath[] = []
101
- if (config.videoPath) {
102
- for (const videoPath of config.videoPath.split(',')) {
103
- const ret = await prepareFakeMedia({ ...config, videoPath })
104
- mediaPaths.push(ret)
115
+ // Download browser if necessary.
116
+ if (!config.chromiumUrl && !config.chromiumPath) {
117
+ await checkChromeExecutable()
105
118
  }
106
- }
107
119
 
108
- // Network throttle.
109
- if (config.throttleConfig) {
110
- await startThrottle(config.throttleConfig)
120
+ // Start the local sessions.
121
+ if (config.randomAudioPeriod) {
122
+ startRandomActivateAudio(
123
+ this.stats.sessions,
124
+ config.randomAudioPeriod,
125
+ config.randomAudioProbability,
126
+ config.randomAudioRange,
127
+ )
128
+ }
129
+ const spawnPeriod = 1000 / config.spawnRate
130
+ log.debug(`Starting ${config.sessions} sessions (spawnPeriod: ${spawnPeriod}ms)`)
131
+ const startTime = Date.now()
132
+ for (let i = 0; i < config.sessions; i += 1) {
133
+ const id = this.stats.consumeSessionId(config.tabsPerSession)
134
+ await this.startSession(id, spawnPeriod)
135
+ // If not the last session, sleep.
136
+ if (i < config.sessions - 1) {
137
+ await sleep(spawnPeriod)
138
+ }
139
+ }
140
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
141
+ const spawnRate = (config.sessions * config.tabsPerSession) / elapsed
142
+ log.debug(`${config.sessions * config.tabsPerSession} pages started in ${elapsed}s (${spawnRate.toFixed(2)}/s)`)
111
143
  }
112
144
 
113
- // Download browser if necessary.
114
- if (!config.chromiumUrl && !config.chromiumPath) {
115
- await checkChromeExecutable()
145
+ if (config.runDuration || config.vmafPath || config.visqolPath) {
146
+ setTimeout(() => this.stop(), config.runDuration * 1000)
116
147
  }
148
+ }
117
149
 
118
- // Start session function.
119
- const startLocalSession = async (id: number, spawnPeriod: number): Promise<void> => {
120
- const throttleIndex = getSessionThrottleIndex(id)
121
- const mediaPath = mediaPaths.length ? mediaPaths[id % mediaPaths.length] : undefined
122
- const session = new Session({
123
- ...config,
124
- mediaPath,
125
- spawnPeriod,
126
- id,
127
- throttleIndex,
128
- })
129
- session.once('stop', () => {
130
- console.warn(`Session ${id} stopped, reloading...`)
131
- setTimeout(startLocalSession, spawnPeriod, id)
132
- })
133
- stats.addSession(session)
134
- await session.start()
135
- }
150
+ private async startSession(id: number, spawnPeriod: number) {
151
+ log.debug(`startSession ${id}`)
152
+ const throttleIndex = getSessionThrottleIndex(id)
153
+ const mediaPath = this.mediaPaths.length ? this.mediaPaths[id % this.mediaPaths.length] : undefined
154
+ const session = new Session({
155
+ ...this.config,
156
+ mediaPath,
157
+ spawnPeriod,
158
+ id,
159
+ throttleIndex,
160
+ })
161
+ session.once('stop', () => {
162
+ console.warn(`Session ${id} stopped, reloading...`)
163
+ setTimeout(() => this.startSession(id, spawnPeriod), spawnPeriod)
164
+ })
165
+ this.stats.addSession(session)
166
+ await session.start()
167
+ }
136
168
 
137
- // Start the local sessions.
138
- if (config.randomAudioPeriod) {
139
- startRandomActivateAudio(
140
- stats.sessions,
141
- config.randomAudioPeriod,
142
- config.randomAudioProbability,
143
- config.randomAudioRange,
144
- )
169
+ private async postTest() {
170
+ log.debug('postTest')
171
+
172
+ // vmaf score.
173
+ if (this.config.vmafPath) {
174
+ console.log('Calculating VMAF score...')
175
+ try {
176
+ await calculateVmafScore(this.config)
177
+ } catch (err: unknown) {
178
+ log.error(`vmaf score error: ${(err as Error).stack}`)
179
+ }
145
180
  }
146
- const spawnPeriod = 1000 / config.spawnRate
147
- log.debug(`Starting ${config.sessions} sessions (spawnPeriod: ${spawnPeriod}ms)`)
148
- const startTime = Date.now()
149
- for (let i = 0; i < config.sessions; i += 1) {
150
- const id = stats.consumeSessionId(config.tabsPerSession)
151
- await startLocalSession(id, spawnPeriod)
152
- // If not the last session, sleep
153
- if (i < config.sessions - 1) {
154
- await sleep(spawnPeriod)
181
+
182
+ // visqol score
183
+ if (this.config.visqolPath) {
184
+ console.log('Calculating Visqol score...')
185
+ try {
186
+ await calculateVisqolScore(this.config)
187
+ } catch (err: unknown) {
188
+ log.error(`visqol score error: ${(err as Error).stack}`)
155
189
  }
156
190
  }
157
- const elapsed = Math.round((Date.now() - startTime) / 1000)
158
- const spawnRate = (config.sessions * config.tabsPerSession) / elapsed
159
- log.debug(`${config.sessions * config.tabsPerSession} pages started in ${elapsed}s (${spawnRate.toFixed(2)}/s)`)
160
191
  }
161
192
 
162
- return {
163
- stats,
164
- stop: async (): Promise<void> => {
165
- log.debug('Stopping')
193
+ async stop(canceled = false) {
194
+ log.debug(`stop (canceled: ${canceled})`)
166
195
 
167
- stopRandomActivateAudio()
196
+ stopRandomActivateAudio()
168
197
 
169
- await stats.stop()
198
+ await this.stats.stop()
170
199
 
171
- if (config.throttleConfig) {
172
- await stopThrottle()
173
- }
200
+ if (this.config.throttleConfig) {
201
+ await stopThrottle()
202
+ }
174
203
 
175
- stopTimers()
204
+ stopTimers()
176
205
 
177
- await postTest(config)
206
+ await this.postTest()
178
207
 
179
- // Copy docker logs to data directory.
180
- if (config.pageLogPath) {
181
- try {
182
- const logPath = await getDockerLogsPath()
183
- const dataDir = path.dirname(config.pageLogPath)
184
- await fs.promises.cp(logPath, path.resolve(dataDir, 'docker.log'))
185
- } catch (err: unknown) {
186
- log.debug(`docker logs not found: ${(err as Error).message}`)
187
- }
208
+ // Copy docker logs to data directory.
209
+ if (this.config.pageLogPath) {
210
+ try {
211
+ const logPath = await getDockerLogsPath()
212
+ const dataDir = path.dirname(this.config.pageLogPath)
213
+ await fs.promises.cp(logPath, path.resolve(dataDir, 'docker.log'))
214
+ } catch (err: unknown) {
215
+ log.debug(`docker logs not found: ${(err as Error).message}`)
188
216
  }
217
+ }
189
218
 
190
- server?.stop()
219
+ this.server?.stop()
191
220
 
192
- log.debug('Stopped')
193
- },
221
+ this.emit('stop', canceled)
194
222
  }
195
223
  }
196
224
 
@@ -200,52 +228,58 @@ export async function setupApplication(config: Config): Promise<{ stats: Stats;
200
228
  async function main(): Promise<void> {
201
229
  showHelpOrVersion()
202
230
 
203
- let config: Config
231
+ const argv = process.argv.slice(2)
204
232
 
205
- if (process.argv.slice(2).includes('--prompt')) {
206
- const params = await loadConfigFromPrompt(
207
- process.argv
208
- .slice(2)
209
- .filter(s => !['--prompt', '--dry-run'].includes(s))
210
- .join(' '),
211
- )
233
+ // Handle docker run.
234
+ if (argv.includes('--docker')) {
235
+ try {
236
+ await runWithDocker(argv)
237
+ } catch (err: unknown) {
238
+ log.error(`runWithDocker error: ${(err as Error).stack}`)
239
+ process.exit(1)
240
+ }
241
+ process.exit(0)
242
+ }
243
+
244
+ let configs: Config[]
245
+
246
+ // Handle prompt.
247
+ if (argv.includes('--prompt')) {
248
+ const params = await loadConfigFromPrompt(argv.filter(s => !['--prompt', '--dry-run'].includes(s)).join(' '))
212
249
  if (process.argv.slice(2).includes('--dry-run')) {
213
250
  console.log(json5.stringify(params, null, 2))
214
251
  process.exit(0)
215
252
  }
216
- config = await loadConfig(undefined, params)
253
+ configs = await loadConfig(undefined, params)
217
254
  } else {
218
- config = await loadConfig(process.argv[2])
255
+ configs = await loadConfig(process.argv[2])
219
256
  }
220
257
 
221
- if (config.vmafPrepareVideo) {
222
- await prepareVideo(config, true)
223
- process.exit(0)
224
- }
258
+ if (!configs.length) throw new Error('No configuration found')
225
259
 
226
- if (config.vmafProcessVideo) {
227
- await convertToIvf(
228
- config.vmafProcessVideo,
229
- config.vmafVideoCrop,
230
- config.vmafKeepSourceFiles,
231
- config.vmafSkipDuplicated,
232
- )
233
- process.exit(0)
234
- }
260
+ let application: Application
261
+ const runNext = () => {
262
+ const config = configs.splice(0, 1)[0]
235
263
 
236
- const { stop: stopApplication } = await setupApplication(config)
264
+ application = new Application(config)
265
+ application.once('stop', canceled => {
266
+ if (!canceled && configs.length) {
267
+ log.info(`Application stopped, running next (${configs.length} left)...`)
268
+ runNext()
269
+ } else {
270
+ process.exit(0)
271
+ }
272
+ })
273
+ return application.start()
274
+ }
237
275
 
238
- const stop = async (): Promise<void> => {
276
+ const stop = async () => {
239
277
  console.log('Exiting...')
240
-
241
- await stopApplication()
242
-
243
- process.exit(0)
278
+ await application.stop(true)
244
279
  }
245
280
  registerExitHandler(() => stop())
246
281
 
247
- // Stop after a configured duration.
248
- setTimeout(stop, config.runDuration * 1000)
282
+ await runNext()
249
283
 
250
284
  // Command line interface.
251
285
  if (process.stdin && process.stdin.setRawMode) {
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import path, { join } from 'path'
6
6
  import json5 from 'json5'
7
7
  import yaml from 'yaml'
8
8
  import toml from 'toml'
9
+ import fs from 'fs'
9
10
 
10
11
  // eslint-disable-next-line @typescript-eslint/no-require-imports
11
12
  const puppeteer = require('puppeteer-core')
@@ -56,8 +57,7 @@ convict.addParser([
56
57
  { extension: 'toml', parse: toml.parse },
57
58
  ])
58
59
 
59
- // config schema
60
- const configSchema = convict({
60
+ const configSchema = {
61
61
  url: {
62
62
  doc: `The page url to load.`,
63
63
  format: String,
@@ -859,7 +859,7 @@ E.g. \`{ w: "iw-10", h: "ih-5", x: "10", y: '5' }\``,
859
859
  env: 'VISQOL_KEEP_SOURCE_FILES',
860
860
  arg: 'visqol-keep-source-files',
861
861
  },
862
- })
862
+ }
863
863
 
864
864
  type ConfigDocs = Record<string, { doc: string; format: string; default: string }>
865
865
 
@@ -897,10 +897,10 @@ function formatDocs(
897
897
  * It returns the formatted configuration docs.
898
898
  */
899
899
  export function getConfigDocs(): ConfigDocs {
900
- return formatDocs({}, null, configSchema.getSchema())
900
+ return formatDocs({}, null, convict(configSchema).getSchema())
901
901
  }
902
902
 
903
- const _schemaProperties = configSchema.getProperties()
903
+ const _schemaProperties = convict(configSchema).getProperties()
904
904
 
905
905
  /** [[include:config.md]] */
906
906
  export type Config = typeof _schemaProperties
@@ -909,7 +909,8 @@ export type Config = typeof _schemaProperties
909
909
  * Loads the config object.
910
910
  */
911
911
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
912
- export async function loadConfig(filePath?: string, values?: any): Promise<Config> {
912
+ export async function loadConfig(filePath?: string, values?: any): Promise<Config[]> {
913
+ const configs: Config[] = []
913
914
  if (filePath) {
914
915
  if (filePath.startsWith('http')) {
915
916
  log.debug(`Loading config from url: ${filePath}`)
@@ -917,41 +918,45 @@ export async function loadConfig(filePath?: string, values?: any): Promise<Confi
917
918
  if (!res?.data) {
918
919
  throw new Error(`Failed to download configuration from: ${filePath}`)
919
920
  }
920
- const values =
921
+ values =
921
922
  res.contentType === 'application/x-yaml'
922
923
  ? yaml.parse(res.data)
923
924
  : res.contentType === 'application/toml'
924
925
  ? toml.parse(res.data)
925
926
  : json5.parse(res.data)
926
- configSchema.load(values)
927
927
  } else if (existsSync(filePath)) {
928
928
  log.debug(`Loading config from local file: ${filePath}`)
929
929
  if (filePath.endsWith('.js') || filePath.endsWith('.mjs')) {
930
930
  const module = await import(/* webpackIgnore: true */ path.resolve(filePath))
931
- configSchema.load(await module.default())
931
+ values = await module.default()
932
932
  } else {
933
- configSchema.loadFile(filePath)
933
+ const data = String(await fs.promises.readFile(filePath))
934
+ values =
935
+ filePath.endsWith('.yml') || filePath.endsWith('.yaml')
936
+ ? yaml.parse(data)
937
+ : filePath.endsWith('.toml')
938
+ ? toml.parse(data)
939
+ : json5.parse(data)
934
940
  }
935
941
  }
936
- } else if (values) {
937
- log.debug('Loading config from values.')
938
- configSchema.load(values)
939
- } else {
940
- log.debug('Loading config from default values.')
941
- configSchema.load({})
942
942
  }
943
-
944
- configSchema.validate({ allowed: 'strict' })
945
- const config = configSchema.getProperties()
946
-
947
- log.debug('Using config:', config)
948
- return config
943
+ if (!Array.isArray(values)) {
944
+ values = [values || {}]
945
+ }
946
+ for (const value of values) {
947
+ const schema = convict(configSchema)
948
+ schema.load(value || {})
949
+ schema.validate({ allowed: 'strict' })
950
+ configs.push(schema.getProperties())
951
+ }
952
+ log.debug('Using config:', configs)
953
+ return configs
949
954
  }
950
955
 
951
956
  function getFunctionDeclaration() {
952
957
  const properties: Record<string, { type: string; description: string; nullable?: boolean }> = {}
953
958
  const required: string[] = []
954
- const schema = configSchema.getSchema()
959
+ const schema = convict(configSchema).getSchema()
955
960
 
956
961
  Object.entries(schema._cvtProperties).forEach(([name, value]) => {
957
962
  const { format, doc, nullable } = value as SchemaObj
package/src/docker.ts ADDED
@@ -0,0 +1,131 @@
1
+ import path from 'path'
2
+ import Docker from 'dockerode'
3
+ import { logger, resolvePackagePath } from './utils'
4
+ import { loadConfig } from './config'
5
+ import fs from 'fs'
6
+
7
+ const log = logger('webrtcperf:docker')
8
+
9
+ export async function runWithDocker(argv: string[]) {
10
+ const docker = new Docker()
11
+ const configPath = argv.filter(s => s !== '--docker')[0]
12
+ if (!configPath) throw new Error('No configuration file specified')
13
+ const configName = path.basename(configPath)
14
+ const config = (await loadConfig(configPath))[0]
15
+
16
+ const startTimestamp = Date.now()
17
+ const dataDir = path.resolve(path.dirname(configPath), 'logs', `${startTimestamp}`)
18
+ await fs.promises.mkdir(dataDir, { recursive: true })
19
+
20
+ const binds: string[] = [
21
+ `${path.resolve(configPath)}:/config/${configName}:ro`,
22
+ '/dev/shm:/dev/shm',
23
+ `${dataDir}:/data`,
24
+ '/tmp/webrtcperf-cache:/root/.webrtcperf',
25
+ ]
26
+
27
+ if (config.scriptPath) {
28
+ const scriptName = path.basename(config.scriptPath)
29
+ binds.push(`${path.resolve(config.scriptPath)}:/scripts/${scriptName}:ro`)
30
+ }
31
+
32
+ if (process.env.DEBUG_SRC) {
33
+ binds.push(`${resolvePackagePath('app.min.js')}:/app/app.min.js:ro`)
34
+ }
35
+
36
+ const portBindings: Docker.PortMap = {}
37
+ const exposedPorts: { [portAndProtocol: string]: object } = {}
38
+ if (config.debuggingPort) {
39
+ for (let i = 0; i < config.sessions; i++) {
40
+ const port = `${config.debuggingPort + i}/tcp`
41
+ portBindings[port] = [{ HostPort: `${config.debuggingPort + i}` }]
42
+ exposedPorts[port] = {}
43
+ }
44
+ }
45
+
46
+ const env = [
47
+ `DEBUG_LEVEL=${process.env.DEBUG_LEVEL || 'info'}`,
48
+ 'SHOW_PAGE_LOG=false',
49
+ 'SHOW_STATS=false',
50
+ 'SERVER_PORT=5000',
51
+ 'SERVER_USE_HTTPS=true',
52
+ 'SERVER_DATA=/data',
53
+ `START_TIMESTAMP=${startTimestamp}`,
54
+ `STATS_PATH=/data/stats.csv`,
55
+ `PAGE_LOG_PATH=/data/page.log`,
56
+ `DETAILED_STATS_PATH=/data/detailed-stats.csv`,
57
+ ]
58
+
59
+ if (config.scriptPath) {
60
+ const scriptName = path.basename(config.scriptPath)
61
+ env.push(`SCRIPT_PATH=/scripts/${scriptName}`)
62
+ }
63
+
64
+ if (config.debuggingPort) {
65
+ env.push(`DEBUGGING_PORT=${config.debuggingPort}`)
66
+ }
67
+
68
+ if (config.prometheusPushgateway.startsWith('http://localhost')) {
69
+ env.push('PROMETHEUS_PUSHGATEWAY=http://pushgateway:9091')
70
+ }
71
+
72
+ const containerConfig: Docker.ContainerCreateOptions = {
73
+ Image: 'ghcr.io/vpalmisano/webrtcperf:devel',
74
+ name: 'webrtcperf',
75
+ Cmd: [`/config/${configName}`],
76
+ HostConfig: {
77
+ Binds: binds,
78
+ PortBindings: portBindings,
79
+ CapAdd: ['NET_ADMIN'],
80
+ NetworkMode: config.prometheusPushgateway.startsWith('http://localhost') ? 'prometheus-stack_default' : 'bridge',
81
+ },
82
+ Env: env,
83
+ AttachStdin: true,
84
+ AttachStdout: true,
85
+ AttachStderr: true,
86
+ Tty: true,
87
+ OpenStdin: true,
88
+ StdinOnce: true,
89
+ ExposedPorts: exposedPorts,
90
+ }
91
+
92
+ try {
93
+ if (!process.env.DEBUG_SRC) {
94
+ log.info('Pulling latest development image...')
95
+ await docker.pull('ghcr.io/vpalmisano/webrtcperf:devel')
96
+ }
97
+
98
+ try {
99
+ const existingContainer = await docker.getContainer('webrtcperf')
100
+ await existingContainer.remove({ force: true })
101
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
102
+ } catch (err: unknown) {
103
+ // Container doesn't exist, continue
104
+ }
105
+
106
+ const container = await docker.createContainer(containerConfig)
107
+ await container.start()
108
+
109
+ const stream = await container.attach({
110
+ stream: true,
111
+ stdin: true,
112
+ stdout: true,
113
+ stderr: true,
114
+ })
115
+
116
+ process.stdin.pipe(stream)
117
+ stream.pipe(process.stdout)
118
+
119
+ await new Promise(resolve => {
120
+ container.wait((err: Error, data: { StatusCode: number }) => {
121
+ if (err) log.error('Error waiting for container:', data, err.stack)
122
+ resolve(data)
123
+ })
124
+ })
125
+
126
+ await container.remove()
127
+ } catch (error) {
128
+ log.error('Docker operation failed:', error)
129
+ throw error
130
+ }
131
+ }