homebridge-plugin-utils 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,9 @@
5
5
  import { HKSV_IDR_INTERVAL, HKSV_TIMEOUT } from "./settings.js";
6
6
  import { runWithTimeout } from "../util.js";
7
7
  import { FfmpegProcess } from "./process.js";
8
- import events from "node:events";
9
8
  import { once } from "node:events";
10
- // 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
11
- // updating this mapping.
9
+ // Utility to map HKSV audio recording codec types to their AAC Object Type identifiers. We also use satisfies here to ensure we account for any future changes that
10
+ // would require updating this mapping.
12
11
  const translateAudioRecordingCodecType = {
13
12
  [1 /* AudioRecordingCodecType.AAC_ELD */]: "38",
14
13
  [0 /* AudioRecordingCodecType.AAC_LC */]: "1"
@@ -22,80 +21,72 @@ const translateAudioSampleRate = {
22
21
  [4 /* AudioRecordingSamplerate.KHZ_44_1 */]: "44.1",
23
22
  [5 /* AudioRecordingSamplerate.KHZ_48 */]: "48"
24
23
  };
24
+ // Reusable empty buffer sentinel for the box-parsing loop. Avoids repeated zero-byte allocations on every box reset.
25
+ const EMPTY_BUFFER = Buffer.alloc(0);
26
+ // Known HKSV-related errors due to occasional inconsistencies produced by the input stream and FFmpeg's own occasional quirkiness. Compiled once at module scope
27
+ // rather than on every error event.
28
+ const FFMPEG_KNOWN_HKSV_ERROR = new RegExp([
29
+ "(Cannot determine format of input stream 0:0 after EOF)",
30
+ "(Could not write header \\(incorrect codec parameters \\?\\): Broken pipe)",
31
+ "(Could not write header for output file #0)",
32
+ "(Error closing file: Broken pipe)",
33
+ "(Error splitting the input into NAL units\\.)",
34
+ "(Invalid data found when processing input)",
35
+ "(moov atom not found)"
36
+ ].join("|"));
25
37
  /**
26
- * FFmpeg process controller for HomeKit Secure Video (HKSV) and fMP4 livestreaming and recording.
38
+ * Abstract base class for fMP4 FFmpeg processes. Owns the shared command line skeleton (preamble, video mapping, movflags, audio encoding, output format) and the fMP4
39
+ * box-parsing loop. Subclasses provide mode-specific pieces (input args, encoder selection, box handling) via protected hook methods, following the template method
40
+ * pattern.
27
41
  *
28
- * 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
29
- * parsing, buffering, and HomeKit segment generation, and emits events for segment and initialization.
30
- *
31
- * @example
32
- *
33
- * ```ts
34
- * // Create a new recording process for an HKSV event.
35
- * const process = new FfmpegRecordingProcess(ffmpegOptions, recordingConfig, 30, true, 5000000, 0);
36
- *
37
- * // Start the process.
38
- * process.start();
39
- *
40
- * // Iterate over generated segments.
41
- * for await(const segment of process.segmentGenerator()) {
42
- *
43
- * // Send segment to HomeKit, etc.
44
- * }
45
- *
46
- * // Stop when finished.
47
- * process.stop();
48
- * ```
49
- *
50
- * @see FfmpegOptions
42
+ * @see FfmpegRecordingProcess
43
+ * @see FfmpegLivestreamProcess
51
44
  * @see FfmpegProcess
52
45
  * @see {@link https://ffmpeg.org/ffmpeg.html | FFmpeg Documentation}
53
46
  */
54
47
  class FfmpegFMp4Process extends FfmpegProcess {
55
- hasInitSegment;
56
- _initSegment;
57
- isLivestream;
58
48
  isLoggingErrors;
59
- isTimedOut;
60
- recordingBuffer;
61
- segmentLength;
49
+ // The HomeKit recording configuration and resolved base options are stored as protected fields so subclass hook methods can reference them without needing their own
50
+ // copies of the shared state.
51
+ fMp4Options;
52
+ recordingConfig;
62
53
  /**
63
- * Constructs a new fMP4 FFmpeg process for HKSV event recording or livestreaming.
54
+ * Constructs a new fMP4 FFmpeg process. Stores shared state and applies defaults to the base options. The command line is not assembled here...subclasses call
55
+ * `buildCommandLine()` after their own initialization to trigger the template method assembly.
64
56
  *
65
57
  * @param ffmpegOptions - FFmpeg configuration options.
66
58
  * @param recordingConfig - HomeKit recording configuration for the session.
67
- * @param isAudioActive - If `true`, enables audio stream processing.
68
- * @param fMp4Options - Configuration for the fMP4 session (fps, type, url, audio input, etc.).
59
+ * @param fMp4Options - Partial base options with defaults applied for any unset fields.
69
60
  * @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
70
- *
71
- * @example
72
- *
73
- * ```ts
74
- * const process = new FfmpegFMp4Process(ffmpegOptions, recordingConfig, true, { fps: 30 });
75
- * ```
76
61
  */
77
62
  constructor(ffmpegOptions, recordingConfig, fMp4Options = {}, isVerbose = false) {
78
63
  // Initialize our parent.
79
64
  super(ffmpegOptions);
80
65
  // We want to log errors when they occur.
81
66
  this.isLoggingErrors = true;
82
- // Initialize our recording buffer.
83
- this.hasInitSegment = false;
84
- this._initSegment = Buffer.alloc(0);
85
- this.recordingBuffer = [];
86
- // Initialize our state.
87
- this.isLivestream = !!fMp4Options.url;
88
- this.isTimedOut = false;
89
- fMp4Options.audioStream ??= 0;
90
- fMp4Options.audioFilters ??= [];
91
- fMp4Options.codec ??= "h264";
92
- fMp4Options.enableAudio ??= true;
93
- fMp4Options.fps ??= 30;
94
- fMp4Options.hardwareDecoding ??= this.options.codecSupport.ffmpegVersion.startsWith("8.") ? this.options.config.hardwareDecoding : false;
95
- fMp4Options.hardwareTranscoding ??= this.options.config.hardwareTranscoding;
96
- fMp4Options.transcodeAudio ??= true;
97
- fMp4Options.videoFilters ??= [];
98
- fMp4Options.videoStream ??= 0;
67
+ // Store the recording configuration for use by subclass hook methods.
68
+ this.recordingConfig = recordingConfig;
69
+ // Apply defaults to the base options and store them. Subclasses store their own mode-specific options separately.
70
+ this.fMp4Options = {
71
+ audioFilters: fMp4Options.audioFilters ?? [],
72
+ audioStream: fMp4Options.audioStream ?? 0,
73
+ codec: fMp4Options.codec ?? "h264",
74
+ enableAudio: fMp4Options.enableAudio ?? true,
75
+ hardwareDecoding: fMp4Options.hardwareDecoding ?? (this.options.codecSupport.ffmpegVersion.startsWith("8.") ? this.options.config.hardwareDecoding : false),
76
+ hardwareTranscoding: fMp4Options.hardwareTranscoding ?? this.options.config.hardwareTranscoding,
77
+ transcodeAudio: fMp4Options.transcodeAudio ?? true,
78
+ videoFilters: fMp4Options.videoFilters ?? [],
79
+ videoStream: fMp4Options.videoStream ?? 0
80
+ };
81
+ // Store the verbose flag for use during command line assembly. We don't build the command line here...subclasses call buildCommandLine() after initializing their
82
+ // own state, which avoids the virtual-call-from-constructor problem.
83
+ this._isVerbose = isVerbose;
84
+ }
85
+ // Verbose flag stored during construction and consumed by buildCommandLine(). This avoids passing isVerbose through the hook method chain.
86
+ _isVerbose;
87
+ // Assembles the FFmpeg command line by calling hook methods in the standard order. The shared skeleton lives here; mode-specific pieces come from subclass overrides.
88
+ // Subclasses call this as the last step of their constructor, after their own state is fully initialized.
89
+ buildCommandLine() {
99
90
  // Configure our video parameters for our input:
100
91
  //
101
92
  // -hide_banner Suppress printing the startup banner in FFmpeg.
@@ -108,100 +99,53 @@ class FfmpegFMp4Process extends FfmpegProcess {
108
99
  "-nostats",
109
100
  "-fflags", "+discardcorrupt",
110
101
  "-err_detect", "ignore_err",
111
- ...this.options.videoDecoder(fMp4Options.codec),
112
- "-max_delay", "500000"
102
+ ...this.options.videoDecoder(this.fMp4Options.codec),
103
+ "-max_delay", "500000",
104
+ // Mode-specific input arguments (RTSP input for livestream, stdin pipe for recording).
105
+ ...this.inputArgs(),
106
+ // Mode-specific separate audio input arguments (livestream with a separate audio endpoint, empty for recording).
107
+ ...this.separateAudioInputArgs()
113
108
  ];
114
- // Track which FFmpeg input index contains the audio stream. By default, audio and video share the same input (index 0). When a separate audio input is provided
115
- // for livestreaming, the audio input becomes index 1.
116
- let audioInputIndex = 0;
117
- if (this.isLivestream && fMp4Options.url) {
118
- // -avioflags direct Tell FFmpeg to minimize buffering to reduce latency for more realtime processing.
119
- // -rtsp_transport tcp Tell the RTSP stream handler that we're looking for a TCP connection.
120
- // -i url RTSPS URL to get our input stream from.
121
- this.commandLineArgs.push("-avioflags", "direct", "-rtsp_transport", "tcp", "-i", fMp4Options.url);
122
- }
123
- else {
124
- // -flags low_delay Tell FFmpeg to optimize for low delay / realtime decoding.
125
- // -probesize number How many bytes should be analyzed for stream information.
126
- // -f mp4 Tell FFmpeg that it should expect an MP4-encoded input stream.
127
- // -i pipe:0 Use standard input to get video data.
128
- // -ss Fast forward to where HKSV is expecting us to be for a recording event.
129
- this.commandLineArgs.push("-flags", "low_delay", "-probesize", (fMp4Options.probesize ?? 5000000).toString(), "-f", "mp4", "-i", "pipe:0", "-ss", (fMp4Options.timeshift ?? 0).toString() + "ms");
130
- }
131
- // If a separate audio input has been configured for livestreaming, add it as a second FFmpeg input. This enables support for devices like DoorBird where video and
132
- // audio are served from different endpoints.
133
- if (this.isLivestream && fMp4Options.enableAudio && fMp4Options.audioInput) {
134
- // Normalize the audio input configuration. A plain string is treated as a URL shorthand.
135
- const audioInput = (typeof fMp4Options.audioInput === "string") ?
136
- { url: fMp4Options.audioInput } :
137
- fMp4Options.audioInput;
138
- // When a raw audio format is specified, we need to explicitly tell FFmpeg how to interpret the incoming stream since it cannot probe raw audio sources.
139
- //
140
- // -f format Specify the raw audio format (e.g., mulaw, alaw, s16le).
141
- // -ar sampleRate Specify the audio sample rate in Hz.
142
- // -ac channels Specify the number of audio channels.
143
- if (audioInput.format) {
144
- this.commandLineArgs.push("-f", audioInput.format, "-ar", (audioInput.sampleRate ?? 8000).toString(), "-ac", (audioInput.channels ?? 1).toString());
145
- }
146
- // For RTSP and RTSPS audio sources, we explicitly request TCP transport to match the behavior we use for the primary video input.
147
- if (["rtsp://", "rtsps://"].some((protocol) => audioInput.url.toLowerCase().startsWith(protocol))) {
148
- this.commandLineArgs.push("-rtsp_transport", "tcp");
149
- }
150
- // -i url Audio input URL.
151
- this.commandLineArgs.push("-i", audioInput.url);
152
- // Audio is now on the second input (index 1) instead of the primary input (index 0).
153
- audioInputIndex = 1;
154
- }
155
109
  // Configure our recording options for the video stream:
156
110
  //
157
111
  // -map 0:v:X Selects the video track from the input.
158
- this.commandLineArgs.push("-map", "0:v:" + fMp4Options.videoStream.toString(), ...(this.isLivestream ? ["-codec:v", "copy"] : this.options.recordEncoder({
159
- bitrate: recordingConfig.videoCodec.parameters.bitRate,
160
- fps: recordingConfig.videoCodec.resolution[2],
161
- hardwareDecoding: fMp4Options.hardwareDecoding,
162
- hardwareTranscoding: fMp4Options.hardwareTranscoding,
163
- height: recordingConfig.videoCodec.resolution[1],
164
- idrInterval: HKSV_IDR_INTERVAL,
165
- inputFps: fMp4Options.fps,
166
- level: recordingConfig.videoCodec.parameters.level,
167
- profile: recordingConfig.videoCodec.parameters.profile,
168
- width: recordingConfig.videoCodec.resolution[0]
169
- })));
112
+ this.commandLineArgs.push("-map", "0:v:" + this.fMp4Options.videoStream.toString(),
113
+ // Mode-specific video encoder arguments (copy for livestream, recordEncoder for recording).
114
+ ...this.videoEncoderArgs());
170
115
  // Configure our video filters, if we have them.
171
- if (fMp4Options.videoFilters.length) {
172
- this.commandLineArgs.push("-filter:v", fMp4Options.videoFilters.join(", "));
173
- }
174
- // If we're livestreaming, emit fragments at one-second intervals.
175
- if (this.isLivestream) {
176
- // -frag_duration number Length of each fMP4 fragment, in microseconds.
177
- this.commandLineArgs.push("-frag_duration", "1000000");
116
+ if (this.fMp4Options.videoFilters.length) {
117
+ this.commandLineArgs.push("-filter:v", this.fMp4Options.videoFilters.join(", "));
178
118
  }
119
+ // Mode-specific post-filter arguments (frag_duration for livestream, empty for recording).
120
+ this.commandLineArgs.push(...this.postFilterArgs());
179
121
  // -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
180
122
  // at each keyframe, skip creating a segment index (SIDX) box in fragments, and skip writing the final MOOV trailer since it's unneeded.
181
123
  // -flush_packets 1 Ensure we flush our write buffer after each muxed packet.
182
124
  // -reset_timestamps Reset timestamps at the beginning of each segment.
183
125
  // -metadata Set the metadata to the name of the camera to distinguish between FFmpeg sessions.
184
- 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"));
185
- if (fMp4Options.enableAudio) {
126
+ 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.metadataLabel());
127
+ // Assemble the audio encoding block. This is shared between both modes...the only mode-specific piece is which FFmpeg input index carries the audio stream.
128
+ let transcodeAudio = this.fMp4Options.transcodeAudio;
129
+ if (this.fMp4Options.enableAudio) {
186
130
  // Configure the audio portion of the command line. Options we use are:
187
131
  //
188
132
  // -map N:a:X? Selects the audio stream from input N, if it exists. The input index is 0 when audio and video share the same input, or 1 when a
189
133
  // separate audio input has been configured.
190
- this.commandLineArgs.push("-map", audioInputIndex.toString() + ":a:" + fMp4Options.audioStream.toString() + "?");
134
+ this.commandLineArgs.push("-map", this.audioInputIndex().toString() + ":a:" + this.fMp4Options.audioStream.toString() + "?");
191
135
  // Configure our audio filters, if we have them.
192
- if (fMp4Options.audioFilters.length) {
193
- this.commandLineArgs.push("-filter:a", fMp4Options.audioFilters.join(", "));
194
- // Audio filters require transcoding. If the user's decided to filter, we enforce this requirement even if they wanted to copy the audio stream.
195
- fMp4Options.transcodeAudio = true;
136
+ if (this.fMp4Options.audioFilters.length) {
137
+ this.commandLineArgs.push("-filter:a", this.fMp4Options.audioFilters.join(", "));
138
+ // Audio filters require transcoding. If the user has decided to filter, we enforce this requirement even if they wanted to copy the audio stream.
139
+ transcodeAudio = true;
196
140
  }
197
- if (fMp4Options.transcodeAudio) {
141
+ if (transcodeAudio) {
198
142
  // Configure the audio portion of the command line. Options we use are:
199
143
  //
200
144
  // -codec:a Encode using the codecs available to us on given platforms.
201
145
  // -profile:a Specify either low-complexity AAC or enhanced low-delay AAC for HKSV events.
202
146
  // -ar samplerate Sample rate to use for this audio. This is specified by HKSV.
203
147
  // -ac number Set the number of audio channels.
204
- 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());
148
+ this.commandLineArgs.push(...this.options.audioEncoder({ codec: this.recordingConfig.audioCodec.type }), "-profile:a", translateAudioRecordingCodecType[this.recordingConfig.audioCodec.type], "-ar", translateAudioSampleRate[this.recordingConfig.audioCodec.samplerate] + "k", "-ac", (this.recordingConfig.audioCodec.audioChannels ?? 1).toString());
205
149
  }
206
150
  else {
207
151
  // Configure the audio portion of the command line. Options we use are:
@@ -216,41 +160,43 @@ class FfmpegFMp4Process extends FfmpegProcess {
216
160
  // pipe:1 Output the stream to standard output.
217
161
  this.commandLineArgs.push("-f", "mp4", "pipe:1");
218
162
  // Additional logging, but only if we're debugging.
219
- if (isVerbose || this.isVerbose) {
163
+ if (this._isVerbose || this.isVerbose) {
220
164
  this.commandLineArgs.unshift("-loglevel", "level+verbose");
221
165
  }
222
166
  }
223
167
  /**
224
- * Prepares and configures the FFmpeg process for reading and parsing output fMP4 data.
225
- *
226
- * This method is called internally by the process lifecycle and is not typically invoked directly by consumers.
168
+ * Prepares and configures the FFmpeg process for reading and parsing output fMP4 data. The box parsing loop is shared...each complete box is dispatched to the
169
+ * subclass via handleParsedBox().
227
170
  */
228
171
  configureProcess() {
229
172
  let dataListener;
230
173
  // Call our parent to get started.
231
174
  super.configureProcess();
232
175
  // Initialize our variables that we need to process incoming FFmpeg packets.
233
- let header = Buffer.alloc(0);
234
- let bufferRemaining = Buffer.alloc(0);
176
+ let header = EMPTY_BUFFER;
177
+ let bufferRemaining = EMPTY_BUFFER;
235
178
  let dataLength = 0;
236
179
  let type = "";
237
- // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video.
180
+ // Process FFmpeg output and parse out the fMP4 stream it's generating. Here, we take on the task of parsing the fMP4 stream that's being generated and split it up
181
+ // into the MP4 boxes that HAP-NodeJS is ultimately expecting.
238
182
  this.process?.stdout.on("data", dataListener = (buffer) => {
239
183
  // If we have anything left from the last buffer we processed, prepend it to this buffer.
240
184
  if (bufferRemaining.length > 0) {
241
185
  buffer = Buffer.concat([bufferRemaining, buffer]);
242
- bufferRemaining = Buffer.alloc(0);
186
+ bufferRemaining = EMPTY_BUFFER;
243
187
  }
244
188
  let offset = 0;
245
- // 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
246
- // 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
247
- // boxes that HAP-NodeJS is ultimately expecting.
189
+ // 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
190
+ // container. It's composed of an 8-byte header, followed by the data payload it carries.
248
191
  for (;;) {
249
192
  let data;
250
- // 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
251
- // container. It's composed of an 8-byte header, followed by the data payload it carries.
252
193
  // No existing header, let's start a new box.
253
194
  if (!header.length) {
195
+ // If there aren't enough bytes for a complete box header, save them for the next chunk.
196
+ if (buffer.length < 8) {
197
+ bufferRemaining = buffer;
198
+ break;
199
+ }
254
200
  // Grab the header. The first four bytes represents the length of the entire box. Second four bytes represent the box type.
255
201
  header = buffer.subarray(0, 8);
256
202
  // Now we retrieve the length of the box.
@@ -272,32 +218,10 @@ class FfmpegFMp4Process extends FfmpegProcess {
272
218
  bufferRemaining = data;
273
219
  break;
274
220
  }
275
- // If we're creating a livestream to be consumed by the timeshift buffer, we need to track the initialization segment, and emit segments.
276
- if (this.isLivestream) {
277
- // If this is part of the initialization segment, store it for future use.
278
- if (!this.hasInitSegment) {
279
- // 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.
280
- if (type === "moof") {
281
- this.hasInitSegment = true;
282
- this.emit("initsegment");
283
- }
284
- else {
285
- this._initSegment = Buffer.concat([this._initSegment, header, data]);
286
- }
287
- }
288
- if (this.hasInitSegment) {
289
- // We only emit segments once we have the initialization segment.
290
- this.emit("segment", Buffer.concat([header, data]));
291
- }
292
- }
293
- else {
294
- // Add it to our queue to be eventually pushed out through our generator function.
295
- this.recordingBuffer.push({ data: data, header: header, length: dataLength, type: type });
296
- this.emit("mp4box");
297
- }
221
+ // Dispatch the complete box to the subclass for mode-specific handling.
222
+ this.handleParsedBox(header, data, dataLength, type);
298
223
  // Prepare to start a new box for the next buffer that we will be processing.
299
- data = Buffer.alloc(0);
300
- header = Buffer.alloc(0);
224
+ header = EMPTY_BUFFER;
301
225
  type = "";
302
226
  // We've parsed an entire box, and there's no more data in this buffer to parse.
303
227
  if (buffer.length === (offset + dataLength)) {
@@ -315,59 +239,16 @@ class FfmpegFMp4Process extends FfmpegProcess {
315
239
  });
316
240
  }
317
241
  /**
318
- * Retrieves the fMP4 initialization segment generated by FFmpeg.
319
- *
320
- * Waits until the initialization segment is available, then returns it.
321
- *
322
- * @returns A promise resolving to the initialization segment as a Buffer.
323
- *
324
- * @example
325
- *
326
- * ```ts
327
- * const initSegment = await process.getInitSegment();
328
- * ```
329
- */
330
- async getInitSegment() {
331
- // If we have the initialization segment, return it.
332
- if (this.hasInitSegment) {
333
- return this._initSegment;
334
- }
335
- // Wait until the initialization segment is seen and then try again.
336
- await events.once(this, "initsegment");
337
- return this.getInitSegment();
338
- }
339
- /**
340
- * Stops the FFmpeg process and performs cleanup, including emitting termination events for segment generators.
341
- *
342
- * This is called as part of the process shutdown sequence.
242
+ * Stops the FFmpeg process and performs cleanup. Subclasses override this to emit mode-specific events before calling super, which handles the shared teardown and
243
+ * emits the "close" event.
343
244
  */
344
245
  stopProcess() {
345
246
  // Call our parent to get started.
346
247
  super.stopProcess();
347
- // Ensure that we clear out of our segment generator by guaranteeing an exit path.
248
+ // Signal that the process has ended.
348
249
  this.isEnded = true;
349
- this.emit("mp4box");
350
250
  this.emit("close");
351
251
  }
352
- /**
353
- * Starts the FFmpeg process, adjusting segment length for livestreams if set.
354
- *
355
- * @example
356
- *
357
- * ```ts
358
- * process.start();
359
- * ```
360
- */
361
- start() {
362
- if (this.isLivestream && (this.segmentLength !== undefined)) {
363
- const fragIndex = this.commandLineArgs.indexOf("-frag_duration");
364
- if (fragIndex !== -1) {
365
- this.commandLineArgs[fragIndex + 1] = (this.segmentLength * 1000).toString();
366
- }
367
- }
368
- // Start the FFmpeg session.
369
- super.start();
370
- }
371
252
  /**
372
253
  * Stops the FFmpeg process and logs errors if specified.
373
254
  *
@@ -399,24 +280,118 @@ class FfmpegFMp4Process extends FfmpegProcess {
399
280
  if (!this.isLoggingErrors) {
400
281
  return;
401
282
  }
402
- // Known HKSV-related errors due to occasional inconsistencies that are occasionally produced by the input stream and FFmpeg's own occasional quirkiness.
403
- const ffmpegKnownHksvError = new RegExp([
404
- "(Cannot determine format of input stream 0:0 after EOF)",
405
- "(Could not write header \\(incorrect codec parameters \\?\\): Broken pipe)",
406
- "(Could not write header for output file #0)",
407
- "(Error closing file: Broken pipe)",
408
- "(Error splitting the input into NAL units\\.)",
409
- "(Invalid data found when processing input)",
410
- "(moov atom not found)"
411
- ].join("|"));
412
283
  // See if we know about this error.
413
- if (this.stderrLog.some(x => ffmpegKnownHksvError.test(x))) {
284
+ if (this.stderrLog.some(x => FFMPEG_KNOWN_HKSV_ERROR.test(x))) {
414
285
  this.log.error("FFmpeg ended unexpectedly due to issues processing the media stream. This error can be safely ignored - it will occur occasionally.");
415
286
  return;
416
287
  }
417
288
  // Otherwise, revert to our default logging in our parent.
418
289
  super.logFfmpegError(exitCode, signal);
419
290
  }
291
+ }
292
+ /**
293
+ * Manages a HomeKit Secure Video recording FFmpeg process.
294
+ *
295
+ * @example
296
+ *
297
+ * ```ts
298
+ * const process = new FfmpegRecordingProcess(ffmpegOptions, recordingConfig, 30, true, 5000000, 0);
299
+ * process.start();
300
+ * ```
301
+ *
302
+ * @see FfmpegFMp4Process
303
+ *
304
+ * @category FFmpeg
305
+ */
306
+ export class FfmpegRecordingProcess extends FfmpegFMp4Process {
307
+ /**
308
+ * Indicates whether the recording has timed out waiting for FFmpeg output.
309
+ */
310
+ isTimedOut;
311
+ fps;
312
+ probesize;
313
+ recordingBuffer;
314
+ timeshift;
315
+ /**
316
+ * Constructs a new FFmpeg recording process for HKSV events.
317
+ *
318
+ * @param options - FFmpeg configuration options.
319
+ * @param recordingConfig - HomeKit recording configuration for the session.
320
+ * @param fMp4Options - fMP4 recording options.
321
+ * @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
322
+ */
323
+ constructor(options, recordingConfig, fMp4Options = {}, isVerbose = false) {
324
+ super(options, recordingConfig, fMp4Options, isVerbose);
325
+ // Store recording-specific options.
326
+ this.fps = fMp4Options.fps ?? 30;
327
+ this.isTimedOut = false;
328
+ this.probesize = fMp4Options.probesize ?? 5000000;
329
+ this.recordingBuffer = [];
330
+ this.timeshift = fMp4Options.timeshift ?? 0;
331
+ // Assemble the FFmpeg command line now that all state is initialized.
332
+ this.buildCommandLine();
333
+ }
334
+ // Recording input: read fMP4 data from standard input with low-delay optimizations and an optional timeshift for HKSV event alignment.
335
+ //
336
+ // -flags low_delay Tell FFmpeg to optimize for low delay / realtime decoding.
337
+ // -probesize number How many bytes should be analyzed for stream information.
338
+ // -f mp4 Tell FFmpeg that it should expect an MP4-encoded input stream.
339
+ // -i pipe:0 Use standard input to get video data.
340
+ // -ss Fast forward to where HKSV is expecting us to be for a recording event.
341
+ inputArgs() {
342
+ return [
343
+ "-flags", "low_delay",
344
+ "-probesize", this.probesize.toString(),
345
+ "-f", "mp4",
346
+ "-i", "pipe:0",
347
+ "-ss", this.timeshift.toString() + "ms"
348
+ ];
349
+ }
350
+ // Recordings always read audio from the primary input...no separate audio source.
351
+ separateAudioInputArgs() {
352
+ return [];
353
+ }
354
+ // Audio is always on the primary input (index 0) for recordings.
355
+ audioInputIndex() {
356
+ return 0;
357
+ }
358
+ // Recordings transcode video using the platform-appropriate encoder for HKSV.
359
+ videoEncoderArgs() {
360
+ return this.options.recordEncoder({
361
+ bitrate: this.recordingConfig.videoCodec.parameters.bitRate,
362
+ fps: this.recordingConfig.videoCodec.resolution[2],
363
+ hardwareDecoding: this.fMp4Options.hardwareDecoding,
364
+ hardwareTranscoding: this.fMp4Options.hardwareTranscoding,
365
+ height: this.recordingConfig.videoCodec.resolution[1],
366
+ idrInterval: HKSV_IDR_INTERVAL,
367
+ inputFps: this.fps,
368
+ level: this.recordingConfig.videoCodec.parameters.level,
369
+ profile: this.recordingConfig.videoCodec.parameters.profile,
370
+ width: this.recordingConfig.videoCodec.resolution[0]
371
+ });
372
+ }
373
+ // Recordings have no post-filter arguments.
374
+ postFilterArgs() {
375
+ return [];
376
+ }
377
+ // Metadata label identifying this as an HKSV event recording.
378
+ metadataLabel() {
379
+ return "HKSV Event";
380
+ }
381
+ // Each parsed box is queued in the recording buffer for consumption by segmentGenerator().
382
+ handleParsedBox(header, data, dataLength, type) {
383
+ this.recordingBuffer.push({ data, header, length: dataLength, type });
384
+ this.emit("mp4box");
385
+ }
386
+ /**
387
+ * Stops the FFmpeg process and performs cleanup, ensuring the segment generator can exit.
388
+ */
389
+ stopProcess() {
390
+ // Emit mp4box to unblock segmentGenerator() if it's waiting, then let the base class handle the rest.
391
+ this.isEnded = true;
392
+ this.emit("mp4box");
393
+ super.stopProcess();
394
+ }
420
395
  /**
421
396
  * Asynchronously generates complete segments from FFmpeg output, formatted for HomeKit Secure Video.
422
397
  *
@@ -437,7 +412,7 @@ class FfmpegFMp4Process extends FfmpegProcess {
437
412
  let segment = [];
438
413
  // Loop forever, generating either FTYP/MOOV box pairs or MOOF/MDAT box pairs for HomeKit Secure Video.
439
414
  for (;;) {
440
- // FFmpeg has finished it's output - we're done.
415
+ // FFmpeg has finished its output - we're done.
441
416
  if (this.isEnded) {
442
417
  return;
443
418
  }
@@ -472,54 +447,6 @@ class FfmpegFMp4Process extends FfmpegProcess {
472
447
  }
473
448
  }
474
449
  }
475
- /**
476
- * Returns the initialization segment as a Buffer, or null if not yet available.
477
- *
478
- * @returns The initialization segment Buffer, or `null` if not yet generated.
479
- *
480
- * @example
481
- *
482
- * ```ts
483
- * const init = process.initSegment;
484
- * if(init) {
485
- *
486
- * // Use the initialization segment.
487
- * }
488
- * ```
489
- */
490
- get initSegment() {
491
- if (!this.hasInitSegment) {
492
- return null;
493
- }
494
- return this._initSegment;
495
- }
496
- }
497
- /**
498
- * Manages a HomeKit Secure Video recording FFmpeg process.
499
- *
500
- * @example
501
- *
502
- * ```ts
503
- * const process = new FfmpegRecordingProcess(ffmpegOptions, recordingConfig, 30, true, 5000000, 0);
504
- * process.start();
505
- * ```
506
- *
507
- * @see FfmpegFMp4Process
508
- *
509
- * @category FFmpeg
510
- */
511
- export class FfmpegRecordingProcess extends FfmpegFMp4Process {
512
- /**
513
- * Constructs a new FFmpeg recording process for HKSV events.
514
- *
515
- * @param options - FFmpeg configuration options.
516
- * @param recordingConfig - HomeKit recording configuration for the session.
517
- * @param fMp4Options - fMP4 recording options.
518
- * @param isVerbose - If `true`, enables more verbose logging for debugging purposes. Defaults to `false`.
519
- */
520
- constructor(options, recordingConfig, fMp4Options = {}, isVerbose = false) {
521
- super(options, recordingConfig, fMp4Options, isVerbose);
522
- }
523
450
  }
524
451
  /**
525
452
  * Manages a HomeKit livestream FFmpeg process for generating fMP4 segments.
@@ -538,6 +465,16 @@ export class FfmpegRecordingProcess extends FfmpegFMp4Process {
538
465
  * @category FFmpeg
539
466
  */
540
467
  export class FfmpegLivestreamProcess extends FfmpegFMp4Process {
468
+ /**
469
+ * Optional override for the fMP4 fragment duration, in milliseconds. When set, the `-frag_duration` argument is updated before starting the FFmpeg process.
470
+ */
471
+ segmentLength;
472
+ // Set to true during separateAudioInputArgs() when a separate audio input is configured, so that audioInputIndex() returns the correct FFmpeg input index.
473
+ _hasAudioInput;
474
+ _initSegment;
475
+ _initSegmentParts;
476
+ hasInitSegment;
477
+ livestreamOptions;
541
478
  /**
542
479
  * Constructs a new FFmpeg livestream process.
543
480
  *
@@ -548,6 +485,113 @@ export class FfmpegLivestreamProcess extends FfmpegFMp4Process {
548
485
  */
549
486
  constructor(options, recordingConfig, livestreamOptions, isVerbose = false) {
550
487
  super(options, recordingConfig, livestreamOptions, isVerbose);
488
+ // Store livestream-specific options.
489
+ this._hasAudioInput = false;
490
+ this._initSegment = Buffer.alloc(0);
491
+ this._initSegmentParts = [];
492
+ this.hasInitSegment = false;
493
+ this.livestreamOptions = livestreamOptions;
494
+ // Assemble the FFmpeg command line now that all state is initialized.
495
+ this.buildCommandLine();
496
+ }
497
+ // Livestream input: connect to an RTSP source with direct I/O and TCP transport.
498
+ //
499
+ // -avioflags direct Tell FFmpeg to minimize buffering to reduce latency for more realtime processing.
500
+ // -rtsp_transport tcp Tell the RTSP stream handler that we're looking for a TCP connection.
501
+ // -i url RTSPS URL to get our input stream from.
502
+ inputArgs() {
503
+ return [
504
+ "-avioflags", "direct",
505
+ "-rtsp_transport", "tcp",
506
+ "-i", this.livestreamOptions.url
507
+ ];
508
+ }
509
+ // If a separate audio input has been configured, build the FFmpeg input arguments for it. This enables support for devices like DoorBird where video and audio are
510
+ // served from different endpoints.
511
+ separateAudioInputArgs() {
512
+ if (!this.fMp4Options.enableAudio || !this.livestreamOptions.audioInput) {
513
+ return [];
514
+ }
515
+ const args = [];
516
+ // Normalize the audio input configuration. A plain string is treated as a URL shorthand.
517
+ const audioInput = (typeof this.livestreamOptions.audioInput === "string") ?
518
+ { url: this.livestreamOptions.audioInput } :
519
+ this.livestreamOptions.audioInput;
520
+ // When a raw audio format is specified, we need to explicitly tell FFmpeg how to interpret the incoming stream since it cannot probe raw audio sources.
521
+ //
522
+ // -f format Specify the raw audio format (e.g., mulaw, alaw, s16le).
523
+ // -ar sampleRate Specify the audio sample rate in Hz.
524
+ // -ac channels Specify the number of audio channels.
525
+ if (audioInput.format) {
526
+ args.push("-f", audioInput.format, "-ar", (audioInput.sampleRate ?? 8000).toString(), "-ac", (audioInput.channels ?? 1).toString());
527
+ }
528
+ // For RTSP and RTSPS audio sources, we explicitly request TCP transport to match the behavior we use for the primary video input.
529
+ if (["rtsp://", "rtsps://"].some((protocol) => audioInput.url.toLowerCase().startsWith(protocol))) {
530
+ args.push("-rtsp_transport", "tcp");
531
+ }
532
+ // -i url Audio input URL.
533
+ args.push("-i", audioInput.url);
534
+ // Track that we have a separate audio input so audioInputIndex() returns the correct value.
535
+ this._hasAudioInput = true;
536
+ return args;
537
+ }
538
+ // When a separate audio input is configured, audio is on the second FFmpeg input (index 1). Otherwise it shares the primary input (index 0).
539
+ audioInputIndex() {
540
+ return this._hasAudioInput ? 1 : 0;
541
+ }
542
+ // Livestreams remux the video stream directly without transcoding.
543
+ videoEncoderArgs() {
544
+ return ["-codec:v", "copy"];
545
+ }
546
+ // Livestreams emit fMP4 fragments at one-second intervals by default.
547
+ //
548
+ // -frag_duration number Length of each fMP4 fragment, in microseconds.
549
+ postFilterArgs() {
550
+ return ["-frag_duration", "1000000"];
551
+ }
552
+ // Metadata label identifying this as a livestream buffer.
553
+ metadataLabel() {
554
+ return "Livestream Buffer";
555
+ }
556
+ // Livestream box handling: accumulate the initialization segment (everything before the first moof box), then emit each subsequent box as a segment event.
557
+ handleParsedBox(header, data, _dataLength, type) {
558
+ // If this is part of the initialization segment, store it for future use.
559
+ if (!this.hasInitSegment) {
560
+ // 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. We collect the parts into an
561
+ // array and concatenate once at the end to avoid creating intermediate buffers on every pre-moof box.
562
+ if (type === "moof") {
563
+ this._initSegment = Buffer.concat(this._initSegmentParts);
564
+ this._initSegmentParts = [];
565
+ this.hasInitSegment = true;
566
+ this.emit("initsegment");
567
+ }
568
+ else {
569
+ this._initSegmentParts.push(header, data);
570
+ }
571
+ }
572
+ if (this.hasInitSegment) {
573
+ // We only emit segments once we have the initialization segment.
574
+ this.emit("segment", Buffer.concat([header, data]));
575
+ }
576
+ }
577
+ /**
578
+ * Starts the FFmpeg process, adjusting the fragment duration if segmentLength has been set.
579
+ *
580
+ * @example
581
+ *
582
+ * ```ts
583
+ * process.start();
584
+ * ```
585
+ */
586
+ start() {
587
+ if (this.segmentLength !== undefined) {
588
+ const fragIndex = this.commandLineArgs.indexOf("-frag_duration");
589
+ if (fragIndex !== -1) {
590
+ this.commandLineArgs[fragIndex + 1] = (this.segmentLength * 1000).toString();
591
+ }
592
+ }
593
+ // Start the FFmpeg session.
594
+ super.start();
551
595
  }
552
596
  /**
553
597
  * Gets the fMP4 initialization segment generated by FFmpeg for the livestream.
@@ -561,7 +605,34 @@ export class FfmpegLivestreamProcess extends FfmpegFMp4Process {
561
605
  * ```
562
606
  */
563
607
  async getInitSegment() {
564
- return super.getInitSegment();
608
+ // If we have the initialization segment, return it.
609
+ if (this.hasInitSegment) {
610
+ return this._initSegment;
611
+ }
612
+ // Wait until the initialization segment is seen and then try again.
613
+ await once(this, "initsegment");
614
+ return this.getInitSegment();
615
+ }
616
+ /**
617
+ * Returns the initialization segment as a Buffer, or null if not yet available.
618
+ *
619
+ * @returns The initialization segment Buffer, or `null` if not yet generated.
620
+ *
621
+ * @example
622
+ *
623
+ * ```ts
624
+ * const init = process.initSegment;
625
+ * if(init) {
626
+ *
627
+ * // Use the initialization segment.
628
+ * }
629
+ * ```
630
+ */
631
+ get initSegment() {
632
+ if (!this.hasInitSegment) {
633
+ return null;
634
+ }
635
+ return this._initSegment;
565
636
  }
566
637
  }
567
638
  //# sourceMappingURL=record.js.map