@camstack/addon-pipeline-orchestrator 1.2.294 → 1.2.296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ node_fs = __toESM(node_fs);
30
30
  let node_path = require("node:path");
31
31
  node_path = __toESM(node_path);
32
32
  let node_url = require("node:url");
33
- //#region ../types/dist/event-category-BGmz_oxW.mjs
33
+ //#region ../types/dist/event-category-B_0XEcLF.mjs
34
34
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
35
35
  EventCategory["SystemBoot"] = "system.boot";
36
36
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -531,15 +531,6 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
531
531
  EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
532
532
  EventCategory["AgentBackupActivated"] = "agent.backup.activated";
533
533
  EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
534
- /**
535
- * Per-agent hwaccel preference changed (user override set, cleared, or
536
- * re-probed). Observability event — decoders pull the current pref at
537
- * `createSession`, so running sessions keep their current backend until
538
- * they rotate naturally (camera add/remove, stream restart). Future
539
- * work can wire a listener in stream-broker that force-rotates live
540
- * sessions; for now this event powers logs + admin-UI toast feedback.
541
- */
542
- EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
543
534
  EventCategory["MotionAnalysis"] = "detection.motion-analysis";
544
535
  /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
545
536
  EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
@@ -5410,7 +5401,7 @@ var ZodIssueCode = {
5410
5401
  var ZodFirstPartyTypeKind;
5411
5402
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5412
5403
  //#endregion
5413
- //#region ../types/dist/sleep-DAJ9Er57.mjs
5404
+ //#region ../types/dist/sleep-BNeXenvr.mjs
5414
5405
  /**
5415
5406
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5416
5407
  * window to float samples (D455).
@@ -6809,32 +6800,12 @@ object({
6809
6800
  audioCodec: string().optional(),
6810
6801
  metadata: record(string(), unknown()).readonly().optional()
6811
6802
  });
6812
- var EncodedPacketSchema = object({
6813
- type: _enum(["video", "audio"]),
6814
- data: _instanceof(Uint8Array),
6815
- pts: number(),
6816
- dts: number(),
6817
- keyframe: boolean(),
6818
- codec: string()
6819
- });
6820
- var DecodedFrameSchema = object({
6821
- data: _instanceof(Uint8Array),
6822
- width: number(),
6823
- height: number(),
6824
- format: _enum([
6825
- "jpeg",
6826
- "rgb",
6827
- "bgr",
6828
- "yuv420",
6829
- "gray"
6830
- ]),
6831
- timestamp: number()
6832
- });
6833
6803
  /**
6834
6804
  * Wire schema for `FrameHandle` — a zero-pixel, serialisable reference to a
6835
6805
  * frame in a shared-memory ring (Phase 5 / D9). Mirrors the `FrameHandle` TS
6836
- * interface in `interfaces/frame-handle.ts` field-for-field so a decoder cap
6837
- * method (`pullHandles`) can carry handles over tRPC / Moleculer.
6806
+ * interface in `interfaces/frame-handle.ts` field-for-field so a cap method
6807
+ * (the runner's native-crop and pipeline-executor inputs) can carry handles
6808
+ * over tRPC / Moleculer.
6838
6809
  *
6839
6810
  * The `satisfies` assertion below pins this schema to the interface: if a
6840
6811
  * field is added to / removed from `FrameHandle` without a matching schema
@@ -6860,50 +6831,6 @@ var FrameHandleSchema = object({
6860
6831
  slotCount: number().int().positive()
6861
6832
  });
6862
6833
  /**
6863
- * Pixel format a frame-handle subscriber can request from the broker
6864
- * (Phase 5 / D9). The packed, raster subset of `FrameFormat` — the formats a
6865
- * `frameSink: 'shm'` decoder session can write into a `FrameRing` slot.
6866
- * `jpeg` is excluded: a variable-length stream is not a fixed-stride ring
6867
- * payload, and no decoded-frame consumer requests it over the shm plane.
6868
- */
6869
- var FrameHandleFormatSchema = _enum([
6870
- "rgb",
6871
- "bgr",
6872
- "yuv420",
6873
- "gray"
6874
- ]);
6875
- /**
6876
- * Input for `stream-broker.subscribeFrames` (Phase 5 / D9). A consumer asks the
6877
- * broker for a `FrameHandle` stream of one `format`; the broker maintains one
6878
- * shared-memory ring per `(brokerId, format)` actually requested, so a `gray`
6879
- * subscriber (motion) and an `rgb` subscriber (detection) each read the format
6880
- * they asked for with no broker-side conversion.
6881
- */
6882
- var SubscribeFramesInputSchema = object({
6883
- brokerId: string(),
6884
- format: FrameHandleFormatSchema,
6885
- /**
6886
- * Optional reader-side cadence hint in frames per second. The broker does
6887
- * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
6888
- * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
6889
- * the consumer can pace its own `pullFrameHandles` polling.
6890
- */
6891
- maxFps: number().positive().optional(),
6892
- /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
6893
- tag: string().optional()
6894
- });
6895
- /**
6896
- * Result of `stream-broker.subscribeFrames`. The consumer then polls
6897
- * `pullFrameHandles({ subscriptionId, maxCount })` and feeds each returned
6898
- * `FrameHandle` to a `FrameRingReader`.
6899
- */
6900
- var SubscribeFramesResultSchema = object({
6901
- /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
6902
- subscriptionId: string(),
6903
- /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
6904
- maxFps: number().nonnegative()
6905
- });
6906
- /**
6907
6834
  * Wire schema for a decoded audio chunk (Phase 5 / D9). Mirrors the
6908
6835
  * `DecodedAudioChunk` TS interface in `interfaces/stream-broker.ts`: PCM
6909
6836
  * sample bytes plus the track parameters a consumer needs to interpret them.
@@ -6911,8 +6838,8 @@ var SubscribeFramesResultSchema = object({
6911
6838
  * Audio chunks are tiny (a ~500ms AudioCodecSession window is a few KB of
6912
6839
  * Float32 PCM), so unlike video frames they ship their bytes INLINE over
6913
6840
  * tRPC / Moleculer — no shared-memory plane. `data` is typed `Uint8Array`
6914
- * (the wire-serialisable supertype of `Buffer`) to match `DecodedFrameSchema`
6915
- * / `EncodedPacketSchema`'s precedent; a `Buffer` is assignable to it.
6841
+ * (the wire-serialisable supertype of `Buffer`); a `Buffer` is assignable to
6842
+ * it.
6916
6843
  */
6917
6844
  var AudioChunkFormatSchema = _enum(AUDIO_CHUNK_FORMATS);
6918
6845
  var DecodedAudioChunkSchema = object({
@@ -6930,11 +6857,10 @@ var DecodedAudioChunkSchema = object({
6930
6857
  format: AudioChunkFormatSchema.optional()
6931
6858
  });
6932
6859
  /**
6933
- * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). The
6934
- * handle-free, ship-bytes-inline analogue of `subscribeFrames`: a consumer
6935
- * asks the broker for the decoded audio-chunk stream of one broker; the
6936
- * broker keeps a per-subscription bounded FIFO queue the consumer drains via
6937
- * `pullAudioChunks`.
6860
+ * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). Audio ships
6861
+ * its bytes inline: a consumer asks the broker for the decoded audio-chunk
6862
+ * stream of one broker; the broker keeps a per-subscription bounded FIFO queue
6863
+ * the consumer drains via `pullAudioChunks`.
6938
6864
  */
6939
6865
  var SubscribeAudioChunksInputSchema = object({
6940
6866
  brokerId: string(),
@@ -6965,9 +6891,7 @@ var BrokerStatsSchema = object({
6965
6891
  "stopped"
6966
6892
  ]),
6967
6893
  inputFps: number(),
6968
- decodeFps: number(),
6969
6894
  encodedSubscribers: number(),
6970
- decodedSubscribers: number(),
6971
6895
  uptimeMs: number(),
6972
6896
  bitrateKbps: number(),
6973
6897
  idrIntervalMs: number(),
@@ -6980,14 +6904,6 @@ var BrokerStatsSchema = object({
6980
6904
  preBufferMs: number(),
6981
6905
  preBufferPackets: number(),
6982
6906
  /**
6983
- * Moleculer node id of the decoder provider currently servicing this
6984
- * stream's decoded subscribers. `null` until the deferred decoder is
6985
- * created (no decoded clients yet, or codec not detected). Surfaces the
6986
- * runtime decoder placement so the UI can show "Decoder: <agent>" without
6987
- * a separate cap call per broker.
6988
- */
6989
- decoderNodeId: string().nullable(),
6990
- /**
6991
6907
  * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
6992
6908
  * when the stream has no audio track or the broker is in cold start.
6993
6909
  * `supported = false` means the codec was detected but the local
@@ -11325,70 +11241,6 @@ var StorageLocationDeclarationSchema = object({
11325
11241
  "backup"
11326
11242
  ]).optional()
11327
11243
  });
11328
- var DecoderStatsSchema = object({
11329
- inputFps: number(),
11330
- outputFps: number(),
11331
- avgDecodeTimeMs: number(),
11332
- droppedFrames: number(),
11333
- /**
11334
- * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
11335
- * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
11336
- * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
11337
- * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
11338
- */
11339
- lagMs: number().optional(),
11340
- effectiveFps: number().optional(),
11341
- adaptiveFps: number().optional()
11342
- });
11343
- var DecoderSessionConfigSchema = object({
11344
- codec: string(),
11345
- maxFps: number().default(0),
11346
- outputFormat: _enum([
11347
- "jpeg",
11348
- "rgb",
11349
- "bgr",
11350
- "yuv420",
11351
- "gray"
11352
- ]).default("jpeg"),
11353
- scale: number().default(1),
11354
- width: number().optional(),
11355
- height: number().optional(),
11356
- /**
11357
- * Identifier of the camera this decoder session serves. Optional
11358
- * because the cap is generic (any caller could request decode), but
11359
- * stream-broker passes it so decoder logs include `deviceId` for
11360
- * per-camera filtering when diagnosing failures (e.g. node-av
11361
- * sendPacket errors on a single hung camera).
11362
- */
11363
- deviceId: number().int().nonnegative().optional(),
11364
- /**
11365
- * Free-form tag for log scoping. Stream-broker uses
11366
- * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
11367
- * on every line so `grep tag=broker:5/high` filters one camera
11368
- * profile cleanly.
11369
- */
11370
- tag: string().optional(),
11371
- /**
11372
- * Where the session delivers decoded frames (Phase 5 / D9):
11373
- *
11374
- * - `'callback'` (default) — the legacy pixel path: decoded frames are
11375
- * buffered as `DecodedFrame`s and drained via `pullFrames`.
11376
- * - `'shm'` — the shared-memory frame plane: decoded frames are written
11377
- * into an OS shared-memory ring and drained as zero-pixel
11378
- * `FrameHandle`s via `pullHandles`. A session is one mode or the
11379
- * other — `pullFrames` returns nothing for an `'shm'` session and
11380
- * `pullHandles` returns nothing for a `'callback'` session.
11381
- */
11382
- frameSink: _enum(["callback", "shm"]).default("callback"),
11383
- /**
11384
- * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
11385
- * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
11386
- * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
11387
- * stream-broker's `streamingDebug` gate — off by default so production logs
11388
- * stay quiet and the emit path pays zero per-frame cost when disabled.
11389
- */
11390
- debug: boolean().optional()
11391
- });
11392
11244
  /**
11393
11245
  * Per-component log CHANNELS — the gate a hot path consults, and the registry
11394
11246
  * an addon declares its channels in.
@@ -13592,13 +13444,6 @@ var BrokerRtspClientSchema = object({
13592
13444
  lastRtpAt: number(),
13593
13445
  bytesSent: number()
13594
13446
  });
13595
- var BrokerDecodedClientSchema = object({
13596
- tag: string(),
13597
- subscribedAt: number(),
13598
- maxFps: number(),
13599
- framesDelivered: number(),
13600
- framesDropped: number()
13601
- });
13602
13447
  var BrokerAudioClientSchema = object({
13603
13448
  tag: string(),
13604
13449
  subscribedAt: number(),
@@ -13690,7 +13535,6 @@ var BrokerEncodedClientSchema = object({
13690
13535
  });
13691
13536
  var BrokerClientsSchema = object({
13692
13537
  rtsp: array(BrokerRtspClientSchema).readonly(),
13693
- decoded: array(BrokerDecodedClientSchema).readonly(),
13694
13538
  audio: array(BrokerAudioClientSchema).readonly(),
13695
13539
  encoded: array(BrokerEncodedClientSchema).readonly(),
13696
13540
  pipeClients: number(),
@@ -14059,11 +13903,7 @@ method(object({
14059
13903
  auth: "admin"
14060
13904
  }), method(object({ brokerId: string() }), BrokerClientsSchema), method(object({
14061
13905
  brokerId: string(),
14062
- channel: _enum([
14063
- "rtsp",
14064
- "decoded",
14065
- "audio"
14066
- ]),
13906
+ channel: _enum(["rtsp", "audio"]),
14067
13907
  handle: string()
14068
13908
  }), object({ killed: boolean() }), {
14069
13909
  kind: "mutation",
@@ -14098,10 +13938,7 @@ method(object({
14098
13938
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
14099
13939
  subscriptionId: string(),
14100
13940
  maxCount: number().int().positive().default(8)
14101
- }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(SubscribeFramesInputSchema, SubscribeFramesResultSchema, { kind: "mutation" }), method(object({
14102
- subscriptionId: string(),
14103
- maxCount: number().int().positive().default(4)
14104
- }), array(FrameHandleSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
13941
+ }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
14105
13942
  brokerId: string(),
14106
13943
  seconds: number().min(0).max(30)
14107
13944
  }), _void(), {
@@ -15258,54 +15095,12 @@ id: union([string(), number()]) }), {
15258
15095
  kind: "mutation",
15259
15096
  auth: "admin"
15260
15097
  });
15261
- /**
15262
- * shm ring usage stats for a `frameSink: 'shm'` decoder session —
15263
- * exposed via `decoder.getShmStats` so downstream consumers can
15264
- * observe ring pressure (slot count, byte budget, hit/miss ratio).
15265
- */
15266
- var ShmRingStatsSchema = object({
15267
- sessionId: string(),
15268
- slotCount: number().int(),
15269
- slotByteLength: number().int(),
15270
- segmentBytes: number().int(),
15271
- budgetMb: number().int(),
15272
- framesWritten: number().int(),
15273
- getFrameHits: number().int(),
15274
- getFrameMisses: number().int()
15275
- });
15276
- method(object({ codec: string() }), boolean()), method(_void(), object({
15098
+ method(_void(), object({
15277
15099
  id: string(),
15278
15100
  name: string(),
15279
- isPullMode: boolean().optional(),
15280
- priority: number().optional(),
15281
15101
  hwaccel: string().optional(),
15282
15102
  probedBestHwaccel: string().optional()
15283
- })), method(DecoderSessionConfigSchema, object({
15284
- sessionId: string(),
15285
- nodeId: string()
15286
- })), method(object({ sessionId: string() }), _void()), method(object({
15287
- sessionId: string(),
15288
- packet: EncodedPacketSchema
15289
- }), _void()), method(object({
15290
- sessionId: string(),
15291
- url: string()
15292
- }), _void()), method(object({
15293
- sessionId: string(),
15294
- maxCount: number().default(1),
15295
- waitMs: number().optional()
15296
- }), array(DecodedFrameSchema)), method(object({
15297
- sessionId: string(),
15298
- maxCount: number().default(1),
15299
- waitMs: number().optional()
15300
- }), array(FrameHandleSchema)), method(object({ handle: FrameHandleSchema }), DecodedFrameSchema.nullable()), method(object({ sessionId: string() }), ShmRingStatsSchema.nullable()), method(object({
15301
- sessionId: string(),
15302
- config: DecoderSessionConfigSchema.partial()
15303
- }), _void()), method(object({ sessionId: string() }), DecoderStatsSchema), method(_void(), array(object({
15304
- sessionId: string(),
15305
- codec: string(),
15306
- outputFormat: string(),
15307
- createdAtMs: number()
15308
- })).readonly()), method(_void(), object({ backend: string() }), {
15103
+ })), method(_void(), object({ backend: string() }), {
15309
15104
  kind: "mutation",
15310
15105
  auth: "admin"
15311
15106
  });
@@ -22375,8 +22170,8 @@ function interpretStoredClusterModel(raw) {
22375
22170
  var CLUSTER_STEP_SETTING_FIELDS = [{
22376
22171
  stepId: "face-embedding",
22377
22172
  key: "minLandmarkFaceSize",
22378
- label: "Min face size for recognition (detection px)",
22379
- description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
22173
+ label: "Min face size to embed (detection-frame px)",
22174
+ description: "Faces smaller than this in the small frame the detector sees are not embedded, so they can never be recognised. Below it the face cannot be aligned reliably and a bad embedding matches everyone. Same value on every node. 0 = no limit.",
22380
22175
  type: "slider",
22381
22176
  min: 0,
22382
22177
  max: 64,
@@ -24428,21 +24223,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
24428
24223
  group: string().optional(),
24429
24224
  settings: record(string(), unknown()).optional()
24430
24225
  }));
24431
- var PipelineTemplateStepSchema = lazy(() => object({
24432
- addonId: string(),
24433
- enabled: boolean(),
24434
- modelId: string(),
24435
- children: array(PipelineTemplateStepSchema).readonly(),
24436
- settings: record(string(), unknown()).optional()
24437
- }));
24438
- var PipelineTemplateSchema$1 = object({
24439
- id: string(),
24440
- name: string(),
24441
- createdAt: string(),
24442
- updatedAt: string(),
24443
- engine: PipelineEngineChoiceSchema,
24444
- steps: array(PipelineTemplateStepSchema).readonly()
24445
- });
24446
24226
  var PipelineModelOptionSchema = object({
24447
24227
  id: string(),
24448
24228
  name: string(),
@@ -24764,18 +24544,6 @@ var pipelineExecutorCapability = {
24764
24544
  steps: array(PipelineStepInputSchema),
24765
24545
  deviceKey: string().optional()
24766
24546
  }), PipelineValidationResultSchema),
24767
- listTemplates: method(_void(), array(PipelineTemplateSchema$1).readonly()),
24768
- saveTemplate: method(object({
24769
- name: string(),
24770
- steps: array(PipelineTemplateStepSchema).readonly(),
24771
- engine: PipelineEngineChoiceSchema
24772
- }), PipelineTemplateSchema$1, { kind: "mutation" }),
24773
- updateTemplate: method(object({
24774
- id: string(),
24775
- name: string().optional(),
24776
- steps: array(PipelineTemplateStepSchema).readonly().optional()
24777
- }), PipelineTemplateSchema$1, { kind: "mutation" }),
24778
- deleteTemplate: method(object({ id: string() }), _void(), { kind: "mutation" }),
24779
24547
  getCapabilities: method(_void(), InferenceCapabilitiesBridge),
24780
24548
  getAddonModels: method(object({ addonId: string() }), ModelAvailabilityListBridge),
24781
24549
  downloadModel: method(object({
@@ -26535,14 +26303,15 @@ var RunnerLocalLoadSchema = object({
26535
26303
  * Per-inference-device live load (multi-device C4). One entry per deviceKey
26536
26304
  * this runner currently has attached cameras on, so the orchestrator's second
26537
26305
  * `balance()` pass (over a node's devices) weights on real per-pool session
26538
- * counts. Empty on single-device / pre-multi-device runners. `queueDepthTotal`
26539
- * per device is 0 until the pool backlog gets a public accessor (follow-up).
26306
+ * counts. Empty on single-device / pre-multi-device runners. There is no
26307
+ * per-device queue depth: the inference pool's backlog has no accessor, and
26308
+ * a field that always read 0 was removed rather than shown as a measurement
26309
+ * (D762).
26540
26310
  */
26541
26311
  devices: array(object({
26542
26312
  deviceKey: string(),
26543
26313
  backend: string(),
26544
- attachedCameras: number(),
26545
- queueDepthTotal: number()
26314
+ attachedCameras: number()
26546
26315
  })).default([]),
26547
26316
  /** Hardware capability flags reported by this node. */
26548
26317
  hardware: object({
@@ -26756,14 +26525,6 @@ var CameraPipelineConfigSchema = object({
26756
26525
  settings: record(string(), unknown()).readonly().optional()
26757
26526
  }).nullable().optional()
26758
26527
  });
26759
- var PipelineTemplateSchema = object({
26760
- id: string(),
26761
- name: string(),
26762
- description: string().optional(),
26763
- config: CameraPipelineConfigSchema,
26764
- createdAt: string(),
26765
- updatedAt: string()
26766
- });
26767
26528
  var DeviceStepConfigSchema = object({
26768
26529
  modelId: string().optional(),
26769
26530
  settings: record(string(), unknown()).optional(),
@@ -26873,7 +26634,10 @@ var PipelineAssignmentSchema = object({
26873
26634
  });
26874
26635
  /**
26875
26636
  * Per-agent load summary surfaced to the load balancer + dashboards.
26876
- * Aggregated from each runner's `getLocalLoad` cap call.
26637
+ * Aggregated from each runner's `getLocalLoad` cap call. In the
26638
+ * `getAgentLoad` report, a single-device node's default-pool sessions (no
26639
+ * `deviceKey`) are credited to its one eligible device in `load.devices` —
26640
+ * the default pool IS that device (D745); the balancer's census stays raw.
26877
26641
  */
26878
26642
  var AgentLoadSummarySchema = object({
26879
26643
  nodeId: string(),
@@ -26962,26 +26726,27 @@ var CameraBrokerStatusSchema = object({
26962
26726
  width: number(),
26963
26727
  height: number(),
26964
26728
  subscribers: number(),
26965
- inFps: number(),
26966
- outFps: number()
26729
+ /**
26730
+ * Encoded packets/s the broker receives from the camera. null when the
26731
+ * broker's stats read failed — unknown, never 0 (a 0 here reads as a stall).
26732
+ * There is no output rate: decoding runs in the session decode workers, not
26733
+ * in the broker, so the broker's own decoded-frame rate is 0 on every live
26734
+ * camera and is not served (D758).
26735
+ */
26736
+ inFps: number().nullable()
26967
26737
  })).readonly(),
26968
26738
  webrtcSessions: number(),
26969
26739
  rtspRestream: boolean()
26970
26740
  });
26971
- /** Shared-memory ring statistics within the decoder block. */
26972
- var CameraDecoderShmSchema = object({
26973
- framesWritten: number(),
26974
- getFrameHits: number(),
26975
- getFrameMisses: number(),
26976
- budgetMb: number()
26977
- });
26978
- /** Decoder block — null when the decoder stage is unreachable or inactive. */
26979
- var CameraDecoderStatusSchema = object({
26980
- nodeId: string(),
26981
- formats: array(string()).readonly(),
26982
- sessionCount: number(),
26983
- shm: CameraDecoderShmSchema
26984
- });
26741
+ /**
26742
+ * Decoder block — WHERE decode runs (co-located with the detection node by
26743
+ * construction); null when the camera has no detection node. It carries no
26744
+ * frame counter: the shared-memory plane whose counters it used to serve is
26745
+ * gone, and those fields read 0 forever. Whether frames actually flow is read
26746
+ * from `detection.actualFps` (frames delivered to and processed by the
26747
+ * detector), never from a counter nothing feeds.
26748
+ */
26749
+ var CameraDecoderStatusSchema = object({ nodeId: string() });
26985
26750
  /** Motion block — null when motion detection is not active for this device. */
26986
26751
  var CameraMotionStatusSchema = object({
26987
26752
  enabled: boolean(),
@@ -27835,33 +27600,7 @@ var pipelineOrchestratorCapability = {
27835
27600
  auth: "admin"
27836
27601
  }),
27837
27602
  /** Snapshot of the in-flight or last disk-wins fleet reconcile. */
27838
- getReconcileFromDiskStatus: method(_void(), DiskReconcileJobSchema, { auth: "admin" }),
27839
- /** List every template the operator has saved. */
27840
- listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
27841
- /** Create a new named preset from a given CameraPipelineConfig. */
27842
- saveTemplate: method(object({
27843
- name: string(),
27844
- description: string().optional(),
27845
- config: CameraPipelineConfigSchema
27846
- }), PipelineTemplateSchema, {
27847
- kind: "mutation",
27848
- auth: "admin"
27849
- }),
27850
- /** Partial update of a template. Any omitted field is preserved. */
27851
- updateTemplate: method(object({
27852
- id: string(),
27853
- name: string().optional(),
27854
- description: string().optional(),
27855
- config: CameraPipelineConfigSchema.optional()
27856
- }), PipelineTemplateSchema, {
27857
- kind: "mutation",
27858
- auth: "admin"
27859
- }),
27860
- /** Remove a template. Camera configs that were seeded from it keep a copy. */
27861
- deleteTemplate: method(object({ id: string() }), object({ success: literal(true) }), {
27862
- kind: "mutation",
27863
- auth: "admin"
27864
- })
27603
+ getReconcileFromDiskStatus: method(_void(), DiskReconcileJobSchema, { auth: "admin" })
27865
27604
  }
27866
27605
  };
27867
27606
  /**
@@ -30083,8 +29822,6 @@ var WebrtcStreamChoiceSchema = object({
30083
29822
  ]).nullable(),
30084
29823
  /** Input FPS from the camera (null for adaptive). */
30085
29824
  inputFps: number().nullable(),
30086
- /** Decoded FPS (null for adaptive). */
30087
- decodeFps: number().nullable(),
30088
29825
  /** Bitrate in kbps (null for adaptive). */
30089
29826
  bitrateKbps: number().nullable()
30090
29827
  });
@@ -40330,90 +40067,18 @@ Object.freeze({
40330
40067
  addonId: null,
40331
40068
  access: "create"
40332
40069
  },
40333
- "decoder.createSession": {
40334
- capName: "decoder",
40335
- capScope: "system",
40336
- addonId: null,
40337
- access: "view"
40338
- },
40339
- "decoder.destroySession": {
40340
- capName: "decoder",
40341
- capScope: "system",
40342
- addonId: null,
40343
- access: "delete"
40344
- },
40345
- "decoder.getFrame": {
40346
- capName: "decoder",
40347
- capScope: "system",
40348
- addonId: null,
40349
- access: "view"
40350
- },
40351
40070
  "decoder.getInfo": {
40352
40071
  capName: "decoder",
40353
40072
  capScope: "system",
40354
40073
  addonId: null,
40355
40074
  access: "view"
40356
40075
  },
40357
- "decoder.getShmStats": {
40358
- capName: "decoder",
40359
- capScope: "system",
40360
- addonId: null,
40361
- access: "view"
40362
- },
40363
- "decoder.getStats": {
40364
- capName: "decoder",
40365
- capScope: "system",
40366
- addonId: null,
40367
- access: "view"
40368
- },
40369
- "decoder.listActiveSessions": {
40370
- capName: "decoder",
40371
- capScope: "system",
40372
- addonId: null,
40373
- access: "view"
40374
- },
40375
- "decoder.openStream": {
40376
- capName: "decoder",
40377
- capScope: "system",
40378
- addonId: null,
40379
- access: "view"
40380
- },
40381
- "decoder.pullFrames": {
40382
- capName: "decoder",
40383
- capScope: "system",
40384
- addonId: null,
40385
- access: "view"
40386
- },
40387
- "decoder.pullHandles": {
40388
- capName: "decoder",
40389
- capScope: "system",
40390
- addonId: null,
40391
- access: "view"
40392
- },
40393
- "decoder.pushPacket": {
40394
- capName: "decoder",
40395
- capScope: "system",
40396
- addonId: null,
40397
- access: "view"
40398
- },
40399
40076
  "decoder.reprobeHwaccel": {
40400
40077
  capName: "decoder",
40401
40078
  capScope: "system",
40402
40079
  addonId: null,
40403
40080
  access: "create"
40404
40081
  },
40405
- "decoder.supportsCodec": {
40406
- capName: "decoder",
40407
- capScope: "system",
40408
- addonId: null,
40409
- access: "view"
40410
- },
40411
- "decoder.updateConfig": {
40412
- capName: "decoder",
40413
- capScope: "system",
40414
- addonId: null,
40415
- access: "view"
40416
- },
40417
40082
  "detectionPipeline.applyDeviceSettingsPatch": {
40418
40083
  capName: "detection-pipeline",
40419
40084
  capScope: "device",
@@ -43504,12 +43169,6 @@ Object.freeze({
43504
43169
  addonId: null,
43505
43170
  access: "delete"
43506
43171
  },
43507
- "pipelineExecutor.deleteTemplate": {
43508
- capName: "pipeline-executor",
43509
- capScope: "system",
43510
- addonId: null,
43511
- access: "delete"
43512
- },
43513
43172
  "pipelineExecutor.downloadModel": {
43514
43173
  capName: "pipeline-executor",
43515
43174
  capScope: "system",
@@ -43648,12 +43307,6 @@ Object.freeze({
43648
43307
  addonId: null,
43649
43308
  access: "view"
43650
43309
  },
43651
- "pipelineExecutor.listTemplates": {
43652
- capName: "pipeline-executor",
43653
- capScope: "system",
43654
- addonId: null,
43655
- access: "view"
43656
- },
43657
43310
  "pipelineExecutor.rearmInferenceDevice": {
43658
43311
  capName: "pipeline-executor",
43659
43312
  capScope: "system",
@@ -43678,12 +43331,6 @@ Object.freeze({
43678
43331
  addonId: null,
43679
43332
  access: "create"
43680
43333
  },
43681
- "pipelineExecutor.saveTemplate": {
43682
- capName: "pipeline-executor",
43683
- capScope: "system",
43684
- addonId: null,
43685
- access: "create"
43686
- },
43687
43334
  "pipelineExecutor.setVideoPipelineSteps": {
43688
43335
  capName: "pipeline-executor",
43689
43336
  capScope: "system",
@@ -43702,12 +43349,6 @@ Object.freeze({
43702
43349
  addonId: null,
43703
43350
  access: "create"
43704
43351
  },
43705
- "pipelineExecutor.updateTemplate": {
43706
- capName: "pipeline-executor",
43707
- capScope: "system",
43708
- addonId: null,
43709
- access: "create"
43710
- },
43711
43352
  "pipelineExecutor.validatePipeline": {
43712
43353
  capName: "pipeline-executor",
43713
43354
  capScope: "system",
@@ -43738,12 +43379,6 @@ Object.freeze({
43738
43379
  addonId: null,
43739
43380
  access: "create"
43740
43381
  },
43741
- "pipelineOrchestrator.deleteTemplate": {
43742
- capName: "pipeline-orchestrator",
43743
- capScope: "system",
43744
- addonId: null,
43745
- access: "delete"
43746
- },
43747
43382
  "pipelineOrchestrator.getAgentLoad": {
43748
43383
  capName: "pipeline-orchestrator",
43749
43384
  capScope: "system",
@@ -43894,12 +43529,6 @@ Object.freeze({
43894
43529
  addonId: null,
43895
43530
  access: "view"
43896
43531
  },
43897
- "pipelineOrchestrator.listTemplates": {
43898
- capName: "pipeline-orchestrator",
43899
- capScope: "system",
43900
- addonId: null,
43901
- access: "view"
43902
- },
43903
43532
  "pipelineOrchestrator.pauseForStorageMigration": {
43904
43533
  capName: "pipeline-orchestrator",
43905
43534
  capScope: "system",
@@ -43954,12 +43583,6 @@ Object.freeze({
43954
43583
  addonId: null,
43955
43584
  access: "create"
43956
43585
  },
43957
- "pipelineOrchestrator.saveTemplate": {
43958
- capName: "pipeline-orchestrator",
43959
- capScope: "system",
43960
- addonId: null,
43961
- access: "create"
43962
- },
43963
43586
  "pipelineOrchestrator.setAgentCapabilities": {
43964
43587
  capName: "pipeline-orchestrator",
43965
43588
  capScope: "system",
@@ -44068,12 +43691,6 @@ Object.freeze({
44068
43691
  addonId: null,
44069
43692
  access: "create"
44070
43693
  },
44071
- "pipelineOrchestrator.updateTemplate": {
44072
- capName: "pipeline-orchestrator",
44073
- capScope: "system",
44074
- addonId: null,
44075
- access: "create"
44076
- },
44077
43694
  "pipelineRunner.attachCamera": {
44078
43695
  capName: "pipeline-runner",
44079
43696
  capScope: "system",
@@ -45508,12 +45125,6 @@ Object.freeze({
45508
45125
  addonId: null,
45509
45126
  access: "view"
45510
45127
  },
45511
- "streamBroker.pullFrameHandles": {
45512
- capName: "stream-broker",
45513
- capScope: "system",
45514
- addonId: null,
45515
- access: "view"
45516
- },
45517
45128
  "streamBroker.regenerateRtspToken": {
45518
45129
  capName: "stream-broker",
45519
45130
  capScope: "system",
@@ -45580,12 +45191,6 @@ Object.freeze({
45580
45191
  addonId: null,
45581
45192
  access: "create"
45582
45193
  },
45583
- "streamBroker.subscribeFrames": {
45584
- capName: "stream-broker",
45585
- capScope: "system",
45586
- addonId: null,
45587
- access: "create"
45588
- },
45589
45194
  "streamBroker.unassignProfile": {
45590
45195
  capName: "stream-broker",
45591
45196
  capScope: "system",
@@ -45598,12 +45203,6 @@ Object.freeze({
45598
45203
  addonId: null,
45599
45204
  access: "create"
45600
45205
  },
45601
- "streamBroker.unsubscribeFrames": {
45602
- capName: "stream-broker",
45603
- capScope: "system",
45604
- addonId: null,
45605
- access: "create"
45606
- },
45607
45206
  "streamCatalog.getCatalog": {
45608
45207
  capName: "stream-catalog",
45609
45208
  capScope: "device",
@@ -48145,11 +47744,6 @@ Object.freeze({
48145
47744
  form: "single",
48146
47745
  optional: false
48147
47746
  }],
48148
- "decoder.createSession": [{
48149
- name: "deviceId",
48150
- form: "single",
48151
- optional: true
48152
- }],
48153
47747
  "detectionPipeline.applyDeviceSettingsPatch": [{
48154
47748
  name: "deviceId",
48155
47749
  form: "single",
@@ -54866,6 +54460,40 @@ var LoadShedController = class LoadShedController {
54866
54460
  }
54867
54461
  };
54868
54462
  //#endregion
54463
+ //#region src/default-pool-attribution.ts
54464
+ /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
54465
+ function backendOf(deviceKey) {
54466
+ const colon = deviceKey.indexOf(":");
54467
+ return colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
54468
+ }
54469
+ /**
54470
+ * `load` with its unattributed sessions credited to `eligibleKeys[0]` when that
54471
+ * is the node's ONLY eligible device; `load` itself (same reference) otherwise.
54472
+ * `eligibleKeys` null = the eligible set is not known.
54473
+ */
54474
+ function attributeDefaultPoolSessions(load, eligibleKeys) {
54475
+ if (eligibleKeys === null || eligibleKeys.length !== 1) return load;
54476
+ const onlyKey = eligibleKeys[0];
54477
+ if (onlyKey === void 0) return load;
54478
+ const attributed = load.devices.reduce((sum, d) => sum + d.attachedCameras, 0);
54479
+ const unattributed = load.attachedCameras - attributed;
54480
+ if (unattributed <= 0) return load;
54481
+ const existing = load.devices.find((d) => d.deviceKey === onlyKey);
54482
+ const credited = existing ? {
54483
+ ...existing,
54484
+ attachedCameras: existing.attachedCameras + unattributed
54485
+ } : {
54486
+ deviceKey: onlyKey,
54487
+ backend: backendOf(onlyKey),
54488
+ attachedCameras: unattributed
54489
+ };
54490
+ const devices = [...load.devices.filter((d) => d.deviceKey !== onlyKey), credited].toSorted((a, b) => a.deviceKey.localeCompare(b.deviceKey));
54491
+ return {
54492
+ ...load,
54493
+ devices
54494
+ };
54495
+ }
54496
+ //#endregion
54869
54497
  //#region src/dispatch-timing.ts
54870
54498
  /**
54871
54499
  * How long the placement census waits for ONE runner's `getLocalCameras`
@@ -54932,6 +54560,25 @@ var AgentLoadService = class AgentLoadService {
54932
54560
  getAgentLoadSummaries() {
54933
54561
  return [...this.cachedAgentLoad.values()];
54934
54562
  }
54563
+ /**
54564
+ * The cached summaries as the load REPORT (`getAgentLoad`) carries them: a
54565
+ * single-device node's default-pool sessions credited to its one device
54566
+ * (`default-pool-attribution.ts`). The balancer's census
54567
+ * ({@link collectAgentLoad}) stays the runner's raw answer.
54568
+ */
54569
+ async reportedAgentLoad() {
54570
+ const resolve = this.deps.eligibleDeviceKeys;
54571
+ const summaries = this.getAgentLoadSummaries();
54572
+ if (resolve === void 0) return summaries;
54573
+ return Promise.all(summaries.map(async (summary) => {
54574
+ const eligible = await resolve(summary.nodeId).catch(() => null);
54575
+ const load = attributeDefaultPoolSessions(summary.load, eligible);
54576
+ return load === summary.load ? summary : {
54577
+ ...summary,
54578
+ load
54579
+ };
54580
+ }));
54581
+ }
54935
54582
  cachedAgentLoadNodeIds() {
54936
54583
  return [...this.cachedAgentLoad.keys()];
54937
54584
  }
@@ -57379,29 +57026,15 @@ function mapBroker(brokerResult) {
57379
57026
  width: p.width,
57380
57027
  height: p.height,
57381
57028
  subscribers: p.subscribers,
57382
- inFps: p.inFps,
57383
- outFps: p.outFps
57029
+ inFps: p.inFps
57384
57030
  })),
57385
57031
  webrtcSessions: brokerResult.webrtcSessions,
57386
57032
  rtspRestream: brokerResult.rtspRestream
57387
57033
  };
57388
57034
  }
57389
- function mapDecoderShm(shm) {
57390
- return {
57391
- framesWritten: shm.framesWritten,
57392
- getFrameHits: shm.getFrameHits,
57393
- getFrameMisses: shm.getFrameMisses,
57394
- budgetMb: shm.budgetMb
57395
- };
57396
- }
57397
57035
  function mapDecoder(decoderResult) {
57398
57036
  if (decoderResult === null) return null;
57399
- return {
57400
- nodeId: decoderResult.nodeId,
57401
- formats: [...decoderResult.formats],
57402
- sessionCount: decoderResult.sessionCount,
57403
- shm: mapDecoderShm(decoderResult.shm)
57404
- };
57037
+ return { nodeId: decoderResult.nodeId };
57405
57038
  }
57406
57039
  function mapMotion(motionResult) {
57407
57040
  if (motionResult === null) return null;
@@ -57780,11 +57413,9 @@ var CameraStatusService = class {
57780
57413
  * `allSlotsFetch` (fetched once by the caller) to avoid a second
57781
57414
  * round-trip. Per-slot `getBrokerStats` + `listClients` are fetched in
57782
57415
  * parallel. WebRTC sessions are counted by filtering `encoded[]` entries
57783
- * whose `attribution.kind` is one of the `WEBRTC_KINDS`. Also captures the
57784
- * broker's actual decode-session node into `liveDecoder` (T6) — the first
57785
- * slot that reports one wins.
57416
+ * whose `attribution.kind` is one of the `WEBRTC_KINDS`.
57786
57417
  */
57787
- buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, sink) {
57418
+ buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, sink) {
57788
57419
  if (!api || !allSlotsFetch) return Promise.resolve(null);
57789
57420
  return this.boundedStage(allSlotsFetch.then(async (slots) => {
57790
57421
  const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
@@ -57801,19 +57432,16 @@ var CameraStatusService = class {
57801
57432
  clients
57802
57433
  };
57803
57434
  })), pass.rtspEntries(api, sourceNodeId).catch(() => null)]);
57804
- const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
57805
- profile: slot.profile,
57806
- status: slot.status,
57807
- codec: stats?.codec ?? slot.codec ?? "",
57808
- width: slot.resolution?.width ?? 0,
57809
- height: slot.resolution?.height ?? 0,
57810
- subscribers: clients?.encodedSubscribers ?? 0,
57811
- inFps: stats?.inputFps ?? 0,
57812
- outFps: stats?.decodeFps ?? 0
57813
- }));
57814
- for (const { stats } of statsAndClients) if (liveDecoder.nodeId === null && typeof stats?.decoderNodeId === "string") liveDecoder.nodeId = stats.decoderNodeId;
57815
57435
  return {
57816
- profiles: profileDetails,
57436
+ profiles: statsAndClients.map(({ slot, stats, clients }) => ({
57437
+ profile: slot.profile,
57438
+ status: slot.status,
57439
+ codec: stats?.codec ?? slot.codec ?? "",
57440
+ width: slot.resolution?.width ?? 0,
57441
+ height: slot.resolution?.height ?? 0,
57442
+ subscribers: clients?.encodedSubscribers ?? 0,
57443
+ inFps: stats?.inputFps ?? null
57444
+ })),
57817
57445
  webrtcSessions: statsAndClients.reduce((total, { clients }) => {
57818
57446
  if (!clients) return total;
57819
57447
  return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
@@ -57825,27 +57453,19 @@ var CameraStatusService = class {
57825
57453
  }), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
57826
57454
  }
57827
57455
  /**
57828
- * Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
57829
- * are not node-addressable (void/sessionId input); live per-node decoder
57830
- * stats would need a node-addressable decoder method. The block therefore
57831
- * reports the node decode runs on BY CONSTRUCTION — the detection node
57832
- * (decode is always co-located with its frame consumer) — with zeroed
57833
- * stats. Unassigned camera → block stays null. (The ASSIGNMENT block
57834
- * upgrades to the broker's live session node when observed — see the T6
57835
- * post-processing in `getCameraStatus`.)
57456
+ * Decoder stage: the node decode runs on BY CONSTRUCTION — the detection
57457
+ * node (decode is always co-located with its frame consumer). Unassigned
57458
+ * camera → null. (The ASSIGNMENT block upgrades to the broker's live session
57459
+ * node when observed — see the T6 post-processing in `getCameraStatus`.)
57460
+ *
57461
+ * No counter rides along. This block used to serve shared-memory ring stats,
57462
+ * session count and formats hard-coded to 0 / [] after the SHM frame plane
57463
+ * was retired (frames live in the in-RAM RetainedFrameStore), and the admin
57464
+ * UI raised "decode path stalled" on every active camera from those zeros.
57465
+ * Frames reaching the detector are read from `detection.actualFps`.
57836
57466
  */
57837
57467
  buildDecoderStage(detectionNodeId) {
57838
- return detectionNodeId ? Promise.resolve({
57839
- nodeId: detectionNodeId,
57840
- formats: [],
57841
- sessionCount: 0,
57842
- shm: {
57843
- framesWritten: 0,
57844
- getFrameHits: 0,
57845
- getFrameMisses: 0,
57846
- budgetMb: 0
57847
- }
57848
- }) : Promise.resolve(null);
57468
+ return Promise.resolve(detectionNodeId ? { nodeId: detectionNodeId } : null);
57849
57469
  }
57850
57470
  /**
57851
57471
  * Motion stage (from cached runner config). Motion configuration lives in
@@ -58015,10 +57635,9 @@ var CameraStatusService = class {
58015
57635
  const api = this.deps.api();
58016
57636
  const degradations = { entries: [] };
58017
57637
  const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
58018
- const liveDecoder = { nodeId: null };
58019
57638
  const allSlotsFetch = api ? pass.profileSlots(api, sourceNodeId) : null;
58020
57639
  const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
58021
- const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, degradations);
57640
+ const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, degradations);
58022
57641
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
58023
57642
  const motionResult = this.buildMotionStage(deviceId);
58024
57643
  const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, pass, degradations);
@@ -58033,11 +57652,10 @@ var CameraStatusService = class {
58033
57652
  switchesFetch
58034
57653
  ]);
58035
57654
  const audioResult = this.buildAudioStage(audioNodeId, switchedOff);
58036
- const liveDecoderNodeId = brokerResult !== null ? liveDecoder.nodeId : null;
58037
- const decoderNodeId = liveDecoderNodeId ?? detectionNodeId;
57655
+ const decoderNodeId = detectionNodeId;
58038
57656
  const reasons = {
58039
57657
  detection: detectionReason,
58040
- decoder: liveDecoderNodeId !== null ? "session" : decoderNodeId !== null ? "co-located" : void 0,
57658
+ decoder: decoderNodeId !== null ? "co-located" : void 0,
58041
57659
  audio: audioPinned ? "manual" : void 0
58042
57660
  };
58043
57661
  return composeCameraStatus({
@@ -60274,7 +59892,7 @@ function deviceAsLoad(deviceKey, load) {
60274
59892
  activeCameras: 0,
60275
59893
  avgInferenceFps: 0,
60276
59894
  avgInferenceTimeMs: 0,
60277
- queueDepthTotal: load?.queueDepthTotal ?? 0,
59895
+ queueDepthTotal: 0,
60278
59896
  devices: [],
60279
59897
  hardware: { hasGpu: false }
60280
59898
  };
@@ -61254,8 +60872,7 @@ function tallyLoad(load, nodeDelta, perDevice) {
61254
60872
  }), ...[...perDevice ?? /* @__PURE__ */ new Map()].filter(([deviceKey, delta]) => !known.has(deviceKey) && delta > 0).map(([deviceKey, delta]) => ({
61255
60873
  deviceKey,
61256
60874
  backend: deviceKey.split(":")[0] ?? deviceKey,
61257
- attachedCameras: delta,
61258
- queueDepthTotal: 0
60875
+ attachedCameras: delta
61259
60876
  }))];
61260
60877
  return {
61261
60878
  ...load,
@@ -64376,8 +63993,8 @@ function migrateCameraSettings(cameraMap) {
64376
63993
  * refused rather than substituted (D54). Reset happens to the STORE, before
64377
63994
  * anything is resolved.
64378
63995
  *
64379
- * Templates and the wholesale `pipelineByAgent` override are not swept: both
64380
- * carry step STRUCTURE only, and provisioning never reads a `modelId` from them.
63996
+ * The wholesale `pipelineByAgent` override is not swept: it carries step
63997
+ * STRUCTURE only, and provisioning never reads a `modelId` from it.
64381
63998
  */
64382
63999
  /** The one predicate: a pin to a deleted catalog id that no custom model owns. */
64383
64000
  function pinNamesRemovedModel(stepId, modelId, isCustom) {
@@ -64626,8 +64243,7 @@ async function redispatchAffected(deps, cleared) {
64626
64243
  /**
64627
64244
  * `orchestrator-store-schemas.ts` — whole-blob Zod schemas for the
64628
64245
  * orchestrator's addon-store keys that persist an ENTIRE collection
64629
- * under a single key (`nodeBindings`, `templates`, `agentSettings`,
64630
- * `cameraSettings`).
64246
+ * under a single key (`nodeBindings`, `agentSettings`, `cameraSettings`).
64631
64247
  *
64632
64248
  * These back the durable-state primitive (`this.state(KEY, Schema, {})`)
64633
64249
  * so a hand-written serializer can never silently drop a field on save:
@@ -64640,32 +64256,7 @@ async function redispatchAffected(deps, cleared) {
64640
64256
  * falls back to the empty map on a whole-blob parse failure, so the
64641
64257
  * schema is kept faithful to the on-disk shape rather than overly strict.
64642
64258
  */
64643
- var EngineChoiceSchema = object({
64644
- runtime: _enum(["python", "node"]).transform(() => "python"),
64645
- backend: string(),
64646
- format: string(),
64647
- device: string().optional()
64648
- });
64649
64259
  var NodeBindingsSchema = record(string(), record(string(), string()));
64650
- var StoredPipelineConfigSchema = object({
64651
- engine: EngineChoiceSchema.optional(),
64652
- steps: array(PipelineStepInputSchema).readonly(),
64653
- audio: object({
64654
- engine: EngineChoiceSchema.optional(),
64655
- modelId: string().optional(),
64656
- enabled: boolean(),
64657
- settings: record(string(), unknown()).readonly().optional()
64658
- }).nullable().optional()
64659
- });
64660
- var StoredPipelineTemplateSchema = object({
64661
- id: string(),
64662
- name: string(),
64663
- description: string().optional(),
64664
- config: StoredPipelineConfigSchema,
64665
- createdAt: string(),
64666
- updatedAt: string()
64667
- });
64668
- var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
64669
64260
  var StoredAgentAddonConfigSchema = object({
64670
64261
  enabled: boolean(),
64671
64262
  modelId: string().optional(),
@@ -64770,7 +64361,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
64770
64361
  blobWriteLock = new KeyedAsyncLock({ isShuttingDown: () => this.disposed });
64771
64362
  static AGENT_SETTINGS_LOCK_KEY = "agentSettings";
64772
64363
  _nodeBindingsState = null;
64773
- _templatesState = null;
64774
64364
  _agentSettingsState = null;
64775
64365
  _cameraSettingsState = null;
64776
64366
  _bindingsMigrationDoneState = null;
@@ -64779,10 +64369,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
64779
64369
  if (!this._nodeBindingsState) this._nodeBindingsState = this.deps.state("nodeBindings", NodeBindingsSchema, {});
64780
64370
  return this._nodeBindingsState;
64781
64371
  }
64782
- get templatesState() {
64783
- if (!this._templatesState) this._templatesState = this.deps.state("templates", TemplatesSchema, {});
64784
- return this._templatesState;
64785
- }
64786
64372
  get agentSettingsState() {
64787
64373
  if (!this._agentSettingsState) this._agentSettingsState = this.deps.state("agentSettings", AgentSettingsMapSchema, {});
64788
64374
  return this._agentSettingsState;
@@ -65065,59 +64651,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
65065
64651
  return rest;
65066
64652
  });
65067
64653
  }
65068
- /** Read the templates map from the addon store via the durable handle. */
65069
- async readTemplatesMap() {
65070
- const raw = await this.templatesState.get();
65071
- const out = {};
65072
- for (const [key, value] of Object.entries(raw)) if (this.isPipelineTemplate(value)) out[key] = value;
65073
- return out;
65074
- }
65075
- async listTemplates() {
65076
- const templates = await this.readTemplatesMap();
65077
- return Object.values(templates).slice().toSorted((a, b) => a.name.localeCompare(b.name));
65078
- }
65079
- async saveTemplate(input) {
65080
- const now = (/* @__PURE__ */ new Date()).toISOString();
65081
- const template = {
65082
- id: this.generateTemplateId(),
65083
- name: input.name,
65084
- description: input.description,
65085
- config: input.config,
65086
- createdAt: now,
65087
- updatedAt: now
65088
- };
65089
- const templates = await this.readTemplatesMap();
65090
- templates[template.id] = template;
65091
- await this.templatesState.set(templates);
65092
- this.deps.logger.info("template saved", { meta: {
65093
- templateId: template.id,
65094
- name: template.name
65095
- } });
65096
- return template;
65097
- }
65098
- async updateTemplate(input) {
65099
- const templates = await this.readTemplatesMap();
65100
- const existing = templates[input.id];
65101
- if (!existing) throw new Error(`updateTemplate: template '${input.id}' not found`);
65102
- const next = {
65103
- ...existing,
65104
- ...input.name !== void 0 ? { name: input.name } : {},
65105
- ...input.description !== void 0 ? { description: input.description } : {},
65106
- ...input.config !== void 0 ? { config: input.config } : {},
65107
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
65108
- };
65109
- templates[input.id] = next;
65110
- await this.templatesState.set(templates);
65111
- return next;
65112
- }
65113
- async deleteTemplate(input) {
65114
- const templates = await this.readTemplatesMap();
65115
- if (!templates[input.id]) return { success: true };
65116
- delete templates[input.id];
65117
- await this.templatesState.set(templates);
65118
- this.deps.logger.info("template deleted", { meta: { templateId: input.id } });
65119
- return { success: true };
65120
- }
65121
64654
  catalogCache = /* @__PURE__ */ new Map();
65122
64655
  /**
65123
64656
  * A node's `PipelineSchema`: the cached answer when fresh, else ONE live
@@ -65679,21 +65212,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
65679
65212
  const v = value;
65680
65213
  return Array.isArray(v["steps"]);
65681
65214
  }
65682
- isPipelineTemplate(value) {
65683
- if (!value || typeof value !== "object") return false;
65684
- const v = value;
65685
- return typeof v["id"] === "string" && typeof v["name"] === "string" && typeof v["createdAt"] === "string" && typeof v["updatedAt"] === "string" && this.isCameraPipelineConfig(v["config"]);
65686
- }
65687
- /**
65688
- * Generate a short template id. Collision risk at N≤1000 templates is
65689
- * effectively zero (12 hex chars = 48 bits of entropy). Not a UUID
65690
- * because the persisted shape is a plain map keyed by id and shorter
65691
- * keys keep the JSON compact.
65692
- */
65693
- generateTemplateId() {
65694
- const rand = Math.random().toString(36).slice(2, 8);
65695
- return `tpl_${Date.now().toString(36).slice(-6)}${rand}`;
65696
- }
65697
65215
  };
65698
65216
  /**
65699
65217
  * The camera's override for one accelerator, with its CAMERA-WIDE settings
@@ -69342,6 +68860,10 @@ async function buildOrchestratorControllers(deps) {
69342
68860
  onCensus: (entries) => {
69343
68861
  motionSliceGate?.noteCensus(entries);
69344
68862
  providerSliceGate?.refreshIfStale("runner-census");
68863
+ },
68864
+ eligibleDeviceKeys: async (nodeId) => {
68865
+ const read = await deps.getEligibleInferenceDevicesIfCached(nodeId);
68866
+ return read.kind === "eligible" ? Object.keys(read.eligible) : null;
69345
68867
  }
69346
68868
  });
69347
68869
  /**
@@ -70200,12 +69722,12 @@ function buildGlobalSettingsSections(options) {
70200
69722
  id: CLUSTER_MODEL_SECTION_ID,
70201
69723
  title: "Enrichment models",
70202
69724
  tab: "pipeline",
70203
- description: "The encoders that turn a crop into a VECTOR, and the admission knobs those encoders share. One value each for the whole cluster, because each writes into a single shared index. Changing a model does not corrupt what is already stored (every vector carries the model that produced it) but it does STRAND it until a re-embed pass has run. Step knobs such as the face-size floor apply to every node for the same reason.",
69725
+ description: "The face and search encoders, one each for the whole cluster: each fills a single shared index, and vectors from two models cannot be compared. Changing one keeps what is stored but hides it from matching and search until it is re-embedded.",
70204
69726
  fields: [...CLUSTER_MODEL_SCOPED_STEPS.map((step) => ({
70205
69727
  key: clusterModelSettingKey(step.stepId),
70206
69728
  type: "select",
70207
69729
  label: step.label,
70208
- description: `Applies to every node. A node whose engine ships no build of the chosen model REFUSES this step and says so in its log, rather than quietly running a different encoder into ${step.indexName}. Changing this strands every vector already in ${step.indexName} until it is re-embedded.`,
69730
+ description: `Used on every node. A node that has no build of this model skips the step (and logs it) rather than use another model. Changing it hides what is already in ${step.indexName} until it is re-embedded.`,
70209
69731
  options: step.options.map((o) => ({
70210
69732
  value: o.id,
70211
69733
  label: o.label
@@ -70227,12 +69749,12 @@ function buildGlobalSettingsSections(options) {
70227
69749
  id: DETAIL_CROP_SECTION_ID,
70228
69750
  title: "Enrichment crop",
70229
69751
  tab: "pipeline",
70230
- description: "How the crop fed to enrichment steps (semantic-search embedding, face, plate) is cut out of the frame. ONE value for the whole cluster: the semantic-search index is a single feature space, and vectors cut with different conventions cannot be compared, so a per-node value would silently corrupt search results. Changing either knob invalidates the stored index — follow it with a rebuild (Camera → Semantic search index → Rebuild), which now cuts through this same setting.",
69752
+ description: "How the crop handed to the search, face and plate models is cut from the frame. One value for the whole cluster, because search results are only comparable when every crop is cut the same way. After changing it, rebuild the search index (Camera → Semantic search index → Rebuild).",
70231
69753
  fields: [{
70232
69754
  key: DETAIL_CROP_PADDING_KEY,
70233
69755
  type: "slider",
70234
69756
  label: "Crop margin",
70235
- description: "Fraction of the detection box added on EACH side before cutting. 0 is the pixel-tight box; 0.15 is the shipped default; 0.2 gives a 1.4x window. CLIP is trained on natural images WITH surroundings, so a tight crop removes exactly the context it is strongest on — but the right value is empirical, which is why it is a knob.",
69757
+ description: "Extra margin added on each side of the detection box, as a fraction of its size. 0 = tight box, 0.15 = default. Search works better with some surroundings in the crop.",
70236
69758
  min: DETAIL_CROP_PADDING_FIELD.min,
70237
69759
  max: DETAIL_CROP_PADDING_FIELD.max,
70238
69760
  step: DETAIL_CROP_PADDING_FIELD.step,
@@ -70242,7 +69764,7 @@ function buildGlobalSettingsSections(options) {
70242
69764
  key: DETAIL_CROP_SQUARE_KEY,
70243
69765
  type: "boolean",
70244
69766
  label: "Square the crop",
70245
- description: "Grow the shorter side so the crop is square before it reaches the model. CLIP's input is square, so a tall subject resized straight to NxN is squashed into a shape the model never saw in training. Off by default because the stored index was built unsquared.",
69767
+ description: "Widen the crop to a square so a tall subject is not squashed when the model resizes it. Off by default because the existing index was built without it.",
70246
69768
  default: DEFAULT_DETAIL_CROP_CONVENTION.square
70247
69769
  }]
70248
69770
  },
@@ -70250,13 +69772,13 @@ function buildGlobalSettingsSections(options) {
70250
69772
  id: NATIVE_LEASE_SECTION_ID,
70251
69773
  title: "Native frame lease",
70252
69774
  tab: "pipeline",
70253
- description: "How a decode worker keeps native pixels available for a LATE crop (post-analysis snapshot, face/plate detail) instead of falling back to the ≤640 detection frame. Two things are kept and they cost very differently: a HELD FRAME is a full native raster (~24.9 MB at 4K on the default pinned-RGB24 path, ~6.2 MB at 1080p) and lives only until its own detection result arrives; a TILE is one subject cut from that frame at native resolution and JPEG-encoded (~60-120 KB at 4K), and lives for tens of seconds. A frame on which nothing was detected produces no tiles and costs nothing. Takes effect on the NEXT decode session for a camera, not on sessions already running.",
69775
+ description: "How each camera keeps full-resolution pixels so a crop taken seconds later (snapshot, face, plate) is sharp instead of cut from the small detection frame. A held frame is a whole picture (~25 MB at 4K) kept until its detections arrive; a tile is one subject, or one whole-scene copy, saved as JPEG for tens of seconds. Applies from the next session of each camera.",
70254
69776
  fields: [
70255
69777
  {
70256
69778
  key: NATIVE_LEASE_HOLD_KEY,
70257
69779
  type: "slider",
70258
69780
  label: "Frames held at once",
70259
- description: "How many delivered frames a worker keeps alive while waiting for their detection results. A frame is freed as soon as its own result comes back and its subject tiles have been cut, so the steady state is inference latency × delivered fps — 1 to 4 frames in practice. This number is only the bound above which the OLDEST held frame is dropped, which is what stops a runner that has stopped answering from pinning RAM. Raising it does not improve crop hit rate; it buys tolerance for a slow runner, and holdOverflow on the metrics line is what tells you that you need it.",
69781
+ description: "Most frames kept per camera while their detections are pending; past it the oldest is dropped. 1 to 4 are in use normally. Raise it only if a slow node drops frames (holdOverflow in the logs) — it does not make crops sharper.",
70260
69782
  min: NATIVE_LEASE_HOLD_FIELD.min,
70261
69783
  max: NATIVE_LEASE_HOLD_FIELD.max,
70262
69784
  step: NATIVE_LEASE_HOLD_FIELD.step,
@@ -70268,7 +69790,7 @@ function buildGlobalSettingsSections(options) {
70268
69790
  key: NATIVE_LEASE_TILE_BUDGET_KEY,
70269
69791
  type: "slider",
70270
69792
  label: "Subject tile RAM",
70271
- description: "RAM per decode worker for the compressed SUBJECT TILES — the native-resolution crops taken at the moment a frame's detections are known, and kept long after the frame itself is gone. This is what serves a crop that arrives seconds late, which measurement says is the ordinary case (2 to 10 seconds on this cluster). At ~60-120 KB a tile, 64 MB is many hundreds of subjects. 0 turns tiles OFF and restores the old behaviour, where a late crop had nothing to fall back to but the ≤640 detection frame.",
69793
+ description: "RAM per camera for subject tiles: full-resolution crops of each detected subject, kept so a crop asked for seconds later is still sharp. A tile is ~60-120 KB. 0 = off (late crops come from the small detection frame).",
70272
69794
  min: NATIVE_LEASE_TILE_BUDGET_FIELD.min,
70273
69795
  max: NATIVE_LEASE_TILE_BUDGET_FIELD.max,
70274
69796
  step: NATIVE_LEASE_TILE_BUDGET_FIELD.step,
@@ -70280,7 +69802,7 @@ function buildGlobalSettingsSections(options) {
70280
69802
  key: NATIVE_LEASE_SCENE_BUDGET_KEY,
70281
69803
  type: "slider",
70282
69804
  label: "Scene tile RAM",
70283
- description: "RAM per decode worker for the SCENE TILES — one JPEG copy of the WHOLE native frame, taken at the same instant as the subject tiles above, and only on frames that detected something. A subject tile cannot answer a request for the whole frame (containment is strict, so that a crop never quietly comes from a different part of the scene), which left the native key frame, the detail plane's full-frame rung and the gallery fallback with nothing to fall back to once the held raster was released — measured at 23.3% of key-frame captures lost, 95.7% of them for exactly that reason. A scene tile is ~1.5-2.5 MB at 4K against ~24 MB for the raster it came from, so this budget buys tens of seconds of coverage where holding the rasters for ten would cost around half a gigabyte per camera. 0 turns scene tiles OFF and restores that gap.",
69805
+ description: "RAM per camera for scene tiles: a full-resolution JPEG of the whole picture (~2 MB at 4K) on frames where something was detected, for key frames and whole-frame crops. 0 = off (those then fall back to the small detection frame).",
70284
69806
  min: NATIVE_LEASE_SCENE_BUDGET_FIELD.min,
70285
69807
  max: NATIVE_LEASE_SCENE_BUDGET_FIELD.max,
70286
69808
  step: NATIVE_LEASE_SCENE_BUDGET_FIELD.step,
@@ -70292,7 +69814,7 @@ function buildGlobalSettingsSections(options) {
70292
69814
  key: NATIVE_LEASE_BUDGET_KEY,
70293
69815
  type: "slider",
70294
69816
  label: "Lease RAM ceiling",
70295
- description: "Hard RAM ceiling for held frames, PER decode worker (one worker per camera per plane). This is a safety ceiling, not the working size — the hold count above is what reclaims frames now, and the ceiling is the number above which something is wrong. Lower it on a small host to bound the worst case. 0 DISABLES the lease entirely and falls the worker back to the tiny GPU surface ring, which misses roughly 85% of late crops — that is the behaviour the lease exists to replace, so 0 is a diagnostic setting, not a tuning one.",
69817
+ description: "Safety ceiling on the RAM held frames may use per camera; the frame count above is what normally limits it. Lower it on a small host. 0 turns full-resolution crops off almost entirely — for diagnosis only.",
70296
69818
  min: NATIVE_LEASE_BUDGET_FIELD.min,
70297
69819
  max: NATIVE_LEASE_BUDGET_FIELD.max,
70298
69820
  step: NATIVE_LEASE_BUDGET_FIELD.step,
@@ -70304,7 +69826,7 @@ function buildGlobalSettingsSections(options) {
70304
69826
  key: NATIVE_LEASE_ACTIVITY_KEY,
70305
69827
  type: "slider",
70306
69828
  label: "Demand window",
70307
- description: "Retention only runs within this long of the last native-crop request (or of the stream dialling). A camera nothing is cropping therefore retains nothing and pays no GPU→RAM download at all; the first crop after the window closes misses once, falls back, and re-opens the window for the retry. 0 means ALWAYS ON — it turns the gate off, it does not turn retention off — which is the legacy behaviour that saturated an N100 with 24 native-4K downloads per second on a camera with zero crop demand. Leave it non-zero unless you are reproducing that.",
69829
+ description: "Keep full-resolution pixels only within this long of the last crop request, so a camera nobody crops costs nothing; the first crop after a quiet spell falls back once. 0 = always keep (heavy on small hosts).",
70308
69830
  min: NATIVE_LEASE_ACTIVITY_FIELD.min,
70309
69831
  max: NATIVE_LEASE_ACTIVITY_FIELD.max,
70310
69832
  step: NATIVE_LEASE_ACTIVITY_FIELD.step,
@@ -70317,7 +69839,7 @@ function buildGlobalSettingsSections(options) {
70317
69839
  key: NATIVE_LEASE_ADMISSION_KEY,
70318
69840
  type: "select",
70319
69841
  label: "What to retain",
70320
- description: "WHICH delivered frames get a native copy. A late crop always names one specific frame, and the only way a frame id leaves the runner is on an inference result — so 'Only frames sent to inference' keeps exactly the set a crop can ever ask for and drops the rest, which at the governor's usual 5-in-25 admit rate is around 80% less retained RAM and 80% less GPU→RAM download. 'Every delivered frame' is the older, unconditional behaviour; choose it if a crop ever has to reach a frame the pipeline never inferred. The decode worker counts any crop that names a non-inferred frame on its metrics line (leaseUnmarkedCrops), so a wrong choice here is visible rather than silent.",
69842
+ description: "Which frames get a full-resolution copy. 'Only frames sent to inference' keeps the frames a crop can actually ask for (about 80% less RAM); 'Every delivered frame' keeps all of them.",
70321
69843
  options: NATIVE_LEASE_ADMISSION_FIELD.options,
70322
69844
  default: NATIVE_LEASE_ADMISSION_FIELD.default
70323
69845
  }
@@ -70330,7 +69852,8 @@ function buildGlobalSettingsSections(options) {
70330
69852
  {
70331
69853
  key: "onDisconnect",
70332
69854
  type: "select",
70333
- label: "On runner disconnect",
69855
+ label: "When a node disconnects",
69856
+ description: "Move its unpinned cameras to other nodes, or leave them stopped.",
70334
69857
  default: DEFAULT_FAILOVER_POLICY.onDisconnect,
70335
69858
  options: [{
70336
69859
  value: "migrate",
@@ -70343,7 +69866,8 @@ function buildGlobalSettingsSections(options) {
70343
69866
  {
70344
69867
  key: "pinnedOnDisconnect",
70345
69868
  type: "select",
70346
- label: "Pinned cameras on disconnect",
69869
+ label: "Its pinned cameras",
69870
+ description: "Keep the pin and wait for the node, or drop the pin and move them.",
70347
69871
  default: DEFAULT_FAILOVER_POLICY.pinnedOnDisconnect,
70348
69872
  options: [{
70349
69873
  value: "leave-pinned",
@@ -70356,7 +69880,8 @@ function buildGlobalSettingsSections(options) {
70356
69880
  {
70357
69881
  key: "onReconnect",
70358
69882
  type: "select",
70359
- label: "On runner reconnect",
69883
+ label: "When it comes back",
69884
+ description: "Put its cameras back where they were, or rebalance the whole cluster.",
70360
69885
  default: DEFAULT_FAILOVER_POLICY.onReconnect,
70361
69886
  options: [{
70362
69887
  value: "restore",
@@ -71853,7 +71378,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
71853
71378
  nodeLifecycle = null;
71854
71379
  /**
71855
71380
  * The 3-level pipeline settings store (agent + camera settings maps,
71856
- * templates, pipeline-executor catalog cache) plus the co-declared
71381
+ * pipeline-executor catalog cache) plus the co-declared
71857
71382
  * `nodeBindings` capability-binding map and the one-shot bindings-
71858
71383
  * migration guard flag — see `pipeline-settings-store.ts` (S4
71859
71384
  * extraction, cluster C11). Constructed early in `onInitialize` (before
@@ -72357,7 +71882,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
72357
71882
  async getAgentLoad() {
72358
71883
  await this.loadService.refreshDecodeHwaccels();
72359
71884
  await this.loadService.collectAgentLoad();
72360
- return this.loadService.getAgentLoadSummaries();
71885
+ return this.loadService.reportedAgentLoad();
72361
71886
  }
72362
71887
  async getGlobalMetrics() {
72363
71888
  const summaries = await this.getAgentLoad();
@@ -73415,18 +72940,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
73415
72940
  const resolved = await this.settingsStore.resolvePipelineForDevice(deviceId);
73416
72941
  this.emitCameraUpdated(deviceId, resolved);
73417
72942
  }
73418
- async listTemplates() {
73419
- return this.settingsStore.listTemplates();
73420
- }
73421
- async saveTemplate(input) {
73422
- return this.settingsStore.saveTemplate(input);
73423
- }
73424
- async updateTemplate(input) {
73425
- return this.settingsStore.updateTemplate(input);
73426
- }
73427
- async deleteTemplate(input) {
73428
- return this.settingsStore.deleteTemplate(input);
73429
- }
73430
72943
  /**
73431
72944
  * The per-camera FUNCTION SWITCH group (D61) — derived, never stored.
73432
72945
  *