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
@@ -23,32 +23,190 @@
23
23
  * @module
24
24
  */
25
25
  import { EOL, cpus } from "node:os";
26
- import { execFile } from "node:child_process";
27
26
  import { env, platform } from "node:process";
27
+ import { composeSignals } from "../util.js";
28
+ import { execFile } from "node:child_process";
28
29
  import { promisify } from "node:util";
29
30
  import { readFileSync } from "node:fs";
30
31
  // Promisified execFile, created once at module level rather than per-invocation.
31
32
  const execFileAsync = promisify(execFile);
33
+ // Default watchdog timeout, in milliseconds, applied to every probe invocation so a slow or hung FFmpeg binary cannot stall the host plugin indefinitely. Thirty
34
+ // seconds comfortably exceeds the realistic worst-case probe (the hardware-acceleration validation on slow Raspberry Pi hosts) while keeping a crashed binary from
35
+ // parking a plugin forever.
36
+ const PROBE_DEFAULT_TIMEOUT_MS = 30_000;
37
+ // Codec-line regexes for `parseFfmpegCodecs`. Compiled once at module scope to match the convention used for `VALID_HOMEKIT_NAME`, `NON_PRINTABLE_CHARS`, and the other
38
+ // hot-path patterns in this package - regex construction per call is wasted work, even on the cold probe path, when one regex serves every line of every probe.
39
+ const FFMPEG_CODECS_DECODERS_REGEX = /\S+\s+(\S+).+\(decoders: (.*?)\s*\)/;
40
+ const FFMPEG_CODECS_ENCODERS_REGEX = /\S+\s+(\S+).+\(encoders: (.*?)\s*\)/;
32
41
  /**
33
- * Probe FFmpeg capabilities and codecs on the host system.
42
+ * Parse the stdout of `ffmpeg -version` and return the version string. Returns `"unknown"` when the expected `"ffmpeg version X Copyright..."` line is not found -
43
+ * matching the behavior of the class-level probe, which records the literal `"unknown"` when it cannot identify the binary.
44
+ *
45
+ * Exposed as a module-scope helper (rather than remaining a closure inside the class) so the parse logic is unit-testable against fixture strings without spinning up
46
+ * the probe plumbing.
47
+ *
48
+ * @param stdout - Captured stdout from `ffmpeg -hide_banner -version`.
49
+ *
50
+ * @returns The version string, or `"unknown"` if the version line is absent.
51
+ *
52
+ * @category FFmpeg
53
+ */
54
+ export function parseFfmpegVersion(stdout) {
55
+ const versionRegex = /^ffmpeg version (.*) Copyright.*$/m;
56
+ const versionMatch = versionRegex.exec(stdout);
57
+ return versionMatch ? (versionMatch[1] ?? "unknown") : "unknown";
58
+ }
59
+ /**
60
+ * Parse an FFmpeg version string into its numeric triple. Splits on `.` and `-` so real-world version strings parse correctly across release tarballs ("8.1.1"),
61
+ * distributor suffixes ("8.1.1-tessus"), distro packages ("4.4.2-0ubuntu0.22.04.1"), and git snapshots ("N-123456-gabcdef"). Any non-numeric segment yields `0` via the
62
+ * `|| 0` fallback, which gives a safe, conservative result: an unknown build parses as `{ major: 0, minor: 0, patch: 0 }` and fails every {@link ffmpegVersionAtLeast}
63
+ * check where the requested major is >= 1.
64
+ *
65
+ * @param version - An FFmpeg version string as produced by {@link parseFfmpegVersion} or by `FfmpegCodecs.ffmpegVersion`.
66
+ *
67
+ * @returns The parsed numeric triple.
68
+ *
69
+ * @example
70
+ *
71
+ * ```ts
72
+ * parseFfmpegVersionParts("8.1.1"); // { major: 8, minor: 1, patch: 1 }
73
+ * parseFfmpegVersionParts("8.1.1-tessus"); // { major: 8, minor: 1, patch: 1 }
74
+ * parseFfmpegVersionParts("N-123456-gabcdef"); // { major: 0, minor: 123456, patch: 0 } - git snapshot, effectively "unknown"
75
+ * parseFfmpegVersionParts("unknown"); // { major: 0, minor: 0, patch: 0 }
76
+ * ```
77
+ *
78
+ * @category FFmpeg
79
+ */
80
+ export function parseFfmpegVersionParts(version) {
81
+ const parts = version.split(/[.-]/);
82
+ return {
83
+ major: Number.parseInt(parts[0] ?? "", 10) || 0,
84
+ minor: Number.parseInt(parts[1] ?? "", 10) || 0,
85
+ patch: Number.parseInt(parts[2] ?? "", 10) || 0
86
+ };
87
+ }
88
+ /**
89
+ * Return `true` when `parts` represents an FFmpeg version at least as new as the requested major/minor/patch. Compares major, then minor, then patch - the canonical
90
+ * semver ordering. This is the single source of truth for version-gating comparisons across the library; both the `FfmpegCodecs.ffmpegAtLeast` instance method and the
91
+ * test-side fixtures delegate here so the boundary logic lives in one implementation.
34
92
  *
35
- * This class provides methods to check available FFmpeg decoders, encoders, and hardware acceleration methods, as well as to determine system-specific resources such as
36
- * GPU memory (on Raspberry Pi). Intended for plugin authors or advanced users needing to assess FFmpeg capabilities dynamically.
93
+ * @param parts - A parsed version triple as produced by {@link parseFfmpegVersionParts}.
94
+ * @param major - The minimum major version to accept.
95
+ * @param minor - The minimum minor version, when major is equal. Defaults to `0`.
96
+ * @param patch - The minimum patch version, when major and minor are equal. Defaults to `0`.
97
+ *
98
+ * @returns `true` if `parts` is >= the requested version; `false` otherwise.
37
99
  *
38
100
  * @example
39
101
  *
40
102
  * ```ts
41
- * const codecs = new FfmpegCodecs({
103
+ * const parts = parseFfmpegVersionParts("8.1.2");
104
+ *
105
+ * ffmpegVersionAtLeast(parts, 8); // true - 8.1.2 >= 8.0.0
106
+ * ffmpegVersionAtLeast(parts, 8, 1, 3); // false - 8.1.2 < 8.1.3
107
+ * ffmpegVersionAtLeast(parts, 9); // false - 8.1.2 < 9.0.0
108
+ * ```
42
109
  *
43
- * ffmpegExec: "ffmpeg",
44
- * log: console,
45
- * verbose: true
46
- * });
110
+ * @category FFmpeg
111
+ */
112
+ export function ffmpegVersionAtLeast(parts, major, minor = 0, patch = 0) {
113
+ if (parts.major !== major) {
114
+ return parts.major > major;
115
+ }
116
+ if (parts.minor !== minor) {
117
+ return parts.minor > minor;
118
+ }
119
+ return parts.patch >= patch;
120
+ }
121
+ /**
122
+ * Parse the stdout of `ffmpeg -hwaccels` and return the list of hardware-acceleration method names in lowercase, in the order they appeared. Skips blank lines and
123
+ * the leading `"Hardware acceleration methods:"` banner.
47
124
  *
48
- * // Probe system and FFmpeg capabilities.
49
- * const ready = await codecs.probe();
125
+ * @param stdout - Captured stdout from `ffmpeg -hide_banner -hwaccels`.
50
126
  *
51
- * if(ready) {
127
+ * @returns Lowercased acceleration method names, one per entry.
128
+ *
129
+ * @category FFmpeg
130
+ */
131
+ export function parseFfmpegHwAccels(stdout) {
132
+ const result = [];
133
+ for (const line of stdout.split(EOL)) {
134
+ if (!line.length) {
135
+ continue;
136
+ }
137
+ if (line === "Hardware acceleration methods:") {
138
+ continue;
139
+ }
140
+ result.push(line.toLowerCase());
141
+ }
142
+ return result;
143
+ }
144
+ /**
145
+ * Parse the stdout of `ffmpeg -codecs` into a format-keyed index of decoders and encoders. Both sets are lowercased for case-insensitive `hasDecoder` /
146
+ * `hasEncoder` lookups. Formats with only decoders or only encoders still produce a full entry (the opposite set is simply empty).
147
+ *
148
+ * @param stdout - Captured stdout from `ffmpeg -hide_banner -codecs`.
149
+ *
150
+ * @returns A record keyed by codec format, each entry carrying the decoders and encoders reported for that format.
151
+ *
152
+ * @category FFmpeg
153
+ */
154
+ export function parseFfmpegCodecs(stdout) {
155
+ const result = {};
156
+ // Capture each codec's decoder and encoder lists separately because FFmpeg reports them in independent parenthesized segments on the same line. The regexes are
157
+ // hoisted to module scope above so they are compiled once rather than once per call.
158
+ for (const codecLine of stdout.split(EOL)) {
159
+ const decodersMatch = FFMPEG_CODECS_DECODERS_REGEX.exec(codecLine);
160
+ const encodersMatch = FFMPEG_CODECS_ENCODERS_REGEX.exec(codecLine);
161
+ if (decodersMatch) {
162
+ const [, format, decoders] = decodersMatch;
163
+ if ((format !== undefined) && (decoders !== undefined)) {
164
+ const entry = (result[format] ??= { decoders: new Set(), encoders: new Set() });
165
+ for (const decoder of decoders.split(" ")) {
166
+ entry.decoders.add(decoder.toLowerCase());
167
+ }
168
+ }
169
+ }
170
+ if (encodersMatch) {
171
+ const [, format, encoders] = encodersMatch;
172
+ if ((format !== undefined) && (encoders !== undefined)) {
173
+ const entry = (result[format] ??= { decoders: new Set(), encoders: new Set() });
174
+ for (const encoder of encoders.split(" ")) {
175
+ entry.encoders.add(encoder.toLowerCase());
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return result;
181
+ }
182
+ /**
183
+ * Parse the stdout of `vcgencmd get_mem gpu` into a megabyte value. Returns `0` when the expected `gpu=<N>M` shape is absent or the captured digits fail integer
184
+ * parsing - matching the class-level fallback.
185
+ *
186
+ * @param stdout - Captured stdout from `vcgencmd get_mem gpu`.
187
+ *
188
+ * @returns The reported GPU memory size in megabytes, or `0` when the value could not be read.
189
+ *
190
+ * @category FFmpeg
191
+ */
192
+ export function parseRpiGpuMem(stdout) {
193
+ const gpuRegex = /^gpu=(.*)M\n$/;
194
+ const gpuMatch = gpuRegex.exec(stdout);
195
+ return Number.parseInt(gpuMatch?.[1] ?? "", 10) || 0;
196
+ }
197
+ /**
198
+ * Probe FFmpeg capabilities and codecs on the host system.
199
+ *
200
+ * Construct via the static factory {@link FfmpegCodecs.probe} to run the live probe pipeline, or via {@link FfmpegCodecs.fromState} to inject pre-assembled state
201
+ * (tests, cached capability data). Instances are immutable value objects - every getter and predicate reads from a frozen {@link FfmpegCodecsState} snapshot
202
+ * assembled at construction, so callers holding a reference know its state cannot shift underneath them.
203
+ *
204
+ * @example
205
+ *
206
+ * ```ts
207
+ * const codecs = await FfmpegCodecs.probe({ ffmpegExec: "ffmpeg", log: console, verbose: true });
208
+ *
209
+ * if(codecs) {
52
210
  *
53
211
  * console.log("Available FFmpeg version:", codecs.ffmpegVersion);
54
212
  *
@@ -62,86 +220,159 @@ const execFileAsync = promisify(execFile);
62
220
  * @category FFmpeg
63
221
  */
64
222
  export class FfmpegCodecs {
223
+ #state;
224
+ // Private constructor - the only paths in are the static factories below. This makes "a live `FfmpegCodecs` has complete state" a type-level fact rather than a
225
+ // convention callers must remember to honor by running a separate async init step after construction.
226
+ constructor(state) {
227
+ this.#state = state;
228
+ }
65
229
  /**
66
- * The path or command name to invoke FFmpeg.
230
+ * Async factory. Runs the full probe pipeline (host-system detection, optional Raspberry Pi GPU memory, FFmpeg version + codec inventory + hardware-accel inventory
231
+ * with per-accel capability validation) and returns a populated instance on success, or `null` when any required probe fails. Every inner probe runs under a
232
+ * watchdog timeout so a slow or hung FFmpeg binary cannot stall the caller indefinitely; the optional `init.signal` composes with that timeout so callers can cancel
233
+ * probing from outside (for example, during plugin shutdown).
234
+ *
235
+ * @param options - Options used to configure the probe (FFmpeg executable, logger, verbose flag).
236
+ * @param init - Optional probe options. `signal` cancels in-flight probes; the per-call watchdog still applies.
237
+ *
238
+ * @returns A promise that resolves to a populated `FfmpegCodecs` instance, or `null` if probing failed.
239
+ *
240
+ * @example
241
+ *
242
+ * ```ts
243
+ * const codecs = await FfmpegCodecs.probe({ log: plugin.log }, { signal: shutdown.signal });
244
+ *
245
+ * if(!codecs) {
246
+ *
247
+ * plugin.log.error("FFmpeg probing failed.");
248
+ * }
249
+ * ```
67
250
  */
68
- ffmpegExec;
69
- _ffmpegVersion;
70
- _gpuMem;
71
- _hostSystem;
72
- _cpuGeneration;
73
- log;
74
- ffmpegCodecs;
75
- ffmpegHwAccels;
251
+ static async probe(options, init = {}) {
252
+ const state = await probeFfmpegCapabilities(options, init.signal);
253
+ return state ? new FfmpegCodecs(state) : null;
254
+ }
76
255
  /**
77
- * Indicates whether verbose logging is enabled for FFmpeg probing.
256
+ * Sync factory. Wraps a pre-assembled {@link FfmpegCodecsState} in a `FfmpegCodecs` instance without running any probes. Intended for tests that build a stand-in
257
+ * capability snapshot and for plugins that cache probe results across restarts and want to rehydrate the class without re-probing.
258
+ *
259
+ * @param state - Fully-assembled capability snapshot.
260
+ *
261
+ * @returns A populated `FfmpegCodecs` instance backed by the supplied state.
262
+ *
263
+ * @example
264
+ *
265
+ * ```ts
266
+ * const codecs = FfmpegCodecs.fromState({
267
+ *
268
+ * codecs: {},
269
+ * cpuGeneration: 0,
270
+ * ffmpegExec: "ffmpeg",
271
+ * ffmpegVersion: "8.0",
272
+ * ffmpegVersionParts: parseFfmpegVersionParts("8.0"),
273
+ * gpuMem: 0,
274
+ * hostSystem: "macOS.Apple",
275
+ * hwAccels: new Set([ "videotoolbox" ]),
276
+ * verbose: false
277
+ * });
278
+ * ```
78
279
  */
79
- verbose;
280
+ static fromState(state) {
281
+ return new FfmpegCodecs(state);
282
+ }
80
283
  /**
81
- * Creates an instance of `FfmpegCodecs`.
284
+ * The path or command name used to invoke FFmpeg.
285
+ */
286
+ get ffmpegExec() {
287
+ return this.#state.ffmpegExec;
288
+ }
289
+ /**
290
+ * Indicates whether verbose logging is enabled for FFmpeg probing and downstream consumers.
291
+ */
292
+ get verbose() {
293
+ return this.#state.verbose;
294
+ }
295
+ /**
296
+ * Returns the amount of GPU memory available on the host system, in megabytes.
82
297
  *
83
- * @param options - Options used to configure FFmpeg probing.
298
+ * @remarks Always returns `0` on non-Raspberry Pi systems.
84
299
  */
85
- constructor(options) {
86
- this.ffmpegExec = options.ffmpegExec ?? "ffmpeg";
87
- this.ffmpegCodecs = {};
88
- this.ffmpegHwAccels = new Set();
89
- this.log = options.log;
90
- this.verbose = options.verbose ?? false;
91
- // Detect our host system type.
92
- this.probeHwOs();
300
+ get gpuMem() {
301
+ return this.#state.gpuMem;
302
+ }
303
+ /**
304
+ * Returns the detected FFmpeg version string. `"unknown"` when the probe ran but the `ffmpeg version X Copyright...` line was not found in stdout. Any other value
305
+ * is the literal version string reported by the binary and may carry suffixes (`"8.1.1-tessus"`, `"4.4.2-0ubuntu0.22.04.1"`, etc.). For version-comparison decisions,
306
+ * prefer {@link ffmpegAtLeast} over parsing this string directly.
307
+ */
308
+ get ffmpegVersion() {
309
+ return this.#state.ffmpegVersion;
93
310
  }
94
311
  /**
95
- * Probes the host system and FFmpeg executable for capabilities, version, codecs, and hardware acceleration support.
312
+ * Returns the detected FFmpeg major version as a number, or `0` when detection failed or the version string doesn't begin with an integer. Useful for display
313
+ * ("Running FFmpeg 8") and for callers that need the raw major number. Use {@link ffmpegAtLeast} for version-gating comparisons so all version logic flows through
314
+ * a single boundary-correct comparison primitive.
96
315
  *
97
- * Returns `true` if probing succeeded, otherwise `false`.
316
+ * @returns The major version number (e.g., `6`, `7`, `8`, `10`), or `0` if the version is unknown or non-numeric.
317
+ */
318
+ get ffmpegMajorVersion() {
319
+ return this.#state.ffmpegVersionParts.major;
320
+ }
321
+ /**
322
+ * Return `true` when the detected FFmpeg build is at least the requested version. Compares major, then minor, then patch - the canonical semver ordering. This is
323
+ * the single source of truth for version-gating decisions across the library; callers prefer this over hand-rolled comparisons so the boundary logic (major-equal
324
+ * but minor-greater means "newer", etc.) lives in one place and stays consistent.
325
+ *
326
+ * @param major - The minimum major version to accept.
327
+ * @param minor - The minimum minor version, when major is equal. Defaults to `0`.
328
+ * @param patch - The minimum patch version, when major and minor are equal. Defaults to `0`.
98
329
  *
99
- * @returns A promise that resolves to `true` if probing is successful, or `false` on failure.
330
+ * @returns `true` if the detected version is >= the requested version; `false` otherwise. Returns `false` for unknown or unparseable version strings (major = 0).
100
331
  *
101
332
  * @example
102
333
  *
103
334
  * ```ts
335
+ * if(codecs.ffmpegAtLeast(8)) {
104
336
  *
105
- * const ready = await codecs.probe();
337
+ * // FFmpeg 8.0.0 or later.
338
+ * }
106
339
  *
107
- * if(!ready) {
340
+ * if(codecs.ffmpegAtLeast(8, 1)) {
108
341
  *
109
- * console.log("FFmpeg probing failed.");
342
+ * // FFmpeg 8.1.0 or later.
110
343
  * }
111
344
  * ```
112
345
  */
113
- async probe() {
114
- // Let's conduct our system-specific capability probes.
115
- switch (this.hostSystem) {
116
- case "raspbian":
117
- // If we're on a Raspberry Pi, let's verify that we have enough GPU memory for hardware-based decoding and encoding.
118
- await this.probeRpiGpuMem();
119
- break;
120
- default:
121
- break;
122
- }
123
- // Capture the version information of FFmpeg.
124
- if (!(await this.probeFfmpegVersion())) {
125
- return false;
126
- }
127
- // Ensure we've got a working video processor before we do anything else.
128
- if (!(await this.probeFfmpegCodecs()) || !(await this.probeFfmpegHwAccel())) {
129
- return false;
130
- }
131
- return true;
346
+ ffmpegAtLeast(major, minor = 0, patch = 0) {
347
+ return ffmpegVersionAtLeast(this.#state.ffmpegVersionParts, major, minor, patch);
348
+ }
349
+ /**
350
+ * Returns the host system type we are running on as one of `"generic"`, `"macOS.Apple"`, `"macOS.Intel"`, or `"raspbian"`.
351
+ *
352
+ * @remarks We are only trying to detect host capabilities to the extent they impact which FFmpeg options we are going to use.
353
+ */
354
+ get hostSystem() {
355
+ return this.#state.hostSystem;
356
+ }
357
+ /**
358
+ * Returns the CPU generation if we're on Linux and have an Intel processor or on macOS and have an Apple Silicon processor.
359
+ *
360
+ * @returns Returns the CPU generation or 0 if it can't be detected or an invalid platform.
361
+ */
362
+ get cpuGeneration() {
363
+ return this.#state.cpuGeneration;
132
364
  }
133
365
  /**
134
366
  * Checks whether a specific decoder is available for a given codec.
135
367
  *
136
- * @param codec - The codec name, e.g., "h264".
137
- * @param decoder - The decoder name to check for, e.g., "h264_qsv".
368
+ * @param codec - The codec name, e.g., `"h264"`.
369
+ * @param decoder - The decoder name to check for, e.g., `"h264_qsv"`.
138
370
  *
139
371
  * @returns `true` if the decoder is available for the codec, `false` otherwise.
140
372
  *
141
373
  * @example
142
374
  *
143
375
  * ```ts
144
- *
145
376
  * if(codecs.hasDecoder("h264", "h264_qsv")) {
146
377
  *
147
378
  * // Use hardware decoding.
@@ -149,24 +380,19 @@ export class FfmpegCodecs {
149
380
  * ```
150
381
  */
151
382
  hasDecoder(codec, decoder) {
152
- // Normalize our lookups.
153
- codec = codec.toLowerCase();
154
- decoder = decoder.toLowerCase();
155
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
156
- return this.ffmpegCodecs[codec]?.decoders.has(decoder) ?? false;
383
+ return this.#state.codecs[codec.toLowerCase()]?.decoders.has(decoder.toLowerCase()) ?? false;
157
384
  }
158
385
  /**
159
386
  * Checks whether a specific encoder is available for a given codec.
160
387
  *
161
- * @param codec - The codec name, e.g., "h264".
162
- * @param encoder - The encoder name to check for, e.g., "h264_videotoolbox".
388
+ * @param codec - The codec name, e.g., `"h264"`.
389
+ * @param encoder - The encoder name to check for, e.g., `"h264_videotoolbox"`.
163
390
  *
164
391
  * @returns `true` if the encoder is available for the codec, `false` otherwise.
165
392
  *
166
393
  * @example
167
394
  *
168
395
  * ```ts
169
- *
170
396
  * if(codecs.hasEncoder("h264", "h264_videotoolbox")) {
171
397
  *
172
398
  * // Use hardware encoding.
@@ -174,16 +400,13 @@ export class FfmpegCodecs {
174
400
  * ```
175
401
  */
176
402
  hasEncoder(codec, encoder) {
177
- // Normalize our lookups.
178
- codec = codec.toLowerCase();
179
- encoder = encoder.toLowerCase();
180
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
181
- return this.ffmpegCodecs[codec]?.encoders.has(encoder) ?? false;
403
+ return this.#state.codecs[codec.toLowerCase()]?.encoders.has(encoder.toLowerCase()) ?? false;
182
404
  }
183
405
  /**
184
- * Checks whether a given hardware acceleration method is available and validated on the host, as provided by the output of `ffmpeg -hwaccels`.
406
+ * Checks whether a given hardware acceleration method is available and validated on the host, as provided by the output of `ffmpeg -hwaccels` and the per-accel
407
+ * capability probe.
185
408
  *
186
- * @param accel - The hardware acceleration method name, e.g., "videotoolbox".
409
+ * @param accel - The hardware acceleration method name, e.g., `"videotoolbox"`.
187
410
  *
188
411
  * @returns `true` if the hardware acceleration method is available, `false` otherwise.
189
412
  *
@@ -197,212 +420,204 @@ export class FfmpegCodecs {
197
420
  * ```
198
421
  */
199
422
  hasHwAccel(accel) {
200
- return this.ffmpegHwAccels.has(accel.toLowerCase());
423
+ return this.#state.hwAccels.has(accel.toLowerCase());
201
424
  }
202
- /**
203
- * Returns the amount of GPU memory available on the host system, in megabytes.
204
- *
205
- * @remarks Always returns `0` on non-Raspberry Pi systems.
206
- */
207
- get gpuMem() {
208
- return this._gpuMem ?? 0;
209
- }
210
- /**
211
- * Returns the detected FFmpeg version string, or "unknown" if detection failed.
212
- */
213
- get ffmpegVersion() {
214
- return this._ffmpegVersion ?? "";
425
+ }
426
+ // Probe pipeline. Module-scope functions rather than class methods because the probe assembles state BEFORE a `FfmpegCodecs` instance exists - putting the
427
+ // orchestration inside the class would conflate "how do I populate state?" with "how do I hold and serve state?" The factory at `FfmpegCodecs.probe` glues the two
428
+ // together.
429
+ // Orchestrate the full probe pipeline. Returns a populated state snapshot on success, or `null` when any required probe fails.
430
+ async function probeFfmpegCapabilities(options, signal) {
431
+ const ffmpegExec = options.ffmpegExec ?? "ffmpeg";
432
+ const verbose = options.verbose ?? false;
433
+ const { log } = options;
434
+ // Sync host-system / CPU-generation detection.
435
+ const { hostSystem, cpuGeneration } = probeHwOs();
436
+ // Raspberry Pi: probe GPU memory via vcgencmd. Non-fatal if the command fails (0 propagates; the configureHwAccel gate treats 0 as insufficient anyway).
437
+ let gpuMem = 0;
438
+ if (hostSystem === "raspbian") {
439
+ gpuMem = await probeRpiGpuMem(log, signal);
215
440
  }
216
- /**
217
- * Returns the host system type we are running on as one of "generic", "macOS.Apple", "macOS.Intel", or "raspbian".
218
- *
219
- * @remarks We are only trying to detect host capabilities to the extent they impact which FFmpeg options we are going to use.
220
- */
221
- get hostSystem() {
222
- return this._hostSystem ?? "generic";
441
+ // FFmpeg version is the first required probe; failure here means the FFmpeg binary isn't usable.
442
+ const ffmpegVersion = await probeFfmpegVersion(ffmpegExec, log, signal);
443
+ if (ffmpegVersion === null) {
444
+ return null;
223
445
  }
224
- /**
225
- * Returns the CPU generation if we're on Linux and have an Intel processor or on macOS and have an Apple Silicon processor.
226
- *
227
- * @returns Returns the CPU generation or 0 if it can't be detected or an invalid platform.
228
- */
229
- get cpuGeneration() {
230
- return this._cpuGeneration ?? 0;
446
+ log.info("Using FFmpeg version: %s.", ffmpegVersion);
447
+ // FFmpeg codec inventory.
448
+ const codecs = await probeFfmpegCodecs(ffmpegExec, log, signal);
449
+ if (codecs === null) {
450
+ return null;
231
451
  }
232
- // Probe our video processor's version.
233
- async probeFfmpegVersion() {
234
- return this.probeCmd(this.ffmpegExec, ["-hide_banner", "-version"], (stdout) => {
235
- // A regular expression to parse out the version.
236
- const versionRegex = /^ffmpeg version (.*) Copyright.*$/m;
237
- // Parse out the version string.
238
- const versionMatch = versionRegex.exec(stdout);
239
- // If we have a version string, let's save it. Otherwise, we're blind.
240
- this._ffmpegVersion = versionMatch ? versionMatch[1] : "unknown";
241
- this.log.info("Using FFmpeg version: %s.", this.ffmpegVersion);
242
- });
452
+ // FFmpeg hwaccels inventory, with per-accel capability validation.
453
+ const hwAccels = await probeFfmpegHwAccels(ffmpegExec, verbose, log, signal);
454
+ if (hwAccels === null) {
455
+ return null;
243
456
  }
244
- // Probe our video processor's hardware acceleration capabilities.
245
- async probeFfmpegHwAccel() {
246
- if (!(await this.probeCmd(this.ffmpegExec, ["-hide_banner", "-hwaccels"], (stdout) => {
247
- // Iterate through each line, and a build a list of encoders.
248
- for (const accel of stdout.split(EOL)) {
249
- // Skip blank lines.
250
- if (!accel.length) {
251
- continue;
252
- }
253
- // Skip the first line.
254
- if (accel === "Hardware acceleration methods:") {
255
- continue;
457
+ return {
458
+ codecs,
459
+ cpuGeneration,
460
+ ffmpegExec,
461
+ ffmpegVersion,
462
+ ffmpegVersionParts: parseFfmpegVersionParts(ffmpegVersion),
463
+ gpuMem,
464
+ hostSystem,
465
+ hwAccels,
466
+ verbose
467
+ };
468
+ }
469
+ // Detect hardware / OS characteristics synchronously from Node built-ins. No subprocesses; cheap and deterministic.
470
+ function probeHwOs() {
471
+ // Retrieve the CPU model string once to avoid repeated allocations from cpus(). A machine always has at least one CPU entry in practice; the empty-string fallback
472
+ // keeps the lookup total for `noUncheckedIndexedAccess` without changing behavior in any real environment.
473
+ const cpuModelString = cpus()[0]?.model ?? "";
474
+ let hostSystem = "generic";
475
+ let cpuGeneration = 0;
476
+ // Take a look at the platform we're on for an initial hint of what we are.
477
+ switch (platform) {
478
+ // The beloved macOS.
479
+ case "darwin":
480
+ hostSystem = "macOS." + (cpuModelString.includes("Apple") ? "Apple" : "Intel");
481
+ // Identify what generation of Apple Silicon we have.
482
+ if (cpuModelString.includes("Apple")) {
483
+ // Extract the CPU model.
484
+ const cpuModel = /Apple M(\d+) .*/i.exec(cpuModelString);
485
+ if (cpuModel?.[1]) {
486
+ cpuGeneration = Number(cpuModel[1]);
256
487
  }
257
- // We've found a hardware acceleration method, let's add it.
258
- this.ffmpegHwAccels.add(accel.toLowerCase());
259
488
  }
260
- }))) {
261
- return false;
262
- }
263
- // Let's test to ensure that just because we have a codec or capability available to us, it doesn't necessarily mean that the user has the hardware capabilities
264
- // needed to use it, resulting in an FFmpeg error. We catch that here and prevent those capabilities from being exposed unless both software and hardware capabilities
265
- // enable it. This simple test, generates a one-second video that is processed by the requested codec. If it fails, we discard the codec.
266
- for (const accel of this.ffmpegHwAccels) {
267
- // eslint-disable-next-line no-await-in-loop
268
- if (!(await this.probeCmd(this.ffmpegExec, [
269
- "-hide_banner", "-hwaccel", accel, "-v", "quiet", "-t", "1", "-f", "lavfi", "-i", "color=black:1920x1080", "-c:v", "libx264", "-f", "null", "-"
270
- ], () => { }, true))) {
271
- this.ffmpegHwAccels.delete(accel);
272
- if (this.verbose) {
273
- this.log.error("Hardware-accelerated decoding and encoding using %s will be unavailable: unable to successfully validate capabilities.", accel);
489
+ break;
490
+ // The indomitable Linux.
491
+ case "linux":
492
+ // Check /sys/firmware for the Raspberry Pi 4 device-tree model string. Failure is non-fatal: we fall through to the generic-Linux path with no Pi-specific
493
+ // configuration.
494
+ try {
495
+ // As of the 4.9 kernel, Raspberry Pi prefers to be identified using this method and has deprecated cpuinfo.
496
+ const systemId = readFileSync("/sys/firmware/devicetree/base/model", { encoding: "utf8" });
497
+ // Is it a Pi 4?
498
+ if (/Raspberry Pi (Compute Module )?4/.test(systemId)) {
499
+ hostSystem = "raspbian";
274
500
  }
275
501
  }
276
- }
277
- return true;
278
- }
279
- // Probe our video processor's encoding and decoding capabilities.
280
- async probeFfmpegCodecs() {
281
- return this.probeCmd(this.ffmpegExec, ["-hide_banner", "-codecs"], (stdout) => {
282
- // A regular expression to parse out the codec and its supported decoders.
283
- const decodersRegex = /\S+\s+(\S+).+\(decoders: (.*?)\s*\)/;
284
- // A regular expression to parse out the codec and its supported encoders.
285
- const encodersRegex = /\S+\s+(\S+).+\(encoders: (.*?)\s*\)/;
286
- // Iterate through each line, and a build a list of encoders.
287
- for (const codecLine of stdout.split(EOL)) {
288
- // Let's see if we have decoders.
289
- const decodersMatch = decodersRegex.exec(codecLine);
290
- // Let's see if we have encoders.
291
- const encodersMatch = encodersRegex.exec(codecLine);
292
- // If we found decoders, add them to our list of supported decoders for this format.
293
- if (decodersMatch) {
294
- this.ffmpegCodecs[decodersMatch[1]] ??= { decoders: new Set(), encoders: new Set() };
295
- for (const decoder of decodersMatch[2].split(" ")) {
296
- this.ffmpegCodecs[decodersMatch[1]].decoders.add(decoder.toLowerCase());
297
- }
298
- }
299
- // If we found encoders, add them to our list of supported encoders for this format.
300
- if (encodersMatch) {
301
- this.ffmpegCodecs[encodersMatch[1]] ??= { decoders: new Set(), encoders: new Set() };
302
- for (const encoder of encodersMatch[2].split(" ")) {
303
- this.ffmpegCodecs[encodersMatch[1]].encoders.add(encoder.toLowerCase());
304
- }
305
- }
502
+ catch {
503
+ // We aren't especially concerned with errors here, given we're just trying to ascertain the system information through hints.
306
504
  }
307
- });
308
- }
309
- // Identify what hardware and operating system environment we're actually running on.
310
- probeHwOs() {
311
- // Retrieve the CPU model string once to avoid repeated allocations from cpus().
312
- const cpuModelString = cpus()[0].model;
313
- // Take a look at the platform we're on for an initial hint of what we are.
314
- switch (platform) {
315
- // The beloved macOS.
316
- case "darwin":
317
- this._hostSystem = "macOS." + (cpuModelString.includes("Apple") ? "Apple" : "Intel");
318
- // Identify what generation of Apple Silicon we have.
319
- if (cpuModelString.includes("Apple")) {
320
- // Extract the CPU model.
321
- const cpuModel = /Apple M(\d+) .*/i.exec(cpuModelString);
322
- this._cpuGeneration = 0;
323
- if (cpuModel?.[1]) {
324
- this._cpuGeneration = Number(cpuModel[1]);
505
+ // Identify what generation of Intel CPU we have if we're on Intel.
506
+ if (cpuModelString.includes("Intel")) {
507
+ // Extract the CPU model.
508
+ const cpuModel = /Intel.*Core.*i\d+-(\d{3,5})/i.exec(cpuModelString);
509
+ if (cpuModel?.[1]) {
510
+ // Grab the individual SKU as both a number and string.
511
+ const skuStr = cpuModel[1];
512
+ const skuNum = Number(skuStr);
513
+ // Now deduce the CPU generation.
514
+ if (skuNum < 1000) {
515
+ // First generation CPUs are three digit SKUs.
516
+ cpuGeneration = 1;
325
517
  }
326
- }
327
- break;
328
- // The indomitable Linux.
329
- case "linux":
330
- // Let's further see if we're a small, but scrappy, Raspberry Pi.
331
- try {
332
- // As of the 4.9 kernel, Raspberry Pi prefers to be identified using this method and has deprecated cpuinfo.
333
- const systemId = readFileSync("/sys/firmware/devicetree/base/model", { encoding: "utf8" });
334
- // Is it a Pi 4?
335
- if (/Raspberry Pi (Compute Module )?4/.test(systemId)) {
336
- this._hostSystem = "raspbian";
518
+ else if (skuStr.length > 4) {
519
+ // For five-digit SKUs, the generation are the leading digits before the last three.
520
+ cpuGeneration = Number(skuStr.slice(0, skuStr.length - 3));
337
521
  }
338
- }
339
- catch (error) {
340
- // We aren't especially concerned with errors here, given we're just trying to ascertain the system information through hints.
341
- }
342
- // Identify what generation of Intel CPU we have if we're on Intel.
343
- if (cpuModelString.includes("Intel")) {
344
- // Extract the CPU model.
345
- const cpuModel = /Intel.*Core.*i\d+-(\d{3,5})/i.exec(cpuModelString);
346
- this._cpuGeneration = 0;
347
- if (cpuModel?.[1]) {
348
- // Grab the individual SKU as both a number and string.
349
- const skuStr = cpuModel[1];
350
- const skuNum = Number(skuStr);
351
- // Now deduce the CPU generation.
352
- if (skuNum < 1000) {
353
- // First generation CPUs are three digit SKUs.
354
- this._cpuGeneration = 1;
355
- }
356
- else if (skuStr.length > 4) {
357
- // For five-digit SKUs, the generation are the leading digits before the last three.
358
- this._cpuGeneration = Number(skuStr.slice(0, skuStr.length - 3));
359
- }
360
- else {
361
- // Finally, for four-digit SKUs, the generation is the first digit.
362
- this._cpuGeneration = Number(skuStr.charAt(0));
363
- }
522
+ else {
523
+ // Finally, for four-digit SKUs, the generation is the first digit.
524
+ cpuGeneration = Number(skuStr.charAt(0));
364
525
  }
365
526
  }
366
- break;
367
- default:
368
- // We aren't trying to solve for every system type.
369
- break;
527
+ }
528
+ break;
529
+ default:
530
+ // We aren't trying to solve for every system type.
531
+ break;
532
+ }
533
+ return { cpuGeneration, hostSystem };
534
+ }
535
+ // Probe the FFmpeg version string via `ffmpeg -hide_banner -version`. Returns the parsed version string, or `null` on probe failure.
536
+ async function probeFfmpegVersion(ffmpegExec, log, signal) {
537
+ let version = null;
538
+ const ok = await probeCmd(ffmpegExec, ["-hide_banner", "-version"], (stdout) => {
539
+ version = parseFfmpegVersion(stdout);
540
+ }, { log, signal });
541
+ return ok ? version : null;
542
+ }
543
+ // Probe the FFmpeg codec inventory via `ffmpeg -hide_banner -codecs`. Returns a format-keyed index, or `null` on probe failure.
544
+ async function probeFfmpegCodecs(ffmpegExec, log, signal) {
545
+ let parsed = null;
546
+ const ok = await probeCmd(ffmpegExec, ["-hide_banner", "-codecs"], (stdout) => {
547
+ parsed = parseFfmpegCodecs(stdout);
548
+ }, { log, signal });
549
+ return ok ? parsed : null;
550
+ }
551
+ // Probe the FFmpeg hardware-accelerator inventory via `ffmpeg -hide_banner -hwaccels`, then validate each advertised accel by running a one-second synthetic transcode
552
+ // that initializes the hardware-acceleration context. The validation catches the case where a build advertises an accel the host hardware cannot actually use;
553
+ // discarding those accelerators here means callers don't have to re-validate downstream.
554
+ async function probeFfmpegHwAccels(ffmpegExec, verbose, log, signal) {
555
+ const hwAccels = new Set();
556
+ const ok = await probeCmd(ffmpegExec, ["-hide_banner", "-hwaccels"], (stdout) => {
557
+ for (const accel of parseFfmpegHwAccels(stdout)) {
558
+ hwAccels.add(accel);
370
559
  }
560
+ }, { log, signal });
561
+ if (!ok) {
562
+ return null;
371
563
  }
372
- // Probe Raspberry Pi GPU capabilities.
373
- async probeRpiGpuMem() {
374
- return this.probeCmd("vcgencmd", ["get_mem", "gpu"], (stdout) => {
375
- // A regular expression to parse out the configured GPU memory on the Raspberry Pi.
376
- const gpuRegex = /^gpu=(.*)M\n$/;
377
- // Let's see what we've got.
378
- const gpuMatch = gpuRegex.exec(stdout);
379
- // Parse the result and retrieve our allocated GPU memory.
380
- this._gpuMem = Number.parseInt(gpuMatch?.[1] ?? "", 10) || 0;
381
- });
564
+ // Capability validation per accel. Hardware / driver availability can diverge from FFmpeg's advertised build capabilities (e.g., `videotoolbox` is always present in
565
+ // macOS builds but unusable on Intel Macs running a pre-VT OS). A quick synthetic transcode against each accel tells us which ones actually work on this host.
566
+ for (const accel of hwAccels) {
567
+ // Validate one accel at a time rather than in parallel: each synthetic transcode initializes the same underlying hardware-acceleration context (GPU or codec
568
+ // engine), so concurrent probes would contend for that shared resource and risk a false negative - an accel reported as unusable because of contention with a
569
+ // sibling probe rather than a genuine host incapability. This is a one-time startup probe, not a throughput-sensitive workload, so serializing it here costs nothing
570
+ // that matters.
571
+ // eslint-disable-next-line no-await-in-loop
572
+ const accelOk = await probeCmd(ffmpegExec, [
573
+ "-hide_banner", "-hwaccel", accel, "-v", "quiet", "-t", "1", "-f", "lavfi", "-i", "color=black:1920x1080", "-c:v", "libx264", "-f", "null", "-"
574
+ ], () => { }, { log, quietRunErrors: true, signal });
575
+ if (!accelOk) {
576
+ hwAccels.delete(accel);
577
+ if (verbose) {
578
+ log.error("Hardware-accelerated decoding and encoding using %s will be unavailable: unable to successfully validate capabilities.", accel);
579
+ }
580
+ }
581
+ }
582
+ return hwAccels;
583
+ }
584
+ // Probe Raspberry Pi GPU memory via `vcgencmd get_mem gpu`. Returns the megabyte value, or `0` on probe failure (the caller's configureHwAccel gate treats `0` as
585
+ // insufficient anyway, so the return-value distinction is cosmetic but keeps the null handling consistent with the other probes).
586
+ async function probeRpiGpuMem(log, signal) {
587
+ let gpuMem = 0;
588
+ await probeCmd("vcgencmd", ["get_mem", "gpu"], (stdout) => {
589
+ gpuMem = parseRpiGpuMem(stdout);
590
+ }, { log, signal });
591
+ return gpuMem;
592
+ }
593
+ // Utility to run a probe subcommand under a composed signal (caller's signal + a per-probe watchdog timeout). The caller's processOutput handler receives the stdout on
594
+ // success; a returned `true` indicates the command completed and the output was handed off. An ENOENT failure (the binary could not be found) always logs regardless of
595
+ // `quietRunErrors`, since a missing binary is worth surfacing no matter the caller's intent; other failures log unless `quietRunErrors` is set - that flag exists for
596
+ // the per-accel validation loop where failures are expected and logged at a higher level by the caller.
597
+ async function probeCmd(command, commandLineArgs, processOutput, options) {
598
+ const { log, quietRunErrors = false, signal: callerSignal } = options;
599
+ // Always compose with a per-probe watchdog timeout so the worst-case outcome is bounded. When the caller also supplies a signal, `composeSignals` falls through to
600
+ // `AbortSignal.any()`; when no caller signal is supplied, the timeout alone governs (composeSignals returns it unwrapped).
601
+ const composed = composeSignals(callerSignal, AbortSignal.timeout(PROBE_DEFAULT_TIMEOUT_MS));
602
+ try {
603
+ const { stdout } = await execFileAsync(command, commandLineArgs, { signal: composed });
604
+ processOutput(stdout);
605
+ return true;
382
606
  }
383
- // Utility to probe the capabilities of FFmpeg and the host platform.
384
- async probeCmd(command, commandLineArgs, processOutput, quietRunErrors = false) {
385
- try {
386
- // Check for the codecs in our video processor.
387
- const { stdout } = await execFileAsync(command, commandLineArgs);
388
- processOutput(stdout);
389
- return true;
607
+ catch (error) {
608
+ const execError = error;
609
+ if (execError.code === "ENOENT") {
610
+ log.error("Unable to find '%s' in path: '%s'.", command, env["PATH"]);
611
+ return false;
390
612
  }
391
- catch (error) {
392
- const execError = error;
393
- if (execError.code === "ENOENT") {
394
- this.log.error("Unable to find '%s' in path: '%s'.", command, env.PATH);
395
- return false;
396
- }
397
- else if (quietRunErrors) {
398
- return false;
399
- }
400
- else {
401
- this.log.error("Error running %s: %s.", command, execError.message.replace(/\.$/, ""));
402
- }
403
- this.log.error("Unable to probe the capabilities of your Homebridge host without access to '%s'. Ensure that it is available in your path and correctly working.", command);
613
+ else if (quietRunErrors) {
404
614
  return false;
405
615
  }
616
+ else {
617
+ log.error("Error running %s: %s.", command, execError.message.replace(/\.$/, ""));
618
+ }
619
+ log.error("Unable to probe the capabilities of your Homebridge host without access to '%s'. Ensure that it is available in your path and correctly working.", command);
620
+ return false;
406
621
  }
407
622
  }
408
623
  //# sourceMappingURL=codecs.js.map