@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.mjs CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- //#region ../types/dist/event-category-BGmz_oxW.mjs
5
+ //#region ../types/dist/event-category-B_0XEcLF.mjs
6
6
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
7
7
  EventCategory["SystemBoot"] = "system.boot";
8
8
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -503,15 +503,6 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
503
503
  EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
504
504
  EventCategory["AgentBackupActivated"] = "agent.backup.activated";
505
505
  EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
506
- /**
507
- * Per-agent hwaccel preference changed (user override set, cleared, or
508
- * re-probed). Observability event — decoders pull the current pref at
509
- * `createSession`, so running sessions keep their current backend until
510
- * they rotate naturally (camera add/remove, stream restart). Future
511
- * work can wire a listener in stream-broker that force-rotates live
512
- * sessions; for now this event powers logs + admin-UI toast feedback.
513
- */
514
- EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
515
506
  EventCategory["MotionAnalysis"] = "detection.motion-analysis";
516
507
  /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
517
508
  EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
@@ -5382,7 +5373,7 @@ var ZodIssueCode = {
5382
5373
  var ZodFirstPartyTypeKind;
5383
5374
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5384
5375
  //#endregion
5385
- //#region ../types/dist/sleep-DAJ9Er57.mjs
5376
+ //#region ../types/dist/sleep-BNeXenvr.mjs
5386
5377
  /**
5387
5378
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5388
5379
  * window to float samples (D455).
@@ -6781,32 +6772,12 @@ object({
6781
6772
  audioCodec: string().optional(),
6782
6773
  metadata: record(string(), unknown()).readonly().optional()
6783
6774
  });
6784
- var EncodedPacketSchema = object({
6785
- type: _enum(["video", "audio"]),
6786
- data: _instanceof(Uint8Array),
6787
- pts: number(),
6788
- dts: number(),
6789
- keyframe: boolean(),
6790
- codec: string()
6791
- });
6792
- var DecodedFrameSchema = object({
6793
- data: _instanceof(Uint8Array),
6794
- width: number(),
6795
- height: number(),
6796
- format: _enum([
6797
- "jpeg",
6798
- "rgb",
6799
- "bgr",
6800
- "yuv420",
6801
- "gray"
6802
- ]),
6803
- timestamp: number()
6804
- });
6805
6775
  /**
6806
6776
  * Wire schema for `FrameHandle` — a zero-pixel, serialisable reference to a
6807
6777
  * frame in a shared-memory ring (Phase 5 / D9). Mirrors the `FrameHandle` TS
6808
- * interface in `interfaces/frame-handle.ts` field-for-field so a decoder cap
6809
- * method (`pullHandles`) can carry handles over tRPC / Moleculer.
6778
+ * interface in `interfaces/frame-handle.ts` field-for-field so a cap method
6779
+ * (the runner's native-crop and pipeline-executor inputs) can carry handles
6780
+ * over tRPC / Moleculer.
6810
6781
  *
6811
6782
  * The `satisfies` assertion below pins this schema to the interface: if a
6812
6783
  * field is added to / removed from `FrameHandle` without a matching schema
@@ -6832,50 +6803,6 @@ var FrameHandleSchema = object({
6832
6803
  slotCount: number().int().positive()
6833
6804
  });
6834
6805
  /**
6835
- * Pixel format a frame-handle subscriber can request from the broker
6836
- * (Phase 5 / D9). The packed, raster subset of `FrameFormat` — the formats a
6837
- * `frameSink: 'shm'` decoder session can write into a `FrameRing` slot.
6838
- * `jpeg` is excluded: a variable-length stream is not a fixed-stride ring
6839
- * payload, and no decoded-frame consumer requests it over the shm plane.
6840
- */
6841
- var FrameHandleFormatSchema = _enum([
6842
- "rgb",
6843
- "bgr",
6844
- "yuv420",
6845
- "gray"
6846
- ]);
6847
- /**
6848
- * Input for `stream-broker.subscribeFrames` (Phase 5 / D9). A consumer asks the
6849
- * broker for a `FrameHandle` stream of one `format`; the broker maintains one
6850
- * shared-memory ring per `(brokerId, format)` actually requested, so a `gray`
6851
- * subscriber (motion) and an `rgb` subscriber (detection) each read the format
6852
- * they asked for with no broker-side conversion.
6853
- */
6854
- var SubscribeFramesInputSchema = object({
6855
- brokerId: string(),
6856
- format: FrameHandleFormatSchema,
6857
- /**
6858
- * Optional reader-side cadence hint in frames per second. The broker does
6859
- * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
6860
- * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
6861
- * the consumer can pace its own `pullFrameHandles` polling.
6862
- */
6863
- maxFps: number().positive().optional(),
6864
- /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
6865
- tag: string().optional()
6866
- });
6867
- /**
6868
- * Result of `stream-broker.subscribeFrames`. The consumer then polls
6869
- * `pullFrameHandles({ subscriptionId, maxCount })` and feeds each returned
6870
- * `FrameHandle` to a `FrameRingReader`.
6871
- */
6872
- var SubscribeFramesResultSchema = object({
6873
- /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
6874
- subscriptionId: string(),
6875
- /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
6876
- maxFps: number().nonnegative()
6877
- });
6878
- /**
6879
6806
  * Wire schema for a decoded audio chunk (Phase 5 / D9). Mirrors the
6880
6807
  * `DecodedAudioChunk` TS interface in `interfaces/stream-broker.ts`: PCM
6881
6808
  * sample bytes plus the track parameters a consumer needs to interpret them.
@@ -6883,8 +6810,8 @@ var SubscribeFramesResultSchema = object({
6883
6810
  * Audio chunks are tiny (a ~500ms AudioCodecSession window is a few KB of
6884
6811
  * Float32 PCM), so unlike video frames they ship their bytes INLINE over
6885
6812
  * tRPC / Moleculer — no shared-memory plane. `data` is typed `Uint8Array`
6886
- * (the wire-serialisable supertype of `Buffer`) to match `DecodedFrameSchema`
6887
- * / `EncodedPacketSchema`'s precedent; a `Buffer` is assignable to it.
6813
+ * (the wire-serialisable supertype of `Buffer`); a `Buffer` is assignable to
6814
+ * it.
6888
6815
  */
6889
6816
  var AudioChunkFormatSchema = _enum(AUDIO_CHUNK_FORMATS);
6890
6817
  var DecodedAudioChunkSchema = object({
@@ -6902,11 +6829,10 @@ var DecodedAudioChunkSchema = object({
6902
6829
  format: AudioChunkFormatSchema.optional()
6903
6830
  });
6904
6831
  /**
6905
- * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). The
6906
- * handle-free, ship-bytes-inline analogue of `subscribeFrames`: a consumer
6907
- * asks the broker for the decoded audio-chunk stream of one broker; the
6908
- * broker keeps a per-subscription bounded FIFO queue the consumer drains via
6909
- * `pullAudioChunks`.
6832
+ * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). Audio ships
6833
+ * its bytes inline: a consumer asks the broker for the decoded audio-chunk
6834
+ * stream of one broker; the broker keeps a per-subscription bounded FIFO queue
6835
+ * the consumer drains via `pullAudioChunks`.
6910
6836
  */
6911
6837
  var SubscribeAudioChunksInputSchema = object({
6912
6838
  brokerId: string(),
@@ -6937,9 +6863,7 @@ var BrokerStatsSchema = object({
6937
6863
  "stopped"
6938
6864
  ]),
6939
6865
  inputFps: number(),
6940
- decodeFps: number(),
6941
6866
  encodedSubscribers: number(),
6942
- decodedSubscribers: number(),
6943
6867
  uptimeMs: number(),
6944
6868
  bitrateKbps: number(),
6945
6869
  idrIntervalMs: number(),
@@ -6952,14 +6876,6 @@ var BrokerStatsSchema = object({
6952
6876
  preBufferMs: number(),
6953
6877
  preBufferPackets: number(),
6954
6878
  /**
6955
- * Moleculer node id of the decoder provider currently servicing this
6956
- * stream's decoded subscribers. `null` until the deferred decoder is
6957
- * created (no decoded clients yet, or codec not detected). Surfaces the
6958
- * runtime decoder placement so the UI can show "Decoder: <agent>" without
6959
- * a separate cap call per broker.
6960
- */
6961
- decoderNodeId: string().nullable(),
6962
- /**
6963
6879
  * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
6964
6880
  * when the stream has no audio track or the broker is in cold start.
6965
6881
  * `supported = false` means the codec was detected but the local
@@ -11297,70 +11213,6 @@ var StorageLocationDeclarationSchema = object({
11297
11213
  "backup"
11298
11214
  ]).optional()
11299
11215
  });
11300
- var DecoderStatsSchema = object({
11301
- inputFps: number(),
11302
- outputFps: number(),
11303
- avgDecodeTimeMs: number(),
11304
- droppedFrames: number(),
11305
- /**
11306
- * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
11307
- * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
11308
- * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
11309
- * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
11310
- */
11311
- lagMs: number().optional(),
11312
- effectiveFps: number().optional(),
11313
- adaptiveFps: number().optional()
11314
- });
11315
- var DecoderSessionConfigSchema = object({
11316
- codec: string(),
11317
- maxFps: number().default(0),
11318
- outputFormat: _enum([
11319
- "jpeg",
11320
- "rgb",
11321
- "bgr",
11322
- "yuv420",
11323
- "gray"
11324
- ]).default("jpeg"),
11325
- scale: number().default(1),
11326
- width: number().optional(),
11327
- height: number().optional(),
11328
- /**
11329
- * Identifier of the camera this decoder session serves. Optional
11330
- * because the cap is generic (any caller could request decode), but
11331
- * stream-broker passes it so decoder logs include `deviceId` for
11332
- * per-camera filtering when diagnosing failures (e.g. node-av
11333
- * sendPacket errors on a single hung camera).
11334
- */
11335
- deviceId: number().int().nonnegative().optional(),
11336
- /**
11337
- * Free-form tag for log scoping. Stream-broker uses
11338
- * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
11339
- * on every line so `grep tag=broker:5/high` filters one camera
11340
- * profile cleanly.
11341
- */
11342
- tag: string().optional(),
11343
- /**
11344
- * Where the session delivers decoded frames (Phase 5 / D9):
11345
- *
11346
- * - `'callback'` (default) — the legacy pixel path: decoded frames are
11347
- * buffered as `DecodedFrame`s and drained via `pullFrames`.
11348
- * - `'shm'` — the shared-memory frame plane: decoded frames are written
11349
- * into an OS shared-memory ring and drained as zero-pixel
11350
- * `FrameHandle`s via `pullHandles`. A session is one mode or the
11351
- * other — `pullFrames` returns nothing for an `'shm'` session and
11352
- * `pullHandles` returns nothing for a `'callback'` session.
11353
- */
11354
- frameSink: _enum(["callback", "shm"]).default("callback"),
11355
- /**
11356
- * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
11357
- * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
11358
- * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
11359
- * stream-broker's `streamingDebug` gate — off by default so production logs
11360
- * stay quiet and the emit path pays zero per-frame cost when disabled.
11361
- */
11362
- debug: boolean().optional()
11363
- });
11364
11216
  /**
11365
11217
  * Per-component log CHANNELS — the gate a hot path consults, and the registry
11366
11218
  * an addon declares its channels in.
@@ -13564,13 +13416,6 @@ var BrokerRtspClientSchema = object({
13564
13416
  lastRtpAt: number(),
13565
13417
  bytesSent: number()
13566
13418
  });
13567
- var BrokerDecodedClientSchema = object({
13568
- tag: string(),
13569
- subscribedAt: number(),
13570
- maxFps: number(),
13571
- framesDelivered: number(),
13572
- framesDropped: number()
13573
- });
13574
13419
  var BrokerAudioClientSchema = object({
13575
13420
  tag: string(),
13576
13421
  subscribedAt: number(),
@@ -13662,7 +13507,6 @@ var BrokerEncodedClientSchema = object({
13662
13507
  });
13663
13508
  var BrokerClientsSchema = object({
13664
13509
  rtsp: array(BrokerRtspClientSchema).readonly(),
13665
- decoded: array(BrokerDecodedClientSchema).readonly(),
13666
13510
  audio: array(BrokerAudioClientSchema).readonly(),
13667
13511
  encoded: array(BrokerEncodedClientSchema).readonly(),
13668
13512
  pipeClients: number(),
@@ -14031,11 +13875,7 @@ method(object({
14031
13875
  auth: "admin"
14032
13876
  }), method(object({ brokerId: string() }), BrokerClientsSchema), method(object({
14033
13877
  brokerId: string(),
14034
- channel: _enum([
14035
- "rtsp",
14036
- "decoded",
14037
- "audio"
14038
- ]),
13878
+ channel: _enum(["rtsp", "audio"]),
14039
13879
  handle: string()
14040
13880
  }), object({ killed: boolean() }), {
14041
13881
  kind: "mutation",
@@ -14070,10 +13910,7 @@ method(object({
14070
13910
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
14071
13911
  subscriptionId: string(),
14072
13912
  maxCount: number().int().positive().default(8)
14073
- }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(SubscribeFramesInputSchema, SubscribeFramesResultSchema, { kind: "mutation" }), method(object({
14074
- subscriptionId: string(),
14075
- maxCount: number().int().positive().default(4)
14076
- }), array(FrameHandleSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
13913
+ }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
14077
13914
  brokerId: string(),
14078
13915
  seconds: number().min(0).max(30)
14079
13916
  }), _void(), {
@@ -15230,54 +15067,12 @@ id: union([string(), number()]) }), {
15230
15067
  kind: "mutation",
15231
15068
  auth: "admin"
15232
15069
  });
15233
- /**
15234
- * shm ring usage stats for a `frameSink: 'shm'` decoder session —
15235
- * exposed via `decoder.getShmStats` so downstream consumers can
15236
- * observe ring pressure (slot count, byte budget, hit/miss ratio).
15237
- */
15238
- var ShmRingStatsSchema = object({
15239
- sessionId: string(),
15240
- slotCount: number().int(),
15241
- slotByteLength: number().int(),
15242
- segmentBytes: number().int(),
15243
- budgetMb: number().int(),
15244
- framesWritten: number().int(),
15245
- getFrameHits: number().int(),
15246
- getFrameMisses: number().int()
15247
- });
15248
- method(object({ codec: string() }), boolean()), method(_void(), object({
15070
+ method(_void(), object({
15249
15071
  id: string(),
15250
15072
  name: string(),
15251
- isPullMode: boolean().optional(),
15252
- priority: number().optional(),
15253
15073
  hwaccel: string().optional(),
15254
15074
  probedBestHwaccel: string().optional()
15255
- })), method(DecoderSessionConfigSchema, object({
15256
- sessionId: string(),
15257
- nodeId: string()
15258
- })), method(object({ sessionId: string() }), _void()), method(object({
15259
- sessionId: string(),
15260
- packet: EncodedPacketSchema
15261
- }), _void()), method(object({
15262
- sessionId: string(),
15263
- url: string()
15264
- }), _void()), method(object({
15265
- sessionId: string(),
15266
- maxCount: number().default(1),
15267
- waitMs: number().optional()
15268
- }), array(DecodedFrameSchema)), method(object({
15269
- sessionId: string(),
15270
- maxCount: number().default(1),
15271
- waitMs: number().optional()
15272
- }), array(FrameHandleSchema)), method(object({ handle: FrameHandleSchema }), DecodedFrameSchema.nullable()), method(object({ sessionId: string() }), ShmRingStatsSchema.nullable()), method(object({
15273
- sessionId: string(),
15274
- config: DecoderSessionConfigSchema.partial()
15275
- }), _void()), method(object({ sessionId: string() }), DecoderStatsSchema), method(_void(), array(object({
15276
- sessionId: string(),
15277
- codec: string(),
15278
- outputFormat: string(),
15279
- createdAtMs: number()
15280
- })).readonly()), method(_void(), object({ backend: string() }), {
15075
+ })), method(_void(), object({ backend: string() }), {
15281
15076
  kind: "mutation",
15282
15077
  auth: "admin"
15283
15078
  });
@@ -22347,8 +22142,8 @@ function interpretStoredClusterModel(raw) {
22347
22142
  var CLUSTER_STEP_SETTING_FIELDS = [{
22348
22143
  stepId: "face-embedding",
22349
22144
  key: "minLandmarkFaceSize",
22350
- label: "Min face size for recognition (detection px)",
22351
- 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.",
22145
+ label: "Min face size to embed (detection-frame px)",
22146
+ 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.",
22352
22147
  type: "slider",
22353
22148
  min: 0,
22354
22149
  max: 64,
@@ -24400,21 +24195,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
24400
24195
  group: string().optional(),
24401
24196
  settings: record(string(), unknown()).optional()
24402
24197
  }));
24403
- var PipelineTemplateStepSchema = lazy(() => object({
24404
- addonId: string(),
24405
- enabled: boolean(),
24406
- modelId: string(),
24407
- children: array(PipelineTemplateStepSchema).readonly(),
24408
- settings: record(string(), unknown()).optional()
24409
- }));
24410
- var PipelineTemplateSchema$1 = object({
24411
- id: string(),
24412
- name: string(),
24413
- createdAt: string(),
24414
- updatedAt: string(),
24415
- engine: PipelineEngineChoiceSchema,
24416
- steps: array(PipelineTemplateStepSchema).readonly()
24417
- });
24418
24198
  var PipelineModelOptionSchema = object({
24419
24199
  id: string(),
24420
24200
  name: string(),
@@ -24736,18 +24516,6 @@ var pipelineExecutorCapability = {
24736
24516
  steps: array(PipelineStepInputSchema),
24737
24517
  deviceKey: string().optional()
24738
24518
  }), PipelineValidationResultSchema),
24739
- listTemplates: method(_void(), array(PipelineTemplateSchema$1).readonly()),
24740
- saveTemplate: method(object({
24741
- name: string(),
24742
- steps: array(PipelineTemplateStepSchema).readonly(),
24743
- engine: PipelineEngineChoiceSchema
24744
- }), PipelineTemplateSchema$1, { kind: "mutation" }),
24745
- updateTemplate: method(object({
24746
- id: string(),
24747
- name: string().optional(),
24748
- steps: array(PipelineTemplateStepSchema).readonly().optional()
24749
- }), PipelineTemplateSchema$1, { kind: "mutation" }),
24750
- deleteTemplate: method(object({ id: string() }), _void(), { kind: "mutation" }),
24751
24519
  getCapabilities: method(_void(), InferenceCapabilitiesBridge),
24752
24520
  getAddonModels: method(object({ addonId: string() }), ModelAvailabilityListBridge),
24753
24521
  downloadModel: method(object({
@@ -26507,14 +26275,15 @@ var RunnerLocalLoadSchema = object({
26507
26275
  * Per-inference-device live load (multi-device C4). One entry per deviceKey
26508
26276
  * this runner currently has attached cameras on, so the orchestrator's second
26509
26277
  * `balance()` pass (over a node's devices) weights on real per-pool session
26510
- * counts. Empty on single-device / pre-multi-device runners. `queueDepthTotal`
26511
- * per device is 0 until the pool backlog gets a public accessor (follow-up).
26278
+ * counts. Empty on single-device / pre-multi-device runners. There is no
26279
+ * per-device queue depth: the inference pool's backlog has no accessor, and
26280
+ * a field that always read 0 was removed rather than shown as a measurement
26281
+ * (D762).
26512
26282
  */
26513
26283
  devices: array(object({
26514
26284
  deviceKey: string(),
26515
26285
  backend: string(),
26516
- attachedCameras: number(),
26517
- queueDepthTotal: number()
26286
+ attachedCameras: number()
26518
26287
  })).default([]),
26519
26288
  /** Hardware capability flags reported by this node. */
26520
26289
  hardware: object({
@@ -26728,14 +26497,6 @@ var CameraPipelineConfigSchema = object({
26728
26497
  settings: record(string(), unknown()).readonly().optional()
26729
26498
  }).nullable().optional()
26730
26499
  });
26731
- var PipelineTemplateSchema = object({
26732
- id: string(),
26733
- name: string(),
26734
- description: string().optional(),
26735
- config: CameraPipelineConfigSchema,
26736
- createdAt: string(),
26737
- updatedAt: string()
26738
- });
26739
26500
  var DeviceStepConfigSchema = object({
26740
26501
  modelId: string().optional(),
26741
26502
  settings: record(string(), unknown()).optional(),
@@ -26845,7 +26606,10 @@ var PipelineAssignmentSchema = object({
26845
26606
  });
26846
26607
  /**
26847
26608
  * Per-agent load summary surfaced to the load balancer + dashboards.
26848
- * Aggregated from each runner's `getLocalLoad` cap call.
26609
+ * Aggregated from each runner's `getLocalLoad` cap call. In the
26610
+ * `getAgentLoad` report, a single-device node's default-pool sessions (no
26611
+ * `deviceKey`) are credited to its one eligible device in `load.devices` —
26612
+ * the default pool IS that device (D745); the balancer's census stays raw.
26849
26613
  */
26850
26614
  var AgentLoadSummarySchema = object({
26851
26615
  nodeId: string(),
@@ -26934,26 +26698,27 @@ var CameraBrokerStatusSchema = object({
26934
26698
  width: number(),
26935
26699
  height: number(),
26936
26700
  subscribers: number(),
26937
- inFps: number(),
26938
- outFps: number()
26701
+ /**
26702
+ * Encoded packets/s the broker receives from the camera. null when the
26703
+ * broker's stats read failed — unknown, never 0 (a 0 here reads as a stall).
26704
+ * There is no output rate: decoding runs in the session decode workers, not
26705
+ * in the broker, so the broker's own decoded-frame rate is 0 on every live
26706
+ * camera and is not served (D758).
26707
+ */
26708
+ inFps: number().nullable()
26939
26709
  })).readonly(),
26940
26710
  webrtcSessions: number(),
26941
26711
  rtspRestream: boolean()
26942
26712
  });
26943
- /** Shared-memory ring statistics within the decoder block. */
26944
- var CameraDecoderShmSchema = object({
26945
- framesWritten: number(),
26946
- getFrameHits: number(),
26947
- getFrameMisses: number(),
26948
- budgetMb: number()
26949
- });
26950
- /** Decoder block — null when the decoder stage is unreachable or inactive. */
26951
- var CameraDecoderStatusSchema = object({
26952
- nodeId: string(),
26953
- formats: array(string()).readonly(),
26954
- sessionCount: number(),
26955
- shm: CameraDecoderShmSchema
26956
- });
26713
+ /**
26714
+ * Decoder block — WHERE decode runs (co-located with the detection node by
26715
+ * construction); null when the camera has no detection node. It carries no
26716
+ * frame counter: the shared-memory plane whose counters it used to serve is
26717
+ * gone, and those fields read 0 forever. Whether frames actually flow is read
26718
+ * from `detection.actualFps` (frames delivered to and processed by the
26719
+ * detector), never from a counter nothing feeds.
26720
+ */
26721
+ var CameraDecoderStatusSchema = object({ nodeId: string() });
26957
26722
  /** Motion block — null when motion detection is not active for this device. */
26958
26723
  var CameraMotionStatusSchema = object({
26959
26724
  enabled: boolean(),
@@ -27807,33 +27572,7 @@ var pipelineOrchestratorCapability = {
27807
27572
  auth: "admin"
27808
27573
  }),
27809
27574
  /** Snapshot of the in-flight or last disk-wins fleet reconcile. */
27810
- getReconcileFromDiskStatus: method(_void(), DiskReconcileJobSchema, { auth: "admin" }),
27811
- /** List every template the operator has saved. */
27812
- listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
27813
- /** Create a new named preset from a given CameraPipelineConfig. */
27814
- saveTemplate: method(object({
27815
- name: string(),
27816
- description: string().optional(),
27817
- config: CameraPipelineConfigSchema
27818
- }), PipelineTemplateSchema, {
27819
- kind: "mutation",
27820
- auth: "admin"
27821
- }),
27822
- /** Partial update of a template. Any omitted field is preserved. */
27823
- updateTemplate: method(object({
27824
- id: string(),
27825
- name: string().optional(),
27826
- description: string().optional(),
27827
- config: CameraPipelineConfigSchema.optional()
27828
- }), PipelineTemplateSchema, {
27829
- kind: "mutation",
27830
- auth: "admin"
27831
- }),
27832
- /** Remove a template. Camera configs that were seeded from it keep a copy. */
27833
- deleteTemplate: method(object({ id: string() }), object({ success: literal(true) }), {
27834
- kind: "mutation",
27835
- auth: "admin"
27836
- })
27575
+ getReconcileFromDiskStatus: method(_void(), DiskReconcileJobSchema, { auth: "admin" })
27837
27576
  }
27838
27577
  };
27839
27578
  /**
@@ -30055,8 +29794,6 @@ var WebrtcStreamChoiceSchema = object({
30055
29794
  ]).nullable(),
30056
29795
  /** Input FPS from the camera (null for adaptive). */
30057
29796
  inputFps: number().nullable(),
30058
- /** Decoded FPS (null for adaptive). */
30059
- decodeFps: number().nullable(),
30060
29797
  /** Bitrate in kbps (null for adaptive). */
30061
29798
  bitrateKbps: number().nullable()
30062
29799
  });
@@ -40302,90 +40039,18 @@ Object.freeze({
40302
40039
  addonId: null,
40303
40040
  access: "create"
40304
40041
  },
40305
- "decoder.createSession": {
40306
- capName: "decoder",
40307
- capScope: "system",
40308
- addonId: null,
40309
- access: "view"
40310
- },
40311
- "decoder.destroySession": {
40312
- capName: "decoder",
40313
- capScope: "system",
40314
- addonId: null,
40315
- access: "delete"
40316
- },
40317
- "decoder.getFrame": {
40318
- capName: "decoder",
40319
- capScope: "system",
40320
- addonId: null,
40321
- access: "view"
40322
- },
40323
40042
  "decoder.getInfo": {
40324
40043
  capName: "decoder",
40325
40044
  capScope: "system",
40326
40045
  addonId: null,
40327
40046
  access: "view"
40328
40047
  },
40329
- "decoder.getShmStats": {
40330
- capName: "decoder",
40331
- capScope: "system",
40332
- addonId: null,
40333
- access: "view"
40334
- },
40335
- "decoder.getStats": {
40336
- capName: "decoder",
40337
- capScope: "system",
40338
- addonId: null,
40339
- access: "view"
40340
- },
40341
- "decoder.listActiveSessions": {
40342
- capName: "decoder",
40343
- capScope: "system",
40344
- addonId: null,
40345
- access: "view"
40346
- },
40347
- "decoder.openStream": {
40348
- capName: "decoder",
40349
- capScope: "system",
40350
- addonId: null,
40351
- access: "view"
40352
- },
40353
- "decoder.pullFrames": {
40354
- capName: "decoder",
40355
- capScope: "system",
40356
- addonId: null,
40357
- access: "view"
40358
- },
40359
- "decoder.pullHandles": {
40360
- capName: "decoder",
40361
- capScope: "system",
40362
- addonId: null,
40363
- access: "view"
40364
- },
40365
- "decoder.pushPacket": {
40366
- capName: "decoder",
40367
- capScope: "system",
40368
- addonId: null,
40369
- access: "view"
40370
- },
40371
40048
  "decoder.reprobeHwaccel": {
40372
40049
  capName: "decoder",
40373
40050
  capScope: "system",
40374
40051
  addonId: null,
40375
40052
  access: "create"
40376
40053
  },
40377
- "decoder.supportsCodec": {
40378
- capName: "decoder",
40379
- capScope: "system",
40380
- addonId: null,
40381
- access: "view"
40382
- },
40383
- "decoder.updateConfig": {
40384
- capName: "decoder",
40385
- capScope: "system",
40386
- addonId: null,
40387
- access: "view"
40388
- },
40389
40054
  "detectionPipeline.applyDeviceSettingsPatch": {
40390
40055
  capName: "detection-pipeline",
40391
40056
  capScope: "device",
@@ -43476,12 +43141,6 @@ Object.freeze({
43476
43141
  addonId: null,
43477
43142
  access: "delete"
43478
43143
  },
43479
- "pipelineExecutor.deleteTemplate": {
43480
- capName: "pipeline-executor",
43481
- capScope: "system",
43482
- addonId: null,
43483
- access: "delete"
43484
- },
43485
43144
  "pipelineExecutor.downloadModel": {
43486
43145
  capName: "pipeline-executor",
43487
43146
  capScope: "system",
@@ -43620,12 +43279,6 @@ Object.freeze({
43620
43279
  addonId: null,
43621
43280
  access: "view"
43622
43281
  },
43623
- "pipelineExecutor.listTemplates": {
43624
- capName: "pipeline-executor",
43625
- capScope: "system",
43626
- addonId: null,
43627
- access: "view"
43628
- },
43629
43282
  "pipelineExecutor.rearmInferenceDevice": {
43630
43283
  capName: "pipeline-executor",
43631
43284
  capScope: "system",
@@ -43650,12 +43303,6 @@ Object.freeze({
43650
43303
  addonId: null,
43651
43304
  access: "create"
43652
43305
  },
43653
- "pipelineExecutor.saveTemplate": {
43654
- capName: "pipeline-executor",
43655
- capScope: "system",
43656
- addonId: null,
43657
- access: "create"
43658
- },
43659
43306
  "pipelineExecutor.setVideoPipelineSteps": {
43660
43307
  capName: "pipeline-executor",
43661
43308
  capScope: "system",
@@ -43674,12 +43321,6 @@ Object.freeze({
43674
43321
  addonId: null,
43675
43322
  access: "create"
43676
43323
  },
43677
- "pipelineExecutor.updateTemplate": {
43678
- capName: "pipeline-executor",
43679
- capScope: "system",
43680
- addonId: null,
43681
- access: "create"
43682
- },
43683
43324
  "pipelineExecutor.validatePipeline": {
43684
43325
  capName: "pipeline-executor",
43685
43326
  capScope: "system",
@@ -43710,12 +43351,6 @@ Object.freeze({
43710
43351
  addonId: null,
43711
43352
  access: "create"
43712
43353
  },
43713
- "pipelineOrchestrator.deleteTemplate": {
43714
- capName: "pipeline-orchestrator",
43715
- capScope: "system",
43716
- addonId: null,
43717
- access: "delete"
43718
- },
43719
43354
  "pipelineOrchestrator.getAgentLoad": {
43720
43355
  capName: "pipeline-orchestrator",
43721
43356
  capScope: "system",
@@ -43866,12 +43501,6 @@ Object.freeze({
43866
43501
  addonId: null,
43867
43502
  access: "view"
43868
43503
  },
43869
- "pipelineOrchestrator.listTemplates": {
43870
- capName: "pipeline-orchestrator",
43871
- capScope: "system",
43872
- addonId: null,
43873
- access: "view"
43874
- },
43875
43504
  "pipelineOrchestrator.pauseForStorageMigration": {
43876
43505
  capName: "pipeline-orchestrator",
43877
43506
  capScope: "system",
@@ -43926,12 +43555,6 @@ Object.freeze({
43926
43555
  addonId: null,
43927
43556
  access: "create"
43928
43557
  },
43929
- "pipelineOrchestrator.saveTemplate": {
43930
- capName: "pipeline-orchestrator",
43931
- capScope: "system",
43932
- addonId: null,
43933
- access: "create"
43934
- },
43935
43558
  "pipelineOrchestrator.setAgentCapabilities": {
43936
43559
  capName: "pipeline-orchestrator",
43937
43560
  capScope: "system",
@@ -44040,12 +43663,6 @@ Object.freeze({
44040
43663
  addonId: null,
44041
43664
  access: "create"
44042
43665
  },
44043
- "pipelineOrchestrator.updateTemplate": {
44044
- capName: "pipeline-orchestrator",
44045
- capScope: "system",
44046
- addonId: null,
44047
- access: "create"
44048
- },
44049
43666
  "pipelineRunner.attachCamera": {
44050
43667
  capName: "pipeline-runner",
44051
43668
  capScope: "system",
@@ -45480,12 +45097,6 @@ Object.freeze({
45480
45097
  addonId: null,
45481
45098
  access: "view"
45482
45099
  },
45483
- "streamBroker.pullFrameHandles": {
45484
- capName: "stream-broker",
45485
- capScope: "system",
45486
- addonId: null,
45487
- access: "view"
45488
- },
45489
45100
  "streamBroker.regenerateRtspToken": {
45490
45101
  capName: "stream-broker",
45491
45102
  capScope: "system",
@@ -45552,12 +45163,6 @@ Object.freeze({
45552
45163
  addonId: null,
45553
45164
  access: "create"
45554
45165
  },
45555
- "streamBroker.subscribeFrames": {
45556
- capName: "stream-broker",
45557
- capScope: "system",
45558
- addonId: null,
45559
- access: "create"
45560
- },
45561
45166
  "streamBroker.unassignProfile": {
45562
45167
  capName: "stream-broker",
45563
45168
  capScope: "system",
@@ -45570,12 +45175,6 @@ Object.freeze({
45570
45175
  addonId: null,
45571
45176
  access: "create"
45572
45177
  },
45573
- "streamBroker.unsubscribeFrames": {
45574
- capName: "stream-broker",
45575
- capScope: "system",
45576
- addonId: null,
45577
- access: "create"
45578
- },
45579
45178
  "streamCatalog.getCatalog": {
45580
45179
  capName: "stream-catalog",
45581
45180
  capScope: "device",
@@ -48117,11 +47716,6 @@ Object.freeze({
48117
47716
  form: "single",
48118
47717
  optional: false
48119
47718
  }],
48120
- "decoder.createSession": [{
48121
- name: "deviceId",
48122
- form: "single",
48123
- optional: true
48124
- }],
48125
47719
  "detectionPipeline.applyDeviceSettingsPatch": [{
48126
47720
  name: "deviceId",
48127
47721
  form: "single",
@@ -54838,6 +54432,40 @@ var LoadShedController = class LoadShedController {
54838
54432
  }
54839
54433
  };
54840
54434
  //#endregion
54435
+ //#region src/default-pool-attribution.ts
54436
+ /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
54437
+ function backendOf(deviceKey) {
54438
+ const colon = deviceKey.indexOf(":");
54439
+ return colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
54440
+ }
54441
+ /**
54442
+ * `load` with its unattributed sessions credited to `eligibleKeys[0]` when that
54443
+ * is the node's ONLY eligible device; `load` itself (same reference) otherwise.
54444
+ * `eligibleKeys` null = the eligible set is not known.
54445
+ */
54446
+ function attributeDefaultPoolSessions(load, eligibleKeys) {
54447
+ if (eligibleKeys === null || eligibleKeys.length !== 1) return load;
54448
+ const onlyKey = eligibleKeys[0];
54449
+ if (onlyKey === void 0) return load;
54450
+ const attributed = load.devices.reduce((sum, d) => sum + d.attachedCameras, 0);
54451
+ const unattributed = load.attachedCameras - attributed;
54452
+ if (unattributed <= 0) return load;
54453
+ const existing = load.devices.find((d) => d.deviceKey === onlyKey);
54454
+ const credited = existing ? {
54455
+ ...existing,
54456
+ attachedCameras: existing.attachedCameras + unattributed
54457
+ } : {
54458
+ deviceKey: onlyKey,
54459
+ backend: backendOf(onlyKey),
54460
+ attachedCameras: unattributed
54461
+ };
54462
+ const devices = [...load.devices.filter((d) => d.deviceKey !== onlyKey), credited].toSorted((a, b) => a.deviceKey.localeCompare(b.deviceKey));
54463
+ return {
54464
+ ...load,
54465
+ devices
54466
+ };
54467
+ }
54468
+ //#endregion
54841
54469
  //#region src/dispatch-timing.ts
54842
54470
  /**
54843
54471
  * How long the placement census waits for ONE runner's `getLocalCameras`
@@ -54904,6 +54532,25 @@ var AgentLoadService = class AgentLoadService {
54904
54532
  getAgentLoadSummaries() {
54905
54533
  return [...this.cachedAgentLoad.values()];
54906
54534
  }
54535
+ /**
54536
+ * The cached summaries as the load REPORT (`getAgentLoad`) carries them: a
54537
+ * single-device node's default-pool sessions credited to its one device
54538
+ * (`default-pool-attribution.ts`). The balancer's census
54539
+ * ({@link collectAgentLoad}) stays the runner's raw answer.
54540
+ */
54541
+ async reportedAgentLoad() {
54542
+ const resolve = this.deps.eligibleDeviceKeys;
54543
+ const summaries = this.getAgentLoadSummaries();
54544
+ if (resolve === void 0) return summaries;
54545
+ return Promise.all(summaries.map(async (summary) => {
54546
+ const eligible = await resolve(summary.nodeId).catch(() => null);
54547
+ const load = attributeDefaultPoolSessions(summary.load, eligible);
54548
+ return load === summary.load ? summary : {
54549
+ ...summary,
54550
+ load
54551
+ };
54552
+ }));
54553
+ }
54907
54554
  cachedAgentLoadNodeIds() {
54908
54555
  return [...this.cachedAgentLoad.keys()];
54909
54556
  }
@@ -57351,29 +56998,15 @@ function mapBroker(brokerResult) {
57351
56998
  width: p.width,
57352
56999
  height: p.height,
57353
57000
  subscribers: p.subscribers,
57354
- inFps: p.inFps,
57355
- outFps: p.outFps
57001
+ inFps: p.inFps
57356
57002
  })),
57357
57003
  webrtcSessions: brokerResult.webrtcSessions,
57358
57004
  rtspRestream: brokerResult.rtspRestream
57359
57005
  };
57360
57006
  }
57361
- function mapDecoderShm(shm) {
57362
- return {
57363
- framesWritten: shm.framesWritten,
57364
- getFrameHits: shm.getFrameHits,
57365
- getFrameMisses: shm.getFrameMisses,
57366
- budgetMb: shm.budgetMb
57367
- };
57368
- }
57369
57007
  function mapDecoder(decoderResult) {
57370
57008
  if (decoderResult === null) return null;
57371
- return {
57372
- nodeId: decoderResult.nodeId,
57373
- formats: [...decoderResult.formats],
57374
- sessionCount: decoderResult.sessionCount,
57375
- shm: mapDecoderShm(decoderResult.shm)
57376
- };
57009
+ return { nodeId: decoderResult.nodeId };
57377
57010
  }
57378
57011
  function mapMotion(motionResult) {
57379
57012
  if (motionResult === null) return null;
@@ -57752,11 +57385,9 @@ var CameraStatusService = class {
57752
57385
  * `allSlotsFetch` (fetched once by the caller) to avoid a second
57753
57386
  * round-trip. Per-slot `getBrokerStats` + `listClients` are fetched in
57754
57387
  * parallel. WebRTC sessions are counted by filtering `encoded[]` entries
57755
- * whose `attribution.kind` is one of the `WEBRTC_KINDS`. Also captures the
57756
- * broker's actual decode-session node into `liveDecoder` (T6) — the first
57757
- * slot that reports one wins.
57388
+ * whose `attribution.kind` is one of the `WEBRTC_KINDS`.
57758
57389
  */
57759
- buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, sink) {
57390
+ buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, sink) {
57760
57391
  if (!api || !allSlotsFetch) return Promise.resolve(null);
57761
57392
  return this.boundedStage(allSlotsFetch.then(async (slots) => {
57762
57393
  const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
@@ -57773,19 +57404,16 @@ var CameraStatusService = class {
57773
57404
  clients
57774
57405
  };
57775
57406
  })), pass.rtspEntries(api, sourceNodeId).catch(() => null)]);
57776
- const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
57777
- profile: slot.profile,
57778
- status: slot.status,
57779
- codec: stats?.codec ?? slot.codec ?? "",
57780
- width: slot.resolution?.width ?? 0,
57781
- height: slot.resolution?.height ?? 0,
57782
- subscribers: clients?.encodedSubscribers ?? 0,
57783
- inFps: stats?.inputFps ?? 0,
57784
- outFps: stats?.decodeFps ?? 0
57785
- }));
57786
- for (const { stats } of statsAndClients) if (liveDecoder.nodeId === null && typeof stats?.decoderNodeId === "string") liveDecoder.nodeId = stats.decoderNodeId;
57787
57407
  return {
57788
- profiles: profileDetails,
57408
+ profiles: statsAndClients.map(({ slot, stats, clients }) => ({
57409
+ profile: slot.profile,
57410
+ status: slot.status,
57411
+ codec: stats?.codec ?? slot.codec ?? "",
57412
+ width: slot.resolution?.width ?? 0,
57413
+ height: slot.resolution?.height ?? 0,
57414
+ subscribers: clients?.encodedSubscribers ?? 0,
57415
+ inFps: stats?.inputFps ?? null
57416
+ })),
57789
57417
  webrtcSessions: statsAndClients.reduce((total, { clients }) => {
57790
57418
  if (!clients) return total;
57791
57419
  return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
@@ -57797,27 +57425,19 @@ var CameraStatusService = class {
57797
57425
  }), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
57798
57426
  }
57799
57427
  /**
57800
- * Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
57801
- * are not node-addressable (void/sessionId input); live per-node decoder
57802
- * stats would need a node-addressable decoder method. The block therefore
57803
- * reports the node decode runs on BY CONSTRUCTION — the detection node
57804
- * (decode is always co-located with its frame consumer) — with zeroed
57805
- * stats. Unassigned camera → block stays null. (The ASSIGNMENT block
57806
- * upgrades to the broker's live session node when observed — see the T6
57807
- * post-processing in `getCameraStatus`.)
57428
+ * Decoder stage: the node decode runs on BY CONSTRUCTION — the detection
57429
+ * node (decode is always co-located with its frame consumer). Unassigned
57430
+ * camera → null. (The ASSIGNMENT block upgrades to the broker's live session
57431
+ * node when observed — see the T6 post-processing in `getCameraStatus`.)
57432
+ *
57433
+ * No counter rides along. This block used to serve shared-memory ring stats,
57434
+ * session count and formats hard-coded to 0 / [] after the SHM frame plane
57435
+ * was retired (frames live in the in-RAM RetainedFrameStore), and the admin
57436
+ * UI raised "decode path stalled" on every active camera from those zeros.
57437
+ * Frames reaching the detector are read from `detection.actualFps`.
57808
57438
  */
57809
57439
  buildDecoderStage(detectionNodeId) {
57810
- return detectionNodeId ? Promise.resolve({
57811
- nodeId: detectionNodeId,
57812
- formats: [],
57813
- sessionCount: 0,
57814
- shm: {
57815
- framesWritten: 0,
57816
- getFrameHits: 0,
57817
- getFrameMisses: 0,
57818
- budgetMb: 0
57819
- }
57820
- }) : Promise.resolve(null);
57440
+ return Promise.resolve(detectionNodeId ? { nodeId: detectionNodeId } : null);
57821
57441
  }
57822
57442
  /**
57823
57443
  * Motion stage (from cached runner config). Motion configuration lives in
@@ -57987,10 +57607,9 @@ var CameraStatusService = class {
57987
57607
  const api = this.deps.api();
57988
57608
  const degradations = { entries: [] };
57989
57609
  const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
57990
- const liveDecoder = { nodeId: null };
57991
57610
  const allSlotsFetch = api ? pass.profileSlots(api, sourceNodeId) : null;
57992
57611
  const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
57993
- const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, degradations);
57612
+ const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, degradations);
57994
57613
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
57995
57614
  const motionResult = this.buildMotionStage(deviceId);
57996
57615
  const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, pass, degradations);
@@ -58005,11 +57624,10 @@ var CameraStatusService = class {
58005
57624
  switchesFetch
58006
57625
  ]);
58007
57626
  const audioResult = this.buildAudioStage(audioNodeId, switchedOff);
58008
- const liveDecoderNodeId = brokerResult !== null ? liveDecoder.nodeId : null;
58009
- const decoderNodeId = liveDecoderNodeId ?? detectionNodeId;
57627
+ const decoderNodeId = detectionNodeId;
58010
57628
  const reasons = {
58011
57629
  detection: detectionReason,
58012
- decoder: liveDecoderNodeId !== null ? "session" : decoderNodeId !== null ? "co-located" : void 0,
57630
+ decoder: decoderNodeId !== null ? "co-located" : void 0,
58013
57631
  audio: audioPinned ? "manual" : void 0
58014
57632
  };
58015
57633
  return composeCameraStatus({
@@ -60246,7 +59864,7 @@ function deviceAsLoad(deviceKey, load) {
60246
59864
  activeCameras: 0,
60247
59865
  avgInferenceFps: 0,
60248
59866
  avgInferenceTimeMs: 0,
60249
- queueDepthTotal: load?.queueDepthTotal ?? 0,
59867
+ queueDepthTotal: 0,
60250
59868
  devices: [],
60251
59869
  hardware: { hasGpu: false }
60252
59870
  };
@@ -61226,8 +60844,7 @@ function tallyLoad(load, nodeDelta, perDevice) {
61226
60844
  }), ...[...perDevice ?? /* @__PURE__ */ new Map()].filter(([deviceKey, delta]) => !known.has(deviceKey) && delta > 0).map(([deviceKey, delta]) => ({
61227
60845
  deviceKey,
61228
60846
  backend: deviceKey.split(":")[0] ?? deviceKey,
61229
- attachedCameras: delta,
61230
- queueDepthTotal: 0
60847
+ attachedCameras: delta
61231
60848
  }))];
61232
60849
  return {
61233
60850
  ...load,
@@ -64348,8 +63965,8 @@ function migrateCameraSettings(cameraMap) {
64348
63965
  * refused rather than substituted (D54). Reset happens to the STORE, before
64349
63966
  * anything is resolved.
64350
63967
  *
64351
- * Templates and the wholesale `pipelineByAgent` override are not swept: both
64352
- * carry step STRUCTURE only, and provisioning never reads a `modelId` from them.
63968
+ * The wholesale `pipelineByAgent` override is not swept: it carries step
63969
+ * STRUCTURE only, and provisioning never reads a `modelId` from it.
64353
63970
  */
64354
63971
  /** The one predicate: a pin to a deleted catalog id that no custom model owns. */
64355
63972
  function pinNamesRemovedModel(stepId, modelId, isCustom) {
@@ -64598,8 +64215,7 @@ async function redispatchAffected(deps, cleared) {
64598
64215
  /**
64599
64216
  * `orchestrator-store-schemas.ts` — whole-blob Zod schemas for the
64600
64217
  * orchestrator's addon-store keys that persist an ENTIRE collection
64601
- * under a single key (`nodeBindings`, `templates`, `agentSettings`,
64602
- * `cameraSettings`).
64218
+ * under a single key (`nodeBindings`, `agentSettings`, `cameraSettings`).
64603
64219
  *
64604
64220
  * These back the durable-state primitive (`this.state(KEY, Schema, {})`)
64605
64221
  * so a hand-written serializer can never silently drop a field on save:
@@ -64612,32 +64228,7 @@ async function redispatchAffected(deps, cleared) {
64612
64228
  * falls back to the empty map on a whole-blob parse failure, so the
64613
64229
  * schema is kept faithful to the on-disk shape rather than overly strict.
64614
64230
  */
64615
- var EngineChoiceSchema = object({
64616
- runtime: _enum(["python", "node"]).transform(() => "python"),
64617
- backend: string(),
64618
- format: string(),
64619
- device: string().optional()
64620
- });
64621
64231
  var NodeBindingsSchema = record(string(), record(string(), string()));
64622
- var StoredPipelineConfigSchema = object({
64623
- engine: EngineChoiceSchema.optional(),
64624
- steps: array(PipelineStepInputSchema).readonly(),
64625
- audio: object({
64626
- engine: EngineChoiceSchema.optional(),
64627
- modelId: string().optional(),
64628
- enabled: boolean(),
64629
- settings: record(string(), unknown()).readonly().optional()
64630
- }).nullable().optional()
64631
- });
64632
- var StoredPipelineTemplateSchema = object({
64633
- id: string(),
64634
- name: string(),
64635
- description: string().optional(),
64636
- config: StoredPipelineConfigSchema,
64637
- createdAt: string(),
64638
- updatedAt: string()
64639
- });
64640
- var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
64641
64232
  var StoredAgentAddonConfigSchema = object({
64642
64233
  enabled: boolean(),
64643
64234
  modelId: string().optional(),
@@ -64742,7 +64333,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
64742
64333
  blobWriteLock = new KeyedAsyncLock({ isShuttingDown: () => this.disposed });
64743
64334
  static AGENT_SETTINGS_LOCK_KEY = "agentSettings";
64744
64335
  _nodeBindingsState = null;
64745
- _templatesState = null;
64746
64336
  _agentSettingsState = null;
64747
64337
  _cameraSettingsState = null;
64748
64338
  _bindingsMigrationDoneState = null;
@@ -64751,10 +64341,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
64751
64341
  if (!this._nodeBindingsState) this._nodeBindingsState = this.deps.state("nodeBindings", NodeBindingsSchema, {});
64752
64342
  return this._nodeBindingsState;
64753
64343
  }
64754
- get templatesState() {
64755
- if (!this._templatesState) this._templatesState = this.deps.state("templates", TemplatesSchema, {});
64756
- return this._templatesState;
64757
- }
64758
64344
  get agentSettingsState() {
64759
64345
  if (!this._agentSettingsState) this._agentSettingsState = this.deps.state("agentSettings", AgentSettingsMapSchema, {});
64760
64346
  return this._agentSettingsState;
@@ -65037,59 +64623,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
65037
64623
  return rest;
65038
64624
  });
65039
64625
  }
65040
- /** Read the templates map from the addon store via the durable handle. */
65041
- async readTemplatesMap() {
65042
- const raw = await this.templatesState.get();
65043
- const out = {};
65044
- for (const [key, value] of Object.entries(raw)) if (this.isPipelineTemplate(value)) out[key] = value;
65045
- return out;
65046
- }
65047
- async listTemplates() {
65048
- const templates = await this.readTemplatesMap();
65049
- return Object.values(templates).slice().toSorted((a, b) => a.name.localeCompare(b.name));
65050
- }
65051
- async saveTemplate(input) {
65052
- const now = (/* @__PURE__ */ new Date()).toISOString();
65053
- const template = {
65054
- id: this.generateTemplateId(),
65055
- name: input.name,
65056
- description: input.description,
65057
- config: input.config,
65058
- createdAt: now,
65059
- updatedAt: now
65060
- };
65061
- const templates = await this.readTemplatesMap();
65062
- templates[template.id] = template;
65063
- await this.templatesState.set(templates);
65064
- this.deps.logger.info("template saved", { meta: {
65065
- templateId: template.id,
65066
- name: template.name
65067
- } });
65068
- return template;
65069
- }
65070
- async updateTemplate(input) {
65071
- const templates = await this.readTemplatesMap();
65072
- const existing = templates[input.id];
65073
- if (!existing) throw new Error(`updateTemplate: template '${input.id}' not found`);
65074
- const next = {
65075
- ...existing,
65076
- ...input.name !== void 0 ? { name: input.name } : {},
65077
- ...input.description !== void 0 ? { description: input.description } : {},
65078
- ...input.config !== void 0 ? { config: input.config } : {},
65079
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
65080
- };
65081
- templates[input.id] = next;
65082
- await this.templatesState.set(templates);
65083
- return next;
65084
- }
65085
- async deleteTemplate(input) {
65086
- const templates = await this.readTemplatesMap();
65087
- if (!templates[input.id]) return { success: true };
65088
- delete templates[input.id];
65089
- await this.templatesState.set(templates);
65090
- this.deps.logger.info("template deleted", { meta: { templateId: input.id } });
65091
- return { success: true };
65092
- }
65093
64626
  catalogCache = /* @__PURE__ */ new Map();
65094
64627
  /**
65095
64628
  * A node's `PipelineSchema`: the cached answer when fresh, else ONE live
@@ -65651,21 +65184,6 @@ var PipelineSettingsStore = class PipelineSettingsStore {
65651
65184
  const v = value;
65652
65185
  return Array.isArray(v["steps"]);
65653
65186
  }
65654
- isPipelineTemplate(value) {
65655
- if (!value || typeof value !== "object") return false;
65656
- const v = value;
65657
- return typeof v["id"] === "string" && typeof v["name"] === "string" && typeof v["createdAt"] === "string" && typeof v["updatedAt"] === "string" && this.isCameraPipelineConfig(v["config"]);
65658
- }
65659
- /**
65660
- * Generate a short template id. Collision risk at N≤1000 templates is
65661
- * effectively zero (12 hex chars = 48 bits of entropy). Not a UUID
65662
- * because the persisted shape is a plain map keyed by id and shorter
65663
- * keys keep the JSON compact.
65664
- */
65665
- generateTemplateId() {
65666
- const rand = Math.random().toString(36).slice(2, 8);
65667
- return `tpl_${Date.now().toString(36).slice(-6)}${rand}`;
65668
- }
65669
65187
  };
65670
65188
  /**
65671
65189
  * The camera's override for one accelerator, with its CAMERA-WIDE settings
@@ -69314,6 +68832,10 @@ async function buildOrchestratorControllers(deps) {
69314
68832
  onCensus: (entries) => {
69315
68833
  motionSliceGate?.noteCensus(entries);
69316
68834
  providerSliceGate?.refreshIfStale("runner-census");
68835
+ },
68836
+ eligibleDeviceKeys: async (nodeId) => {
68837
+ const read = await deps.getEligibleInferenceDevicesIfCached(nodeId);
68838
+ return read.kind === "eligible" ? Object.keys(read.eligible) : null;
69317
68839
  }
69318
68840
  });
69319
68841
  /**
@@ -70172,12 +69694,12 @@ function buildGlobalSettingsSections(options) {
70172
69694
  id: CLUSTER_MODEL_SECTION_ID,
70173
69695
  title: "Enrichment models",
70174
69696
  tab: "pipeline",
70175
- 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.",
69697
+ 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.",
70176
69698
  fields: [...CLUSTER_MODEL_SCOPED_STEPS.map((step) => ({
70177
69699
  key: clusterModelSettingKey(step.stepId),
70178
69700
  type: "select",
70179
69701
  label: step.label,
70180
- 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.`,
69702
+ 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.`,
70181
69703
  options: step.options.map((o) => ({
70182
69704
  value: o.id,
70183
69705
  label: o.label
@@ -70199,12 +69721,12 @@ function buildGlobalSettingsSections(options) {
70199
69721
  id: DETAIL_CROP_SECTION_ID,
70200
69722
  title: "Enrichment crop",
70201
69723
  tab: "pipeline",
70202
- 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.",
69724
+ 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).",
70203
69725
  fields: [{
70204
69726
  key: DETAIL_CROP_PADDING_KEY,
70205
69727
  type: "slider",
70206
69728
  label: "Crop margin",
70207
- 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.",
69729
+ 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.",
70208
69730
  min: DETAIL_CROP_PADDING_FIELD.min,
70209
69731
  max: DETAIL_CROP_PADDING_FIELD.max,
70210
69732
  step: DETAIL_CROP_PADDING_FIELD.step,
@@ -70214,7 +69736,7 @@ function buildGlobalSettingsSections(options) {
70214
69736
  key: DETAIL_CROP_SQUARE_KEY,
70215
69737
  type: "boolean",
70216
69738
  label: "Square the crop",
70217
- 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.",
69739
+ 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.",
70218
69740
  default: DEFAULT_DETAIL_CROP_CONVENTION.square
70219
69741
  }]
70220
69742
  },
@@ -70222,13 +69744,13 @@ function buildGlobalSettingsSections(options) {
70222
69744
  id: NATIVE_LEASE_SECTION_ID,
70223
69745
  title: "Native frame lease",
70224
69746
  tab: "pipeline",
70225
- 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.",
69747
+ 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.",
70226
69748
  fields: [
70227
69749
  {
70228
69750
  key: NATIVE_LEASE_HOLD_KEY,
70229
69751
  type: "slider",
70230
69752
  label: "Frames held at once",
70231
- 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.",
69753
+ 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.",
70232
69754
  min: NATIVE_LEASE_HOLD_FIELD.min,
70233
69755
  max: NATIVE_LEASE_HOLD_FIELD.max,
70234
69756
  step: NATIVE_LEASE_HOLD_FIELD.step,
@@ -70240,7 +69762,7 @@ function buildGlobalSettingsSections(options) {
70240
69762
  key: NATIVE_LEASE_TILE_BUDGET_KEY,
70241
69763
  type: "slider",
70242
69764
  label: "Subject tile RAM",
70243
- 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.",
69765
+ 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).",
70244
69766
  min: NATIVE_LEASE_TILE_BUDGET_FIELD.min,
70245
69767
  max: NATIVE_LEASE_TILE_BUDGET_FIELD.max,
70246
69768
  step: NATIVE_LEASE_TILE_BUDGET_FIELD.step,
@@ -70252,7 +69774,7 @@ function buildGlobalSettingsSections(options) {
70252
69774
  key: NATIVE_LEASE_SCENE_BUDGET_KEY,
70253
69775
  type: "slider",
70254
69776
  label: "Scene tile RAM",
70255
- 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.",
69777
+ 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).",
70256
69778
  min: NATIVE_LEASE_SCENE_BUDGET_FIELD.min,
70257
69779
  max: NATIVE_LEASE_SCENE_BUDGET_FIELD.max,
70258
69780
  step: NATIVE_LEASE_SCENE_BUDGET_FIELD.step,
@@ -70264,7 +69786,7 @@ function buildGlobalSettingsSections(options) {
70264
69786
  key: NATIVE_LEASE_BUDGET_KEY,
70265
69787
  type: "slider",
70266
69788
  label: "Lease RAM ceiling",
70267
- 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.",
69789
+ 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.",
70268
69790
  min: NATIVE_LEASE_BUDGET_FIELD.min,
70269
69791
  max: NATIVE_LEASE_BUDGET_FIELD.max,
70270
69792
  step: NATIVE_LEASE_BUDGET_FIELD.step,
@@ -70276,7 +69798,7 @@ function buildGlobalSettingsSections(options) {
70276
69798
  key: NATIVE_LEASE_ACTIVITY_KEY,
70277
69799
  type: "slider",
70278
69800
  label: "Demand window",
70279
- 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.",
69801
+ 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).",
70280
69802
  min: NATIVE_LEASE_ACTIVITY_FIELD.min,
70281
69803
  max: NATIVE_LEASE_ACTIVITY_FIELD.max,
70282
69804
  step: NATIVE_LEASE_ACTIVITY_FIELD.step,
@@ -70289,7 +69811,7 @@ function buildGlobalSettingsSections(options) {
70289
69811
  key: NATIVE_LEASE_ADMISSION_KEY,
70290
69812
  type: "select",
70291
69813
  label: "What to retain",
70292
- 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.",
69814
+ 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.",
70293
69815
  options: NATIVE_LEASE_ADMISSION_FIELD.options,
70294
69816
  default: NATIVE_LEASE_ADMISSION_FIELD.default
70295
69817
  }
@@ -70302,7 +69824,8 @@ function buildGlobalSettingsSections(options) {
70302
69824
  {
70303
69825
  key: "onDisconnect",
70304
69826
  type: "select",
70305
- label: "On runner disconnect",
69827
+ label: "When a node disconnects",
69828
+ description: "Move its unpinned cameras to other nodes, or leave them stopped.",
70306
69829
  default: DEFAULT_FAILOVER_POLICY.onDisconnect,
70307
69830
  options: [{
70308
69831
  value: "migrate",
@@ -70315,7 +69838,8 @@ function buildGlobalSettingsSections(options) {
70315
69838
  {
70316
69839
  key: "pinnedOnDisconnect",
70317
69840
  type: "select",
70318
- label: "Pinned cameras on disconnect",
69841
+ label: "Its pinned cameras",
69842
+ description: "Keep the pin and wait for the node, or drop the pin and move them.",
70319
69843
  default: DEFAULT_FAILOVER_POLICY.pinnedOnDisconnect,
70320
69844
  options: [{
70321
69845
  value: "leave-pinned",
@@ -70328,7 +69852,8 @@ function buildGlobalSettingsSections(options) {
70328
69852
  {
70329
69853
  key: "onReconnect",
70330
69854
  type: "select",
70331
- label: "On runner reconnect",
69855
+ label: "When it comes back",
69856
+ description: "Put its cameras back where they were, or rebalance the whole cluster.",
70332
69857
  default: DEFAULT_FAILOVER_POLICY.onReconnect,
70333
69858
  options: [{
70334
69859
  value: "restore",
@@ -71825,7 +71350,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
71825
71350
  nodeLifecycle = null;
71826
71351
  /**
71827
71352
  * The 3-level pipeline settings store (agent + camera settings maps,
71828
- * templates, pipeline-executor catalog cache) plus the co-declared
71353
+ * pipeline-executor catalog cache) plus the co-declared
71829
71354
  * `nodeBindings` capability-binding map and the one-shot bindings-
71830
71355
  * migration guard flag — see `pipeline-settings-store.ts` (S4
71831
71356
  * extraction, cluster C11). Constructed early in `onInitialize` (before
@@ -72329,7 +71854,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
72329
71854
  async getAgentLoad() {
72330
71855
  await this.loadService.refreshDecodeHwaccels();
72331
71856
  await this.loadService.collectAgentLoad();
72332
- return this.loadService.getAgentLoadSummaries();
71857
+ return this.loadService.reportedAgentLoad();
72333
71858
  }
72334
71859
  async getGlobalMetrics() {
72335
71860
  const summaries = await this.getAgentLoad();
@@ -73387,18 +72912,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
73387
72912
  const resolved = await this.settingsStore.resolvePipelineForDevice(deviceId);
73388
72913
  this.emitCameraUpdated(deviceId, resolved);
73389
72914
  }
73390
- async listTemplates() {
73391
- return this.settingsStore.listTemplates();
73392
- }
73393
- async saveTemplate(input) {
73394
- return this.settingsStore.saveTemplate(input);
73395
- }
73396
- async updateTemplate(input) {
73397
- return this.settingsStore.updateTemplate(input);
73398
- }
73399
- async deleteTemplate(input) {
73400
- return this.settingsStore.deleteTemplate(input);
73401
- }
73402
72915
  /**
73403
72916
  * The per-camera FUNCTION SWITCH group (D61) — derived, never stored.
73404
72917
  *