@camstack/addon-pipeline 0.1.14 → 0.1.16
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 -4
- package/dist/audio-analyzer/index.js.map +1 -1
- package/dist/audio-analyzer/index.mjs +2 -4
- package/dist/audio-analyzer/index.mjs.map +1 -1
- package/dist/audio-codec-nodeav/index.js +1 -1
- package/dist/audio-codec-nodeav/index.mjs +1 -1
- package/dist/decoder-nodeav/index.js +552 -18
- package/dist/decoder-nodeav/index.js.map +1 -1
- package/dist/decoder-nodeav/index.mjs +553 -19
- package/dist/decoder-nodeav/index.mjs.map +1 -1
- package/dist/detection-pipeline/index.js +2 -4
- package/dist/detection-pipeline/index.js.map +1 -1
- package/dist/detection-pipeline/index.mjs +2 -4
- package/dist/detection-pipeline/index.mjs.map +1 -1
- package/dist/{index-DKh0uEve.mjs → index-CVzLrojg.mjs} +539 -97
- package/dist/index-CVzLrojg.mjs.map +1 -0
- package/dist/{index-CFPKrb2Y.js → index-p-6GfKOg.js} +539 -97
- package/dist/index-p-6GfKOg.js.map +1 -0
- package/dist/motion-wasm/index.js +2 -4
- package/dist/motion-wasm/index.js.map +1 -1
- package/dist/motion-wasm/index.mjs +2 -4
- package/dist/motion-wasm/index.mjs.map +1 -1
- package/dist/pipeline-runner/index.js +133 -54
- package/dist/pipeline-runner/index.js.map +1 -1
- package/dist/pipeline-runner/index.mjs +133 -54
- package/dist/pipeline-runner/index.mjs.map +1 -1
- package/dist/stream-broker/@mf-types.zip +0 -0
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +19 -0
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B4l8Nb2y.mjs +20 -0
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DePVYdid.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DAssX3h0.mjs} +4 -2
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CBlCGyx5.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DFoJJhpt.mjs} +1 -1
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-DZchZKbW.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-x7XMEeuJ.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CqeKw-Ig.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-iA_8b8fz.mjs} +6 -6
- package/dist/stream-broker/{client-BK73l2KT.mjs → client-CZXrddDR.mjs} +2990 -3217
- package/dist/stream-broker/{hostInit-DkjoXTMb.mjs → hostInit-1sVsS6_a.mjs} +12 -12
- package/dist/stream-broker/{index-BP0-1QYT.mjs → index-BCEx31Mh.mjs} +3808 -3100
- package/dist/stream-broker/{index-lmXLeXy8.mjs → index-BvV3RVTZ.mjs} +1 -1
- package/dist/stream-broker/{index-IUYKHbxX.mjs → index-C0BzaWmB.mjs} +1 -1
- package/dist/stream-broker/index-CWkKuNLr.mjs +232 -0
- package/dist/stream-broker/{index-ns1fRD30.mjs → index-CZNxa0ad.mjs} +1 -1
- package/dist/stream-broker/index-Kb4xa8FX.mjs +36403 -0
- package/dist/stream-broker/{index-BxHaCH3N.mjs → index-KtR7Pp0O.mjs} +1 -1
- package/dist/stream-broker/{index-Ss9m7Jum.mjs → index-cYW01SNH.mjs} +1 -1
- package/dist/stream-broker/index.js +802 -541
- package/dist/stream-broker/index.js.map +1 -1
- package/dist/stream-broker/index.mjs +802 -519
- package/dist/stream-broker/index.mjs.map +1 -1
- package/dist/stream-broker/{jsx-runtime-ZdY5pIZz.mjs → jsx-runtime-B_evVsXl.mjs} +1 -1
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +23 -31
- package/dist/index-CFPKrb2Y.js.map +0 -1
- package/dist/index-DKh0uEve.mjs.map +0 -1
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-CpCK52pE.mjs +0 -19
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BN3K4dM8.mjs +0 -20
- package/dist/stream-broker/index-DKercbDS.mjs +0 -20855
- package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
|
-
var __defProp = Object.defineProperty;
|
|
4
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __copyProps = (to, from, except, desc) => {
|
|
9
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
-
for (let key of __getOwnPropNames(from))
|
|
11
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
-
}
|
|
14
|
-
return to;
|
|
15
|
-
};
|
|
16
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
-
mod
|
|
23
|
-
));
|
|
24
2
|
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
25
|
-
const index = require("../index-
|
|
26
|
-
const net = require("node:net");
|
|
3
|
+
const index = require("../index-p-6GfKOg.js");
|
|
27
4
|
const crypto = require("node:crypto");
|
|
5
|
+
const net = require("node:net");
|
|
28
6
|
const net$1 = require("net");
|
|
29
7
|
const events = require("events");
|
|
30
8
|
const node_child_process = require("node:child_process");
|
|
@@ -49,8 +27,8 @@ function _interopNamespaceDefault(e) {
|
|
|
49
27
|
n.default = e;
|
|
50
28
|
return Object.freeze(n);
|
|
51
29
|
}
|
|
52
|
-
const net__namespace = /* @__PURE__ */ _interopNamespaceDefault(net);
|
|
53
30
|
const crypto__namespace = /* @__PURE__ */ _interopNamespaceDefault(crypto);
|
|
31
|
+
const net__namespace = /* @__PURE__ */ _interopNamespaceDefault(net);
|
|
54
32
|
const fs__namespace = /* @__PURE__ */ _interopNamespaceDefault(fs);
|
|
55
33
|
const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path);
|
|
56
34
|
const os__namespace = /* @__PURE__ */ _interopNamespaceDefault(os);
|
|
@@ -74,6 +52,21 @@ class DecoderSessionProxy {
|
|
|
74
52
|
if (frames.length === 0) await new Promise((r) => setTimeout(r, 1));
|
|
75
53
|
}
|
|
76
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Poll a `frameSink: 'shm'` session for `FrameHandle`s (Phase 5 / D9).
|
|
57
|
+
* Mirrors `startPolling` but drains `pullHandles` — the decoder has
|
|
58
|
+
* already written the pixels into a shared-memory ring, so what crosses
|
|
59
|
+
* the cap boundary is the tiny serialisable handle. Runs until
|
|
60
|
+
* `stopPolling` or `destroy`.
|
|
61
|
+
*/
|
|
62
|
+
async startHandlePolling(onHandle) {
|
|
63
|
+
this.polling = true;
|
|
64
|
+
while (this.polling) {
|
|
65
|
+
const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
|
|
66
|
+
for (const handle of handles) onHandle(handle);
|
|
67
|
+
if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
77
70
|
stopPolling() {
|
|
78
71
|
this.polling = false;
|
|
79
72
|
}
|
|
@@ -88,6 +81,462 @@ class DecoderSessionProxy {
|
|
|
88
81
|
await this.api.destroySession({ sessionId: this.sessionId });
|
|
89
82
|
}
|
|
90
83
|
}
|
|
84
|
+
const HANDLE_QUEUE_CAPACITY = 64;
|
|
85
|
+
const DEFAULT_HINT_FPS = 5;
|
|
86
|
+
const DECODE_FPS_WINDOW_MS = 2e3;
|
|
87
|
+
class FrameHandlePlane {
|
|
88
|
+
decoderApi;
|
|
89
|
+
logger;
|
|
90
|
+
resolveStreamInfo;
|
|
91
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
92
|
+
sessions = /* @__PURE__ */ new Map();
|
|
93
|
+
disposed = false;
|
|
94
|
+
/** Re-entrancy guard for `armPendingSessions` — collapses the per-packet
|
|
95
|
+
* `pushPacket` calls into a single in-flight deferred-arm pass. */
|
|
96
|
+
armInFlight = false;
|
|
97
|
+
/** Aggregate decoded-frame rate state — frames fanned out in the current
|
|
98
|
+
* rolling window, the window's start epoch, and the last computed fps. */
|
|
99
|
+
framesInWindow = 0;
|
|
100
|
+
windowStartMs = Date.now();
|
|
101
|
+
decodedFps = 0;
|
|
102
|
+
constructor(options) {
|
|
103
|
+
this.decoderApi = options.decoderApi;
|
|
104
|
+
this.logger = options.logger;
|
|
105
|
+
this.resolveStreamInfo = options.resolveStreamInfo;
|
|
106
|
+
}
|
|
107
|
+
/** Number of active handle subscriptions — surfaced for diagnostics. */
|
|
108
|
+
get subscriberCount() {
|
|
109
|
+
return this.subscriptions.size;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Moleculer nodeIDs of the decoder providers currently hosting this plane's
|
|
113
|
+
* `frameSink: 'shm'` sessions. Used by the broker-manager's per-agent
|
|
114
|
+
* hwaccel-change handler to decide which brokers need a decoder rotation.
|
|
115
|
+
*/
|
|
116
|
+
decoderNodeIds() {
|
|
117
|
+
const ids = [];
|
|
118
|
+
for (const session of this.sessions.values()) {
|
|
119
|
+
if (session.nodeId) ids.push(session.nodeId);
|
|
120
|
+
}
|
|
121
|
+
return ids;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Rotate every shm decoder session hosted on `agentNodeId` — destroy and
|
|
125
|
+
* rebuild it so the new session re-pulls the latest per-agent decoder
|
|
126
|
+
* preferences (hwaccel backend, …). Subscriptions are preserved: the same
|
|
127
|
+
* `subscriptionId`s read from the rebuilt session's fresh ring. A no-op for
|
|
128
|
+
* sessions on other nodes. Returns the number of sessions rotated.
|
|
129
|
+
*/
|
|
130
|
+
async rotate(agentNodeId, reason) {
|
|
131
|
+
if (this.disposed) return 0;
|
|
132
|
+
let rotated = 0;
|
|
133
|
+
for (const session of this.sessions.values()) {
|
|
134
|
+
const sessionAgent = session.nodeId && session.nodeId.includes("/") ? session.nodeId.split("/")[0] : session.nodeId;
|
|
135
|
+
if (sessionAgent !== agentNodeId) continue;
|
|
136
|
+
this.logger?.info("frame-handle plane: rotating shm decoder session", {
|
|
137
|
+
meta: { format: session.format, reason, nodeId: session.nodeId }
|
|
138
|
+
});
|
|
139
|
+
await this.destroySession(session);
|
|
140
|
+
await this.startSessionDecoder(session);
|
|
141
|
+
rotated += 1;
|
|
142
|
+
}
|
|
143
|
+
return rotated;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Register a frame-handle subscription. Spins up (or reuses) a
|
|
147
|
+
* `frameSink: 'shm'` decoder session producing `format`. The returned
|
|
148
|
+
* `subscriptionId` is the handle the consumer passes to
|
|
149
|
+
* `pullFrameHandles` / `unsubscribe`.
|
|
150
|
+
*/
|
|
151
|
+
async subscribe(input) {
|
|
152
|
+
if (this.disposed) {
|
|
153
|
+
throw new Error("FrameHandlePlane: subscribe after dispose");
|
|
154
|
+
}
|
|
155
|
+
const subscriptionId = `fh-${crypto.randomUUID()}`;
|
|
156
|
+
const maxFps = input.maxFps !== void 0 && input.maxFps > 0 ? input.maxFps : DEFAULT_HINT_FPS;
|
|
157
|
+
const subscription = {
|
|
158
|
+
id: subscriptionId,
|
|
159
|
+
format: input.format,
|
|
160
|
+
maxFps,
|
|
161
|
+
tag: input.tag ?? "unknown",
|
|
162
|
+
subscribedAt: Date.now(),
|
|
163
|
+
queue: new index.RingBuffer(HANDLE_QUEUE_CAPACITY),
|
|
164
|
+
framesDelivered: 0
|
|
165
|
+
};
|
|
166
|
+
this.subscriptions.set(subscriptionId, subscription);
|
|
167
|
+
await this.ensureSession(input.format, subscriptionId);
|
|
168
|
+
this.logger?.info("frame-handle subscription added", {
|
|
169
|
+
meta: { subscriptionId, format: input.format, tag: subscription.tag, maxFps }
|
|
170
|
+
});
|
|
171
|
+
return { subscriptionId, maxFps };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Drain up to `maxCount` `FrameHandle`s for a subscription, latest-wins.
|
|
175
|
+
* An unknown subscription id returns `[]` (the consumer may have been
|
|
176
|
+
* torn down concurrently).
|
|
177
|
+
*/
|
|
178
|
+
pullHandles(subscriptionId, maxCount) {
|
|
179
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
180
|
+
if (!subscription) return [];
|
|
181
|
+
const handles = subscription.queue.drain(maxCount);
|
|
182
|
+
subscription.framesDelivered += handles.length;
|
|
183
|
+
return handles;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Release a subscription. When it was the last reader of its format the
|
|
187
|
+
* underlying decoder session is destroyed (and its shm segment unlinked
|
|
188
|
+
* by the decoder). Returns `true` when a known subscription was released.
|
|
189
|
+
*/
|
|
190
|
+
async unsubscribe(subscriptionId) {
|
|
191
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
192
|
+
if (!subscription) return false;
|
|
193
|
+
this.subscriptions.delete(subscriptionId);
|
|
194
|
+
const session = this.sessions.get(subscription.format);
|
|
195
|
+
if (session) {
|
|
196
|
+
session.subscriberIds.delete(subscriptionId);
|
|
197
|
+
if (session.subscriberIds.size === 0) {
|
|
198
|
+
this.sessions.delete(subscription.format);
|
|
199
|
+
await this.destroySession(session);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.logger?.info("frame-handle subscription removed", {
|
|
203
|
+
meta: { subscriptionId, format: subscription.format }
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Forward a video `EncodedPacket` to every active shm decoder session.
|
|
209
|
+
* Push-mode decoders consume this; a pull-mode decoder ignores it (it
|
|
210
|
+
* reads its own pipe via `openStream`). Audio packets are not forwarded.
|
|
211
|
+
*
|
|
212
|
+
* The packet feed doubles as the plane's stream-ready signal: the broker
|
|
213
|
+
* only calls `pushPacket` once it has a source and the first keyframe has
|
|
214
|
+
* landed — exactly the point `resolveStreamInfo` starts returning a
|
|
215
|
+
* descriptor. Each packet therefore triggers `armPendingSessions`, which
|
|
216
|
+
* re-attempts session creation for any deferred subscription so a lone
|
|
217
|
+
* first subscriber on a not-yet-started broker self-arms without a
|
|
218
|
+
* redundant re-`subscribe`.
|
|
219
|
+
*/
|
|
220
|
+
pushPacket(packet) {
|
|
221
|
+
if (this.disposed || packet.type !== "video") return;
|
|
222
|
+
void this.armPendingSessions(packet).catch((err) => {
|
|
223
|
+
this.logger?.warn("frame-handle plane: arm pending sessions error", {
|
|
224
|
+
meta: { error: index.errMsg(err) }
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
this.feedSessions(packet);
|
|
228
|
+
}
|
|
229
|
+
/** Forward a video `EncodedPacket` to every live shm decoder session. */
|
|
230
|
+
feedSessions(packet) {
|
|
231
|
+
for (const session of this.sessions.values()) {
|
|
232
|
+
if (!session.proxy) continue;
|
|
233
|
+
session.proxy.pushPacket(packet).catch((err) => {
|
|
234
|
+
this.logger?.warn("frame-handle plane: decoder push error", {
|
|
235
|
+
meta: { format: session.format, error: index.errMsg(err) }
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Re-attempt session creation for every subscription whose format has no
|
|
242
|
+
* live `FormatSession` yet — the subscriptions whose `startSessionDecoder`
|
|
243
|
+
* was deferred because the broker had no stream at `subscribe` time. Driven
|
|
244
|
+
* off the `pushPacket` feed (event-driven, no polling timer); a no-op once
|
|
245
|
+
* every subscribed format already owns a session.
|
|
246
|
+
*
|
|
247
|
+
* `armInFlight` collapses re-entrant calls: `pushPacket` fires per packet,
|
|
248
|
+
* but `ensureSession` is async — without the guard a burst of packets would
|
|
249
|
+
* launch overlapping decoder-creation round-trips for the same format.
|
|
250
|
+
*
|
|
251
|
+
* `triggerPacket` is the packet whose arrival armed the pass. Because
|
|
252
|
+
* `ensureSession` is async, the synchronous `feedSessions` in `pushPacket`
|
|
253
|
+
* runs before the session lands in `this.sessions` — so this method feeds
|
|
254
|
+
* the triggering packet to each session it just created, otherwise that
|
|
255
|
+
* first (keyframe-carrying) packet would be lost and a push-mode H264/H265
|
|
256
|
+
* decoder would stall waiting for SPS/PPS.
|
|
257
|
+
*/
|
|
258
|
+
async armPendingSessions(triggerPacket) {
|
|
259
|
+
if (this.disposed || this.armInFlight) return;
|
|
260
|
+
const pending = /* @__PURE__ */ new Map();
|
|
261
|
+
for (const subscription of this.subscriptions.values()) {
|
|
262
|
+
if (this.sessions.has(subscription.format)) continue;
|
|
263
|
+
if (!pending.has(subscription.format)) {
|
|
264
|
+
pending.set(subscription.format, subscription.id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (pending.size === 0) return;
|
|
268
|
+
this.armInFlight = true;
|
|
269
|
+
try {
|
|
270
|
+
for (const [format, subscriptionId] of pending) {
|
|
271
|
+
if (this.disposed) return;
|
|
272
|
+
await this.ensureSession(format, subscriptionId);
|
|
273
|
+
const armed = this.sessions.get(format);
|
|
274
|
+
if (armed?.proxy) {
|
|
275
|
+
armed.proxy.pushPacket(triggerPacket).catch((err) => {
|
|
276
|
+
this.logger?.warn("frame-handle plane: decoder push error", {
|
|
277
|
+
meta: { format, error: index.errMsg(err) }
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
this.armInFlight = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Aggregate decoded-frame rate across every `frameSink: 'shm'` session in
|
|
288
|
+
* this plane, frames/s. Each `fanoutHandle` call is one decoded frame
|
|
289
|
+
* delivered into the plane; the rate is recomputed over a rolling
|
|
290
|
+
* `DECODE_FPS_WINDOW_MS` window. Returns the last computed value between
|
|
291
|
+
* window rolls, and `0` while no session is producing.
|
|
292
|
+
*/
|
|
293
|
+
decodeFps() {
|
|
294
|
+
this.rollDecodeFpsWindow();
|
|
295
|
+
return this.decodedFps;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Roll the decoded-fps window when it has elapsed: convert frames seen in
|
|
299
|
+
* the window into a per-second rate, then reset the counter. Called both
|
|
300
|
+
* on every fanout and on every `decodeFps()` read so a stalled session
|
|
301
|
+
* (no fanout) decays its rate to `0` rather than reporting a stale value.
|
|
302
|
+
*/
|
|
303
|
+
rollDecodeFpsWindow() {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const windowMs = now - this.windowStartMs;
|
|
306
|
+
if (windowMs < DECODE_FPS_WINDOW_MS) return;
|
|
307
|
+
this.decodedFps = this.framesInWindow / windowMs * 1e3;
|
|
308
|
+
this.framesInWindow = 0;
|
|
309
|
+
this.windowStartMs = now;
|
|
310
|
+
}
|
|
311
|
+
/** Diagnostic snapshot of every active frame-handle subscription. */
|
|
312
|
+
listSubscribers() {
|
|
313
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
314
|
+
tag: s.tag,
|
|
315
|
+
subscribedAt: s.subscribedAt,
|
|
316
|
+
maxFps: s.maxFps,
|
|
317
|
+
framesDelivered: s.framesDelivered
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Force-release every subscription whose tag matches `tag`. Returns the
|
|
322
|
+
* number released. Decoder sessions wind down when their last subscriber
|
|
323
|
+
* leaves, same as a normal `unsubscribe`.
|
|
324
|
+
*/
|
|
325
|
+
async killByTag(tag) {
|
|
326
|
+
const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
|
|
327
|
+
for (const id of victims) {
|
|
328
|
+
await this.unsubscribe(id);
|
|
329
|
+
}
|
|
330
|
+
return victims.length;
|
|
331
|
+
}
|
|
332
|
+
/** Tear down every subscription + decoder session. Idempotent. */
|
|
333
|
+
async dispose() {
|
|
334
|
+
if (this.disposed) return;
|
|
335
|
+
this.disposed = true;
|
|
336
|
+
this.subscriptions.clear();
|
|
337
|
+
const sessions = [...this.sessions.values()];
|
|
338
|
+
this.sessions.clear();
|
|
339
|
+
await Promise.all(sessions.map((s) => this.destroySession(s)));
|
|
340
|
+
}
|
|
341
|
+
// ── Internal ───────────────────────────────────────────────────────
|
|
342
|
+
/**
|
|
343
|
+
* Ensure a `frameSink: 'shm'` decoder session for `format` exists and add
|
|
344
|
+
* `subscriptionId` to its reader set. Reuses the session when one already
|
|
345
|
+
* runs for that format.
|
|
346
|
+
*
|
|
347
|
+
* When a session is created fresh, its reader set is seeded with EVERY
|
|
348
|
+
* subscription currently reading `format`, not just `subscriptionId` — a
|
|
349
|
+
* deferred-arm pass (`armPendingSessions`) may create the session long
|
|
350
|
+
* after several subscriptions of that format have registered, and the
|
|
351
|
+
* session is reference-counted by `subscriberIds`. Seeding only the one id
|
|
352
|
+
* would let an `unsubscribe` of that id tear the session down while its
|
|
353
|
+
* sibling subscriptions still read it.
|
|
354
|
+
*/
|
|
355
|
+
async ensureSession(format, subscriptionId) {
|
|
356
|
+
const existing = this.sessions.get(format);
|
|
357
|
+
if (existing) {
|
|
358
|
+
existing.subscriberIds.add(subscriptionId);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const subscriberIds = /* @__PURE__ */ new Set([subscriptionId]);
|
|
362
|
+
for (const subscription of this.subscriptions.values()) {
|
|
363
|
+
if (subscription.format === format) subscriberIds.add(subscription.id);
|
|
364
|
+
}
|
|
365
|
+
const session = {
|
|
366
|
+
format,
|
|
367
|
+
proxy: null,
|
|
368
|
+
nodeId: null,
|
|
369
|
+
subscriberIds
|
|
370
|
+
};
|
|
371
|
+
const started = await this.startSessionDecoder(session);
|
|
372
|
+
if (started) {
|
|
373
|
+
this.sessions.set(format, session);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Create the `frameSink: 'shm'` decoder for a `FormatSession` and start
|
|
378
|
+
* draining its handle queue. Mutates `session.proxy` / `session.nodeId` in
|
|
379
|
+
* place so a `rotate()` can rebuild a registered session without disturbing
|
|
380
|
+
* its `subscriberIds`. Returns `false` when no stream / unsupported codec
|
|
381
|
+
* means no decoder could be created.
|
|
382
|
+
*/
|
|
383
|
+
async startSessionDecoder(session) {
|
|
384
|
+
const { format } = session;
|
|
385
|
+
const info = this.resolveStreamInfo();
|
|
386
|
+
if (!info) {
|
|
387
|
+
this.logger?.info("frame-handle plane: no stream — session deferred", {
|
|
388
|
+
meta: { format }
|
|
389
|
+
});
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
const supported = await this.decoderApi.supportsCodec({ codec: info.codec });
|
|
393
|
+
if (!supported) {
|
|
394
|
+
this.logger?.warn("frame-handle plane: codec unsupported — session skipped", {
|
|
395
|
+
meta: { format, codec: info.codec }
|
|
396
|
+
});
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
const { sessionId, nodeId } = await this.decoderApi.createSession({
|
|
400
|
+
codec: info.codec,
|
|
401
|
+
maxFps: 0,
|
|
402
|
+
outputFormat: format,
|
|
403
|
+
scale: 1,
|
|
404
|
+
frameSink: "shm",
|
|
405
|
+
...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
|
|
406
|
+
tag: `${info.tag}:shm:${format}`
|
|
407
|
+
});
|
|
408
|
+
const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
|
|
409
|
+
session.proxy = proxy;
|
|
410
|
+
session.nodeId = nodeId;
|
|
411
|
+
proxy.startHandlePolling((handle) => {
|
|
412
|
+
this.fanoutHandle(format, handle);
|
|
413
|
+
}).catch((err) => {
|
|
414
|
+
this.logger?.warn("frame-handle plane: handle polling error", {
|
|
415
|
+
meta: { format, error: index.errMsg(err) }
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
if (info.pullModeUrl) {
|
|
419
|
+
proxy.openStream(info.pullModeUrl).catch((err) => {
|
|
420
|
+
this.logger?.error("frame-handle plane: pull-mode openStream failed", {
|
|
421
|
+
meta: { format, error: index.errMsg(err) }
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
this.logger?.info("frame-handle plane: shm decoder session created", {
|
|
426
|
+
meta: { format, codec: info.codec, sessionId, nodeId }
|
|
427
|
+
});
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
/** Push one decoded `FrameHandle` to every subscription of `format`. */
|
|
431
|
+
fanoutHandle(format, handle) {
|
|
432
|
+
this.rollDecodeFpsWindow();
|
|
433
|
+
this.framesInWindow += 1;
|
|
434
|
+
for (const subscription of this.subscriptions.values()) {
|
|
435
|
+
if (subscription.format !== format) continue;
|
|
436
|
+
subscription.queue.push(handle);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Destroy a decoder session, swallowing teardown errors. */
|
|
440
|
+
async destroySession(session) {
|
|
441
|
+
const proxy = session.proxy;
|
|
442
|
+
session.proxy = null;
|
|
443
|
+
session.nodeId = null;
|
|
444
|
+
if (!proxy) return;
|
|
445
|
+
try {
|
|
446
|
+
await proxy.destroy();
|
|
447
|
+
} catch (err) {
|
|
448
|
+
this.logger?.warn("frame-handle plane: session destroy failed", {
|
|
449
|
+
meta: { format: session.format, error: index.errMsg(err) }
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const AUDIO_QUEUE_CAPACITY = 64;
|
|
455
|
+
class AudioChunkPlane {
|
|
456
|
+
logger;
|
|
457
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
458
|
+
constructor(logger) {
|
|
459
|
+
this.logger = logger;
|
|
460
|
+
}
|
|
461
|
+
/** Number of active audio-chunk subscriptions — surfaced as audio demand. */
|
|
462
|
+
get subscriberCount() {
|
|
463
|
+
return this.subscriptions.size;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Register an audio-chunk subscription. The returned `subscriptionId` is
|
|
467
|
+
* the handle the consumer passes to `pull` / `unsubscribe`.
|
|
468
|
+
*/
|
|
469
|
+
subscribe(input) {
|
|
470
|
+
const subscriptionId = `ac-${crypto.randomUUID()}`;
|
|
471
|
+
const subscription = {
|
|
472
|
+
id: subscriptionId,
|
|
473
|
+
tag: input?.tag ?? "unknown",
|
|
474
|
+
subscribedAt: Date.now(),
|
|
475
|
+
queue: new index.RingBuffer(AUDIO_QUEUE_CAPACITY),
|
|
476
|
+
chunksDelivered: 0
|
|
477
|
+
};
|
|
478
|
+
this.subscriptions.set(subscriptionId, subscription);
|
|
479
|
+
this.logger?.info("audio-chunk subscription added", {
|
|
480
|
+
meta: { subscriptionId, tag: subscription.tag }
|
|
481
|
+
});
|
|
482
|
+
return { subscriptionId };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Drain up to `maxCount` `DecodedAudioChunk`s for a subscription, in FIFO
|
|
486
|
+
* arrival order. An unknown subscription id returns `[]` (the consumer may
|
|
487
|
+
* have been torn down concurrently).
|
|
488
|
+
*/
|
|
489
|
+
pull(subscriptionId, maxCount) {
|
|
490
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
491
|
+
if (!subscription) return [];
|
|
492
|
+
const chunks = subscription.queue.drain(maxCount);
|
|
493
|
+
subscription.chunksDelivered += chunks.length;
|
|
494
|
+
return chunks;
|
|
495
|
+
}
|
|
496
|
+
/** Release a subscription. Returns `true` when a known subscription was released. */
|
|
497
|
+
unsubscribe(subscriptionId) {
|
|
498
|
+
const existed = this.subscriptions.delete(subscriptionId);
|
|
499
|
+
if (existed) {
|
|
500
|
+
this.logger?.info("audio-chunk subscription removed", {
|
|
501
|
+
meta: { subscriptionId }
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return existed;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Fan one decoded `DecodedAudioChunk` into every active subscription's FIFO
|
|
508
|
+
* queue. A full queue drops its oldest chunk (bounded safety valve for a
|
|
509
|
+
* stalled consumer). No-op when there are no subscriptions.
|
|
510
|
+
*/
|
|
511
|
+
fanout(chunk) {
|
|
512
|
+
for (const subscription of this.subscriptions.values()) {
|
|
513
|
+
subscription.queue.push(chunk);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** Diagnostic snapshot of every active audio-chunk subscription. */
|
|
517
|
+
listSubscribers() {
|
|
518
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
519
|
+
tag: s.tag,
|
|
520
|
+
subscribedAt: s.subscribedAt,
|
|
521
|
+
chunksDelivered: s.chunksDelivered
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Force-release every subscription whose tag matches `tag`. Returns the
|
|
526
|
+
* number released.
|
|
527
|
+
*/
|
|
528
|
+
killByTag(tag) {
|
|
529
|
+
const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
|
|
530
|
+
for (const id of victims) {
|
|
531
|
+
this.subscriptions.delete(id);
|
|
532
|
+
}
|
|
533
|
+
return victims.length;
|
|
534
|
+
}
|
|
535
|
+
/** Tear down every subscription. Idempotent. */
|
|
536
|
+
dispose() {
|
|
537
|
+
this.subscriptions.clear();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
91
540
|
const SAMPLERATE_TABLE = [
|
|
92
541
|
96e3,
|
|
93
542
|
88200,
|
|
@@ -477,25 +926,6 @@ class AudioCodecSession {
|
|
|
477
926
|
});
|
|
478
927
|
}
|
|
479
928
|
}
|
|
480
|
-
class FrameDropper {
|
|
481
|
-
intervalMs;
|
|
482
|
-
lastPassedAt = -Infinity;
|
|
483
|
-
constructor(maxFps) {
|
|
484
|
-
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
485
|
-
}
|
|
486
|
-
shouldKeep() {
|
|
487
|
-
if (this.intervalMs === 0) return true;
|
|
488
|
-
const now = Date.now();
|
|
489
|
-
if (now - this.lastPassedAt >= this.intervalMs) {
|
|
490
|
-
this.lastPassedAt = now;
|
|
491
|
-
return true;
|
|
492
|
-
}
|
|
493
|
-
return false;
|
|
494
|
-
}
|
|
495
|
-
setMaxFps(maxFps) {
|
|
496
|
-
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
929
|
const MAX_WRITE_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
500
930
|
class StreamPipeServer {
|
|
501
931
|
server;
|
|
@@ -3187,27 +3617,8 @@ function getPlaceholderFrame(kind, codec) {
|
|
|
3187
3617
|
codecCache[kind] = buf;
|
|
3188
3618
|
return buf;
|
|
3189
3619
|
}
|
|
3190
|
-
let cachedSharp = null;
|
|
3191
|
-
async function getSharp() {
|
|
3192
|
-
if (cachedSharp) return cachedSharp;
|
|
3193
|
-
const mod = await import("sharp");
|
|
3194
|
-
cachedSharp = mod.default;
|
|
3195
|
-
return cachedSharp;
|
|
3196
|
-
}
|
|
3197
|
-
function swapRedBlue(rgb) {
|
|
3198
|
-
const out = Buffer.allocUnsafe(rgb.length);
|
|
3199
|
-
for (let i = 0; i + 2 < rgb.length; i += 3) {
|
|
3200
|
-
out[i] = rgb[i + 2];
|
|
3201
|
-
out[i + 1] = rgb[i + 1];
|
|
3202
|
-
out[i + 2] = rgb[i];
|
|
3203
|
-
}
|
|
3204
|
-
return out;
|
|
3205
|
-
}
|
|
3206
|
-
const DEFAULT_MAX_FPS = 5;
|
|
3207
|
-
const DEFAULT_SCALE = 1;
|
|
3208
3620
|
const INITIAL_RECONNECT_DELAY_MS = 1e3;
|
|
3209
3621
|
const MAX_RECONNECT_DELAY_MS = 3e4;
|
|
3210
|
-
const DECODER_TEARDOWN_GRACE_MS = 2e3;
|
|
3211
3622
|
const SUSPEND_GRACE_MS = 5e3;
|
|
3212
3623
|
const PUSH_STALL_TIMEOUT_MS = 15e3;
|
|
3213
3624
|
class StreamBroker {
|
|
@@ -3247,17 +3658,13 @@ class StreamBroker {
|
|
|
3247
3658
|
lastRefreshRequestAt = 0;
|
|
3248
3659
|
/** Detected or configured codec — persists across reconnects */
|
|
3249
3660
|
detectedCodec;
|
|
3250
|
-
decoderProxy = null;
|
|
3251
|
-
/** Current output format of the shared decoder session. */
|
|
3252
|
-
decoderOutputFormat = null;
|
|
3253
3661
|
/**
|
|
3254
|
-
*
|
|
3255
|
-
*
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
* decoder rotation. Null when no shared decoder session exists.
|
|
3662
|
+
* The shared-memory frame-handle surface (Phase 5 / D9) — the broker's sole
|
|
3663
|
+
* decoded-frame path. Lazily created on the first `subscribeFrameHandles`
|
|
3664
|
+
* call; owns the per-format `frameSink: 'shm'` decoder sessions a handle
|
|
3665
|
+
* consumer reads from. `null` until a consumer subscribes.
|
|
3259
3666
|
*/
|
|
3260
|
-
|
|
3667
|
+
frameHandlePlane = null;
|
|
3261
3668
|
decoderApi;
|
|
3262
3669
|
audioCodecApi;
|
|
3263
3670
|
logger;
|
|
@@ -3308,7 +3715,6 @@ class StreamBroker {
|
|
|
3308
3715
|
static FIRST_VIDEO_TIMEOUT_MS = 8e3;
|
|
3309
3716
|
manualStop = false;
|
|
3310
3717
|
stopping = false;
|
|
3311
|
-
decoderTeardownTimer;
|
|
3312
3718
|
restreamers = [];
|
|
3313
3719
|
pipeServer;
|
|
3314
3720
|
rtspRestreamer;
|
|
@@ -3343,19 +3749,20 @@ class StreamBroker {
|
|
|
3343
3749
|
* quiet unless an operator explicitly enables debug for one device.
|
|
3344
3750
|
*/
|
|
3345
3751
|
streamingDebug = false;
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3752
|
+
/**
|
|
3753
|
+
* Decoded audio-chunk plane (Phase 5 / D9) — the poll-based, tRPC-reachable
|
|
3754
|
+
* replacement for the live-object `onDecodedAudioChunk` callback path.
|
|
3755
|
+
* Holds the per-subscription FIFO chunk queues. Assigned in the constructor
|
|
3756
|
+
* so it gets a scoped logger child.
|
|
3757
|
+
*/
|
|
3758
|
+
audioChunkPlane;
|
|
3759
|
+
/**
|
|
3760
|
+
* Gate for feeding encoded packets to the decoder: H.264/H.265 decoders
|
|
3761
|
+
* need the SPS/PPS carried in the first keyframe to initialise, so the
|
|
3762
|
+
* broker withholds packets from the shm frame plane until the first
|
|
3763
|
+
* keyframe arrives.
|
|
3764
|
+
*/
|
|
3352
3765
|
firstKeyframeReceived = false;
|
|
3353
|
-
/** Retry handle for "no decoder provider yet" — addons register in
|
|
3354
|
-
* arbitrary order, the broker must wait rather than fail. Cancelled
|
|
3355
|
-
* on decoder attach or broker destroy. */
|
|
3356
|
-
decoderResolveRetryTimer;
|
|
3357
|
-
/** Attempt counter for the retry loop above — feeds bounded backoff. */
|
|
3358
|
-
decoderResolveAttempts = 0;
|
|
3359
3766
|
/** Timer for push-mode stall detection. */
|
|
3360
3767
|
pushStallTimer;
|
|
3361
3768
|
/** Whether pre-buffer counts as demand (keeps the stream alive even with 0 subscribers). */
|
|
@@ -3364,15 +3771,10 @@ class StreamBroker {
|
|
|
3364
3771
|
suspendTimer;
|
|
3365
3772
|
/** True while the stream is suspended (ffmpeg killed, no RTSP connection). */
|
|
3366
3773
|
_suspended = false;
|
|
3367
|
-
/** Debug counters for decoder troubleshooting */
|
|
3368
|
-
decoderPushCount = 0;
|
|
3369
|
-
decoderFrameCount = 0;
|
|
3370
3774
|
/** Counter for video RTP packets — drives the milestone diagnostic
|
|
3371
3775
|
* log alongside `audioRtpSeen`, so a wake-up cycle that brings audio
|
|
3372
3776
|
* back but never video is observable from the structured logs. */
|
|
3373
3777
|
videoRtpSeen = 0;
|
|
3374
|
-
/** Cached decoder stats — updated by the polling loop, read synchronously by getStats(). */
|
|
3375
|
-
cachedDecoderStats;
|
|
3376
3778
|
/** Tracking flags set synchronously by the RTP depacketizer callback. */
|
|
3377
3779
|
_lastNalKeyframe = false;
|
|
3378
3780
|
_lastNalParamSet = false;
|
|
@@ -3405,6 +3807,7 @@ class StreamBroker {
|
|
|
3405
3807
|
this.rtspRestreamer = new RtspRestreamer(deviceId);
|
|
3406
3808
|
if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
|
|
3407
3809
|
this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
|
|
3810
|
+
this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
|
|
3408
3811
|
this.rtspRestreamer.onSessionCountChanged(() => this.checkDemand());
|
|
3409
3812
|
this.pipeServer.onClientCountChanged(() => this.checkDemand());
|
|
3410
3813
|
}
|
|
@@ -3576,10 +3979,6 @@ class StreamBroker {
|
|
|
3576
3979
|
clearTimeout(this.reconnectTimer);
|
|
3577
3980
|
this.reconnectTimer = void 0;
|
|
3578
3981
|
}
|
|
3579
|
-
if (this.decoderTeardownTimer) {
|
|
3580
|
-
clearTimeout(this.decoderTeardownTimer);
|
|
3581
|
-
this.decoderTeardownTimer = void 0;
|
|
3582
|
-
}
|
|
3583
3982
|
if (this.suspendTimer) {
|
|
3584
3983
|
clearTimeout(this.suspendTimer);
|
|
3585
3984
|
this.suspendTimer = void 0;
|
|
@@ -3588,18 +3987,18 @@ class StreamBroker {
|
|
|
3588
3987
|
clearTimeout(this.pushStallTimer);
|
|
3589
3988
|
this.pushStallTimer = void 0;
|
|
3590
3989
|
}
|
|
3591
|
-
if (this.
|
|
3592
|
-
|
|
3990
|
+
if (this.frameHandlePlane) {
|
|
3991
|
+
const plane = this.frameHandlePlane;
|
|
3992
|
+
this.frameHandlePlane = null;
|
|
3993
|
+
await plane.dispose();
|
|
3593
3994
|
}
|
|
3594
3995
|
await this.pipeServer.stop();
|
|
3595
3996
|
this.rtspRestreamer.destroy();
|
|
3596
3997
|
this.preBuffer.clear();
|
|
3597
3998
|
this.pendingParamNals = [];
|
|
3598
|
-
this.pendingPullModeOpen = null;
|
|
3599
3999
|
this.firstKeyframeReceived = false;
|
|
3600
4000
|
this.encodedCallbacks.clear();
|
|
3601
|
-
this.
|
|
3602
|
-
this.audioSubscribers.clear();
|
|
4001
|
+
this.audioChunkPlane.dispose();
|
|
3603
4002
|
}
|
|
3604
4003
|
// ── Auto-suspend / resume ─────────────────────────────────────────────
|
|
3605
4004
|
/** Whether the broker is currently suspended (idle, no RTSP connection). */
|
|
@@ -3615,8 +4014,8 @@ class StreamBroker {
|
|
|
3615
4014
|
hasDemand() {
|
|
3616
4015
|
if (this.encodedCallbacks.size > 0) return true;
|
|
3617
4016
|
if (this.rtpVideoCallbacks.size > 0) return true;
|
|
3618
|
-
if (this.
|
|
3619
|
-
if (this.
|
|
4017
|
+
if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
|
|
4018
|
+
if (this.audioChunkPlane.subscriberCount > 0) return true;
|
|
3620
4019
|
if (this.rtspRestreamer.getSessionCount() > 0) return true;
|
|
3621
4020
|
if (this.pipeServer.getClientCount() > 0) return true;
|
|
3622
4021
|
if (this._preBufferEnabled && this.preBuffer.getDuration() > 0) return true;
|
|
@@ -3626,160 +4025,6 @@ class StreamBroker {
|
|
|
3626
4025
|
* Check demand and schedule suspend/resume. Called after every subscriber
|
|
3627
4026
|
* add/remove and after pre-buffer enable/disable.
|
|
3628
4027
|
*/
|
|
3629
|
-
/**
|
|
3630
|
-
* Pick the decoder's native output format given the current subscriber
|
|
3631
|
-
* mix. Phase 3 collapsed three concerns into this one decision:
|
|
3632
|
-
*
|
|
3633
|
-
* 1. Avoid wasted CPU when subscribers are homogeneous — `gray`-
|
|
3634
|
-
* only stays `gray` (motion-only camera), `jpeg`-only stays
|
|
3635
|
-
* `jpeg` (no detection consumer joined yet, broker doesn't have
|
|
3636
|
-
* to encode either).
|
|
3637
|
-
* 2. Pick the cheapest *canonical* mode for mixed subscribers —
|
|
3638
|
-
* `rgb` is the universal source for the broker-side conversion
|
|
3639
|
-
* cache: gray is one luminance pass, jpeg is one sharp encode,
|
|
3640
|
-
* bgr is a channel swap. Producing JPEG decoder-side and then
|
|
3641
|
-
* *decoding back* to feed a raw consumer would cost more than
|
|
3642
|
-
* both consumers combined.
|
|
3643
|
-
* 3. Stay stable when there are no subscribers — defaults to
|
|
3644
|
-
* `jpeg` so the legacy "no subscriber yet" code path keeps
|
|
3645
|
-
* behaving as it did pre-refactor.
|
|
3646
|
-
*/
|
|
3647
|
-
resolveDecoderFormat() {
|
|
3648
|
-
if (this.decodedSubscribers.size === 0) return "jpeg";
|
|
3649
|
-
let allGray = true;
|
|
3650
|
-
let allJpeg = true;
|
|
3651
|
-
for (const sub of this.decodedSubscribers.values()) {
|
|
3652
|
-
if (sub.format !== "gray") allGray = false;
|
|
3653
|
-
if (sub.format !== "jpeg") allJpeg = false;
|
|
3654
|
-
}
|
|
3655
|
-
if (allGray) return "gray";
|
|
3656
|
-
if (allJpeg) return "jpeg";
|
|
3657
|
-
return "rgb";
|
|
3658
|
-
}
|
|
3659
|
-
/**
|
|
3660
|
-
* Check if the decoder's output format needs upgrading because a new
|
|
3661
|
-
* subscriber requires a richer format (e.g. gray → jpeg when detection
|
|
3662
|
-
* joins after motion). Calls updateConfig on the decoder proxy to
|
|
3663
|
-
* reinitialize the scaler without recreating the session.
|
|
3664
|
-
*/
|
|
3665
|
-
maybeUpgradeDecoderFormat() {
|
|
3666
|
-
if (!this.decoderProxy || !this.decoderOutputFormat) return;
|
|
3667
|
-
const needed = this.resolveDecoderFormat();
|
|
3668
|
-
if (needed === this.decoderOutputFormat) return;
|
|
3669
|
-
this.logger?.info("upgrading decoder output format", {
|
|
3670
|
-
meta: { from: this.decoderOutputFormat, to: needed }
|
|
3671
|
-
});
|
|
3672
|
-
this.decoderOutputFormat = needed;
|
|
3673
|
-
this.decoderProxy.updateConfig({ outputFormat: needed }).catch((err) => {
|
|
3674
|
-
this.logger?.warn("decoder format upgrade failed", { meta: { error: index.errMsg(err) } });
|
|
3675
|
-
});
|
|
3676
|
-
}
|
|
3677
|
-
/**
|
|
3678
|
-
* Fan a single decoded frame out to every active subscriber. Each
|
|
3679
|
-
* subscriber may have requested a different `format` than the one the
|
|
3680
|
-
* decoder is currently producing — the per-frame `convertedCache` map
|
|
3681
|
-
* memoises any format the decoder didn't already supply, so multiple
|
|
3682
|
-
* subscribers asking for the same target share the conversion.
|
|
3683
|
-
*
|
|
3684
|
-
* Conversion paths covered today:
|
|
3685
|
-
* - rgb → jpeg (sharp encode)
|
|
3686
|
-
* - rgb → bgr (R/B channel swap, in-process)
|
|
3687
|
-
* - rgb → gray (BT.601 luminance)
|
|
3688
|
-
* - rgb → yuv420 (sharp toFormat)
|
|
3689
|
-
* - gray → jpeg (sharp encode 1ch)
|
|
3690
|
-
* - jpeg → rgb / bgr / gray / yuv420 — not supported in Phase 3.
|
|
3691
|
-
* Decoder picks `jpeg` as canonical only when ALL subscribers
|
|
3692
|
-
* want jpeg, so this branch should never trigger. Such a request
|
|
3693
|
-
* is logged and the subscriber receives the JPEG passthrough as a
|
|
3694
|
-
* graceful fallback (caller is expected to re-decode).
|
|
3695
|
-
*/
|
|
3696
|
-
fanoutDecodedFrame(frame) {
|
|
3697
|
-
const convertedCache = /* @__PURE__ */ new Map();
|
|
3698
|
-
convertedCache.set(frame.format, frame);
|
|
3699
|
-
for (const subscriber of this.decodedSubscribers.values()) {
|
|
3700
|
-
if (!subscriber.frameDropper.shouldKeep()) {
|
|
3701
|
-
subscriber.framesDropped++;
|
|
3702
|
-
continue;
|
|
3703
|
-
}
|
|
3704
|
-
this.deliverConvertedFrame(subscriber, frame, convertedCache);
|
|
3705
|
-
}
|
|
3706
|
-
}
|
|
3707
|
-
deliverConvertedFrame(subscriber, frame, cache2) {
|
|
3708
|
-
if (subscriber.format === frame.format) {
|
|
3709
|
-
subscriber.callback(frame);
|
|
3710
|
-
subscriber.framesDelivered++;
|
|
3711
|
-
return;
|
|
3712
|
-
}
|
|
3713
|
-
const cached = cache2.get(subscriber.format);
|
|
3714
|
-
if (cached && !(cached instanceof Promise)) {
|
|
3715
|
-
subscriber.callback(cached);
|
|
3716
|
-
subscriber.framesDelivered++;
|
|
3717
|
-
return;
|
|
3718
|
-
}
|
|
3719
|
-
if (cached instanceof Promise) {
|
|
3720
|
-
cached.then((converted) => {
|
|
3721
|
-
subscriber.callback(converted);
|
|
3722
|
-
subscriber.framesDelivered++;
|
|
3723
|
-
}).catch((err) => {
|
|
3724
|
-
subscriber.framesDropped++;
|
|
3725
|
-
this.logger?.warn("frame conversion failed", {
|
|
3726
|
-
meta: { tag: subscriber.tag, target: subscriber.format, error: index.errMsg(err) }
|
|
3727
|
-
});
|
|
3728
|
-
});
|
|
3729
|
-
return;
|
|
3730
|
-
}
|
|
3731
|
-
const conversion = this.convertFrame(frame, subscriber.format);
|
|
3732
|
-
cache2.set(subscriber.format, conversion);
|
|
3733
|
-
conversion.then((converted) => {
|
|
3734
|
-
cache2.set(subscriber.format, converted);
|
|
3735
|
-
subscriber.callback(converted);
|
|
3736
|
-
subscriber.framesDelivered++;
|
|
3737
|
-
}).catch((err) => {
|
|
3738
|
-
subscriber.framesDropped++;
|
|
3739
|
-
this.logger?.warn("frame conversion failed", {
|
|
3740
|
-
meta: { tag: subscriber.tag, target: subscriber.format, error: index.errMsg(err) }
|
|
3741
|
-
});
|
|
3742
|
-
});
|
|
3743
|
-
}
|
|
3744
|
-
async convertFrame(source, target) {
|
|
3745
|
-
if (source.format === target) return source;
|
|
3746
|
-
const sharp = await getSharp();
|
|
3747
|
-
if (source.format === "rgb" || source.format === "gray") {
|
|
3748
|
-
const channels = source.format === "gray" ? 1 : 3;
|
|
3749
|
-
const raw = { width: source.width, height: source.height, channels };
|
|
3750
|
-
const pipeline = sharp(source.data, { raw });
|
|
3751
|
-
if (target === "jpeg") {
|
|
3752
|
-
const data = await pipeline.jpeg({ quality: 80, mozjpeg: false }).toBuffer();
|
|
3753
|
-
return { ...source, data, format: "jpeg" };
|
|
3754
|
-
}
|
|
3755
|
-
if (target === "bgr") {
|
|
3756
|
-
if (source.format !== "rgb") {
|
|
3757
|
-
throw new Error(`bgr conversion requires rgb source, got ${source.format}`);
|
|
3758
|
-
}
|
|
3759
|
-
const data = swapRedBlue(source.data);
|
|
3760
|
-
return { ...source, data, format: "bgr" };
|
|
3761
|
-
}
|
|
3762
|
-
if (target === "gray") {
|
|
3763
|
-
const data = await pipeline.toColorspace("b-w").raw().toBuffer();
|
|
3764
|
-
return { ...source, data, format: "gray" };
|
|
3765
|
-
}
|
|
3766
|
-
if (target === "rgb") {
|
|
3767
|
-
const data = await pipeline.toColorspace("srgb").raw().toBuffer();
|
|
3768
|
-
return { ...source, data, format: "rgb" };
|
|
3769
|
-
}
|
|
3770
|
-
if (target === "yuv420") {
|
|
3771
|
-
const data = await pipeline.toColorspace("yuv").raw().toBuffer();
|
|
3772
|
-
return { ...source, data, format: "yuv420" };
|
|
3773
|
-
}
|
|
3774
|
-
}
|
|
3775
|
-
if (source.format === "jpeg") {
|
|
3776
|
-
this.logger?.warn("jpeg-source frame requested as non-jpeg — passthrough", {
|
|
3777
|
-
meta: { target }
|
|
3778
|
-
});
|
|
3779
|
-
return source;
|
|
3780
|
-
}
|
|
3781
|
-
throw new Error(`unsupported conversion ${source.format} → ${target}`);
|
|
3782
|
-
}
|
|
3783
4028
|
checkDemand() {
|
|
3784
4029
|
if (this.manualStop || this.stopping) return;
|
|
3785
4030
|
if (this.hasDemand()) {
|
|
@@ -3965,10 +4210,6 @@ class StreamBroker {
|
|
|
3965
4210
|
if (!this.firstKeyframeReceived && packet.keyframe) {
|
|
3966
4211
|
this.firstKeyframeReceived = true;
|
|
3967
4212
|
this.logger?.info("First keyframe received — decoder can start");
|
|
3968
|
-
if (this.pendingPullModeOpen) {
|
|
3969
|
-
this.pendingPullModeOpen();
|
|
3970
|
-
this.pendingPullModeOpen = null;
|
|
3971
|
-
}
|
|
3972
4213
|
}
|
|
3973
4214
|
}
|
|
3974
4215
|
for (const restreamer of this.restreamers) {
|
|
@@ -3976,22 +4217,7 @@ class StreamBroker {
|
|
|
3976
4217
|
restreamer.pushPacket(streamId, packet);
|
|
3977
4218
|
}
|
|
3978
4219
|
if (packet.type === "video" && this.firstKeyframeReceived) {
|
|
3979
|
-
|
|
3980
|
-
this.decoderPushCount++;
|
|
3981
|
-
if (this.decoderPushCount === 1 || this.decoderPushCount % 500 === 0) {
|
|
3982
|
-
this.logger?.info("decoder push", {
|
|
3983
|
-
meta: {
|
|
3984
|
-
pushCount: this.decoderPushCount,
|
|
3985
|
-
size: packet.data.length,
|
|
3986
|
-
keyframe: packet.keyframe,
|
|
3987
|
-
decodedCount: this.decoderFrameCount
|
|
3988
|
-
}
|
|
3989
|
-
});
|
|
3990
|
-
}
|
|
3991
|
-
this.decoderProxy.pushPacket(packet).catch((err) => {
|
|
3992
|
-
this.logger?.warn("decoder push error", { meta: { error: index.errMsg(err) } });
|
|
3993
|
-
});
|
|
3994
|
-
}
|
|
4220
|
+
this.frameHandlePlane?.pushPacket(packet);
|
|
3995
4221
|
}
|
|
3996
4222
|
}
|
|
3997
4223
|
onEncodedData(callback) {
|
|
@@ -4163,89 +4389,107 @@ class StreamBroker {
|
|
|
4163
4389
|
setStreamingDebug(enabled) {
|
|
4164
4390
|
this.streamingDebug = enabled;
|
|
4165
4391
|
}
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4392
|
+
// ── Decoded audio-chunk plane (Phase 5 / D9) ─────────────────────────
|
|
4393
|
+
/**
|
|
4394
|
+
* Open a decoded audio-chunk subscription — the poll-based, tRPC-reachable
|
|
4395
|
+
* replacement for the live-object `onDecodedAudioChunk` callback. The
|
|
4396
|
+
* broker registers a per-subscription FIFO queue and returns a
|
|
4397
|
+
* `subscriptionId` the consumer drains via `pullAudioChunks`.
|
|
4398
|
+
*/
|
|
4399
|
+
subscribeAudioChunks(options) {
|
|
4170
4400
|
const tag = options?.tag;
|
|
4171
4401
|
if (!tag) {
|
|
4172
|
-
this.logger?.warn(`
|
|
4402
|
+
this.logger?.warn(`subscribeAudioChunks called without tag — listClients will show 'unknown'`);
|
|
4173
4403
|
}
|
|
4174
|
-
const
|
|
4175
|
-
callback,
|
|
4176
|
-
frameDropper,
|
|
4177
|
-
tag: tag ?? "unknown",
|
|
4178
|
-
maxFps,
|
|
4179
|
-
format: options?.format ?? "jpeg",
|
|
4180
|
-
subscribedAt: Date.now(),
|
|
4181
|
-
framesDelivered: 0,
|
|
4182
|
-
framesDropped: 0
|
|
4183
|
-
};
|
|
4184
|
-
this.decodedSubscribers.set(subscriberId, subscriber);
|
|
4185
|
-
this.logger?.info("decoded subscriber added", {
|
|
4186
|
-
meta: { tag: subscriber.tag, total: this.decodedSubscribers.size, maxFps, format: subscriber.format }
|
|
4187
|
-
});
|
|
4404
|
+
const result = this.audioChunkPlane.subscribe({ tag });
|
|
4188
4405
|
this.checkDemand();
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
}
|
|
4406
|
+
return result;
|
|
4407
|
+
}
|
|
4408
|
+
/** Drain up to `maxCount` `DecodedAudioChunk`s for an audio-chunk subscription. */
|
|
4409
|
+
pullAudioChunks(subscriptionId, maxCount) {
|
|
4410
|
+
return this.audioChunkPlane.pull(subscriptionId, maxCount);
|
|
4411
|
+
}
|
|
4412
|
+
/** Release an audio-chunk subscription. Returns `true` when it existed. */
|
|
4413
|
+
unsubscribeAudioChunks(subscriptionId) {
|
|
4414
|
+
const released = this.audioChunkPlane.unsubscribe(subscriptionId);
|
|
4415
|
+
if (released) this.checkDemand();
|
|
4416
|
+
return released;
|
|
4417
|
+
}
|
|
4418
|
+
// ── Shared-memory frame-handle plane (Phase 5 / D9) ──────────────────
|
|
4419
|
+
/**
|
|
4420
|
+
* Resolve the stream descriptor the `FrameHandlePlane` needs to create a
|
|
4421
|
+
* `frameSink: 'shm'` decoder session — codec, numeric device id, log tag,
|
|
4422
|
+
* and (for a pull-mode decoder) the local pipe URL. Returns `null` before
|
|
4423
|
+
* the broker has a source.
|
|
4424
|
+
*/
|
|
4425
|
+
resolveFrameHandleStreamInfo() {
|
|
4426
|
+
const codec = this.detectedCodec ?? this.source?.videoCodec;
|
|
4427
|
+
if (!codec || !this.source) return null;
|
|
4428
|
+
const slash = this.deviceId.indexOf("/");
|
|
4429
|
+
const numericDeviceId = slash >= 0 ? Number.parseInt(this.deviceId.slice(0, slash), 10) : Number.parseInt(this.deviceId, 10);
|
|
4430
|
+
return {
|
|
4431
|
+
codec,
|
|
4432
|
+
...Number.isFinite(numericDeviceId) ? { numericDeviceId } : {},
|
|
4433
|
+
tag: `broker:${this.deviceId}`,
|
|
4434
|
+
// push-mode is the node-av default today; pull-mode URL wiring is deferred to a later Phase-5 task.
|
|
4435
|
+
pullModeUrl: null
|
|
4220
4436
|
};
|
|
4221
4437
|
}
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
if (
|
|
4225
|
-
|
|
4438
|
+
/** Lazily construct the frame-handle plane, returning `null` with no decoder. */
|
|
4439
|
+
ensureFrameHandlePlane() {
|
|
4440
|
+
if (this.frameHandlePlane) return this.frameHandlePlane;
|
|
4441
|
+
if (!this.decoderApi) {
|
|
4442
|
+
this.logger?.warn("subscribeFrameHandles: no decoder API — frame handles unavailable");
|
|
4443
|
+
return null;
|
|
4444
|
+
}
|
|
4445
|
+
this.frameHandlePlane = new FrameHandlePlane({
|
|
4446
|
+
decoderApi: this.decoderApi,
|
|
4447
|
+
logger: this.logger?.child("frame-handle-plane"),
|
|
4448
|
+
resolveStreamInfo: () => this.resolveFrameHandleStreamInfo()
|
|
4449
|
+
});
|
|
4450
|
+
return this.frameHandlePlane;
|
|
4451
|
+
}
|
|
4452
|
+
/**
|
|
4453
|
+
* Subscribe to this broker's shared-memory frame-handle stream — the broker's
|
|
4454
|
+
* decoded-frame surface. The broker spins up (or reuses) a `frameSink: 'shm'`
|
|
4455
|
+
* decoder session producing `input.format` and returns a `subscriptionId`
|
|
4456
|
+
* the consumer polls via `pullFrameHandles`. fps throttling is implicit
|
|
4457
|
+
* (latest-wins ring reads); `input.maxFps` is echoed as a cadence hint.
|
|
4458
|
+
*/
|
|
4459
|
+
async subscribeFrameHandles(input) {
|
|
4460
|
+
const plane = this.ensureFrameHandlePlane();
|
|
4461
|
+
if (!plane) {
|
|
4462
|
+
throw new Error("stream-broker: no decoder available for frame-handle subscription");
|
|
4226
4463
|
}
|
|
4227
|
-
const subscriberId = Symbol("audio-subscriber");
|
|
4228
|
-
const subscriber = {
|
|
4229
|
-
callback,
|
|
4230
|
-
tag: tag ?? "unknown",
|
|
4231
|
-
subscribedAt: Date.now(),
|
|
4232
|
-
chunksDelivered: 0
|
|
4233
|
-
};
|
|
4234
|
-
this.audioSubscribers.set(subscriberId, subscriber);
|
|
4235
4464
|
this.checkDemand();
|
|
4236
|
-
|
|
4237
|
-
|
|
4465
|
+
const { subscriptionId, maxFps } = await plane.subscribe({
|
|
4466
|
+
format: input.format,
|
|
4467
|
+
maxFps: input.maxFps,
|
|
4468
|
+
tag: input.tag
|
|
4469
|
+
});
|
|
4470
|
+
return { subscriptionId, maxFps };
|
|
4471
|
+
}
|
|
4472
|
+
/** Drain up to `maxCount` `FrameHandle`s for a frame-handle subscription. */
|
|
4473
|
+
pullFrameHandles(subscriptionId, maxCount) {
|
|
4474
|
+
return this.frameHandlePlane?.pullHandles(subscriptionId, maxCount) ?? [];
|
|
4475
|
+
}
|
|
4476
|
+
/** Release a frame-handle subscription. Returns `true` when it existed. */
|
|
4477
|
+
async unsubscribeFrameHandles(subscriptionId) {
|
|
4478
|
+
const plane = this.frameHandlePlane;
|
|
4479
|
+
if (!plane) return false;
|
|
4480
|
+
const released = await plane.unsubscribe(subscriptionId);
|
|
4481
|
+
if (released && plane.subscriberCount === 0) {
|
|
4238
4482
|
this.checkDemand();
|
|
4239
|
-
}
|
|
4483
|
+
}
|
|
4484
|
+
return released;
|
|
4240
4485
|
}
|
|
4241
4486
|
getStats() {
|
|
4242
|
-
const decoderStats = this._suspended ? void 0 : this.cachedDecoderStats;
|
|
4243
4487
|
return {
|
|
4244
4488
|
status: this._suspended ? "idle" : this._status,
|
|
4245
|
-
inputFps:
|
|
4246
|
-
decodeFps:
|
|
4489
|
+
inputFps: this.encodedInputFps,
|
|
4490
|
+
decodeFps: this.frameHandlePlane?.decodeFps() ?? 0,
|
|
4247
4491
|
encodedSubscribers: this.encodedCallbacks.size,
|
|
4248
|
-
decodedSubscribers: this.
|
|
4492
|
+
decodedSubscribers: this.frameHandlePlane?.subscriberCount ?? 0,
|
|
4249
4493
|
uptimeMs: Date.now() - this.startedAt,
|
|
4250
4494
|
bitrateKbps: this.bitrateKbps,
|
|
4251
4495
|
idrIntervalMs: this.idrIntervalMs,
|
|
@@ -4257,7 +4501,7 @@ class StreamBroker {
|
|
|
4257
4501
|
preBufferSec: this.preBuffer.getDuration(),
|
|
4258
4502
|
preBufferMs: this.preBuffer.getBufferedDurationMs(),
|
|
4259
4503
|
preBufferPackets: this.preBuffer.getPacketCount(),
|
|
4260
|
-
decoderNodeId: this.
|
|
4504
|
+
decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
|
|
4261
4505
|
audio: this.audioTrackInfo ?? null
|
|
4262
4506
|
};
|
|
4263
4507
|
}
|
|
@@ -4271,14 +4515,17 @@ class StreamBroker {
|
|
|
4271
4515
|
listClients() {
|
|
4272
4516
|
return {
|
|
4273
4517
|
rtsp: this.rtspRestreamer.listSessionInfos(),
|
|
4274
|
-
|
|
4518
|
+
// Phase 5 / D9: the `decoded` channel is the shm frame-handle plane's
|
|
4519
|
+
// subscriber set. `framesDropped` is always 0 — a slow consumer's
|
|
4520
|
+
// dropped frames are recycled silently at the ring, not counted here.
|
|
4521
|
+
decoded: (this.frameHandlePlane?.listSubscribers() ?? []).map((s) => ({
|
|
4275
4522
|
tag: s.tag,
|
|
4276
4523
|
subscribedAt: s.subscribedAt,
|
|
4277
4524
|
maxFps: s.maxFps,
|
|
4278
4525
|
framesDelivered: s.framesDelivered,
|
|
4279
|
-
framesDropped:
|
|
4526
|
+
framesDropped: 0
|
|
4280
4527
|
})),
|
|
4281
|
-
audio:
|
|
4528
|
+
audio: this.audioChunkPlane.listSubscribers().map((s) => ({
|
|
4282
4529
|
tag: s.tag,
|
|
4283
4530
|
subscribedAt: s.subscribedAt,
|
|
4284
4531
|
chunksDelivered: s.chunksDelivered
|
|
@@ -4290,24 +4537,38 @@ class StreamBroker {
|
|
|
4290
4537
|
/**
|
|
4291
4538
|
* Force-disconnect a single consumer. Matches by channel:
|
|
4292
4539
|
* - `rtsp`: by `sessionId` (calls `RtspRestreamer.killSession`).
|
|
4293
|
-
* - `decoded
|
|
4294
|
-
* matches
|
|
4295
|
-
*
|
|
4540
|
+
* - `decoded`: by `tag` — releases every shm frame-handle subscription
|
|
4541
|
+
* whose tag matches (the plane winds the decoder session down once its
|
|
4542
|
+
* last subscriber leaves).
|
|
4543
|
+
* - `audio`: by `tag` — drops every audio subscriber whose tag matches,
|
|
4544
|
+
* then re-evaluates demand.
|
|
4296
4545
|
* Returns `true` when at least one consumer was dropped.
|
|
4297
4546
|
*/
|
|
4298
4547
|
killClient(channel, handle) {
|
|
4299
4548
|
if (channel === "rtsp") {
|
|
4300
4549
|
return this.rtspRestreamer.killSession(handle);
|
|
4301
4550
|
}
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4551
|
+
if (channel === "decoded") {
|
|
4552
|
+
const plane = this.frameHandlePlane;
|
|
4553
|
+
if (!plane) return false;
|
|
4554
|
+
const matched = plane.listSubscribers().some((s) => s.tag === handle);
|
|
4555
|
+
if (!matched) return false;
|
|
4556
|
+
plane.killByTag(handle).then((killed2) => {
|
|
4557
|
+
this.logger?.info("frame-handle subscribers force-disconnected", {
|
|
4558
|
+
meta: { handle, killed: killed2 }
|
|
4559
|
+
});
|
|
4560
|
+
this.checkDemand();
|
|
4561
|
+
}).catch((err) => {
|
|
4562
|
+
this.logger?.warn("frame-handle killByTag failed", {
|
|
4563
|
+
meta: { handle, error: index.errMsg(err) }
|
|
4564
|
+
});
|
|
4565
|
+
});
|
|
4566
|
+
return true;
|
|
4306
4567
|
}
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
this.logger?.info("subscribers force-disconnected", {
|
|
4310
|
-
meta: {
|
|
4568
|
+
const killed = this.audioChunkPlane.killByTag(handle);
|
|
4569
|
+
if (killed === 0) return false;
|
|
4570
|
+
this.logger?.info("audio subscribers force-disconnected", {
|
|
4571
|
+
meta: { handle, killed, remaining: this.audioChunkPlane.subscriberCount }
|
|
4311
4572
|
});
|
|
4312
4573
|
this.checkDemand();
|
|
4313
4574
|
return true;
|
|
@@ -4455,13 +4716,6 @@ class StreamBroker {
|
|
|
4455
4716
|
});
|
|
4456
4717
|
this.armFirstVideoWatchdog();
|
|
4457
4718
|
this.rtspRestreamer.setCameraSdp(sdpText, track.codec, track.codecParams);
|
|
4458
|
-
if (!this.decoderProxy && this.decodedSubscribers.size > 0 && this.pendingDecodeOptions !== void 0) {
|
|
4459
|
-
this.logger?.info("native: codec detected — creating deferred decoder", {
|
|
4460
|
-
meta: { codec: track.codec }
|
|
4461
|
-
});
|
|
4462
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions);
|
|
4463
|
-
this.pendingDecodeOptions = void 0;
|
|
4464
|
-
}
|
|
4465
4719
|
},
|
|
4466
4720
|
onVideoRtp: (rtpData) => {
|
|
4467
4721
|
if (this.stopping) return;
|
|
@@ -4516,10 +4770,7 @@ class StreamBroker {
|
|
|
4516
4770
|
let effectiveChannels = audioTrack.channels;
|
|
4517
4771
|
if (supportedNative) {
|
|
4518
4772
|
this.audioRtpDecoder = new AudioRtpDecoder(codecUpper, (chunk) => {
|
|
4519
|
-
|
|
4520
|
-
sub.callback(chunk);
|
|
4521
|
-
sub.chunksDelivered++;
|
|
4522
|
-
}
|
|
4773
|
+
this.audioChunkPlane.fanout(chunk);
|
|
4523
4774
|
});
|
|
4524
4775
|
this.logger?.info("native: audio decoder initialized", {
|
|
4525
4776
|
meta: { codec: codecUpper, sampleRate: audioTrack.clockRate, channels: audioTrack.channels }
|
|
@@ -4932,90 +5183,28 @@ class StreamBroker {
|
|
|
4932
5183
|
this._status = "error";
|
|
4933
5184
|
}, PUSH_STALL_TIMEOUT_MS);
|
|
4934
5185
|
}
|
|
4935
|
-
destroyDecoder() {
|
|
4936
|
-
if (this.decoderResolveRetryTimer) {
|
|
4937
|
-
clearTimeout(this.decoderResolveRetryTimer);
|
|
4938
|
-
this.decoderResolveRetryTimer = void 0;
|
|
4939
|
-
}
|
|
4940
|
-
this.decoderResolveAttempts = 0;
|
|
4941
|
-
const proxy = this.decoderProxy;
|
|
4942
|
-
this.decoderProxy = null;
|
|
4943
|
-
this.decoderNodeId = null;
|
|
4944
|
-
this.cachedDecoderStats = void 0;
|
|
4945
|
-
return proxy?.destroy() ?? Promise.resolve();
|
|
4946
|
-
}
|
|
4947
5186
|
/**
|
|
4948
|
-
* Moleculer nodeID of
|
|
4949
|
-
*
|
|
4950
|
-
* broker-manager's `rotateDecodersOnNode`
|
|
4951
|
-
*
|
|
5187
|
+
* Moleculer nodeID of a decoder provider hosting one of this broker's shm
|
|
5188
|
+
* frame-plane sessions. `null` when the plane has no session. Used by the
|
|
5189
|
+
* broker-manager's `rotateDecodersOnNode` to drop only the sessions
|
|
5190
|
+
* affected by a per-agent event (e.g. an hwaccel override change).
|
|
4952
5191
|
*/
|
|
4953
5192
|
getDecoderNodeId() {
|
|
4954
|
-
return this.
|
|
4955
|
-
}
|
|
4956
|
-
/**
|
|
4957
|
-
* Force the current shared decoder session to tear down. The next
|
|
4958
|
-
* subscriber will trigger a fresh `createSharedDecoderSession` which
|
|
4959
|
-
* re-pulls the latest per-agent preferences (hwaccel backend, …). Safe
|
|
4960
|
-
* to call when there's no active session — resolves to a no-op.
|
|
4961
|
-
*/
|
|
4962
|
-
async rotateDecoderSession(reason) {
|
|
4963
|
-
if (!this.decoderProxy) return;
|
|
4964
|
-
this.logger?.info("rotating decoder session", { meta: { reason, decoderNodeId: this.decoderNodeId } });
|
|
4965
|
-
await this.destroyDecoder();
|
|
4966
|
-
if (this.decodedSubscribers.size > 0 && this.source) {
|
|
4967
|
-
const codec = this.detectedCodec ?? this.source.videoCodec;
|
|
4968
|
-
if (codec) {
|
|
4969
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions).catch((err) => {
|
|
4970
|
-
this.logger?.warn("decoder session rebuild after rotation failed", { meta: { error: index.errMsg(err) } });
|
|
4971
|
-
});
|
|
4972
|
-
}
|
|
4973
|
-
}
|
|
5193
|
+
return this.frameHandlePlane?.decoderNodeIds()[0] ?? null;
|
|
4974
5194
|
}
|
|
4975
5195
|
/**
|
|
4976
|
-
*
|
|
4977
|
-
*
|
|
4978
|
-
*
|
|
4979
|
-
*
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
* with noisy errors.
|
|
4989
|
-
*/
|
|
4990
|
-
scheduleDecoderResolveRetry(codec) {
|
|
4991
|
-
if (this.decoderResolveRetryTimer) return;
|
|
4992
|
-
const attempt = this.decoderResolveAttempts++;
|
|
4993
|
-
const backoffMs = Math.min(500 * Math.pow(2, attempt), 8e3);
|
|
4994
|
-
const cumulativeMs = (Math.pow(2, this.decoderResolveAttempts) - 1) * 500;
|
|
4995
|
-
if (cumulativeMs > 6e4) {
|
|
4996
|
-
this.logger?.error(
|
|
4997
|
-
"No decoder provider — giving up. Check that addon-decoder-nodeav or addon-decoder-ffmpeg is installed and enabled.",
|
|
4998
|
-
{ meta: { codec, attempts: this.decoderResolveAttempts } }
|
|
4999
|
-
);
|
|
5000
|
-
this.decoderResolveAttempts = 0;
|
|
5001
|
-
return;
|
|
5002
|
-
}
|
|
5003
|
-
if (attempt === 0) {
|
|
5004
|
-
this.logger?.debug(
|
|
5005
|
-
"Decoder not yet registered — will retry (addon boot race)",
|
|
5006
|
-
{ meta: { codec } }
|
|
5007
|
-
);
|
|
5008
|
-
}
|
|
5009
|
-
this.decoderResolveRetryTimer = setTimeout(() => {
|
|
5010
|
-
this.decoderResolveRetryTimer = void 0;
|
|
5011
|
-
if (this.decoderProxy) return;
|
|
5012
|
-
if (this.decodedSubscribers.size === 0) {
|
|
5013
|
-
this.decoderResolveAttempts = 0;
|
|
5014
|
-
this.pendingDecodeOptions = void 0;
|
|
5015
|
-
return;
|
|
5016
|
-
}
|
|
5017
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions);
|
|
5018
|
-
}, backoffMs);
|
|
5196
|
+
* Force this broker's shm frame-plane decoder sessions hosted on
|
|
5197
|
+
* `agentNodeId` to rotate — destroy and rebuild so they re-pull the latest
|
|
5198
|
+
* per-agent preferences (hwaccel backend, …). A no-op when the broker has
|
|
5199
|
+
* no frame plane. Subscriptions survive the rotation.
|
|
5200
|
+
*/
|
|
5201
|
+
async rotateDecoderSession(reason, agentNodeId) {
|
|
5202
|
+
const plane = this.frameHandlePlane;
|
|
5203
|
+
if (!plane) return;
|
|
5204
|
+
const target = agentNodeId ?? plane.decoderNodeIds()[0]?.split("/")[0];
|
|
5205
|
+
if (!target) return;
|
|
5206
|
+
this.logger?.info("rotating shm decoder sessions", { meta: { reason, agentNodeId: target } });
|
|
5207
|
+
await plane.rotate(target, reason);
|
|
5019
5208
|
}
|
|
5020
5209
|
destroyNativeClient() {
|
|
5021
5210
|
if (this.nativeClient) {
|
|
@@ -5102,78 +5291,6 @@ class StreamBroker {
|
|
|
5102
5291
|
});
|
|
5103
5292
|
}
|
|
5104
5293
|
}
|
|
5105
|
-
async createSharedDecoderSession(options) {
|
|
5106
|
-
const codec = this.detectedCodec ?? this.source?.videoCodec ?? "h264";
|
|
5107
|
-
if (!this.decoderApi) {
|
|
5108
|
-
this.logger?.warn("no decoder API available — decoded frames will not be produced");
|
|
5109
|
-
return;
|
|
5110
|
-
}
|
|
5111
|
-
const supported = await this.decoderApi.supportsCodec({ codec });
|
|
5112
|
-
if (!supported) {
|
|
5113
|
-
this.pendingDecodeOptions = options;
|
|
5114
|
-
this.scheduleDecoderResolveRetry(codec);
|
|
5115
|
-
return;
|
|
5116
|
-
}
|
|
5117
|
-
if (this.decoderResolveRetryTimer) {
|
|
5118
|
-
clearTimeout(this.decoderResolveRetryTimer);
|
|
5119
|
-
this.decoderResolveRetryTimer = void 0;
|
|
5120
|
-
}
|
|
5121
|
-
this.decoderResolveAttempts = 0;
|
|
5122
|
-
const resolvedFormat = this.resolveDecoderFormat();
|
|
5123
|
-
const slash = this.deviceId.indexOf("/");
|
|
5124
|
-
const numericDeviceId = slash >= 0 ? Number.parseInt(this.deviceId.slice(0, slash), 10) : Number.parseInt(this.deviceId, 10);
|
|
5125
|
-
const config = {
|
|
5126
|
-
codec,
|
|
5127
|
-
maxFps: 0,
|
|
5128
|
-
outputFormat: resolvedFormat,
|
|
5129
|
-
scale: options?.scale ?? DEFAULT_SCALE,
|
|
5130
|
-
...Number.isFinite(numericDeviceId) ? { deviceId: numericDeviceId } : {},
|
|
5131
|
-
tag: `broker:${this.deviceId}`
|
|
5132
|
-
};
|
|
5133
|
-
this.decoderOutputFormat = resolvedFormat;
|
|
5134
|
-
const { sessionId, nodeId } = await this.decoderApi.createSession(config);
|
|
5135
|
-
const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
|
|
5136
|
-
this.decoderProxy = proxy;
|
|
5137
|
-
this.decoderNodeId = nodeId;
|
|
5138
|
-
const onFrame = (frame) => {
|
|
5139
|
-
this.decoderFrameCount++;
|
|
5140
|
-
if (this.decoderFrameCount === 1) {
|
|
5141
|
-
this.logger?.info("first decoded frame", {
|
|
5142
|
-
meta: { width: frame.width, height: frame.height, format: frame.format }
|
|
5143
|
-
});
|
|
5144
|
-
}
|
|
5145
|
-
if (this.decoderFrameCount % 30 === 0) {
|
|
5146
|
-
proxy.getStats().then((stats) => {
|
|
5147
|
-
this.cachedDecoderStats = stats;
|
|
5148
|
-
}).catch(() => {
|
|
5149
|
-
});
|
|
5150
|
-
}
|
|
5151
|
-
this.fanoutDecodedFrame(frame);
|
|
5152
|
-
};
|
|
5153
|
-
proxy.startPolling(onFrame).catch((err) => {
|
|
5154
|
-
this.logger?.warn("decoder polling error", { meta: { error: index.errMsg(err) } });
|
|
5155
|
-
});
|
|
5156
|
-
const info = await this.decoderApi.getInfo();
|
|
5157
|
-
if (info.isPullMode) {
|
|
5158
|
-
const localUrl = this.pipeServer.getUrl();
|
|
5159
|
-
const isHevc = codec === "h265" || codec === "hevc";
|
|
5160
|
-
const inputUrl = `${localUrl}?format=${isHevc ? "hevc" : "h264"}`;
|
|
5161
|
-
const doOpen = () => {
|
|
5162
|
-
this.logger?.info("Pull mode decoder: opening local pipe", {
|
|
5163
|
-
meta: { localUrl, codec }
|
|
5164
|
-
});
|
|
5165
|
-
proxy.openStream(inputUrl).catch((err) => {
|
|
5166
|
-
this.logger?.error("Pull mode decoder failed", { meta: { error: index.errMsg(err) } });
|
|
5167
|
-
});
|
|
5168
|
-
};
|
|
5169
|
-
if (this.firstKeyframeReceived) {
|
|
5170
|
-
doOpen();
|
|
5171
|
-
} else {
|
|
5172
|
-
this.logger?.info("Pull mode decoder: waiting for first keyframe before opening");
|
|
5173
|
-
this.pendingPullModeOpen = doOpen;
|
|
5174
|
-
}
|
|
5175
|
-
}
|
|
5176
|
-
}
|
|
5177
5294
|
/**
|
|
5178
5295
|
* Map an SDP audio track to an `AudioCodecSession` config the
|
|
5179
5296
|
* audio-codec cap can consume. Returns `null` when the codec is
|
|
@@ -5188,10 +5305,7 @@ class StreamBroker {
|
|
|
5188
5305
|
*/
|
|
5189
5306
|
buildAudioCodecEmitCallback() {
|
|
5190
5307
|
return (chunk) => {
|
|
5191
|
-
|
|
5192
|
-
sub.callback(chunk);
|
|
5193
|
-
sub.chunksDelivered++;
|
|
5194
|
-
}
|
|
5308
|
+
this.audioChunkPlane.fanout(chunk);
|
|
5195
5309
|
if (this.encodedCallbacks.size > 0) {
|
|
5196
5310
|
const f32 = new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4);
|
|
5197
5311
|
const pcm16 = new Int16Array(f32.length);
|
|
@@ -5849,7 +5963,6 @@ class TranscodePipelineManager {
|
|
|
5849
5963
|
logger;
|
|
5850
5964
|
api;
|
|
5851
5965
|
cameraStreamLookup;
|
|
5852
|
-
rtspEntryLookup;
|
|
5853
5966
|
localRtspPort;
|
|
5854
5967
|
releaseGraceMs;
|
|
5855
5968
|
ffmpegPath;
|
|
@@ -5857,7 +5970,6 @@ class TranscodePipelineManager {
|
|
|
5857
5970
|
this.logger = opts.logger;
|
|
5858
5971
|
this.api = opts.api;
|
|
5859
5972
|
this.cameraStreamLookup = opts.cameraStreamLookup;
|
|
5860
|
-
this.rtspEntryLookup = opts.rtspEntryLookup;
|
|
5861
5973
|
this.localRtspPort = opts.localRtspPort;
|
|
5862
5974
|
this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
|
|
5863
5975
|
this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
@@ -6162,6 +6274,18 @@ class StreamBrokerManager {
|
|
|
6162
6274
|
*/
|
|
6163
6275
|
streamHealthByBroker = /* @__PURE__ */ new Map();
|
|
6164
6276
|
streamHealthTimer;
|
|
6277
|
+
/**
|
|
6278
|
+
* Routes a frame-handle `subscriptionId` (Phase 5 / D9) back to its owning
|
|
6279
|
+
* brokerId so `pullFrameHandles` / `unsubscribeFrames` resolve in O(1)
|
|
6280
|
+
* without scanning every broker.
|
|
6281
|
+
*/
|
|
6282
|
+
frameSubscriptionBroker = /* @__PURE__ */ new Map();
|
|
6283
|
+
/**
|
|
6284
|
+
* Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
|
|
6285
|
+
* brokerId so `pullAudioChunks` / `unsubscribeAudioChunks` resolve in O(1)
|
|
6286
|
+
* without scanning every broker. Mirrors `frameSubscriptionBroker`.
|
|
6287
|
+
*/
|
|
6288
|
+
audioSubscriptionBroker = /* @__PURE__ */ new Map();
|
|
6165
6289
|
// Legacy static restreamers list — production reads via capabilities.
|
|
6166
6290
|
restreamers = [];
|
|
6167
6291
|
staticDecoders;
|
|
@@ -6266,6 +6390,12 @@ class StreamBrokerManager {
|
|
|
6266
6390
|
if (!localApi) return [];
|
|
6267
6391
|
return localApi.pullFrames(input);
|
|
6268
6392
|
},
|
|
6393
|
+
pullHandles: async (input) => {
|
|
6394
|
+
const proxy = apiDecoder();
|
|
6395
|
+
if (proxy) return proxy.pullHandles.query(input);
|
|
6396
|
+
if (!localApi) return [];
|
|
6397
|
+
return localApi.pullHandles(input);
|
|
6398
|
+
},
|
|
6269
6399
|
destroySession: async (input) => {
|
|
6270
6400
|
const proxy = apiDecoder();
|
|
6271
6401
|
if (proxy) return proxy.destroySession.query(input);
|
|
@@ -6335,6 +6465,14 @@ class StreamBrokerManager {
|
|
|
6335
6465
|
if (!buffer) return [];
|
|
6336
6466
|
return buffer.drain(input.maxCount);
|
|
6337
6467
|
},
|
|
6468
|
+
// The in-process `staticDecoders` fallback has no shm sink — its
|
|
6469
|
+
// `IDecoderSession` exposes only `onFrame` (pixels), not
|
|
6470
|
+
// `onFrameHandle`. The shared-memory frame plane (Phase 5 / D9) runs
|
|
6471
|
+
// through the real `decoder` cap (the node-av addon, which owns the
|
|
6472
|
+
// `frameSink: 'shm'` ring writer). `pullHandles` therefore returns
|
|
6473
|
+
// nothing here — a `frameSink: 'shm'` request degrades to no frames
|
|
6474
|
+
// rather than crashing the broker.
|
|
6475
|
+
pullHandles: async () => [],
|
|
6338
6476
|
destroySession: async (input) => {
|
|
6339
6477
|
const session = sessions.get(input.sessionId);
|
|
6340
6478
|
if (session) {
|
|
@@ -6598,10 +6736,111 @@ class StreamBrokerManager {
|
|
|
6598
6736
|
if (!this.cameraStreams.has(deviceId) && !this.assignments.has(deviceId)) return [];
|
|
6599
6737
|
return this.snapshotProfileSlots(deviceId);
|
|
6600
6738
|
}
|
|
6601
|
-
// ──
|
|
6739
|
+
// ── Internal: live broker lookup (not a cap method) ─────────────────
|
|
6740
|
+
/**
|
|
6741
|
+
* Resolve the live `StreamBroker` instance for a broker id. In-process
|
|
6742
|
+
* only — a `StreamBroker` is not tRPC-serialisable, so this is reachable
|
|
6743
|
+
* solely from same-process callers (device-scoped providers, the WebRTC
|
|
6744
|
+
* server). Cross-process frame access goes through the shm `subscribeFrames`
|
|
6745
|
+
* surface; cross-process stats through `getBrokerStats` / `listClients`.
|
|
6746
|
+
*/
|
|
6602
6747
|
async getBroker(input) {
|
|
6603
6748
|
return this.brokers.get(input.brokerId) ?? null;
|
|
6604
6749
|
}
|
|
6750
|
+
// ── Cap methods: broker runtime (stats + client inventory) ──────────
|
|
6751
|
+
// ── Cap methods: shared-memory frame-handle plane (Phase 5 / D9) ─────
|
|
6752
|
+
/**
|
|
6753
|
+
* Open a `FrameHandle` subscription on a broker — the handle-based
|
|
6754
|
+
* replacement for the live-object `onDecodedFrame` callback. Routed over
|
|
6755
|
+
* tRPC, so a consumer in a different process can subscribe; the consumer
|
|
6756
|
+
* then polls `pullFrameHandles` and feeds each handle to a
|
|
6757
|
+
* `FrameRingReader`.
|
|
6758
|
+
*/
|
|
6759
|
+
async subscribeFrames(input) {
|
|
6760
|
+
const broker = this.brokers.get(input.brokerId);
|
|
6761
|
+
if (!broker) {
|
|
6762
|
+
throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
|
|
6763
|
+
}
|
|
6764
|
+
const result = await broker.subscribeFrameHandles({
|
|
6765
|
+
format: input.format,
|
|
6766
|
+
maxFps: input.maxFps,
|
|
6767
|
+
tag: input.tag
|
|
6768
|
+
});
|
|
6769
|
+
this.frameSubscriptionBroker.set(result.subscriptionId, input.brokerId);
|
|
6770
|
+
return result;
|
|
6771
|
+
}
|
|
6772
|
+
/** Drain `FrameHandle`s for a subscription opened via `subscribeFrames`. */
|
|
6773
|
+
async pullFrameHandles(input) {
|
|
6774
|
+
const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
|
|
6775
|
+
if (!brokerId) return [];
|
|
6776
|
+
const broker = this.brokers.get(brokerId);
|
|
6777
|
+
if (!broker) return [];
|
|
6778
|
+
return broker.pullFrameHandles(input.subscriptionId, input.maxCount);
|
|
6779
|
+
}
|
|
6780
|
+
/**
|
|
6781
|
+
* Drop every `frameSubscriptionBroker` + `audioSubscriptionBroker` entry
|
|
6782
|
+
* pointing at `brokerId`. Called on broker teardown so a destroyed broker
|
|
6783
|
+
* leaves no dangling `subscriptionId → brokerId` rows (a slow consumer that
|
|
6784
|
+
* never calls `unsubscribeFrames` / `unsubscribeAudioChunks` would otherwise
|
|
6785
|
+
* leak a map entry per stream).
|
|
6786
|
+
*/
|
|
6787
|
+
sweepFrameSubscriptions(brokerId) {
|
|
6788
|
+
for (const [subscriptionId, mappedBrokerId] of this.frameSubscriptionBroker) {
|
|
6789
|
+
if (mappedBrokerId === brokerId) {
|
|
6790
|
+
this.frameSubscriptionBroker.delete(subscriptionId);
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6793
|
+
for (const [subscriptionId, mappedBrokerId] of this.audioSubscriptionBroker) {
|
|
6794
|
+
if (mappedBrokerId === brokerId) {
|
|
6795
|
+
this.audioSubscriptionBroker.delete(subscriptionId);
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
/** Release a frame-handle subscription. */
|
|
6800
|
+
async unsubscribeFrames(input) {
|
|
6801
|
+
const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
|
|
6802
|
+
if (!brokerId) return { released: false };
|
|
6803
|
+
this.frameSubscriptionBroker.delete(input.subscriptionId);
|
|
6804
|
+
const broker = this.brokers.get(brokerId);
|
|
6805
|
+
if (!broker) return { released: false };
|
|
6806
|
+
const released = await broker.unsubscribeFrameHandles(input.subscriptionId);
|
|
6807
|
+
return { released };
|
|
6808
|
+
}
|
|
6809
|
+
// ── Cap methods: decoded audio-chunk plane (Phase 5 / D9) ────────────
|
|
6810
|
+
/**
|
|
6811
|
+
* Open a decoded audio-chunk subscription on a broker — the poll-based,
|
|
6812
|
+
* tRPC-reachable replacement for the live-object `onDecodedAudioChunk`
|
|
6813
|
+
* callback. The consumer then polls `pullAudioChunks` and feeds each
|
|
6814
|
+
* `DecodedAudioChunk` to its downstream audio logic. Audio chunks are tiny
|
|
6815
|
+
* so their bytes travel inline on the RPC wire — no shared memory.
|
|
6816
|
+
*/
|
|
6817
|
+
async subscribeAudioChunks(input) {
|
|
6818
|
+
const broker = this.brokers.get(input.brokerId);
|
|
6819
|
+
if (!broker) {
|
|
6820
|
+
throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
|
|
6821
|
+
}
|
|
6822
|
+
const result = broker.subscribeAudioChunks({ tag: input.tag });
|
|
6823
|
+
this.audioSubscriptionBroker.set(result.subscriptionId, input.brokerId);
|
|
6824
|
+
return result;
|
|
6825
|
+
}
|
|
6826
|
+
/** Drain `DecodedAudioChunk`s for a subscription opened via `subscribeAudioChunks`. */
|
|
6827
|
+
async pullAudioChunks(input) {
|
|
6828
|
+
const brokerId = this.audioSubscriptionBroker.get(input.subscriptionId);
|
|
6829
|
+
if (!brokerId) return [];
|
|
6830
|
+
const broker = this.brokers.get(brokerId);
|
|
6831
|
+
if (!broker) return [];
|
|
6832
|
+
return broker.pullAudioChunks(input.subscriptionId, input.maxCount);
|
|
6833
|
+
}
|
|
6834
|
+
/** Release an audio-chunk subscription. */
|
|
6835
|
+
async unsubscribeAudioChunks(input) {
|
|
6836
|
+
const brokerId = this.audioSubscriptionBroker.get(input.subscriptionId);
|
|
6837
|
+
if (!brokerId) return { released: false };
|
|
6838
|
+
this.audioSubscriptionBroker.delete(input.subscriptionId);
|
|
6839
|
+
const broker = this.brokers.get(brokerId);
|
|
6840
|
+
if (!broker) return { released: false };
|
|
6841
|
+
const released = broker.unsubscribeAudioChunks(input.subscriptionId);
|
|
6842
|
+
return { released };
|
|
6843
|
+
}
|
|
6605
6844
|
async getBrokerStats(input) {
|
|
6606
6845
|
const broker = this.brokers.get(input.brokerId);
|
|
6607
6846
|
if (!broker) {
|
|
@@ -6642,12 +6881,10 @@ class StreamBrokerManager {
|
|
|
6642
6881
|
async rotateDecodersOnNode(agentNodeId, reason) {
|
|
6643
6882
|
let rotated = 0;
|
|
6644
6883
|
for (const broker of this.brokers.values()) {
|
|
6645
|
-
const
|
|
6646
|
-
if (!
|
|
6647
|
-
const brokerAgent = nodeId.includes("/") ? nodeId.split("/")[0] : nodeId;
|
|
6648
|
-
if (brokerAgent !== agentNodeId) continue;
|
|
6884
|
+
const onNode = broker.getDecoderNodeId()?.split("/")[0] === agentNodeId;
|
|
6885
|
+
if (!onNode) continue;
|
|
6649
6886
|
try {
|
|
6650
|
-
await broker.rotateDecoderSession(reason);
|
|
6887
|
+
await broker.rotateDecoderSession(reason, agentNodeId);
|
|
6651
6888
|
rotated++;
|
|
6652
6889
|
} catch (err) {
|
|
6653
6890
|
this.logger.warn("decoder rotation failed", {
|
|
@@ -6674,6 +6911,8 @@ class StreamBrokerManager {
|
|
|
6674
6911
|
await Promise.all(stopPromises);
|
|
6675
6912
|
this.brokers.clear();
|
|
6676
6913
|
this.streamHealthByBroker.clear();
|
|
6914
|
+
this.frameSubscriptionBroker.clear();
|
|
6915
|
+
this.audioSubscriptionBroker.clear();
|
|
6677
6916
|
await this.rtspServer.stop();
|
|
6678
6917
|
}
|
|
6679
6918
|
// ── Stream health watchdog ──────────────────────────────────────────
|
|
@@ -7313,6 +7552,7 @@ class StreamBrokerManager {
|
|
|
7313
7552
|
this.streamHealthByBroker.delete(brokerId);
|
|
7314
7553
|
await broker.stop();
|
|
7315
7554
|
this.brokers.delete(brokerId);
|
|
7555
|
+
this.sweepFrameSubscriptions(brokerId);
|
|
7316
7556
|
this.rtspProvider.persistTokens();
|
|
7317
7557
|
}
|
|
7318
7558
|
/**
|
|
@@ -10297,11 +10537,17 @@ class StreamBrokerAddon extends index.BaseAddon {
|
|
|
10297
10537
|
const widgetsProvider = {
|
|
10298
10538
|
listWidgets: async () => [
|
|
10299
10539
|
{
|
|
10300
|
-
|
|
10540
|
+
tab: "device-tab",
|
|
10301
10541
|
label: "Stream Brokers",
|
|
10542
|
+
kind: "remote",
|
|
10543
|
+
remote: {
|
|
10544
|
+
remoteName: "addon_stream_broker_widgets",
|
|
10545
|
+
exposedModule: "./widgets",
|
|
10546
|
+
componentKey: "stream-broker-panel"
|
|
10547
|
+
},
|
|
10548
|
+
stableId: "stream-broker-panel",
|
|
10302
10549
|
description: "Per-camera stream broker panel with adaptive controls.",
|
|
10303
10550
|
icon: "radio",
|
|
10304
|
-
remoteName: "addon_stream_broker_widgets",
|
|
10305
10551
|
bundle: "remoteEntry.js",
|
|
10306
10552
|
hosts: ["device-tab", "dashboard"],
|
|
10307
10553
|
requires: { deviceContext: true, integrationContext: false },
|
|
@@ -10317,15 +10563,11 @@ class StreamBrokerAddon extends index.BaseAddon {
|
|
|
10317
10563
|
{ capability: index.streamBrokerCapability, provider: this.brokerManager },
|
|
10318
10564
|
{
|
|
10319
10565
|
capability: index.cameraStreamsCapability,
|
|
10320
|
-
provider: cameraStreamsProvider
|
|
10321
|
-
kind: "wrapper",
|
|
10322
|
-
defaultActive: true
|
|
10566
|
+
provider: cameraStreamsProvider
|
|
10323
10567
|
},
|
|
10324
10568
|
{
|
|
10325
10569
|
capability: index.webrtcSessionCapability,
|
|
10326
|
-
provider: webrtcSessionProvider
|
|
10327
|
-
kind: "wrapper",
|
|
10328
|
-
defaultActive: true
|
|
10570
|
+
provider: webrtcSessionProvider
|
|
10329
10571
|
},
|
|
10330
10572
|
{ capability: index.addonWidgetsSourceCapability, provider: widgetsProvider }
|
|
10331
10573
|
];
|
|
@@ -10455,6 +10697,25 @@ class StreamBrokerAddon extends index.BaseAddon {
|
|
|
10455
10697
|
});
|
|
10456
10698
|
}
|
|
10457
10699
|
}
|
|
10700
|
+
class FrameDropper {
|
|
10701
|
+
intervalMs;
|
|
10702
|
+
lastPassedAt = -Infinity;
|
|
10703
|
+
constructor(maxFps) {
|
|
10704
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
10705
|
+
}
|
|
10706
|
+
shouldKeep() {
|
|
10707
|
+
if (this.intervalMs === 0) return true;
|
|
10708
|
+
const now = Date.now();
|
|
10709
|
+
if (now - this.lastPassedAt >= this.intervalMs) {
|
|
10710
|
+
this.lastPassedAt = now;
|
|
10711
|
+
return true;
|
|
10712
|
+
}
|
|
10713
|
+
return false;
|
|
10714
|
+
}
|
|
10715
|
+
setMaxFps(maxFps) {
|
|
10716
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
10717
|
+
}
|
|
10718
|
+
}
|
|
10458
10719
|
const noopLogger = {
|
|
10459
10720
|
debug() {
|
|
10460
10721
|
},
|