@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.
Files changed (76) hide show
  1. package/dist/audio-analyzer/index.js +2 -4
  2. package/dist/audio-analyzer/index.js.map +1 -1
  3. package/dist/audio-analyzer/index.mjs +2 -4
  4. package/dist/audio-analyzer/index.mjs.map +1 -1
  5. package/dist/audio-codec-nodeav/index.js +1 -1
  6. package/dist/audio-codec-nodeav/index.mjs +1 -1
  7. package/dist/decoder-nodeav/index.js +552 -18
  8. package/dist/decoder-nodeav/index.js.map +1 -1
  9. package/dist/decoder-nodeav/index.mjs +553 -19
  10. package/dist/decoder-nodeav/index.mjs.map +1 -1
  11. package/dist/detection-pipeline/index.js +2 -4
  12. package/dist/detection-pipeline/index.js.map +1 -1
  13. package/dist/detection-pipeline/index.mjs +2 -4
  14. package/dist/detection-pipeline/index.mjs.map +1 -1
  15. package/dist/{index-DKh0uEve.mjs → index-CVzLrojg.mjs} +539 -97
  16. package/dist/index-CVzLrojg.mjs.map +1 -0
  17. package/dist/{index-CFPKrb2Y.js → index-p-6GfKOg.js} +539 -97
  18. package/dist/index-p-6GfKOg.js.map +1 -0
  19. package/dist/motion-wasm/index.js +2 -4
  20. package/dist/motion-wasm/index.js.map +1 -1
  21. package/dist/motion-wasm/index.mjs +2 -4
  22. package/dist/motion-wasm/index.mjs.map +1 -1
  23. package/dist/pipeline-runner/index.js +133 -54
  24. package/dist/pipeline-runner/index.js.map +1 -1
  25. package/dist/pipeline-runner/index.mjs +133 -54
  26. package/dist/pipeline-runner/index.mjs.map +1 -1
  27. package/dist/stream-broker/@mf-types.zip +0 -0
  28. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +19 -0
  29. 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
  30. 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
  31. 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
  32. 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
  33. package/dist/stream-broker/_stub.js +2 -2
  34. 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
  35. package/dist/stream-broker/{client-BK73l2KT.mjs → client-CZXrddDR.mjs} +2990 -3217
  36. package/dist/stream-broker/{hostInit-DkjoXTMb.mjs → hostInit-B86vUcFC.mjs} +12 -12
  37. package/dist/stream-broker/{index-BP0-1QYT.mjs → index-BCEx31Mh.mjs} +3808 -3100
  38. package/dist/stream-broker/{index-lmXLeXy8.mjs → index-BvV3RVTZ.mjs} +1 -1
  39. package/dist/stream-broker/{index-IUYKHbxX.mjs → index-C0BzaWmB.mjs} +1 -1
  40. package/dist/stream-broker/index-CWkKuNLr.mjs +232 -0
  41. package/dist/stream-broker/{index-ns1fRD30.mjs → index-CZNxa0ad.mjs} +1 -1
  42. package/dist/stream-broker/index-Kb4xa8FX.mjs +36403 -0
  43. package/dist/stream-broker/{index-BxHaCH3N.mjs → index-KtR7Pp0O.mjs} +1 -1
  44. package/dist/stream-broker/{index-Ss9m7Jum.mjs → index-cYW01SNH.mjs} +1 -1
  45. package/dist/stream-broker/index.js +802 -541
  46. package/dist/stream-broker/index.js.map +1 -1
  47. package/dist/stream-broker/index.mjs +802 -519
  48. package/dist/stream-broker/index.mjs.map +1 -1
  49. package/dist/stream-broker/{jsx-runtime-ZdY5pIZz.mjs → jsx-runtime-B_evVsXl.mjs} +1 -1
  50. package/dist/stream-broker/remoteEntry.js +1 -1
  51. package/package.json +23 -31
  52. package/dist/index-CFPKrb2Y.js.map +0 -1
  53. package/dist/index-DKh0uEve.mjs.map +0 -1
  54. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-CpCK52pE.mjs +0 -19
  55. 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
  56. package/dist/stream-broker/index-DKercbDS.mjs +0 -20855
  57. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  58. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  59. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  61. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  64. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  65. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  66. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  70. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  71. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  72. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  73. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  75. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  76. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
@@ -1,8 +1,8 @@
1
- import { e as errMsg, J as maskUrlCredentials, R as RingBuffer, 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-DKh0uEve.mjs";
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
- * Moleculer nodeID of the decoder provider currently hosting
3212
- * `decoderProxy`. Set from the `{ nodeId }` returned by
3213
- * `decoderApi.createSession`; used by the broker-manager's
3214
- * hwaccel-change subscription to decide which brokers need a forced
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
- decoderNodeId = null;
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
- decodedSubscribers = /* @__PURE__ */ new Map();
3304
- audioSubscribers = /* @__PURE__ */ new Map();
3305
- /** Stored options from first onDecodedFrame call when codec was not yet known */
3306
- pendingDecodeOptions;
3307
- /** Pull mode decoder needs to wait for the first keyframe before opening */
3308
- pendingPullModeOpen = null;
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.decoderProxy) {
3549
- await this.destroyDecoder();
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.decodedSubscribers.clear();
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.decodedSubscribers.size > 0) return true;
3576
- if (this.audioSubscribers.size > 0) return true;
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
- if (this.decoderProxy) {
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
- onDecodedFrame(callback, options) {
4124
- const maxFps = options?.maxFps ?? DEFAULT_MAX_FPS;
4125
- const subscriberId = Symbol("decoded-subscriber");
4126
- const frameDropper = new FrameDropper(maxFps);
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(`onDecodedFrame called without tag — listClients will show 'unknown' (Phase 10: every caller must pass options.tag)`);
4381
+ this.logger?.warn(`subscribeAudioChunks called without tag — listClients will show 'unknown'`);
4130
4382
  }
4131
- const subscriber = {
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
- if (this.decoderTeardownTimer) {
4147
- clearTimeout(this.decoderTeardownTimer);
4148
- this.decoderTeardownTimer = void 0;
4149
- }
4150
- if (!this.decoderProxy && this.source) {
4151
- const codec = this.detectedCodec ?? this.source.videoCodec;
4152
- if (codec) {
4153
- this.createSharedDecoderSession(options);
4154
- } else {
4155
- this.pendingDecodeOptions = options;
4156
- this.logger?.info("Decoder creation deferred — waiting for codec probe");
4157
- }
4158
- } else if (this.decoderProxy) {
4159
- this.maybeUpgradeDecoderFormat();
4160
- }
4161
- return () => {
4162
- this.decodedSubscribers.delete(subscriberId);
4163
- this.logger?.info("decoded subscriber removed", { meta: { total: this.decodedSubscribers.size } });
4164
- this.checkDemand();
4165
- if (this.decodedSubscribers.size === 0 && this.decoderProxy) {
4166
- this.logger?.info("last decoded subscriber left scheduling decoder teardown", {
4167
- meta: { graceMs: DECODER_TEARDOWN_GRACE_MS }
4168
- });
4169
- this.decoderTeardownTimer = setTimeout(() => {
4170
- this.decoderTeardownTimer = void 0;
4171
- if (this.decodedSubscribers.size === 0 && this.decoderProxy) {
4172
- this.logger?.info("decoder teardown no subscribers reconnected");
4173
- this.destroyDecoder();
4174
- }
4175
- }, DECODER_TEARDOWN_GRACE_MS);
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
- onDecodedAudioChunk(callback, options) {
4180
- const tag = options?.tag;
4181
- if (!tag) {
4182
- this.logger?.warn(`onDecodedAudioChunk called without tag — listClients will show 'unknown' (Phase 10)`);
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
- return () => {
4194
- this.audioSubscribers.delete(subscriberId);
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: decoderStats?.inputFps ?? this.encodedInputFps,
4203
- decodeFps: decoderStats?.outputFps ?? 0,
4468
+ inputFps: this.encodedInputFps,
4469
+ decodeFps: this.frameHandlePlane?.decodeFps() ?? 0,
4204
4470
  encodedSubscribers: this.encodedCallbacks.size,
4205
- decodedSubscribers: this.decodedSubscribers.size,
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.decoderNodeId,
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
- decoded: [...this.decodedSubscribers.values()].map((s) => ({
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: s.framesDropped
4505
+ framesDropped: 0
4237
4506
  })),
4238
- audio: [...this.audioSubscribers.values()].map((s) => ({
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` / `audio`: by `tag` — drops every subscriber whose tag
4251
- * matches, then re-evaluates demand so the decoder can wind down
4252
- * if nothing else is holding it.
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
- const subscribers = channel === "decoded" ? this.decodedSubscribers : this.audioSubscribers;
4260
- const victims = [];
4261
- for (const [key, sub] of subscribers) {
4262
- if (sub.tag === handle) victims.push(key);
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
- if (victims.length === 0) return false;
4265
- for (const key of victims) subscribers.delete(key);
4266
- this.logger?.info("subscribers force-disconnected", {
4267
- meta: { channel, handle, killed: victims.length, remaining: subscribers.size }
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
- for (const sub of this.audioSubscribers.values()) {
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 the decoder provider currently owning this broker's
4906
- * shared session. `null` when no session is active. Used by the
4907
- * broker-manager's `rotateDecodersOnNode` method to drop only the
4908
- * sessions affected by a per-agent event (e.g. hwaccel override change).
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.decoderNodeId;
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
- * Poll the decoder resolver until a provider for `codec` shows up, then
4934
- * build the session. Addons register asynchronously and out-of-order:
4935
- * the broker must NOT fail when the decoder addon hasn't yet finished
4936
- * spawning its isolated process it must wait.
4937
- *
4938
- * Bounded backoff: 500ms → 1s → 2s → 4s → 8s (capped), with a hard
4939
- * ceiling of ~60s total wait. If the decoder still isn't registered
4940
- * after that, log once at error level and stop retrying — at that
4941
- * point something is genuinely wrong with the addon config.
4942
- *
4943
- * The first failure is logged at debug so a healthy boot (decoder
4944
- * arriving within the first few hundred ms) doesn't pollute the log
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
- for (const sub of this.audioSubscribers.values()) {
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
- // ── Cap methods: broker runtime (stats + client inventory) ──────────
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 nodeId = broker.getDecoderNodeId();
6603
- if (!nodeId) continue;
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
- stableId: "stream-broker-panel",
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
  },