@camstack/addon-pipeline 0.1.14 → 0.1.15
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-CWHjxwIc.mjs} +6 -6
- package/dist/stream-broker/{client-BK73l2KT.mjs → client-CZXrddDR.mjs} +2990 -3217
- package/dist/stream-broker/{hostInit-DkjoXTMb.mjs → hostInit-B86vUcFC.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,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import * as net from "node:net";
|
|
3
|
-
import net__default from "node:net";
|
|
1
|
+
import { R as RingBuffer, e as errMsg, J as maskUrlCredentials, E as EventCategory, K as CAM_PROFILE_ORDER, L as DeviceFeature, B as BaseAddon, M as asJsonObject, N as streamBrokerCapability, O as cameraStreamsCapability, P as webrtcSessionCapability, Q as addonWidgetsSourceCapability, f as createEvent } from "../index-CVzLrojg.mjs";
|
|
4
2
|
import * as crypto from "node:crypto";
|
|
5
3
|
import crypto__default, { randomUUID, createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import * as net from "node:net";
|
|
5
|
+
import net__default from "node:net";
|
|
6
6
|
import { Socket } from "net";
|
|
7
7
|
import { once } from "events";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
@@ -31,6 +31,21 @@ class DecoderSessionProxy {
|
|
|
31
31
|
if (frames.length === 0) await new Promise((r) => setTimeout(r, 1));
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Poll a `frameSink: 'shm'` session for `FrameHandle`s (Phase 5 / D9).
|
|
36
|
+
* Mirrors `startPolling` but drains `pullHandles` — the decoder has
|
|
37
|
+
* already written the pixels into a shared-memory ring, so what crosses
|
|
38
|
+
* the cap boundary is the tiny serialisable handle. Runs until
|
|
39
|
+
* `stopPolling` or `destroy`.
|
|
40
|
+
*/
|
|
41
|
+
async startHandlePolling(onHandle) {
|
|
42
|
+
this.polling = true;
|
|
43
|
+
while (this.polling) {
|
|
44
|
+
const handles = await this.api.pullHandles({ sessionId: this.sessionId, maxCount: 4 });
|
|
45
|
+
for (const handle of handles) onHandle(handle);
|
|
46
|
+
if (handles.length === 0) await new Promise((r) => setTimeout(r, 1));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
34
49
|
stopPolling() {
|
|
35
50
|
this.polling = false;
|
|
36
51
|
}
|
|
@@ -45,6 +60,462 @@ class DecoderSessionProxy {
|
|
|
45
60
|
await this.api.destroySession({ sessionId: this.sessionId });
|
|
46
61
|
}
|
|
47
62
|
}
|
|
63
|
+
const HANDLE_QUEUE_CAPACITY = 64;
|
|
64
|
+
const DEFAULT_HINT_FPS = 5;
|
|
65
|
+
const DECODE_FPS_WINDOW_MS = 2e3;
|
|
66
|
+
class FrameHandlePlane {
|
|
67
|
+
decoderApi;
|
|
68
|
+
logger;
|
|
69
|
+
resolveStreamInfo;
|
|
70
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
71
|
+
sessions = /* @__PURE__ */ new Map();
|
|
72
|
+
disposed = false;
|
|
73
|
+
/** Re-entrancy guard for `armPendingSessions` — collapses the per-packet
|
|
74
|
+
* `pushPacket` calls into a single in-flight deferred-arm pass. */
|
|
75
|
+
armInFlight = false;
|
|
76
|
+
/** Aggregate decoded-frame rate state — frames fanned out in the current
|
|
77
|
+
* rolling window, the window's start epoch, and the last computed fps. */
|
|
78
|
+
framesInWindow = 0;
|
|
79
|
+
windowStartMs = Date.now();
|
|
80
|
+
decodedFps = 0;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.decoderApi = options.decoderApi;
|
|
83
|
+
this.logger = options.logger;
|
|
84
|
+
this.resolveStreamInfo = options.resolveStreamInfo;
|
|
85
|
+
}
|
|
86
|
+
/** Number of active handle subscriptions — surfaced for diagnostics. */
|
|
87
|
+
get subscriberCount() {
|
|
88
|
+
return this.subscriptions.size;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Moleculer nodeIDs of the decoder providers currently hosting this plane's
|
|
92
|
+
* `frameSink: 'shm'` sessions. Used by the broker-manager's per-agent
|
|
93
|
+
* hwaccel-change handler to decide which brokers need a decoder rotation.
|
|
94
|
+
*/
|
|
95
|
+
decoderNodeIds() {
|
|
96
|
+
const ids = [];
|
|
97
|
+
for (const session of this.sessions.values()) {
|
|
98
|
+
if (session.nodeId) ids.push(session.nodeId);
|
|
99
|
+
}
|
|
100
|
+
return ids;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Rotate every shm decoder session hosted on `agentNodeId` — destroy and
|
|
104
|
+
* rebuild it so the new session re-pulls the latest per-agent decoder
|
|
105
|
+
* preferences (hwaccel backend, …). Subscriptions are preserved: the same
|
|
106
|
+
* `subscriptionId`s read from the rebuilt session's fresh ring. A no-op for
|
|
107
|
+
* sessions on other nodes. Returns the number of sessions rotated.
|
|
108
|
+
*/
|
|
109
|
+
async rotate(agentNodeId, reason) {
|
|
110
|
+
if (this.disposed) return 0;
|
|
111
|
+
let rotated = 0;
|
|
112
|
+
for (const session of this.sessions.values()) {
|
|
113
|
+
const sessionAgent = session.nodeId && session.nodeId.includes("/") ? session.nodeId.split("/")[0] : session.nodeId;
|
|
114
|
+
if (sessionAgent !== agentNodeId) continue;
|
|
115
|
+
this.logger?.info("frame-handle plane: rotating shm decoder session", {
|
|
116
|
+
meta: { format: session.format, reason, nodeId: session.nodeId }
|
|
117
|
+
});
|
|
118
|
+
await this.destroySession(session);
|
|
119
|
+
await this.startSessionDecoder(session);
|
|
120
|
+
rotated += 1;
|
|
121
|
+
}
|
|
122
|
+
return rotated;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Register a frame-handle subscription. Spins up (or reuses) a
|
|
126
|
+
* `frameSink: 'shm'` decoder session producing `format`. The returned
|
|
127
|
+
* `subscriptionId` is the handle the consumer passes to
|
|
128
|
+
* `pullFrameHandles` / `unsubscribe`.
|
|
129
|
+
*/
|
|
130
|
+
async subscribe(input) {
|
|
131
|
+
if (this.disposed) {
|
|
132
|
+
throw new Error("FrameHandlePlane: subscribe after dispose");
|
|
133
|
+
}
|
|
134
|
+
const subscriptionId = `fh-${randomUUID()}`;
|
|
135
|
+
const maxFps = input.maxFps !== void 0 && input.maxFps > 0 ? input.maxFps : DEFAULT_HINT_FPS;
|
|
136
|
+
const subscription = {
|
|
137
|
+
id: subscriptionId,
|
|
138
|
+
format: input.format,
|
|
139
|
+
maxFps,
|
|
140
|
+
tag: input.tag ?? "unknown",
|
|
141
|
+
subscribedAt: Date.now(),
|
|
142
|
+
queue: new RingBuffer(HANDLE_QUEUE_CAPACITY),
|
|
143
|
+
framesDelivered: 0
|
|
144
|
+
};
|
|
145
|
+
this.subscriptions.set(subscriptionId, subscription);
|
|
146
|
+
await this.ensureSession(input.format, subscriptionId);
|
|
147
|
+
this.logger?.info("frame-handle subscription added", {
|
|
148
|
+
meta: { subscriptionId, format: input.format, tag: subscription.tag, maxFps }
|
|
149
|
+
});
|
|
150
|
+
return { subscriptionId, maxFps };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Drain up to `maxCount` `FrameHandle`s for a subscription, latest-wins.
|
|
154
|
+
* An unknown subscription id returns `[]` (the consumer may have been
|
|
155
|
+
* torn down concurrently).
|
|
156
|
+
*/
|
|
157
|
+
pullHandles(subscriptionId, maxCount) {
|
|
158
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
159
|
+
if (!subscription) return [];
|
|
160
|
+
const handles = subscription.queue.drain(maxCount);
|
|
161
|
+
subscription.framesDelivered += handles.length;
|
|
162
|
+
return handles;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Release a subscription. When it was the last reader of its format the
|
|
166
|
+
* underlying decoder session is destroyed (and its shm segment unlinked
|
|
167
|
+
* by the decoder). Returns `true` when a known subscription was released.
|
|
168
|
+
*/
|
|
169
|
+
async unsubscribe(subscriptionId) {
|
|
170
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
171
|
+
if (!subscription) return false;
|
|
172
|
+
this.subscriptions.delete(subscriptionId);
|
|
173
|
+
const session = this.sessions.get(subscription.format);
|
|
174
|
+
if (session) {
|
|
175
|
+
session.subscriberIds.delete(subscriptionId);
|
|
176
|
+
if (session.subscriberIds.size === 0) {
|
|
177
|
+
this.sessions.delete(subscription.format);
|
|
178
|
+
await this.destroySession(session);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.logger?.info("frame-handle subscription removed", {
|
|
182
|
+
meta: { subscriptionId, format: subscription.format }
|
|
183
|
+
});
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Forward a video `EncodedPacket` to every active shm decoder session.
|
|
188
|
+
* Push-mode decoders consume this; a pull-mode decoder ignores it (it
|
|
189
|
+
* reads its own pipe via `openStream`). Audio packets are not forwarded.
|
|
190
|
+
*
|
|
191
|
+
* The packet feed doubles as the plane's stream-ready signal: the broker
|
|
192
|
+
* only calls `pushPacket` once it has a source and the first keyframe has
|
|
193
|
+
* landed — exactly the point `resolveStreamInfo` starts returning a
|
|
194
|
+
* descriptor. Each packet therefore triggers `armPendingSessions`, which
|
|
195
|
+
* re-attempts session creation for any deferred subscription so a lone
|
|
196
|
+
* first subscriber on a not-yet-started broker self-arms without a
|
|
197
|
+
* redundant re-`subscribe`.
|
|
198
|
+
*/
|
|
199
|
+
pushPacket(packet) {
|
|
200
|
+
if (this.disposed || packet.type !== "video") return;
|
|
201
|
+
void this.armPendingSessions(packet).catch((err) => {
|
|
202
|
+
this.logger?.warn("frame-handle plane: arm pending sessions error", {
|
|
203
|
+
meta: { error: errMsg(err) }
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
this.feedSessions(packet);
|
|
207
|
+
}
|
|
208
|
+
/** Forward a video `EncodedPacket` to every live shm decoder session. */
|
|
209
|
+
feedSessions(packet) {
|
|
210
|
+
for (const session of this.sessions.values()) {
|
|
211
|
+
if (!session.proxy) continue;
|
|
212
|
+
session.proxy.pushPacket(packet).catch((err) => {
|
|
213
|
+
this.logger?.warn("frame-handle plane: decoder push error", {
|
|
214
|
+
meta: { format: session.format, error: errMsg(err) }
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Re-attempt session creation for every subscription whose format has no
|
|
221
|
+
* live `FormatSession` yet — the subscriptions whose `startSessionDecoder`
|
|
222
|
+
* was deferred because the broker had no stream at `subscribe` time. Driven
|
|
223
|
+
* off the `pushPacket` feed (event-driven, no polling timer); a no-op once
|
|
224
|
+
* every subscribed format already owns a session.
|
|
225
|
+
*
|
|
226
|
+
* `armInFlight` collapses re-entrant calls: `pushPacket` fires per packet,
|
|
227
|
+
* but `ensureSession` is async — without the guard a burst of packets would
|
|
228
|
+
* launch overlapping decoder-creation round-trips for the same format.
|
|
229
|
+
*
|
|
230
|
+
* `triggerPacket` is the packet whose arrival armed the pass. Because
|
|
231
|
+
* `ensureSession` is async, the synchronous `feedSessions` in `pushPacket`
|
|
232
|
+
* runs before the session lands in `this.sessions` — so this method feeds
|
|
233
|
+
* the triggering packet to each session it just created, otherwise that
|
|
234
|
+
* first (keyframe-carrying) packet would be lost and a push-mode H264/H265
|
|
235
|
+
* decoder would stall waiting for SPS/PPS.
|
|
236
|
+
*/
|
|
237
|
+
async armPendingSessions(triggerPacket) {
|
|
238
|
+
if (this.disposed || this.armInFlight) return;
|
|
239
|
+
const pending = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const subscription of this.subscriptions.values()) {
|
|
241
|
+
if (this.sessions.has(subscription.format)) continue;
|
|
242
|
+
if (!pending.has(subscription.format)) {
|
|
243
|
+
pending.set(subscription.format, subscription.id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (pending.size === 0) return;
|
|
247
|
+
this.armInFlight = true;
|
|
248
|
+
try {
|
|
249
|
+
for (const [format, subscriptionId] of pending) {
|
|
250
|
+
if (this.disposed) return;
|
|
251
|
+
await this.ensureSession(format, subscriptionId);
|
|
252
|
+
const armed = this.sessions.get(format);
|
|
253
|
+
if (armed?.proxy) {
|
|
254
|
+
armed.proxy.pushPacket(triggerPacket).catch((err) => {
|
|
255
|
+
this.logger?.warn("frame-handle plane: decoder push error", {
|
|
256
|
+
meta: { format, error: errMsg(err) }
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
this.armInFlight = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Aggregate decoded-frame rate across every `frameSink: 'shm'` session in
|
|
267
|
+
* this plane, frames/s. Each `fanoutHandle` call is one decoded frame
|
|
268
|
+
* delivered into the plane; the rate is recomputed over a rolling
|
|
269
|
+
* `DECODE_FPS_WINDOW_MS` window. Returns the last computed value between
|
|
270
|
+
* window rolls, and `0` while no session is producing.
|
|
271
|
+
*/
|
|
272
|
+
decodeFps() {
|
|
273
|
+
this.rollDecodeFpsWindow();
|
|
274
|
+
return this.decodedFps;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Roll the decoded-fps window when it has elapsed: convert frames seen in
|
|
278
|
+
* the window into a per-second rate, then reset the counter. Called both
|
|
279
|
+
* on every fanout and on every `decodeFps()` read so a stalled session
|
|
280
|
+
* (no fanout) decays its rate to `0` rather than reporting a stale value.
|
|
281
|
+
*/
|
|
282
|
+
rollDecodeFpsWindow() {
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const windowMs = now - this.windowStartMs;
|
|
285
|
+
if (windowMs < DECODE_FPS_WINDOW_MS) return;
|
|
286
|
+
this.decodedFps = this.framesInWindow / windowMs * 1e3;
|
|
287
|
+
this.framesInWindow = 0;
|
|
288
|
+
this.windowStartMs = now;
|
|
289
|
+
}
|
|
290
|
+
/** Diagnostic snapshot of every active frame-handle subscription. */
|
|
291
|
+
listSubscribers() {
|
|
292
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
293
|
+
tag: s.tag,
|
|
294
|
+
subscribedAt: s.subscribedAt,
|
|
295
|
+
maxFps: s.maxFps,
|
|
296
|
+
framesDelivered: s.framesDelivered
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Force-release every subscription whose tag matches `tag`. Returns the
|
|
301
|
+
* number released. Decoder sessions wind down when their last subscriber
|
|
302
|
+
* leaves, same as a normal `unsubscribe`.
|
|
303
|
+
*/
|
|
304
|
+
async killByTag(tag) {
|
|
305
|
+
const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
|
|
306
|
+
for (const id of victims) {
|
|
307
|
+
await this.unsubscribe(id);
|
|
308
|
+
}
|
|
309
|
+
return victims.length;
|
|
310
|
+
}
|
|
311
|
+
/** Tear down every subscription + decoder session. Idempotent. */
|
|
312
|
+
async dispose() {
|
|
313
|
+
if (this.disposed) return;
|
|
314
|
+
this.disposed = true;
|
|
315
|
+
this.subscriptions.clear();
|
|
316
|
+
const sessions = [...this.sessions.values()];
|
|
317
|
+
this.sessions.clear();
|
|
318
|
+
await Promise.all(sessions.map((s) => this.destroySession(s)));
|
|
319
|
+
}
|
|
320
|
+
// ── Internal ───────────────────────────────────────────────────────
|
|
321
|
+
/**
|
|
322
|
+
* Ensure a `frameSink: 'shm'` decoder session for `format` exists and add
|
|
323
|
+
* `subscriptionId` to its reader set. Reuses the session when one already
|
|
324
|
+
* runs for that format.
|
|
325
|
+
*
|
|
326
|
+
* When a session is created fresh, its reader set is seeded with EVERY
|
|
327
|
+
* subscription currently reading `format`, not just `subscriptionId` — a
|
|
328
|
+
* deferred-arm pass (`armPendingSessions`) may create the session long
|
|
329
|
+
* after several subscriptions of that format have registered, and the
|
|
330
|
+
* session is reference-counted by `subscriberIds`. Seeding only the one id
|
|
331
|
+
* would let an `unsubscribe` of that id tear the session down while its
|
|
332
|
+
* sibling subscriptions still read it.
|
|
333
|
+
*/
|
|
334
|
+
async ensureSession(format, subscriptionId) {
|
|
335
|
+
const existing = this.sessions.get(format);
|
|
336
|
+
if (existing) {
|
|
337
|
+
existing.subscriberIds.add(subscriptionId);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const subscriberIds = /* @__PURE__ */ new Set([subscriptionId]);
|
|
341
|
+
for (const subscription of this.subscriptions.values()) {
|
|
342
|
+
if (subscription.format === format) subscriberIds.add(subscription.id);
|
|
343
|
+
}
|
|
344
|
+
const session = {
|
|
345
|
+
format,
|
|
346
|
+
proxy: null,
|
|
347
|
+
nodeId: null,
|
|
348
|
+
subscriberIds
|
|
349
|
+
};
|
|
350
|
+
const started = await this.startSessionDecoder(session);
|
|
351
|
+
if (started) {
|
|
352
|
+
this.sessions.set(format, session);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Create the `frameSink: 'shm'` decoder for a `FormatSession` and start
|
|
357
|
+
* draining its handle queue. Mutates `session.proxy` / `session.nodeId` in
|
|
358
|
+
* place so a `rotate()` can rebuild a registered session without disturbing
|
|
359
|
+
* its `subscriberIds`. Returns `false` when no stream / unsupported codec
|
|
360
|
+
* means no decoder could be created.
|
|
361
|
+
*/
|
|
362
|
+
async startSessionDecoder(session) {
|
|
363
|
+
const { format } = session;
|
|
364
|
+
const info = this.resolveStreamInfo();
|
|
365
|
+
if (!info) {
|
|
366
|
+
this.logger?.info("frame-handle plane: no stream — session deferred", {
|
|
367
|
+
meta: { format }
|
|
368
|
+
});
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const supported = await this.decoderApi.supportsCodec({ codec: info.codec });
|
|
372
|
+
if (!supported) {
|
|
373
|
+
this.logger?.warn("frame-handle plane: codec unsupported — session skipped", {
|
|
374
|
+
meta: { format, codec: info.codec }
|
|
375
|
+
});
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const { sessionId, nodeId } = await this.decoderApi.createSession({
|
|
379
|
+
codec: info.codec,
|
|
380
|
+
maxFps: 0,
|
|
381
|
+
outputFormat: format,
|
|
382
|
+
scale: 1,
|
|
383
|
+
frameSink: "shm",
|
|
384
|
+
...info.numericDeviceId !== void 0 ? { deviceId: info.numericDeviceId } : {},
|
|
385
|
+
tag: `${info.tag}:shm:${format}`
|
|
386
|
+
});
|
|
387
|
+
const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
|
|
388
|
+
session.proxy = proxy;
|
|
389
|
+
session.nodeId = nodeId;
|
|
390
|
+
proxy.startHandlePolling((handle) => {
|
|
391
|
+
this.fanoutHandle(format, handle);
|
|
392
|
+
}).catch((err) => {
|
|
393
|
+
this.logger?.warn("frame-handle plane: handle polling error", {
|
|
394
|
+
meta: { format, error: errMsg(err) }
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
if (info.pullModeUrl) {
|
|
398
|
+
proxy.openStream(info.pullModeUrl).catch((err) => {
|
|
399
|
+
this.logger?.error("frame-handle plane: pull-mode openStream failed", {
|
|
400
|
+
meta: { format, error: errMsg(err) }
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
this.logger?.info("frame-handle plane: shm decoder session created", {
|
|
405
|
+
meta: { format, codec: info.codec, sessionId, nodeId }
|
|
406
|
+
});
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
/** Push one decoded `FrameHandle` to every subscription of `format`. */
|
|
410
|
+
fanoutHandle(format, handle) {
|
|
411
|
+
this.rollDecodeFpsWindow();
|
|
412
|
+
this.framesInWindow += 1;
|
|
413
|
+
for (const subscription of this.subscriptions.values()) {
|
|
414
|
+
if (subscription.format !== format) continue;
|
|
415
|
+
subscription.queue.push(handle);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** Destroy a decoder session, swallowing teardown errors. */
|
|
419
|
+
async destroySession(session) {
|
|
420
|
+
const proxy = session.proxy;
|
|
421
|
+
session.proxy = null;
|
|
422
|
+
session.nodeId = null;
|
|
423
|
+
if (!proxy) return;
|
|
424
|
+
try {
|
|
425
|
+
await proxy.destroy();
|
|
426
|
+
} catch (err) {
|
|
427
|
+
this.logger?.warn("frame-handle plane: session destroy failed", {
|
|
428
|
+
meta: { format: session.format, error: errMsg(err) }
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const AUDIO_QUEUE_CAPACITY = 64;
|
|
434
|
+
class AudioChunkPlane {
|
|
435
|
+
logger;
|
|
436
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
437
|
+
constructor(logger) {
|
|
438
|
+
this.logger = logger;
|
|
439
|
+
}
|
|
440
|
+
/** Number of active audio-chunk subscriptions — surfaced as audio demand. */
|
|
441
|
+
get subscriberCount() {
|
|
442
|
+
return this.subscriptions.size;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Register an audio-chunk subscription. The returned `subscriptionId` is
|
|
446
|
+
* the handle the consumer passes to `pull` / `unsubscribe`.
|
|
447
|
+
*/
|
|
448
|
+
subscribe(input) {
|
|
449
|
+
const subscriptionId = `ac-${randomUUID()}`;
|
|
450
|
+
const subscription = {
|
|
451
|
+
id: subscriptionId,
|
|
452
|
+
tag: input?.tag ?? "unknown",
|
|
453
|
+
subscribedAt: Date.now(),
|
|
454
|
+
queue: new RingBuffer(AUDIO_QUEUE_CAPACITY),
|
|
455
|
+
chunksDelivered: 0
|
|
456
|
+
};
|
|
457
|
+
this.subscriptions.set(subscriptionId, subscription);
|
|
458
|
+
this.logger?.info("audio-chunk subscription added", {
|
|
459
|
+
meta: { subscriptionId, tag: subscription.tag }
|
|
460
|
+
});
|
|
461
|
+
return { subscriptionId };
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Drain up to `maxCount` `DecodedAudioChunk`s for a subscription, in FIFO
|
|
465
|
+
* arrival order. An unknown subscription id returns `[]` (the consumer may
|
|
466
|
+
* have been torn down concurrently).
|
|
467
|
+
*/
|
|
468
|
+
pull(subscriptionId, maxCount) {
|
|
469
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
470
|
+
if (!subscription) return [];
|
|
471
|
+
const chunks = subscription.queue.drain(maxCount);
|
|
472
|
+
subscription.chunksDelivered += chunks.length;
|
|
473
|
+
return chunks;
|
|
474
|
+
}
|
|
475
|
+
/** Release a subscription. Returns `true` when a known subscription was released. */
|
|
476
|
+
unsubscribe(subscriptionId) {
|
|
477
|
+
const existed = this.subscriptions.delete(subscriptionId);
|
|
478
|
+
if (existed) {
|
|
479
|
+
this.logger?.info("audio-chunk subscription removed", {
|
|
480
|
+
meta: { subscriptionId }
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return existed;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Fan one decoded `DecodedAudioChunk` into every active subscription's FIFO
|
|
487
|
+
* queue. A full queue drops its oldest chunk (bounded safety valve for a
|
|
488
|
+
* stalled consumer). No-op when there are no subscriptions.
|
|
489
|
+
*/
|
|
490
|
+
fanout(chunk) {
|
|
491
|
+
for (const subscription of this.subscriptions.values()) {
|
|
492
|
+
subscription.queue.push(chunk);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Diagnostic snapshot of every active audio-chunk subscription. */
|
|
496
|
+
listSubscribers() {
|
|
497
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
498
|
+
tag: s.tag,
|
|
499
|
+
subscribedAt: s.subscribedAt,
|
|
500
|
+
chunksDelivered: s.chunksDelivered
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Force-release every subscription whose tag matches `tag`. Returns the
|
|
505
|
+
* number released.
|
|
506
|
+
*/
|
|
507
|
+
killByTag(tag) {
|
|
508
|
+
const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
|
|
509
|
+
for (const id of victims) {
|
|
510
|
+
this.subscriptions.delete(id);
|
|
511
|
+
}
|
|
512
|
+
return victims.length;
|
|
513
|
+
}
|
|
514
|
+
/** Tear down every subscription. Idempotent. */
|
|
515
|
+
dispose() {
|
|
516
|
+
this.subscriptions.clear();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
48
519
|
const SAMPLERATE_TABLE = [
|
|
49
520
|
96e3,
|
|
50
521
|
88200,
|
|
@@ -434,25 +905,6 @@ class AudioCodecSession {
|
|
|
434
905
|
});
|
|
435
906
|
}
|
|
436
907
|
}
|
|
437
|
-
class FrameDropper {
|
|
438
|
-
intervalMs;
|
|
439
|
-
lastPassedAt = -Infinity;
|
|
440
|
-
constructor(maxFps) {
|
|
441
|
-
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
442
|
-
}
|
|
443
|
-
shouldKeep() {
|
|
444
|
-
if (this.intervalMs === 0) return true;
|
|
445
|
-
const now = Date.now();
|
|
446
|
-
if (now - this.lastPassedAt >= this.intervalMs) {
|
|
447
|
-
this.lastPassedAt = now;
|
|
448
|
-
return true;
|
|
449
|
-
}
|
|
450
|
-
return false;
|
|
451
|
-
}
|
|
452
|
-
setMaxFps(maxFps) {
|
|
453
|
-
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
908
|
const MAX_WRITE_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
457
909
|
class StreamPipeServer {
|
|
458
910
|
server;
|
|
@@ -3144,27 +3596,8 @@ function getPlaceholderFrame(kind, codec) {
|
|
|
3144
3596
|
codecCache[kind] = buf;
|
|
3145
3597
|
return buf;
|
|
3146
3598
|
}
|
|
3147
|
-
let cachedSharp = null;
|
|
3148
|
-
async function getSharp() {
|
|
3149
|
-
if (cachedSharp) return cachedSharp;
|
|
3150
|
-
const mod = await import("sharp");
|
|
3151
|
-
cachedSharp = mod.default;
|
|
3152
|
-
return cachedSharp;
|
|
3153
|
-
}
|
|
3154
|
-
function swapRedBlue(rgb) {
|
|
3155
|
-
const out = Buffer.allocUnsafe(rgb.length);
|
|
3156
|
-
for (let i = 0; i + 2 < rgb.length; i += 3) {
|
|
3157
|
-
out[i] = rgb[i + 2];
|
|
3158
|
-
out[i + 1] = rgb[i + 1];
|
|
3159
|
-
out[i + 2] = rgb[i];
|
|
3160
|
-
}
|
|
3161
|
-
return out;
|
|
3162
|
-
}
|
|
3163
|
-
const DEFAULT_MAX_FPS = 5;
|
|
3164
|
-
const DEFAULT_SCALE = 1;
|
|
3165
3599
|
const INITIAL_RECONNECT_DELAY_MS = 1e3;
|
|
3166
3600
|
const MAX_RECONNECT_DELAY_MS = 3e4;
|
|
3167
|
-
const DECODER_TEARDOWN_GRACE_MS = 2e3;
|
|
3168
3601
|
const SUSPEND_GRACE_MS = 5e3;
|
|
3169
3602
|
const PUSH_STALL_TIMEOUT_MS = 15e3;
|
|
3170
3603
|
class StreamBroker {
|
|
@@ -3204,17 +3637,13 @@ class StreamBroker {
|
|
|
3204
3637
|
lastRefreshRequestAt = 0;
|
|
3205
3638
|
/** Detected or configured codec — persists across reconnects */
|
|
3206
3639
|
detectedCodec;
|
|
3207
|
-
decoderProxy = null;
|
|
3208
|
-
/** Current output format of the shared decoder session. */
|
|
3209
|
-
decoderOutputFormat = null;
|
|
3210
3640
|
/**
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3214
|
-
*
|
|
3215
|
-
* decoder rotation. Null when no shared decoder session exists.
|
|
3641
|
+
* The shared-memory frame-handle surface (Phase 5 / D9) — the broker's sole
|
|
3642
|
+
* decoded-frame path. Lazily created on the first `subscribeFrameHandles`
|
|
3643
|
+
* call; owns the per-format `frameSink: 'shm'` decoder sessions a handle
|
|
3644
|
+
* consumer reads from. `null` until a consumer subscribes.
|
|
3216
3645
|
*/
|
|
3217
|
-
|
|
3646
|
+
frameHandlePlane = null;
|
|
3218
3647
|
decoderApi;
|
|
3219
3648
|
audioCodecApi;
|
|
3220
3649
|
logger;
|
|
@@ -3265,7 +3694,6 @@ class StreamBroker {
|
|
|
3265
3694
|
static FIRST_VIDEO_TIMEOUT_MS = 8e3;
|
|
3266
3695
|
manualStop = false;
|
|
3267
3696
|
stopping = false;
|
|
3268
|
-
decoderTeardownTimer;
|
|
3269
3697
|
restreamers = [];
|
|
3270
3698
|
pipeServer;
|
|
3271
3699
|
rtspRestreamer;
|
|
@@ -3300,19 +3728,20 @@ class StreamBroker {
|
|
|
3300
3728
|
* quiet unless an operator explicitly enables debug for one device.
|
|
3301
3729
|
*/
|
|
3302
3730
|
streamingDebug = false;
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3731
|
+
/**
|
|
3732
|
+
* Decoded audio-chunk plane (Phase 5 / D9) — the poll-based, tRPC-reachable
|
|
3733
|
+
* replacement for the live-object `onDecodedAudioChunk` callback path.
|
|
3734
|
+
* Holds the per-subscription FIFO chunk queues. Assigned in the constructor
|
|
3735
|
+
* so it gets a scoped logger child.
|
|
3736
|
+
*/
|
|
3737
|
+
audioChunkPlane;
|
|
3738
|
+
/**
|
|
3739
|
+
* Gate for feeding encoded packets to the decoder: H.264/H.265 decoders
|
|
3740
|
+
* need the SPS/PPS carried in the first keyframe to initialise, so the
|
|
3741
|
+
* broker withholds packets from the shm frame plane until the first
|
|
3742
|
+
* keyframe arrives.
|
|
3743
|
+
*/
|
|
3309
3744
|
firstKeyframeReceived = false;
|
|
3310
|
-
/** Retry handle for "no decoder provider yet" — addons register in
|
|
3311
|
-
* arbitrary order, the broker must wait rather than fail. Cancelled
|
|
3312
|
-
* on decoder attach or broker destroy. */
|
|
3313
|
-
decoderResolveRetryTimer;
|
|
3314
|
-
/** Attempt counter for the retry loop above — feeds bounded backoff. */
|
|
3315
|
-
decoderResolveAttempts = 0;
|
|
3316
3745
|
/** Timer for push-mode stall detection. */
|
|
3317
3746
|
pushStallTimer;
|
|
3318
3747
|
/** Whether pre-buffer counts as demand (keeps the stream alive even with 0 subscribers). */
|
|
@@ -3321,15 +3750,10 @@ class StreamBroker {
|
|
|
3321
3750
|
suspendTimer;
|
|
3322
3751
|
/** True while the stream is suspended (ffmpeg killed, no RTSP connection). */
|
|
3323
3752
|
_suspended = false;
|
|
3324
|
-
/** Debug counters for decoder troubleshooting */
|
|
3325
|
-
decoderPushCount = 0;
|
|
3326
|
-
decoderFrameCount = 0;
|
|
3327
3753
|
/** Counter for video RTP packets — drives the milestone diagnostic
|
|
3328
3754
|
* log alongside `audioRtpSeen`, so a wake-up cycle that brings audio
|
|
3329
3755
|
* back but never video is observable from the structured logs. */
|
|
3330
3756
|
videoRtpSeen = 0;
|
|
3331
|
-
/** Cached decoder stats — updated by the polling loop, read synchronously by getStats(). */
|
|
3332
|
-
cachedDecoderStats;
|
|
3333
3757
|
/** Tracking flags set synchronously by the RTP depacketizer callback. */
|
|
3334
3758
|
_lastNalKeyframe = false;
|
|
3335
3759
|
_lastNalParamSet = false;
|
|
@@ -3362,6 +3786,7 @@ class StreamBroker {
|
|
|
3362
3786
|
this.rtspRestreamer = new RtspRestreamer(deviceId);
|
|
3363
3787
|
if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
|
|
3364
3788
|
this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
|
|
3789
|
+
this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
|
|
3365
3790
|
this.rtspRestreamer.onSessionCountChanged(() => this.checkDemand());
|
|
3366
3791
|
this.pipeServer.onClientCountChanged(() => this.checkDemand());
|
|
3367
3792
|
}
|
|
@@ -3533,10 +3958,6 @@ class StreamBroker {
|
|
|
3533
3958
|
clearTimeout(this.reconnectTimer);
|
|
3534
3959
|
this.reconnectTimer = void 0;
|
|
3535
3960
|
}
|
|
3536
|
-
if (this.decoderTeardownTimer) {
|
|
3537
|
-
clearTimeout(this.decoderTeardownTimer);
|
|
3538
|
-
this.decoderTeardownTimer = void 0;
|
|
3539
|
-
}
|
|
3540
3961
|
if (this.suspendTimer) {
|
|
3541
3962
|
clearTimeout(this.suspendTimer);
|
|
3542
3963
|
this.suspendTimer = void 0;
|
|
@@ -3545,18 +3966,18 @@ class StreamBroker {
|
|
|
3545
3966
|
clearTimeout(this.pushStallTimer);
|
|
3546
3967
|
this.pushStallTimer = void 0;
|
|
3547
3968
|
}
|
|
3548
|
-
if (this.
|
|
3549
|
-
|
|
3969
|
+
if (this.frameHandlePlane) {
|
|
3970
|
+
const plane = this.frameHandlePlane;
|
|
3971
|
+
this.frameHandlePlane = null;
|
|
3972
|
+
await plane.dispose();
|
|
3550
3973
|
}
|
|
3551
3974
|
await this.pipeServer.stop();
|
|
3552
3975
|
this.rtspRestreamer.destroy();
|
|
3553
3976
|
this.preBuffer.clear();
|
|
3554
3977
|
this.pendingParamNals = [];
|
|
3555
|
-
this.pendingPullModeOpen = null;
|
|
3556
3978
|
this.firstKeyframeReceived = false;
|
|
3557
3979
|
this.encodedCallbacks.clear();
|
|
3558
|
-
this.
|
|
3559
|
-
this.audioSubscribers.clear();
|
|
3980
|
+
this.audioChunkPlane.dispose();
|
|
3560
3981
|
}
|
|
3561
3982
|
// ── Auto-suspend / resume ─────────────────────────────────────────────
|
|
3562
3983
|
/** Whether the broker is currently suspended (idle, no RTSP connection). */
|
|
@@ -3572,8 +3993,8 @@ class StreamBroker {
|
|
|
3572
3993
|
hasDemand() {
|
|
3573
3994
|
if (this.encodedCallbacks.size > 0) return true;
|
|
3574
3995
|
if (this.rtpVideoCallbacks.size > 0) return true;
|
|
3575
|
-
if (this.
|
|
3576
|
-
if (this.
|
|
3996
|
+
if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
|
|
3997
|
+
if (this.audioChunkPlane.subscriberCount > 0) return true;
|
|
3577
3998
|
if (this.rtspRestreamer.getSessionCount() > 0) return true;
|
|
3578
3999
|
if (this.pipeServer.getClientCount() > 0) return true;
|
|
3579
4000
|
if (this._preBufferEnabled && this.preBuffer.getDuration() > 0) return true;
|
|
@@ -3583,160 +4004,6 @@ class StreamBroker {
|
|
|
3583
4004
|
* Check demand and schedule suspend/resume. Called after every subscriber
|
|
3584
4005
|
* add/remove and after pre-buffer enable/disable.
|
|
3585
4006
|
*/
|
|
3586
|
-
/**
|
|
3587
|
-
* Pick the decoder's native output format given the current subscriber
|
|
3588
|
-
* mix. Phase 3 collapsed three concerns into this one decision:
|
|
3589
|
-
*
|
|
3590
|
-
* 1. Avoid wasted CPU when subscribers are homogeneous — `gray`-
|
|
3591
|
-
* only stays `gray` (motion-only camera), `jpeg`-only stays
|
|
3592
|
-
* `jpeg` (no detection consumer joined yet, broker doesn't have
|
|
3593
|
-
* to encode either).
|
|
3594
|
-
* 2. Pick the cheapest *canonical* mode for mixed subscribers —
|
|
3595
|
-
* `rgb` is the universal source for the broker-side conversion
|
|
3596
|
-
* cache: gray is one luminance pass, jpeg is one sharp encode,
|
|
3597
|
-
* bgr is a channel swap. Producing JPEG decoder-side and then
|
|
3598
|
-
* *decoding back* to feed a raw consumer would cost more than
|
|
3599
|
-
* both consumers combined.
|
|
3600
|
-
* 3. Stay stable when there are no subscribers — defaults to
|
|
3601
|
-
* `jpeg` so the legacy "no subscriber yet" code path keeps
|
|
3602
|
-
* behaving as it did pre-refactor.
|
|
3603
|
-
*/
|
|
3604
|
-
resolveDecoderFormat() {
|
|
3605
|
-
if (this.decodedSubscribers.size === 0) return "jpeg";
|
|
3606
|
-
let allGray = true;
|
|
3607
|
-
let allJpeg = true;
|
|
3608
|
-
for (const sub of this.decodedSubscribers.values()) {
|
|
3609
|
-
if (sub.format !== "gray") allGray = false;
|
|
3610
|
-
if (sub.format !== "jpeg") allJpeg = false;
|
|
3611
|
-
}
|
|
3612
|
-
if (allGray) return "gray";
|
|
3613
|
-
if (allJpeg) return "jpeg";
|
|
3614
|
-
return "rgb";
|
|
3615
|
-
}
|
|
3616
|
-
/**
|
|
3617
|
-
* Check if the decoder's output format needs upgrading because a new
|
|
3618
|
-
* subscriber requires a richer format (e.g. gray → jpeg when detection
|
|
3619
|
-
* joins after motion). Calls updateConfig on the decoder proxy to
|
|
3620
|
-
* reinitialize the scaler without recreating the session.
|
|
3621
|
-
*/
|
|
3622
|
-
maybeUpgradeDecoderFormat() {
|
|
3623
|
-
if (!this.decoderProxy || !this.decoderOutputFormat) return;
|
|
3624
|
-
const needed = this.resolveDecoderFormat();
|
|
3625
|
-
if (needed === this.decoderOutputFormat) return;
|
|
3626
|
-
this.logger?.info("upgrading decoder output format", {
|
|
3627
|
-
meta: { from: this.decoderOutputFormat, to: needed }
|
|
3628
|
-
});
|
|
3629
|
-
this.decoderOutputFormat = needed;
|
|
3630
|
-
this.decoderProxy.updateConfig({ outputFormat: needed }).catch((err) => {
|
|
3631
|
-
this.logger?.warn("decoder format upgrade failed", { meta: { error: errMsg(err) } });
|
|
3632
|
-
});
|
|
3633
|
-
}
|
|
3634
|
-
/**
|
|
3635
|
-
* Fan a single decoded frame out to every active subscriber. Each
|
|
3636
|
-
* subscriber may have requested a different `format` than the one the
|
|
3637
|
-
* decoder is currently producing — the per-frame `convertedCache` map
|
|
3638
|
-
* memoises any format the decoder didn't already supply, so multiple
|
|
3639
|
-
* subscribers asking for the same target share the conversion.
|
|
3640
|
-
*
|
|
3641
|
-
* Conversion paths covered today:
|
|
3642
|
-
* - rgb → jpeg (sharp encode)
|
|
3643
|
-
* - rgb → bgr (R/B channel swap, in-process)
|
|
3644
|
-
* - rgb → gray (BT.601 luminance)
|
|
3645
|
-
* - rgb → yuv420 (sharp toFormat)
|
|
3646
|
-
* - gray → jpeg (sharp encode 1ch)
|
|
3647
|
-
* - jpeg → rgb / bgr / gray / yuv420 — not supported in Phase 3.
|
|
3648
|
-
* Decoder picks `jpeg` as canonical only when ALL subscribers
|
|
3649
|
-
* want jpeg, so this branch should never trigger. Such a request
|
|
3650
|
-
* is logged and the subscriber receives the JPEG passthrough as a
|
|
3651
|
-
* graceful fallback (caller is expected to re-decode).
|
|
3652
|
-
*/
|
|
3653
|
-
fanoutDecodedFrame(frame) {
|
|
3654
|
-
const convertedCache = /* @__PURE__ */ new Map();
|
|
3655
|
-
convertedCache.set(frame.format, frame);
|
|
3656
|
-
for (const subscriber of this.decodedSubscribers.values()) {
|
|
3657
|
-
if (!subscriber.frameDropper.shouldKeep()) {
|
|
3658
|
-
subscriber.framesDropped++;
|
|
3659
|
-
continue;
|
|
3660
|
-
}
|
|
3661
|
-
this.deliverConvertedFrame(subscriber, frame, convertedCache);
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
deliverConvertedFrame(subscriber, frame, cache2) {
|
|
3665
|
-
if (subscriber.format === frame.format) {
|
|
3666
|
-
subscriber.callback(frame);
|
|
3667
|
-
subscriber.framesDelivered++;
|
|
3668
|
-
return;
|
|
3669
|
-
}
|
|
3670
|
-
const cached = cache2.get(subscriber.format);
|
|
3671
|
-
if (cached && !(cached instanceof Promise)) {
|
|
3672
|
-
subscriber.callback(cached);
|
|
3673
|
-
subscriber.framesDelivered++;
|
|
3674
|
-
return;
|
|
3675
|
-
}
|
|
3676
|
-
if (cached instanceof Promise) {
|
|
3677
|
-
cached.then((converted) => {
|
|
3678
|
-
subscriber.callback(converted);
|
|
3679
|
-
subscriber.framesDelivered++;
|
|
3680
|
-
}).catch((err) => {
|
|
3681
|
-
subscriber.framesDropped++;
|
|
3682
|
-
this.logger?.warn("frame conversion failed", {
|
|
3683
|
-
meta: { tag: subscriber.tag, target: subscriber.format, error: errMsg(err) }
|
|
3684
|
-
});
|
|
3685
|
-
});
|
|
3686
|
-
return;
|
|
3687
|
-
}
|
|
3688
|
-
const conversion = this.convertFrame(frame, subscriber.format);
|
|
3689
|
-
cache2.set(subscriber.format, conversion);
|
|
3690
|
-
conversion.then((converted) => {
|
|
3691
|
-
cache2.set(subscriber.format, converted);
|
|
3692
|
-
subscriber.callback(converted);
|
|
3693
|
-
subscriber.framesDelivered++;
|
|
3694
|
-
}).catch((err) => {
|
|
3695
|
-
subscriber.framesDropped++;
|
|
3696
|
-
this.logger?.warn("frame conversion failed", {
|
|
3697
|
-
meta: { tag: subscriber.tag, target: subscriber.format, error: errMsg(err) }
|
|
3698
|
-
});
|
|
3699
|
-
});
|
|
3700
|
-
}
|
|
3701
|
-
async convertFrame(source, target) {
|
|
3702
|
-
if (source.format === target) return source;
|
|
3703
|
-
const sharp = await getSharp();
|
|
3704
|
-
if (source.format === "rgb" || source.format === "gray") {
|
|
3705
|
-
const channels = source.format === "gray" ? 1 : 3;
|
|
3706
|
-
const raw = { width: source.width, height: source.height, channels };
|
|
3707
|
-
const pipeline = sharp(source.data, { raw });
|
|
3708
|
-
if (target === "jpeg") {
|
|
3709
|
-
const data = await pipeline.jpeg({ quality: 80, mozjpeg: false }).toBuffer();
|
|
3710
|
-
return { ...source, data, format: "jpeg" };
|
|
3711
|
-
}
|
|
3712
|
-
if (target === "bgr") {
|
|
3713
|
-
if (source.format !== "rgb") {
|
|
3714
|
-
throw new Error(`bgr conversion requires rgb source, got ${source.format}`);
|
|
3715
|
-
}
|
|
3716
|
-
const data = swapRedBlue(source.data);
|
|
3717
|
-
return { ...source, data, format: "bgr" };
|
|
3718
|
-
}
|
|
3719
|
-
if (target === "gray") {
|
|
3720
|
-
const data = await pipeline.toColorspace("b-w").raw().toBuffer();
|
|
3721
|
-
return { ...source, data, format: "gray" };
|
|
3722
|
-
}
|
|
3723
|
-
if (target === "rgb") {
|
|
3724
|
-
const data = await pipeline.toColorspace("srgb").raw().toBuffer();
|
|
3725
|
-
return { ...source, data, format: "rgb" };
|
|
3726
|
-
}
|
|
3727
|
-
if (target === "yuv420") {
|
|
3728
|
-
const data = await pipeline.toColorspace("yuv").raw().toBuffer();
|
|
3729
|
-
return { ...source, data, format: "yuv420" };
|
|
3730
|
-
}
|
|
3731
|
-
}
|
|
3732
|
-
if (source.format === "jpeg") {
|
|
3733
|
-
this.logger?.warn("jpeg-source frame requested as non-jpeg — passthrough", {
|
|
3734
|
-
meta: { target }
|
|
3735
|
-
});
|
|
3736
|
-
return source;
|
|
3737
|
-
}
|
|
3738
|
-
throw new Error(`unsupported conversion ${source.format} → ${target}`);
|
|
3739
|
-
}
|
|
3740
4007
|
checkDemand() {
|
|
3741
4008
|
if (this.manualStop || this.stopping) return;
|
|
3742
4009
|
if (this.hasDemand()) {
|
|
@@ -3922,10 +4189,6 @@ class StreamBroker {
|
|
|
3922
4189
|
if (!this.firstKeyframeReceived && packet.keyframe) {
|
|
3923
4190
|
this.firstKeyframeReceived = true;
|
|
3924
4191
|
this.logger?.info("First keyframe received — decoder can start");
|
|
3925
|
-
if (this.pendingPullModeOpen) {
|
|
3926
|
-
this.pendingPullModeOpen();
|
|
3927
|
-
this.pendingPullModeOpen = null;
|
|
3928
|
-
}
|
|
3929
4192
|
}
|
|
3930
4193
|
}
|
|
3931
4194
|
for (const restreamer of this.restreamers) {
|
|
@@ -3933,22 +4196,7 @@ class StreamBroker {
|
|
|
3933
4196
|
restreamer.pushPacket(streamId, packet);
|
|
3934
4197
|
}
|
|
3935
4198
|
if (packet.type === "video" && this.firstKeyframeReceived) {
|
|
3936
|
-
|
|
3937
|
-
this.decoderPushCount++;
|
|
3938
|
-
if (this.decoderPushCount === 1 || this.decoderPushCount % 500 === 0) {
|
|
3939
|
-
this.logger?.info("decoder push", {
|
|
3940
|
-
meta: {
|
|
3941
|
-
pushCount: this.decoderPushCount,
|
|
3942
|
-
size: packet.data.length,
|
|
3943
|
-
keyframe: packet.keyframe,
|
|
3944
|
-
decodedCount: this.decoderFrameCount
|
|
3945
|
-
}
|
|
3946
|
-
});
|
|
3947
|
-
}
|
|
3948
|
-
this.decoderProxy.pushPacket(packet).catch((err) => {
|
|
3949
|
-
this.logger?.warn("decoder push error", { meta: { error: errMsg(err) } });
|
|
3950
|
-
});
|
|
3951
|
-
}
|
|
4199
|
+
this.frameHandlePlane?.pushPacket(packet);
|
|
3952
4200
|
}
|
|
3953
4201
|
}
|
|
3954
4202
|
onEncodedData(callback) {
|
|
@@ -4120,89 +4368,107 @@ class StreamBroker {
|
|
|
4120
4368
|
setStreamingDebug(enabled) {
|
|
4121
4369
|
this.streamingDebug = enabled;
|
|
4122
4370
|
}
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4371
|
+
// ── Decoded audio-chunk plane (Phase 5 / D9) ─────────────────────────
|
|
4372
|
+
/**
|
|
4373
|
+
* Open a decoded audio-chunk subscription — the poll-based, tRPC-reachable
|
|
4374
|
+
* replacement for the live-object `onDecodedAudioChunk` callback. The
|
|
4375
|
+
* broker registers a per-subscription FIFO queue and returns a
|
|
4376
|
+
* `subscriptionId` the consumer drains via `pullAudioChunks`.
|
|
4377
|
+
*/
|
|
4378
|
+
subscribeAudioChunks(options) {
|
|
4127
4379
|
const tag = options?.tag;
|
|
4128
4380
|
if (!tag) {
|
|
4129
|
-
this.logger?.warn(`
|
|
4381
|
+
this.logger?.warn(`subscribeAudioChunks called without tag — listClients will show 'unknown'`);
|
|
4130
4382
|
}
|
|
4131
|
-
const
|
|
4132
|
-
callback,
|
|
4133
|
-
frameDropper,
|
|
4134
|
-
tag: tag ?? "unknown",
|
|
4135
|
-
maxFps,
|
|
4136
|
-
format: options?.format ?? "jpeg",
|
|
4137
|
-
subscribedAt: Date.now(),
|
|
4138
|
-
framesDelivered: 0,
|
|
4139
|
-
framesDropped: 0
|
|
4140
|
-
};
|
|
4141
|
-
this.decodedSubscribers.set(subscriberId, subscriber);
|
|
4142
|
-
this.logger?.info("decoded subscriber added", {
|
|
4143
|
-
meta: { tag: subscriber.tag, total: this.decodedSubscribers.size, maxFps, format: subscriber.format }
|
|
4144
|
-
});
|
|
4383
|
+
const result = this.audioChunkPlane.subscribe({ tag });
|
|
4145
4384
|
this.checkDemand();
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
}
|
|
4385
|
+
return result;
|
|
4386
|
+
}
|
|
4387
|
+
/** Drain up to `maxCount` `DecodedAudioChunk`s for an audio-chunk subscription. */
|
|
4388
|
+
pullAudioChunks(subscriptionId, maxCount) {
|
|
4389
|
+
return this.audioChunkPlane.pull(subscriptionId, maxCount);
|
|
4390
|
+
}
|
|
4391
|
+
/** Release an audio-chunk subscription. Returns `true` when it existed. */
|
|
4392
|
+
unsubscribeAudioChunks(subscriptionId) {
|
|
4393
|
+
const released = this.audioChunkPlane.unsubscribe(subscriptionId);
|
|
4394
|
+
if (released) this.checkDemand();
|
|
4395
|
+
return released;
|
|
4396
|
+
}
|
|
4397
|
+
// ── Shared-memory frame-handle plane (Phase 5 / D9) ──────────────────
|
|
4398
|
+
/**
|
|
4399
|
+
* Resolve the stream descriptor the `FrameHandlePlane` needs to create a
|
|
4400
|
+
* `frameSink: 'shm'` decoder session — codec, numeric device id, log tag,
|
|
4401
|
+
* and (for a pull-mode decoder) the local pipe URL. Returns `null` before
|
|
4402
|
+
* the broker has a source.
|
|
4403
|
+
*/
|
|
4404
|
+
resolveFrameHandleStreamInfo() {
|
|
4405
|
+
const codec = this.detectedCodec ?? this.source?.videoCodec;
|
|
4406
|
+
if (!codec || !this.source) return null;
|
|
4407
|
+
const slash = this.deviceId.indexOf("/");
|
|
4408
|
+
const numericDeviceId = slash >= 0 ? Number.parseInt(this.deviceId.slice(0, slash), 10) : Number.parseInt(this.deviceId, 10);
|
|
4409
|
+
return {
|
|
4410
|
+
codec,
|
|
4411
|
+
...Number.isFinite(numericDeviceId) ? { numericDeviceId } : {},
|
|
4412
|
+
tag: `broker:${this.deviceId}`,
|
|
4413
|
+
// push-mode is the node-av default today; pull-mode URL wiring is deferred to a later Phase-5 task.
|
|
4414
|
+
pullModeUrl: null
|
|
4177
4415
|
};
|
|
4178
4416
|
}
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
if (
|
|
4182
|
-
|
|
4417
|
+
/** Lazily construct the frame-handle plane, returning `null` with no decoder. */
|
|
4418
|
+
ensureFrameHandlePlane() {
|
|
4419
|
+
if (this.frameHandlePlane) return this.frameHandlePlane;
|
|
4420
|
+
if (!this.decoderApi) {
|
|
4421
|
+
this.logger?.warn("subscribeFrameHandles: no decoder API — frame handles unavailable");
|
|
4422
|
+
return null;
|
|
4423
|
+
}
|
|
4424
|
+
this.frameHandlePlane = new FrameHandlePlane({
|
|
4425
|
+
decoderApi: this.decoderApi,
|
|
4426
|
+
logger: this.logger?.child("frame-handle-plane"),
|
|
4427
|
+
resolveStreamInfo: () => this.resolveFrameHandleStreamInfo()
|
|
4428
|
+
});
|
|
4429
|
+
return this.frameHandlePlane;
|
|
4430
|
+
}
|
|
4431
|
+
/**
|
|
4432
|
+
* Subscribe to this broker's shared-memory frame-handle stream — the broker's
|
|
4433
|
+
* decoded-frame surface. The broker spins up (or reuses) a `frameSink: 'shm'`
|
|
4434
|
+
* decoder session producing `input.format` and returns a `subscriptionId`
|
|
4435
|
+
* the consumer polls via `pullFrameHandles`. fps throttling is implicit
|
|
4436
|
+
* (latest-wins ring reads); `input.maxFps` is echoed as a cadence hint.
|
|
4437
|
+
*/
|
|
4438
|
+
async subscribeFrameHandles(input) {
|
|
4439
|
+
const plane = this.ensureFrameHandlePlane();
|
|
4440
|
+
if (!plane) {
|
|
4441
|
+
throw new Error("stream-broker: no decoder available for frame-handle subscription");
|
|
4183
4442
|
}
|
|
4184
|
-
const subscriberId = Symbol("audio-subscriber");
|
|
4185
|
-
const subscriber = {
|
|
4186
|
-
callback,
|
|
4187
|
-
tag: tag ?? "unknown",
|
|
4188
|
-
subscribedAt: Date.now(),
|
|
4189
|
-
chunksDelivered: 0
|
|
4190
|
-
};
|
|
4191
|
-
this.audioSubscribers.set(subscriberId, subscriber);
|
|
4192
4443
|
this.checkDemand();
|
|
4193
|
-
|
|
4194
|
-
|
|
4444
|
+
const { subscriptionId, maxFps } = await plane.subscribe({
|
|
4445
|
+
format: input.format,
|
|
4446
|
+
maxFps: input.maxFps,
|
|
4447
|
+
tag: input.tag
|
|
4448
|
+
});
|
|
4449
|
+
return { subscriptionId, maxFps };
|
|
4450
|
+
}
|
|
4451
|
+
/** Drain up to `maxCount` `FrameHandle`s for a frame-handle subscription. */
|
|
4452
|
+
pullFrameHandles(subscriptionId, maxCount) {
|
|
4453
|
+
return this.frameHandlePlane?.pullHandles(subscriptionId, maxCount) ?? [];
|
|
4454
|
+
}
|
|
4455
|
+
/** Release a frame-handle subscription. Returns `true` when it existed. */
|
|
4456
|
+
async unsubscribeFrameHandles(subscriptionId) {
|
|
4457
|
+
const plane = this.frameHandlePlane;
|
|
4458
|
+
if (!plane) return false;
|
|
4459
|
+
const released = await plane.unsubscribe(subscriptionId);
|
|
4460
|
+
if (released && plane.subscriberCount === 0) {
|
|
4195
4461
|
this.checkDemand();
|
|
4196
|
-
}
|
|
4462
|
+
}
|
|
4463
|
+
return released;
|
|
4197
4464
|
}
|
|
4198
4465
|
getStats() {
|
|
4199
|
-
const decoderStats = this._suspended ? void 0 : this.cachedDecoderStats;
|
|
4200
4466
|
return {
|
|
4201
4467
|
status: this._suspended ? "idle" : this._status,
|
|
4202
|
-
inputFps:
|
|
4203
|
-
decodeFps:
|
|
4468
|
+
inputFps: this.encodedInputFps,
|
|
4469
|
+
decodeFps: this.frameHandlePlane?.decodeFps() ?? 0,
|
|
4204
4470
|
encodedSubscribers: this.encodedCallbacks.size,
|
|
4205
|
-
decodedSubscribers: this.
|
|
4471
|
+
decodedSubscribers: this.frameHandlePlane?.subscriberCount ?? 0,
|
|
4206
4472
|
uptimeMs: Date.now() - this.startedAt,
|
|
4207
4473
|
bitrateKbps: this.bitrateKbps,
|
|
4208
4474
|
idrIntervalMs: this.idrIntervalMs,
|
|
@@ -4214,7 +4480,7 @@ class StreamBroker {
|
|
|
4214
4480
|
preBufferSec: this.preBuffer.getDuration(),
|
|
4215
4481
|
preBufferMs: this.preBuffer.getBufferedDurationMs(),
|
|
4216
4482
|
preBufferPackets: this.preBuffer.getPacketCount(),
|
|
4217
|
-
decoderNodeId: this.
|
|
4483
|
+
decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
|
|
4218
4484
|
audio: this.audioTrackInfo ?? null
|
|
4219
4485
|
};
|
|
4220
4486
|
}
|
|
@@ -4228,14 +4494,17 @@ class StreamBroker {
|
|
|
4228
4494
|
listClients() {
|
|
4229
4495
|
return {
|
|
4230
4496
|
rtsp: this.rtspRestreamer.listSessionInfos(),
|
|
4231
|
-
|
|
4497
|
+
// Phase 5 / D9: the `decoded` channel is the shm frame-handle plane's
|
|
4498
|
+
// subscriber set. `framesDropped` is always 0 — a slow consumer's
|
|
4499
|
+
// dropped frames are recycled silently at the ring, not counted here.
|
|
4500
|
+
decoded: (this.frameHandlePlane?.listSubscribers() ?? []).map((s) => ({
|
|
4232
4501
|
tag: s.tag,
|
|
4233
4502
|
subscribedAt: s.subscribedAt,
|
|
4234
4503
|
maxFps: s.maxFps,
|
|
4235
4504
|
framesDelivered: s.framesDelivered,
|
|
4236
|
-
framesDropped:
|
|
4505
|
+
framesDropped: 0
|
|
4237
4506
|
})),
|
|
4238
|
-
audio:
|
|
4507
|
+
audio: this.audioChunkPlane.listSubscribers().map((s) => ({
|
|
4239
4508
|
tag: s.tag,
|
|
4240
4509
|
subscribedAt: s.subscribedAt,
|
|
4241
4510
|
chunksDelivered: s.chunksDelivered
|
|
@@ -4247,24 +4516,38 @@ class StreamBroker {
|
|
|
4247
4516
|
/**
|
|
4248
4517
|
* Force-disconnect a single consumer. Matches by channel:
|
|
4249
4518
|
* - `rtsp`: by `sessionId` (calls `RtspRestreamer.killSession`).
|
|
4250
|
-
* - `decoded
|
|
4251
|
-
* matches
|
|
4252
|
-
*
|
|
4519
|
+
* - `decoded`: by `tag` — releases every shm frame-handle subscription
|
|
4520
|
+
* whose tag matches (the plane winds the decoder session down once its
|
|
4521
|
+
* last subscriber leaves).
|
|
4522
|
+
* - `audio`: by `tag` — drops every audio subscriber whose tag matches,
|
|
4523
|
+
* then re-evaluates demand.
|
|
4253
4524
|
* Returns `true` when at least one consumer was dropped.
|
|
4254
4525
|
*/
|
|
4255
4526
|
killClient(channel, handle) {
|
|
4256
4527
|
if (channel === "rtsp") {
|
|
4257
4528
|
return this.rtspRestreamer.killSession(handle);
|
|
4258
4529
|
}
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4530
|
+
if (channel === "decoded") {
|
|
4531
|
+
const plane = this.frameHandlePlane;
|
|
4532
|
+
if (!plane) return false;
|
|
4533
|
+
const matched = plane.listSubscribers().some((s) => s.tag === handle);
|
|
4534
|
+
if (!matched) return false;
|
|
4535
|
+
plane.killByTag(handle).then((killed2) => {
|
|
4536
|
+
this.logger?.info("frame-handle subscribers force-disconnected", {
|
|
4537
|
+
meta: { handle, killed: killed2 }
|
|
4538
|
+
});
|
|
4539
|
+
this.checkDemand();
|
|
4540
|
+
}).catch((err) => {
|
|
4541
|
+
this.logger?.warn("frame-handle killByTag failed", {
|
|
4542
|
+
meta: { handle, error: errMsg(err) }
|
|
4543
|
+
});
|
|
4544
|
+
});
|
|
4545
|
+
return true;
|
|
4263
4546
|
}
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
this.logger?.info("subscribers force-disconnected", {
|
|
4267
|
-
meta: {
|
|
4547
|
+
const killed = this.audioChunkPlane.killByTag(handle);
|
|
4548
|
+
if (killed === 0) return false;
|
|
4549
|
+
this.logger?.info("audio subscribers force-disconnected", {
|
|
4550
|
+
meta: { handle, killed, remaining: this.audioChunkPlane.subscriberCount }
|
|
4268
4551
|
});
|
|
4269
4552
|
this.checkDemand();
|
|
4270
4553
|
return true;
|
|
@@ -4412,13 +4695,6 @@ class StreamBroker {
|
|
|
4412
4695
|
});
|
|
4413
4696
|
this.armFirstVideoWatchdog();
|
|
4414
4697
|
this.rtspRestreamer.setCameraSdp(sdpText, track.codec, track.codecParams);
|
|
4415
|
-
if (!this.decoderProxy && this.decodedSubscribers.size > 0 && this.pendingDecodeOptions !== void 0) {
|
|
4416
|
-
this.logger?.info("native: codec detected — creating deferred decoder", {
|
|
4417
|
-
meta: { codec: track.codec }
|
|
4418
|
-
});
|
|
4419
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions);
|
|
4420
|
-
this.pendingDecodeOptions = void 0;
|
|
4421
|
-
}
|
|
4422
4698
|
},
|
|
4423
4699
|
onVideoRtp: (rtpData) => {
|
|
4424
4700
|
if (this.stopping) return;
|
|
@@ -4473,10 +4749,7 @@ class StreamBroker {
|
|
|
4473
4749
|
let effectiveChannels = audioTrack.channels;
|
|
4474
4750
|
if (supportedNative) {
|
|
4475
4751
|
this.audioRtpDecoder = new AudioRtpDecoder(codecUpper, (chunk) => {
|
|
4476
|
-
|
|
4477
|
-
sub.callback(chunk);
|
|
4478
|
-
sub.chunksDelivered++;
|
|
4479
|
-
}
|
|
4752
|
+
this.audioChunkPlane.fanout(chunk);
|
|
4480
4753
|
});
|
|
4481
4754
|
this.logger?.info("native: audio decoder initialized", {
|
|
4482
4755
|
meta: { codec: codecUpper, sampleRate: audioTrack.clockRate, channels: audioTrack.channels }
|
|
@@ -4889,90 +5162,28 @@ class StreamBroker {
|
|
|
4889
5162
|
this._status = "error";
|
|
4890
5163
|
}, PUSH_STALL_TIMEOUT_MS);
|
|
4891
5164
|
}
|
|
4892
|
-
destroyDecoder() {
|
|
4893
|
-
if (this.decoderResolveRetryTimer) {
|
|
4894
|
-
clearTimeout(this.decoderResolveRetryTimer);
|
|
4895
|
-
this.decoderResolveRetryTimer = void 0;
|
|
4896
|
-
}
|
|
4897
|
-
this.decoderResolveAttempts = 0;
|
|
4898
|
-
const proxy = this.decoderProxy;
|
|
4899
|
-
this.decoderProxy = null;
|
|
4900
|
-
this.decoderNodeId = null;
|
|
4901
|
-
this.cachedDecoderStats = void 0;
|
|
4902
|
-
return proxy?.destroy() ?? Promise.resolve();
|
|
4903
|
-
}
|
|
4904
5165
|
/**
|
|
4905
|
-
* Moleculer nodeID of
|
|
4906
|
-
*
|
|
4907
|
-
* broker-manager's `rotateDecodersOnNode`
|
|
4908
|
-
*
|
|
5166
|
+
* Moleculer nodeID of a decoder provider hosting one of this broker's shm
|
|
5167
|
+
* frame-plane sessions. `null` when the plane has no session. Used by the
|
|
5168
|
+
* broker-manager's `rotateDecodersOnNode` to drop only the sessions
|
|
5169
|
+
* affected by a per-agent event (e.g. an hwaccel override change).
|
|
4909
5170
|
*/
|
|
4910
5171
|
getDecoderNodeId() {
|
|
4911
|
-
return this.
|
|
4912
|
-
}
|
|
4913
|
-
/**
|
|
4914
|
-
* Force the current shared decoder session to tear down. The next
|
|
4915
|
-
* subscriber will trigger a fresh `createSharedDecoderSession` which
|
|
4916
|
-
* re-pulls the latest per-agent preferences (hwaccel backend, …). Safe
|
|
4917
|
-
* to call when there's no active session — resolves to a no-op.
|
|
4918
|
-
*/
|
|
4919
|
-
async rotateDecoderSession(reason) {
|
|
4920
|
-
if (!this.decoderProxy) return;
|
|
4921
|
-
this.logger?.info("rotating decoder session", { meta: { reason, decoderNodeId: this.decoderNodeId } });
|
|
4922
|
-
await this.destroyDecoder();
|
|
4923
|
-
if (this.decodedSubscribers.size > 0 && this.source) {
|
|
4924
|
-
const codec = this.detectedCodec ?? this.source.videoCodec;
|
|
4925
|
-
if (codec) {
|
|
4926
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions).catch((err) => {
|
|
4927
|
-
this.logger?.warn("decoder session rebuild after rotation failed", { meta: { error: errMsg(err) } });
|
|
4928
|
-
});
|
|
4929
|
-
}
|
|
4930
|
-
}
|
|
5172
|
+
return this.frameHandlePlane?.decoderNodeIds()[0] ?? null;
|
|
4931
5173
|
}
|
|
4932
5174
|
/**
|
|
4933
|
-
*
|
|
4934
|
-
*
|
|
4935
|
-
*
|
|
4936
|
-
*
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
* with noisy errors.
|
|
4946
|
-
*/
|
|
4947
|
-
scheduleDecoderResolveRetry(codec) {
|
|
4948
|
-
if (this.decoderResolveRetryTimer) return;
|
|
4949
|
-
const attempt = this.decoderResolveAttempts++;
|
|
4950
|
-
const backoffMs = Math.min(500 * Math.pow(2, attempt), 8e3);
|
|
4951
|
-
const cumulativeMs = (Math.pow(2, this.decoderResolveAttempts) - 1) * 500;
|
|
4952
|
-
if (cumulativeMs > 6e4) {
|
|
4953
|
-
this.logger?.error(
|
|
4954
|
-
"No decoder provider — giving up. Check that addon-decoder-nodeav or addon-decoder-ffmpeg is installed and enabled.",
|
|
4955
|
-
{ meta: { codec, attempts: this.decoderResolveAttempts } }
|
|
4956
|
-
);
|
|
4957
|
-
this.decoderResolveAttempts = 0;
|
|
4958
|
-
return;
|
|
4959
|
-
}
|
|
4960
|
-
if (attempt === 0) {
|
|
4961
|
-
this.logger?.debug(
|
|
4962
|
-
"Decoder not yet registered — will retry (addon boot race)",
|
|
4963
|
-
{ meta: { codec } }
|
|
4964
|
-
);
|
|
4965
|
-
}
|
|
4966
|
-
this.decoderResolveRetryTimer = setTimeout(() => {
|
|
4967
|
-
this.decoderResolveRetryTimer = void 0;
|
|
4968
|
-
if (this.decoderProxy) return;
|
|
4969
|
-
if (this.decodedSubscribers.size === 0) {
|
|
4970
|
-
this.decoderResolveAttempts = 0;
|
|
4971
|
-
this.pendingDecodeOptions = void 0;
|
|
4972
|
-
return;
|
|
4973
|
-
}
|
|
4974
|
-
this.createSharedDecoderSession(this.pendingDecodeOptions);
|
|
4975
|
-
}, backoffMs);
|
|
5175
|
+
* Force this broker's shm frame-plane decoder sessions hosted on
|
|
5176
|
+
* `agentNodeId` to rotate — destroy and rebuild so they re-pull the latest
|
|
5177
|
+
* per-agent preferences (hwaccel backend, …). A no-op when the broker has
|
|
5178
|
+
* no frame plane. Subscriptions survive the rotation.
|
|
5179
|
+
*/
|
|
5180
|
+
async rotateDecoderSession(reason, agentNodeId) {
|
|
5181
|
+
const plane = this.frameHandlePlane;
|
|
5182
|
+
if (!plane) return;
|
|
5183
|
+
const target = agentNodeId ?? plane.decoderNodeIds()[0]?.split("/")[0];
|
|
5184
|
+
if (!target) return;
|
|
5185
|
+
this.logger?.info("rotating shm decoder sessions", { meta: { reason, agentNodeId: target } });
|
|
5186
|
+
await plane.rotate(target, reason);
|
|
4976
5187
|
}
|
|
4977
5188
|
destroyNativeClient() {
|
|
4978
5189
|
if (this.nativeClient) {
|
|
@@ -5059,78 +5270,6 @@ class StreamBroker {
|
|
|
5059
5270
|
});
|
|
5060
5271
|
}
|
|
5061
5272
|
}
|
|
5062
|
-
async createSharedDecoderSession(options) {
|
|
5063
|
-
const codec = this.detectedCodec ?? this.source?.videoCodec ?? "h264";
|
|
5064
|
-
if (!this.decoderApi) {
|
|
5065
|
-
this.logger?.warn("no decoder API available — decoded frames will not be produced");
|
|
5066
|
-
return;
|
|
5067
|
-
}
|
|
5068
|
-
const supported = await this.decoderApi.supportsCodec({ codec });
|
|
5069
|
-
if (!supported) {
|
|
5070
|
-
this.pendingDecodeOptions = options;
|
|
5071
|
-
this.scheduleDecoderResolveRetry(codec);
|
|
5072
|
-
return;
|
|
5073
|
-
}
|
|
5074
|
-
if (this.decoderResolveRetryTimer) {
|
|
5075
|
-
clearTimeout(this.decoderResolveRetryTimer);
|
|
5076
|
-
this.decoderResolveRetryTimer = void 0;
|
|
5077
|
-
}
|
|
5078
|
-
this.decoderResolveAttempts = 0;
|
|
5079
|
-
const resolvedFormat = this.resolveDecoderFormat();
|
|
5080
|
-
const slash = this.deviceId.indexOf("/");
|
|
5081
|
-
const numericDeviceId = slash >= 0 ? Number.parseInt(this.deviceId.slice(0, slash), 10) : Number.parseInt(this.deviceId, 10);
|
|
5082
|
-
const config = {
|
|
5083
|
-
codec,
|
|
5084
|
-
maxFps: 0,
|
|
5085
|
-
outputFormat: resolvedFormat,
|
|
5086
|
-
scale: options?.scale ?? DEFAULT_SCALE,
|
|
5087
|
-
...Number.isFinite(numericDeviceId) ? { deviceId: numericDeviceId } : {},
|
|
5088
|
-
tag: `broker:${this.deviceId}`
|
|
5089
|
-
};
|
|
5090
|
-
this.decoderOutputFormat = resolvedFormat;
|
|
5091
|
-
const { sessionId, nodeId } = await this.decoderApi.createSession(config);
|
|
5092
|
-
const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
|
|
5093
|
-
this.decoderProxy = proxy;
|
|
5094
|
-
this.decoderNodeId = nodeId;
|
|
5095
|
-
const onFrame = (frame) => {
|
|
5096
|
-
this.decoderFrameCount++;
|
|
5097
|
-
if (this.decoderFrameCount === 1) {
|
|
5098
|
-
this.logger?.info("first decoded frame", {
|
|
5099
|
-
meta: { width: frame.width, height: frame.height, format: frame.format }
|
|
5100
|
-
});
|
|
5101
|
-
}
|
|
5102
|
-
if (this.decoderFrameCount % 30 === 0) {
|
|
5103
|
-
proxy.getStats().then((stats) => {
|
|
5104
|
-
this.cachedDecoderStats = stats;
|
|
5105
|
-
}).catch(() => {
|
|
5106
|
-
});
|
|
5107
|
-
}
|
|
5108
|
-
this.fanoutDecodedFrame(frame);
|
|
5109
|
-
};
|
|
5110
|
-
proxy.startPolling(onFrame).catch((err) => {
|
|
5111
|
-
this.logger?.warn("decoder polling error", { meta: { error: errMsg(err) } });
|
|
5112
|
-
});
|
|
5113
|
-
const info = await this.decoderApi.getInfo();
|
|
5114
|
-
if (info.isPullMode) {
|
|
5115
|
-
const localUrl = this.pipeServer.getUrl();
|
|
5116
|
-
const isHevc = codec === "h265" || codec === "hevc";
|
|
5117
|
-
const inputUrl = `${localUrl}?format=${isHevc ? "hevc" : "h264"}`;
|
|
5118
|
-
const doOpen = () => {
|
|
5119
|
-
this.logger?.info("Pull mode decoder: opening local pipe", {
|
|
5120
|
-
meta: { localUrl, codec }
|
|
5121
|
-
});
|
|
5122
|
-
proxy.openStream(inputUrl).catch((err) => {
|
|
5123
|
-
this.logger?.error("Pull mode decoder failed", { meta: { error: errMsg(err) } });
|
|
5124
|
-
});
|
|
5125
|
-
};
|
|
5126
|
-
if (this.firstKeyframeReceived) {
|
|
5127
|
-
doOpen();
|
|
5128
|
-
} else {
|
|
5129
|
-
this.logger?.info("Pull mode decoder: waiting for first keyframe before opening");
|
|
5130
|
-
this.pendingPullModeOpen = doOpen;
|
|
5131
|
-
}
|
|
5132
|
-
}
|
|
5133
|
-
}
|
|
5134
5273
|
/**
|
|
5135
5274
|
* Map an SDP audio track to an `AudioCodecSession` config the
|
|
5136
5275
|
* audio-codec cap can consume. Returns `null` when the codec is
|
|
@@ -5145,10 +5284,7 @@ class StreamBroker {
|
|
|
5145
5284
|
*/
|
|
5146
5285
|
buildAudioCodecEmitCallback() {
|
|
5147
5286
|
return (chunk) => {
|
|
5148
|
-
|
|
5149
|
-
sub.callback(chunk);
|
|
5150
|
-
sub.chunksDelivered++;
|
|
5151
|
-
}
|
|
5287
|
+
this.audioChunkPlane.fanout(chunk);
|
|
5152
5288
|
if (this.encodedCallbacks.size > 0) {
|
|
5153
5289
|
const f32 = new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4);
|
|
5154
5290
|
const pcm16 = new Int16Array(f32.length);
|
|
@@ -5806,7 +5942,6 @@ class TranscodePipelineManager {
|
|
|
5806
5942
|
logger;
|
|
5807
5943
|
api;
|
|
5808
5944
|
cameraStreamLookup;
|
|
5809
|
-
rtspEntryLookup;
|
|
5810
5945
|
localRtspPort;
|
|
5811
5946
|
releaseGraceMs;
|
|
5812
5947
|
ffmpegPath;
|
|
@@ -5814,7 +5949,6 @@ class TranscodePipelineManager {
|
|
|
5814
5949
|
this.logger = opts.logger;
|
|
5815
5950
|
this.api = opts.api;
|
|
5816
5951
|
this.cameraStreamLookup = opts.cameraStreamLookup;
|
|
5817
|
-
this.rtspEntryLookup = opts.rtspEntryLookup;
|
|
5818
5952
|
this.localRtspPort = opts.localRtspPort;
|
|
5819
5953
|
this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
|
|
5820
5954
|
this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
@@ -6119,6 +6253,18 @@ class StreamBrokerManager {
|
|
|
6119
6253
|
*/
|
|
6120
6254
|
streamHealthByBroker = /* @__PURE__ */ new Map();
|
|
6121
6255
|
streamHealthTimer;
|
|
6256
|
+
/**
|
|
6257
|
+
* Routes a frame-handle `subscriptionId` (Phase 5 / D9) back to its owning
|
|
6258
|
+
* brokerId so `pullFrameHandles` / `unsubscribeFrames` resolve in O(1)
|
|
6259
|
+
* without scanning every broker.
|
|
6260
|
+
*/
|
|
6261
|
+
frameSubscriptionBroker = /* @__PURE__ */ new Map();
|
|
6262
|
+
/**
|
|
6263
|
+
* Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
|
|
6264
|
+
* brokerId so `pullAudioChunks` / `unsubscribeAudioChunks` resolve in O(1)
|
|
6265
|
+
* without scanning every broker. Mirrors `frameSubscriptionBroker`.
|
|
6266
|
+
*/
|
|
6267
|
+
audioSubscriptionBroker = /* @__PURE__ */ new Map();
|
|
6122
6268
|
// Legacy static restreamers list — production reads via capabilities.
|
|
6123
6269
|
restreamers = [];
|
|
6124
6270
|
staticDecoders;
|
|
@@ -6223,6 +6369,12 @@ class StreamBrokerManager {
|
|
|
6223
6369
|
if (!localApi) return [];
|
|
6224
6370
|
return localApi.pullFrames(input);
|
|
6225
6371
|
},
|
|
6372
|
+
pullHandles: async (input) => {
|
|
6373
|
+
const proxy = apiDecoder();
|
|
6374
|
+
if (proxy) return proxy.pullHandles.query(input);
|
|
6375
|
+
if (!localApi) return [];
|
|
6376
|
+
return localApi.pullHandles(input);
|
|
6377
|
+
},
|
|
6226
6378
|
destroySession: async (input) => {
|
|
6227
6379
|
const proxy = apiDecoder();
|
|
6228
6380
|
if (proxy) return proxy.destroySession.query(input);
|
|
@@ -6292,6 +6444,14 @@ class StreamBrokerManager {
|
|
|
6292
6444
|
if (!buffer) return [];
|
|
6293
6445
|
return buffer.drain(input.maxCount);
|
|
6294
6446
|
},
|
|
6447
|
+
// The in-process `staticDecoders` fallback has no shm sink — its
|
|
6448
|
+
// `IDecoderSession` exposes only `onFrame` (pixels), not
|
|
6449
|
+
// `onFrameHandle`. The shared-memory frame plane (Phase 5 / D9) runs
|
|
6450
|
+
// through the real `decoder` cap (the node-av addon, which owns the
|
|
6451
|
+
// `frameSink: 'shm'` ring writer). `pullHandles` therefore returns
|
|
6452
|
+
// nothing here — a `frameSink: 'shm'` request degrades to no frames
|
|
6453
|
+
// rather than crashing the broker.
|
|
6454
|
+
pullHandles: async () => [],
|
|
6295
6455
|
destroySession: async (input) => {
|
|
6296
6456
|
const session = sessions.get(input.sessionId);
|
|
6297
6457
|
if (session) {
|
|
@@ -6555,10 +6715,111 @@ class StreamBrokerManager {
|
|
|
6555
6715
|
if (!this.cameraStreams.has(deviceId) && !this.assignments.has(deviceId)) return [];
|
|
6556
6716
|
return this.snapshotProfileSlots(deviceId);
|
|
6557
6717
|
}
|
|
6558
|
-
// ──
|
|
6718
|
+
// ── Internal: live broker lookup (not a cap method) ─────────────────
|
|
6719
|
+
/**
|
|
6720
|
+
* Resolve the live `StreamBroker` instance for a broker id. In-process
|
|
6721
|
+
* only — a `StreamBroker` is not tRPC-serialisable, so this is reachable
|
|
6722
|
+
* solely from same-process callers (device-scoped providers, the WebRTC
|
|
6723
|
+
* server). Cross-process frame access goes through the shm `subscribeFrames`
|
|
6724
|
+
* surface; cross-process stats through `getBrokerStats` / `listClients`.
|
|
6725
|
+
*/
|
|
6559
6726
|
async getBroker(input) {
|
|
6560
6727
|
return this.brokers.get(input.brokerId) ?? null;
|
|
6561
6728
|
}
|
|
6729
|
+
// ── Cap methods: broker runtime (stats + client inventory) ──────────
|
|
6730
|
+
// ── Cap methods: shared-memory frame-handle plane (Phase 5 / D9) ─────
|
|
6731
|
+
/**
|
|
6732
|
+
* Open a `FrameHandle` subscription on a broker — the handle-based
|
|
6733
|
+
* replacement for the live-object `onDecodedFrame` callback. Routed over
|
|
6734
|
+
* tRPC, so a consumer in a different process can subscribe; the consumer
|
|
6735
|
+
* then polls `pullFrameHandles` and feeds each handle to a
|
|
6736
|
+
* `FrameRingReader`.
|
|
6737
|
+
*/
|
|
6738
|
+
async subscribeFrames(input) {
|
|
6739
|
+
const broker = this.brokers.get(input.brokerId);
|
|
6740
|
+
if (!broker) {
|
|
6741
|
+
throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
|
|
6742
|
+
}
|
|
6743
|
+
const result = await broker.subscribeFrameHandles({
|
|
6744
|
+
format: input.format,
|
|
6745
|
+
maxFps: input.maxFps,
|
|
6746
|
+
tag: input.tag
|
|
6747
|
+
});
|
|
6748
|
+
this.frameSubscriptionBroker.set(result.subscriptionId, input.brokerId);
|
|
6749
|
+
return result;
|
|
6750
|
+
}
|
|
6751
|
+
/** Drain `FrameHandle`s for a subscription opened via `subscribeFrames`. */
|
|
6752
|
+
async pullFrameHandles(input) {
|
|
6753
|
+
const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
|
|
6754
|
+
if (!brokerId) return [];
|
|
6755
|
+
const broker = this.brokers.get(brokerId);
|
|
6756
|
+
if (!broker) return [];
|
|
6757
|
+
return broker.pullFrameHandles(input.subscriptionId, input.maxCount);
|
|
6758
|
+
}
|
|
6759
|
+
/**
|
|
6760
|
+
* Drop every `frameSubscriptionBroker` + `audioSubscriptionBroker` entry
|
|
6761
|
+
* pointing at `brokerId`. Called on broker teardown so a destroyed broker
|
|
6762
|
+
* leaves no dangling `subscriptionId → brokerId` rows (a slow consumer that
|
|
6763
|
+
* never calls `unsubscribeFrames` / `unsubscribeAudioChunks` would otherwise
|
|
6764
|
+
* leak a map entry per stream).
|
|
6765
|
+
*/
|
|
6766
|
+
sweepFrameSubscriptions(brokerId) {
|
|
6767
|
+
for (const [subscriptionId, mappedBrokerId] of this.frameSubscriptionBroker) {
|
|
6768
|
+
if (mappedBrokerId === brokerId) {
|
|
6769
|
+
this.frameSubscriptionBroker.delete(subscriptionId);
|
|
6770
|
+
}
|
|
6771
|
+
}
|
|
6772
|
+
for (const [subscriptionId, mappedBrokerId] of this.audioSubscriptionBroker) {
|
|
6773
|
+
if (mappedBrokerId === brokerId) {
|
|
6774
|
+
this.audioSubscriptionBroker.delete(subscriptionId);
|
|
6775
|
+
}
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
/** Release a frame-handle subscription. */
|
|
6779
|
+
async unsubscribeFrames(input) {
|
|
6780
|
+
const brokerId = this.frameSubscriptionBroker.get(input.subscriptionId);
|
|
6781
|
+
if (!brokerId) return { released: false };
|
|
6782
|
+
this.frameSubscriptionBroker.delete(input.subscriptionId);
|
|
6783
|
+
const broker = this.brokers.get(brokerId);
|
|
6784
|
+
if (!broker) return { released: false };
|
|
6785
|
+
const released = await broker.unsubscribeFrameHandles(input.subscriptionId);
|
|
6786
|
+
return { released };
|
|
6787
|
+
}
|
|
6788
|
+
// ── Cap methods: decoded audio-chunk plane (Phase 5 / D9) ────────────
|
|
6789
|
+
/**
|
|
6790
|
+
* Open a decoded audio-chunk subscription on a broker — the poll-based,
|
|
6791
|
+
* tRPC-reachable replacement for the live-object `onDecodedAudioChunk`
|
|
6792
|
+
* callback. The consumer then polls `pullAudioChunks` and feeds each
|
|
6793
|
+
* `DecodedAudioChunk` to its downstream audio logic. Audio chunks are tiny
|
|
6794
|
+
* so their bytes travel inline on the RPC wire — no shared memory.
|
|
6795
|
+
*/
|
|
6796
|
+
async subscribeAudioChunks(input) {
|
|
6797
|
+
const broker = this.brokers.get(input.brokerId);
|
|
6798
|
+
if (!broker) {
|
|
6799
|
+
throw new Error(`stream-broker: no broker for "${input.brokerId}"`);
|
|
6800
|
+
}
|
|
6801
|
+
const result = broker.subscribeAudioChunks({ tag: input.tag });
|
|
6802
|
+
this.audioSubscriptionBroker.set(result.subscriptionId, input.brokerId);
|
|
6803
|
+
return result;
|
|
6804
|
+
}
|
|
6805
|
+
/** Drain `DecodedAudioChunk`s for a subscription opened via `subscribeAudioChunks`. */
|
|
6806
|
+
async pullAudioChunks(input) {
|
|
6807
|
+
const brokerId = this.audioSubscriptionBroker.get(input.subscriptionId);
|
|
6808
|
+
if (!brokerId) return [];
|
|
6809
|
+
const broker = this.brokers.get(brokerId);
|
|
6810
|
+
if (!broker) return [];
|
|
6811
|
+
return broker.pullAudioChunks(input.subscriptionId, input.maxCount);
|
|
6812
|
+
}
|
|
6813
|
+
/** Release an audio-chunk subscription. */
|
|
6814
|
+
async unsubscribeAudioChunks(input) {
|
|
6815
|
+
const brokerId = this.audioSubscriptionBroker.get(input.subscriptionId);
|
|
6816
|
+
if (!brokerId) return { released: false };
|
|
6817
|
+
this.audioSubscriptionBroker.delete(input.subscriptionId);
|
|
6818
|
+
const broker = this.brokers.get(brokerId);
|
|
6819
|
+
if (!broker) return { released: false };
|
|
6820
|
+
const released = broker.unsubscribeAudioChunks(input.subscriptionId);
|
|
6821
|
+
return { released };
|
|
6822
|
+
}
|
|
6562
6823
|
async getBrokerStats(input) {
|
|
6563
6824
|
const broker = this.brokers.get(input.brokerId);
|
|
6564
6825
|
if (!broker) {
|
|
@@ -6599,12 +6860,10 @@ class StreamBrokerManager {
|
|
|
6599
6860
|
async rotateDecodersOnNode(agentNodeId, reason) {
|
|
6600
6861
|
let rotated = 0;
|
|
6601
6862
|
for (const broker of this.brokers.values()) {
|
|
6602
|
-
const
|
|
6603
|
-
if (!
|
|
6604
|
-
const brokerAgent = nodeId.includes("/") ? nodeId.split("/")[0] : nodeId;
|
|
6605
|
-
if (brokerAgent !== agentNodeId) continue;
|
|
6863
|
+
const onNode = broker.getDecoderNodeId()?.split("/")[0] === agentNodeId;
|
|
6864
|
+
if (!onNode) continue;
|
|
6606
6865
|
try {
|
|
6607
|
-
await broker.rotateDecoderSession(reason);
|
|
6866
|
+
await broker.rotateDecoderSession(reason, agentNodeId);
|
|
6608
6867
|
rotated++;
|
|
6609
6868
|
} catch (err) {
|
|
6610
6869
|
this.logger.warn("decoder rotation failed", {
|
|
@@ -6631,6 +6890,8 @@ class StreamBrokerManager {
|
|
|
6631
6890
|
await Promise.all(stopPromises);
|
|
6632
6891
|
this.brokers.clear();
|
|
6633
6892
|
this.streamHealthByBroker.clear();
|
|
6893
|
+
this.frameSubscriptionBroker.clear();
|
|
6894
|
+
this.audioSubscriptionBroker.clear();
|
|
6634
6895
|
await this.rtspServer.stop();
|
|
6635
6896
|
}
|
|
6636
6897
|
// ── Stream health watchdog ──────────────────────────────────────────
|
|
@@ -7270,6 +7531,7 @@ class StreamBrokerManager {
|
|
|
7270
7531
|
this.streamHealthByBroker.delete(brokerId);
|
|
7271
7532
|
await broker.stop();
|
|
7272
7533
|
this.brokers.delete(brokerId);
|
|
7534
|
+
this.sweepFrameSubscriptions(brokerId);
|
|
7273
7535
|
this.rtspProvider.persistTokens();
|
|
7274
7536
|
}
|
|
7275
7537
|
/**
|
|
@@ -10254,11 +10516,17 @@ class StreamBrokerAddon extends BaseAddon {
|
|
|
10254
10516
|
const widgetsProvider = {
|
|
10255
10517
|
listWidgets: async () => [
|
|
10256
10518
|
{
|
|
10257
|
-
|
|
10519
|
+
tab: "device-tab",
|
|
10258
10520
|
label: "Stream Brokers",
|
|
10521
|
+
kind: "remote",
|
|
10522
|
+
remote: {
|
|
10523
|
+
remoteName: "addon_stream_broker_widgets",
|
|
10524
|
+
exposedModule: "./widgets",
|
|
10525
|
+
componentKey: "stream-broker-panel"
|
|
10526
|
+
},
|
|
10527
|
+
stableId: "stream-broker-panel",
|
|
10259
10528
|
description: "Per-camera stream broker panel with adaptive controls.",
|
|
10260
10529
|
icon: "radio",
|
|
10261
|
-
remoteName: "addon_stream_broker_widgets",
|
|
10262
10530
|
bundle: "remoteEntry.js",
|
|
10263
10531
|
hosts: ["device-tab", "dashboard"],
|
|
10264
10532
|
requires: { deviceContext: true, integrationContext: false },
|
|
@@ -10274,15 +10542,11 @@ class StreamBrokerAddon extends BaseAddon {
|
|
|
10274
10542
|
{ capability: streamBrokerCapability, provider: this.brokerManager },
|
|
10275
10543
|
{
|
|
10276
10544
|
capability: cameraStreamsCapability,
|
|
10277
|
-
provider: cameraStreamsProvider
|
|
10278
|
-
kind: "wrapper",
|
|
10279
|
-
defaultActive: true
|
|
10545
|
+
provider: cameraStreamsProvider
|
|
10280
10546
|
},
|
|
10281
10547
|
{
|
|
10282
10548
|
capability: webrtcSessionCapability,
|
|
10283
|
-
provider: webrtcSessionProvider
|
|
10284
|
-
kind: "wrapper",
|
|
10285
|
-
defaultActive: true
|
|
10549
|
+
provider: webrtcSessionProvider
|
|
10286
10550
|
},
|
|
10287
10551
|
{ capability: addonWidgetsSourceCapability, provider: widgetsProvider }
|
|
10288
10552
|
];
|
|
@@ -10412,6 +10676,25 @@ class StreamBrokerAddon extends BaseAddon {
|
|
|
10412
10676
|
});
|
|
10413
10677
|
}
|
|
10414
10678
|
}
|
|
10679
|
+
class FrameDropper {
|
|
10680
|
+
intervalMs;
|
|
10681
|
+
lastPassedAt = -Infinity;
|
|
10682
|
+
constructor(maxFps) {
|
|
10683
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
10684
|
+
}
|
|
10685
|
+
shouldKeep() {
|
|
10686
|
+
if (this.intervalMs === 0) return true;
|
|
10687
|
+
const now = Date.now();
|
|
10688
|
+
if (now - this.lastPassedAt >= this.intervalMs) {
|
|
10689
|
+
this.lastPassedAt = now;
|
|
10690
|
+
return true;
|
|
10691
|
+
}
|
|
10692
|
+
return false;
|
|
10693
|
+
}
|
|
10694
|
+
setMaxFps(maxFps) {
|
|
10695
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
10696
|
+
}
|
|
10697
|
+
}
|
|
10415
10698
|
const noopLogger = {
|
|
10416
10699
|
debug() {
|
|
10417
10700
|
},
|