@camstack/addon-pipeline 1.1.43 → 1.1.44

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 (36) hide show
  1. package/dist/audio-analyzer/index.js +5 -4
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/chunk-D6vf50IK.js +28 -0
  4. package/dist/detection-pipeline/index.js +526 -109
  5. package/dist/detection-pipeline/index.mjs +520 -104
  6. package/dist/{dist-GFd8M6KO.mjs → dist-CgBGV988.mjs} +214 -186
  7. package/dist/{dist-NvwN60Fq.js → dist-CySHUkXY.js} +219 -185
  8. package/dist/{model-download-service-C-IHWnXx-DxM2DSns.js → model-download-service-C-IHWnXx-BLXzxpRB.js} +3 -29
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +122 -287
  12. package/dist/pipeline-runner/index.mjs +118 -285
  13. package/dist/recorder/index.js +60 -20
  14. package/dist/recorder/index.mjs +57 -18
  15. package/dist/{frame-handle-plane-E_AsR77T.mjs → remote-source-plane-B4hRwchu.mjs} +219 -2
  16. package/dist/{frame-handle-plane-B3Fxww8H.js → remote-source-plane-D9m0z1N9.js} +236 -1
  17. package/dist/session-decode/decode-worker-child.js +86 -2
  18. package/dist/session-decode/decode-worker-child.mjs +83 -2
  19. package/dist/stream-broker/_stub.js +550 -523
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-G6wtUb3G.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dlagq9X_.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUpF966l.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-DmjqskOR.mjs → hostInit-BSzP3BIj.mjs} +2 -2
  24. package/dist/stream-broker/index.js +746 -135
  25. package/dist/stream-broker/index.mjs +737 -127
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js → MaskShapeCanvas-DI4BY7W2-D2ML5TPi.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-D43fIRgB.js → MotionZonesSettings-NcxxQN8r-TTWGGaqv.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js → PrivacyMaskSettings-APgPLF7p-CJqHU2nU.js} +1 -1
  30. package/embed-dist/assets/index-C1oaL9s7.js +81 -0
  31. package/embed-dist/assets/{index-CSFtK41z.css → index-CA4kv43T.css} +1 -1
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +2 -2
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqJvWBKS.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-dGka8yZY.mjs +0 -26
  36. package/embed-dist/assets/index-CX-rILhw.js +0 -81
@@ -1,6 +1,6 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { $ as discriminatedUnion, B as DeviceType, D as nodePin, F as webrtcSessionCapability, G as makeSourceBrokerId, H as createEvent, I as errMsg, L as BaseAddon, N as streamBrokerCapability, Q as boolean, R as CAM_PROFILE_ORDER, V as EventCategory, W as makeProfileBrokerId, X as _enum, Z as array, at as string, c as EncodeProfileSchema, d as RingBuffer, g as cameraStreamsCapability, it as record, nt as number, ot as union, p as addonWidgetsSourceCapability, q as parseProfileBrokerId, rt as object, tt as literal, w as maskUrlCredentials, z as DeviceFeature } from "../dist-GFd8M6KO.mjs";
3
- import { n as DecoderSessionProxy, t as FrameHandlePlane } from "../frame-handle-plane-E_AsR77T.mjs";
2
+ import { $ as boolean, B as DeviceType, D as nodePin, F as webrtcSessionCapability, G as makeProfileBrokerId, H as createEvent, I as errMsg, J as parseProfileBrokerId, K as makeSourceBrokerId, L as BaseAddon, N as streamBrokerCapability, Q as array, R as CAM_PROFILE_ORDER, V as EventCategory, W as isEvent, Z as _enum, at as record, c as EncodeProfileSchema, d as RingBuffer, et as discriminatedUnion, g as cameraStreamsCapability, it as object, nt as literal, ot as string, p as addonWidgetsSourceCapability, rt as number, st as union, w as maskUrlCredentials, z as DeviceFeature } from "../dist-CgBGV988.mjs";
3
+ import { a as DecoderSessionProxy, i as FrameHandlePlane, r as profileForStreamId } from "../remote-source-plane-B4hRwchu.mjs";
4
4
  import { t as createFileDataPlaneHandler } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as crypto$1 from "node:crypto";
@@ -5173,6 +5173,16 @@ var RtspListenServer = class RtspListenServer {
5173
5173
  //#endregion
5174
5174
  //#region src/stream-broker/rtsp/rtsp-restream-provider.ts
5175
5175
  /**
5176
+ * Parse the numeric deviceId out of a canonical brokerId
5177
+ * (`${deviceId}/${camStreamId}` or `${deviceId}/${profile}`). Returns
5178
+ * `undefined` for a malformed id — so every device-scoped log line here
5179
+ * can carry `tags.deviceId` without risking a bogus tag value.
5180
+ */
5181
+ function deviceIdFromBrokerId$1(brokerId) {
5182
+ const parsed = Number(brokerId.split("/")[0]);
5183
+ return Number.isFinite(parsed) ? parsed : void 0;
5184
+ }
5185
+ /**
5176
5186
  * Internal sub-object of `StreamBrokerManager` that implements the RTSP
5177
5187
  * restream bookkeeping. No longer registered as a separate capability —
5178
5188
  * accessed via passthrough methods on `StreamBrokerManager`.
@@ -5255,20 +5265,26 @@ var RtspRestreamProviderImpl = class {
5255
5265
  const token = this.server.regenerateToken(brokerId);
5256
5266
  if (token) {
5257
5267
  this.persistTokens();
5258
- this.logger.info("RTSP token regenerated", { meta: {
5259
- brokerId,
5260
- tokenPrefix: token.slice(0, 8)
5261
- } });
5268
+ this.logger.info("RTSP token regenerated", {
5269
+ tags: { deviceId: deviceIdFromBrokerId$1(brokerId) },
5270
+ meta: {
5271
+ brokerId,
5272
+ tokenPrefix: token.slice(0, 8)
5273
+ }
5274
+ });
5262
5275
  }
5263
5276
  return token;
5264
5277
  }
5265
5278
  setEnabled(brokerId, enabled) {
5266
5279
  this.server.setEnabled(brokerId, enabled);
5267
5280
  this.persistEnabled();
5268
- this.logger.info("RTSP restream state changed", { meta: {
5269
- brokerId,
5270
- enabled
5271
- } });
5281
+ this.logger.info("RTSP restream state changed", {
5282
+ tags: { deviceId: deviceIdFromBrokerId$1(brokerId) },
5283
+ meta: {
5284
+ brokerId,
5285
+ enabled
5286
+ }
5287
+ });
5272
5288
  }
5273
5289
  isEnabled(brokerId) {
5274
5290
  return this.server.isEnabled(brokerId);
@@ -9433,7 +9449,6 @@ var StreamBroker = class StreamBroker {
9433
9449
  static RTP_ACTIVITY_TIMEOUT_MS = 8e3;
9434
9450
  manualStop = false;
9435
9451
  stopping = false;
9436
- restreamers = [];
9437
9452
  pipeServer;
9438
9453
  rtspRestreamer;
9439
9454
  preBuffer;
@@ -9610,9 +9625,6 @@ var StreamBroker = class StreamBroker {
9610
9625
  url
9611
9626
  };
9612
9627
  }
9613
- setRestreamers(restreamers) {
9614
- this.restreamers = restreamers;
9615
- }
9616
9628
  /**
9617
9629
  * Inject a manager-owned resolver for this broker's OWN RTSP restream URL
9618
9630
  * (`rtsp://127.0.0.1:<port>/<token>[/muted]`). When the source is an RTP
@@ -10053,10 +10065,6 @@ var StreamBroker = class StreamBroker {
10053
10065
  this.logger?.info("First keyframe received — decoder can start");
10054
10066
  }
10055
10067
  }
10056
- for (const restreamer of this.restreamers) {
10057
- const streamId = `${this.deviceId}/video`;
10058
- restreamer.pushPacket(streamId, packet);
10059
- }
10060
10068
  if (packet.type === "video" && this.firstKeyframeReceived) {
10061
10069
  const decoderPacket = this.decoderPacketWithParamSets(packet);
10062
10070
  this.decoderSeedBuffer.push(decoderPacket);
@@ -12153,6 +12161,168 @@ var TranscodePipelineManager = class {
12153
12161
  }
12154
12162
  };
12155
12163
  //#endregion
12164
+ //#region src/stream-broker/webrtc/remote-encoded-source-broker.ts
12165
+ var RemoteEncodedSourceBroker = class {
12166
+ api;
12167
+ logger;
12168
+ deviceId;
12169
+ streamId;
12170
+ ownerNodeId;
12171
+ hostname;
12172
+ createBroker;
12173
+ /** Single-flight acquire. */
12174
+ pending = null;
12175
+ disposed = false;
12176
+ constructor(options) {
12177
+ this.api = options.api;
12178
+ this.logger = options.logger.withTags({
12179
+ deviceId: options.deviceId,
12180
+ streamId: options.streamId
12181
+ });
12182
+ this.deviceId = options.deviceId;
12183
+ this.streamId = options.streamId;
12184
+ this.ownerNodeId = options.ownerNodeId;
12185
+ this.hostname = options.reachableHost;
12186
+ this.createBroker = options.createBroker;
12187
+ }
12188
+ /**
12189
+ * Single-flight: acquire the owner restream + construct/start the inner
12190
+ * broker once, sharing the same instance across concurrent callers. A
12191
+ * rejected acquire is retryable — the next `getBroker()` re-attempts from
12192
+ * scratch (fresh URL/token).
12193
+ */
12194
+ async getBroker() {
12195
+ if (this.disposed) throw new Error("RemoteEncodedSourceBroker: getBroker after dispose");
12196
+ if (!this.pending) {
12197
+ const creation = this.createEntry();
12198
+ this.pending = creation;
12199
+ creation.catch(() => {
12200
+ if (this.pending === creation) this.pending = null;
12201
+ });
12202
+ }
12203
+ return (await this.pending).broker;
12204
+ }
12205
+ /** Stop the inner broker and release the acquired restream once. Idempotent. */
12206
+ async dispose() {
12207
+ if (this.disposed) return;
12208
+ this.disposed = true;
12209
+ const pending = this.pending;
12210
+ this.pending = null;
12211
+ if (!pending) return;
12212
+ let entry;
12213
+ try {
12214
+ entry = await pending;
12215
+ } catch {
12216
+ return;
12217
+ }
12218
+ await entry.broker.stop().catch((err) => {
12219
+ this.logger.warn("remote-encoded source broker: inner broker stop failed", { meta: {
12220
+ streamId: this.streamId,
12221
+ error: errMsg(err)
12222
+ } });
12223
+ });
12224
+ await this.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: entry.pipelineKey }, nodePin(this.ownerNodeId)).catch((err) => {
12225
+ this.logger.debug("remote-encoded source broker: releaseStreamWithCodec failed (benign on hub restart)", { meta: {
12226
+ streamId: this.streamId,
12227
+ pipelineKey: entry.pipelineKey,
12228
+ error: errMsg(err)
12229
+ } });
12230
+ });
12231
+ }
12232
+ async createEntry() {
12233
+ const { api, deviceId, streamId, ownerNodeId } = this;
12234
+ if (this.hostname === void 0) throw new Error("RemoteEncodedSourceBroker: owner hostname unresolvable — set frameSource.hubHostnameOverride or CAMSTACK_HUB_URL (a 127.0.0.1 restream URL would dial the wrong box)");
12235
+ const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, nodePin(ownerNodeId)), deviceId, streamId);
12236
+ if (profile === null) throw new Error(`RemoteEncodedSourceBroker: no broker profile is fed by camStream '${streamId}' of device ${deviceId} — cannot acquire the owner restream`);
12237
+ const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
12238
+ deviceId,
12239
+ video: "copy",
12240
+ audio: "none",
12241
+ profile,
12242
+ hostname: this.hostname,
12243
+ tag: `webrtc:remote-encoded:${deviceId}/${streamId}`
12244
+ }, nodePin(ownerNodeId));
12245
+ const source = {
12246
+ type: "rtsp",
12247
+ url: rtpSource.url,
12248
+ videoCodec: rtpSource.videoCodec
12249
+ };
12250
+ const brokerId = `${deviceId}/${profile}`;
12251
+ const broker = this.createBroker({
12252
+ brokerId,
12253
+ logger: this.logger.child(profile)
12254
+ });
12255
+ broker.setSourceProvider(() => source);
12256
+ await broker.start(source);
12257
+ this.logger.info("remote-encoded source broker: owner restream acquired", { meta: {
12258
+ streamId,
12259
+ profile,
12260
+ codec: rtpSource.videoCodec,
12261
+ pipelineKey: rtpSource.pipelineKey,
12262
+ hostname: this.hostname
12263
+ } });
12264
+ return {
12265
+ broker,
12266
+ pipelineKey: rtpSource.pipelineKey
12267
+ };
12268
+ }
12269
+ };
12270
+ //#endregion
12271
+ //#region src/stream-broker/webrtc/resolve-tier-slot.ts
12272
+ /**
12273
+ * `resolveTierSlot` — pure helper: given an owner's profile slots, a
12274
+ * deviceId, and a preferred tier, resolve WHICH assigned tier camStream to
12275
+ * consume.
12276
+ *
12277
+ * Slice 3b (ADAPTIVE-target WebRTC live-view for remote-owned cameras,
12278
+ * lazy-per-tier). The exact-tier slot wins when it is assigned; otherwise
12279
+ * falls back through the canonical `CAM_PROFILE_ORDER` (high→mid→low) — the
12280
+ * same single-source tier order used by `stream-broker-manager` and the
12281
+ * analogous no-hints tier-selection fallback in `broker-webrtc-server.ts`.
12282
+ */
12283
+ /**
12284
+ * Resolve the camStream feeding `deviceId` at `preferredTier`. Prefers an
12285
+ * exact-tier match; when that tier is unassigned, falls back through
12286
+ * `TIER_PREFERENCE` order to the first assigned tier. Returns `null` when
12287
+ * the device has no assigned slot at all. Pure; never mutates `slots`.
12288
+ */
12289
+ function resolveTierSlot(slots, deviceId, preferredTier) {
12290
+ const deviceSlots = slots.filter((slot) => slot.deviceId === deviceId);
12291
+ const exact = deviceSlots.find((slot) => slot.profile === preferredTier && slot.sourceCamStreamId !== null);
12292
+ if (exact && exact.sourceCamStreamId !== null) return {
12293
+ camStreamId: exact.sourceCamStreamId,
12294
+ profile: exact.profile
12295
+ };
12296
+ for (const tier of CAM_PROFILE_ORDER) {
12297
+ const match = deviceSlots.find((slot) => slot.profile === tier && slot.sourceCamStreamId !== null);
12298
+ if (match && match.sourceCamStreamId !== null) return {
12299
+ camStreamId: match.sourceCamStreamId,
12300
+ profile: match.profile
12301
+ };
12302
+ }
12303
+ return null;
12304
+ }
12305
+ //#endregion
12306
+ //#region src/stream-broker/webrtc/webrtc-source-mode.ts
12307
+ /**
12308
+ * Decide how a WebRTC live-view node sources a camera. Owner==self → the
12309
+ * local broker path (unchanged). Owner≠self + reachable → a remote-encoded
12310
+ * adapter dialing the owner restream. Owner≠self + no reachableHost →
12311
+ * unresolvable (fail fast; a 127.0.0.1 restream URL would dial the wrong box).
12312
+ */
12313
+ function resolveWebrtcSourceMode(input) {
12314
+ if (input.ownerNodeId === input.ownNodeId) return { kind: "local" };
12315
+ if (input.reachableHost === void 0) return {
12316
+ kind: "unresolvable",
12317
+ ownerNodeId: input.ownerNodeId
12318
+ };
12319
+ return {
12320
+ kind: "remote-adapter",
12321
+ ownerNodeId: input.ownerNodeId,
12322
+ reachableHost: input.reachableHost
12323
+ };
12324
+ }
12325
+ //#endregion
12156
12326
  //#region src/stream-broker/stream-broker/stream-broker-manager.ts
12157
12327
  /**
12158
12328
  * Resolve a derived stream's source to a physical camStreamId, if it is pinned
@@ -12289,6 +12459,19 @@ function findProfileForStream(map, camStreamId) {
12289
12459
  return null;
12290
12460
  }
12291
12461
  /**
12462
+ * Pure lookup behind `StreamBrokerManager`'s `profileTierResolver` closure
12463
+ * (Slice 3b Task 2). `localLookup` mirrors today's behavior — parse the
12464
+ * brokerId and walk the LOCAL device's `this.assignments` map — which is
12465
+ * always empty for a remote-owned camera, so it returns null for every
12466
+ * remote-ensured adapter's brokerId. `remoteBrokerTiers` is the fallback
12467
+ * populated at the remote-adapter ensure site (Task 3) so `selectBestBroker`
12468
+ * can still tier-match a remote-owned camera's broker. Local wins when both
12469
+ * resolve a tier — a local broker is never shadowed by a stale remote entry.
12470
+ */
12471
+ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
12472
+ return localLookup(brokerId) ?? remoteBrokerTiers.get(brokerId) ?? null;
12473
+ }
12474
+ /**
12292
12475
  * Watchdog parameters for the stream-health emitter. Brokers that have
12293
12476
  * received zero video packets for STREAM_STALE_TIMEOUT_MS are reported
12294
12477
  * as `stream.offline`; the next packet flips them back to `stream.online`.
@@ -12327,7 +12510,7 @@ function parseBrokerId(brokerId) {
12327
12510
  camStreamId
12328
12511
  };
12329
12512
  }
12330
- var StreamBrokerManager = class {
12513
+ var StreamBrokerManager = class StreamBrokerManager {
12331
12514
  /**
12332
12515
  * brokers keyed by brokerId = `${deviceId}/${camStreamId}`.
12333
12516
  *
@@ -12396,7 +12579,6 @@ var StreamBrokerManager = class {
12396
12579
  * it down on idle / retract. Populated by {@link attachDerivedFeeder}.
12397
12580
  */
12398
12581
  derivedFeeders = /* @__PURE__ */ new Map();
12399
- restreamers = [];
12400
12582
  staticDecoders;
12401
12583
  logger;
12402
12584
  /** Public accessor for same-process callers (e.g. `CameraStreamsProvider.pickStream`)
@@ -12415,7 +12597,6 @@ var StreamBrokerManager = class {
12415
12597
  * next pull after a hub restart. Keyed by `brokerIdFor(deviceId, camStreamId)`.
12416
12598
  */
12417
12599
  lastProbedByBroker = /* @__PURE__ */ new Map();
12418
- capabilities = null;
12419
12600
  /**
12420
12601
  * tRPC api proxy injected from the host addon. Used to resolve the
12421
12602
  * `decoder` capability (and any future cross-cap consumers) via the
@@ -12444,9 +12625,66 @@ var StreamBrokerManager = class {
12444
12625
  * node. `null` until `setApiAccess` wires it (falls back to unpinned).
12445
12626
  */
12446
12627
  localNodeId = null;
12628
+ /**
12629
+ * Short-TTL cache of `clusterRoles.ingestNode` (via `getIngestOwner`) for the
12630
+ * `ensureBroker` dormancy gate — the reconcile calls `ensureBroker` per
12631
+ * camera, so a per-call cross-node query would be wasteful. Refreshed lazily
12632
+ * when older than {@link INGEST_OWNER_CACHE_TTL_MS}. `null` = never resolved.
12633
+ */
12634
+ cachedIngestOwnerNodeId = null;
12635
+ cachedIngestOwnerAt = 0;
12636
+ static INGEST_OWNER_CACHE_TTL_MS = 6e4;
12637
+ lastOwnershipReconcile = Promise.resolve();
12447
12638
  defaultPreBufferSec = 10;
12448
12639
  webrtcServer = null;
12449
12640
  /**
12641
+ * brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
12642
+ * session (Slice 3 Task 3 — cross-node ingest wiring). Mirrors `this.brokers`
12643
+ * for the cross-node leg: `ensureBrokerForWebrtcSession` get-or-creates one
12644
+ * when `getIngestOwner` resolves to a DIFFERENT node than this one, and
12645
+ * tears it down on the adapter's inner broker's own `onDemandChange(false)`
12646
+ * edge (last-session-idle) — the same hook `attachDerivedFeeder` uses to
12647
+ * tear down a derived feeder.
12648
+ */
12649
+ remoteBrokerAdapters = /* @__PURE__ */ new Map();
12650
+ /**
12651
+ * brokerId → in-flight `ensureBrokerForWebrtcSession` get-or-create for
12652
+ * that broker's remote adapter. Manager-level single-flight guard: two
12653
+ * concurrent WebRTC session creations for the SAME non-owned brokerId
12654
+ * (a real race — first-session-ever for a camera whose owner is a
12655
+ * different node) both pass the `remoteBrokerAdapters.has()` early-return
12656
+ * with the map still empty, because the check and the eventual `.set()`
12657
+ * straddle an `await getIngestOwner.query()`. Without this guard both
12658
+ * callers would construct their own `RemoteEncodedSourceBroker`, both
12659
+ * `.set()` into `remoteBrokerAdapters` (second overwrites first), and
12660
+ * both acquire the owner's restream (`getStreamWithCodec`) — the first
12661
+ * adapter becomes unreachable from the map so its `onDemandChange(false)`
12662
+ * teardown never runs and its restream refcount leaks forever. Concurrent
12663
+ * callers instead await the SAME promise, so exactly one adapter is
12664
+ * constructed, registered, and refcounted per brokerId.
12665
+ */
12666
+ remoteAdapterEnsures = /* @__PURE__ */ new Map();
12667
+ /**
12668
+ * brokerId → the profile tier a remote-ensured adapter was created for
12669
+ * (Slice 3b Task 2). `this.assignments` only tracks LOCALLY-owned devices,
12670
+ * so the `profileTierResolver` closure below always misses for a remote
12671
+ * adapter's brokerId — this map is the fallback so `selectBestBroker` can
12672
+ * still tier-match it. Populated at the ensure site alongside
12673
+ * `remoteBrokerAdapters.set(brokerId, ...)` (Task 3 threads the tier in)
12674
+ * and cleared at every `remoteBrokerAdapters.delete(brokerId)` site so no
12675
+ * stale tier lingers past the adapter's own lifetime.
12676
+ */
12677
+ remoteBrokerTiers = /* @__PURE__ */ new Map();
12678
+ /**
12679
+ * Injected factory for the inner broker a `RemoteEncodedSourceBroker`
12680
+ * drives. Production wraps the real `StreamBroker` — this leg never decodes
12681
+ * locally, so the decoder/audio-codec caps and local-node pin are unused
12682
+ * (mirrors the recommendation in `RemoteEncodedSourceBroker`'s doc comment).
12683
+ * Tests substitute a fake via `setRemoteEncodedBrokerFactory` so no real
12684
+ * RTSP dial happens.
12685
+ */
12686
+ createRemoteEncodedBroker = (deps) => new StreamBroker(deps.brokerId, null, deps.logger, null, null);
12687
+ /**
12450
12688
  * Transcode-pipeline manager — backs `getStreamWithCodec`. Lazily
12451
12689
  * created on first acquire, lives until `destroyAll`.
12452
12690
  */
@@ -12673,9 +12911,6 @@ var StreamBrokerManager = class {
12673
12911
  }
12674
12912
  };
12675
12913
  }
12676
- setCapabilitiesAccess(access) {
12677
- this.capabilities = access;
12678
- }
12679
12914
  /**
12680
12915
  * Wire the addon's `ctx.api` proxy. Required for the broker to
12681
12916
  * resolve the `decoder` capability via tRPC — see
@@ -12720,6 +12955,61 @@ var StreamBrokerManager = class {
12720
12955
  this.eventBus = bus;
12721
12956
  this.startStreamHealthWatchdog();
12722
12957
  this.subscribePlaceholderStateSources(bus);
12958
+ this.subscribeIngestOwnerChanges(bus);
12959
+ }
12960
+ /**
12961
+ * Track the cluster camera-source owner from the orchestrator's pushed
12962
+ * `PipelineIngestOwnerChanged` events instead of polling `getIngestOwner`
12963
+ * on every `ensureBroker`. The orchestrator re-emits on every node-capability
12964
+ * refresh (boot / agent online-offline / ingest flip), so this keeps the
12965
+ * ingest-owner gate current and prompt; the query in {@link isLocalIngestOwner}
12966
+ * survives only as a cold-start + long-backstop reconcile (loss-safety).
12967
+ */
12968
+ subscribeIngestOwnerChanges(bus) {
12969
+ bus.subscribe({ category: EventCategory.PipelineIngestOwnerChanged }, (event) => {
12970
+ if (!isEvent(event, EventCategory.PipelineIngestOwnerChanged)) return;
12971
+ const { ownerNodeId } = event.data;
12972
+ const changed = this.cachedIngestOwnerNodeId !== ownerNodeId;
12973
+ this.cachedIngestOwnerNodeId = ownerNodeId;
12974
+ this.cachedIngestOwnerAt = Date.now();
12975
+ if (changed) this.logger.debug("ingest owner changed (pushed)", { meta: {
12976
+ ownerNodeId,
12977
+ self: this.localNodeId
12978
+ } });
12979
+ this.lastOwnershipReconcile = this.reconcileOwnershipTeardown();
12980
+ });
12981
+ }
12982
+ /**
12983
+ * Tear down every LOCAL physical source broker when this node is no longer the
12984
+ * cluster ingest owner. `this.brokers` holds only physical source + derived
12985
+ * brokers (the `RemoteEncodedSourceBroker` restream adapters a non-owner uses
12986
+ * live in the separate `remoteBrokerAdapters` map, so they are untouched).
12987
+ * Re-creation is blocked by the ingest-owner gate on this node; the node's
12988
+ * consumers re-source from the new owner's restream via the orchestrator's
12989
+ * re-dispatch. No-op when this node IS the owner or holds no brokers, so a
12990
+ * repeated same-owner re-emit is cheap.
12991
+ */
12992
+ async reconcileOwnershipTeardown() {
12993
+ if (this.localNodeId === null) return;
12994
+ if (this.cachedIngestOwnerNodeId === null) return;
12995
+ if (this.cachedIngestOwnerNodeId === this.localNodeId) return;
12996
+ if (this.brokers.size === 0) return;
12997
+ const brokerIds = [...this.brokers.keys()];
12998
+ this.logger.info("ingest owner moved away — tearing down local physical brokers", { meta: {
12999
+ owner: this.cachedIngestOwnerNodeId,
13000
+ self: this.localNodeId,
13001
+ count: brokerIds.length
13002
+ } });
13003
+ for (const brokerId of brokerIds) {
13004
+ const parsed = this.parseBrokerId(brokerId);
13005
+ if (!parsed) continue;
13006
+ await this.releaseBroker(parsed.deviceId, parsed.camStreamId).catch((err) => {
13007
+ this.logger.warn("reverse-flip teardown: releaseBroker failed", { meta: {
13008
+ brokerId,
13009
+ error: errMsg(err)
13010
+ } });
13011
+ });
13012
+ }
12723
13013
  }
12724
13014
  /**
12725
13015
  * Subscribe to the cap-scoped bus events that drive the per-broker
@@ -12763,14 +13053,242 @@ var StreamBrokerManager = class {
12763
13053
  }
12764
13054
  setWebrtcServer(server) {
12765
13055
  this.webrtcServer = server;
12766
- server.setProfileTierResolver((brokerId) => {
12767
- const parsed = this.parseBrokerId(brokerId);
13056
+ server.setProfileTierResolver((brokerId) => resolveBrokerTier(brokerId, (id) => {
13057
+ const parsed = this.parseBrokerId(id);
12768
13058
  if (!parsed) return null;
12769
13059
  return findProfileForStream(this.assignments.get(parsed.deviceId)?.map ?? {}, parsed.camStreamId);
12770
- });
13060
+ }, this.remoteBrokerTiers));
12771
13061
  server.setSourceRestreamUrlResolver((brokerId) => this.rtspProvider.getEntry(brokerId)?.url ?? null);
13062
+ server.setEnsureAdaptiveTierBrokerHook((deviceId, tier) => this.ensureRemoteAdaptiveTierBroker(deviceId, tier));
12772
13063
  for (const [brokerId, broker] of this.brokers) server.registerBroker(brokerId, broker);
12773
13064
  }
13065
+ /**
13066
+ * Test/injection seam for the inner broker a `RemoteEncodedSourceBroker`
13067
+ * drives (Slice 3 Task 3). Production never calls this — the field default
13068
+ * already wraps the real `StreamBroker`. Mirrors the `createBroker`
13069
+ * injection point `RemoteEncodedSourceBroker` itself exposes.
13070
+ */
13071
+ setRemoteEncodedBrokerFactory(factory) {
13072
+ this.createRemoteEncodedBroker = factory;
13073
+ }
13074
+ /**
13075
+ * Ensure a broker exists for `brokerId` before a WebRTC session subscribes
13076
+ * to it (Slice 3 Task 3 — cross-node ingest wiring). Called by
13077
+ * `WebrtcSessionProvider.createSession`/`handleOffer` right before they
13078
+ * delegate to `BrokerWebrtcServer`.
13079
+ *
13080
+ * No-op when a LOCAL broker already covers `brokerId` — the
13081
+ * default/dormant state while every camera's `ingestNode` is the hub, so
13082
+ * this returns on the very first check with ZERO extra calls (byte-
13083
+ * identical to today). Otherwise resolves the cluster's camera-source
13084
+ * owner (`getIngestOwner`) and branches via `resolveWebrtcSourceMode`:
13085
+ *
13086
+ * - `local` → this node IS the owner but hasn't published/registered a
13087
+ * broker for this brokerId (a genuinely missing/misconfigured
13088
+ * stream) — do nothing and let `BrokerWebrtcServer` throw its normal
13089
+ * "No broker for stream" error, unchanged.
13090
+ * - `remote-adapter` → get-or-create a `RemoteEncodedSourceBroker` that
13091
+ * dials the owner's compressed passthrough restream, register its
13092
+ * inner broker with the WebRTC server exactly like a local broker (so
13093
+ * `setupSessionForBroker`'s resolution, tier logic, and everything
13094
+ * downstream see an ordinary `IStreamBroker` — this leg never touches
13095
+ * `broker-webrtc-server.ts`), and tear the adapter down on its own
13096
+ * `onDemandChange(false)` (last-session-idle) edge, mirroring
13097
+ * `attachDerivedFeeder`'s local-broker teardown hook.
13098
+ * - `unresolvable` → the owner is set but has no reachable host —
13099
+ * throw a descriptive error rather than let the session hang.
13100
+ *
13101
+ * `camStreamId === undefined` (an `adaptive` target with no cam stream
13102
+ * pinned yet) can't resolve a single owner profile to acquire — falls
13103
+ * through to the natural local-only resolution/error, same as today.
13104
+ *
13105
+ * `tier` (Slice 3b Task 3) is an optional hint from
13106
+ * `ensureRemoteAdaptiveTierBroker` — the profile the caller already
13107
+ * resolved `camStreamId` from via `resolveTierSlot`. When set, it is
13108
+ * recorded into `remoteBrokerTiers` alongside the adapter registration so
13109
+ * `resolveBrokerTier`/`selectBestBroker` can tier-match this brokerId even
13110
+ * though it's absent from `this.assignments` (remote-owned). Callers that
13111
+ * don't have a resolved tier (the plain WebRTC session path) omit it —
13112
+ * unchanged behavior.
13113
+ */
13114
+ async ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId, tier) {
13115
+ if (this.brokers.has(brokerId)) return;
13116
+ if (this.remoteBrokerAdapters.has(brokerId)) return;
13117
+ if (camStreamId === void 0) return;
13118
+ if (!this.api || this.localNodeId === null) return;
13119
+ const api = this.api;
13120
+ const ownNodeId = this.localNodeId;
13121
+ const existingEnsure = this.remoteAdapterEnsures.get(brokerId);
13122
+ if (existingEnsure) {
13123
+ await existingEnsure;
13124
+ return;
13125
+ }
13126
+ const ensure = this.createRemoteBrokerAdapterForSession(api, ownNodeId, deviceId, brokerId, camStreamId, tier);
13127
+ this.remoteAdapterEnsures.set(brokerId, ensure);
13128
+ try {
13129
+ await ensure;
13130
+ } finally {
13131
+ this.remoteAdapterEnsures.delete(brokerId);
13132
+ }
13133
+ }
13134
+ /**
13135
+ * Single-flight body for `ensureBrokerForWebrtcSession`'s remote-adapter
13136
+ * get-or-create — resolves the ingest owner, and for `remote-adapter` mode
13137
+ * constructs the `RemoteEncodedSourceBroker`, registers it with the WebRTC
13138
+ * server, and wires its idle-teardown hook. Only ever run once per
13139
+ * brokerId at a time; see `remoteAdapterEnsures`.
13140
+ */
13141
+ async createRemoteBrokerAdapterForSession(api, ownNodeId, deviceId, brokerId, camStreamId, tier) {
13142
+ let owner;
13143
+ try {
13144
+ owner = await api.pipelineOrchestrator.getIngestOwner.query();
13145
+ } catch (err) {
13146
+ this.logger.debug("webrtc-session: getIngestOwner unavailable — defaulting to local (cap not mounted yet?)", {
13147
+ tags: { deviceId },
13148
+ meta: { error: errMsg(err) }
13149
+ });
13150
+ owner = { ownerNodeId: ownNodeId };
13151
+ }
13152
+ const mode = resolveWebrtcSourceMode({
13153
+ ownerNodeId: owner.ownerNodeId,
13154
+ ownNodeId,
13155
+ reachableHost: owner.reachableHost
13156
+ });
13157
+ if (mode.kind === "local") return;
13158
+ if (mode.kind === "unresolvable") {
13159
+ const configIssueSuffix = owner.configIssue ? ` (${owner.configIssue})` : "";
13160
+ throw new Error(`webrtc-session: device ${deviceId} is owned by node "${mode.ownerNodeId}" but no reachable host is configured for it — cannot source its WebRTC stream remotely${configIssueSuffix}`);
13161
+ }
13162
+ const adapter = new RemoteEncodedSourceBroker({
13163
+ api,
13164
+ logger: this.logger.child("remote-encoded").withTags({ deviceId }),
13165
+ deviceId,
13166
+ streamId: camStreamId,
13167
+ ownerNodeId: mode.ownerNodeId,
13168
+ reachableHost: mode.reachableHost,
13169
+ createBroker: this.createRemoteEncodedBroker
13170
+ });
13171
+ this.remoteBrokerAdapters.set(brokerId, adapter);
13172
+ if (tier) this.remoteBrokerTiers.set(brokerId, tier);
13173
+ let broker;
13174
+ try {
13175
+ broker = await adapter.getBroker();
13176
+ } catch (err) {
13177
+ this.remoteBrokerAdapters.delete(brokerId);
13178
+ this.remoteBrokerTiers.delete(brokerId);
13179
+ throw err;
13180
+ }
13181
+ this.webrtcServer?.registerBroker(brokerId, broker);
13182
+ let unsubscribeDemand = () => {};
13183
+ unsubscribeDemand = broker.onDemandChange((active) => {
13184
+ if (active) return;
13185
+ unsubscribeDemand();
13186
+ this.remoteBrokerAdapters.delete(brokerId);
13187
+ this.remoteBrokerTiers.delete(brokerId);
13188
+ this.webrtcServer?.unregisterBroker(brokerId);
13189
+ adapter.dispose().catch((disposeErr) => {
13190
+ this.logger.warn("webrtc-session: remote-encoded adapter dispose failed", {
13191
+ tags: { deviceId },
13192
+ meta: {
13193
+ brokerId,
13194
+ error: errMsg(disposeErr)
13195
+ }
13196
+ });
13197
+ });
13198
+ });
13199
+ }
13200
+ /**
13201
+ * Idempotent cleanup for a remote adapter registered by
13202
+ * `ensureBrokerForWebrtcSession`. `WebrtcSessionProvider.createSession`/
13203
+ * `handleOffer` call this when the downstream `BrokerWebrtcServer`
13204
+ * delegate throws AFTER the adapter was already registered.
13205
+ *
13206
+ * Without this, teardown is driven ONLY by the inner broker's
13207
+ * `onDemandChange(true→false)` edge (last-WebRTC-subscriber-leaves). If
13208
+ * the delegate never succeeds, demand never reaches `true`, that edge
13209
+ * never fires, and the adapter + its WebRTC registration + the owner
13210
+ * restream refcount (`releaseStreamWithCodec`) leak permanently.
13211
+ *
13212
+ * A cheap no-op when no remote adapter exists for `brokerId` — the
13213
+ * owner==self / local-broker path never registers one, so this call adds
13214
+ * zero behavior there. Also safe if the adapter was already torn down
13215
+ * (e.g. a racing `onDemandChange(false)` edge) — `RemoteEncodedSourceBroker
13216
+ * .dispose()` is itself idempotent. Dispose failures are logged and
13217
+ * swallowed; this method never throws, so callers can always re-throw the
13218
+ * original delegate error afterward.
13219
+ */
13220
+ async disposeRemoteBrokerAdapterIfPresent(brokerId) {
13221
+ const adapter = this.remoteBrokerAdapters.get(brokerId);
13222
+ if (!adapter) return;
13223
+ this.remoteBrokerAdapters.delete(brokerId);
13224
+ this.remoteBrokerTiers.delete(brokerId);
13225
+ this.webrtcServer?.unregisterBroker(brokerId);
13226
+ try {
13227
+ await adapter.dispose();
13228
+ } catch (disposeErr) {
13229
+ this.logger.warn("webrtc-session: remote-encoded adapter dispose failed (delegate-failure cleanup)", { meta: {
13230
+ brokerId,
13231
+ error: errMsg(disposeErr)
13232
+ } });
13233
+ }
13234
+ }
13235
+ /**
13236
+ * Lazily ensure a remote adapter exists for a device's PREFERRED tier's
13237
+ * camStream (Slice 3b Task 3 — adaptive WebRTC live-view for remote-owned
13238
+ * cameras, lazy-per-tier). Adaptive-target sessions don't carry a
13239
+ * session-pinned camStreamId, so unlike `ensureBrokerForWebrtcSession`
13240
+ * this method's own job is resolving WHICH camStreamId the preferred tier
13241
+ * maps to on the owning node — via `resolveTierSlot` walking the owner's
13242
+ * `listAllProfileSlots` — before delegating to
13243
+ * `ensureBrokerForWebrtcSession` for the actual get-or-create (reusing its
13244
+ * single-flight guard and idle-teardown wiring unchanged).
13245
+ *
13246
+ * No-op for a LOCALLY-owned device — `this.assignments` already tracks it
13247
+ * and the normal (non-adaptive) resolve path handles it; this returns on
13248
+ * the very first check with ZERO extra calls, byte-identical to today.
13249
+ *
13250
+ * Mirrors `ensureBrokerForWebrtcSession`'s owner-resolution: graceful
13251
+ * `getIngestOwner` (defaults to local on failure — cap not mounted yet) +
13252
+ * `resolveWebrtcSourceMode`. `local` → return (nothing to do; the normal
13253
+ * resolve path already covers this node being the owner). `unresolvable`
13254
+ * → throw the same descriptive error rather than let a session hang.
13255
+ *
13256
+ * When the preferred tier (and every fallback tier) has no assigned slot
13257
+ * on the owner, `resolveTierSlot` returns null — this method returns
13258
+ * without throwing; the normal WebRTC resolve path will surface its own
13259
+ * "no broker for stream" error naturally once a session actually tries to
13260
+ * subscribe.
13261
+ */
13262
+ async ensureRemoteAdaptiveTierBroker(deviceId, preferredTier) {
13263
+ if (this.assignments.has(deviceId)) return;
13264
+ if (!this.api || this.localNodeId === null) return;
13265
+ const api = this.api;
13266
+ const ownNodeId = this.localNodeId;
13267
+ let owner;
13268
+ try {
13269
+ owner = await api.pipelineOrchestrator.getIngestOwner.query();
13270
+ } catch (err) {
13271
+ this.logger.debug("webrtc-adaptive-tier: getIngestOwner unavailable — defaulting to local (cap not mounted yet?)", {
13272
+ tags: { deviceId },
13273
+ meta: { error: errMsg(err) }
13274
+ });
13275
+ owner = { ownerNodeId: ownNodeId };
13276
+ }
13277
+ const mode = resolveWebrtcSourceMode({
13278
+ ownerNodeId: owner.ownerNodeId,
13279
+ ownNodeId,
13280
+ reachableHost: owner.reachableHost
13281
+ });
13282
+ if (mode.kind === "local") return;
13283
+ if (mode.kind === "unresolvable") {
13284
+ const configIssueSuffix = owner.configIssue ? ` (${owner.configIssue})` : "";
13285
+ throw new Error(`webrtc-adaptive-tier: device ${deviceId} is owned by node "${mode.ownerNodeId}" but no reachable host is configured for it — cannot source its WebRTC stream remotely${configIssueSuffix}`);
13286
+ }
13287
+ const hit = resolveTierSlot(await api.streamBroker.listAllProfileSlots.query(void 0, nodePin(mode.ownerNodeId)), deviceId, preferredTier);
13288
+ if (hit === null) return;
13289
+ const brokerId = makeSourceBrokerId(deviceId, hit.camStreamId);
13290
+ await this.ensureBrokerForWebrtcSession(deviceId, brokerId, hit.camStreamId, hit.profile);
13291
+ }
12774
13292
  setProfileMapPersister(persister) {
12775
13293
  this.profileMapPersister = persister;
12776
13294
  }
@@ -12778,19 +13296,6 @@ var StreamBrokerManager = class {
12778
13296
  this.assignments.clear();
12779
13297
  for (const [k, v] of entries) this.assignments.set(k, v);
12780
13298
  }
12781
- getLiveRestreamers() {
12782
- const live = this.capabilities?.getCollection?.("restreamer");
12783
- if (live && live.length > 0) return live;
12784
- return this.restreamers;
12785
- }
12786
- setRestreamers(restreamers) {
12787
- this.restreamers = restreamers;
12788
- for (const broker of this.brokers.values()) broker.setRestreamers(restreamers);
12789
- this.logger.info("Updated restreamers", { meta: { count: restreamers.length } });
12790
- }
12791
- getRestreamers() {
12792
- return this.restreamers;
12793
- }
12794
13299
  getRtspRestreamProvider() {
12795
13300
  return this.rtspProvider;
12796
13301
  }
@@ -13320,12 +13825,16 @@ var StreamBrokerManager = class {
13320
13825
  await broker.rotateDecoderSession(reason, agentNodeId);
13321
13826
  rotated++;
13322
13827
  } catch (err) {
13323
- this.logger.warn("decoder rotation failed", { meta: {
13324
- brokerId: broker.deviceId,
13325
- agentNodeId,
13326
- reason,
13327
- error: errMsg(err)
13328
- } });
13828
+ const parsedBrokerId = parseBrokerId(broker.deviceId);
13829
+ this.logger.warn("decoder rotation failed", {
13830
+ ...parsedBrokerId ? { tags: { deviceId: parsedBrokerId.deviceId } } : {},
13831
+ meta: {
13832
+ brokerId: broker.deviceId,
13833
+ agentNodeId,
13834
+ reason,
13835
+ error: errMsg(err)
13836
+ }
13837
+ });
13329
13838
  }
13330
13839
  }
13331
13840
  return rotated;
@@ -13415,6 +13924,7 @@ var StreamBrokerManager = class {
13415
13924
  clearInterval(this.streamHealthTimer);
13416
13925
  this.streamHealthTimer = void 0;
13417
13926
  }
13927
+ await this.lastOwnershipReconcile.catch(() => void 0);
13418
13928
  if (this.transcodeManager) {
13419
13929
  this.transcodeManager.shutdownAll();
13420
13930
  this.transcodeManager = null;
@@ -13511,15 +14021,7 @@ var StreamBrokerManager = class {
13511
14021
  packetCount: stats.preBufferPackets
13512
14022
  };
13513
14023
  }
13514
- async getStreamUrl(input) {
13515
- const prefix = input.streamId.split("/")[0];
13516
- if (!prefix) return { url: "" };
13517
- const deviceId = Number(prefix);
13518
- if (!Number.isFinite(deviceId)) return { url: "" };
13519
- for (const restreamer of this.getLiveRestreamers()) {
13520
- const match = restreamer.getExposedResources(deviceId).find((r) => r.format === input.format);
13521
- if (match) return { url: match.value };
13522
- }
14024
+ async getStreamUrl(_input) {
13523
14025
  return { url: "" };
13524
14026
  }
13525
14027
  async getRtspPort() {
@@ -13611,13 +14113,24 @@ var StreamBrokerManager = class {
13611
14113
  return this.rtspProvider.getEntry(input.brokerId, input.hostname);
13612
14114
  }
13613
14115
  async regenerateRtspToken(input) {
13614
- this.logger.info("regenerateRtspToken called", { meta: { brokerId: input.brokerId } });
14116
+ const parsedBrokerId = parseBrokerId(input.brokerId);
14117
+ const tags = parsedBrokerId ? { deviceId: parsedBrokerId.deviceId } : void 0;
14118
+ this.logger.info("regenerateRtspToken called", {
14119
+ tags,
14120
+ meta: { brokerId: input.brokerId }
14121
+ });
13615
14122
  const result = this.rtspProvider.regenerateToken(input.brokerId);
13616
- if (result) this.logger.info("regenerateRtspToken succeeded", { meta: {
13617
- brokerId: input.brokerId,
13618
- newTokenPrefix: result.slice(0, 8)
13619
- } });
13620
- else this.logger.warn("regenerateRtspToken failed — broker not found", { meta: { brokerId: input.brokerId } });
14123
+ if (result) this.logger.info("regenerateRtspToken succeeded", {
14124
+ tags,
14125
+ meta: {
14126
+ brokerId: input.brokerId,
14127
+ newTokenPrefix: result.slice(0, 8)
14128
+ }
14129
+ });
14130
+ else this.logger.warn("regenerateRtspToken failed — broker not found", {
14131
+ tags,
14132
+ meta: { brokerId: input.brokerId }
14133
+ });
13621
14134
  return result;
13622
14135
  }
13623
14136
  async setRtspEnabled(input) {
@@ -14656,18 +15169,69 @@ var StreamBrokerManager = class {
14656
15169
  * registered. Tied to publish lifecycle: `publishCameraStream` calls
14657
15170
  * this once per stream; `retractCameraStream` releases it.
14658
15171
  */
15172
+ /**
15173
+ * True when THIS node owns the cluster's camera source pull
15174
+ * (`clusterRoles.ingestNode`). Backs the `ensureBroker` dormancy gate.
15175
+ *
15176
+ * Ownership is normally PUSHED by the orchestrator's
15177
+ * `PipelineIngestOwnerChanged` event ({@link subscribeIngestOwnerChanges}),
15178
+ * so the steady-state gate is a pure cache read — no per-`ensureBroker`
15179
+ * cross-process query.
15180
+ *
15181
+ * - Not yet wired (`api`/`localNodeId` absent) → `true` (unchanged behavior;
15182
+ * the manager runs as today until `setApiAccess` supplies a node id).
15183
+ * - Cached owner fresh (event-stamped, within {@link INGEST_OWNER_CACHE_TTL_MS}) →
15184
+ * pure comparison, no query.
15185
+ * - Cold start / no event for the backstop window → a single `getIngestOwner`
15186
+ * reconcile query. It fails FAST now (FIX #1 makes an unregistered-provider
15187
+ * miss a precise error, not a 30s hang), so on failure we FAIL-OPEN: use the
15188
+ * last-known owner, or ALLOW when nothing is known — a rare transient
15189
+ * double-pull self-heals on the next pushed event, whereas denying would
15190
+ * starve the owner (no prebuffer, no recording).
15191
+ */
15192
+ async isLocalIngestOwner() {
15193
+ if (!this.api || this.localNodeId === null) return true;
15194
+ const now = Date.now();
15195
+ if (this.cachedIngestOwnerNodeId !== null && now - this.cachedIngestOwnerAt < StreamBrokerManager.INGEST_OWNER_CACHE_TTL_MS) return this.cachedIngestOwnerNodeId === this.localNodeId;
15196
+ try {
15197
+ const owner = await this.api.pipelineOrchestrator.getIngestOwner.query();
15198
+ this.cachedIngestOwnerNodeId = owner.ownerNodeId;
15199
+ this.cachedIngestOwnerAt = now;
15200
+ return owner.ownerNodeId === this.localNodeId;
15201
+ } catch (err) {
15202
+ this.logger.debug("isLocalIngestOwner: getIngestOwner unavailable — fail-open", { meta: {
15203
+ lastKnown: this.cachedIngestOwnerNodeId,
15204
+ self: this.localNodeId,
15205
+ error: errMsg(err)
15206
+ } });
15207
+ return this.cachedIngestOwnerNodeId === null ? true : this.cachedIngestOwnerNodeId === this.localNodeId;
15208
+ }
15209
+ }
14659
15210
  async ensureBroker(deviceId, camStreamId) {
14660
15211
  const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
14661
15212
  if (!cam) return;
14662
15213
  const brokerId = brokerIdFor(deviceId, camStreamId);
14663
15214
  if (this.brokers.has(brokerId)) return;
15215
+ if (!await this.isLocalIngestOwner()) {
15216
+ this.logger.debug("ensureBroker: not the ingest owner — skipping local physical broker", {
15217
+ tags: {
15218
+ deviceId,
15219
+ camStreamId
15220
+ },
15221
+ meta: {
15222
+ brokerId,
15223
+ ingestOwner: this.cachedIngestOwnerNodeId,
15224
+ self: this.localNodeId
15225
+ }
15226
+ });
15227
+ return;
15228
+ }
14664
15229
  const brokerLogger = this.logger.child(camStreamId).withTags({
14665
15230
  deviceId,
14666
15231
  camStreamId
14667
15232
  });
14668
15233
  const source = this.buildSourceFromCamStream(cam);
14669
15234
  const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
14670
- broker.setRestreamers(this.restreamers);
14671
15235
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
14672
15236
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
14673
15237
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
@@ -14855,44 +15419,10 @@ var StreamBrokerManager = class {
14855
15419
  broker.setPreBufferEnabled(defaultWarm);
14856
15420
  broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
14857
15421
  }
14858
- async fanOutRegistration(deviceId, brokerId, cam, broker) {
14859
- const log = this.logger.withTags({
14860
- deviceId,
14861
- brokerId,
14862
- camStreamId: cam.camStreamId
14863
- });
14864
- const localStreamUrl = broker.getLocalStreamUrl();
14865
- const codec = "h264";
14866
- const label = cam.label ?? brokerId;
14867
- for (const restreamer of this.getLiveRestreamers()) try {
14868
- await restreamer.registerDevice(deviceId, [{
14869
- streamId: brokerId,
14870
- label,
14871
- codec,
14872
- type: "video",
14873
- sourceUrl: localStreamUrl
14874
- }]);
14875
- } catch (err) {
14876
- log.error("Failed to register with restreamer", { meta: {
14877
- restreamerId: restreamer.id,
14878
- error: errMsg(err)
14879
- } });
14880
- }
15422
+ async fanOutRegistration(_deviceId, brokerId, _cam, broker) {
14881
15423
  if (this.webrtcServer) this.webrtcServer.registerBroker(brokerId, broker);
14882
15424
  }
14883
- async fanOutUnregistration(deviceId, brokerId) {
14884
- const log = this.logger.withTags({
14885
- deviceId,
14886
- brokerId
14887
- });
14888
- for (const restreamer of this.getLiveRestreamers()) try {
14889
- await restreamer.unregisterDevice(deviceId);
14890
- } catch (err) {
14891
- log.error("Failed to unregister from restreamer", { meta: {
14892
- restreamerId: restreamer.id,
14893
- error: errMsg(err)
14894
- } });
14895
- }
15425
+ async fanOutUnregistration(_deviceId, brokerId) {
14896
15426
  if (this.webrtcServer) this.webrtcServer.unregisterBroker(brokerId);
14897
15427
  }
14898
15428
  brokerStatusToSlotStatus(status) {
@@ -16314,6 +16844,15 @@ var BrokerWebrtcServer = class {
16314
16844
  * returns null when no restream is dialed → the feed falls back to stdin.
16315
16845
  */
16316
16846
  sourceRestreamUrlResolver = null;
16847
+ /**
16848
+ * Hook the manager injects to ensure the wanted tier's ADAPTIVE broker is
16849
+ * registered locally before `resolveBrokerId` runs — the manager wires it
16850
+ * to `ensureRemoteAdaptiveTierBroker` so a remote node's broker gets a
16851
+ * local registration in time for the resolve. No-ops on local-owned
16852
+ * devices (the manager's own gate), so an unset hook or a local-owner call
16853
+ * leaves adaptive resolution byte-identical to before this hook existed.
16854
+ */
16855
+ ensureAdaptiveTierBroker = null;
16317
16856
  /** sessionId → session entry. */
16318
16857
  sessions = /* @__PURE__ */ new Map();
16319
16858
  /**
@@ -16357,7 +16896,11 @@ var BrokerWebrtcServer = class {
16357
16896
  }
16358
16897
  registerBroker(brokerId, broker) {
16359
16898
  this.brokers.set(brokerId, broker);
16360
- this.logger.info("WebRTC broker registered", { meta: { brokerId } });
16899
+ const did = deviceIdFromBrokerId(brokerId);
16900
+ this.logger.info("WebRTC broker registered", {
16901
+ ...did !== -1 ? { tags: { deviceId: did } } : {},
16902
+ meta: { brokerId }
16903
+ });
16361
16904
  }
16362
16905
  /**
16363
16906
  * Provide a resolver that maps brokerId → current profile tier. The
@@ -16376,13 +16919,27 @@ var BrokerWebrtcServer = class {
16376
16919
  setSourceRestreamUrlResolver(resolver) {
16377
16920
  this.sourceRestreamUrlResolver = resolver;
16378
16921
  }
16922
+ /**
16923
+ * Provide a hook that ensures the wanted tier's remote adaptive broker
16924
+ * exists BEFORE `resolveBrokerId` runs, for ADAPTIVE streams only. The
16925
+ * manager injects one wired to `ensureRemoteAdaptiveTierBroker` (Task 3).
16926
+ * Without it (or on a local-owner call, which the hook itself no-ops on)
16927
+ * adaptive resolution behaves exactly as before this hook existed.
16928
+ */
16929
+ setEnsureAdaptiveTierBrokerHook(hook) {
16930
+ this.ensureAdaptiveTierBroker = hook;
16931
+ }
16379
16932
  unregisterBroker(brokerId) {
16380
16933
  this.brokers.delete(brokerId);
16381
16934
  for (const [sid, entry] of this.sessions) if (entry.brokerId === brokerId) {
16382
16935
  this.cleanupSession(sid);
16383
16936
  entry.session.close().catch(() => {});
16384
16937
  }
16385
- this.logger.info("WebRTC broker unregistered", { meta: { brokerId } });
16938
+ const unregisterDid = deviceIdFromBrokerId(brokerId);
16939
+ this.logger.info("WebRTC broker unregistered", {
16940
+ ...unregisterDid !== -1 ? { tags: { deviceId: unregisterDid } } : {},
16941
+ meta: { brokerId }
16942
+ });
16386
16943
  }
16387
16944
  supportsStream(streamId) {
16388
16945
  if (this.brokers.has(streamId)) return true;
@@ -16476,11 +17033,21 @@ var BrokerWebrtcServer = class {
16476
17033
  */
16477
17034
  async setupSessionForBroker(streamId, hints, opts) {
16478
17035
  if (this.stopped) throw new Error("Server stopped");
16479
- const intent = streamId.endsWith("/adaptive") && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
17036
+ const isAdaptiveStream = streamId.endsWith("/adaptive");
17037
+ const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
16480
17038
  const resolveHints = intent !== void 0 ? {
16481
17039
  ...hints,
16482
17040
  prefersTier: intent.prefersTier
16483
17041
  } : hints;
17042
+ if (isAdaptiveStream) {
17043
+ const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
17044
+ const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
17045
+ try {
17046
+ await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
17047
+ } catch (err) {
17048
+ throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${errMsg(err)}`);
17049
+ }
17050
+ }
16484
17051
  const brokerId = this.resolveBrokerId(streamId, resolveHints);
16485
17052
  const broker = this.brokers.get(brokerId);
16486
17053
  if (!broker) throw new Error(`No broker for stream "${streamId}" (resolved: "${brokerId}")`);
@@ -17152,13 +17719,17 @@ var BrokerWebrtcServer = class {
17152
17719
  bestScore = score;
17153
17720
  }
17154
17721
  }
17155
- this.logger.debug("Adaptive source selected", { meta: {
17156
- deviceKey,
17157
- selected: bestId,
17158
- tier: bestTier,
17159
- score: Math.round(bestScore * 100) / 100,
17160
- hints
17161
- } });
17722
+ const adaptiveDeviceId = Number(deviceKey.split("/")[0]);
17723
+ this.logger.debug("Adaptive source selected", {
17724
+ ...Number.isFinite(adaptiveDeviceId) ? { tags: { deviceId: adaptiveDeviceId } } : {},
17725
+ meta: {
17726
+ deviceKey,
17727
+ selected: bestId,
17728
+ tier: bestTier,
17729
+ score: Math.round(bestScore * 100) / 100,
17730
+ hints
17731
+ }
17732
+ });
17162
17733
  return bestId;
17163
17734
  }
17164
17735
  async resolveIceServers() {
@@ -17476,6 +18047,34 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
17476
18047
  if (camStreamId === void 0) return WEBRTC_FALLBACK_PROFILE;
17477
18048
  return withSourceResolution(WEBRTC_FALLBACK_PROFILE, this.manager.getCameraStreamProbedParams(deviceId, camStreamId));
17478
18049
  }
18050
+ /**
18051
+ * Ensure a broker is registered for `brokerId` before delegating to the
18052
+ * WebRTC server (Slice 3 Task 3 — cross-node ingest wiring). Defers to
18053
+ * `StreamBrokerManager.ensureBrokerForWebrtcSession`, guarded the same way
18054
+ * `resolveSessionDebug` guards `manager.isStreamingDebug`/`isWebrtcDebug` —
18055
+ * older/test manager doubles that don't implement the hook simply skip it,
18056
+ * leaving today's local-only resolution completely unchanged.
18057
+ */
18058
+ async ensureBrokerAvailable(deviceId, brokerId, camStreamId) {
18059
+ if (typeof this.manager.ensureBrokerForWebrtcSession !== "function") return;
18060
+ await this.manager.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId);
18061
+ }
18062
+ /**
18063
+ * Best-effort cleanup for the remote-encoded adapter `ensureBrokerAvailable`
18064
+ * may have just registered, run when the `BrokerWebrtcServer` delegate that
18065
+ * follows it throws. Without this, a delegate failure AFTER a successful
18066
+ * remote-adapter registration (session-construction/offer error, server
18067
+ * stopping mid-request, …) leaks the adapter forever — its only other
18068
+ * teardown path is the inner broker's `onDemandChange(false)` edge, which
18069
+ * never fires because no session ever reached the `true` (demand-active)
18070
+ * edge. Guarded the same way `ensureBrokerAvailable` guards the manager
18071
+ * hook itself — older/test manager doubles without the method are a no-op,
18072
+ * so the owner==self / local-broker happy path is byte-identical.
18073
+ */
18074
+ async disposeRemoteBrokerAdapterOnDelegateFailure(brokerId) {
18075
+ if (typeof this.manager.disposeRemoteBrokerAdapterIfPresent !== "function") return;
18076
+ await this.manager.disposeRemoteBrokerAdapterIfPresent(brokerId).catch(() => {});
18077
+ }
17479
18078
  async listStreams(input) {
17480
18079
  const { deviceId } = input;
17481
18080
  const assignedSlots = this.manager.getProfileSlotsForDevice(deviceId).filter((s) => s.sourceCamStreamId !== null);
@@ -17537,32 +18136,44 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
17537
18136
  }
17538
18137
  async createSession(input) {
17539
18138
  const streamId = this.resolveTargetToStreamId(input.deviceId, input.target);
17540
- const streamingDebug = this.resolveSessionDebug(input.deviceId);
17541
18139
  const camStreamId = this.resolveCamStreamId(input.deviceId, input.target);
18140
+ await this.ensureBrokerAvailable(input.deviceId, streamId, camStreamId);
18141
+ const streamingDebug = this.resolveSessionDebug(input.deviceId);
17542
18142
  const transcodeProfile = this.resolveTranscodeProfile(input.deviceId, camStreamId);
17543
- return this.webrtcServer.createSession(streamId, input.hints, {
17544
- streamingDebug,
17545
- relayOnly: input.relayOnly === true,
17546
- transcodeProfile,
17547
- ...input.consumerAttribution !== void 0 ? { consumerAttribution: input.consumerAttribution } : {}
17548
- });
18143
+ try {
18144
+ return await this.webrtcServer.createSession(streamId, input.hints, {
18145
+ streamingDebug,
18146
+ relayOnly: input.relayOnly === true,
18147
+ transcodeProfile,
18148
+ ...input.consumerAttribution !== void 0 ? { consumerAttribution: input.consumerAttribution } : {}
18149
+ });
18150
+ } catch (err) {
18151
+ await this.disposeRemoteBrokerAdapterOnDelegateFailure(streamId);
18152
+ throw err;
18153
+ }
17549
18154
  }
17550
18155
  async handleOffer(input) {
17551
18156
  const target = input.target ?? { kind: "adaptive" };
17552
18157
  const streamId = this.resolveTargetToStreamId(input.deviceId, target);
17553
- const streamingDebug = this.resolveSessionDebug(input.deviceId);
17554
18158
  const camStreamId = this.resolveCamStreamId(input.deviceId, target);
18159
+ await this.ensureBrokerAvailable(input.deviceId, streamId, camStreamId);
18160
+ const streamingDebug = this.resolveSessionDebug(input.deviceId);
17555
18161
  const transcodeProfile = this.resolveTranscodeProfile(input.deviceId, camStreamId);
17556
- return this.webrtcServer.handleOffer(streamId, input.sdpOffer, void 0, {
17557
- streamingDebug,
17558
- sessionId: input.sessionId,
17559
- relayOnly: input.relayOnly === true,
17560
- nonTrickle: input.nonTrickle === true,
17561
- disableTurn: input.disableTurn === true,
17562
- disableIpv6: input.disableIpv6 === true,
17563
- transcodeProfile,
17564
- ...input.consumerAttribution !== void 0 ? { consumerAttribution: input.consumerAttribution } : {}
17565
- });
18162
+ try {
18163
+ return await this.webrtcServer.handleOffer(streamId, input.sdpOffer, void 0, {
18164
+ streamingDebug,
18165
+ sessionId: input.sessionId,
18166
+ relayOnly: input.relayOnly === true,
18167
+ nonTrickle: input.nonTrickle === true,
18168
+ disableTurn: input.disableTurn === true,
18169
+ disableIpv6: input.disableIpv6 === true,
18170
+ transcodeProfile,
18171
+ ...input.consumerAttribution !== void 0 ? { consumerAttribution: input.consumerAttribution } : {}
18172
+ });
18173
+ } catch (err) {
18174
+ await this.disposeRemoteBrokerAdapterOnDelegateFailure(streamId);
18175
+ throw err;
18176
+ }
17566
18177
  }
17567
18178
  async handleAnswer(input) {
17568
18179
  await this.webrtcServer.handleAnswer(input.sessionId, input.sdpAnswer);
@@ -17786,7 +18397,6 @@ var StreamBrokerAddon = class extends BaseAddon {
17786
18397
  const localNode = this.ctx.kernel.localNodeId;
17787
18398
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
17788
18399
  this.brokerManager.setApiAccess(this.ctx.api, localNodeId, this.ctx.kernel.hwaccel);
17789
- if (this.capabilities) this.brokerManager.setCapabilitiesAccess(this.capabilities);
17790
18400
  await this.loadFfmpegConfig();
17791
18401
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
17792
18402
  const brokerManager = this.brokerManager;