1048 lines
43 KiB
JavaScript
1048 lines
43 KiB
JavaScript
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 {
|
|
ffprobe,
|
|
splitForFFMPEG,
|
|
} = require('../ffmpeg/utils.js')(s,config,lang)
|
|
const {
|
|
copyFile,
|
|
hmsToSeconds,
|
|
moveFile,
|
|
} = require('../basic/utils.js')(process.cwd(),config)
|
|
const {
|
|
convertAviToMp4,
|
|
} = require('./aviUtils.js')(s,config,lang)
|
|
const chunkReadSize = 4096;
|
|
// orphanedVideoCheck : new function
|
|
const checkIfVideoIsOrphaned = (monitor,videosDirectory,filename) => {
|
|
const response = {ok: true}
|
|
return new Promise((resolve,reject) => {
|
|
fs.stat(videosDirectory + filename,(err,stats) => {
|
|
if(!err && stats.size > 10){
|
|
s.knexQuery({
|
|
action: "select",
|
|
columns: "*",
|
|
table: "Videos",
|
|
where: [
|
|
['ke','=',monitor.ke],
|
|
['mid','=',monitor.mid],
|
|
['time','=',s.nameToTime(filename)],
|
|
],
|
|
limit: 1
|
|
},(err,rows) => {
|
|
if(!err && (!rows || !rows[0])){
|
|
//row does not exist, create one for video
|
|
var video = rows[0]
|
|
s.insertCompletedVideo(monitor,{
|
|
file : filename
|
|
},() => {
|
|
response.status = 2
|
|
resolve(response)
|
|
})
|
|
}else{
|
|
//row exists, no errors
|
|
response.status = 1
|
|
resolve(response)
|
|
}
|
|
})
|
|
}else{
|
|
response.status = 0
|
|
resolve(response)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
const scanForOrphanedVideos = (monitor, options) => {
|
|
options = options || {}
|
|
return new Promise((resolve,reject) => {
|
|
const response = {ok: false}
|
|
if(options.forceCheck === true || config.insertOrphans === true){
|
|
if(!options.checkMax){
|
|
options.checkMax = config.orphanedVideoCheckMax || 2
|
|
}
|
|
let finished = false
|
|
let orphanedFilesCount = 0;
|
|
let videosFound = 0;
|
|
const videosDirectory = s.getVideoDirectory(monitor)
|
|
const tempDirectory = s.getStreamsDirectory(monitor)
|
|
// Write the `sh` script
|
|
try{
|
|
fs.writeFileSync(
|
|
tempDirectory + 'orphanCheck.sh',
|
|
`find "${s.checkCorrectPathEnding(videosDirectory,true)}" -maxdepth 1 -type f -exec stat -c "%n" {} + | sort -r | head -n ${options.checkMax}`
|
|
);
|
|
} catch(err) {
|
|
console.log('Failed scanForOrphanedVideos', monitor.ke, monitor.mid)
|
|
response.err = err.toString()
|
|
return resolve(response)
|
|
}
|
|
|
|
let listing = spawn('sh',[tempDirectory + 'orphanCheck.sh'])
|
|
const onError = options.onError ? options.onError : s.systemLog
|
|
|
|
const onExit = async () => {
|
|
try {
|
|
listing.kill('SIGTERM')
|
|
await fs.promises.rm(tempDirectory + 'orphanCheck.sh')
|
|
} catch(err) {
|
|
s.debugLog(err)
|
|
}
|
|
delete(listing)
|
|
}
|
|
|
|
const onFinish = () => {
|
|
if(!finished){
|
|
finished = true
|
|
response.ok = true
|
|
response.orphanedFilesCount = orphanedFilesCount
|
|
resolve(response)
|
|
onExit()
|
|
}
|
|
}
|
|
|
|
const processLine = async (filePath) => {
|
|
let filename = filePath.split('/').pop().trim()
|
|
if(filename && filename.indexOf('-') > -1 && filename.indexOf('.') > -1){
|
|
const { status } = await checkIfVideoIsOrphaned(monitor,videosDirectory,filename)
|
|
if(status === 2){
|
|
++orphanedFilesCount
|
|
}
|
|
++videosFound
|
|
if(videosFound === options.checkMax){
|
|
onFinish()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------
|
|
// Inactivity logic: if no data has arrived for 10 seconds, kill the process
|
|
// ------------------------------------------------------------------------------
|
|
let lastDataTimestamp = Date.now()
|
|
const INACTIVITY_TIMEOUT = 10000
|
|
|
|
const checkInactivity = () => {
|
|
if(finished) return // If we've already finished, do nothing
|
|
const now = Date.now()
|
|
if(now - lastDataTimestamp >= INACTIVITY_TIMEOUT){
|
|
// It's been more than 10 seconds since the last data event
|
|
onFinish()
|
|
} else {
|
|
// Check again in 1 second
|
|
setTimeout(checkInactivity, 1000)
|
|
}
|
|
}
|
|
// Start the inactivity checker
|
|
setTimeout(checkInactivity, 1000)
|
|
// ------------------------------------------------------------------------------
|
|
|
|
listing.stdout.on('data', async (d) => {
|
|
// Reset the inactivity timer
|
|
lastDataTimestamp = Date.now()
|
|
|
|
const filePathLines = d.toString().split('\n')
|
|
for (let i = 0; i < filePathLines.length; i++) {
|
|
await processLine(filePathLines[i])
|
|
}
|
|
})
|
|
listing.stderr.on('data', d => onError(d.toString()))
|
|
listing.on('close', (code) => {
|
|
setTimeout(() => {
|
|
onFinish()
|
|
},1000)
|
|
});
|
|
} else {
|
|
// If we are not going to check for orphans, just resolve
|
|
resolve(response)
|
|
}
|
|
})
|
|
}
|
|
// orphanedVideoCheck : old function
|
|
const orphanedVideoCheck = (monitor,checkMax,callback,forceCheck) => {
|
|
var finish = function(orphanedFilesCount){
|
|
if(callback)callback(orphanedFilesCount)
|
|
}
|
|
if(forceCheck === true || config.insertOrphans === true){
|
|
if(!checkMax){
|
|
checkMax = config.orphanedVideoCheckMax || 2
|
|
}
|
|
|
|
var videosDirectory = s.getVideoDirectory(monitor)// + s.formattedTime(video.time) + '.' + video.ext
|
|
fs.readdir(videosDirectory,function(err,files){
|
|
if(files && files.length > 0){
|
|
var fiveRecentFiles = files.slice(files.length - checkMax,files.length)
|
|
var completedFile = 0
|
|
var orphanedFilesCount = 0
|
|
var fileComplete = function(){
|
|
++completedFile
|
|
if(fiveRecentFiles.length === completedFile){
|
|
finish(orphanedFilesCount)
|
|
}
|
|
}
|
|
fiveRecentFiles.forEach(function(filename){
|
|
if(/T[0-9][0-9]-[0-9][0-9]-[0-9][0-9]./.test(filename)){
|
|
fs.stat(videosDirectory + filename,(err,stats) => {
|
|
if(!err && stats.size > 10){
|
|
s.knexQuery({
|
|
action: "select",
|
|
columns: "*",
|
|
table: "Videos",
|
|
where: [
|
|
['ke','=',monitor.ke],
|
|
['mid','=',monitor.mid],
|
|
['time','=',s.nameToTime(filename)],
|
|
],
|
|
limit: 1
|
|
},(err,rows) => {
|
|
if(!err && (!rows || !rows[0])){
|
|
++orphanedFilesCount
|
|
var video = rows[0]
|
|
s.insertCompletedVideo(monitor,{
|
|
file : filename
|
|
},() => {
|
|
fileComplete()
|
|
})
|
|
}else{
|
|
fileComplete()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}else{
|
|
finish()
|
|
}
|
|
})
|
|
}else{
|
|
finish()
|
|
}
|
|
}
|
|
function cutVideoLength(options){
|
|
return new Promise((resolve,reject) => {
|
|
const response = {ok: false}
|
|
const inputFilePath = options.filePath
|
|
const monitorId = options.mid
|
|
const groupKey = options.ke
|
|
const cutLength = options.cutLength || 10
|
|
const startTime = options.startTime
|
|
const tempDirectory = s.getStreamsDirectory(options)
|
|
let fileExt = inputFilePath.split('.')
|
|
fileExt = fileExt[fileExt.length -1]
|
|
const filename = `${s.gid(10)}.${fileExt}`
|
|
const videoOutPath = `${tempDirectory}${filename}`
|
|
const ffmpegCmd = ['-loglevel','warning','-i', inputFilePath, '-c','copy','-t',`${cutLength}`,videoOutPath]
|
|
if(startTime){
|
|
ffmpegCmd.splice(2, 0, "-ss")
|
|
ffmpegCmd.splice(3, 0, `${startTime}`)
|
|
s.debugLog(`cutVideoLength ffmpegCmd with startTime`,ffmpegCmd)
|
|
}
|
|
const cuttingProcess = spawn(config.ffmpegDir,ffmpegCmd)
|
|
cuttingProcess.stderr.on('data',(data) => {
|
|
const err = data.toString()
|
|
s.debugLog('cutVideoLength STDERR',options,err)
|
|
})
|
|
cuttingProcess.on('close',(data) => {
|
|
fs.stat(videoOutPath,(err) => {
|
|
if(!err){
|
|
response.ok = true
|
|
response.filename = filename
|
|
response.filePath = videoOutPath
|
|
setTimeout(() => {
|
|
s.file('delete',videoOutPath)
|
|
},1000 * 60 * 3)
|
|
}else{
|
|
s.debugLog('cutVideoLength:readFile',options,err)
|
|
}
|
|
resolve(response)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
async function getVideosBasedOnTagFoundInMatrixOfAssociatedEvent({
|
|
groupKey,
|
|
monitorId,
|
|
startTime,
|
|
endTime,
|
|
searchQuery,
|
|
monitorRestrictions,
|
|
andOnly
|
|
}){
|
|
const theSearches = searchQuery.split(',').map(query => ['objects','LIKE',`%${query.trim()}%`]);
|
|
const lastIndex = theSearches.length - 1;
|
|
if(!andOnly){
|
|
theSearches.forEach(function(item, n){
|
|
if(n !== 0)theSearches[n] = ['or', ...item];
|
|
});
|
|
}
|
|
const initialEventQuery = [
|
|
['ke','=',groupKey],
|
|
];
|
|
if(monitorId)initialEventQuery.push(['mid','=',monitorId]);
|
|
if(startTime)initialEventQuery.push(['time','>',startTime]);
|
|
if(endTime)initialEventQuery.push(['end','<',endTime]);
|
|
if(monitorRestrictions.length > 0)initialEventQuery.push(monitorRestrictions);
|
|
initialEventQuery.push([...theSearches]);
|
|
const videoSelectResponse = await s.knexQueryPromise({
|
|
action: "select",
|
|
columns: "*",
|
|
table: "Videos",
|
|
orderBy: ['time','desc'],
|
|
where: initialEventQuery
|
|
});
|
|
return videoSelectResponse
|
|
}
|
|
async function stitchMp4Files(options){
|
|
return new Promise((resolve,reject) => {
|
|
const concatListFile = options.listFile
|
|
const finalMp4OutputLocation = options.output
|
|
const commandString = `-y -threads 1 -f concat -safe 0 -i "${concatListFile}" -c:v copy -an -preset ultrafast "${finalMp4OutputLocation}"`
|
|
s.debugLog("stitchMp4Files",commandString)
|
|
const videoBuildProcess = spawn(config.ffmpegDir,splitForFFMPEG(commandString))
|
|
videoBuildProcess.stdout.on('data',function(data){
|
|
s.debugLog('stdout',finalMp4OutputLocation,data.toString())
|
|
})
|
|
videoBuildProcess.stderr.on('data',function(data){
|
|
s.debugLog('stderr',finalMp4OutputLocation,data.toString())
|
|
})
|
|
videoBuildProcess.on('exit',async function(data){
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
const fixingAlready = {}
|
|
function reEncodeVideoAndReplace(videoRow){
|
|
return new Promise((resolve,reject) => {
|
|
const response = {ok: true}
|
|
const fixingId = `${videoRow.ke}${videoRow.mid}${videoRow.time}`
|
|
if(fixingAlready[fixingId]){
|
|
response.ok = false
|
|
response.msg = lang['Already Processing']
|
|
resolve(response)
|
|
}else{
|
|
const filename = s.formattedTime(videoRow.time)+'.'+videoRow.ext
|
|
const tempFilename = s.formattedTime(videoRow.time)+'.reencoding.'+videoRow.ext
|
|
const videoFolder = s.getVideoDirectory(videoRow)
|
|
const inputFilePath = `${videoFolder}${filename}`
|
|
const outputFilePath = `${videoFolder}${tempFilename}`
|
|
const commandString = `-y -threads 1 -re -i "${inputFilePath}" -c:v copy -c:a copy -preset ultrafast "${outputFilePath}"`
|
|
fixingAlready[fixingId] = true
|
|
const videoBuildProcess = spawn(config.ffmpegDir,splitForFFMPEG(commandString))
|
|
videoBuildProcess.stdout.on('data',function(data){
|
|
s.debugLog('stdout',outputFilePath,data.toString())
|
|
})
|
|
videoBuildProcess.stderr.on('data',function(data){
|
|
s.debugLog('stderr',outputFilePath,data.toString())
|
|
})
|
|
videoBuildProcess.on('exit',async function(data){
|
|
fixingAlready[fixingId] = false
|
|
try{
|
|
function failed(err){
|
|
response.ok = false
|
|
response.err = err
|
|
resolve(response)
|
|
}
|
|
const newFileStats = await fs.promises.stat(outputFilePath)
|
|
await fs.promises.rm(inputFilePath)
|
|
let readStream = fs.createReadStream(outputFilePath);
|
|
let writeStream = fs.createWriteStream(inputFilePath);
|
|
readStream.pipe(writeStream);
|
|
writeStream.on('finish', async () => {
|
|
resolve(response)
|
|
await fs.promises.rm(outputFilePath)
|
|
});
|
|
writeStream.on('error', failed);
|
|
readStream.on('error', failed);
|
|
}catch(err){
|
|
failed()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
const reEncodeVideoAndBinOriginalQueue = {}
|
|
function reEncodeVideoAndBinOriginalAddToQueue(data){
|
|
const groupKey = data.video.ke
|
|
if(!reEncodeVideoAndBinOriginalQueue[groupKey]){
|
|
reEncodeVideoAndBinOriginalQueue[groupKey] = async.queue(function(data, callback) {
|
|
reEncodeVideoAndBinOriginal(data).then((response) => {
|
|
callback(response)
|
|
})
|
|
}, 1);
|
|
}
|
|
return new Promise((resolve) => {
|
|
reEncodeVideoAndBinOriginalQueue[groupKey].push(data, function(response){
|
|
resolve(response)
|
|
})
|
|
})
|
|
}
|
|
function reEncodeVideoAndBinOriginal({
|
|
video,
|
|
targetVideoCodec,
|
|
targetAudioCodec,
|
|
targetQuality,
|
|
targetExtension,
|
|
doSlowly,
|
|
onPercentChange,
|
|
automated,
|
|
}){
|
|
targetVideoCodec = targetVideoCodec || `copy`
|
|
targetAudioCodec = targetAudioCodec || `copy`
|
|
targetQuality = targetQuality || ``
|
|
onPercentChange = onPercentChange || function(){};
|
|
if(!targetVideoCodec || !targetAudioCodec || !targetQuality){
|
|
switch(targetExtension){
|
|
case'mp4':
|
|
targetVideoCodec = `libx264`
|
|
targetAudioCodec = `aac -strict -2`
|
|
targetQuality = `-crf 1`
|
|
break;
|
|
case'webm':
|
|
case'mkv':
|
|
targetVideoCodec = `vp9`
|
|
targetAudioCodec = `libopus`
|
|
targetQuality = `-q:v 1 -b:a 96K`
|
|
break;
|
|
}
|
|
}
|
|
const response = {ok: true}
|
|
const groupKey = video.ke
|
|
const monitorId = video.mid
|
|
const filename = s.formattedTime(video.time)+'.'+video.ext
|
|
const tempFilename = s.formattedTime(video.time)+'.reencoding.'+ targetExtension
|
|
const finalFilename = s.formattedTime(video.time)+'.'+ targetExtension
|
|
const tempFolder = s.getStreamsDirectory(video)
|
|
const videoFolder = s.getVideoDirectory(video)
|
|
const fileBinFolder = s.getFileBinDirectory(video)
|
|
const inputFilePath = `${videoFolder}${filename}`
|
|
const fileBinFilePath = `${fileBinFolder}${filename}`
|
|
const outputFilePath = `${tempFolder}${tempFilename}`
|
|
const finalFilePath = `${videoFolder}${finalFilename}`
|
|
const fixingId = `${video.ke}${video.mid}${video.time}`
|
|
return new Promise(async (resolve,reject) => {
|
|
function completeResolve(data){
|
|
s.tx({
|
|
f: 'video_compress_completed',
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
oldName: filename,
|
|
name: finalFilename,
|
|
automated: !!automated,
|
|
success: !!data.ok,
|
|
},'GRP_'+groupKey);
|
|
resolve(data)
|
|
}
|
|
try{
|
|
if(fixingAlready[fixingId]){
|
|
response.ok = false
|
|
response.msg = lang['Already Processing']
|
|
resolve(response)
|
|
}else{
|
|
const inputFileStats = await fs.promises.stat(inputFilePath)
|
|
const originalFileInfo = (await ffprobe(inputFilePath,inputFilePath)).result
|
|
const videoDuration = originalFileInfo.format.duration
|
|
const commandString = `-y ${doSlowly ? `-re -threads 1` : ''} -i "${inputFilePath}" -c:v ${targetVideoCodec} -c:a ${targetAudioCodec} ${targetQuality} "${outputFilePath}"`
|
|
fixingAlready[fixingId] = true
|
|
s.tx({
|
|
f: 'video_compress_started',
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
oldName: filename,
|
|
name: finalFilename,
|
|
},'GRP_'+groupKey);
|
|
const videoBuildProcess = spawn(config.ffmpegDir,splitForFFMPEG(commandString))
|
|
videoBuildProcess.stdout.on('data',function(data){
|
|
s.debugLog('stdout',outputFilePath,data.toString())
|
|
})
|
|
videoBuildProcess.stderr.on('data',function(data){
|
|
const text = data.toString()
|
|
if(text.includes('frame=')){
|
|
const durationSoFar = hmsToSeconds(text.split('time=')[1].trim().split(/(\s+)/)[0])
|
|
const percent = (durationSoFar / videoDuration * 100).toFixed(1)
|
|
s.tx({
|
|
f: 'video_compress_percent',
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
oldName: filename,
|
|
name: finalFilename,
|
|
percent: percent,
|
|
},'GRP_'+groupKey);
|
|
onPercentChange(percent)
|
|
s.debugLog('stderr',outputFilePath,`${percent}%`)
|
|
}else{
|
|
s.debugLog('stderr',lang['Compression Info'],text)
|
|
}
|
|
})
|
|
videoBuildProcess.on('exit',async function(data){
|
|
fixingAlready[fixingId] = false
|
|
try{
|
|
// check that new file is existing
|
|
const newFileStats = await fs.promises.stat(outputFilePath)
|
|
// move old file to fileBin
|
|
await copyFile(inputFilePath,fileBinFilePath)
|
|
const fileBinInsertQuery = {
|
|
ke: video.ke,
|
|
mid: video.mid,
|
|
name: filename,
|
|
size: video.size,
|
|
details: video.details,
|
|
status: video.status,
|
|
time: video.time,
|
|
}
|
|
await s.insertFileBinEntry(fileBinInsertQuery)
|
|
// delete original
|
|
await s.deleteVideo(video)
|
|
// copy temp file to final path
|
|
await copyFile(outputFilePath,finalFilePath)
|
|
await fs.promises.rm(outputFilePath)
|
|
s.insertCompletedVideo({
|
|
id: video.mid,
|
|
mid: video.mid,
|
|
ke: video.ke,
|
|
ext: targetExtension,
|
|
},{
|
|
file: finalFilename,
|
|
objects: video.objects,
|
|
endTime: video.end,
|
|
ext: targetExtension,
|
|
},function(){
|
|
completeResolve({
|
|
ok: true,
|
|
path: finalFilePath,
|
|
time: video.time,
|
|
fileBin: fileBinInsertQuery,
|
|
videoCodec: targetVideoCodec,
|
|
audioCodec: targetAudioCodec,
|
|
videoQuality: targetQuality,
|
|
})
|
|
})
|
|
}catch(err){
|
|
response.ok = false
|
|
response.err = err
|
|
completeResolve(response)
|
|
}
|
|
})
|
|
}
|
|
}catch(err){
|
|
response.ok = false
|
|
response.err = err
|
|
completeResolve(response)
|
|
}
|
|
})
|
|
}
|
|
function archiveVideo(video,unarchive){
|
|
return new Promise((resolve) => {
|
|
s.knexQuery({
|
|
action: "update",
|
|
table: 'Videos',
|
|
update: {
|
|
archive: unarchive ? '0' : 1
|
|
},
|
|
where: {
|
|
ke: video.ke,
|
|
mid: video.mid,
|
|
time: video.time,
|
|
}
|
|
},function(errVideos){
|
|
s.knexQuery({
|
|
action: "update",
|
|
table: 'Events',
|
|
update: {
|
|
archive: unarchive ? '0' : 1
|
|
},
|
|
where: [
|
|
['ke','=',video.ke],
|
|
['mid','=',video.mid],
|
|
['time','>=',video.time],
|
|
['time','<=',video.end],
|
|
]
|
|
},function(errEvents){
|
|
s.knexQuery({
|
|
action: "update",
|
|
table: 'Timelapse Frames',
|
|
update: {
|
|
archive: unarchive ? '0' : 1
|
|
},
|
|
limit: 1,
|
|
where: [
|
|
['ke','=',video.ke],
|
|
['mid','=',video.mid],
|
|
['time','>=',video.time],
|
|
['time','<=',video.end],
|
|
]
|
|
},function(errTimelapseFrames){
|
|
resolve({
|
|
ok: !errVideos && !errEvents && !errTimelapseFrames,
|
|
err: errVideos || errEvents || errTimelapseFrames ? {
|
|
errVideos,
|
|
errEvents,
|
|
errTimelapseFrames,
|
|
} : undefined,
|
|
archived: !unarchive
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
async function sliceVideo(video,{
|
|
startTime,
|
|
endTime,
|
|
}){
|
|
const response = {ok: false}
|
|
if(!startTime || !endTime){
|
|
response.msg = 'Missing startTime or endTime!'
|
|
return response
|
|
}
|
|
try{
|
|
const groupKey = video.ke
|
|
const monitorId = video.mid
|
|
const filename = s.formattedTime(video.time) + '.' + video.ext
|
|
const finalFilename = s.formattedTime(video.time) + `-sliced-${s.gid(5)}.` + video.ext
|
|
const videoFolder = s.getVideoDirectory(video)
|
|
const fileBinFolder = s.getFileBinDirectory(video)
|
|
const inputFilePath = `${videoFolder}${filename}`
|
|
const fileBinFilePath = `${fileBinFolder}${finalFilename}`
|
|
const cutLength = parseFloat(endTime) - parseFloat(startTime);
|
|
s.debugLog(`sliceVideo start slice...`)
|
|
const cutProcessResponse = await cutVideoLength({
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
cutLength,
|
|
startTime,
|
|
filePath: inputFilePath,
|
|
});
|
|
s.debugLog(`sliceVideo cutProcessResponse`,cutProcessResponse)
|
|
const newFilePath = cutProcessResponse.filePath
|
|
const copyResponse = await copyFile(newFilePath,fileBinFilePath)
|
|
const fileSize = (await fs.promises.stat(fileBinFilePath)).size
|
|
s.debugLog(`sliceVideo copyResponse`,copyResponse)
|
|
const fileBinInsertQuery = {
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
name: finalFilename,
|
|
size: fileSize,
|
|
details: video.details,
|
|
status: 1,
|
|
time: video.time,
|
|
}
|
|
await s.insertFileBinEntry(fileBinInsertQuery)
|
|
s.notifyFileBinUploaded(fileBinInsertQuery)
|
|
s.tx(Object.assign({
|
|
f: 'fileBin_item_added',
|
|
slicedVideo: true,
|
|
},fileBinInsertQuery),'GRP_'+video.ke);
|
|
response.ok = true
|
|
}catch(err){
|
|
response.err = err
|
|
s.debugLog('sliceVideo ERROR',err)
|
|
}
|
|
return response
|
|
}
|
|
const mergingVideos = {};
|
|
const mergeVideos = async function({
|
|
groupKey,
|
|
monitorId,
|
|
filePaths,
|
|
outputFilePath,
|
|
videoCodec = 'libx265',
|
|
audioCodec = 'aac',
|
|
onStdout = (data) => {s.systemLog(`${data}`)},
|
|
onStderr = (data) => {s.systemLog(`${data}`)},
|
|
}) {
|
|
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
|
throw new Error('First parameter must be a non-empty array of absolute file paths.');
|
|
}
|
|
if(mergingVideos[outputFilePath])return;
|
|
const currentDate = new Date();
|
|
const fileExtensions = filePaths.map(file => path.extname(file).toLowerCase());
|
|
const allSameExtension = fileExtensions.every(ext => ext === fileExtensions[0]);
|
|
const fileList = filePaths.map(file => `file '${file}'`).join('\n');
|
|
const tempFileListPath = path.join(s.dir.streams, groupKey, monitorId, `video_merge_${currentDate}.txt`);
|
|
mergingVideos[outputFilePath] = currentDate;
|
|
try {
|
|
await fsP.writeFile(tempFileListPath, fileList);
|
|
let ffmpegArgs;
|
|
// if (allSameExtension) {
|
|
// ffmpegArgs = [
|
|
// '-f', 'concat',
|
|
// '-safe', '0',
|
|
// '-i', tempFileListPath,
|
|
// '-c', 'copy',
|
|
// '-y',
|
|
// outputFilePath
|
|
// ];
|
|
// } else {
|
|
ffmpegArgs = [
|
|
'-loglevel', 'warning',
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', tempFileListPath,
|
|
'-c:v', videoCodec,
|
|
'-c:a', audioCodec,
|
|
'-strict', '-2',
|
|
'-crf', '1',
|
|
'-y',
|
|
outputFilePath
|
|
];
|
|
// }
|
|
s.debugLog(fileList)
|
|
s.debugLog(ffmpegArgs)
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const ffmpegProcess = spawn(config.ffmpegDir, ffmpegArgs);
|
|
ffmpegProcess.stdout.on('data', onStdout);
|
|
ffmpegProcess.stderr.on('data', onStderr);
|
|
ffmpegProcess.on('close', (code) => {
|
|
delete(mergingVideos[outputFilePath]);
|
|
if (code === 0) {
|
|
console.log(`FFmpeg process exited with code ${code}`);
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`FFmpeg process exited with code ${code}`));
|
|
}
|
|
});
|
|
ffmpegProcess.on('error', (err) => {
|
|
reject(new Error(`FFmpeg error: ${err}`));
|
|
});
|
|
});
|
|
} finally {
|
|
await fsP.unlink(tempFileListPath);
|
|
}
|
|
};
|
|
async function mergeVideosAndBin(videos){
|
|
const currentTime = new Date();
|
|
const firstVideo = videos[0];
|
|
const lastVideo = videos[videos.length - 1];
|
|
const groupKey = firstVideo.ke;
|
|
const monitorId = firstVideo.mid;
|
|
const logTarget = { ke: groupKey, mid: '$USER' };
|
|
try{
|
|
try{
|
|
await fsP.stat(outputFilePath)
|
|
return outputFilePath
|
|
}catch(err){
|
|
|
|
}
|
|
const filePaths = videos.reverse().map(video => {
|
|
const monitorConfig = s.group[video.ke].rawMonitorConfigurations[video.mid];
|
|
const filePath = path.join(s.getVideoDirectory(video), `${s.formattedTime(video.time)}.mp4`);
|
|
return filePath
|
|
});
|
|
const filename = `${s.formattedTime(firstVideo.time)}-${s.formattedTime(lastVideo.time)}-${filePaths.length}.mp4`
|
|
const fileBinFolder = s.getFileBinDirectory(firstVideo);
|
|
const outputFilePath = path.join(fileBinFolder, filename);
|
|
|
|
s.userLog(logTarget,{
|
|
type: 'mergeVideos ffmpeg START',
|
|
msg: {
|
|
monitorId,
|
|
numberOfVideos: filePaths.length,
|
|
}
|
|
});
|
|
await mergeVideos({
|
|
groupKey,
|
|
monitorId,
|
|
filePaths,
|
|
outputFilePath,
|
|
onStdout: (data) => {
|
|
s.debugLog(data.toString())
|
|
s.userLog(logTarget,{
|
|
type: 'mergeVideos ffmpeg LOG',
|
|
msg: `${data}`
|
|
});
|
|
},
|
|
onStderr: (data) => {
|
|
s.debugLog(data.toString())
|
|
s.userLog(logTarget,{
|
|
type: 'mergeVideos ffmpeg ERROR',
|
|
msg: `${data}`
|
|
});
|
|
},
|
|
});
|
|
const fileSize = (await fsP.stat(outputFilePath)).size;
|
|
const fileBinInsertQuery = {
|
|
ke: groupKey,
|
|
mid: monitorId,
|
|
name: filename,
|
|
size: fileSize,
|
|
details: {},
|
|
status: 1,
|
|
time: currentTime,
|
|
}
|
|
await s.insertFileBinEntry(fileBinInsertQuery);
|
|
s.notifyFileBinUploaded(fileBinInsertQuery);
|
|
return outputFilePath
|
|
}catch(err){
|
|
console.log('mergeVideos process ERROR', err)
|
|
s.userLog(logTarget,{
|
|
type: 'mergeVideos process ERROR',
|
|
msg: `${err.toString()}`
|
|
});
|
|
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} : ${ffmpegArgs.join(' ')}`));
|
|
}
|
|
});
|
|
|
|
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){
|
|
try{
|
|
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)
|
|
}catch(err){
|
|
s.debugLog(err)
|
|
}
|
|
}
|
|
// 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;
|
|
}
|
|
};
|
|
function isApplicableVideosDirectory(givenDirectory, addStorageOnly){
|
|
if(!addStorageOnly && (givenDirectory === '' || !givenDirectory))return true
|
|
let canDo = false;
|
|
const addStorageLocations = (config.addStorage || []).map(item => item.path);
|
|
for(aLocation of addStorageLocations){
|
|
if(givenDirectory === aLocation.replace('__DIR__', s.mainDirectory)){
|
|
canDo = true
|
|
}
|
|
}
|
|
return canDo
|
|
}
|
|
return {
|
|
reEncodeVideoAndReplace,
|
|
stitchMp4Files,
|
|
orphanedVideoCheck,
|
|
scanForOrphanedVideos,
|
|
cutVideoLength,
|
|
getVideosBasedOnTagFoundInMatrixOfAssociatedEvent,
|
|
reEncodeVideoAndBinOriginal,
|
|
reEncodeVideoAndBinOriginalAddToQueue,
|
|
archiveVideo,
|
|
sliceVideo,
|
|
mergeVideos,
|
|
mergeVideosAndBin,
|
|
saveVideoFrameToTimelapse,
|
|
postProcessCompletedMp4Video,
|
|
readChunkForMoov,
|
|
checkMoovAtBeginning,
|
|
checkMoovAtEnd,
|
|
hasMoovAtom,
|
|
convertAviToMp4,
|
|
isApplicableVideosDirectory,
|
|
}
|
|
}
|