├── .eslintignore ├── .coveralls.yml ├── tests ├── inputs │ └── test_IPPPP.mp4 ├── mocha.opts ├── Bootstrap.js ├── .eslintrc ├── Unit │ ├── StreamsInfo │ │ ├── Helpers │ │ │ └── index.js │ │ ├── _adjustAspectRatio.test.js │ │ ├── constructor.test.js │ │ ├── _adjustAspectRatio.data.js │ │ ├── constructor.data.js │ │ ├── _runShowStreamsProcess.test.js │ │ ├── _parseStreamsInfo.test.js │ │ └── fetch.test.js │ ├── FramesMonitor │ │ ├── _isValidErrorLevel.test.js │ │ ├── isListening.test.js │ │ ├── Helpers │ │ │ └── index.js │ │ ├── _onStderrData.test.js │ │ ├── _frameToJson.test.js │ │ ├── _assertExecutable.test.js │ │ ├── _onExit.test.js │ │ ├── _handleProcessingError.test.js │ │ ├── _reduceFramesFromChunks.test.js │ │ ├── _onProcessStartError.test.js │ │ ├── _onProcessStreamsError.test.js │ │ ├── constructor.data.js │ │ ├── listen.test.js │ │ ├── _runShowFramesProcess.test.js │ │ ├── stopListen.test.js │ │ ├── constructor.test.js │ │ └── _onStdoutChunk.test.js │ └── processFrames │ │ ├── calculateGopDuration.test.js │ │ ├── findGCD.test.js │ │ ├── filterVideoFrames.test.js │ │ ├── hasAudioFrames.test.js │ │ ├── calculateFps.test.js │ │ ├── calculateDisplayAspectRatio.test.js │ │ ├── calculateBitrate.test.js │ │ ├── areAllGopsIdentical.test.js │ │ ├── gopPktSize.test.js │ │ ├── networkStats.test.js │ │ ├── identifyGops.test.js │ │ ├── calculateDisplayAspectRatio.data.js │ │ ├── networkStats.data.js │ │ ├── gopDurationInSec.test.js │ │ ├── identifyGops.data.js │ │ └── encoderStats.test.js └── Functional │ ├── Helpers │ └── index.js │ ├── StreamsInfo │ └── fetch.test.js │ └── FramesMonitor │ └── listen.test.js ├── .travis.yml ├── .gitignore ├── index.js ├── src ├── ExitReasons.js ├── Errors │ ├── ExtendableError.js │ └── index.js ├── StreamsInfo.js ├── processFrames.js └── FramesMonitor.js ├── LICENSE.md ├── .github └── workflows │ └── npm_publish.yml ├── package.json ├── examples └── realtimeStats.js ├── CHANGELOG.md ├── .eslintrc └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: 8GEF1J4M0dTOXiaoSjHIXTK35xPVABpKx 2 | -------------------------------------------------------------------------------- /tests/inputs/test_IPPPP.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LCMApps/video-quality-tools/HEAD/tests/inputs/test_IPPPP.mp4 -------------------------------------------------------------------------------- /tests/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter list 2 | --slow 50 3 | --check-leaks 4 | --recursive 5 | --require ./tests/Bootstrap.js 6 | -------------------------------------------------------------------------------- /tests/Bootstrap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | require('app-module-path').addPath(path.join(__dirname, '../')); 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "8" 5 | 6 | script: 7 | - "yarn lint" 8 | - "yarn test" 9 | 10 | after_success: "yarn coveralls" 11 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "globals": { 8 | "sinon": true, 9 | "assert": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /var/cache/* 2 | /var/log/* 3 | /web/assets/** 4 | /node_modules 5 | /config/local-*.json 6 | /coverage 7 | .nyc_output 8 | package-lock.json 9 | .idea 10 | *.iml 11 | 12 | # MUST be the last line of this file 13 | !.gitkeep 14 | 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const FramesMonitor = require('./src/FramesMonitor'); 4 | const StreamsInfo = require('./src/StreamsInfo'); 5 | const processFrames = require('./src/processFrames'); 6 | const ExitReasons = require('./src/ExitReasons'); 7 | 8 | const Errors = require('./src/Errors'); 9 | 10 | module.exports = { 11 | FramesMonitor, 12 | StreamsInfo, 13 | processFrames, 14 | ExitReasons, 15 | Errors 16 | }; 17 | -------------------------------------------------------------------------------- /src/ExitReasons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Reason { 4 | constructor(payload) { 5 | this.payload = payload; 6 | } 7 | } 8 | 9 | class StartError extends Reason {} 10 | class ExternalSignal extends Reason {} 11 | class NormalExit extends Reason {} 12 | class AbnormalExit extends Reason {} 13 | class ProcessingError extends Reason {} 14 | 15 | module.exports = { 16 | StartError, 17 | ExternalSignal, 18 | NormalExit, 19 | AbnormalExit, 20 | ProcessingError 21 | }; 22 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/Helpers/index.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('proxyquire'); 2 | 3 | const correctPath = '/correct/path'; 4 | 5 | const StreamsInfo = proxyquire('src/StreamsInfo', { 6 | fs: { 7 | accessSync(filePath) { 8 | if (filePath !== correctPath) { 9 | throw new Error('no such file or directory'); 10 | } 11 | } 12 | } 13 | }); 14 | 15 | const correctUrl = 'rtmp://localhost:1935/myapp/mystream'; 16 | 17 | module.exports = { 18 | correctPath, 19 | correctUrl, 20 | StreamsInfo 21 | }; 22 | -------------------------------------------------------------------------------- /src/Errors/ExtendableError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class ExtendableError extends Error { 4 | /** 5 | * @param {string} message 6 | * @param {*} extra 7 | */ 8 | constructor(message = '', extra) { 9 | super(message); 10 | 11 | this.message = message; 12 | this.extra = extra; 13 | this.name = this.constructor.name; 14 | 15 | // noinspection JSUnresolvedFunction 16 | Error.captureStackTrace(this, this.constructor); 17 | } 18 | 19 | /** 20 | * @return {string} 21 | */ 22 | toString() { 23 | return this.extra ? super.toString() + ' Extra: ' + JSON.stringify(this.extra) : super.toString(); 24 | } 25 | } 26 | 27 | module.exports = ExtendableError; 28 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_isValidErrorLevel.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const FramesMonitor = require('src/FramesMonitor'); 6 | const {config} = require('./Helpers'); 7 | 8 | describe('FramesMonitor::_isValidErrorLevel', () => { 9 | it('must return true for correct error level', () => { 10 | const expectedResult = true; 11 | 12 | const result = FramesMonitor._isValidErrorLevel(config.errorLevel); 13 | 14 | assert.strictEqual(result, expectedResult); 15 | }); 16 | 17 | it('must return false for in-correct error level', () => { 18 | const incorrectPath = 'incorrect-part'; 19 | const expectedResult = false; 20 | 21 | const result = FramesMonitor._isValidErrorLevel(config.errorLevel + incorrectPath); 22 | 23 | assert.strictEqual(result, expectedResult); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/calculateGopDuration.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.calculateGopDuration', () => { 8 | 9 | it('must correct calculate min, max and average gopDuration for gops', () => { 10 | const expectedGopDuration = { 11 | min : 1, 12 | max : 6, 13 | mean: 3.5 14 | }; 15 | 16 | const gops = [ 17 | { 18 | startTime: 1, 19 | endTime : 2 20 | }, 21 | { 22 | startTime: 3, 23 | endTime : 9 24 | } 25 | ]; 26 | 27 | const gopDuration = processFrames.calculateGopDuration(gops); 28 | 29 | assert.deepEqual(gopDuration, expectedGopDuration); 30 | }); 31 | 32 | }); 33 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/isListening.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers/'); 6 | 7 | describe('FramesMonitor::isListening', () => { 8 | 9 | let framesMonitor; 10 | 11 | beforeEach(() => { 12 | framesMonitor = new FramesMonitor(config, url); 13 | }); 14 | 15 | it("must return false, cuz we didn't run listen method", () => { 16 | const expectedIsListening = false; 17 | 18 | framesMonitor._cp = null; 19 | 20 | assert.strictEqual(framesMonitor.isListening(), expectedIsListening); 21 | }); 22 | 23 | it('must return true, cuz we started listen', () => { 24 | const expectedIsListening = true; 25 | 26 | framesMonitor._cp = makeChildProcess(); 27 | 28 | assert.strictEqual(framesMonitor.isListening(), expectedIsListening); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/findGCD.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | 8 | describe('findGcd', () => { 9 | const data = [ 10 | {a: 0, b: 0, answer: 0}, 11 | {a: 1, b: 0, answer: 1}, 12 | {a: 0, b: 1, answer: 1}, 13 | {a: 1, b: 1, answer: 1}, 14 | {a: 13, b: 7, answer: 1}, 15 | {a: 7, b: 13, answer: 1}, 16 | {a: 56, b: 98, answer: 14}, 17 | {a: 98, b: 56, answer: 14}, 18 | {a: 1280, b: 720, answer: 80}, 19 | ]; 20 | 21 | dataDriven(data, function () { 22 | it('for ({a}, {b})', function (ctx) { 23 | const expectation = ctx.answer; 24 | 25 | const result = processFrames.findGcd(ctx.a, ctx.b); 26 | 27 | assert.strictEqual(result, expectation); 28 | }); 29 | }); 30 | 31 | }); 32 | -------------------------------------------------------------------------------- /src/Errors/index.js: -------------------------------------------------------------------------------- 1 | const ExtendableError = require('./ExtendableError'); 2 | 3 | class AlreadyListeningError extends ExtendableError {} 4 | class FramesMonitorError extends ExtendableError {} 5 | class StreamsInfoError extends ExtendableError {} 6 | class ConfigError extends ExtendableError {} 7 | class ProcessStreamError extends ExtendableError {} 8 | class InvalidFrameError extends ExtendableError {} 9 | class ExecutablePathError extends ExtendableError {} 10 | class FrameInvalidData extends ExtendableError {} 11 | class GopInvalidData extends ExtendableError {} 12 | class GopNotFoundError extends ExtendableError {} 13 | class ProcessStartError extends ExtendableError {} 14 | class ProcessExitError extends ExtendableError {} 15 | 16 | module.exports = { 17 | AlreadyListeningError, 18 | ConfigError, 19 | ProcessStreamError, 20 | FramesMonitorError, 21 | StreamsInfoError, 22 | InvalidFrameError, 23 | ExecutablePathError, 24 | FrameInvalidData, 25 | GopInvalidData, 26 | GopNotFoundError, 27 | ProcessStartError, 28 | ProcessExitError 29 | }; 30 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/filterVideoFrames.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.filterVideoFrames', () => { 8 | 9 | it('must corret filter video frames', () => { 10 | const expectedResult = [ 11 | {media_type: 'video', width: 1}, 12 | {media_type: 'video', width: 2} 13 | ]; 14 | 15 | let frames = [ 16 | {media_type: 'video', width: 1}, 17 | {media_type: 'audio'}, 18 | {media_type: 'data'}, 19 | {media_type: 'video', width: 2} 20 | ]; 21 | 22 | const videoFrames = processFrames.filterVideoFrames(frames); 23 | 24 | assert.deepEqual(videoFrames, expectedResult); 25 | }); 26 | 27 | it('must corret filter empty array of frames', () => { 28 | const expectedResult = []; 29 | 30 | const videoFrames = processFrames.filterVideoFrames([]); 31 | 32 | assert.deepEqual(videoFrames, expectedResult); 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/hasAudioFrames.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.hasAudioFrames', () => { 8 | 9 | it('must detect the audio frames existence', () => { 10 | const expectedResult = true; 11 | 12 | const frames = [ 13 | {media_type: 'video', width: 1}, 14 | {media_type: 'audio'}, 15 | {media_type: 'data'}, 16 | {media_type: 'video', width: 2} 17 | ]; 18 | 19 | const hasAudioFrames = processFrames.hasAudioFrames(frames); 20 | 21 | assert.deepEqual(hasAudioFrames, expectedResult); 22 | }); 23 | 24 | it('must detect the audio frames absence', () => { 25 | const expectedResult = false; 26 | 27 | const frames = [ 28 | {media_type: 'video', width: 1}, 29 | {media_type: 'data'}, 30 | {media_type: 'video', width: 2} 31 | ]; 32 | 33 | const hasAudioFrames = processFrames.hasAudioFrames(frames); 34 | 35 | assert.deepEqual(hasAudioFrames, expectedResult); 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/calculateFps.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.calculateFps', () => { 8 | 9 | it('must correct calculate min, max and average fps for gops', () => { 10 | const expectedFps = { 11 | min : 0.5, 12 | max : 1, 13 | mean: 0.75 14 | }; 15 | 16 | const gops = [ 17 | { 18 | frames : [ 19 | {key_frame: 1, pkt_pts_time: 1}, 20 | ], 21 | startTime: 1, 22 | endTime : 2 23 | }, 24 | { 25 | frames : [ 26 | {key_frame: 1, pkt_pts_time: 3}, 27 | {key_frame: 0, pkt_pts_time: 5}, 28 | {key_frame: 0, pkt_pts_time: 7}, 29 | ], 30 | startTime: 3, 31 | endTime : 9 32 | } 33 | ]; 34 | 35 | const bitrate = processFrames.calculateFps(gops); 36 | 37 | assert.deepEqual(bitrate, expectedFps); 38 | }); 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/calculateDisplayAspectRatio.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | const {invalidParams, validParams} = require('./calculateDisplayAspectRatio.data'); 8 | 9 | describe('processFrames.calculateDisplayAspectRatio', () => { 10 | 11 | dataDriven(invalidParams, function () { 12 | it('width and height must be a positive integers, but {description} was passed', function (ctx) { 13 | const expectedErrorMsg = /must be a positive integer/; 14 | const expectedErrorClass = TypeError; 15 | 16 | assert.throws(() => { 17 | processFrames.calculateDisplayAspectRatio(ctx.width, ctx.height); 18 | }, expectedErrorClass, expectedErrorMsg); 19 | }); 20 | }); 21 | 22 | dataDriven(validParams, function () { 23 | it('calculate display aspect ratio for correct input {aspectRatio}', (ctx) => { 24 | const expected = ctx.aspectRatio; 25 | 26 | const result = processFrames.calculateDisplayAspectRatio(ctx.width, ctx.height); 27 | 28 | assert.strictEqual(result, expected); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/calculateBitrate.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.calculateBitrate', () => { 8 | 9 | it('must correct calculate min, max and average bitrate for gops', () => { 10 | const expectedBitrate = { 11 | min : ((1 + 2) / (5 - 2)) * 8 / 1024, 12 | max : (1 / (2 - 1)) * 8 / 1024, 13 | mean: (((1 + 2) / (5 - 2)) + (1 / (2 - 1))) / 2 * 8 / 1024 14 | }; 15 | 16 | const gops = [ 17 | { 18 | frames : [ 19 | {key_frame: 1, pkt_size: 1, pkt_pts_time: 1}, 20 | ], 21 | startTime: 1, 22 | endTime : 2, 23 | }, 24 | { 25 | frames : [ 26 | {key_frame: 1, pkt_size: 1, pkt_pts_time: 2}, 27 | {key_frame: 0, pkt_size: 2, pkt_pts_time: 4}, 28 | ], 29 | startTime: 2, 30 | endTime : 5 31 | } 32 | ]; 33 | 34 | const bitrate = processFrames.calculateBitrate(gops); 35 | 36 | assert.deepEqual(bitrate, expectedBitrate); 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2022 Oleh Poberezhets 4 | Copyright (c) 2017-2018 Dmitry Menshikov 5 | Copyright (c) 2017-2018 Alexandr Lisovenko 6 | Copyright (c) 2017-2018 Eduard Bondarenko 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | 26 | -------------------------------------------------------------------------------- /tests/Functional/Helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const {spawn} = require('child_process'); 5 | 6 | assert(process.env.FFPROBE, 'Specify path for ffprobe via FFPROBE env var'); 7 | assert(process.env.FFMPEG, 'Specify path for ffmpeg via FFMPEG env var'); 8 | 9 | function startStream(testFile, streamUrl) { 10 | const command = `${process.env.FFMPEG} -re -i ${testFile} -vcodec copy -acodec copy -listen 1 -f flv ${streamUrl}` 11 | .split(' '); 12 | 13 | const ffmpeg = spawn(command[0], command.slice(1)); 14 | 15 | return new Promise((resolve, reject) => { 16 | // we listen stderr cuz we rely on stderr banner output 17 | ffmpeg.stderr.once('data', () => { 18 | resolve(ffmpeg); 19 | }); 20 | 21 | setTimeout(() => { 22 | reject('Can not run ffmpeg process'); 23 | }, 5 * 1000); 24 | }); 25 | } 26 | 27 | function stopStream(stream) { 28 | return new Promise((resolve, reject) => { 29 | stream.kill(); 30 | 31 | stream.on('exit', (code, signal) => { 32 | if (signal === null || signal === 'SIGTERM') { 33 | return resolve({code, signal}); 34 | } 35 | return reject(`cannot stop stream in correct way - code: ${code}, signal: ${signal}`); 36 | }); 37 | 38 | setTimeout(() => { 39 | stream.kill('SIGKILL'); 40 | }, 5 * 1000); 41 | }); 42 | } 43 | 44 | module.exports = { 45 | startStream, 46 | stopStream 47 | }; 48 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/Helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {EventEmitter} = require('events'); 4 | 5 | const proxyquire = require('proxyquire'); 6 | 7 | const ffprobePath = '/correct/path'; 8 | const bufferMaxLengthInBytes = 2 ** 20; 9 | const timeoutInMs = 1000; 10 | const url = 'rtmp://localhost:1935/myapp/mystream'; 11 | const errorLevel = 'fatal'; // https://ffmpeg.org/ffprobe.html 12 | const exitProcessGuardTimeoutInMs = 2000; 13 | const analyzeDurationInMs = 1000; 14 | 15 | 16 | const FramesMonitor = proxyquire('src/FramesMonitor', { 17 | fs: { 18 | accessSync(filePath) { 19 | if (filePath !== ffprobePath) { 20 | throw new Error('no such file or directory'); 21 | } 22 | } 23 | } 24 | }); 25 | 26 | function makeChildProcess() { 27 | const childProcess = new EventEmitter(); 28 | childProcess.stdout = new EventEmitter(); 29 | childProcess.stderr = new EventEmitter(); 30 | childProcess.kill = (signal) => { 31 | setImmediate(() => { 32 | childProcess.emit('exit', null, signal); 33 | }); 34 | }; 35 | 36 | return childProcess; 37 | } 38 | 39 | module.exports = { 40 | config: { 41 | ffprobePath, 42 | timeoutInMs, 43 | bufferMaxLengthInBytes, 44 | errorLevel, 45 | exitProcessGuardTimeoutInMs, 46 | analyzeDurationInMs 47 | }, 48 | url, 49 | FramesMonitor, 50 | makeChildProcess 51 | }; 52 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/areAllGopsIdentical.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const processFrames = require('src/processFrames'); 6 | 7 | describe('processFrames.areAllGopsIdentical', () => { 8 | 9 | it('must determine that all gops identical', () => { 10 | const gops = [ 11 | { 12 | frames: [ 13 | {key_frame: 1, pict_type: 'I'}, 14 | {key_frame: 0, pict_type: 'I'} 15 | ] 16 | }, 17 | { 18 | frames: [ 19 | {key_frame: 1, pict_type: 'I'}, 20 | {key_frame: 0, pict_type: 'P'} 21 | ] 22 | } 23 | ]; 24 | 25 | const expectedAnswer = true; 26 | 27 | const res = processFrames.areAllGopsIdentical(gops); 28 | 29 | assert.strictEqual(res, expectedAnswer); 30 | }); 31 | 32 | it('must determine that not all gops identical', () => { 33 | const gops = [ 34 | { 35 | frames: [ 36 | {key_frame: 1, pict_type: 'I'}, 37 | {key_frame: 0, pict_type: 'I'}, 38 | {key_frame: 0, pict_type: 'P'} 39 | ] 40 | }, 41 | { 42 | frames: [ 43 | {key_frame: 1, pict_type: 'I'}, 44 | {key_frame: 0, pict_type: 'P'} 45 | ] 46 | } 47 | ]; 48 | 49 | const expectedAnswer = false; 50 | 51 | const res = processFrames.areAllGopsIdentical(gops); 52 | 53 | assert.strictEqual(res, expectedAnswer); 54 | }); 55 | 56 | }); 57 | -------------------------------------------------------------------------------- /.github/workflows/npm_publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM Release 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | check: 9 | name: Check version and tag 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Get package version 14 | shell: bash 15 | id: package_version 16 | run: | 17 | ver=$(jq .version package.json | sed -e "s/^\"//" -e "s/\"//") 18 | echo "::set-output name=version::$ver" 19 | - name: Compare package version and release tag 20 | if: steps.package_version.outputs.version != github.event.release.tag_name 21 | env: 22 | TAG: "${{ github.event.release.tag_name }}" 23 | PKG_VER: ${{ steps.package_version.outputs.version }} 24 | run: | 25 | echo "Mismatch NPM version $PKG_VER and git tag $TAG" 26 | exit 1 27 | 28 | build: 29 | name: Lint and test 30 | needs: check 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v1 34 | - uses: actions/setup-node@v1 35 | with: 36 | node-version: 12 37 | - run: yarn install --frozen-lock-file 38 | - run: yarn lint 39 | - run: yarn test 40 | 41 | publish-npm: 42 | name: Publish to NPM 43 | needs: [check, build] 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v1 47 | - uses: actions/setup-node@v1 48 | with: 49 | node-version: 12 50 | registry-url: https://registry.npmjs.org/ 51 | - run: yarn install --frozen-lock-file 52 | - run: yarn publish 53 | env: 54 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 55 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_onStderrData.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const {config, url, FramesMonitor} = require('./Helpers'); 6 | 7 | describe('FramesMonitor::_onStderrData', () => { 8 | let framesMonitor; 9 | 10 | beforeEach(() => { 11 | framesMonitor = new FramesMonitor(config, url); 12 | }); 13 | 14 | it('must store last N data from process stderr output', () => { 15 | const expectedMessage = `got stderr output from a ${config.ffprobePath} process`; 16 | 17 | const STDERR_OBJECTS_LIMIT = 5; 18 | const overflowOffset = 2; 19 | 20 | const numberOfItems = STDERR_OBJECTS_LIMIT + overflowOffset; 21 | 22 | const stderr = 'some error with id: '; 23 | 24 | for (let i = 0; i < numberOfItems; i++) { 25 | framesMonitor._onStderrData(Buffer.from(stderr + i)); 26 | } 27 | 28 | assert.lengthOf(framesMonitor._stderrOutputs, STDERR_OBJECTS_LIMIT); 29 | 30 | framesMonitor._stderrOutputs.forEach(errObject => { 31 | assert.strictEqual(errObject.message, expectedMessage); 32 | }); 33 | 34 | assert.deepEqual(framesMonitor._stderrOutputs[0].extra, {data: 'some error with id: 2', url: url}); 35 | assert.deepEqual(framesMonitor._stderrOutputs[1].extra, {data: 'some error with id: 3', url: url}); 36 | assert.deepEqual(framesMonitor._stderrOutputs[2].extra, {data: 'some error with id: 4', url: url}); 37 | assert.deepEqual(framesMonitor._stderrOutputs[3].extra, {data: 'some error with id: 5', url: url}); 38 | assert.deepEqual(framesMonitor._stderrOutputs[4].extra, {data: 'some error with id: 6', url: url}); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-quality-tools", 3 | "version": "3.0.3", 4 | "description": "Set of tools to evaluate video stream quality.", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=8.1" 8 | }, 9 | "directories": { 10 | "test": "tests" 11 | }, 12 | "scripts": { 13 | "lint": "eslint .", 14 | "test": "yarn test:unit", 15 | "test:unit": "mocha --opts tests/mocha.opts -R spec './tests/Unit/**/*.js'", 16 | "test:func": "mocha --opts tests/mocha.opts -R spec './tests/Functional/**/*.js' --timeout 30000", 17 | "test:coverage": "nyc --reporter=text --reporter=text-summary mocha --opts tests/mocha.opts -R spec './tests/Unit/**/*.js'", 18 | "coveralls": "nyc --reporter=text-lcov mocha --opts tests/mocha.opts -R spec './tests/Unit/**/*.js' && nyc report --reporter=text-lcov | coveralls" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/LCMApps/video-quality-tools.git" 23 | }, 24 | "keywords": [ 25 | "ffmpeg", 26 | "ffprobe", 27 | "monitor", 28 | "livestream", 29 | "live", 30 | "rtmp", 31 | "hls", 32 | "dash", 33 | "monitoring" 34 | ], 35 | "license": "MIT", 36 | "devDependencies": { 37 | "chai": "^4.2.0", 38 | "coveralls": "^3.0.7", 39 | "data-driven": "^1.4.0", 40 | "eslint": "^6.8.0", 41 | "get-port": "^5.0.0", 42 | "mocha": "^6.2.2", 43 | "nyc": "^14.1.1", 44 | "proxyquire": "^2.1.3", 45 | "sinon": "^7.5.0" 46 | }, 47 | "dependencies": { 48 | "app-module-path": "^2.2.0", 49 | "lodash": "^4.17.21" 50 | }, 51 | "bugs": { 52 | "url": "https://github.com/LCMApps/video-quality-tools/issues" 53 | }, 54 | "homepage": "https://github.com/LCMApps/video-quality-tools" 55 | } 56 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/_adjustAspectRatio.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | const _ = require('lodash'); 6 | 7 | const {StreamsInfoError} = require('src/Errors'); 8 | 9 | const {correctPath, correctUrl, StreamsInfo} = require('./Helpers/'); 10 | 11 | const {invalidParams, validParams} = require('./_adjustAspectRatio.data'); 12 | 13 | describe('StreamsInfo::_adjustAspectRatio', () => { 14 | 15 | const streamsInfo = new StreamsInfo({ 16 | ffprobePath: correctPath, 17 | timeoutInMs: 1 18 | }, correctUrl); 19 | 20 | dataDriven(invalidParams, function () { 21 | const expectedErrorMessage = 'Can not calculate aspect ratio due to invalid video resolution'; 22 | const expectedErrorClass = StreamsInfoError; 23 | 24 | it('{description}', function (ctx) { 25 | assert.throws(() => { 26 | streamsInfo._adjustAspectRatio(ctx.data); 27 | }, expectedErrorClass, expectedErrorMessage); 28 | }); 29 | }); 30 | 31 | dataDriven(validParams, function () { 32 | it('{description}', function (ctx) { 33 | const expected = ctx.res; 34 | 35 | const frames = streamsInfo._adjustAspectRatio(ctx.data); 36 | 37 | assert.deepEqual(frames, expected); 38 | }); 39 | }); 40 | 41 | it('all params are good', () => { 42 | const inputData = [ 43 | {sample_aspect_ratio: '10:1', display_aspect_ratio: '10:1', width: 30, height: 10} 44 | ]; 45 | 46 | const expected = _.cloneDeep(inputData); 47 | 48 | const frames = streamsInfo._adjustAspectRatio(inputData); 49 | 50 | assert.deepEqual(frames, expected); 51 | }); 52 | 53 | }); 54 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_frameToJson.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | 5 | const {FramesMonitor} = require('./Helpers'); 6 | 7 | describe('FramesMonitor::_frameToJson', () => { 8 | 9 | it('must return empty object for empty string input', () => { 10 | const expectedResult = {}; 11 | 12 | const result = FramesMonitor._frameToJson(''); 13 | 14 | assert.deepEqual(result, expectedResult); 15 | }); 16 | 17 | it('must return empty object for arbitrary string, with no key value pairs', () => { 18 | const expectedResult = {}; 19 | 20 | const result = FramesMonitor._frameToJson('lorem lorem lorem lorem !!!'); 21 | 22 | assert.deepEqual(result, expectedResult); 23 | }); 24 | 25 | it('must return empty object for arbitrary string, with one key value pair', () => { 26 | const expectedResult = {'lorem key': 'value lorem !!!'}; 27 | 28 | const result = FramesMonitor._frameToJson('lorem key=value lorem !!!'); 29 | 30 | assert.deepEqual(result, expectedResult); 31 | }); 32 | 33 | it('must return correct object for real input data, with several key value pairs', () => { 34 | const expectedResult = { 35 | media_type : 'video', 36 | pkt_pts_time : 9.967900, 37 | pkt_duration_time: 0.03300, 38 | pkt_size : 4253, 39 | pict_type : 'P', 40 | key_frame : 0 41 | }; 42 | 43 | const result = FramesMonitor._frameToJson( 44 | '[FRAME]\nmedia_type=video\npkt_pts_time=9.9679000\n' + 45 | 'pkt_duration_time=0.033000\npkt_size=4253\npict_type=P\nkey_frame=0' 46 | ); 47 | 48 | assert.deepEqual(result, expectedResult); 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /examples/realtimeStats.js: -------------------------------------------------------------------------------- 1 | const {FramesMonitor, processFrames} = require('../index'); 2 | // or 3 | // const {FramesMonitor, processFrames} = require('video-quality-tools'); 4 | // if you use it outside this repo 5 | 6 | const INTERVAL_TO_ANALYZE_FRAMES = 5000; // in milliseconds 7 | const STREAM_URI = 'rtmp://host:port/path'; 8 | 9 | const framesMonitorOptions = { 10 | ffprobePath: '/usr/local/bin/ffprobe', 11 | timeoutInMs: 2000, 12 | bufferMaxLengthInBytes: 100000, 13 | errorLevel: 'error', 14 | exitProcessGuardTimeoutInMs: 1000 15 | }; 16 | 17 | const framesMonitor = new FramesMonitor(framesMonitorOptions, STREAM_URI); 18 | 19 | let frames = []; 20 | 21 | function firstVideoFrameListener(frame) { 22 | if (frame.media_type === 'video') { 23 | framesMonitor.removeListener('frame', firstVideoFrameListener); 24 | framesMonitor.on('frame', frameListener); 25 | startAnalysis(); 26 | } 27 | } 28 | 29 | function frameListener(frame) { 30 | frames.push(frame); 31 | } 32 | 33 | function startAnalysis() { 34 | setInterval(() => { 35 | try { 36 | const info = processFrames.networkStats(frames, INTERVAL_TO_ANALYZE_FRAMES); 37 | 38 | console.log(info); 39 | 40 | frames = []; 41 | } catch (err) { 42 | // only if arguments are invalid 43 | console.log(err); 44 | process.exit(1); 45 | } 46 | }, INTERVAL_TO_ANALYZE_FRAMES); 47 | } 48 | 49 | // We listens first video frame to start processing. We do such thing to avoid incorrect stats for the first 50 | // run of networkStats function after the first interval. 51 | framesMonitor.on('frame', firstVideoFrameListener); 52 | 53 | framesMonitor.on('exit', reason => { 54 | console.log('EXIT', reason); 55 | process.exit(); 56 | }); 57 | 58 | framesMonitor.listen(); 59 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/constructor.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const Errors = require('src/Errors'); 7 | 8 | const {correctPath, correctUrl, StreamsInfo} = require('./Helpers/'); 9 | 10 | const {incorrectConfigData, incorrectUrlData, incorrectConfig} = require('./constructor.data'); 11 | 12 | describe('StreamsInfo::constructor', () => { 13 | 14 | dataDriven(incorrectConfigData, function () { 15 | it('config param has invalid ({type}) type', function (ctx) { 16 | assert.throws(() => { 17 | new StreamsInfo(ctx.config, undefined); 18 | }, TypeError, 'Config param should be an object.'); 19 | }); 20 | }); 21 | 22 | dataDriven(incorrectUrlData, function () { 23 | it('url param has invalid ({type}) type', function (ctx) { 24 | assert.throws(() => { 25 | new StreamsInfo({}, ctx.url); 26 | }, TypeError, 'You should provide a correct url.'); 27 | }); 28 | }); 29 | 30 | dataDriven(incorrectConfig, function () { 31 | it('{description}', function (ctx) { 32 | assert.throws(() => { 33 | new StreamsInfo(ctx.config, correctUrl); 34 | }, Errors.ConfigError, ctx.errorMsg); 35 | }); 36 | }); 37 | 38 | it('config.ffprobePath points to incorrect path', () => { 39 | assert.throws(() => { 40 | new StreamsInfo({ 41 | ffprobePath: `/incorrect/path/${correctUrl}`, 42 | timeoutInMs: 1 43 | }, correctUrl); 44 | }, Errors.ExecutablePathError); 45 | }); 46 | 47 | it('all params are good', () => { 48 | assert.doesNotThrow(() => { 49 | new StreamsInfo({ 50 | ffprobePath: correctPath, 51 | timeoutInMs: 1 52 | }, correctUrl); 53 | }); 54 | }); 55 | 56 | }); 57 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_assertExecutable.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const proxyquire = require('proxyquire'); 5 | const sinon = require('sinon'); 6 | const {assert} = require('chai'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | const {config} = require('./Helpers'); 11 | 12 | describe('FramesMonitor::_assertExecutable', () => { 13 | it('do nothing if path points to executable file', () => { 14 | const stubAccessSync = sinon.stub(); 15 | 16 | const FramesMonitor = proxyquire('src/FramesMonitor', { 17 | fs: { 18 | accessSync: stubAccessSync 19 | } 20 | }); 21 | 22 | FramesMonitor._assertExecutable(config.ffprobePath); 23 | 24 | assert.isTrue(stubAccessSync.calledOnce); 25 | assert.isTrue(stubAccessSync.calledWithExactly(config.ffprobePath, fs.constants.X_OK)); 26 | }); 27 | 28 | it('throw an exception if accessSync throws an error', () => { 29 | const expectedError = new Error('1'); 30 | const stubAccessSync = sinon.stub().throws(expectedError); 31 | 32 | const FramesMonitor = proxyquire('src/FramesMonitor', { 33 | fs: { 34 | accessSync: stubAccessSync 35 | } 36 | }); 37 | 38 | try { 39 | FramesMonitor._assertExecutable(config.ffprobePath); 40 | assert.isTrue(false, '_assertExecutable should throw an error, and you should not be here'); 41 | } catch (err) { 42 | assert.isTrue(stubAccessSync.calledOnce); 43 | assert.isTrue(stubAccessSync.calledWithExactly(config.ffprobePath, fs.constants.X_OK)); 44 | 45 | assert.instanceOf(err, Errors.ExecutablePathError); 46 | assert.strictEqual(err.message, expectedError.message); 47 | assert.deepEqual(err.extra, { 48 | path: config.ffprobePath 49 | }); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/gopPktSize.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | function typeOf(item) { 11 | return Object.prototype.toString.call(item); 12 | } 13 | 14 | describe('processFrames.accumulatePktSize', () => { 15 | 16 | const invalidData = [ 17 | undefined, 18 | null, 19 | true, 20 | '1', 21 | {}, 22 | () => {}, 23 | Symbol(), 24 | Buffer.alloc(0) 25 | ]; 26 | 27 | dataDriven( 28 | invalidData.map(item => ({type: typeOf(item), item: item})), 29 | () => { 30 | it('must throw an error if frame pkt_size field has invalid {type} type', ctx => { 31 | const invalidFrame = {pkt_size: ctx.item}; 32 | const invalidInput = { 33 | frames : [invalidFrame], 34 | startTime: 1, 35 | endTime : 1 36 | }; 37 | 38 | try { 39 | processFrames.calculatePktSize(invalidInput.frames); 40 | assert.isFalse(true, 'should not be here'); 41 | } catch (error) { 42 | assert.instanceOf(error, Errors.FrameInvalidData); 43 | 44 | assert.strictEqual(error.message, `frame's pkt_size field has invalid type ${ctx.type}`); 45 | 46 | assert.deepEqual(error.extra, {frame: invalidFrame}); 47 | } 48 | }); 49 | } 50 | ); 51 | 52 | it('must correct accumulate frames pkt_size', () => { 53 | const frames = [ 54 | {pkt_size: 1}, 55 | {pkt_size: 2}, 56 | {pkt_size: 3} 57 | ]; 58 | 59 | const expectedRes = frames.reduce((sum, frame) => sum + frame.pkt_size, 0); 60 | 61 | const res = processFrames.calculatePktSize(frames); 62 | 63 | assert.strictEqual(res, expectedRes); 64 | }); 65 | 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 3.0.3 4 | 5 | - Fix url in FFmpeg commands. 6 | 7 | ### 3.0.1 8 | 9 | - Fix problem with [librtmp](https://linux.die.net/man/3/librtmp) style URL parameters. 10 | 11 | ### 3.0.0 12 | 13 | - `timeoutInSec` option was changed to `timeoutInMs` in the `FrameMonitor` and in the `StreamInfo` class. 14 | 15 | IMPROVEMENTS: 16 | 17 | - Added new option `analyzeDurationInMs` that specifies the maximum analyzing time of the input 18 | [[GH-97](https://github.com/LCMApps/video-quality-tools/issues/97)] 19 | 20 | BUG FIXES: 21 | 22 | - Fixed lack of support of the `timeout` for a `non-librtmp` builds of `ffmpeg` 23 | [[GH-92](https://github.com/LCMApps/video-quality-tools/issues/92)] 24 | 25 | ### 2.0.0 26 | 27 | IMPROVEMENTS: 28 | 29 | - Function `processFrames` from the module with the same name actually does calculations of encoder statistic. To 30 | improve naming it was renamed to `processFrames.encoderStats` 31 | [[GH-10](https://github.com/LCMApps/video-quality-tools/issues/10)] 32 | - `processFrames.accumulatePktSize` was renamed to `processFrames.calculatePktSize` 33 | [[GH-17](https://github.com/LCMApps/video-quality-tools/issues/17)] 34 | - New function `processFrames.networkStats` for analyzing network link quality and losses in realtime. Check the 35 | README for more details. 36 | [[GH-17](https://github.com/LCMApps/video-quality-tools/issues/17)] 37 | - Example for the `processFrames.networkStats` at [examples/networkStats.js](examples/networkStats.js) 38 | [[GH-17](https://github.com/LCMApps/video-quality-tools/issues/17)] 39 | - Dependencies was bumped 40 | 41 | BUG FIXES: 42 | 43 | - Fix of functional tests (aspectRatio -> displayAspectRatio) 44 | [[GH-12](https://github.com/LCMApps/video-quality-tools/pull/12)] 45 | - ffprobe ran without `-fflags nobuffer` so `FramesMonitor` receives incorrect info at the time of first analysis. 46 | Check [[GH-18](https://github.com/LCMApps/video-quality-tools/pull/18)] for more details. 47 | 48 | ### 1.1.0 49 | 50 | - Added new fields `gopDuration`, `displayAspectRatio`, `width`, `height`, `hasAudioStream` to the result of 51 | _processFrames_ execution 52 | - Added new methods to _processFrames_: `calculateGopDuration`, `calculateDisplayAspectRatio`, `hasAudioFrames` 53 | - `FramesMonitor` fetches video and audio frames from the stream now. 54 | - Added `width` and `height` info to video frames. 55 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/_adjustAspectRatio.data.js: -------------------------------------------------------------------------------- 1 | const invalidParams = [ 2 | { 3 | description: 'width param is invalid', 4 | data : [{sample_aspect_ratio: '0:1', display_aspect_ratio: '10:1', width: 'N/A', height: 10}], 5 | errorMsg : 'width field has invalid value.' 6 | }, 7 | { 8 | description: 'height param is invalid', 9 | data : [{sample_aspect_ratio: '0:1', display_aspect_ratio: '10:1', width: 10, height: 'N/A'}], 10 | errorMsg : 'height field has invalid value.' 11 | }, 12 | { 13 | description: 'height param is invalid', 14 | data : [{sample_aspect_ratio: '10:1', display_aspect_ratio: '0:1', width: 'N/A', height: 10}], 15 | errorMsg : 'height field has invalid value.' 16 | }, 17 | { 18 | description: 'height param is invalid', 19 | data : [{sample_aspect_ratio: '10:1', display_aspect_ratio: '0:1', width: 10, height: 'N/A'}], 20 | errorMsg : 'height field has invalid value.' 21 | }, 22 | ]; 23 | 24 | const validParams = [ 25 | { 26 | description: 'sample_aspect_ratio param is invalid', 27 | data : [], 28 | res : [] 29 | }, 30 | { 31 | description: 'sample_aspect_ratio param is invalid', 32 | data : [{sample_aspect_ratio: '0:1', display_aspect_ratio: '200:100', width: 10, height: 4}], 33 | res : [{sample_aspect_ratio: '1:1', display_aspect_ratio: '5:2', width: 10, height: 4}] 34 | }, 35 | { 36 | description: 'display_aspect_ratio param is invalid', 37 | data : [{sample_aspect_ratio: '200:100', display_aspect_ratio: '0:1', width: 20, height: 10}], 38 | res : [{sample_aspect_ratio: '1:1', display_aspect_ratio: '18:9', width: 20, height: 10}] 39 | }, 40 | { 41 | description: 'sample_aspect_ratio param is invalid', 42 | data : [{sample_aspect_ratio: 'N/A', display_aspect_ratio: '200:100', width: 10, height: 4}], 43 | res : [{sample_aspect_ratio: '1:1', display_aspect_ratio: '5:2', width: 10, height: 4}] 44 | }, 45 | { 46 | description: 'display_aspect_ratio param is invalid', 47 | data : [{sample_aspect_ratio: '200:100', display_aspect_ratio: 'N/A', width: 20, height: 10}], 48 | res : [{sample_aspect_ratio: '1:1', display_aspect_ratio: '18:9', width: 20, height: 10}] 49 | } 50 | ]; 51 | 52 | module.exports = { 53 | invalidParams, 54 | validParams 55 | }; 56 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/networkStats.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const {assert} = require('chai'); 5 | const dataDriven = require('data-driven'); 6 | 7 | const processFrames = require('src/processFrames'); 8 | 9 | const {invalidFramesTypes, invalidDurationInSecTypes, testData} = require('./networkStats.data'); 10 | 11 | const PRECISION = 0.00001; 12 | 13 | function typeOf(item) { 14 | return Object.prototype.toString.call(item); 15 | } 16 | 17 | describe('processFrames.networkStats', () => { 18 | 19 | dataDriven( 20 | invalidFramesTypes.map(item => ({type: typeOf(item), item: item})), 21 | () => { 22 | it('must throw an exception for invalid input {type} type for frames', ctx => { 23 | assert.throws(() => { 24 | processFrames.networkStats(ctx.item); 25 | }, TypeError, 'Method accepts only an array of frames'); 26 | }); 27 | } 28 | ); 29 | 30 | dataDriven( 31 | invalidDurationInSecTypes.map(item => ({type: typeOf(item), item: item})), 32 | () => { 33 | it('must throw an exception for invalid input {type} type for durationInMsec', ctx => { 34 | assert.throws(() => { 35 | processFrames.networkStats([], ctx.item); 36 | }, TypeError, 'Method accepts only a positive integer as duration'); 37 | }); 38 | } 39 | ); 40 | 41 | dataDriven(testData, () => { 42 | it('{description}', ctx => { 43 | const expectedResult = ctx.expected; 44 | 45 | const result = processFrames.networkStats(ctx.frames, ctx.durationInMsec); 46 | 47 | assert.isTrue(_.inRange( 48 | result.videoFrameRate, 49 | expectedResult.videoFrameRate - PRECISION, 50 | expectedResult.videoFrameRate + PRECISION, 51 | )); 52 | 53 | assert.isTrue(_.inRange( 54 | result.audioFrameRate, 55 | expectedResult.audioFrameRate - PRECISION, 56 | expectedResult.audioFrameRate + PRECISION, 57 | )); 58 | 59 | assert.isTrue(_.inRange( 60 | result.videoBitrate, 61 | expectedResult.videoBitrate - PRECISION, 62 | expectedResult.videoBitrate + PRECISION 63 | )); 64 | 65 | assert.isTrue(_.inRange( 66 | result.audioBitrate, 67 | expectedResult.audioBitrate - PRECISION, 68 | expectedResult.audioBitrate + PRECISION 69 | )); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/identifyGops.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | const {invalidKeyFramesTypes, invalidKeyFramesValues, testData} = require('./identifyGops.data'); 11 | 12 | function typeOf(item) { 13 | return Object.prototype.toString.call(item); 14 | } 15 | 16 | describe('processFrames.identifyGops', () => { 17 | 18 | dataDriven( 19 | invalidKeyFramesTypes.map(item => ({type: typeOf(item), key_frame: item})), 20 | () => { 21 | it('must throw an error if frame key_frame field has invalid {type} type', ctx => { 22 | const invalidFrame = {key_frame: ctx.key_frame}; 23 | const invalidInput = [invalidFrame]; 24 | 25 | try { 26 | processFrames.identifyGops(invalidInput); 27 | assert.isFalse(true, 'should not be here'); 28 | } catch (error) { 29 | assert.instanceOf(error, Errors.FrameInvalidData); 30 | 31 | assert.strictEqual(error.message, `frame's key_frame field has invalid type: ${ctx.type}`); 32 | 33 | assert.deepEqual(error.extra, {frame: invalidFrame}); 34 | } 35 | }); 36 | } 37 | ); 38 | 39 | dataDriven( 40 | invalidKeyFramesValues.map(item => ({key_frame: item})), 41 | () => { 42 | it('must throw an error if frame key_frame field has invalid value: {key_frame}. Must be 0 or 1.', ctx => { 43 | const invalidFrame = {key_frame: ctx.key_frame}; 44 | const invalidInput = [invalidFrame]; 45 | 46 | try { 47 | processFrames.identifyGops(invalidInput); 48 | assert.isFalse(true, 'should not be here'); 49 | } catch (error) { 50 | assert.instanceOf(error, Errors.FrameInvalidData); 51 | 52 | assert.strictEqual( 53 | error.message, 54 | `frame's key_frame field has invalid value: ${ctx.key_frame}. Must be 1 or 0.` 55 | ); 56 | 57 | assert.deepEqual(error.extra, {frame: invalidFrame}); 58 | } 59 | }); 60 | } 61 | ); 62 | 63 | dataDriven(testData, () => { 64 | it('{description}', ctx => { 65 | const expectedResult = ctx.res; 66 | 67 | const gops = processFrames.identifyGops(ctx.input); 68 | 69 | assert.deepEqual(gops, expectedResult); 70 | }); 71 | }); 72 | 73 | }); 74 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/calculateDisplayAspectRatio.data.js: -------------------------------------------------------------------------------- 1 | const invalidParams = [ 2 | { 3 | description: 'undefined width', 4 | width : undefined, 5 | height : 123, 6 | }, 7 | { 8 | description: 'undefined height', 9 | width : 123, 10 | height : undefined, 11 | }, 12 | { 13 | description: 'not-int width', 14 | width : 10, 15 | height : '123', 16 | }, 17 | { 18 | description: 'not-int height', 19 | width : '123', 20 | height : 10, 21 | }, 22 | { 23 | description: 'float width', 24 | width : 11.5, 25 | height : 10, 26 | }, 27 | { 28 | description: 'float height', 29 | width : 10, 30 | height : 11.5, 31 | }, 32 | { 33 | description: 'zero width', 34 | width : 0, 35 | height : 10, 36 | }, 37 | { 38 | description: 'zero height', 39 | width : 10, 40 | height : 0, 41 | }, 42 | ]; 43 | 44 | const validParams = [ 45 | { 46 | width : 1, 47 | height : 1, 48 | aspectRatio: '1:1' 49 | }, 50 | { 51 | width : 10, 52 | height : 1, 53 | aspectRatio: '10:1' 54 | }, 55 | { 56 | width : 1, 57 | height : 10, 58 | aspectRatio: '1:10' 59 | }, 60 | { 61 | width : 13, 62 | height : 7, 63 | aspectRatio: '13:7' 64 | }, 65 | { 66 | width : 7, 67 | height : 13, 68 | aspectRatio: '7:13' 69 | }, 70 | { 71 | width : 10, 72 | height : 5, 73 | aspectRatio: '18:9' 74 | }, 75 | { 76 | width : 5, 77 | height : 10, 78 | aspectRatio: '1:2' 79 | }, 80 | { 81 | width : 640, 82 | height : 480, 83 | aspectRatio: '4:3' 84 | }, 85 | { 86 | width : 854, 87 | height : 480, 88 | aspectRatio: '16:9' 89 | }, 90 | { 91 | width : 1280, 92 | height : 720, 93 | aspectRatio: '16:9' 94 | }, 95 | { 96 | width : 1284, 97 | height : 720, 98 | aspectRatio: '16:9' 99 | }, 100 | { 101 | width : 1275, 102 | height : 720, 103 | aspectRatio: '16:9' 104 | }, 105 | { 106 | width : 1440, 107 | height : 720, 108 | aspectRatio: '18:9' 109 | }, 110 | { 111 | width : 1680, 112 | height : 720, 113 | aspectRatio: '21:9' 114 | }, 115 | { 116 | width : 1000, 117 | height : 720, 118 | aspectRatio: '25:18' 119 | } 120 | ]; 121 | 122 | module.exports = {invalidParams, validParams}; 123 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_onExit.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | 6 | const ExitReasons = require('src/ExitReasons'); 7 | 8 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers'); 9 | 10 | describe('FramesMonitor::_onExit', () => { 11 | let framesMonitor; 12 | let childProcess; 13 | 14 | let stubRunShowFramesProcess; 15 | let spyOnRemoveAllListeners; 16 | 17 | beforeEach(() => { 18 | framesMonitor = new FramesMonitor(config, url); 19 | 20 | childProcess = makeChildProcess(); 21 | 22 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 23 | 24 | framesMonitor.listen(); 25 | 26 | spyOnRemoveAllListeners = sinon.spy(framesMonitor._cp, 'removeAllListeners'); 27 | }); 28 | 29 | afterEach(() => { 30 | stubRunShowFramesProcess.restore(); 31 | spyOnRemoveAllListeners.restore(); 32 | }); 33 | 34 | const data = [ 35 | { 36 | exitCode : undefined, 37 | exitSignal : 'SIGTERM', 38 | stderrOutput : [], 39 | expectedReason: new ExitReasons.ExternalSignal({signal: 'SIGTERM'}) 40 | }, 41 | { 42 | exitCode : 0, 43 | exitSignal : undefined, 44 | stderrOutput : [], 45 | expectedReason: new ExitReasons.NormalExit({code: 0}) 46 | }, 47 | { 48 | exitCode : 1, 49 | exitSignal : null, 50 | stderrOutput : [], 51 | expectedReason: new ExitReasons.AbnormalExit({code: 1, stderrOutput: ''}) 52 | }, 53 | { 54 | exitCode : 1, 55 | exitSignal : null, 56 | stderrOutput : [ 57 | 'error1', 58 | 'error2', 59 | 'error3' 60 | ], 61 | expectedReason: new ExitReasons.AbnormalExit({code: 1, stderrOutput: 'error1\nerror2\nerror3'}) 62 | } 63 | ]; 64 | 65 | data.forEach(testCase => { 66 | it('must emit exit event with correct reason type ExternalSignal', done => { 67 | const {exitCode, exitSignal} = testCase; 68 | 69 | framesMonitor.on('exit', reason => { 70 | assert.instanceOf(reason, testCase.expectedReason.constructor); 71 | assert.deepEqual(reason, testCase.expectedReason); 72 | 73 | assert.isTrue(spyOnRemoveAllListeners.calledOnce); 74 | assert.isTrue(spyOnRemoveAllListeners.calledWithExactly()); 75 | 76 | assert.isNull(framesMonitor._cp); 77 | 78 | // done is used in order to check that exactly exit event has been emitted 79 | done(); 80 | }); 81 | 82 | framesMonitor._stderrOutputs = testCase.stderrOutput; 83 | 84 | framesMonitor._onExit(exitCode, exitSignal); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/Functional/StreamsInfo/fetch.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const {assert} = require('chai'); 6 | const getPort = require('get-port'); 7 | 8 | const StreamsInfo = require('src/StreamsInfo'); 9 | const {StreamsInfoError} = require('src/Errors'); 10 | 11 | const {startStream, stopStream} = require('../Helpers'); 12 | 13 | const testFile = path.join(__dirname, '../../inputs/test_IPPPP.mp4'); 14 | 15 | describe('StreamsInfo::fetch, fetch streams info from inactive stream', () => { 16 | 17 | let streamUrl; 18 | let streamsInfo; 19 | 20 | beforeEach(async () => { 21 | const port = await getPort(); 22 | 23 | streamUrl = `http://localhost:${port}`; 24 | 25 | streamsInfo = new StreamsInfo({ 26 | ffprobePath: process.env.FFPROBE, 27 | timeoutInMs: 1000, 28 | }, streamUrl); 29 | }); 30 | 31 | it('fetch streams info from inactive stream', async () => { 32 | try { 33 | await streamsInfo.fetch(); 34 | 35 | assert.fail('fetch method must throw an error, why are you still here ?'); 36 | } catch (err) { 37 | assert.instanceOf(err, StreamsInfoError); 38 | 39 | assert(err.message); 40 | } 41 | }); 42 | 43 | }); 44 | 45 | describe('StreamsInfo::fetch, fetch streams info from active stream', () => { 46 | 47 | let stream; 48 | let streamUrl; 49 | let streamsInfo; 50 | 51 | beforeEach(async () => { 52 | const port = await getPort(); 53 | 54 | streamUrl = `http://localhost:${port}`; 55 | 56 | streamsInfo = new StreamsInfo({ 57 | ffprobePath: process.env.FFPROBE, 58 | timeoutInMs: 1000, 59 | }, streamUrl); 60 | 61 | stream = await startStream(testFile, streamUrl); 62 | }); 63 | 64 | afterEach(async () => { 65 | await stopStream(stream); 66 | }); 67 | 68 | it('fetch streams info from active stream', async () => { 69 | const info = await streamsInfo.fetch(); 70 | 71 | assert.isObject(info); 72 | 73 | const {videos, audios} = info; 74 | 75 | assert.lengthOf(videos, 1); 76 | assert.lengthOf(audios, 1); 77 | 78 | assert.deepInclude(videos[0], { 79 | codec_name : 'h264', 80 | codec_long_name : 'H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10', 81 | profile : 'Main', 82 | codec_type : 'video', 83 | width : 854, 84 | height : 480, 85 | sample_aspect_ratio : '1280:1281', 86 | display_aspect_ratio: '16:9' 87 | }); 88 | 89 | assert.deepInclude(audios[0], { 90 | codec_name : 'aac', 91 | codec_long_name: 'AAC (Advanced Audio Coding)', 92 | profile : 'LC', 93 | channels : 6, 94 | channel_layout : '5.1', 95 | sample_rate : '44100' 96 | }); 97 | 98 | return Promise.resolve(); 99 | }); 100 | 101 | }); 102 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_handleProcessingError.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | 6 | const ExitReasons = require('src/ExitReasons'); 7 | 8 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers'); 9 | 10 | describe('FramesMonitor::_handleProcessingError', () => { 11 | let framesMonitor; 12 | let childProcess; 13 | 14 | let stubRunShowFramesProcess; 15 | 16 | beforeEach(() => { 17 | framesMonitor = new FramesMonitor(config, url); 18 | 19 | childProcess = makeChildProcess(); 20 | 21 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 22 | 23 | framesMonitor.listen(); 24 | }); 25 | 26 | afterEach(() => { 27 | stubRunShowFramesProcess.restore(); 28 | }); 29 | 30 | it('must call `exit` event after correct stopListen call', async () => { 31 | const expectedError = new Error('super puper bad error'); 32 | 33 | const stubStopListen = sinon.stub(framesMonitor, 'stopListen').resolves(); 34 | const spyOnExit = sinon.spy(); 35 | const spyOnError = sinon.spy(); 36 | 37 | framesMonitor.on('error', spyOnError); 38 | framesMonitor.on('exit', spyOnExit); 39 | 40 | await framesMonitor._handleProcessingError(expectedError); 41 | 42 | assert.isTrue(stubStopListen.calledOnce); 43 | assert.isTrue(stubStopListen.calledWithExactly()); 44 | 45 | assert.isTrue(spyOnError.notCalled); 46 | assert.isTrue(spyOnExit.calledOnce); 47 | 48 | const errorObjectOnExit = spyOnExit.getCall(0).args[0]; 49 | assert.instanceOf(errorObjectOnExit, ExitReasons.ProcessingError); 50 | assert.strictEqual(errorObjectOnExit.payload.error, expectedError); 51 | 52 | stubStopListen.restore(); 53 | }); 54 | 55 | it('must call `error` event right before `exit` one if stopListen rejected promise', async () => { 56 | const expectedError = new Error('super puper bad error'); 57 | const rejectError = new Error('reject error'); 58 | 59 | const stubStopListen = sinon.stub(framesMonitor, 'stopListen').rejects(rejectError); 60 | const spyOnExit = sinon.spy(); 61 | const spyOnError = sinon.spy(); 62 | 63 | framesMonitor.on('error', spyOnError); 64 | framesMonitor.on('exit', spyOnExit); 65 | 66 | await framesMonitor._handleProcessingError(expectedError); 67 | 68 | assert.isTrue(stubStopListen.calledOnce); 69 | assert.isTrue(stubStopListen.calledWithExactly()); 70 | 71 | assert.isTrue(spyOnError.calledOnce); 72 | assert.isTrue(spyOnExit.calledOnce); 73 | 74 | const errorObjectOnError = spyOnError.getCall(0).args[0]; 75 | assert.strictEqual(errorObjectOnError, rejectError); 76 | 77 | const errorObjectOnExit = spyOnExit.getCall(0).args[0]; 78 | assert.instanceOf(errorObjectOnExit, ExitReasons.ProcessingError); 79 | assert.strictEqual(errorObjectOnExit.payload.error, expectedError); 80 | 81 | sinon.assert.callOrder(spyOnError, spyOnExit); 82 | 83 | stubStopListen.restore(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 8, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "no-console": 0, 13 | "no-case-declarations": 0, 14 | "new-cap": 0, 15 | "no-unused-vars": [ 16 | "error", 17 | { 18 | "args": "none" 19 | } 20 | ], 21 | "indent": [ 22 | "error", 23 | 4, 24 | { 25 | "SwitchCase": 1, 26 | "ObjectExpression": 1, 27 | "CallExpression": { 28 | "arguments": 1 29 | } 30 | } 31 | ], 32 | "linebreak-style": [ 33 | "error", 34 | "unix" 35 | ], 36 | "quotes": [ 37 | "error", 38 | "single", 39 | { 40 | "avoidEscape": true 41 | } 42 | ], 43 | "semi": [ 44 | "error", 45 | "always" 46 | ], 47 | "arrow-spacing": [ 48 | "error" 49 | ], 50 | "curly": [ 51 | "error" 52 | ], 53 | "no-use-before-define": [ 54 | "off" 55 | ], 56 | "no-caller": [ 57 | "error" 58 | ], 59 | "no-trailing-spaces": [ 60 | "error" 61 | ], 62 | "guard-for-in": [ 63 | "off" 64 | ], 65 | "eqeqeq": [ 66 | "error" 67 | ], 68 | "no-var": [ 69 | "error" 70 | ], 71 | "no-bitwise": [ 72 | "error" 73 | ], 74 | "camelcase": [ 75 | "error", 76 | { 77 | "properties": "never" 78 | } 79 | ], 80 | "no-proto": [ 81 | "error" 82 | ], 83 | "no-new-wrappers": [ 84 | "error" 85 | ], 86 | "space-before-function-paren": [ 87 | "error", 88 | { 89 | "anonymous": "always", 90 | "named": "never", 91 | "asyncArrow": "always" 92 | } 93 | ], 94 | "func-call-spacing": [ 95 | "error", 96 | "never" 97 | ], 98 | "array-bracket-spacing": [ 99 | "error" 100 | ], 101 | "space-in-parens": [ 102 | "error" 103 | ], 104 | "quote-props": [ 105 | "error", 106 | "as-needed", 107 | { 108 | "keywords": false, 109 | "unnecessary": false 110 | } 111 | ], 112 | "space-unary-ops": [ 113 | "error" 114 | ], 115 | "space-infix-ops": [ 116 | "error" 117 | ], 118 | "comma-spacing": [ 119 | "error" 120 | ], 121 | "yoda": [ 122 | "error", 123 | "never", 124 | { 125 | "exceptRange": true 126 | } 127 | ], 128 | "no-with": [ 129 | "error" 130 | ], 131 | "brace-style": [ 132 | "error", 133 | "1tbs" 134 | ], 135 | "no-multi-str": [ 136 | "error" 137 | ], 138 | "no-multi-spaces": [ 139 | "error", 140 | { 141 | "exceptions": { 142 | "VariableDeclarator": true, 143 | "AssignmentExpression": true 144 | } 145 | } 146 | ], 147 | "one-var": [ 148 | "error", 149 | "never" 150 | ], 151 | "semi-spacing": [ 152 | "error" 153 | ], 154 | "space-before-blocks": [ 155 | "error" 156 | ], 157 | "wrap-iife": [ 158 | "error" 159 | ], 160 | "comma-style": [ 161 | "error" 162 | ], 163 | "eol-last": [ 164 | "error" 165 | ], 166 | "dot-notation": [ 167 | "error" 168 | ], 169 | "max-len": [ 170 | "error", 171 | 120 172 | ], 173 | "spaced-comment": [ 174 | "error" 175 | ] 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/constructor.data.js: -------------------------------------------------------------------------------- 1 | const {correctPath} = require('./Helpers/'); 2 | 3 | const incorrectConfigData = [ 4 | { 5 | 'type' : 'Boolean', 6 | 'config': false, 7 | }, 8 | { 9 | 'type' : 'Null', 10 | 'config': null, 11 | }, 12 | { 13 | 'type' : 'Undefined', 14 | 'config': undefined, 15 | }, 16 | { 17 | 'type' : 'Number', 18 | 'config': 111, 19 | }, 20 | { 21 | 'type' : 'String', 22 | 'config': '111', 23 | }, 24 | { 25 | 'type' : 'Symbol', 26 | 'config': Symbol(), 27 | }, 28 | { 29 | 'type' : 'Function', 30 | 'config': function () { 31 | }, 32 | }, 33 | ]; 34 | 35 | const incorrectUrlData = [ 36 | { 37 | 'type': 'Boolean', 38 | 'url' : false, 39 | }, 40 | { 41 | 'type': 'Null', 42 | 'url' : null, 43 | }, 44 | { 45 | 'type': 'Undefined', 46 | 'url' : undefined, 47 | }, 48 | { 49 | 'type': 'Number', 50 | 'url' : 111, 51 | }, 52 | { 53 | 'type': 'Object', 54 | 'url' : {}, 55 | }, 56 | { 57 | 'type': 'Symbol', 58 | 'url' : Symbol(), 59 | }, 60 | { 61 | 'type': 'Function', 62 | 'url' : function () { 63 | }, 64 | }, 65 | ]; 66 | 67 | const incorrectConfig = [ 68 | { 69 | 'description': 'config object must not be empty', 70 | 'config' : {}, 71 | 'errorMsg' : 'You should provide a correct path to ffprobe.' 72 | }, 73 | { 74 | 'description': 'config.timeoutInMs must be passed', 75 | 'config' : {ffprobePath: correctPath}, 76 | 'errorMsg' : 'You should provide a correct timeout.' 77 | }, 78 | { 79 | 'description': 'config.timeoutInMs param must be a positive integer, float is passed', 80 | 'config' : {ffprobePath: correctPath, timeoutInMs: 1.1}, 81 | 'errorMsg' : 'You should provide a correct timeout.' 82 | }, 83 | { 84 | 'description': 'config.timeoutInMs param must be a positive integer, negative is passed', 85 | 'config' : {ffprobePath: correctPath, timeoutInMs: -1}, 86 | 'errorMsg' : 'You should provide a correct timeout.' 87 | }, 88 | { 89 | 'description': 'config.timeoutInMs param must be a positive integer, string is passed', 90 | 'config' : {ffprobePath: correctPath, timeoutInMs: '10'}, 91 | 'errorMsg' : 'You should provide a correct timeout.' 92 | }, 93 | { 94 | 'description': 'config.analyzeDurationInMs param must be a positive integer, float is passed', 95 | 'config' : {ffprobePath: correctPath, timeoutInMs: 1, analyzeDurationInMs: 1.1}, 96 | 'errorMsg' : 'You should provide a correct analyze duration.' 97 | }, 98 | { 99 | 'description': 'config.analyzeDurationInMs param must be a positive integer, negative is passed', 100 | 'config' : {ffprobePath: correctPath, timeoutInMs: 1, analyzeDurationInMs: -1}, 101 | 'errorMsg' : 'You should provide a correct analyze duration.' 102 | }, 103 | { 104 | 'description': 'config.analyzeDurationInMs param must be a positive integer, string is passed', 105 | 'config' : {ffprobePath: correctPath, timeoutInMs: 1, analyzeDurationInMs: '10'}, 106 | 'errorMsg' : 'You should provide a correct analyze duration.' 107 | }, 108 | ]; 109 | 110 | module.exports = { 111 | incorrectConfigData, 112 | incorrectUrlData, 113 | incorrectConfig 114 | }; 115 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/_runShowStreamsProcess.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const sinon = require('sinon'); 5 | const proxyquire = require('proxyquire'); 6 | 7 | const {correctPath, correctUrl} = require('./Helpers/'); 8 | 9 | function getExecCommand(ffprobePath, timeout, analyzeDuration, url) { 10 | const commandArgs = [ffprobePath, '-hide_banner', '-v error']; 11 | 12 | if (analyzeDuration) { 13 | commandArgs.push('-analyzeduration', analyzeDuration); 14 | } 15 | 16 | commandArgs.push('-rw_timeout', timeout, '-show_streams', '-print_format json', '-i', url); 17 | 18 | return commandArgs.join(' '); 19 | } 20 | 21 | describe('StreamsInfo::_runShowStreamsProcess', () => { 22 | const timeoutInMs = 1000; 23 | 24 | it('must returns child process object just fine', () => { 25 | const analyzeDurationInMs = undefined; 26 | const expectedFfprobeCommand = getExecCommand( 27 | correctPath, timeoutInMs * 1000, analyzeDurationInMs, correctUrl 28 | ); 29 | 30 | const execOutput = {cp: true}; 31 | const exec = () => execOutput; 32 | const spyExec = sinon.spy(exec); 33 | 34 | const stubPromisify = sinon.stub(); 35 | stubPromisify.returns(spyExec); 36 | 37 | const StreamsInfo = proxyquire('src/StreamsInfo', { 38 | fs: { 39 | accessSync(filePath) { 40 | if (filePath !== correctPath) { 41 | throw new Error('no such file or directory'); 42 | } 43 | } 44 | }, 45 | util: { 46 | promisify: stubPromisify 47 | }, 48 | child_process: { 49 | exec: spyExec 50 | } 51 | }); 52 | 53 | const streamsInfo = new StreamsInfo({ 54 | ffprobePath: correctPath, 55 | timeoutInMs 56 | }, correctUrl); 57 | 58 | const result = streamsInfo._runShowStreamsProcess(); 59 | 60 | assert.strictEqual(result, execOutput); 61 | 62 | assert.isTrue(spyExec.calledOnce); 63 | assert.isTrue( 64 | spyExec.calledWithExactly(expectedFfprobeCommand) 65 | ); 66 | }); 67 | 68 | it('must returns child process object just fine and ffprobe with "-analyzeduration" argument', () => { 69 | const analyzeDurationInMs = 5000; 70 | const expectedFfprobeCommand = getExecCommand( 71 | correctPath, timeoutInMs * 1000, analyzeDurationInMs * 1000, correctUrl 72 | ); 73 | 74 | const execOutput = {cp: true}; 75 | const exec = () => execOutput; 76 | const spyExec = sinon.spy(exec); 77 | 78 | const stubPromisify = sinon.stub(); 79 | stubPromisify.returns(spyExec); 80 | 81 | const StreamsInfo = proxyquire('src/StreamsInfo', { 82 | fs: { 83 | accessSync(filePath) { 84 | if (filePath !== correctPath) { 85 | throw new Error('no such file or directory'); 86 | } 87 | } 88 | }, 89 | util: { 90 | promisify: stubPromisify 91 | }, 92 | child_process: { 93 | exec: spyExec 94 | } 95 | }); 96 | 97 | const streamsInfo = new StreamsInfo({ 98 | ffprobePath: correctPath, 99 | timeoutInMs, 100 | analyzeDurationInMs 101 | }, correctUrl); 102 | 103 | const result = streamsInfo._runShowStreamsProcess(); 104 | 105 | assert.strictEqual(result, execOutput); 106 | 107 | assert.isTrue(spyExec.calledOnce); 108 | assert.isTrue( 109 | spyExec.calledWithExactly(expectedFfprobeCommand) 110 | ); 111 | }); 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/networkStats.data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const invalidFramesTypes = [ 4 | undefined, 5 | null, 6 | false, 7 | 1, 8 | '1', 9 | {}, 10 | Symbol(), 11 | () => {}, 12 | Buffer.alloc(0) 13 | ]; 14 | 15 | const invalidDurationInSecTypes = [ 16 | undefined, 17 | null, 18 | false, 19 | [], 20 | [1, 2], 21 | '1', 22 | 1.2, 23 | -1, 24 | {}, 25 | Symbol(), 26 | () => {}, 27 | Buffer.alloc(0) 28 | ]; 29 | 30 | const testData = [ 31 | { 32 | description: 'empty frames array', 33 | frames : [], 34 | durationInMsec: 1000, 35 | expected : { 36 | videoFrameRate: 0, 37 | audioFrameRate: 0, 38 | videoBitrate: 0, 39 | audioBitrate: 0, 40 | }, 41 | }, 42 | { 43 | description : 'audio only frames with 1000 msec duration', 44 | frames : [ 45 | {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, 46 | {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, 47 | ], 48 | durationInMsec: 1000, 49 | expected : { 50 | videoFrameRate: 0, 51 | audioFrameRate: 2, 52 | videoBitrate: 0, 53 | audioBitrate: 0.0390625, 54 | }, 55 | }, 56 | { 57 | description : 'video only frames with 1000 msec duration', 58 | frames : [ 59 | {width: 854, height: 480, pkt_size: 2, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, 60 | {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, 61 | ], 62 | durationInMsec: 1000, 63 | expected : { 64 | videoFrameRate: 2, 65 | audioFrameRate: 0, 66 | videoBitrate: 0.0390625, 67 | audioBitrate: 0, 68 | }, 69 | }, 70 | { 71 | description : 'frames with 200 msec duration', 72 | frames : [ 73 | {width: 854, height: 480, pkt_size: 2, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, 74 | {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, 75 | {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, 76 | {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, 77 | {pkt_size: 4, pkt_pts_time: 12, media_type: 'audio', key_frame: 1}, 78 | ], 79 | durationInMsec: 200, 80 | expected : { 81 | videoFrameRate: 10, 82 | audioFrameRate: 15, 83 | videoBitrate: 0.1953125, 84 | audioBitrate: 0.3515625, 85 | }, 86 | }, 87 | { 88 | description : 'audio only frames with 2000 msec duration', 89 | frames : [ 90 | {width: 854, height: 480, pkt_size: 5, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, 91 | {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, 92 | {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, 93 | {width: 854, height: 480, pkt_size: 6, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, 94 | {pkt_size: 4, pkt_pts_time: 12, media_type: 'audio', key_frame: 1}, 95 | {width: 854, height: 480, pkt_size: 7, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'I'}, 96 | ], 97 | durationInMsec: 2000, 98 | expected : { 99 | videoFrameRate: 1.5, 100 | audioFrameRate: 1.5, 101 | videoBitrate: 0.0703125, 102 | audioBitrate: 0.03515625, 103 | }, 104 | }, 105 | ]; 106 | 107 | module.exports = { 108 | invalidFramesTypes, 109 | invalidDurationInSecTypes, 110 | testData 111 | }; 112 | 113 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_reduceFramesFromChunks.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const {FramesMonitor} = require('./Helpers'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | describe('FramesMonitor::_reduceFramesFromChunks', () => { 11 | 12 | const data = [ 13 | { 14 | description : 'must return empty array of frames for empty input', 15 | inputChunkRemainder : '[FRAME]\na=b\n', 16 | input : 'c=d', 17 | outputChunkRemainder: '[FRAME]\na=b\nc=d', 18 | expectedFrames : [] 19 | }, 20 | { 21 | description : 'must return empty array of frames for incomplete frame input', 22 | inputChunkRemainder : '', 23 | input : '[FRAME]\na=b\nc=d', 24 | outputChunkRemainder: '[FRAME]\na=b\nc=d', 25 | expectedFrames : [] 26 | }, 27 | { 28 | description : 'must return an array with one raw frame', 29 | inputChunkRemainder : '', 30 | input : '[FRAME]\na=b\nc=d\n[/FRAME]', 31 | outputChunkRemainder: '', 32 | expectedFrames : ['a=b\nc=d'] 33 | }, 34 | { 35 | description : 'must return an array with two raw frames', 36 | inputChunkRemainder : '[FRAME]\na=b\n', 37 | input : 'c=d\n[/FRAME]\n[FRAME]\na2=b2\nc2=d2\n[/FRAME]\n[FRAME]\na3=b3\nc2=d2', 38 | outputChunkRemainder: '\n[FRAME]\na3=b3\nc2=d2', 39 | expectedFrames : ['a=b\nc=d', 'a2=b2\nc2=d2'] 40 | } 41 | ]; 42 | 43 | dataDriven(data, () => { 44 | it('{description}', ctx => { 45 | let chunkRemainder; 46 | let frames; 47 | 48 | assert.doesNotThrow(() => { 49 | ({chunkRemainder, frames} = FramesMonitor._reduceFramesFromChunks(ctx.inputChunkRemainder + ctx.input)); 50 | }); 51 | 52 | assert.deepStrictEqual(frames, ctx.expectedFrames); 53 | assert.deepStrictEqual(chunkRemainder, ctx.outputChunkRemainder); 54 | }); 55 | }); 56 | 57 | it('must throw an exception, invalid data input (unclosed frame)', () => { 58 | const expectedErrorType = Errors.InvalidFrameError; 59 | const expectedErrorMessage = 'Can not process frame with invalid structure.'; 60 | 61 | const chunkRemainder = '[FRAME]\na=b\n'; 62 | const newChunk = '[FRAME]\na=b\nc=d\n[/FRAME]'; 63 | 64 | try { 65 | FramesMonitor._reduceFramesFromChunks(chunkRemainder + newChunk); 66 | assert.isFalse(true, 'Should not be here'); 67 | } catch (err) { 68 | assert.instanceOf(err, expectedErrorType); 69 | 70 | assert.strictEqual(err.message, expectedErrorMessage); 71 | 72 | assert.deepEqual(err.extra, { 73 | data : chunkRemainder + newChunk, 74 | frame: '[FRAME]\na=b\n[FRAME]\na=b\nc=d\n' 75 | }); 76 | } 77 | }); 78 | 79 | it('must throw an exception, invalid data input (end block without starting one)', () => { 80 | const expectedErrorType = Errors.InvalidFrameError; 81 | const expectedErrorMessage = 'Can not process frame with invalid structure.'; 82 | 83 | const chunkRemainder = '[FRAME]\na=b\n[/FRAME]\n'; 84 | const newChunk = 'a=b\nc=d\n[/FRAME]'; 85 | 86 | try { 87 | FramesMonitor._reduceFramesFromChunks(chunkRemainder + newChunk); 88 | assert.isFalse(true, 'Should not be here'); 89 | } catch (err) { 90 | assert.instanceOf(err, expectedErrorType); 91 | 92 | assert.strictEqual(err.message, expectedErrorMessage); 93 | 94 | assert.deepEqual(err.extra, { 95 | data : chunkRemainder + newChunk, 96 | frame: '\na=b\nc=d\n' 97 | }); 98 | } 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_onProcessStartError.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | 6 | const ExitReasons = require('src/ExitReasons'); 7 | 8 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers'); 9 | 10 | describe('FramesMonitor::_onProcessStartError', () => { 11 | let framesMonitor; 12 | let childProcess; 13 | 14 | let stubRunShowFramesProcess; 15 | 16 | let spyOnCpRemoveAllListeners; 17 | let spyOnCpStdoutRemoveAllListeners; 18 | let spyOnCpStderrRemoveAllListeners; 19 | 20 | beforeEach(() => { 21 | framesMonitor = new FramesMonitor(config, url); 22 | 23 | childProcess = makeChildProcess(); 24 | 25 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 26 | 27 | spyOnCpRemoveAllListeners = sinon.spy(childProcess, 'removeAllListeners'); 28 | spyOnCpStdoutRemoveAllListeners = sinon.spy(childProcess.stdout, 'removeAllListeners'); 29 | spyOnCpStderrRemoveAllListeners = sinon.spy(childProcess.stderr, 'removeAllListeners'); 30 | 31 | framesMonitor.listen(); 32 | }); 33 | 34 | afterEach(() => { 35 | stubRunShowFramesProcess.restore(); 36 | 37 | spyOnCpRemoveAllListeners.restore(); 38 | spyOnCpStdoutRemoveAllListeners.restore(); 39 | spyOnCpStderrRemoveAllListeners.restore(); 40 | }); 41 | 42 | it('must emit processStartError on call and doesn\'t remove listeners from child process cuz it\'s null', done => { 43 | const originalError = new Error('original, but not enough'); 44 | 45 | const expectedErrorMessage = `${config.ffprobePath} process could not be started.`; 46 | const expectedChildProcessValue = null; 47 | 48 | // explicitly set to null to test that no listeners will be removed 49 | framesMonitor._cp = expectedChildProcessValue; 50 | 51 | framesMonitor.on('exit', reason => { 52 | assert.isTrue(spyOnCpRemoveAllListeners.notCalled); 53 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.notCalled); 54 | assert.isTrue(spyOnCpStderrRemoveAllListeners.notCalled); 55 | 56 | assert.strictEqual(framesMonitor._cp, expectedChildProcessValue); 57 | 58 | assert.instanceOf(reason, ExitReasons.StartError); 59 | 60 | assert.strictEqual(reason.payload.error.message, expectedErrorMessage); 61 | assert.strictEqual(reason.payload.error.extra.url, url); 62 | assert.strictEqual(reason.payload.error.extra.error, originalError); 63 | 64 | // done is used in order to check that exactly exit event has been emitted 65 | done(); 66 | }); 67 | 68 | framesMonitor._onProcessStartError(originalError); 69 | }); 70 | 71 | it('must emit processStartError on call and remove all listeners from the child process', done => { 72 | const originalError = new Error('original, but not enough'); 73 | 74 | const expectedErrorMessage = `${config.ffprobePath} process could not be started.`; 75 | const expectedChildProcessValue = null; 76 | 77 | framesMonitor.on('exit', reason => { 78 | assert.isTrue(spyOnCpRemoveAllListeners.calledOnce); 79 | assert.isTrue(spyOnCpRemoveAllListeners.calledWithExactly()); 80 | 81 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 82 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledWithExactly()); 83 | 84 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 85 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledWithExactly()); 86 | 87 | assert.strictEqual(framesMonitor._cp, expectedChildProcessValue); 88 | 89 | assert.instanceOf(reason, ExitReasons.StartError); 90 | 91 | assert.strictEqual(reason.payload.error.message, expectedErrorMessage); 92 | assert.strictEqual(reason.payload.error.extra.url, url); 93 | assert.strictEqual(reason.payload.error.extra.error, originalError); 94 | 95 | done(); 96 | }); 97 | 98 | framesMonitor._onProcessStartError(originalError); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_onProcessStreamsError.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | const dataDriven = require('data-driven'); 6 | 7 | const Errors = require('src/Errors'); 8 | 9 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers'); 10 | 11 | describe('FramesMonitor::_onProcessStreamsError', () => { 12 | 13 | let framesMonitor; 14 | let childProcess; 15 | 16 | let stubRunShowFramesProcess; 17 | 18 | beforeEach(() => { 19 | framesMonitor = new FramesMonitor(config, url); 20 | 21 | childProcess = makeChildProcess(); 22 | 23 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 24 | }); 25 | 26 | afterEach(() => { 27 | stubRunShowFramesProcess.restore(); 28 | }); 29 | 30 | const data = [ 31 | {type: 'stdout'}, 32 | {type: 'stderr'} 33 | ]; 34 | 35 | dataDriven(data, () => { 36 | it('must wrap and handle each error emitted by the child process {type} stream object', async ctx => { 37 | const expectedErrorType = Errors.ProcessStreamError; 38 | 39 | const stubHandleProcessingError = sinon.stub(framesMonitor, '_handleProcessingError').resolves(); 40 | 41 | const originalError = new Error('test error'); 42 | const expectedError = new Errors.ProcessStreamError( 43 | `got an error from a ${config.ffprobePath} ${ctx.type} process stream.`, { 44 | url : url, 45 | error: originalError 46 | } 47 | ); 48 | 49 | await framesMonitor._onProcessStreamsError(ctx.type, originalError); 50 | 51 | assert.isTrue(stubHandleProcessingError.calledOnce); 52 | 53 | assert.lengthOf(stubHandleProcessingError.getCall(0).args, 1); 54 | 55 | const error = stubHandleProcessingError.getCall(0).args[0]; 56 | 57 | assert.instanceOf(error, expectedErrorType); 58 | assert.strictEqual(error.message, expectedError.message); 59 | assert.strictEqual(error.extra.url, expectedError.extra.url); 60 | assert.strictEqual(error.extra.error, originalError); 61 | 62 | stubHandleProcessingError.restore(); 63 | }); 64 | }); 65 | 66 | dataDriven(data, () => { 67 | it('handle error emitted by the child process {type} stream object and reject promise if the error has occurred during the processing', async ctx => { // eslint-disable-line 68 | const innerError = new Error('some badass error'); 69 | const expectedErrorType = Errors.ProcessStreamError; 70 | 71 | const stubHandleProcessingError = sinon.stub(framesMonitor, '_handleProcessingError').rejects(innerError); 72 | 73 | const originalError = new Error('test error'); 74 | const expectedError = new Errors.ProcessStreamError( 75 | `got an error from a ${config.ffprobePath} ${ctx.type} process stream.`, { 76 | url : url, 77 | error: originalError 78 | } 79 | ); 80 | 81 | try { 82 | await framesMonitor._onProcessStreamsError(ctx.type, originalError); 83 | assert.isTrue(false, 'should not be here, cuz _onProcessStreamsError rejected promise'); 84 | } catch (err) { 85 | assert.instanceOf(err, innerError.constructor); 86 | assert.strictEqual(err.message, innerError.message); 87 | 88 | assert.isTrue(stubHandleProcessingError.calledOnce); 89 | 90 | assert.lengthOf(stubHandleProcessingError.getCall(0).args, 1); 91 | 92 | const error = stubHandleProcessingError.getCall(0).args[0]; 93 | 94 | assert.instanceOf(error, expectedErrorType); 95 | assert.strictEqual(error.message, expectedError.message); 96 | assert.strictEqual(error.extra.url, expectedError.extra.url); 97 | assert.strictEqual(error.extra.error, originalError); 98 | } finally { 99 | stubHandleProcessingError.restore(); 100 | } 101 | }); 102 | }); 103 | 104 | }); 105 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/constructor.data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const incorrectConfig = [ 4 | undefined, 5 | null, 6 | false, 7 | 1, 8 | [], 9 | '1', 10 | Symbol(), 11 | () => {}, 12 | Buffer.alloc(1), 13 | new Error('error') 14 | ]; 15 | 16 | const incorrectUrl = [ 17 | undefined, 18 | null, 19 | false, 20 | 1, 21 | [], 22 | {}, 23 | Symbol(), 24 | () => {}, 25 | Buffer.alloc(1), 26 | new Error('error') 27 | ]; 28 | 29 | const incorrectFfprobePath = [ 30 | undefined, 31 | null, 32 | false, 33 | 1, 34 | [], 35 | {}, 36 | Symbol(), 37 | () => {}, 38 | Buffer.alloc(1), 39 | new Error('error') 40 | ]; 41 | 42 | const incorrectTimeoutInMs = [ 43 | undefined, 44 | null, 45 | false, 46 | '1', 47 | [], 48 | {}, 49 | Symbol(), 50 | () => {}, 51 | Buffer.alloc(1), 52 | new Error('error') 53 | ]; 54 | 55 | const incorrectBufferMaxLengthInBytes = [ 56 | undefined, 57 | null, 58 | false, 59 | '1', 60 | [], 61 | {}, 62 | Symbol(), 63 | () => {}, 64 | Buffer.alloc(1), 65 | new Error('error') 66 | ]; 67 | 68 | const incorrectErrorLevel = [ 69 | undefined, 70 | null, 71 | false, 72 | 1, 73 | [], 74 | {}, 75 | Symbol(), 76 | () => {}, 77 | Buffer.alloc(1), 78 | new Error('error') 79 | ]; 80 | 81 | const incorrectExitProcessGuardTimeoutInMs = [ 82 | undefined, 83 | null, 84 | false, 85 | '1', 86 | [], 87 | {}, 88 | Symbol(), 89 | () => {}, 90 | Buffer.alloc(1), 91 | new Error('error') 92 | ]; 93 | 94 | const incorrectConfigObject = [ 95 | { 96 | description: 'config.timeoutInMs param must be a positive integer, float is passed', 97 | config : {timeoutInMs: 1.1}, 98 | errorMsg : 'You should provide a correct timeout.' 99 | }, 100 | { 101 | description: 'config.timeoutInMs param must be a positive integer, negative is passed', 102 | config : {timeoutInMs: -1}, 103 | errorMsg : 'You should provide a correct timeout.' 104 | }, 105 | { 106 | description: 'config.bufferMaxLengthInBytes param must be a positive integer, float is passed', 107 | config : {bufferMaxLengthInBytes: 1.1}, 108 | errorMsg : 'bufferMaxLengthInBytes param should be a positive integer.' 109 | }, 110 | { 111 | description: 'config.bufferMaxLengthInBytes param must be a positive integer, negative is passed', 112 | config : {bufferMaxLengthInBytes: -1}, 113 | errorMsg : 'bufferMaxLengthInBytes param should be a positive integer.' 114 | }, 115 | { 116 | description: 'config.errorLevel param must be a correct string', 117 | config : {errorLevel: 'error' + 'incorrect-part'}, 118 | errorMsg : 'You should provide correct error level. Check ffprobe documentation.' 119 | }, 120 | { 121 | description: 'config.exitProcessGuardTimeoutInMs param must be a positive integer, float is passed', 122 | config : {exitProcessGuardTimeoutInMs: 1.1}, 123 | errorMsg : 'exitProcessGuardTimeoutInMs param should be a positive integer.' 124 | }, 125 | { 126 | description: 'config.exitProcessGuardTimeoutInMs param must be a positive integer, negative is passed', 127 | config : {exitProcessGuardTimeoutInMs: -1}, 128 | errorMsg : 'exitProcessGuardTimeoutInMs param should be a positive integer.' 129 | }, 130 | { 131 | description: 'config.analyzeDurationInMs param must be a positive integer, float is passed', 132 | config : {analyzeDurationInMs: 1.1}, 133 | errorMsg : 'You should provide a correct analyze duration.' 134 | }, 135 | { 136 | description: 'config.analyzeDurationInMs param must be a positive integer, negative is passed', 137 | config : {analyzeDurationInMs: -1}, 138 | errorMsg : 'You should provide a correct analyze duration.' 139 | }, 140 | { 141 | description: 'config.analyzeDurationInMs param must be a positive integer, string is passed', 142 | config : {analyzeDurationInMs: '10'}, 143 | errorMsg : 'You should provide a correct analyze duration.' 144 | }, 145 | ]; 146 | 147 | module.exports = { 148 | incorrectConfig, 149 | incorrectUrl, 150 | incorrectFfprobePath, 151 | incorrectTimeoutInMs, 152 | incorrectBufferMaxLengthInBytes, 153 | incorrectErrorLevel, 154 | incorrectExitProcessGuardTimeoutInMs, 155 | incorrectConfigObject, 156 | }; 157 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/_parseStreamsInfo.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const {StreamsInfoError} = require('src/Errors'); 7 | 8 | const {correctPath, correctUrl, StreamsInfo} = require('./Helpers/'); 9 | 10 | function typeOf(obj) { 11 | return Object.prototype.toString.call(obj); 12 | } 13 | 14 | describe('StreamsInfo::_parseStreamsInfo', () => { 15 | 16 | const streamsInfo = new StreamsInfo({ 17 | ffprobePath: correctPath, 18 | timeoutInMs: 1 19 | }, correctUrl); 20 | 21 | it('method awaits for stringified json', () => { 22 | const expectedErrorMessage = 'Failed to parse ffprobe data'; 23 | const expectedErrorClass = StreamsInfoError; 24 | const rawStreamInfo = '{lol {kek}}'; 25 | 26 | try { 27 | streamsInfo._parseStreamsInfo(rawStreamInfo); 28 | } catch (error) { 29 | assert.instanceOf(error, expectedErrorClass); 30 | 31 | assert.equal(error.message, expectedErrorMessage); 32 | assert.equal(error.extra.url, correctUrl); 33 | assert.instanceOf(error.extra.error, Error); 34 | } 35 | }); 36 | 37 | dataDriven( 38 | [null, true, [], 123, '123'].map(item => ({type: typeOf(item), data: item})), 39 | () => { 40 | it('method awaits for json stringified object, but {type} received', (ctx) => { 41 | const expectedErrorMessage = 'Ffprobe streams data must be an object'; 42 | const expectedErrorClass = StreamsInfoError; 43 | const rawStreamInfo = JSON.stringify(ctx.data); 44 | 45 | try { 46 | streamsInfo._parseStreamsInfo(rawStreamInfo); 47 | } catch (error) { 48 | assert.instanceOf(error, expectedErrorClass); 49 | 50 | assert.equal(error.message, expectedErrorMessage); 51 | assert.equal(error.extra.url, correctUrl); 52 | } 53 | }); 54 | } 55 | ); 56 | 57 | dataDriven( 58 | [null, true, {}, 123, '123'].map(item => ({type: typeOf(item), data: item})), 59 | () => { 60 | it("method awaits for 'streams' prop of array type, but {type} received", (ctx) => { 61 | const expectedErrorMessage = `'streams' field should be an Array. Instead it has ${ctx.type} type.`; 62 | const expectedErrorClass = StreamsInfoError; 63 | const rawStreamInfo = JSON.stringify({streams: ctx.data}); 64 | 65 | try { 66 | streamsInfo._parseStreamsInfo(rawStreamInfo); 67 | } catch (error) { 68 | assert.instanceOf(error, expectedErrorClass); 69 | 70 | assert.equal(error.message, expectedErrorMessage); 71 | assert.equal(error.extra.url, correctUrl); 72 | } 73 | }); 74 | } 75 | ); 76 | 77 | it('empty streams array', () => { 78 | const expectedResult = { 79 | videos: [], 80 | audios: [] 81 | }; 82 | const rawStreamsData = JSON.stringify({streams: []}); 83 | 84 | const result = streamsInfo._parseStreamsInfo(rawStreamsData); 85 | 86 | assert.deepEqual(result, expectedResult); 87 | }); 88 | 89 | it('streams array with not relevant codec_type', () => { 90 | const expectedResult = { 91 | videos: [], 92 | audios: [] 93 | }; 94 | const rawStreamsData = JSON.stringify({streams: [{codec_type: 'data'}]}); 95 | 96 | const result = streamsInfo._parseStreamsInfo(rawStreamsData); 97 | 98 | assert.deepEqual(result, expectedResult); 99 | }); 100 | 101 | it('correct streams array with 2 audios, 2 videos and several data streams', () => { 102 | 103 | const expectedResult = { 104 | videos: [ 105 | {codec_type: 'video', profile: 'Main', width: 100, height: 100}, 106 | {codec_type: 'video', profile: 'Main', width: 101, height: 101} 107 | ], 108 | audios: [ 109 | {codec_type: 'audio', profile: 'LC', codec_time_base: '1/44100'}, 110 | {codec_type: 'audio', profile: 'HC', codec_time_base: '1/44101'} 111 | ] 112 | }; 113 | 114 | const streamData = { 115 | streams: [ 116 | ...expectedResult.videos, 117 | ...expectedResult.audios, 118 | {codec_type: 'data', profile: 'unknown'}, 119 | {codec_type: 'data', profile: 'unknown'} 120 | ] 121 | }; 122 | 123 | const rawStreamsData = JSON.stringify(streamData); 124 | 125 | const result = streamsInfo._parseStreamsInfo(rawStreamsData); 126 | 127 | assert.deepEqual(result, expectedResult); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/StreamsInfo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const fs = require('fs'); 5 | const {exec} = require('child_process'); 6 | const {promisify} = require('util'); 7 | 8 | const Errors = require('./Errors/'); 9 | const processFrames = require('./processFrames'); 10 | 11 | const DAR_OR_SAR_NA = 'N/A'; 12 | const DAR_OR_SAR_01 = '0:1'; 13 | 14 | class StreamsInfo { 15 | constructor(config, url) { 16 | if (!_.isObject(config) || _.isFunction(config)) { 17 | throw new TypeError('Config param should be an object.'); 18 | } 19 | 20 | if (!_.isString(url)) { 21 | throw new TypeError('You should provide a correct url.'); 22 | } 23 | 24 | const {ffprobePath, timeoutInMs, analyzeDurationInMs} = config; 25 | 26 | if (!_.isString(ffprobePath) || _.isEmpty(ffprobePath)) { 27 | throw new Errors.ConfigError('You should provide a correct path to ffprobe.'); 28 | } 29 | 30 | if (!_.isInteger(timeoutInMs) || timeoutInMs <= 0) { 31 | throw new Errors.ConfigError('You should provide a correct timeout.'); 32 | } 33 | 34 | if (analyzeDurationInMs !== undefined && (!_.isInteger(analyzeDurationInMs) || analyzeDurationInMs <= 0)) { 35 | throw new Errors.ConfigError('You should provide a correct analyze duration.'); 36 | } 37 | 38 | this._assertExecutable(ffprobePath); 39 | 40 | this._config = { 41 | ffprobePath: config.ffprobePath, 42 | timeout: config.timeoutInMs * 1000, 43 | analyzeDuration: config.analyzeDurationInMs && config.analyzeDurationInMs * 1000 || 0 44 | }; 45 | 46 | this._url = url; 47 | } 48 | 49 | async fetch() { 50 | let stdout; 51 | let stderr; 52 | 53 | try { 54 | ({stdout, stderr} = await this._runShowStreamsProcess()); 55 | } catch (e) { 56 | throw new Errors.StreamsInfoError('Ffprobe failed to fetch streams data', {error: e, url: this._url}); 57 | } 58 | 59 | if (stderr) { 60 | throw new Errors.StreamsInfoError(`Ffprobe wrote to stderr: ${stderr}`, {url: this._url}); 61 | } 62 | 63 | if (!_.isString(stdout)) { 64 | throw new Errors.StreamsInfoError('Ffprobe stdout has invalid type. Must be a String.', { 65 | stdout: stdout, 66 | type : Object.prototype.toString.call(stdout), 67 | url : this._url 68 | }); 69 | } 70 | 71 | if (_.isEmpty(stdout)) { 72 | throw new Errors.StreamsInfoError('Ffprobe stdout is empty', {url: this._url}); 73 | } 74 | 75 | let {videos, audios} = this._parseStreamsInfo(stdout); 76 | 77 | videos = this._adjustAspectRatio(videos); 78 | 79 | return {videos, audios}; 80 | } 81 | 82 | _assertExecutable(path) { 83 | try { 84 | fs.accessSync(path, fs.constants.X_OK); 85 | } catch (e) { 86 | throw new Errors.ExecutablePathError(e.message, {path}); 87 | } 88 | } 89 | 90 | _runShowStreamsProcess() { 91 | const {ffprobePath, timeout, analyzeDuration} = this._config; 92 | 93 | const commandArgs = [ffprobePath, '-hide_banner', '-v error']; 94 | 95 | if (analyzeDuration) { 96 | commandArgs.push('-analyzeduration', analyzeDuration); 97 | } 98 | 99 | commandArgs.push('-rw_timeout', timeout, '-show_streams', '-print_format json', '-i', this._url); 100 | 101 | return promisify(exec)(commandArgs.join(' ')); 102 | } 103 | 104 | _parseStreamsInfo(rawResult) { 105 | let jsonResult; 106 | 107 | try { 108 | jsonResult = JSON.parse(rawResult); 109 | } catch (e) { 110 | throw new Errors.StreamsInfoError('Failed to parse ffprobe data', {error: e, url: this._url}); 111 | } 112 | 113 | if (Object.prototype.toString.call(jsonResult) !== '[object Object]') { 114 | throw new Errors.StreamsInfoError('Ffprobe streams data must be an object', {url: this._url}); 115 | } 116 | 117 | if (!Array.isArray(jsonResult.streams)) { 118 | throw new Errors.StreamsInfoError( 119 | "'streams' field should be an Array. " + 120 | `Instead it has ${Object.prototype.toString.call(jsonResult.streams)} type.`, 121 | {url: this._url} 122 | ); 123 | } 124 | 125 | const videos = jsonResult.streams.filter(stream => stream.codec_type === 'video'); 126 | const audios = jsonResult.streams.filter(stream => stream.codec_type === 'audio'); 127 | 128 | return {videos, audios}; 129 | } 130 | 131 | _adjustAspectRatio(videoFrames) { 132 | const frames = videoFrames.slice(); 133 | 134 | return frames.map(video => { 135 | if (video.sample_aspect_ratio === DAR_OR_SAR_01 || 136 | video.display_aspect_ratio === DAR_OR_SAR_01 || 137 | video.sample_aspect_ratio === DAR_OR_SAR_NA || 138 | video.display_aspect_ratio === DAR_OR_SAR_NA 139 | ) { 140 | video.sample_aspect_ratio = '1:1'; 141 | try { 142 | video.display_aspect_ratio = processFrames.calculateDisplayAspectRatio(video.width, video.height); 143 | } catch (err) { 144 | throw new Errors.StreamsInfoError( 145 | 'Can not calculate aspect ratio due to invalid video resolution', 146 | {width: video.width, height: video.height, url: this._url} 147 | ); 148 | } 149 | } 150 | 151 | return video; 152 | }); 153 | } 154 | } 155 | 156 | module.exports = StreamsInfo; 157 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/listen.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | 6 | const Errors = require('src/Errors'); 7 | 8 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers/'); 9 | 10 | describe('FramesMonitor::listen', () => { 11 | 12 | let framesMonitor; 13 | let childProcess; 14 | 15 | let stubRunShowFramesProcess; 16 | let spyIsListening; 17 | 18 | beforeEach(() => { 19 | framesMonitor = new FramesMonitor(config, url); 20 | 21 | childProcess = makeChildProcess(); 22 | 23 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 24 | spyIsListening = sinon.spy(framesMonitor, 'isListening'); 25 | }); 26 | 27 | afterEach(() => { 28 | stubRunShowFramesProcess.restore(); 29 | spyIsListening.restore(); 30 | }); 31 | 32 | it('must start listen just fine', () => { 33 | framesMonitor.listen(); 34 | 35 | assert.isTrue(spyIsListening.calledOnce); 36 | 37 | assert.isTrue(stubRunShowFramesProcess.calledOnce); 38 | assert.isTrue(stubRunShowFramesProcess.firstCall.calledWithExactly()); 39 | 40 | assert.isDefined(framesMonitor._cp); 41 | }); 42 | 43 | it('must throw an exception when try listen several times in a row', () => { 44 | const expectedErrorType = Errors.AlreadyListeningError; 45 | const expectedErrorMessage = 'You are already listening.'; 46 | 47 | framesMonitor.listen(); 48 | 49 | try { 50 | framesMonitor.listen(); 51 | assert.isTrue(false, 'listen must throw exception'); 52 | } catch (err) { 53 | assert.isTrue(spyIsListening.calledTwice); 54 | 55 | assert.instanceOf(err, expectedErrorType); 56 | 57 | assert.strictEqual(err.message, expectedErrorMessage); 58 | assert.isUndefined(err.extra); 59 | 60 | assert.isTrue(stubRunShowFramesProcess.calledOnce); 61 | assert.isTrue(stubRunShowFramesProcess.firstCall.calledWithExactly()); 62 | 63 | assert.isDefined(framesMonitor._cp); 64 | } 65 | }); 66 | 67 | it('must correct set callback for exit event', () => { 68 | const expectedCode = 1; 69 | const expectedSignal = 'SIGTERM'; 70 | 71 | const stubOnExit = sinon.stub(framesMonitor, '_onExit'); 72 | 73 | framesMonitor.listen(); 74 | 75 | childProcess.emit('exit', expectedCode, expectedSignal); 76 | 77 | assert.isTrue(spyIsListening.calledOnce); 78 | 79 | assert.isDefined(framesMonitor._cp); 80 | 81 | assert.isTrue(stubOnExit.calledOnce); 82 | assert.isTrue(stubOnExit.calledWithExactly(expectedCode, expectedSignal)); 83 | 84 | stubOnExit.restore(); 85 | }); 86 | 87 | it('must correct set callback for error event', () => { 88 | const expectedError = new Error('process start error'); 89 | 90 | const stubOnProcessStartError = sinon.stub(framesMonitor, '_onProcessStartError'); 91 | 92 | framesMonitor.listen(); 93 | 94 | childProcess.emit('error', expectedError); 95 | 96 | assert.isTrue(spyIsListening.calledOnce); 97 | 98 | assert.isDefined(framesMonitor._cp); 99 | 100 | assert.isTrue(stubOnProcessStartError.calledOnce); 101 | assert.isTrue(stubOnProcessStartError.calledWithExactly(expectedError)); 102 | 103 | stubOnProcessStartError.restore(); 104 | }); 105 | 106 | it('must correct set callback for stdout stream error event', () => { 107 | const expectedError = new Error('stdout stream error'); 108 | 109 | const stubOnProcessStdoutStreamError = sinon.stub(framesMonitor, '_onProcessStdoutStreamError'); 110 | 111 | framesMonitor.listen(); 112 | 113 | childProcess.stdout.emit('error', expectedError); 114 | 115 | assert.isTrue(spyIsListening.calledOnce); 116 | 117 | assert.isDefined(framesMonitor._cp); 118 | 119 | assert.isTrue(stubOnProcessStdoutStreamError.calledOnce); 120 | assert.isTrue(stubOnProcessStdoutStreamError.calledWithExactly(expectedError)); 121 | 122 | stubOnProcessStdoutStreamError.restore(); 123 | }); 124 | 125 | it('must correct set callback for stderr stream error event', () => { 126 | const expectedError = new Error('stderr stream error'); 127 | 128 | const stubOnProcessStderrStreamError = sinon.stub(framesMonitor, '_onProcessStderrStreamError'); 129 | 130 | framesMonitor.listen(); 131 | 132 | childProcess.stderr.emit('error', expectedError); 133 | 134 | assert.isTrue(spyIsListening.calledOnce); 135 | 136 | assert.isDefined(framesMonitor._cp); 137 | 138 | assert.isTrue(stubOnProcessStderrStreamError.calledOnce); 139 | assert.isTrue(stubOnProcessStderrStreamError.calledWithExactly(expectedError)); 140 | 141 | stubOnProcessStderrStreamError.restore(); 142 | }); 143 | 144 | it('must correct set callback for stderr data event', () => { 145 | const expectedData = Buffer.from('some error in stderr'); 146 | 147 | const stubOnStderrData = sinon.stub(framesMonitor, '_onStderrData'); 148 | 149 | framesMonitor.listen(); 150 | 151 | childProcess.stderr.emit('data', expectedData); 152 | 153 | assert.isTrue(spyIsListening.calledOnce); 154 | 155 | assert.isDefined(framesMonitor._cp); 156 | 157 | assert.isTrue(stubOnStderrData.calledOnce); 158 | assert.isTrue(stubOnStderrData.calledWithExactly(expectedData)); 159 | 160 | stubOnStderrData.restore(); 161 | }); 162 | 163 | it('must correct set callback for stdout data event', () => { 164 | const expectedData = Buffer.from('some data in stdout'); 165 | 166 | const stubOnStdoutChunk = sinon.stub(framesMonitor, '_onStdoutChunk'); 167 | 168 | framesMonitor.listen(); 169 | 170 | childProcess.stdout.emit('data', expectedData); 171 | 172 | assert.isTrue(spyIsListening.calledOnce); 173 | 174 | assert.isDefined(framesMonitor._cp); 175 | 176 | assert.isTrue(stubOnStdoutChunk.calledOnce); 177 | assert.isTrue(stubOnStdoutChunk.calledWithExactly(expectedData)); 178 | 179 | stubOnStdoutChunk.restore(); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/gopDurationInSec.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | function typeOf(item) { 11 | return Object.prototype.toString.call(item); 12 | } 13 | 14 | describe('processFrames.gopDurationInSec', () => { 15 | 16 | const invalidData = [ 17 | undefined, 18 | null, 19 | true, 20 | '1', 21 | {}, 22 | [], 23 | () => {} 24 | ]; 25 | 26 | dataDriven( 27 | invalidData.map(item => ({type: typeOf(item), startTime: item})), 28 | () => { 29 | it('must throw an error if gop start time field has invalid {type} type', ctx => { 30 | const invalidStartTime = ctx.startTime; 31 | const validEndTime = 2; 32 | 33 | const invalidInput = { 34 | frames : [], 35 | startTime: invalidStartTime, 36 | endTime : validEndTime 37 | }; 38 | 39 | try { 40 | processFrames.gopDurationInSec(invalidInput); 41 | assert.isFalse(true, 'should not be here'); 42 | } catch (error) { 43 | assert.instanceOf(error, Errors.GopInvalidData); 44 | 45 | assert.strictEqual( 46 | error.message, 47 | `gops's start time has invalid type ${Object.prototype.toString.call(invalidInput.startTime)}` 48 | ); 49 | 50 | assert.deepEqual(error.extra, {gop: invalidInput}); 51 | } 52 | }); 53 | } 54 | ); 55 | 56 | dataDriven( 57 | invalidData.map(item => ({type: typeOf(item), endTime: item})), 58 | () => { 59 | it('must throw an error if gop end time field has invalid {type} type', ctx => { 60 | const validStartTime = 1; 61 | const invalidEndTime = ctx.endTime; 62 | 63 | const invalidInput = { 64 | frames : [], 65 | startTime: validStartTime, 66 | endTime : invalidEndTime 67 | }; 68 | 69 | try { 70 | processFrames.gopDurationInSec(invalidInput); 71 | assert.isFalse(true, 'should not be here'); 72 | } catch (error) { 73 | assert.instanceOf(error, Errors.GopInvalidData); 74 | 75 | assert.strictEqual( 76 | error.message, 77 | `gops's end time has invalid type ${Object.prototype.toString.call(invalidInput.endTime)}` 78 | ); 79 | 80 | assert.deepEqual(error.extra, {gop: invalidInput}); 81 | } 82 | }); 83 | } 84 | ); 85 | 86 | it('must throw an error if gop start time field has invalid, negative value', () => { 87 | const invalidStartTime = -1; 88 | const validEndTime = 2; 89 | 90 | const invalidInput = { 91 | frames : [], 92 | startTime: invalidStartTime, 93 | endTime : validEndTime 94 | }; 95 | 96 | try { 97 | processFrames.gopDurationInSec(invalidInput); 98 | assert.isFalse(true, 'should not be here'); 99 | } catch (error) { 100 | assert.instanceOf(error, Errors.GopInvalidData); 101 | 102 | assert.strictEqual( 103 | error.message, 104 | `gop's start time has invalid value ${invalidInput.startTime}` 105 | ); 106 | 107 | assert.deepEqual(error.extra, {gop: invalidInput}); 108 | } 109 | }); 110 | 111 | it('must throw an error if gop end time field has invalid, negative value', () => { 112 | const validStartTime = 1; 113 | const invalidValidEndTime = -1; 114 | 115 | const invalidInput = { 116 | frames : [], 117 | startTime: validStartTime, 118 | endTime : invalidValidEndTime 119 | }; 120 | 121 | try { 122 | processFrames.gopDurationInSec(invalidInput); 123 | assert.isFalse(true, 'should not be here'); 124 | } catch (error) { 125 | assert.instanceOf(error, Errors.GopInvalidData); 126 | 127 | assert.strictEqual( 128 | error.message, 129 | `gop's end time has invalid value ${invalidInput.endTime}` 130 | ); 131 | 132 | assert.deepEqual(error.extra, {gop: invalidInput}); 133 | } 134 | }); 135 | 136 | it('must throw an error if gop end time has invalid, zero value', () => { 137 | const validStartTime = 0; 138 | const invalidEndTime = 0; 139 | 140 | const invalidInput = { 141 | frames : [], 142 | startTime: validStartTime, 143 | endTime : invalidEndTime 144 | }; 145 | 146 | try { 147 | processFrames.gopDurationInSec(invalidInput); 148 | assert.isFalse(true, 'should not be here'); 149 | } catch (error) { 150 | assert.instanceOf(error, Errors.GopInvalidData); 151 | 152 | assert.strictEqual( 153 | error.message, 154 | `gop's end time has invalid value ${invalidInput.endTime}` 155 | ); 156 | 157 | assert.deepEqual(error.extra, {gop: invalidInput}); 158 | } 159 | }); 160 | 161 | it('must throw an error cuz the diff between gop start and end times equals to zero', () => { 162 | const validStartTime = 2; 163 | const invalidEndTime = 1; 164 | 165 | const invalidInput = { 166 | frames : [], 167 | startTime: validStartTime, 168 | endTime : invalidEndTime 169 | }; 170 | 171 | try { 172 | processFrames.gopDurationInSec(invalidInput); 173 | assert.isFalse(true, 'should not be here'); 174 | } catch (error) { 175 | assert.instanceOf(error, Errors.GopInvalidData); 176 | 177 | assert.strictEqual( 178 | error.message, 179 | `invalid difference between gop start and end time: ${invalidEndTime - validStartTime}`, 180 | ); 181 | 182 | assert.deepEqual(error.extra, {gop: invalidInput}); 183 | } 184 | }); 185 | 186 | }); 187 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_runShowFramesProcess.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const proxyquire = require('proxyquire'); 4 | const sinon = require('sinon'); 5 | const {assert} = require('chai'); 6 | 7 | const {config, url} = require('./Helpers'); 8 | 9 | function getSpawnArguments(url, timeoutInMs, analyzeDurationInMs, errorLevel) { 10 | const args = [ 11 | '-hide_banner', 12 | '-v', 13 | errorLevel, 14 | '-fflags', 15 | 'nobuffer', 16 | '-rw_timeout', 17 | timeoutInMs * 1000, 18 | '-show_frames', 19 | '-show_entries', 20 | 'frame=pkt_size,pkt_pts_time,media_type,pict_type,key_frame,width,height', 21 | ]; 22 | 23 | if (analyzeDurationInMs) { 24 | args.push('-analyzeduration', analyzeDurationInMs * 1000); 25 | } 26 | 27 | args.push('-i', url); 28 | 29 | return args; 30 | } 31 | 32 | describe('FramesMonitor::_handleProcessingError', () => { 33 | const expectedFfprobePath = config.ffprobePath; 34 | const expectedFfprobeArguments = getSpawnArguments( 35 | url, config.timeoutInMs, config.analyzeDurationInMs, config.errorLevel 36 | ); 37 | 38 | it('must returns child process object just fine', () => { 39 | const expectedOutput = {cp: true}; 40 | 41 | const spawn = () => expectedOutput; 42 | const spySpawn = sinon.spy(spawn); 43 | 44 | const FramesMonitor = proxyquire('src/FramesMonitor', { 45 | fs : { 46 | accessSync(filePath) { 47 | if (filePath !== config.ffprobePath) { 48 | throw new Error('no such file or directory'); 49 | } 50 | } 51 | }, 52 | child_process: { 53 | spawn: spySpawn 54 | } 55 | }); 56 | 57 | const framesMonitor = new FramesMonitor(config, url); 58 | 59 | const spyOnProcessStartError = sinon.spy(framesMonitor, '_onProcessStartError'); 60 | 61 | const result = framesMonitor._runShowFramesProcess(); 62 | 63 | assert.strictEqual(result, expectedOutput); 64 | 65 | assert.isTrue(spySpawn.calledOnce); 66 | assert.isTrue( 67 | spySpawn.calledWithExactly(expectedFfprobePath, expectedFfprobeArguments) 68 | ); 69 | 70 | assert.isTrue(spyOnProcessStartError.notCalled); 71 | }); 72 | 73 | it('must returns child process object just fine with default analyze duration', () => { 74 | const analyzeDurationInMs = undefined; 75 | 76 | const expectedOutput = {cp: true}; 77 | const expectedFfprobeArguments = getSpawnArguments( 78 | url, config.timeoutInMs, analyzeDurationInMs, config.errorLevel 79 | ); 80 | 81 | const spawn = () => expectedOutput; 82 | const spySpawn = sinon.spy(spawn); 83 | 84 | const FramesMonitor = proxyquire('src/FramesMonitor', { 85 | fs : { 86 | accessSync(filePath) { 87 | if (filePath !== config.ffprobePath) { 88 | throw new Error('no such file or directory'); 89 | } 90 | } 91 | }, 92 | child_process: { 93 | spawn: spySpawn 94 | } 95 | }); 96 | 97 | const options = Object.assign({}, config, {analyzeDurationInMs}); 98 | 99 | const framesMonitor = new FramesMonitor(options, url); 100 | 101 | const spyOnProcessStartError = sinon.spy(framesMonitor, '_onProcessStartError'); 102 | 103 | const result = framesMonitor._runShowFramesProcess(); 104 | 105 | assert.strictEqual(result, expectedOutput); 106 | 107 | assert.isTrue(spySpawn.calledOnce); 108 | assert.isTrue( 109 | spySpawn.calledWithExactly(expectedFfprobePath, expectedFfprobeArguments) 110 | ); 111 | 112 | assert.isTrue(spyOnProcessStartError.notCalled); 113 | }); 114 | 115 | it('must re-thrown TypeError error from the spawn call', () => { 116 | const expectedError = new TypeError('some error'); 117 | 118 | const spawn = () => { 119 | throw expectedError; 120 | }; 121 | 122 | const spySpawn = sinon.spy(spawn); 123 | 124 | const FramesMonitor = proxyquire('src/FramesMonitor', { 125 | fs : { 126 | accessSync(filePath) { 127 | if (filePath !== config.ffprobePath) { 128 | throw new Error('no such file or directory'); 129 | } 130 | } 131 | }, 132 | child_process: { 133 | spawn: spySpawn 134 | } 135 | }); 136 | 137 | const framesMonitor = new FramesMonitor(config, url); 138 | 139 | const spyOnProcessStartError = sinon.spy(framesMonitor, '_onProcessStartError'); 140 | 141 | assert.throws(() => { 142 | framesMonitor._runShowFramesProcess(); 143 | }, TypeError, expectedError.message); 144 | 145 | assert.isTrue(spySpawn.calledOnce); 146 | assert.isTrue( 147 | spySpawn.calledWithExactly(expectedFfprobePath, expectedFfprobeArguments) 148 | ); 149 | 150 | assert.isTrue(spyOnProcessStartError.notCalled); 151 | }); 152 | 153 | it('must call FramesMonitor::_onProcessStartError method if error type is not TypeError', () => { 154 | const expectedError = new EvalError('some error'); 155 | 156 | const spawn = () => { 157 | throw expectedError; 158 | }; 159 | 160 | const spySpawn = sinon.spy(spawn); 161 | 162 | const FramesMonitor = proxyquire('src/FramesMonitor', { 163 | fs : { 164 | accessSync(filePath) { 165 | if (filePath !== config.ffprobePath) { 166 | throw new Error('no such file or directory'); 167 | } 168 | } 169 | }, 170 | child_process: { 171 | spawn: spySpawn 172 | } 173 | }); 174 | 175 | const framesMonitor = new FramesMonitor(config, url); 176 | 177 | const spyOnProcessStartError = sinon.spy(framesMonitor, '_onProcessStartError'); 178 | 179 | framesMonitor._runShowFramesProcess(); 180 | 181 | assert.isTrue(spySpawn.calledOnce); 182 | assert.isTrue( 183 | spySpawn.calledWithExactly(expectedFfprobePath, expectedFfprobeArguments) 184 | ); 185 | 186 | assert.isTrue(spyOnProcessStartError.calledOnce); 187 | assert.isTrue(spyOnProcessStartError.calledWithExactly(expectedError)); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /tests/Functional/FramesMonitor/listen.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const {assert} = require('chai'); 6 | const sinon = require('sinon'); 7 | const getPort = require('get-port'); 8 | 9 | const FramesMonitor = require('src/FramesMonitor'); 10 | const ExitReasons = require('src/ExitReasons'); 11 | 12 | const {startStream, stopStream} = require('../Helpers'); 13 | 14 | const testFile = path.join(__dirname, '../../inputs/test_IPPPP.mp4'); 15 | 16 | const bufferMaxLengthInBytes = 2 ** 20; 17 | const errorLevel = 'error'; 18 | const exitProcessGuardTimeoutInMs = 2000; 19 | 20 | describe('FramesMonitor::listen, fetch frames from inactive stream', () => { 21 | let streamUrl; 22 | let framesMonitor; 23 | 24 | let spyOnFrame; 25 | let spyOnStderr; 26 | 27 | beforeEach(async () => { 28 | const port = await getPort(); 29 | 30 | streamUrl = `http://localhost:${port}`; 31 | 32 | framesMonitor = new FramesMonitor({ 33 | ffprobePath : process.env.FFPROBE, 34 | timeoutInMs : 1000, 35 | bufferMaxLengthInBytes : bufferMaxLengthInBytes, 36 | errorLevel : errorLevel, 37 | exitProcessGuardTimeoutInMs: exitProcessGuardTimeoutInMs 38 | }, streamUrl); 39 | 40 | spyOnFrame = sinon.spy(); 41 | spyOnStderr = sinon.spy(); 42 | }); 43 | 44 | afterEach(() => { 45 | spyOnFrame.resetHistory(); 46 | spyOnStderr.resetHistory(); 47 | }); 48 | 49 | it('must receive error cuz stream is inactive', done => { 50 | const expectedReturnCode = 1; 51 | 52 | framesMonitor.listen(); 53 | 54 | framesMonitor.on('frame', spyOnFrame); 55 | 56 | framesMonitor.on('exit', reason => { 57 | assert.instanceOf(reason, ExitReasons.AbnormalExit); 58 | assert.strictEqual(reason.payload.code, expectedReturnCode); 59 | 60 | assert.isString(reason.payload.stderrOutput); 61 | assert.isNotEmpty(reason.payload.stderrOutput); 62 | 63 | assert.isTrue(spyOnFrame.notCalled); 64 | 65 | done(); 66 | }); 67 | }); 68 | }); 69 | 70 | describe('FramesMonitor::listen, fetch frames from active stream', () => { 71 | let streamUrl; 72 | let framesMonitor; 73 | 74 | let spyOnIFrame; 75 | let spyOnPFrame; 76 | let spyOnAudioFrame; 77 | 78 | beforeEach(async () => { 79 | const port = await getPort(); 80 | 81 | streamUrl = `http://localhost:${port}`; 82 | 83 | framesMonitor = new FramesMonitor({ 84 | ffprobePath : process.env.FFPROBE, 85 | timeoutInMs : 1000, 86 | bufferMaxLengthInBytes : bufferMaxLengthInBytes, 87 | errorLevel : errorLevel, 88 | exitProcessGuardTimeoutInMs: exitProcessGuardTimeoutInMs 89 | }, streamUrl); 90 | 91 | spyOnIFrame = sinon.spy(); 92 | spyOnPFrame = sinon.spy(); 93 | spyOnAudioFrame = sinon.spy(); 94 | 95 | await startStream(testFile, streamUrl); 96 | }); 97 | 98 | afterEach(() => { 99 | spyOnPFrame.resetHistory(); 100 | spyOnIFrame.resetHistory(); 101 | spyOnAudioFrame.resetHistory(); 102 | }); 103 | 104 | it('must receive all stream frames', done => { 105 | const expectedReturnCode = 0; 106 | 107 | const onFrame = {I: spyOnIFrame, P: spyOnPFrame}; 108 | 109 | framesMonitor.listen(); 110 | 111 | framesMonitor.on('frame', frame => { 112 | if (frame.media_type === 'audio') { 113 | spyOnAudioFrame(); 114 | } else { 115 | onFrame[frame.pict_type](); 116 | } 117 | }); 118 | 119 | framesMonitor.on('exit', reason => { 120 | try { 121 | assert.instanceOf(reason, ExitReasons.NormalExit); 122 | assert.strictEqual(reason.payload.code, expectedReturnCode); 123 | 124 | assert.isTrue(spyOnAudioFrame.called); 125 | 126 | assert.isTrue(spyOnIFrame.called); 127 | assert.isTrue(spyOnPFrame.called); 128 | 129 | done(); 130 | } catch (err) { 131 | done(err); 132 | } 133 | }); 134 | }); 135 | }); 136 | 137 | describe('FramesMonitor::listen, stop ffprobe process', () => { 138 | let stream; 139 | let streamUrl; 140 | let framesMonitor; 141 | 142 | beforeEach(async () => { 143 | const port = await getPort(); 144 | 145 | streamUrl = `http://localhost:${port}`; 146 | 147 | framesMonitor = new FramesMonitor({ 148 | ffprobePath : process.env.FFPROBE, 149 | timeoutInMs : 1000, 150 | bufferMaxLengthInBytes : bufferMaxLengthInBytes, 151 | errorLevel : errorLevel, 152 | exitProcessGuardTimeoutInMs: exitProcessGuardTimeoutInMs 153 | }, streamUrl); 154 | 155 | stream = await startStream(testFile, streamUrl); 156 | }); 157 | 158 | afterEach(async () => { 159 | await stopStream(stream); 160 | }); 161 | 162 | it('must exit with correct signal after kill', done => { 163 | const expectedReturnCode = null; 164 | const expectedSignal = 'SIGTERM'; 165 | 166 | framesMonitor.listen(); 167 | 168 | framesMonitor.once('frame', async () => { 169 | const {code, signal} = await framesMonitor.stopListen(); 170 | 171 | assert.strictEqual(code, expectedReturnCode); 172 | assert.strictEqual(signal, expectedSignal); 173 | 174 | done(); 175 | }); 176 | }); 177 | }); 178 | 179 | describe('FramesMonitor::listen, exit with correct code after stream has been finished', () => { 180 | let stream; 181 | let streamUrl; 182 | let framesMonitor; 183 | 184 | beforeEach(async () => { 185 | const port = await getPort(); 186 | 187 | streamUrl = `http://localhost:${port}`; 188 | 189 | framesMonitor = new FramesMonitor({ 190 | ffprobePath : process.env.FFPROBE, 191 | timeoutInMs : 1000, 192 | bufferMaxLengthInBytes : bufferMaxLengthInBytes, 193 | errorLevel : errorLevel, 194 | exitProcessGuardTimeoutInMs: exitProcessGuardTimeoutInMs 195 | }, streamUrl); 196 | 197 | stream = await startStream(testFile, streamUrl); 198 | }); 199 | 200 | it('must exit with correct zero code after stream has been finished', done => { 201 | const expectedReturnCode = 0; 202 | 203 | framesMonitor.listen(); 204 | 205 | framesMonitor.once('frame', () => { 206 | setTimeout(async () => { 207 | await stopStream(stream); 208 | }, 1000); 209 | }); 210 | 211 | framesMonitor.on('exit', reason => { 212 | assert.instanceOf(reason, ExitReasons.NormalExit); 213 | assert.strictEqual(reason.payload.code, expectedReturnCode); 214 | 215 | done(); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/identifyGops.data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const invalidKeyFramesTypes = [ 4 | undefined, 5 | null, 6 | true, 7 | '1', 8 | {}, 9 | [], 10 | () => {} 11 | ]; 12 | 13 | const invalidKeyFramesValues = [-1, -0.5, 0.5, 2, 2.5]; 14 | 15 | const testData = [ 16 | { 17 | description: 'works okay for the set of frames with no key frame', 18 | input : [ 19 | {key_frame: 0}, 20 | {key_frame: 0}, 21 | ], 22 | res : {gops: [], remainedFrames: []} 23 | }, 24 | { 25 | description: 'works okay for the set of frames with one key frame in the middle, but not completed', 26 | input : [ 27 | {key_frame: 0, pkt_pts_time: 1}, 28 | {key_frame: 1, pkt_pts_time: 2}, 29 | {key_frame: 0, pkt_pts_time: 3}, 30 | ], 31 | res : { 32 | gops : [], 33 | remainedFrames: [ 34 | {key_frame: 1, pkt_pts_time: 2}, 35 | {key_frame: 0, pkt_pts_time: 3}, 36 | ] 37 | } 38 | }, 39 | { 40 | description: 'works okay for the set of frames with one key frame at the beginning, but not completed', 41 | input : [ 42 | {key_frame: 1, pkt_pts_time: 1}, 43 | {key_frame: 0, pkt_pts_time: 2}, 44 | {key_frame: 0, pkt_pts_time: 3}, 45 | {key_frame: 0, pkt_pts_time: 4}, 46 | ], 47 | res : { 48 | gops : [], 49 | remainedFrames: [ 50 | {key_frame: 1, pkt_pts_time: 1}, 51 | {key_frame: 0, pkt_pts_time: 2}, 52 | {key_frame: 0, pkt_pts_time: 3}, 53 | {key_frame: 0, pkt_pts_time: 4}, 54 | ] 55 | } 56 | }, 57 | { 58 | description: 'works okay for the set of frames with two completed gops, starts from gop with one frame', 59 | input : [ 60 | {key_frame: 1, pkt_pts_time: 1}, 61 | {key_frame: 1, pkt_pts_time: 2}, 62 | {key_frame: 0, pkt_pts_time: 3}, 63 | {key_frame: 0, pkt_pts_time: 4}, 64 | {key_frame: 1, pkt_pts_time: 5}, 65 | ], 66 | res : { 67 | gops : [ 68 | { 69 | frames : [ 70 | {key_frame: 1, pkt_pts_time: 1} 71 | ], 72 | startTime: 1, 73 | endTime : 2 74 | }, 75 | { 76 | frames : [ 77 | {key_frame: 1, pkt_pts_time: 2}, 78 | {key_frame: 0, pkt_pts_time: 3}, 79 | {key_frame: 0, pkt_pts_time: 4} 80 | ], 81 | startTime: 2, 82 | endTime : 5 83 | } 84 | ], 85 | remainedFrames: [ 86 | {key_frame: 1, pkt_pts_time: 5}, 87 | ] 88 | } 89 | }, 90 | { 91 | description: 'edge case, works okay even for undefined pkt_pts_time values, error exception would be throw on the next level', // eslint-disable-line 92 | input : [ 93 | {key_frame: 1, pkt_pts_time: undefined}, 94 | {key_frame: 0, pkt_pts_time: 3}, 95 | {key_frame: 0, pkt_pts_time: 4}, 96 | {key_frame: 1, pkt_pts_time: undefined}, 97 | ], 98 | res : { 99 | gops : [ 100 | { 101 | frames : [ 102 | {key_frame: 1, pkt_pts_time: undefined}, 103 | {key_frame: 0, pkt_pts_time: 3}, 104 | {key_frame: 0, pkt_pts_time: 4} 105 | ], 106 | startTime: undefined, 107 | endTime : undefined 108 | } 109 | ], 110 | remainedFrames: [ 111 | {key_frame: 1, pkt_pts_time: undefined}, 112 | ] 113 | } 114 | }, 115 | { 116 | description: 'works okay for the set of frames with two completed gops, starts from key_frame=1', 117 | input : [ 118 | {key_frame: 1, pkt_pts_time: 1}, 119 | {key_frame: 0, pkt_pts_time: 2}, 120 | {key_frame: 0, pkt_pts_time: 3}, 121 | {key_frame: 1, pkt_pts_time: 4}, 122 | {key_frame: 0, pkt_pts_time: 5}, 123 | {key_frame: 0, pkt_pts_time: 6}, 124 | {key_frame: 1, pkt_pts_time: 7}, 125 | {key_frame: 0, pkt_pts_time: 8} 126 | ], 127 | res : { 128 | gops : [ 129 | { 130 | frames : [ 131 | {key_frame: 1, pkt_pts_time: 1}, 132 | {key_frame: 0, pkt_pts_time: 2}, 133 | {key_frame: 0, pkt_pts_time: 3} 134 | ], 135 | startTime: 1, 136 | endTime : 4 137 | }, 138 | { 139 | frames : [ 140 | {key_frame: 1, pkt_pts_time: 4}, 141 | {key_frame: 0, pkt_pts_time: 5}, 142 | {key_frame: 0, pkt_pts_time: 6} 143 | ], 144 | startTime: 4, 145 | endTime : 7 146 | } 147 | ], 148 | remainedFrames: [ 149 | {key_frame: 1, pkt_pts_time: 7}, 150 | {key_frame: 0, pkt_pts_time: 8} 151 | ] 152 | } 153 | }, 154 | { 155 | description: 'works okay for the set of frames with two completed gops, starts from key_frame=0', 156 | input : [ 157 | {key_frame: 0, pkt_pts_time: 1}, 158 | {key_frame: 0, pkt_pts_time: 2}, 159 | {key_frame: 1, pkt_pts_time: 3}, 160 | {key_frame: 0, pkt_pts_time: 4}, 161 | {key_frame: 0, pkt_pts_time: 5}, 162 | {key_frame: 1, pkt_pts_time: 6}, 163 | {key_frame: 0, pkt_pts_time: 7}, 164 | {key_frame: 0, pkt_pts_time: 8}, 165 | {key_frame: 1, pkt_pts_time: 9}, 166 | {key_frame: 0, pkt_pts_time: 10}, 167 | ], 168 | res : { 169 | gops : [ 170 | { 171 | frames : [ 172 | {key_frame: 1, pkt_pts_time: 3}, 173 | {key_frame: 0, pkt_pts_time: 4}, 174 | {key_frame: 0, pkt_pts_time: 5} 175 | ], 176 | startTime: 3, 177 | endTime : 6 178 | }, 179 | { 180 | frames : [ 181 | {key_frame: 1, pkt_pts_time: 6}, 182 | {key_frame: 0, pkt_pts_time: 7}, 183 | {key_frame: 0, pkt_pts_time: 8} 184 | ], 185 | startTime: 6, 186 | endTime : 9 187 | } 188 | ], 189 | remainedFrames: [ 190 | {key_frame: 1, pkt_pts_time: 9}, 191 | {key_frame: 0, pkt_pts_time: 10}, 192 | ] 193 | } 194 | } 195 | ]; 196 | 197 | module.exports = { 198 | invalidKeyFramesTypes, 199 | invalidKeyFramesValues, 200 | testData 201 | }; 202 | 203 | -------------------------------------------------------------------------------- /tests/Unit/StreamsInfo/fetch.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const {assert} = require('chai'); 5 | const dataDriven = require('data-driven'); 6 | 7 | const {StreamsInfoError} = require('src/Errors'); 8 | 9 | const {correctPath, correctUrl, StreamsInfo} = require('./Helpers/'); 10 | 11 | function typeOf(obj) { 12 | return Object.prototype.toString.call(obj); 13 | } 14 | 15 | describe('StreamsInfo::fetch', () => { 16 | 17 | let streamsInfo = new StreamsInfo({ 18 | ffprobePath: correctPath, 19 | timeoutInMs: 1 20 | }, correctUrl); 21 | 22 | let stubRunShowStreamsProcess; 23 | let stubParseStreamsInfo; 24 | let stubAdjustAspectRatio; 25 | 26 | beforeEach(() => { 27 | stubParseStreamsInfo = sinon.stub(streamsInfo, '_parseStreamsInfo').callThrough(); 28 | stubAdjustAspectRatio = sinon.stub(streamsInfo, '_adjustAspectRatio').callThrough(); 29 | stubRunShowStreamsProcess = sinon.stub(streamsInfo, '_runShowStreamsProcess'); 30 | }); 31 | 32 | afterEach(() => { 33 | stubParseStreamsInfo.restore(); 34 | stubAdjustAspectRatio.restore(); 35 | stubRunShowStreamsProcess.restore(); 36 | }); 37 | 38 | it('ffmpeg child process returns with error code right after the start, fs.exec throws err', async () => { 39 | const expectedWrappedErrorMessage = 'Ffprobe failed to fetch streams data'; 40 | const expectedWrappedErrorClass = StreamsInfoError; 41 | 42 | const spawnError = new Error('some exception'); 43 | 44 | stubRunShowStreamsProcess.rejects(spawnError); 45 | 46 | try { 47 | await streamsInfo.fetch(); 48 | } catch (err) { 49 | assert.instanceOf(err, expectedWrappedErrorClass); 50 | 51 | assert.strictEqual(err.message, expectedWrappedErrorMessage); 52 | assert.deepEqual(err.extra, { 53 | error: spawnError, 54 | url : correctUrl 55 | }); 56 | 57 | assert(stubRunShowStreamsProcess.calledOnce); 58 | assert.isTrue(stubRunShowStreamsProcess.firstCall.calledWithExactly()); 59 | 60 | assert.isFalse(stubParseStreamsInfo.called); 61 | assert.isFalse(stubAdjustAspectRatio.called); 62 | } 63 | }); 64 | 65 | it('ffmpeg child process wrote to stderr, fetch method must throw an exception', async () => { 66 | const error = 'some error'; 67 | 68 | const expectedErrorMessage = `Ffprobe wrote to stderr: ${error}`; 69 | const expectedErrorClass = StreamsInfoError; 70 | 71 | stubRunShowStreamsProcess.resolves({ 72 | stderr: error, 73 | stdout: 'even stdout here, but nevermind' 74 | }); 75 | 76 | try { 77 | await streamsInfo.fetch(); 78 | } catch (err) { 79 | assert.instanceOf(err, expectedErrorClass); 80 | 81 | assert.strictEqual(err.message, expectedErrorMessage); 82 | assert.deepEqual(err.extra, { 83 | url: correctUrl 84 | }); 85 | 86 | assert(stubRunShowStreamsProcess.calledOnce); 87 | assert.isTrue(stubRunShowStreamsProcess.firstCall.calledWithExactly()); 88 | 89 | assert.isFalse(stubParseStreamsInfo.called); 90 | assert.isFalse(stubAdjustAspectRatio.called); 91 | } 92 | }); 93 | 94 | dataDriven( 95 | [undefined, null, true, 123, Symbol(123), [], {}, () => {}].map(data => ({type: typeOf(data), stdout: data})), 96 | () => { 97 | it('ffmpeg child process processed correct, but stdout has invalid {type} type.', async (ctx) => { 98 | const expectedErrorMessage = 'Ffprobe stdout has invalid type. Must be a String.'; 99 | const expectedErrorClass = StreamsInfoError; 100 | 101 | stubRunShowStreamsProcess.resolves({stdout: ctx.stdout}); 102 | 103 | try { 104 | await streamsInfo.fetch(); 105 | } catch (error) { 106 | assert.instanceOf(error, expectedErrorClass); 107 | 108 | assert.equal(error.message, expectedErrorMessage); 109 | assert.deepEqual(error.extra, { 110 | stdout: ctx.stdout, 111 | url : correctUrl, 112 | type : typeOf(ctx.stdout) 113 | }); 114 | } 115 | }); 116 | } 117 | ); 118 | 119 | it('ffmpeg child process processed correct, but stdout string is empty. fetch method must throw an exception', async () => { // eslint-disable-line 120 | const expectedErrorMessage = 'Ffprobe stdout is empty'; 121 | const expectedErrorClass = StreamsInfoError; 122 | 123 | stubRunShowStreamsProcess.resolves({stdout: ''}); 124 | 125 | try { 126 | await streamsInfo.fetch(); 127 | } catch (err) { 128 | assert.instanceOf(err, expectedErrorClass); 129 | 130 | assert.strictEqual(err.message, expectedErrorMessage); 131 | assert.deepEqual(err.extra, { 132 | url: correctUrl 133 | }); 134 | 135 | assert(stubRunShowStreamsProcess.calledOnce); 136 | assert.isTrue(stubRunShowStreamsProcess.firstCall.calledWithExactly()); 137 | 138 | assert.isFalse(stubParseStreamsInfo.called); 139 | assert.isFalse(stubAdjustAspectRatio.called); 140 | } 141 | }); 142 | 143 | dataDriven( 144 | [undefined, null, true, 123, Symbol(123), {}, () => {}].map(data => ({type: typeOf(data), data})), 145 | () => { 146 | it("ffmpeg child process processed correct, but stdout must has 'streams' prop of array type, but {type} received", async (ctx) => { // eslint-disable-line 147 | const expectedErrorClass = StreamsInfoError; 148 | const rawStreamInfo = JSON.stringify({streams: ctx.data}); 149 | 150 | stubRunShowStreamsProcess.resolves({stdout: rawStreamInfo}); 151 | 152 | try { 153 | await streamsInfo.fetch(); 154 | } catch (err) { 155 | assert.instanceOf(err, expectedErrorClass); 156 | 157 | assert(stubRunShowStreamsProcess.calledOnce); 158 | assert.isTrue(stubRunShowStreamsProcess.firstCall.calledWithExactly()); 159 | 160 | assert(stubParseStreamsInfo.calledOnce); 161 | assert.isTrue(stubParseStreamsInfo.firstCall.calledWithExactly(rawStreamInfo)); 162 | 163 | assert.isFalse(stubAdjustAspectRatio.called); 164 | } 165 | }); 166 | } 167 | ); 168 | 169 | 170 | it('child process stdout contains not empty streams array, 2 audios, 2 videos and several data streams', async () => { // eslint-disable-line 171 | const expectedResult = { 172 | videos: [ 173 | {codec_type: 'video', profile: 'Main', width: 100, height: 100}, 174 | {codec_type: 'video', profile: 'Main', width: 101, height: 101} 175 | ], 176 | audios: [ 177 | {codec_type: 'audio', profile: 'LC', codec_time_base: '1/44100'}, 178 | {codec_type: 'audio', profile: 'HC', codec_time_base: '1/44101'} 179 | ] 180 | }; 181 | 182 | const stdout = { 183 | streams: [ 184 | ...expectedResult.videos, 185 | ...expectedResult.audios, 186 | {codec_type: 'data', profile: 'unknown'}, 187 | {codec_type: 'data', profile: 'unknown'} 188 | ] 189 | }; 190 | 191 | const input = JSON.stringify(stdout); 192 | 193 | stubRunShowStreamsProcess.resolves({stdout: input}); 194 | 195 | try { 196 | const result = await streamsInfo.fetch(); 197 | 198 | assert(stubRunShowStreamsProcess.calledOnce); 199 | assert.isTrue(stubRunShowStreamsProcess.firstCall.calledWithExactly()); 200 | 201 | assert(stubParseStreamsInfo.calledOnce); 202 | assert.isTrue(stubParseStreamsInfo.firstCall.calledWithExactly(input)); 203 | 204 | assert(stubAdjustAspectRatio.calledOnce); 205 | 206 | assert.deepEqual(result, expectedResult); 207 | } catch (err) { 208 | assert.ifError(err); 209 | } 210 | }); 211 | 212 | }); 213 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/stopListen.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const sinon = require('sinon'); 5 | const Errors = require('src/Errors'); 6 | 7 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers/'); 8 | 9 | describe('FramesMonitor::stopListen', () => { 10 | let framesMonitor; 11 | let childProcess; 12 | 13 | let stubRunShowFramesProcess; 14 | let spyOnCpRemoveAllListeners; 15 | let spyOnCpStdoutRemoveAllListeners; 16 | let spyOnCpStderrRemoveAllListeners; 17 | 18 | let spySetTimeout; 19 | let spyCleanTimeout; 20 | 21 | beforeEach(() => { 22 | framesMonitor = new FramesMonitor(config, url); 23 | 24 | childProcess = makeChildProcess(); 25 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 26 | 27 | spyOnCpRemoveAllListeners = sinon.spy(childProcess, 'removeAllListeners'); 28 | spyOnCpStdoutRemoveAllListeners = sinon.spy(childProcess.stdout, 'removeAllListeners'); 29 | spyOnCpStderrRemoveAllListeners = sinon.spy(childProcess.stderr, 'removeAllListeners'); 30 | 31 | spySetTimeout = sinon.spy(global, 'setTimeout'); 32 | spyCleanTimeout = sinon.spy(global, 'clearTimeout'); 33 | }); 34 | 35 | afterEach(() => { 36 | stubRunShowFramesProcess.restore(); 37 | 38 | spyOnCpRemoveAllListeners.restore(); 39 | spyOnCpStdoutRemoveAllListeners.restore(); 40 | spyOnCpStderrRemoveAllListeners.restore(); 41 | 42 | spySetTimeout.restore(); 43 | spyCleanTimeout.restore(); 44 | }); 45 | 46 | it('must just resolve when try to stop listen before start listening', async () => { 47 | const expectedResult = undefined; 48 | 49 | const spyOnKill = sinon.spy(childProcess, 'kill'); 50 | 51 | const result = await framesMonitor.stopListen(); 52 | 53 | assert.strictEqual(result, expectedResult); 54 | 55 | assert.isTrue(spyOnKill.notCalled); 56 | 57 | assert.isTrue(spyOnCpRemoveAllListeners.notCalled); 58 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.notCalled); 59 | assert.isTrue(spyOnCpStderrRemoveAllListeners.notCalled); 60 | 61 | assert.isTrue(spySetTimeout.notCalled); 62 | assert.isTrue(spyCleanTimeout.notCalled); 63 | 64 | spyOnKill.restore(); 65 | }); 66 | 67 | it('must stop listen just fine', async () => { 68 | const expectedResult = {code: null, signal: 'SIGTERM'}; 69 | 70 | const expectedSignal = 'SIGTERM'; 71 | 72 | const spyOnKill = sinon.spy(childProcess, 'kill'); 73 | 74 | framesMonitor.listen(); 75 | 76 | const result = await framesMonitor.stopListen(); 77 | 78 | assert.deepEqual(result, expectedResult); 79 | 80 | assert.isTrue(spyOnKill.calledOnce); 81 | assert.isTrue(spyOnKill.alwaysCalledWithExactly(expectedSignal)); 82 | 83 | assert.isTrue(spyOnCpRemoveAllListeners.calledTwice); 84 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 85 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 86 | 87 | assert.isTrue(spySetTimeout.calledOnce); 88 | assert.isTrue(spyCleanTimeout.calledOnce); 89 | 90 | assert.isNull(framesMonitor._cp); 91 | 92 | spyOnKill.restore(); 93 | }); 94 | 95 | it('must reject listen cuz ChildProcess::kill method emitted error event', async () => { 96 | const expectedError = new EvalError('SIGTERM is not supported'); 97 | const expectedErrorMessage = 'process exit error'; 98 | const expectedSignal = 'SIGTERM'; 99 | 100 | const stubOnKill = sinon.stub(childProcess, 'kill').callsFake(() => { 101 | childProcess.emit('error', expectedError); 102 | }); 103 | 104 | framesMonitor.listen(); 105 | 106 | try { 107 | await framesMonitor.stopListen(); 108 | assert.isTrue(false, 'stopListen must reject promise'); 109 | } catch (err) { 110 | assert.instanceOf(err, Errors.ProcessExitError); 111 | assert.strictEqual(err.message, expectedErrorMessage); 112 | assert.strictEqual(err.extra.url, url); 113 | assert.strictEqual(err.extra.error, expectedError); 114 | 115 | assert.isTrue(stubOnKill.calledOnce); 116 | assert.isTrue(stubOnKill.alwaysCalledWithExactly(expectedSignal)); 117 | 118 | assert.isTrue(spyOnCpRemoveAllListeners.calledTwice); 119 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 120 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 121 | 122 | assert.isTrue(spySetTimeout.notCalled); 123 | assert.isTrue(spyCleanTimeout.notCalled); 124 | 125 | assert.isNull(framesMonitor._cp); 126 | 127 | stubOnKill.restore(); 128 | } 129 | }); 130 | 131 | it('must reject listen cuz ChildProcess::kill method thrown an error', async () => { 132 | const expectedError = new EvalError('SIGTERM is not supported'); 133 | const expectedErrorMessage = 'process exit error'; 134 | const expectedSignal = 'SIGTERM'; 135 | 136 | const stubOnKill = sinon.stub(childProcess, 'kill').throws(expectedError); 137 | 138 | framesMonitor.listen(); 139 | try { 140 | await framesMonitor.stopListen(); 141 | assert.isTrue(false, 'stopListen must reject promise'); 142 | } catch (err) { 143 | assert.instanceOf(err, Errors.ProcessExitError); 144 | assert.strictEqual(err.message, expectedErrorMessage); 145 | assert.strictEqual(err.extra.url, url); 146 | assert.strictEqual(err.extra.error, expectedError); 147 | 148 | assert.isTrue(stubOnKill.calledOnce); 149 | assert.isTrue(stubOnKill.alwaysCalledWithExactly(expectedSignal)); 150 | 151 | assert.isTrue(spyOnCpRemoveAllListeners.calledTwice); 152 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 153 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 154 | 155 | assert.isTrue(spySetTimeout.notCalled); 156 | assert.isTrue(spyCleanTimeout.notCalled); 157 | 158 | assert.isNull(framesMonitor._cp); 159 | 160 | stubOnKill.restore(); 161 | } 162 | }); 163 | 164 | it('must try to kill with SIGKILL if process has ignored SIGTERM', async function () { 165 | this.timeout(config.exitProcessGuardTimeoutInMs + 2000); 166 | 167 | const expectedResult = {code: null, signal: 'SIGKILL'}; 168 | 169 | const stubOnKill = sinon.stub(childProcess, 'kill').callsFake(signal => { 170 | if (signal === 'SIGKILL') { 171 | return childProcess.emit('exit', null, 'SIGKILL'); 172 | } 173 | }); 174 | 175 | framesMonitor.listen(); 176 | 177 | const result = await framesMonitor.stopListen(); 178 | 179 | assert.deepEqual(result, expectedResult); 180 | 181 | assert.isTrue(stubOnKill.calledTwice); 182 | assert(stubOnKill.getCall(0).calledWithExactly('SIGTERM')); 183 | assert(stubOnKill.getCall(1).calledWithExactly('SIGKILL')); 184 | 185 | assert.isTrue(spyOnCpRemoveAllListeners.calledTwice); 186 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 187 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 188 | 189 | assert.isTrue(spySetTimeout.calledOnce); 190 | assert.isTrue(spyCleanTimeout.calledOnce); 191 | 192 | assert.isNull(framesMonitor._cp); 193 | 194 | stubOnKill.restore(); 195 | }); 196 | 197 | it('must resolve just okay during the second stop listen in a row', async () => { 198 | const expectedSignal = 'SIGTERM'; 199 | const expectedResult1 = {code: null, signal: expectedSignal}; 200 | const expectedResult2 = undefined; 201 | 202 | const spyOnKill = sinon.spy(childProcess, 'kill'); 203 | 204 | framesMonitor.listen(); 205 | 206 | const result1 = await framesMonitor.stopListen(); 207 | const result2 = await framesMonitor.stopListen(); 208 | 209 | assert.deepEqual(result1, expectedResult1); 210 | assert.strictEqual(result2, expectedResult2); 211 | 212 | assert.isTrue(spyOnKill.calledOnce); 213 | assert.isTrue(spyOnKill.alwaysCalledWithExactly(expectedSignal)); 214 | 215 | assert.isTrue(spyOnCpRemoveAllListeners.calledTwice); 216 | assert.isTrue(spyOnCpStdoutRemoveAllListeners.calledOnce); 217 | assert.isTrue(spyOnCpStderrRemoveAllListeners.calledOnce); 218 | 219 | assert.isTrue(spySetTimeout.calledOnce); 220 | assert.isTrue(spyCleanTimeout.calledOnce); 221 | 222 | assert.isNull(framesMonitor._cp); 223 | 224 | spyOnKill.restore(); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/constructor.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const sinon = require('sinon'); 5 | const dataDriven = require('data-driven'); 6 | 7 | const Errors = require('src/Errors/'); 8 | 9 | const {config, url, FramesMonitor} = require('./Helpers'); 10 | 11 | const testData = require('./constructor.data'); 12 | 13 | function typeOf(item) { 14 | return Object.prototype.toString.call(item); 15 | } 16 | 17 | describe('FramesMonitor::constructor', () => { 18 | let spyAssertExecutable; 19 | 20 | beforeEach(() => { 21 | spyAssertExecutable = sinon.spy(FramesMonitor, '_assertExecutable'); 22 | }); 23 | 24 | afterEach(() => { 25 | spyAssertExecutable.restore(); 26 | }); 27 | 28 | dataDriven( 29 | testData.incorrectConfig.map(item => ({type: typeOf(item), config: item})), 30 | () => { 31 | it('config param has invalid ({type}) type', ctx => { 32 | assert.throws(() => { 33 | new FramesMonitor(ctx.config, url); 34 | }, TypeError, 'Config param should be a plain object.'); 35 | 36 | assert.isTrue(spyAssertExecutable.notCalled); 37 | }); 38 | } 39 | ); 40 | 41 | dataDriven( 42 | testData.incorrectUrl.map(item => ({type: typeOf(item), url: item})), 43 | () => { 44 | it('url param has invalid ({type}) type', ctx => { 45 | const config = {}; 46 | 47 | assert.throws(() => { 48 | new FramesMonitor(config, ctx.url); 49 | }, TypeError, 'You should provide a correct url.'); 50 | 51 | assert.isTrue(spyAssertExecutable.notCalled); 52 | }); 53 | } 54 | ); 55 | 56 | dataDriven( 57 | testData.incorrectFfprobePath.map(item => ({type: typeOf(item), ffprobePath: item})), 58 | () => { 59 | it('config.ffprobePath param has invalid ({type}) type', ctx => { 60 | const incorrectConfig = Object.assign({}, config, { 61 | ffprobePath: ctx.ffprobePath 62 | }); 63 | 64 | assert.throws(() => { 65 | new FramesMonitor(incorrectConfig, url); 66 | }, Error.ConfigError, 'You should provide a correct path to ffprobe.'); 67 | 68 | assert.isTrue(spyAssertExecutable.notCalled); 69 | }); 70 | } 71 | ); 72 | 73 | dataDriven( 74 | testData.incorrectTimeoutInMs.map(item => ({type: typeOf(item), timeoutInMs: item})), 75 | () => { 76 | it('config.timeoutInMs param has invalid ({type}) type', ctx => { 77 | const incorrectConfig = Object.assign({}, config, { 78 | timeoutInMs: ctx.timeoutInMs 79 | }); 80 | 81 | assert.throws(() => { 82 | new FramesMonitor(incorrectConfig, url); 83 | }, Error.ConfigError, 'You should provide a correct timeout.'); 84 | 85 | assert.isTrue(spyAssertExecutable.notCalled); 86 | }); 87 | } 88 | ); 89 | 90 | dataDriven( 91 | testData.incorrectBufferMaxLengthInBytes.map(item => ({type: typeOf(item), bufferMaxLengthInBytes: item})), 92 | () => { 93 | it('config.bufferMaxLengthInBytes param has invalid ({type}) type', ctx => { 94 | const incorrectConfig = Object.assign({}, config, { 95 | bufferMaxLengthInBytes: ctx.bufferMaxLengthInBytes 96 | }); 97 | 98 | assert.throws(() => { 99 | new FramesMonitor(incorrectConfig, url); 100 | }, Error.ConfigError, 'bufferMaxLengthInBytes param should be a positive integer.'); 101 | 102 | assert.isTrue(spyAssertExecutable.notCalled); 103 | }); 104 | } 105 | ); 106 | 107 | dataDriven( 108 | testData.incorrectErrorLevel.map(item => ({type: typeOf(item), errorLevel: item})), 109 | () => { 110 | it('config.errorLevel param has invalid ({type}) type', ctx => { 111 | const incorrectConfig = Object.assign({}, config, { 112 | errorLevel: ctx.errorLevel 113 | }); 114 | 115 | assert.throws(() => { 116 | new FramesMonitor(incorrectConfig, url); 117 | }, Error.ConfigError, 'You should provide correct error level. Check ffprobe documentation.'); 118 | 119 | assert.isTrue(spyAssertExecutable.notCalled); 120 | }); 121 | } 122 | ); 123 | 124 | dataDriven( 125 | testData.incorrectExitProcessGuardTimeoutInMs.map(item => ({ 126 | type : typeOf(item), 127 | exitProcessGuardTimeoutInMs: item 128 | })), 129 | () => { 130 | it('config.exitProcessGuardTimeoutInMs param has invalid ({type}) type', ctx => { 131 | const incorrectConfig = Object.assign({}, config, { 132 | exitProcessGuardTimeoutInMs: ctx.exitProcessGuardTimeoutInMs 133 | }); 134 | 135 | assert.throws(() => { 136 | new FramesMonitor(incorrectConfig, url); 137 | }, Error.ConfigError, 'exitProcessGuardTimeoutInMs param should be a positive integer.'); 138 | 139 | assert.isTrue(spyAssertExecutable.notCalled); 140 | }); 141 | } 142 | ); 143 | 144 | dataDriven(testData.incorrectConfigObject, () => { 145 | it('{description}', ctx => { 146 | const incorrectConfig = Object.assign({}, config, ctx.config); 147 | 148 | assert.throws(() => { 149 | new FramesMonitor(incorrectConfig, url); 150 | }, Errors.ConfigError, ctx.errorMsg); 151 | 152 | assert.isTrue(spyAssertExecutable.notCalled); 153 | }); 154 | }); 155 | 156 | it('config.ffprobePath points to incorrect path', () => { 157 | const ffprobeIncorrectPath = `/incorrect/path/${config.ffprobePath}`; 158 | 159 | assert.throws(() => { 160 | const incorrectConfig = Object.assign({}, config, { 161 | ffprobePath: ffprobeIncorrectPath 162 | }); 163 | 164 | new FramesMonitor(incorrectConfig, url); 165 | }, Errors.ExecutablePathError); 166 | 167 | assert.isTrue(spyAssertExecutable.calledOnce); 168 | assert.isTrue(spyAssertExecutable.calledWithExactly(ffprobeIncorrectPath)); 169 | }); 170 | 171 | it('all params are good', () => { 172 | const expectedChildProcessDefaultValue = null; 173 | const expectedChunkRemainderDefaultValue = ''; 174 | const expectedStderrOutputs = []; 175 | const expectedConfig = { 176 | ffprobePath : config.ffprobePath, 177 | bufferMaxLengthInBytes : config.bufferMaxLengthInBytes, 178 | errorLevel : config.errorLevel, 179 | exitProcessGuardTimeoutInMs: config.exitProcessGuardTimeoutInMs, 180 | timeout : config.timeoutInMs * 1000, 181 | analyzeDuration : config.analyzeDurationInMs * 1000 182 | }; 183 | 184 | const framesMonitor = new FramesMonitor(config, url); 185 | 186 | assert.isTrue(spyAssertExecutable.calledOnce); 187 | assert.isTrue(spyAssertExecutable.calledWithExactly(config.ffprobePath)); 188 | 189 | 190 | assert.deepEqual(framesMonitor._config, expectedConfig); 191 | assert.strictEqual(framesMonitor._url, url); 192 | 193 | assert.strictEqual(framesMonitor._cp, expectedChildProcessDefaultValue); 194 | assert.strictEqual(framesMonitor._chunkRemainder, expectedChunkRemainderDefaultValue); 195 | assert.deepEqual(framesMonitor._stderrOutputs, expectedStderrOutputs); 196 | }); 197 | 198 | 199 | it('analyzeDurationInMs not setted in config', () => { 200 | const configLocal = Object.assign({}, config, {analyzeDurationInMs: undefined}); 201 | 202 | const expectedChildProcessDefaultValue = null; 203 | const expectedChunkRemainderDefaultValue = ''; 204 | const expectedStderrOutputs = []; 205 | const expectedConfig = { 206 | ffprobePath : configLocal.ffprobePath, 207 | bufferMaxLengthInBytes : configLocal.bufferMaxLengthInBytes, 208 | errorLevel : configLocal.errorLevel, 209 | exitProcessGuardTimeoutInMs: configLocal.exitProcessGuardTimeoutInMs, 210 | timeout : configLocal.timeoutInMs * 1000, 211 | analyzeDuration : undefined 212 | }; 213 | 214 | const framesMonitor = new FramesMonitor(configLocal, url); 215 | 216 | assert.isTrue(spyAssertExecutable.calledOnce); 217 | assert.isTrue(spyAssertExecutable.calledWithExactly(config.ffprobePath)); 218 | 219 | 220 | assert.deepEqual(framesMonitor._config, expectedConfig); 221 | assert.strictEqual(framesMonitor._url, url); 222 | 223 | assert.strictEqual(framesMonitor._cp, expectedChildProcessDefaultValue); 224 | assert.strictEqual(framesMonitor._chunkRemainder, expectedChunkRemainderDefaultValue); 225 | assert.deepEqual(framesMonitor._stderrOutputs, expectedStderrOutputs); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /tests/Unit/processFrames/encoderStats.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const dataDriven = require('data-driven'); 5 | 6 | const processFrames = require('src/processFrames'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | function typeOf(item) { 11 | return Object.prototype.toString.call(item); 12 | } 13 | 14 | describe('processFrames.encoderStats', () => { 15 | 16 | const invalidFramesTypes = [ 17 | undefined, 18 | null, 19 | false, 20 | 1, 21 | '1', 22 | {}, 23 | Symbol(), 24 | () => {}, 25 | Buffer.alloc(0) 26 | ]; 27 | 28 | dataDriven( 29 | invalidFramesTypes.map(item => ({type: typeOf(item), item: item})), 30 | () => { 31 | it('must throw an exception for invalid input {type} type', ctx => { 32 | assert.throws(() => { 33 | processFrames.encoderStats(ctx.item); 34 | }, TypeError, 'Method accepts only an array of frames'); 35 | }); 36 | } 37 | ); 38 | 39 | it('must throw an exception cuz method cannot find gop', () => { 40 | const frames = [ 41 | {pkt_size: 3, pkt_pts_time: 15, media_type: 'video', key_frame: 1}, 42 | {pkt_size: 2, pkt_pts_time: 2, media_type: 'audio', key_frame: 1}, 43 | {pkt_size: 5, pkt_pts_time: 17, media_type: 'video', key_frame: 0}, 44 | {pkt_size: 7, pkt_pts_time: 19, media_type: 'video', key_frame: 0}, 45 | {pkt_size: 4, pkt_pts_time: 4, media_type: 'audio', key_frame: 1}, 46 | ]; 47 | 48 | try { 49 | processFrames.encoderStats(frames); 50 | assert.isFalse(true, 'should not be here'); 51 | } catch (error) { 52 | assert.instanceOf(error, Errors.GopNotFoundError); 53 | 54 | assert.strictEqual(error.message, 'Can not find any gop for these frames'); 55 | 56 | assert.deepEqual(error.extra, {frames}); 57 | } 58 | }); 59 | 60 | it('must return correct info just fine', () => { 61 | const frames1 = [ 62 | {width: 640, height: 480, pkt_size: 1, pkt_pts_time: 11, media_type: 'video', key_frame: 1}, 63 | {width: 640, height: 480, pkt_size: 3, pkt_pts_time: 13, media_type: 'video', key_frame: 0}, 64 | {width: 640, height: 480, pkt_size: 5, pkt_pts_time: 15, media_type: 'video', key_frame: 1}, 65 | {width: 640, height: 480, pkt_size: 7, pkt_pts_time: 17, media_type: 'video', key_frame: 0}, 66 | {width: 640, height: 480, pkt_size: 9, pkt_pts_time: 19, media_type: 'video', key_frame: 1} 67 | ]; 68 | 69 | const frames2 = [ 70 | {width: 854, height: 480, pkt_size: 1, pkt_pts_time: 1, media_type: 'audio', key_frame: 1}, 71 | {width: 854, height: 480, pkt_size: 1, pkt_pts_time: 11, media_type: 'video', key_frame: 0}, 72 | {width: 854, height: 480, pkt_size: 2, pkt_pts_time: 13, media_type: 'video', key_frame: 0}, 73 | {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 15, media_type: 'video', key_frame: 1}, 74 | {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 15, media_type: 'audio', key_frame: 1}, 75 | {width: 854, height: 480, pkt_size: 4, pkt_pts_time: 17, media_type: 'video', key_frame: 0}, 76 | {width: 854, height: 480, pkt_size: 5, pkt_pts_time: 19, media_type: 'video', key_frame: 0}, 77 | {width: 854, height: 480, pkt_size: 6, pkt_pts_time: 21, media_type: 'video', key_frame: 1}, 78 | {width: 854, height: 480, pkt_size: 7, pkt_pts_time: 23, media_type: 'video', key_frame: 0}, 79 | {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 15, media_type: 'audio', key_frame: 1}, 80 | {width: 854, height: 480, pkt_size: 8, pkt_pts_time: 25, media_type: 'video', key_frame: 0}, 81 | {width: 854, height: 480, pkt_size: 9, pkt_pts_time: 27, media_type: 'video', key_frame: 1}, 82 | {width: 854, height: 480, pkt_size: 10, pkt_pts_time: 29, media_type: 'video', key_frame: 0} 83 | ]; 84 | 85 | const expectedBitrate1 = { 86 | min : processFrames.toKbs((1 + 3) / (15 - 11)), 87 | max : processFrames.toKbs((5 + 7) / (19 - 15)), 88 | mean: processFrames.toKbs(((1 + 3) / (15 - 11) + (5 + 7) / (19 - 15)) / 2) 89 | }; 90 | 91 | const expectedRemainedFrames1 = [ 92 | {pkt_size: 9, pkt_pts_time: 19, media_type: 'video', key_frame: 1, width: 640, height: 480} 93 | ]; 94 | 95 | const expectedBitrate2 = { 96 | min : processFrames.toKbs((3 + 4 + 5) / (21 - 15)), 97 | max : processFrames.toKbs((6 + 7 + 8) / (27 - 21)), 98 | mean: processFrames.toKbs(((3 + 4 + 5) / (21 - 15) + (6 + 7 + 8) / (27 - 21)) / 2) 99 | }; 100 | 101 | const expectedRemainedFrames2 = [ 102 | {pkt_size: 9, pkt_pts_time: 27, media_type: 'video', key_frame: 1, width: 854, height: 480}, 103 | {pkt_size: 10, pkt_pts_time: 29, media_type: 'video', key_frame: 0, width: 854, height: 480} 104 | ]; 105 | 106 | const expectedFps1 = {min: 0.5, max: 0.5, mean: 0.5}; 107 | const expectedFps2 = {min: 0.5, max: 0.5, mean: 0.5}; 108 | 109 | const expectedGopDuration1 = { 110 | min: 15 - 11, 111 | max: 19 - 15, 112 | mean: (15 - 11 + 19 - 15) / 2 113 | }; 114 | 115 | const expectedGopDuration2 = { 116 | min: 21 - 15, 117 | max: 27 - 21, 118 | mean: (21 - 15 + 27 - 21) / 2 119 | }; 120 | 121 | const expectedAspectRatio1 = '4:3'; 122 | const expectedAspectRatio2 = '16:9'; 123 | const expectedWidth1 = 640; 124 | const expectedHeight1 = 480; 125 | const expectedWidth2 = 854; 126 | const expectedHeight2 = 480; 127 | const expectAudio1 = false; 128 | const expectAudio2 = true; 129 | 130 | let res1 = processFrames.encoderStats(frames1); 131 | 132 | assert.deepEqual(res1.payload, { 133 | areAllGopsIdentical: true, 134 | bitrate : expectedBitrate1, 135 | fps : expectedFps1, 136 | gopDuration : expectedGopDuration1, 137 | displayAspectRatio : expectedAspectRatio1, 138 | width : expectedWidth1, 139 | height : expectedHeight1, 140 | hasAudioStream : expectAudio1 141 | }); 142 | 143 | assert.deepEqual(res1.remainedFrames, expectedRemainedFrames1); 144 | 145 | let res2 = processFrames.encoderStats(frames2); 146 | 147 | assert.deepEqual(res2.payload, { 148 | areAllGopsIdentical: true, 149 | bitrate : expectedBitrate2, 150 | fps : expectedFps2, 151 | gopDuration : expectedGopDuration2, 152 | displayAspectRatio : expectedAspectRatio2, 153 | width : expectedWidth2, 154 | height : expectedHeight2, 155 | hasAudioStream : expectAudio2 156 | }); 157 | 158 | assert.deepEqual(res2.remainedFrames, expectedRemainedFrames2); 159 | }); 160 | 161 | it('must detect that GOPs is not identical ', () => { 162 | const frames = [ 163 | {width: 640, height: 480, pkt_size: 1, pkt_pts_time: 11, media_type: 'video', key_frame: 1}, 164 | {width: 640, height: 480, pkt_size: 3, pkt_pts_time: 13, media_type: 'video', key_frame: 0}, 165 | {width: 640, height: 480, pkt_size: 5, pkt_pts_time: 15, media_type: 'video', key_frame: 1}, 166 | {width: 640, height: 480, pkt_size: 6, pkt_pts_time: 17, media_type: 'video', key_frame: 0}, 167 | {width: 640, height: 480, pkt_size: 9, pkt_pts_time: 19, media_type: 'video', key_frame: 0}, 168 | {width: 640, height: 480, pkt_size: 11, pkt_pts_time: 21, media_type: 'video', key_frame: 1} 169 | ]; 170 | 171 | const expectedBitrate = { 172 | min: processFrames.toKbs((1 + 3) / (15 - 11)), 173 | max: processFrames.toKbs((5 + 6 + 9) / (21 - 15)), 174 | mean: processFrames.toKbs(((1 + 3) / (15 - 11) + (5 + 6 + 9) / (21 - 15)) / 2) 175 | }; 176 | 177 | const expectedRemainedFrames = [ 178 | {pkt_size: 11, pkt_pts_time: 21, media_type: 'video', key_frame: 1, width: 640, height: 480} 179 | ]; 180 | 181 | const expectedFps = {min: 0.5, max: 0.5, mean: 0.5}; 182 | 183 | const expectedGopDuration = { 184 | min: 15 - 11, 185 | max: 21 - 15, 186 | mean: (15 - 11 + 21 - 15) / 2 187 | }; 188 | 189 | const expectedAspectRatio = '4:3'; 190 | const expectedWidth = 640; 191 | const expectedHeight = 480; 192 | const expectAudio = false; 193 | const expectAreAllGopsIdentical = false; 194 | 195 | let res = processFrames.encoderStats(frames); 196 | 197 | assert.deepEqual(res.payload, { 198 | areAllGopsIdentical: expectAreAllGopsIdentical, 199 | bitrate: expectedBitrate, 200 | fps: expectedFps, 201 | gopDuration: expectedGopDuration, 202 | displayAspectRatio: expectedAspectRatio, 203 | width: expectedWidth, 204 | height: expectedHeight, 205 | hasAudioStream: expectAudio 206 | }); 207 | 208 | assert.deepEqual(res.remainedFrames, expectedRemainedFrames); 209 | }); 210 | 211 | }); 212 | -------------------------------------------------------------------------------- /tests/Unit/FramesMonitor/_onStdoutChunk.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {assert} = require('chai'); 4 | const sinon = require('sinon'); 5 | 6 | const {config, url, FramesMonitor, makeChildProcess} = require('./Helpers'); 7 | 8 | const Errors = require('src/Errors'); 9 | 10 | describe('FramesMonitor::_onStdoutChunk', () => { 11 | 12 | let framesMonitor; 13 | 14 | let spyOnCompleteFrame; 15 | let stubRunShowFramesProcess; 16 | let stubHandleProcessingError; 17 | let spyFrameToJson; 18 | let spyReduceFramesFromChunks; 19 | 20 | beforeEach(() => { 21 | framesMonitor = new FramesMonitor(config, url); 22 | 23 | const childProcess = makeChildProcess(); 24 | 25 | stubRunShowFramesProcess = sinon.stub(framesMonitor, '_runShowFramesProcess').returns(childProcess); 26 | stubHandleProcessingError = sinon.stub(framesMonitor, '_handleProcessingError').resolves(); 27 | spyFrameToJson = sinon.spy(FramesMonitor, '_frameToJson'); 28 | spyReduceFramesFromChunks = sinon.spy(FramesMonitor, '_reduceFramesFromChunks'); 29 | spyOnCompleteFrame = sinon.spy(); 30 | 31 | framesMonitor.listen(); 32 | }); 33 | 34 | afterEach(() => { 35 | spyOnCompleteFrame.resetHistory(); 36 | stubRunShowFramesProcess.restore(); 37 | stubHandleProcessingError.restore(); 38 | spyFrameToJson.restore(); 39 | spyReduceFramesFromChunks.restore(); 40 | }); 41 | 42 | const data = [ 43 | { 44 | description : 'emit entire frame with empty chunkRemainder, chunkRemainder should have new, empty value', // eslint-disable-line 45 | currentChunkRemainder: '', 46 | input : '[FRAME]\na=b\n[/FRAME]', 47 | newChundRemainder : '', 48 | output : {a: 'b'} 49 | }, 50 | { 51 | description : 'emit entire frame with empty chunkRemainder, chunkRemainder should have new, not-empty value', // eslint-disable-line 52 | currentChunkRemainder: '', 53 | input : '[FRAME]\na=b\n[/FRAME]\n[FRAME]\nc=d', 54 | newChundRemainder : '\n[FRAME]\nc=d', 55 | output : {a: 'b'} 56 | }, 57 | { 58 | description : 'emit accumulated set of frames, chunkRemainder should have new, not-empty value', 59 | currentChunkRemainder: '[FRAME]\na=b\n', 60 | input : 'c=d\n[/FRAME]\n[FRAME]\ne=', 61 | newChundRemainder : '\n[FRAME]\ne=', 62 | output : {a: 'b', c: 'd'} 63 | } 64 | ]; 65 | 66 | data.forEach((test) => { 67 | it(`${test.description}`, done => { 68 | framesMonitor._chunkRemainder = test.currentChunkRemainder; 69 | 70 | const input = Buffer.from(test.input); 71 | 72 | framesMonitor.on('frame', spyOnCompleteFrame); 73 | 74 | framesMonitor._onStdoutChunk(input) 75 | .then(() => { 76 | setImmediate(() => { 77 | assert.isTrue(spyOnCompleteFrame.calledOnce); 78 | 79 | assert.isTrue(stubHandleProcessingError.notCalled); 80 | assert.isTrue(spyFrameToJson.calledOnce); 81 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); 82 | 83 | done(); 84 | }); 85 | }); 86 | }); 87 | }); 88 | 89 | it('must not emit empty frame', done => { 90 | const input = Buffer.from(''); 91 | 92 | framesMonitor.on('frame', spyOnCompleteFrame); 93 | 94 | framesMonitor._onStdoutChunk(input) 95 | .then(() => { 96 | setImmediate(() => { 97 | assert(spyOnCompleteFrame.notCalled); 98 | 99 | assert.isTrue(stubHandleProcessingError.notCalled); 100 | assert.isTrue(spyFrameToJson.notCalled); 101 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); 102 | 103 | done(); 104 | }); 105 | }); 106 | }); 107 | 108 | it('must not emit uncomplete frame', done => { 109 | const input = Buffer.from('[FRAME]\na=b'); 110 | 111 | framesMonitor.on('frame', spyOnCompleteFrame); 112 | 113 | framesMonitor._onStdoutChunk(input) 114 | .then(() => { 115 | setImmediate(() => { 116 | assert(spyOnCompleteFrame.notCalled); 117 | 118 | assert.isTrue(stubHandleProcessingError.notCalled); 119 | assert.isTrue(spyFrameToJson.notCalled); 120 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); 121 | 122 | done(); 123 | }); 124 | }); 125 | }); 126 | 127 | it('must emit complete raw frames in json format, which has been accumulated by several chunks', done => { 128 | const tests = [ 129 | Buffer.from('[FRAME]\na=b\nc=d\n'), 130 | Buffer.from('a2=b2\nc2=d2\n'), 131 | Buffer.from('e2=f2\n[/FRAME]\n'), 132 | Buffer.from('[FRAME]\na=b\n[/FRAME]\n'), 133 | Buffer.from('[FRAME]\na=b\nc=d\n') 134 | ]; 135 | 136 | const expectedResult1 = { 137 | a : 'b', 138 | c : 'd', 139 | a2: 'b2', 140 | c2: 'd2', 141 | e2: 'f2' 142 | }; 143 | 144 | const expectedResult2 = {a: 'b'}; 145 | 146 | framesMonitor.on('frame', spyOnCompleteFrame); 147 | 148 | // we cannot use done with async func identifier, so will use then method 149 | Promise.all( 150 | tests.map(async test => { 151 | await framesMonitor._onStdoutChunk(test); 152 | }) 153 | ) 154 | .then(() => { 155 | // _onStdoutChunk uses setImmediate under the hood, so we use it here too 156 | setImmediate(() => { 157 | assert(spyOnCompleteFrame.calledTwice); 158 | 159 | assert.isTrue(spyOnCompleteFrame.firstCall.calledWithExactly(expectedResult1)); 160 | assert.isTrue(spyOnCompleteFrame.secondCall.calledWithExactly(expectedResult2)); 161 | 162 | assert.isTrue(stubHandleProcessingError.notCalled); 163 | assert.isTrue(spyFrameToJson.calledTwice); 164 | assert.strictEqual(spyReduceFramesFromChunks.callCount, tests.length); 165 | 166 | done(); 167 | }); 168 | }); 169 | }); 170 | 171 | it('must emit error, invalid data input (too big frame, probably infinite)', async () => { 172 | const smallInput = '\na=b\n'.repeat(10); 173 | const largeInput = '\na=b\n'.repeat(config.bufferMaxLengthInBytes - 10); 174 | 175 | await framesMonitor._onStdoutChunk(smallInput); 176 | await framesMonitor._onStdoutChunk(largeInput); 177 | 178 | assert.isTrue(stubHandleProcessingError.calledOnce); 179 | 180 | const error = stubHandleProcessingError.getCall(0).args[0]; 181 | 182 | assert.instanceOf(error, Errors.InvalidFrameError); 183 | 184 | assert.strictEqual( 185 | error.message, 186 | 'Too long (probably infinite) frame.' + 187 | `The frame length is ${smallInput.length + largeInput.length}.` + 188 | `The max frame length must be ${config.bufferMaxLengthInBytes}` 189 | ); 190 | 191 | assert.isTrue(spyFrameToJson.notCalled); 192 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); // during the first call on smallInput 193 | }); 194 | 195 | it('must throw an exception, invalid data input (unclosed frame)', async () => { 196 | const expectedErrorType = Errors.InvalidFrameError; 197 | const expectedErrorMessage = 'Can not process frame with invalid structure.'; 198 | 199 | const chunkRemainder = '[FRAME]\na=b\n'; 200 | const newChunk = '[FRAME]\na=b\nc=d\n[/FRAME]'; 201 | 202 | framesMonitor._chunkRemainder = chunkRemainder; 203 | 204 | await framesMonitor._onStdoutChunk(newChunk); 205 | 206 | assert.isTrue(stubHandleProcessingError.calledOnce); 207 | 208 | const error = stubHandleProcessingError.getCall(0).args[0]; 209 | 210 | assert.instanceOf(error, expectedErrorType); 211 | 212 | assert.strictEqual(error.message, expectedErrorMessage); 213 | 214 | assert.deepEqual(error.extra, { 215 | data : chunkRemainder + newChunk, 216 | frame: '[FRAME]\na=b\n[FRAME]\na=b\nc=d\n' 217 | }); 218 | 219 | assert.isTrue(spyFrameToJson.notCalled); 220 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); 221 | }); 222 | 223 | it('must throw an exception, invalid data input (end block without starting one)', async () => { 224 | const expectedErrorType = Errors.InvalidFrameError; 225 | const expectedErrorMessage = 'Can not process frame with invalid structure.'; 226 | 227 | const newChunk = 'a=b\nc=d\n[/FRAME]'; 228 | 229 | await framesMonitor._onStdoutChunk(newChunk); 230 | 231 | assert.isTrue(stubHandleProcessingError.calledOnce); 232 | 233 | const error = stubHandleProcessingError.getCall(0).args[0]; 234 | 235 | assert.instanceOf(error, expectedErrorType); 236 | 237 | assert.strictEqual(error.message, expectedErrorMessage); 238 | 239 | assert.deepEqual(error.extra, { 240 | data : newChunk, 241 | frame: 'a=b\nc=d\n' 242 | }); 243 | 244 | assert.isTrue(spyFrameToJson.notCalled); 245 | assert.isTrue(spyReduceFramesFromChunks.calledOnce); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /src/processFrames.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | 5 | const Errors = require('./Errors'); 6 | 7 | const MSECS_IN_SEC = 1000; 8 | 9 | const AR_CALCULATION_PRECISION = 0.01; 10 | 11 | const SQUARE_AR_COEFFICIENT = 1; 12 | const SQUARE_AR = '1:1'; 13 | 14 | const TRADITIONAL_TV_AR_COEFFICIENT = 1.333; 15 | const TRADITIONAL_TV_AR = '4:3'; 16 | 17 | const HD_VIDEO_AR_COEFFICIENT = 1.777; 18 | const HD_VIDEO_AR = '16:9'; 19 | 20 | const UNIVISIUM_AR_COEFFICIENT = 2; 21 | const UNIVISIUM_AR = '18:9'; 22 | 23 | const WIDESCREEN_AR_COEFFICIENT = 2.33; 24 | const WIDESCREEN_AR = '21:9'; 25 | 26 | function encoderStats(frames) { 27 | if (!Array.isArray(frames)) { 28 | throw new TypeError('Method accepts only an array of frames'); 29 | } 30 | 31 | const videoFrames = filterVideoFrames(frames); 32 | const {gops, remainedFrames} = identifyGops(videoFrames); 33 | 34 | if (_.isEmpty(gops)) { 35 | throw new Errors.GopNotFoundError('Can not find any gop for these frames', {frames}); 36 | } 37 | 38 | let areAllGopsIdentical = true; 39 | const hasAudioStream = hasAudioFrames(frames); 40 | const baseGopSize = gops[0].frames.length; 41 | const bitrates = []; 42 | const fpsList = []; 43 | const gopDurations = []; 44 | 45 | gops.forEach(gop => { 46 | areAllGopsIdentical = areAllGopsIdentical && baseGopSize === gop.frames.length; 47 | const calculatedPktSize = calculatePktSize(gop.frames); 48 | const gopDuration = gopDurationInSec(gop); 49 | 50 | const gopBitrate = toKbs(calculatedPktSize / gopDuration); 51 | bitrates.push(gopBitrate); 52 | 53 | const gopFps = gop.frames.length / gopDuration; 54 | fpsList.push(gopFps); 55 | 56 | gopDurations.push(gopDuration); 57 | }); 58 | 59 | const bitrate = { 60 | mean: _.mean(bitrates), 61 | min : Math.min(...bitrates), 62 | max : Math.max(...bitrates) 63 | }; 64 | 65 | const fps = { 66 | mean: _.mean(fpsList), 67 | min : Math.min(...fpsList), 68 | max : Math.max(...fpsList) 69 | }; 70 | 71 | const gopDuration = { 72 | mean: _.mean(gopDurations), 73 | min: Math.min(...gopDurations), 74 | max: Math.max(...gopDurations) 75 | }; 76 | 77 | const width = gops[0].frames[0].width; 78 | const height = gops[0].frames[0].height; 79 | const displayAspectRatio = calculateDisplayAspectRatio(width, height); 80 | 81 | return { 82 | payload : { 83 | areAllGopsIdentical, 84 | bitrate, 85 | fps, 86 | gopDuration, 87 | displayAspectRatio, 88 | width, 89 | height, 90 | hasAudioStream 91 | }, 92 | remainedFrames: remainedFrames 93 | }; 94 | } 95 | 96 | function networkStats(frames, durationInMsec) { 97 | if (!Array.isArray(frames)) { 98 | throw new TypeError('Method accepts only an array of frames'); 99 | } 100 | 101 | if (!_.isInteger(durationInMsec) || durationInMsec <= 0) { 102 | throw new TypeError('Method accepts only a positive integer as duration'); 103 | } 104 | 105 | const videoFrames = filterVideoFrames(frames); 106 | const audioFrames = filterAudioFrames(frames); 107 | 108 | const durationInSec = durationInMsec / MSECS_IN_SEC; 109 | 110 | return { 111 | videoFrameRate: videoFrames.length / durationInSec, 112 | audioFrameRate: audioFrames.length / durationInSec, 113 | videoBitrate: toKbs(calculatePktSize(videoFrames) / durationInSec), 114 | audioBitrate: toKbs(calculatePktSize(audioFrames) / durationInSec), 115 | }; 116 | } 117 | 118 | function identifyGops(frames) { 119 | const GOP_TEMPLATE = { 120 | frames: [] 121 | }; 122 | 123 | const setOfGops = []; 124 | let newGop = _.cloneDeep(GOP_TEMPLATE); 125 | 126 | for (let i = 0; i < frames.length; i++) { 127 | const currentFrame = frames[i]; 128 | 129 | if (!_.isNumber(currentFrame.key_frame)) { 130 | throw new Errors.FrameInvalidData( 131 | `frame's key_frame field has invalid type: ${Object.prototype.toString.call(currentFrame.key_frame)}`, 132 | {frame: currentFrame} 133 | ); 134 | } 135 | 136 | if (currentFrame.key_frame === 1) { 137 | if ('startTime' in newGop) { 138 | newGop.endTime = currentFrame.pkt_pts_time; 139 | setOfGops.push(newGop); 140 | newGop = _.cloneDeep(GOP_TEMPLATE); 141 | i -= 1; 142 | } else { 143 | newGop.frames.push(_.cloneDeep(currentFrame)); 144 | 145 | newGop.startTime = currentFrame.pkt_pts_time; 146 | } 147 | } else if (currentFrame.key_frame === 0) { 148 | if (newGop.frames.length > 0) { 149 | newGop.frames.push(_.cloneDeep(frames[i])); 150 | } 151 | } else { 152 | throw new Errors.FrameInvalidData( 153 | `frame's key_frame field has invalid value: ${currentFrame.key_frame}. Must be 1 or 0.`, 154 | {frame: currentFrame} 155 | ); 156 | } 157 | } 158 | 159 | // remainedFrames is a set of frames for which we didn't find gop 160 | // for example for this array of frames [1 0 0 0 1 0 0] the remainedFrames should be last three frames [1 0 0] 161 | // this is done in order not to lost part of the next gop and as a direct consequence - entire gop 162 | return { 163 | gops : setOfGops, 164 | remainedFrames: newGop.frames 165 | }; 166 | } 167 | 168 | function calculateBitrate(gops) { 169 | let bitrates = []; 170 | 171 | gops.forEach(gop => { 172 | const calculatedPktSize = calculatePktSize(gop.frames); 173 | const durationInSec = gopDurationInSec(gop); 174 | 175 | const gopBitrate = toKbs(calculatedPktSize / durationInSec); 176 | 177 | bitrates.push(gopBitrate); 178 | }); 179 | 180 | return { 181 | mean: _.mean(bitrates), 182 | min : Math.min.apply(null, bitrates), 183 | max : Math.max.apply(null, bitrates) 184 | }; 185 | } 186 | 187 | function calculatePktSize(frames) { 188 | const accumulatedPktSize = frames.reduce((accumulator, frame) => { 189 | if (!_.isNumber(frame.pkt_size)) { 190 | throw new Errors.FrameInvalidData( 191 | `frame's pkt_size field has invalid type ${Object.prototype.toString.call(frame.pkt_size)}`, 192 | {frame} 193 | ); 194 | } 195 | 196 | return accumulator + frame.pkt_size; 197 | }, 0); 198 | 199 | return accumulatedPktSize; 200 | } 201 | 202 | function gopDurationInSec(gop) { 203 | if (!_.isNumber(gop.startTime)) { 204 | throw new Errors.GopInvalidData( 205 | `gops's start time has invalid type ${Object.prototype.toString.call(gop.startTime)}`, 206 | {gop} 207 | ); 208 | } 209 | 210 | if (!_.isNumber(gop.endTime)) { 211 | throw new Errors.GopInvalidData( 212 | `gops's end time has invalid type ${Object.prototype.toString.call(gop.endTime)}`, 213 | {gop} 214 | ); 215 | } 216 | 217 | // start time may be 0 218 | if (gop.startTime < 0) { 219 | throw new Errors.GopInvalidData( 220 | `gop's start time has invalid value ${gop.startTime}`, 221 | {gop} 222 | ); 223 | } 224 | 225 | // end time must be positive 226 | if (gop.endTime <= 0) { 227 | throw new Errors.GopInvalidData( 228 | `gop's end time has invalid value ${gop.endTime}`, 229 | {gop} 230 | ); 231 | } 232 | 233 | const diff = gop.endTime - gop.startTime; 234 | 235 | if (diff <= 0) { 236 | throw new Errors.GopInvalidData( 237 | `invalid difference between gop start and end time: ${diff}`, 238 | {gop} 239 | ); 240 | } 241 | 242 | return diff; 243 | } 244 | 245 | function calculateFps(gops) { 246 | let fps = []; 247 | 248 | gops.forEach(gop => { 249 | const durationInSec = gopDurationInSec(gop); 250 | const gopFps = gop.frames.length / durationInSec; 251 | 252 | fps.push(gopFps); 253 | }); 254 | 255 | return { 256 | mean: _.mean(fps), 257 | min : Math.min.apply(null, fps), 258 | max : Math.max.apply(null, fps) 259 | }; 260 | } 261 | 262 | function calculateGopDuration(gops) { 263 | const gopsDurations = []; 264 | 265 | gops.forEach(gop => { 266 | const durationInSec = gopDurationInSec(gop); 267 | 268 | gopsDurations.push(durationInSec); 269 | }); 270 | 271 | return { 272 | mean: _.mean(gopsDurations), 273 | min: Math.min(...gopsDurations), 274 | max: Math.max(...gopsDurations) 275 | }; 276 | } 277 | 278 | function calculateDisplayAspectRatio(width, height) { 279 | if (!_.isInteger(width) || width <= 0) { 280 | throw new TypeError('"width" must be a positive integer'); 281 | } 282 | 283 | if (!_.isInteger(height) || height <= 0) { 284 | throw new TypeError('"height" must be a positive integer'); 285 | } 286 | 287 | const arCoefficient = width / height; 288 | 289 | if (Math.abs(arCoefficient - SQUARE_AR_COEFFICIENT) <= AR_CALCULATION_PRECISION) { 290 | return SQUARE_AR; 291 | } 292 | 293 | if (Math.abs(arCoefficient - TRADITIONAL_TV_AR_COEFFICIENT) <= AR_CALCULATION_PRECISION) { 294 | return TRADITIONAL_TV_AR; 295 | } 296 | 297 | if (Math.abs(arCoefficient - HD_VIDEO_AR_COEFFICIENT) <= AR_CALCULATION_PRECISION) { 298 | return HD_VIDEO_AR; 299 | } 300 | 301 | if (Math.abs(arCoefficient - UNIVISIUM_AR_COEFFICIENT) <= AR_CALCULATION_PRECISION) { 302 | return UNIVISIUM_AR; 303 | } 304 | 305 | if (Math.abs(arCoefficient - WIDESCREEN_AR_COEFFICIENT) <= AR_CALCULATION_PRECISION) { 306 | return WIDESCREEN_AR; 307 | } 308 | 309 | const gcd = findGcd(width, height); 310 | 311 | return `${width / gcd}:${height / gcd}`; 312 | } 313 | 314 | function areAllGopsIdentical(gops) { 315 | return gops.every(gop => _.isEqual(gops[0].frames.length, gop.frames.length)); 316 | } 317 | 318 | function filterVideoFrames(frames) { 319 | return frames.filter(frame => frame.media_type === 'video'); 320 | } 321 | 322 | function filterAudioFrames(frames) { 323 | return frames.filter(frame => frame.media_type === 'audio'); 324 | } 325 | 326 | function hasAudioFrames(frames) { 327 | return frames.some(frame => frame.media_type === 'audio'); 328 | } 329 | 330 | function toKbs(val) { 331 | return val * 8 / 1024; 332 | } 333 | 334 | function findGcd(a, b) { 335 | if (a === 0 && b === 0) { 336 | return 0; 337 | } 338 | 339 | if (b === 0) { 340 | return a; 341 | } 342 | 343 | return findGcd(b, a % b); 344 | } 345 | 346 | module.exports = { 347 | encoderStats, 348 | networkStats, 349 | identifyGops, 350 | calculateBitrate, 351 | calculateFps, 352 | calculateGopDuration, 353 | filterVideoFrames, 354 | hasAudioFrames, 355 | gopDurationInSec, 356 | toKbs, 357 | calculatePktSize, 358 | areAllGopsIdentical, 359 | findGcd, 360 | calculateDisplayAspectRatio 361 | }; 362 | -------------------------------------------------------------------------------- /src/FramesMonitor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const fs = require('fs'); 5 | const {EventEmitter} = require('events'); 6 | const {spawn} = require('child_process'); 7 | 8 | const Errors = require('./Errors/'); 9 | const ExitReasons = require('./ExitReasons'); 10 | 11 | const STDOUT = 'STDOUT'; 12 | const STDERR = 'STDERR'; 13 | 14 | const startFrameTag = '[FRAME]'; 15 | const endFrameTag = '[/FRAME]'; 16 | 17 | const validErrorLevels = [ 18 | 'trace', 19 | 'debug', 20 | 'verbose', 21 | 'info', 22 | 'warning', 23 | 'error', 24 | 'fatal', 25 | 'panic', 26 | 'quiet' 27 | ]; 28 | 29 | const STDERR_OBJECTS_LIMIT = 5; 30 | 31 | class FramesMonitor extends EventEmitter { 32 | constructor(config, url) { 33 | super(); 34 | 35 | if (!_.isPlainObject(config)) { 36 | throw new TypeError('Config param should be a plain object.'); 37 | } 38 | 39 | if (!_.isString(url)) { 40 | throw new TypeError('You should provide a correct url.'); 41 | } 42 | 43 | const { 44 | ffprobePath, 45 | timeoutInMs, 46 | bufferMaxLengthInBytes, 47 | errorLevel, 48 | exitProcessGuardTimeoutInMs, 49 | analyzeDurationInMs 50 | } = config; 51 | 52 | if (!_.isString(ffprobePath) || _.isEmpty(ffprobePath)) { 53 | throw new Errors.ConfigError('You should provide a correct path to ffprobe.'); 54 | } 55 | 56 | if (!_.isSafeInteger(timeoutInMs) || timeoutInMs <= 0) { 57 | throw new Errors.ConfigError('You should provide a correct timeout.'); 58 | } 59 | 60 | if (!_.isSafeInteger(bufferMaxLengthInBytes) || bufferMaxLengthInBytes <= 0) { 61 | throw new Errors.ConfigError('bufferMaxLengthInBytes param should be a positive integer.'); 62 | } 63 | 64 | if (!_.isString(errorLevel) || !FramesMonitor._isValidErrorLevel(errorLevel)) { 65 | throw new Errors.ConfigError( 66 | 'You should provide correct error level. Check ffprobe documentation.' 67 | ); 68 | } 69 | 70 | if (!_.isSafeInteger(exitProcessGuardTimeoutInMs) || exitProcessGuardTimeoutInMs <= 0) { 71 | throw new Errors.ConfigError('exitProcessGuardTimeoutInMs param should be a positive integer.'); 72 | } 73 | 74 | if (analyzeDurationInMs !== undefined && (!_.isSafeInteger(analyzeDurationInMs) || analyzeDurationInMs <= 0)) { 75 | throw new Errors.ConfigError('You should provide a correct analyze duration.'); 76 | } 77 | 78 | FramesMonitor._assertExecutable(ffprobePath); 79 | 80 | this._config = { 81 | ffprobePath, 82 | bufferMaxLengthInBytes, 83 | errorLevel, 84 | exitProcessGuardTimeoutInMs, 85 | timeout: timeoutInMs * 1000, 86 | analyzeDuration: analyzeDurationInMs && analyzeDurationInMs * 1000 || undefined 87 | }; 88 | 89 | this._url = url; 90 | 91 | this._cp = null; 92 | this._chunkRemainder = ''; 93 | 94 | this._stderrOutputs = []; 95 | 96 | this._onExit = this._onExit.bind(this); 97 | this._onProcessStartError = this._onProcessStartError.bind(this); 98 | this._onProcessStdoutStreamError = this._onProcessStreamsError.bind(this, STDOUT); 99 | this._onProcessStderrStreamError = this._onProcessStreamsError.bind(this, STDERR); 100 | this._onStderrData = this._onStderrData.bind(this); 101 | this._onStdoutChunk = this._onStdoutChunk.bind(this); 102 | } 103 | 104 | listen() { 105 | if (this.isListening()) { 106 | throw new Errors.AlreadyListeningError('You are already listening.'); 107 | } 108 | 109 | this._cp = this._runShowFramesProcess(); 110 | 111 | this._cp.once('exit', this._onExit); 112 | 113 | this._cp.on('error', this._onProcessStartError); 114 | 115 | this._cp.stdout.on('error', this._onProcessStdoutStreamError); 116 | this._cp.stderr.on('error', this._onProcessStderrStreamError); 117 | 118 | this._cp.stderr.on('data', this._onStderrData); 119 | 120 | this._cp.stdout.on('data', this._onStdoutChunk); 121 | } 122 | 123 | isListening() { 124 | return !!this._cp; 125 | } 126 | 127 | stopListen() { 128 | let exitProcessGuard = null; 129 | let isError = false; 130 | 131 | return new Promise((resolve, reject) => { 132 | if (!this._cp) { 133 | return resolve(); 134 | } 135 | 136 | this._cp.removeAllListeners(); 137 | this._cp.stderr.removeAllListeners(); 138 | this._cp.stdout.removeAllListeners(); 139 | 140 | this._cp.once('exit', (code, signal) => { 141 | clearTimeout(exitProcessGuard); 142 | 143 | this._cp.removeAllListeners(); 144 | this._cp = null; 145 | 146 | return resolve({code, signal}); 147 | }); 148 | 149 | this._cp.once('error', err => { 150 | const error = new Errors.ProcessExitError('process exit error', { 151 | url : this._url, 152 | error: err 153 | }); 154 | 155 | this._cp.removeAllListeners(); 156 | this._cp = null; 157 | 158 | isError = true; 159 | 160 | return reject(error); 161 | }); 162 | 163 | try { 164 | // ChildProcess kill method for some corner cases can throw an exception 165 | this._cp.kill('SIGTERM'); 166 | 167 | if (isError) { 168 | return; 169 | } 170 | 171 | // if kill() call returns okay, it does not mean that the process will exit 172 | // it's just means that signal was received, but child process can ignore it, so we will set guard 173 | // and clean it in the exit event handler. 174 | exitProcessGuard = setTimeout(() => { 175 | this._cp.kill('SIGKILL'); 176 | }, this._config.exitProcessGuardTimeoutInMs); 177 | } catch (err) { 178 | // platform does not support SIGTERM (probably SIGKILL also) 179 | 180 | const error = new Errors.ProcessExitError('process exit error', { 181 | url : this._url, 182 | error: err 183 | }); 184 | 185 | this._cp.removeAllListeners(); 186 | this._cp = null; 187 | 188 | return reject(error); 189 | } 190 | }); 191 | } 192 | 193 | async _handleProcessingError(error) { 194 | try { 195 | await this.stopListen(); 196 | } catch (err) { 197 | this.emit('error', err); 198 | } finally { 199 | this.emit('exit', new ExitReasons.ProcessingError({error})); 200 | } 201 | } 202 | 203 | _onProcessStartError(err) { 204 | const {ffprobePath} = this._config; 205 | 206 | if (this._cp) { 207 | this._cp.removeAllListeners(); 208 | 209 | this._cp.stderr.removeAllListeners(); 210 | this._cp.stdout.removeAllListeners(); 211 | 212 | this._cp = null; 213 | } 214 | 215 | const error = new Errors.ProcessStartError( 216 | `${ffprobePath} process could not be started.`, { 217 | url : this._url, 218 | error: err 219 | } 220 | ); 221 | 222 | const reason = new ExitReasons.StartError({error}); 223 | 224 | this.emit('exit', reason); 225 | } 226 | 227 | async _onProcessStreamsError(streamType, err) { 228 | const {ffprobePath} = this._config; 229 | 230 | const error = new Errors.ProcessStreamError( 231 | `got an error from a ${ffprobePath} ${streamType} process stream.`, { 232 | url : this._url, 233 | error: err 234 | } 235 | ); 236 | 237 | return await this._handleProcessingError(error); 238 | } 239 | 240 | _onStderrData(data) { 241 | const {ffprobePath} = this._config; 242 | 243 | const error = new Errors.FramesMonitorError( 244 | `got stderr output from a ${ffprobePath} process`, { 245 | data: data.toString(), 246 | url : this._url 247 | } 248 | ); 249 | 250 | this._stderrOutputs.push(error); 251 | 252 | if (this._stderrOutputs.length > STDERR_OBJECTS_LIMIT) { 253 | this._stderrOutputs.shift(); 254 | } 255 | } 256 | 257 | _onExit(code, signal) { 258 | this._cp.removeAllListeners(); 259 | this._cp = null; 260 | 261 | let reason; 262 | 263 | if (signal) { 264 | reason = new ExitReasons.ExternalSignal({signal}); 265 | } else if (code === 0) { 266 | reason = new ExitReasons.NormalExit({code}); 267 | } else if (code > 0) { 268 | const stderrOutput = this._stderrOutputs.join('\n'); 269 | reason = new ExitReasons.AbnormalExit({code, stderrOutput}); 270 | } 271 | 272 | return this.emit('exit', reason); 273 | } 274 | 275 | _runShowFramesProcess() { 276 | const {ffprobePath, timeout, analyzeDuration, errorLevel} = this._config; 277 | 278 | const args = [ 279 | '-hide_banner', 280 | '-v', 281 | errorLevel, 282 | '-fflags', 283 | 'nobuffer', 284 | '-rw_timeout', 285 | timeout, 286 | '-show_frames', 287 | '-show_entries', 288 | 'frame=pkt_size,pkt_pts_time,media_type,pict_type,key_frame,width,height', 289 | ]; 290 | 291 | if (analyzeDuration) { 292 | args.push('-analyzeduration', analyzeDuration); 293 | } 294 | 295 | args.push('-i', this._url); 296 | 297 | try { 298 | return spawn(ffprobePath, args); 299 | } catch (err) { 300 | if (err instanceof TypeError) { 301 | // spawn method throws TypeError if some argument is invalid 302 | // we don't want to emit this type of errors 303 | throw err; 304 | } else { 305 | // at the same time spawn method can throw another type of error from libuv library 306 | // we prefer to emit this stuff 307 | this._onProcessStartError(err); 308 | } 309 | } 310 | } 311 | 312 | async _onStdoutChunk(newChunk) { 313 | const data = this._chunkRemainder + newChunk.toString(); 314 | 315 | if (data.length > this._config.bufferMaxLengthInBytes) { 316 | const error = new Errors.InvalidFrameError( 317 | 'Too long (probably infinite) frame.' + 318 | `The frame length is ${data.length}.` + 319 | `The max frame length must be ${this._config.bufferMaxLengthInBytes}`, { 320 | url: this._url 321 | } 322 | ); 323 | 324 | return await this._handleProcessingError(error); 325 | } 326 | 327 | let frames; 328 | 329 | try { 330 | const res = FramesMonitor._reduceFramesFromChunks(data); 331 | 332 | this._chunkRemainder = res.chunkRemainder; 333 | frames = res.frames; 334 | } catch (error) { 335 | return await this._handleProcessingError(error); 336 | } 337 | 338 | for (const frame of frames) { 339 | setImmediate(() => { 340 | this.emit('frame', FramesMonitor._frameToJson(frame)); 341 | }); 342 | } 343 | } 344 | 345 | static _assertExecutable(path) { 346 | try { 347 | fs.accessSync(path, fs.constants.X_OK); 348 | } catch (e) { 349 | throw new Errors.ExecutablePathError(e.message, {path}); 350 | } 351 | } 352 | 353 | static _reduceFramesFromChunks(data) { 354 | let chunkRemainder = ''; 355 | let frames = data.split(endFrameTag); 356 | 357 | if (frames[frames.length - 1]) { 358 | chunkRemainder = frames[frames.length - 1]; 359 | } 360 | 361 | frames.splice(-1); 362 | 363 | for (const frame of frames) { 364 | if ( 365 | frame.indexOf(startFrameTag) === -1 366 | || (frame.indexOf(startFrameTag) !== frame.lastIndexOf(startFrameTag)) 367 | ) { 368 | throw new Errors.InvalidFrameError('Can not process frame with invalid structure.', {data, frame}); 369 | } 370 | } 371 | 372 | frames = frames.map(frame => frame.replace(startFrameTag, '')); 373 | 374 | frames = frames.map(frame => frame.trim()); 375 | 376 | return {chunkRemainder, frames}; 377 | } 378 | 379 | static _frameToJson(rawFrame) { 380 | const frame = {}; 381 | const frameLines = rawFrame.split('\n'); 382 | 383 | frameLines.forEach(frameLine => { 384 | let [key, value] = frameLine.split('=').map(item => item.trim()); 385 | 386 | if (key && value) { 387 | value = _.isNaN(Number(value)) ? value : Number(value); 388 | frame[key] = value; 389 | } 390 | }); 391 | 392 | return frame; 393 | } 394 | 395 | static _isValidErrorLevel(level) { 396 | return _.includes(validErrorLevels, level); 397 | } 398 | } 399 | 400 | module.exports = FramesMonitor; 401 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video Quality Tools module - helps to measure live stream characteristics by RTMP/HLS/DASH streams 2 | 3 | Features: 4 | * fetching live video stream on demand, displaying info on video/audio characteristics; 5 | * monitoring live video streams in real time, running quality checks: fps and bitrate drops, GOP 6 | structure changes and more. 7 | 8 | `video-quality-tools` requires `ffmpeg` and `ffprobe`. 9 | 10 | [![NPM version](https://img.shields.io/npm/v/video-quality-tools.svg)](https://www.npmjs.com/package/video-quality-tools) 11 | [![Release Status](https://github.com/LCMApps/video-quality-tools/workflows/NPM%20Release/badge.svg)](https://github.com/LCMApps/video-quality-tools/releases) 12 | [![Build Status](https://travis-ci.org/LCMApps/video-quality-tools.svg?branch=master)](https://travis-ci.org/LCMApps/video-quality-tools) 13 | [![Coverage Status](https://coveralls.io/repos/github/LCMApps/video-quality-tools/badge.svg?branch=master)](https://coveralls.io/github/LCMApps/video-quality-tools?branch=master) 14 | 15 | # Installation 16 | 17 | Using npm: 18 | ```shell 19 | $ npm install --save video-quality-tools 20 | ``` 21 | 22 | Using yarn: 23 | ```shell 24 | $ yarn add video-quality-tools 25 | ``` 26 | 27 | # Basic Concepts 28 | 29 | ## Error Handling 30 | 31 | There are a lot of methods in module that may throw an error. All errors are subclasses of a basic javascript `Error` 32 | object and may be distinguished by prototype. To do this, just import the `Error` object from the module. 33 | 34 | ```javascript 35 | const {Errors} = require('video-quality-tools'); 36 | ``` 37 | 38 | `Errors` object contains a set of error classes that may be thrown. 39 | 40 | For example: `StreamInfo` constructor throws `Errors.ConfigError` for incorrect options. 41 | 42 | ```javascript 43 | const {StreamsInfo, Errors} = require('video-quality-tools'); 44 | 45 | try { 46 | const streamsInfo = new StreamsInfo(null, 'rtmp://host:port/appInstance/name'); 47 | } catch (err) { 48 | if (err instanceof Errors.ConfigError) { 49 | console.error('Invalid options:', err); 50 | } 51 | } 52 | ``` 53 | 54 | ## Exit Reasons 55 | 56 | `StreamsInfo` and `FramesMonitor` use `ffprobe` to fetch the data. The underlying ffprobe process may be killed 57 | by someone, it may fail due to `spawn` issues, it may exit normally or with error code or it may be killed by the module 58 | itself if frames have invalid format. 59 | 60 | To distinguish exit reasons you may import `ExitReasons` object. 61 | 62 | ```javascript 63 | const {ExitReasons} = require('video-quality-tools'); 64 | ``` 65 | 66 | There are such available classes: 67 | 68 | * `ExitReasons.StartError` 69 | * `ExitReasons.ExternalSignal` 70 | * `ExitReasons.NormalExit` 71 | * `ExitReasons.AbnormalExit` 72 | * `ExitReasons.ProcessingError` 73 | 74 | Description of a specific reason class may be found in further chapters. 75 | 76 | # One-time Live Stream Info 77 | 78 | To fetch one-time info you need to create `StreamsInfo` class instance 79 | 80 | ```javascript 81 | const {StreamsInfo} = require('video-quality-tools'); 82 | 83 | const streamsInfoOptions = { 84 | ffprobePath: '/usr/local/bin/ffprobe', 85 | timeoutInMs: 2000 86 | }; 87 | const streamsInfo = new StreamsInfo(streamsInfoOptions, 'rtmp://host:port/appInstance/name'); 88 | ``` 89 | 90 | Constructor throws: 91 | * `Errors.ConfigError` if options have invalid type or value; 92 | * `Errors.ExecutablePathError` if `options.ffprobePath` is not found or it's not an executable. 93 | 94 | After that you may run `fetch` method to retrieve video and audio info. Method can be called as many times as you want. 95 | 96 | ```javascript 97 | // async-await style 98 | const streamInfo = await streamsInfo.fetch(); 99 | 100 | // or using old-school promise style 101 | 102 | streamsInfo.fetch() 103 | .then(info => { 104 | console.log('Video info:'); 105 | console.log(info.videos); 106 | console.log('Audio info:'); 107 | console.log(info.audios); 108 | }) 109 | .catch(err => console.error(err)); 110 | ``` 111 | 112 | Method may throw the `Errors.StreamsInfoError` if it can't receive stream, stream is invalid, `ffprobe` exits 113 | with error or returns unexpected output. 114 | 115 | The `videos` and `audios` fields of the returned `info` object are arrays. Usually there is only one 116 | video or audio stream for RTMP streams. Each element of the `videos` or `audios` array has almost the same 117 | structure as the `ffprobe -show_streams` output has. You may find a typical output of `fetch` command below. 118 | 119 | `videos` and `audios` may be an empty array if there are no appropriate streams in the live stream. 120 | 121 | ``` 122 | { videos: 123 | [ { index: 1, 124 | codec_name: 'h264', 125 | codec_long_name: 'H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10', 126 | profile: 'Main', 127 | codec_type: 'video', 128 | codec_time_base: '33/2000', 129 | codec_tag_string: '[0][0][0][0]', 130 | codec_tag: '0x0000', 131 | width: 854, 132 | height: 480, 133 | coded_width: 854, 134 | coded_height: 480, 135 | has_b_frames: 0, 136 | sample_aspect_ratio: '1280:1281', 137 | display_aspect_ratio: '16:9', 138 | pix_fmt: 'yuv420p', 139 | level: 31, 140 | chroma_location: 'left', 141 | field_order: 'progressive', 142 | refs: 1, 143 | is_avc: 'true', 144 | nal_length_size: '4', 145 | r_frame_rate: '30/1', 146 | avg_frame_rate: '1000/33', 147 | time_base: '1/1000', 148 | start_pts: 2062046, 149 | start_time: '2062.046000', 150 | bits_per_raw_sample: '8', 151 | disposition: [Object] } ], 152 | audios: 153 | [ { index: 0, 154 | codec_name: 'aac', 155 | codec_long_name: 'AAC (Advanced Audio Coding)', 156 | profile: 'LC', 157 | codec_type: 'audio', 158 | codec_time_base: '1/44100', 159 | codec_tag_string: '[0][0][0][0]', 160 | codec_tag: '0x0000', 161 | sample_fmt: 'fltp', 162 | sample_rate: '44100', 163 | channels: 2, 164 | channel_layout: 'stereo', 165 | bits_per_sample: 0, 166 | r_frame_rate: '0/0', 167 | avg_frame_rate: '0/0', 168 | time_base: '1/1000', 169 | start_pts: 2061964, 170 | start_time: '2061.964000', 171 | disposition: [Object] } ] } 172 | ``` 173 | 174 | # Live Frames Monitor 175 | 176 | To measure live stream info you need to create instance of `FramesMonitor` class 177 | 178 | ```javascript 179 | const {FramesMonitor} = require('video-quality-tools'); 180 | 181 | const framesMonitorOptions = { 182 | ffprobePath: '/usr/local/bin/ffprobe', 183 | timeoutInMs: 2000, 184 | bufferMaxLengthInBytes: 100000, 185 | errorLevel: 'error', 186 | exitProcessGuardTimeoutInMs: 1000, 187 | analyzeDurationInMs: 9000 188 | }; 189 | 190 | const framesMonitor = new FramesMonitor(framesMonitorOptions, 'rtmp://host:port/appInstance/name'); 191 | ``` 192 | 193 | Constructor throws: 194 | * `Errors.ConfigError` or `TypeError` if options have invalid type or value; 195 | * `Errors.ExecutablePathError` if `options.ffprobePath` is not found or it's not an executable. 196 | 197 | ## Frames Monitor Config 198 | 199 | The first argument of `FramesMonitor` must be an `options` object. All `options` object's fields are mandatory: 200 | 201 | * `ffprobePath` - string, path to ffprobe executable; 202 | * `timeoutInMs` - integer, greater than 0, specifies the maximum time to wait for (network) read/write operations 203 | to complete; 204 | * `bufferMaxLengthInBytes` - integer, greater than 0, specifies the buffer length for ffprobe frames. This setting 205 | prevents from hanging and receiving incorrect data from the stream, usually 1-2 KB is enough; 206 | * `errorLevel` - specifies log level for debugging purposes, must be equal to ffprobe's 207 | [`-loglevel` option](https://www.ffmpeg.org/ffprobe.html#toc-Generic-options). May be one of the following 208 | values: `trace`, `debug`, `verbose`, `info`, `warning`, `error`, `fatal`, `panic`, `quiet`. For most cases 209 | `error` level is enough; 210 | * `exitProcessGuardTimeoutInMs` - integer, greater than 0, specifies the amount of time after which the monitoring 211 | process will be hard killed if the attempt of soft stop fails. When you try to stop a monitor with `stopListen()` 212 | method the `FramesMonitor` sends `SIGTERM` signal to ffprobe process. ffprobe may ignore this signal (some versions 213 | do it pretty often). If ffprobe doesn't exit after `exitProcessGuardTimeoutInMs` milliseconds, `FramesMonitor` sends 214 | `SIGKILL` signal and forces underlying ffprobe process to exit. 215 | * analyzeDurationInMs - integer, greater than 0, specifies the maximum analyzing time of the input. 216 | 217 | ## Listening of Frames 218 | 219 | After creation of the `FramesMonitor` instance, you may start listening live stream data. To do so, just 220 | run `framesMonitor.listen()` method. After that `framesMonitor` starts emitting `frame` event as soon as ffprobe 221 | decodes frame from the stream. It emits video and audio frames. 222 | 223 | ```javascript 224 | const {FramesMonitor, processFrames, ExitReasons} = require('video-quality-tools'); 225 | 226 | const framesMonitor = new FramesMonitor(options, 'rtmp://host:port/appInstance/name'); 227 | 228 | framesMonitor.on('frame', frameInfo => { 229 | console.log(frameInfo); 230 | }); 231 | 232 | framesMonitor.listen(); 233 | ``` 234 | 235 | `listen()` method doesn't return anything but may throw `Errors.AlreadyListeningError`. 236 | 237 | ## Stop Listening of Frames 238 | 239 | To stop listening, call `framesMonitor.stopListen()` method. It returns promise. Rejection of that promise means 240 | that the underlying ffprobe process can't be killed with a signal. Method tries to send `SIGTERM` signal first 241 | and wait for `exitProcessGuardTimeoutInMs` milliseconds (ref. Frames Monitor Config section). If the 242 | process doesn't exit after that timeout, method sends `SIGKILL` and forces it to exit. Resolved promise means that 243 | the process was successfully killed. 244 | 245 | ```javascript 246 | try { 247 | const {code, signal} = await framesMonitor.stopListen(); 248 | console.log(`Monitor was stopped successfully, code=${code}, signal=${signal}`); 249 | } catch (err) { 250 | // instance of Errors.ProcessExitError 251 | console.log(`Error listening url "${err.payload.url}": ${err.payload.error.message}`); 252 | } 253 | ``` 254 | 255 | ## `frame` event 256 | 257 | This event is generated on each video and audio frame decoded by ffprobe. 258 | The structure of the frame object is the following: 259 | 260 | ``` 261 | { media_type: 'video', 262 | key_frame: 0, 263 | pkt_pts_time: 3530.279, 264 | pkt_size: 3332, 265 | width: 640, 266 | height: 480, 267 | pict_type: 'P' } 268 | ``` 269 | or 270 | ``` 271 | { media_type: 'audio', 272 | key_frame: 1, 273 | pkt_pts_time: 'N/A', 274 | pkt_size: 20 } 275 | ``` 276 | 277 | ## `exit` event 278 | 279 | Underlying process may not start at all, it may fail after some time or it may be killed with signal. In such situations 280 | `FramesMonitor` class instance emits `exit` event and passes one of the `ExitReasons` instances. Each instance has its 281 | own reason-specific `payload` field. There is a list of reasons: 282 | 283 | * `ExitReasons.StartError` - ffprobe can't be spawned, the error object is stored in `payload.error` field; 284 | * `ExitReasons.NormalExit` - ffprobe has exited with code = 0, `payload.code` is provided; 285 | * `ExitReasons.AbnormalExit` - ffprobe has exited with non-zero exit code, `payload.code` contains the exit code of 286 | the ffprobe process and `payload.stderrOutput` contains the last 5 lines from ffprobe's stderr output; 287 | * `ExitReasons.ProcessingError` - monitor has detected a logical issue and forces ffprobe to exit, this exit reason 288 | contains error object in `payload.error` field that may be either `Errors.ProcessStreamError` or `Errors.InvalidFrameError`; 289 | * `ExitReasons.ExternalSignal` - ffprobe process was killed by someone or by another process with the signal, the 290 | signal name can be found in `payload.signal` field; 291 | 292 | ```javascript 293 | framesMonitor.on('exit', reason => { 294 | switch(reason.constructor) { 295 | case ExitReasons.AbnormalExit: 296 | assert(reason.payload.code); 297 | assert(reason.payload.stderrOutput); // stderrOutput may be empty 298 | break; 299 | case ExitReasons.NormalExit: 300 | assert(reason.payload.code); 301 | break; 302 | case ExitReasons.ExternalSignal: 303 | assert(reason.payload.signal); 304 | break; 305 | case ExitReasons.StartError: 306 | assert.instanceOf(reason.payload.error, Error); 307 | break; 308 | case ExitReasons.ProcessingError: 309 | assert.instanceOf(reason.payload.error, Error); 310 | break; 311 | } 312 | }); 313 | ``` 314 | 315 | ## `error` event 316 | 317 | May be emitted only once and only in case the `framesMonitor.stopListen()` method receives `error` event on 318 | killing of an underlying ffprobe process. 319 | 320 | ```javascript 321 | framesMonitor.on('error', err => { 322 | // indicates error during the kill process 323 | // when ProcessingError occurs we may encounter that can not kill process 324 | // in this case this error event would be emitted 325 | 326 | assert.instanceOf(err, Error); 327 | }); 328 | ``` 329 | 330 | # Video Quality Info 331 | 332 | `video-quality-tools` ships with functions that help determining live stream info based on the set of frames 333 | collected from `FramesMonitor`: 334 | - `processFrames.networkStats` 335 | - `processFrames.encoderStats` 336 | 337 | 338 | ## `processFrames.networkStats(frames, durationInMsec)` 339 | 340 | Receives an array of `frames` collected for a given time interval `durationInMsec`. 341 | 342 | This method doesn't analyze GOP structure and isn't dependant on fullness of GOP between runs. Method shows only 343 | frame rate of audio and video streams received, bitrate of audio and video. Instead of `processFrames.networkStats` 344 | this method allows to control quality of network link between sender and receiver (like RTMP server). 345 | 346 | > Remember that this module must be located not far away from receiver server (that is under analysis). If link 347 | between receiver and module affects delivery of RTMP packages this module indicates incorrect values. It's better 348 | to run this module near the receiver. 349 | 350 | ```javascript 351 | const {processFrames} = require('video-quality-tools'); 352 | 353 | const INTERVAL_TO_ANALYZE_FRAMES = 5000; // in milliseconds 354 | 355 | let frames = []; 356 | 357 | framesMonitor.on('frame', frame => { 358 | frames.push(frame); 359 | }); 360 | 361 | setInterval(() => { 362 | try { 363 | const info = processFrames.networkStats(frames, INTERVAL_TO_ANALYZE_FRAMES); 364 | 365 | console.log(info); 366 | 367 | frames = []; 368 | } catch(err) { 369 | // only if arguments are invalid 370 | console.log(err); 371 | process.exit(1); 372 | } 373 | }, INTERVAL_TO_ANALYZE_FRAMES); 374 | ``` 375 | 376 | There is an output for the example above: 377 | 378 | ``` 379 | { 380 | videoFrameRate: 29, 381 | audioFrameRate: 50, 382 | videoBitrate: 1403.5421875, 383 | audioBitrate: 39.846875 384 | } 385 | ``` 386 | 387 | Check [examples/networkStats.js](examples/networkStats.js) to see an example code. 388 | 389 | 390 | ## `processFrames.encoderStats(frames)` 391 | 392 | It relies on [GOP structure](https://en.wikipedia.org/wiki/Group_of_pictures) of the stream. 393 | 394 | The following example shows how to gather frames and pass them to the function that analyzes encoder statistic. 395 | 396 | ```javascript 397 | const {processFrames} = require('video-quality-tools'); 398 | 399 | const AMOUNT_OF_FRAMES_TO_GATHER = 300; 400 | 401 | let frames = []; 402 | 403 | framesMonitor.on('frame', frame => { 404 | frames.push(frame); 405 | 406 | if (AMOUNT_OF_FRAMES_TO_GATHER > frames.length) { 407 | return; 408 | } 409 | 410 | try { 411 | const info = processFrames.encoderStats(frames); 412 | frames = info.remainedFrames; 413 | 414 | console.log(info.payload); 415 | } catch(err) { 416 | // processing error 417 | console.log(err); 418 | process.exit(1); 419 | } 420 | }); 421 | ``` 422 | 423 | There is an output for the example above: 424 | 425 | ``` 426 | { areAllGopsIdentical: true, 427 | bitrate: 428 | { mean: 1494.9075520833333, 429 | min: 1440.27734375, 430 | max: 1525.95703125 }, 431 | fps: { 432 | mean: 30, 433 | min: 30, 434 | max: 30 }, 435 | gopDuration: { 436 | mean: 2, 437 | min: 1.9, 438 | max: 2.1 }, 439 | displayAspectRatio: '16:9', 440 | width: 1280, 441 | height: 720, 442 | hasAudioStream: true 443 | } 444 | ``` 445 | 446 | In given example the frames are collected in `frames` array and than use `processFrames.encoderStats` function for 447 | sets of 300 frames (`AMOUNT_OF_FRAMES_TO_GATHER`). The function searches the 448 | [key frames](https://en.wikipedia.org/wiki/Video_compression_picture_types#Intra-coded_(I)_frames/slices_(key_frames)) 449 | and measures the distance between them. 450 | 451 | It's impossible to detect GOP structure for a set of frames with only one key frame, so `processFrames.encoderStats` 452 | returns back all passed frames as an array in `remainedFrames` field. 453 | 454 | If there are more than 2 key frames, `processFrames.encoderStats` uses full GOPs to track fps and bitrate and returns 455 | all frames back in the last GOP that was not finished. It's important to remember the `remainedFrames` output 456 | and push a new frame to the `remainedFrames` array when it arrives. 457 | 458 | For the full GOPs `processFrames.encoderStats` calculates min/max/mean values of bitrates (in kbit/s), framerates 459 | and GOP duration (in seconds) and returns them in `payload` field. The result of the check for the similarity 460 | of GOP structures for the collected GOPs is returned in `areAllGopsIdentical` field. Fields `width`, `height` 461 | and `displayAspectRatio` are taken from data from first frame of the first collected GOP. Value of `hasAudioStream` 462 | reflects presence of audio frames. 463 | 464 | To calculate display aspect ratio method `processFrames::calculateDisplayAspectRatio` uses list of 465 | [video aspect ratio standards](https://en.wikipedia.org/wiki/Aspect_ratio_(image)) 466 | with approximation of frames width and height ratio. If ratio can't be found in list of known standards, even in delta 467 | neighbourhood, then 468 | [GCD algorithm](https://en.wikipedia.org/wiki/Greatest_common_divisor) is used to simplify returned value. 469 | 470 | `processFrames.encoderStats` may throw `Errors.GopNotFoundError`. 471 | 472 | Also, you may extend the metrics. Check `src/processFrames.js` to find common functions. 473 | --------------------------------------------------------------------------------