auto generate a timelapse frame for completed videos

plugin-touch-ups
Moe 2024-09-03 18:37:57 -07:00
parent 0b02762ad8
commit 6e8d4bf098
6 changed files with 268 additions and 16 deletions

View File

@ -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,
}
}

View File

@ -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)
})
})
}

View File

@ -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`]
)

View File

@ -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
}
}

View File

@ -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)
})
})
})
}

View File

@ -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,
};