camera.ui
Version:
NVR like user interface for RTSP capable cameras.
460 lines (364 loc) • 12.9 kB
JavaScript
'use-strict';
import fs from 'fs-extra';
import piexif from 'piexifjs';
import { spawn } from 'child_process';
import * as cameraUtils from '../controller/camera/utils/camera.utils.js';
import ConfigService from '../services/config/config.service.js';
import LoggerService from '../services/logger/logger.service.js';
import CameraController from '../controller/camera/camera.controller.js';
const { log } = LoggerService;
const snapshotCache = {};
const replaceJpegWithExifJPEG = (cameraName, filePath, label) => {
let jpeg;
try {
jpeg = fs.readFileSync(filePath);
} catch {
log.debug(`Can not read file ${filePath} to create EXIF information, skipping..`);
}
if (!jpeg) {
return;
}
const zeroth = {};
const data = jpeg.toString('binary');
zeroth[piexif.ImageIFD.XPTitle] = [...Buffer.from(cameraName, 'ucs2')];
zeroth[piexif.ImageIFD.XPComment] = [...Buffer.from(label, 'ucs2')];
zeroth[piexif.ImageIFD.XPAuthor] = [...Buffer.from('camera.ui', 'ucs2')];
const exifObject = { '0th': zeroth, Exif: {}, GPS: {} };
const exifbytes = piexif.dump(exifObject);
var newData = piexif.insert(exifbytes, data);
var newJpeg = Buffer.from(newData, 'binary');
fs.writeFileSync(filePath, newJpeg);
};
const storeFrameFromVideoBuffer = (camera, fileBuffer, outputPath) => {
return new Promise((resolve, reject) => {
const videoProcessor = ConfigService.ui.options.videoProcessor;
const videoConfig = cameraUtils.generateVideoConfig(camera.videoConfig);
const videoWidth = videoConfig.maxWidth;
const videoHeight = videoConfig.maxHeight;
const ffmpegArguments = [
'-hide_banner',
'-loglevel',
'error',
'-an',
'-sn',
'-dn',
'-y',
'-re',
'-i',
'-',
'-s',
`${videoWidth}x${videoHeight}`,
'-f',
'image2',
'-update',
'1',
];
ffmpegArguments.push(outputPath);
log.debug(`Snapshot command: ${videoProcessor} ${ffmpegArguments.join(' ')}`, camera.name);
const ffmpeg = spawn(videoProcessor, ffmpegArguments, { env: process.env });
let errors = [];
ffmpeg.stderr.on('data', (data) => {
errors = errors.slice(-5);
errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' '));
});
ffmpeg.on('error', (error) => reject(error));
ffmpeg.on('exit', (code, signal) => {
if (code === 1) {
errors.unshift(`FFmpeg snapshot process exited with error! (${signal})`);
reject(new Error(errors.join(' - ')));
} else {
log.debug('FFmpeg snapshot process exited (expected)', camera.name, 'ffmpeg');
log.debug(`Snapshot stored to: ${outputPath}`, camera.name);
resolve();
}
return;
});
ffmpeg.stdin.write(fileBuffer);
ffmpeg.stdin.destroy();
});
};
export const storeBuffer = async (
camera,
fileBuffer,
recordingPath,
fileName,
label,
isPlaceholder,
externRecording
) => {
let outputPath = `${recordingPath}/${fileName}${isPlaceholder ? '@2' : ''}.jpeg`;
// eslint-disable-next-line unicorn/prefer-ternary
if (externRecording) {
await storeFrameFromVideoBuffer(camera, fileBuffer, outputPath);
} else {
await fs.outputFile(outputPath, fileBuffer, { encoding: 'base64' });
}
replaceJpegWithExifJPEG(camera.name, outputPath, label);
};
export const getAndStoreSnapshot = (
camera,
fromSubSource,
recordingPath,
fileName,
label,
isPlaceholder,
storeSnapshot
) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
if (!storeSnapshot && snapshotCache[camera.name]) {
let store = fromSubSource ? snapshotCache[camera.name].sub : snapshotCache[camera.name].main;
if (store) {
const now = Date.now();
if ((now - store.time) / 1000 <= 10) {
log.debug('Snapshot requested (cache)', camera.name);
return resolve(store.buffer);
}
}
}
const videoProcessor = ConfigService.ui.options.videoProcessor;
const videoConfig = cameraUtils.generateVideoConfig(camera.videoConfig);
const videoWidth = videoConfig.maxWidth;
const videoHeight = videoConfig.maxHeight;
const destination = storeSnapshot ? `${recordingPath}/${fileName}${isPlaceholder ? '@2' : ''}.jpeg` : '-';
const controller = CameraController.cameras.get(camera.name);
let ffmpegInput = [
...cameraUtils.generateInputSource(videoConfig, fromSubSource ? videoConfig.subSource : false).split(/\s+/),
];
ffmpegInput = cameraUtils.checkDeprecatedFFmpegArguments(controller?.media?.codecs?.ffmpegVersion, ffmpegInput);
if (!fromSubSource && camera.prebuffering && controller?.prebuffer) {
try {
ffmpegInput = await controller.prebuffer.getVideo();
} catch {
// ignore
}
}
const ffmpegArguments = [
'-hide_banner',
'-loglevel',
'error',
'-y',
...ffmpegInput,
'-s',
`${videoWidth}x${videoHeight}`,
'-frames:v',
'2',
'-r',
'1',
'-update',
'1',
'-f',
'image2',
];
if (videoConfig.videoFilter) {
ffmpegArguments.push('-filter:v', videoConfig.videoFilter);
}
ffmpegArguments.push(destination);
log.debug(`Snapshot requested, command: ${videoProcessor} ${ffmpegArguments.join(' ')}`, camera.name);
const ffmpeg = spawn(videoProcessor, ffmpegArguments, { env: process.env });
let errors = [];
ffmpeg.stderr.on('data', (data) => {
errors = errors.slice(-5);
errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' '));
});
let imageBuffer = Buffer.alloc(0);
ffmpeg.stdout.on('data', (data) => {
imageBuffer = Buffer.concat([imageBuffer, data]);
if (storeSnapshot) {
log.debug(data.toString(), camera.name);
}
});
ffmpeg.on('error', (error) => reject(error));
ffmpeg.on('exit', (code, signal) => {
if (code === 1) {
errors.unshift(`FFmpeg snapshot process exited with error! (${signal})`);
reject(new Error(errors.join(' - ')));
} else if (!imageBuffer || (imageBuffer && imageBuffer.length <= 0)) {
errors.unshift('Image Buffer is empty!');
reject(new Error(errors.join(' - ')));
} else {
log.debug('FFmpeg snapshot process exited (expected)', camera.name, 'ffmpeg');
if (storeSnapshot) {
replaceJpegWithExifJPEG(camera.name, destination, label);
}
snapshotCache[camera.name] = {
time: Date.now(),
buffer: imageBuffer,
};
resolve(imageBuffer);
}
});
});
};
export const storeSnapshotFromVideo = async (camera, recordingPath, fileName, label) => {
return new Promise((resolve, reject) => {
const videoProcessor = ConfigService.ui.options.videoProcessor;
const videoName = `${recordingPath}/${fileName}.mp4`;
const destination = `${recordingPath}/${fileName}@2.jpeg`;
const ffmpegArguments = [
'-hide_banner',
'-loglevel',
'error',
'-y',
'-ss',
'00:00:03.500',
'-i',
videoName,
'-frames:v',
'1',
];
ffmpegArguments.push(destination);
log.debug(`Snapshot requested, command: ${videoProcessor} ${ffmpegArguments.join(' ')}`, camera.name);
const ffmpeg = spawn(videoProcessor, ffmpegArguments, { env: process.env });
let errors = [];
ffmpeg.stderr.on('data', (data) => {
errors = errors.slice(-5);
errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' '));
});
ffmpeg.on('error', (error) => reject(error));
ffmpeg.on('exit', (code, signal) => {
if (code === 1) {
errors.unshift(`FFmpeg snapshot process exited with error! (${signal})`);
reject(new Error(errors.join(' - ')));
} else {
log.debug('FFmpeg snapshot process exited (expected)', camera.name, 'ffmpeg');
replaceJpegWithExifJPEG(camera.name, destination, label);
resolve();
}
});
});
};
// eslint-disable-next-line no-unused-vars
export const storeVideo = (camera, recordingPath, fileName, recordingTimer) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
const videoProcessor = ConfigService.ui.options.videoProcessor;
const videoConfig = cameraUtils.generateVideoConfig(camera.videoConfig);
const videoName = `${recordingPath}/${fileName}.mp4`;
const videoWidth = videoConfig.maxWidth;
const videoHeight = videoConfig.maxHeight;
const vcodec = videoConfig.vcodec;
const controller = CameraController.cameras.get(camera.name);
let ffmpegInput = [...cameraUtils.generateInputSource(videoConfig).split(/\s+/)];
ffmpegInput = cameraUtils.checkDeprecatedFFmpegArguments(controller?.media?.codecs?.ffmpegVersion, ffmpegInput);
if (camera.prebuffering && controller?.prebuffer) {
try {
ffmpegInput = await controller.prebuffer.getVideo();
} catch {
// ignore
}
}
const ffmpegArguments = [
'-hide_banner',
'-loglevel',
'error',
'-nostdin',
'-y',
...ffmpegInput,
'-t',
recordingTimer.toString(),
'-strict',
'experimental',
'-threads',
'0',
'-s',
`${videoWidth}x${videoHeight}`,
'-vcodec',
`${vcodec}`,
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-crf',
'23',
];
if (videoConfig.mapvideo) {
ffmpegArguments.push('-map', videoConfig.mapvideo);
}
if (videoConfig.videoFilter) {
ffmpegArguments.push('-filter:v', videoConfig.videoFilter);
}
if (videoConfig.mapaudio) {
ffmpegArguments.push('-map', videoConfig.mapaudio);
}
ffmpegArguments.push(videoName);
log.debug(`Video requested, command: ${videoProcessor} ${ffmpegArguments.join(' ')}`, camera.name);
const ffmpeg = spawn(videoProcessor, ffmpegArguments, { env: process.env });
let errors = [];
ffmpeg.stderr.on('data', (data) => {
errors = errors.slice(-5);
errors.push(data.toString().replace(/(\r\n|\n|\r)/gm, ' '));
});
ffmpeg.on('error', (error) => reject(error));
ffmpeg.on('exit', (code, signal) => {
if (code === 1) {
errors.unshift(`FFmpeg video process exited with error! (${signal})`);
reject(new Error(errors.join(' - ')));
} else {
log.debug('FFmpeg video process exited (expected)', camera.name, 'ffmpeg');
log.debug(`Video stored to: ${videoName}`, camera.name);
resolve();
}
});
});
};
export const storeVideoBuffer = (camera, fileBuffer, recordingPath, fileName) => {
return new Promise((resolve, reject) => {
const videoName = `${recordingPath}/${fileName}.mp4`;
log.debug(`Storing video to: ${videoName}`, camera.name);
const writeStream = fs.createWriteStream(videoName);
writeStream.write(fileBuffer);
writeStream.end();
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
};
export const handleFragmentsRequests = async function* (camera) {
log.debug('Video fragments requested from interface', camera.name);
const videoConfig = cameraUtils.generateVideoConfig(camera.videoConfig);
const audioArguments = ['-acodec', 'copy'];
const videoArguments = ['-vcodec', 'copy'];
const controller = CameraController.cameras.get(camera.name);
let ffmpegInput = [...cameraUtils.generateInputSource(videoConfig).split(/\s+/)];
ffmpegInput = cameraUtils.checkDeprecatedFFmpegArguments(controller?.media?.codecs?.ffmpegVersion, ffmpegInput);
if (camera.prebuffering && controller?.prebuffer) {
try {
log.debug('Setting prebuffer stream as input', camera.name);
const input = await controller.prebuffer.getVideo({
container: 'mp4',
prebuffer: camera.prebufferLength,
});
ffmpegInput = [];
ffmpegInput.push(...input);
} catch (error) {
log.warn(`Can not access prebuffer stream, skipping: ${error}`, camera.name, 'ffmpeg');
}
}
const session = await cameraUtils.startFFMPegFragmetedMP4Session(
camera.name,
videoConfig.debug,
ConfigService.ui.options.videoProcessor,
ffmpegInput,
audioArguments,
videoArguments
);
log.debug('Recording started', camera.name);
const { socket, cp, generator } = session;
let pending = [];
try {
for await (const box of generator) {
const { header, type, data } = box;
pending.push(header, data);
if (type === 'moov' || type === 'mdat') {
const fileBuffer = pending;
pending = [];
yield fileBuffer;
}
}
} catch {
log.debug('Recording completed. (UI)', camera.name);
} finally {
socket.destroy();
cp.kill();
}
};