UNPKG

homebridge-camera-ffmpeg

Version:

Homebridge Plugin Providing FFmpeg-based Camera Support

411 lines 20.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.StreamingDelegate = void 0; const child_process_1 = require("child_process"); const dgram_1 = require("dgram"); const ffmpeg_for_homebridge_1 = __importDefault(require("ffmpeg-for-homebridge")); const pick_port_1 = __importDefault(require("pick-port")); const ffmpeg_1 = require("./ffmpeg"); class StreamingDelegate { constructor(log, cameraConfig, api, hap, videoProcessor) { var _a; this.pendingSessions = new Map(); this.ongoingSessions = new Map(); this.timeouts = new Map(); this.log = log; this.hap = hap; this.cameraName = cameraConfig.name; this.unbridge = (_a = cameraConfig.unbridge) !== null && _a !== void 0 ? _a : false; this.videoConfig = cameraConfig.videoConfig; this.videoProcessor = videoProcessor || ffmpeg_for_homebridge_1.default || 'ffmpeg'; api.on("shutdown", () => { for (const session in this.ongoingSessions) { this.stopStream(session); } }); const options = { cameraStreamCount: this.videoConfig.maxStreams || 2, delegate: this, streamingOptions: { supportedCryptoSuites: [0], video: { resolutions: [ [320, 180, 30], [320, 240, 15], [320, 240, 30], [480, 270, 30], [480, 360, 30], [640, 360, 30], [640, 480, 30], [1280, 720, 30], [1280, 960, 30], [1920, 1080, 30], [1600, 1200, 30] ], codec: { profiles: [0, 1, 2], levels: [0, 1, 2] } }, audio: { twoWayAudio: !!this.videoConfig.returnAudioTarget, codecs: [ { type: "AAC-eld", samplerate: 16 } ] } } }; this.controller = new hap.CameraController(options); } determineResolution(request, isSnapshot) { var _a; const resInfo = { width: request.width, height: request.height }; if (!isSnapshot) { if (this.videoConfig.maxWidth !== undefined && (this.videoConfig.forceMax || request.width > this.videoConfig.maxWidth)) { resInfo.width = this.videoConfig.maxWidth; } if (this.videoConfig.maxHeight !== undefined && (this.videoConfig.forceMax || request.height > this.videoConfig.maxHeight)) { resInfo.height = this.videoConfig.maxHeight; } } const filters = ((_a = this.videoConfig.videoFilter) === null || _a === void 0 ? void 0 : _a.split(',')) || []; const noneFilter = filters.indexOf('none'); if (noneFilter >= 0) { filters.splice(noneFilter, 1); } 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'); } if (filters.length > 0) { resInfo.videoFilter = filters.join(','); } return resInfo; } fetchSnapshot(snapFilter) { this.snapshotPromise = new Promise((resolve, reject) => { const startTime = Date.now(); const ffmpegArgs = (this.videoConfig.stillImageSource || this.videoConfig.source) + ' -frames:v 1' + (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 = (0, child_process_1.spawn)(this.videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); let snapshotBuffer = Buffer.alloc(0); ffmpeg.stdout.on('data', (data) => { snapshotBuffer = Buffer.concat([snapshotBuffer, data]); }); ffmpeg.on('error', (error) => { reject('FFmpeg process creation failed: ' + error.message); }); ffmpeg.stderr.on('data', (data) => { data.toString().split('\n').forEach((line) => { if (this.videoConfig.debug && line.length > 0) { this.log.error(line, this.cameraName + '] [Snapshot'); } }); }); ffmpeg.on('close', () => { if (snapshotBuffer.length > 0) { resolve(snapshotBuffer); } else { reject('Failed to fetch snapshot.'); } setTimeout(() => { this.snapshotPromise = undefined; }, 3 * 1000); const runtime = (Date.now() - startTime) / 1000; let message = 'Fetching snapshot took ' + runtime + ' seconds.'; if (runtime < 5) { this.log.debug(message, this.cameraName, this.videoConfig.debug); } else { if (!this.unbridge) { message += ' It is highly recommended you switch to unbridge mode.'; } if (runtime < 22) { this.log.warn(message, this.cameraName); } else { message += ' The request has timed out and the snapshot has not been refreshed in HomeKit.'; this.log.error(message, this.cameraName); } } }); }); return this.snapshotPromise; } resizeSnapshot(snapshot, resizeFilter) { return new Promise((resolve, reject) => { const ffmpegArgs = '-i pipe:' + ' -frames:v 1' + (resizeFilter ? ' -filter:v ' + resizeFilter : '') + ' -f image2 -'; this.log.debug('Resize command: ' + this.videoProcessor + ' ' + ffmpegArgs, this.cameraName, this.videoConfig.debug); const ffmpeg = (0, child_process_1.spawn)(this.videoProcessor, ffmpegArgs.split(/\s+/), { env: process.env }); let resizeBuffer = Buffer.alloc(0); ffmpeg.stdout.on('data', (data) => { resizeBuffer = Buffer.concat([resizeBuffer, data]); }); ffmpeg.on('error', (error) => { reject('FFmpeg process creation failed: ' + error.message); }); ffmpeg.on('close', () => { resolve(resizeBuffer); }); ffmpeg.stdin.end(snapshot); }); } async handleSnapshotRequest(request, callback) { const resolution = this.determineResolution(request, true); try { const cachedSnapshot = !!this.snapshotPromise; this.log.debug('Snapshot requested: ' + request.width + ' x ' + request.height, this.cameraName, this.videoConfig.debug); 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.resizeFilter); callback(undefined, resized); } catch (err) { this.log.error(err, this.cameraName); callback(); } } async prepareStream(request, callback) { const ipv6 = request.addressVersion === 'ipv6'; const options = { type: 'udp', ip: ipv6 ? '::' : '0.0.0.0', reserveTimeout: 15 }; const videoReturnPort = await (0, pick_port_1.default)(options); const videoSSRC = this.hap.CameraController.generateSynchronisationSource(); const audioReturnPort = await (0, pick_port_1.default)(options); const audioSSRC = this.hap.CameraController.generateSynchronisationSource(); const sessionInfo = { address: request.targetAddress, ipv6: ipv6, videoPort: request.video.port, videoReturnPort: videoReturnPort, videoCryptoSuite: request.video.srtpCryptoSuite, videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), videoSSRC: videoSSRC, audioPort: request.audio.port, audioReturnPort: audioReturnPort, audioCryptoSuite: request.audio.srtpCryptoSuite, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), audioSSRC: audioSSRC }; const response = { video: { port: videoReturnPort, ssrc: videoSSRC, srtp_key: request.video.srtp_key, srtp_salt: request.video.srtp_salt }, audio: { port: audioReturnPort, ssrc: audioSSRC, srtp_key: request.audio.srtp_key, srtp_salt: request.audio.srtp_salt } }; this.pendingSessions.set(request.sessionID, sessionInfo); callback(undefined, response); } startStream(request, callback) { const sessionInfo = this.pendingSessions.get(request.sessionID); if (sessionInfo) { const vcodec = this.videoConfig.vcodec || 'libx264'; const mtu = this.videoConfig.packetSize || 1316; 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 = undefined; fps = 0; videoBitrate = 0; } 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 += (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 += ' -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 === "OPUS" || request.audio.codec === "AAC-eld") { ffmpegArgs += (this.videoConfig.mapaudio ? ' -map ' + this.videoConfig.mapaudio : ' -vn -sn -dn') + (request.audio.codec === "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 += ' -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 += ' -loglevel level' + (this.videoConfig.debug ? '+verbose' : '') + ' -progress pipe:1'; const activeSession = {}; activeSession.socket = (0, dgram_1.createSocket)(sessionInfo.ipv6 ? 'udp6' : 'udp4'); activeSession.socket.on('error', (err) => { this.log.error('Socket error: ' + err.message, 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); this.stopStream(request.sessionID); }, request.video.rtcp_interval * 5 * 1000); }); activeSession.socket.bind(sessionInfo.videoReturnPort); activeSession.mainProcess = new ffmpeg_1.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' + '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 ffmpeg_1.FfmpegProcess(this.cameraName + '] [Two-way', request.sessionID, this.videoProcessor, ffmpegReturnArgs, this.log, this.videoConfig.debugReturn, this); activeSession.returnProcess.stdin.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')); } } handleStreamRequest(request, callback) { switch (request.type) { case "start": this.startStream(request, callback); break; case "reconfigure": this.log.debug('Received request to reconfigure: ' + request.video.width + ' x ' + request.video.height + ', ' + request.video.fps + ' fps, ' + request.video.max_bit_rate + ' kbps (Ignored)', this.cameraName, this.videoConfig.debug); callback(); break; case "stop": this.stopStream(request.sessionID); callback(); break; } } stopStream(sessionId) { var _a, _b, _c; const session = this.ongoingSessions.get(sessionId); if (session) { if (session.timeout) { clearTimeout(session.timeout); } try { (_a = session.socket) === null || _a === void 0 ? void 0 : _a.close(); } catch (err) { this.log.error('Error occurred closing socket: ' + err, this.cameraName); } try { (_b = session.mainProcess) === null || _b === void 0 ? void 0 : _b.stop(); } catch (err) { this.log.error('Error occurred terminating main FFmpeg process: ' + err, this.cameraName); } try { (_c = session.returnProcess) === null || _c === void 0 ? void 0 : _c.stop(); } catch (err) { this.log.error('Error occurred terminating two-way FFmpeg process: ' + err, this.cameraName); } } this.ongoingSessions.delete(sessionId); this.log.info('Stopped video stream.', this.cameraName); } } exports.StreamingDelegate = StreamingDelegate; //# sourceMappingURL=streamingDelegate.js.map