homebridge-camera-ui
Version:
User Interface for RTSP capable cameras with HSV support.
356 lines (301 loc) • 12.3 kB
JavaScript
/* eslint-disable unicorn/prevent-abbreviations */
'use-strict';
import * as cameraUtils from 'camera.ui/src/controller/camera/utils/camera.utils.js';
import Logger from '../../services/logger/logger.service.js';
const MAX_RECORDING_TIME = 3;
const compatibleAudio = /(aac)/;
export default class RecordingDelegate {
constructor(api, accessory, config, cameraUi, handler) {
this.api = api;
this.log = Logger.log;
this.config = config;
this.accessory = accessory;
this.handler = handler;
this.cameraUi = cameraUi;
this.configuration = {};
this.handlingStreamingRequest = false;
this.session = null;
this.closeReason = null;
this.forceCloseTimer = null;
}
// eslint-disable-next-line no-unused-vars
async *handleRecordingStreamRequest(streamId) {
this.handlingStreamingRequest = true;
this.closeReason = null;
this.log.debug('Video fragments requested from HSV', this.accessory.displayName);
const hksvSource = this.accessory.context.config.hksvConfig?.source;
const videoConfig = cameraUtils.generateVideoConfig(this.accessory.context.config.videoConfig);
const controller = this.cameraUi.cameraController.get(this.accessory.displayName);
let prebufferInput = false;
let ffmpegInput = [...cameraUtils.generateInputSource(videoConfig, hksvSource).split(/\s+/)];
ffmpegInput = cameraUtils.checkDeprecatedFFmpegArguments(controller?.media?.codecs?.ffmpegVersion, ffmpegInput);
if (this.accessory.context.config.prebuffering && controller?.prebuffer && !hksvSource) {
if (hksvSource) {
this.log.debug('Skipping prebuffered video due to HKSV source', this.accessory.displayName);
} else {
try {
this.log.debug('Setting prebuffer stream as input', this.accessory.displayName);
const input = await controller.prebuffer.getVideo({
container: 'mp4',
prebuffer: this.accessory.context.config.prebufferLength,
});
ffmpegInput = prebufferInput = [...input];
} catch (error) {
this.log.warn(
`Can not access prebuffer stream, skipping: ${error}`,
this.accessory.displayName,
'Homebridge'
);
}
}
}
const videoArguments = [];
const audioArguments = [];
if (!prebufferInput && videoConfig.mapvideo) {
videoArguments.push('-map', videoConfig.mapvideo);
}
if (!prebufferInput && videoConfig.mapaudio) {
audioArguments.push('-map', videoConfig.mapaudio);
}
let acodec = this.accessory.context.config.hksvConfig?.acodec || videoConfig.acodec;
let vcodec = this.accessory.context.config.hksvConfig?.vcodec || videoConfig.vcodec;
let audioEnabled = this.accessory.context.config.hksvConfig?.audio || videoConfig.audio;
let audioSourceFound = controller?.media.codecs.audio.length;
let probeAudio = controller?.media.codecs.audio;
let incompatibleAudio = audioSourceFound && !probeAudio.some((codec) => compatibleAudio.test(codec));
//let probeTimedOut = controller?.media.codecs.timedout;
if (audioEnabled) {
if (audioSourceFound) {
if (incompatibleAudio && acodec !== 'libfdk_aac') {
this.log.debug(
`Incompatible audio stream detected ${probeAudio}, transcoding with "libfdk_aac"..`,
this.accessory.displayName,
'Homebridge'
);
acodec = 'libfdk_aac';
//vcodec = vcodec === 'copy' ? 'libx264' : vcodec;
} else if (!incompatibleAudio && !acodec) {
this.log.debug('Compatible audio stream detected, copying..');
acodec = 'copy';
}
} else {
this.log.debug(
'Replacing audio with a dummy track, audio source not found or timed out during probe stream (recording). Disable "audio" to mute this warning.',
this.accessory.displayName,
'Homebridge'
);
ffmpegInput.push('-f', 'lavfi', '-i', 'anullsrc=cl=1', '-shortest');
acodec = 'libfdk_aac';
}
if (acodec !== 'copy') {
let samplerate;
switch (this.configuration.audioCodec.samplerate) {
case this.api.hap.AudioRecordingSamplerate.KHZ_8:
samplerate = '8';
break;
case this.api.hap.AudioRecordingSamplerate.KHZ_16:
samplerate = '16';
break;
case this.api.hap.AudioRecordingSamplerate.KHZ_24:
samplerate = '24';
break;
case this.api.hap.AudioRecordingSamplerate.KHZ_32:
samplerate = '32';
break;
case this.api.hap.AudioRecordingSamplerate.KHZ_44_1:
samplerate = '44.1';
break;
case this.api.hap.AudioRecordingSamplerate.KHZ_48:
samplerate = '48';
break;
default:
throw new Error(`Unsupported audio samplerate: ${this.configuration.audioCodec.samplerate}`);
}
audioArguments.push(
'-bsf:a',
'aac_adtstoasc',
'-acodec',
'libfdk_aac',
...(this.configuration.audioCodec.type === this.api.hap.AudioRecordingCodecType.AAC_LC
? ['-profile:a', 'aac_low']
: ['-profile:a', 'aac_eld']),
'-ar',
`${samplerate}k`,
'-b:a',
`${this.configuration.audioCodec.bitrate}k`,
'-ac',
`${this.configuration.audioCodec.audioChannels}`
);
} else {
vcodec = 'copy';
audioArguments.push('-bsf:a', 'aac_adtstoasc', '-acodec', 'copy');
}
} else {
audioArguments.push('-an');
}
const profile =
this.configuration.videoCodec.profile === this.api.hap.H264Profile.HIGH
? 'high'
: this.configuration.videoCodec.profile === this.api.hap.H264Profile.MAIN
? 'main'
: 'baseline';
const level =
this.configuration.videoCodec.level === this.api.hap.H264Level.LEVEL4_0
? '4.0'
: this.configuration.videoCodec.level === this.api.hap.H264Level.LEVEL3_2
? '3.2'
: '3.1';
const width = this.accessory.context.config.hksvConfig?.maxWidth || this.configuration.videoCodec.resolution[0];
const height = this.accessory.context.config.hksvConfig?.maxHeight || this.configuration.videoCodec.resolution[1];
const fps = this.accessory.context.config.hksvConfig?.maxFPS || 25; //this.configuration.videoCodec.resolution[2];
const videoBitrate =
this.accessory.context.config.hksvConfig?.maxBitrate || this.configuration.videoCodec.parameters.bitRate;
const iFrameInterval = this.configuration.videoCodec.parameters.iFrameInterval;
if (vcodec === 'copy') {
videoArguments.push('-vcodec', 'copy');
} else {
videoArguments.push(
//'-an',
'-sn',
'-dn',
'-vcodec',
vcodec,
'-pix_fmt',
'yuv420p',
'-profile:v',
profile,
'-level:v',
level,
'-b:v',
`${videoBitrate}k`,
//'-bufsize',
//`${2 * videoBitrate}k`,
//'-maxrate',
//`${videoBitrate}k`,
//'-r',
//`${fps}`,
'-vf',
//`scale=w=${width}:h=${height}:force_original_aspect_ratio=1,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,
`framerate=fps=${fps}*1000/1001,scale=w=${width}:h=${height}:force_original_aspect_ratio=1,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,
//'-fflags',
//'+genpts+discardcorrupt',
//'-reset_timestamps',
//'1',
'-force_key_frames',
`expr:gte(t,n_forced*${iFrameInterval / 1000})`
//'-vsync',
//'2'
);
if (this.accessory.context.config.hksvConfig?.encoderOptions) {
videoArguments.push(...this.accessory.context.config.hksvConfig.encoderOptions.split(' '));
}
}
this.session = await cameraUtils.startFFMPegFragmetedMP4Session(
this.accessory.displayName,
this.accessory.context.config.videoConfig,
this.config.options.videoProcessor,
ffmpegInput,
audioArguments,
videoArguments
);
this.log.debug('Recording started', this.accessory.displayName);
const cameraSettings = await this.cameraUi?.database?.interface.chain
.get('settings')
.get('cameras')
.find({ name: this.accessory.displayName })
.value();
const timer = cameraSettings?.videoanalysis?.forceCloseTimer || MAX_RECORDING_TIME;
if (timer > 0) {
this.forceCloseTimer = setTimeout(() => {
this.log.warn(
`The recording process has been running for ${timer} minutes and is now being forced closed!`,
this.accessory.displayName
);
this.accessory
.getService(this.api.hap.Service.MotionSensor)
.getCharacteristic(this.api.hap.Characteristic.MotionDetected)
.updateValue(false);
}, timer * 60 * 1000);
}
let pending = [];
let filebuffer = Buffer.alloc(0);
try {
for await (const box of this.session.generator) {
const { header, type, data } = box;
pending.push(header, data);
const motionDetected = this.accessory
.getService(this.api.hap.Service.MotionSensor)
.getCharacteristic(this.api.hap.Characteristic.MotionDetected).value;
if (type === 'moov' || type === 'mdat') {
const fragment = Buffer.concat(pending);
filebuffer = Buffer.concat([filebuffer, Buffer.concat(pending)]);
pending = [];
yield {
data: fragment,
isLast: !motionDetected,
};
if (!motionDetected) {
this.log.debug('Ending recording session due to motion stopped!', this.accessory.displayName);
break;
}
}
}
} catch (error) {
if (!error.message?.startsWith('FFMPEG')) {
this.log.info('Encountered unexpected error on generator');
this.log.error(error);
} else {
this.log.debug(error.message || error, this.accessory.displayName);
}
} finally {
if (this.closeReason && this.closeReason !== this.api.hap.HDSProtocolSpecificErrorReason.NORMAL) {
this.log.warn(
`The recording process was aborted by HSV with reason "${
this.api.hap.HDSProtocolSpecificErrorReason[this.closeReason]
}"`,
this.accessory.displayName,
'Homebridge'
);
} else if (filebuffer.length > 0) {
this.log.debug('Recording completed (HSV)', this.accessory.displayName);
this.cameraUi.eventController.triggerEvent('HSV', this.accessory.displayName, true, filebuffer, 'Video');
}
}
}
// eslint-disable-next-line no-unused-vars
updateRecordingActive(active) {
//this.log.debug(`Recording: ${active}`, this.accessory.displayName);
}
updateRecordingConfiguration(configuration) {
//this.log.debug(`Updating recording configuration: ${JSON.stringify(configuration)}`, this.accessory.displayName);
this.configuration = configuration;
}
async closeRecordingStream(streamId, reason) {
this.log.info('Closing recording process', this.accessory.displayName);
if (this.session) {
this.session.socket?.destroy();
this.session.cp?.kill('SIGKILL');
this.session = undefined;
}
if (this.forceCloseTimer) {
clearTimeout(this.forceCloseTimer);
this.forceCloseTimer = null;
}
const motionState = this.accessory
.getService(this.api.hap.Service.MotionSensor)
.getCharacteristic(this.api.hap.Characteristic.MotionDetected).value;
if (motionState) {
// If HSV interrupts the recording with an error,
// the process is restarted because the motion sensor still indicates motion.
// Most likely, this is due to an incorrect camera configuration.
// To avoid a restart loop, the motion sensor is reset.
this.log.debug('Resetting motion sensor, because HSV closed the recording process', this.accessory.displayName);
await this.handler.handle('motion', this.accessory.displayName, false);
}
this.closeReason = reason;
this.handlingStreamingRequest = false;
}
acknowledgeStream(streamId) {
this.closeRecordingStream(streamId);
}
}