@camstack/addon-pipeline 1.2.268 → 1.2.270

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-6gUUOaeo.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZlxPoTV.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-DBu-k8Ps.mjs → hostInit-DFerKhvZ.mjs} +2 -2
  28. package/dist/stream-broker/index.js +481 -63
  29. package/dist/stream-broker/index.mjs +481 -63
  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
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-Dm1V0nJX.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-B5haKIVa.mjs";
2
+ import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-Cqf-U6EN.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-jLmcbaar.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BaWzAeJu.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-B2TLcR_g.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -491,6 +491,8 @@ function logBannerArgs(level) {
491
491
  }
492
492
  //#endregion
493
493
  //#region src/shared/ffmpeg-decode-args.ts
494
+ /** Default DRM render node used for the VA-API / QSV hardware decode path. */
495
+ var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
494
496
  /**
495
497
  * Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
496
498
  *
@@ -5277,6 +5279,26 @@ function decideAudioPlaneAction(observation, state, nowMs) {
5277
5279
  * "Device creation failed: -12 … Cannot allocate memory". Every egress child
5278
5280
  * therefore spends ~3 s dying before its own software retry rescues it.
5279
5281
  *
5282
+ * ## CORRECTION (2026-09-18): that `-12` was not a resource condition
5283
+ *
5284
+ * The two runners were not competing for a device — they were running
5285
+ * DIFFERENT BINARIES. The decoder addon reached a capable ffmpeg; the broker
5286
+ * reached the downloaded static build, which has no VAAPI compiled in at all
5287
+ * (`hwaccels: vdpau`). `-12` is what fftools prints when
5288
+ * `av_hwdevice_ctx_alloc` returns NULL because the type is not in the build:
5289
+ * the NAME parses, so the request looks valid and the device simply cannot
5290
+ * exist. It READS as a shortage and is a property of the binary (D536).
5291
+ *
5292
+ * That is why it "recurred forever on a hub where it has never once
5293
+ * succeeded": there was nothing to recover. The fix is upstream of this file —
5294
+ * `TranscodePipelineManager` now asks the binary (`ffmpeg -hwaccels`) and
5295
+ * refuses a backend it does not carry, so this quarantine never sees that
5296
+ * class of failure again.
5297
+ *
5298
+ * Everything below still stands for what remains: a backend the binary HAS and
5299
+ * the device still refuses. That one is a genuine runtime condition and can
5300
+ * clear, which is exactly why this is a quarantine and not a tombstone.
5301
+ *
5280
5302
  * That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
5281
5303
  * poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
5282
5304
  * for the verdict: it exists to avoid a per-session cross-process
@@ -5288,10 +5310,11 @@ function decideAudioPlaneAction(observation, state, nowMs) {
5288
5310
  *
5289
5311
  * ## Why this is a quarantine and not a tombstone
5290
5312
  *
5291
- * A permanent verdict is wrong for the failure that is actually observed:
5292
- * ENOMEM on device creation is a RESOURCE condition, and a resource condition
5293
- * can clear (the operator gives the container more of the device, another
5294
- * process releases it). A process that decided "never again" at 03:00 would
5313
+ * A permanent verdict is wrong for the failure this file is now left with:
5314
+ * a device that exists in the build and still refuses is a RESOURCE condition,
5315
+ * and a resource condition can clear (the operator gives the container more of
5316
+ * the device, another process releases it). The build-property case — the one
5317
+ * that produced the measurements above — no longer reaches here at all. A process that decided "never again" at 03:00 would
5295
5318
  * still be decoding in software at noon with a working accelerator, and nothing
5296
5319
  * would ever say so.
5297
5320
  *
@@ -5429,36 +5452,54 @@ function classifyDecodeHwAccel(setting) {
5429
5452
  function toAudioSidecarCodec(audio) {
5430
5453
  return audio === "aac" || audio === "opus" || audio === "pcmu" ? audio : null;
5431
5454
  }
5432
- var KNOWN_ENCODERS = new Set([
5433
- "libx264",
5434
- "libx265",
5435
- "h264_videotoolbox",
5436
- "hevc_videotoolbox",
5437
- "h264_vaapi",
5438
- "hevc_vaapi",
5439
- "h264_qsv",
5440
- "hevc_qsv",
5441
- "h264_nvenc",
5442
- "hevc_nvenc",
5443
- "h264_amf",
5444
- "hevc_amf"
5445
- ]);
5446
- /** Type guard — an encoder id the shared builder knows how to emit. */
5447
- function isKnownEncoder(value) {
5448
- return KNOWN_ENCODERS.has(value);
5449
- }
5450
5455
  /**
5451
- * Pick the video encoder, honouring the `hwAccel` preference. `'none'`/`'copy'`
5452
- * force the software ENCODER too, so a software-decode preference yields a
5453
- * fully software — and therefore maximally compatible — transcode. An encoder
5454
- * the shared builder does not know falls back to software rather than being
5455
- * passed through unchecked.
5456
- */
5457
- function pickVideoEncoder(target, encoders, hwAccel) {
5458
- const software = target === "H264" ? "libx264" : "libx265";
5459
- if (hwAccel === "none" || hwAccel === "copy" || !encoders) return software;
5460
- const picked = target === "H264" ? encoders.defaultH264 : encoders.defaultH265;
5461
- return isKnownEncoder(picked) ? picked : software;
5456
+ * Choose the video encoder — separately from the DECODE preference, which is
5457
+ * the whole point.
5458
+ *
5459
+ * {@link pickVideoEncoder} folded the two together: `hwAccel: 'copy'` meant
5460
+ * "software everything". But `copy` is this fleet's decode DEFAULT for a real
5461
+ * reason — hardware decode hard-fails on a fragile HEVC source — and it was
5462
+ * quietly deciding the encoder as well. On a hub with a working Intel iGPU
5463
+ * that put every egress and derived transcode on `libx264`, spending cores
5464
+ * this box does not have, while the GPU idled. A software decode can feed a
5465
+ * hardware encoder (`hwupload` is exactly that), and the encode is where the
5466
+ * CPU goes.
5467
+ *
5468
+ * Three things can send it back to the CPU, and each is NAMED so the caller
5469
+ * can say which — a silent software transcode is the thing this exists to end:
5470
+ *
5471
+ * - `operator-forced-software` — someone asked for `none`. Honoured.
5472
+ * - `no-hardware-on-node` — the hwaccel resolver offers nothing.
5473
+ * - `binary-lacks-encoder` — the node has the hardware and THIS ffmpeg cannot
5474
+ * use it. That is a build property, not a resource condition (D536).
5475
+ *
5476
+ * An UNKNOWN encoder list (`null`, a probe that failed) is not a "no": we
5477
+ * could not ask, so the hardware encoder is still attempted and ffmpeg's own
5478
+ * failure is the answer. Reading "unknown" as "unsupported" would keep a
5479
+ * capable node in software forever on one bad probe.
5480
+ */
5481
+ function chooseVideoEncoder(input) {
5482
+ const software = input.target === "H264" ? "libx264" : "libx265";
5483
+ const fallback = (reason) => ({
5484
+ encoder: software,
5485
+ hwEncodeDevice: null,
5486
+ softwareReason: reason
5487
+ });
5488
+ if (input.hwAccelSetting === "none") return fallback("operator-forced-software");
5489
+ const backend = input.backend === null ? null : input.backend.toLowerCase();
5490
+ if (backend === null) return fallback("no-hardware-on-node");
5491
+ const ids = ENCODER_IDS_BY_BACKEND[backend];
5492
+ if (!ids) return fallback("no-hardware-on-node");
5493
+ const encoder = input.target === "H264" ? ids.h264 : ids.h265;
5494
+ if (input.binaryEncoders !== null && !input.binaryEncoders.has(encoder)) return fallback("binary-lacks-encoder");
5495
+ return {
5496
+ encoder,
5497
+ hwEncodeDevice: backend === "vaapi" ? {
5498
+ kind: "vaapi",
5499
+ renderNode: input.renderNode
5500
+ } : null,
5501
+ softwareReason: null
5502
+ };
5462
5503
  }
5463
5504
  /**
5464
5505
  * The h264/hevc hardware ENCODER ids for a decode-hwaccel backend name — a
@@ -5491,24 +5532,6 @@ var ENCODER_IDS_BY_BACKEND = {
5491
5532
  }
5492
5533
  };
5493
5534
  /**
5494
- * Build the {@link HardwareEncoders} default pair for THIS node's decode
5495
- * backend, resolved LOCALLY from `ctx.kernel.hwaccel` — WITHOUT probing
5496
- * `ffmpeg -encoders` through the `platform-probe` singleton, whose per-node
5497
- * `nodePin` does not survive a forked agent child's link chain (it answers with
5498
- * the HUB's encoders). Returns `null` for software / an unmapped backend.
5499
- */
5500
- function localHardwareEncoders(backend) {
5501
- if (backend === null) return null;
5502
- const ids = ENCODER_IDS_BY_BACKEND[backend.toLowerCase()];
5503
- if (!ids) return null;
5504
- return {
5505
- encoders: [],
5506
- defaultH264: ids.h264,
5507
- defaultH265: ids.h265,
5508
- probedAt: Date.now()
5509
- };
5510
- }
5511
- /**
5512
5535
  * Translate the broker's transcode request into the shared
5513
5536
  * {@link FfmpegInvocation}. Emits no arguments itself — `buildFfmpegArgs` from
5514
5537
  * `@camstack/types` does that, and it is the only thing that may.
@@ -5522,6 +5545,7 @@ function brokerTranscodeInvocation(inv) {
5522
5545
  return {
5523
5546
  logLevel: "warning",
5524
5547
  decodeHwAccel: inv.decodeHwAccel,
5548
+ ...inv.hwEncodeDevice ? { hwEncodeDevice: inv.hwEncodeDevice } : {},
5525
5549
  input: {
5526
5550
  url: inv.sourceUrl,
5527
5551
  rtspTransport: "tcp"
@@ -13060,6 +13084,15 @@ var StreamBroker = class StreamBroker {
13060
13084
  * H.264 video is 30fps → 80pps minimum on a working stream) while a
13061
13085
  * real stall is detected before Chrome's ~15s ICE-disconnect timer
13062
13086
  * has any chance to fire. */
13087
+ /** How many times a resume re-asks the registry before it stops asking. */
13088
+ static RESUME_RETRY_MAX = 5;
13089
+ /** Spacing of those asks — long enough for a catalog reconcile to land. */
13090
+ static RESUME_RETRY_DELAY_MS = 3e3;
13091
+ resumeRetryTimer;
13092
+ resumeRetryAttempts = 0;
13093
+ resumeRetryExhausted = false;
13094
+ /** One line per suspension when packets arrive with no watchdog armed. */
13095
+ suspendedActivityNoted = false;
13063
13096
  static RTP_ACTIVITY_TIMEOUT_MS = 8e3;
13064
13097
  /**
13065
13098
  * How often the single sweep timer checks {@link lastRtpActivityAt}.
@@ -13570,6 +13603,54 @@ var StreamBroker = class StreamBroker {
13570
13603
  this.scheduleReconnect();
13571
13604
  }
13572
13605
  /**
13606
+ * Ask again for a source the registry did not have yet — bounded.
13607
+ *
13608
+ * Demand exists and nothing can be dialled, so waiting for the NEXT demand
13609
+ * edge is waiting for something that may never come: the consumer is already
13610
+ * attached. The retry is what turns "nothing happened" back into either a
13611
+ * stream or a stated failure, and the bound is what stops a camera that
13612
+ * genuinely publishes nothing from spinning for the life of the process.
13613
+ */
13614
+ scheduleResumeRetry() {
13615
+ if (this.resumeRetryTimer !== void 0) return;
13616
+ if (this.resumeRetryAttempts >= StreamBroker.RESUME_RETRY_MAX) {
13617
+ if (!this.resumeRetryExhausted) {
13618
+ this.resumeRetryExhausted = true;
13619
+ this.logger?.warn("demand is waiting and this broker still has no source — giving up until something changes", { meta: {
13620
+ brokerId: this.deviceId,
13621
+ attempts: this.resumeRetryAttempts,
13622
+ ...this.numericDeviceId === null ? {} : { deviceId: this.numericDeviceId }
13623
+ } });
13624
+ }
13625
+ return;
13626
+ }
13627
+ this.resumeRetryAttempts += 1;
13628
+ this.logger?.warn("demand detected but no source could be resolved — retrying", {
13629
+ ...this.numericDeviceId === null ? {} : { tags: { deviceId: this.numericDeviceId } },
13630
+ meta: {
13631
+ brokerId: this.deviceId,
13632
+ attempt: this.resumeRetryAttempts,
13633
+ ofMax: StreamBroker.RESUME_RETRY_MAX
13634
+ }
13635
+ });
13636
+ this.resumeRetryTimer = setTimeout(() => {
13637
+ this.resumeRetryTimer = void 0;
13638
+ if (this.manualStop || this.stopping || !this._suspended) return;
13639
+ if (!this.hasLiveIntent()) return;
13640
+ this.checkDemand();
13641
+ }, StreamBroker.RESUME_RETRY_DELAY_MS);
13642
+ this.resumeRetryTimer.unref?.();
13643
+ }
13644
+ /** A resume that SUCCEEDED clears the episode, counter included. */
13645
+ cancelResumeRetry() {
13646
+ if (this.resumeRetryTimer !== void 0) {
13647
+ clearTimeout(this.resumeRetryTimer);
13648
+ this.resumeRetryTimer = void 0;
13649
+ }
13650
+ this.resumeRetryAttempts = 0;
13651
+ this.resumeRetryExhausted = false;
13652
+ }
13653
+ /**
13573
13654
  * Resolve the source to use for the next dial attempt. Prefers the
13574
13655
  * manager-supplied resolver — re-derives from the cam-stream
13575
13656
  * registry, so a re-publish that updated the URL is honoured on the
@@ -14078,6 +14159,7 @@ var StreamBroker = class StreamBroker {
14078
14159
  if (this._suspended) {
14079
14160
  const fresh = this.resolveCurrentSource();
14080
14161
  if (fresh) {
14162
+ this.cancelResumeRetry();
14081
14163
  this._suspended = false;
14082
14164
  this.logger?.info("demand detected — resuming stream");
14083
14165
  this.start(fresh).catch((err) => {
@@ -14085,7 +14167,7 @@ var StreamBroker = class StreamBroker {
14085
14167
  this._suspended = true;
14086
14168
  this._status = "idle";
14087
14169
  });
14088
- }
14170
+ } else this.scheduleResumeRetry();
14089
14171
  }
14090
14172
  } else if (!this._suspended) {
14091
14173
  if (!this.suspendTimer) this.suspendTimer = setTimeout(() => {
@@ -15785,7 +15867,20 @@ var StreamBroker = class StreamBroker {
15785
15867
  * operations on the highest-frequency path in the busiest process.
15786
15868
  */
15787
15869
  kickRtpActivityWatchdog() {
15788
- if (this.manualStop || this.stopping || this._suspended) return;
15870
+ if (this.manualStop || this.stopping || this._suspended) {
15871
+ if (this._suspended && !this.suspendedActivityNoted) {
15872
+ this.suspendedActivityNoted = true;
15873
+ this.logger?.warn("source RTP is arriving while this broker is suspended — the stall watchdog is NOT armed for it", {
15874
+ ...this.numericDeviceId === null ? {} : { tags: { deviceId: this.numericDeviceId } },
15875
+ meta: {
15876
+ brokerId: this.deviceId,
15877
+ status: this._status
15878
+ }
15879
+ });
15880
+ }
15881
+ return;
15882
+ }
15883
+ this.suspendedActivityNoted = false;
15789
15884
  this.lastRtpActivityAt = Date.now();
15790
15885
  if (this.rtpActivitySweep !== void 0) return;
15791
15886
  this.rtpActivitySweep = setInterval(() => {
@@ -15825,7 +15920,17 @@ var StreamBroker = class StreamBroker {
15825
15920
  */
15826
15921
  onRtpActivityWatchdogExpired() {
15827
15922
  this.rtpActivityWatchdogFiredCount++;
15828
- if (this.manualStop || this.stopping || this._suspended) return;
15923
+ if (this.manualStop || this.stopping || this._suspended) {
15924
+ if (this._suspended) this.logger?.warn("source RTP went silent on a suspended broker — the watchdog is disarmed and nothing will reconnect it", {
15925
+ ...this.numericDeviceId === null ? {} : { tags: { deviceId: this.numericDeviceId } },
15926
+ meta: {
15927
+ brokerId: this.deviceId,
15928
+ timeoutMs: StreamBroker.RTP_ACTIVITY_TIMEOUT_MS,
15929
+ firedCount: this.rtpActivityWatchdogFiredCount
15930
+ }
15931
+ });
15932
+ return;
15933
+ }
15829
15934
  const meta = {
15830
15935
  timeoutMs: StreamBroker.RTP_ACTIVITY_TIMEOUT_MS,
15831
15936
  ...this.sourceMetaForLog()
@@ -16276,6 +16381,9 @@ var TranscodePipelineManager = class {
16276
16381
  /** In-flight transcode starts, keyed by pipeline key — single-flight the
16277
16382
  * async egress start so concurrent acquires share one egress. */
16278
16383
  pending = /* @__PURE__ */ new Map();
16384
+ /** What the ffmpeg at a path can do, asked once. `null` = could not ask. */
16385
+ binaryCapabilities = /* @__PURE__ */ new Map();
16386
+ probeFfmpeg;
16279
16387
  logger;
16280
16388
  hwaccel;
16281
16389
  cameraStreamLookup;
@@ -16295,6 +16403,7 @@ var TranscodePipelineManager = class {
16295
16403
  this.resolveProfileSource = opts.resolveProfileSource ?? null;
16296
16404
  this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
16297
16405
  this.ffmpegConfig = opts.ffmpegConfig ?? (() => DEFAULT_FFMPEG_CONFIG);
16406
+ this.probeFfmpeg = opts.probeFfmpeg ?? probeFfmpegBinary;
16298
16407
  this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
16299
16408
  this.spawnFn = opts.spawnFn ?? spawn;
16300
16409
  this.acquireChannel = opts.acquireChannel ?? null;
@@ -16367,8 +16476,33 @@ var TranscodePipelineManager = class {
16367
16476
  if (!this.acquireChannel) throw new Error("getStreamWithCodec: transcode egress not wired (acquireChannel missing)");
16368
16477
  const cfg = this.ffmpegConfig();
16369
16478
  const effectiveHwAccel = this.effectiveHwAccelSetting(opts.deviceId, opts.camStreamId, cfg.hwAccel);
16370
- const encoders = await this.resolveHardwareEncoders();
16371
- const encoder = pickVideoEncoder(opts.targetCodec, encoders, effectiveHwAccel);
16479
+ const choice = chooseVideoEncoder({
16480
+ target: opts.targetCodec,
16481
+ backend: await this.resolveHardwareBackend(),
16482
+ binaryEncoders: await this.resolveBinaryEncoders(cfg.binaryPath),
16483
+ hwAccelSetting: effectiveHwAccel,
16484
+ renderNode: DEFAULT_VAAPI_RENDER_DEVICE
16485
+ });
16486
+ const encoder = choice.encoder;
16487
+ if (choice.softwareReason !== null) this.logger.error("transcode is running on the CPU — a software encode is work the hardware was supposed to do", {
16488
+ tags: { deviceId: opts.deviceId },
16489
+ meta: {
16490
+ reason: choice.softwareReason,
16491
+ encoder,
16492
+ camStreamId: opts.camStreamId,
16493
+ targetCodec: opts.targetCodec,
16494
+ hwAccelSetting: effectiveHwAccel,
16495
+ ffmpeg: cfg.binaryPath
16496
+ }
16497
+ });
16498
+ else this.logger.info("transcode encodes on the GPU", {
16499
+ tags: { deviceId: opts.deviceId },
16500
+ meta: {
16501
+ encoder,
16502
+ camStreamId: opts.camStreamId,
16503
+ device: choice.hwEncodeDevice?.renderNode
16504
+ }
16505
+ });
16372
16506
  const decodeHwAccel = await this.resolveDecodeHwAccel(effectiveHwAccel);
16373
16507
  const sourceUrl = this.resolveSourceUrl(opts.deviceId, opts.camStreamId);
16374
16508
  const brokerId = `transcode:${opts.deviceId}:${randomUUID().slice(0, 8)}`;
@@ -16385,6 +16519,7 @@ var TranscodePipelineManager = class {
16385
16519
  sourceUrl,
16386
16520
  targetCodec: opts.targetCodec,
16387
16521
  encoder,
16522
+ hwEncodeDevice: choice.hwEncodeDevice,
16388
16523
  decodeHwAccel,
16389
16524
  scale: opts.scale,
16390
16525
  audio: opts.audio,
@@ -16515,16 +16650,59 @@ var TranscodePipelineManager = class {
16515
16650
  * ranked above qsv (qsv is broken on the Intel stack) so an Intel node
16516
16651
  * transcodes with `h264_vaapi`.
16517
16652
  */
16518
- async resolveHardwareEncoders() {
16653
+ /**
16654
+ * The node's hardware backend name (`vaapi`, `qsv`, `videotoolbox`, …), or
16655
+ * `null`. Resolved LOCALLY from `ctx.kernel.hwaccel` for the reason the
16656
+ * sibling method's docblock gives: the `platform-probe` singleton answers
16657
+ * with the HUB's hardware on a forked agent child.
16658
+ */
16659
+ async resolveHardwareBackend() {
16519
16660
  if (!this.hwaccel) return null;
16520
16661
  try {
16521
- return localHardwareEncoders(rankDecodeHwAccels((await this.hwaccel.resolve(null)).preferred, [])[0] ?? null);
16662
+ return rankDecodeHwAccels((await this.hwaccel.resolve(null)).preferred, [])[0] ?? null;
16522
16663
  } catch (err) {
16523
- this.logger.warn("local hardware-encoder resolve failed — software encode fallback", { meta: { error: errMsg(err) } });
16664
+ this.logger.warn("local hardware resolve failed — the encoder falls back to software", { meta: { error: errMsg(err) } });
16524
16665
  return null;
16525
16666
  }
16526
16667
  }
16527
16668
  /**
16669
+ * What THIS ffmpeg can encode with, asked of the binary once and latched.
16670
+ *
16671
+ * `null` means we could not ask, which is not the same as "it cannot" — see
16672
+ * {@link chooseVideoEncoder}. Keyed by binary PATH, so an operator who
16673
+ * repoints `binaryPath` gets a fresh answer without a restart.
16674
+ */
16675
+ async resolveBinaryEncoders(binaryPath) {
16676
+ const caps = await this.resolveBinaryCapabilities(binaryPath);
16677
+ return caps === null || caps.encoders.size === 0 ? null : caps.encoders;
16678
+ }
16679
+ /**
16680
+ * What the ffmpeg at this path reports about itself, asked once and latched
16681
+ * per PATH — so an operator who repoints `binaryPath` gets a fresh answer
16682
+ * without a restart.
16683
+ *
16684
+ * A probe that answers nothing is `null`: "we could not ask" must never be
16685
+ * read as "it cannot", or one bad probe pins a capable node in software.
16686
+ */
16687
+ async resolveBinaryCapabilities(binaryPath) {
16688
+ const cached = this.binaryCapabilities.get(binaryPath);
16689
+ if (cached !== void 0) return await cached;
16690
+ const probing = this.probeFfmpeg(binaryPath).then((caps) => {
16691
+ if (caps.encoders.size === 0 && caps.hwaccels.size === 0) {
16692
+ this.logger.warn("ffmpeg answered nothing about itself — hardware is attempted anyway, and its own failure is the answer", { meta: { ffmpeg: binaryPath } });
16693
+ return null;
16694
+ }
16695
+ this.logger.info("ffmpeg capabilities probed for transcoding", { meta: {
16696
+ ffmpeg: binaryPath,
16697
+ version: caps.version,
16698
+ hwaccels: [...caps.hwaccels].join(",") || "none"
16699
+ } });
16700
+ return caps;
16701
+ });
16702
+ this.binaryCapabilities.set(binaryPath, probing);
16703
+ return await probing;
16704
+ }
16705
+ /**
16528
16706
  * Resolve the effective decode `hwaccel` setting for a (deviceId, camStreamId)
16529
16707
  * source. Precedence: per-source-stream override > global `ffmpeg.hwAccel`.
16530
16708
  * The global default itself is `'copy'` (software) out of the box.
@@ -16558,7 +16736,18 @@ var TranscodePipelineManager = class {
16558
16736
  async resolveSpecificDecodeHwAccel(prefer) {
16559
16737
  if (!this.hwaccel) return null;
16560
16738
  try {
16561
- return rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16739
+ const backend = rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16740
+ if (backend === null) return null;
16741
+ const caps = await this.resolveBinaryCapabilities(this.ffmpegConfig().binaryPath);
16742
+ if (caps !== null && !caps.hwaccels.has(backend)) {
16743
+ 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: {
16744
+ backend,
16745
+ ffmpeg: this.ffmpegConfig().binaryPath,
16746
+ hwaccels: [...caps.hwaccels].join(",") || "none"
16747
+ } });
16748
+ return null;
16749
+ }
16750
+ return backend;
16562
16751
  } catch (err) {
16563
16752
  this.logger.warn("local decode hw-accel resolve failed — software decode fallback", { meta: { error: errMsg(err) } });
16564
16753
  return null;
@@ -18124,6 +18313,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18124
18313
  if (!parsed) return null;
18125
18314
  return findProfileForStream(this.assignments.get(parsed.deviceId)?.map ?? {}, parsed.camStreamId);
18126
18315
  }, this.remoteBrokerTiers));
18316
+ server.setStreamOriginResolver((brokerId) => {
18317
+ if (this.remoteBrokerAdapters.has(brokerId)) return {
18318
+ ingestNodeId: this.cachedIngestOwnerNodeId,
18319
+ ingestIsRemote: true
18320
+ };
18321
+ if (this.localNodeId === null) return {
18322
+ ingestNodeId: null,
18323
+ ingestIsRemote: null
18324
+ };
18325
+ return {
18326
+ ingestNodeId: this.localNodeId,
18327
+ ingestIsRemote: false
18328
+ };
18329
+ });
18127
18330
  server.setEgressTranscodeProvider({
18128
18331
  acquire: (request) => this.acquireEgressTranscode(request),
18129
18332
  release: (input) => this.releaseEgressTranscode(input)
@@ -23438,6 +23641,18 @@ var AdaptiveSession = class AdaptiveSession {
23438
23641
  transcodeToBaseline: this.transcodeToBaseline
23439
23642
  });
23440
23643
  }
23644
+ /**
23645
+ * The codec off the SOURCE. Read-only, for the stream-debug overlay: an
23646
+ * operator staring at a frozen picture needs to know whether this hub is
23647
+ * re-encoding for them, and `needsTranscode` alone does not say WHAT from.
23648
+ */
23649
+ get debugSourceCodec() {
23650
+ return this.sourceCodec;
23651
+ }
23652
+ /** The codec this client's SDP answer settled on. See `debugSourceCodec`. */
23653
+ get debugNegotiatedCodec() {
23654
+ return this.negotiatedCodec;
23655
+ }
23441
23656
  _firstKeyFrame;
23442
23657
  /**
23443
23658
  * Last seen SPS and PPS NALs. Many cameras send SPS/PPS only once
@@ -26242,6 +26457,85 @@ var AdaptiveController = class {
26242
26457
  this.healthySince = now;
26243
26458
  }
26244
26459
  };
26460
+ //#endregion
26461
+ //#region src/stream-broker/webrtc/session-debug.ts
26462
+ /** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
26463
+ function camStreamIdOf(brokerId) {
26464
+ const slash = brokerId.indexOf("/");
26465
+ if (slash <= 0) return null;
26466
+ const tail = brokerId.slice(slash + 1);
26467
+ return tail.length > 0 ? tail : null;
26468
+ }
26469
+ /**
26470
+ * Native (the camera's own encode) vs derived (a rung this hub BUILT).
26471
+ * `unknown` when the brokerId does not parse — a guess here would tell an
26472
+ * operator the hub is transcoding when it is not, or the reverse.
26473
+ */
26474
+ function sourceKindOf(camStreamId) {
26475
+ if (camStreamId === null) return "unknown";
26476
+ return camStreamId.startsWith("derived:") ? "derived" : "native";
26477
+ }
26478
+ var DEAD = {
26479
+ alive: false,
26480
+ brokerId: null,
26481
+ camStreamId: null,
26482
+ sourceKind: "unknown",
26483
+ adaptive: false,
26484
+ servedTier: null,
26485
+ ingestNodeId: null,
26486
+ ingestIsRemote: null,
26487
+ sourceCodec: null,
26488
+ negotiatedCodec: null,
26489
+ egressLeg: "unknown",
26490
+ brokerStatus: null,
26491
+ brokerCodec: null,
26492
+ brokerBitrateKbps: null,
26493
+ brokerInputFps: null,
26494
+ brokerUptimeMs: null,
26495
+ rtpPacketsSent: null,
26496
+ trackMode: null,
26497
+ lastDecision: null,
26498
+ tierFailures: [],
26499
+ upgradeCount: null
26500
+ };
26501
+ function projectSessionDebug(input) {
26502
+ const { session } = input;
26503
+ if (session === null) return {
26504
+ ...DEAD,
26505
+ sessionId: input.sessionId,
26506
+ observedAt: input.observedAt
26507
+ };
26508
+ const camStreamId = camStreamIdOf(session.brokerId);
26509
+ const stats = input.brokerStats;
26510
+ return {
26511
+ sessionId: input.sessionId,
26512
+ alive: true,
26513
+ observedAt: input.observedAt,
26514
+ brokerId: session.brokerId,
26515
+ camStreamId,
26516
+ sourceKind: sourceKindOf(camStreamId),
26517
+ adaptive: session.adaptive,
26518
+ servedTier: input.servedTier,
26519
+ ingestNodeId: input.origin?.ingestNodeId ?? null,
26520
+ ingestIsRemote: input.origin?.ingestIsRemote ?? null,
26521
+ sourceCodec: session.sourceCodec,
26522
+ negotiatedCodec: session.negotiatedCodec,
26523
+ egressLeg: session.needsTranscode ? "transcode" : "copy",
26524
+ brokerStatus: stats?.status ?? null,
26525
+ brokerCodec: stats?.codec !== void 0 && stats.codec.length > 0 ? stats.codec : null,
26526
+ brokerBitrateKbps: stats?.bitrateKbps ?? null,
26527
+ brokerInputFps: stats?.inputFps ?? null,
26528
+ brokerUptimeMs: stats?.uptimeMs ?? null,
26529
+ rtpPacketsSent: session.rtpPacketsSent,
26530
+ trackMode: session.trackMode,
26531
+ lastDecision: input.ladder?.lastDecision ?? null,
26532
+ tierFailures: (input.ladder?.failures ?? []).map(([tier, failures]) => ({
26533
+ tier,
26534
+ failures
26535
+ })),
26536
+ upgradeCount: input.ladder?.upgradeCount ?? null
26537
+ };
26538
+ }
26245
26539
  var PLAYBACK_PROFILE_FALLBACK_ORDER = [
26246
26540
  "high",
26247
26541
  "mid",
@@ -30601,6 +30895,29 @@ var BrokerWebrtcServer = class {
30601
30895
  * close.
30602
30896
  */
30603
30897
  adaptiveIntent = /* @__PURE__ */ new Map();
30898
+ /**
30899
+ * sessionId → the ladder's LAST move, and the numbers it rested on.
30900
+ *
30901
+ * Kept on the SERVER and not on the `SessionEntry` for the same reason
30902
+ * `adaptiveIntent` is: a tier switch is a close-and-recreate re-offer, so
30903
+ * the entry that made the decision is gone by the time anybody asks — which
30904
+ * is exactly the moment an operator opens the debug overlay. Cleared on
30905
+ * close, in `cleanupSession`.
30906
+ *
30907
+ * A snapshot, deliberately, not a re-read of the controller: the decision
30908
+ * was made on a window that has since moved on, and a recomputed "current"
30909
+ * loss ratio answers a different question than "what made it move".
30910
+ * Absent until the ladder actually moves — a ladder that has never moved
30911
+ * reports `null`, which is NOT the same fact as a `healthy` move with a
30912
+ * 0 loss ratio (D393).
30913
+ */
30914
+ lastLadderDecision = /* @__PURE__ */ new Map();
30915
+ /**
30916
+ * brokerId → where its media is ingested. Injected by the manager, which
30917
+ * owns the ownership map; unset (or a null answer) means UNRESOLVED, and
30918
+ * the overlay says so rather than naming the local node.
30919
+ */
30920
+ streamOriginResolver = null;
30604
30921
  stopped = false;
30605
30922
  constructor(options) {
30606
30923
  this.logger = options.logger;
@@ -31551,6 +31868,7 @@ var BrokerWebrtcServer = class {
31551
31868
  closes.push(entry.session.close().catch(() => {}));
31552
31869
  }
31553
31870
  await Promise.all(closes);
31871
+ this.lastLadderDecision.clear();
31554
31872
  this.brokers.clear();
31555
31873
  this.logger.info("WebRTC server stopped");
31556
31874
  }
@@ -31566,6 +31884,92 @@ var BrokerWebrtcServer = class {
31566
31884
  alive: entry !== void 0
31567
31885
  };
31568
31886
  }
31887
+ /** Injected by the manager: brokerId → where its media is ingested. */
31888
+ setStreamOriginResolver(resolver) {
31889
+ this.streamOriginResolver = resolver;
31890
+ }
31891
+ /**
31892
+ * The SERVER half of the stream-debug overlay for one session — the rung
31893
+ * `auto` actually resolved to, why the ladder last moved, where the media
31894
+ * is ingested, and whether the egress leg copies or re-encodes.
31895
+ *
31896
+ * Read-only and allocation-light: polled at ~1 Hz by ONE overlay while it
31897
+ * is open. Shaping lives in the pure `projectSessionDebug`; this method
31898
+ * only gathers.
31899
+ */
31900
+ getSessionDebug(sessionId, now) {
31901
+ const entry = this.sessions.get(sessionId);
31902
+ if (entry === void 0) return projectSessionDebug({
31903
+ sessionId,
31904
+ observedAt: now,
31905
+ session: null,
31906
+ servedTier: null,
31907
+ origin: null,
31908
+ brokerStats: null,
31909
+ ladder: null
31910
+ });
31911
+ const controller = entry.adaptiveController;
31912
+ const decision = this.lastLadderDecision.get(sessionId) ?? null;
31913
+ const ladder = controller !== void 0 ? {
31914
+ lastDecision: decision,
31915
+ failures: [...controller.failureCounts],
31916
+ upgradeCount: controller.successfulUpgradeCount
31917
+ } : decision !== null ? {
31918
+ lastDecision: decision,
31919
+ failures: [],
31920
+ upgradeCount: 0
31921
+ } : null;
31922
+ let brokerStats = null;
31923
+ try {
31924
+ brokerStats = this.brokers.get(entry.brokerId)?.getStats() ?? null;
31925
+ } catch {
31926
+ brokerStats = null;
31927
+ }
31928
+ return projectSessionDebug({
31929
+ sessionId,
31930
+ observedAt: now,
31931
+ session: {
31932
+ brokerId: entry.brokerId,
31933
+ adaptive: controller !== void 0,
31934
+ sourceCodec: entry.session.debugSourceCodec,
31935
+ negotiatedCodec: entry.session.debugNegotiatedCodec,
31936
+ needsTranscode: entry.session.needsTranscode,
31937
+ rtpPacketsSent: entry.session.rtpPacketsSentTotal,
31938
+ trackMode: entry.timeline?.currentMode ?? "live"
31939
+ },
31940
+ servedTier: this.profileTierResolver?.(entry.brokerId) ?? null,
31941
+ origin: this.streamOriginResolver?.(entry.brokerId) ?? null,
31942
+ brokerStats,
31943
+ ladder
31944
+ });
31945
+ }
31946
+ /**
31947
+ * Bound the ladder-decision memory. Entries survive a session teardown on
31948
+ * purpose (the re-offer keeps the id), so nothing else would ever evict the
31949
+ * one left behind by a client that disconnected mid-switch. Insertion order
31950
+ * is eviction order.
31951
+ */
31952
+ pruneLadderDecisions() {
31953
+ const MAX_REMEMBERED_DECISIONS = 64;
31954
+ while (this.lastLadderDecision.size > MAX_REMEMBERED_DECISIONS) {
31955
+ const oldest = this.lastLadderDecision.keys().next();
31956
+ if (oldest.done === true) break;
31957
+ this.lastLadderDecision.delete(oldest.value);
31958
+ }
31959
+ }
31960
+ /** Snapshot the ladder's move so the overlay can name it later. */
31961
+ recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
31962
+ this.lastLadderDecision.set(sessionId, {
31963
+ cause: decision.cause,
31964
+ tier,
31965
+ forcedTranscodeDown,
31966
+ lossRatio: decision.lossRatio,
31967
+ clientKbps: decision.stall.clientKbps,
31968
+ stalledForMs: decision.stall.stalledForMs,
31969
+ serverSending: decision.stall.serverSending,
31970
+ at: Date.now()
31971
+ });
31972
+ }
31569
31973
  /**
31570
31974
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
31571
31975
  * The controller is fed loss from two sources — RTCP Receiver Reports
@@ -31590,9 +31994,11 @@ var BrokerWebrtcServer = class {
31590
31994
  priorUpgradeCount,
31591
31995
  config: ADAPTIVE_CONFIG,
31592
31996
  onSwitchTier: (tier, decision) => {
31997
+ this.recordLadderDecision(sessionId, decision, tier, false);
31593
31998
  this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
31594
31999
  },
31595
32000
  onReduceMtu: (decision) => {
32001
+ this.recordLadderDecision(sessionId, decision, null, false);
31596
32002
  entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU);
31597
32003
  this.logger.info("adaptive: reducing repacketizer MTU", {
31598
32004
  tags: { deviceId },
@@ -31609,6 +32015,7 @@ var BrokerWebrtcServer = class {
31609
32015
  });
31610
32016
  },
31611
32017
  onTranscodeDown: (decision) => {
32018
+ this.recordLadderDecision(sessionId, decision, "low", true);
31612
32019
  this.switchSessionTier(entry, deviceId, "low", sessionId, true, decision);
31613
32020
  }
31614
32021
  });
@@ -31714,6 +32121,7 @@ var BrokerWebrtcServer = class {
31714
32121
  }
31715
32122
  if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
31716
32123
  this.adaptiveIntent.delete(sessionId);
32124
+ this.pruneLadderDecisions();
31717
32125
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
31718
32126
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
31719
32127
  entry.attributionTagged = true;
@@ -32361,6 +32769,16 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
32361
32769
  async getSessionState(input) {
32362
32770
  return this.webrtcServer.getSessionRenegotiationState(input.sessionId);
32363
32771
  }
32772
+ /**
32773
+ * The SERVER half of the stream-debug overlay. Thin facade — the gathering
32774
+ * lives in the WebRTC server and the shaping in the pure
32775
+ * `projectSessionDebug`. `deviceId` is not used to look the session up
32776
+ * (session ids are already unique); it is part of the device-scoped cap's
32777
+ * signature and identifies the camera for the route.
32778
+ */
32779
+ async getSessionDebug(input) {
32780
+ return this.webrtcServer.getSessionDebug(input.sessionId, Date.now());
32781
+ }
32364
32782
  };
32365
32783
  //#endregion
32366
32784
  //#region src/stream-broker/event-loop-stall-monitor.ts