@camstack/addon-pipeline 1.1.55 → 1.1.57

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 (46) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +92 -88
  4. package/dist/detection-pipeline/index.mjs +91 -87
  5. package/dist/{dist-DI57FC8K.js → dist-CK8Ur-OS.js} +1045 -43
  6. package/dist/{dist-RWGGPwVx.mjs → dist-CL531uVA.mjs} +1045 -43
  7. package/dist/motion-wasm/index.js +9 -41
  8. package/dist/motion-wasm/index.mjs +9 -41
  9. package/dist/pipeline-runner/index.js +338 -664
  10. package/dist/pipeline-runner/index.mjs +327 -655
  11. package/dist/recorder/index.js +2 -2
  12. package/dist/recorder/index.mjs +2 -2
  13. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  14. package/dist/remote-restream-CO36Sr30.js +36 -0
  15. package/dist/session-decode/decode-worker-child.js +5 -3
  16. package/dist/session-decode/decode-worker-child.mjs +5 -3
  17. package/dist/{step-definitions-CP9kVSml.mjs → step-definitions-1d3_vQ6S.mjs} +45 -3
  18. package/dist/{step-definitions-CNBFKjZe.js → step-definitions-DHAxruAQ.js} +45 -3
  19. package/dist/stream-broker/_stub.js +351 -291
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-mDCPzmT3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dn_pxzP-.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs} +2 -2
  23. package/dist/stream-broker/{hostInit-GM_CI22k.mjs → hostInit-DEaqJpGA.mjs} +2 -2
  24. package/dist/stream-broker/index.js +935 -38
  25. package/dist/stream-broker/index.mjs +932 -35
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BChW0ntM.js → MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DDzqEbSe.js → MotionZonesSettings-NcxxQN8r-CmZbzLQi.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-B879GXaf.js → PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js} +1 -1
  30. package/embed-dist/assets/index-Bmpl9O1K.css +2 -0
  31. package/embed-dist/assets/index-DOsK8CZb.js +81 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +7 -5
  34. package/python/inference_pool.py +183 -29
  35. package/python/postprocessors/__init__.py +2 -0
  36. package/python/postprocessors/ssd.py +128 -0
  37. package/python/requirements-edgetpu.txt +23 -0
  38. package/python/test_inference_pool_layout.py +16 -1
  39. package/python/test_inference_pool_preprocess.py +65 -0
  40. package/dist/remote-source-plane-CHgvzzA6.js +0 -1030
  41. package/dist/remote-source-plane-DU0aRSPv.mjs +0 -1001
  42. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B3gTdHEh.mjs +0 -26
  43. package/embed-dist/assets/index-CC06JBcl.css +0 -2
  44. package/embed-dist/assets/index-CNjQ5rAE.js +0 -81
  45. package/dist/{model-download-service-C-IHWnXx-BPy6aoAx.mjs → model-download-service-C-IHWnXx-3Mmeob3l.mjs} +1 -1
  46. package/dist/{model-download-service-C-IHWnXx-BLXzxpRB.js → model-download-service-C-IHWnXx-D326YNnt.js} +1 -1
@@ -3,11 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-DI57FC8K.js");
7
- const require_remote_source_plane = require("../remote-source-plane-CHgvzzA6.js");
8
- const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-BLXzxpRB.js");
9
- let node_crypto = require("node:crypto");
10
- node_crypto = require_chunk.__toESM(node_crypto);
6
+ const require_dist = require("../dist-CK8Ur-OS.js");
7
+ const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
+ const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-D326YNnt.js");
11
9
  let node_child_process = require("node:child_process");
12
10
  let node_url = require("node:url");
13
11
  let node_fs = require("node:fs");
@@ -16,6 +14,8 @@ let node_path = require("node:path");
16
14
  node_path = require_chunk.__toESM(node_path);
17
15
  let node_os = require("node:os");
18
16
  node_os = require_chunk.__toESM(node_os);
17
+ let node_crypto = require("node:crypto");
18
+ node_crypto = require_chunk.__toESM(node_crypto);
19
19
  let node_dns_promises = require("node:dns/promises");
20
20
  let node_module = require("node:module");
21
21
  let node_net = require("node:net");
@@ -2426,6 +2426,61 @@ function extractFmtpLines(sdp) {
2426
2426
  return sdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=fmtp:") || l.startsWith("a=rtpmap:"));
2427
2427
  }
2428
2428
  /**
2429
+ * Strip the trailing `:port` from a candidate `host:port` string. IPv6 hosts
2430
+ * carry embedded colons, so the port is the segment after the LAST colon.
2431
+ * Returns the input unchanged when there's no port separator.
2432
+ */
2433
+ function hostFromCandidateAddress(address) {
2434
+ const idx = address.lastIndexOf(":");
2435
+ return idx > 0 ? address.slice(0, idx) : address;
2436
+ }
2437
+ /** IPv4 / IPv6 / unknown of a bare host literal (for log meta). */
2438
+ function remoteAddressFamily(host) {
2439
+ if (host.includes(":")) return "ipv6";
2440
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return "ipv4";
2441
+ return "unknown";
2442
+ }
2443
+ /** 100.64.0.0/10 (Tailscale CGNAT v4) or fd7a:115c:a1e0::/48 (Tailscale v6). */
2444
+ function isTailscaleOverlay(host) {
2445
+ if (host.toLowerCase().startsWith("fd7a:115c:a1e0:")) return true;
2446
+ const m = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
2447
+ if (!m) return false;
2448
+ const a = Number.parseInt(m[1], 10);
2449
+ const b = Number.parseInt(m[2], 10);
2450
+ return a === 100 && b >= 64 && b <= 127;
2451
+ }
2452
+ /** RFC1918 / loopback / link-local / generic ULA — i.e. a direct LAN host. */
2453
+ function isPrivateHost(host) {
2454
+ const lower = host.toLowerCase();
2455
+ if (lower === "::1" || lower === "::") return true;
2456
+ if (lower.startsWith("fe80")) return true;
2457
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
2458
+ const m = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
2459
+ if (!m) return false;
2460
+ const a = Number.parseInt(m[1], 10);
2461
+ const b = Number.parseInt(m[2], 10);
2462
+ if (a === 10 || a === 127) return true;
2463
+ if (a === 172 && b >= 16 && b <= 31) return true;
2464
+ if (a === 192 && b === 168) return true;
2465
+ if (a === 169 && b === 254) return true;
2466
+ return false;
2467
+ }
2468
+ /**
2469
+ * Derive the coarse pair class from the nominated pair's remote candidate:
2470
+ * - `relay` — the ICE type is `relay` (TURN-relayed, whatever the addr).
2471
+ * - `cgnat-vpn` — Tailscale overlay address (100.64/10 or fd7a:115c:a1e0::/48).
2472
+ * - `lan-direct`— private / loopback / link-local (RFC1918, fc00::/7, …).
2473
+ * - `public` — any other (routable internet) address.
2474
+ * Pure — never throws; unparseable addresses fall through to `public`.
2475
+ */
2476
+ function derivePairClass(remoteType, remoteAddr) {
2477
+ if (remoteType.toLowerCase() === "relay") return "relay";
2478
+ const host = hostFromCandidateAddress(remoteAddr);
2479
+ if (isTailscaleOverlay(host)) return "cgnat-vpn";
2480
+ if (isPrivateHost(host)) return "lan-direct";
2481
+ return "public";
2482
+ }
2483
+ /**
2429
2484
  * ffmpeg `-f` input demuxer for the transcode feed, keyed on the source
2430
2485
  * codec: H.265 Annex-B → `hevc`, H.264 Annex-B → `h264`. Output is always
2431
2486
  * Constrained Baseline H.264 (see `startTranscodeFeed`).
@@ -2978,7 +3033,9 @@ var AdaptiveSession = class AdaptiveSession {
2978
3033
  selectedLocalType: pair.localType,
2979
3034
  selectedLocalAddr: pair.localAddr,
2980
3035
  selectedRemoteType: pair.remoteType,
2981
- selectedRemoteAddr: pair.remoteAddr
3036
+ selectedRemoteAddr: pair.remoteAddr,
3037
+ remoteAddressFamily: remoteAddressFamily(hostFromCandidateAddress(pair.remoteAddr)),
3038
+ pairClass: derivePairClass(pair.remoteType, pair.remoteAddr)
2982
3039
  } });
2983
3040
  }
2984
3041
  /** Build PeerConnection options including H.264 codec config. */
@@ -9018,6 +9075,787 @@ var EncodedRingBuffer = class {
9018
9075
  }
9019
9076
  };
9020
9077
  //#endregion
9078
+ //#region src/stream-broker/stream-broker/decoder-session-proxy.ts
9079
+ /**
9080
+ * Long-poll window handed to `pullFrames`/`pullHandles`: an empty drain blocks
9081
+ * decoder-side until a frame commits or this elapses, replacing the old ~1kHz
9082
+ * `setTimeout(…,1)` busy-spin with a single await-notify round-trip. Kept
9083
+ * modest so a hung UDS/Moleculer transport can't pin the call; the decoder
9084
+ * cancels the waiter on `destroySession` so teardown never waits it out.
9085
+ */
9086
+ var POLL_WAIT_MS = 50;
9087
+ var DecoderSessionProxy = class {
9088
+ api;
9089
+ sessionId;
9090
+ polling = false;
9091
+ constructor(api, sessionId) {
9092
+ this.api = api;
9093
+ this.sessionId = sessionId;
9094
+ }
9095
+ async pushPacket(packet) {
9096
+ await this.api.pushPacket({
9097
+ sessionId: this.sessionId,
9098
+ packet
9099
+ });
9100
+ }
9101
+ async openStream(url) {
9102
+ await this.api.openStream({
9103
+ sessionId: this.sessionId,
9104
+ url
9105
+ });
9106
+ }
9107
+ async startPolling(onFrame) {
9108
+ this.polling = true;
9109
+ while (this.polling) {
9110
+ const frames = await this.api.pullFrames({
9111
+ sessionId: this.sessionId,
9112
+ maxCount: 4,
9113
+ waitMs: POLL_WAIT_MS
9114
+ });
9115
+ for (const frame of frames) onFrame(frame);
9116
+ }
9117
+ }
9118
+ /**
9119
+ * Poll a `frameSink: 'shm'` session for `FrameHandle`s (Phase 5 / D9).
9120
+ * Mirrors `startPolling` but drains `pullHandles` — the decoder has
9121
+ * already written the pixels into a shared-memory ring, so what crosses
9122
+ * the cap boundary is the tiny serialisable handle. Runs until
9123
+ * `stopPolling`, `destroy`, or the session is destroyed externally.
9124
+ *
9125
+ * When the decoder reports the session is gone (`unknown sessionId` /
9126
+ * `Service … not found`) the loop exits gracefully — those errors are
9127
+ * expected during decoder restart/shutdown and must not be re-thrown to
9128
+ * the caller's `.catch()` handler, which would otherwise spin forever.
9129
+ */
9130
+ async startHandlePolling(onHandle) {
9131
+ this.polling = true;
9132
+ while (this.polling) try {
9133
+ const handles = await this.api.pullHandles({
9134
+ sessionId: this.sessionId,
9135
+ maxCount: 4,
9136
+ waitMs: POLL_WAIT_MS
9137
+ });
9138
+ for (const handle of handles) onHandle(handle);
9139
+ } catch (err) {
9140
+ this.polling = false;
9141
+ const msg = err instanceof Error ? err.message : String(err);
9142
+ if (!msg.includes("unknown sessionId") && !msg.includes("Service") && !msg.includes("not found")) throw err;
9143
+ return;
9144
+ }
9145
+ }
9146
+ stopPolling() {
9147
+ this.polling = false;
9148
+ }
9149
+ async updateConfig(config) {
9150
+ await this.api.updateConfig({
9151
+ sessionId: this.sessionId,
9152
+ config
9153
+ });
9154
+ }
9155
+ async getStats() {
9156
+ return this.api.getStats({ sessionId: this.sessionId });
9157
+ }
9158
+ async destroy() {
9159
+ this.stopPolling();
9160
+ await this.api.destroySession({ sessionId: this.sessionId });
9161
+ }
9162
+ };
9163
+ //#endregion
9164
+ //#region src/stream-broker/stream-broker/frame-handle-plane.ts
9165
+ /**
9166
+ * `FrameHandlePlane` — the broker's shared-memory frame-handle surface
9167
+ * (Phase 5 / D9).
9168
+ *
9169
+ * This is the handle-based replacement for `StreamBroker.onDecodedFrame`'s
9170
+ * live-object callback path. Instead of fanning pixel `DecodedFrame`s out to
9171
+ * in-process callbacks (which cannot cross a process boundary), the broker
9172
+ * publishes zero-pixel, serialisable `FrameHandle`s a consumer drains over
9173
+ * tRPC and reads back from a shared-memory ring with a `FrameRingReader`.
9174
+ *
9175
+ * ## Ring-per-format model
9176
+ *
9177
+ * A `frameSink: 'shm'` decoder session produces exactly ONE pixel format
9178
+ * (the decoder's scaler is configured for it). The broker therefore keeps
9179
+ * one decoder session — hence one shm ring — per `(brokerId, format)` that a
9180
+ * consumer actually subscribes to:
9181
+ *
9182
+ * - motion subscribes `gray` → one `gray` decoder session + ring
9183
+ * - detection subscribes `rgb` → one `rgb` decoder session + ring
9184
+ *
9185
+ * Only the formats consumers ask for are materialised. There is NO
9186
+ * broker-side `sharp` conversion any more — each consumer reads the exact
9187
+ * format it requested. A session is reference-counted by its subscriber set
9188
+ * and torn down when the last subscriber of that format leaves.
9189
+ *
9190
+ * ## fps throttling
9191
+ *
9192
+ * Throttling is implicit: the ring is latest-wins, so a slow consumer that
9193
+ * polls `pullFrameHandles` infrequently simply receives the newest handles
9194
+ * and the intervening frames are dropped at the ring (a recycled slot the
9195
+ * consumer never read). The optional `maxFps` from `subscribeFrames` is NOT
9196
+ * enforced here — it is echoed back to the consumer as a reader-side polling
9197
+ * cadence hint.
9198
+ *
9199
+ * ## Packet feed
9200
+ *
9201
+ * The plane's decoder sessions are push-mode by default: the broker forwards
9202
+ * every video `EncodedPacket` to `pushPacket`. For a pull-mode decoder the
9203
+ * broker supplies a `pullModeUrl` and the plane calls `openStream` once the
9204
+ * session exists.
9205
+ */
9206
+ /**
9207
+ * Collapse a hierarchical Moleculer nodeID (`hub/child-abc`) to its owning
9208
+ * node (`hub`). Frame-plane co-location only cares about the physical node, not
9209
+ * which forked child within it hosts the session.
9210
+ */
9211
+ function collapseNodeId(nodeId) {
9212
+ const slash = nodeId.indexOf("/");
9213
+ return slash >= 0 ? nodeId.slice(0, slash) : nodeId;
9214
+ }
9215
+ /**
9216
+ * Decide whether a decoder session created on `decoderNodeId` is co-located
9217
+ * with the broker whose local node is `brokerNodeId`.
9218
+ *
9219
+ * The broker's shm frame ring is NODE-LOCAL: a decoder session on any other
9220
+ * node can never deliver frame handles to this broker's consumers, so a
9221
+ * mis-placed session must be rejected. Both ids are collapsed (`hub/child`→
9222
+ * `hub`) before comparison.
9223
+ *
9224
+ * A `null` `brokerNodeId` means the broker's own node is unknown — enforcement
9225
+ * is skipped (legacy behavior, accept any placement).
9226
+ */
9227
+ function isDecoderNodeColocated(brokerNodeId, decoderNodeId) {
9228
+ if (brokerNodeId === null) return true;
9229
+ return collapseNodeId(brokerNodeId) === collapseNodeId(decoderNodeId);
9230
+ }
9231
+ /** Per-subscription handle-queue capacity. A handle is tiny; 64 absorbs a
9232
+ * burst between two `pullFrameHandles` polls without unbounded growth. */
9233
+ var HANDLE_QUEUE_CAPACITY = 64;
9234
+ /** Default reader-side cadence hint when a subscriber omits `maxFps`. */
9235
+ var DEFAULT_HINT_FPS = 5;
9236
+ /** Rolling window for the aggregate `decodeFps` rate, ms. Mirrors the
9237
+ * broker's 2s `inputFps` window so the two stats are comparable. */
9238
+ var DECODE_FPS_WINDOW_MS = 2e3;
9239
+ /**
9240
+ * Keep-warm grace before a decoder session with no subscribers is torn down.
9241
+ *
9242
+ * Subscribers flap on ~30s cadences (occupancy recheck bursts, detection
9243
+ * motion-gating watch↔active, onboard gray gating). Destroying the
9244
+ * ffmpeg/VAAPI decode session the instant the last subscriber leaves spawns +
9245
+ * destroys a full session every cycle — wasted setup/teardown. Chosen >30s so
9246
+ * consecutive bursts and watch↔active flips reuse the warm session, yet a
9247
+ * genuinely idle session still frees its ffmpeg within ~a minute.
9248
+ */
9249
+ var SESSION_LINGER_MS = 45e3;
9250
+ /**
9251
+ * Short keep-warm grace for a PURE-OCCUPANCY session (every remaining
9252
+ * subscriber is a periodic on-motion occupancy recheck, tag `'occupancy'`).
9253
+ *
9254
+ * Occupancy rechecks subscribe to decoded frames for ~1s every ~30s. The
9255
+ * normal `SESSION_LINGER_MS` (45s) EXCEEDS that ~30s interval, so the linger
9256
+ * timer is perpetually cancelled by the next recheck before it can fire — the
9257
+ * decoder never tears down and decodes continuously at source-rate to serve a
9258
+ * 1s/30s consumer (~30x wasted decode; multiplied across cameras this is the
9259
+ * iGPU baseline that saturated the decoder). A pure-occupancy session instead
9260
+ * tears down between polls: short enough to fire well before the next ~30s
9261
+ * recheck, long enough to absorb the ~1s poll plus minor jitter. Re-dial is
9262
+ * cheap on GPU-scale decode and the restream burst re-primes the live edge
9263
+ * fast, so on-demand re-arm on the next recheck is fine.
9264
+ */
9265
+ var OCCUPANCY_LINGER_MS = 5e3;
9266
+ /** Subscription tag emitted by the periodic on-motion occupancy recheck. A
9267
+ * session whose subscribers have only ever carried this tag gets the short
9268
+ * `OCCUPANCY_LINGER_MS` teardown; any other subscriber biases to the long
9269
+ * `SESSION_LINGER_MS` warm linger (interactive viewers, detection, motion). */
9270
+ var OCCUPANCY_TAG = "occupancy";
9271
+ /**
9272
+ * Default retry cooldown after a decoder session lands on a NON-LOCAL node
9273
+ * (see `FrameHandlePlaneOptions.sessionRetryCooldownMs`). Long enough to
9274
+ * turn a per-packet storm into a slow probe; short enough that the plane
9275
+ * recovers within seconds once the local decoder respawns.
9276
+ */
9277
+ var SESSION_RETRY_COOLDOWN_MS = 1e4;
9278
+ /**
9279
+ * Owns the broker's shared-memory frame-handle subscriptions and the
9280
+ * per-format `frameSink: 'shm'` decoder sessions that feed them.
9281
+ */
9282
+ var FrameHandlePlane = class {
9283
+ decoderApi;
9284
+ logger;
9285
+ resolveStreamInfo;
9286
+ localNodeId;
9287
+ sessionRetryCooldownMs;
9288
+ getSeedPackets;
9289
+ isDebugEnabled;
9290
+ subscriptions = /* @__PURE__ */ new Map();
9291
+ sessions = /* @__PURE__ */ new Map();
9292
+ /** Per-format retry gate set when a session lands on a non-local node:
9293
+ * `ensureSession` skips creation until the epoch-ms deadline passes. */
9294
+ sessionRetryCooldownUntil = /* @__PURE__ */ new Map();
9295
+ /** In-flight `ensureSession` creation per format. Coalesces concurrent
9296
+ * same-format callers onto ONE decoder session — without it two callers
9297
+ * both pass the `sessions.get` check, both `createSession` (two ffmpeg),
9298
+ * and the second registration orphans the first with no `destroySession`. */
9299
+ sessionCreating = /* @__PURE__ */ new Map();
9300
+ disposed = false;
9301
+ /** Re-entrancy guard for `armPendingSessions` — collapses the per-packet
9302
+ * `pushPacket` calls into a single in-flight deferred-arm pass. */
9303
+ armInFlight = false;
9304
+ /** DIAG(592): P-frames skipped on an un-bootstrapped session before its
9305
+ * first live keyframe. Reset when a keyframe bootstraps the session. */
9306
+ unbootstrappedSkips = 0;
9307
+ /** Aggregate decoded-frame rate state — frames fanned out in the current
9308
+ * rolling window, the window's start epoch, and the last computed fps. */
9309
+ framesInWindow = 0;
9310
+ windowStartMs = Date.now();
9311
+ decodedFps = 0;
9312
+ constructor(options) {
9313
+ this.decoderApi = options.decoderApi;
9314
+ this.logger = options.logger;
9315
+ this.resolveStreamInfo = options.resolveStreamInfo;
9316
+ this.localNodeId = options.localNodeId;
9317
+ this.sessionRetryCooldownMs = options.sessionRetryCooldownMs ?? SESSION_RETRY_COOLDOWN_MS;
9318
+ this.getSeedPackets = options.getSeedPackets;
9319
+ this.isDebugEnabled = options.isDebugEnabled;
9320
+ }
9321
+ /** Number of active handle subscriptions — surfaced for diagnostics. */
9322
+ get subscriberCount() {
9323
+ return this.subscriptions.size;
9324
+ }
9325
+ /**
9326
+ * Moleculer nodeIDs of the decoder providers currently hosting this plane's
9327
+ * `frameSink: 'shm'` sessions. Used by the broker-manager's per-agent
9328
+ * hwaccel-change handler to decide which brokers need a decoder rotation.
9329
+ */
9330
+ decoderNodeIds() {
9331
+ const ids = [];
9332
+ for (const session of this.sessions.values()) if (session.nodeId) ids.push(session.nodeId);
9333
+ return ids;
9334
+ }
9335
+ /**
9336
+ * Rotate every shm decoder session hosted on `agentNodeId` — destroy and
9337
+ * rebuild it so the new session re-pulls the latest per-agent decoder
9338
+ * preferences (hwaccel backend, …). Subscriptions are preserved: the same
9339
+ * `subscriptionId`s read from the rebuilt session's fresh ring. A no-op for
9340
+ * sessions on other nodes. Returns the number of sessions rotated.
9341
+ */
9342
+ async rotate(agentNodeId, reason) {
9343
+ if (this.disposed) return 0;
9344
+ let rotated = 0;
9345
+ for (const session of this.sessions.values()) {
9346
+ if ((session.nodeId && session.nodeId.includes("/") ? session.nodeId.split("/")[0] : session.nodeId) !== agentNodeId) continue;
9347
+ this.logger?.info("frame-handle plane: rotating shm decoder session", { meta: {
9348
+ format: session.format,
9349
+ reason,
9350
+ nodeId: session.nodeId
9351
+ } });
9352
+ await this.destroySession(session);
9353
+ let restarted = false;
9354
+ try {
9355
+ restarted = await this.startSessionDecoder(session);
9356
+ } finally {
9357
+ if (!restarted) this.sessions.delete(session.format);
9358
+ }
9359
+ if (restarted) rotated += 1;
9360
+ }
9361
+ return rotated;
9362
+ }
9363
+ /**
9364
+ * Register a frame-handle subscription. Spins up (or reuses) a
9365
+ * `frameSink: 'shm'` decoder session producing `format`. The returned
9366
+ * `subscriptionId` is the handle the consumer passes to
9367
+ * `pullFrameHandles` / `unsubscribe`.
9368
+ */
9369
+ async subscribe(input) {
9370
+ if (this.disposed) throw new Error("FrameHandlePlane: subscribe after dispose");
9371
+ const subscriptionId = `fh-${(0, node_crypto.randomUUID)()}`;
9372
+ const maxFps = input.maxFps !== void 0 && input.maxFps > 0 ? input.maxFps : DEFAULT_HINT_FPS;
9373
+ const subscription = {
9374
+ id: subscriptionId,
9375
+ format: input.format,
9376
+ maxFps,
9377
+ tag: input.tag ?? "unknown",
9378
+ subscribedAt: Date.now(),
9379
+ queue: new require_dist.RingBuffer(HANDLE_QUEUE_CAPACITY),
9380
+ framesDelivered: 0
9381
+ };
9382
+ this.subscriptions.set(subscriptionId, subscription);
9383
+ await this.ensureSession(input.format, subscriptionId);
9384
+ this.reconcileSessionFps(input.format);
9385
+ this.logger?.info("frame-handle subscription added", { meta: {
9386
+ subscriptionId,
9387
+ format: input.format,
9388
+ tag: subscription.tag,
9389
+ maxFps
9390
+ } });
9391
+ return {
9392
+ subscriptionId,
9393
+ maxFps
9394
+ };
9395
+ }
9396
+ /**
9397
+ * The emit `maxFps` a format's decoder session should run at — the MAX of all
9398
+ * current subscribers' `maxFps` for that format (fallback `DEFAULT_HINT_FPS`
9399
+ * when none, e.g. mid-teardown). The decoder emits at this rate; each
9400
+ * subscriber then down-samples to its own `maxFps` on read (latest-wins).
9401
+ */
9402
+ computeFormatFps(format) {
9403
+ let max = 0;
9404
+ for (const sub of this.subscriptions.values()) if (sub.format === format && sub.maxFps > max) max = sub.maxFps;
9405
+ return max > 0 ? max : DEFAULT_HINT_FPS;
9406
+ }
9407
+ /**
9408
+ * Recompute a format session's emit `maxFps` after a subscriber joins/leaves
9409
+ * and push it to the live decoder via `updateConfig` when it changed — so the
9410
+ * decoder emits only as fast as the busiest reader needs (saving the
9411
+ * scale+convert+shm-write for frames nobody reads).
9412
+ */
9413
+ reconcileSessionFps(format) {
9414
+ const session = this.sessions.get(format);
9415
+ if (!session || !session.proxy) return;
9416
+ const target = this.computeFormatFps(format);
9417
+ if (target === session.targetFps) return;
9418
+ session.targetFps = target;
9419
+ session.proxy.updateConfig({ maxFps: target }).catch((err) => {
9420
+ this.logger?.warn("frame-handle plane: updateConfig(maxFps) failed", { meta: {
9421
+ format,
9422
+ maxFps: target,
9423
+ error: require_dist.errMsg(err)
9424
+ } });
9425
+ });
9426
+ }
9427
+ /**
9428
+ * Drain up to `maxCount` `FrameHandle`s for a subscription, latest-wins.
9429
+ * An unknown subscription id returns `[]` (the consumer may have been
9430
+ * torn down concurrently).
9431
+ */
9432
+ pullHandles(subscriptionId, maxCount) {
9433
+ const subscription = this.subscriptions.get(subscriptionId);
9434
+ if (!subscription) return [];
9435
+ const stale = subscription.queue.size - maxCount;
9436
+ if (stale > 0) subscription.queue.drain(stale);
9437
+ const handles = subscription.queue.drain(maxCount);
9438
+ subscription.framesDelivered += handles.length;
9439
+ return handles;
9440
+ }
9441
+ /**
9442
+ * Release a subscription. When it was the last reader of its format the
9443
+ * underlying decoder session is destroyed (and its shm segment unlinked
9444
+ * by the decoder). Returns `true` when a known subscription was released.
9445
+ */
9446
+ async unsubscribe(subscriptionId, immediate = false) {
9447
+ const subscription = this.subscriptions.get(subscriptionId);
9448
+ if (!subscription) return false;
9449
+ this.subscriptions.delete(subscriptionId);
9450
+ const session = this.sessions.get(subscription.format);
9451
+ if (session) {
9452
+ session.subscriberIds.delete(subscriptionId);
9453
+ if (session.subscriberIds.size === 0) if (immediate) {
9454
+ this.clearLinger(session);
9455
+ this.sessions.delete(subscription.format);
9456
+ await this.destroySession(session);
9457
+ } else this.scheduleLinger(session);
9458
+ else this.reconcileSessionFps(subscription.format);
9459
+ }
9460
+ this.logger?.info("frame-handle subscription removed", { meta: {
9461
+ subscriptionId,
9462
+ format: subscription.format
9463
+ } });
9464
+ return true;
9465
+ }
9466
+ /**
9467
+ * Arm the keep-warm teardown timer for a session whose last subscriber just
9468
+ * left. Clears any existing timer first (idempotent). On fire it re-checks
9469
+ * `subscriberIds.size === 0` — a subscriber may have returned in the interim,
9470
+ * in which case the timer is a no-op — and only then removes the session and
9471
+ * destroys the decoder. The timer is `unref`'d so a lingering warm session
9472
+ * never keeps the process alive.
9473
+ */
9474
+ scheduleLinger(session) {
9475
+ this.clearLinger(session);
9476
+ const occupancyOnly = !session.hasNonOccupancySubscriberEver;
9477
+ const lingerMs = occupancyOnly ? OCCUPANCY_LINGER_MS : SESSION_LINGER_MS;
9478
+ this.logger?.debug(occupancyOnly ? "frame-handle plane: short occupancy linger scheduled" : "frame-handle plane: keep-warm linger scheduled", { meta: {
9479
+ format: session.format,
9480
+ lingerMs,
9481
+ occupancyOnly
9482
+ } });
9483
+ const timer = setTimeout(() => {
9484
+ session.lingerTimer = null;
9485
+ if (session.subscriberIds.size > 0) return;
9486
+ this.sessions.delete(session.format);
9487
+ this.destroySession(session).catch(() => {});
9488
+ }, lingerMs);
9489
+ timer.unref?.();
9490
+ session.lingerTimer = timer;
9491
+ }
9492
+ /**
9493
+ * Latch `session.hasNonOccupancySubscriberEver` when the subscription
9494
+ * `subscriptionId` carries any tag other than `OCCUPANCY_TAG`. Called at
9495
+ * every point a subscription is attached to a session so the keep-warm
9496
+ * decision only fast-tears-down sessions that have been PURELY on-demand
9497
+ * occupancy rechecks. Unknown ids (mid-teardown) are ignored.
9498
+ */
9499
+ noteSessionSubscriberTag(session, subscriptionId) {
9500
+ const subscription = this.subscriptions.get(subscriptionId);
9501
+ if (subscription && subscription.tag !== OCCUPANCY_TAG) session.hasNonOccupancySubscriberEver = true;
9502
+ }
9503
+ /** Cancel a session's pending keep-warm teardown timer, if any. */
9504
+ clearLinger(session) {
9505
+ if (session.lingerTimer) {
9506
+ clearTimeout(session.lingerTimer);
9507
+ session.lingerTimer = null;
9508
+ }
9509
+ }
9510
+ /**
9511
+ * Forward a video `EncodedPacket` to every active shm decoder session.
9512
+ * Push-mode decoders consume this; a pull-mode decoder ignores it (it
9513
+ * reads its own pipe via `openStream`). Audio packets are not forwarded.
9514
+ *
9515
+ * The packet feed doubles as the plane's stream-ready signal: the broker
9516
+ * only calls `pushPacket` once it has a source and the first keyframe has
9517
+ * landed — exactly the point `resolveStreamInfo` starts returning a
9518
+ * descriptor. Each packet therefore triggers `armPendingSessions`, which
9519
+ * re-attempts session creation for any deferred subscription so a lone
9520
+ * first subscriber on a not-yet-started broker self-arms without a
9521
+ * redundant re-`subscribe`.
9522
+ */
9523
+ pushPacket(packet) {
9524
+ if (this.disposed || packet.type !== "video") return;
9525
+ this.armPendingSessions(packet).catch((err) => {
9526
+ this.logger?.warn("frame-handle plane: arm pending sessions error", { meta: { error: require_dist.errMsg(err) } });
9527
+ });
9528
+ this.feedSessions(packet);
9529
+ }
9530
+ /** Forward a video `EncodedPacket` to every live PUSH-mode shm decoder session. */
9531
+ feedSessions(packet) {
9532
+ for (const session of this.sessions.values()) {
9533
+ if (!session.proxy) continue;
9534
+ if (session.pullMode) continue;
9535
+ if (!session.bootstrapped) {
9536
+ if (!packet.keyframe) {
9537
+ this.unbootstrappedSkips = (this.unbootstrappedSkips ?? 0) + 1;
9538
+ continue;
9539
+ }
9540
+ this.logger?.debug("frame-handle plane: session bootstrapped from live keyframe", { meta: {
9541
+ format: session.format,
9542
+ pFramesSkippedBefore: this.unbootstrappedSkips ?? 0
9543
+ } });
9544
+ this.unbootstrappedSkips = 0;
9545
+ session.bootstrapped = true;
9546
+ }
9547
+ session.proxy.pushPacket(packet).catch((err) => {
9548
+ const msg = require_dist.errMsg(err);
9549
+ this.logger?.warn("frame-handle plane: decoder push error", { meta: {
9550
+ format: session.format,
9551
+ error: msg
9552
+ } });
9553
+ if (msg.includes("unknown sessionId") || msg.includes("Service") || msg.includes("not found")) {
9554
+ this.logger?.info("frame-handle plane: invalidating stale proxy", { meta: { format: session.format } });
9555
+ this.destroySession(session).catch(() => {});
9556
+ this.sessions.delete(session.format);
9557
+ }
9558
+ });
9559
+ }
9560
+ }
9561
+ /**
9562
+ * Re-attempt session creation for every subscription whose format has no
9563
+ * live `FormatSession` yet — the subscriptions whose `startSessionDecoder`
9564
+ * was deferred because the broker had no stream at `subscribe` time. Driven
9565
+ * off the `pushPacket` feed (event-driven, no polling timer); a no-op once
9566
+ * every subscribed format already owns a session.
9567
+ *
9568
+ * `armInFlight` collapses re-entrant calls: `pushPacket` fires per packet,
9569
+ * but `ensureSession` is async — without the guard a burst of packets would
9570
+ * launch overlapping decoder-creation round-trips for the same format.
9571
+ *
9572
+ * `triggerPacket` is the packet whose arrival armed the pass. Because
9573
+ * `ensureSession` is async, the synchronous `feedSessions` in `pushPacket`
9574
+ * runs before the session lands in `this.sessions`, so the triggering packet
9575
+ * would be lost to the new session. `startSessionDecoder` normally bootstraps
9576
+ * it by replaying the broker's keyframe-aligned seed GOP; only when NO seed
9577
+ * was available (no `getSeedPackets`, or the ring held no keyframe yet) is the
9578
+ * session left un-bootstrapped — and then this method hands it the triggering
9579
+ * packet, but ONLY if that packet is a keyframe. A P-frame trigger is left for
9580
+ * `feedSessions` to skip until the first live keyframe, so a push-mode decoder
9581
+ * never sees a slice referencing an unseen param set.
9582
+ */
9583
+ async armPendingSessions(triggerPacket) {
9584
+ if (this.disposed || this.armInFlight) return;
9585
+ const pending = /* @__PURE__ */ new Map();
9586
+ for (const subscription of this.subscriptions.values()) {
9587
+ if (this.sessions.has(subscription.format)) continue;
9588
+ if (!pending.has(subscription.format)) pending.set(subscription.format, subscription.id);
9589
+ }
9590
+ if (pending.size === 0) return;
9591
+ this.armInFlight = true;
9592
+ try {
9593
+ for (const [format, subscriptionId] of pending) {
9594
+ if (this.disposed) return;
9595
+ await this.ensureSession(format, subscriptionId);
9596
+ const armed = this.sessions.get(format);
9597
+ if (armed?.proxy && !armed.bootstrapped && triggerPacket.keyframe) {
9598
+ armed.bootstrapped = true;
9599
+ armed.proxy.pushPacket(triggerPacket).catch((err) => {
9600
+ this.logger?.warn("frame-handle plane: decoder push error", { meta: {
9601
+ format,
9602
+ error: require_dist.errMsg(err)
9603
+ } });
9604
+ });
9605
+ }
9606
+ }
9607
+ } finally {
9608
+ this.armInFlight = false;
9609
+ }
9610
+ }
9611
+ /**
9612
+ * Aggregate decoded-frame rate across every `frameSink: 'shm'` session in
9613
+ * this plane, frames/s. Each `fanoutHandle` call is one decoded frame
9614
+ * delivered into the plane; the rate is recomputed over a rolling
9615
+ * `DECODE_FPS_WINDOW_MS` window. Returns the last computed value between
9616
+ * window rolls, and `0` while no session is producing.
9617
+ */
9618
+ decodeFps() {
9619
+ this.rollDecodeFpsWindow();
9620
+ return this.decodedFps;
9621
+ }
9622
+ /**
9623
+ * Roll the decoded-fps window when it has elapsed: convert frames seen in
9624
+ * the window into a per-second rate, then reset the counter. Called both
9625
+ * on every fanout and on every `decodeFps()` read so a stalled session
9626
+ * (no fanout) decays its rate to `0` rather than reporting a stale value.
9627
+ */
9628
+ rollDecodeFpsWindow() {
9629
+ const now = Date.now();
9630
+ const windowMs = now - this.windowStartMs;
9631
+ if (windowMs < DECODE_FPS_WINDOW_MS) return;
9632
+ this.decodedFps = this.framesInWindow / windowMs * 1e3;
9633
+ this.framesInWindow = 0;
9634
+ this.windowStartMs = now;
9635
+ }
9636
+ /** Diagnostic snapshot of every active frame-handle subscription. */
9637
+ listSubscribers() {
9638
+ return [...this.subscriptions.values()].map((s) => ({
9639
+ tag: s.tag,
9640
+ subscribedAt: s.subscribedAt,
9641
+ maxFps: s.maxFps,
9642
+ framesDelivered: s.framesDelivered
9643
+ }));
9644
+ }
9645
+ /**
9646
+ * Force-release every subscription whose tag matches `tag`. Returns the
9647
+ * number released. Decoder sessions wind down when their last subscriber
9648
+ * leaves, same as a normal `unsubscribe`.
9649
+ */
9650
+ async killByTag(tag) {
9651
+ const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
9652
+ for (const id of victims) await this.unsubscribe(id, true);
9653
+ return victims.length;
9654
+ }
9655
+ /** Tear down every subscription + decoder session. Idempotent. */
9656
+ async dispose() {
9657
+ if (this.disposed) return;
9658
+ this.disposed = true;
9659
+ this.subscriptions.clear();
9660
+ const sessions = [...this.sessions.values()];
9661
+ this.sessions.clear();
9662
+ await Promise.all(sessions.map((s) => this.destroySession(s)));
9663
+ }
9664
+ /**
9665
+ * Ensure a `frameSink: 'shm'` decoder session for `format` exists and add
9666
+ * `subscriptionId` to its reader set. Reuses the session when one already
9667
+ * runs for that format.
9668
+ *
9669
+ * When a session is created fresh, its reader set is seeded with EVERY
9670
+ * subscription currently reading `format`, not just `subscriptionId` — a
9671
+ * deferred-arm pass (`armPendingSessions`) may create the session long
9672
+ * after several subscriptions of that format have registered, and the
9673
+ * session is reference-counted by `subscriberIds`. Seeding only the one id
9674
+ * would let an `unsubscribe` of that id tear the session down while its
9675
+ * sibling subscriptions still read it.
9676
+ */
9677
+ async ensureSession(format, subscriptionId) {
9678
+ const existing = this.sessions.get(format);
9679
+ if (existing) {
9680
+ this.clearLinger(existing);
9681
+ existing.subscriberIds.add(subscriptionId);
9682
+ this.noteSessionSubscriberTag(existing, subscriptionId);
9683
+ return;
9684
+ }
9685
+ const inflight = this.sessionCreating.get(format);
9686
+ if (inflight) {
9687
+ await inflight;
9688
+ const session = this.sessions.get(format);
9689
+ if (session) {
9690
+ session.subscriberIds.add(subscriptionId);
9691
+ this.noteSessionSubscriberTag(session, subscriptionId);
9692
+ }
9693
+ return;
9694
+ }
9695
+ if (Date.now() < (this.sessionRetryCooldownUntil.get(format) ?? 0)) return;
9696
+ const creation = (async () => {
9697
+ const subscriberIds = new Set([subscriptionId]);
9698
+ for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
9699
+ const session = {
9700
+ format,
9701
+ proxy: null,
9702
+ nodeId: null,
9703
+ subscriberIds,
9704
+ bootstrapped: false,
9705
+ pullMode: false,
9706
+ targetFps: this.computeFormatFps(format),
9707
+ lingerTimer: null,
9708
+ hasNonOccupancySubscriberEver: false
9709
+ };
9710
+ for (const id of subscriberIds) this.noteSessionSubscriberTag(session, id);
9711
+ let started = false;
9712
+ try {
9713
+ started = await this.startSessionDecoder(session);
9714
+ } catch (err) {
9715
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9716
+ throw err;
9717
+ }
9718
+ if (started) this.sessions.set(format, session);
9719
+ })();
9720
+ this.sessionCreating.set(format, creation);
9721
+ try {
9722
+ await creation;
9723
+ } finally {
9724
+ this.sessionCreating.delete(format);
9725
+ }
9726
+ }
9727
+ /**
9728
+ * Create the `frameSink: 'shm'` decoder for a `FormatSession` and start
9729
+ * draining its handle queue. Mutates `session.proxy` / `session.nodeId` in
9730
+ * place so a `rotate()` can rebuild a registered session without disturbing
9731
+ * its `subscriberIds`. Returns `false` when no stream / unsupported codec
9732
+ * means no decoder could be created.
9733
+ */
9734
+ async startSessionDecoder(session) {
9735
+ const { format } = session;
9736
+ const info = this.resolveStreamInfo();
9737
+ if (!info) {
9738
+ this.logger?.info("frame-handle plane: no stream — session deferred", { meta: { format } });
9739
+ return false;
9740
+ }
9741
+ if (!await this.decoderApi.supportsCodec({ codec: info.codec })) {
9742
+ this.logger?.warn("frame-handle plane: codec unsupported — session skipped", { meta: {
9743
+ format,
9744
+ codec: info.codec
9745
+ } });
9746
+ return false;
9747
+ }
9748
+ const targetFps = this.computeFormatFps(format);
9749
+ session.targetFps = targetFps;
9750
+ const { sessionId, nodeId } = await this.decoderApi.createSession({
9751
+ codec: info.codec,
9752
+ maxFps: targetFps,
9753
+ outputFormat: format,
9754
+ scale: 1,
9755
+ frameSink: "shm",
9756
+ ...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
9757
+ tag: `${info.tag}:shm:${format}`,
9758
+ ...this.isDebugEnabled?.() ? { debug: true } : {}
9759
+ });
9760
+ if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
9761
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9762
+ this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
9763
+ format,
9764
+ requestedNode: this.localNodeId,
9765
+ gotNode: nodeId,
9766
+ sessionId,
9767
+ retryInMs: this.sessionRetryCooldownMs
9768
+ } });
9769
+ await this.decoderApi.destroySession({ sessionId }).catch((err) => {
9770
+ this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
9771
+ format,
9772
+ sessionId,
9773
+ error: require_dist.errMsg(err)
9774
+ } });
9775
+ });
9776
+ return false;
9777
+ }
9778
+ this.sessionRetryCooldownUntil.delete(format);
9779
+ const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
9780
+ session.proxy = proxy;
9781
+ session.nodeId = nodeId;
9782
+ session.bootstrapped = false;
9783
+ session.pullMode = false;
9784
+ proxy.startHandlePolling((handle) => {
9785
+ this.fanoutHandle(format, handle);
9786
+ }).catch((err) => {
9787
+ this.logger?.warn("frame-handle plane: handle polling error", { meta: {
9788
+ format,
9789
+ error: require_dist.errMsg(err)
9790
+ } });
9791
+ });
9792
+ if (info.pullModeUrl) {
9793
+ session.pullMode = true;
9794
+ session.bootstrapped = true;
9795
+ proxy.openStream(info.pullModeUrl).catch((err) => {
9796
+ this.logger?.error("frame-handle plane: pull-mode openStream failed", { meta: {
9797
+ format,
9798
+ error: require_dist.errMsg(err)
9799
+ } });
9800
+ });
9801
+ this.logger?.info("frame-handle plane: shm decoder session created (pull-mode RTSP)", { meta: {
9802
+ format,
9803
+ codec: info.codec,
9804
+ sessionId,
9805
+ nodeId,
9806
+ pullModeUrl: info.pullModeUrl
9807
+ } });
9808
+ return true;
9809
+ }
9810
+ const seed = this.getSeedPackets?.() ?? [];
9811
+ if (seed.length > 0) {
9812
+ for (const seedPacket of seed) await proxy.pushPacket(seedPacket).catch((err) => {
9813
+ this.logger?.warn("frame-handle plane: seed-replay push error", { meta: {
9814
+ format,
9815
+ error: require_dist.errMsg(err)
9816
+ } });
9817
+ });
9818
+ session.bootstrapped = true;
9819
+ this.logger?.debug("frame-handle plane: session bootstrapped from seed GOP", { meta: {
9820
+ format,
9821
+ seedPackets: seed.length
9822
+ } });
9823
+ }
9824
+ this.logger?.info("frame-handle plane: shm decoder session created", { meta: {
9825
+ format,
9826
+ codec: info.codec,
9827
+ sessionId,
9828
+ nodeId
9829
+ } });
9830
+ return true;
9831
+ }
9832
+ /** Push one decoded `FrameHandle` to every subscription of `format`. */
9833
+ fanoutHandle(format, handle) {
9834
+ this.rollDecodeFpsWindow();
9835
+ this.framesInWindow += 1;
9836
+ for (const subscription of this.subscriptions.values()) {
9837
+ if (subscription.format !== format) continue;
9838
+ subscription.queue.push(handle);
9839
+ }
9840
+ }
9841
+ /** Destroy a decoder session, swallowing teardown errors. */
9842
+ async destroySession(session) {
9843
+ this.clearLinger(session);
9844
+ const proxy = session.proxy;
9845
+ session.proxy = null;
9846
+ session.nodeId = null;
9847
+ if (!proxy) return;
9848
+ try {
9849
+ await proxy.destroy();
9850
+ } catch (err) {
9851
+ this.logger?.warn("frame-handle plane: session destroy failed", { meta: {
9852
+ format: session.format,
9853
+ error: require_dist.errMsg(err)
9854
+ } });
9855
+ }
9856
+ }
9857
+ };
9858
+ //#endregion
9021
9859
  //#region src/stream-broker/stream-broker/annexb-param-sets.ts
9022
9860
  /**
9023
9861
  * Annex-B parameter-set detection for the decoder-priming path.
@@ -10465,7 +11303,7 @@ var StreamBroker = class StreamBroker {
10465
11303
  this.logger?.warn("subscribeFrameHandles: no decoder API — frame handles unavailable");
10466
11304
  return null;
10467
11305
  }
10468
- this.frameHandlePlane = new require_remote_source_plane.FrameHandlePlane({
11306
+ this.frameHandlePlane = new FrameHandlePlane({
10469
11307
  decoderApi: this.decoderApi,
10470
11308
  logger: this.logger?.child("frame-handle-plane"),
10471
11309
  resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
@@ -12237,7 +13075,7 @@ var RemoteEncodedSourceBroker = class {
12237
13075
  async createEntry() {
12238
13076
  const { api, deviceId, streamId, ownerNodeId } = this;
12239
13077
  if (this.hostname === void 0) throw new Error("RemoteEncodedSourceBroker: owner hostname unresolvable — set frameSource.hubHostnameOverride or CAMSTACK_HUB_URL (a 127.0.0.1 restream URL would dial the wrong box)");
12240
- const profile = require_remote_source_plane.profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(ownerNodeId)), deviceId, streamId);
13078
+ const profile = require_remote_restream.profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(ownerNodeId)), deviceId, streamId);
12241
13079
  if (profile === null) throw new Error(`RemoteEncodedSourceBroker: no broker profile is fed by camStream '${streamId}' of device ${deviceId} — cannot acquire the owner restream`);
12242
13080
  const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
12243
13081
  deviceId,
@@ -14357,7 +15195,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14357
15195
  value: setting,
14358
15196
  label: hwAccelOptionLabel(setting)
14359
15197
  }));
14360
- const streamTabs = streams.map((stream) => {
15198
+ const streamTabs = streams.filter((s) => !s.camStreamId.startsWith("derived:")).map((stream) => {
14361
15199
  const camStreamId = stream.camStreamId;
14362
15200
  const brokerId = brokerIdFor(deviceId, camStreamId);
14363
15201
  const profile = findProfileForStream(assignment.map, camStreamId);
@@ -14661,7 +15499,9 @@ var StreamBrokerManager = class StreamBrokerManager {
14661
15499
  return { success: true };
14662
15500
  }
14663
15501
  let overrideDirty = false;
15502
+ let derivedDirty = false;
14664
15503
  const nextOverride = { ...this.deviceOverrides.get(deviceId) };
15504
+ const priorDerivedDefs = new Map(Object.entries(this.deviceOverrides.get(deviceId)?.derivedStreams ?? {}));
14665
15505
  for (const [fieldKey, value] of Object.entries(input.patch)) {
14666
15506
  if (fieldKey.startsWith("streamProfile:")) {
14667
15507
  const profile = fieldKey.slice(14);
@@ -14768,6 +15608,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14768
15608
  }
14769
15609
  if (fieldKey === "derivedStreamNames" && Array.isArray(value)) {
14770
15610
  overrideDirty = true;
15611
+ derivedDirty = true;
14771
15612
  const names = value.filter((v) => typeof v === "string" && v.length > 0);
14772
15613
  const next = { ...nextOverride.derivedStreams };
14773
15614
  for (const n of names) if (!next[n]) next[n] = { encodeProfile: {
@@ -14776,37 +15617,12 @@ var StreamBrokerManager = class StreamBrokerManager {
14776
15617
  } };
14777
15618
  for (const existing of Object.keys(next)) if (!names.includes(existing)) delete next[existing];
14778
15619
  nextOverride.derivedStreams = next;
14779
- for (const existing of Object.keys(nextOverride.derivedStreams === next ? {} : nextOverride.derivedStreams ?? {})) if (!names.includes(existing)) this.retractCameraStream({
14780
- deviceId,
14781
- camStreamId: `derived:${existing}`
14782
- });
14783
- for (const n of names) {
14784
- const def = next[n];
14785
- if (!def?.source) continue;
14786
- const pinnedCamStreamId = derivedSourceCamStreamId(def.source);
14787
- if (pinnedCamStreamId && !this.brokers.has(brokerIdFor(deviceId, pinnedCamStreamId))) continue;
14788
- this.publishCameraStream({
14789
- deviceId,
14790
- camStreamId: `derived:${n}`,
14791
- kind: "derived",
14792
- source: def.source,
14793
- encodeProfile: def.encodeProfile
14794
- }).catch((err) => {
14795
- if (err instanceof Error && err.message.includes("SOURCE_NOT_FOUND")) return;
14796
- this.logger.warn("applyDeviceSettingsPatch: derivedStreamNames materialise failed", {
14797
- tags: { deviceId },
14798
- meta: {
14799
- name: n,
14800
- error: err instanceof Error ? err.message : String(err)
14801
- }
14802
- });
14803
- });
14804
- }
14805
15620
  continue;
14806
15621
  }
14807
15622
  if (fieldKey.startsWith("derivedSource:")) {
14808
15623
  const name = fieldKey.slice(14);
14809
15624
  overrideDirty = true;
15625
+ derivedDirty = true;
14810
15626
  const next = { ...nextOverride.derivedStreams };
14811
15627
  if (!next[name]) next[name] = { encodeProfile: {
14812
15628
  video: { codec: "h264" },
@@ -14827,6 +15643,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14827
15643
  if (fieldKey.startsWith("derivedProfile:")) {
14828
15644
  const name = fieldKey.slice(15);
14829
15645
  overrideDirty = true;
15646
+ derivedDirty = true;
14830
15647
  const next = { ...nextOverride.derivedStreams };
14831
15648
  if (!next[name]) next[name] = { encodeProfile: {
14832
15649
  video: { codec: "h264" },
@@ -14842,6 +15659,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14842
15659
  if (fieldKey.startsWith("derivedPrebufferEnabled:")) {
14843
15660
  const name = fieldKey.slice(24);
14844
15661
  overrideDirty = true;
15662
+ derivedDirty = true;
14845
15663
  const next = { ...nextOverride.derivedStreams };
14846
15664
  if (next[name]) {
14847
15665
  next[name] = {
@@ -14861,6 +15679,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14861
15679
  if (fieldKey.startsWith("derivedPrebufferSec:")) {
14862
15680
  const name = fieldKey.slice(20);
14863
15681
  overrideDirty = true;
15682
+ derivedDirty = true;
14864
15683
  const next = { ...nextOverride.derivedStreams };
14865
15684
  if (next[name] && typeof value === "number") {
14866
15685
  next[name] = {
@@ -14880,6 +15699,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14880
15699
  if (fieldKey.startsWith("derivedRtspEnabled:")) {
14881
15700
  const name = fieldKey.slice(19);
14882
15701
  overrideDirty = true;
15702
+ derivedDirty = true;
14883
15703
  const next = { ...nextOverride.derivedStreams };
14884
15704
  if (next[name]) {
14885
15705
  next[name] = {
@@ -14896,8 +15716,81 @@ var StreamBrokerManager = class StreamBrokerManager {
14896
15716
  else this.deviceOverrides.set(deviceId, nextOverride);
14897
15717
  this.deviceOverridePersister?.(this.deviceOverrides);
14898
15718
  }
15719
+ if (derivedDirty) await this.reconcileDerivedStreams(deviceId, priorDerivedDefs);
14899
15720
  return { success: true };
14900
15721
  }
15722
+ /**
15723
+ * Materialize / rebind / tear down the live `derived:*` brokers of a device
15724
+ * so they match the persisted `derivedStreams` overrides. Idempotent:
15725
+ *
15726
+ * - a derived WITH a source that isn't live yet → publish (materialize);
15727
+ * - a materialized derived whose SOURCE changed → retract + republish so
15728
+ * its feeder rebinds to the new source;
15729
+ * - a materialized derived whose source is unchanged → left running (an
15730
+ * encode-only edit must NOT tear down a live stream on every keystroke —
15731
+ * the new encode params apply on the next feeder respawn, matching the
15732
+ * `publishCameraStream` single-flight contract);
15733
+ * - a derived removed from the list (present in `prior`, absent now) →
15734
+ * retract.
15735
+ *
15736
+ * A PROFILE source resolves its physical stream dynamically (no
15737
+ * precondition); a pinned CAMSTREAM source needs its broker up first — when
15738
+ * absent the def is queued in `pendingDerivedStreams` so a later source
15739
+ * publish drains it.
15740
+ */
15741
+ async reconcileDerivedStreams(deviceId, prior) {
15742
+ const current = this.deviceOverrides.get(deviceId)?.derivedStreams ?? {};
15743
+ for (const name of prior.keys()) if (!(name in current)) await this.retractCameraStream({
15744
+ deviceId,
15745
+ camStreamId: `derived:${name}`
15746
+ }).catch(() => {});
15747
+ for (const [name, def] of Object.entries(current)) {
15748
+ const camStreamId = `derived:${name}`;
15749
+ const materialized = this.cameraStreams.get(deviceId)?.has(camStreamId) ?? false;
15750
+ if (!def.source) {
15751
+ if (materialized) await this.retractCameraStream({
15752
+ deviceId,
15753
+ camStreamId
15754
+ }).catch(() => {});
15755
+ continue;
15756
+ }
15757
+ const priorSource = prior.get(name)?.source;
15758
+ const sourceChanged = priorSource === void 0 || encodeDerivedSourceValue(priorSource) !== encodeDerivedSourceValue(def.source);
15759
+ if (materialized && !sourceChanged) continue;
15760
+ if (materialized && sourceChanged) await this.retractCameraStream({
15761
+ deviceId,
15762
+ camStreamId
15763
+ }).catch(() => {});
15764
+ const pinned = derivedSourceCamStreamId(def.source);
15765
+ if (pinned && !this.brokers.has(brokerIdFor(deviceId, pinned))) {
15766
+ const sourceBrokerId = brokerIdFor(deviceId, pinned);
15767
+ const list = this.pendingDerivedStreams.get(sourceBrokerId) ?? [];
15768
+ if (!list.some((p) => p.name === name)) list.push({
15769
+ deviceId,
15770
+ name,
15771
+ def
15772
+ });
15773
+ this.pendingDerivedStreams.set(sourceBrokerId, list);
15774
+ continue;
15775
+ }
15776
+ await this.publishCameraStream({
15777
+ deviceId,
15778
+ camStreamId,
15779
+ kind: "derived",
15780
+ source: def.source,
15781
+ encodeProfile: def.encodeProfile
15782
+ }).catch((err) => {
15783
+ if (err instanceof Error && err.message.includes("SOURCE_NOT_FOUND")) return;
15784
+ this.logger.warn("reconcileDerivedStreams: materialize failed", {
15785
+ tags: { deviceId },
15786
+ meta: {
15787
+ name,
15788
+ error: err instanceof Error ? err.message : String(err)
15789
+ }
15790
+ });
15791
+ });
15792
+ }
15793
+ }
14901
15794
  nextOrder(deviceId) {
14902
15795
  const next = (this.streamOrderSeq.get(deviceId) ?? 0) + 1;
14903
15796
  this.streamOrderSeq.set(deviceId, next);
@@ -14914,7 +15807,7 @@ var StreamBrokerManager = class StreamBrokerManager {
14914
15807
  return false;
14915
15808
  }
14916
15809
  labelForDropdown(s) {
14917
- const base = s.label ?? s.camStreamId;
15810
+ const base = s.label ?? (s.kind === "derived" && s.camStreamId.startsWith("derived:") ? `Derived · ${s.camStreamId.slice(8)}` : s.camStreamId);
14918
15811
  if (s.resolution) return `${base} (${s.resolution.width}×${s.resolution.height})`;
14919
15812
  return base;
14920
15813
  }
@@ -18183,9 +19076,13 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
18183
19076
  for (const cam of camStreams) {
18184
19077
  const camStreamId = cam.camStreamId;
18185
19078
  const stats = (await this.manager.getBroker({ brokerId: require_dist.makeSourceBrokerId(deviceId, camStreamId) }))?.getStats();
19079
+ const isDerived = camStreamId.startsWith("derived:");
19080
+ const derivedName = isDerived ? camStreamId.slice(8) : camStreamId;
19081
+ const res = cam.resolution ? ` (${cam.resolution.width}×${cam.resolution.height})` : "";
19082
+ const label = cam.label ?? (isDerived ? `Derived · ${derivedName}${res}` : camStreamId);
18186
19083
  choices.push({
18187
19084
  id: `stream:${camStreamId}`,
18188
- label: cam.label ?? camStreamId,
19085
+ label,
18189
19086
  target: {
18190
19087
  kind: "cam-stream",
18191
19088
  camStreamId
@@ -20193,7 +21090,7 @@ function fromBase64Url(s) {
20193
21090
  //#region src/stream-broker/index.ts
20194
21091
  var stream_broker_default = StreamBrokerAddon;
20195
21092
  //#endregion
20196
- exports.DecoderSessionProxy = require_remote_source_plane.DecoderSessionProxy;
21093
+ exports.DecoderSessionProxy = DecoderSessionProxy;
20197
21094
  exports.FfmpegDecoderProvider = FfmpegDecoderProvider;
20198
21095
  exports.InProcessTransport = InProcessTransport;
20199
21096
  exports.LengthPrefixCodec = LengthPrefixCodec;