@camstack/addon-pipeline 1.2.341 → 1.2.343

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 +5 -4
  2. package/dist/audio-analyzer/index.mjs +5 -4
  3. package/dist/{custom-model-catalog-CoJEC8nz.js → custom-model-catalog-CC8vgYZ8.js} +3 -3
  4. package/dist/{custom-model-catalog-DNSmyZTT.mjs → custom-model-catalog-m7EBV9wu.mjs} +3 -3
  5. package/dist/detection-pipeline/index.js +4 -118
  6. package/dist/detection-pipeline/index.mjs +4 -118
  7. package/dist/{dist-B8oPeJDI.mjs → dist-BAHBDujp.mjs} +257 -654
  8. package/dist/{dist-PiNPe8uQ.js → dist-BwipY6K1.js} +256 -659
  9. package/dist/{model-catalogs-RM3pkuZm.mjs → model-catalogs-CUU_L1ec.mjs} +1 -1
  10. package/dist/{model-catalogs-CEphd9Va.js → model-catalogs-Dotb1OFs.js} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-CMTBIa3-.js → node-BHYEkNaS.js} +1 -1
  14. package/dist/{node-DFUmMJ_f.mjs → node-BPwelwyj.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +11 -13
  16. package/dist/pipeline-runner/index.mjs +11 -13
  17. package/dist/{process-memory-CzAfDP9P.js → process-memory-BRCKXa-P.js} +1 -1
  18. package/dist/{process-memory-C4v8DBKn.mjs → process-memory-_8o1iZnL.mjs} +1 -1
  19. package/dist/recorder/index.js +14 -16
  20. package/dist/recorder/index.mjs +14 -16
  21. package/dist/{segment-demux-js-CIpCdbx4.mjs → segment-demux-js-DK8jbZy6.mjs} +1 -1
  22. package/dist/{segment-demux-js-C-n_ZsMK.js → segment-demux-js-DveVPFl9.js} +1 -1
  23. package/dist/stream-broker/_stub.js +48 -130
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CIDS0Tr9.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CF-T-9YU.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BUfCTcDV.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-uAQqU5lv.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-CW_cmeNQ.mjs → hostInit-C6jr8fSt.mjs} +3 -3
  30. package/dist/stream-broker/index.js +49 -1693
  31. package/dist/stream-broker/index.mjs +54 -1696
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -6
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BvfT5BVP.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-LU_e-xJm.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as object, An as CAM_PROFILE_ORDER, Bn as makeSourceBrokerId, C as EncodeProfileSchema, Cn as invocationFromEncodeProfile, Dn as errMsg, E as G711_SCALE_CORRECTION_DB, En as DeviceType, F as RATE_CONTROL_TIGHT, H as RingBuffer, In as expandAudioChunkToF32le, Jn as boolean, K as addonWidgetsSourceCapability, Kn as _enum, Lt as memoizePinnedFfmpeg, Mt as logChannelsCapability, Nn as createEvent, On as BOOT_RECOVERY_BACKOFF_MS, P as RATE_CONTROL_RELAXED, Pt as maskUrlCredentials, Q as batteryCapability, Qn as number, Rn as isEvent, St as ffmpegRate, Tn as DeviceFeature, Tt as gifFromMp4Args, Un as parseProfileBrokerId, Vn as nodePin, Yn as discriminatedUnion, Yt as rankStreams, Zn as literal, _n as webrtcSessionCapability, at as createHwAccelCache, c as CLIP_STILL_REASON_CODE_HEADER, et as cameraStreamsCapability, ht as egressTransportFromRequest, ir as EventCategory, jn as CamProfileSchema, jt as loadContributionCapability, kn as BaseAddon, lt as deriveBatteryPresence, mt as egressTranscodeSharingKey, nn as resolveEgressDecodeHwAccel, nr as string, nt as clipExportFailureOf, o as BatteryStatusSchema, ot as createLogChannelsProvider, pn as streamBrokerCapability, qn as array, rr as union, s as CLIP_BROKER_PACED_RATES, st as declareLogChannel, tr as record, vn as AUDIO_PRESETS, wn as isSoftwareDecode, wt as gifDeriveSpeed, xn as buildFfmpegArgs$1, zn as makeProfileBrokerId } from "../dist-B8oPeJDI.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-DFUmMJ_f.mjs";
2
+ import { An as CAM_PROFILE_ORDER, Bn as makeSourceBrokerId, C as EncodeProfileSchema, Cn as invocationFromEncodeProfile, Dn as errMsg, E as G711_SCALE_CORRECTION_DB, En as DeviceType, F as RATE_CONTROL_TIGHT, Gn as _enum, H as RingBuffer, Hn as parseProfileBrokerId, In as expandAudioChunkToF32le, Jn as discriminatedUnion, K as addonWidgetsSourceCapability, Kn as array, Lt as memoizePinnedFfmpeg, Mt as logChannelsCapability, Nn as createEvent, On as BOOT_RECOVERY_BACKOFF_MS, P as RATE_CONTROL_RELAXED, Pt as maskUrlCredentials, Q as batteryCapability, Qn as object, Rn as isEvent, St as ffmpegRate, Tn as DeviceFeature, Tt as gifFromMp4Args, Vn as nodePin, Xn as literal, Yt as rankStreams, Zn as number, _n as webrtcSessionCapability, at as createHwAccelCache, c as CLIP_STILL_REASON_CODE_HEADER, er as record, et as cameraStreamsCapability, ht as egressTransportFromRequest, jn as CamProfileSchema, jt as loadContributionCapability, kn as BaseAddon, lt as deriveBatteryPresence, mt as egressTranscodeSharingKey, nn as resolveEgressDecodeHwAccel, nr as union, nt as clipExportFailureOf, o as BatteryStatusSchema, ot as createLogChannelsProvider, pn as streamBrokerCapability, qn as boolean, rr as EventCategory, s as CLIP_BROKER_PACED_RATES, st as declareLogChannel, tr as string, vn as AUDIO_PRESETS, wn as isSoftwareDecode, wt as gifDeriveSpeed, xn as buildFfmpegArgs, zn as makeProfileBrokerId } from "../dist-BAHBDujp.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BPwelwyj.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 { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-CIpCdbx4.mjs";
7
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-DK8jbZy6.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -561,16 +561,6 @@ var STREAM_BROKER_RETIRED_ROOT_KEYS = [
561
561
  }
562
562
  ];
563
563
  //#endregion
564
- //#region src/stream-broker/stream-broker/ffmpeg-args-common.ts
565
- /** The `-hide_banner -loglevel <level>` preamble every broker ffmpeg site opens with. */
566
- function logBannerArgs(level) {
567
- return [
568
- "-hide_banner",
569
- "-loglevel",
570
- level
571
- ];
572
- }
573
- //#endregion
574
564
  //#region src/shared/ffmpeg-decode-args.ts
575
565
  /** Default DRM render node used for the VA-API / QSV hardware decode path. */
576
566
  var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
@@ -1999,7 +1989,7 @@ function buildDerivedTranscodeArgs(input) {
1999
1989
  const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
2000
1990
  const video = withCarrierDefaults(input.profile.video);
2001
1991
  return {
2002
- args: buildFfmpegArgs$1(invocationFromEncodeProfile({
1992
+ args: buildFfmpegArgs(invocationFromEncodeProfile({
2003
1993
  profile: {
2004
1994
  ...input.profile,
2005
1995
  video,
@@ -5675,7 +5665,7 @@ function audioPlanFromTarget(audio) {
5675
5665
  * — `scripts/check-ffmpeg-primitive.ts` refuses a second builder.
5676
5666
  */
5677
5667
  function buildBrokerEgressArgs(invocation, sink, audioSidecar) {
5678
- return buildFfmpegArgs$1({
5668
+ return buildFfmpegArgs({
5679
5669
  ...invocation,
5680
5670
  sink,
5681
5671
  audioSidecar
@@ -6791,7 +6781,7 @@ var EgressTranscodeManager = class {
6791
6781
  */
6792
6782
  const spawnAttempt = (decodeHwAccel) => {
6793
6783
  const mine = ++generation;
6794
- const args = buildFfmpegArgs$1({
6784
+ const args = buildFfmpegArgs({
6795
6785
  ...spec.invocation,
6796
6786
  decodeHwAccel,
6797
6787
  sink: {
@@ -7707,15 +7697,15 @@ function isH265IrapAccessUnit(annexB) {
7707
7697
  //#endregion
7708
7698
  //#region src/stream-broker/stream-broker/annexb-param-sets.ts
7709
7699
  /**
7710
- * Annex-B parameter-set detection for the decoder-priming path.
7700
+ * Annex-B parameter-set detection for the clip-priming path.
7711
7701
  *
7712
- * The frame-handle-plane decoder (`ffmpeg -f h264|hevc -i pipe:0`) initialises
7713
- * only once it sees the codec parameter sets (H.264 SPS+PPS / H.265 SPS+PPS,
7714
- * plus VPS) in its Annex-B input. Most cameras carry them in-band with each
7715
- * IDR; some (Reolink high-profile native streams) ship them ONLY in the SDP.
7716
- * The broker harvests those SDP param sets (`sdpParameterSets`) but must not
7717
- * blindly re-inject them ahead of every keyframe — that would duplicate the
7718
- * sets for well-behaved cameras. This scan lets the broker inject only when a
7702
+ * A decoder of a pre-buffer clip (`prebuffer-clip.ts`) initialises only once it
7703
+ * sees the codec parameter sets (H.264 SPS+PPS / H.265 SPS+PPS, plus VPS) in
7704
+ * its Annex-B input. Most cameras carry them in-band with each IDR; some
7705
+ * (Reolink high-profile native streams) ship them ONLY in the SDP. The broker
7706
+ * harvests those SDP param sets (`sdpParameterSets`) but must not blindly
7707
+ * re-inject them ahead of every keyframe — that would duplicate the sets for
7708
+ * well-behaved cameras. This scan lets the clip writer inject only when a
7719
7709
  * keyframe's own NAL stream is missing them.
7720
7710
  *
7721
7711
  * Pure byte scanning: no allocation beyond the walk; safe on any Annex-B blob.
@@ -7761,20 +7751,6 @@ function annexBHasParameterSets(data, codec) {
7761
7751
  }
7762
7752
  return hasSps && hasPps;
7763
7753
  }
7764
- /**
7765
- * Concatenate raw parameter-set NAL payloads into a single Annex-B buffer
7766
- * (each prefixed with a 4-byte start code) — the shape the decoder's stdin
7767
- * expects. Mirrors the existing SDP-param-set replay for encoded subscribers.
7768
- */
7769
- function concatParameterSetsAnnexB(parameterSets) {
7770
- const startCode = Buffer.from([
7771
- 0,
7772
- 0,
7773
- 0,
7774
- 1
7775
- ]);
7776
- return Buffer.concat(parameterSets.flatMap((ps) => [startCode, ps]));
7777
- }
7778
7754
  //#endregion
7779
7755
  //#region src/stream-broker/stream-broker/prebuffer-clip.ts
7780
7756
  /** Profiles cheapest-first — the default picks the first one assigned. */
@@ -8183,7 +8159,7 @@ var STILL_ENCODER_PARAMS = "keyint=1:repeat-headers=1";
8183
8159
  */
8184
8160
  function buildPrestreamEncodeArgs(plan) {
8185
8161
  const h265 = plan.codec === "h265";
8186
- return buildFfmpegArgs$1({
8162
+ return buildFfmpegArgs({
8187
8163
  logLevel: "error",
8188
8164
  decodeHwAccel: null,
8189
8165
  input: {
@@ -11366,806 +11342,6 @@ var EncodedRingBuffer = class {
11366
11342
  }
11367
11343
  };
11368
11344
  //#endregion
11369
- //#region src/stream-broker/stream-broker/decoder-session-proxy.ts
11370
- /**
11371
- * Long-poll window handed to `pullFrames`/`pullHandles`: an empty drain blocks
11372
- * decoder-side until a frame commits or this elapses, replacing the old ~1kHz
11373
- * `setTimeout(…,1)` busy-spin with a single await-notify round-trip. Kept
11374
- * modest so a hung UDS/Moleculer transport can't pin the call; the decoder
11375
- * cancels the waiter on `destroySession` so teardown never waits it out.
11376
- */
11377
- var POLL_WAIT_MS = 50;
11378
- var DecoderSessionProxy = class {
11379
- api;
11380
- sessionId;
11381
- polling = false;
11382
- constructor(api, sessionId) {
11383
- this.api = api;
11384
- this.sessionId = sessionId;
11385
- }
11386
- /**
11387
- * Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
11388
- * regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
11389
- * `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
11390
- * is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
11391
- * peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
11392
- * stub, an old decoder that predates `waitMs`, a test fake — would otherwise
11393
- * turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
11394
- * that pins the CPU, starves every timer in the process, and allocates until
11395
- * the heap dies (observed: a 4 GB OOM in under 15 s).
11396
- */
11397
- async paceEmptyDrain(drainStartedAt) {
11398
- const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
11399
- if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
11400
- }
11401
- async pushPacket(packet) {
11402
- await this.api.pushPacket({
11403
- sessionId: this.sessionId,
11404
- packet
11405
- });
11406
- }
11407
- async openStream(url) {
11408
- await this.api.openStream({
11409
- sessionId: this.sessionId,
11410
- url
11411
- });
11412
- }
11413
- async startPolling(onFrame) {
11414
- this.polling = true;
11415
- while (this.polling) {
11416
- const drainStartedAt = Date.now();
11417
- const frames = await this.api.pullFrames({
11418
- sessionId: this.sessionId,
11419
- maxCount: 4,
11420
- waitMs: POLL_WAIT_MS
11421
- });
11422
- for (const frame of frames) onFrame(frame);
11423
- if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
11424
- }
11425
- }
11426
- /**
11427
- * Poll a `frameSink: 'shm'` session for `FrameHandle`s (Phase 5 / D9).
11428
- * Mirrors `startPolling` but drains `pullHandles` — the decoder has
11429
- * already written the pixels into a shared-memory ring, so what crosses
11430
- * the cap boundary is the tiny serialisable handle. Runs until
11431
- * `stopPolling`, `destroy`, or the session is destroyed externally.
11432
- *
11433
- * When the decoder reports the session is gone (`unknown sessionId` /
11434
- * `Service … not found`) the loop exits gracefully — those errors are
11435
- * expected during decoder restart/shutdown and must not be re-thrown to
11436
- * the caller's `.catch()` handler, which would otherwise spin forever.
11437
- */
11438
- async startHandlePolling(onHandle) {
11439
- this.polling = true;
11440
- while (this.polling) try {
11441
- const drainStartedAt = Date.now();
11442
- const handles = await this.api.pullHandles({
11443
- sessionId: this.sessionId,
11444
- maxCount: 4,
11445
- waitMs: POLL_WAIT_MS
11446
- });
11447
- for (const handle of handles) onHandle(handle);
11448
- if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
11449
- } catch (err) {
11450
- this.polling = false;
11451
- const msg = err instanceof Error ? err.message : String(err);
11452
- if (!msg.includes("unknown sessionId") && !msg.includes("Service") && !msg.includes("not found")) throw err;
11453
- return;
11454
- }
11455
- }
11456
- stopPolling() {
11457
- this.polling = false;
11458
- }
11459
- async updateConfig(config) {
11460
- await this.api.updateConfig({
11461
- sessionId: this.sessionId,
11462
- config
11463
- });
11464
- }
11465
- async getStats() {
11466
- return this.api.getStats({ sessionId: this.sessionId });
11467
- }
11468
- async destroy() {
11469
- this.stopPolling();
11470
- await this.api.destroySession({ sessionId: this.sessionId });
11471
- }
11472
- };
11473
- //#endregion
11474
- //#region src/stream-broker/stream-broker/frame-handle-plane.ts
11475
- /**
11476
- * `FrameHandlePlane` — the broker's shared-memory frame-handle surface
11477
- * (Phase 5 / D9).
11478
- *
11479
- * This is the handle-based replacement for `StreamBroker.onDecodedFrame`'s
11480
- * live-object callback path. Instead of fanning pixel `DecodedFrame`s out to
11481
- * in-process callbacks (which cannot cross a process boundary), the broker
11482
- * publishes zero-pixel, serialisable `FrameHandle`s a consumer drains over
11483
- * tRPC and reads back from a shared-memory ring with a `FrameRingReader`.
11484
- *
11485
- * ## Ring-per-format model
11486
- *
11487
- * A `frameSink: 'shm'` decoder session produces exactly ONE pixel format
11488
- * (the decoder's scaler is configured for it). The broker therefore keeps
11489
- * one decoder session — hence one shm ring — per `(brokerId, format)` that a
11490
- * consumer actually subscribes to:
11491
- *
11492
- * - motion subscribes `gray` → one `gray` decoder session + ring
11493
- * - detection subscribes `rgb` → one `rgb` decoder session + ring
11494
- *
11495
- * Only the formats consumers ask for are materialised. There is NO
11496
- * broker-side `sharp` conversion any more — each consumer reads the exact
11497
- * format it requested. A session is reference-counted by its subscriber set
11498
- * and torn down when the last subscriber of that format leaves.
11499
- *
11500
- * ## fps throttling
11501
- *
11502
- * Throttling is implicit: the ring is latest-wins, so a slow consumer that
11503
- * polls `pullFrameHandles` infrequently simply receives the newest handles
11504
- * and the intervening frames are dropped at the ring (a recycled slot the
11505
- * consumer never read). The optional `maxFps` from `subscribeFrames` is NOT
11506
- * enforced here — it is echoed back to the consumer as a reader-side polling
11507
- * cadence hint.
11508
- *
11509
- * ## Packet feed
11510
- *
11511
- * The plane's decoder sessions are push-mode by default: the broker forwards
11512
- * every video `EncodedPacket` to `pushPacket`. For a pull-mode decoder the
11513
- * broker supplies a `pullModeUrl` and the plane calls `openStream` once the
11514
- * session exists.
11515
- */
11516
- /**
11517
- * Collapse a hierarchical Moleculer nodeID (`hub/child-abc`) to its owning
11518
- * node (`hub`). Frame-plane co-location only cares about the physical node, not
11519
- * which forked child within it hosts the session.
11520
- */
11521
- function collapseNodeId(nodeId) {
11522
- const slash = nodeId.indexOf("/");
11523
- return slash >= 0 ? nodeId.slice(0, slash) : nodeId;
11524
- }
11525
- /**
11526
- * Decide whether a decoder session created on `decoderNodeId` is co-located
11527
- * with the broker whose local node is `brokerNodeId`.
11528
- *
11529
- * The broker's shm frame ring is NODE-LOCAL: a decoder session on any other
11530
- * node can never deliver frame handles to this broker's consumers, so a
11531
- * mis-placed session must be rejected. Both ids are collapsed (`hub/child`→
11532
- * `hub`) before comparison.
11533
- *
11534
- * A `null` `brokerNodeId` means the broker's own node is unknown — enforcement
11535
- * is skipped (legacy behavior, accept any placement).
11536
- */
11537
- function isDecoderNodeColocated(brokerNodeId, decoderNodeId) {
11538
- if (brokerNodeId === null) return true;
11539
- return collapseNodeId(brokerNodeId) === collapseNodeId(decoderNodeId);
11540
- }
11541
- /** Per-subscription handle-queue capacity. A handle is tiny; 64 absorbs a
11542
- * burst between two `pullFrameHandles` polls without unbounded growth. */
11543
- var HANDLE_QUEUE_CAPACITY = 64;
11544
- /** Default reader-side cadence hint when a subscriber omits `maxFps`. */
11545
- var DEFAULT_HINT_FPS = 5;
11546
- /** Rolling window for the aggregate `decodeFps` rate, ms. Mirrors the
11547
- * broker's 2s `inputFps` window so the two stats are comparable. */
11548
- var DECODE_FPS_WINDOW_MS = 2e3;
11549
- /**
11550
- * Keep-warm grace before a decoder session with no subscribers is torn down.
11551
- *
11552
- * Subscribers flap on ~30s cadences (occupancy recheck bursts, detection
11553
- * motion-gating watch↔active, onboard gray gating). Destroying the
11554
- * ffmpeg/VAAPI decode session the instant the last subscriber leaves spawns +
11555
- * destroys a full session every cycle — wasted setup/teardown. Chosen >30s so
11556
- * consecutive bursts and watch↔active flips reuse the warm session, yet a
11557
- * genuinely idle session still frees its ffmpeg within ~a minute.
11558
- */
11559
- var SESSION_LINGER_MS = 45e3;
11560
- /**
11561
- * Short keep-warm grace for a PURE-OCCUPANCY session (every remaining
11562
- * subscriber is a periodic on-motion occupancy recheck, tag `'occupancy'`).
11563
- *
11564
- * Occupancy rechecks subscribe to decoded frames for ~1s every ~30s. The
11565
- * normal `SESSION_LINGER_MS` (45s) EXCEEDS that ~30s interval, so the linger
11566
- * timer is perpetually cancelled by the next recheck before it can fire — the
11567
- * decoder never tears down and decodes continuously at source-rate to serve a
11568
- * 1s/30s consumer (~30x wasted decode; multiplied across cameras this is the
11569
- * iGPU baseline that saturated the decoder). A pure-occupancy session instead
11570
- * tears down between polls: short enough to fire well before the next ~30s
11571
- * recheck, long enough to absorb the ~1s poll plus minor jitter. Re-dial is
11572
- * cheap on GPU-scale decode and the restream burst re-primes the live edge
11573
- * fast, so on-demand re-arm on the next recheck is fine.
11574
- */
11575
- var OCCUPANCY_LINGER_MS = 5e3;
11576
- /** Subscription tag emitted by the periodic on-motion occupancy recheck. A
11577
- * session whose subscribers have only ever carried this tag gets the short
11578
- * `OCCUPANCY_LINGER_MS` teardown; any other subscriber biases to the long
11579
- * `SESSION_LINGER_MS` warm linger (interactive viewers, detection, motion). */
11580
- var OCCUPANCY_TAG = "occupancy";
11581
- /**
11582
- * Default retry cooldown after a decoder session lands on a NON-LOCAL node
11583
- * (see `FrameHandlePlaneOptions.sessionRetryCooldownMs`). Long enough to
11584
- * turn a per-packet storm into a slow probe; short enough that the plane
11585
- * recovers within seconds once the local decoder respawns.
11586
- */
11587
- var SESSION_RETRY_COOLDOWN_MS = 1e4;
11588
- /**
11589
- * Owns the broker's shared-memory frame-handle subscriptions and the
11590
- * per-format `frameSink: 'shm'` decoder sessions that feed them.
11591
- */
11592
- var FrameHandlePlane = class {
11593
- decoderApi;
11594
- logger;
11595
- resolveStreamInfo;
11596
- localNodeId;
11597
- sessionRetryCooldownMs;
11598
- getSeedPackets;
11599
- isDebugEnabled;
11600
- subscriptions = /* @__PURE__ */ new Map();
11601
- sessions = /* @__PURE__ */ new Map();
11602
- /** Per-format retry gate set when a session lands on a non-local node:
11603
- * `ensureSession` skips creation until the epoch-ms deadline passes. */
11604
- sessionRetryCooldownUntil = /* @__PURE__ */ new Map();
11605
- /** In-flight `ensureSession` creation per format. Coalesces concurrent
11606
- * same-format callers onto ONE decoder session — without it two callers
11607
- * both pass the `sessions.get` check, both `createSession` (two ffmpeg),
11608
- * and the second registration orphans the first with no `destroySession`. */
11609
- sessionCreating = /* @__PURE__ */ new Map();
11610
- disposed = false;
11611
- /** Re-entrancy guard for `armPendingSessions` — collapses the per-packet
11612
- * `pushPacket` calls into a single in-flight deferred-arm pass. */
11613
- armInFlight = false;
11614
- /** DIAG(592): P-frames skipped on an un-bootstrapped session before its
11615
- * first live keyframe. Reset when a keyframe bootstraps the session. */
11616
- unbootstrappedSkips = 0;
11617
- /** Aggregate decoded-frame rate state — frames fanned out in the current
11618
- * rolling window, the window's start epoch, and the last computed fps. */
11619
- framesInWindow = 0;
11620
- windowStartMs = Date.now();
11621
- decodedFps = 0;
11622
- constructor(options) {
11623
- this.decoderApi = options.decoderApi;
11624
- this.logger = options.logger;
11625
- this.resolveStreamInfo = options.resolveStreamInfo;
11626
- this.localNodeId = options.localNodeId;
11627
- this.sessionRetryCooldownMs = options.sessionRetryCooldownMs ?? SESSION_RETRY_COOLDOWN_MS;
11628
- this.getSeedPackets = options.getSeedPackets;
11629
- this.isDebugEnabled = options.isDebugEnabled;
11630
- }
11631
- /** Number of active handle subscriptions — surfaced for diagnostics. */
11632
- get subscriberCount() {
11633
- return this.subscriptions.size;
11634
- }
11635
- /**
11636
- * Moleculer nodeIDs of the decoder providers currently hosting this plane's
11637
- * `frameSink: 'shm'` sessions. Used by the broker-manager's per-agent
11638
- * hwaccel-change handler to decide which brokers need a decoder rotation.
11639
- */
11640
- decoderNodeIds() {
11641
- const ids = [];
11642
- for (const session of this.sessions.values()) if (session.nodeId) ids.push(session.nodeId);
11643
- return ids;
11644
- }
11645
- /**
11646
- * Rotate every shm decoder session hosted on `agentNodeId` — destroy and
11647
- * rebuild it so the new session re-pulls the latest per-agent decoder
11648
- * preferences (hwaccel backend, …). Subscriptions are preserved: the same
11649
- * `subscriptionId`s read from the rebuilt session's fresh ring. A no-op for
11650
- * sessions on other nodes. Returns the number of sessions rotated.
11651
- */
11652
- async rotate(agentNodeId, reason) {
11653
- if (this.disposed) return 0;
11654
- let rotated = 0;
11655
- for (const session of this.sessions.values()) {
11656
- if ((session.nodeId && session.nodeId.includes("/") ? session.nodeId.split("/")[0] : session.nodeId) !== agentNodeId) continue;
11657
- this.logger?.info("frame-handle plane: rotating shm decoder session", { meta: {
11658
- format: session.format,
11659
- reason,
11660
- nodeId: session.nodeId
11661
- } });
11662
- await this.destroySession(session);
11663
- let restarted = false;
11664
- try {
11665
- restarted = await this.startSessionDecoder(session);
11666
- } finally {
11667
- if (!restarted) this.sessions.delete(session.format);
11668
- }
11669
- if (restarted) rotated += 1;
11670
- }
11671
- return rotated;
11672
- }
11673
- /**
11674
- * Register a frame-handle subscription. Spins up (or reuses) a
11675
- * `frameSink: 'shm'` decoder session producing `format`. The returned
11676
- * `subscriptionId` is the handle the consumer passes to
11677
- * `pullFrameHandles` / `unsubscribe`.
11678
- */
11679
- async subscribe(input) {
11680
- if (this.disposed) throw new Error("FrameHandlePlane: subscribe after dispose");
11681
- const subscriptionId = `fh-${randomUUID()}`;
11682
- const maxFps = input.maxFps !== void 0 && input.maxFps > 0 ? input.maxFps : DEFAULT_HINT_FPS;
11683
- const subscription = {
11684
- id: subscriptionId,
11685
- format: input.format,
11686
- maxFps,
11687
- tag: input.tag ?? "unknown",
11688
- subscribedAt: Date.now(),
11689
- queue: new RingBuffer(HANDLE_QUEUE_CAPACITY),
11690
- framesDelivered: 0
11691
- };
11692
- this.subscriptions.set(subscriptionId, subscription);
11693
- await this.ensureSession(input.format, subscriptionId);
11694
- this.reconcileSessionFps(input.format);
11695
- this.logger?.info("frame-handle subscription added", { meta: {
11696
- subscriptionId,
11697
- format: input.format,
11698
- tag: subscription.tag,
11699
- maxFps
11700
- } });
11701
- return {
11702
- subscriptionId,
11703
- maxFps
11704
- };
11705
- }
11706
- /**
11707
- * The emit `maxFps` a format's decoder session should run at — the MAX of all
11708
- * current subscribers' `maxFps` for that format (fallback `DEFAULT_HINT_FPS`
11709
- * when none, e.g. mid-teardown). The decoder emits at this rate; each
11710
- * subscriber then down-samples to its own `maxFps` on read (latest-wins).
11711
- */
11712
- computeFormatFps(format) {
11713
- let max = 0;
11714
- for (const sub of this.subscriptions.values()) if (sub.format === format && sub.maxFps > max) max = sub.maxFps;
11715
- return max > 0 ? max : DEFAULT_HINT_FPS;
11716
- }
11717
- /**
11718
- * Recompute a format session's emit `maxFps` after a subscriber joins/leaves
11719
- * and push it to the live decoder via `updateConfig` when it changed — so the
11720
- * decoder emits only as fast as the busiest reader needs (saving the
11721
- * scale+convert+shm-write for frames nobody reads).
11722
- */
11723
- reconcileSessionFps(format) {
11724
- const session = this.sessions.get(format);
11725
- if (!session || !session.proxy) return;
11726
- const target = this.computeFormatFps(format);
11727
- if (target === session.targetFps) return;
11728
- session.targetFps = target;
11729
- session.proxy.updateConfig({ maxFps: target }).catch((err) => {
11730
- this.logger?.warn("frame-handle plane: updateConfig(maxFps) failed", { meta: {
11731
- format,
11732
- maxFps: target,
11733
- error: errMsg(err)
11734
- } });
11735
- });
11736
- }
11737
- /**
11738
- * Drain up to `maxCount` `FrameHandle`s for a subscription, latest-wins.
11739
- * An unknown subscription id returns `[]` (the consumer may have been
11740
- * torn down concurrently).
11741
- */
11742
- pullHandles(subscriptionId, maxCount) {
11743
- const subscription = this.subscriptions.get(subscriptionId);
11744
- if (!subscription) return [];
11745
- const stale = subscription.queue.size - maxCount;
11746
- if (stale > 0) subscription.queue.drain(stale);
11747
- const handles = subscription.queue.drain(maxCount);
11748
- subscription.framesDelivered += handles.length;
11749
- return handles;
11750
- }
11751
- /**
11752
- * Release a subscription. When it was the last reader of its format the
11753
- * underlying decoder session is destroyed (and its shm segment unlinked
11754
- * by the decoder). Returns `true` when a known subscription was released.
11755
- */
11756
- async unsubscribe(subscriptionId, immediate = false) {
11757
- const subscription = this.subscriptions.get(subscriptionId);
11758
- if (!subscription) return false;
11759
- this.subscriptions.delete(subscriptionId);
11760
- const session = this.sessions.get(subscription.format);
11761
- if (session) {
11762
- session.subscriberIds.delete(subscriptionId);
11763
- if (session.subscriberIds.size === 0) if (immediate) {
11764
- this.clearLinger(session);
11765
- this.sessions.delete(subscription.format);
11766
- await this.destroySession(session);
11767
- } else this.scheduleLinger(session);
11768
- else this.reconcileSessionFps(subscription.format);
11769
- }
11770
- this.logger?.info("frame-handle subscription removed", { meta: {
11771
- subscriptionId,
11772
- format: subscription.format
11773
- } });
11774
- return true;
11775
- }
11776
- /**
11777
- * Arm the keep-warm teardown timer for a session whose last subscriber just
11778
- * left. Clears any existing timer first (idempotent). On fire it re-checks
11779
- * `subscriberIds.size === 0` — a subscriber may have returned in the interim,
11780
- * in which case the timer is a no-op — and only then removes the session and
11781
- * destroys the decoder. The timer is `unref`'d so a lingering warm session
11782
- * never keeps the process alive.
11783
- */
11784
- scheduleLinger(session) {
11785
- this.clearLinger(session);
11786
- const occupancyOnly = !session.hasNonOccupancySubscriberEver;
11787
- const lingerMs = occupancyOnly ? OCCUPANCY_LINGER_MS : SESSION_LINGER_MS;
11788
- this.logger?.debug(occupancyOnly ? "frame-handle plane: short occupancy linger scheduled" : "frame-handle plane: keep-warm linger scheduled", { meta: {
11789
- format: session.format,
11790
- lingerMs,
11791
- occupancyOnly
11792
- } });
11793
- const timer = setTimeout(() => {
11794
- session.lingerTimer = null;
11795
- if (session.subscriberIds.size > 0) return;
11796
- this.sessions.delete(session.format);
11797
- this.destroySession(session).catch(() => {});
11798
- }, lingerMs);
11799
- timer.unref?.();
11800
- session.lingerTimer = timer;
11801
- }
11802
- /**
11803
- * Latch `session.hasNonOccupancySubscriberEver` when the subscription
11804
- * `subscriptionId` carries any tag other than `OCCUPANCY_TAG`. Called at
11805
- * every point a subscription is attached to a session so the keep-warm
11806
- * decision only fast-tears-down sessions that have been PURELY on-demand
11807
- * occupancy rechecks. Unknown ids (mid-teardown) are ignored.
11808
- */
11809
- noteSessionSubscriberTag(session, subscriptionId) {
11810
- const subscription = this.subscriptions.get(subscriptionId);
11811
- if (subscription && subscription.tag !== OCCUPANCY_TAG) session.hasNonOccupancySubscriberEver = true;
11812
- }
11813
- /** Cancel a session's pending keep-warm teardown timer, if any. */
11814
- clearLinger(session) {
11815
- if (session.lingerTimer) {
11816
- clearTimeout(session.lingerTimer);
11817
- session.lingerTimer = null;
11818
- }
11819
- }
11820
- /**
11821
- * Forward a video `EncodedPacket` to every active shm decoder session.
11822
- * Push-mode decoders consume this; a pull-mode decoder ignores it (it
11823
- * reads its own pipe via `openStream`). Audio packets are not forwarded.
11824
- *
11825
- * The packet feed doubles as the plane's stream-ready signal: the broker
11826
- * only calls `pushPacket` once it has a source and the first keyframe has
11827
- * landed — exactly the point `resolveStreamInfo` starts returning a
11828
- * descriptor. Each packet therefore triggers `armPendingSessions`, which
11829
- * re-attempts session creation for any deferred subscription so a lone
11830
- * first subscriber on a not-yet-started broker self-arms without a
11831
- * redundant re-`subscribe`.
11832
- */
11833
- pushPacket(packet) {
11834
- if (this.disposed || packet.type !== "video") return;
11835
- this.armPendingSessions(packet).catch((err) => {
11836
- this.logger?.warn("frame-handle plane: arm pending sessions error", { meta: { error: errMsg(err) } });
11837
- });
11838
- this.feedSessions(packet);
11839
- }
11840
- /** Forward a video `EncodedPacket` to every live PUSH-mode shm decoder session. */
11841
- feedSessions(packet) {
11842
- for (const session of this.sessions.values()) {
11843
- if (!session.proxy) continue;
11844
- if (session.pullMode) continue;
11845
- if (!session.bootstrapped) {
11846
- if (!packet.keyframe) {
11847
- this.unbootstrappedSkips = (this.unbootstrappedSkips ?? 0) + 1;
11848
- continue;
11849
- }
11850
- this.logger?.debug("frame-handle plane: session bootstrapped from live keyframe", { meta: {
11851
- format: session.format,
11852
- pFramesSkippedBefore: this.unbootstrappedSkips ?? 0
11853
- } });
11854
- this.unbootstrappedSkips = 0;
11855
- session.bootstrapped = true;
11856
- }
11857
- session.proxy.pushPacket(packet).catch((err) => {
11858
- const msg = errMsg(err);
11859
- this.logger?.warn("frame-handle plane: decoder push error", { meta: {
11860
- format: session.format,
11861
- error: msg
11862
- } });
11863
- if (msg.includes("unknown sessionId") || msg.includes("Service") || msg.includes("not found")) {
11864
- this.logger?.info("frame-handle plane: invalidating stale proxy", { meta: { format: session.format } });
11865
- this.destroySession(session).catch(() => {});
11866
- this.sessions.delete(session.format);
11867
- }
11868
- });
11869
- }
11870
- }
11871
- /**
11872
- * Re-attempt session creation for every subscription whose format has no
11873
- * live `FormatSession` yet — the subscriptions whose `startSessionDecoder`
11874
- * was deferred because the broker had no stream at `subscribe` time. Driven
11875
- * off the `pushPacket` feed (event-driven, no polling timer); a no-op once
11876
- * every subscribed format already owns a session.
11877
- *
11878
- * `armInFlight` collapses re-entrant calls: `pushPacket` fires per packet,
11879
- * but `ensureSession` is async — without the guard a burst of packets would
11880
- * launch overlapping decoder-creation round-trips for the same format.
11881
- *
11882
- * `triggerPacket` is the packet whose arrival armed the pass. Because
11883
- * `ensureSession` is async, the synchronous `feedSessions` in `pushPacket`
11884
- * runs before the session lands in `this.sessions`, so the triggering packet
11885
- * would be lost to the new session. `startSessionDecoder` normally bootstraps
11886
- * it by replaying the broker's keyframe-aligned seed GOP; only when NO seed
11887
- * was available (no `getSeedPackets`, or the ring held no keyframe yet) is the
11888
- * session left un-bootstrapped — and then this method hands it the triggering
11889
- * packet, but ONLY if that packet is a keyframe. A P-frame trigger is left for
11890
- * `feedSessions` to skip until the first live keyframe, so a push-mode decoder
11891
- * never sees a slice referencing an unseen param set.
11892
- */
11893
- async armPendingSessions(triggerPacket) {
11894
- if (this.disposed || this.armInFlight) return;
11895
- const pending = /* @__PURE__ */ new Map();
11896
- for (const subscription of this.subscriptions.values()) {
11897
- if (this.sessions.has(subscription.format)) continue;
11898
- if (!pending.has(subscription.format)) pending.set(subscription.format, subscription.id);
11899
- }
11900
- if (pending.size === 0) return;
11901
- this.armInFlight = true;
11902
- try {
11903
- for (const [format, subscriptionId] of pending) {
11904
- if (this.disposed) return;
11905
- await this.ensureSession(format, subscriptionId);
11906
- const armed = this.sessions.get(format);
11907
- if (armed?.proxy && !armed.bootstrapped && triggerPacket.keyframe) {
11908
- armed.bootstrapped = true;
11909
- armed.proxy.pushPacket(triggerPacket).catch((err) => {
11910
- this.logger?.warn("frame-handle plane: decoder push error", { meta: {
11911
- format,
11912
- error: errMsg(err)
11913
- } });
11914
- });
11915
- }
11916
- }
11917
- } finally {
11918
- this.armInFlight = false;
11919
- }
11920
- }
11921
- /**
11922
- * Aggregate decoded-frame rate across every `frameSink: 'shm'` session in
11923
- * this plane, frames/s. Each `fanoutHandle` call is one decoded frame
11924
- * delivered into the plane; the rate is recomputed over a rolling
11925
- * `DECODE_FPS_WINDOW_MS` window. Returns the last computed value between
11926
- * window rolls, and `0` while no session is producing.
11927
- */
11928
- decodeFps() {
11929
- this.rollDecodeFpsWindow();
11930
- return this.decodedFps;
11931
- }
11932
- /**
11933
- * Roll the decoded-fps window when it has elapsed: convert frames seen in
11934
- * the window into a per-second rate, then reset the counter. Called both
11935
- * on every fanout and on every `decodeFps()` read so a stalled session
11936
- * (no fanout) decays its rate to `0` rather than reporting a stale value.
11937
- */
11938
- rollDecodeFpsWindow() {
11939
- const now = Date.now();
11940
- const windowMs = now - this.windowStartMs;
11941
- if (windowMs < DECODE_FPS_WINDOW_MS) return;
11942
- this.decodedFps = this.framesInWindow / windowMs * 1e3;
11943
- this.framesInWindow = 0;
11944
- this.windowStartMs = now;
11945
- }
11946
- /** Diagnostic snapshot of every active frame-handle subscription. */
11947
- listSubscribers() {
11948
- return [...this.subscriptions.values()].map((s) => ({
11949
- tag: s.tag,
11950
- subscribedAt: s.subscribedAt,
11951
- maxFps: s.maxFps,
11952
- framesDelivered: s.framesDelivered
11953
- }));
11954
- }
11955
- /**
11956
- * Force-release every subscription whose tag matches `tag`. Returns the
11957
- * number released. Decoder sessions wind down when their last subscriber
11958
- * leaves, same as a normal `unsubscribe`.
11959
- */
11960
- async killByTag(tag) {
11961
- const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
11962
- for (const id of victims) await this.unsubscribe(id, true);
11963
- return victims.length;
11964
- }
11965
- /** Tear down every subscription + decoder session. Idempotent. */
11966
- async dispose() {
11967
- if (this.disposed) return;
11968
- this.disposed = true;
11969
- this.subscriptions.clear();
11970
- const sessions = [...this.sessions.values()];
11971
- this.sessions.clear();
11972
- await Promise.all(sessions.map((s) => this.destroySession(s)));
11973
- }
11974
- /**
11975
- * Ensure a `frameSink: 'shm'` decoder session for `format` exists and add
11976
- * `subscriptionId` to its reader set. Reuses the session when one already
11977
- * runs for that format.
11978
- *
11979
- * When a session is created fresh, its reader set is seeded with EVERY
11980
- * subscription currently reading `format`, not just `subscriptionId` — a
11981
- * deferred-arm pass (`armPendingSessions`) may create the session long
11982
- * after several subscriptions of that format have registered, and the
11983
- * session is reference-counted by `subscriberIds`. Seeding only the one id
11984
- * would let an `unsubscribe` of that id tear the session down while its
11985
- * sibling subscriptions still read it.
11986
- */
11987
- async ensureSession(format, subscriptionId) {
11988
- const existing = this.sessions.get(format);
11989
- if (existing) {
11990
- this.clearLinger(existing);
11991
- existing.subscriberIds.add(subscriptionId);
11992
- this.noteSessionSubscriberTag(existing, subscriptionId);
11993
- return;
11994
- }
11995
- const inflight = this.sessionCreating.get(format);
11996
- if (inflight) {
11997
- await inflight;
11998
- const session = this.sessions.get(format);
11999
- if (session) {
12000
- session.subscriberIds.add(subscriptionId);
12001
- this.noteSessionSubscriberTag(session, subscriptionId);
12002
- }
12003
- return;
12004
- }
12005
- if (Date.now() < (this.sessionRetryCooldownUntil.get(format) ?? 0)) return;
12006
- const creation = (async () => {
12007
- const subscriberIds = new Set([subscriptionId]);
12008
- for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
12009
- const session = {
12010
- format,
12011
- proxy: null,
12012
- nodeId: null,
12013
- subscriberIds,
12014
- bootstrapped: false,
12015
- pullMode: false,
12016
- targetFps: this.computeFormatFps(format),
12017
- lingerTimer: null,
12018
- hasNonOccupancySubscriberEver: false
12019
- };
12020
- for (const id of subscriberIds) this.noteSessionSubscriberTag(session, id);
12021
- let started = false;
12022
- try {
12023
- started = await this.startSessionDecoder(session);
12024
- } catch (err) {
12025
- this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
12026
- throw err;
12027
- }
12028
- if (started) this.sessions.set(format, session);
12029
- })();
12030
- this.sessionCreating.set(format, creation);
12031
- try {
12032
- await creation;
12033
- } finally {
12034
- this.sessionCreating.delete(format);
12035
- }
12036
- }
12037
- /**
12038
- * Create the `frameSink: 'shm'` decoder for a `FormatSession` and start
12039
- * draining its handle queue. Mutates `session.proxy` / `session.nodeId` in
12040
- * place so a `rotate()` can rebuild a registered session without disturbing
12041
- * its `subscriberIds`. Returns `false` when no stream / unsupported codec
12042
- * means no decoder could be created.
12043
- */
12044
- async startSessionDecoder(session) {
12045
- const { format } = session;
12046
- const info = this.resolveStreamInfo();
12047
- if (!info) {
12048
- this.logger?.info("frame-handle plane: no stream — session deferred", { meta: { format } });
12049
- return false;
12050
- }
12051
- if (!await this.decoderApi.supportsCodec({ codec: info.codec })) {
12052
- this.logger?.warn("frame-handle plane: codec unsupported — session skipped", { meta: {
12053
- format,
12054
- codec: info.codec
12055
- } });
12056
- return false;
12057
- }
12058
- const targetFps = this.computeFormatFps(format);
12059
- session.targetFps = targetFps;
12060
- const { sessionId, nodeId } = await this.decoderApi.createSession({
12061
- codec: info.codec,
12062
- maxFps: targetFps,
12063
- outputFormat: format,
12064
- scale: 1,
12065
- frameSink: "shm",
12066
- ...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
12067
- tag: `${info.tag}:shm:${format}`,
12068
- ...this.isDebugEnabled?.() ? { debug: true } : {}
12069
- });
12070
- if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
12071
- this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
12072
- this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
12073
- format,
12074
- requestedNode: this.localNodeId,
12075
- gotNode: nodeId,
12076
- sessionId,
12077
- retryInMs: this.sessionRetryCooldownMs
12078
- } });
12079
- await this.decoderApi.destroySession({ sessionId }).catch((err) => {
12080
- this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
12081
- format,
12082
- sessionId,
12083
- error: errMsg(err)
12084
- } });
12085
- });
12086
- return false;
12087
- }
12088
- this.sessionRetryCooldownUntil.delete(format);
12089
- const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
12090
- session.proxy = proxy;
12091
- session.nodeId = nodeId;
12092
- session.bootstrapped = false;
12093
- session.pullMode = false;
12094
- proxy.startHandlePolling((handle) => {
12095
- this.fanoutHandle(format, handle);
12096
- }).catch((err) => {
12097
- this.logger?.warn("frame-handle plane: handle polling error", { meta: {
12098
- format,
12099
- error: errMsg(err)
12100
- } });
12101
- });
12102
- if (info.pullModeUrl) {
12103
- session.pullMode = true;
12104
- session.bootstrapped = true;
12105
- proxy.openStream(info.pullModeUrl).catch((err) => {
12106
- this.logger?.error("frame-handle plane: pull-mode openStream failed", { meta: {
12107
- format,
12108
- error: errMsg(err)
12109
- } });
12110
- });
12111
- this.logger?.info("frame-handle plane: shm decoder session created (pull-mode RTSP)", { meta: {
12112
- format,
12113
- codec: info.codec,
12114
- sessionId,
12115
- nodeId,
12116
- pullModeUrl: info.pullModeUrl
12117
- } });
12118
- return true;
12119
- }
12120
- const seed = this.getSeedPackets?.() ?? [];
12121
- if (seed.length > 0) {
12122
- for (const seedPacket of seed) await proxy.pushPacket(seedPacket).catch((err) => {
12123
- this.logger?.warn("frame-handle plane: seed-replay push error", { meta: {
12124
- format,
12125
- error: errMsg(err)
12126
- } });
12127
- });
12128
- session.bootstrapped = true;
12129
- this.logger?.debug("frame-handle plane: session bootstrapped from seed GOP", { meta: {
12130
- format,
12131
- seedPackets: seed.length
12132
- } });
12133
- }
12134
- this.logger?.info("frame-handle plane: shm decoder session created", { meta: {
12135
- format,
12136
- codec: info.codec,
12137
- sessionId,
12138
- nodeId
12139
- } });
12140
- return true;
12141
- }
12142
- /** Push one decoded `FrameHandle` to every subscription of `format`. */
12143
- fanoutHandle(format, handle) {
12144
- this.rollDecodeFpsWindow();
12145
- this.framesInWindow += 1;
12146
- for (const subscription of this.subscriptions.values()) {
12147
- if (subscription.format !== format) continue;
12148
- subscription.queue.push(handle);
12149
- }
12150
- }
12151
- /** Destroy a decoder session, swallowing teardown errors. */
12152
- async destroySession(session) {
12153
- this.clearLinger(session);
12154
- const proxy = session.proxy;
12155
- session.proxy = null;
12156
- session.nodeId = null;
12157
- if (!proxy) return;
12158
- try {
12159
- await proxy.destroy();
12160
- } catch (err) {
12161
- this.logger?.warn("frame-handle plane: session destroy failed", { meta: {
12162
- format: session.format,
12163
- error: errMsg(err)
12164
- } });
12165
- }
12166
- }
12167
- };
12168
- //#endregion
12169
11345
  //#region src/stream-broker/stream-broker/g711-mulaw.ts
12170
11346
  /**
12171
11347
  * G.711 µ-law encoder — converts 16-bit signed PCM samples to µ-law (ITU-T G.711).
@@ -12862,8 +12038,8 @@ var MAX_RECONNECT_DELAY_MS = 3e4;
12862
12038
  var SUSPEND_GRACE_MS = 5e3;
12863
12039
  /**
12864
12040
  * Grace window before an idle audio-codec decode session is torn down
12865
- * after the last audio consumer detaches. Mirrors the frame-handle /
12866
- * suspend occupancy-linger pattern: a brief gap between consumers (e.g.
12041
+ * after the last audio consumer detaches. Mirrors the suspend linger
12042
+ * pattern: a brief gap between consumers (e.g.
12867
12043
  * a WebRTC renegotiation or an analyzer re-subscribe) must not thrash
12868
12044
  * the upstream decode session create/destroy.
12869
12045
  */
@@ -12901,8 +12077,6 @@ var StreamBroker = class StreamBroker {
12901
12077
  * falls back to the source passed to `start()`.
12902
12078
  */
12903
12079
  sourceProvider = null;
12904
- /** Resolver for this broker's own RTSP restream URL — see `setRestreamUrlResolver`. */
12905
- restreamUrlResolver = null;
12906
12080
  /**
12907
12081
  * Manager-supplied notifier that asks the publishing addon to
12908
12082
  * refresh its loopback transport (e.g. re-run
@@ -12927,25 +12101,8 @@ var StreamBroker = class StreamBroker {
12927
12101
  lastKickAt = 0;
12928
12102
  /** Detected or configured codec — persists across reconnects */
12929
12103
  detectedCodec;
12930
- /**
12931
- * The shared-memory frame-handle surface (Phase 5 / D9) — the broker's sole
12932
- * decoded-frame path. Lazily created on the first `subscribeFrameHandles`
12933
- * call; owns the per-format `frameSink: 'shm'` decoder sessions a handle
12934
- * consumer reads from. `null` until a consumer subscribes.
12935
- */
12936
- frameHandlePlane = null;
12937
- /** One-shot latch for the "seed ring not filled" line — see `pushEncodedPacket`. */
12938
- seedRingSkipLogged = false;
12939
- decoderApi;
12940
12104
  audioCodecApi;
12941
12105
  logger;
12942
- /**
12943
- * This broker's OWN local Moleculer node id, threaded from the manager
12944
- * (`ctx.kernel.localNodeId`). Passed to the `FrameHandlePlane` so it can
12945
- * reject decoder sessions that land on a different node (the shm frame ring
12946
- * is node-local). `null` = unknown → co-location enforcement skipped.
12947
- */
12948
- localNodeId;
12949
12106
  startedAt = Date.now();
12950
12107
  /**
12951
12108
  * How many times `start()` has actually dialed. `> 0` at dial time means the
@@ -13037,9 +12194,9 @@ var StreamBroker = class StreamBroker {
13037
12194
  * The lazy branch is re-entered on every materialize poll tick, so stopping
13038
12195
  * the pump is not enough on its own: the very next tick would re-open a
13039
12196
  * window that real bytes have already closed, and the consumer would get a
13040
- * still picture painted over live video every second. Reset exactly where
13041
- * `firstKeyframeReceived` is — broker stop and suspend — so a battery camera
13042
- * that goes back to sleep gets its sentry again on the next wake.
12197
+ * still picture painted over live video every second. Reset on broker stop
12198
+ * and suspend, so a battery camera that goes back to sleep gets its sentry
12199
+ * again on the next wake.
13043
12200
  */
13044
12201
  sentryWindowClosed = false;
13045
12202
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
@@ -13217,17 +12374,6 @@ var StreamBroker = class StreamBroker {
13217
12374
  pipeServer;
13218
12375
  rtspRestreamer;
13219
12376
  preBuffer;
13220
- /**
13221
- * Keyframe-aligned GOP of the EXACT packets fed to the decoder plane (i.e.
13222
- * param-set prepended). Replayed to a freshly-armed shm decoder session so it
13223
- * initialises on a keyframe (SPS/PPS) instead of the next live P-frame — the
13224
- * decoder cold-start fix for push-mode sessions. Populated for ALL source
13225
- * types (unlike `preBuffer`, which push-only sources fill for the WebRTC
13226
- * late-join path): an rfc4571 Reolink source has an EMPTY `preBuffer`, so this
13227
- * is the only GOP available to seed its decoder. `getPackets()` returns just
13228
- * the current GOP (from the last keyframe), so a modest retention suffices.
13229
- */
13230
- decoderSeedBuffer;
13231
12377
  encodedCallbacks = /* @__PURE__ */ new Map();
13232
12378
  /**
13233
12379
  * Source-RTP video subscribers — receive raw on-wire RTP bytes
@@ -13266,13 +12412,6 @@ var StreamBroker = class StreamBroker {
13266
12412
  * so it gets a scoped logger child.
13267
12413
  */
13268
12414
  audioChunkPlane;
13269
- /**
13270
- * Gate for feeding encoded packets to the decoder: H.264/H.265 decoders
13271
- * need the SPS/PPS carried in the first keyframe to initialise, so the
13272
- * broker withholds packets from the shm frame plane until the first
13273
- * keyframe arrives.
13274
- */
13275
- firstKeyframeReceived = false;
13276
12415
  /** Timer for push-mode stall detection. */
13277
12416
  pushStallTimer;
13278
12417
  /**
@@ -13458,20 +12597,11 @@ var StreamBroker = class StreamBroker {
13458
12597
  * (`isClampedByBytes`).
13459
12598
  */
13460
12599
  static CLIP_RING_MAX_BYTES = 16 * 1024 * 1024;
13461
- /**
13462
- * Retention for the decoder-seed GOP ring. Only ever needs to hold the
13463
- * current GOP (a fresh keyframe evicts the previous GOP on read), so a few
13464
- * seconds covers every realistic keyframe interval; cameras with a GOP longer
13465
- * than this fall back to the plane's live-keyframe bootstrap.
13466
- */
13467
- static DECODER_SEED_BUFFER_SEC = 6;
13468
- constructor(deviceId, decoderApi, logger, audioCodecApi, localNodeId) {
12600
+ constructor(deviceId, logger, audioCodecApi) {
13469
12601
  this.deviceId = deviceId;
13470
12602
  this.numericDeviceId = deviceIdFromBrokerId$1(deviceId);
13471
- this.decoderApi = decoderApi;
13472
12603
  this.audioCodecApi = audioCodecApi ?? null;
13473
12604
  this.logger = logger;
13474
- this.localNodeId = localNodeId ?? null;
13475
12605
  this.pipeServer = new StreamPipeServer(logger?.child("pipe-server"));
13476
12606
  this.rtspRestreamer = new RtspRestreamer(deviceId);
13477
12607
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
@@ -13484,7 +12614,6 @@ var StreamBroker = class StreamBroker {
13484
12614
  };
13485
12615
  });
13486
12616
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
13487
- this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
13488
12617
  this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"), () => this.checkDemand());
13489
12618
  this.rtspRestreamer.onSessionCountChanged(() => this.checkDemand());
13490
12619
  this.pipeServer.onClientCountChanged(() => this.checkDemand());
@@ -13531,22 +12660,6 @@ var StreamBroker = class StreamBroker {
13531
12660
  };
13532
12661
  }
13533
12662
  /**
13534
- * Inject a manager-owned resolver for this broker's OWN RTSP restream URL
13535
- * (`rtsp://127.0.0.1:<port>/<token>[/muted]`). When the source is an RTP
13536
- * source ({@link isRtpSource}), the frame-handle decoder decodes via PULL
13537
- * mode from this restream instead of the broker's hand-depacketized AnnexB
13538
- * push feed — ffmpeg's own RTSP/RTP demuxer does the FU-A reassembly, which
13539
- * is robust where the AnnexB depacketizer chokes on heavily-fragmented 4K
13540
- * NALs (Reolink rfc4571 produced 0 decoded frames on the push path; the same
13541
- * stream decodes at full fps through the RTSP restream): RTP is passed
13542
- * through to the consumer's demuxer.
13543
- * Returns `null` before a restream token exists → falls back to the
13544
- * push path.
13545
- */
13546
- setRestreamUrlResolver(resolver) {
13547
- this.restreamUrlResolver = resolver;
13548
- }
13549
- /**
13550
12663
  * Inject where the "ffmpeg parser" ingress ({@link startFfmpegSourceReader})
13551
12664
  * reads the node's pinned ffmpeg: the manager's live config, asked per dial.
13552
12665
  */
@@ -13881,18 +12994,11 @@ var StreamBroker = class StreamBroker {
13881
12994
  clearTimeout(this.pushStallTimer);
13882
12995
  this.pushStallTimer = void 0;
13883
12996
  }
13884
- if (this.frameHandlePlane) {
13885
- const plane = this.frameHandlePlane;
13886
- this.frameHandlePlane = null;
13887
- await plane.dispose();
13888
- }
13889
12997
  await this.pipeServer.stop();
13890
12998
  this.rtspRestreamer.destroy();
13891
12999
  this.preBuffer.clear();
13892
- this.decoderSeedBuffer.clear();
13893
13000
  this.clipBuffer?.clear();
13894
13001
  this.pendingParamNals = [];
13895
- this.firstKeyframeReceived = false;
13896
13002
  this.encodedCallbacks.clear();
13897
13003
  this.audioChunkPlane.dispose();
13898
13004
  }
@@ -14143,7 +13249,6 @@ var StreamBroker = class StreamBroker {
14143
13249
  hasLiveIntent() {
14144
13250
  if (this.encodedCallbacks.size > 0) return true;
14145
13251
  if (this.rtpVideoCallbacks.size > 0) return true;
14146
- if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
14147
13252
  if (this.audioChunkPlane.subscriberCount > 0) return true;
14148
13253
  if (this.rtspRestreamer.getLiveIntentSessionCount() > 0) return true;
14149
13254
  if (this.pipeServer.getClientCount() > 0) return true;
@@ -14153,9 +13258,9 @@ var StreamBroker = class StreamBroker {
14153
13258
  }
14154
13259
  /**
14155
13260
  * True when at least one consumer needs DECODED audio. The audio-specific
14156
- * subset of {@link hasDemand} — deliberately excludes the video
14157
- * frame-handle plane so a video-only viewer never spins up the
14158
- * AAC/Opus decode chain. `encodedCallbacks` covers WebRTC (its G.711
13261
+ * subset of {@link hasDemand} — deliberately excludes video-only
13262
+ * consumers so a video-only viewer never spins up the AAC/Opus decode
13263
+ * chain. `encodedCallbacks` covers WebRTC (its G.711
14159
13264
  * egress needs decoded PCM); `audioChunkPlane` covers the analyzer.
14160
13265
  */
14161
13266
  hasAudioConsumer() {
@@ -14355,9 +13460,7 @@ var StreamBroker = class StreamBroker {
14355
13460
  this.sentryWindowClosed = false;
14356
13461
  this.reconcilePrestreamWindow();
14357
13462
  this.preBuffer.clear();
14358
- this.decoderSeedBuffer.clear();
14359
13463
  this.clipBuffer?.clear();
14360
- this.firstKeyframeReceived = false;
14361
13464
  this.pendingParamNals = [];
14362
13465
  this.rtspRestreamer.resetStreamState();
14363
13466
  this.totalBytes = 0;
@@ -14495,61 +13598,8 @@ var StreamBroker = class StreamBroker {
14495
13598
  if (packet.type === "video") {
14496
13599
  this.pipeServer.broadcast(packet.data);
14497
13600
  if (!this.rtspRestreamer.rtpPassthroughMode) this.rtspRestreamer.pushPacket(packet);
14498
- if (!this.firstKeyframeReceived && packet.keyframe && packet.isPlaceholder !== true) {
14499
- this.firstKeyframeReceived = true;
14500
- this.logger?.info("First keyframe received — decoder can start");
14501
- }
14502
- }
14503
- if (packet.type === "video" && this.firstKeyframeReceived && packet.isPlaceholder !== true) {
14504
- const plane = this.frameHandlePlane;
14505
- if (plane) {
14506
- const decoderPacket = this.decoderPacketWithParamSets(packet);
14507
- this.decoderSeedBuffer.push(decoderPacket);
14508
- plane.pushPacket(decoderPacket);
14509
- } else if (!this.seedRingSkipLogged) {
14510
- this.seedRingSkipLogged = true;
14511
- this.logger?.info("decoder seed ring not filled — no frame-handle consumer on this broker", {
14512
- tags: { deviceId: this.numericDeviceId },
14513
- meta: { seedSec: StreamBroker.DECODER_SEED_BUFFER_SEC }
14514
- });
14515
- }
14516
13601
  }
14517
13602
  }
14518
- /**
14519
- * Return the packet to feed the decoder plane, prepending the SDP-harvested
14520
- * SPS/PPS to a keyframe that lacks them in-band.
14521
- *
14522
- * The plane seeds a freshly-armed decoder session with the SINGLE keyframe
14523
- * packet that triggered the arm (`armPendingSessions`), so the parameter sets
14524
- * MUST travel inside that same packet — a separate param-set packet pushed
14525
- * just before it reaches only already-live sessions and is lost on the
14526
- * arming one. Unlike the encoded-subscriber path (`emitSdpParamSetsTo`), the
14527
- * decoder plane is otherwise never handed `sdpParameterSets`. Cameras that
14528
- * ship parameter sets ONLY in the SDP (Reolink high-profile native streams)
14529
- * therefore starve the `ffmpeg -i pipe:0` decoder of SPS/PPS, which loops
14530
- * `non-existing PPS → no frame` and produces zero decoded frames (verified
14531
- * live on device 592: WebRTC worked, detection decoded nothing). Only
14532
- * keyframes actually missing the sets are rewritten — well-behaved cameras
14533
- * are returned untouched — and the check runs per keyframe so a decoder
14534
- * restart mid-stream re-primes on the next IDR. The returned object is a
14535
- * fresh copy; the original `packet` fanned out to the other consumers above
14536
- * (WebRTC, restreamers, pre-buffer) is never mutated.
14537
- */
14538
- decoderPacketWithParamSets(packet) {
14539
- if (!packet.keyframe) return packet;
14540
- const paramSets = this.sdpParameterSets;
14541
- if (!paramSets || paramSets.length === 0) return packet;
14542
- if (annexBHasParameterSets(packet.data, packet.codec)) return packet;
14543
- if (this.streamingDebug || CH_RTP.on) this.logger?.info("Prepending SDP param sets to decoder keyframe (in-band SPS/PPS absent)", { meta: {
14544
- codec: packet.codec,
14545
- psCount: paramSets.length,
14546
- keyframeBytes: packet.data.length
14547
- } });
14548
- return {
14549
- ...packet,
14550
- data: Buffer.concat([concatParameterSetsAnnexB(paramSets), packet.data])
14551
- };
14552
- }
14553
13603
  onEncodedData(callback, attribution = UNKNOWN_ATTRIBUTION) {
14554
13604
  this.subscriberIdCounter += 1;
14555
13605
  const entry = {
@@ -14828,7 +13878,7 @@ var StreamBroker = class StreamBroker {
14828
13878
  *
14829
13879
  * Every ring this broker owns is sized from it, each fed by the source type
14830
13880
  * it belongs to: the source-RTP ring (RTSP cameras, the history a join burst
14831
- * is cut from), the Annex-B `preBuffer` (push sources), and the decoder seed.
13881
+ * is cut from) and the Annex-B `preBuffer` (push sources).
14832
13882
  * That is the correction this record exists for — the seconds used to size
14833
13883
  * ONLY the Annex-B ring, which is never fed for an RTP source, so on every
14834
13884
  * warm camera in this fleet the configured seconds retained nothing while two
@@ -14838,10 +13888,7 @@ var StreamBroker = class StreamBroker {
14838
13888
  const next = prebufferRetentionMs(config);
14839
13889
  this.setPreBufferEnabled(config.enabled);
14840
13890
  this.setPreBufferDuration(config.seconds);
14841
- if (next === this.prebufferMs) return;
14842
13891
  this.prebufferMs = next;
14843
- const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
14844
- this.decoderSeedBuffer.setDuration(seedSec);
14845
13892
  }
14846
13893
  /**
14847
13894
  * How far back the ring is kept — the prebuffer, and nothing else.
@@ -14874,33 +13921,6 @@ var StreamBroker = class StreamBroker {
14874
13921
  getPreRollBudget() {
14875
13922
  return preRollBudgetBytes(this.getPreRollRetentionMs(), this.bitrateKbps);
14876
13923
  }
14877
- /**
14878
- * The seed replayed into a freshly-armed PUSH-mode decoder session.
14879
- *
14880
- * The current GOP (`getPackets()`) while pre-roll is off — today's behaviour —
14881
- * and the same keyframe-aligned pre-roll window otherwise, cut from the seed
14882
- * ring's whole retention rather than from its last keyframe. This is the
14883
- * push/Annex-B counterpart of {@link getRtpPreRollBuffer}: RTP sources decode
14884
- * in PULL mode off the restream and take their pre-roll as the join burst,
14885
- * push sources are fed packet by packet and take it here. One selector serves
14886
- * both, so a fix to the boundary cannot land on only one plane.
14887
- */
14888
- getDecoderSeedPackets() {
14889
- if (this.prebufferMs <= 0) return this.decoderSeedBuffer.getPackets();
14890
- const all = this.decoderSeedBuffer.getAllPackets();
14891
- if (all.length === 0) return [];
14892
- const newestPts = all[all.length - 1].pts;
14893
- const marks = [];
14894
- for (let i = 0; i < all.length; i++) {
14895
- const packet = all[i];
14896
- if (packet.keyframe && packet.type === "video") marks.push({
14897
- index: i,
14898
- ageMs: newestPts - packet.pts
14899
- });
14900
- }
14901
- const selection = selectPreRollStart(marks, this.prebufferMs);
14902
- return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
14903
- }
14904
13924
  /** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
14905
13925
  rtpPreRollMarks(newestTs) {
14906
13926
  return this.rtpRingKeyframeMarks.map((mark) => ({
@@ -15034,7 +14054,7 @@ var StreamBroker = class StreamBroker {
15034
14054
  * does NOT re-emit EncodedPackets; it only derives the per-packet
15035
14055
  * keyframe/param-set flags the pre-buffer capture needs (via a
15036
14056
  * flag-only depacketizer that never touches the AnnexB plane), keeping
15037
- * the AnnexB consumers (decoder, restreamer) single-fed by the provider.
14057
+ * the AnnexB consumers (pipe, restreamer) single-fed by the provider.
15038
14058
  *
15039
14059
  * The pre-buffer is captured even with zero subscribers attached — a
15040
14060
  * viewer that connects mid-GOP must be able to replay the GOP that
@@ -15062,7 +14082,7 @@ var StreamBroker = class StreamBroker {
15062
14082
  * EncodedPackets via `handleDepacketizedNal`), this callback ONLY sets
15063
14083
  * `_lastNalKeyframe`/`_lastNalParamSet` — the push provider owns the
15064
14084
  * AnnexB plane through `pushEncodedPacket`, so re-emitting here would
15065
- * double-feed the decoder/restreamer. push-rtp has no SDP `onVideoTrack`,
14085
+ * double-feed the pipe/restreamer. push-rtp has no SDP `onVideoTrack`,
15066
14086
  * so the codec comes from the source declaration (`detectedCodec`, seeded
15067
14087
  * in `start()`). Dropped on teardown (`resetRtpPipeline`) so a redial
15068
14088
  * starts with clean FU reassembly state.
@@ -15136,82 +14156,11 @@ var StreamBroker = class StreamBroker {
15136
14156
  if (released) this.checkDemand();
15137
14157
  return released;
15138
14158
  }
15139
- /**
15140
- * Resolve the stream descriptor the `FrameHandlePlane` needs to create a
15141
- * `frameSink: 'shm'` decoder session — codec, numeric device id, log tag,
15142
- * and (for a pull-mode decoder) the local pipe URL. Returns `null` before
15143
- * the broker has a source.
15144
- */
15145
- resolveFrameHandleStreamInfo() {
15146
- const codec = this.detectedCodec ?? this.source?.videoCodec;
15147
- if (!codec || !this.source) return null;
15148
- const slash = this.deviceId.indexOf("/");
15149
- const numericDeviceId = slash >= 0 ? Number.parseInt(this.deviceId.slice(0, slash), 10) : Number.parseInt(this.deviceId, 10);
15150
- const pullModeUrl = this.isRtpSource() ? this.restreamUrlResolver?.() ?? null : null;
15151
- return {
15152
- codec,
15153
- ...Number.isFinite(numericDeviceId) ? { numericDeviceId } : {},
15154
- tag: `broker:${this.deviceId}`,
15155
- pullModeUrl
15156
- };
15157
- }
15158
- /** Lazily construct the frame-handle plane, returning `null` with no decoder. */
15159
- ensureFrameHandlePlane() {
15160
- if (this.frameHandlePlane) return this.frameHandlePlane;
15161
- if (!this.decoderApi) {
15162
- this.logger?.warn("subscribeFrameHandles: no decoder API — frame handles unavailable");
15163
- return null;
15164
- }
15165
- this.frameHandlePlane = new FrameHandlePlane({
15166
- decoderApi: this.decoderApi,
15167
- logger: this.logger?.child("frame-handle-plane"),
15168
- resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
15169
- localNodeId: this.localNodeId,
15170
- getSeedPackets: () => this.getDecoderSeedPackets(),
15171
- isDebugEnabled: () => this.streamingDebug
15172
- });
15173
- return this.frameHandlePlane;
15174
- }
15175
- /**
15176
- * Subscribe to this broker's shared-memory frame-handle stream — the broker's
15177
- * decoded-frame surface. The broker spins up (or reuses) a `frameSink: 'shm'`
15178
- * decoder session producing `input.format` and returns a `subscriptionId`
15179
- * the consumer polls via `pullFrameHandles`. fps throttling is implicit
15180
- * (latest-wins ring reads); `input.maxFps` is echoed as a cadence hint.
15181
- */
15182
- async subscribeFrameHandles(input) {
15183
- const plane = this.ensureFrameHandlePlane();
15184
- if (!plane) throw new Error("stream-broker: no decoder available for frame-handle subscription");
15185
- const { subscriptionId, maxFps } = await plane.subscribe({
15186
- format: input.format,
15187
- maxFps: input.maxFps,
15188
- tag: input.tag
15189
- });
15190
- this.checkDemand();
15191
- return {
15192
- subscriptionId,
15193
- maxFps
15194
- };
15195
- }
15196
- /** Drain up to `maxCount` `FrameHandle`s for a frame-handle subscription. */
15197
- pullFrameHandles(subscriptionId, maxCount) {
15198
- return this.frameHandlePlane?.pullHandles(subscriptionId, maxCount) ?? [];
15199
- }
15200
- /** Release a frame-handle subscription. Returns `true` when it existed. */
15201
- async unsubscribeFrameHandles(subscriptionId) {
15202
- const plane = this.frameHandlePlane;
15203
- if (!plane) return false;
15204
- const released = await plane.unsubscribe(subscriptionId);
15205
- if (released && plane.subscriberCount === 0) this.checkDemand();
15206
- return released;
15207
- }
15208
14159
  getStats() {
15209
14160
  return {
15210
14161
  status: this._suspended ? "idle" : this._status,
15211
14162
  inputFps: this.encodedInputFps,
15212
- decodeFps: this.frameHandlePlane?.decodeFps() ?? 0,
15213
14163
  encodedSubscribers: this.encodedCallbacks.size + this.rtpVideoCallbacks.size,
15214
- decodedSubscribers: this.frameHandlePlane?.subscriberCount ?? 0,
15215
14164
  uptimeMs: Date.now() - this.startedAt,
15216
14165
  bitrateKbps: this.bitrateKbps,
15217
14166
  idrIntervalMs: this.idrIntervalMs,
@@ -15223,7 +14172,6 @@ var StreamBroker = class StreamBroker {
15223
14172
  preBufferSec: this.preBuffer.getDuration(),
15224
14173
  preBufferMs: this.preBuffer.getBufferedDurationMs(),
15225
14174
  preBufferPackets: this.preBuffer.getPacketCount(),
15226
- decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
15227
14175
  audio: this.audioTrackInfo ?? null,
15228
14176
  audioMuted: this.audioMuted,
15229
14177
  audioMutedDropped: this.audioMutedDropped
@@ -15231,8 +14179,8 @@ var StreamBroker = class StreamBroker {
15231
14179
  }
15232
14180
  /**
15233
14181
  * Per-broker consumer inventory (Phase 10). Returns the live subscriber
15234
- * list grouped by transport — every RTSP client, decoded-frame
15235
- * subscriber, and audio-chunk subscriber. Used by the admin UI's
14182
+ * list grouped by transport — every RTSP client, encoded subscriber and
14183
+ * audio-chunk subscriber. Used by the admin UI's
15236
14184
  * Streams tab and by operators diagnosing "phantom" holders keeping a
15237
14185
  * broker open.
15238
14186
  */
@@ -15266,13 +14214,6 @@ var StreamBroker = class StreamBroker {
15266
14214
  });
15267
14215
  return {
15268
14216
  rtsp: this.rtspRestreamer.listSessionInfos(),
15269
- decoded: (this.frameHandlePlane?.listSubscribers() ?? []).map((s) => ({
15270
- tag: s.tag,
15271
- subscribedAt: s.subscribedAt,
15272
- maxFps: s.maxFps,
15273
- framesDelivered: s.framesDelivered,
15274
- framesDropped: 0
15275
- })),
15276
14217
  audio: this.audioChunkPlane.listSubscribers().map((s) => ({
15277
14218
  tag: s.tag,
15278
14219
  subscribedAt: s.subscribedAt,
@@ -15286,33 +14227,12 @@ var StreamBroker = class StreamBroker {
15286
14227
  /**
15287
14228
  * Force-disconnect a single consumer. Matches by channel:
15288
14229
  * - `rtsp`: by `sessionId` (calls `RtspRestreamer.killSession`).
15289
- * - `decoded`: by `tag` — releases every shm frame-handle subscription
15290
- * whose tag matches (the plane winds the decoder session down once its
15291
- * last subscriber leaves).
15292
14230
  * - `audio`: by `tag` — drops every audio subscriber whose tag matches,
15293
14231
  * then re-evaluates demand.
15294
14232
  * Returns `true` when at least one consumer was dropped.
15295
14233
  */
15296
14234
  killClient(channel, handle) {
15297
14235
  if (channel === "rtsp") return this.rtspRestreamer.killSession(handle);
15298
- if (channel === "decoded") {
15299
- const plane = this.frameHandlePlane;
15300
- if (!plane) return false;
15301
- if (!plane.listSubscribers().some((s) => s.tag === handle)) return false;
15302
- plane.killByTag(handle).then((killed) => {
15303
- this.logger?.info("frame-handle subscribers force-disconnected", { meta: {
15304
- handle,
15305
- killed
15306
- } });
15307
- this.checkDemand();
15308
- }).catch((err) => {
15309
- this.logger?.warn("frame-handle killByTag failed", { meta: {
15310
- handle,
15311
- error: errMsg(err)
15312
- } });
15313
- });
15314
- return true;
15315
- }
15316
14236
  const killed = this.audioChunkPlane.killByTag(handle);
15317
14237
  if (killed === 0) return false;
15318
14238
  this.logger?.info("audio subscribers force-disconnected", { meta: {
@@ -15566,7 +14486,7 @@ var StreamBroker = class StreamBroker {
15566
14486
  * Shared RTP-stream callback factory. Used by both the native RTSP
15567
14487
  * reader and the RFC 4571 reader — they ingest RTP from different
15568
14488
  * transports but the broker's downstream pipeline (depacketize →
15569
- * AnnexB → prebuffer / pipe / decoder / restream) is identical.
14489
+ * AnnexB → prebuffer / pipe / restream) is identical.
15570
14490
  *
15571
14491
  * `label` is the source-tag we attach to log messages so it's clear
15572
14492
  * which transport surfaced the event. `onTerminate` is the
@@ -15800,7 +14720,7 @@ var StreamBroker = class StreamBroker {
15800
14720
  /**
15801
14721
  * Handle a complete NAL from the RTP depacketizer.
15802
14722
  * Converts to EncodedPacket and feeds all Annex-B consumers
15803
- * (prebuffer, pipe server, decoder, restreamers).
14723
+ * (prebuffer, pipe server, restreamers).
15804
14724
  *
15805
14725
  * Called synchronously from processPacket — sets _lastNalKeyframe and
15806
14726
  * _lastNalParamSet so the onVideoRtp callback can forward them to the
@@ -15810,7 +14730,7 @@ var StreamBroker = class StreamBroker {
15810
14730
  * Pending parameter-set NALs (SPS/PPS for H.264, VPS/SPS/PPS for H.265).
15811
14731
  * Cameras emit these as separate RTP packets before the IDR/IRAP frame.
15812
14732
  * We buffer them here and prepend to the next VCL NAL so downstream
15813
- * consumers (WebRTC, prebuffer, decoder) receive complete access units
14733
+ * consumers (WebRTC, prebuffer, restream) receive complete access units
15814
14734
  * with a single `EncodedPacket` that contains SPS+PPS+IDR together.
15815
14735
  */
15816
14736
  pendingParamNals = [];
@@ -16167,32 +15087,6 @@ var StreamBroker = class StreamBroker {
16167
15087
  this._status = "error";
16168
15088
  }, PUSH_STALL_TIMEOUT_MS);
16169
15089
  }
16170
- /**
16171
- * Moleculer nodeID of a decoder provider hosting one of this broker's shm
16172
- * frame-plane sessions. `null` when the plane has no session. Used by the
16173
- * broker-manager's `rotateDecodersOnNode` to drop only the sessions
16174
- * affected by a per-agent event (e.g. an hwaccel override change).
16175
- */
16176
- getDecoderNodeId() {
16177
- return this.frameHandlePlane?.decoderNodeIds()[0] ?? null;
16178
- }
16179
- /**
16180
- * Force this broker's shm frame-plane decoder sessions hosted on
16181
- * `agentNodeId` to rotate — destroy and rebuild so they re-pull the latest
16182
- * per-agent preferences (hwaccel backend, …). A no-op when the broker has
16183
- * no frame plane. Subscriptions survive the rotation.
16184
- */
16185
- async rotateDecoderSession(reason, agentNodeId) {
16186
- const plane = this.frameHandlePlane;
16187
- if (!plane) return;
16188
- const target = agentNodeId ?? plane.decoderNodeIds()[0]?.split("/")[0];
16189
- if (!target) return;
16190
- this.logger?.info("rotating shm decoder sessions", { meta: {
16191
- reason,
16192
- agentNodeId: target
16193
- } });
16194
- await plane.rotate(target, reason);
16195
- }
16196
15090
  destroyNativeClient() {
16197
15091
  if (this.nativeClient) {
16198
15092
  this.nativeClient.destroy();
@@ -16206,7 +15100,7 @@ var StreamBroker = class StreamBroker {
16206
15100
  * Reolink lib's `createRfc4571TcpServer`). Same downstream pipeline as
16207
15101
  * the native RTSP reader: SDP arrives out-of-band on the StreamSource,
16208
15102
  * RTP packets feed the same depacketizer, AnnexB output goes through
16209
- * the same prebuffer / pipe / decoder paths.
15103
+ * the same prebuffer / pipe / restream paths.
16210
15104
  *
16211
15105
  * `source.metadata.sdp` is required and must describe the video
16212
15106
  * (and optionally audio) track the publisher is multiplexing on the
@@ -17398,15 +16292,9 @@ var StreamBrokerManager = class StreamBrokerManager {
17398
16292
  streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
17399
16293
  streamHealthTimer;
17400
16294
  /**
17401
- * Routes a frame-handle `subscriptionId` (Phase 5 / D9) back to its owning
17402
- * brokerId so `pullFrameHandles` / `unsubscribeFrames` resolve in O(1)
17403
- * without scanning every broker.
17404
- */
17405
- frameSubscriptionBroker = /* @__PURE__ */ new Map();
17406
- /**
17407
16295
  * Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
17408
16296
  * brokerId so `pullAudioChunks` / `unsubscribeAudioChunks` resolve in O(1)
17409
- * without scanning every broker. Mirrors `frameSubscriptionBroker`.
16297
+ * without scanning every broker.
17410
16298
  */
17411
16299
  audioSubscriptionBroker = /* @__PURE__ */ new Map();
17412
16300
  /**
@@ -17423,7 +16311,6 @@ var StreamBrokerManager = class StreamBrokerManager {
17423
16311
  * it down on idle / retract. Populated by {@link attachDerivedFeeder}.
17424
16312
  */
17425
16313
  derivedFeeders = /* @__PURE__ */ new Map();
17426
- staticDecoders;
17427
16314
  logger;
17428
16315
  /** Public accessor for same-process callers (e.g. `CameraStreamsProvider.pickStream`)
17429
16316
  * that want to emit diagnostics through the manager's logger. Internal callers
@@ -17554,12 +16441,12 @@ var StreamBrokerManager = class StreamBrokerManager {
17554
16441
  /**
17555
16442
  * Injected factory for the inner broker a `RemoteEncodedSourceBroker`
17556
16443
  * drives. Production wraps the real `StreamBroker` — this leg never decodes
17557
- * locally, so the decoder/audio-codec caps and local-node pin are unused
17558
- * (mirrors the recommendation in `RemoteEncodedSourceBroker`'s doc comment).
16444
+ * audio locally, so the audio-codec cap is unused (mirrors the
16445
+ * recommendation in `RemoteEncodedSourceBroker`'s doc comment).
17559
16446
  * Tests substitute a fake via `setRemoteEncodedBrokerFactory` so no real
17560
16447
  * RTSP dial happens.
17561
16448
  */
17562
- createRemoteEncodedBroker = (deps) => new StreamBroker(deps.brokerId, null, deps.logger, null, null);
16449
+ createRemoteEncodedBroker = (deps) => new StreamBroker(deps.brokerId, deps.logger, null);
17563
16450
  /**
17564
16451
  * Transcode-pipeline manager — backs `getStreamWithCodec`. Lazily
17565
16452
  * created on first acquire, lives until `destroyAll`.
@@ -17611,8 +16498,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17611
16498
  hwAccel: "copy",
17612
16499
  threadCount: 0
17613
16500
  };
17614
- constructor(staticDecoders, logger) {
17615
- this.staticDecoders = staticDecoders ?? [];
16501
+ constructor(logger) {
17616
16502
  this.logger = logger;
17617
16503
  this.rtspProvider = new RtspRestreamProviderImpl(this.rtspServer, logger.child("rtsp-restream"));
17618
16504
  }
@@ -17879,11 +16765,12 @@ var StreamBrokerManager = class StreamBrokerManager {
17879
16765
  }
17880
16766
  /** The decoder addon's answer for this node — before the process quarantine. */
17881
16767
  async readEgressHwAccel(deviceId, override) {
17882
- const decoderApi = this.buildDecoderCapApi(deviceId);
17883
16768
  return resolveEgressDecodeHwAccel({
17884
16769
  readDecoderBackend: async () => {
17885
- if (!decoderApi) throw new Error("decoder cap not reachable from this broker");
17886
- return (await decoderApi.getInfo()).probedBestHwaccel ?? "";
16770
+ const api = this.api;
16771
+ if (!api) throw new Error("decoder cap not reachable from this broker");
16772
+ const pin = this.localNodeId ? nodePin(this.localNodeId) : void 0;
16773
+ return (await api.decoder.getInfo.query(void 0, pin)).probedBestHwaccel ?? "";
17887
16774
  },
17888
16775
  readKernelPreferred: async () => {
17889
16776
  if (!this.hwaccel) return [];
@@ -17909,173 +16796,9 @@ var StreamBrokerManager = class StreamBrokerManager {
17909
16796
  async releaseEgressTranscode(input) {
17910
16797
  return this.getEgressTranscodeManager().release(input);
17911
16798
  }
17912
- buildDecoderCapApi(_deviceId) {
17913
- const localApi = this.buildLocalDecoderCapApi();
17914
- const apiDecoder = () => {
17915
- if (!this.api) return null;
17916
- const decoder = this.api["decoder"];
17917
- return decoder ? decoder : null;
17918
- };
17919
- const pin = this.localNodeId ? nodePin(this.localNodeId) : void 0;
17920
- return {
17921
- supportsCodec: async (input) => {
17922
- const proxy = apiDecoder();
17923
- if (proxy) return proxy.supportsCodec.query(input, pin);
17924
- if (!localApi) return false;
17925
- return localApi.supportsCodec(input);
17926
- },
17927
- getInfo: async () => {
17928
- const proxy = apiDecoder();
17929
- if (proxy) return proxy.getInfo.query(void 0, pin);
17930
- if (!localApi) return {
17931
- id: "none",
17932
- name: "No decoder"
17933
- };
17934
- return localApi.getInfo();
17935
- },
17936
- createSession: async (config) => {
17937
- const proxy = apiDecoder();
17938
- if (proxy) return proxy.createSession.query(config, pin);
17939
- if (!localApi) throw new Error("No decoder provider available");
17940
- return localApi.createSession(config);
17941
- },
17942
- pushPacket: async (input) => {
17943
- const proxy = apiDecoder();
17944
- if (proxy) return proxy.pushPacket.query(input, pin);
17945
- if (!localApi) return;
17946
- return localApi.pushPacket(input);
17947
- },
17948
- pullFrames: async (input) => {
17949
- const proxy = apiDecoder();
17950
- if (proxy) return proxy.pullFrames.query(input, pin);
17951
- if (!localApi) return [];
17952
- return localApi.pullFrames(input);
17953
- },
17954
- pullHandles: async (input) => {
17955
- const proxy = apiDecoder();
17956
- if (proxy) return proxy.pullHandles.query(input, pin);
17957
- if (!localApi) return [];
17958
- return localApi.pullHandles(input);
17959
- },
17960
- destroySession: async (input) => {
17961
- const proxy = apiDecoder();
17962
- if (proxy) return proxy.destroySession.query(input, pin);
17963
- if (!localApi) return;
17964
- return localApi.destroySession(input);
17965
- },
17966
- openStream: async (input) => {
17967
- const proxy = apiDecoder();
17968
- if (proxy) return proxy.openStream.query(input, pin);
17969
- if (!localApi) return;
17970
- return localApi.openStream(input);
17971
- },
17972
- updateConfig: async (input) => {
17973
- const proxy = apiDecoder();
17974
- if (proxy) return proxy.updateConfig.query(input, pin);
17975
- if (!localApi) return;
17976
- return localApi.updateConfig(input);
17977
- },
17978
- getStats: async (input) => {
17979
- const proxy = apiDecoder();
17980
- if (proxy) return proxy.getStats.query(input, pin);
17981
- if (!localApi) return {
17982
- inputFps: 0,
17983
- outputFps: 0,
17984
- avgDecodeTimeMs: 0,
17985
- droppedFrames: 0
17986
- };
17987
- return localApi.getStats(input);
17988
- }
17989
- };
17990
- }
17991
- buildLocalDecoderCapApi() {
17992
- if (this.staticDecoders.length === 0) return null;
17993
- const FRAME_BUFFER_CAPACITY = 32;
17994
- const frameBuffers = /* @__PURE__ */ new Map();
17995
- const sessions = /* @__PURE__ */ new Map();
17996
- const unsubscribers = /* @__PURE__ */ new Map();
17997
- let nextId = 0;
17998
- const resolveDecoder = async (codec) => {
17999
- for (const p of this.staticDecoders) if (await p.supportsCodec({ codec })) return p;
18000
- return null;
18001
- };
18002
- return {
18003
- supportsCodec: async (input) => await resolveDecoder(input.codec) !== null,
18004
- getInfo: async () => {
18005
- const provider = this.staticDecoders[0];
18006
- if (!provider) return {
18007
- id: "none",
18008
- name: "No decoder"
18009
- };
18010
- return {
18011
- id: provider.id,
18012
- name: provider.name,
18013
- isPullMode: provider.isPullMode,
18014
- priority: provider.priority
18015
- };
18016
- },
18017
- createSession: async (config) => {
18018
- const provider = await resolveDecoder(config.codec);
18019
- if (!provider) throw new Error(`No decoder provider for codec "${config.codec}"`);
18020
- const session = await provider.createSession(config);
18021
- const sessionId = `dec-${++nextId}`;
18022
- sessions.set(sessionId, session);
18023
- const buffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
18024
- frameBuffers.set(sessionId, buffer);
18025
- const unsub = session.onFrame((frame) => {
18026
- buffer.push(frame);
18027
- });
18028
- unsubscribers.set(sessionId, unsub);
18029
- return {
18030
- sessionId,
18031
- nodeId: "local"
18032
- };
18033
- },
18034
- pushPacket: async (input) => {
18035
- const session = sessions.get(input.sessionId);
18036
- if (session) session.pushPacket(input.packet);
18037
- },
18038
- pullFrames: async (input) => {
18039
- const buffer = frameBuffers.get(input.sessionId);
18040
- if (!buffer) return [];
18041
- return buffer.drain(input.maxCount);
18042
- },
18043
- pullHandles: async () => [],
18044
- destroySession: async (input) => {
18045
- const session = sessions.get(input.sessionId);
18046
- if (session) {
18047
- const unsub = unsubscribers.get(input.sessionId);
18048
- if (unsub) unsub();
18049
- sessions.delete(input.sessionId);
18050
- frameBuffers.delete(input.sessionId);
18051
- unsubscribers.delete(input.sessionId);
18052
- await session.destroy();
18053
- }
18054
- },
18055
- openStream: async (input) => {
18056
- const session = sessions.get(input.sessionId);
18057
- if (session?.openStream) await session.openStream(input.url);
18058
- },
18059
- updateConfig: async (input) => {
18060
- const session = sessions.get(input.sessionId);
18061
- if (session) session.updateConfig(input.config);
18062
- },
18063
- getStats: async (input) => {
18064
- const session = sessions.get(input.sessionId);
18065
- if (!session) return {
18066
- inputFps: 0,
18067
- outputFps: 0,
18068
- avgDecodeTimeMs: 0,
18069
- droppedFrames: 0
18070
- };
18071
- return session.getStats();
18072
- }
18073
- };
18074
- }
18075
16799
  /**
18076
- * Wire the addon's `ctx.api` proxy. Required for the broker to
18077
- * resolve the `decoder` capability via tRPC — see
18078
- * `buildDecoderCapApi` for the full rationale.
16800
+ * Wire the addon's `ctx.api` proxy — the broker's route to other caps
16801
+ * (the stream catalog, the decoder's per-node hwaccel probe, …).
18079
16802
  */
18080
16803
  setApiAccess(api, localNodeId, hwaccel) {
18081
16804
  this.api = api;
@@ -20068,59 +18791,20 @@ var StreamBrokerManager = class StreamBrokerManager {
20068
18791
  * Resolve the live `StreamBroker` instance for a broker id. In-process
20069
18792
  * only — a `StreamBroker` is not tRPC-serialisable, so this is reachable
20070
18793
  * solely from same-process callers (device-scoped providers, the WebRTC
20071
- * server). Cross-process frame access goes through the shm `subscribeFrames`
20072
- * surface; cross-process stats through `getBrokerStats` / `listClients`.
18794
+ * server). Cross-process stats go through `getBrokerStats` / `listClients`.
20073
18795
  */
20074
18796
  async getBroker(input) {
20075
18797
  return this.brokers.get(input.brokerId) ?? null;
20076
18798
  }
20077
18799
  /**
20078
- * Open a `FrameHandle` subscription on a broker — the handle-based
20079
- * replacement for the live-object `onDecodedFrame` callback. Routed over
20080
- * tRPC, so a consumer in a different process can subscribe; the consumer
20081
- * then polls `pullFrameHandles` and feeds each handle to a
20082
- * `FrameRingReader`.
18800
+ * Drop every `audioSubscriptionBroker` entry pointing at `brokerId`. Called
18801
+ * on broker teardown so a destroyed broker leaves no dangling
18802
+ * `subscriptionId → brokerId` rows (a slow consumer that never calls
18803
+ * `unsubscribeAudioChunks` would otherwise leak a map entry per stream).
20083
18804
  */
20084
- async subscribeFrames(input) {
20085
- await this.ensureBrokerForId(input.brokerId);
20086
- const broker = this.brokers.get(input.brokerId);
20087
- if (!broker) throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
20088
- const result = await broker.subscribeFrameHandles({
20089
- format: input.format,
20090
- maxFps: input.maxFps,
20091
- tag: input.tag
20092
- });
20093
- this.frameSubscriptionBroker.set(result.subscriptionId, input.brokerId);
20094
- return result;
20095
- }
20096
- /** Drain `FrameHandle`s for a subscription opened via `subscribeFrames`. */
20097
- async pullFrameHandles(input) {
20098
- const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
20099
- if (!brokerId) return [];
20100
- const broker = this.brokers.get(brokerId);
20101
- if (!broker) return [];
20102
- return broker.pullFrameHandles(input.subscriptionId, input.maxCount);
20103
- }
20104
- /**
20105
- * Drop every `frameSubscriptionBroker` + `audioSubscriptionBroker` entry
20106
- * pointing at `brokerId`. Called on broker teardown so a destroyed broker
20107
- * leaves no dangling `subscriptionId → brokerId` rows (a slow consumer that
20108
- * never calls `unsubscribeFrames` / `unsubscribeAudioChunks` would otherwise
20109
- * leak a map entry per stream).
20110
- */
20111
- sweepFrameSubscriptions(brokerId) {
20112
- for (const [subscriptionId, mappedBrokerId] of this.frameSubscriptionBroker) if (mappedBrokerId === brokerId) this.frameSubscriptionBroker.delete(subscriptionId);
18805
+ sweepAudioSubscriptions(brokerId) {
20113
18806
  for (const [subscriptionId, mappedBrokerId] of this.audioSubscriptionBroker) if (mappedBrokerId === brokerId) this.audioSubscriptionBroker.delete(subscriptionId);
20114
18807
  }
20115
- /** Release a frame-handle subscription. */
20116
- async unsubscribeFrames(input) {
20117
- const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
20118
- if (!brokerId) return { released: false };
20119
- this.frameSubscriptionBroker.delete(input.subscriptionId);
20120
- const broker = this.brokers.get(brokerId);
20121
- if (!broker) return { released: false };
20122
- return { released: await broker.unsubscribeFrameHandles(input.subscriptionId) };
20123
- }
20124
18808
  /**
20125
18809
  * Open a decoded audio-chunk subscription on a broker — the poll-based,
20126
18810
  * tRPC-reachable replacement for the live-object `onDecodedAudioChunk`
@@ -20161,9 +18845,7 @@ var StreamBrokerManager = class StreamBrokerManager {
20161
18845
  if (!broker) return {
20162
18846
  status: "stopped",
20163
18847
  inputFps: 0,
20164
- decodeFps: 0,
20165
18848
  encodedSubscribers: 0,
20166
- decodedSubscribers: 0,
20167
18849
  uptimeMs: 0,
20168
18850
  bitrateKbps: 0,
20169
18851
  idrIntervalMs: 0,
@@ -20174,7 +18856,6 @@ var StreamBrokerManager = class StreamBrokerManager {
20174
18856
  preBufferSec: 0,
20175
18857
  preBufferMs: 0,
20176
18858
  preBufferPackets: 0,
20177
- decoderNodeId: null,
20178
18859
  audio: null
20179
18860
  };
20180
18861
  return broker.getStats();
@@ -20183,7 +18864,6 @@ var StreamBrokerManager = class StreamBrokerManager {
20183
18864
  const broker = this.brokers.get(input.brokerId);
20184
18865
  if (!broker) return {
20185
18866
  rtsp: [],
20186
- decoded: [],
20187
18867
  audio: [],
20188
18868
  encoded: [],
20189
18869
  pipeClients: 0,
@@ -20196,28 +18876,6 @@ var StreamBrokerManager = class StreamBrokerManager {
20196
18876
  if (!broker) return { killed: false };
20197
18877
  return { killed: broker.killClient(input.channel, input.handle) };
20198
18878
  }
20199
- async rotateDecodersOnNode(agentNodeId, reason) {
20200
- let rotated = 0;
20201
- for (const broker of this.brokers.values()) {
20202
- if (!(broker.getDecoderNodeId()?.split("/")[0] === agentNodeId)) continue;
20203
- try {
20204
- await broker.rotateDecoderSession(reason, agentNodeId);
20205
- rotated++;
20206
- } catch (err) {
20207
- const parsedBrokerId = parseBrokerId(broker.deviceId);
20208
- this.logger.warn("decoder rotation failed", {
20209
- ...parsedBrokerId ? { tags: { deviceId: parsedBrokerId.deviceId } } : {},
20210
- meta: {
20211
- brokerId: broker.deviceId,
20212
- agentNodeId,
20213
- reason,
20214
- error: errMsg(err)
20215
- }
20216
- });
20217
- }
20218
- }
20219
- return rotated;
20220
- }
20221
18879
  /** Raw broker instances — internal use only. */
20222
18880
  getBrokerInstances() {
20223
18881
  return [...this.brokers.values()];
@@ -20318,7 +18976,6 @@ var StreamBrokerManager = class StreamBrokerManager {
20318
18976
  this.brokers.clear();
20319
18977
  this.streamHealthByBroker.clear();
20320
18978
  this.streamOfflineReasonByBroker.clear();
20321
- this.frameSubscriptionBroker.clear();
20322
18979
  this.audioSubscriptionBroker.clear();
20323
18980
  await this.rtspServer.stop();
20324
18981
  }
@@ -22116,8 +20773,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22116
20773
  camStreamId
22117
20774
  });
22118
20775
  const source = this.buildSourceFromCamStream(cam);
22119
- const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
22120
- broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
20776
+ const broker = new StreamBroker(brokerId, brokerLogger, buildAudioCodecCapApi(this.api));
22121
20777
  this.bindBrokerFfmpeg(broker);
22122
20778
  if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
22123
20779
  this.applyPrebufferConfig(broker, deviceId, camStreamId);
@@ -22183,7 +20839,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22183
20839
  await broker.stop();
22184
20840
  this.brokers.delete(brokerId);
22185
20841
  this.sentryFrames.delete(brokerId);
22186
- this.sweepFrameSubscriptions(brokerId);
20842
+ this.sweepAudioSubscriptions(brokerId);
22187
20843
  this.rtspProvider.persistTokens();
22188
20844
  this.syncProfileRestreamers(deviceId);
22189
20845
  }
@@ -37654,7 +36310,6 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
37654
36310
  resolution: null,
37655
36311
  status: null,
37656
36312
  inputFps: null,
37657
- decodeFps: null,
37658
36313
  bitrateKbps: null
37659
36314
  });
37660
36315
  for (const slot of assignedSlots) {
@@ -37674,7 +36329,6 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
37674
36329
  resolution: cam?.resolution ?? slot.resolution ?? null,
37675
36330
  status: stats?.status ?? null,
37676
36331
  inputFps: stats?.inputFps ?? null,
37677
- decodeFps: stats?.decodeFps ?? null,
37678
36332
  bitrateKbps: stats?.bitrateKbps ?? null
37679
36333
  });
37680
36334
  }
@@ -37696,7 +36350,6 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
37696
36350
  resolution: cam.resolution ?? null,
37697
36351
  status: stats?.status ?? null,
37698
36352
  inputFps: stats?.inputFps ?? null,
37699
- decodeFps: stats?.decodeFps ?? null,
37700
36353
  bitrateKbps: stats?.bitrateKbps ?? null
37701
36354
  });
37702
36355
  }
@@ -38068,7 +36721,7 @@ var StreamBrokerAddon = class extends BaseAddon {
38068
36721
  this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
38069
36722
  } });
38070
36723
  this.loopStallMonitor.start();
38071
- this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
36724
+ this.brokerManager = new StreamBrokerManager(this.ctx.logger);
38072
36725
  this.brokerManager.setCostRegistry(this.costRegistry);
38073
36726
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
38074
36727
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
@@ -38190,47 +36843,6 @@ var StreamBrokerAddon = class extends BaseAddon {
38190
36843
  return { rtspTokenRowsDeleted };
38191
36844
  });
38192
36845
  await this.brokerManager.startRtspServer(this.config.rtspPort);
38193
- this.subscribe({ category: EventCategory.PipelineAgentHwaccelChanged }, (event) => {
38194
- const { agentNodeId, reason } = event.data;
38195
- if (typeof agentNodeId !== "string" || agentNodeId.length === 0) return;
38196
- const manager = this.brokerManager;
38197
- if (!manager) return;
38198
- const reasonLabel = typeof reason === "string" ? reason : "hwaccel-changed";
38199
- manager.rotateDecodersOnNode(agentNodeId, reasonLabel).then((rotated) => {
38200
- if (rotated > 0) this.ctx.logger.info("decoders rotated for hwaccel change", {
38201
- tags: { nodeId: agentNodeId },
38202
- meta: {
38203
- rotated,
38204
- reason: reasonLabel
38205
- }
38206
- });
38207
- }).catch((err) => {
38208
- this.ctx.logger.warn("hwaccel-driven decoder rotation failed", {
38209
- tags: { nodeId: agentNodeId },
38210
- meta: { error: errMsg(err) }
38211
- });
38212
- });
38213
- });
38214
- this.watchCapability(["decoder"], { onDown: (nodeId, capName) => {
38215
- if (capName !== "decoder") return;
38216
- const manager = this.brokerManager;
38217
- if (!manager) return;
38218
- const reasonLabel = `decoder-down:${nodeId}`;
38219
- manager.rotateDecodersOnNode(nodeId, reasonLabel).then((rotated) => {
38220
- if (rotated > 0) this.ctx.logger.info("decoders rotated after provider went down", {
38221
- tags: { nodeId },
38222
- meta: {
38223
- rotated,
38224
- reason: reasonLabel
38225
- }
38226
- });
38227
- }).catch((err) => {
38228
- this.ctx.logger.warn("decoder-down rotation failed", {
38229
- tags: { nodeId },
38230
- meta: { error: errMsg(err) }
38231
- });
38232
- });
38233
- } });
38234
36846
  const reconcileAll = (reason) => {
38235
36847
  this.brokerManager?.reconcileAllCatalogs().catch((err) => {
38236
36848
  this.ctx.logger.warn("catalog reconcile failed", { meta: {
@@ -38706,260 +37318,6 @@ var StreamBrokerAddon = class extends BaseAddon {
38706
37318
  }
38707
37319
  };
38708
37320
  //#endregion
38709
- //#region src/stream-broker/stream-broker/frame-dropper.ts
38710
- var FrameDropper = class {
38711
- intervalMs;
38712
- lastPassedAt = -Infinity;
38713
- constructor(maxFps) {
38714
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
38715
- }
38716
- shouldKeep() {
38717
- if (this.intervalMs === 0) return true;
38718
- const now = Date.now();
38719
- if (now - this.lastPassedAt >= this.intervalMs) {
38720
- this.lastPassedAt = now;
38721
- return true;
38722
- }
38723
- return false;
38724
- }
38725
- setMaxFps(maxFps) {
38726
- this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
38727
- }
38728
- };
38729
- //#endregion
38730
- //#region src/stream-broker/stream-broker/ffmpeg-decoder-session.ts
38731
- /** Minimal no-op logger for default parameter */
38732
- var noopLogger = {
38733
- debug() {},
38734
- info() {},
38735
- warn() {},
38736
- error() {},
38737
- child() {
38738
- return noopLogger;
38739
- },
38740
- withTags() {
38741
- return noopLogger;
38742
- }
38743
- };
38744
- var SOI = Buffer.from([255, 216]);
38745
- var EOI = Buffer.from([255, 217]);
38746
- function codecToInputFormat(codec) {
38747
- switch (codec) {
38748
- case "h265":
38749
- case "hevc": return "hevc";
38750
- case "mjpeg": return "mjpeg";
38751
- default: return "h264";
38752
- }
38753
- }
38754
- function buildFfmpegArgs(config) {
38755
- const inputFormat = codecToInputFormat(config.codec);
38756
- const args = [
38757
- ...logBannerArgs("error"),
38758
- "-fflags",
38759
- "+nobuffer+flush_packets",
38760
- "-flags",
38761
- "low_delay",
38762
- "-probesize",
38763
- "32",
38764
- "-analyzeduration",
38765
- "0",
38766
- "-f",
38767
- inputFormat,
38768
- "-i",
38769
- "pipe:0"
38770
- ];
38771
- if (config.scale > 1) args.push("-vf", `scale=iw/${config.scale}:ih/${config.scale}`);
38772
- args.push("-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", "-threads", "1", "pipe:1");
38773
- return args;
38774
- }
38775
- var FfmpegDecoderSession = class {
38776
- config;
38777
- frameDropper;
38778
- process = null;
38779
- frameCallbacks = /* @__PURE__ */ new Set();
38780
- outputBuffer = Buffer.alloc(0);
38781
- destroyed = false;
38782
- logger;
38783
- /** The pinned ffmpeg to spawn (D540). Required: there is no PATH default. */
38784
- ffmpegPath;
38785
- cachedWidth = 0;
38786
- cachedHeight = 0;
38787
- inputPackets = 0;
38788
- outputFrames = 0;
38789
- droppedFrames = 0;
38790
- totalDecodeTimeMs = 0;
38791
- decodeCount = 0;
38792
- startTime = Date.now();
38793
- constructor(config, ffmpegPath, logger = noopLogger) {
38794
- this.config = { ...config };
38795
- this.ffmpegPath = ffmpegPath;
38796
- this.logger = logger;
38797
- this.frameDropper = new FrameDropper(config.maxFps);
38798
- this.spawnFfmpeg();
38799
- }
38800
- spawnFfmpeg() {
38801
- if (this.destroyed) return;
38802
- this.killFfmpeg();
38803
- this.outputBuffer = Buffer.alloc(0);
38804
- this.cachedWidth = 0;
38805
- this.cachedHeight = 0;
38806
- const args = buildFfmpegArgs(this.config);
38807
- const child = spawn(this.ffmpegPath, args);
38808
- this.process = child;
38809
- child.stdin?.on("error", () => {});
38810
- child.stdout?.on("data", (chunk) => {
38811
- if (child === this.process) this.handleOutputData(chunk);
38812
- });
38813
- child.stderr?.on("data", (data) => {
38814
- if (child !== this.process) return;
38815
- const line = data.toString().trim();
38816
- if (line) this.logger.debug("ffmpeg stderr", { meta: { line } });
38817
- });
38818
- child.on("error", (err) => {
38819
- if (child !== this.process) return;
38820
- this.logger.error("FFmpeg decoder spawn error", { meta: { error: err.message } });
38821
- });
38822
- child.on("close", (_code, _signal) => {
38823
- if (child === this.process && !this.destroyed) this.process = null;
38824
- });
38825
- }
38826
- killFfmpeg() {
38827
- const child = this.process;
38828
- if (!child) return;
38829
- this.process = null;
38830
- child.stdout?.removeAllListeners("data");
38831
- child.stderr?.removeAllListeners("data");
38832
- child.removeAllListeners("close");
38833
- try {
38834
- child.kill("SIGKILL");
38835
- } catch {}
38836
- }
38837
- handleOutputData(chunk) {
38838
- this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
38839
- let searchFrom = 0;
38840
- while (true) {
38841
- const soiIndex = this.outputBuffer.indexOf(SOI, searchFrom);
38842
- if (soiIndex === -1) break;
38843
- const eoiIndex = this.outputBuffer.indexOf(EOI, soiIndex + 2);
38844
- if (eoiIndex === -1) break;
38845
- const frameEnd = eoiIndex + 2;
38846
- const jpegData = this.outputBuffer.subarray(soiIndex, frameEnd);
38847
- searchFrom = frameEnd;
38848
- this.emitFrame(Buffer.from(jpegData));
38849
- }
38850
- if (searchFrom > 0) this.outputBuffer = Buffer.from(this.outputBuffer.subarray(searchFrom));
38851
- }
38852
- emitFrame(data) {
38853
- const decodeStart = Date.now();
38854
- if (!this.frameDropper.shouldKeep()) {
38855
- this.droppedFrames++;
38856
- return;
38857
- }
38858
- const decodeTime = Date.now() - decodeStart;
38859
- this.totalDecodeTimeMs += decodeTime;
38860
- this.decodeCount++;
38861
- this.outputFrames++;
38862
- if (this.cachedWidth === 0) {
38863
- const dims = parseJpegDimensions(data);
38864
- this.cachedWidth = dims.width;
38865
- this.cachedHeight = dims.height;
38866
- }
38867
- const frame = {
38868
- data,
38869
- width: this.cachedWidth,
38870
- height: this.cachedHeight,
38871
- format: "jpeg",
38872
- timestamp: Date.now()
38873
- };
38874
- for (const cb of this.frameCallbacks) cb(frame);
38875
- }
38876
- pushPacket(packet) {
38877
- if (this.destroyed || !this.process?.stdin) return;
38878
- this.inputPackets++;
38879
- try {
38880
- this.process.stdin.write(packet.data);
38881
- } catch {}
38882
- }
38883
- onFrame(callback) {
38884
- this.frameCallbacks.add(callback);
38885
- return () => {
38886
- this.frameCallbacks.delete(callback);
38887
- };
38888
- }
38889
- updateConfig(update) {
38890
- const needsRestart = update.scale !== void 0 && update.scale !== this.config.scale || update.outputFormat !== void 0 && update.outputFormat !== this.config.outputFormat || update.codec !== void 0 && update.codec !== this.config.codec;
38891
- this.config = {
38892
- ...this.config,
38893
- ...update
38894
- };
38895
- if (update.maxFps !== void 0) this.frameDropper.setMaxFps(update.maxFps);
38896
- if (needsRestart) this.spawnFfmpeg();
38897
- }
38898
- async destroy() {
38899
- if (this.destroyed) return;
38900
- this.destroyed = true;
38901
- this.killFfmpeg();
38902
- this.frameCallbacks.clear();
38903
- }
38904
- getStats() {
38905
- const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
38906
- return {
38907
- inputFps: this.inputPackets / uptimeSec,
38908
- outputFps: this.outputFrames / uptimeSec,
38909
- avgDecodeTimeMs: this.decodeCount > 0 ? this.totalDecodeTimeMs / this.decodeCount : 0,
38910
- droppedFrames: this.droppedFrames
38911
- };
38912
- }
38913
- };
38914
- /**
38915
- * Parse JPEG SOF0/SOF2 marker to extract image dimensions.
38916
- * Scans for 0xFF 0xC0 (baseline) or 0xFF 0xC2 (progressive) markers.
38917
- * Returns {width: 0, height: 0} if not found.
38918
- */
38919
- function parseJpegDimensions(data) {
38920
- for (let i = 0; i < data.length - 8; i++) if (data[i] === 255 && (data[i + 1] === 192 || data[i + 1] === 194)) {
38921
- const height = data[i + 5] << 8 | data[i + 6];
38922
- return {
38923
- width: data[i + 7] << 8 | data[i + 8],
38924
- height
38925
- };
38926
- }
38927
- return {
38928
- width: 0,
38929
- height: 0
38930
- };
38931
- }
38932
- //#endregion
38933
- //#region src/stream-broker/stream-broker/ffmpeg-decoder-provider.ts
38934
- var SUPPORTED_CODECS = new Set([
38935
- "h264",
38936
- "h265",
38937
- "hevc",
38938
- "mjpeg"
38939
- ]);
38940
- /**
38941
- * A static decoder provider for stand-alone broker harnesses. Production
38942
- * decodes through the `decoder` cap; this exists for the manual streaming
38943
- * diagnostics. It is handed the pinned ffmpeg (D540) — there is no PATH
38944
- * default for it to fall back to.
38945
- */
38946
- var FfmpegDecoderProvider = class {
38947
- ffmpegPath;
38948
- id = "ffmpeg";
38949
- name = "FFmpeg Decoder";
38950
- /** Software decoder — used as fallback when hardware decoders are unavailable. */
38951
- priority = 50;
38952
- async supportsCodec(input) {
38953
- return SUPPORTED_CODECS.has(input.codec);
38954
- }
38955
- constructor(ffmpegPath) {
38956
- this.ffmpegPath = ffmpegPath;
38957
- }
38958
- async createSession(config) {
38959
- return new FfmpegDecoderSession(config, this.ffmpegPath);
38960
- }
38961
- };
38962
- //#endregion
38963
37321
  //#region src/stream-broker/frame-transport/codecs/length-prefix-codec.ts
38964
37322
  var LENGTH_PREFIX_BYTES = 4;
38965
37323
  /** Hard cap — 2^31-1 to stay safely within uint32 BE range and V8 Buffer limits. */
@@ -39924,4 +38282,4 @@ function fromBase64Url(s) {
39924
38282
  //#region src/stream-broker/index.ts
39925
38283
  var stream_broker_default = StreamBrokerAddon;
39926
38284
  //#endregion
39927
- export { DecoderSessionProxy, FfmpegDecoderProvider, InProcessTransport, LengthPrefixCodec, StreamBroker, StreamBrokerAddon, StreamBrokerManager, StreamPipeServer, TcpTransport, UdsTransport, createAuthPair, createFrameClient, createFrameServer, decodeDecodedFrame, decodedFrameCodec, stream_broker_default as default, encodeDecodedFrame };
38285
+ export { InProcessTransport, LengthPrefixCodec, StreamBroker, StreamBrokerAddon, StreamBrokerManager, StreamPipeServer, TcpTransport, UdsTransport, createAuthPair, createFrameClient, createFrameServer, decodeDecodedFrame, decodedFrameCodec, stream_broker_default as default, encodeDecodedFrame };