@camstack/addon-pipeline 1.2.267 → 1.2.269

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-CKNqjjRy.js → default-detection-model-BfMsW_XK.js} +1 -1
  4. package/dist/{default-detection-model--x95ODoz.mjs → default-detection-model-pXamampo.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-C7CvyImc.js → dist-CkNVb7Zh.js} +187 -11
  8. package/dist/{dist-Dm1V0nJX.mjs → dist-Cqf-U6EN.mjs} +187 -11
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CjN6Vze9.js → node-CqC8R84-.js} +116 -2
  12. package/dist/{node-B5haKIVa.mjs → node-jLmcbaar.mjs} +111 -3
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-BMCVwJcT.mjs → process-memory-B1YHg1_g.mjs} +1 -1
  16. package/dist/{process-memory-CaFOThD9.js → process-memory-D7LlWXty.js} +1 -1
  17. package/dist/recorder/index.js +2 -2
  18. package/dist/recorder/index.mjs +2 -2
  19. package/dist/{segment-demux-js-BaWzAeJu.mjs → segment-demux-js-B2TLcR_g.mjs} +1 -1
  20. package/dist/{segment-demux-js-bfaGjVqq.js → segment-demux-js-Bu-_Q7Um.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DEMQXM_a.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbUvu4Jn.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DJVznXRG.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CeR_IxxU.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-C9moW6r3.mjs → hostInit-9ZN1ghaZ.mjs} +2 -2
  28. package/dist/stream-broker/index.js +404 -71
  29. package/dist/stream-broker/index.mjs +404 -71
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/python/inference_pool.py +84 -2
  33. package/python/test_inference_pool_ov_threads.py +112 -0
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B5hze8qG.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DTzfOQZq.mjs +0 -26
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-C7CvyImc.js");
7
- const require_node = require("../node-CjN6Vze9.js");
6
+ const require_dist = require("../dist-CkNVb7Zh.js");
7
+ const require_node = require("../node-CqC8R84-.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-bfaGjVqq.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-Bu-_Q7Um.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -495,6 +495,8 @@ function logBannerArgs(level) {
495
495
  }
496
496
  //#endregion
497
497
  //#region src/shared/ffmpeg-decode-args.ts
498
+ /** Default DRM render node used for the VA-API / QSV hardware decode path. */
499
+ var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
498
500
  /**
499
501
  * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
500
502
  *
@@ -5281,6 +5283,26 @@ function decideAudioPlaneAction(observation, state, nowMs) {
5281
5283
  * "Device creation failed: -12 … Cannot allocate memory". Every egress child
5282
5284
  * therefore spends ~3 s dying before its own software retry rescues it.
5283
5285
  *
5286
+ * ## CORRECTION (2026-09-18): that `-12` was not a resource condition
5287
+ *
5288
+ * The two runners were not competing for a device — they were running
5289
+ * DIFFERENT BINARIES. The decoder addon reached a capable ffmpeg; the broker
5290
+ * reached the downloaded static build, which has no VAAPI compiled in at all
5291
+ * (`hwaccels: vdpau`). `-12` is what fftools prints when
5292
+ * `av_hwdevice_ctx_alloc` returns NULL because the type is not in the build:
5293
+ * the NAME parses, so the request looks valid and the device simply cannot
5294
+ * exist. It READS as a shortage and is a property of the binary (D536).
5295
+ *
5296
+ * That is why it "recurred forever on a hub where it has never once
5297
+ * succeeded": there was nothing to recover. The fix is upstream of this file —
5298
+ * `TranscodePipelineManager` now asks the binary (`ffmpeg -hwaccels`) and
5299
+ * refuses a backend it does not carry, so this quarantine never sees that
5300
+ * class of failure again.
5301
+ *
5302
+ * Everything below still stands for what remains: a backend the binary HAS and
5303
+ * the device still refuses. That one is a genuine runtime condition and can
5304
+ * clear, which is exactly why this is a quarantine and not a tombstone.
5305
+ *
5284
5306
  * That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
5285
5307
  * poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
5286
5308
  * for the verdict: it exists to avoid a per-session cross-process
@@ -5292,10 +5314,11 @@ function decideAudioPlaneAction(observation, state, nowMs) {
5292
5314
  *
5293
5315
  * ## Why this is a quarantine and not a tombstone
5294
5316
  *
5295
- * A permanent verdict is wrong for the failure that is actually observed:
5296
- * ENOMEM on device creation is a RESOURCE condition, and a resource condition
5297
- * can clear (the operator gives the container more of the device, another
5298
- * process releases it). A process that decided "never again" at 03:00 would
5317
+ * A permanent verdict is wrong for the failure this file is now left with:
5318
+ * a device that exists in the build and still refuses is a RESOURCE condition,
5319
+ * and a resource condition can clear (the operator gives the container more of
5320
+ * the device, another process releases it). The build-property case — the one
5321
+ * that produced the measurements above — no longer reaches here at all. A process that decided "never again" at 03:00 would
5299
5322
  * still be decoding in software at noon with a working accelerator, and nothing
5300
5323
  * would ever say so.
5301
5324
  *
@@ -5433,36 +5456,54 @@ function classifyDecodeHwAccel(setting) {
5433
5456
  function toAudioSidecarCodec(audio) {
5434
5457
  return audio === "aac" || audio === "opus" || audio === "pcmu" ? audio : null;
5435
5458
  }
5436
- var KNOWN_ENCODERS = new Set([
5437
- "libx264",
5438
- "libx265",
5439
- "h264_videotoolbox",
5440
- "hevc_videotoolbox",
5441
- "h264_vaapi",
5442
- "hevc_vaapi",
5443
- "h264_qsv",
5444
- "hevc_qsv",
5445
- "h264_nvenc",
5446
- "hevc_nvenc",
5447
- "h264_amf",
5448
- "hevc_amf"
5449
- ]);
5450
- /** Type guard — an encoder id the shared builder knows how to emit. */
5451
- function isKnownEncoder(value) {
5452
- return KNOWN_ENCODERS.has(value);
5453
- }
5454
5459
  /**
5455
- * Pick the video encoder, honouring the `hwAccel` preference. `'none'`/`'copy'`
5456
- * force the software ENCODER too, so a software-decode preference yields a
5457
- * fully software — and therefore maximally compatible — transcode. An encoder
5458
- * the shared builder does not know falls back to software rather than being
5459
- * passed through unchecked.
5460
- */
5461
- function pickVideoEncoder(target, encoders, hwAccel) {
5462
- const software = target === "H264" ? "libx264" : "libx265";
5463
- if (hwAccel === "none" || hwAccel === "copy" || !encoders) return software;
5464
- const picked = target === "H264" ? encoders.defaultH264 : encoders.defaultH265;
5465
- return isKnownEncoder(picked) ? picked : software;
5460
+ * Choose the video encoder — separately from the DECODE preference, which is
5461
+ * the whole point.
5462
+ *
5463
+ * {@link pickVideoEncoder} folded the two together: `hwAccel: 'copy'` meant
5464
+ * "software everything". But `copy` is this fleet's decode DEFAULT for a real
5465
+ * reason — hardware decode hard-fails on a fragile HEVC source — and it was
5466
+ * quietly deciding the encoder as well. On a hub with a working Intel iGPU
5467
+ * that put every egress and derived transcode on `libx264`, spending cores
5468
+ * this box does not have, while the GPU idled. A software decode can feed a
5469
+ * hardware encoder (`hwupload` is exactly that), and the encode is where the
5470
+ * CPU goes.
5471
+ *
5472
+ * Three things can send it back to the CPU, and each is NAMED so the caller
5473
+ * can say which — a silent software transcode is the thing this exists to end:
5474
+ *
5475
+ * - `operator-forced-software` — someone asked for `none`. Honoured.
5476
+ * - `no-hardware-on-node` — the hwaccel resolver offers nothing.
5477
+ * - `binary-lacks-encoder` — the node has the hardware and THIS ffmpeg cannot
5478
+ * use it. That is a build property, not a resource condition (D536).
5479
+ *
5480
+ * An UNKNOWN encoder list (`null`, a probe that failed) is not a "no": we
5481
+ * could not ask, so the hardware encoder is still attempted and ffmpeg's own
5482
+ * failure is the answer. Reading "unknown" as "unsupported" would keep a
5483
+ * capable node in software forever on one bad probe.
5484
+ */
5485
+ function chooseVideoEncoder(input) {
5486
+ const software = input.target === "H264" ? "libx264" : "libx265";
5487
+ const fallback = (reason) => ({
5488
+ encoder: software,
5489
+ hwEncodeDevice: null,
5490
+ softwareReason: reason
5491
+ });
5492
+ if (input.hwAccelSetting === "none") return fallback("operator-forced-software");
5493
+ const backend = input.backend === null ? null : input.backend.toLowerCase();
5494
+ if (backend === null) return fallback("no-hardware-on-node");
5495
+ const ids = ENCODER_IDS_BY_BACKEND[backend];
5496
+ if (!ids) return fallback("no-hardware-on-node");
5497
+ const encoder = input.target === "H264" ? ids.h264 : ids.h265;
5498
+ if (input.binaryEncoders !== null && !input.binaryEncoders.has(encoder)) return fallback("binary-lacks-encoder");
5499
+ return {
5500
+ encoder,
5501
+ hwEncodeDevice: backend === "vaapi" ? {
5502
+ kind: "vaapi",
5503
+ renderNode: input.renderNode
5504
+ } : null,
5505
+ softwareReason: null
5506
+ };
5466
5507
  }
5467
5508
  /**
5468
5509
  * The h264/hevc hardware ENCODER ids for a decode-hwaccel backend name — a
@@ -5495,24 +5536,6 @@ var ENCODER_IDS_BY_BACKEND = {
5495
5536
  }
5496
5537
  };
5497
5538
  /**
5498
- * Build the {@link HardwareEncoders} default pair for THIS node's decode
5499
- * backend, resolved LOCALLY from `ctx.kernel.hwaccel` — WITHOUT probing
5500
- * `ffmpeg -encoders` through the `platform-probe` singleton, whose per-node
5501
- * `nodePin` does not survive a forked agent child's link chain (it answers with
5502
- * the HUB's encoders). Returns `null` for software / an unmapped backend.
5503
- */
5504
- function localHardwareEncoders(backend) {
5505
- if (backend === null) return null;
5506
- const ids = ENCODER_IDS_BY_BACKEND[backend.toLowerCase()];
5507
- if (!ids) return null;
5508
- return {
5509
- encoders: [],
5510
- defaultH264: ids.h264,
5511
- defaultH265: ids.h265,
5512
- probedAt: Date.now()
5513
- };
5514
- }
5515
- /**
5516
5539
  * Translate the broker's transcode request into the shared
5517
5540
  * {@link FfmpegInvocation}. Emits no arguments itself — `buildFfmpegArgs` from
5518
5541
  * `@camstack/types` does that, and it is the only thing that may.
@@ -5526,6 +5549,7 @@ function brokerTranscodeInvocation(inv) {
5526
5549
  return {
5527
5550
  logLevel: "warning",
5528
5551
  decodeHwAccel: inv.decodeHwAccel,
5552
+ ...inv.hwEncodeDevice ? { hwEncodeDevice: inv.hwEncodeDevice } : {},
5529
5553
  input: {
5530
5554
  url: inv.sourceUrl,
5531
5555
  rtspTransport: "tcp"
@@ -16280,6 +16304,9 @@ var TranscodePipelineManager = class {
16280
16304
  /** In-flight transcode starts, keyed by pipeline key — single-flight the
16281
16305
  * async egress start so concurrent acquires share one egress. */
16282
16306
  pending = /* @__PURE__ */ new Map();
16307
+ /** What the ffmpeg at a path can do, asked once. `null` = could not ask. */
16308
+ binaryCapabilities = /* @__PURE__ */ new Map();
16309
+ probeFfmpeg;
16283
16310
  logger;
16284
16311
  hwaccel;
16285
16312
  cameraStreamLookup;
@@ -16299,6 +16326,7 @@ var TranscodePipelineManager = class {
16299
16326
  this.resolveProfileSource = opts.resolveProfileSource ?? null;
16300
16327
  this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
16301
16328
  this.ffmpegConfig = opts.ffmpegConfig ?? (() => DEFAULT_FFMPEG_CONFIG);
16329
+ this.probeFfmpeg = opts.probeFfmpeg ?? require_node.probeFfmpegBinary;
16302
16330
  this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
16303
16331
  this.spawnFn = opts.spawnFn ?? node_child_process.spawn;
16304
16332
  this.acquireChannel = opts.acquireChannel ?? null;
@@ -16371,8 +16399,33 @@ var TranscodePipelineManager = class {
16371
16399
  if (!this.acquireChannel) throw new Error("getStreamWithCodec: transcode egress not wired (acquireChannel missing)");
16372
16400
  const cfg = this.ffmpegConfig();
16373
16401
  const effectiveHwAccel = this.effectiveHwAccelSetting(opts.deviceId, opts.camStreamId, cfg.hwAccel);
16374
- const encoders = await this.resolveHardwareEncoders();
16375
- const encoder = pickVideoEncoder(opts.targetCodec, encoders, effectiveHwAccel);
16402
+ const choice = chooseVideoEncoder({
16403
+ target: opts.targetCodec,
16404
+ backend: await this.resolveHardwareBackend(),
16405
+ binaryEncoders: await this.resolveBinaryEncoders(cfg.binaryPath),
16406
+ hwAccelSetting: effectiveHwAccel,
16407
+ renderNode: DEFAULT_VAAPI_RENDER_DEVICE
16408
+ });
16409
+ const encoder = choice.encoder;
16410
+ if (choice.softwareReason !== null) this.logger.error("transcode is running on the CPU — a software encode is work the hardware was supposed to do", {
16411
+ tags: { deviceId: opts.deviceId },
16412
+ meta: {
16413
+ reason: choice.softwareReason,
16414
+ encoder,
16415
+ camStreamId: opts.camStreamId,
16416
+ targetCodec: opts.targetCodec,
16417
+ hwAccelSetting: effectiveHwAccel,
16418
+ ffmpeg: cfg.binaryPath
16419
+ }
16420
+ });
16421
+ else this.logger.info("transcode encodes on the GPU", {
16422
+ tags: { deviceId: opts.deviceId },
16423
+ meta: {
16424
+ encoder,
16425
+ camStreamId: opts.camStreamId,
16426
+ device: choice.hwEncodeDevice?.renderNode
16427
+ }
16428
+ });
16376
16429
  const decodeHwAccel = await this.resolveDecodeHwAccel(effectiveHwAccel);
16377
16430
  const sourceUrl = this.resolveSourceUrl(opts.deviceId, opts.camStreamId);
16378
16431
  const brokerId = `transcode:${opts.deviceId}:${(0, node_crypto.randomUUID)().slice(0, 8)}`;
@@ -16389,6 +16442,7 @@ var TranscodePipelineManager = class {
16389
16442
  sourceUrl,
16390
16443
  targetCodec: opts.targetCodec,
16391
16444
  encoder,
16445
+ hwEncodeDevice: choice.hwEncodeDevice,
16392
16446
  decodeHwAccel,
16393
16447
  scale: opts.scale,
16394
16448
  audio: opts.audio,
@@ -16519,16 +16573,59 @@ var TranscodePipelineManager = class {
16519
16573
  * ranked above qsv (qsv is broken on the Intel stack) so an Intel node
16520
16574
  * transcodes with `h264_vaapi`.
16521
16575
  */
16522
- async resolveHardwareEncoders() {
16576
+ /**
16577
+ * The node's hardware backend name (`vaapi`, `qsv`, `videotoolbox`, …), or
16578
+ * `null`. Resolved LOCALLY from `ctx.kernel.hwaccel` for the reason the
16579
+ * sibling method's docblock gives: the `platform-probe` singleton answers
16580
+ * with the HUB's hardware on a forked agent child.
16581
+ */
16582
+ async resolveHardwareBackend() {
16523
16583
  if (!this.hwaccel) return null;
16524
16584
  try {
16525
- return localHardwareEncoders(rankDecodeHwAccels((await this.hwaccel.resolve(null)).preferred, [])[0] ?? null);
16585
+ return rankDecodeHwAccels((await this.hwaccel.resolve(null)).preferred, [])[0] ?? null;
16526
16586
  } catch (err) {
16527
- this.logger.warn("local hardware-encoder resolve failed — software encode fallback", { meta: { error: require_dist.errMsg(err) } });
16587
+ this.logger.warn("local hardware resolve failed — the encoder falls back to software", { meta: { error: require_dist.errMsg(err) } });
16528
16588
  return null;
16529
16589
  }
16530
16590
  }
16531
16591
  /**
16592
+ * What THIS ffmpeg can encode with, asked of the binary once and latched.
16593
+ *
16594
+ * `null` means we could not ask, which is not the same as "it cannot" — see
16595
+ * {@link chooseVideoEncoder}. Keyed by binary PATH, so an operator who
16596
+ * repoints `binaryPath` gets a fresh answer without a restart.
16597
+ */
16598
+ async resolveBinaryEncoders(binaryPath) {
16599
+ const caps = await this.resolveBinaryCapabilities(binaryPath);
16600
+ return caps === null || caps.encoders.size === 0 ? null : caps.encoders;
16601
+ }
16602
+ /**
16603
+ * What the ffmpeg at this path reports about itself, asked once and latched
16604
+ * per PATH — so an operator who repoints `binaryPath` gets a fresh answer
16605
+ * without a restart.
16606
+ *
16607
+ * A probe that answers nothing is `null`: "we could not ask" must never be
16608
+ * read as "it cannot", or one bad probe pins a capable node in software.
16609
+ */
16610
+ async resolveBinaryCapabilities(binaryPath) {
16611
+ const cached = this.binaryCapabilities.get(binaryPath);
16612
+ if (cached !== void 0) return await cached;
16613
+ const probing = this.probeFfmpeg(binaryPath).then((caps) => {
16614
+ if (caps.encoders.size === 0 && caps.hwaccels.size === 0) {
16615
+ this.logger.warn("ffmpeg answered nothing about itself — hardware is attempted anyway, and its own failure is the answer", { meta: { ffmpeg: binaryPath } });
16616
+ return null;
16617
+ }
16618
+ this.logger.info("ffmpeg capabilities probed for transcoding", { meta: {
16619
+ ffmpeg: binaryPath,
16620
+ version: caps.version,
16621
+ hwaccels: [...caps.hwaccels].join(",") || "none"
16622
+ } });
16623
+ return caps;
16624
+ });
16625
+ this.binaryCapabilities.set(binaryPath, probing);
16626
+ return await probing;
16627
+ }
16628
+ /**
16532
16629
  * Resolve the effective decode `hwaccel` setting for a (deviceId, camStreamId)
16533
16630
  * source. Precedence: per-source-stream override > global `ffmpeg.hwAccel`.
16534
16631
  * The global default itself is `'copy'` (software) out of the box.
@@ -16562,7 +16659,18 @@ var TranscodePipelineManager = class {
16562
16659
  async resolveSpecificDecodeHwAccel(prefer) {
16563
16660
  if (!this.hwaccel) return null;
16564
16661
  try {
16565
- return rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16662
+ const backend = rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16663
+ if (backend === null) return null;
16664
+ const caps = await this.resolveBinaryCapabilities(this.ffmpegConfig().binaryPath);
16665
+ if (caps !== null && !caps.hwaccels.has(backend)) {
16666
+ this.logger.warn("this ffmpeg has no such decode backend compiled in — decoding in software instead of dying on a device that cannot exist", { meta: {
16667
+ backend,
16668
+ ffmpeg: this.ffmpegConfig().binaryPath,
16669
+ hwaccels: [...caps.hwaccels].join(",") || "none"
16670
+ } });
16671
+ return null;
16672
+ }
16673
+ return backend;
16566
16674
  } catch (err) {
16567
16675
  this.logger.warn("local decode hw-accel resolve failed — software decode fallback", { meta: { error: require_dist.errMsg(err) } });
16568
16676
  return null;
@@ -18128,6 +18236,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18128
18236
  if (!parsed) return null;
18129
18237
  return findProfileForStream(this.assignments.get(parsed.deviceId)?.map ?? {}, parsed.camStreamId);
18130
18238
  }, this.remoteBrokerTiers));
18239
+ server.setStreamOriginResolver((brokerId) => {
18240
+ if (this.remoteBrokerAdapters.has(brokerId)) return {
18241
+ ingestNodeId: this.cachedIngestOwnerNodeId,
18242
+ ingestIsRemote: true
18243
+ };
18244
+ if (this.localNodeId === null) return {
18245
+ ingestNodeId: null,
18246
+ ingestIsRemote: null
18247
+ };
18248
+ return {
18249
+ ingestNodeId: this.localNodeId,
18250
+ ingestIsRemote: false
18251
+ };
18252
+ });
18131
18253
  server.setEgressTranscodeProvider({
18132
18254
  acquire: (request) => this.acquireEgressTranscode(request),
18133
18255
  release: (input) => this.releaseEgressTranscode(input)
@@ -23442,6 +23564,18 @@ var AdaptiveSession = class AdaptiveSession {
23442
23564
  transcodeToBaseline: this.transcodeToBaseline
23443
23565
  });
23444
23566
  }
23567
+ /**
23568
+ * The codec off the SOURCE. Read-only, for the stream-debug overlay: an
23569
+ * operator staring at a frozen picture needs to know whether this hub is
23570
+ * re-encoding for them, and `needsTranscode` alone does not say WHAT from.
23571
+ */
23572
+ get debugSourceCodec() {
23573
+ return this.sourceCodec;
23574
+ }
23575
+ /** The codec this client's SDP answer settled on. See `debugSourceCodec`. */
23576
+ get debugNegotiatedCodec() {
23577
+ return this.negotiatedCodec;
23578
+ }
23445
23579
  _firstKeyFrame;
23446
23580
  /**
23447
23581
  * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
@@ -26246,6 +26380,85 @@ var AdaptiveController = class {
26246
26380
  this.healthySince = now;
26247
26381
  }
26248
26382
  };
26383
+ //#endregion
26384
+ //#region src/stream-broker/webrtc/session-debug.ts
26385
+ /** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
26386
+ function camStreamIdOf(brokerId) {
26387
+ const slash = brokerId.indexOf("/");
26388
+ if (slash <= 0) return null;
26389
+ const tail = brokerId.slice(slash + 1);
26390
+ return tail.length > 0 ? tail : null;
26391
+ }
26392
+ /**
26393
+ * Native (the camera's own encode) vs derived (a rung this hub BUILT).
26394
+ * `unknown` when the brokerId does not parse — a guess here would tell an
26395
+ * operator the hub is transcoding when it is not, or the reverse.
26396
+ */
26397
+ function sourceKindOf(camStreamId) {
26398
+ if (camStreamId === null) return "unknown";
26399
+ return camStreamId.startsWith("derived:") ? "derived" : "native";
26400
+ }
26401
+ var DEAD = {
26402
+ alive: false,
26403
+ brokerId: null,
26404
+ camStreamId: null,
26405
+ sourceKind: "unknown",
26406
+ adaptive: false,
26407
+ servedTier: null,
26408
+ ingestNodeId: null,
26409
+ ingestIsRemote: null,
26410
+ sourceCodec: null,
26411
+ negotiatedCodec: null,
26412
+ egressLeg: "unknown",
26413
+ brokerStatus: null,
26414
+ brokerCodec: null,
26415
+ brokerBitrateKbps: null,
26416
+ brokerInputFps: null,
26417
+ brokerUptimeMs: null,
26418
+ rtpPacketsSent: null,
26419
+ trackMode: null,
26420
+ lastDecision: null,
26421
+ tierFailures: [],
26422
+ upgradeCount: null
26423
+ };
26424
+ function projectSessionDebug(input) {
26425
+ const { session } = input;
26426
+ if (session === null) return {
26427
+ ...DEAD,
26428
+ sessionId: input.sessionId,
26429
+ observedAt: input.observedAt
26430
+ };
26431
+ const camStreamId = camStreamIdOf(session.brokerId);
26432
+ const stats = input.brokerStats;
26433
+ return {
26434
+ sessionId: input.sessionId,
26435
+ alive: true,
26436
+ observedAt: input.observedAt,
26437
+ brokerId: session.brokerId,
26438
+ camStreamId,
26439
+ sourceKind: sourceKindOf(camStreamId),
26440
+ adaptive: session.adaptive,
26441
+ servedTier: input.servedTier,
26442
+ ingestNodeId: input.origin?.ingestNodeId ?? null,
26443
+ ingestIsRemote: input.origin?.ingestIsRemote ?? null,
26444
+ sourceCodec: session.sourceCodec,
26445
+ negotiatedCodec: session.negotiatedCodec,
26446
+ egressLeg: session.needsTranscode ? "transcode" : "copy",
26447
+ brokerStatus: stats?.status ?? null,
26448
+ brokerCodec: stats?.codec !== void 0 && stats.codec.length > 0 ? stats.codec : null,
26449
+ brokerBitrateKbps: stats?.bitrateKbps ?? null,
26450
+ brokerInputFps: stats?.inputFps ?? null,
26451
+ brokerUptimeMs: stats?.uptimeMs ?? null,
26452
+ rtpPacketsSent: session.rtpPacketsSent,
26453
+ trackMode: session.trackMode,
26454
+ lastDecision: input.ladder?.lastDecision ?? null,
26455
+ tierFailures: (input.ladder?.failures ?? []).map(([tier, failures]) => ({
26456
+ tier,
26457
+ failures
26458
+ })),
26459
+ upgradeCount: input.ladder?.upgradeCount ?? null
26460
+ };
26461
+ }
26249
26462
  var PLAYBACK_PROFILE_FALLBACK_ORDER = [
26250
26463
  "high",
26251
26464
  "mid",
@@ -30605,6 +30818,29 @@ var BrokerWebrtcServer = class {
30605
30818
  * close.
30606
30819
  */
30607
30820
  adaptiveIntent = /* @__PURE__ */ new Map();
30821
+ /**
30822
+ * sessionId → the ladder's LAST move, and the numbers it rested on.
30823
+ *
30824
+ * Kept on the SERVER and not on the `SessionEntry` for the same reason
30825
+ * `adaptiveIntent` is: a tier switch is a close-and-recreate re-offer, so
30826
+ * the entry that made the decision is gone by the time anybody asks — which
30827
+ * is exactly the moment an operator opens the debug overlay. Cleared on
30828
+ * close, in `cleanupSession`.
30829
+ *
30830
+ * A snapshot, deliberately, not a re-read of the controller: the decision
30831
+ * was made on a window that has since moved on, and a recomputed "current"
30832
+ * loss ratio answers a different question than "what made it move".
30833
+ * Absent until the ladder actually moves — a ladder that has never moved
30834
+ * reports `null`, which is NOT the same fact as a `healthy` move with a
30835
+ * 0 loss ratio (D393).
30836
+ */
30837
+ lastLadderDecision = /* @__PURE__ */ new Map();
30838
+ /**
30839
+ * brokerId → where its media is ingested. Injected by the manager, which
30840
+ * owns the ownership map; unset (or a null answer) means UNRESOLVED, and
30841
+ * the overlay says so rather than naming the local node.
30842
+ */
30843
+ streamOriginResolver = null;
30608
30844
  stopped = false;
30609
30845
  constructor(options) {
30610
30846
  this.logger = options.logger;
@@ -31555,6 +31791,7 @@ var BrokerWebrtcServer = class {
31555
31791
  closes.push(entry.session.close().catch(() => {}));
31556
31792
  }
31557
31793
  await Promise.all(closes);
31794
+ this.lastLadderDecision.clear();
31558
31795
  this.brokers.clear();
31559
31796
  this.logger.info("WebRTC server stopped");
31560
31797
  }
@@ -31570,6 +31807,92 @@ var BrokerWebrtcServer = class {
31570
31807
  alive: entry !== void 0
31571
31808
  };
31572
31809
  }
31810
+ /** Injected by the manager: brokerId → where its media is ingested. */
31811
+ setStreamOriginResolver(resolver) {
31812
+ this.streamOriginResolver = resolver;
31813
+ }
31814
+ /**
31815
+ * The SERVER half of the stream-debug overlay for one session — the rung
31816
+ * `auto` actually resolved to, why the ladder last moved, where the media
31817
+ * is ingested, and whether the egress leg copies or re-encodes.
31818
+ *
31819
+ * Read-only and allocation-light: polled at ~1 Hz by ONE overlay while it
31820
+ * is open. Shaping lives in the pure `projectSessionDebug`; this method
31821
+ * only gathers.
31822
+ */
31823
+ getSessionDebug(sessionId, now) {
31824
+ const entry = this.sessions.get(sessionId);
31825
+ if (entry === void 0) return projectSessionDebug({
31826
+ sessionId,
31827
+ observedAt: now,
31828
+ session: null,
31829
+ servedTier: null,
31830
+ origin: null,
31831
+ brokerStats: null,
31832
+ ladder: null
31833
+ });
31834
+ const controller = entry.adaptiveController;
31835
+ const decision = this.lastLadderDecision.get(sessionId) ?? null;
31836
+ const ladder = controller !== void 0 ? {
31837
+ lastDecision: decision,
31838
+ failures: [...controller.failureCounts],
31839
+ upgradeCount: controller.successfulUpgradeCount
31840
+ } : decision !== null ? {
31841
+ lastDecision: decision,
31842
+ failures: [],
31843
+ upgradeCount: 0
31844
+ } : null;
31845
+ let brokerStats = null;
31846
+ try {
31847
+ brokerStats = this.brokers.get(entry.brokerId)?.getStats() ?? null;
31848
+ } catch {
31849
+ brokerStats = null;
31850
+ }
31851
+ return projectSessionDebug({
31852
+ sessionId,
31853
+ observedAt: now,
31854
+ session: {
31855
+ brokerId: entry.brokerId,
31856
+ adaptive: controller !== void 0,
31857
+ sourceCodec: entry.session.debugSourceCodec,
31858
+ negotiatedCodec: entry.session.debugNegotiatedCodec,
31859
+ needsTranscode: entry.session.needsTranscode,
31860
+ rtpPacketsSent: entry.session.rtpPacketsSentTotal,
31861
+ trackMode: entry.timeline?.currentMode ?? "live"
31862
+ },
31863
+ servedTier: this.profileTierResolver?.(entry.brokerId) ?? null,
31864
+ origin: this.streamOriginResolver?.(entry.brokerId) ?? null,
31865
+ brokerStats,
31866
+ ladder
31867
+ });
31868
+ }
31869
+ /**
31870
+ * Bound the ladder-decision memory. Entries survive a session teardown on
31871
+ * purpose (the re-offer keeps the id), so nothing else would ever evict the
31872
+ * one left behind by a client that disconnected mid-switch. Insertion order
31873
+ * is eviction order.
31874
+ */
31875
+ pruneLadderDecisions() {
31876
+ const MAX_REMEMBERED_DECISIONS = 64;
31877
+ while (this.lastLadderDecision.size > MAX_REMEMBERED_DECISIONS) {
31878
+ const oldest = this.lastLadderDecision.keys().next();
31879
+ if (oldest.done === true) break;
31880
+ this.lastLadderDecision.delete(oldest.value);
31881
+ }
31882
+ }
31883
+ /** Snapshot the ladder's move so the overlay can name it later. */
31884
+ recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
31885
+ this.lastLadderDecision.set(sessionId, {
31886
+ cause: decision.cause,
31887
+ tier,
31888
+ forcedTranscodeDown,
31889
+ lossRatio: decision.lossRatio,
31890
+ clientKbps: decision.stall.clientKbps,
31891
+ stalledForMs: decision.stall.stalledForMs,
31892
+ serverSending: decision.stall.serverSending,
31893
+ at: Date.now()
31894
+ });
31895
+ }
31573
31896
  /**
31574
31897
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
31575
31898
  * The controller is fed loss from two sources — RTCP Receiver Reports
@@ -31594,9 +31917,11 @@ var BrokerWebrtcServer = class {
31594
31917
  priorUpgradeCount,
31595
31918
  config: ADAPTIVE_CONFIG,
31596
31919
  onSwitchTier: (tier, decision) => {
31920
+ this.recordLadderDecision(sessionId, decision, tier, false);
31597
31921
  this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
31598
31922
  },
31599
31923
  onReduceMtu: (decision) => {
31924
+ this.recordLadderDecision(sessionId, decision, null, false);
31600
31925
  entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU);
31601
31926
  this.logger.info("adaptive: reducing repacketizer MTU", {
31602
31927
  tags: { deviceId },
@@ -31613,6 +31938,7 @@ var BrokerWebrtcServer = class {
31613
31938
  });
31614
31939
  },
31615
31940
  onTranscodeDown: (decision) => {
31941
+ this.recordLadderDecision(sessionId, decision, "low", true);
31616
31942
  this.switchSessionTier(entry, deviceId, "low", sessionId, true, decision);
31617
31943
  }
31618
31944
  });
@@ -31718,6 +32044,7 @@ var BrokerWebrtcServer = class {
31718
32044
  }
31719
32045
  if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
31720
32046
  this.adaptiveIntent.delete(sessionId);
32047
+ this.pruneLadderDecisions();
31721
32048
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
31722
32049
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
31723
32050
  entry.attributionTagged = true;
@@ -32365,6 +32692,16 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
32365
32692
  async getSessionState(input) {
32366
32693
  return this.webrtcServer.getSessionRenegotiationState(input.sessionId);
32367
32694
  }
32695
+ /**
32696
+ * The SERVER half of the stream-debug overlay. Thin facade — the gathering
32697
+ * lives in the WebRTC server and the shaping in the pure
32698
+ * `projectSessionDebug`. `deviceId` is not used to look the session up
32699
+ * (session ids are already unique); it is part of the device-scoped cap's
32700
+ * signature and identifies the camera for the route.
32701
+ */
32702
+ async getSessionDebug(input) {
32703
+ return this.webrtcServer.getSessionDebug(input.sessionId, Date.now());
32704
+ }
32368
32705
  };
32369
32706
  //#endregion
32370
32707
  //#region src/stream-broker/event-loop-stall-monitor.ts
@@ -32539,20 +32876,16 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
32539
32876
  }
32540
32877
  /**
32541
32878
  * Resolve the ffmpeg binary for THIS node: a per-node `binaryPath` override
32542
- * (`binaryPath@<node>`) → else `ensureFfmpeg()` which provisions a guaranteed
32543
- * portable static binary locally (hub / agent / Mac). Replaces the retired
32544
- * global `ffmpeg` system section (node-local/empty on agents → a cross-node
32545
- * footgun). Falls back to PATH `ffmpeg` only if the provision genuinely fails.
32879
+ * (`binaryPath@<node>`, the operator's voluntary choice) → else
32880
+ * `ensureFfmpeg()`, which provisions a binary WE provide (the image's, or the
32881
+ * static build it downloads). Replaces the retired global `ffmpeg` system
32882
+ * section (node-local/empty on agents → a cross-node footgun). There is no
32883
+ * PATH fallback: the host's ffmpeg is never ours.
32546
32884
  */
32547
32885
  async resolveFfmpegBinaryPath() {
32548
32886
  const override = this.config.binaryPath;
32549
32887
  if (typeof override === "string" && override.trim().length > 0) return override;
32550
- try {
32551
- return await this.ctx.deps.ensureFfmpeg();
32552
- } catch (err) {
32553
- this.ctx.logger.error("stream-broker: ensureFfmpeg() failed to provision ffmpeg — falling back to PATH \"ffmpeg\"", { meta: { error: require_dist.errMsg(err) } });
32554
- return "ffmpeg";
32555
- }
32888
+ return await this.ctx.deps.ensureFfmpeg();
32556
32889
  }
32557
32890
  /**
32558
32891
  * Push THIS node's ffmpeg config into the broker — all node-local now, NOT the
@@ -32580,7 +32913,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
32580
32913
  hwAccel
32581
32914
  } });
32582
32915
  } catch (err) {
32583
- this.ctx.logger.warn("Failed to load ffmpeg config — using defaults", { meta: { error: require_dist.errMsg(err) } });
32916
+ this.ctx.logger.error("stream-broker: could not resolve an ffmpeg binary — no transcode on this node can be trusted until this is fixed", { meta: { error: require_dist.errMsg(err) } });
32584
32917
  }
32585
32918
  }
32586
32919
  async onInitialize() {