UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

344 lines 14.1 kB
/* Copyright(C) 2017-2025, HJD (https://github.com/hjdhjd). All rights reserved. * * ffmpeg/process.ts: Base class to provide FFmpeg process control and capability introspection. */ /** * FFmpeg process management and capability introspection. * * This module defines the `FfmpegProcess` class, which abstracts the spawning, monitoring, and logging of FFmpeg subprocesses. It manages process state, handles * command-line argument composition, processes standard streams (stdin, stdout, stderr), and robustly reports process errors and exit conditions. * * Designed for use in Homebridge plugins, this module enables safe and flexible execution of FFmpeg commands, making it easier to integrate video/audio processing * pipelines with realtime control and diagnostics. * * Key features: * * - Comprehensive FFmpeg subprocess management (start, monitor, stop, cleanup). * - Streamlined error handling and logging, with pluggable loggers. * - Access to process I/O streams for data injection and consumption. * - Flexible callback and event-based architecture for streaming scenarios. * * Intended for developers needing direct, reliable control over FFmpeg process lifecycles with detailed runtime insights, especially in plugin or media automation * contexts. * * @module */ import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import os from "node:os"; import util from "node:util"; /** * Base class providing FFmpeg process management and capability introspection. * * This class encapsulates spawning, managing, and logging of FFmpeg processes, as well as handling process I/O and errors. It is designed as a reusable foundation for * advanced FFmpeg process control in Homebridge plugins or similar environments. Originally inspired by the Homebridge and homebridge-camera-ffmpeg source code. * * @example * * ```ts * // Create and start an FFmpeg process. * const process = new FfmpegProcess(options, ["-i", "input.mp4", "-f", "null", "-"]); * process.start(); * * // Access process streams if needed. * const stdin = process.stdin; * const stdout = process.stdout; * const stderr = process.stderr; * * // Stop the FFmpeg process when done. * process.stop(); * ``` * * @see {@link https://ffmpeg.org/documentation.html | FFmpeg Documentation} * * @see {@link https://nodejs.org/api/child_process.html | Node.js child_process} * * @see FfmpegOptions * * @category FFmpeg */ export class FfmpegProcess extends EventEmitter { /** * Indicates if an error has occurred during FFmpeg process execution. */ hasError; /** * Indicates whether the FFmpeg process has ended. */ isEnded; /** * Indicates whether the FFmpeg process has started. */ isStarted; /** * Optional callback to be called when the FFmpeg process is ready for streaming. */ callback; /** * The command line arguments for invoking FFmpeg. */ commandLineArgs; /** * Enables verbose logging for FFmpeg process output. */ isVerbose; /** * Logger instance for output and error reporting. */ log; /** * FFmpeg process configuration options. */ options; /** * The underlying Node.js ChildProcess instance for the FFmpeg process. */ process; /** * Accumulated log lines from standard error for error reporting and debugging. */ stderrLog; ffmpegTimeout; isLogging; stderrBuffer; // Create a new FFmpeg process instance. constructor(options, commandLineArgs, callback) { // Initialize our parent. super(); this.callback = null; this.commandLineArgs = []; this.hasError = false; this.isLogging = false; this.isEnded = false; this.isStarted = false; this.log = options.log; this.options = options; this.process = null; this.stderrBuffer = ""; this.stderrLog = []; // Toggle FFmpeg logging, if configured. this.isVerbose = this.options.codecSupport.verbose; // If we've specified a command line or a callback, let's save them. if (commandLineArgs) { this.commandLineArgs = commandLineArgs; } if (callback) { this.callback = callback; } } // Prepare and start our FFmpeg process. prepareProcess(commandLineArgs, callback) { // If we've specified a new command line or callback, let's save them. if (commandLineArgs) { this.commandLineArgs = commandLineArgs; } // No command line arguments - we're done. if (!this.commandLineArgs) { this.log.error("No FFmpeg command line specified."); return false; } // Save the callback, if we have one. if (callback) { this.callback = callback; } // See if we should display ffmpeg command output. this.isLogging = false; // Track if we've started or ended FFmpeg. this.isStarted = false; this.isEnded = false; // If we've got a loglevel specified, ensure we display it. if (this.commandLineArgs.indexOf("-loglevel") !== -1) { this.isLogging = true; } // Inform the user, if we've been asked to do so. if (this.isLogging || this.isVerbose || this.options.debug) { this.log.info("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" ")); } else { this.log.debug("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" ")); } return true; } /** * Starts the FFmpeg process with the provided command line and callback. * * @param commandLineArgs - Optional. Arguments for FFmpeg command line. * @param callback - Optional. Callback invoked when streaming is ready. * @param errorHandler - Optional. Function called if FFmpeg fails to start or terminates with error. * * @example * * ```ts * process.start(["-i", "input.mp4", "-f", "null", "-"]); * ``` */ start(commandLineArgs, callback, errorHandler) { // Prepared our FFmpeg process. if (!this.prepareProcess(commandLineArgs, callback)) { this.log.error("Error preparing to run FFmpeg."); return; } // Execute the command line based on what we've prepared. this.process = spawn(this.options.codecSupport.ffmpegExec, this.commandLineArgs); // Configure any post-spawn listeners and other plumbing. this.configureProcess(errorHandler); } // Configure our FFmpeg process, once started. configureProcess(errorHandler) { let dataListener; let errorListener; // Handle errors emitted during process creation, such as an invalid command line. this.process?.once("error", (error) => { this.log.error("FFmpeg failed to start: %s.", error.message); // Execute our error handler, if one is provided. if (errorHandler) { void errorHandler(error.name + ": " + error.message); } }); // Handle errors on stdin. this.process?.stdin?.on("error", errorListener = (error) => { if (!error.message.includes("EPIPE")) { this.log.error("FFmpeg error: %s.", error.message); } }); // Handle logging output that gets sent to stderr. this.process?.stderr?.on("data", dataListener = (data) => { // Inform us when we start receiving data back from FFmpeg. We do this here because it's the only // truly reliable place we can check on FFmpeg. stdin and stdout may not be used at all, depending // on the way FFmpeg is called, but stderr will always be there. if (!this.isStarted) { this.isStarted = true; this.isEnded = false; this.log.debug("Received the first frame."); // Always remember to execute the callback once we're setup to let homebridge know we're streaming. if (this.callback) { this.callback(); this.callback = null; } } // Append to the current line we've been buffering. We don't want to output not-printable characters to ensure the log output is readable. this.stderrBuffer += data.toString().replace(/\p{C}+/gu, os.EOL); // Debugging and additional logging collection. for (;;) { // Find the next newline. const lineIndex = this.stderrBuffer.indexOf(os.EOL); // If there's no newline, we're done until we get more data. if (lineIndex === -1) { return; } // Grab the next complete line, and increment our buffer. const line = this.stderrBuffer.slice(0, lineIndex); this.stderrBuffer = this.stderrBuffer.slice(lineIndex + os.EOL.length); this.stderrLog.push(line); // Show it to the user if it's been requested. if (this.isLogging || this.isVerbose || this.options.debug) { this.log.info(line); } } }); // Handle our process termination. this.process?.once("exit", (exitCode, signal) => { // Clear out our canary. if (this.ffmpegTimeout) { clearTimeout(this.ffmpegTimeout); } this.isStarted = false; this.isEnded = true; // Some utilities to streamline things. const logPrefix = "FFmpeg process ended "; // FFmpeg ended normally and our canary didn't need to enforce FFmpeg's extinction. if (this.ffmpegTimeout && (exitCode === 0)) { this.log.debug(logPrefix + "(Normal)."); } else if (((exitCode === null) || (exitCode === 255)) && this.process?.killed) { // FFmpeg has ended. Let's figure out if it's because we killed it or whether it died of natural causes. this.log.debug(logPrefix + (signal === "SIGKILL" ? "(Killed)." : "(Expected).")); } else { // Flag that we've run into an FFmpeg error. this.hasError = true; // Flush out any remaining output in our error buffer. if (this.stderrBuffer.length) { this.stderrLog.push(this.stderrBuffer + "\n"); this.stderrBuffer = ""; } // Inform the user. this.logFfmpegError(exitCode, signal); // Execute our error handler, if one is provided. if (errorHandler) { void errorHandler(util.format(this.options.name() + ": " + logPrefix + " unexpectedly with exit code %s and signal %s.", exitCode, signal)); } } // Cleanup after ourselves. this.process?.stdin?.off("error", errorListener); this.process?.stderr?.off("data", dataListener); this.process = null; this.stderrLog = []; }); } // Stop the FFmpeg process and complete any cleanup activities. stopProcess() { // Check to make sure we aren't using stdin for data before telling FFmpeg we're done. if (!this.commandLineArgs.includes("pipe:0")) { this.process?.stdin.end("q"); } // Close our input and output. this.process?.stdin.destroy(); this.process?.stdout.destroy(); // In case we need to kill it again, just to be sure it's really dead. this.ffmpegTimeout = setTimeout(() => { this.process?.kill("SIGKILL"); }, 5000); // Send the kill shot. this.process?.kill(); } /** * Stops the FFmpeg process and performs necessary cleanup. * * @example * * ```ts * process.stop(); * ``` */ stop() { this.stopProcess(); } /** * Logs an error message for FFmpeg process termination. * * @param exitCode - The exit code from FFmpeg. * @param signal - The signal, if any, used to terminate the process. */ logFfmpegError(exitCode, signal) { // Something else has occurred. Inform the user, and stop everything. this.log.error("FFmpeg process ended unexpectedly with %s%s%s.", (exitCode !== null) ? "an exit code of " + exitCode.toString() : "", ((exitCode !== null) && signal) ? " and " : "", signal ? "a signal received of " + signal : ""); this.log.error("FFmpeg (%s) command that errored out was: %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" ")); this.stderrLog.map(x => this.log.error(x)); } /** * Returns the writable standard input stream for the FFmpeg process, if available. * * @returns The standard input stream, or `null` if not available. */ get stdin() { return this.process?.stdin ?? null; } /** * Returns the readable standard output stream for the FFmpeg process, if available. * * @returns The standard output stream, or `null` if not available. */ get stdout() { return this.process?.stdout ?? null; } /** * Returns the readable standard error stream for the FFmpeg process, if available. * * @returns The standard error stream, or `null` if not available. */ get stderr() { return this.process?.stderr ?? null; } } //# sourceMappingURL=process.js.map