I have been experimenting with a different way of recording video from my rtsp ip cams lately. The new technique I have settled on is using a single file byte-range HLS playlist. When the video is recorded and written to disc, there will be a single mp4 file along with it's companion HLS playlist.
So far, video playback has been working great using hls.js on modern browsers and native HLS on mac/ios safari browsers. The benefits, as I see them, is that the recording can be played in the browser while being written to disc and allows for easy scrubbing along the video timeline due to the nature of the byte-range requests.
I was previously just recording as mp4 files using FFmpeg's segment muxer, but found that there was no good way to play the video before the segment was complete. Unfortunately, I could not find a way to combine FFmpeg's segment muxer and the byte-range HLS muxer to do what I wanted, so I had to involve the function node to programmably write the HLS playlist.
For my particular setup with a raspberry pi 4, I am using an externally powered usb connected case holding a 6TB WD Purple meant for 24/7 video recording. I have configured node-red's settings.js file to include:
httpStaticRoot: '/static/',
httpStatic: [
{ path: '/run/media/system/surveillance1/cctv/recordings/', root: '/recordings/' }
],
The recordings are placed into the folder /run/media/system/surveillance1/cctv/recordings/
and served via http at /static/recordings/
.
The files created are automatically named based on the current timestamp every 15 minutes and placed into folders created for the year, month, day.
The HLS playlist will look a bit like this:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-PLAYLIST-TYPE:EVENT
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="02h.15m.00s.mp4",BYTERANGE="1242@0"
#EXTINF:1.0002555555555555
#EXT-X-BYTERANGE:707851@1242
02h.15m.00s.mp4
#EXTINF:0.9962444444444445
#EXT-X-BYTERANGE:679209@709093
02h.15m.00s.mp4
flow for a 24/7 video recording setup: (Do not use this without configuring the recording node)
[{"id":"f3874cf4a963908d","type":"subflow","name":"recording","info":"","category":"","in":[{"x":80,"y":160,"wires":[{"id":"d0615cb16ccf58fb"}]}],"out":[{"x":320,"y":120,"wires":[{"id":"d0615cb16ccf58fb","port":0}]}],"env":[{"name":"STATIC_PATH","type":"str","value":"/run/media/system/surveillance1/cctv/recordings"},{"name":"STATIC_ROOT","type":"str","value":"/static/recordings"}],"meta":{},"color":"#DDAA99","status":{"x":320,"y":200,"wires":[{"id":"d0615cb16ccf58fb","port":1}]}},{"id":"d0615cb16ccf58fb","type":"function","z":"f3874cf4a963908d","name":"recording","func":"const { payload, payload: { length }, duration, topic } = msg;\n\nconst [ , id, , type ] = topic.split('/');\n\nconst recording = context.get('recording');\n\nif (type === 'seg' || type === 'pre') {\n\n const { mp4WriteStream = {}, m3u8WriteStream = {}, mp4Name, bytesWritten, segments, playlist } = recording;\n\n if (mp4WriteStream.writable && m3u8WriteStream.writable) {\n\n // write live (seg) or previous (pre) segments to existing writeStreams\n\n mp4WriteStream.write(payload);\n\n m3u8WriteStream.write(`#EXTINF:${duration}\\n#EXT-X-BYTERANGE:${length}@${bytesWritten}\\n${mp4Name}\\n`);\n\n recording.bytesWritten += length;\n\n recording.segments++;\n\n // only send playlist after the first segment is written so that hls.js does not throw Hls.Events.ERROR, Hls.ErrorTypes.NETWORK_ERROR, Hls.ErrorDetails.LEVEL_EMPTY_ERROR\n\n if (segments === 0) {\n\n return { payload: playlist };\n\n }\n\n } else {\n\n node.error(`mp4WriteStream and m3u8WriteStream must be writable`);\n\n }\n\n return;\n\n}\n\nif (type === 'init') {\n\n // create new or end existing write streams based on buffer length\n\n if (length) {\n\n const date = new Date();\n\n const year = date.getFullYear();\n\n const month = (date.getMonth() + 1).toString().padStart(2, '0');\n\n const day = date.getDate().toString().padStart(2, '0');\n\n const hours = date.getHours().toString().padStart(2, '0');\n\n const minutes = date.getMinutes().toString().padStart(2, '0');\n\n const seconds = date.getSeconds().toString().padStart(2, '0');\n\n // create name based on timestamp\n\n const baseName = `${hours}h.${minutes}m.${seconds}s`; // 00h.00m.00s\n\n const mp4Name = `${baseName}.mp4`; // 00h.00m.00s.mp4\n\n const m3u8Name = `${baseName}.m3u8`; // 00h.00m.00s.m3u8\n\n const baseRoot = `${env.get('STATIC_ROOT')}/${id}`;\n\n const basePath = `${env.get('STATIC_PATH')}/${id}`;\n\n // create dir base on timestamp\n\n const fullDir = `${basePath}/${year}/${month}/${day}`; // basePath/yyyy/mm/dd\n\n const { mkdirSync, createWriteStream, writeFile } = fs;\n\n const newDir = mkdirSync(fullDir, { recursive: true });\n\n newDir && writeFile(`${basePath}/listing.txt`, `${year}/${month}/${day}\\n`, { encoding: 'utf8', flag: 'a' }, (err) => { err && node.error(err) });\n\n writeFile(`${fullDir}/listing.txt`, `${m3u8Name}\\n`, { encoding: 'utf8', flag: 'a' }, (err) => { err && node.error(err) });\n\n // create new write streams for mp4 and playlist\n \n const mp4WriteStream = createWriteStream(`${fullDir}/${mp4Name}`)\n .on('error', (err) => {\n node.error(err);\n });\n\n const m3u8WriteStream = createWriteStream(`${fullDir}/${m3u8Name}`)\n .on('error', (err) => {\n node.error(err);\n });\n\n mp4WriteStream.write(payload);\n\n m3u8WriteStream.write(`#EXTM3U\\n#EXT-X-VERSION:7\\n#EXT-X-PLAYLIST-TYPE:EVENT\\n#EXT-X-TARGETDURATION:${Math.round(duration) || 1}\\n#EXT-X-MEDIA-SEQUENCE:0\\n#EXT-X-MAP:URI=\"${mp4Name}\",BYTERANGE=\"${length}@0\"\\n`);\n\n recording.mp4WriteStream = mp4WriteStream;\n\n recording.m3u8WriteStream = m3u8WriteStream;\n\n recording.mp4Name = mp4Name;\n\n recording.bytesWritten = length;\n\n recording.segments = 0;\n\n // save playlist\n\n recording.playlist = `${baseRoot}/${year}/${month}/${day}/${m3u8Name}`;\n\n // send status\n\n return [ null, { payload: { fill: 'green', shape: \"dot\", text: `${id}/${year}/${month}/${day}/${baseName}` } }];\n\n } else {\n\n // end existing writeStreams\n\n const { mp4WriteStream = {}, m3u8WriteStream = {} } = recording;\n\n if (mp4WriteStream.writable) {\n\n mp4WriteStream.end();\n\n }\n\n if (m3u8WriteStream.writable) {\n\n m3u8WriteStream.end('#EXT-X-ENDLIST\\n');\n\n }\n\n return;\n\n }\n\n}","outputs":2,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is deployed.\n\ncontext.set('recording', {});","finalize":"// Code added here will be run when the\n// node is being stopped or re-deployed.\n\nconst { mp4WriteStream = {}, m3u8WriteStream = {} } = context.get('recording');\n\nif (mp4WriteStream.writable) {\n\n mp4WriteStream.end();\n\n}\n\nif (m3u8WriteStream.writable) {\n\n m3u8WriteStream.end('#EXT-X-ENDLIST\\n');\n\n}\n\ncontext.set('recording', undefined);","libs":[{"var":"fs","module":"fs"}],"x":200,"y":160,"wires":[[],[]]},{"id":"32d61e0f.0490b2","type":"subflow","name":"stderr","info":"","category":"","in":[{"x":60,"y":80,"wires":[{"id":"211d46bf.a79c7a"}]}],"out":[],"env":[],"color":"#DDAA99","status":{"x":280,"y":120,"wires":[{"id":"211d46bf.a79c7a","port":1}]}},{"id":"211d46bf.a79c7a","type":"function","z":"32d61e0f.0490b2","name":"stderr","func":"// convert payload for Buffer to String and split by newlines\n\nconst stderr = msg.payload.toString().split('\\n');\n\nnode.send([ { stderr }, { payload: { fill: 'red', text: `${new Date().toString()}` } } ]);","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[],"x":170,"y":80,"wires":[["c6e5408.f19c4c"],[]]},{"id":"c6e5408.f19c4c","type":"debug","z":"32d61e0f.0490b2","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"stderr","targetType":"msg","statusVal":"","statusType":"auto","x":330,"y":40,"wires":[]},{"id":"9b59c1a2222210b8","type":"inject","z":"0528fb314ac39109","name":"start","props":[{"p":"action","v":"{\"command\":\"start\"}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":"5","topic":"","x":130,"y":220,"wires":[["2281776c992410dd"]]},{"id":"2281776c992410dd","type":"ffmpeg-spawn","z":"0528fb314ac39109","name":"","outputs":3,"cmdPath":"","cmdArgs":"[\"-loglevel\",\"+level+fatal\",\"-nostats\",\"-re\",\"-f\",\"lavfi\",\"-i\",\"testsrc=size=qcif:rate=5[out0];anoisesrc=c=brown:r=44100:a=0.5[out1]\",\"-g\",\"5\",\"-c:a\",\"aac\",\"-c:v\",\"libx264\",\"-profile:v\",\"high\",\"-pix_fmt\",\"yuv420p\",\"-level\",\"5.0\",\"-f\",\"mp4\",\"-movflags\",\"+faststart+frag_keyframe+empty_moov+default_base_moof\",\"-metadata\",\"title=test source\",\"pipe:1\"]","cmdOutputs":2,"killSignal":"SIGTERM","x":300,"y":260,"wires":[["58b77ee9a2e29092"],["58b77ee9a2e29092"],["a8741b7c88d88102"]]},{"id":"b4a8c0bc44499078","type":"inject","z":"0528fb314ac39109","name":"restart","props":[{"p":"action","v":"{\"command\":\"restart\"}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":"1","topic":"","x":130,"y":260,"wires":[["2281776c992410dd"]]},{"id":"7dde9f15d2aa5933","type":"inject","z":"0528fb314ac39109","name":"stop","props":[{"p":"action","v":"{\"command\":\"stop\"}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":130,"y":300,"wires":[["2281776c992410dd"]]},{"id":"58b77ee9a2e29092","type":"mp4frag","z":"0528fb314ac39109","name":"","outputs":2,"hlsPlaylistSize":4,"hlsPlaylistExtra":0,"basePath":"id","repeated":"false","timeLimit":"-1","preBuffer":"0","autoStart":"true","statusLocation":"displayed","x":550,"y":220,"wires":[[],["571c8af49dfc40ae"]]},{"id":"a8741b7c88d88102","type":"subflow:32d61e0f.0490b2","z":"0528fb314ac39109","name":"","x":470,"y":300,"wires":[]},{"id":"ef6008f4d7425c22","type":"inject","z":"0528fb314ac39109","name":"write restart","props":[{"p":"action","v":"{\"command\":\"restart\",\"subject\":\"write\",\"preBuffer\":1,\"timeLimit\":-1,\"repeated\":false}","vt":"json"}],"repeat":"","crontab":"*/15 0-23 * * *","once":false,"onceDelay":"5","topic":"","x":310,"y":140,"wires":[["58b77ee9a2e29092"]]},{"id":"e23111864612f2c5","type":"inject","z":"0528fb314ac39109","name":"write stop","props":[{"p":"action","v":"{\"command\":\"stop\",\"subject\":\"write\"}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":320,"y":180,"wires":[["58b77ee9a2e29092"]]},{"id":"571c8af49dfc40ae","type":"subflow:f3874cf4a963908d","z":"0528fb314ac39109","name":"","x":780,"y":260,"wires":[["e2da3c3cee315665"]]},{"id":"e2da3c3cee315665","type":"ui_mp4frag","z":"0528fb314ac39109","name":"","group":"9a076144e0b8c687","order":1,"width":"20","height":"14","readyPoster":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAJYCAMAAACtqHJCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2hpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowMzgwMTE3NDA3MjA2ODExODIyQUIzREQ3RTA0MDAyNCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpGRkU5MkM4RDJCNTkxMUVCQjJCOTg0MEQ0QThCQ0YyQyIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpGRkU5MkM4QzJCNTkxMUVCQjJCOTg0MEQ0QThCQ0YyQyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2IChNYWNpbnRvc2gpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6Rjc3RjExNzQwNzIwNjgxMTgyMkFDQTkzQjdGQkY1MzAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDM4MDExNzQwNzIwNjgxMTgyMkFCM0REN0UwNDAwMjQiLz4gPC9yZGY6RGVzY3Jp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","errorPoster":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAJYCAMAAACtqHJCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2hpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowMzgwMTE3NDA3MjA2ODExODIyQUIzREQ3RTA0MDAyNCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpGRkU5MkM5MTJCNTkxMUVCQjJCOTg0MEQ0QThCQ0YyQyIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpGRkU5MkM5MDJCNTkxMUVCQjJCOTg0MEQ0QThCQ0YyQyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2IChNYWNpbnRvc2gpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6Rjc3RjExNzQwNzIwNjgxMTgyMkFDQTkzQjdGQkY1MzAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDM4MDExNzQwNzIwNjgxMTgyMkFCM0REN0UwNDAwMjQiLz4gPC9yZGY6RGVzY3Jp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","hlsJsConfig":"{\"startPosition\":0,\"liveDurationInfinity\":false,\"progressive\":false}","autoplay":"true","unload":"false","threshold":0.1,"controls":"true","muted":"true","players":["hls.js","hls","socket.io","mp4"],"x":950,"y":220,"wires":[["9ec4f3ad80a3ec74"]]},{"id":"9ec4f3ad80a3ec74","type":"ui_template","z":"0528fb314ac39109","group":"9a076144e0b8c687","name":"video controls","order":15,"width":"20","height":"1","format":"<div layout-fill layout=\"row\">\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"toggleControls()\">\n <md-icon class=\"material-icons\">smart_button</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"playPause()\">\n <md-icon class=\"material-icons\">play_arrow</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"forwardFive()\">\n <md-icon class=\"material-icons\">forward_5</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"replayFive()\">\n <md-icon class=\"material-icons\">replay_5</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"decreaseRate()\">\n <md-icon class=\"material-icons\">slow_motion_video</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"increaseRate()\">\n <md-icon class=\"material-icons\">speed</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"toggleMuted()\">\n <md-icon class=\"material-icons\">volume_mute</md-icon>\n </md-button>\n </div>\n\n <div layout-fill>\n <md-button layout-fill class=\"md-raised\" ng-click=\"videoFullscreen()\">\n <md-icon class=\"material-icons\">fullscreen</md-icon>\n </md-button>\n </div>\n\n</div>\n\n<script>\n ((scope) => {\n\n const unwatchMsg = scope.$watch('msg', (msg) => {\n\n if (msg && msg.videoId) {\n\n const video = document.getElementById(msg.videoId);\n \n if (video) {\n\n const rates = [ 0.1, 0.2, 0.5, 1, 2, 5, 10];\n\n let rateIndex = 3;\n\n const requestFullscreen = video.webkitRequestFullscreen || video.webkitEnterFullScreen || video.mozRequestFullScreen || video.requestFullscreen;\n\n scope.toggleControls = () => {\n\n video.controls = !video.controls;\n\n }\n\n scope.playPause = () => {\n\n video.paused ? video.play() : video.pause();\n\n }\n\n scope.toggleMuted = () => {\n\n video.muted = !video.muted;\n\n }\n\n scope.videoFullscreen = () => {\n\n requestFullscreen.call(video);\n\n }\n\n scope.videoDisplay = () => {\n\n video.style.display = video.style.display === 'none' ? 'initial' : 'none';\n\n }\n\n scope.increaseRate = () => {\n\n rateIndex++;\n\n if (rateIndex > 6) {\n\n rateIndex = 6;\n\n }\n\n video.playbackRate = rates[rateIndex];\n\n }\n\n scope.decreaseRate = () => {\n \n rateIndex--;\n \n if (rateIndex < 0) {\n\n rateIndex = 0;\n \n }\n \n video.playbackRate = rates[rateIndex];\n \n }\n\n scope.forwardFive = () => {\n \n video.currentTime += 5;\n \n }\n\n scope.replayFive = () => {\n \n video.currentTime -= 5;\n \n }\n\n unwatchMsg();\n\n }\n\n }\n\n });\n\n})(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":1140,"y":260,"wires":[[]]},{"id":"9a076144e0b8c687","type":"ui_group","name":"shared","tab":"e671ec4ca9656cab","order":1,"disp":true,"width":"20","collapse":false,"className":""},{"id":"e671ec4ca9656cab","type":"ui_tab","name":"shared","icon":"dashboard","disabled":false,"hidden":false}]
This requires 3 of my unpublished experimental nodes that are available from github by using the commands:
npm install kevinGodell/node-red-contrib-ffmpeg-spawn
npm install kevinGodell/node-red-contrib-mp4frag
npm install kevinGodell/node-red-contrib-ui-mp4frag
For those of you already using these nodes, you will have to update them because of some small changes I made recently to be compatible with this flow.
*note: FFmpeg is not included with these nodes.