Cctv recording and playback (work in progress)

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.

6 Likes

Kevin,
So nice to hear from you again! I feared that you had vaporized in our community :wink:

Since I have very few free time lately, I have asked a collegue of mine (who is using your nodes at home) if he can test this.

Hopefully some other community members can find some time to test your new developments :pray:

Thanks for your hard work!!!!!!!!!
Bart

2 Likes

Kevin,

I've following settings in NR:

httpStatic: '/mntlarge/',
httpStaticRoot: '/mntlarge/',

The static location is een mounted SSD drive . When running your flow I see the recordings in

image

but I don't see any output on ui-mp4frag

My settings in the recording are

the msg.payload is correct
payload: "/mntlarge/Camera/Ftp/test/58b77ee9a2e29092/2022/11/14/11h.11m.07s.m3u8"

The staticRoot is a new setting in NR, I presume. I've used the httpStatic for quite a while, cause all my recordings of my cams are stored in subfolders under the mntlarge dir.

EDIT: even when i change the msg.payload to the correct full path location, nothing plays in the ui - when I trigger it directly in the brower path, the file is correctly downloaded thus NR can access it

Maybe I don't have hls.js support? How can this be checked? Any idea?

Many thanks

I can give a better answer later, as I am about to run out the door in a couple minutes.

For comparison, I have static path set as /run/media/system/surveillance1/cctv/recordings and static root set as /static/recordings, which makes my playlist available at http://xxxxxxxxxxxx:1880/static/recordings/front_corner_main/2022/11/14/05h.30m.00s.m3u8.

I think maybe your static path setting might need to be changed.

Can you place a debug node after the recording node to see what the playlist is in the output?

Also, have you updated the existing nodes? I fixed something that helped the hls.js lib load if you change the standard paths of node-red.

I updated the 3 nodes en restarted Nr

Have already changed the recording nodes multiple time to match to path

the output of the recording is
https://XXXX:XX/Camera/Ftp/test/58b77ee9a2e29092/2022/11/14/13h.53m.53s.m3u8

Path is correct because when entering it directly in the browser the m3u8 is downloaded

What does my node ui_mp4frag takes as input? Does it use the httpStatic settings or does it just use the path output of the msg.payload of the recording node?

PS It works now!!! Now going to test it :slight_smile:

1 Like

It should use the url playlist output from the recording node, but that will only work if the path and root are correct. The STATIC_PATH is to tell it where to save the files, and the STATIC_ROOT is to tell it where you are serving the files for http. The recording node builds the playlist url based on your settings.

Perhaps some confusion is because I might be using a different version of node-red than you. The settings seemed new to me from my old version to the current. There is a little description in the settings.js file:

All static routes will be appended to httpStaticRoot
e.g. if httpStatic = "/home/nol/docs" and  httpStaticRoot = "/static/"
     then "/home/nol/docs" will be served at "/static/"
e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}]
     and httpStaticRoot = "/static/"
     then "/home/nol/pics/" will be served at "/static/img/"

I hope to hear good news and maybe we can figure out what you did to fix things.

1 Like

When starting the recording only the first frame is showed on the dashboard but the clip is not playing the rest of the clip.

In the network google console I see the output coming
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-PLAYLIST-TYPE:EVENT
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="11h.28m.49s.mp4",BYTERANGE="1298@0"
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:36683@1298
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:53243@37981
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:66925@91224
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:70516@158149
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:71510@228665
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69806@300175
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:81588@369981
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:61233@451569
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:66393@512802
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69045@579195
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:70404@648240
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69785@718644
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:80054@788429
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:60290@868483
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:65677@928773
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:68476@994450
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:70253@1062926
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:70398@1133179
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:80010@1203577
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:64172@1283587
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:67849@1347759
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69276@1415608
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:71042@1484884
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69426@1555926
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:78355@1625352
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:69512@1703707
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:72166@1773219
11h.28m.49s.mp4
#EXTINF:0.3333333333333333
#EXT-X-BYTERANGE:74212@1845385
11h.28m.49s.mp4

What could be the cause the player isn't playing the clip but only shows the first frame?

When killing the ffmpeg spawn, the recording stays 'playing' the clip. Should it not also kill the recording?

The only error I see in the console is
Cannot GET /ui/loading.html

When reloading the page I see a couple of frames playing and then the clip stops playing

Are you using another version of de the NR dashboard than 3.2.0?

Is there any way you can share the complete flow with me? Of course, hide any private info. Also, are you taking advantage of the SECRET option in the ffmpeg node, which allows you to hide your ip camera source path which may have passwords in the url? That secret will not be shared when exporting the flow.


When in your browser, are you able to load the generate listing.txt file?

Currently, i do not have the recording node not output a blank playlist to clear the old, since we are not live streaming. I figured that since you are viewing a recording, there would be no need to make it empty. Also, when you do stop ffmpeg, there should be a final line written to the playlist

#EXTINF:1
#EXT-X-BYTERANGE:554736@28446428
05h.14m.03s.mp4
#EXT-X-ENDLIST

That gives the clue to hls.js or native hls in safari that the event is no longer live and can be treated as a vod, or something like that.

Screen Shot 2022-11-15 at 5.30.47 AM

Just had a thought as I was commuting. I remember trying to do this on my old node red instance and every time I tried to load the files in the browser, it would be empty. It seemed like the static hosting did not like my files or I had it configured wrong. I had abandoned that and started my work on the new pi and it just simply worked. I wonder if there was any file type restriction in the static hosting of the older versions of nodered?

Kevin,

With the arguments you showed in your reply the clip is working now for a part. So my setup could't clearly work with the ffmpeg parameters of the flows provided.

I copied your arguments, except the stimeout because then I couldn't start the ffmeg spawn, it's showing error [fatal] Error splitting the argument list: Option not found)

Problem is we're really depending how ffmpeg was installed. Will try to update ffmpeg so the stimeout is a valid parameter

The ffmpeg spawn is closed after a big minute with following ffmpeg parameters
image

"-loglevel",
"+level+fatal",
"-nostats",
"-rtsp_transport",
"tcp",
"-i",
"SECRET",
"-c:a",
"copy",
"-c:v",
"copy",
"-f",
"mp4",
"-movflags",
"+frag_keyframe+empty_moov+default_base_moof",
"-metadata",
"title=zij",
"pipe:1",
"-progress",
"pipe:3"

The listing shows indeed all the recordings made.

In case you need to troubleshoot the front end hls.js trying to play the video, you can add a little to the hls.js options section on the ui-mp4frag node's settings.

{
    "debug": true,
    "startPosition": 0,
    "liveDurationInfinity": false,
    "progressive": false
}

debug true will cause hls.js to post much info to the browsers console window that looks like:

startPosition 0 is not a required setting. If there is a live hls playlist, then this tells hls.js where to start playing. If you leave this out, then the video will play near live position. If 0, then it will play from the oldest video position.

1 Like

Hi @kevinGodell,

Finally I had enough technical background about this, via our mp4frag cheat sheet. Which allowed me to have recording running in a real short amount of time. You are my hero for today :star_struck:

Of course I only have a basic recording setup, which need to become more advanced in the near future. But at least I now have a starting point.

Thanks for all your hard labour!!!

2 Likes

Nice, it works fine! Both recording and viewing works smooth but I do not think I understood the necessary settings

Can we elaborate a bit about the httpStaticRoot and httpStatic?

In my settings.js I have since long only:

    httpStatic: '/var/www/html/pics/',

To this I have mounted an USB stick to avoid heavy writing of images and other files to my SD card. Now I added a subfolder 'recordings' to the /var/www/html/pics folder. I then configured the "recording" subflow to this:

When I start the streaming, recording begins and I find those files in a structure of subfolders below the 'recordings' subfolder.

The following url is sent to the ui_mp4frag node:

"/recordings/58b77ee9a2e29092/2023/02/24/14h.57m.30s.m3u8"

Viewing also starts and I can use the controls, all is working fine

But I cannot see previous recordings, there is no video file selection feature, should that be possible? Is there something wrong with my http settings in the settings.js that prevents that?

Best regards, Walter

I think at some point, node-red allowed more finer control of the static hosting. It didn't work for me on my old v1 node-red, but just fine on v3. From what I understand, the path is referring to the file path on disk, while the root is how the url shows up when hosting and serving it via http. This can be set to whatever you need to make it work on your system, as you have.

In my settings.js, i have the following settings (it could be more than 1 in the array):

httpStatic: [
  { path: '/run/media/system/surveillance1/cctv/recordings/', root: '/recordings/' }
],

httpStaticRoot: '/static/',

This has my files being served via http at mydomain/static/recordings/thevideo. The root allows you to change the http url path so that it does not have to be the same as the file path, maybe.

I matched that setting in my function node to use those paths set in the settings.js

That is not something built-in anywhere. I was starting to tinker with a template node to play back the recordings, but never got very far. I was waiting for somebody else to make it :grin:.

I will share my small effort and maybe you can expand on it. It makes use of the listing.txt file almost as a master playlist. The template fetches the list and loads the urls to the video sources. I think you can take this and make it better. Or you could make some flow that reads the listing file and then send an individual m3u8 playlist to ui_mp4rag or any other mp4/hlsjs nodes. Or maybe fetch the listing and use it to populate some html select element playlist picker and that will send the playlist to the ui node. I am not really sure how it should be. Nobody was using this, so there was no feedback that I could act upon. Also, there is always that lack of free time and now I am elbow deep in nginx trying to create the ultimate performance configuration for the cctv stuff.

[{"id":"a73b2f112ad47063","type":"http in","z":"e9f5eca971f24054","name":"","url":"/hlsjs/recordings/:i/:y/:m/:d","method":"get","upload":false,"swaggerDoc":"","x":230,"y":600,"wires":[["2944f4b7ec1f333c"]]},{"id":"2944f4b7ec1f333c","type":"template","z":"e9f5eca971f24054","name":"video","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!doctype html>\n<html>\n    <head>\n        <title>{{req.params.i}} {{req.params.y}} {{req.params.m}} {{req.params.d}}</title>\n        <style>\n            html {\n                height: 100%;\n                overflow: hidden;\n            }\n            body {\n                height: 100%;\n                margin: 0;\n            }\n            video {\n                width: 100%;\n                height: 100%;\n                object-fit: fill;\n            }\n        </style>\n        <style>\n    html {\n      height: 100vh;\n      overflow: hidden;\n    }\n    body {\n      height: 100vh;\n      margin: 0;\n    }\n    video {\n      width: 100vw;\n      height: 100vh;\n      object-fit: fill;\n      z-index: 0;\n    }\n\n    button#prev {\n      opacity: 0.4;\n      font-size: xxx-large;\n      position: absolute;\n      top: 50vh;\n      left: 1vw;\n      z-index: 1;\n    }\n\n    button#next {\n      opacity: 0.4;\n      font-size: xxx-large;\n      position: absolute;\n      top: 50vh;\n      right: 1vw;\n      z-index: 1;\n    }\n\n  </style>\n  <style>\n    a {\n      text-decoration: none;\n      display: inline-block;\n      padding: 8px 16px;\n    }\n\n    a:hover {\n      background-color: #ddd;\n      color: black;\n    }\n\n    .previous {\n      opacity: 0.3;\n      background-color: #f1f1f1;\n      color: black;\n      position: absolute;\n      top: 50vh;\n      left: 1vw;\n      z-index: 1;\n      font-size: x-large;\n    }\n\n    .next {\n      opacity: 0.3;\n      background-color: #f1f1f1;\n      color: black;\n      position: absolute;\n      top: 50vh;\n      right: 1vw;\n      z-index: 1;\n      font-size: x-large;\n    }\n\n    .round {\n      border-radius: 50%;\n    }\n  </style>\n        <script src=\"//cdn.jsdelivr.net/npm/hls.js@latest\"></script>\n    </head>\n    <body>\n<div id=\"container\">\n<video id=\"hls\" controls muted playsinline></video>\n<a href=\"#\" id=\"prev\" class=\"previous round\">&#8249;</a>\n<a href=\"#\" id=\"next\" class=\"next round\">&#8250;</a>\n</div>\n<script>\nconst dir = '/static/recordings/{{req.params.i}}/{{req.params.y}}/{{req.params.m}}/{{req.params.d}}';\nfetch(`${dir}/listing.txt`)\n  .then(res => {\n    return res.text()\n  }).then(text => {\n  console.log(text);\n  const arr = text.split('\\n').filter(n => n);\n  console.log(arr);\n  const length = arr.length;\n  const minIndex = 0;\n  const maxIndex = length - 1;\n  let currentIndex = maxIndex;\n  const video = document.getElementById('hls');\n  const prev = document.getElementById('prev');\n  const next = document.getElementById('next');\n  const videoSrc = arr[currentIndex];\n  const hlsOptions = { startPosition: 0, liveDurationInfinity: false, progressive: false, maxMaxBufferLength: 16 * 60, maxBufferSize: 300 * 1000 * 1000, backBufferLength: 16 * 60 };  \n  let hls;\n    \n  const loadHls = () => {\n    if (Hls.isSupported()) {\n      hls = new Hls(hlsOptions);\n      hls.attachMedia(video);\n      hls.loadSource(`${dir}/${arr[currentIndex]}`);\n      hls.on(Hls.Events.MANIFEST_PARSED, async (event, data) => {\n        console.log('manifest loaded, found ' + data.levels.length + ' quality level');\n        try {\n          await video.play();\n          video.playbackRate = 5.0;\n        } catch(e) {\n          console.log(e);\n          // noop\n        }\n      });\n    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n      video.src = `${dir}/${arr[currentIndex]}`;\n      video.play();\n    }\n  }\n\n  next.addEventListener('click', () => {\n    ++currentIndex;\n    if (currentIndex > maxIndex) {\n      currentIndex = maxIndex;\n      return;\n    }\n    hls && hls.destroy();\n    loadHls();\n  });\n\n  prev.addEventListener('click', () => {\n    currentIndex--;\n    if (currentIndex < minIndex) {\n      currentIndex = minIndex;\n      return;\n    }\n    hls && hls.destroy();\n    loadHls();\n  });\n\n  loadHls();\n});\n</script>\n</body>\n</html>","output":"str","x":470,"y":600,"wires":[["78503259e2433081"]]},{"id":"78503259e2433081","type":"http response","z":"e9f5eca971f24054","name":"","statusCode":"","headers":{},"x":670,"y":600,"wires":[]}]

This allows me to view the recordings for a single camera for a single day by going to the url mywebsite/hlsjs/recordings/front_driveway_main/2023/02/24

Be warned, most of the values are hard coded and it will not work without modification.

1 Like

Great, thanks, understood!

When you set this setting, httpStaticRoot: '/static/', did you also physically create that folder 'static' in the root or is it just, well, something virtual?

Interesting with the listing.txt, I will for sure dive into, cool
Best regards, Walter

1 Like

Not ment to be perfect,,,,but technology is so far working really great!!!

2 Likes

Playing a bit further, I found that the logic in NR would be simpler with just one common file listing the recordings. So just experimented a bit, maintaining my own recordings.txt with all recordings with the complete path. SO for every new recording, it's appended to the file and a watch node that updates the dropdown. Still not the target to make it winning a beauty contest, just verifying the logic

3 Likes

I think that is only for the url. I do not have a static folder in the file system. It just makes it easier to differentiate the http path, which could help when using a reverse proxy and you can tweak the caching, amongst other things.

Yes, many ways it can be done. Originally, I had wanted to make a select element that I could pick the camera, then the day, then the individual recording. Never got around to doing it. Playing back old recordings never became a priority for me since I rarely ever have to look back. Not too much happening near my house.

p.s. When playing back recordings, you can try changing the playback speed so it can play in fast motion to quickly get through the recordings if they are long. video.playbackRate = 5.0; That would have to be manually programmed in the ui template video controls, but should work. It would also be nice if you could have it automatically load the next recording after the current recording finishes. You could probably add some event listener to detect when the video has ended. The possibilities are many. It could be complicated, but I know you can do it.

2 Likes

Great stuff, great ideas & concepts, really like it! The quality of the recordings and live viewing is really exceptional. Using the slider, video moves so smooth and direct, no delays what I can see. Now, I only have recorded the red bull video so I have no idea if regular ip cameras provides the same fine quality but I assume they do

You have provided such a good toolbox, I would say everything is now there to create wonderful ui's for video management of ip cameras (is just a pitty my current house is stuffed with usb devices, need to change to another house somehow)

Great work, thanks!!
Best regards, Walter

2 Likes

Just tried to check if I could add some extra to the hls.js config in the ui_mp4frag but it seems there are no such event available in hls.js api what I could find. Catching an event in the "video controls" ui template will not work (I think) since there is no msg sent from the ui_mp4frag node when the playlist is either stopped, started or finished.

Does the ui_mp4frag node know when the playlist is playing - paused - finished? If so, could the node not send messages when that happen?

1 Like