camera.ui
Version:
NVR like user interface for RTSP capable cameras.
515 lines (410 loc) • 14.2 kB
JavaScript
'use-strict';
import isEqual from 'lodash/isEqual.js';
import moment from 'moment';
import P2P from 'pipe2pam';
import PamDiff from 'pam-diff';
import { spawn } from 'child_process';
import * as cameraUtils from '../utils/camera.utils.js';
import Ping from '../../../common/ping.js';
import Database from '../../../api/database.js';
import LoggerService from '../../../services/logger/logger.service.js';
import ConfigService from '../../../services/config/config.service.js';
import MotionController from '../../motion/motion.controller.js';
import Socket from '../../../api/socket.js';
const { log } = LoggerService;
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isUINT = (value) => Number.isInteger(value) && value >= 0;
const FFMPEG_MODE = 'rgb24'; // gray, rgba, rgb24
const FFMPEG_RESOLUTION = '640:360';
const FFMPEG_FPS = '2';
const DEFAULT_FORCECLOSE_TIME = 3;
const DEFAULT_DWELL_TIME = 60;
const DEFAULT_DIFFERENCE = 5; // 1 - 255
const DEFAULT_SENSITIVITY = 75; // 0 - 100
const DEFAULT_ZONE = [
{
name: 'region0',
finished: true,
coords: [
[0, 100],
[0, 0],
[100, 0],
[100, 100],
],
},
];
export default class VideoAnalysisService {
#camera;
#controller;
#prebufferService;
#mediaService;
videoanalysisSession = null;
killed = false;
destroyed = false;
cameraState = true;
restartTimer = null;
watchdog = null;
motionEventTimeout = null;
forceCloseTimeout = null;
finishLaunching = false;
constructor(camera, prebufferService, mediaService, controller) {
//log.debug('Initializing video analysis', camera.name);
this.#camera = camera;
this.#controller = controller;
this.#prebufferService = prebufferService;
this.#mediaService = mediaService;
this.cameraName = camera.name;
this.#controller?.on('finishLaunching', () => {
if (camera.videoanalysis?.active) {
log.debug('Videoanalysis finished launching', this.cameraName);
}
this.finishLaunching = true;
});
}
reconfigure(camera) {
const oldVideoConfig = this.#camera.videoConfig;
const newVideoConfig = camera.videoConfig;
this.#camera = camera;
this.cameraName = camera.name;
if (!isEqual(oldVideoConfig, newVideoConfig) && this.videoanalysisSession) {
log.info('Videoanalysis: Video configuration changed! Restarting...', this.cameraName);
this.restart();
}
}
changeSettings(zones = [], sensitivity, difference, dwellTimer, forceCloseTimer) {
if (this.videoanalysisSession?.pamDiff) {
const diff = difference >= 0 && difference <= 255 ? difference : DEFAULT_DIFFERENCE;
const percent = sensitivity >= 0 && sensitivity <= 100 ? sensitivity : DEFAULT_SENSITIVITY;
const regions = this.#createRegions(zones, percent, diff);
const dwellTime = dwellTimer >= 15 && dwellTimer <= 180 ? dwellTimer : DEFAULT_DWELL_TIME;
const forceCloseTime = forceCloseTimer >= 0 && forceCloseTimer <= 10 ? forceCloseTimer : DEFAULT_DWELL_TIME;
this.videoanalysisSession.pamDiff.dwellTime = dwellTime;
this.videoanalysisSession.pamDiff.forceCloseTime = forceCloseTime;
this.videoanalysisSession.pamDiff.setDifference(diff);
this.videoanalysisSession.pamDiff.setPercent(100 - percent);
this.videoanalysisSession.pamDiff.setRegions(regions.length > 0 ? regions : null);
}
}
async start() {
try {
this.resetVideoAnalysis();
const videoConfig = cameraUtils.generateVideoConfig(this.#camera.videoConfig);
let ffmpegInput = cameraUtils.generateInputSource(videoConfig, videoConfig.subSource).split(/\s+/);
ffmpegInput = cameraUtils.checkDeprecatedFFmpegArguments(this.#mediaService.codecs.ffmpegVersion, ffmpegInput);
let withPrebuffer = false;
if (this.#camera.prebuffering && videoConfig.subSource === videoConfig.source) {
try {
ffmpegInput = withPrebuffer = await this.#prebufferService.getVideo();
} catch {
// retry
log.debug(
'Can not start videoanalysis, prebuffer process not yet started, retrying in 10s..',
this.cameraName
);
this.stop(true);
await timeout(10000);
this.start();
return;
}
} else {
this.cameraState = await this.#pingCamera();
if (!this.cameraState) {
log.warn(
'Can not start video analysis, camera not reachable. Trying again in 60s..',
this.cameraName,
'videoanalysis'
);
this.stop(true);
await timeout(60000);
this.start();
return;
}
}
this.videoanalysisSession = await this.#startVideoAnalysis(ffmpegInput, videoConfig);
if (!withPrebuffer) {
const timer = this.#millisUntilTime('04:00');
log.debug(
`Videoanalysis scheduled for restart at 4AM: ${Math.round(timer / 1000 / 60)} minutes`,
this.cameraName
);
this.restartTimer = setTimeout(() => {
log.info('Sheduled restart of videoanalysis is executed...', this.cameraName);
this.restart();
}, timer);
}
} catch (error) {
if (error) {
log.info('An error occured during starting videoanalysis!', this.cameraName, 'videoanalysis');
log.error(error, this.cameraName, 'videoanalysis');
}
}
}
resetVideoAnalysis() {
this.stop(true);
this.videoanalysisSession = null;
this.killed = false;
this.cameraState = true;
this.restartTimer = null;
this.watchdog = null;
this.motionEventTimeout = null;
this.forceCloseTimeout = null;
}
destroy() {
this.destroyed = true;
this.resetVideoAnalysis();
}
stop(killed) {
if (this.destroyed) {
return;
}
if (this.videoanalysisSession) {
if (killed) {
this.killed = true;
}
if (this.watchdog) {
clearTimeout(this.watchdog);
}
if (this.motionEventTimeout || this.forceCloseTimeout) {
this.#triggerMotion(false, {
time: new Date(),
event: 'killed',
});
}
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
this.videoanalysisSession.cp?.kill('SIGKILL');
this.videoanalysisSession = null;
}
}
async restart() {
log.info('Restart videoanalysis session..', this.cameraName);
this.stop(true);
await timeout(14000);
await this.start();
}
async #startVideoAnalysis(input, videoConfig) {
if (this.videoanalysisSession) {
return this.videoanalysisSession;
}
let isActive = true;
log.debug('Start videoanalysis...', this.cameraName);
const videoArguments = ['-an', '-vcodec', 'pam'];
if (!this.#camera.prebuffering && videoConfig.mapvideo) {
videoArguments.unshift('-map', videoConfig.mapvideo);
}
const ffmpegArguments = [
'-hide_banner',
'-loglevel',
'error',
//'-hwaccel',
//'auto',
...input,
...videoArguments,
'-pix_fmt',
FFMPEG_MODE,
'-f',
'image2pipe',
'-vf',
`fps=${FFMPEG_FPS},scale=${FFMPEG_RESOLUTION}`,
'pipe:1',
];
log.debug(
`Videoanalysis command: ${ConfigService.ui.options.videoProcessor} ${ffmpegArguments.join(' ')}`,
this.cameraName
);
let errors = [];
const settings = await Database.interfaceDB.chain
.get('settings')
.get('cameras')
.find({ name: this.cameraName })
.cloneDeep()
.value();
const zones = settings?.videoanalysis?.regions || [];
const difference = settings?.videoanalysis?.difference || DEFAULT_DIFFERENCE;
const sensitivity = settings?.videoanalysis?.sensitivity || DEFAULT_SENSITIVITY;
const regions = this.#createRegions(zones, sensitivity, difference);
const dwellTime = settings?.videoanalysis?.dwellTimer || DEFAULT_DWELL_TIME;
const forceCloseTime = settings?.videoanalysis?.forceCloseTimer || DEFAULT_FORCECLOSE_TIME;
const p2p = new P2P();
const pamDiff = new PamDiff({
difference: difference,
percent: 100 - sensitivity,
regions: regions.length > 0 ? regions : null,
response: 'percent',
});
pamDiff.dwellTime = dwellTime;
pamDiff.forceCloseTime = forceCloseTime;
const restartWatchdog = () => {
clearTimeout(this.watchdog);
this.watchdog = setTimeout(() => {
log.error('Videoanalysis timed out... killing ffmpeg session', this.cameraName, 'videoanalysis');
cp?.kill('SIGKILL');
isActive = false;
}, 15000);
};
p2p.on('pam', () => {
restartWatchdog();
Socket.io.emit('videoanalysisStatus', {
camera: this.cameraName,
status: 'active',
});
isActive = true;
});
// eslint-disable-next-line no-unused-vars
pamDiff.on('diff', async (data) => {
if (!this.finishLaunching) {
return;
}
const event = data.trigger.map((data) => {
return {
zone: data.name,
percent: data.percent,
sensitivity: Math.round(100 - data.percent) + 1,
dwell: pamDiff.dwellTime,
forceClose: pamDiff.forceCloseTime,
};
});
if (!this.motionEventTimeout) {
this.#triggerMotion(true, event);
// forceClose after 3min
if (pamDiff.forceCloseTime > 0) {
this.forceCloseTimeout = setTimeout(() => {
this.#triggerMotion(false, {
time: new Date(),
event: `forceClose (${pamDiff.forceCloseTime}m)`,
});
}, pamDiff.forceCloseTime * 60 * 1000);
}
}
if (this.motionEventTimeout) {
log.debug(`New motion detected, resetting motion in ${pamDiff.dwellTime}s..`, this.cameraName);
log.debug(`Motion data: ${JSON.stringify(event)}}`, this.cameraName);
clearTimeout(this.motionEventTimeout);
this.motionEventTimeout = null;
}
this.motionEventTimeout = setTimeout(async () => {
this.#triggerMotion(false, {
time: new Date(),
event: `dwellTime (${pamDiff.dwellTime}s)`,
});
}, pamDiff.dwellTime * 1000);
});
const cp = spawn(ConfigService.ui.options.videoProcessor, ffmpegArguments, {
env: process.env,
});
cp.stderr.on('data', (data) => {
errors = errors.slice(-5);
errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' '));
});
cp.on('exit', (code, signal) => {
isActive = false;
if (code === 1 || (!this.killed && errors.length > 0)) {
errors.unshift(`FFmpeg videoanalysis process exited with error! (${signal})`);
log.error(errors.join(' - '), this.cameraName, 'videoanalysis');
} else {
log.debug('FFmpeg videoanalysis process exited (expected)', this.cameraName);
}
});
cp.on('close', () => {
isActive = false;
log.debug('Videoanalysis process closed', this.cameraName);
Socket.io.emit('videoanalysisStatus', {
camera: this.cameraName,
status: 'inactive',
});
if (!this.killed) {
if (errors.length > 0) {
log.error(errors.join(' - '), this.cameraName, 'videoanalysis');
}
this.restart();
}
});
cp.stdout.pipe(p2p).pipe(pamDiff);
restartWatchdog();
return {
isActive() {
return isActive;
},
cp,
pamDiff,
p2p,
};
}
async #triggerMotion(state, data) {
log.info(
`New message: Data: ${JSON.stringify(data)} - Motion: ${state ? 'detected' : 'resetted'} - Camera: ${
this.cameraName
}`,
'VIDEOANALYSIS'
);
if (!state) {
if (this.forceCloseTimeout) {
clearTimeout(this.forceCloseTimeout);
this.forceCloseTimeout = null;
}
if (this.motionEventTimeout) {
clearTimeout(this.motionEventTimeout);
this.motionEventTimeout = null;
}
}
await MotionController.handleMotion('motion', this.cameraName, state, 'videoanalysis');
}
#millisUntilTime(end = '04:00') {
const startTime = moment();
const endTime = moment(end, 'HH:mm');
if ((startTime.hour() >= 12 && endTime.hour() <= 12) || endTime.isBefore(startTime)) {
endTime.add(1, 'days');
}
return endTime.diff(startTime);
}
async #pingCamera() {
let state = true;
try {
state = await Ping.status(this.#camera, 1);
} catch (error) {
log.info('An error occured during pinging camera, skipping..', this.cameraName);
log.error(error, this.cameraName);
}
return state;
}
#createRegions(zones = [], sensitivity, difference) {
const dif = difference >= 0 && difference <= 255 ? difference : DEFAULT_DIFFERENCE;
const sens = sensitivity >= 0 && sensitivity <= 100 ? sensitivity : DEFAULT_SENSITIVITY;
const percent = 100 - sens;
if (!zones?.length) {
zones = DEFAULT_ZONE;
}
const regions = zones
?.map((zone, index) => {
if (zone.coords?.length > 2) {
return {
name: `region${index}`,
difference: dif,
percent: percent,
polygon: zone.coords
?.map((coord) => {
let x = coord[0] < 0 ? 0 : coord[0] > 100 ? 100 : coord[0];
let y = coord[1] < 0 ? 0 : coord[1] > 100 ? 100 : coord[1];
if (isUINT(x) && isUINT(y)) {
//x: 0 - 100 % => 0 - 640 px
//y: 0 - 100 % => 0 - 360 px
return {
x: Math.round((640 / 100) * x),
y: Math.round((360 / 100) * y),
};
}
})
.filter(Boolean),
};
}
})
.filter((zone) => zone?.polygon?.length > 2);
log.debug(
`Videoanalysis: Difference: ${dif} - Sensitivity: ${sens} - Zones: ${JSON.stringify(regions)}`,
this.cameraName
);
return regions;
}
}