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