UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

374 lines (319 loc) 12.9 kB
// FFmpeg Session Manager // Part of homebridge-nest-accfactory // // Provides a lightweight wrapper around the FFmpeg binary for managing // streaming and recording sessions used by camera and doorbell devices. // // Responsibilities: // - Locate and validate FFmpeg binary and feature support // - Spawn and manage FFmpeg processes // - Track active FFmpeg sessions per device // - Provide controlled lifecycle management (create / replace / stop sessions) // - Ensure stderr is safely drained to prevent process blocking // // Features: // - Automatic FFmpeg binary discovery across common platform paths (macOS, Linux, Windows) // - One-time binary probing (version + codec capability detection) // - Session-based process management via createSession() / killSession() // - Safe replacement of existing sessions with the same key // - Externalised error handling via callback hooks // - Support for multiple concurrent session types (e.g. live, record, talkback) // - Safe process cleanup and resource handling // // Notes: // - Used primarily by camera and doorbell modules for: // - Live streaming to HomeKit // - HomeKit Secure Video (HKSV) recording // - Two-way audio (talkback) // - Does NOT perform any media processing itself — only manages FFmpeg execution // - Logging and debugging are handled by the calling module // - Binary validation and capability checks are performed during initialisation // // Code version 2026.05.06 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import child_process from 'node:child_process'; // FFmpeg object export default class FFmpeg { #binary = undefined; #version = undefined; #features = {}; #sessions = new Map(); // Map of "uuid:sessionID:sessionType" => ChildProcess constructor(binaryPath = undefined) { let binaryName = 'ffmpeg' + (os.platform() === 'win32' ? '.exe' : ''); if ((binaryPath?.trim?.() ?? '') !== '') { binaryPath = binaryPath.trim(); if (path.isAbsolute(binaryPath) === false && binaryPath.includes('/') === false && binaryPath.includes('\\') === false) { // Bare command name, allow PATH lookup this.#binary = binaryPath; } else { // If path starts with '~' expand to user home directory if (binaryPath.startsWith('~') === true) { binaryPath = path.join(os.homedir(), binaryPath.slice(1)); } let resolved = path.resolve(binaryPath); let resolvedNormalised = resolved.replace(/[\\/]+$/, ''); if (fs.existsSync(resolvedNormalised) === true && fs.statSync(resolvedNormalised).isDirectory() === true) { this.#binary = path.join(resolvedNormalised, binaryName); } else if (path.basename(resolvedNormalised).toLowerCase() === binaryName.toLowerCase()) { this.#binary = resolvedNormalised; } else { this.#binary = path.join(resolvedNormalised, binaryName); } } } else { this.#binary = this.#findBinary(); } this.#probeBinary(); } hasMinimumSupport(min = {}) { if (typeof this.#version !== 'string') { return false; } if ( typeof min?.version === 'string' && this.#version.localeCompare(min.version, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper', }) === -1 ) { return false; } // Helper to check if all required items are in available list const hasAllRequired = (required, available) => { if (Array.isArray(required) === false || required.length === 0) { return true; } return required.every((item) => available.includes(item) === true); }; let encoders = this.#features.encoders || []; let decoders = this.#features.decoders || []; let muxers = this.#features.muxers || []; // Check all feature requirements if (hasAllRequired(min?.encoders, encoders) === false) { return false; } if (hasAllRequired(min?.decoders, decoders) === false) { return false; } if (hasAllRequired(min?.muxers, muxers) === false) { return false; } return true; } get binary() { return this.#binary; } get version() { return this.#version; } get features() { return this.#features; } get supportsHardwareH264() { return ( this.#features?.h264_nvenc === true || this.#features?.h264_vaapi === true || this.#features?.h264_v4l2m2m === true || this.#features?.h264_qsv === true || this.#features?.h264_videotoolbox === true ); } get hardwareH264Codec() { return this.#features?.hardwareH264Codec; } createSession(uuid, sessionID, args, sessionType = 'default', errorCallback, pipeCount = 3) { let key = String(uuid) + ':' + String(sessionID) + ':' + String(sessionType); if (this.#sessions.has(key) === true) { this.killSession(uuid, sessionID, sessionType); } // Ensure at least 3 pipes (stdin, stdout, stderr) if (pipeCount < 3) { pipeCount = 3; } let stdio = Array.from({ length: pipeCount }, () => 'pipe'); let child = child_process.spawn(this.#binary, args, { stdio, env: process.env }); this.#sessions.set(key, child); child?.stderr?.on?.('data', (data) => { errorCallback?.(data); }); child?.on?.('error', (error) => { if (this.#sessions.get(key) === child) { this.#sessions.delete(key); } errorCallback?.('Failed to start ffmpeg session "' + key + '". Error was "' + String(error?.message || error) + '"'); }); child?.on?.('exit', () => { if (this.#sessions.get(key) === child) { this.#sessions.delete(key); } }); for (let i = 0; i < pipeCount; i++) { child?.stdio?.[i]?.on?.('error', (error) => { if (error?.code === 'EPIPE') { // Empty } }); } return { process: child, stdin: child.stdio[0], stdout: child.stdio[1], stderr: child.stdio[2], stdio: child.stdio, on: (...args) => child.on(...args), once: (...args) => child.once(...args), kill: (signal) => child.kill(signal), }; } killSession(uuid, sessionID, sessionType = 'default', signal = 'SIGTERM') { let key = String(uuid) + ':' + String(sessionID) + ':' + String(sessionType); let child = this.#sessions.get(key); child?.kill?.(signal); this.#sessions.delete(key); } hasSession(uuid, sessionID, sessionType = 'default') { let key = String(uuid) + ':' + String(sessionID) + ':' + String(sessionType); return this.#sessions.has(key); } listSessions() { return Array.from(this.#sessions.keys()); } killAllSessions(uuid, signal = 'SIGKILL') { for (let [key, child] of this.#sessions.entries()) { if (key.startsWith(String(uuid) + ':') === true) { child?.kill?.(signal); this.#sessions.delete(key); } } } // Validate binary, extract version + feature flags #probeBinary() { if ((this.#binary.includes('/') === true || this.#binary.includes('\\') === true) && fs.existsSync(this.#binary) === false) { // Specified binary path does not exist return; } let versionOutput = child_process.spawnSync(this.#binary, ['-version'], { env: process.env }); if (versionOutput?.error !== undefined || versionOutput?.stdout === null || versionOutput.status !== 0) { // Failed to execute specified binary with -version command return; } let stdout = String(versionOutput.stdout); let match = stdout.match(/^ffmpeg version\s+([0-9]+(?:\.[0-9]+)*)(?:[-\s]|$)/i); if ((match?.[1] ?? '') !== '') { this.#version = match[1]; } // Parse --enable-xxx flags from build config let enabledLibs = stdout.match(/--enable-[^\s]+/g) || []; this.#features.enabled = enabledLibs.map((f) => f.replace('--enable-', '')); // Helper function to parse feature lists with different regex patterns const parseFeatures = (command, regex) => { let output = child_process.spawnSync(this.#binary, [command], { env: process.env }); if (output?.error !== undefined || output?.stdout === null || output.status !== 0) { return []; } let features = []; for (let line of String(output.stdout).split('\n')) { let m = line.match(regex); if ((m?.[1] ?? '') !== '') { features.push(m[1]); } } return features; }; // Parse feature lists (encoders, decoders, muxers, demuxers) this.#features.encoders = parseFeatures('-encoders', /^\s*[A-Z.]+\s+([^\s]+)/); this.#features.decoders = parseFeatures('-decoders', /^\s*[A-Z.]+\s+([^\s]+)/); this.#features.muxers = parseFeatures('-muxers', /^\s*[E][A-Z.]*\s+([^\s]+)/); this.#features.demuxers = parseFeatures('-demuxers', /^\s*[D][A-Z.]*\s+([^\s]+)/); // Detect hardware H264 codec flags let encoders = String(child_process.spawnSync(this.#binary, ['-encoders'], { env: process.env })?.stdout ?? ''); if (encoders !== '') { this.#features.h264_nvenc = encoders.includes('h264_nvenc') === true; this.#features.h264_vaapi = encoders.includes('h264_vaapi') === true; this.#features.h264_v4l2m2m = encoders.includes('h264_v4l2m2m') === true; this.#features.h264_qsv = encoders.includes('h264_qsv') === true; this.#features.h264_videotoolbox = encoders.includes('h264_videotoolbox') === true; // Platform-aware preferred hardware encoder this.#features.hardwareH264Codec = undefined; let platform = os.platform(); let hasDri = fs.existsSync('/dev/dri/renderD128') === true || fs.existsSync('/dev/dri/card0') === true; let hasVideo = fs.existsSync('/dev/video0') === true; let hasIntelQSV = fs.existsSync('/dev/dri') === true && fs.readdirSync('/dev/dri').some((f) => f.startsWith('render')) === true; // macOS: prefer videotoolbox if (platform === 'darwin' && this.#features.h264_videotoolbox === true) { this.#features.hardwareH264Codec = 'h264_videotoolbox'; } // Linux: prioritise nvenc > qsv > vaapi > v4l2m2m, only if required devices exist else if (platform === 'linux') { let linuxEncoders = [ { key: 'h264_nvenc', device: hasDri }, { key: 'h264_qsv', device: hasIntelQSV }, { key: 'h264_vaapi', device: hasDri }, { key: 'h264_v4l2m2m', device: hasVideo }, ]; for (let encoder of linuxEncoders) { if (this.#features[encoder.key] === true) { if (encoder.device !== true) { this.#features[encoder.key] = false; // Disable if device not available } else if (this.#features.hardwareH264Codec === undefined) { this.#features.hardwareH264Codec = encoder.key; // First match becomes selected codec } } } } // Windows: qsv preferred else if (platform === 'win32' && this.#features.h264_qsv === true) { this.#features.hardwareH264Codec = 'h264_qsv'; } } } // Locate ffmpeg binary when no explicit path is provided. // Searches common install locations per platform and falls back to PATH. // // Behaviour: // - On Unix/macOS: // - Checks common locations such as /usr/local/bin and Homebrew paths // - On Windows: // - Checks common install directories and current working directory // - Returns the first valid binary found // - Falls back to plain "ffmpeg" so OS PATH resolution can be used // // Notes: // - Does not validate binary execution (handled later by #probeBinary()) // - Order of search paths defines priority // - Designed to support typical Homebridge / Node.js environments #findBinary() { let binaryName = 'ffmpeg' + (os.platform() === 'win32' ? '.exe' : ''); // Default search paths for Unix/macOS systems let searchPaths = [ '/usr/local/bin', // Common manual install location '/opt/homebrew/bin', // Homebrew (Apple Silicon macOS) '/usr/bin', // System binaries '/bin', // Fallback system path ]; // Override search paths for Windows environments if (os.platform() === 'win32') { searchPaths = [ process.cwd(), // Local project directory 'C:\\ffmpeg\\bin', // Common manual install path 'C:\\Program Files\\ffmpeg\\bin', // Typical installer location ]; } // Iterate through search paths and return first valid binary for (let searchPath of searchPaths) { let binaryPath = path.join(searchPath, binaryName); if (fs.existsSync(binaryPath) === true) { return binaryPath; } } // Fallback to PATH resolution (e.g. global install) return binaryName; } }