diff --git a/README.md b/README.md index 5f2439c..5eb3f44 100644 --- a/README.md +++ b/README.md @@ -326,10 +326,68 @@ framesMonitor.on('error', err => { # Video Quality Info `video-quality-tools` ships with functions that help determining live stream info based on the set of frames -collected from `FramesMonitor`. It relies on -[GOP structure](https://en.wikipedia.org/wiki/Group_of_pictures) of the stream. +collected from `FramesMonitor`: +- `processFrames.networkStats` +- `processFrames.encoderStats` -The following example shows how to gather frames and pass them to the function that analyzes encoder statistic + +## `processFrames.networkStats(frames, durationInMsec)` + +Receives an array of `frames` collected for a given time interval `durationInMsec`. + +This method doesn't analyze GOP structure and isn't dependant on fullness of GOP between runs. Method shows only +frame rate of audio and video streams received, bitrate of audio and video. Instead of `processFrames.networkStats` +this method allows to control quality of network link between sender and receiver (like RTMP server). + +> Remember that this module must be located not far away from receiver server (that is under analysis). If link +between receiver and module affects delivery of RTMP packages this module indicates incorrect values. It's better +to run this module near the receiver. + +```javascript +const {processFrames} = require('video-quality-tools'); + +const INTERVAL_TO_ANALYZE_FRAMES = 5000; // in milliseconds + +let frames = []; + +framesMonitor.on('frame', frame => { + frames.push(frame); +}); + +setInterval(() => { + try { + const info = processFrames.networkStats(frames, INTERVAL_TO_ANALYZE_FRAMES); + + console.log(info); + + frames = []; + } catch(err) { + // only if arguments are invalid + console.log(err); + process.exit(1); + } +}, INTERVAL_TO_ANALYZE_FRAMES); +``` + +There is an output for the example above: + +``` +{ + videoFrameRate: 29, + audioFrameRate: 50, + videoBitrate: 1403.5421875, + audioBitrate: 39.846875 +} +``` + +Check [examples/networkStats.js](examples/networkStats.js) to see an example code. + + +## `processFrames.encoderStats(frames)` + +It relies on [GOP structure](https://en.wikipedia.org/wiki/Group_of_pictures) of the stream. + +The following example shows how to gather frames and pass them to the function that analyzes encoder statistic. ```javascript const {processFrames} = require('video-quality-tools'); diff --git a/examples/realtimeStats.js b/examples/realtimeStats.js new file mode 100644 index 0000000..834a07f --- /dev/null +++ b/examples/realtimeStats.js @@ -0,0 +1,58 @@ +const {FramesMonitor, processFrames} = require('../index'); +// or +// const {processFrames} = require('video-quality-tools'); +// if you use it outside this repo + +const INTERVAL_TO_ANALYZE_FRAMES = 5000; // in milliseconds +const STREAM_URI = 'rtmp://host:port/path'; + +const framesMonitorOptions = { + ffprobePath: '/usr/local/bin/ffprobe', + timeoutInSec: 5, + bufferMaxLengthInBytes: 100000, + errorLevel: 'error', + exitProcessGuardTimeoutInMs: 1000 +}; + +const framesMonitor = new FramesMonitor(framesMonitorOptions, STREAM_URI); + +let frames = []; + +function firstVideoFrameListener(frame) { + if (frame.media_type === 'video') { + framesMonitor.removeListener('frame', firstVideoFrameListener); + framesMonitor.on('frame', frameListener); + startAnalysis(); + } +} + +function frameListener(frame) { + frames.push(frame); +} + +function startAnalysis() { + setInterval(() => { + try { + const info = processFrames.networkStats(frames, INTERVAL_TO_ANALYZE_FRAMES); + + console.log(info); + + frames = []; + } catch (err) { + // only if arguments are invalid + console.log(err); + process.exit(1); + } + }, INTERVAL_TO_ANALYZE_FRAMES); +} + +// We listens first video frame to start processing. We do such thing to avoid incorrect stats for the first +// run of networkStats function after the first interval. +framesMonitor.on('frame', firstVideoFrameListener); + +framesMonitor.on('exit', reason => { + console.log('EXIT', reason); + process.exit(); +}); + +framesMonitor.listen(); diff --git a/src/processFrames.js b/src/processFrames.js index ac10ec3..51087a7 100644 --- a/src/processFrames.js +++ b/src/processFrames.js @@ -4,6 +4,8 @@ const _ = require('lodash'); const Errors = require('./Errors'); +const MSECS_IN_SEC = 1000; + const AR_CALCULATION_PRECISION = 0.01; const SQUARE_AR_COEFFICIENT = 1; @@ -23,7 +25,7 @@ const WIDESCREEN_AR = '21:9'; function encoderStats(frames) { if (!Array.isArray(frames)) { - throw new TypeError('process method is supposed to accept an array of frames.'); + throw new TypeError('Method accepts only an array of frames'); } const videoFrames = filterVideoFrames(frames); @@ -41,11 +43,11 @@ function encoderStats(frames) { const gopDurations = []; gops.forEach(gop => { - areAllGopsIdentical = areAllGopsIdentical && baseGopSize === gop.frames.length; - const accumulatedPktSize = accumulatePktSize(gop); - const gopDuration = gopDurationInSec(gop); + areAllGopsIdentical = areAllGopsIdentical && baseGopSize === gop.frames.length; + const calculatedPktSize = calculatePktSize(gop.frames); + const gopDuration = gopDurationInSec(gop); - const gopBitrate = toKbs(accumulatedPktSize / gopDuration); + const gopBitrate = toKbs(calculatedPktSize / gopDuration); bitrates.push(gopBitrate); const gopFps = gop.frames.length / gopDuration; @@ -91,6 +93,28 @@ function encoderStats(frames) { }; } +function networkStats(frames, durationInMsec) { + if (!Array.isArray(frames)) { + throw new TypeError('Method accepts only an array of frames'); + } + + if (!_.isInteger(durationInMsec) || durationInMsec <= 0) { + throw new TypeError('Method accepts only a positive integer as duration'); + } + + const videoFrames = filterVideoFrames(frames); + const audioFrames = filterAudioFrames(frames); + + const durationInSec = durationInMsec / MSECS_IN_SEC; + + return { + videoFrameRate: videoFrames.length / durationInSec, + audioFrameRate: audioFrames.length / durationInSec, + videoBitrate: toKbs(calculatePktSize(videoFrames) / durationInSec), + audioBitrate: toKbs(calculatePktSize(audioFrames) / durationInSec), + }; +} + function identifyGops(frames) { const GOP_TEMPLATE = { frames: [] @@ -145,10 +169,10 @@ function calculateBitrate(gops) { let bitrates = []; gops.forEach(gop => { - const accumulatedPktSize = accumulatePktSize(gop); - const durationInSec = gopDurationInSec(gop); + const calculatedPktSize = calculatePktSize(gop.frames); + const durationInSec = gopDurationInSec(gop); - const gopBitrate = toKbs(accumulatedPktSize / durationInSec); + const gopBitrate = toKbs(calculatedPktSize / durationInSec); bitrates.push(gopBitrate); }); @@ -160,8 +184,8 @@ function calculateBitrate(gops) { }; } -function accumulatePktSize(gop) { - const accumulatedPktSize = gop.frames.reduce((accumulator, frame) => { +function calculatePktSize(frames) { + const accumulatedPktSize = frames.reduce((accumulator, frame) => { if (!_.isNumber(frame.pkt_size)) { throw new Errors.FrameInvalidData( `frame's pkt_size field has invalid type ${Object.prototype.toString.call(frame.pkt_size)}`, @@ -295,6 +319,10 @@ function filterVideoFrames(frames) { return frames.filter(frame => frame.media_type === 'video'); } +function filterAudioFrames(frames) { + return frames.filter(frame => frame.media_type === 'audio'); +} + function hasAudioFrames(frames) { return frames.some(frame => frame.media_type === 'audio'); } @@ -317,6 +345,7 @@ function findGcd(a, b) { module.exports = { encoderStats, + networkStats, identifyGops, calculateBitrate, calculateFps, @@ -325,7 +354,7 @@ module.exports = { hasAudioFrames, gopDurationInSec, toKbs, - accumulatePktSize, + calculatePktSize, areAllGopsIdentical, findGcd, calculateDisplayAspectRatio diff --git a/tests/Unit/processFrames/encoderStats.test.js b/tests/Unit/processFrames/encoderStats.test.js index 5cb53ba..98b0405 100644 --- a/tests/Unit/processFrames/encoderStats.test.js +++ b/tests/Unit/processFrames/encoderStats.test.js @@ -31,7 +31,7 @@ describe('processFrames.encoderStats', () => { it('must throw an exception for invalid input {type} type', ctx => { assert.throws(() => { processFrames.encoderStats(ctx.item); - }, TypeError, 'process method is supposed to accept an array of frames.'); + }, TypeError, 'Method accepts only an array of frames'); }); } ); diff --git a/tests/Unit/processFrames/gopPktSize.test.js b/tests/Unit/processFrames/gopPktSize.test.js index 124d98d..f92c0ca 100644 --- a/tests/Unit/processFrames/gopPktSize.test.js +++ b/tests/Unit/processFrames/gopPktSize.test.js @@ -36,7 +36,7 @@ describe('processFrames.accumulatePktSize', () => { }; try { - processFrames.accumulatePktSize(invalidInput); + processFrames.calculatePktSize(invalidInput.frames); assert.isFalse(true, 'should not be here'); } catch (error) { assert.instanceOf(error, Errors.FrameInvalidData); @@ -58,7 +58,7 @@ describe('processFrames.accumulatePktSize', () => { const expectedRes = frames.reduce((sum, frame) => sum + frame.pkt_size, 0); - const res = processFrames.accumulatePktSize({frames}); + const res = processFrames.calculatePktSize(frames); assert.strictEqual(res, expectedRes); }); diff --git a/tests/Unit/processFrames/networkStats.data.js b/tests/Unit/processFrames/networkStats.data.js new file mode 100644 index 0000000..daaf10d --- /dev/null +++ b/tests/Unit/processFrames/networkStats.data.js @@ -0,0 +1,112 @@ +'use strict'; + +const invalidFramesTypes = [ + undefined, + null, + false, + 1, + '1', + {}, + Symbol(), + () => {}, + Buffer.alloc(0) +]; + +const invalidDurationInSecTypes = [ + undefined, + null, + false, + [], + [1, 2], + '1', + 1.2, + -1, + {}, + Symbol(), + () => {}, + Buffer.alloc(0) +]; + +const testData = [ + { + description: 'empty frames array', + frames : [], + durationInMsec: 1000, + expected : { + videoFrameRate: 0, + audioFrameRate: 0, + videoBitrate: 0, + audioBitrate: 0, + }, + }, + { + description : 'audio only frames with 1000 msec duration', + frames : [ + {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, + {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, + ], + durationInMsec: 1000, + expected : { + videoFrameRate: 0, + audioFrameRate: 2, + videoBitrate: 0, + audioBitrate: 0.0390625, + }, + }, + { + description : 'video only frames with 1000 msec duration', + frames : [ + {width: 854, height: 480, pkt_size: 2, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, + {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, + ], + durationInMsec: 1000, + expected : { + videoFrameRate: 2, + audioFrameRate: 0, + videoBitrate: 0.0390625, + audioBitrate: 0, + }, + }, + { + description : 'frames with 200 msec duration', + frames : [ + {width: 854, height: 480, pkt_size: 2, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, + {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, + {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, + {width: 854, height: 480, pkt_size: 3, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, + {pkt_size: 4, pkt_pts_time: 12, media_type: 'audio', key_frame: 1}, + ], + durationInMsec: 200, + expected : { + videoFrameRate: 10, + audioFrameRate: 15, + videoBitrate: 0.1953125, + audioBitrate: 0.3515625, + }, + }, + { + description : 'audio only frames with 2000 msec duration', + frames : [ + {width: 854, height: 480, pkt_size: 5, pkt_pts_time: 1, media_type: 'video', key_frame: 1, pict_type: 'P'}, + {pkt_size: 2, pkt_pts_time: 10, media_type: 'audio', key_frame: 1}, + {pkt_size: 3, pkt_pts_time: 11, media_type: 'audio', key_frame: 1}, + {width: 854, height: 480, pkt_size: 6, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'P'}, + {pkt_size: 4, pkt_pts_time: 12, media_type: 'audio', key_frame: 1}, + {width: 854, height: 480, pkt_size: 7, pkt_pts_time: 10, media_type: 'video', key_frame: 0, pict_type: 'I'}, + ], + durationInMsec: 2000, + expected : { + videoFrameRate: 1.5, + audioFrameRate: 1.5, + videoBitrate: 0.0703125, + audioBitrate: 0.03515625, + }, + }, +]; + +module.exports = { + invalidFramesTypes, + invalidDurationInSecTypes, + testData +}; + diff --git a/tests/Unit/processFrames/networkStats.test.js b/tests/Unit/processFrames/networkStats.test.js new file mode 100644 index 0000000..08d639a --- /dev/null +++ b/tests/Unit/processFrames/networkStats.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const _ = require('lodash'); +const {assert} = require('chai'); +const dataDriven = require('data-driven'); + +const processFrames = require('src/processFrames'); + +const {invalidFramesTypes, invalidDurationInSecTypes, testData} = require('./networkStats.data'); + +const PRECISION = 0.00001; + +function typeOf(item) { + return Object.prototype.toString.call(item); +} + +describe('processFrames.networkStats', () => { + + dataDriven( + invalidFramesTypes.map(item => ({type: typeOf(item), item: item})), + () => { + it('must throw an exception for invalid input {type} type for frames', ctx => { + assert.throws(() => { + processFrames.networkStats(ctx.item); + }, TypeError, 'Method accepts only an array of frames'); + }); + } + ); + + dataDriven( + invalidDurationInSecTypes.map(item => ({type: typeOf(item), item: item})), + () => { + it('must throw an exception for invalid input {type} type for durationInMsec', ctx => { + assert.throws(() => { + processFrames.networkStats([], ctx.item); + }, TypeError, 'Method accepts only a positive integer as duration'); + }); + } + ); + + dataDriven(testData, () => { + it('{description}', ctx => { + const expectedResult = ctx.expected; + + const result = processFrames.networkStats(ctx.frames, ctx.durationInMsec); + + assert.isTrue(_.inRange( + result.videoFrameRate, + expectedResult.videoFrameRate - PRECISION, + expectedResult.videoFrameRate + PRECISION, + )); + + assert.isTrue(_.inRange( + result.audioFrameRate, + expectedResult.audioFrameRate - PRECISION, + expectedResult.audioFrameRate + PRECISION, + )); + + assert.isTrue(_.inRange( + result.videoBitrate, + expectedResult.videoBitrate - PRECISION, + expectedResult.videoBitrate + PRECISION + )); + + assert.isTrue(_.inRange( + result.audioBitrate, + expectedResult.audioBitrate - PRECISION, + expectedResult.audioBitrate + PRECISION + )); + }); + }); +});