Skip to content

Commit

Permalink
Merge pull request #17 from LCMApps/network-stats-function
Browse files Browse the repository at this point in the history
Network stats function
  • Loading branch information
WoZ committed Oct 4, 2018
2 parents 7b7ae2a + abd57d3 commit f637555
Show file tree
Hide file tree
Showing 7 changed files with 346 additions and 17 deletions.
64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
58 changes: 58 additions & 0 deletions examples/realtimeStats.js
Original file line number Diff line number Diff line change
@@ -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();
51 changes: 40 additions & 11 deletions src/processFrames.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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: []
Expand Down Expand Up @@ -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);
});
Expand All @@ -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)}`,
Expand Down Expand Up @@ -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');
}
Expand All @@ -317,6 +345,7 @@ function findGcd(a, b) {

module.exports = {
encoderStats,
networkStats,
identifyGops,
calculateBitrate,
calculateFps,
Expand All @@ -325,7 +354,7 @@ module.exports = {
hasAudioFrames,
gopDurationInSec,
toKbs,
accumulatePktSize,
calculatePktSize,
areAllGopsIdentical,
findGcd,
calculateDisplayAspectRatio
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/processFrames/encoderStats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
}
);
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/processFrames/gopPktSize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
Expand Down
112 changes: 112 additions & 0 deletions tests/Unit/processFrames/networkStats.data.js
Original file line number Diff line number Diff line change
@@ -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
};

Loading

0 comments on commit f637555

Please sign in to comment.