homebridge-plugin-utils 1.35.0 → 2.0.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 (198) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +95 -0
  23. package/dist/cli/index.js +355 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -262
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +49 -2
  55. package/dist/ffmpeg/fmp4.js +47 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +142 -96
  73. package/dist/ffmpeg/process.js +406 -278
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -567
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -251
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -2
  88. package/dist/ffmpeg/settings.js +20 -5
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -150
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -6
  100. package/dist/index.js +7 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +602 -45
  186. package/dist/util.js +783 -78
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -3,365 +3,493 @@
3
3
  * ffmpeg/process.ts: Base class to provide FFmpeg process control and capability introspection.
4
4
  */
5
5
  /**
6
- * FFmpeg process management and capability introspection.
6
+ * FFmpeg process management with AbortSignal-based lifecycle.
7
7
  *
8
- * This module defines the `FfmpegProcess` class, which abstracts the spawning, monitoring, and logging of FFmpeg subprocesses. It manages process state, handles
9
- * command-line argument composition, processes standard streams (stdin, stdout, stderr), and robustly reports process errors and exit conditions.
8
+ * This module defines the `FfmpegProcess` base class, the foundation every other FFmpeg class in `homebridge-plugin-utils` builds on. Construction spawns the child; the
9
+ * composed {@link AbortSignal} exposed as `this.signal` is the single source of truth for the process's lifetime. Every teardown path - external `abort()`, parent
10
+ * signal aborting, natural exit, spawn failure, optional startup timeout - converges on the same signal.
10
11
  *
11
- * Designed for use in Homebridge plugins, this module enables safe and flexible execution of FFmpeg commands, making it easier to integrate video/audio processing
12
- * pipelines with realtime control and diagnostics.
12
+ * The class is an {@link AsyncDisposable} so callers can manage the process with `await using` for scope-bound lifetimes. It is intentionally not an `EventEmitter`:
13
+ * ready/exit are {@link Promise}s, stderr accumulates to {@link FfmpegProcess.stderrLog}, and fine-grained termination hooks are registered against `this.signal`.
13
14
  *
14
15
  * Key features:
15
16
  *
16
- * - Comprehensive FFmpeg subprocess management (start, monitor, stop, cleanup).
17
- * - Streamlined error handling and logging, with pluggable loggers.
18
- * - Access to process I/O streams for data injection and consumption.
19
- * - Flexible callback and event-based architecture for streaming scenarios.
20
- *
21
- * Intended for developers needing direct, reliable control over FFmpeg process lifecycles with detailed runtime insights, especially in plugin or media automation
22
- * contexts.
17
+ * - Spawn-on-construction. No `start()` step, no configure-then-run.
18
+ * - {@link AbortSignal}-driven teardown. Node's native `spawn({ signal, killSignal })` owns the kill path; no manual SIGKILL fallback timer.
19
+ * - `ready` promise resolves when FFmpeg produces its first stderr byte (the earliest reliable "we are actually running" signal).
20
+ * - `exited` promise resolves with the child's exit code and signal once it terminates. Rejects with `signal.reason` only when the child never started (e.g., `ENOENT`).
21
+ * - Reason-based teardown logging: `"failed"` dumps stderr at ERROR, `"timeout"` logs at WARN, other reasons log at DEBUG.
23
22
  *
24
23
  * @module
25
24
  */
26
- import { spawn } from "node:child_process";
25
+ import { HbpuAbortError, composeSignals, isHbpuAbortError, isHbpuAbortReason, isTimeoutReason, markHandled, onAbort } from "../util.js";
27
26
  import { EOL } from "node:os";
28
- import { EventEmitter } from "node:events";
29
- import { FFMPEG_INPUT_TIMEOUT } from "./settings.js";
30
- import util from "node:util";
31
- // Matches non-printable control characters for stripping from FFmpeg stderr output. Compiled once at module scope rather than per data event.
27
+ import { spawn } from "node:child_process";
28
+ // Matches non-printable control characters that #onStderrData replaces with EOL so control sequences become line boundaries rather than vanishing. Compiled once at
29
+ // module scope rather than per data event.
32
30
  const NON_PRINTABLE_CHARS = /\p{C}+/gu;
31
+ // Shape-level type guard for exit-info-like cause values. The class attaches exit information to `HbpuAbortError`'s `cause` field for `"closed"` and `"failed"`
32
+ // reasons, but `cause` is typed `unknown` at the error boundary, so any consumer that wants to read exit info (including our own teardown logger) must narrow.
33
+ //
34
+ // The predicate's return type is intentionally narrower than the public `FfmpegProcessExitInfo`. The public type declares `exitSignal: Nullable<NodeJS.Signals>` - a
35
+ // compile-time union of ~30 specific strings. Runtime membership cannot be verified without a platform-dependent signal whitelist that would drift from Node itself,
36
+ // so the guard only checks `exitSignal` is a string (or null). The mismatch between "what we can produce strictly" and "what we can validate at the read side" is
37
+ // bridged by `satisfies FfmpegProcessExitInfo` at the production site (see `#onExit`), which gives us strict types for callers of `exited` without asking the
38
+ // read-side guard to lie about what it can actually check.
39
+ //
40
+ // `describeCause` only uses `exitSignal` for string concatenation, so losing the `NodeJS.Signals` narrowing here costs nothing. If a future consumer needs the strict
41
+ // union, they should either enforce it at the produce side (as `#onExit` does) or accept the looser shape at the read side.
42
+ function isExitInfoShape(value) {
43
+ if ((typeof value !== "object") || (value === null)) {
44
+ return false;
45
+ }
46
+ const candidate = value;
47
+ const exitCodeOk = (candidate["exitCode"] === null) || (typeof candidate["exitCode"] === "number");
48
+ const exitSignalOk = (candidate["exitSignal"] === null) || (typeof candidate["exitSignal"] === "string");
49
+ return exitCodeOk && exitSignalOk;
50
+ }
51
+ // Render a `"failed"` abort reason's `cause` for human consumption. Recognized cause shapes, in dispatch order:
52
+ //
53
+ // - `FfmpegProcessExitInfo`: produced by `#onExit` for natural non-zero exits. Rendered as "exit code N" and/or "signal S".
54
+ // - `Error`: produced by `#onSpawnError` from Node's ENOENT/EACCES/etc. Rendered as the error's message.
55
+ // - anything else (including `null`, primitives, arbitrary user-supplied cause values): rendered as "unknown".
56
+ //
57
+ // `cause` is `unknown` by design; this function is the single narrowing point so the teardown logger never touches a field it has not validated. Pure and free-standing
58
+ // because it is stateless - no reason to attach it to the class.
59
+ function describeCause(cause) {
60
+ if (isExitInfoShape(cause)) {
61
+ const parts = [];
62
+ if (cause.exitCode !== null) {
63
+ parts.push("exit code " + cause.exitCode.toString());
64
+ }
65
+ if (cause.exitSignal !== null) {
66
+ parts.push("signal " + cause.exitSignal);
67
+ }
68
+ return (parts.length > 0) ? parts.join(", ") : "no exit information";
69
+ }
70
+ if (cause instanceof Error) {
71
+ return cause.message;
72
+ }
73
+ return "unknown";
74
+ }
33
75
  /**
34
- * Base class providing FFmpeg process management and capability introspection.
76
+ * Base class providing FFmpeg process management with signal-driven lifecycle.
35
77
  *
36
- * This class encapsulates spawning, managing, and logging of FFmpeg processes, as well as handling process I/O and errors. It is designed as a reusable foundation for
37
- * advanced FFmpeg process control in Homebridge plugins or similar environments. Originally inspired by the Homebridge and homebridge-camera-ffmpeg source code.
78
+ * Construction spawns the child immediately. The composed `this.signal` is the single source of truth for the process's lifetime: external `abort()`, a parent signal
79
+ * firing, a non-zero exit, a spawn failure, and (optionally) a startup-timeout all converge on the same signal. Subclasses register additional `"abort"` listeners on
80
+ * `this.signal` rather than overriding `abort()` or `[Symbol.asyncDispose]`.
38
81
  *
39
82
  * @example
40
83
  *
41
84
  * ```ts
42
- * // Create and start an FFmpeg process.
43
- * const process = new FfmpegProcess(options, ["-i", "input.mp4", "-f", "null", "-"]);
44
- * process.start();
85
+ * // Scope-bound: the child is guaranteed to be torn down when the block exits, regardless of success or exception.
86
+ * await using proc = new FfmpegProcess(options, { args: [ "-i", "input.mp4", "-f", "null", "-" ] });
45
87
  *
46
- * // Access process streams if needed.
47
- * const stdin = process.stdin;
48
- * const stdout = process.stdout;
49
- * const stderr = process.stderr;
88
+ * await proc.ready;
89
+ * const { exitCode } = await proc.exited;
90
+ * ```
91
+ *
92
+ * @example
50
93
  *
51
- * // Stop the FFmpeg process when done.
52
- * process.stop();
94
+ * ```ts
95
+ * // Signal-driven: parent controls teardown through its own AbortController.
96
+ * const controller = new AbortController();
97
+ * const proc = new FfmpegProcess(options, { args, signal: controller.signal });
98
+ *
99
+ * // Later, from anywhere with the controller:
100
+ * controller.abort();
53
101
  * ```
54
102
  *
55
103
  * @see {@link https://ffmpeg.org/documentation.html | FFmpeg Documentation}
56
104
  *
57
- * @see {@link https://nodejs.org/api/child_process.html | Node.js child_process}
105
+ * @see {@link https://nodejs.org/api/child_process.html#child_processspawncommand-args-options | Node.js child_process.spawn}
58
106
  *
59
107
  * @see FfmpegOptions
60
108
  *
61
109
  * @category FFmpeg
62
110
  */
63
- export class FfmpegProcess extends EventEmitter {
111
+ export class FfmpegProcess {
64
112
  /**
65
- * Optional callback to be called when the FFmpeg process is ready for streaming.
113
+ * The composed abort signal representing this process's lifetime. Aborts exactly once when the child exits, the parent signal fires, or `abort()` is called; the
114
+ * reason encoded on `signal.reason` names the cause (see {@link HbpuAbortReason}). Subclasses and external callers attach `"abort"` listeners to this signal when they
115
+ * need scope-bound teardown hooks of their own.
66
116
  */
67
- callback;
117
+ signal;
68
118
  /**
69
- * The command line arguments for invoking FFmpeg.
119
+ * Resolves when FFmpeg has produced its first stderr byte - the earliest point at which we can reliably say the child is running. Rejects with `this.signal.reason`
120
+ * when the process aborts before becoming ready (external abort, spawn failure, startup timeout, early natural exit).
70
121
  */
71
- commandLineArgs;
122
+ ready;
72
123
  /**
73
- * Enables verbose logging for FFmpeg process output.
124
+ * Resolves with the child's exit code and signal once the process terminates. Rejects with `this.signal.reason` only when the child never started (e.g., the FFmpeg
125
+ * binary could not be located); in every other case it resolves with the actual exit information, even when the abort reason is `"failed"`.
74
126
  */
75
- isVerbose;
127
+ exited;
76
128
  /**
77
- * Logger instance for output and error reporting.
129
+ * Writable standard input stream for the FFmpeg process.
78
130
  */
79
- log;
131
+ stdin;
132
+ /**
133
+ * Readable standard output stream. Subclasses that consume this stream internally narrow the public type to `never` via `declare`.
134
+ */
135
+ stdout;
136
+ /**
137
+ * Readable standard error stream. Primarily useful to callers who want to observe stderr in addition to the accumulated {@link FfmpegProcess.stderrLog}; most callers
138
+ * should prefer `stderrLog` since the class already buffers lines for them.
139
+ */
140
+ stderr;
80
141
  /**
81
- * FFmpeg process configuration options.
142
+ * The FFmpeg options instance this process was constructed with. Exposed for subclasses that need access to the shared configuration (codec support, logger, etc.).
82
143
  */
83
144
  options;
84
145
  /**
85
- * The underlying Node.js ChildProcess instance for the FFmpeg process.
146
+ * Logger instance resolved from `options.log`. Shared by all log output in this class and subclasses.
86
147
  */
87
- process;
88
- _hasError;
89
- _isStarted;
148
+ log;
90
149
  /**
91
- * Accumulated log lines from standard error for error reporting and debugging.
150
+ * The resolved FFmpeg command-line arguments this process was spawned with. Retained so teardown logging can include the original command in error reports.
92
151
  */
93
- _stderrLog;
94
- ffmpegTimeout;
95
- isLogging;
96
- stderrBuffer;
152
+ args;
97
153
  /**
98
- * Indicates whether the FFmpeg process has ended. Protected to allow subclass state transitions (e.g., signaling generators before shutdown).
154
+ * Protected alias for {@link FfmpegProcess.stdin}. Subclasses that publicly narrow `stdin` to `never` still consume the underlying stream through this typed path.
99
155
  */
100
- _isEnded;
101
- // Create a new FFmpeg process instance.
102
- constructor(options, commandLineArgs, callback) {
103
- // Initialize our parent.
104
- super();
105
- this.callback = null;
106
- this.commandLineArgs = [];
107
- this._hasError = false;
108
- this._isEnded = false;
109
- this._isStarted = false;
110
- this.isLogging = false;
111
- this.log = options.log;
112
- this.options = options;
113
- this.process = null;
114
- this.stderrBuffer = "";
115
- this._stderrLog = [];
116
- // Toggle FFmpeg logging, if configured.
117
- this.isVerbose = this.options.codecSupport.verbose;
118
- // If we've specified a command line or a callback, let's save them.
119
- if (commandLineArgs) {
120
- this.commandLineArgs = commandLineArgs;
121
- }
122
- if (callback) {
123
- this.callback = callback;
124
- }
125
- }
126
- // Prepare and start our FFmpeg process.
127
- prepareProcess(commandLineArgs, callback) {
128
- // If we've specified a new command line or callback, let's save them.
129
- if (commandLineArgs) {
130
- this.commandLineArgs = commandLineArgs;
131
- }
132
- // Save the callback, if we have one.
133
- if (callback) {
134
- this.callback = callback;
135
- }
136
- // See if we should display ffmpeg command output.
137
- this.isLogging = false;
138
- // Track if we've started or ended FFmpeg.
139
- this._isStarted = false;
140
- this._isEnded = false;
141
- // If we've got a loglevel specified, ensure we display it.
142
- if (this.commandLineArgs.includes("-loglevel")) {
143
- this.isLogging = true;
144
- }
145
- // Inform the user, if we've been asked to do so.
146
- if (this.isLogging || this.isVerbose || this.options.debug) {
147
- this.log.info("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" "));
148
- }
149
- else {
150
- this.log.debug("FFmpeg command (version: %s): %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" "));
151
- }
152
- return true;
153
- }
156
+ _stdin;
154
157
  /**
155
- * Starts the FFmpeg process with the provided command line and callback.
156
- *
157
- * @param commandLineArgs - Optional. Arguments for FFmpeg command line.
158
- * @param callback - Optional. Callback invoked when streaming is ready.
159
- * @param errorHandler - Optional. Function called if FFmpeg fails to start or terminates with error.
158
+ * Protected alias for {@link FfmpegProcess.stdout}. Subclasses that publicly narrow `stdout` to `never` (because an internal consumer such as `Mp4SegmentAssembler`
159
+ * owns the stream) still consume the underlying stream through this typed path.
160
+ */
161
+ _stdout;
162
+ /**
163
+ * Protected alias for {@link FfmpegProcess.stderr}. Provided for symmetry with `_stdin` / `_stdout`.
164
+ */
165
+ _stderr;
166
+ // The private AbortController whose signal is fed into the composed `this.signal`. When a parent signal is provided, `AbortSignal.any` composes this controller's
167
+ // signal with the parent's; otherwise `this.signal` is this controller's signal directly.
168
+ #controller;
169
+ // The underlying Node ChildProcess. Held privately so subclasses route through `_stdin` / `_stdout` / `_stderr` rather than reaching into the raw object.
170
+ #process;
171
+ // Accumulated stderr lines. Exposed publicly as a readonly view via `stderrLog`. Preserved across teardown for post-mortem inspection.
172
+ #stderrLog = [];
173
+ // Pending partial stderr line (text read from the stream that has not yet terminated with a line break).
174
+ #stderrBuffer = "";
175
+ // Resolver pair for `ready`. First stderr byte resolves; teardown rejects if still pending.
176
+ #readyResolvers;
177
+ // Resolver pair for `exited`. "exit" event resolves with the actual info; "error" rejects when the child never spawned.
178
+ #exitedResolvers;
179
+ // Set `true` once we have observed first stderr output. Used by the optional startup-timeout path to decide whether the timeout fired meaningfully.
180
+ #firstStderr = false;
181
+ // Cached decision - should stderr lines be mirrored to `log.info`? Computed once from `args`, `verbose`, and `debug` during construction and immutable thereafter.
182
+ // Cached rather than recomputed because the stderr hot path consults it per chunk and the inputs never change.
183
+ #liveLog;
184
+ /**
185
+ * Construct and spawn a new FFmpeg process.
160
186
  *
161
- * @example
187
+ * Spawning happens synchronously as part of construction: by the time the constructor returns, the child is already running (or has already scheduled its `"error"`
188
+ * event for a spawn failure). There is no separate `start()` step.
162
189
  *
163
- * ```ts
164
- * process.start(["-i", "input.mp4", "-f", "null", "-"]);
165
- * ```
190
+ * @param options - Shared {@link FfmpegOptions} configuration (codec support, logger, debug flag, name).
191
+ * @param init - Optional init options. See {@link FfmpegProcessInit}.
166
192
  */
167
- start(commandLineArgs, callback, errorHandler) {
168
- // Prepared our FFmpeg process.
169
- if (!this.prepareProcess(commandLineArgs, callback)) {
170
- this.log.error("Error preparing to run FFmpeg.");
171
- return;
172
- }
173
- // Execute the command line based on what we've prepared.
174
- this.process = spawn(this.options.codecSupport.ffmpegExec, this.commandLineArgs);
175
- // Configure any post-spawn listeners and other plumbing.
176
- this.configureProcess(errorHandler);
177
- }
178
- // Configure our FFmpeg process, once started.
179
- configureProcess(errorHandler) {
180
- let dataListener;
181
- let errorListener;
182
- // Handle errors emitted during process creation, such as an invalid command line.
183
- this.process?.once("error", (error) => {
184
- let message = error.message;
185
- if (error.code === "ENOENT") {
186
- message = "unable to find '" + (error.path ?? "unknown") + "'";
187
- }
188
- this.log.error("FFmpeg failed to start: %s.", message);
189
- // Execute our error handler, if one is provided.
190
- if (errorHandler) {
191
- void errorHandler(error.name + ": " + message);
192
- }
193
- });
194
- // Handle errors on stdin.
195
- this.process?.stdin.on("error", errorListener = (error) => {
196
- if (!error.message.includes("EPIPE")) {
197
- this.log.error("FFmpeg error: %s.", error.message);
198
- }
199
- });
200
- // Handle logging output that gets sent to stderr.
201
- this.process?.stderr.on("data", dataListener = (data) => {
202
- // Inform us when we start receiving data back from FFmpeg. We do this here because it's the only
203
- // truly reliable place we can check on FFmpeg. stdin and stdout may not be used at all, depending
204
- // on the way FFmpeg is called, but stderr will always be there.
205
- if (!this._isStarted) {
206
- this._isStarted = true;
207
- this._isEnded = false;
208
- this.log.debug("FFmpeg process started.");
209
- // Always remember to execute the callback once we're setup to let homebridge know we're streaming.
210
- if (this.callback) {
211
- this.callback();
212
- this.callback = null;
213
- }
214
- }
215
- // Append to the current line we've been buffering. We don't want to output not-printable characters to ensure the log output is readable.
216
- this.stderrBuffer += data.toString().replace(NON_PRINTABLE_CHARS, EOL);
217
- // Debugging and additional logging collection.
218
- for (;;) {
219
- // Find the next newline.
220
- const lineIndex = this.stderrBuffer.indexOf(EOL);
221
- // If there's no newline, we're done until we get more data.
222
- if (lineIndex === -1) {
223
- return;
224
- }
225
- // Grab the next complete line, and increment our buffer.
226
- const line = this.stderrBuffer.slice(0, lineIndex);
227
- this.stderrBuffer = this.stderrBuffer.slice(lineIndex + EOL.length);
228
- this._stderrLog.push(line);
229
- // Show it to the user if it's been requested.
230
- if (this.isLogging || this.isVerbose || this.options.debug) {
231
- this.log.info(line);
232
- }
233
- }
234
- });
235
- // Handle our process termination.
236
- this.process?.once("exit", (exitCode, signal) => {
237
- // Clear out our canary.
238
- if (this.ffmpegTimeout) {
239
- clearTimeout(this.ffmpegTimeout);
240
- }
241
- this._isStarted = false;
242
- this._isEnded = true;
243
- // Some utilities to streamline things.
244
- const logPrefix = "FFmpeg process ended ";
245
- // FFmpeg ended normally and our canary didn't need to enforce FFmpeg's extinction.
246
- if (this.ffmpegTimeout && (exitCode === 0)) {
247
- this.log.debug(logPrefix + "(Normal).");
248
- }
249
- else if (((exitCode === null) || (exitCode === 255)) && this.process?.killed) {
250
- // FFmpeg has ended. Let's figure out if it's because we killed it or whether it died of natural causes.
251
- this.log.debug(logPrefix + (signal === "SIGKILL" ? "(Killed)." : "(Expected)."));
252
- }
253
- else {
254
- // Flag that we've run into an FFmpeg error.
255
- this._hasError = true;
256
- // Flush out any remaining output in our error buffer.
257
- if (this.stderrBuffer.length) {
258
- this._stderrLog.push(this.stderrBuffer + "\n");
259
- this.stderrBuffer = "";
260
- }
261
- // Inform the user.
262
- this.logFfmpegError(exitCode, signal);
263
- // Execute our error handler, if one is provided.
264
- if (errorHandler) {
265
- void errorHandler(util.format(this.options.name() + ": " + logPrefix + " unexpectedly with exit code %s and signal %s.", exitCode, signal));
193
+ constructor(options, init = {}) {
194
+ const { args = [], signal: parentSignal, startupTimeout } = init;
195
+ this.options = options;
196
+ this.log = options.log;
197
+ // Freeze a copy of the caller's args so the `readonly` type is enforced at runtime as well as in TypeScript. Without the copy, a caller mutating their own array
198
+ // post-construction would silently mutate our stored value - visible in the teardown log, invisible during review.
199
+ this.args = Object.freeze([...args]);
200
+ // Resolve the logging decision once, at construction time. The live-log path runs on every stderr chunk; recomputing `this.args.includes("-loglevel")` each time
201
+ // would be wasteful. Verbose and debug are configuration, not signal state, so they are also resolved here.
202
+ this.#liveLog = this.args.includes("-loglevel") || this.#verbose || options.debug;
203
+ // Compose our lifetime. The internal controller lets us abort the process from within the class; the optional parent signal lets a caller tear us down as part of
204
+ // their own lifecycle. `composeSignals` handles the "one or both" case uniformly so we never hand-roll the `AbortSignal.any` branching.
205
+ this.#controller = new AbortController();
206
+ this.signal = composeSignals(parentSignal, this.#controller.signal);
207
+ // Log the command once at construction. The policy: verbose/debug/loglevel-in-args configurations surface the command at info; every other configuration logs it
208
+ // at debug. Consumers that need this in structured form read `args` directly. We select the level dynamically so there is a single format string to maintain -
209
+ // parallel if/else branches drift as they age.
210
+ const commandLogLevel = this.#liveLog ? "info" : "debug";
211
+ this.log[commandLogLevel]("FFmpeg command (version: %s): %s %s.", this.#ffmpegVersion, this.#ffmpegExec, this.args.join(" "));
212
+ // Wire up the ready/exited resolvers before spawning. Node emits spawn-failure `"error"` events on the next microtask; the resolvers must be in place by then so
213
+ // the error path can settle them. We bind via typed locals so `PromiseWithResolvers<T>` is expressed as a type annotation rather than a call-site generic argument.
214
+ const readyResolvers = Promise.withResolvers();
215
+ const exitedResolvers = Promise.withResolvers();
216
+ this.#readyResolvers = readyResolvers;
217
+ this.#exitedResolvers = exitedResolvers;
218
+ // Mark both promises as observed. Callers that await `ready` or `exited` still receive the rejection through their own chain - `markHandled` only opts the promise
219
+ // out of Node's unhandled-rejection tracker for the case where nobody awaits one of them (e.g., a session that aborts before the process becomes ready and the
220
+ // caller never asked for the ready outcome).
221
+ this.ready = markHandled(readyResolvers.promise);
222
+ this.exited = markHandled(exitedResolvers.promise);
223
+ // Spawn immediately. Node's spawn accepts `{ signal, killSignal }` natively: when the signal aborts, Node kills the child with `killSignal`, letting the kernel
224
+ // own the kill path. Pairing the signal with `SIGKILL` is the right choice for FFmpeg specifically because it is a media pipeline, not a service we want to
225
+ // negotiate orderly shutdown with - when we are done, we are done. We spawn from `this.args` (the frozen copy) rather than the caller's live reference so the
226
+ // class has a single source of truth for its command line.
227
+ this.#process = spawn(this.#ffmpegExec, this.args, { killSignal: "SIGKILL", signal: this.signal });
228
+ // Cache references to the child's streams. `ChildProcessWithoutNullStreams` guarantees these are non-null, and the cached references stay live even after the child
229
+ // exits so subclasses can still inspect them.
230
+ this.stdin = this._stdin = this.#process.stdin;
231
+ this.stdout = this._stdout = this.#process.stdout;
232
+ this.stderr = this._stderr = this.#process.stderr;
233
+ // Attach listeners to the ChildProcess and its streams. Node's `EventEmitter.on` / `.once` do not accept the `{ signal }` option that `EventTarget.addEventListener`
234
+ // does - that helper lives on the module-level `events.on` / `events.once` wrappers, which are oriented around promises and async iterators rather than callbacks.
235
+ // For the base class's single-producer-single-consumer callbacks, manual attachment is fine: the underlying ChildProcess and its pipes are destroyed when the signal
236
+ // aborts (Node's `spawn({ signal, killSignal })` owns the kill path), so post-abort events simply never fire and there is no runaway-listener risk.
237
+ this.#process.once("error", (error) => this.#onSpawnError(error));
238
+ this.#process.once("exit", (exitCode, exitSignal) => this.#onExit(exitCode, exitSignal));
239
+ this.#process.stdin.on("error", (error) => this.#onStdinError(error));
240
+ this.#process.stderr.on("data", (chunk) => this.#onStderrData(chunk));
241
+ // Single teardown convergence point. `onAbort` registers the one-shot teardown listener for the normal abort path AND runs it synchronously when the signal is
242
+ // already aborted at construction time - typically because the caller passed a pre-aborted parent signal. `#onSpawnError` will later settle `exited` when Node's
243
+ // pre-aborted spawn emits its `"error"` event on the next microtask.
244
+ onAbort(this.signal, () => this.#teardown());
245
+ // Optional startup watchdog. `AbortSignal.timeout` fires an abort after the configured window; we observe that abort and convert it into our own `"timeout"`
246
+ // reason when the process has not yet emitted its first stderr byte. The `{ signal: this.signal }` option on `addEventListener` ties the listener's lifetime to
247
+ // the process signal, so the listener auto-unregisters (and the timeout signal becomes GC-eligible) the moment the process aborts for any other reason - no
248
+ // manual `clearTimeout` bookkeeping, and the listener cannot fire after the process is already torn down. The `#firstStderr` guard prevents firing after a
249
+ // successful start; this is a startup watchdog, not a general stall detector.
250
+ if (startupTimeout !== undefined) {
251
+ const timeoutSignal = AbortSignal.timeout(startupTimeout);
252
+ timeoutSignal.addEventListener("abort", () => {
253
+ // Only act if we are still waiting for first output and the process has not aborted for another reason.
254
+ if (!this.aborted && !this.#firstStderr) {
255
+ this.#controller.abort(new HbpuAbortError("timeout"));
266
256
  }
267
- }
268
- // Cleanup after ourselves. We intentionally preserve _stderrLog so callers can inspect it for post-mortem diagnostics after the process exits.
269
- this.process?.stdin.off("error", errorListener);
270
- this.process?.stderr.off("data", dataListener);
271
- this.process = null;
272
- });
273
- }
274
- // Stop the FFmpeg process and complete any cleanup activities.
275
- stopProcess() {
276
- // Check to make sure we aren't using stdin for data before telling FFmpeg we're done.
277
- if (!this.commandLineArgs.includes("pipe:0")) {
278
- this.process?.stdin.end("q");
257
+ }, { once: true, signal: this.signal });
279
258
  }
280
- // Close our input and output.
281
- this.process?.stdin.destroy();
282
- this.process?.stdout.destroy();
283
- // In case we need to kill it again, just to be sure it's really dead.
284
- this.ffmpegTimeout = setTimeout(() => {
285
- this.process?.kill("SIGKILL");
286
- }, FFMPEG_INPUT_TIMEOUT);
287
- // Send the kill shot.
288
- this.process?.kill();
289
259
  }
290
260
  /**
291
- * Stops the FFmpeg process and performs necessary cleanup.
261
+ * Abort the process and tear it down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
292
262
  *
293
- * @example
263
+ * Safe to call more than once: subsequent calls are no-ops because the underlying signal only aborts once. Calling `abort()` after natural exit is also safe for the
264
+ * same reason.
294
265
  *
295
- * ```ts
296
- * process.stop();
297
- * ```
266
+ * @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors (`TimeoutError`, `AbortError`) also interoperate by convention.
298
267
  */
299
- stop() {
300
- this.stopProcess();
268
+ abort(reason) {
269
+ // Fast path - no need to construct a default reason if we are already done.
270
+ if (this.aborted) {
271
+ return;
272
+ }
273
+ this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
301
274
  }
302
275
  /**
303
- * Logs an error message for FFmpeg process termination.
276
+ * `AsyncDisposable` implementation. Aborts the process (defaulting to `"shutdown"`) and awaits actual exit before returning, so callers using `await using` are
277
+ * guaranteed the child has terminated by the time the block exits.
304
278
  *
305
- * @param exitCode - The exit code from FFmpeg.
306
- * @param signal - The signal, if any, used to terminate the process.
279
+ * @returns A promise that resolves once the child has fully exited.
307
280
  */
308
- logFfmpegError(exitCode, signal) {
309
- // Something else has occurred. Inform the user, and stop everything.
310
- this.log.error("FFmpeg process ended unexpectedly with %s%s%s.", (exitCode !== null) ? "an exit code of " + exitCode.toString() : "", ((exitCode !== null) && signal) ? " and " : "", signal ? "a signal received of " + signal : "");
311
- this.log.error("FFmpeg (%s) command that errored out was: %s %s", this.options.codecSupport.ffmpegVersion, this.options.codecSupport.ffmpegExec, this.commandLineArgs.join(" "));
312
- for (const x of this._stderrLog) {
313
- this.log.error(x);
314
- }
281
+ async [Symbol.asyncDispose]() {
282
+ this.abort();
283
+ // `exited` rejects only when the child never spawned. In either case, the caller's dispose scope is done; swallow the rejection so `await using` does not surface
284
+ // disposal errors that the caller has no way to react to.
285
+ await this.exited.catch(() => { });
315
286
  }
316
287
  /**
317
- * Indicates if an error has occurred during FFmpeg process execution.
288
+ * `true` once `this.signal` has aborted. Derived from the signal; no independent state.
318
289
  */
319
- get hasError() {
320
- return this._hasError;
290
+ get aborted() {
291
+ return this.signal.aborted;
321
292
  }
322
293
  /**
323
- * Indicates whether the FFmpeg process has ended.
294
+ * `true` when the abort reason was `HbpuAbortError("failed")`. Covers spawn failures and non-zero natural exits. Derived from `this.signal.reason`; no stored flag.
324
295
  */
325
- get isEnded() {
326
- return this._isEnded;
296
+ get hasError() {
297
+ return isHbpuAbortReason(this.signal.reason, "failed");
327
298
  }
328
299
  /**
329
- * Indicates whether the FFmpeg process has started.
300
+ * `true` when the abort reason indicates a timeout. Matches both the canonical `HbpuAbortError("timeout")` and the platform `TimeoutError` emitted by
301
+ * `AbortSignal.timeout()`. The discrimination lives in {@link isTimeoutReason} so this getter stays a one-line delegation and every resource class in the library
302
+ * shares one definition of "timeout."
330
303
  */
331
- get isStarted() {
332
- return this._isStarted;
304
+ get isTimedOut() {
305
+ return isTimeoutReason(this.signal.reason);
333
306
  }
334
307
  /**
335
- * Returns the accumulated standard error log lines from the FFmpeg process.
336
- *
337
- * @returns An array of stderr log lines.
308
+ * The accumulated stderr lines this process has produced, preserved across teardown for post-mortem inspection. The array is returned as a readonly view to make the
309
+ * intent explicit: callers read from it, they do not mutate it.
338
310
  */
339
311
  get stderrLog() {
340
- return this._stderrLog;
312
+ return this.#stderrLog;
341
313
  }
342
- /**
343
- * Returns the writable standard input stream for the FFmpeg process, if available.
344
- *
345
- * @returns The standard input stream, or `null` if not available.
346
- */
347
- get stdin() {
348
- return this.process?.stdin ?? null;
314
+ // Called when Node emits an `"error"` event on the child. The handler has exactly one job: handle the genuine pre-spawn failure case where the syscall never assigned
315
+ // a pid and no `"exit"` event will fire. Every other shape of error (post-spawn AbortError from the signal-driven kill path, post-spawn kill / IPC errors, anything
316
+ // else Node might attach) lands on a live child whose `"exit"` event is the canonical settlement - the exit listener will resolve `exited` with real exit info, and
317
+ // re-aborting from here would only overwrite a meaningful signal reason with a derived one.
318
+ //
319
+ // The pid check is the discriminator. `.pid` is assigned synchronously by libuv when fork+exec succeeds, so checking it here is a reliable "did the kernel run our
320
+ // child?" test that does not depend on event-ordering between `"spawn"` and `"error"`. When pid is set the child is alive (or was alive); when pid is undefined, the
321
+ // spawn syscall failed before assigning one and this handler owns the diagnostic + settlement.
322
+ #onSpawnError(error) {
323
+ // Post-spawn: the child got a pid, so the `"exit"` event is the canonical settlement. Skip - the exit handler will resolve `exited` with the real exit info and the
324
+ // re-abort guard there preserves whatever signal reason aborted us.
325
+ if (this.#process.pid !== undefined) {
326
+ return;
327
+ }
328
+ // Genuine pre-spawn failure. Emit a user-facing diagnostic (ENOENT is the common case - wrong FFmpeg path), abort with a structured reason preserving the
329
+ // underlying error via `cause`, and settle `exited` ourselves since nothing else will.
330
+ const message = (error.code === "ENOENT") ? ("unable to find \"" + (error.path ?? "unknown") + "\"") : error.message;
331
+ this.log.error("FFmpeg failed to start: %s.", message);
332
+ if (!this.aborted) {
333
+ this.#controller.abort(new HbpuAbortError("failed", { cause: error }));
334
+ }
335
+ this.#exitedResolvers.reject(this.signal.reason);
336
+ }
337
+ // Called when Node emits an `"exit"` event on the child. This is the canonical settlement point for `exited` regardless of whether the exit was natural or driven by
338
+ // abort: the caller always gets to read the actual exit code / signal. When we are not yet aborted, we convert the exit into an abort reason; when we are already
339
+ // aborted (because the caller or a parent triggered the teardown), the existing `signal.reason` stands and we only resolve `exited`.
340
+ #onExit(exitCode, exitSignal) {
341
+ // Flush any trailing stderr buffer that did not end with a newline - callers inspecting `stderrLog` after exit should see the complete record.
342
+ if (this.#stderrBuffer.length > 0) {
343
+ this.#stderrLog.push(this.#stderrBuffer);
344
+ this.#stderrBuffer = "";
345
+ }
346
+ // Canonical: always settle `exited` with the actual exit info. Even when abort drove the kill and the exit code reflects the signal we sent, callers benefit from
347
+ // knowing what actually happened at the syscall level.
348
+ this.#exitedResolvers.resolve({ exitCode, exitSignal });
349
+ // Re-abort guard. If the signal is already aborted - because we aborted it externally, because a parent aborted, or because `#onSpawnError` aborted us - the
350
+ // existing reason is authoritative. Re-aborting would overwrite a meaningful reason (e.g., `"shutdown"`) with a less useful one derived from the kill-driven exit.
351
+ if (this.aborted) {
352
+ return;
353
+ }
354
+ // Natural exit. Code 0 is the closed case; anything else is a failure. The cause shape is the single invariant either way; only the reason name varies. The
355
+ // `satisfies` operator pins production to the strict `FfmpegProcessExitInfo` type so `exited`'s callers get the `NodeJS.Signals` narrowing; the read-side guard
356
+ // (`isExitInfoShape`) verifies what the runtime can actually check. Both sides share this literal shape, so a future rename of either field breaks both ends.
357
+ const reasonName = (exitCode === 0) ? "closed" : "failed";
358
+ const cause = { exitCode, exitSignal };
359
+ this.#controller.abort(new HbpuAbortError(reasonName, { cause }));
360
+ }
361
+ // Stdin error listener. FFmpeg's stdin typically closes cleanly once we are done piping data; the only error we expect in normal operation is `EPIPE`, which happens
362
+ // when we try to write after ffmpeg has already exited. We swallow `EPIPE` silently and log anything else. The underlying stream is destroyed when the signal aborts
363
+ // (Node's `spawn({ signal, killSignal })` owns the kill path), so post-abort stdin events simply do not fire - no manual `.off()` needed.
364
+ #onStdinError(error) {
365
+ if (error.message.includes("EPIPE")) {
366
+ return;
367
+ }
368
+ this.log.error("FFmpeg error: %s.", error.message);
369
+ }
370
+ // Stderr data listener. Responsibilities, in order: mark the process as "ready" on first byte (this is the only reliable signal that FFmpeg is running, since stdin
371
+ // and stdout may not be used at all depending on the invocation), append each complete line to the rolling stderr log, and emit live-log output if configured.
372
+ #onStderrData(chunk) {
373
+ if (!this.#firstStderr) {
374
+ this.#firstStderr = true;
375
+ this.#readyResolvers.resolve();
376
+ this.log.debug("FFmpeg process started.");
377
+ }
378
+ // Replace non-printable characters with EOL so control sequences become line boundaries rather than silently vanishing. Keeping this as a pre-normalization step
379
+ // means the line-splitting below does not have to worry about hidden escape sequences fracturing log entries.
380
+ this.#stderrBuffer += chunk.toString().replace(NON_PRINTABLE_CHARS, EOL);
381
+ // Line-by-line drain. EOL from `node:os` handles Windows-style line endings as a single unit; using it explicitly rather than searching for `"\n"` keeps this loop
382
+ // correct across platforms.
383
+ for (;;) {
384
+ const lineIndex = this.#stderrBuffer.indexOf(EOL);
385
+ if (lineIndex === -1) {
386
+ return;
387
+ }
388
+ const line = this.#stderrBuffer.slice(0, lineIndex);
389
+ this.#stderrBuffer = this.#stderrBuffer.slice(lineIndex + EOL.length);
390
+ this.#stderrLog.push(line);
391
+ if (this.#liveLog) {
392
+ this.log.info(line);
393
+ }
394
+ }
395
+ }
396
+ // Single teardown convergence point. Fires exactly once (registered with `{ once: true }`) when `this.signal` aborts, regardless of which path triggered the abort.
397
+ // Responsibilities: settle `ready` if still pending, apply the reason-based teardown log policy. Note that `exited` is NOT settled here - the `"exit"` or `"error"`
398
+ // listener is the single source of truth for that promise, so that callers always read the real exit info when one is available.
399
+ #teardown() {
400
+ // Reject ready if still pending. If it already resolved because we saw first stderr, this call is a no-op (promise resolvers are idempotent after first settlement).
401
+ this.#readyResolvers.reject(this.signal.reason);
402
+ // Apply the reason-based teardown log policy.
403
+ this.#logTeardown();
404
+ }
405
+ // Reason-based teardown logging. The policy is driven by `this.signal.reason`, mapping each reason variant to a log level and message shape:
406
+ //
407
+ // - `HbpuAbortError("failed", ...)` -> delegated to `logFailedTeardown()` (ERROR with full stderr dump and exit context by default; subclasses override to
408
+ // substitute a bespoke message for known benign error shapes).
409
+ // - `HbpuAbortError("timeout")` -> delegated to `logTimeoutTeardown()` (a static WARN by default; the recording subclass overrides it to debug for a benign
410
+ // inter-segment timeout).
411
+ // - Any other HbpuAbortError -> DEBUG with the reason name (self-inflicted or orderly teardown).
412
+ // - Platform TimeoutError/AbortError -> DEBUG with the reason name (no dump).
413
+ // - Anything else -> DEBUG with a generic message.
414
+ //
415
+ // Lifting a teardown branch into a `protected` method lets subclasses substitute a level / message for a teardown shape they characterize differently, without
416
+ // re-implementing the whole teardown policy or trying to pre-empt the base's listener ordering. Two such seams exist: `logFailedTeardown` (the recording subclass
417
+ // substitutes a friendly message for known benign HKSV error shapes, returning early to suppress the canonical ERROR dump; non-matching subclasses call
418
+ // `super.logFailedTeardown(reason)` and the dump fires) and `logTimeoutTeardown` (the recording subclass demotes the benign inter-segment timeout to debug, while the
419
+ // default stays WARN because a stall on a general - streaming - process genuinely is a problem).
420
+ #logTeardown() {
421
+ const reason = this.signal.reason;
422
+ if (isHbpuAbortError(reason)) {
423
+ switch (reason.name) {
424
+ case "failed": {
425
+ this.logFailedTeardown(reason);
426
+ return;
427
+ }
428
+ case "timeout": {
429
+ this.logTimeoutTeardown(reason);
430
+ return;
431
+ }
432
+ default: {
433
+ this.log.debug("%s (%s).", this.#teardownPrefix, reason.name);
434
+ return;
435
+ }
436
+ }
437
+ }
438
+ // Platform TimeoutError / AbortError or any other reason. Log at debug - if callers want richer behavior, they observe `exited` or attach their own abort listener.
439
+ const name = (reason instanceof Error) ? reason.name : "unknown";
440
+ this.log.debug("%s (%s).", this.#teardownPrefix, name);
349
441
  }
350
442
  /**
351
- * Returns the readable standard output stream for the FFmpeg process, if available.
443
+ * Log the "failed" teardown branch. Overridable by subclasses that want to substitute a bespoke message for known benign error shapes before falling through to the
444
+ * canonical ERROR dump.
445
+ *
446
+ * The default implementation emits two ERROR lines (an "ended unexpectedly" summary and the command that produced it), then dumps every accumulated stderr line at
447
+ * ERROR level. Subclasses may inspect `this.stderrLog` and `reason.cause`, log a friendly message, and return early to suppress the dump; or call
448
+ * `super.logFailedTeardown(reason)` to emit the canonical dump after prepending their own context.
352
449
  *
353
- * @returns The standard output stream, or `null` if not available.
450
+ * @param reason - The `"failed"` reason that drove this teardown. Its `cause` field carries structured exit info (`{ exitCode, exitSignal }`) for natural non-zero
451
+ * exits, or the underlying `Error` for spawn failures.
354
452
  */
355
- get stdout() {
356
- return this.process?.stdout ?? null;
453
+ logFailedTeardown(reason) {
454
+ this.log.error("%s unexpectedly. Reason: %s.", this.#teardownPrefix, describeCause(reason.cause));
455
+ this.log.error("FFmpeg (%s) command that errored out was: %s %s.", this.#ffmpegVersion, this.#ffmpegExec, this.args.join(" "));
456
+ for (const line of this.#stderrLog) {
457
+ this.log.error(line);
458
+ }
357
459
  }
358
460
  /**
359
- * Returns the readable standard error stream for the FFmpeg process, if available.
461
+ * Log the "timeout" teardown branch. Overridable by subclasses for which an inter-segment / watchdog timeout is benign by default - e.g. an HKSV recording, which
462
+ * ends exactly this way whenever its segment source quiets.
463
+ *
464
+ * The default implementation emits a single WARN: a stall that trips the watchdog on a general FFmpeg process (a live stream) genuinely is a problem. A subclass may
465
+ * override to demote the reap to debug and leave the severity verdict to the consumer that holds the input-feed and reachability context.
360
466
  *
361
- * @returns The standard error stream, or `null` if not available.
467
+ * @param _reason - The `"timeout"` abort reason that drove this teardown. Part of the seam contract so an override can inspect it (parallel to `logFailedTeardown`),
468
+ * but neither the default body nor the recording override reads it - a watchdog timeout carries no actionable cause - so it is `_`-prefixed.
362
469
  */
363
- get stderr() {
364
- return this.process?.stderr ?? null;
470
+ logTimeoutTeardown(_reason) {
471
+ this.log.warn("%s: stalled past its watchdog window.", this.#teardownPrefix);
472
+ }
473
+ // Common prefix used by the BASE teardown log lines: the caller's camera / session name followed by the canonical teardown phrase. Single source of truth so the
474
+ // wording stays consistent across the failed / timeout / debug DEFAULT bodies and any future base teardown message. The subclass overrides (`FfmpegRecordingProcess`'s
475
+ // `logFailedTeardown` and `logTimeoutTeardown`) deliberately do NOT read this getter - they compose their own bespoke messages from `this.options.name()`, so a future
476
+ // change to this prefix cannot leave their wording stale. Keyed off `options.name()` rather than cached because the name function can return different values over the
477
+ // instance's lifetime (e.g., a mid-session camera rename in HBUP).
478
+ get #teardownPrefix() {
479
+ return this.options.name() + ": FFmpeg process ended";
480
+ }
481
+ // Private forwarders to the codec-support scalars this class reads during spawn and teardown; routing each through a named accessor keeps the long
482
+ // `this.options.config.codecSupport.X` path out of the call sites and makes it obvious at a glance which config values the class actually depends on. The class
483
+ // reads these scalars from a handful of call sites across construction, spawn, and teardown. No stored state - each getter is a one-line forward through the
484
+ // config chain.
485
+ get #ffmpegExec() {
486
+ return this.options.config.codecSupport.ffmpegExec;
487
+ }
488
+ get #ffmpegVersion() {
489
+ return this.options.config.codecSupport.ffmpegVersion;
490
+ }
491
+ get #verbose() {
492
+ return this.options.config.codecSupport.verbose;
365
493
  }
366
494
  }
367
495
  //# sourceMappingURL=process.js.map