From 6e8d4bf098600c34d66af57017ce6b2c027ac7e4 Mon Sep 17 00:00:00 2001 From: Moe Date: Tue, 3 Sep 2024 18:37:57 -0700 Subject: [PATCH] auto generate a timelapse frame for completed videos --- libs/basic/utils.js | 8 ++ libs/childNode/utils.js | 27 +++-- libs/timelapse.js | 8 +- libs/video/utils.js | 230 ++++++++++++++++++++++++++++++++++++ libs/videos.js | 10 +- web/assets/js/bs5.videos.js | 1 + 6 files changed, 268 insertions(+), 16 deletions(-) diff --git a/libs/basic/utils.js b/libs/basic/utils.js index 7bcaf77d..85586399 100644 --- a/libs/basic/utils.js +++ b/libs/basic/utils.js @@ -218,6 +218,13 @@ module.exports = (processCwd,config) => { readStream.pipe(writeStream) }) } + async function moveFile(inputFilePath,outputFilePath) { + try{ + await fsP.rm(outputFilePath) + }catch(err){} + await copyFile(inputFilePath, outputFilePath) + await fsP.rm(inputFilePath) + } function hmsToSeconds(str) { var p = str.split(':'), s = 0, m = 1; @@ -274,5 +281,6 @@ module.exports = (processCwd,config) => { hmsToSeconds, setDefaultIfUndefined, deleteFilesInFolder, + moveFile, } } diff --git a/libs/childNode/utils.js b/libs/childNode/utils.js index 14c85044..642ae15d 100644 --- a/libs/childNode/utils.js +++ b/libs/childNode/utils.js @@ -1,5 +1,8 @@ const fs = require('fs'); module.exports = function(s,config,lang,app,io){ + const { + postProcessCompletedMp4Video, + } = require('../video/utils.js')(s,config,lang) const masterDoWorkToo = config.childNodes.masterDoWorkToo; const maxCpuPercent = config.childNodes.maxCpuPercent || 75; const maxRamPercent = config.childNodes.maxRamPercent || 75; @@ -177,17 +180,21 @@ module.exports = function(s,config,lang,app,io){ filename : filename, filesizeMB : parseFloat((data.filesize/1048576).toFixed(2)) } - s.insertDatabaseRow(monitorConfig,insert) - s.insertCompletedVideoExtensions.forEach(function(extender){ - extender(activeMonitor, monitorConfig, insert) + s.insertDatabaseRow(monitorConfig,insert,function(response){ + postProcessCompletedMp4Video(response.insertQuery).then((isGood) => { + if(!isGood)return console.error(`FAILED VIDEO INSERT`); + s.insertCompletedVideoExtensions.forEach(function(extender){ + extender(activeMonitor, monitorConfig, insert) + }) + //purge over max + s.purgeDiskForGroup(data.ke) + //send new diskUsage values + s.setDiskUsedForGroup(data.ke,insert.filesizeMB) + clearTimeout(activeMonitor.recordingChecker) + clearTimeout(activeMonitor.streamChecker) + resolve(response) + }) }) - //purge over max - s.purgeDiskForGroup(data.ke) - //send new diskUsage values - s.setDiskUsedForGroup(data.ke,insert.filesizeMB) - clearTimeout(activeMonitor.recordingChecker) - clearTimeout(activeMonitor.streamChecker) - resolve(response) }) }) } diff --git a/libs/timelapse.js b/libs/timelapse.js index 4a543e89..a7eb0ddb 100644 --- a/libs/timelapse.js +++ b/libs/timelapse.js @@ -34,6 +34,7 @@ module.exports = function(s,config,lang,app,io){ s.createTimelapseFrameAndInsert = function(e,location,filename,eventTime,frameDetails){ //e = monitor object //location = file location + var monitorId = e.id || e.mid; var filePath = location + filename var fileStats = fs.statSync(filePath) var details = Object.assign({},frameDetails || {}) @@ -43,7 +44,7 @@ module.exports = function(s,config,lang,app,io){ const timeNow = eventTime || new Date() const queryInfo = { ke: e.ke, - mid: e.id, + mid: monitorId, details: s.s(details), filename: filename, size: fileStats.size, @@ -53,7 +54,7 @@ module.exports = function(s,config,lang,app,io){ var currentDate = s.formattedTime(timeNow,'YYYY-MM-DD') const childNodeData = { ke: e.ke, - mid: e.id, + mid: monitorId, time: currentDate, filename: filename, currentDate: currentDate, @@ -559,8 +560,7 @@ module.exports = function(s,config,lang,app,io){ actionParameter && ( isRestrictedApiKey && apiKeyPermissions.delete_videos_disallowed || isRestricted && !monitorPermissions[`${monitorId}_video_delete`] - ) || - !actionParameter && ( + ) || !actionParameter && ( isRestrictedApiKey && apiKeyPermissions.watch_videos_disallowed || isRestricted && monitorId && !monitorPermissions[`${monitorId}_video_view`] ) diff --git a/libs/video/utils.js b/libs/video/utils.js index c750211c..a749f103 100644 --- a/libs/video/utils.js +++ b/libs/video/utils.js @@ -2,6 +2,7 @@ const fs = require('fs') const { spawn } = require('child_process') const async = require('async'); const path = require('path'); +const moment = require('moment'); const fsP = require('fs').promises; module.exports = (s,config,lang) => { const { @@ -11,7 +12,9 @@ module.exports = (s,config,lang) => { const { copyFile, hmsToSeconds, + moveFile, } = require('../basic/utils.js')(process.cwd(),config) + const chunkReadSize = 4096; // orphanedVideoCheck : new function const checkIfVideoIsOrphaned = (monitor,videosDirectory,filename) => { const response = {ok: true} @@ -750,6 +753,227 @@ module.exports = (s,config,lang) => { return null; } } + + async function readChunkForMoov(filePath, start, end) { + const stream = fs.createReadStream(filePath, { start, end }); + let hasMoov = false; + + for await (const chunk of stream) { + if (chunk.includes('moov')) { + hasMoov = true; + break; + } + } + + return hasMoov; + } + + async function checkMoovAtBeginning(filePath) { + return await readChunkForMoov(filePath, 0, chunkReadSize - 1); + } + + async function checkMoovAtEnd(filePath) { + const stats = await fs.promises.stat(filePath); + const fileSize = stats.size; + return await readChunkForMoov(filePath, fileSize - chunkReadSize, fileSize - 1); + } + + async function hasMoovAtom(filePath) { + const foundAtBeginning = await checkMoovAtBeginning(filePath); + + if (foundAtBeginning) { + return true; + } + + const foundAtEnd = await checkMoovAtEnd(filePath); + return foundAtEnd; + } + const addMoovAtom = async (inputFilePath, outputFilePath, videoCodec = 'libx264', audioCodec = 'aac') => { + try { + const ffmpegArgs = [ + '-i', inputFilePath, + '-c:v', videoCodec, + ]; + if(audioCodec){ + ffmpegArgs.push('-c:a', audioCodec, '-strict', '-2') + }else{ + ffmpegArgs.push('-an') + } + ffmpegArgs.push( + '-movflags', '+faststart', + '-crf', '0', + '-q:a', '0', + outputFilePath + ); + console.log(config.ffmpegDir + ' ' + ffmpegArgs.join(' ')) + return new Promise((resolve, reject) => { + const ffmpegProcess = spawn(config.ffmpegDir, ffmpegArgs); + + ffmpegProcess.stdout.on('data', (data) => { + console.log(`FFmpeg stdout: ${data}`); + }); + + ffmpegProcess.stderr.on('data', (data) => { + console.error(`FFmpeg stderr: ${data}`); + }); + + ffmpegProcess.on('close', (code) => { + if (code === 0) { + resolve(outputFilePath); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpegProcess.on('error', (err) => { + reject(err); + }); + }); + } catch (error) { + throw new Error(`Failed to re-encode file: ${error.message}`); + } + }; + async function getVideoFrameAsJpeg(filePath, seconds = 7){ + return new Promise((resolve, reject) => { + const ffmpegArgs = [ + '-loglevel', 'warning', + '-ss', seconds.toString(), + '-i', filePath, + '-frames:v', '1', + '-q:v', '2', + '-f', 'image2pipe', + '-vcodec', 'mjpeg', + 'pipe:1' + ]; + const ffmpegProcess = spawn(config.ffmpegDir, ffmpegArgs); + let buffer = Buffer.alloc(0); + ffmpegProcess.stdout.on('data', (data) => { + buffer = Buffer.concat([buffer, data]); + }); + + ffmpegProcess.stderr.on('data', (data) => { + s.debugLog(`getVideoFrameAsJpeg FFmpeg stderr: ${data}`); + }); + + ffmpegProcess.on('close', (code) => { + if (code === 0) { + resolve(buffer); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpegProcess.on('error', (err) => { + reject(err); + }); + }); + }; + function getVideoPath(video){ + const videoPath = path.join(s.getVideoDirectory(video), `${s.formattedTime(video.time)}.${video.ext}`); + return videoPath + } + async function saveVideoFrameToTimelapse(video, secondsIn = 7){ + // console.error(video) + const monitorConfig = s.group[video.ke].rawMonitorConfigurations[video.mid]; + const activeMonitor = s.group[video.ke].activeMonitors[video.mid]; + const frameTime = moment(video.time).add(secondsIn, 'seconds'); + const frameDate = s.formattedTime(frameTime,'YYYY-MM-DD'); + const timelapseRecordingDirectory = s.getTimelapseFrameDirectory(monitorConfig); + const videoPath = getVideoPath(video); + const frameFilename = s.formattedTime(frameTime) + '.jpg'; + const location = timelapseRecordingDirectory + frameDate + '/'; + const framePath = path.join(location, frameFilename); + try{ + await fsP.stat(framePath) + }catch(err){ + const frameBuffer = await getVideoFrameAsJpeg(videoPath, secondsIn); + await fsP.mkdir(location, { recursive: true }) + await fsP.writeFile(framePath, frameBuffer) + await s.createTimelapseFrameAndInsert(activeMonitor,location,frameFilename, frameTime._d) + } + // console.error('Completed Saving Frame from New Video!', framePath) + } + function getVideoCodecsFromMonitorConfig(video){ + const monitorConfig = s.group[video.ke].rawMonitorConfigurations[video.mid]; + const modeIsRecord = monitorConfig.mode === 'record' + let eventBasedVideoCodec = monitorConfig.details.detector_buffer_vcodec + let eventBasedAudioCodec = monitorConfig.details.detector_buffer_acodec + let recordingVideoCodec = monitorConfig.details.vcodec + let recordingAudioCodec = monitorConfig.details.acodec + switch(eventBasedVideoCodec){ + case null:case '':case undefined:case'auto': + eventBasedVideoCodec = 'libx264' + break; + } + switch(eventBasedAudioCodec){ + case null:case '':case undefined:case'auto': + eventBasedAudioCodec = 'aac' + break; + case'no': + eventBasedAudioCodec = null + break; + } + switch(recordingVideoCodec){ + case null:case '':case undefined:case'auto':case'default':case'copy': + recordingVideoCodec = 'libx264' + break; + } + switch(recordingAudioCodec){ + case null:case '':case undefined:case'auto':case'copy': + recordingAudioCodec = 'aac' + break; + case'no': + recordingAudioCodec = null + break; + } + return { + videoCodec: modeIsRecord ? recordingVideoCodec : eventBasedVideoCodec, + audioCodec: modeIsRecord ? recordingAudioCodec : eventBasedAudioCodec, + recordingVideoCodec, + recordingAudioCodec, + eventBasedVideoCodec, + eventBasedAudioCodec, + } + } + async function postProcessCompletedMp4Video(chosenVideo){ + try { + const video = Object.assign({ + ext: 'mp4' + },chosenVideo); + const videoPath = getVideoPath(video); + const moovExists = await hasMoovAtom(videoPath); + if (moovExists) { + s.debugLog('The file already has a moov atom.'); + } else { + return true; + // const { videoCodec, audioCodec } = getVideoCodecsFromMonitorConfig(video); + // const tempPath = path.join(s.getVideoDirectory(video), `TEMP_${s.formattedTime(video.time)}.${video.ext}`); + // await addMoovAtom(videoPath, tempPath, videoCodec, audioCodec); + // await moveFile(tempPath, videoPath) + // const newFileSize = (await fsP.stat(videoPath)).size; + // const updateResponse = await s.knexQueryPromise({ + // action: "update", + // table: "Videos", + // update: { + // size: newFileSize + // }, + // where: [ + // ['ke','=',video.ke], + // ['mid','=',video.mid], + // ['time','=',video.time], + // ['end','=',video.end], + // ['ext','=',video.ext], + // ] + // }); + } + // await saveVideoFrameToTimelapse(video, 0) + await saveVideoFrameToTimelapse(video, 7) + return true; + } catch (error) { + console.error('Error processing MP4 file:', error); + return false; + } + }; return { reEncodeVideoAndReplace, stitchMp4Files, @@ -763,5 +987,11 @@ module.exports = (s,config,lang) => { sliceVideo, mergeVideos, mergeVideosAndBin, + saveVideoFrameToTimelapse, + postProcessCompletedMp4Video, + readChunkForMoov, + checkMoovAtBeginning, + checkMoovAtEnd, + hasMoovAtom } } diff --git a/libs/videos.js b/libs/videos.js index af766161..c4b9f614 100644 --- a/libs/videos.js +++ b/libs/videos.js @@ -5,6 +5,9 @@ module.exports = function(s,config,lang){ const { sendVideoToMasterNode, } = require('./childNode/childUtils.js')(s,config,lang) + const { + postProcessCompletedMp4Video, + } = require('./video/utils.js')(s,config,lang) /** * Gets the video directory of the supplied video or monitor database row. * @constructor @@ -180,8 +183,11 @@ module.exports = function(s,config,lang){ }) s.insertDatabaseRow(e,k,(err,response) => { if(callback)callback(err,response); - s.insertCompletedVideoExtensions.forEach(function(extender){ - extender(e,k,response.insertQuery,response) + postProcessCompletedMp4Video(response.insertQuery).then((isGood) => { + if(!isGood)return console.error(`FAILED VIDEO INSERT`); + s.insertCompletedVideoExtensions.forEach(function(extender){ + extender(e,k,response.insertQuery,response) + }) }) }) } diff --git a/web/assets/js/bs5.videos.js b/web/assets/js/bs5.videos.js index 161de51e..046569e6 100644 --- a/web/assets/js/bs5.videos.js +++ b/web/assets/js/bs5.videos.js @@ -499,6 +499,7 @@ function mergeVideosAndBin(options,callback){ mid: video.mid, time: video.time, end: video.end, + ext: video.ext, saveDir: video.saveDir, details: video.details, };