homebridge-plugin-utils
Version:
Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.
539 lines • 26.1 kB
JavaScript
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* ffmpeg/record.ts: Provide FFmpeg process control to support livestreaming and HomeKit Secure Video.
*/
import { HKSV_IDR_INTERVAL, HKSV_TIMEOUT } from "./settings.js";
import { runWithTimeout } from "../util.js";
import { FfmpegProcess } from "./process.js";
import events from "node:events";
import { once } from "node:events";
// Utility to map HKSV audio recording profiles to FFmpeg profile strings. We also use satisfies here to ensure we account for any future changes that would require
// updating this mapping.
const translateAudioRecordingCodecType = {
[1 /* AudioRecordingCodecType.AAC_ELD */]: "38",
[0 /* AudioRecordingCodecType.AAC_LC */]: "1"
};
// Utility to map audio sample rates to strings. We also use satisfies here to ensure we account for any future changes that would require updating this mapping.
const translateAudioSampleRate = {
[0 /* AudioRecordingSamplerate.KHZ_8 */]: "8",
[1 /* AudioRecordingSamplerate.KHZ_16 */]: "16",
[2 /* AudioRecordingSamplerate.KHZ_24 */]: "24",
[3 /* AudioRecordingSamplerate.KHZ_32 */]: "32",
[4 /* AudioRecordingSamplerate.KHZ_44_1 */]: "44.1",
[5 /* AudioRecordingSamplerate.KHZ_48 */]: "48"
};
/**
* FFmpeg process controller for HomeKit Secure Video (HKSV) and fMP4 livestreaming and recording.
*
* This class manages the lifecycle and parsing of an FFmpeg process to support HKSV and livestreaming in fMP4 format. It handles initialization segments, media segment
* parsing, buffering, and HomeKit segment generation, and emits events for segment and initialization.
*
* @example
*
* ```ts
* // Create a new recording process for an HKSV event.
* const process = new FfmpegRecordingProcess(ffmpegOptions, recordingConfig, 30, true, 5000000, 0);
*
* // Start the process.
* process.start();
*
* // Iterate over generated segments.
* for await(const segment of process.segmentGenerator()) {
*
* // Send segment to HomeKit, etc.
* }
*
* // Stop when finished.
* process.stop();
* ```
*
* @see FfmpegOptions
* @see FfmpegProcess
* @see {@link https://ffmpeg.org/ffmpeg.html | FFmpeg Documentation}
*/
class FfmpegFMp4Process extends FfmpegProcess {
hasInitSegment;
_initSegment;
isLivestream;
isLoggingErrors;
isTimedOut;
recordingBuffer;
segmentLength;
/**
* Constructs a new fMP4 FFmpeg process for HKSV event recording or livestreaming.
*
* @param ffmpegOptions - FFmpeg configuration options.
* @param recordingConfig - HomeKit recording configuration for the session.
* @param isAudioActive - If `true`, enables audio stream processing.
* @param fMp4Options - Configuration for the fMP4 session (fps, type, url, etc.).
* @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
*
* @example
*
* ```ts
* const process = new FfmpegFMp4Process(ffmpegOptions, recordingConfig, true, { fps: 30 });
* ```
*/
constructor(ffmpegOptions, recordingConfig, fMp4Options = {}, isVerbose = false) {
// Initialize our parent.
super(ffmpegOptions);
// We want to log errors when they occur.
this.isLoggingErrors = true;
// Initialize our recording buffer.
this.hasInitSegment = false;
this._initSegment = Buffer.alloc(0);
this.recordingBuffer = [];
// Initialize our state.
this.isLivestream = !!fMp4Options.url;
this.isTimedOut = false;
fMp4Options.audioStream ??= 0;
fMp4Options.audioFilters ??= [];
fMp4Options.codec ??= "h264";
fMp4Options.enableAudio ??= true;
fMp4Options.fps ??= 30;
fMp4Options.hardwareDecoding ??= this.options.codecSupport.ffmpegVersion.startsWith("8.") ? this.options.config.hardwareDecoding : false;
fMp4Options.hardwareTranscoding ??= this.options.config.hardwareTranscoding;
fMp4Options.transcodeAudio ??= true;
fMp4Options.videoFilters ??= [];
fMp4Options.videoStream ??= 0;
// Configure our video parameters for our input:
//
// -hide_banner Suppress printing the startup banner in FFmpeg.
// -nostats Suppress printing progress reports while encoding in FFmpeg.
// -fflags flags Set the format flags to discard any corrupt packets rather than exit.
// -err_detect ignore_err Ignore decoding errors and continue rather than exit.
// -max_delay 500000 Set an upper limit on how much time FFmpeg can take in demuxing packets, in microseconds.
this.commandLineArgs = [
"-hide_banner",
"-nostats",
"-fflags", "+discardcorrupt",
"-err_detect", "ignore_err",
...this.options.videoDecoder(fMp4Options.codec),
"-max_delay", "500000"
];
if (this.isLivestream && fMp4Options.url) {
// -avioflags direct Tell FFmpeg to minimize buffering to reduce latency for more realtime processing.
// -rtsp_transport tcp Tell the RTSP stream handler that we're looking for a TCP connection.
// -i url RTSPS URL to get our input stream from.
this.commandLineArgs.push("-avioflags", "direct", "-rtsp_transport", "tcp", "-i", fMp4Options.url);
}
else {
// -flags low_delay Tell FFmpeg to optimize for low delay / realtime decoding.
// -probesize number How many bytes should be analyzed for stream information.
// -f mp4 Tell FFmpeg that it should expect an MP4-encoded input stream.
// -i pipe:0 Use standard input to get video data.
// -ss Fast forward to where HKSV is expecting us to be for a recording event.
this.commandLineArgs.push("-flags", "low_delay", "-probesize", (fMp4Options.probesize ?? 5000000).toString(), "-f", "mp4", "-i", "pipe:0", "-ss", (fMp4Options.timeshift ?? 0).toString() + "ms");
}
// Configure our recording options for the video stream:
//
// -map 0:v:X Selects the video track from the input.
this.commandLineArgs.push("-map", "0:v:" + fMp4Options.videoStream.toString(), ...(this.isLivestream ? ["-codec:v", "copy"] : this.options.recordEncoder({
bitrate: recordingConfig.videoCodec.parameters.bitRate,
fps: recordingConfig.videoCodec.resolution[2],
hardwareDecoding: fMp4Options.hardwareDecoding,
hardwareTranscoding: fMp4Options.hardwareTranscoding,
height: recordingConfig.videoCodec.resolution[1],
idrInterval: HKSV_IDR_INTERVAL,
inputFps: fMp4Options.fps,
level: recordingConfig.videoCodec.parameters.level,
profile: recordingConfig.videoCodec.parameters.profile,
width: recordingConfig.videoCodec.resolution[0]
})));
// Configure our video filters, if we have them.
if (fMp4Options.videoFilters.length) {
this.commandLineArgs.push("-filter:v", fMp4Options.videoFilters.join(", "));
}
// If we're livestreaming, emit fragments at one-second intervals.
if (this.isLivestream) {
// -frag_duration number Length of each fMP4 fragment, in microseconds.
this.commandLineArgs.push("-frag_duration", "1000000");
}
// -movflags flags In the generated fMP4 stream: set the default-base-is-moof flag in the header, write an initial empty MOOV box, start a new fragment
// at each keyframe, skip creating a segment index (SIDX) box in fragments, and skip writing the final MOOV trailer since it's unneeded.
// -flush_packets 1 Ensure we flush our write buffer after each muxed packet.
// -reset_timestamps Reset timestamps at the beginning of each segment.
// -metadata Set the metadata to the name of the camera to distinguish between FFmpeg sessions.
this.commandLineArgs.push("-movflags", "default_base_moof+empty_moov+frag_keyframe+skip_sidx+skip_trailer", "-flush_packets", "1", "-reset_timestamps", "1", "-metadata", "comment=" + this.options.name() + " " + (this.isLivestream ? "Livestream Buffer" : "HKSV Event"));
if (fMp4Options.enableAudio) {
// Configure the audio portion of the command line. Options we use are:
//
// -map 0:a:X? Selects the audio stream from the input, if it exists.
this.commandLineArgs.push("-map", "0:a:" + fMp4Options.audioStream.toString() + "?");
// Configure our audio filters, if we have them.
if (fMp4Options.audioFilters.length) {
this.commandLineArgs.push("-filter:a", fMp4Options.audioFilters.join(", "));
// Audio filters require transcoding. If the user's decided to filter, we enforce this requirement even if they wanted to copy the audio stream.
fMp4Options.transcodeAudio = true;
}
if (fMp4Options.transcodeAudio) {
// Configure the audio portion of the command line. Options we use are:
//
// -codec:a Encode using the codecs available to us on given platforms.
// -profile:a Specify either low-complexity AAC or enhanced low-delay AAC for HKSV events.
// -ar samplerate Sample rate to use for this audio. This is specified by HKSV.
// -ac number Set the number of audio channels.
this.commandLineArgs.push(...this.options.audioEncoder({ codec: recordingConfig.audioCodec.type }), "-profile:a", translateAudioRecordingCodecType[recordingConfig.audioCodec.type], "-ar", translateAudioSampleRate[recordingConfig.audioCodec.samplerate] + "k", "-ac", (recordingConfig.audioCodec.audioChannels ?? 1).toString());
}
else {
// Configure the audio portion of the command line. Options we use are:
//
// -codec:a copy Copy the audio stream, since it's already in AAC.
this.commandLineArgs.push("-codec:a", "copy");
}
}
// Configure our video parameters for outputting our final stream:
//
// -f mp4 Tell ffmpeg that it should create an MP4-encoded output stream.
// pipe:1 Output the stream to standard output.
this.commandLineArgs.push("-f", "mp4", "pipe:1");
// Additional logging, but only if we're debugging.
if (isVerbose || this.isVerbose) {
this.commandLineArgs.unshift("-loglevel", "level+verbose");
}
}
/**
* Prepares and configures the FFmpeg process for reading and parsing output fMP4 data.
*
* This method is called internally by the process lifecycle and is not typically invoked directly by consumers.
*/
configureProcess() {
let dataListener;
// Call our parent to get started.
super.configureProcess();
// Initialize our variables that we need to process incoming FFmpeg packets.
let header = Buffer.alloc(0);
let bufferRemaining = Buffer.alloc(0);
let dataLength = 0;
let type = "";
// Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video.
this.process?.stdout.on("data", dataListener = (buffer) => {
// If we have anything left from the last buffer we processed, prepend it to this buffer.
if (bufferRemaining.length > 0) {
buffer = Buffer.concat([bufferRemaining, buffer]);
bufferRemaining = Buffer.alloc(0);
}
let offset = 0;
// FFmpeg is outputting an fMP4 stream that's suitable for HomeKit Secure Video. However, we can't just pass this stream directly back to HomeKit since we're using
// a generator-based API to send packets back to HKSV. Here, we take on the task of parsing the fMP4 stream that's being generated and split it up into the MP4
// boxes that HAP-NodeJS is ultimately expecting.
for (;;) {
let data;
// The MP4 container format is well-documented and designed around the concept of boxes. A box (or atom as they used to be called) is at the center of an MP4
// container. It's composed of an 8-byte header, followed by the data payload it carries.
// No existing header, let's start a new box.
if (!header.length) {
// Grab the header. The first four bytes represents the length of the entire box. Second four bytes represent the box type.
header = buffer.subarray(0, 8);
// Now we retrieve the length of the box.
dataLength = header.readUInt32BE(0);
// Get the type of the box. This is always a string and has a funky history to it that makes for an interesting read!
type = header.subarray(4).toString();
// Finally, we get the data portion of the box.
data = buffer.subarray(8, dataLength);
// Mark our data offset so we account for the length of the data header and subtract it from the overall length to capture just the data portion.
dataLength -= offset = 8;
}
else {
// Grab the data from our buffer.
data = buffer.subarray(0, dataLength);
offset = 0;
}
// If we don't have enough data in this buffer, save what we have for the next buffer we see and append it there.
if (data.length < dataLength) {
bufferRemaining = data;
break;
}
// If we're creating a livestream to be consumed by the timeshift buffer, we need to track the initialization segment, and emit segments.
if (this.isLivestream) {
// If this is part of the initialization segment, store it for future use.
if (!this.hasInitSegment) {
// The initialization segment is everything before the first moof box. Once we've seen a moof box, we know we've captured it in full.
if (type === "moof") {
this.hasInitSegment = true;
this.emit("initsegment");
}
else {
this._initSegment = Buffer.concat([this._initSegment, header, data]);
}
}
if (this.hasInitSegment) {
// We only emit segments once we have the initialization segment.
this.emit("segment", Buffer.concat([header, data]));
}
}
else {
// Add it to our queue to be eventually pushed out through our generator function.
this.recordingBuffer.push({ data: data, header: header, length: dataLength, type: type });
this.emit("mp4box");
}
// Prepare to start a new box for the next buffer that we will be processing.
data = Buffer.alloc(0);
header = Buffer.alloc(0);
type = "";
// We've parsed an entire box, and there's no more data in this buffer to parse.
if (buffer.length === (offset + dataLength)) {
dataLength = 0;
break;
}
// If there's anything left in the buffer, move us to the new box and let's keep iterating.
buffer = buffer.subarray(offset + dataLength);
dataLength = 0;
}
});
// Make sure we cleanup our listeners when we're done.
this.process?.once("exit", () => {
this.process?.stdout.off("data", dataListener);
});
}
/**
* Retrieves the fMP4 initialization segment generated by FFmpeg.
*
* Waits until the initialization segment is available, then returns it.
*
* @returns A promise resolving to the initialization segment as a Buffer.
*
* @example
*
* ```ts
* const initSegment = await process.getInitSegment();
* ```
*/
async getInitSegment() {
// If we have the initialization segment, return it.
if (this.hasInitSegment) {
return this._initSegment;
}
// Wait until the initialization segment is seen and then try again.
await events.once(this, "initsegment");
return this.getInitSegment();
}
/**
* Stops the FFmpeg process and performs cleanup, including emitting termination events for segment generators.
*
* This is called as part of the process shutdown sequence.
*/
stopProcess() {
// Call our parent to get started.
super.stopProcess();
// Ensure that we clear out of our segment generator by guaranteeing an exit path.
this.isEnded = true;
this.emit("mp4box");
this.emit("close");
}
/**
* Starts the FFmpeg process, adjusting segment length for livestreams if set.
*
* @example
*
* ```ts
* process.start();
* ```
*/
start() {
if (this.isLivestream && (this.segmentLength !== undefined)) {
const fragIndex = this.commandLineArgs.indexOf("-frag_duration");
if (fragIndex !== -1) {
this.commandLineArgs[fragIndex + 1] = (this.segmentLength * 1000).toString();
}
}
// Start the FFmpeg session.
super.start();
}
/**
* Stops the FFmpeg process and logs errors if specified.
*
* @param logErrors - If `true`, logs FFmpeg errors. Defaults to the internal process logging state.
*
* @example
*
* ```ts
* process.stop();
* ```
*/
stop(logErrors = this.isLoggingErrors) {
const savedLogErrors = this.isLoggingErrors;
// Flag whether we should log abnormal exits (e.g. being killed) or not.
this.isLoggingErrors = logErrors;
// Call our parent to finish the job.
super.stop();
// Restore our previous logging state.
this.isLoggingErrors = savedLogErrors;
}
/**
* Logs errors from FFmpeg process execution, handling known benign HKSV stream errors gracefully.
*
* @param exitCode - The exit code from the FFmpeg process.
* @param signal - The signal (if any) used to terminate the process.
*/
logFfmpegError(exitCode, signal) {
// If we're ignoring errors, we're done.
if (!this.isLoggingErrors) {
return;
}
// Known HKSV-related errors due to occasional inconsistencies that are occasionally produced by the input stream and FFmpeg's own occasional quirkiness.
const ffmpegKnownHksvError = new RegExp([
"(Cannot determine format of input stream 0:0 after EOF)",
"(Could not write header \\(incorrect codec parameters \\?\\): Broken pipe)",
"(Could not write header for output file #0)",
"(Error closing file: Broken pipe)",
"(Error splitting the input into NAL units\\.)",
"(Invalid data found when processing input)",
"(moov atom not found)"
].join("|"));
// See if we know about this error.
if (this.stderrLog.some(x => ffmpegKnownHksvError.test(x))) {
this.log.error("FFmpeg ended unexpectedly due to issues processing the media stream. This error can be safely ignored - it will occur occasionally.");
return;
}
// Otherwise, revert to our default logging in our parent.
super.logFfmpegError(exitCode, signal);
}
/**
* Asynchronously generates complete segments from FFmpeg output, formatted for HomeKit Secure Video.
*
* This async generator yields fMP4 segments as Buffers, or ends on process termination or timeout.
*
* @yields A Buffer containing a complete MP4 segment suitable for HomeKit.
*
* @example
*
* ```ts
* for await(const segment of process.segmentGenerator()) {
*
* // Process each segment for HomeKit.
* }
* ```
*/
async *segmentGenerator() {
let segment = [];
// Loop forever, generating either FTYP/MOOV box pairs or MOOF/MDAT box pairs for HomeKit Secure Video.
for (;;) {
// FFmpeg has finished it's output - we're done.
if (this.isEnded) {
return;
}
// If the buffer is empty, wait for our FFmpeg process to produce more boxes.
if (!this.recordingBuffer.length) {
// Segments are output by FFmpeg according to our specified IDR interval. If we don't see a segment within the timeframe we need for HKSV's timing requirements,
// we flag it accordingly and return null back to the generator that's calling us.
// eslint-disable-next-line no-await-in-loop
await runWithTimeout(once(this, "mp4box"), HKSV_TIMEOUT);
}
// Grab the next fMP4 box from our buffer.
const box = this.recordingBuffer.shift();
// FFmpeg hasn't produced any output. Given the time-sensitive nature of HKSV that constrains us to no more than 5 seconds to provide the next segment, we're done.
if (!box) {
this.isTimedOut = true;
return;
}
// Queue up this fMP4 box to send back to HomeKit.
segment.push(box.header, box.data);
// What we want to send are two types of complete segments, made up of multiple MP4 boxes:
//
// - a complete MOOV box, usually with an accompanying FTYP box, that's sent at the very beginning of any valid fMP4 stream. HomeKit Secure Video looks for this
// before anything else.
//
// - a complete MOOF/MDAT pair. MOOF describes the sample locations and their sizes and MDAT contains the actual audio and video data related to that segment. Think
// of MOOF as the audio/video data "header", and MDAT as the "payload".
//
// Once we see these, we combine all the segments in our queue to send back to HomeKit.
if ((box.type === "moov") || (box.type === "mdat")) {
yield Buffer.concat(segment);
segment = [];
}
}
}
/**
* Returns the initialization segment as a Buffer, or null if not yet available.
*
* @returns The initialization segment Buffer, or `null` if not yet generated.
*
* @example
*
* ```ts
* const init = process.initSegment;
* if(init) {
*
* // Use the initialization segment.
* }
* ```
*/
get initSegment() {
if (!this.hasInitSegment) {
return null;
}
return this._initSegment;
}
}
/**
* Manages a HomeKit Secure Video recording FFmpeg process.
*
* @example
*
* ```ts
* const process = new FfmpegRecordingProcess(ffmpegOptions, recordingConfig, 30, true, 5000000, 0);
* process.start();
* ```
*
* @see FfmpegFMp4Process
*
* @category FFmpeg
*/
export class FfmpegRecordingProcess extends FfmpegFMp4Process {
/**
* Constructs a new FFmpeg recording process for HKSV events.
*
* @param options - FFmpeg configuration options.
* @param recordingConfig - HomeKit recording configuration for the session.
* @param fMp4Options - fMP4 recording options.
* @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
*/
constructor(options, recordingConfig, fMp4Options = {}, isVerbose = false) {
super(options, recordingConfig, fMp4Options, isVerbose);
}
}
/**
* Manages a HomeKit livestream FFmpeg process for generating fMP4 segments.
*
* @example
*
* ```ts
* const process = new FfmpegLivestreamProcess(ffmpegOptions, recordingConfig, url, 30, true);
* process.start();
*
* const initSegment = await process.getInitSegment();
* ```
*
* @see FfmpegFMp4Process
*
* @category FFmpeg
*/
export class FfmpegLivestreamProcess extends FfmpegFMp4Process {
/**
* Constructs a new FFmpeg livestream process.
*
* @param options - FFmpeg configuration options.
* @param recordingConfig - HomeKit recording configuration for the session.
* @param livestreamOptions - livestream segmenting options.
* @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
*/
constructor(options, recordingConfig, livestreamOptions, isVerbose = false) {
super(options, recordingConfig, livestreamOptions, isVerbose);
}
/**
* Gets the fMP4 initialization segment generated by FFmpeg for the livestream.
*
* @returns A promise resolving to the initialization segment as a Buffer.
*
* @example
*
* ```ts
* const initSegment = await process.getInitSegment();
* ```
*/
async getInitSegment() {
return super.getInitSegment();
}
}
//# sourceMappingURL=record.js.map