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