Add Video Merge Feature in Videos Table

plugin-touch-ups
Moe 2024-09-02 13:56:36 -07:00
parent 7508bd79fb
commit 0b02762ad8
9 changed files with 362 additions and 30 deletions

View File

@ -468,6 +468,8 @@
"Max Storage Amount": "Max Storage Amount",
"Video Share": "Video Share",
"FileBin": "FileBin",
"File Saved": "File Saved",
"checkFileBinForNewFile": "Check the FileBin for the newly created file.",
"File Download Ready": "File Download Ready",
"Timelapse Video Build Complete": "Timelapse Video Build Complete",
"yourFileDownloadedShortly": "Please wait. Your file will be downloaded shortly.",
@ -570,6 +572,10 @@
"clientStreamFailedattemptingReconnect": "Client side stream check failed, attempting reconnect.",
"Export Video": "Export Video",
"Merge Video": "Merge Video",
"Merge Videos": "Merge Videos",
"Merge": "Merge",
"MergeAllSelected": "Merge all selected Videos?",
"MergeAllInRange": "Merge all Videos in date range selected?",
"Delete Filter": "Delete Filter",
"Delete Files": "Delete Files",
"confirmDeleteFilter": "Do you want to delete this filter? You cannot recover it.",
@ -1093,6 +1099,7 @@
"Can't Connect": "Can't Connect",
"Video Finished": "Video Finished",
"No Monitors Selected": "No Monitors Selected",
"No Monitor Selected": "No Monitor Selected",
"Nothing Selected": "Nothing Selected",
"makeASelection": "Make a selection and try again.",
"monSavedButNotCopied": "Your monitor was saved but not copied to any other monitor.",

View File

@ -192,6 +192,11 @@ module.exports = function(s,config,lang,app,io){
})
})
}
s.notifyFileBinUploaded = function(fileBinInsertQuery){
s.tx(Object.assign({
f: 'fileBin_item_added',
},fileBinInsertQuery),'GRP_'+fileBinInsertQuery.ke);
}
s.getFileBinDirectory = getFileBinDirectory
s.getFileBinEntry = getFileBinEntry
s.getFileBinBuffer = getFileBinBuffer

View File

@ -1,6 +1,8 @@
const fs = require('fs')
const { spawn } = require('child_process')
const async = require('async');
const path = require('path');
const fsP = require('fs').promises;
module.exports = (s,config,lang) => {
const {
ffprobe,
@ -593,6 +595,7 @@ module.exports = (s,config,lang) => {
time: video.time,
}
await s.insertFileBinEntry(fileBinInsertQuery)
s.notifyFileBinUploaded(fileBinInsertQuery)
s.tx(Object.assign({
f: 'fileBin_item_added',
slicedVideo: true,
@ -604,6 +607,149 @@ module.exports = (s,config,lang) => {
}
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.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;
}
}
return {
reEncodeVideoAndReplace,
stitchMp4Files,
@ -615,5 +761,7 @@ module.exports = (s,config,lang) => {
reEncodeVideoAndBinOriginalAddToQueue,
archiveVideo,
sliceVideo,
mergeVideos,
mergeVideosAndBin,
}
}

View File

@ -39,6 +39,7 @@ module.exports = function(s,config,lang,app,io){
reEncodeVideoAndReplace,
reEncodeVideoAndBinOriginalAddToQueue,
getVideosBasedOnTagFoundInMatrixOfAssociatedEvent,
mergeVideosAndBin,
} = require('./video/utils.js')(s,config,lang)
s.renderPage = function(req,res,paths,passables,callback){
passables.window = {}
@ -1970,7 +1971,69 @@ module.exports = function(s,config,lang,app,io){
res.end(s.prettyPrint(response));
})
},res,req);
});
/**
* API : Merge Videos and Bin it
*/
app.post(config.webPaths.apiPrefix+':auth/mergeVideos/:ke/:id', function (req,res){
s.auth(req.params, async function(user){
const monitorId = req.params.id
const groupKey = req.params.ke
const {
monitorPermissions,
monitorRestrictions,
} = s.getMonitorsPermitted(user.details,monitorId,'video_view')
const {
isRestricted,
isRestrictedApiKey,
apiKeyPermissions,
} = s.checkPermission(user);
if(
isRestrictedApiKey && apiKeyPermissions.watch_videos_disallowed ||
isRestricted && (
monitorId && !monitorPermissions[`${monitorId}_video_view`] ||
monitorRestrictions.length === 0
)
){
s.closeJsonResponse(res,{ok: false, msg: lang['Not Authorized'], videos: []});
return
}
const response = { ok: false }
const selectedVideos = s.getPostData(req,'videos');
console.log('selected',selectedVideos)
if(selectedVideos && selectedVideos.length > 1){
const mergedFilePath = await mergeVideosAndBin(selectedVideos);
response.ok = !!mergedFilePath;
s.closeJsonResponse(res, response);
}else{
s.sqlQueryBetweenTimesWithPermissions({
table: 'Videos',
user: user,
noCount: true,
groupKey,
monitorId,
startTime: s.getPostData(req,'start'),
endTime: s.getPostData(req,'end'),
startTimeOperator: s.getPostData(req,'startOperator'),
endTimeOperator: s.getPostData(req,'endOperator'),
noLimit: s.getPostData(req,'noLimit'),
limit: s.getPostData(req,'limit'),
archived: s.getPostData(req,'archived'),
endIsStartTo: !!s.getPostData(req,'endIsStartTo'),
parseRowDetails: false,
rowName: 'videos',
monitorRestrictions: monitorRestrictions,
preliminaryValidationFailed: false
}, async ({ videos }) => {
if(videos){
const mergedFilePath = await mergeVideosAndBin(videos);
response.ok = !!mergedFilePath;
}
s.closeJsonResponse(res, response);
})
}
},res,req);
});
/**
* API : Get Login Tokens
*/

View File

@ -1168,6 +1168,9 @@ function getRunningMonitors(asArray){
})
return asArray ? Object.values(foundMonitors) : foundMonitors
}
function buildFileBinUrl(data){
return apiBaseUrl + '/fileBin/' + data.ke + '/' + data.mid + '/' + data.name
}
$(document).ready(function(){
$('body')
.on('click','[system]',function(){

View File

@ -311,9 +311,6 @@ $(document).ready(function(e){
function downloadTimelapseFrame(frame){
downloadFile(frame.href,frame.filename)
}
function buildFileBinUrl(data){
return apiBaseUrl + '/fileBin/' + data.ke + '/' + data.mid + '/' + data.name
}
function downloadTimelapseVideo(data){
var downloadUrl = buildFileBinUrl(data)
downloadFile(downloadUrl,data.name)

View File

@ -442,9 +442,7 @@ function loadEventsData(videoEvents){
loadedEventsInMemory[`${anEvent.mid}${anEvent.time}`] = anEvent
})
}
function getVideos(options,callback,noEvents){
return new Promise((resolve,reject) => {
options = options ? options : {}
function getVideoSearchRequestQueries(options){
var searchQuery = options.searchQuery
var requestQueries = []
var monitorId = options.monitorId
@ -455,8 +453,6 @@ function getVideos(options,callback,noEvents){
var doLimitOnFames = options.doLimitOnFames || false
var eventStartTime
var eventEndTime
// var startDate = options.startDate
// var endDate = options.endDate
if(options.startDate){
eventStartTime = formattedTimeForFilename(options.startDate,false)
requestQueries.push(`start=${eventStartTime}`)
@ -471,6 +467,68 @@ function getVideos(options,callback,noEvents){
if(archived){
requestQueries.push(`archived=1`)
}
return {
searchQuery,
monitorId,
archived,
customVideoSet,
limit,
eventLimit,
doLimitOnFames,
eventStartTime,
eventEndTime,
requestQueries,
}
}
function mergeVideosAndBin(options,callback){
const {
searchQuery,
monitorId,
archived,
customVideoSet,
limit,
eventLimit,
doLimitOnFames,
eventStartTime,
eventEndTime,
requestQueries,
} = getVideoSearchRequestQueries(options);
const videos = options.videos.map(video => {
const newVideo = {
ke: video.ke,
mid: video.mid,
time: video.time,
end: video.end,
saveDir: video.saveDir,
details: video.details,
};
delete(newVideo.timelapseFrames)
return newVideo
});
console.log(videos)
return new Promise((resolve) => {
$.post(`${getApiPrefix(`mergeVideos`)}${monitorId ? `/${monitorId}` : ''}?${requestQueries.concat([limit ? `limit=${limit}` : `noLimit=1`]).join('&')}`, {
videos,
},function(data){
resolve(data)
})
})
}
function getVideos(options,callback,noEvents){
return new Promise((resolve,reject) => {
options = options ? options : {}
const {
searchQuery,
monitorId,
archived,
customVideoSet,
limit,
eventLimit,
doLimitOnFames,
eventStartTime,
eventEndTime,
requestQueries,
} = getVideoSearchRequestQueries(options);
$.getJSON(`${getApiPrefix(customVideoSet ? customVideoSet : searchQuery ? `videosByEventTag` : `videos`)}${monitorId ? `/${monitorId}` : ''}?${requestQueries.concat([limit ? `limit=${limit}` : `noLimit=1`]).join('&')}`,function(data){
var videos = data.videos.map((video) => {
return Object.assign({},video,{

View File

@ -274,6 +274,43 @@ $(document).ready(function(e){
var downloadUrl = buildNewFileLink(data)
downloadFile(downloadUrl,data.name)
}
function mergeSelectedVideos(){
var videos = getSelectedRows(true)
var dateRange = getSelectedTime(dateSelector);
var searchQuery = objectTagSearchField.val() || null;
var startDate = dateRange.startDate;
var endDate = dateRange.endDate;
var monitorId = monitorsList.val();
var wantsArchivedVideo = getVideoSetSelected() === 'archive';
if(!monitorId){
new PNotify({
title: lang['No Monitor Selected'],
text: lang['No Monitor Found, Ignoring Request'],
type: 'danger',
})
return
}
$.confirm.create({
title: lang["Merge Videos"],
body: `${videos.length > 0 ? lang.MergeAllSelected : lang.MergeAllInRange}`,
clickOptions: {
title: '<i class="fa fa-check"></i> ' + lang.Save,
class: 'btn-success btn-sm'
},
clickCallback: async function(){
console.log('Merging Video...')
var result = await mergeVideosAndBin({
monitorId,
startDate,
endDate,
videos,
searchQuery,
archived: wantsArchivedVideo,
});
console.log('Merged Video! Check Filebin.')
}
});
}
$('body')
.on('click','.open-videosTable',function(e){
e.preventDefault()
@ -307,6 +344,11 @@ $(document).ready(function(e){
zipVideosAndDownloadWithConfirm(videos)
return false;
})
.on('click','.merge-selected-videos',function(e){
e.preventDefault()
mergeSelectedVideos();
return false;
})
.on('click','.refresh-data',function(e){
e.preventDefault()
drawVideosTableViewElements()
@ -410,6 +452,14 @@ $(document).ready(function(e){
})
onWebSocketEvent((data) => {
switch(data.f){
case'fileBin_item_added':
new PNotify({
title: lang['File Saved'],
text: `${lang.checkFileBinForNewFile}<br><br><a class="btn btn-sm btn-success" href="${buildFileBinUrl(data)}" download>${lang.Download}</a>`,
type: 'success',
sticky: true,
})
break;
case'video_delete':
case'video_delete_cloud':
if(tabTree.name === 'videosTableView'){

View File

@ -21,6 +21,7 @@
<li><a class="dropdown-item unarchive-selected-videos cursor-pointer"><%- lang.Unarchive %></a></li>
<li><a class="dropdown-item compress-selected-videos cursor-pointer"><%- lang.Compress %></a></li>
<li><a class="dropdown-item zip-selected-videos cursor-pointer"><%- lang['Zip and Download'] %></a></li>
<li><a class="dropdown-item merge-selected-videos cursor-pointer"><%- lang['Merge'] %></a></li>
<!-- <li><a class="dropdown-item merge-selected-videos cursor-pointer"><%- lang.Merge %></a></li> -->
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item delete-selected-videos cursor-pointer"><%- lang.Delete %></a></li>