From 9073cf5573808ea0cbba79dfa7005f1884a064ff Mon Sep 17 00:00:00 2001 From: David Maher Date: Fri, 5 Mar 2021 19:13:47 -0500 Subject: [PATCH] Always log FFmpeg errors, snapshot improvements --- package-lock.json | 32 ++-- package.json | 6 +- src/ffmpeg.ts | 119 +++++++------ src/index.ts | 11 +- src/streamingDelegate.ts | 357 +++++++++++++++++++++------------------ 5 files changed, 276 insertions(+), 249 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15ffda65..da6b9e9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "homebridge-camera-ffmpeg", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "3.1.1", + "version": "3.1.2", "funding": [ { "type": "kofi", @@ -35,9 +35,9 @@ "@typescript-eslint/eslint-plugin": "^4.16.1", "@typescript-eslint/parser": "^4.16.1", "eslint": "^7.21.0", - "homebridge": "^1.3.1", + "homebridge": "^1.3.2", "rimraf": "^3.0.2", - "typescript": "^4.2.2" + "typescript": "^4.2.3" }, "engines": { "homebridge": ">=1.0.0", @@ -1654,9 +1654,9 @@ } }, "node_modules/homebridge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.3.1.tgz", - "integrity": "sha512-JTADGF5E2nE2WXD4KUh8qEd4zCw7Pmy/0Htw8+qReDgD2/ARcDjCDl4G8QFlYe6UAbY5kt6RNzVBO/ktUcEckQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.3.2.tgz", + "integrity": "sha512-/fnAVBJHGXiSaO5yYoDtg21OE6URPcJhdjo71Lepwjqg6881+eu7bTGEkXucGpkP2gd4HOV88uy9cDXfEavoBw==", "dev": true, "dependencies": { "chalk": "^4.1.0", @@ -3113,9 +3113,9 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "node_modules/typescript": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.2.tgz", - "integrity": "sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz", + "integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -4531,9 +4531,9 @@ } }, "homebridge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.3.1.tgz", - "integrity": "sha512-JTADGF5E2nE2WXD4KUh8qEd4zCw7Pmy/0Htw8+qReDgD2/ARcDjCDl4G8QFlYe6UAbY5kt6RNzVBO/ktUcEckQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.3.2.tgz", + "integrity": "sha512-/fnAVBJHGXiSaO5yYoDtg21OE6URPcJhdjo71Lepwjqg6881+eu7bTGEkXucGpkP2gd4HOV88uy9cDXfEavoBw==", "dev": true, "requires": { "chalk": "^4.1.0", @@ -5593,9 +5593,9 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "typescript": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.2.tgz", - "integrity": "sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz", + "integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==", "dev": true }, "unc-path-regex": { diff --git a/package.json b/package.json index 8c8feec4..54b7d681 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "displayName": "Homebridge Camera FFmpeg", "name": "homebridge-camera-ffmpeg", - "version": "3.1.1", + "version": "3.1.2", "description": "Homebridge Plugin Providing FFmpeg-based Camera Support", "main": "dist/index.js", "license": "ISC", @@ -63,9 +63,9 @@ "@typescript-eslint/eslint-plugin": "^4.16.1", "@typescript-eslint/parser": "^4.16.1", "eslint": "^7.21.0", - "homebridge": "^1.3.1", + "homebridge": "^1.3.2", "rimraf": "^3.0.2", - "typescript": "^4.2.2" + "typescript": "^4.2.3" }, "dependencies": { "ffmpeg-for-homebridge": "^0.0.9", diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 81c5d720..2a729b7e 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -1,23 +1,26 @@ -import { ChildProcess, spawn } from 'child_process'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; import { StreamRequestCallback } from 'homebridge'; +import readline from 'readline'; import { Writable } from 'stream'; import { Logger } from './logger'; import { StreamingDelegate } from './streamingDelegate'; -interface FfmpegProgress { - frame?: number; - fps?: number; - stream_0_0_q?: number; - bitrate?: number; - total_size?: number; - out_time_us?: number; - dup_frames?: number; - drop_frames?: number; - speed?: number; -} +type FfmpegProgress = { + frame: number; + fps: number; + stream_q: number; + bitrate: number; + total_size: number; + out_time_us: number; + out_time: string; + dup_frames: number; + drop_frames: number; + speed: number; + progress: string; +}; export class FfmpegProcess { - private readonly process: ChildProcess; + private readonly process: ChildProcessWithoutNullStreams; constructor(cameraName: string, sessionId: string, videoProcessor: string, ffmpegArgs: string, log: Logger, debug = false, delegate: StreamingDelegate, callback?: StreamRequestCallback) { @@ -27,38 +30,40 @@ export class FfmpegProcess { const startTime = Date.now(); this.process = spawn(videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); - this.process.stdout?.on('data', () => { - //const progress = this.parseProgress(data); - if (!started) { - started = true; - if (callback) { - callback(); - } - const runtime = (Date.now() - startTime) / 1000; - const message = 'Getting the first frames took ' + runtime + ' seconds.'; - if (runtime < 5) { - log.debug(message, cameraName, debug); - } else if (runtime < 22) { - log.warn(message, cameraName); - } else { - log.error(message, cameraName); + this.process.stdout.on('data', (data) => { + const progress = this.parseProgress(data); + if (progress) { + if (!started && progress.frame > 0) { + started = true; + const runtime = (Date.now() - startTime) / 1000; + const message = 'Getting the first frames took ' + runtime + ' seconds.'; + if (runtime < 5) { + log.debug(message, cameraName, debug); + } else if (runtime < 22) { + log.warn(message, cameraName); + } else { + log.error(message, cameraName); + } } } }); - this.process.stdin?.on('error', (error: Error) => { - if (!error.message.includes('EPIPE')) { - log.error(error.message, cameraName); + const stderr = readline.createInterface({ + input: this.process.stderr, + terminal: false + }); + stderr.on('line', (line: string) => { + if (callback) { + callback(); + callback = undefined; + } + if (line.match(/\[(panic|fatal|error)\]/)) { + log.error(line, cameraName); + } else if (debug) { + log.debug(line, cameraName, true); } }); - if (debug) { - this.process.stderr?.on('data', (data) => { - data.toString().split('\n').forEach((line: string) => { - log.debug(line, cameraName, true); - }); - }); - } this.process.on('error', (error: Error) => { - log.error('Failed to start stream: ' + error.message, cameraName); + log.error('FFmpeg process creation failed: ' + error.message, cameraName); if (callback) { callback(new Error('FFmpeg process creation failed')); } @@ -89,19 +94,29 @@ export class FfmpegProcess { const input = data.toString(); if (input.indexOf('frame=') == 0) { - const progress: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any - input.split('\n').forEach((line) => { - const split = line.split('=', 2); - - const key = split[0]; - const value = parseFloat(split[1]); - - if (!isNaN(value)) { - progress[key] = value; - } - }); + try { + const progress = new Map(); + input.split(/\r?\n/).forEach((line) => { + const split = line.split('=', 2); + progress.set(split[0], split[1]); + }); - return progress; + return { + frame: parseInt(progress.get('frame')!), + fps: parseFloat(progress.get('fps')!), + stream_q: parseFloat(progress.get('stream_0_0_q')!), + bitrate: parseFloat(progress.get('bitrate')!), + total_size: parseInt(progress.get('total_size')!), + out_time_us: parseInt(progress.get('out_time_us')!), + out_time: progress.get('out_time')!.trim(), + dup_frames: parseInt(progress.get('dup_frames')!), + drop_frames: parseInt(progress.get('drop_frames')!), + speed: parseFloat(progress.get('speed')!), + progress: progress.get('progress')!.trim() + }; + } catch { + return undefined; + } } else { return undefined; } @@ -111,7 +126,7 @@ export class FfmpegProcess { this.process.kill('SIGKILL'); } - public getStdin(): Writable | null { + public getStdin(): Writable { return this.process.stdin; } } diff --git a/src/index.ts b/src/index.ts index fd70fd95..c53c3cd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,11 +47,6 @@ class FfmpegPlatform implements DynamicPlatformPlugin { this.api = api; this.config = config as FfmpegPlatformConfig; - if (__dirname.includes('hoobs')) { - this.log.warn('This plugin has not been tested under HOOBS, it is highly recommended that ' + - 'you switch to Homebridge: https://git.io/Jtxb0'); - } - this.config.cameras?.forEach((cameraConfig: CameraConfig) => { let error = false; @@ -220,9 +215,8 @@ class FfmpegPlatform implements DynamicPlatformPlugin { doorbellTrigger.updateCharacteristic(hap.Characteristic.On, true); let timeoutConfig = this.cameraConfigs.get(accessory.UUID)?.motionTimeout; timeoutConfig = timeoutConfig && timeoutConfig > 0 ? timeoutConfig : 1; - const log = this.log; const timer = setTimeout(() => { - log.debug('Doorbell handler timeout.', accessory.displayName); + this.log.debug('Doorbell handler timeout.', accessory.displayName); this.doorbellTimers.delete(accessory.UUID); doorbellTrigger.updateCharacteristic(hap.Characteristic.On, false); }, timeoutConfig * 1000); @@ -273,9 +267,8 @@ class FfmpegPlatform implements DynamicPlatformPlugin { timeoutConfig = minimumTimeout; } if (timeoutConfig > 0) { - const log = this.log; const timer = setTimeout(() => { - log.debug('Motion handler timeout.', accessory.displayName); + this.log.debug('Motion handler timeout.', accessory.displayName); this.motionTimers.delete(accessory.UUID); motionSensor.updateCharacteristic(hap.Characteristic.MotionDetected, false); if (motionTrigger) { diff --git a/src/streamingDelegate.ts b/src/streamingDelegate.ts index 87a63bac..4b7bcdc7 100644 --- a/src/streamingDelegate.ts +++ b/src/streamingDelegate.ts @@ -47,7 +47,9 @@ type SessionInfo = { type ResolutionInfo = { width: number; height: number; - videoFilter: string; + videoFilter?: string; + snapFilter?: string; + resizeFilter?: string; }; type ActiveSession = { @@ -68,9 +70,9 @@ export class StreamingDelegate implements CameraStreamingDelegate { private snapshotPromise?: Promise; // keep track of sessions - pendingSessions: Record = {}; - ongoingSessions: Record = {}; - timeouts: Record = {}; + pendingSessions: Map = new Map(); + ongoingSessions: Map = new Map(); + timeouts: Map = new Map(); constructor(log: Logger, cameraConfig: CameraConfig, api: API, hap: HAP, videoProcessor?: string) { // eslint-disable-line @typescript-eslint/explicit-module-boundary-types this.log = log; @@ -129,16 +131,18 @@ export class StreamingDelegate implements CameraStreamingDelegate { } private determineResolution(request: SnapshotRequest | VideoInfo, isSnapshot: boolean): ResolutionInfo { - let width = request.width; - let height = request.height; + const resInfo: ResolutionInfo = { + width: request.width, + height: request.height + }; if (!isSnapshot) { if (this.videoConfig.maxWidth !== undefined && (this.videoConfig.forceMax || request.width > this.videoConfig.maxWidth)) { - width = this.videoConfig.maxWidth; + resInfo.width = this.videoConfig.maxWidth; } if (this.videoConfig.maxHeight !== undefined && (this.videoConfig.forceMax || request.height > this.videoConfig.maxHeight)) { - height = this.videoConfig.maxHeight; + resInfo.height = this.videoConfig.maxHeight; } } @@ -146,26 +150,32 @@ export class StreamingDelegate implements CameraStreamingDelegate { const noneFilter = filters.indexOf('none'); if (noneFilter >= 0) { filters.splice(noneFilter, 1); - } else if (width > 0 || height > 0) { - filters.push('scale=' + (width > 0 ? '\'min(' + width + ',iw)\'' : 'iw') + ':' + - (height > 0 ? '\'min(' + height + ',ih)\'' : 'ih') + - ':force_original_aspect_ratio=decrease'); + } + resInfo.snapFilter = filters.join(','); + if ((noneFilter < 0) && (resInfo.width > 0 || resInfo.height > 0)) { + resInfo.resizeFilter = 'scale=' + (resInfo.width > 0 ? '\'min(' + resInfo.width + ',iw)\'' : 'iw') + ':' + + (resInfo.height > 0 ? '\'min(' + resInfo.height + ',ih)\'' : 'ih') + + ':force_original_aspect_ratio=decrease'; + filters.push(resInfo.resizeFilter); filters.push('scale=trunc(iw/2)*2:trunc(ih/2)*2'); // Force to fit encoder restrictions } - return { - width: width, - height: height, - videoFilter: filters.join(',') - }; + if (filters.length > 0) { + resInfo.videoFilter = filters.join(','); + } + + return resInfo; } - fetchSnapshot(): Promise { - this.snapshotPromise = new Promise((resolve) => { + fetchSnapshot(snapFilter?: string): Promise { + this.snapshotPromise = new Promise((resolve, reject) => { const startTime = Date.now(); const ffmpegArgs = (this.videoConfig.stillImageSource || this.videoConfig.source!) + // Still ' -frames:v 1' + - ' -f image2 -'; + (snapFilter ? ' -filter:v ' + snapFilter : '') + + ' -f image2 -' + + ' -hide_banner' + + ' -loglevel error'; this.log.debug('Snapshot command: ' + this.videoProcessor + ' ' + ffmpegArgs, this.cameraName, this.videoConfig.debug); const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); @@ -175,10 +185,21 @@ export class StreamingDelegate implements CameraStreamingDelegate { snapshotBuffer = Buffer.concat([snapshotBuffer, data]); }); ffmpeg.on('error', (error: Error) => { - this.log.error('An error occurred while making snapshot request: ' + error.message, this.cameraName); + reject('FFmpeg process creation failed: ' + error.message); + }); + ffmpeg.stderr.on('data', (data) => { + data.toString().split('\n').forEach((line: string) => { + if (line.length > 0) { + this.log.error(line, this.cameraName + '] [Snapshot'); + } + }); }); ffmpeg.on('close', () => { - resolve(snapshotBuffer); + if (snapshotBuffer.length > 0) { + resolve(snapshotBuffer); + } else { + reject('Failed to fetch snapshot.'); + } setTimeout(() => { this.snapshotPromise = undefined; @@ -204,23 +225,22 @@ export class StreamingDelegate implements CameraStreamingDelegate { return this.snapshotPromise; } - resizeSnapshot(snapshot: Buffer, resolution: ResolutionInfo): Promise { - return new Promise((resolve) => { + resizeSnapshot(snapshot: Buffer, resizeFilter?: string): Promise { + return new Promise((resolve, reject) => { const ffmpegArgs = '-i pipe:' + // Resize ' -frames:v 1' + - (resolution.videoFilter ? ' -filter:v ' + resolution.videoFilter : '') + + (resizeFilter ? ' -filter:v ' + resizeFilter : '') + ' -f image2 -'; + this.log.debug('Resize command: ' + this.videoProcessor + ' ' + ffmpegArgs, this.cameraName, this.videoConfig.debug); const ffmpeg = spawn(this.videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); let resizeBuffer = Buffer.alloc(0); - this.log.debug('Resize command: ' + this.videoProcessor + ' ' + ffmpegArgs, this.cameraName, this.videoConfig.debug); ffmpeg.stdout.on('data', (data) => { resizeBuffer = Buffer.concat([resizeBuffer, data]); }); - const log = this.log; ffmpeg.on('error', (error: Error) => { - log.error('An error occurred while resizing snapshot: ' + error.message, this.cameraName); + reject('FFmpeg process creation failed: ' + error.message); }); ffmpeg.on('close', () => { resolve(resizeBuffer); @@ -238,13 +258,13 @@ export class StreamingDelegate implements CameraStreamingDelegate { this.log.debug('Snapshot requested: ' + request.width + ' x ' + request.height, this.cameraName, this.videoConfig.debug); - const snapshot = await (this.snapshotPromise || this.fetchSnapshot()); + const snapshot = await (this.snapshotPromise || this.fetchSnapshot(resolution.snapFilter)); this.log.debug('Sending snapshot: ' + (resolution.width > 0 ? resolution.width : 'native') + ' x ' + (resolution.height > 0 ? resolution.height : 'native') + (cachedSnapshot ? ' (cached)' : ''), this.cameraName, this.videoConfig.debug); - const resized = await this.resizeSnapshot(snapshot, resolution); + const resized = await this.resizeSnapshot(snapshot, resolution.resizeFilter); callback(undefined, resized); } catch (err) { this.log.error(err, this.cameraName); @@ -294,157 +314,156 @@ export class StreamingDelegate implements CameraStreamingDelegate { } }; - this.pendingSessions[request.sessionID] = sessionInfo; + this.pendingSessions.set(request.sessionID, sessionInfo); callback(undefined, response); } private startStream(request: StartStreamRequest, callback: StreamRequestCallback): void{ - const sessionInfo = this.pendingSessions[request.sessionID]; - const vcodec = this.videoConfig.vcodec || 'libx264'; - const mtu = this.videoConfig.packetSize || 1316; // request.video.mtu is not used - let encoderOptions = this.videoConfig.encoderOptions; - if (!encoderOptions && vcodec === 'libx264') { - encoderOptions = '-preset ultrafast -tune zerolatency'; - } - - const resolution = this.determineResolution(request.video, false); - - let fps = (this.videoConfig.maxFPS !== undefined && - (this.videoConfig.forceMax || request.video.fps > this.videoConfig.maxFPS)) ? - this.videoConfig.maxFPS : request.video.fps; - let videoBitrate = (this.videoConfig.maxBitrate !== undefined && - (this.videoConfig.forceMax || request.video.max_bit_rate > this.videoConfig.maxBitrate)) ? - this.videoConfig.maxBitrate : request.video.max_bit_rate; - - if (vcodec === 'copy') { - resolution.width = 0; - resolution.height = 0; - resolution.videoFilter = ''; - fps = 0; - videoBitrate = 0; - } + const sessionInfo = this.pendingSessions.get(request.sessionID); + if (sessionInfo) { + const vcodec = this.videoConfig.vcodec || 'libx264'; + const mtu = this.videoConfig.packetSize || 1316; // request.video.mtu is not used + let encoderOptions = this.videoConfig.encoderOptions; + if (!encoderOptions && vcodec === 'libx264') { + encoderOptions = '-preset ultrafast -tune zerolatency'; + } - this.log.debug('Video stream requested: ' + request.video.width + ' x ' + request.video.height + ', ' + - request.video.fps + ' fps, ' + request.video.max_bit_rate + ' kbps', this.cameraName, this.videoConfig.debug); - this.log.info('Starting video stream: ' + (resolution.width > 0 ? resolution.width : 'native') + ' x ' + - (resolution.height > 0 ? resolution.height : 'native') + ', ' + (fps > 0 ? fps : 'native') + - ' fps, ' + (videoBitrate > 0 ? videoBitrate : '???') + ' kbps' + - (this.videoConfig.audio ? (' (' + request.audio.codec + ')') : ''), this.cameraName); - - let ffmpegArgs = this.videoConfig.source!; - - ffmpegArgs += // Video - (this.videoConfig.mapvideo ? ' -map ' + this.videoConfig.mapvideo : ' -an -sn -dn') + - ' -codec:v ' + vcodec + - ' -pix_fmt yuv420p' + - ' -color_range mpeg' + - (fps > 0 ? ' -r ' + fps : '') + - ' -f rawvideo' + - (encoderOptions ? ' ' + encoderOptions : '') + - (resolution.videoFilter.length > 0 ? ' -filter:v ' + resolution.videoFilter : '') + - (videoBitrate > 0 ? ' -b:v ' + videoBitrate + 'k' : '') + - ' -payload_type ' + request.video.pt; - - ffmpegArgs += // Video Stream - ' -ssrc ' + sessionInfo.videoSSRC + - ' -f rtp' + - ' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' + - ' -srtp_out_params ' + sessionInfo.videoSRTP.toString('base64') + - ' srtp://' + sessionInfo.address + ':' + sessionInfo.videoPort + - '?rtcpport=' + sessionInfo.videoPort + '&pkt_size=' + mtu; - - if (this.videoConfig.audio) { - if (request.audio.codec === AudioStreamingCodecType.OPUS || request.audio.codec === AudioStreamingCodecType.AAC_ELD) { - ffmpegArgs += // Audio - (this.videoConfig.mapaudio ? ' -map ' + this.videoConfig.mapaudio : ' -vn -sn -dn') + - (request.audio.codec === AudioStreamingCodecType.OPUS ? - ' -codec:a libopus' + - ' -application lowdelay' : - ' -codec:a libfdk_aac' + - ' -profile:a aac_eld') + - ' -flags +global_header' + - ' -f null' + - ' -ar ' + request.audio.sample_rate + 'k' + - ' -b:a ' + request.audio.max_bit_rate + 'k' + - ' -ac ' + request.audio.channel + - ' -payload_type ' + request.audio.pt; - - ffmpegArgs += // Audio Stream - ' -ssrc ' + sessionInfo.audioSSRC + - ' -f rtp' + - ' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' + - ' -srtp_out_params ' + sessionInfo.audioSRTP.toString('base64') + - ' srtp://' + sessionInfo.address + ':' + sessionInfo.audioPort + - '?rtcpport=' + sessionInfo.audioPort + '&pkt_size=188'; - } else { - this.log.error('Unsupported audio codec requested: ' + request.audio.codec, this.cameraName); + const resolution = this.determineResolution(request.video, false); + + let fps = (this.videoConfig.maxFPS !== undefined && + (this.videoConfig.forceMax || request.video.fps > this.videoConfig.maxFPS)) ? + this.videoConfig.maxFPS : request.video.fps; + let videoBitrate = (this.videoConfig.maxBitrate !== undefined && + (this.videoConfig.forceMax || request.video.max_bit_rate > this.videoConfig.maxBitrate)) ? + this.videoConfig.maxBitrate : request.video.max_bit_rate; + + if (vcodec === 'copy') { + resolution.width = 0; + resolution.height = 0; + resolution.videoFilter = undefined; + fps = 0; + videoBitrate = 0; } - } - if (this.videoConfig.debug) { - ffmpegArgs += ' -loglevel level+verbose'; - } + this.log.debug('Video stream requested: ' + request.video.width + ' x ' + request.video.height + ', ' + + request.video.fps + ' fps, ' + request.video.max_bit_rate + ' kbps', this.cameraName, this.videoConfig.debug); + this.log.info('Starting video stream: ' + (resolution.width > 0 ? resolution.width : 'native') + ' x ' + + (resolution.height > 0 ? resolution.height : 'native') + ', ' + (fps > 0 ? fps : 'native') + + ' fps, ' + (videoBitrate > 0 ? videoBitrate : '???') + ' kbps' + + (this.videoConfig.audio ? (' (' + request.audio.codec + ')') : ''), this.cameraName); + + let ffmpegArgs = this.videoConfig.source!; + + ffmpegArgs += // Video + (this.videoConfig.mapvideo ? ' -map ' + this.videoConfig.mapvideo : ' -an -sn -dn') + + ' -codec:v ' + vcodec + + ' -pix_fmt yuv420p' + + ' -color_range mpeg' + + (fps > 0 ? ' -r ' + fps : '') + + ' -f rawvideo' + + (encoderOptions ? ' ' + encoderOptions : '') + + (resolution.videoFilter ? ' -filter:v ' + resolution.videoFilter : '') + + (videoBitrate > 0 ? ' -b:v ' + videoBitrate + 'k' : '') + + ' -payload_type ' + request.video.pt; + + ffmpegArgs += // Video Stream + ' -ssrc ' + sessionInfo.videoSSRC + + ' -f rtp' + + ' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' + + ' -srtp_out_params ' + sessionInfo.videoSRTP.toString('base64') + + ' srtp://' + sessionInfo.address + ':' + sessionInfo.videoPort + + '?rtcpport=' + sessionInfo.videoPort + '&pkt_size=' + mtu; + + if (this.videoConfig.audio) { + if (request.audio.codec === AudioStreamingCodecType.OPUS || request.audio.codec === AudioStreamingCodecType.AAC_ELD) { + ffmpegArgs += // Audio + (this.videoConfig.mapaudio ? ' -map ' + this.videoConfig.mapaudio : ' -vn -sn -dn') + + (request.audio.codec === AudioStreamingCodecType.OPUS ? + ' -codec:a libopus' + + ' -application lowdelay' : + ' -codec:a libfdk_aac' + + ' -profile:a aac_eld') + + ' -flags +global_header' + + ' -f null' + + ' -ar ' + request.audio.sample_rate + 'k' + + ' -b:a ' + request.audio.max_bit_rate + 'k' + + ' -ac ' + request.audio.channel + + ' -payload_type ' + request.audio.pt; + + ffmpegArgs += // Audio Stream + ' -ssrc ' + sessionInfo.audioSSRC + + ' -f rtp' + + ' -srtp_out_suite AES_CM_128_HMAC_SHA1_80' + + ' -srtp_out_params ' + sessionInfo.audioSRTP.toString('base64') + + ' srtp://' + sessionInfo.address + ':' + sessionInfo.audioPort + + '?rtcpport=' + sessionInfo.audioPort + '&pkt_size=188'; + } else { + this.log.error('Unsupported audio codec requested: ' + request.audio.codec, this.cameraName); + } + } - ffmpegArgs += ' -progress pipe:1'; + ffmpegArgs += ' -loglevel level' + (this.videoConfig.debug ? '+verbose' : '') + + ' -progress pipe:1'; - const activeSession: ActiveSession = {}; + const activeSession: ActiveSession = {}; - activeSession.socket = createSocket(sessionInfo.ipv6 ? 'udp6' : 'udp4'); - activeSession.socket.on('error', (err: Error) => { - this.log.error('Socket error: ' + err.name, this.cameraName); - this.stopStream(request.sessionID); - }); - activeSession.socket.on('message', () => { - if (activeSession.timeout) { - clearTimeout(activeSession.timeout); - } - activeSession.timeout = setTimeout(() => { - this.log.info('Device appears to be inactive. Stopping stream.', this.cameraName); - this.controller.forceStopStreamingSession(request.sessionID); + activeSession.socket = createSocket(sessionInfo.ipv6 ? 'udp6' : 'udp4'); + activeSession.socket.on('error', (err: Error) => { + this.log.error('Socket error: ' + err.name, this.cameraName); this.stopStream(request.sessionID); - }, request.video.rtcp_interval * 5 * 1000); - }); - activeSession.socket.bind(sessionInfo.videoReturnPort); - - activeSession.mainProcess = new FfmpegProcess(this.cameraName, request.sessionID, this.videoProcessor, - ffmpegArgs, this.log, this.videoConfig.debug, this, callback); - - if (this.videoConfig.returnAudioTarget) { - let ffmpegReturnArgs = - '-hide_banner' + - ' -protocol_whitelist pipe,udp,rtp,file,crypto' + - ' -f sdp' + - ' -c:a libfdk_aac' + - ' -i pipe:' + - ' ' + this.videoConfig.returnAudioTarget; - - if (this.videoConfig.debugReturn) { - ffmpegReturnArgs += ' -loglevel level+verbose'; + }); + activeSession.socket.on('message', () => { + if (activeSession.timeout) { + clearTimeout(activeSession.timeout); + } + activeSession.timeout = setTimeout(() => { + this.log.info('Device appears to be inactive. Stopping stream.', this.cameraName); + this.controller.forceStopStreamingSession(request.sessionID); + this.stopStream(request.sessionID); + }, request.video.rtcp_interval * 5 * 1000); + }); + activeSession.socket.bind(sessionInfo.videoReturnPort); + + activeSession.mainProcess = new FfmpegProcess(this.cameraName, request.sessionID, this.videoProcessor, + ffmpegArgs, this.log, this.videoConfig.debug, this, callback); + + if (this.videoConfig.returnAudioTarget) { + const ffmpegReturnArgs = + '-hide_banner' + + ' -protocol_whitelist pipe,udp,rtp,file,crypto' + + ' -f sdp' + + ' -c:a libfdk_aac' + + ' -i pipe:' + + ' ' + this.videoConfig.returnAudioTarget + + ' -loglevel level' + (this.videoConfig.debugReturn ? '+verbose' : ''); + + const ipVer = sessionInfo.ipv6 ? 'IP6' : 'IP4'; + + const sdpReturnAudio = + 'v=0\r\n' + + 'o=- 0 0 IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' + + 's=Talk\r\n' + + 'c=IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' + + 't=0 0\r\n' + + 'm=audio ' + sessionInfo.audioReturnPort + ' RTP/AVP 110\r\n' + + 'b=AS:24\r\n' + + 'a=rtpmap:110 MPEG4-GENERIC/16000/1\r\n' + + 'a=rtcp-mux\r\n' + // FFmpeg ignores this, but might as well + 'a=fmtp:110 ' + + 'profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; ' + + 'config=F8F0212C00BC00\r\n' + + 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:' + sessionInfo.audioSRTP.toString('base64') + '\r\n'; + activeSession.returnProcess = new FfmpegProcess(this.cameraName + '] [Two-way', request.sessionID, + this.videoProcessor, ffmpegReturnArgs, this.log, this.videoConfig.debugReturn, this); + activeSession.returnProcess.getStdin().end(sdpReturnAudio); } - const ipVer = sessionInfo.ipv6 ? 'IP6' : 'IP4'; - - const sdpReturnAudio = - 'v=0\r\n' + - 'o=- 0 0 IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' + - 's=Talk\r\n' + - 'c=IN ' + ipVer + ' ' + sessionInfo.address + '\r\n' + - 't=0 0\r\n' + - 'm=audio ' + sessionInfo.audioReturnPort + ' RTP/AVP 110\r\n' + - 'b=AS:24\r\n' + - 'a=rtpmap:110 MPEG4-GENERIC/16000/1\r\n' + - 'a=rtcp-mux\r\n' + // FFmpeg ignores this, but might as well - 'a=fmtp:110 ' + - 'profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; ' + - 'config=F8F0212C00BC00\r\n' + - 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:' + sessionInfo.audioSRTP.toString('base64') + '\r\n'; - activeSession.returnProcess = new FfmpegProcess(this.cameraName + '] [Two-way', request.sessionID, - this.videoProcessor, ffmpegReturnArgs, this.log, this.videoConfig.debugReturn, this); - activeSession.returnProcess.getStdin()?.end(sdpReturnAudio); + this.ongoingSessions.set(request.sessionID, activeSession); + this.pendingSessions.delete(request.sessionID); + } else { + this.log.error('Error finding session information.', this.cameraName); + callback(new Error('Error finding session information')); } - - this.ongoingSessions[request.sessionID] = activeSession; - delete this.pendingSessions[request.sessionID]; } handleStreamRequest(request: StreamingRequest, callback: StreamRequestCallback): void { @@ -465,7 +484,7 @@ export class StreamingDelegate implements CameraStreamingDelegate { } public stopStream(sessionId: string): void { - const session = this.ongoingSessions[sessionId]; + const session = this.ongoingSessions.get(sessionId); if (session) { if (session.timeout) { clearTimeout(session.timeout); @@ -486,7 +505,7 @@ export class StreamingDelegate implements CameraStreamingDelegate { this.log.error('Error occurred terminating two-way FFmpeg process: ' + err, this.cameraName); } } - delete this.ongoingSessions[sessionId]; + this.ongoingSessions.delete(sessionId); this.log.info('Stopped video stream.', this.cameraName); } }