Merge branch 'video-slicer' into 'dev'

# Conflicts:
#   web/assets/js/bs5.videosTable.js
fix-timezone-by-ui
Moe 2022-10-16 22:47:14 +00:00
commit a8c7d8182e
11 changed files with 504 additions and 13 deletions

View File

@ -8142,6 +8142,81 @@ module.exports = function(s,config,lang){
},
}
},
"Studio": {
"section": lang["Studio"],
"blocks": {
"Video Playback": {
id: "studioVideoPlayback",
noHeader: true,
noDefaultSectionClasses: true,
"color": "green",
"section-pre-class": "col-md-8 search-parent",
"info": [
{
"id": "studioViewingCanvas",
"fieldType": "div",
},
{
"id": "studioMonitorControls",
"color": "blue",
noHeader: true,
isSection: true,
isFormGroupGroup: true,
'section-class': 'text-center',
"info": [
{
"fieldType": "btn-group",
"normalWidth": true,
"btns": [
{
"fieldType": "btn",
"class": `btn-default btn-sm play-preview`,
"attribute": `title="${lang['Play']}"`,
"btnContent": `<i class="fa fa-play"></i>`,
},
{
"fieldType": "btn",
"class": `btn-default btn-sm slice-video`,
"attribute": `title="${lang['Slice']}"`,
"btnContent": `<i class="fa fa-scissors"></i>`,
},
],
},
]
},
{
"color": "bg-gradient-blue text-white",
noHeader: true,
isSection: true,
isFormGroupGroup: true,
'section-class': 'text-center',
"info": [
{
"id": "studioTimelineStrip",
"fieldType": "div",
"divContent": `
<div id="studio-time-ticks"></div>
<div id="studio-seek-tick"></div>
<div id="studio-slice-selection" style="width: 80%;left: 10%;"></div>
`,
},
]
},
]
},
"Container2": {
noHeader: true,
"section-pre-class": "col-md-4",
"noDefaultSectionClasses": true,
"info": [
{
"id": "studio-completed-videos",
"fieldType": "div",
},
]
}
}
},
"Calendar": {
"section": "Calendar",
"blocks": {

View File

@ -98,6 +98,9 @@
"No API Key": "No API Key",
"Use Camera Timestamps": "Use Camera Timestamps",
"Power Viewer": "Power Viewer",
"Slice": "Slice",
"Stitch": "Stitch",
"Studio": "Studio",
"Power Video Viewer": "Power Video Viewer",
"Time-lapse": "Time-lapse",
"Montage": "Montage",

View File

@ -192,15 +192,22 @@ module.exports = (s,config,lang) => {
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 cuttingProcess = spawn(config.ffmpegDir,['-loglevel','warning','-i', inputFilePath, '-c','copy','-t',`${cutLength}`,videoOutPath])
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',options,err)
s.debugLog('cutVideoLength STDERR',options,err)
})
cuttingProcess.on('close',(data) => {
fs.stat(videoOutPath,(err) => {
@ -534,6 +541,58 @@ module.exports = (s,config,lang) => {
})
})
}
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)
s.debugLog(`sliceVideo copyResponse`,copyResponse)
const fileBinInsertQuery = {
ke: groupKey,
mid: monitorId,
name: finalFilename,
size: video.size,
details: video.details,
status: 1,
time: video.time,
}
await s.insertFileBinEntry(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
}
return {
reEncodeVideoAndReplace,
stitchMp4Files,
@ -544,5 +603,6 @@ module.exports = (s,config,lang) => {
reEncodeVideoAndBinOriginal,
reEncodeVideoAndBinOriginalAddToQueue,
archiveVideo,
sliceVideo,
}
}

View File

@ -31,6 +31,7 @@ module.exports = function(s,config,lang,app,io){
destroySubstreamProcess,
} = require('./monitor/utils.js')(s,config,lang)
const {
sliceVideo,
archiveVideo,
reEncodeVideoAndReplace,
reEncodeVideoAndBinOriginalAddToQueue,
@ -1816,6 +1817,15 @@ module.exports = function(s,config,lang,app,io){
const originalFileName = `${s.formattedTime(r.time)+'.'+r.ext}`
var details = s.parseJSON(r.details) || {}
switch(req.params.mode){
case'slice':
const startTime = s.getPostData(req,'startTime');
const endTime = s.getPostData(req,'endTime');
const sliceResponse = await sliceVideo(r,{
startTime: startTime,
endTime: endTime,
});
response = sliceResponse
break;
case'archive':
response.ok = true
const unarchive = s.getPostData(req,'unarchive') == '1';

View File

@ -0,0 +1,45 @@
#tab-studio {
}
#studioTimelineStrip {
height: 60px;
background: rgba(0,0,0,0.3);
position: relative;
border-radius: 5px;
}
#studio-slice-selection {
transition: none;
height: 100%;
background: rgb(216 230 249 / 70%);
border-radius: 5px;
position: absolute;
cursor: e-resize;
}
#studio-time-ticks {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#studio-time-ticks .tick {
position: absolute;
border-left:1px solid rgba(0,0,0,0.1);
height: 100%;
}
#studio-seek-tick {
position: absolute;
border-left: 2px solid #fffc00;
border-radius: 5px;
transition: none;
height: 80%;
top: 10%;
left: 10%;
}
#studio-time-ticks .tick span {
left: -50%;
position: relative;
font-size: 80%;
font-family: monospace;
}

270
web/assets/js/bs5.studio.js Normal file
View File

@ -0,0 +1,270 @@
$(document).ready(function(){
var theEnclosure = $('#tab-studio')
var viewingCanvas = $('#studioViewingCanvas')
var timelineStrip = $('#studioTimelineStrip')
var seekTick = $('#studio-seek-tick')
var completedVideosList = $('#studio-completed-videos')
var timelineStripTimeTicksContainer = $('#studio-time-ticks')
var timelineStripSliceSelection = $('#studio-slice-selection')
var stripWidth = timelineStrip.width()
var loadedVideoForSlicer = null
var loadedVideoElement = null
var timelineStripMousemoveX = 0
var timelineStripMousemoveY = 0
var userInvokedPlayState = false
var slicerQueue = {}
var changeTimeout
function setMoveTimeout(){
clearTimeout(changeTimeout)
changeTimeout = setTimeout(function(){
changeTimeout = null
},500)
}
function initStudio(){
var lastStartTime = 0
var lastEndTime = 0
function onChange(){
setMoveTimeout()
var data = getSliceSelection()
var startTime = data.startTimeSeconds
var endTime = data.endTimeSeconds
if(lastStartTime === startTime && lastEndTime !== endTime){
setSeekPosition(endTime)
}else{
setSeekPosition(startTime)
}
lastStartTime = parseFloat(startTime)
lastEndTime = parseFloat(endTime)
setSeekRestraintOnVideo()
}
timelineStripSliceSelection.resizable({
containment: '#studioTimelineStrip',
handles: "e,w",
});
timelineStripSliceSelection.resize(function(){
setMoveTimeout()
onChange()
})
timelineStripSliceSelection.draggable({
containment: '#studioTimelineStrip',
axis: "x",
start: function(){
changeTimeout = true
},
drag: onChange,
stop: function(){
changeTimeout = null
}
});
}
function validateTimeSlot(timeValue){
var roundedValue = Math.round(timeValue)
return `${roundedValue}`.length === 1 ? `0${roundedValue}` : roundedValue
}
function getTimeInfo(timePx){
var timeDifference = loadedVideoElement.duration
var timePercent = timePx / stripWidth * 100
var timeSeconds = (timeDifference * (timePercent / 100))
// var timeMinutes = parseInt(timeSeconds / 60)
// var timeLastSeconds = timeSeconds - (timeMinutes * 60)
// var timestamp = `00:${validateTimeSlot(timeMinutes)}:${validateTimeSlot(timeLastSeconds)}`
return {
timePercent,
timeSeconds,
// timeMinutes,
// timeLastSeconds,
// timestamp,
}
}
function getSliceSelection(){
var amountOfSecondsBetween = loadedVideoElement.duration
//
var startTimePx = timelineStripSliceSelection.position().left
var startTimeInfo = getTimeInfo(startTimePx)
//
var endTimePx = startTimePx + timelineStripSliceSelection.width()
var endTimeInfo = getTimeInfo(endTimePx)
return {
// startTimestamp: startTimeInfo.timestamp,
// endTimestamp: endTimeInfo.timestamp,
startTimeSeconds: startTimeInfo.timeSeconds,
endTimeSeconds: endTimeInfo.timeSeconds
}
}
function sliceVideo(){
var video = Object.assign({},loadedVideoForSlicer)
var monitorId = video.mid
var filename = video.filename
var groupKey = video.ke
const sliceInfo = getSliceSelection()
$.get(`${getApiPrefix('videos')}/${monitorId}/${filename}/slice?startTime=${sliceInfo.startTimeSeconds}&endTime=${sliceInfo.endTimeSeconds}`,function(data){
console.log('sliceVideo',data)
})
}
function drawTimeTicks(video){
var amountOfSecondsBetween = loadedVideoElement.duration
var tickDivisor = amountOfSecondsBetween > 60 * 60 ? 500 : amountOfSecondsBetween > 60 ? 100 : amountOfSecondsBetween > 30 ? 20 : 2
var numberOfTicks = amountOfSecondsBetween / tickDivisor
var tickStripWidth = timelineStripTimeTicksContainer.width()
var tickSpacingWidth = tickStripWidth / numberOfTicks
var tickSpacingPercent = tickSpacingWidth / tickStripWidth * 100
var tickHtml = ''
for (let i = 1; i < numberOfTicks; i++) {
var tickPercent = tickSpacingPercent * i
var numberOfSecondsForTick = parseInt(amountOfSecondsBetween / numberOfTicks) * i;
tickHtml += `<div class="tick" style="left:${tickPercent}%"><span>${numberOfSecondsForTick}s</span></div>`;
}
timelineStripTimeTicksContainer.html(tickHtml)
}
function createVideoElement(video){
var html = `<video class="video_video" controls src="${video.href}"></video>`
viewingCanvas.html(html)
var videoElement = theEnclosure.find('video')
loadedVideoElement = videoElement[0]
}
function updateSeekTickPosition(){
const percentMoved = loadedVideoElement.currentTime / loadedVideoElement.duration * 100
seekTick.css('left',`${percentMoved}%`)
}
function setSeekRestraintOnVideo(){
var data = getSliceSelection()
var startTime = data.startTimeSeconds
var endTime = data.endTimeSeconds
console.log(data)
loadedVideoElement.ontimeupdate = (event) => {
updateSeekTickPosition()
if(loadedVideoElement.currentTime <= startTime){
loadedVideoElement.currentTime = startTime
}else if(loadedVideoElement.currentTime >= endTime){
loadedVideoElement.currentTime = startTime
// pauseVideo()
}
};
loadedVideoElement.onplay = (event) => {
userInvokedPlayState = true
togglePlayPauseIcon()
}
loadedVideoElement.onpause = (event) => {
userInvokedPlayState = false
togglePlayPauseIcon()
}
}
function pauseVideo(){
loadedVideoElement.pause()
}
function playVideo(){
loadedVideoElement.play()
}
function togglePlayPause(){
try{
if(userInvokedPlayState){
pauseVideo()
}else{
playVideo()
}
}catch(err){
console.log(err)
}
}
function togglePlayPauseIcon(){
var iconEl = theEnclosure.find('.play-preview i')
if(!userInvokedPlayState){
iconEl.addClass('fa-play').removeClass('fa-pause')
}else{
iconEl.addClass('fa-pause').removeClass('fa-play')
}
}
function setSeekPosition(secondsIn){
loadedVideoElement.currentTime = parseFloat(secondsIn) || 1
try{
loadedVideoElement.play()
}catch(err){
}
pauseVideo()
}
function loadVideoIntoSlicer(video){
loadedVideoForSlicer = Object.assign({},video)
var startTime = new Date(loadedVideoForSlicer.time)
var endTime = new Date(loadedVideoForSlicer.end)
createVideoElement(video)
drawTimeTicks(video)
completedVideosList.empty()
setSeekRestraintOnVideo()
}
function drawCompletedVideoRow(file){
var videoEndpoint = getApiPrefix(`fileBin`) + '/' + file.mid + '/' + file.name
var html = `<div class="card bg-dark mb-3" data-mid="${file.mid}" data-ke="${file.ke}" data-filename="${file.name}">
<div class="card-body">
<div class="d-flex flex-row">
<div class="flex-grow-1 text-white">
${file.name}
</div>
<div>
<a class="btn btn-sm btn-primary open-fileBin-video" href="${videoEndpoint}" title="${lang.Play}"><i class="fa fa-play"></i></a>
<a class="btn btn-sm btn-success" href="${videoEndpoint}" title="${lang.Download}" download><i class="fa fa-download"></i></a>
</div>
</div>
</div>
</div>`
completedVideosList.append(html)
}
function seekByTimelineClick(e){
if(!changeTimeout){
var currentlyPlaying = !!userInvokedPlayState
var leftOffset = e.pageX - timelineStripSliceSelection.offset().left
var tickPx = timelineStripSliceSelection.position().left + leftOffset
var timeSeconds = getTimeInfo(tickPx).timeSeconds
setSeekPosition(timeSeconds)
if(currentlyPlaying){
playVideo()
}
}
}
onWebSocketEvent(function(data){
switch(data.f){
case'fileBin_item_added':
if(data.slicedVideo){
drawCompletedVideoRow(data)
}
break;
}
})
addOnTabAway('studio', function () {
loadedVideoElement.pause()
})
$(window).resize(function(){
drawTimeTicks(loadedVideoForSlicer)
})
timelineStrip.resize(function(){
stripWidth = timelineStrip.width()
})
timelineStripSliceSelection.mousedown(seekByTimelineClick)
$('body')
.on('click','.open-video-studio',function(e){
e.preventDefault()
var el = $(this).parents('[data-mid]')
var monitorId = el.attr('data-mid')
var videoTime = el.attr('data-time')
var video = loadedVideosInMemory[`${monitorId}${videoTime}`]
openTab('studio')
loadVideoIntoSlicer(video)
return false;
});
theEnclosure
.on('click','.slice-video',function(){
sliceVideo()
})
.on('click','.play-preview',function(){
togglePlayPause()
togglePlayPauseIcon()
})
.on('mouseup','.ui-resizable-handle.ui-resizable-e',function(){
var data = getSliceSelection()
var startTime = data.startTimeSeconds
setSeekPosition(startTime)
});
initStudio()
})

View File

@ -376,18 +376,20 @@ $(document).ready(function(e){
window.askedForTimelapseVideoBuild = false
break;
case'fileBin_item_added':
var saveBuiltVideo = dashboardOptions().switches.timelapseSaveBuiltVideo
let statusText = `${lang['Done!']}`
onTimelapseVideoBuildComplete(data)
if(data.timelapseVideo && saveBuiltVideo === 1){
downloadTimelapseVideo(data)
statusText = lang['Downloaded!']
if(data.timelapseVideo){
var saveBuiltVideo = dashboardOptions().switches.timelapseSaveBuiltVideo
let statusText = `${lang['Done!']}`
onTimelapseVideoBuildComplete(data)
if(saveBuiltVideo === 1){
downloadTimelapseVideo(data)
statusText = lang['Downloaded!']
}
setDownloadButtonLabel(statusText, '')
var progressItem = sideLinkListBox.find(`[data-mid="${data.mid}"][data-ke="${data.mid}"][data-name="${data.name}"]`)
progressItem.find('.row-status').text(statusText)
progressItem.find('.dot').removeClass('dot-orange').addClass('dot-green')
progressItem.find('.download-button').show()
}
setDownloadButtonLabel(statusText, '')
var progressItem = sideLinkListBox.find(`[data-mid="${data.mid}"][data-ke="${data.mid}"][data-name="${data.name}"]`)
progressItem.find('.row-status').text(statusText)
progressItem.find('.dot').removeClass('dot-orange').addClass('dot-green')
progressItem.find('.download-button').show()
break;
case'timelapse_build_percent':
var progressItem = sideLinkListBox.find(`[data-mid="${data.mid}"][data-ke="${data.mid}"][data-name="${data.name}"]`)

7
web/assets/vendor/jquery-ui.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,7 @@
<link rel="stylesheet" href="<%-window.libURL%>assets/vendor/daterangepicker.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/vendor/gridstack.min.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/vendor/gridstack-extra.min.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/vendor/jquery-ui.min.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/css/clock.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/css/bs5.liveGrid.css">
<link rel="stylesheet" href="<%-window.libURL%>assets/css/bs5.regionEditor.css">

View File

@ -28,6 +28,7 @@
'home/eventListWithPics',
'home/fileBin',
'home/videosTable',
'home/studio',
'confirm',
'home/help',
]).forEach(function(block){ %>

View File

@ -0,0 +1,17 @@
<main class="page-tab pt-3" id="tab-studio">
<div class="row <%- define.Theme.isDark ? `dark` : '' %>" id="studio">
<%
var drawBlock
var buildOptions
%>
<%
include fieldBuilders.ejs
%>
<% Object.keys(define['Studio'].blocks).forEach(function(blockKey){
var theBlock = define['Studio'].blocks[blockKey]
drawBlock(theBlock)
}) %>
</div>
</main>
<link rel="stylesheet" href="<%-window.libURL%>assets/css/bs5.studio.css">
<script src="<%-window.libURL%>assets/js/bs5.studio.js"></script>