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