Add Video Merge Feature in Videos Table
parent
7508bd79fb
commit
0b02762ad8
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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(){
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -442,35 +442,93 @@ function loadEventsData(videoEvents){
|
|||
loadedEventsInMemory[`${anEvent.mid}${anEvent.time}`] = anEvent
|
||||
})
|
||||
}
|
||||
function getVideoSearchRequestQueries(options){
|
||||
var searchQuery = options.searchQuery
|
||||
var requestQueries = []
|
||||
var monitorId = options.monitorId
|
||||
var archived = options.archived
|
||||
var customVideoSet = options.customVideoSet
|
||||
var limit = options.limit
|
||||
var eventLimit = options.eventLimit || 300
|
||||
var doLimitOnFames = options.doLimitOnFames || false
|
||||
var eventStartTime
|
||||
var eventEndTime
|
||||
if(options.startDate){
|
||||
eventStartTime = formattedTimeForFilename(options.startDate,false)
|
||||
requestQueries.push(`start=${eventStartTime}`)
|
||||
}
|
||||
if(options.endDate){
|
||||
eventEndTime = formattedTimeForFilename(options.endDate,false)
|
||||
requestQueries.push(`end=${eventEndTime}`)
|
||||
}
|
||||
if(searchQuery){
|
||||
requestQueries.push(`search=${searchQuery}`)
|
||||
}
|
||||
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 : {}
|
||||
var searchQuery = options.searchQuery
|
||||
var requestQueries = []
|
||||
var monitorId = options.monitorId
|
||||
var archived = options.archived
|
||||
var customVideoSet = options.customVideoSet
|
||||
var limit = options.limit
|
||||
var eventLimit = options.eventLimit || 300
|
||||
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}`)
|
||||
}
|
||||
if(options.endDate){
|
||||
eventEndTime = formattedTimeForFilename(options.endDate,false)
|
||||
requestQueries.push(`end=${eventEndTime}`)
|
||||
}
|
||||
if(searchQuery){
|
||||
requestQueries.push(`search=${searchQuery}`)
|
||||
}
|
||||
if(archived){
|
||||
requestQueries.push(`archived=1`)
|
||||
}
|
||||
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,{
|
||||
|
|
|
@ -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'){
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue