UNPKG

nvr-js

Version:

A simple, lightweight, but very functional NVR aimed at 24/7 recording using nodejs

558 lines (531 loc) 19 kB
'use strict'; const { Transform } = require('stream'); const _FTYP = Buffer.from([0x66, 0x74, 0x79, 0x70]); // ftyp const _MOOV = Buffer.from([0x6d, 0x6f, 0x6f, 0x76]); // moov const _MOOF = Buffer.from([0x6d, 0x6f, 0x6f, 0x66]); // moof const _MFRA = Buffer.from([0x6d, 0x66, 0x72, 0x61]); // mfra const _MDAT = Buffer.from([0x6d, 0x64, 0x61, 0x74]); // mdat const _MP4A = Buffer.from([0x6d, 0x70, 0x34, 0x61]); // mp4a const _AVCC = Buffer.from([0x61, 0x76, 0x63, 0x43]); // avcC /** * @fileOverview Creates a stream transform for piping a fmp4 (fragmented mp4) from ffmpeg. * Can be used to generate a fmp4 m3u8 HLS playlist and compatible file fragments. * Can also be used for storing past segments of the mp4 video in a buffer for later access. * Must use the following ffmpeg flags <b><i>-movflags +frag_keyframe+empty_moov</i></b> to generate a fmp4 * with a compatible file structure : ftyp+moov -> moof+mdat -> moof+mdat -> moof+mdat ... * @requires stream.Transform */ class Mp4Frag extends Transform { /** * @constructor * @param {Object} [options] - Configuration options. * @param {String} [options.hlsBase] - Base name of files in fmp4 m3u8 playlist. Affects the generated m3u8 playlist by naming file fragments. Must be set to generate m3u8 playlist. * @param {Number} [options.hlsListSize] - Number of segments to keep in fmp4 m3u8 playlist. Must be an integer ranging from 2 to 10. Defaults to 4 if hlsBase is set and hlsListSize is not set. * @param {Boolean} [options.hlsListInit] - Indicates that m3u8 playlist should be generated after init segment is created and before media segments are created. Defaults to false. * @param {Number} [options.bufferListSize] - Number of segments to keep buffered. Must be an integer ranging from 2 to 10. Not related to HLS settings. * @returns {Mp4Frag} this - Returns reference to new instance of Mp4Frag for chaining event listeners. */ constructor(options) { super(options); if (options) { if (typeof options.hlsBase === 'string' && /^[a-z0-9]+$/i.exec(options.hlsBase)) { const hlsListSize = parseInt(options.hlsListSize); this._hlsListInit = options.hlsListInit === true; if (isNaN(hlsListSize)) { this._hlsListSize = 4; } else if (hlsListSize < 2) { this._hlsListSize = 2; } else if (hlsListSize > 10) { this._hlsListSize = 10; } else { this._hlsListSize = hlsListSize; } this._hlsList = []; this._hlsBase = options.hlsBase; this._sequence = -1; } if (options.hasOwnProperty('bufferListSize')) { const bufferListSize = parseInt(options.bufferListSize); if (isNaN(bufferListSize) || bufferListSize < 2) { this._bufferListSize = 2; } else if (bufferListSize > 10) { this._bufferListSize = 10; } else { this._bufferListSize = bufferListSize; } this._bufferList = []; } } this._parseChunk = this._findFtyp; return this; } /** * @readonly * @property {String} mime * - Returns the mime codec information as a String. * <br/> * - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}. * @returns {String} */ get mime() { return this._mime || null; } /** * @readonly * @property {Buffer} initialization * - Returns the mp4 initialization fragment as a Buffer. * <br/> * - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}. * @returns {Buffer} */ get initialization() { return this._initialization || null; } /** * @readonly * @property {Buffer} segment * - Returns the latest Mp4 segment as a Buffer. * <br/> * - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Buffer} */ get segment() { return this._segment || null; } /** * @readonly * @property {Number} timestamp * - Returns the timestamp of the latest Mp4 segment as an Integer(milliseconds). * <br/> * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Number} */ get timestamp() { return this._timestamp || -1; } /** * @readonly * @property {Number} duration * - Returns the duration of latest Mp4 segment as a Float(seconds). * <br/> * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Number} */ get duration() { return this._duration || -1; } /** * @readonly * @property {String} m3u8 * - Returns the fmp4 HLS m3u8 playlist as a String. * <br/> * - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}. * @returns {String} */ get m3u8() { return this._m3u8 || null; } /** * @readonly * @property {Number} sequence * - Returns the latest sequence of the fmp4 HLS m3u8 playlist as an Integer. * <br/> * - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Number} */ get sequence() { return Number.isInteger(this._sequence) ? this._sequence : -1; } /** * @readonly * @property {Array} bufferList * - Returns the buffered mp4 segments as an Array. * <br/> * - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Array} */ get bufferList() { if (this._bufferList && this._bufferList.length > 0) { return this._bufferList; } return null; } /** * @readonly * @property {Buffer} bufferListConcat * - Returns the [Mp4Frag.bufferList]{@link Mp4Frag#bufferList} concatenated as a Buffer. * <br/> * - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Buffer} */ get bufferListConcat() { if (this._bufferList && this._bufferList.length > 0) { return Buffer.concat(this._bufferList); } return null; } /** * @readonly * @property {Buffer} bufferConcat * - Returns the [Mp4Frag.initialization]{@link Mp4Frag#initialization} and [Mp4Frag.bufferList]{@link Mp4Frag#bufferList} concatenated as a Buffer. * <br/> * - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}. * @returns {Buffer} */ get bufferConcat() { if (this._initialization && this._bufferList && this._bufferList.length > 0) { return Buffer.concat([this._initialization, ...this._bufferList]); } return null; } /** * @param {Number|String} sequence * - Returns the Mp4 segment that corresponds to the HLS numbered sequence as a Buffer. * <br/> * - Returns <b>Null</b> if there is no .m4s segment that corresponds to sequence number. * @returns {Buffer} */ getHlsSegment(sequence) { return this.getHlsNamedSegment(`${this._hlsBase}${sequence}.m4s`); } /** * @param {String} name * - Returns the Mp4 segment that corresponds to the HLS named sequence as a Buffer. * <br/> * - Returns <b>Null</b> if there is no .m4s segment that corresponds to sequence name. * @returns {Buffer} */ getHlsNamedSegment(name) { if (name && this._hlsList && this._hlsList.length > 0) { for (let i = 0; i < this._hlsList.length; i++) { if (this._hlsList[i].name === name) { return this._hlsList[i].segment; } } } return null; } /** * Search buffer for ftyp. * @private */ _findFtyp(chunk) { const chunkLength = chunk.length; if (chunkLength < 8 || chunk.indexOf(_FTYP) !== 4) { this.emit('error', new Error(`${_FTYP.toString()} not found.`)); return; } this._ftypLength = chunk.readUInt32BE(0, true); if (this._ftypLength < chunkLength) { this._ftyp = chunk.slice(0, this._ftypLength); this._parseChunk = this._findMoov; this._parseChunk(chunk.slice(this._ftypLength)); } else if (this._ftypLength === chunkLength) { this._ftyp = chunk; this._parseChunk = this._findMoov; } else { //should not be possible to get here because ftyp is approximately 24 bytes //will have to buffer this chunk and wait for rest of it on next pass this.emit('error', new Error(`ftypLength:${this._ftypLength} > chunkLength:${chunkLength}`)); //return; } } /** * Search buffer for moov. * @private */ _findMoov(chunk) { const chunkLength = chunk.length; if (chunkLength < 8 || chunk.indexOf(_MOOV) !== 4) { this.emit('error', new Error(`${_MOOV.toString()} not found.`)); return; } const moovLength = chunk.readUInt32BE(0, true); if (moovLength < chunkLength) { this._parseMoov(Buffer.concat([this._ftyp, chunk], this._ftypLength + moovLength)); delete this._ftyp; delete this._ftypLength; this._parseChunk = this._findMoof; this._parseChunk(chunk.slice(moovLength)); } else if (moovLength === chunkLength) { this._parseMoov(Buffer.concat([this._ftyp, chunk], this._ftypLength + moovLength)); delete this._ftyp; delete this._ftypLength; this._parseChunk = this._findMoof; } else { //probably should not arrive here here because moov is typically < 800 bytes //will have to store chunk until size is big enough to have entire moov piece //ffmpeg may have crashed before it could output moov and got us here this.emit('error', new Error(`moovLength:${moovLength} > chunkLength:${chunkLength}`)); //return; } } /** * Parse moov for mime. * @fires Mp4Frag#initialized * @private */ _parseMoov(value) { this._initialization = value; let audioString = ''; if (this._initialization.indexOf(_MP4A) !== -1) { audioString = ', mp4a.40.2'; } let index = this._initialization.indexOf(_AVCC); if (index === -1) { this.emit('error', new Error(`${_AVCC.toString()} codec info not found.`)); return; } index += 5; this._mime = `video/mp4; codecs="avc1.${this._initialization .slice(index, index + 3) .toString('hex') .toUpperCase()}${audioString}"`; this._timestamp = Date.now(); if (this._hlsList && this._hlsListInit) { let m3u8 = '#EXTM3U\n'; m3u8 += '#EXT-X-VERSION:7\n'; //m3u8 += '#EXT-X-ALLOW-CACHE:NO\n'; m3u8 += `#EXT-X-TARGETDURATION:1\n`; m3u8 += `#EXT-X-MEDIA-SEQUENCE:0\n`; m3u8 += `#EXT-X-MAP:URI="init-${this._hlsBase}.mp4"\n`; this._m3u8 = m3u8; } /** * Fires when the init fragment of the Mp4 is parsed from the piped data. * @event Mp4Frag#initialized * @type {Event} * @property {Object} Object * @property {String} Object.mime - [Mp4Frag.mime]{@link Mp4Frag#mime} * @property {Buffer} Object.initialization - [Mp4Frag.initialization]{@link Mp4Frag#initialization} * @property {String} Object.m3u8 - [Mp4Frag.m3u8]{@link Mp4Frag#m3u8} */ this.emit('initialized', { mime: this._mime, initialization: this._initialization, m3u8: this._m3u8 || null }); } /** * Find moof after miss due to corrupt data in pipe. * @private */ _moofHunt(chunk) { if (this._moofHunts < this._moofHuntsLimit) { this._moofHunts++; //console.warn(`MOOF hunt attempt number ${this._moofHunts}.`); const index = chunk.indexOf(_MOOF); if (index > 3 && chunk.length > index + 3) { delete this._moofHunts; delete this._moofHuntsLimit; this._parseChunk = this._findMoof; this._parseChunk(chunk.slice(index - 4)); } } else { this.emit('error', new Error(`${_MOOF.toString()} hunt failed after ${this._moofHunts} attempts.`)); //return; } } /** * Search buffer for moof. * @private */ _findMoof(chunk) { if (this._moofBuffer) { this._moofBuffer.push(chunk); const chunkLength = chunk.length; this._moofBufferSize += chunkLength; if (this._moofLength === this._moofBufferSize) { //todo verify this works this._moof = Buffer.concat(this._moofBuffer, this._moofLength); delete this._moofBuffer; delete this._moofBufferSize; this._parseChunk = this._findMdat; } else if (this._moofLength < this._moofBufferSize) { this._moof = Buffer.concat(this._moofBuffer, this._moofLength); const sliceIndex = chunkLength - (this._moofBufferSize - this._moofLength); delete this._moofBuffer; delete this._moofBufferSize; this._parseChunk = this._findMdat; this._parseChunk(chunk.slice(sliceIndex)); } } else { const chunkLength = chunk.length; if (chunkLength < 8 || chunk.indexOf(_MOOF) !== 4) { //ffmpeg occasionally pipes corrupt data, lets try to get back to normal if we can find next MOOF box before attempts run out const mfraIndex = chunk.indexOf(_MFRA); if (mfraIndex !== -1) { //console.log(`MFRA was found at ${mfraIndex}. This is expected at the end of stream.`); return; } //console.warn('Failed to find MOOF. Starting MOOF hunt. Ignore this if your file stream input has ended.'); this._moofHunts = 0; this._moofHuntsLimit = 40; this._parseChunk = this._moofHunt; this._parseChunk(chunk); return; } this._moofLength = chunk.readUInt32BE(0, true); if (this._moofLength === 0) { this.emit('error', new Error(`Bad data from input stream reports ${_MOOF.toString()} length of 0.`)); return; } if (this._moofLength < chunkLength) { this._moof = chunk.slice(0, this._moofLength); this._parseChunk = this._findMdat; this._parseChunk(chunk.slice(this._moofLength)); } else if (this._moofLength === chunkLength) { //todo verify this works this._moof = chunk; this._parseChunk = this._findMdat; } else { this._moofBuffer = [chunk]; this._moofBufferSize = chunkLength; } } } /** * Process current segment. * @fires Mp4Frag#segment * @param chunk {Buffer} * @private */ _setSegment(chunk) { this._segment = chunk; const currentTime = Date.now(); this._duration = Math.max((currentTime - this._timestamp) / 1000, 1); this._timestamp = currentTime; if (this._hlsList) { this._hlsList.push({ sequence: ++this._sequence, name: `${this._hlsBase}${this._sequence}.m4s`, segment: this._segment, duration: this._duration }); while (this._hlsList.length > this._hlsListSize) { this._hlsList.shift(); } let m3u8 = '#EXTM3U\n'; m3u8 += '#EXT-X-VERSION:7\n'; //m3u8 += '#EXT-X-ALLOW-CACHE:NO\n'; m3u8 += `#EXT-X-TARGETDURATION:${Math.round(this._duration)}\n`; m3u8 += `#EXT-X-MEDIA-SEQUENCE:${this._hlsList[0].sequence}\n`; m3u8 += `#EXT-X-MAP:URI="init-${this._hlsBase}.mp4"\n`; for (let i = 0; i < this._hlsList.length; i++) { m3u8 += `#EXTINF:${this._hlsList[i].duration.toFixed(6)},\n`; m3u8 += `${this._hlsList[i].name}\n`; } this._m3u8 = m3u8; } if (this._bufferList) { this._bufferList.push(this._segment); while (this._bufferList.length > this._bufferListSize) { this._bufferList.shift(); } } if (this._readableState.pipesCount > 0) { this.push(this._segment); } /** * Fires when the latest Mp4 segment is parsed from the piped data. * @event Mp4Frag#segment * @type {Event} * @property {Buffer} segment - [Mp4Frag.segment]{@link Mp4Frag#segment} */ this.emit('segment', this._segment); } /** * Search buffer for mdat. * @private */ _findMdat(chunk) { if (this._mdatBuffer) { this._mdatBuffer.push(chunk); const chunkLength = chunk.length; this._mdatBufferSize += chunkLength; if (this._mdatLength === this._mdatBufferSize) { this._setSegment(Buffer.concat([this._moof, ...this._mdatBuffer], this._moofLength + this._mdatLength)); delete this._moof; delete this._mdatBuffer; delete this._mdatBufferSize; delete this._mdatLength; delete this._moofLength; this._parseChunk = this._findMoof; } else if (this._mdatLength < this._mdatBufferSize) { this._setSegment(Buffer.concat([this._moof, ...this._mdatBuffer], this._moofLength + this._mdatLength)); const sliceIndex = chunkLength - (this._mdatBufferSize - this._mdatLength); delete this._moof; delete this._mdatBuffer; delete this._mdatBufferSize; delete this._mdatLength; delete this._moofLength; this._parseChunk = this._findMoof; this._parseChunk(chunk.slice(sliceIndex)); } } else { const chunkLength = chunk.length; if (chunkLength < 8 || chunk.indexOf(_MDAT) !== 4) { this.emit('error', new Error(`${_MDAT.toString()} not found.`)); return; } this._mdatLength = chunk.readUInt32BE(0, true); if (this._mdatLength > chunkLength) { this._mdatBuffer = [chunk]; this._mdatBufferSize = chunkLength; } else if (this._mdatLength === chunkLength) { this._setSegment(Buffer.concat([this._moof, chunk], this._moofLength + chunkLength)); delete this._moof; delete this._moofLength; delete this._mdatLength; this._parseChunk = this._findMoof; } else { this._setSegment(Buffer.concat([this._moof, chunk], this._moofLength + this._mdatLength)); const sliceIndex = this._mdatLength; delete this._moof; delete this._moofLength; delete this._mdatLength; this._parseChunk = this._findMoof; this._parseChunk(chunk.slice(sliceIndex)); } } } /** * Required for stream transform. * @private */ _transform(chunk, encoding, callback) { this._parseChunk(chunk); callback(); } /** * Run cleanup when unpiped. * @private */ _flush(callback) { this.resetCache(); callback(); } /** * Clear cached values */ resetCache() { this._parseChunk = this._findFtyp; delete this._mime; delete this._initialization; delete this._segment; delete this._timestamp; delete this._duration; delete this._moof; delete this._mdatBuffer; delete this._moofLength; delete this._mdatLength; delete this._mdatBufferSize; delete this._ftyp; delete this._ftypLength; delete this._m3u8; if (this._hlsList) { this._hlsList = []; this._sequence = -1; } if (this._bufferList) { this._bufferList = []; } } } module.exports = Mp4Frag;