@camstack/types 1.2.224 → 1.2.226

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.
package/dist/node.js CHANGED
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_canonical_hash = require("./canonical-hash-DYsYm8is.js");
24
+ const require_canonical_hash = require("./canonical-hash-CSE4ioRi.js");
25
25
  const require_err_msg = require("./err-msg-COpsHMw2.js");
26
26
  let node_crypto = require("node:crypto");
27
27
  let node_fs = require("node:fs");
@@ -185,16 +185,208 @@ async function ensureBinary(opts) {
185
185
  });
186
186
  }
187
187
  //#endregion
188
+ //#region src/ffmpeg/binary-capabilities.ts
189
+ /**
190
+ * What an ffmpeg binary can actually DO, asked of the binary itself.
191
+ *
192
+ * ## The failure this exists to end
193
+ *
194
+ * `ensureFfmpeg` prefers a downloaded STATIC build over the system one, and a
195
+ * static build is compiled for portability: no vendor libraries, no VAAPI, no
196
+ * QSV. On a machine with a working Intel iGPU that is precisely backwards, and
197
+ * the symptom does not say so. Measured on the hub, 2026-09-18:
198
+ *
199
+ * ```
200
+ * /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
201
+ * /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
202
+ * ```
203
+ *
204
+ * Every consumer that went through `ensureFfmpeg` — the broker's egress and
205
+ * derived transcodes, the WebRTC transcode leg, the camera grid — asked for
206
+ * `-hwaccel vaapi`, got `Device creation failed: -12`, and fell back to
207
+ * software. `-12` is what fftools prints when `av_hwdevice_ctx_alloc` returns
208
+ * NULL because the type is not in the build: the NAME parses, so the request
209
+ * looks valid and the device simply cannot exist. It reads as a resource
210
+ * problem and is a build property.
211
+ *
212
+ * ## Why asking the kernel was not enough
213
+ *
214
+ * The existing hardware resolver answers from `/dev/dri` presence. That is a
215
+ * correct description of the NODE, and it is what `node-av` needs — the
216
+ * detection workers were on VAAPI the whole time. It says nothing about the
217
+ * separate binary a child will be spawned with, and it was being read as
218
+ * though it did.
219
+ *
220
+ * So: two questions, two answers. What does this node HAVE (the resolver), and
221
+ * what can this BINARY use (here). A backend needs both.
222
+ */
223
+ /** How long a probe may take before the binary counts as unusable. */
224
+ var PROBE_TIMEOUT_MS = 5e3;
225
+ function run(path, args) {
226
+ return new Promise((resolve) => {
227
+ (0, node_child_process.execFile)(path, [...args], { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
228
+ resolve(error ? "" : stdout);
229
+ });
230
+ });
231
+ }
232
+ /**
233
+ * `-hwaccels` prints a header line and then one name per line. Anything with a
234
+ * space in it is prose, not a name.
235
+ */
236
+ function parseNames(output) {
237
+ const names = /* @__PURE__ */ new Set();
238
+ for (const raw of output.split("\n")) {
239
+ const line = raw.trim();
240
+ if (line.length === 0 || line.includes(" ")) continue;
241
+ names.add(line);
242
+ }
243
+ return names;
244
+ }
245
+ /**
246
+ * `-encoders` prints a LEGEND and then a table, separated by a dashed line:
247
+ *
248
+ * ```
249
+ * Encoders:
250
+ * V..... = Video
251
+ * .F.... = Frame-level multithreading
252
+ * ------
253
+ * V....D h264_vaapi H.264/AVC (VAAPI) (codec h264)
254
+ * ```
255
+ *
256
+ * The legend rows have the SAME leading flag shape as the table rows, so a
257
+ * pattern alone matches ` V..... = Video` and yields `=` as an encoder name.
258
+ * Verified against the hub's own output on 2026-09-18, which is how that was
259
+ * caught. So the separator is the parser's state: nothing counts before it.
260
+ */
261
+ function parseEncoders(output) {
262
+ const names = /* @__PURE__ */ new Set();
263
+ let inTable = false;
264
+ for (const raw of output.split("\n")) {
265
+ if (!inTable) {
266
+ if (raw.trim().startsWith("---")) inTable = true;
267
+ continue;
268
+ }
269
+ const match = /^\s[VAS][.F][.S][.X][.B][.D]\s+(\S+)/.exec(raw);
270
+ if (match?.[1] !== void 0) names.add(match[1]);
271
+ }
272
+ return names;
273
+ }
274
+ /**
275
+ * Turn three raw outputs into capabilities. Separated from the spawning so the
276
+ * parsers can be held against what ffmpeg REALLY prints — which is how the
277
+ * legend rows were caught being read as encoder names.
278
+ */
279
+ function parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut) {
280
+ return {
281
+ path,
282
+ version: versionOut.split("\n")[0]?.trim() ?? "",
283
+ hwaccels: parseNames(hwaccelOut),
284
+ encoders: parseEncoders(encoderOut)
285
+ };
286
+ }
287
+ /** Ask one binary what it can do. Never throws — an unusable binary answers empty. */
288
+ async function probeFfmpegBinary(path) {
289
+ const [versionOut, hwaccelOut, encoderOut] = await Promise.all([
290
+ run(path, ["-hide_banner", "-version"]),
291
+ run(path, ["-hide_banner", "-hwaccels"]),
292
+ run(path, ["-hide_banner", "-encoders"])
293
+ ]);
294
+ return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
295
+ }
296
+ //#endregion
297
+ //#region src/deps/ffmpeg-binary-source.ts
298
+ /**
299
+ * WHICH ffmpeg a node runs, and why it is never the host's.
300
+ *
301
+ * CamStack spawns ffmpeg for the broker's egress and derived transcodes, the
302
+ * WebRTC transcode leg, the recorder, snapshots and the camera grid. What that
303
+ * binary is compiled with decides whether any of them can use the machine's
304
+ * hardware, so it is not a detail to leave to whatever the host happens to have
305
+ * on its PATH:
306
+ *
307
+ * - a host binary is an unknown version with unknown vendor libraries, and it
308
+ * changes under us on any host update;
309
+ * - a self-hosted NVR cannot assume the host has ffmpeg at all.
310
+ *
311
+ * So the binary is always OURS. Two ways it can be ours, and which one applies
312
+ * is a property of where the node runs — not of an environment variable:
313
+ *
314
+ * | Node | Binary |
315
+ * | --- | --- |
316
+ * | our container image | {@link BUNDLED_FFMPEG_PATH}, installed by the image |
317
+ * | anything else (a native macOS agent) | the static build we download into `<dataDir>/deps` |
318
+ *
319
+ * `BUNDLED_FFMPEG_PATH` is a CamStack-owned path that only our image creates
320
+ * (a link to the ffmpeg the image installs). That is what makes "is this ours?"
321
+ * answerable: `/usr/bin/ffmpeg` is ours INSIDE the image and the host's outside
322
+ * it, and nothing in the process can tell those apart. A path only we ever
323
+ * write can.
324
+ *
325
+ * The image build is the reason to prefer it over the download: the image
326
+ * installs Ubuntu's ffmpeg next to the Intel media driver, so it carries VAAPI
327
+ * and QSV. The portable static builds carry neither — measured on the hub,
328
+ * 2026-09-18:
329
+ *
330
+ * ```
331
+ * /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
332
+ * /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
333
+ * ```
334
+ *
335
+ * A child spawned with the first one asks for `-hwaccel vaapi` and gets
336
+ * `Device creation failed: -12` — the name parses, the device cannot exist —
337
+ * then transcodes in software with nothing naming the cause.
338
+ *
339
+ * {@link FFMPEG_PATH_ENV} is an operator's VOLUNTARY override, and only that:
340
+ * unset is the normal, correct state on every node, and nothing in the product
341
+ * sets it.
342
+ */
343
+ /** Where our container image puts the ffmpeg it installs. Only the image writes this. */
344
+ var BUNDLED_FFMPEG_PATH = "/opt/camstack/bin/ffmpeg";
345
+ /** Operator override. Unset on every node unless a human deliberately set it. */
346
+ var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
347
+ /**
348
+ * The binary to use, or `null` when there is nothing yet and one must be
349
+ * downloaded.
350
+ *
351
+ * An override that does not exist is NOT silently skipped — it is an operator
352
+ * mistake, and falling through to a different binary than the one they named
353
+ * would hide it. The caller reports it and carries on with the default, which
354
+ * is the only safe direction: a typo must not stop a node from serving video.
355
+ */
356
+ function chooseFfmpegBinary(input) {
357
+ const override = input.override?.trim() ?? "";
358
+ if (override.length > 0 && input.exists(override)) return {
359
+ origin: "override",
360
+ path: override
361
+ };
362
+ const bundled = bundledFfmpegPath(input.platform);
363
+ if (bundled !== null && input.exists(bundled)) return {
364
+ origin: "bundled",
365
+ path: bundled
366
+ };
367
+ if (input.exists(input.downloadedPath)) return {
368
+ origin: "downloaded",
369
+ path: input.downloadedPath
370
+ };
371
+ return null;
372
+ }
373
+ /** The image-provided path for a platform, or `null` where we ship no image. */
374
+ function bundledFfmpegPath(platform) {
375
+ return platform === "linux" ? BUNDLED_FFMPEG_PATH : null;
376
+ }
377
+ //#endregion
188
378
  //#region src/deps/ffmpeg-downloader.ts
189
379
  /**
190
- * Download ffmpeg static build for the current platform.
380
+ * The ffmpeg binary a node runs, and the static build we download when no image
381
+ * provided one.
191
382
  *
192
- * Sources:
193
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
194
- * - macOS: https://evermeet.cx/ffmpeg/ or homebrew
383
+ * Which binary, and why it is never the host's, is in `ffmpeg-binary-source.ts`.
384
+ * This module is the download half: the URLs, and the resolution that reports
385
+ * what it picked.
195
386
  *
196
- * Using BtbN's GitHub releases as they cover both platforms:
197
- * https://github.com/BtbN/FFmpeg-Builds/releases
387
+ * Sources for the download:
388
+ * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
389
+ * - macOS: https://www.osxexperts.net/
198
390
  */
199
391
  var FFMPEG_VERSION = "7.1";
200
392
  function getFfmpegDownloadUrl(platform, arch) {
@@ -232,21 +424,48 @@ function getFfmpegArchiveInfo(platform) {
232
424
  }
233
425
  }
234
426
  /**
235
- * Ensure ffmpeg binary is available.
236
- * Checks: deps dir → system PATH → download.
427
+ * Ensure an ffmpeg binary WE provide is available on this node, and report
428
+ * which one and what it can do.
429
+ *
430
+ * Order: the operator's voluntary override → the one our image installed → a
431
+ * copy we already downloaded → download it. The system PATH is not in that
432
+ * list and must never be: see `ffmpeg-binary-source.ts`.
433
+ *
434
+ * The line it logs is the point as much as the path is. "Which ffmpeg am I
435
+ * running, and does it have vaapi" had no answer anywhere in the product, and
436
+ * that is what let a hardware-incapable binary serve every transcode on a hub
437
+ * with a working iGPU for months, silently in software.
237
438
  */
238
439
  async function ensureFfmpeg(dataDir, logger) {
239
440
  const depsDir = (0, node_path.join)(dataDir, "deps");
240
441
  const platform = process.platform;
241
442
  const arch = process.arch;
242
- const archiveInfo = getFfmpegArchiveInfo(platform);
243
- return ensureBinary({
443
+ const ext = platform === "win32" ? ".exe" : "";
444
+ const downloadedPath = (0, node_path.join)(depsDir, `ffmpeg${ext}`);
445
+ const override = process.env[FFMPEG_PATH_ENV];
446
+ const chosen = chooseFfmpegBinary({
447
+ platform,
448
+ downloadedPath,
449
+ override,
450
+ exists: node_fs.existsSync
451
+ });
452
+ if (override !== void 0 && override.trim().length > 0 && chosen?.origin !== "override") logger.error(`${FFMPEG_PATH_ENV} names a binary that does not exist — ignoring it`, { meta: { [FFMPEG_PATH_ENV]: override } });
453
+ const path = chosen?.path ?? await downloadBinary({
244
454
  name: "ffmpeg",
455
+ url: getFfmpegDownloadUrl(platform, arch),
245
456
  targetDir: depsDir,
246
- downloadUrl: getFfmpegDownloadUrl(platform, arch),
457
+ targetName: `ffmpeg${ext}`,
247
458
  logger,
248
- ...archiveInfo
459
+ ...getFfmpegArchiveInfo(platform)
249
460
  });
461
+ const capabilities = await probeFfmpegBinary(path);
462
+ logger.info("ffmpeg binary resolved", { meta: {
463
+ path,
464
+ origin: chosen?.origin ?? "downloaded",
465
+ version: capabilities.version,
466
+ hwaccels: [...capabilities.hwaccels].join(",") || "none"
467
+ } });
468
+ return path;
250
469
  }
251
470
  //#endregion
252
471
  //#region src/deps/python-downloader.ts
@@ -1751,9 +1970,11 @@ exports.getPythonDownloadUrl = getPythonDownloadUrl;
1751
1970
  exports.installPythonPackages = installPythonPackages;
1752
1971
  exports.installPythonRequirements = installPythonRequirements;
1753
1972
  exports.nodeProcStatReader = nodeProcStatReader;
1973
+ exports.parseFfmpegCapabilities = parseFfmpegCapabilities;
1754
1974
  exports.parseProcCpuSeconds = parseProcCpuSeconds;
1755
1975
  exports.parseProcRssBytes = parseProcRssBytes;
1756
1976
  exports.physicalRootOf = physicalRootOf;
1977
+ exports.probeFfmpegBinary = probeFfmpegBinary;
1757
1978
  exports.readProcessCost = readProcessCost;
1758
1979
  exports.resolveExportFingerprint = resolveExportFingerprint;
1759
1980
  exports.signExpiringUrl = signExpiringUrl;
package/dist/node.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-CvL03d3i.mjs";
1
+ import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-CPK2Dy60.mjs";
2
2
  import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
3
3
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import * as fs from "node:fs";
@@ -7,7 +7,7 @@ import * as path$1 from "node:path";
7
7
  import path, { basename, join } from "node:path";
8
8
  import { pipeline } from "node:stream/promises";
9
9
  import { Readable } from "node:stream";
10
- import { execFileSync, spawn } from "node:child_process";
10
+ import { execFile, execFileSync, spawn } from "node:child_process";
11
11
  import { readFile } from "node:fs/promises";
12
12
  //#region src/deps/binary-downloader.ts
13
13
  /**
@@ -162,16 +162,208 @@ async function ensureBinary(opts) {
162
162
  });
163
163
  }
164
164
  //#endregion
165
+ //#region src/ffmpeg/binary-capabilities.ts
166
+ /**
167
+ * What an ffmpeg binary can actually DO, asked of the binary itself.
168
+ *
169
+ * ## The failure this exists to end
170
+ *
171
+ * `ensureFfmpeg` prefers a downloaded STATIC build over the system one, and a
172
+ * static build is compiled for portability: no vendor libraries, no VAAPI, no
173
+ * QSV. On a machine with a working Intel iGPU that is precisely backwards, and
174
+ * the symptom does not say so. Measured on the hub, 2026-09-18:
175
+ *
176
+ * ```
177
+ * /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
178
+ * /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
179
+ * ```
180
+ *
181
+ * Every consumer that went through `ensureFfmpeg` — the broker's egress and
182
+ * derived transcodes, the WebRTC transcode leg, the camera grid — asked for
183
+ * `-hwaccel vaapi`, got `Device creation failed: -12`, and fell back to
184
+ * software. `-12` is what fftools prints when `av_hwdevice_ctx_alloc` returns
185
+ * NULL because the type is not in the build: the NAME parses, so the request
186
+ * looks valid and the device simply cannot exist. It reads as a resource
187
+ * problem and is a build property.
188
+ *
189
+ * ## Why asking the kernel was not enough
190
+ *
191
+ * The existing hardware resolver answers from `/dev/dri` presence. That is a
192
+ * correct description of the NODE, and it is what `node-av` needs — the
193
+ * detection workers were on VAAPI the whole time. It says nothing about the
194
+ * separate binary a child will be spawned with, and it was being read as
195
+ * though it did.
196
+ *
197
+ * So: two questions, two answers. What does this node HAVE (the resolver), and
198
+ * what can this BINARY use (here). A backend needs both.
199
+ */
200
+ /** How long a probe may take before the binary counts as unusable. */
201
+ var PROBE_TIMEOUT_MS = 5e3;
202
+ function run(path, args) {
203
+ return new Promise((resolve) => {
204
+ execFile(path, [...args], { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
205
+ resolve(error ? "" : stdout);
206
+ });
207
+ });
208
+ }
209
+ /**
210
+ * `-hwaccels` prints a header line and then one name per line. Anything with a
211
+ * space in it is prose, not a name.
212
+ */
213
+ function parseNames(output) {
214
+ const names = /* @__PURE__ */ new Set();
215
+ for (const raw of output.split("\n")) {
216
+ const line = raw.trim();
217
+ if (line.length === 0 || line.includes(" ")) continue;
218
+ names.add(line);
219
+ }
220
+ return names;
221
+ }
222
+ /**
223
+ * `-encoders` prints a LEGEND and then a table, separated by a dashed line:
224
+ *
225
+ * ```
226
+ * Encoders:
227
+ * V..... = Video
228
+ * .F.... = Frame-level multithreading
229
+ * ------
230
+ * V....D h264_vaapi H.264/AVC (VAAPI) (codec h264)
231
+ * ```
232
+ *
233
+ * The legend rows have the SAME leading flag shape as the table rows, so a
234
+ * pattern alone matches ` V..... = Video` and yields `=` as an encoder name.
235
+ * Verified against the hub's own output on 2026-09-18, which is how that was
236
+ * caught. So the separator is the parser's state: nothing counts before it.
237
+ */
238
+ function parseEncoders(output) {
239
+ const names = /* @__PURE__ */ new Set();
240
+ let inTable = false;
241
+ for (const raw of output.split("\n")) {
242
+ if (!inTable) {
243
+ if (raw.trim().startsWith("---")) inTable = true;
244
+ continue;
245
+ }
246
+ const match = /^\s[VAS][.F][.S][.X][.B][.D]\s+(\S+)/.exec(raw);
247
+ if (match?.[1] !== void 0) names.add(match[1]);
248
+ }
249
+ return names;
250
+ }
251
+ /**
252
+ * Turn three raw outputs into capabilities. Separated from the spawning so the
253
+ * parsers can be held against what ffmpeg REALLY prints — which is how the
254
+ * legend rows were caught being read as encoder names.
255
+ */
256
+ function parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut) {
257
+ return {
258
+ path,
259
+ version: versionOut.split("\n")[0]?.trim() ?? "",
260
+ hwaccels: parseNames(hwaccelOut),
261
+ encoders: parseEncoders(encoderOut)
262
+ };
263
+ }
264
+ /** Ask one binary what it can do. Never throws — an unusable binary answers empty. */
265
+ async function probeFfmpegBinary(path) {
266
+ const [versionOut, hwaccelOut, encoderOut] = await Promise.all([
267
+ run(path, ["-hide_banner", "-version"]),
268
+ run(path, ["-hide_banner", "-hwaccels"]),
269
+ run(path, ["-hide_banner", "-encoders"])
270
+ ]);
271
+ return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
272
+ }
273
+ //#endregion
274
+ //#region src/deps/ffmpeg-binary-source.ts
275
+ /**
276
+ * WHICH ffmpeg a node runs, and why it is never the host's.
277
+ *
278
+ * CamStack spawns ffmpeg for the broker's egress and derived transcodes, the
279
+ * WebRTC transcode leg, the recorder, snapshots and the camera grid. What that
280
+ * binary is compiled with decides whether any of them can use the machine's
281
+ * hardware, so it is not a detail to leave to whatever the host happens to have
282
+ * on its PATH:
283
+ *
284
+ * - a host binary is an unknown version with unknown vendor libraries, and it
285
+ * changes under us on any host update;
286
+ * - a self-hosted NVR cannot assume the host has ffmpeg at all.
287
+ *
288
+ * So the binary is always OURS. Two ways it can be ours, and which one applies
289
+ * is a property of where the node runs — not of an environment variable:
290
+ *
291
+ * | Node | Binary |
292
+ * | --- | --- |
293
+ * | our container image | {@link BUNDLED_FFMPEG_PATH}, installed by the image |
294
+ * | anything else (a native macOS agent) | the static build we download into `<dataDir>/deps` |
295
+ *
296
+ * `BUNDLED_FFMPEG_PATH` is a CamStack-owned path that only our image creates
297
+ * (a link to the ffmpeg the image installs). That is what makes "is this ours?"
298
+ * answerable: `/usr/bin/ffmpeg` is ours INSIDE the image and the host's outside
299
+ * it, and nothing in the process can tell those apart. A path only we ever
300
+ * write can.
301
+ *
302
+ * The image build is the reason to prefer it over the download: the image
303
+ * installs Ubuntu's ffmpeg next to the Intel media driver, so it carries VAAPI
304
+ * and QSV. The portable static builds carry neither — measured on the hub,
305
+ * 2026-09-18:
306
+ *
307
+ * ```
308
+ * /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
309
+ * /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
310
+ * ```
311
+ *
312
+ * A child spawned with the first one asks for `-hwaccel vaapi` and gets
313
+ * `Device creation failed: -12` — the name parses, the device cannot exist —
314
+ * then transcodes in software with nothing naming the cause.
315
+ *
316
+ * {@link FFMPEG_PATH_ENV} is an operator's VOLUNTARY override, and only that:
317
+ * unset is the normal, correct state on every node, and nothing in the product
318
+ * sets it.
319
+ */
320
+ /** Where our container image puts the ffmpeg it installs. Only the image writes this. */
321
+ var BUNDLED_FFMPEG_PATH = "/opt/camstack/bin/ffmpeg";
322
+ /** Operator override. Unset on every node unless a human deliberately set it. */
323
+ var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
324
+ /**
325
+ * The binary to use, or `null` when there is nothing yet and one must be
326
+ * downloaded.
327
+ *
328
+ * An override that does not exist is NOT silently skipped — it is an operator
329
+ * mistake, and falling through to a different binary than the one they named
330
+ * would hide it. The caller reports it and carries on with the default, which
331
+ * is the only safe direction: a typo must not stop a node from serving video.
332
+ */
333
+ function chooseFfmpegBinary(input) {
334
+ const override = input.override?.trim() ?? "";
335
+ if (override.length > 0 && input.exists(override)) return {
336
+ origin: "override",
337
+ path: override
338
+ };
339
+ const bundled = bundledFfmpegPath(input.platform);
340
+ if (bundled !== null && input.exists(bundled)) return {
341
+ origin: "bundled",
342
+ path: bundled
343
+ };
344
+ if (input.exists(input.downloadedPath)) return {
345
+ origin: "downloaded",
346
+ path: input.downloadedPath
347
+ };
348
+ return null;
349
+ }
350
+ /** The image-provided path for a platform, or `null` where we ship no image. */
351
+ function bundledFfmpegPath(platform) {
352
+ return platform === "linux" ? BUNDLED_FFMPEG_PATH : null;
353
+ }
354
+ //#endregion
165
355
  //#region src/deps/ffmpeg-downloader.ts
166
356
  /**
167
- * Download ffmpeg static build for the current platform.
357
+ * The ffmpeg binary a node runs, and the static build we download when no image
358
+ * provided one.
168
359
  *
169
- * Sources:
170
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
171
- * - macOS: https://evermeet.cx/ffmpeg/ or homebrew
360
+ * Which binary, and why it is never the host's, is in `ffmpeg-binary-source.ts`.
361
+ * This module is the download half: the URLs, and the resolution that reports
362
+ * what it picked.
172
363
  *
173
- * Using BtbN's GitHub releases as they cover both platforms:
174
- * https://github.com/BtbN/FFmpeg-Builds/releases
364
+ * Sources for the download:
365
+ * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
366
+ * - macOS: https://www.osxexperts.net/
175
367
  */
176
368
  var FFMPEG_VERSION = "7.1";
177
369
  function getFfmpegDownloadUrl(platform, arch) {
@@ -209,21 +401,48 @@ function getFfmpegArchiveInfo(platform) {
209
401
  }
210
402
  }
211
403
  /**
212
- * Ensure ffmpeg binary is available.
213
- * Checks: deps dir → system PATH → download.
404
+ * Ensure an ffmpeg binary WE provide is available on this node, and report
405
+ * which one and what it can do.
406
+ *
407
+ * Order: the operator's voluntary override → the one our image installed → a
408
+ * copy we already downloaded → download it. The system PATH is not in that
409
+ * list and must never be: see `ffmpeg-binary-source.ts`.
410
+ *
411
+ * The line it logs is the point as much as the path is. "Which ffmpeg am I
412
+ * running, and does it have vaapi" had no answer anywhere in the product, and
413
+ * that is what let a hardware-incapable binary serve every transcode on a hub
414
+ * with a working iGPU for months, silently in software.
214
415
  */
215
416
  async function ensureFfmpeg(dataDir, logger) {
216
417
  const depsDir = join(dataDir, "deps");
217
418
  const platform = process.platform;
218
419
  const arch = process.arch;
219
- const archiveInfo = getFfmpegArchiveInfo(platform);
220
- return ensureBinary({
420
+ const ext = platform === "win32" ? ".exe" : "";
421
+ const downloadedPath = join(depsDir, `ffmpeg${ext}`);
422
+ const override = process.env[FFMPEG_PATH_ENV];
423
+ const chosen = chooseFfmpegBinary({
424
+ platform,
425
+ downloadedPath,
426
+ override,
427
+ exists: existsSync
428
+ });
429
+ if (override !== void 0 && override.trim().length > 0 && chosen?.origin !== "override") logger.error(`${FFMPEG_PATH_ENV} names a binary that does not exist — ignoring it`, { meta: { [FFMPEG_PATH_ENV]: override } });
430
+ const path = chosen?.path ?? await downloadBinary({
221
431
  name: "ffmpeg",
432
+ url: getFfmpegDownloadUrl(platform, arch),
222
433
  targetDir: depsDir,
223
- downloadUrl: getFfmpegDownloadUrl(platform, arch),
434
+ targetName: `ffmpeg${ext}`,
224
435
  logger,
225
- ...archiveInfo
436
+ ...getFfmpegArchiveInfo(platform)
226
437
  });
438
+ const capabilities = await probeFfmpegBinary(path);
439
+ logger.info("ffmpeg binary resolved", { meta: {
440
+ path,
441
+ origin: chosen?.origin ?? "downloaded",
442
+ version: capabilities.version,
443
+ hwaccels: [...capabilities.hwaccels].join(",") || "none"
444
+ } });
445
+ return path;
227
446
  }
228
447
  //#endregion
229
448
  //#region src/deps/python-downloader.ts
@@ -1705,4 +1924,4 @@ function resolveExportFingerprint(input) {
1705
1924
  return input.persisted ?? input.fresh;
1706
1925
  }
1707
1926
  //#endregion
1708
- export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
1927
+ export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseFfmpegCapabilities, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, probeFfmpegBinary, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
@@ -1362,6 +1362,17 @@ var CAM_PROFILE_ORDER = [
1362
1362
  "mid",
1363
1363
  "low"
1364
1364
  ];
1365
+ /**
1366
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
1367
+ *
1368
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
1369
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
1370
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
1371
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
1372
+ * the operator picked. Today's one user is the audio analyser, whose rule is
1373
+ * "the lowest AVAILABLE native rung" (D537).
1374
+ */
1375
+ var AudioStreamPickSchema = z.union([CamProfileSchema, z.literal("auto")]);
1365
1376
  var CamStreamKindSchema = z.enum([
1366
1377
  "pull-rtsp",
1367
1378
  "pull-rtmp",
@@ -3880,7 +3891,8 @@ function createDeviceProxy(api, binding, opts) {
3880
3891
  getIceCandidates: (input) => dispatch("webrtc-session", "webrtcSession", "getIceCandidates", "query", input),
3881
3892
  closeSession: (input) => dispatch("webrtc-session", "webrtcSession", "closeSession", "mutation", input),
3882
3893
  hasAdaptiveBitrate: (input) => dispatch("webrtc-session", "webrtcSession", "hasAdaptiveBitrate", "query", input),
3883
- getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input)
3894
+ getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input),
3895
+ getSessionDebug: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionDebug", "query", input)
3884
3896
  },
3885
3897
  zoneAnalytics: {
3886
3898
  getCurrentSnapshot: (input) => dispatch("zone-analytics", "zoneAnalytics", "getCurrentSnapshot", "query", input),
@@ -4320,4 +4332,4 @@ function sleepCancellable(ms, signal) {
4320
4332
  });
4321
4333
  }
4322
4334
  //#endregion
4323
- export { CAM_PROFILE_ORDER as $, DeviceType as A, isEvent as At, systemMethod as B, isAudioChunkFormat as Bt, customAction as C, SHARE_VIEW_KINDS as Ct, ChargingStatus as D, createDurableState as Dt, deviceCustomAction as E, normalizeAddonInitResult as Et, event as F, hydrateSchema as Ft, ReadinessRegistry as G, nodePin as H, expandCapMethods as I, resolveHydratedFieldValue as It, readinessKey as J, ReadinessTimeoutError as K, isDeviceConfigCap as L, AUDIO_CHUNK_FORMATS as Lt, DEFAULT_RUNTIME_STATE_DURABILITY as M, WELL_KNOWN_TAB_MAP as Mt, DEVICE_SETTINGS_CONTRIBUTION_METHODS as N, collectHydratedFieldEntries as Nt, DeviceFeature as O, createEvent as Ot, DEVICE_STATUS_METHOD as P, collectHydratedFieldValues as Pt, BrokerStatusSchema as Q, method as R, audioChunkBytesPerSample as Rt, DEVICE_CHILDREN_BATCH_MAX as S, DATAPLANE_SECRET_HEADER as St, describeCustomActions as T, BaseAddon as Tt, readNodePin as U, CAP_NODE_PIN_CONTEXT_KEY as V, toNodeId as W, AudioChunkFormatSchema as X, scopeKey as Y, BrokerStatsSchema as Z, createMirrorSource as _, SubscribeFramesResultSchema as _t, asJsonObject as a, DecodedFrameSchema as at, deviceOpsCapability as b, parseProfileBrokerId as bt, parseJsonArray as c, FrameHandleSchema as ct, BOOT_RECOVERY_BACKOFF_MS as d, ProfileSlotStatusSchema as dt, CamProfileSchema as et, DEVICE_SCOPED_CAPS as f, StreamSourceEntrySchema$1 as ft, createLazyTrpcSource as g, SubscribeFramesInputSchema as gt, createEventBusSliceSource as h, SubscribeAudioChunksResultSchema as ht, asJsonArray as i, DecodedAudioChunkSchema as it, adminUiCapability as j, WELL_KNOWN_TABS as jt, DeviceRole as k, emitReadiness as kt, parseJsonObject as l, ProfileRtspEntrySchema as lt, createDeviceProxy as m, SubscribeAudioChunksInputSchema as mt, sleepCancellable as n, CamStreamResolutionSchema as nt, asNumber as o, EncodedPacketSchema as ot, isDeviceScopedCap as p, StreamSourceSchema as pt, emitDownForOwnedCaps as q, asBoolean as r, CameraStreamSchema as rt, asString as s, FrameHandleFormatSchema as st, sleep as t, CamStreamKindSchema as tt, parseJsonUnknown as u, ProfileSlotSchema as ut, createSliceHandle as v, makeProfileBrokerId as vt, defineCustomActions as w, DisposerChain as wt, viewerUiCapability as x, selectAssignedProfileSlots as xt, RawStateResultSchema as y, makeSourceBrokerId as yt, resolveCapMount as z, expandAudioChunkToF32le as zt };
4335
+ export { BrokerStatusSchema as $, DeviceType as A, emitReadiness as At, systemMethod as B, expandAudioChunkToF32le as Bt, customAction as C, DATAPLANE_SECRET_HEADER as Ct, ChargingStatus as D, normalizeAddonInitResult as Dt, deviceCustomAction as E, BaseAddon as Et, event as F, collectHydratedFieldValues as Ft, ReadinessRegistry as G, nodePin as H, expandCapMethods as I, hydrateSchema as It, readinessKey as J, ReadinessTimeoutError as K, isDeviceConfigCap as L, resolveHydratedFieldValue as Lt, DEFAULT_RUNTIME_STATE_DURABILITY as M, WELL_KNOWN_TABS as Mt, DEVICE_SETTINGS_CONTRIBUTION_METHODS as N, WELL_KNOWN_TAB_MAP as Nt, DeviceFeature as O, createDurableState as Ot, DEVICE_STATUS_METHOD as P, collectHydratedFieldEntries as Pt, BrokerStatsSchema as Q, method as R, AUDIO_CHUNK_FORMATS as Rt, DEVICE_CHILDREN_BATCH_MAX as S, selectAssignedProfileSlots as St, describeCustomActions as T, DisposerChain as Tt, readNodePin as U, CAP_NODE_PIN_CONTEXT_KEY as V, isAudioChunkFormat as Vt, toNodeId as W, AudioChunkFormatSchema as X, scopeKey as Y, AudioStreamPickSchema as Z, createMirrorSource as _, SubscribeFramesInputSchema as _t, asJsonObject as a, DecodedAudioChunkSchema as at, deviceOpsCapability as b, makeSourceBrokerId as bt, parseJsonArray as c, FrameHandleFormatSchema as ct, BOOT_RECOVERY_BACKOFF_MS as d, ProfileSlotSchema as dt, CAM_PROFILE_ORDER as et, DEVICE_SCOPED_CAPS as f, ProfileSlotStatusSchema as ft, createLazyTrpcSource as g, SubscribeAudioChunksResultSchema as gt, createEventBusSliceSource as h, SubscribeAudioChunksInputSchema as ht, asJsonArray as i, CameraStreamSchema as it, adminUiCapability as j, isEvent as jt, DeviceRole as k, createEvent as kt, parseJsonObject as l, FrameHandleSchema as lt, createDeviceProxy as m, StreamSourceSchema as mt, sleepCancellable as n, CamStreamKindSchema as nt, asNumber as o, DecodedFrameSchema as ot, isDeviceScopedCap as p, StreamSourceEntrySchema$1 as pt, emitDownForOwnedCaps as q, asBoolean as r, CamStreamResolutionSchema as rt, asString as s, EncodedPacketSchema as st, sleep as t, CamProfileSchema as tt, parseJsonUnknown as u, ProfileRtspEntrySchema as ut, createSliceHandle as v, SubscribeFramesResultSchema as vt, defineCustomActions as w, SHARE_VIEW_KINDS as wt, viewerUiCapability as x, parseProfileBrokerId as xt, RawStateResultSchema as y, makeProfileBrokerId as yt, resolveCapMount as z, audioChunkBytesPerSample as zt };
@@ -1362,6 +1362,17 @@ var CAM_PROFILE_ORDER = [
1362
1362
  "mid",
1363
1363
  "low"
1364
1364
  ];
1365
+ /**
1366
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
1367
+ *
1368
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
1369
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
1370
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
1371
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
1372
+ * the operator picked. Today's one user is the audio analyser, whose rule is
1373
+ * "the lowest AVAILABLE native rung" (D537).
1374
+ */
1375
+ var AudioStreamPickSchema = zod.z.union([CamProfileSchema, zod.z.literal("auto")]);
1365
1376
  var CamStreamKindSchema = zod.z.enum([
1366
1377
  "pull-rtsp",
1367
1378
  "pull-rtmp",
@@ -3880,7 +3891,8 @@ function createDeviceProxy(api, binding, opts) {
3880
3891
  getIceCandidates: (input) => dispatch("webrtc-session", "webrtcSession", "getIceCandidates", "query", input),
3881
3892
  closeSession: (input) => dispatch("webrtc-session", "webrtcSession", "closeSession", "mutation", input),
3882
3893
  hasAdaptiveBitrate: (input) => dispatch("webrtc-session", "webrtcSession", "hasAdaptiveBitrate", "query", input),
3883
- getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input)
3894
+ getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input),
3895
+ getSessionDebug: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionDebug", "query", input)
3884
3896
  },
3885
3897
  zoneAnalytics: {
3886
3898
  getCurrentSnapshot: (input) => dispatch("zone-analytics", "zoneAnalytics", "getCurrentSnapshot", "query", input),
@@ -4332,6 +4344,12 @@ Object.defineProperty(exports, "AudioChunkFormatSchema", {
4332
4344
  return AudioChunkFormatSchema;
4333
4345
  }
4334
4346
  });
4347
+ Object.defineProperty(exports, "AudioStreamPickSchema", {
4348
+ enumerable: true,
4349
+ get: function() {
4350
+ return AudioStreamPickSchema;
4351
+ }
4352
+ });
4335
4353
  Object.defineProperty(exports, "BOOT_RECOVERY_BACKOFF_MS", {
4336
4354
  enumerable: true,
4337
4355
  get: function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.224",
3
+ "version": "1.2.226",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",